Skip to main content
Search...

database adapters

Connect duck-iam to your storage layer with adapters for in-memory, Prisma, Drizzle ORM, and HTTP APIs.

Overview

Adapters are the storage layer for duck-iam. They implement the Adapter interface, which combines three stores:

Loading diagram...

  • PolicyStore -- CRUD for access policies (rules, conditions, combining algorithms)
  • RoleStore -- CRUD for roles (permissions, inheritance, scopes)
  • SubjectStore -- role assignments, scoped roles, and subject attributes

Every adapter is fully typed with your application's action, resource, role, and scope types.

import { Engine } from '@gentleduck/iam'
 
const engine = new Engine({
  adapter: yourAdapter,
  defaultEffect: 'deny',
  cacheTTL: 60,
})
import { Engine } from '@gentleduck/iam'
 
const engine = new Engine({
  adapter: yourAdapter,
  defaultEffect: 'deny',
  cacheTTL: 60,
})

Memory adapter

import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'

The in-memory adapter stores everything in Map instances. It is designed for development, testing, and prototyping. Data does not survive process restarts.

Basic usage

import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const adapter = new MemoryAdapter({
  roles: [
    {
      id: 'admin',
      name: 'Administrator',
      permissions: [
        { action: '*', resource: '*' },
      ],
    },
    {
      id: 'editor',
      name: 'Editor',
      permissions: [
        { action: 'read', resource: '*' },
        { action: 'create', resource: 'post' },
        { action: 'update', resource: 'post' },
        { action: 'delete', resource: 'post' },
      ],
    },
    {
      id: 'viewer',
      name: 'Viewer',
      permissions: [
        { action: 'read', resource: '*' },
      ],
    },
  ],
 
  policies: [
    {
      id: 'default',
      name: 'Default Policy',
      algorithm: 'deny-overrides',
      rules: [
        {
          id: 'deny-banned',
          effect: 'deny',
          priority: 100,
          actions: ['*'],
          resources: ['*'],
          conditions: {
            all: [{ field: 'subject.attributes.status', operator: 'eq', value: 'banned' }],
          },
        },
        {
          id: 'allow-all',
          effect: 'allow',
          priority: 1,
          actions: ['*'],
          resources: ['*'],
          conditions: { all: [] },
        },
      ],
    },
  ],
 
  assignments: {
    'user-1': ['admin'],
    'user-2': ['editor'],
    'user-3': ['viewer'],
  },
})
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
const adapter = new MemoryAdapter({
  roles: [
    {
      id: 'admin',
      name: 'Administrator',
      permissions: [
        { action: '*', resource: '*' },
      ],
    },
    {
      id: 'editor',
      name: 'Editor',
      permissions: [
        { action: 'read', resource: '*' },
        { action: 'create', resource: 'post' },
        { action: 'update', resource: 'post' },
        { action: 'delete', resource: 'post' },
      ],
    },
    {
      id: 'viewer',
      name: 'Viewer',
      permissions: [
        { action: 'read', resource: '*' },
      ],
    },
  ],
 
  policies: [
    {
      id: 'default',
      name: 'Default Policy',
      algorithm: 'deny-overrides',
      rules: [
        {
          id: 'deny-banned',
          effect: 'deny',
          priority: 100,
          actions: ['*'],
          resources: ['*'],
          conditions: {
            all: [{ field: 'subject.attributes.status', operator: 'eq', value: 'banned' }],
          },
        },
        {
          id: 'allow-all',
          effect: 'allow',
          priority: 1,
          actions: ['*'],
          resources: ['*'],
          conditions: { all: [] },
        },
      ],
    },
  ],
 
  assignments: {
    'user-1': ['admin'],
    'user-2': ['editor'],
    'user-3': ['viewer'],
  },
})

With subject attributes

Pass initial attributes for ABAC-style conditions.

const adapter = new MemoryAdapter({
  roles: [...],
  assignments: { 'user-1': ['editor'] },
  attributes: {
    'user-1': {
      department: 'engineering',
      level: 3,
      verified: true,
    },
  },
})
const adapter = new MemoryAdapter({
  roles: [...],
  assignments: { 'user-1': ['editor'] },
  attributes: {
    'user-1': {
      department: 'engineering',
      level: 3,
      verified: true,
    },
  },
})

Constructor options

OptionTypeDescription
rolesRole[]Initial role definitions
policiesPolicy[]Initial policy definitions
assignmentsRecord<string, string[]>Map of subject ID to role IDs
attributesRecord<string, Attributes>Map of subject ID to attribute objects

Behavior details

  • Duplicate assignments: assignRole checks for existing entries and silently skips duplicates. Calling assignRole('user-1', 'editor') twice has no effect the second time.
  • Attribute merging: setSubjectAttributes shallow-merges new attributes into existing ones. To remove an attribute, set it to null.
  • Scoped vs unscoped roles: stored in the same array. getSubjectRoles filters for entries without a scope; getSubjectScopedRoles filters for entries with a scope.

When to use

  • Unit tests -- seed known roles and assignments, assert engine behavior
  • Development -- get started without a database
  • Prototyping -- explore the policy model before committing to a schema
  • CI pipelines -- run integration tests without external dependencies

Prisma adapter

import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'

The Prisma adapter stores policies, roles, assignments, and subject attributes in your database through Prisma Client. It expects four models in your Prisma schema.

Required schema

Add these models to your schema.prisma file:

model AccessPolicy {
  id          String  @id
  name        String
  description String?
  version     Int     @default(1)
  algorithm   String
  rules       Json
  targets     Json?
 
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@map("access_policies")
}
 
model AccessRole {
  id          String  @id
  name        String
  description String?
  permissions Json
  inherits    String[]
  scope       String?
  metadata    Json?
 
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@map("access_roles")
}
 
model AccessAssignment {
  id        String  @id @default(cuid())
  subjectId String
  roleId    String
  scope     String?
 
  createdAt DateTime @default(now())
 
  @@unique([subjectId, roleId, scope])
  @@index([subjectId])
  @@map("access_assignments")
}
 
model AccessSubjectAttr {
  subjectId String @id
  data      Json
 
  updatedAt DateTime @updatedAt
 
  @@map("access_subject_attrs")
}
model AccessPolicy {
  id          String  @id
  name        String
  description String?
  version     Int     @default(1)
  algorithm   String
  rules       Json
  targets     Json?
 
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@map("access_policies")
}
 
model AccessRole {
  id          String  @id
  name        String
  description String?
  permissions Json
  inherits    String[]
  scope       String?
  metadata    Json?
 
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@map("access_roles")
}
 
model AccessAssignment {
  id        String  @id @default(cuid())
  subjectId String
  roleId    String
  scope     String?
 
  createdAt DateTime @default(now())
 
  @@unique([subjectId, roleId, scope])
  @@index([subjectId])
  @@map("access_assignments")
}
 
model AccessSubjectAttr {
  subjectId String @id
  data      Json
 
  updatedAt DateTime @updatedAt
 
  @@map("access_subject_attrs")
}

Run prisma migrate dev after adding these models.

Usage

import { PrismaClient } from '@prisma/client'
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { Engine } from '@gentleduck/iam'
 
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)
 
const engine = new Engine({ adapter })
import { PrismaClient } from '@prisma/client'
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
import { Engine } from '@gentleduck/iam'
 
const prisma = new PrismaClient()
const adapter = new PrismaAdapter(prisma)
 
const engine = new Engine({ adapter })

The adapter uses upsert for save operations, so calling savePolicy or saveRole with an existing ID updates the record rather than throwing a conflict error.

How it maps

Adapter methodPrisma operation
listPolicies()accessPolicy.findMany()
getPolicy(id)accessPolicy.findUnique({ where: { id } })
savePolicy(p)accessPolicy.upsert(...)
deletePolicy(id)accessPolicy.delete({ where: { id } })
listRoles()accessRole.findMany()
getRole(id)accessRole.findUnique({ where: { id } })
saveRole(r)accessRole.upsert(...)
deleteRole(id)accessRole.delete({ where: { id } })
getSubjectRoles(id)accessAssignment.findMany({ where: { subjectId } })
getSubjectScopedRoles(id)Same query, filtered for non-null scope
assignRole(id, role, scope?)accessAssignment.create(...)
revokeRole(id, role, scope?)accessAssignment.deleteMany(...)
getSubjectAttributes(id)accessSubjectAttr.findUnique(...)
setSubjectAttributes(id, attrs)accessSubjectAttr.upsert(...) (merges with existing)

Drizzle adapter

import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'

The Drizzle adapter works with any Drizzle ORM database driver (PostgreSQL, MySQL, SQLite). You provide your table definitions and Drizzle's operator functions.

Table definitions

Define your tables using Drizzle's schema builder. The column names should match the expected row shapes.

// db/schema/access.ts
import { pgTable, text, integer, json, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
 
export const accessPolicies = pgTable('access_policies', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  version: integer('version').notNull().default(1),
  algorithm: text('algorithm').notNull(),
  rules: json('rules').notNull(),
  targets: json('targets'),
})
 
export const accessRoles = pgTable('access_roles', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  permissions: json('permissions').notNull(),
  inherits: json('inherits'),
  scope: text('scope'),
  metadata: json('metadata'),
})
 
export const accessAssignments = pgTable('access_assignments', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  subjectId: text('subject_id').notNull(),
  roleId: text('role_id').notNull(),
  scope: text('scope'),
}, (t) => ({
  unique: uniqueIndex('uniq_assignment').on(t.subjectId, t.roleId, t.scope),
}))
 
export const accessSubjectAttrs = pgTable('access_subject_attrs', {
  subjectId: text('subject_id').primaryKey(),
  data: json('data').notNull(),
})
// db/schema/access.ts
import { pgTable, text, integer, json, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
 
export const accessPolicies = pgTable('access_policies', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  version: integer('version').notNull().default(1),
  algorithm: text('algorithm').notNull(),
  rules: json('rules').notNull(),
  targets: json('targets'),
})
 
export const accessRoles = pgTable('access_roles', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  permissions: json('permissions').notNull(),
  inherits: json('inherits'),
  scope: text('scope'),
  metadata: json('metadata'),
})
 
export const accessAssignments = pgTable('access_assignments', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  subjectId: text('subject_id').notNull(),
  roleId: text('role_id').notNull(),
  scope: text('scope'),
}, (t) => ({
  unique: uniqueIndex('uniq_assignment').on(t.subjectId, t.roleId, t.scope),
}))
 
export const accessSubjectAttrs = pgTable('access_subject_attrs', {
  subjectId: text('subject_id').primaryKey(),
  data: json('data').notNull(),
})

Usage

import { drizzle } from 'drizzle-orm/node-postgres'
import { eq, and } from 'drizzle-orm'
import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
import { Engine } from '@gentleduck/iam'
import * as tables from './db/schema/access'
 
const db = drizzle(pool)
 
const adapter = new DrizzleAdapter({
  db,
  tables: {
    policies: tables.accessPolicies,
    roles: tables.accessRoles,
    assignments: tables.accessAssignments,
    attrs: tables.accessSubjectAttrs,
  },
  ops: { eq, and },
})
 
const engine = new Engine({ adapter })
import { drizzle } from 'drizzle-orm/node-postgres'
import { eq, and } from 'drizzle-orm'
import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
import { Engine } from '@gentleduck/iam'
import * as tables from './db/schema/access'
 
const db = drizzle(pool)
 
const adapter = new DrizzleAdapter({
  db,
  tables: {
    policies: tables.accessPolicies,
    roles: tables.accessRoles,
    assignments: tables.accessAssignments,
    attrs: tables.accessSubjectAttrs,
  },
  ops: { eq, and },
})
 
const engine = new Engine({ adapter })

Constructor config

OptionTypeDescription
dbDrizzle database instanceYour Drizzle db object with select, insert, delete
tables.policiesDrizzle tablePolicy table reference
tables.rolesDrizzle tableRole table reference
tables.assignmentsDrizzle tableAssignment table reference
tables.attrsDrizzle tableSubject attributes table reference
ops.eq(col, val) -> conditionDrizzle eq operator
ops.and(...conditions) -> conditionDrizzle and operator

JSON handling

The Drizzle adapter automatically handles JSON serialization. If your database driver returns JSON columns as strings (common with SQLite), the adapter parses them. If the driver returns parsed objects (common with PostgreSQL json/jsonb columns), the adapter uses them directly.


HTTP adapter

import { HttpAdapter } from '@gentleduck/iam/adapters/http'

The HTTP adapter delegates all storage operations to a remote API. This is the right choice when:

  • Your access engine runs on a dedicated service
  • You want to share policies across multiple applications
  • Your client-side code needs to evaluate permissions against a server

Usage

import { HttpAdapter } from '@gentleduck/iam/adapters/http'
import { Engine } from '@gentleduck/iam'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  headers: {
    Authorization: 'Bearer ' + serviceToken,
  },
})
 
const engine = new Engine({ adapter })
import { HttpAdapter } from '@gentleduck/iam/adapters/http'
import { Engine } from '@gentleduck/iam'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  headers: {
    Authorization: 'Bearer ' + serviceToken,
  },
})
 
const engine = new Engine({ adapter })

Dynamic headers

Pass a function to compute headers per-request. Useful for rotating tokens or per-request context.

const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  headers: async () => ({
    Authorization: 'Bearer ' + await getServiceToken(),
    'X-Request-Id': crypto.randomUUID(),
  }),
})
const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  headers: async () => ({
    Authorization: 'Bearer ' + await getServiceToken(),
    'X-Request-Id': crypto.randomUUID(),
  }),
})

Custom fetch

Provide your own fetch implementation for environments that do not have a global fetch, or to add middleware like logging or retries.

import { HttpAdapter } from '@gentleduck/iam/adapters/http'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  fetch: async (url, init) => {
    console.log('Access API request:', url)
    const res = await globalThis.fetch(url, init)
    console.log('Access API response:', res.status)
    return res
  },
})
import { HttpAdapter } from '@gentleduck/iam/adapters/http'
 
const adapter = new HttpAdapter({
  baseUrl: 'https://api.example.com/access',
  fetch: async (url, init) => {
    console.log('Access API request:', url)
    const res = await globalThis.fetch(url, init)
    console.log('Access API response:', res.status)
    return res
  },
})

API endpoints

The HTTP adapter expects the following REST endpoints on your server:

MethodPathDescription
GET/policiesList all policies
GET/policies/:idGet a single policy
PUT/policiesCreate or update a policy
DELETE/policies/:idDelete a policy
GET/rolesList all roles
GET/roles/:idGet a single role
PUT/rolesCreate or update a role
DELETE/roles/:idDelete a role
GET/subjects/:id/rolesGet a subject's roles
GET/subjects/:id/scoped-rolesGet a subject's scoped roles
POST/subjects/:id/rolesAssign a role (body: { roleId, scope? })
DELETE/subjects/:id/roles/:roleIdRevoke a role (query: ?scope=...)
GET/subjects/:id/attributesGet subject attributes
PATCH/subjects/:id/attributesUpdate subject attributes (body: attribute object)

You can expose these endpoints using the Express adminRouter helper or implement them manually.

Error handling

The HTTP adapter throws an Error with the message "@gentleduck/iam HTTP {status}: {responseText}" for any non-2xx response. It does not retry failed requests. If you need retries, implement them in your custom fetch function.

A Content-Type: application/json header is sent on every request automatically. If the headers option is a function, it is awaited before each request (useful for refreshing auth tokens).

Constructor config

OptionTypeDefaultDescription
baseUrlstring--Base URL for the access API (trailing slash stripped)
headersRecord or () -> Record or Promise{}Static or dynamic headers
fetchtypeof fetchglobalThis.fetchCustom fetch implementation

Custom adapters

Implement the Adapter interface to connect duck-iam to any storage backend.

The Adapter interface

import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
 
interface Adapter<TAction, TResource, TRole, TScope> {
  // PolicyStore
  listPolicies(): Promise<Policy<TAction, TResource, TRole>[]>
  getPolicy(id: string): Promise<Policy<TAction, TResource, TRole> | null>
  savePolicy(policy: Policy<TAction, TResource, TRole>): Promise<void>
  deletePolicy(id: string): Promise<void>
 
  // RoleStore
  listRoles(): Promise<Role<TAction, TResource, TRole, TScope>[]>
  getRole(id: string): Promise<Role<TAction, TResource, TRole, TScope> | null>
  saveRole(role: Role<TAction, TResource, TRole, TScope>): Promise<void>
  deleteRole(id: string): Promise<void>
 
  // SubjectStore
  getSubjectRoles(subjectId: string): Promise<TRole[]>
  getSubjectScopedRoles?(subjectId: string): Promise<ScopedRole<TRole, TScope>[]>
  assignRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  revokeRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  getSubjectAttributes(subjectId: string): Promise<Attributes>
  setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void>
}
import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
 
interface Adapter<TAction, TResource, TRole, TScope> {
  // PolicyStore
  listPolicies(): Promise<Policy<TAction, TResource, TRole>[]>
  getPolicy(id: string): Promise<Policy<TAction, TResource, TRole> | null>
  savePolicy(policy: Policy<TAction, TResource, TRole>): Promise<void>
  deletePolicy(id: string): Promise<void>
 
  // RoleStore
  listRoles(): Promise<Role<TAction, TResource, TRole, TScope>[]>
  getRole(id: string): Promise<Role<TAction, TResource, TRole, TScope> | null>
  saveRole(role: Role<TAction, TResource, TRole, TScope>): Promise<void>
  deleteRole(id: string): Promise<void>
 
  // SubjectStore
  getSubjectRoles(subjectId: string): Promise<TRole[]>
  getSubjectScopedRoles?(subjectId: string): Promise<ScopedRole<TRole, TScope>[]>
  assignRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  revokeRole(subjectId: string, roleId: TRole, scope?: TScope): Promise<void>
  getSubjectAttributes(subjectId: string): Promise<Attributes>
  setSubjectAttributes(subjectId: string, attrs: Attributes): Promise<void>
}

Note that getSubjectScopedRoles is optional. If your application does not use scoped roles, you can omit it.

Example: Redis adapter

import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
import type { Redis } from 'ioredis'
 
export class RedisAdapter implements Adapter {
  constructor(private redis: Redis, private prefix = 'iam:') {}
 
  private key(type: string, id?: string) {
    return id ? `${this.prefix}${type}:${id}` : `${this.prefix}${type}`
  }
 
  async listPolicies(): Promise<Policy[]> {
    const keys = await this.redis.keys(this.key('policy', '*'))
    const results = await Promise.all(keys.map(k => this.redis.get(k)))
    return results.filter(Boolean).map(r => JSON.parse(r!))
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    const raw = await this.redis.get(this.key('policy', id))
    return raw ? JSON.parse(raw) : null
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.redis.set(this.key('policy', p.id), JSON.stringify(p))
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.redis.del(this.key('policy', id))
  }
 
  // ... implement remaining methods for roles and subjects
}
import type { Adapter, Policy, Role, Attributes, ScopedRole } from '@gentleduck/iam'
import type { Redis } from 'ioredis'
 
export class RedisAdapter implements Adapter {
  constructor(private redis: Redis, private prefix = 'iam:') {}
 
  private key(type: string, id?: string) {
    return id ? `${this.prefix}${type}:${id}` : `${this.prefix}${type}`
  }
 
  async listPolicies(): Promise<Policy[]> {
    const keys = await this.redis.keys(this.key('policy', '*'))
    const results = await Promise.all(keys.map(k => this.redis.get(k)))
    return results.filter(Boolean).map(r => JSON.parse(r!))
  }
 
  async getPolicy(id: string): Promise<Policy | null> {
    const raw = await this.redis.get(this.key('policy', id))
    return raw ? JSON.parse(raw) : null
  }
 
  async savePolicy(p: Policy): Promise<void> {
    await this.redis.set(this.key('policy', p.id), JSON.stringify(p))
  }
 
  async deletePolicy(id: string): Promise<void> {
    await this.redis.del(this.key('policy', id))
  }
 
  // ... implement remaining methods for roles and subjects
}

Testing custom adapters

Use the MemoryAdapter as a reference implementation. Your adapter should behave identically for the same inputs. A good testing strategy:

  1. Seed your adapter with known roles, policies, and assignments
  2. Create an engine with your adapter
  3. Assert that engine.can() returns the expected results
  4. Test edge cases: missing subjects, empty roles, scoped vs unscoped assignments
import { Engine } from '@gentleduck/iam'
import { YourAdapter } from './your-adapter'
 
const adapter = new YourAdapter(/* config */)
 
// Seed
await adapter.saveRole({
  id: 'editor',
  name: 'Editor',
  permissions: [
    { action: 'read', resource: '*' },
    { action: 'update', resource: 'post' },
  ],
})
await adapter.assignRole('user-1', 'editor')
 
// Test
const engine = new Engine({ adapter })
const canUpdate = await engine.can('user-1', 'update', { type: 'post', attributes: {} })
const canDelete = await engine.can('user-1', 'delete', { type: 'post', attributes: {} })
 
assert(canUpdate === true)
assert(canDelete === false)
import { Engine } from '@gentleduck/iam'
import { YourAdapter } from './your-adapter'
 
const adapter = new YourAdapter(/* config */)
 
// Seed
await adapter.saveRole({
  id: 'editor',
  name: 'Editor',
  permissions: [
    { action: 'read', resource: '*' },
    { action: 'update', resource: 'post' },
  ],
})
await adapter.assignRole('user-1', 'editor')
 
// Test
const engine = new Engine({ adapter })
const canUpdate = await engine.can('user-1', 'update', { type: 'post', attributes: {} })
const canDelete = await engine.can('user-1', 'delete', { type: 'post', attributes: {} })
 
assert(canUpdate === true)
assert(canDelete === false)

Choosing an adapter

AdapterUse casePersistenceDependencies
MemoryDev, testing, prototypingNone (in-process)None
PrismaProduction apps using PrismaDatabase (any Prisma-supported)@prisma/client
DrizzleProduction apps using DrizzleDatabase (PG, MySQL, SQLite)drizzle-orm
HTTPMicroservices, client-server splitRemote APINone (uses fetch)
CustomAny other storage backendYour choiceYour choice

All adapters are interchangeable. You can start with the memory adapter during development and switch to Prisma or Drizzle in production without changing any engine or middleware code.