@deeblr/auth-adapter-kit
v0.4.0
Published
The Universal Adapter System for Deeblr Auth: adapter registry, factory, manager, capability detection, extended repository interfaces, and a reusable conformance test suite every adapter can be validated against.
Readme
@deeblr/auth-adapter-kit
The Universal Adapter System for Deeblr Auth: registry, factory, manager, capability detection, extended repository interfaces, and a reusable conformance test suite. This is the infrastructure that makes "any database, any storage engine, without touching Deeblr Auth" actually true.
Install
npm install @deeblr/auth-adapter-kitThe contract you're implementing
Deeblr Auth's core dependency-inversion boundary hasn't changed since it
was written: DatabaseAdapter<TUser> from @deeblr/auth-types — five
required methods (createUser, getUserById, getUserByEmail,
updateUser, deleteUser) plus optional session methods
(createSession, getSession, updateSession, deleteSession,
deleteSessionsForUser). This package doesn't replace that contract —
it's the infrastructure around it.
How adapters work
Your adapter class
implements DatabaseAdapter<TUser> <- required, from @deeblr/auth-types
implements UserRepository<TUser>? <- optional: search/soft-delete/batch
implements TransactionCapable? <- optional: transaction()
implements CapabilityAware? <- optional: declare non-inferable traits
|
v
detectCapabilities(adapter) <- infers + reads explicit declarations
|
v
AdapterManager <- owns lifecycle, guards feature calls
|
v
new DeeblrAuthCore({ adapter }) <- works exactly as it always hasAn adapter that implements only the five required methods works fully —
every optional interface is additive. Nothing in @deeblr/auth-core,
@deeblr/auth-session, or @deeblr/auth-jwt was changed to build this
system; they still only ever call the base DatabaseAdapter contract they
always have.
Building a custom adapter
import type { DatabaseAdapter, CreateUserInput, UpdateUserInput, BaseUser } from "@deeblr/auth-types";
import type { CapabilityAware, TransactionCapable, UserRepository } from "@deeblr/auth-adapter-kit";
class MyAdapter<TUser extends BaseUser = BaseUser>
implements DatabaseAdapter<TUser>, UserRepository<TUser>, TransactionCapable, CapabilityAware
{
readonly name = "my-adapter";
// Declare anything not inferable from method presence.
readonly capabilities = { supportsJson: true, supportsTTL: false };
async createUser(input: CreateUserInput): Promise<TUser> { /* ... */ }
async getUserById(id: string): Promise<TUser | null> { /* ... */ }
async getUserByEmail(email: string): Promise<TUser | null> { /* ... */ }
async updateUser(id: string, input: UpdateUserInput<TUser>): Promise<TUser> { /* ... */ }
async deleteUser(id: string): Promise<void> { /* ... */ }
// Optional — implement only what your store can reasonably do:
async searchUsers(query: string) { /* ... */ }
async transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> { /* ... */ }
}Then validate it and see what it reports:
import { validateAdapter, detectCapabilities } from "@deeblr/auth-adapter-kit";
const adapter = new MyAdapter();
validateAdapter(adapter); // throws AdapterValidationError if the base contract isn't satisfied
console.log(detectCapabilities(adapter));
// { supportsTransactions: true, supportsSearch: true, supportsJson: true, ... }Testing your adapter — the conformance suite
Every adapter should pass the SAME tests. Import the suite, point it at a factory that builds a fresh instance, and run it:
// my-adapter/tests/conformance.test.mjs
import { runAdapterConformanceTests } from "@deeblr/auth-adapter-kit";
import { MyAdapter } from "../dist/index.js";
runAdapterConformanceTests({
name: "MyAdapter",
createAdapter: () => new MyAdapter(),
cleanup: async (adapter) => { await adapter.disconnect?.(); },
});node --test tests/conformance.test.mjsThe base CRUD tests (create/get/update/delete, case-insensitive email
lookup) always run — every adapter must pass them. Everything else
(sessions, transactions, search, soft-delete, batch) is
capability-gated: the suite checks detectCapabilities() first and
skips (not fails) a test for anything your adapter doesn't declare. A
minimal adapter implementing only the five required methods shows a clean
run of passes and honest skips — never a false failure.
Registering and discovering adapters
import { defaultAdapterRegistry, defineAdapterFactory } from "@deeblr/auth-adapter-kit";
// In your adapter package:
export default defineAdapterFactory((config) => new MyAdapter(config));
defaultAdapterRegistry.register("my-adapter", MyAdapterFactory);
// In application bootstrap code, resolving by name (e.g. from an env var):
const factory = defaultAdapterRegistry.get(process.env.DATABASE_ADAPTER);
const adapter = await factory({ connectionString: process.env.DATABASE_URL });AdapterManager
Wraps a live instance: validates it, tracks connect()/disconnect()
lifecycle state, exposes its capabilities, and provides a
capability-guarded transaction():
import { AdapterManager } from "@deeblr/auth-adapter-kit";
const manager = new AdapterManager(myAdapter);
await manager.connect();
if (manager.hasCapability("supportsTransactions")) {
await manager.transaction(async (tx) => { /* ... */ });
}
await manager.disconnect();Reference adapters
@deeblr/auth-adapter-memory— real in-process adapter (search, soft-delete, batch, transactions all genuinely implemented). Good for dev/tests.@deeblr/auth-adapter-sqlite— real, persistent adapter using Node's built-innode:sqlite. Zero external dependencies.
Implementing other databases
The contract is small on purpose — five required methods. Here's what each looks like against real ORM/driver APIs (these code samples use each library's actual, correct API; they're a starting point, not a shipped, tested package the way the two reference adapters above are — build and run your own conformance suite against them, per the section above).
Prisma
class PrismaAdapter implements DatabaseAdapter {
readonly name = "prisma-adapter";
constructor(private prisma: PrismaClient) {}
async createUser(input: CreateUserInput) {
return this.prisma.user.create({ data: { email: input.email, passwordHash: input.passwordHash, emailVerified: input.emailVerified ?? false } });
}
async getUserById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async getUserByEmail(email: string) {
return this.prisma.user.findFirst({ where: { email: { equals: email, mode: "insensitive" } } });
}
async updateUser(id: string, input: UpdateUserInput) {
return this.prisma.user.update({ where: { id }, data: input });
}
async deleteUser(id: string) {
await this.prisma.user.delete({ where: { id } });
}
async transaction<T>(fn: (tx: Prisma.TransactionClient) => Promise<T>) {
return this.prisma.$transaction(fn);
}
}Drizzle ORM
class DrizzleAdapter implements DatabaseAdapter {
readonly name = "drizzle-adapter";
constructor(private db: NodePgDatabase) {}
async createUser(input: CreateUserInput) {
const [row] = await this.db.insert(users).values({
email: input.email, passwordHash: input.passwordHash, emailVerified: input.emailVerified ?? false,
}).returning();
return row;
}
async getUserById(id: string) {
const [row] = await this.db.select().from(users).where(eq(users.id, id));
return row ?? null;
}
async getUserByEmail(email: string) {
const [row] = await this.db.select().from(users).where(sql`lower(${users.email}) = lower(${email})`);
return row ?? null;
}
async updateUser(id: string, input: UpdateUserInput) {
const [row] = await this.db.update(users).set(input).where(eq(users.id, id)).returning();
return row;
}
async deleteUser(id: string) {
await this.db.delete(users).where(eq(users.id, id));
}
async transaction<T>(fn: (tx: NodePgDatabase) => Promise<T>) {
return this.db.transaction(fn);
}
}TypeORM
class TypeOrmAdapter implements DatabaseAdapter {
readonly name = "typeorm-adapter";
constructor(private repo: Repository<UserEntity>, private dataSource: DataSource) {}
async createUser(input: CreateUserInput) {
return this.repo.save(this.repo.create(input));
}
async getUserById(id: string) {
return this.repo.findOneBy({ id });
}
async getUserByEmail(email: string) {
return this.repo.createQueryBuilder("u").where("lower(u.email) = lower(:email)", { email }).getOne();
}
async updateUser(id: string, input: UpdateUserInput) {
await this.repo.update(id, input);
return this.repo.findOneByOrFail({ id });
}
async deleteUser(id: string) {
await this.repo.delete(id);
}
async transaction<T>(fn: (tx: EntityManager) => Promise<T>) {
return this.dataSource.transaction(fn);
}
}Mongoose
class MongooseAdapter implements DatabaseAdapter {
readonly name = "mongoose-adapter";
constructor(private UserModel: Model<UserDocument>) {}
async createUser(input: CreateUserInput) {
const doc = await this.UserModel.create(input);
return this.toUser(doc);
}
async getUserById(id: string) {
const doc = await this.UserModel.findById(id);
return doc ? this.toUser(doc) : null;
}
async getUserByEmail(email: string) {
const doc = await this.UserModel.findOne({ email: new RegExp(`^${email}$`, "i") });
return doc ? this.toUser(doc) : null;
}
async updateUser(id: string, input: UpdateUserInput) {
const doc = await this.UserModel.findByIdAndUpdate(id, input, { new: true });
return this.toUser(doc);
}
async deleteUser(id: string) {
await this.UserModel.findByIdAndDelete(id);
}
private toUser(doc: UserDocument): BaseUser {
return { id: doc._id.toString(), email: doc.email, passwordHash: doc.passwordHash, emailVerified: doc.emailVerified, createdAt: doc.createdAt.toISOString(), updatedAt: doc.updatedAt.toISOString() };
}
}MongoDB native driver
class MongoDbAdapter implements DatabaseAdapter {
readonly name = "mongodb-adapter";
constructor(private collection: Collection<UserDoc>) {}
async createUser(input: CreateUserInput) {
const now = new Date().toISOString();
const doc = { _id: new ObjectId(), ...input, emailVerified: input.emailVerified ?? false, createdAt: now, updatedAt: now };
await this.collection.insertOne(doc);
return this.toUser(doc);
}
async getUserById(id: string) {
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
return doc ? this.toUser(doc) : null;
}
async getUserByEmail(email: string) {
const doc = await this.collection.findOne({ email: { $regex: `^${email}$`, $options: "i" } });
return doc ? this.toUser(doc) : null;
}
// updateUser / deleteUser follow the same findOneAndUpdate / deleteOne pattern.
}PostgreSQL / MySQL / MariaDB / SQL Server (raw driver, e.g. pg, mysql2, mssql)
The shape is identical across these — parameterized SQL through whichever
driver's query method, with the driver's own placeholder syntax
($1 / ? / @p1):
class PostgresAdapter implements DatabaseAdapter {
readonly name = "postgres-adapter";
constructor(private pool: Pool) {}
async createUser(input: CreateUserInput) {
const { rows } = await this.pool.query(
`INSERT INTO users (email, password_hash, email_verified) VALUES ($1, $2, $3) RETURNING *`,
[input.email, input.passwordHash ?? null, input.emailVerified ?? false],
);
return this.toUser(rows[0]);
}
async getUserByEmail(email: string) {
const { rows } = await this.pool.query(`SELECT * FROM users WHERE lower(email) = lower($1)`, [email]);
return rows[0] ? this.toUser(rows[0]) : null;
}
async transaction<T>(fn: (client: PoolClient) => Promise<T>) {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}@deeblr/auth-adapter-sqlite (in this monorepo) is a complete, tested,
working example of exactly this pattern for SQLite specifically — read its
source for a full reference implementation, not just a sketch.
License
MIT
