Skip to main content

Custom CRUD Adapter

Credo is database-agnostic. It does not know or care how you store data. Instead, Credo communicates with your database through CRUD adapters—plain JavaScript objects containing asynchronous functions.

Your only responsibility is to implement these functions and return the expected values.

What is a CRUD Adapter?

A CRUD adapter is an object that groups database logic by domain rather than database type. Credo expects three domains:

  • user
  • refreshToken
  • otp

Each domain exposes a set of async functions that Credo calls internally.

Adapter Shape

const crud = {
user: {
findUserByEmail: async (email) => {},
findUserById: async (id) => {},
createUser: async (data) => {},
findUserAndUpdate: async (query, update) => {}
},
refreshToken: {
createRefreshToken: async (data) => {},
findValidRefreshTokenByTokenHash: async (tokenHash) => {},
revokeRefreshTokenByTokenHash: async (tokenHash) => {}
},
otp: {
createOTP: async (data) => {},
findOTPByEmail: async (email , purpose) => {},
deleteOTPByEmail: async (email , purpose) => {},
incrementOTPAttempts: async (email , purpose) => {},
verifyOTP: async (email, purpose) => {}
}
};

User Adapter

The user adapter handles user persistence.

Required Functions

user: {
findUserByEmail(email),
findUserById(id),
createUser(data),
findUserAndUpdate(query, update)
}

Function Contracts

  • findUserByEmail(email): Returns a user object or null.
  • findUserById(id): Returns a user object or null.
  • createUser(data): Creates a user and returns the created user object.
  • findUserAndUpdate(query, update): Finds a user using query, updates it using update, and returns the updated user or null.

Credo does not enforce how query or update are structured; you decide how they are interpreted.

Refresh Token Adapter

Handles refresh token storage and validation.

Required Functions

refreshToken: {
createRefreshToken(data),
findValidRefreshTokenByTokenHash(tokenHash),
revokeRefreshTokenByTokenHash(tokenHash)
}

Function Contracts

  • createRefreshToken(data): Stores a refresh token and returns the stored token.
  • findValidRefreshTokenByTokenHash(tokenHash): Returns a valid token object or null.
  • revokeRefreshTokenByTokenHash(tokenHash): Revokes the token and returns true or false.

OTP Adapter

Handles one-time password (OTP) operations.

Required Functions

otp: {
createOTP(data),
findOTPByEmail(email),
deleteOTPByEmail(email),
incrementOTPAttempts(email),
verifyOTP(email, code)
}

Function Contracts

  • createOTP(data): Creates an OTP entry and returns the created OTP.
  • findOTPByEmail(email): Returns OTP data or null.
  • deleteOTPByEmail(email): Deletes OTP records and returns true or false.
  • incrementOTPAttempts(email): Increments failed attempts and returns updated OTP data.
  • verifyOTP(email, code): Verifies the OTP and returns true or false.

Example: Simple In-Memory Adapter

This example demonstrates how adapters work without an external database.

const users = [];

export const userAdapter = {
findUserByEmail: async (email) => {
return users.find(u => u.email === email) || null;
},

findUserById: async (id) => {
return users.find(u => u.id === id) || null;
},

createUser: async (data) => {
const user = { id: Date.now().toString(), ...data };
users.push(user);
return user;
},
updateUserPassword: async (email, password) => {
const user = users.find(u => u.email === email);
if (!user) return null;

user.password = password;
return user;
},
verfiyUserEmail: async (email) => {
const user = users.find(u => u.email === email);
if (!user) return null;

user.isEmailVerified = true;
return user;
}
};

const refreshTokens = []

export const refreshTokenAdapter = {
createRefreshToken: async (data) => {
const token = {
id: Date.now(),
revoked: false,
...data
}

refreshTokens.push(token)
return token
},

findValidRefreshTokenByTokenHash: async (tokenHash) => {
return (
refreshTokens.find(
t => t.tokenHash === tokenHash && t.revoked === false
) || null
)
},

revokeRefreshTokenByTokenHash: async (tokenHash) => {
const token = refreshTokens.find(t => t.tokenHash === tokenHash)
if (!token) return false

token.revoked = true
return true
}
}

const otps = []

export const otpAdapter = {
createOTP: async (data) => {
const otp = {
attempts: 0,
verified: false,
createdAt: Date.now(),
...data
}

otps.push(otp)
return otp
},

findOTPByEmail: async (email , purpose) => {
return otps.find(o => o.email === email && o.purpose === purpose) || null
},

deleteOTPByEmail: async (email , purpose) => {
const index = otps.findIndex(o => o.email === email && o.purpose === purpose)
if (index === -1) return false

otps.splice(index, 1)
return true
},

incrementOTPAttempts: async (email , purpose) => {
const otp = otps.find(o => o.email === email && o.purpose === purpose)
if (!otp) return null

otp.attempts += 1
return otp
},

verifyOTP: async (email, code , purpose) => {
const otp = otps.find(o => o.email === email && o.purpose === purpose)
if (!otp) return false

if (otp.code !== code) {
otp.attempts += 1
return false
}

otp.verified = true
return true
}
}

This same pattern applies to any database. Whether using MongoDB, SQL, or Prisma:

  • SQL users may map updates manually.
  • Prisma users may ignore query and map IDs.
  • All Credo cares about is the returned value.

Plugging the Adapter into Credo

createAuthSystem({
crud: {
user: userAdapter,
refreshToken: refreshTokenAdapter,
otp: otpAdapter
}
});

Once provided, Credo will use your adapters internally for all authentication flows.