Skip to main content

Adapters

WIP

The stores an engine persists through, the shipped adapters, mixing Redis sessions with SQL, transactions, limiters, and writing your own

An adapter is three stores: identities, credentials and sessions. Pass one as stores:

import { drizzlePgAdapter } from '@gentleduck/auth/adapters/drizzle/pg'

const auth = createAuth({
  baseUrl: 'https://app.example.com',
  stores: drizzlePgAdapter(process.env.DATABASE_URL ?? ''),
  // ...
})
AdapterImportHoldsFor
Memoryadapters/memoryAll three, and orgsTests and local runs. strict() refuses it in production.
Drizzleadapters/drizzle/{pg,mysql,sqlite}All threeProduction.
Redis and Valkeyadapters/redis, adapters/valkeySessions, events, noncesSessions across instances, and the shared state beside them.

An orgs store is yours to write over your own tables.

Mixing stores

Each store is independent, so sessions can live in Redis beside identities in SQL:

import { session } from '@gentleduck/auth/adapters/redis'

const sql = drizzlePgAdapter(db)
createAuth({
  // ...
  stores: { identities: sql.identities, credentials: sql.credentials, sessions: session({ redis }) },
})

Transactions

auth.withTransaction(tx) answers the engine bound to your transaction handle, so its writes commit or roll back with yours. Its events wait in pending, so flush them once the transaction commits:

await db.transaction(async (tx) => {
  const bound = auth.withTransaction(tx)
  const ada = await bound.identities.create({ profile: { email: 'ada@example.com', username: 'ada' } })
  await tx.insert(accounts).values({ ownerId: ada.id })
  return bound.pending
}).then((pending) => pending.flush())

Only a Drizzle adapter passed whole can join one. A hand-built mix and the memory adapter throw AUTH_MISCONFIGURED.

Limiters

The limiter is what every sign-in, link request and challenge spends from. Pass one as limiter:

LimiterImportNotes
redisLimiter({ redis })limiters/redisShared across instances. Default 10 per 15 minutes per key.
valkeyLimiter({ redis })limiters/valkeyThe same over an ioredis or iovalkey client.
new MemoryLimiter()limiters/memoryOne process only.
noopLimiter()limiters/mockNever refuses. Tests only.

Without one, the engine counts in a MemoryLimiter. strict() refuses that in production, and the noop one: a limiter per process grants every budget once per instance.

Writing your own

Implement Identities.Store, Credential.Store and Sessions.Store from @gentleduck/auth/core. The contract each method keeps is on its type.

  • A missing row rejects with its not-found code, such as AUTH_IDENTITY_NOT_FOUND; it never answers null.
  • A stale expectedVersion rejects with AUTH_STALE_WRITE, and a taken email, username or login with its own code.
  • Extend AdapterStore from @gentleduck/auth/adapters and call through run: it maps what your driver throws onto the codes you declare, and anything else to AUTH_ADAPTER_FAILED.
  • Give it a withClient(tx) answering the same stores on a transaction handle, and it can join withTransaction.