Menu
On this page

@setu-ts/database-plugin

Database access with the repository pattern and Unit of Work. Registers an IDatabaseService under CAPABILITIES.DATABASE ('database').

Driver errors that pass through IDatabaseService (repositories, transactions, query()) are scrubbed before they are classified or logged. Drizzle’s Failed query: wrapper keeps its SQL with placeholders and loses its bound values, from the message, the whole multiline stack header and params. A SQLSTATE class 22 cause (data exception: invalid text, out of range, bad date, bad enum) is rebuilt from its code, because PostgreSQL’s message quotes the rejected input. Classification is unchanged. Two limits: an error from a query built through getDrizzleDatabase(...), or a raw driver call, never passes through the service and is logged as the driver wrote it; and the scrub walks at most five causes, so a value nested deeper survives. It is not a general log-redaction policy, and request paths logged by the request logger are outside it.

Four adapters ship: MemoryAdapter (zero-dependency default), PrismaAdapter, DrizzleAdapter, and MongoAdapter (the native mongodb driver). The application owns the optional ORM and driver clients and injects them into the plugin.

ORM compatibility

Drizzle’s current tested baseline is 0.45.2; a broader application-instance range is pending removal of the adapter’s exact lazy-loader pin and tests at both declared endpoints. Prisma v7 is the current integration, but its formal support range is also pending a boundary repair. Pass options.provider for connector-sensitive contains filters rather than relying on automatic connector detection: that fallback is not a compatibility promise. This distinction is deliberate — a documented range is a tested compatibility claim, not a guess.

Installation

import { DatabasePlugin } from '@setu-ts/database-plugin';

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DatabasePlugin, type IDatabaseService } from '@setu-ts/database-plugin';
import { CAPABILITIES } from '@setu-ts/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.ts';

const prismaClient = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }),
});

const app = createApplication({
  plugins: [RuntimePlugin(), DatabasePlugin({ type: 'prisma', options: { prismaClient } })],
});
await app.start({ port: 3000 });

const db = app.services.get<IDatabaseService>(CAPABILITIES.DATABASE);
const users = db.getRepository<User, string>('User');

const created = await users.create({ email: 'ada@example.com' });
const page = await users.findAll({
  where: { active: true },
  orderBy: { createdAt: 'desc' },
  limit: 20,
  offset: 0,
});

// Group work into one transaction; a throw rolls back and propagates.
await db.transaction(async (uow) => {
  await uow.getRepository<User, string>('User').update(created.id, { active: true });
});

The repository surface

Portable data guarantees (M119e, 0.11.0)

Operation / conditionGuarantee
Unknown payload field on Drizzle, Prisma or D1 create / update / updateWhereUnknownPayloadFieldError (400); no field or value in the HTTP detail
Unknown payload field on Memory, MongoDB, DynamoDB, Cosmos DB or BigtableStored and returned; restrict fields with the route’s body schema
PostgreSQL refused field value through Drizzle repositoriesInvalidFieldValueError (400), including malformed UUID keys; fixed detail, scrubbed cause
Prisma validation other than unknown arguments, or known-request P2007InvalidFieldValueError (400), including malformed UUID keys and conditional writes
D1 STRICT-table field type refusalInvalidFieldValueError (400)
Tenant column on DrizzleResolve the property first, otherwise a unique SQL column name; unresolved or ambiguous mappings throw TenantColumnUnresolvedError (500) before statement I/O
Explicit Drizzle entities.<Entity>.primaryKey missing from a registered tableMissingKeyColumnError at connect; an id-less query-only table without an override remains valid until repository creation
Scoped keyset cursors (all eight adapters)Bound to entity, key columns and sort; a fingerprint is a correctness check, and tenant predicates still authorize access

UnknownPayloadFieldError is defined in @setu-ts/common and re-exported by this package as the same class. Prisma conditional-write validation uses the same 400 split as ordinary writes, replacing its previous 501. Prisma refusal causes are new errors with the original name and a fixed message naming only the name and code; the original query and payload are discarded.

Drizzle pool setup

The application owns the pool and should report its idle-client errors:

import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { describeError } from '@setu-ts/common';
import { createDrizzleDatabase, DatabasePlugin } from '@setu-ts/database-plugin';

const pool = new Pool({ connectionString: process.env['DATABASE_URL']! });
pool.on('error', (error: Error) => logger.warn('Database pool error', { error: describeError(error) }));
const database = drizzle(pool);
const plugin = DatabasePlugin({
  type: 'drizzle',
  options: {
    drizzleInstance: createDrizzleDatabase(database, (db, work) => db.transaction(work)),
    drizzleTables: { User: users },
  },
});

When the pool has no 'error' listener, the framework attaches one after connect, reporting through the optional logger’s warn. Without a logger it still attaches the listener. Existing app listeners are preserved. The listener remains for the pool’s lifetime; the app closes its pool.

IDatabaseService.getRepository<Entity, Id>(name) returns IRepository<Entity, Id>. This table describes the port and BaseRepository; see the backend restrictions below rather than assuming every backend supports every query.

MethodPromise result / missing record / refusal
findById(id: Id)Entity | null
findAll(options?: FindOptions)Entity[]; no matches gives []
findOne(options?: FindOptions)Entity | null; applies a limit of one
create(data: Partial<Entity>)Persisted Entity, including generated fields
update(id: Id, data: Partial<Entity>)Updated Entity; missing record rejects
delete(id: Id)boolean; false when missing
updateWhere?(id: Id, where: WritePrecondition, data: Partial<Entity>)Entity | null; null when missing or predicate mismatches
deleteWhere?(id: Id, where: WritePrecondition)boolean; false when missing or predicate mismatches
exists(id: Id)boolean; false when missing
count(options?: CountOptions)number; zero when no matches
findPage(options: PageOptions)Page<Entity>; UnsupportedQueryFeatureError if the source lacks cursor paging

The interface’s @since annotations record introduction history:

First releaseMethods
0.1.0findById, findAll, create, update, delete, exists, count
0.2.0findOne, findPage
0.9.0updateWhere, deleteWhere

Conditional methods are optional on the port. BaseRepository exposes them but rejects with UnsupportedQueryFeatureError before I/O if the data source lacks them or the precondition/update is invalid. WritePrecondition is Readonly<Record<string, string | number>>: a non-empty equality map conjoined with the primary key. See the backend limits for Cosmos and Bigtable. Precondition field names must be non-empty and may not start with $, contain a dot, or equal __proto__; values must be strings or finite numbers.

FindOptions fields:

FieldTypeMeaning
whereRecord<string, unknown>Equality conditions
filterFilterExpressionPortable expression conjoined with where
orderByRecord<string, OrderDirection>Field directions ('asc' / 'desc')
limitnumberMaximum results
offsetnumberResults to skip
selectreadonly string[]Projected fields
cursorstringContinuation position for findPage

PageOptions is an alias of FindOptions. findPage refuses a cursor with a non-zero offset with PageNormalizationError; findAll is the offset/limit path and does not consume a cursor. See the Cosmos and Bigtable backend tables for query refusals.

Other shapeFields
CountOptionsOptional where: Record<string, unknown> and filter: FilterExpression
Page<Entity>rows: Entity[], nextCursor: string | null (null ends traversal)

Use the same query on each page:

import type { IRepository } from '@setu-ts/database-plugin';

interface Invoice { id: string; status: string; }
export async function readInvoices(invoices: IRepository<Invoice>): Promise<Invoice[]> {
  const rows: Invoice[] = [];
  let cursor: string | null = null;
  do {
    const page = await invoices.findPage({
      limit: 100,
      orderBy: { id: 'asc' },
      ...(cursor === null ? {} : { cursor }),
    });
    rows.push(...page.rows);
    cursor = page.nextCursor;
  } while (cursor !== null);
  return rows;
}

Check for the optional conditional method, then distinguish a mismatch from a successful write:

import type { IRepository } from '@setu-ts/database-plugin';

interface Invoice { id: string; status: string; }
export async function payInvoice(invoices: IRepository<Invoice>, id: string): Promise<boolean> {
  if (!invoices.updateWhere) throw new Error('Conditional writes are required');
  const updated = await invoices.updateWhere(id, { status: 'pending' }, { status: 'paid' });
  return updated !== null;
}

Transaction isolation

Pass an isolation level when a read-modify-write needs an explicit concurrency guarantee:

await db.transaction(
  async (uow) => {
    // Read and update through transaction-scoped repositories.
  },
  { isolation: 'serializable' },
);

Omitting the option preserves each adapter’s current default. A requested level is never silently downgraded: this package’s adapters throw UnsupportedIsolationLevelError; D1 throws its own CloudflareUnsupportedError because plugins must not depend on one another.

AdapterSupported isolation
PrismaPostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none
DrizzleAll four, when its bridge is explicitly wrapped with withIsolationSupport()
Memoryserializable, process-local only
MongoDBNone
D1, DynamoDB, Cosmos DB, BigtableNone; their transactional primitives are not levelled

A custom IDatabaseAdapter must declare transactionIsolationLevels before callers can request isolation through the database service; an omitted declaration is refused rather than silently using the adapter default. Prisma needs a resolved provider for an isolation request: when its client provider cannot be detected, set options.provider explicitly.

Tenant data-store bridge

createDatabaseTenantDataStore is the one shipped ITenantDataStore over IDatabaseService. Pass it to the multi-tenancy plugin’s dataStore option and tenant-scoped repositories read and write through this backend:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { createDatabaseTenantDataStore, DatabasePlugin } from '@setu-ts/database-plugin';
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    DatabasePlugin({ type: 'memory' }),
    MultiTenancyPlugin({
      resolver: 'header',
      database: 'column-per-tenant',
      dataStore: createDatabaseTenantDataStore(),
    }),
  ],
});
await app.start({ port: 3000 });

The factory is resolved in onInit, so DatabasePlugin may be registered before OR after the tenancy plugin — every register() phase completes before any onInit.

The bridge supports the 'column-per-tenant' strategy only: it conjoins the tenant column to every read and stamps it on every write (the column is spread LAST, so a caller’s filter cannot override it, and stripped from every update payload, so no write can move a row between tenants). 'schema-per-tenant' and 'database-per-tenant' throw TenantStoreStrategyUnsupportedError at startup, because IRepository offers no schema or database switch. The tenant column comes from that strategy — 'tenant_id' by default, database: new ColumnPerTenant('org_id') for another — so there is one place to name it. Lookups by key go through the repository’s own findById, so an entity whose primary key is not id (a MongoDB primaryKey: 'user_id', a composite key) is addressed the way its adapter is configured; the tenant column is then checked on the row that comes back. update and delete guard the tenant column in the write itself through updateWhere / deleteWhere, which every built-in non-transactional data source offers. A custom data source without them falls back to an ownership read followed by a key-addressed write: if the row is deleted and a row from another tenant is created under the same caller-supplied key in between, the write lands on that row (a delete still reports true; an update refuses to return the foreign row, but has already written it). Generated keys never reuse a key, so let the backend generate them, or implement the two members. DatabaseTenantDataStore is also exported for an application that holds its own IDatabaseService and constructs the store directly; its optional second constructor argument names the column for a store used outside the multi-tenancy plugin.

Transactional outbox store

createDatabaseOutboxStore is the shipped IOutboxStore (the port in @setu-ts/common) over IDatabaseService. Pass it to the messaging plugin’s outbox.store option: the outbox writes each integration event as a row in the SAME transaction as the business change, through your own unit of work, and a relay publishes the rows afterwards.

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { SchedulerPlugin } from '@setu-ts/scheduler-plugin';
import { createDatabaseOutboxStore, DatabasePlugin } from '@setu-ts/database-plugin';
import { MessagingPlugin } from '@setu-ts/messaging-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    // The relay is a scheduled job: without a scheduler (or `relay: { schedule:
    // false }` and your own trigger) `start()` rejects OutboxRelayUnscheduledError.
    SchedulerPlugin(),
    DatabasePlugin({ type: 'memory' }),
    MessagingPlugin({ outbox: { store: createDatabaseOutboxStore() } }),
  ],
});
await app.start();

The write, the relay, its promise and the per-backend provisioning are documented in the messaging-plugin README.

The factory resolves CAPABILITIES.DATABASE in onInit, so the two plugins may be registered in either order. Options: entity (default 'Outbox') and database, which selects a named connection (DatabasePlugin({ name }) → database.<name>). That connection must be the database your unit of work belongs to: a row written through another database’s unit of work is never relayed by this store.

The discriminator. Every row carries kind: 'setu-outbox'; every read requires it, and a lookup by id treats a row of another kind as missing. An outbox sharing an entity with business data (a Cosmos container is queried as a whole) never reads, counts, transitions or deletes a business document that happens to carry status: 'pending' or 'sent'.

Transitions are conditional. claim compares kind, pending and the read claimVersion, increments the version once and writes the lease. markSent and markFailure require the held version; deleteNow uses the same predicate through deleteWhere. A version miss on a pending row answers claim-lost and writes nothing. markFailure clears the lease; markInvalid requires only kind and pending, writes failed/invalid-row, clears the lease and preserves attempts. release('retry') requires failed, resets attempts, the lease and lastError, and keeps the version — so a row poisoned for an exhausted claimVersion (MAX_SAFE_INTEGER, stored by a normal claim of a row at MAX_SAFE_INTEGER - 1, which takes 2^53 − 1 claims of one row or an edit) is poisoned again by the next sweep; release('discard') or editing the row are the only ways out. A miss is re-read for classification; unexplained misses retry at most three rounds. Sources without native conditional support are refused; there is no fallback. Absent optional columns are written as NULL and read back as absent. sentBy is an operator diagnostic only.

Startup check. verify(), run by the outbox before it schedules the relay or accepts a write, runs the relay’s first query, a transactional read, and two conditional capability probes on the fixed non-UUID id setu-outbox-claim-probe with { kind, status: 'pending', claimVersion: -1 }: an updateWhere writing { claimVersion: 0 } and a deleteWhere (needed because retainSentMs: 0 deletes a sent row conditionally after publishing it). No row can carry version -1, so neither probe writes, even to a row appended under the probe id. verify() rejects with OutboxStoreUnavailableError (reason, entity, the adapter error as cause) when any step fails:

BackendVerdict
memorysupported (single process)
Prismasupported; the table must exist
Drizzlesupported; drizzleTables must register the outbox table
D1supported; the table must exist
MongoDBsupported on a replica set; a standalone server is refused (reason: 'mongodb-replica-set')
DynamoDBsupported with a GSI { partitionKey: 'status', sortKey: 'position' }, projection ALL; refused without it ('dynamodb-index')
Cosmossupported when the outbox entity maps to the business container and its partition-key path is a column the row carries (tenantId or orderingKey)
No conditional writesrefused ('conditional-writes-unsupported'): updateWhere or deleteWhere is absent, or either rejects conditional-write
Bigtablerefused ('bigtable'): no secondary index, so the relay query cannot run — use a change-data-capture relay

Any other refusal — a missing or unreadable table — is reason: 'entity-unavailable'. On the memory adapter and MongoDB a missing entity cannot be detected (both create lazily), so that check passes there by construction. No portable migration exists (migrate() always rejects): create the table yourself. The old M107 SQL schema lacks the claim fields and is refused at startup as entity-unavailable. A claim reads a version in [0, Number.MAX_SAFE_INTEGER - 1] and stores one more, so the stored value reaches Number.MAX_SAFE_INTEGER, which is exhausted; PostgreSQL uses bigint, SQLite/D1 use 64-bit INTEGER, and JSON backends store the number. For PostgreSQL:

CREATE TABLE setu_outbox (
  id            text    PRIMARY KEY,
  kind          text    NOT NULL,
  topic         text    NOT NULL,
  envelope      text    NOT NULL,
  options       text    NOT NULL,
  ordering_key  text,
  tenant_id     text,
  traceparent   text,
  position      text    NOT NULL,
  created_at    bigint  NOT NULL,
  status        text    NOT NULL,
  attempts      integer NOT NULL,
  available_at  bigint  NOT NULL,
  claim_version bigint  NOT NULL,
  lease_until   bigint  NOT NULL,
  last_error    text,
  settled_at    bigint,
  sent_by       text
);
CREATE INDEX setu_outbox_relay ON setu_outbox (kind, status, position);
CREATE INDEX setu_outbox_purge ON setu_outbox (kind, status, settled_at);

Map it with drizzleTables: { Outbox: pgTable('setu_outbox', { … }) } (JS keys orderingKey, tenantId, createdAt, availableAt, claimVersion, leaseUntil, lastError, settledAt, sentBy over the snake_case columns; claim_version and lease_until must be 64-bit; the bigint columns in { mode: 'number' }).

Consumer inbox store

createDatabaseInboxStore is the shipped IInboxStore (the port in @setu-ts/common) over IDatabaseService. Pass it to the messaging plugin’s inbox.store option: an onIntegrationEvent(..., { inbox: { consumer } }) subscription then records each event it applied as a marker in the SAME transaction as the handler’s writes, and hands the handler that transaction’s unit of work.

The delivery path, its promise, the failure and parking policy and the per-backend notes are documented in the messaging-plugin README.

The factory resolves CAPABILITIES.DATABASE (or database.<name> with database) in onInit. Options: entity (default 'Inbox') and database. The handlers must write to that database: the marker is created in its transaction, so a handler writing elsewhere gets no once-only guarantee.

The marker’s insert is the authority. run opens one transaction, creates the marker in it first, then runs the handler; any rejection rolls both back. Every row carries kind: 'setu-inbox' and every read requires it. The failure count compares kind and the read attempts value in the write, retrying at most five rounds; release requires kind and status: 'parked'. Without conditional support, or for a non-safe-integer stored count, the two-call fallback can lose an increment under concurrent failures.

Startup check. verify() refuses Cosmos DB and Bigtable by adapter arm or class ('cosmos-unsupported', 'bigtable-unsupported'), runs retention’s first query, and runs a transaction that writes two probe rows and always rolls back — which is what refuses a standalone MongoDB ('mongodb-standalone') and an adapter bounded to one row per transaction ('transaction-scope'). Anything else is 'entity-unavailable'. Nothing the probe writes survives. Create the table yourself; for PostgreSQL:

CREATE TABLE setu_inbox (
  id          text    PRIMARY KEY,
  kind        text    NOT NULL,
  consumer    text    NOT NULL,
  topic       text    NOT NULL,
  envelope_id text,
  status      text    NOT NULL,
  attempts    integer NOT NULL,
  updated_at  bigint  NOT NULL,
  last_error  text,
  envelope    text
);
CREATE INDEX setu_inbox_status ON setu_inbox (kind, status, updated_at);

Map it with drizzleTables: { Inbox: pgTable('setu_inbox', { … }) } (JS keys envelopeId, updatedAt, lastError over the snake_case columns; updated_at in { mode: 'number' }).

Tier-C idempotency store

createDatabaseIdempotencyStore() uses the default entity name Idempotency. Create the PostgreSQL table before using the store:

CREATE TABLE setu_idempotency (
  id          text   PRIMARY KEY,
  kind        text   NOT NULL,
  role        text   NOT NULL,
  fingerprint text   NOT NULL,
  created_at  bigint NOT NULL,
  expires_at  bigint NOT NULL,
  result      text
);
CREATE INDEX setu_idempotency_expiry ON setu_idempotency (kind, role, expires_at);

For SQLite (including D1), use:

CREATE TABLE setu_idempotency (
  id          TEXT    PRIMARY KEY,
  kind        TEXT    NOT NULL,
  role        TEXT    NOT NULL,
  fingerprint TEXT    NOT NULL,
  createdAt   INTEGER NOT NULL,
  expiresAt   INTEGER NOT NULL,
  result      TEXT
);
CREATE INDEX setu_idempotency_expiry ON setu_idempotency (kind, role, expiresAt);

The expiry index serves the purge query over kind, role, and expiry time. For Drizzle on PostgreSQL, register the table under the default entity name:

import { bigint, pgTable, text } from 'drizzle-orm/pg-core';

const tables = {
  drizzleTables: {
    Idempotency: pgTable('setu_idempotency', {
      id: text('id').primaryKey(),
      kind: text('kind').notNull(),
      role: text('role').notNull(),
      fingerprint: text('fingerprint').notNull(),
      createdAt: bigint('created_at', { mode: 'number' }).notNull(),
      expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
      result: text('result'),
    }),
  },
};

Pass tables.drizzleTables as the database plugin’s drizzleTables option. The JavaScript keys createdAt and expiresAt map to created_at and expires_at; { mode: 'number' } keeps epoch milliseconds numeric for the store.

Scoped RBAC sources

createDatabaseGrantSource({ entity }) and createDatabaseRoleSource({ entity }) feed AuthPlugin’s scopedRbac option from the application’s own database — one row per grant, and one row per (scope, role, permission) for roles a tenant defines at runtime. Both return factories AuthPlugin resolves at onInit, read the default database through the repository surface with portable filters (so every adapter works), and refuse a non-identifier column name when called.

import { AuthPlugin } from '@setu-ts/auth-plugin';
import { createDatabaseGrantSource, createDatabaseRoleSource } from '@setu-ts/database-plugin';

export const auth = AuthPlugin({
  rbac: { roles: { approver: { permissions: ['invoices:approve'] } } },
  scopedRbac: {
    // Columns default to subject / role / scopeType / scopeId; both scope
    // columns null is a global grant.
    sources: [{ kind: 'custom', source: createDatabaseGrantSource({ entity: 'role_grants' }) }],
    // Columns default to scopeType / scopeId / role / permission.
    customRoles: createDatabaseRoleSource({ entity: 'tenant_roles' }),
  },
});

A check’s question is one query: the subject’s rows that are global or in one of the asked scopes. More than limit matching rows (default 10 001 for grants, 100 000 for role rows) refuse the question, so the check denies, rather than answering with an arbitrary subset.

Migrations and readiness

Opt in on any connection with DatabasePlugin({ readiness: { migrations: MIGRATION_NAMES } }), importing MIGRATION_NAMES from your generated ./src/migrations/index.ts. Without readiness, the indicator’s status and data are unchanged. After the existing reachability checks, a separate ledger probe caches reads for 5 seconds and bounds each read at 2 seconds:

LedgerIndicatorMigration data
Expected names missingdegraded: /ready 503, /health 200{ state: 'pending', pending: count }
All expected names presentExisting up or degraded status{ state: 'current', applied: count }
Read rejected, timed out, or over 10,000 rowsdown{ state: 'unreadable' }

Additional names from a newer build add unknown: count to current data and preserve status, so old replicas stay ready during rolling deploys. Names never appear in health data. Since M119b (V10-31) the ledger is read only when the connection result is not down and its data.reachable is not false or 'unknown'; otherwise the result is returned unchanged with data.migrations.state: 'unknown' added — an adapter with no reachability check (reachable omitted) is still checked. A paused database therefore reads degraded within one ~2 s bound instead of down after two stacked bounds. Expected names must be unique non-empty strings, at most 10,000; ledgerEntity, when supplied, must be non-empty. Invalid options throw TypeError at construction without echoing values. This checks migration history, not every application table: an intact ledger cannot prove that someone has not independently dropped a business table.

createMigrationLedger(database, { entity? }) returns IMigrationLedger: applied() reads names, record(name, appliedAt) inserts caller-supplied ISO-8601 time, and remove(name) resolves even if absent. DatabaseReadinessOptions.ledgerEntity defaults to MIGRATION_LEDGER_ENTITY ('setu_migrations'). A ledger created without MigrationLedgerOptions.entity — which is how the generated run.ts creates it — uses the ledgerEntity its connection was registered with, so setting it once on DatabasePlugin is enough; an explicit entity overrides it. applied() requests only id, with a limit of 10,001, and rejects above 10,000 rows with MigrationLedgerError, whose reason is 'oversized'. The framework reads no clock for ledger writes and never creates schema.

Create this PostgreSQL table (also shipped in test/fixtures/migration-ledger-postgres.sql):

CREATE TABLE setu_migrations (
  id text PRIMARY KEY,
  applied_at text NOT NULL
);

SQLite / D1 (test/fixtures/migration-ledger-sqlite.sql):

CREATE TABLE setu_migrations (
  id TEXT PRIMARY KEY NOT NULL,
  appliedAt TEXT NOT NULL
);
AdapterApplication setup
PrismaCreate the SQL table and expose a setu_migrations model with id and appliedAt mapped to applied_at.
DrizzleCreate the SQL table and add setu_migrations to drizzleTables, with id: text('id').primaryKey() and appliedAt: text('applied_at').notNull().
D1Create the SQLite table above, whose appliedAt column matches the repository field directly.
MongoDBNo DDL; the default entity collection is sufficient, or map it through collections.
DynamoDBProvision a table with partition key id; map entities.setu_migrations to { table: 'your-table', partitionKey: 'id' }.
CosmosProvision a container with partition path /id; map containers.setu_migrations to { container: 'your-container', partitionKey: 'id' }.
BigtableProvision a table and map tables.setu_migrations with its row-key and column-family configuration, as for other entities.
MemoryNo DDL. History is process-local; run migrations in the serving process. A separate runner process loses its history at exit.

Measured locally through createMigrationLedger and repository read-back (2026-10-19):

AdapterDuplicate record rejectionPreserved driver cause
MemoryDuplicateKeyErrorNone
Drizzle / real PostgreSQLDuplicateKeyErrorDrizzle Error → pg DatabaseError (name: 'error', SQLSTATE 23505)
MongoDB replica setDuplicateKeyErrorMongoServerError, code 11000
DynamoDB LocalDuplicateKeyErrorConditionalCheckFailedException

Other adapters were not measured for duplicate rejection. The ledger propagates the adapter’s create rejection unchanged. Run one migration job: no portable lock prevents two jobs from applying a change concurrently. A duplicate ledger insert makes the race visible after execution. The ledger is only as trustworthy as whoever can write it: a database principal able to write setu_migrations can mark a replica ready, or make the runner skip or re-run a migration — the same trust it already holds over the schema.

setu generate migration maintains run.ts and index.ts and exports MIGRATION_NAMES from the barrel. Migrations receive { database }. The runner applies only pending names oldest first; --down reverses only applied names newest first. It writes/removes the row after each successful change, outside any transaction. A crash between a change and its ledger write re-runs that change: make migrations idempotent. A failure stops the run and names the migration.

On socket targets, executing run.ts dynamically loads setu.config.ts, awaits createApp(), starts the whole app without a port, resolves the default database and stops the app in finally. Every plugin’s start-up effects therefore run for the runner’s lifetime — queue processors, scheduled jobs and broker subscriptions included. On Deno, run it with the same permission flags as your start task rather than -A. Importing runMigrations does not load the application. For a named connection, call runMigrations('up', { database, ledger: createMigrationLedger(database) }) from your own module. Workers emit no entry block: call the function from code holding platform bindings or use wrangler d1 migrations.

Options

OptionTypeDefaultDescription
type'memory' | 'prisma' | 'drizzle' | 'mongodb' | 'dynamodb' | 'cosmos' | 'bigtable' | 'custom''memory'Backend adapter.
namestring'default'Named connection for multi-database use.
optionsper-arm (see type)—Adapter-specific configuration.
readinessDatabaseReadinessOptions—Opt-in expected migration names and ledger entity; see Migrations and readiness.

A name other than 'default' registers under database.<name> (e.g. database.primary). Note the dot, not a colon — createCapabilityToken rejects colons.

Each arm narrows options: type: 'prisma' requires prismaClient, type: 'drizzle' requires both drizzleInstance and drizzleTables, type: 'mongodb' requires either url or client, type: 'dynamodb' requires either region or client; type: 'cosmos' requires database plus either an endpoint/key pair or a client; type: 'bigtable' requires instance plus either a projectId or a client; and type: 'custom' requires adapter. Those are required by the union, so omitting one is a compile error rather than a startup throw. Three arms carry their own option bag rather than the shared DatabaseAdapterOptions: the 'mongodb' arm its MongoAdapterOptions — see the MongoDB backend below — the 'cosmos' arm its CosmosAdapterOptions, see the Azure Cosmos DB backend, and the 'dynamodb' arm its DynamoAdapterOptions (see the DynamoDB backend section of PUBLIC_API.md), and the 'bigtable' arm its BigtableAdapterOptions — see the Cloud Bigtable backend.

options fieldTypeDefaultRead by
logQueriesbooleanfalseThe database service, so it applies to every adapter including 'custom' — logs entity, operation and monotonic duration.
prismaClientunknown—Prisma. Required.
providerPrismaSqlProviderderivedPrisma. Names the connector when it cannot be detected; see contains below.
drizzleInstanceDrizzleDatabaseIdentity—Drizzle. Required; created by createDrizzleDatabase().
drizzleTablesRecord<string, unknown>—Drizzle. Required; entity name → real table definition.
transactionTimeoutnumber (ms)30000Prisma only. Raises Prisma’s ~5 s interactive-transaction default, which is too short for a full Unit of Work. Unread by Memory and Drizzle.
urlstring—Deprecated and unread by Prisma — a v7 client carries its own connection configuration; see below. The 'mongodb' arm reads it as the driver connection string.

Prisma 7 setup

Generate and construct the client in the application, then pass it as options.prismaClient: a framework package cannot locate an application-selected generated-client output path. Three prerequisites are easy to miss, and none of them is this package’s to supply:

  1. A driver adapter is required. Prisma 7 removed the Rust query engine, so new PrismaClient() with no argument does not compile — install the adapter for your connector (@prisma/adapter-pg, @prisma/adapter-mariadb, …) and pass it as adapter.
  2. schema.prisma may no longer carry url. Prisma 7 answers P1012: The datasource property 'url' is no longer supported in schema files; the connection string moves to a prisma.config.ts.
  3. A non-public PostgreSQL schema must be named on the adapter. ?schema=… in the URL is read by Prisma Migrate only, so the driver adapter needs new PrismaPg({ connectionString }, { schema }). Without it every query resolves against public and fails with The table 'public.<Model>' does not exist.

For Drizzle, wrap a configured Promise-aware driver and explicit transaction bridge with createDrizzleDatabase() and pass that opaque configuration beside a table registry. Every field supplied to repository where, orderBy, or select must be a real column on that table. Its create, update, and delete operations require a dialect that supports RETURNING, so the adapter can return the actual persisted row instead of guessing:

const drizzleDatabase = createDrizzleDatabase(db, (database, work) => database.transaction(work));

DatabasePlugin({
  type: 'drizzle',
  options: {
    drizzleInstance: drizzleDatabase,
    drizzleTables: { User: users },
  },
});

A Drizzle table whose key column is not a column of the table is refused when the repository is created — getRepository (and the Unit of Work’s data source) — naming the table and the missing column, instead of surfacing lazily inside findById, update or delete. A create, update or updateWhere payload that names a field that is not a column of the table is likewise refused by name before any I/O, instead of being dropped by Drizzle and surfacing as the driver’s raw Failed query. That refusal is UnknownPayloadFieldError, answered 400 Bad Request with the fixed detail The request names a field this resource does not have. — a body passed straight to repo.update(id, body) carries the client’s fields, so an extra one is the caller’s error. The message names the entity and the field for the log; the served detail names neither.

Nested JSON path filters

A filter whose field is a path array (['profile', 'city']) addresses inside a JSON column, and no two SQL dialects spell that extraction alike — PostgreSQL uses #>>, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract. The adapter reads the dialect off the Drizzle instance, which works for every dialect Drizzle ships; pass dialect explicitly when detection cannot name it, and a path filter is otherwise refused by name rather than emitted in a guessed syntax:

DatabasePlugin({
  type: 'drizzle',
  options: { drizzleInstance: drizzleDatabase, drizzleTables, dialect: 'postgresql' },
});

Two behaviours are worth knowing: in expands to an OR of equality legs, because no dialect offers a path membership operator; and extraction normalises to text, casting back to numeric for an ordered comparison against a number, so age > 9 matches 30 rather than comparing '30' > '9' as text.

The registry accepts any table, including one with a composite primary key. The id column is a repository precondition rather than a registry one — IRepository.findById/update/delete are single-key by contract — so getRepository('TenantFlag') on an id-less table is refused by name while the typed query builder reaches the whole schema. Registering a join or per-tenant table so a native join can name it therefore no longer locks every other table out.

Promise-aware SQLite Proxy/libsql-shaped Drizzle instances are accepted even when they do not expose execute(). Repositories, transactions, and typed builders remain available; only IDatabaseService.query() rejects, with guidance to use the typed builder instead. That refusal is a contract requirement rather than an unfinished feature: those drivers do expose all(), but on a raw statement the proxy protocol answers with positional rows ([['a', 1]]) because Drizzle has no field map for a statement it did not build, while query<T>(): Promise<T[]> promises row objects — as Prisma and D1 both return.

IDatabaseService.query(sql, params) binds every parameter, never interpolating it. The statement carries the connector’s own placeholders ($1… on PostgreSQL, ? on MySQL and SQLite) and is emitted verbatim for an ascending-placeholder statement. A placeholder count that disagrees with the parameter list, a gap in the $N sequence, or both styles in one statement is refused before the statement reaches the driver — a mis-bound parameter is silent, so guessing is not an option.

On PostgreSQL, ?, ?| and ?& are also jsonb key-containment operators, and no scanner can tell one from a placeholder. Such a statement is refused here (its token count disagrees with the parameter list) or fails at the database as a syntax error — never mis-bound. Write it with $N placeholders, which are unambiguous on that connector.

Synchronous callback drivers such as better-sqlite3, Bun SQLite, Expo SQLite, and OP SQLite are not supported by this adapter. Their transaction callbacks return before awaited Unit-of-Work work can run, so accepting them would falsely report atomicity. createDrizzleDatabase() rejects their published transaction types at compile time; passing an unwrapped instance is rejected during startup. Unknown Promise-adopting and thenable wrappers are not inferred safe; without an explicit source-owned bridge they are rejected before native transaction work or application work begins.

Typed Drizzle queries

Use the same opaque configuration supplied in plugin options. getDrizzleDatabase() infers and returns the full configured type. getDrizzleTransaction() derives Drizzle’s native callback transaction type from it, preserving schema and selected-row inference while excluding outer-only operations such as SQLite Proxy’s batch(). Repository work and native joins therefore share one rollback boundary without falsely exposing the complete outer database:

import { eq } from 'drizzle-orm';
import { integer, pgTable, text } from 'drizzle-orm/pg-core';
import {
  createDrizzleDatabase,
  getDrizzleDatabase,
  getDrizzleTransaction,
} from '@setu-ts/database-plugin';

const users = pgTable('users', { id: text('id'), teamId: integer('team_id') });
const teams = pgTable('teams', { id: integer('id'), name: text('name') });

const drizzleDatabase = createDrizzleDatabase(drizzleDb, (database, work) =>
  database.transaction(work),
);
const outer = getDrizzleDatabase(db, drizzleDatabase);
const allUsers = await outer.select().from(users);

await db.transaction(async (uow) => {
  await uow.getRepository<User>('User').create(newUser);

  const tx = getDrizzleTransaction(uow, drizzleDatabase);
  const joined = await tx
    .select({ userId: users.id, teamName: teams.name })
    .from(users)
    .innerJoin(teams, eq(users.teamId, teams.id));
});

Always supply the same opaque object configured on that plugin instance. Package-private storage correlates the inferred type, database identity, and transaction bridge, so mutation, cloning, or a freely selected generic cannot claim another database’s transaction surface. Use getDrizzleDatabase() when an outer-only operation is needed; getDrizzleTransaction() intentionally exposes only Drizzle’s transaction-safe callback surface. Memory, Prisma, and custom services throw an error naming their configured adapter. Objects not created by this plugin throw an invalid-scope error.

The MongoDB backend

type: 'mongodb' serves the database capability from a document store, over the native mongodb driver (mongodb). It implements all six IDataSource methods and translates all six NormalizedQuery members natively — where and filter become a match document, orderBy/offset/limit/select become sort/skip/limit/projection. Nothing is filtered, sorted or paginated in JavaScript.

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'mongodb',
    options: {
      url: 'mongodb://127.0.0.1:27017/app',
      collections: { User: { collection: 'users', primaryKey: 'user_id' } },
    },
  }),
);
options fieldTypeDefaultRead by
urlstring—The lazy client path. Required unless client is supplied; also supplies database when that option is absent.
clientIMongoClient—connect(). An already-constructed client; when present the lazy import('mongodb') never runs.
objectIdCtorIMongoObjectIdCtorfrom the lazy loadThe _id conversion. Supply it alongside an injected client whose collections use ObjectId keys.
databasestringfrom urlThe collection resolver. When neither this nor url yields a name, connect() fails at startup naming the option.
collectionsRecord<string, MongoEntityMapping>{}Per-entity { collection?, primaryKey?, idType? }. An unmapped entity uses its own name and 'id'.

Identity. _id is mapped to the configured primary key on read and back to _id on write. An ObjectId is rendered as its 24-hex string so a row stays JSON.stringify-able; a JSON scalar (string, number, boolean, null) keeps its own type, so the value create() returns is the value findById() accepts. Under the default idType: 'auto' a 24-hex string id is converted to an ObjectId, because findOne({ _id: '<24-hex>' }) misses an ObjectId key; anything else — including a numeric primary key — is passed to the driver verbatim. 'objectId' forces the conversion and refuses a value it cannot convert by name; 'raw' forbids it, which is the setting for a collection whose _id values genuinely are 24-hex strings (no runtime test can tell that case from an ObjectId one).

contains. Compiles to an escaped $regex. This is the inverse of the SQL case: % and _ are ordinary data in Mongo, while . and * are wildcards, so the value is regex-escaped and a search for 3.5 does not match 315. The match is case-sensitive and cannot be made otherwise through this operator: MongoDB does not apply a collection’s collation to $regex, so even a case-insensitive collation leaves contains case-sensitive.

rawQuery is refused by name with UnsupportedRawQueryError — MongoDB has no SQL, so IDatabaseService.query() is unavailable on this arm and an application reaches the injected client directly for native commands (aggregate, runCommand), exactly as it does for a Prisma raw query.

Transactions use a driver session. They require a replica set, and the refusal is late — never at connect() — so a standalone mongod remains a valid deployment for an application that never opens one. On the real driver it surfaces at the first operation inside the transaction, not at beginTransaction(): startTransaction() resolves, and that operation rejects with the driver’s own MongoServerError (code: 20, codeName: 'IllegalOperation'), unwrapped (measured, M107). MongoTransactionUnavailableError wraps only a startTransaction() that itself throws, which the real driver does not.

The Azure Cosmos DB backend

type: 'cosmos' serves the database capability from Azure Cosmos DB’s NoSQL (SQL) API, over @azure/cosmos (@azure/cosmos). Every NormalizedQuery member is translated natively — where/filter become a bound SQL predicate, orderBy an ORDER BY, offset/limit an OFFSET … LIMIT, and select a projection list. Nothing is filtered, sorted or paginated in JavaScript.

Cosmos DB’s MongoDB API is a different wire protocol and is served by the 'mongodb' arm pointed at a Cosmos connection string, not by this one. That route is documented rather than tested, and the reason is a version floor rather than a dead image: the emulator’s MongoDB endpoint (AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT) tops out at API version 4.0, which reports wire version 7, while the mongodb driver this package pins requires wire version 8 (MongoDB 4.2). Measured: the endpoint completes a handshake and then refuses with reports maximum wire version 7, but this version of the Node.js Driver requires at least 8. A live Azure Cosmos for MongoDB account offers server versions above that floor, so the route is unverified against a live account rather than known to fail.

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'cosmos',
    options: {
      endpoint: 'https://my-account.documents.azure.com:443/',
      key: cosmosKey,
      database: 'app',
      containers: { Order: { container: 'orders', partitionKey: 'tenantId' } },
    },
  }),
);
options fieldTypeDefaultRead by
endpointstring—The lazy client path. Required unless client is supplied.
keystring—The lazy client path. Required unless client is supplied.
clientICosmosClient—connect(). When present the lazy import('@azure/cosmos') never runs — the route for an Entra ID (managed identity) client.
databasestring—Required on both arms. A Cosmos endpoint encodes no database name, so unlike a MongoDB URI there is nothing to fall back to.
containersRecord<string, CosmosEntityMapping>{}Per-entity { container?, primaryKey?, partitionKey? }. An unmapped entity uses its own name and 'id'.
logQueriesbooleanfalseThe database service, exactly as for every other arm.

Containers must already exist. The adapter creates no database and no container: throughput, partition-key and indexing choices belong to the application’s provisioning. A missing container is refused by name at first use, naming the database and the container.

The partition key is discovered, not guessed. A point read carrying the wrong partition key answers 404 rather than an error, so a mistyped path would make every read of a healthy container report “not found” for the life of the process. The adapter therefore reads the container definition once per container and uses the paths it declares. A partitionKey in the mapping is still honoured — and validated against the definition, refused by name when the two disagree.

Identity. A Cosmos document is addressed by the pair (partition key, id), which is why findById behaves in three ways:

  • a composite EntityKey record carrying both the primary key and the partition key is a point read;
  • a scalar key is also a point read when the container partitions by the primary-key field itself;
  • otherwise the partition key is unknown, so the row is found by a cross-partition query. That costs more request units than a point read, and an id is unique only WITHIN a partition, so a lookup matching two documents is refused by name rather than answered with one of them.

A primary key must be a string: the service refuses a non-string id outright, and converting one silently would return a key of a different type than the caller supplied. A partition-key value of any other JSON scalar type is passed through untouched.

update merges the payload server-side with a patch while it fits within one request, and falls back to a read-merge-replace guarded by the document’s _etag beyond that — a concurrent writer is then reported as CosmosConcurrentModificationError rather than silently overwritten. The primary key never travels in the payload, and a payload that would CHANGE a partition-key value is refused by name, because such a replace answers 404 rather than moving the item.

contains compiles to CONTAINS, a literal substring match in which % and _ carry no special meaning, so nothing is escaped — the inverse of the SQL case. The match is case-sensitive.

Pagination uses the framework’s portable keyset cursor rather than a Cosmos continuation token. That is this adapter’s design choice, and it is what the tested backend supports: measured against the emulator, an ORDER BY query returns no continuation token even when maxItemCount is supplied as a query option — the option is ignored and the whole result set arrives in one page. The claim is scoped to what was measured rather than asserted of every Cosmos deployment, and a page without a stable sort is not a page in any case. On a real account a multi-property ORDER BY needs a composite index, and keyset paging always adds the key column as its tiebreaker — so define a composite index over (sort field, id) for every container you page.

rawQuery is refused by name with UnsupportedRawQueryError. Cosmos has a SQL dialect, but every query is scoped to one container and query(sql, params) names none; an application reaches the injected client directly (container.items.query).

Transactions are a deferred batch. Cosmos has no interactive transaction, so beginTransaction() buffers every write and flushes the buffer as one transactional batch at commit, and rollback() discards it without sending anything. That batch is atomic within one container and one partition-key value, and caps at 100 operations; a write that leaves those bounds is refused with CosmosTransactionScopeError at the write itself, naming what it crossed. Reads inside a transaction observe committed state only.

A buffered update is a patch, not a whole-document replace, exactly as the non-transactional path is: it writes only the fields the payload names, so it neither clobbers a concurrent writer’s other fields nor discards an earlier update of the same row — two patches of one row compose. Only an update too wide for a single patch request falls back to a replace assembled from the committed read, and that replace carries the whole document, so it cannot compose: buffering one for a row the same transaction has ALREADY written is refused with CosmosTransactionScopeError rather than silently discarding the earlier write. Merge the two updates, or use two transactions.

rollback() is idempotent, unlike commit(). That asymmetry is deliberate: the framework rolls back inside the same catch that sees a failed commit, so a refusal there would replace the batch’s own diagnostic — its status and per-operation codes, the only thing naming the operation that failed — with a complaint about rollback, on every throttled or rejected batch.

What this adapter deliberately cannot do

Two of these are platform limits — what Cosmos itself refuses — and three are contract limits, which the portable data-access contract does not express. The distinction decides who could close them:

Not availableWhy
Cross-container joinsImpossible on Cosmos. A query addresses exactly one container; a second source in the FROM or a JOIN against another container is rejected with a 400 (measured, both spellings). Cosmos’s JOIN unwinds an array inside one item, which the portable contract has no member for — reach the injected client for it.
Grouping / aggregationAbsent from the portable contract, not from Cosmos. NormalizedQuery carries where/filter/orderBy/limit/offset/select/cursor, and count is its only aggregate; the dialect itself supports GROUP BY (measured). Closing this is a common widening every adapter would have to answer, not a Cosmos change.
Continuation tokensNot returned by Cosmos for any query carrying ORDER BY (measured, cross-partition and single-partition alike), which is why paging is the portable keyset cursor.
rawQuery / query()Refused by name: a Cosmos query is scoped to one container, and the signature names none.
RUs, consistency, TTL, index policyOutside the portable contract by design — no two candidate backends spell any of them alike. Configure them on the container, or reach the injected client.

Running the guarded Cosmos suite

test/integration/real-cosmos-adapter.test.ts is guarded on COSMOS_ENDPOINT and skipped without it. Against the local emulator:

docker run -d --name cosmos -p 127.0.0.1:8082:8082 \
  -e PORT=8082 -e PROTOCOL=http \
  mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
# wait for "PostgreSQL=OK, Gateway=OK" in `docker logs cosmos` (~25 s cold)

COSMOS_ENDPOINT=http://127.0.0.1:8082/ deno test -A \
  packages/database-plugin/test/integration/real-cosmos-adapter.test.ts

COSMOS_KEY defaults to the emulator’s well-known key; set it for a real account. The suite is not run by CI — the image is 2.5 GB, which is the same reason the Pub/Sub and Service Bus emulator suites are local-only.

The Cloud Bigtable backend

type: 'bigtable' serves the database capability from Google Cloud Bigtable, over @google-cloud/bigtable (@google-cloud/bigtable).

Bigtable inverts the DynamoDB problem. Its row key is a single lexicographically-sorted string, so findById fits it natively with no key object at all. What it lacks instead is everything around the key: there is no secondary index of any kind, so a predicate on a non-key column is a scan, and orderBy is row-key order or nothing.

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'bigtable',
    options: {
      projectId: 'my-project',
      instance: 'app-instance',
      tables: {
        Order: {
          table: 'orders',
          rowKey: { fields: ['tenantId', 'orderId'], separator: '#' },
          columnFamily: 'o',
          columns: { total: 'metrics:amount' },
        },
      },
    },
  }),
);
options fieldTypeDefaultRead by
projectIdstring—The lazy client path. Required unless client is supplied.
instancestring—Required on both arms. A table is addressed as project/instance/table, and neither a client nor a project encodes it.
apiEndpointstring—The lazy client path. The emulator address; an endpoint alone reaches cbtemulator with no credentials.
clientIBigtableClient—connect(). When present the lazy import('@google-cloud/bigtable') never runs.
tablesRecord<string, BigtableEntityMapping>{}Per-entity { table?, rowKey?, columnFamily?, columns?, valueEncoding? }. An unmapped entity uses its own name, ['id'] and cf.
maxPageFetchesnumber10findPage’s fill loop, bounding the re-fetch when a client-side filter empties a raw batch.
logQueriesbooleanfalseThe database service, exactly as for every other arm.

Tables must already exist. The adapter creates no instance and no table: column families, garbage-collection policies and split points belong to the application’s provisioning. A missing table is reported by the service as 5 NOT_FOUND quoting the full resource path, which is also why connect() issues no RPC at all — an instance.getTables() probe is a table-ADMIN call a data-plane service account commonly cannot make, so probing would refuse a working configuration. Configuration mistakes are caught at construction instead, by name.

The row key is composed from logical fields. rowKey: { fields, separator?, prefix? } maps the repository’s EntityKey onto the single string Bigtable addresses a row by. A single-field key accepts a scalar; a multi-field key requires a record naming every field, and refuses a scalar — a scalar cannot say which field it is. A field value containing the separator is refused, because two different logical keys would otherwise compose to one row key: a write would silently overwrite an unrelated row and a read would return it.

A key field’s type is not part of the row key. A numeric field renders as its decimal text, so 1 and '1' are one physical row: creating one refuses the other as existing, and findById('1') answers the row stored under 1 — whose id cell still decodes as the number. Tagging the key would make it unreadable in cbt and break every table this adapter did not write, so the mapping does not; choose one type per key field. If lexicographic order matters for a numeric field, zero-pad it, exactly as you would writing row keys by hand.

Key fields are written as ordinary cells AND recovered from the row key on read, with the cells winning. All three parts matter: a Bigtable row cannot exist with zero cells, so writing the key guarantees the row exists; the row key is bytes and records no type, so overlaying it over a cell would turn a numeric key field into a string; and a table written outside this framework has no key cells at all, which is what the parse-back is for.

Values are tagged by default. valueEncoding: 'tagged' writes <tag>:<payload>, so a number, boolean, null, Date or object round-trips as itself. A cell carrying no recognised tag decodes as its raw string — the interop path, without which this adapter could not read a table it did not write. valueEncoding: 'raw' writes String(value) and reads every cell as a string, which removes the residual ambiguity for an application whose table is entirely foreign.

orderBy is the row key or nothing. An empty sort is honoured, and so is one naming exactly the mapped key fields, in order, all ascending — that IS the scan order. Everything else is refused by name: a non-key field has no index to sort by, and descending is refused deliberately rather than shipped on reversed: true, because the emulator this adapter is tested against silently ignores that option (measured: it answered ascending, with no error), so a descending path could not be verified. A non-zero offset is refused too — Bigtable has no row offset, and discarding scanned rows would read and bill them.

Three things reach the server, and nothing else. The row set (an eq on every key field is an exact key; an eq pinning a leading prefix is a prefix range; a pinned prefix plus an in on the final field is an explicit key list), byte-exact value equality for each conjunctive non-key eq, and the column projection. Everything else — contains, the ordered comparisons, in on a non-key field, any disjunction — is evaluated by the same matchesFilter the memory adapter uses as the portable reference, so the six backends cannot drift about what a FilterExpression means. The invariant is that a push-down may only ever match a superset of what the client-side evaluator keeps; every fallback widens rather than narrows.

Two details of that push-down are correctness requirements rather than tuning. A value test is an exact BYTE RANGE and never the SDK’s string form, which is a regex — measured, { value: 'a.*b' } matched both a.*b and axxb. And it is wrapped in a condition filter rather than chained directly, because a bare chain STRIPS every non-matching cell, so the row would come back carrying only the cell that matched. An ordered comparison on a key field is not pushed down either: the composed key is a string, so a numeric key field does not sort numerically inside it — zero-pad a numeric key field if you need its lexicographic order to match.

Pagination is a start-key cursor over the framework’s portable keyset codec, which is exactly what Bigtable’s continuation mechanism is. nextCursor is non-null if and only if the page is non-terminal, never derived from rows.length: a client-side filter can empty a whole raw batch, so a page bounded by maxPageFetches returns zero rows AND a cursor, minted from the last row scanned.

rawQuery is refused by name with UnsupportedRawQueryError. Bigtable has no query language behind query(sql, params) — its data plane is ReadRows, MutateRow and CheckAndMutateRow — so IDatabaseService.query() is unavailable on this arm and an application reaches the injected client directly.

Transactions are one row. Bigtable’s only atomicity unit is the single row: a multi-row batch is atomic per entry and not as a whole. So beginTransaction() buffers writes, refuses a second row key at the write that crosses the bound with BigtableTransactionScopeError, and commits the buffer as ONE CheckAndMutateRow whose mutation list applies atomically and in order — a buffered delete followed by writes replaces the row wholesale. rollback() discards and sends nothing, and is idempotent. Reads inside a transaction observe committed state only.

create and update are conditional writes, not blind ones: Bigtable’s insert is an upsert, so create would otherwise overwrite an existing row and update would fabricate an absent one. The CheckAndMutateRow match flag is what makes both refusals real, and it holds inside a transaction too — a buffered create whose row turns out to exist is refused at commit.

What this adapter deliberately cannot do

Not availableWhy
Any non-key sort or indexAbsent from Bigtable. There are no secondary indexes; the row key is the only ordering. Design the row key for the access pattern, as the platform intends.
Descending scansRefused rather than shipped: the emulator this adapter is tested against silently ignores reversed: true, so the path could not be verified. Re-openable by a milestone that can test a live instance.
offsetAbsent from Bigtable. Emulating one would read and bill the skipped rows; findPage is the route.
rawQuery / query()Refused by name: Bigtable’s data plane is ReadRows/MutateRow/CheckAndMutateRow, and GoogleSQL executeQuery has no portable member in this contract.
Cell versioning and GC policiesOutside the portable contract by design — no other backend has a counterpart, so exposing it would invent a concept for one adapter. Configure GC on the column family; reach the injected client for versions.
Multi-row transactionsAbsent from Bigtable. A batch is atomic per entry, not as a whole; a second row key is refused by name rather than promised.
Grouping / aggregation and joinsAbsent from the portable contract, exactly as for Cosmos and DynamoDB. count is its only aggregate.

Running the guarded Bigtable suite

test/integration/real-bigtable-adapter.test.ts is guarded on BIGTABLE_EMULATOR_ENDPOINT and skipped without it. Against the local emulator:

docker run -d --name he-bigtable -p 127.0.0.1:8086:8086 \
  gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators \
  gcloud beta emulators bigtable start --host-port=0.0.0.0:8086

BIGTABLE_EMULATOR_ENDPOINT=127.0.0.1:8086 deno test -A \
  packages/database-plugin/test/integration/real-bigtable-adapter.test.ts

The emulator implements no instance admin API — instance.create() answers 12 UNIMPLEMENTED — so instances are implicit and the suite creates tables directly. Unlike the Cosmos one, this suite is run by CI: the image is 1.75 GB against the Cosmos emulator’s 2.48 GB, and every push-down above is only correct if the service agrees.

Filtering and single-row lookup

findAll, findOne, and count accept an equality where map and an optional portable filter expression. The two are conjoined, so existing where-only calls are unchanged. Every built-in adapter — Memory, Prisma, Drizzle, and cloudflare-plugin’s D1 — translates the same operators: eq, contains, gt, gte, lt, lte, and in, composed with and / or.

const repo = db.getRepository<User>('User');

const user = await repo.findOne({
  filter: { type: 'comparison', field: 'email', operator: 'eq', value: 'ada@example.com' },
});

const recent = await repo.findAll({
  where: { active: true },
  filter: {
    type: 'or',
    filters: [
      { type: 'comparison', field: 'name', operator: 'contains', value: 'Ada' },
      { type: 'comparison', field: 'age', operator: 'gte', value: 18 },
    ],
  },
  orderBy: { age: 'desc' },
});

findOne returns the first matching row or null; it is findAll with limit: 1, so it shares one evaluation path with every adapter. An in with an empty list matches nothing, and a list containing null matches rows whose column is null (SQL IN never matches NULL on its own, so the adapters emit an explicit null branch).

contains is a substring match, and its case sensitivity belongs to the database. The Memory adapter, D1 and Mongo match case-sensitively — Mongo compiles to $regex, which MongoDB does not apply collation to, so a case-insensitive collection collation does not change it. A LIKE-based backend follows the column’s collation, which is case-sensitive on PostgreSQL and case-insensitive on SQLite and most MySQL collations.

% and _ in the searched value are data, never wildcards — and on the Prisma adapter the connector decides how that is achieved. Memory (includes), Drizzle (LIKE … ESCAPE '\') and D1 (instr) honour it unconditionally. Prisma emits a bare LIKE with no ESCAPE clause, so the effective escape character is the connector’s own default:

Prisma connectorcontains behaviour
postgresql / postgres, mysql, sqlserver, cockroachdbValue escaped and matched literally — their LIKE defaults its escape character to backslash.
mongodbValue passed through unchanged — contains compiles to a $regex match, where % and _ are already literal.
sqliteRefused with UnsupportedFilterOperatorError — Prisma emits no ESCAPE clause and SQLite defines no default escape character, so a literal match is not expressible through Prisma’s filter API. Use a raw query, or the Memory/Drizzle adapter.

When the connector cannot be determined the same error is thrown naming the provider option; pass provider (e.g. provider: 'postgresql') in the adapter options to name it explicitly.

The Memory adapter’s guarantees

MemoryAdapter is the default because it needs no driver, not because it is a stand-in for one. It is never given a schema, so what it can and cannot enforce follows from that, and the difference matters most to the people it is aimed at: develop against the default, deploy against Prisma or Drizzle, and an unenforced rule becomes a 500 in production.

BehaviourMemoryPrisma / Drizzle
Unknown select / orderBy columnRefused by nameRefused by name
Unknown where / filter fieldMatches nothingRefused
Duplicate primary keyRefused (a caller-supplied key)Refused
Changing a primary key by updateRefused (PrimaryKeyChangeError, 400)Refused (PrimaryKeyChangeError, 400)
Unique constraintNot enforced — a duplicate value is acceptedEnforced
Column typesNot enforced — a string into an Int is storedEnforced
Foreign keys, checks, defaultsNot enforcedEnforced

This refusal is now portable. On every adapter — Memory, Drizzle, Prisma, MongoDB, DynamoDB, Cosmos DB, Cloud Bigtable and the D1 adapter in @setu-ts/cloudflare-plugin — an update or updateWhere payload that carries a key column whose value is not undefined and differs from the addressed key’s value is refused with PrimaryKeyChangeError (400), before any I/O. An equal value is accepted; it is compared as a string for string, number, bigint and ObjectId-like values, and by time for Date values. That is why update('5', { id: 5 }) is accepted — a route parameter is a string, a JSON body carries a number — while '05' against 5 is refused. The check covers scalar key columns. Through Prisma, a body passed straight to update can still change a key column that is also a relation’s foreign key with a nested relation write such as { tenant: { connect: … } }; this case has not been tested against a generated client, so write the fields you mean rather than forwarding a body.

The first two rows are things this adapter can decide, and it does. The primary key is the one constraint it knows, and every lookup by id addresses the first match, so a create whose caller-supplied key is already stored is refused instead of leaving a second, unaddressable row. For select/orderBy: a select or orderBy field that no stored row carries is refused with the entity, the clause and the observed column list, matching what Drizzle answers for the same call. Two consequences of measuring rather than declaring are worth knowing: a field carried by at least one row counts as known (so a sparse optional column works), and an entity holding no rows at all accepts anything, because there is nothing to observe and nothing to return.

where and filter are deliberately not checked. Without a schema this adapter cannot tell an unknown column from one that is absent on every row, and returning no rows is a defensible answer to the second — whereas ordering by a column no row has returns rows in an arbitrary order and projecting one silently changes the response shape.

Uniqueness and types are outside what any schema-less store can do. Use the Memory adapter for development and tests, and run integration tests against the backend you deploy on.

Development: preserve caught causes

When an adapter replaces a caught error with a more actionable framework error, it forwards the original value through native ErrorOptions (new Error(message, { cause })). The replacement explains the failed framework operation; the cause preserves the driver diagnostic for logs. A catch that intentionally drops a cause must carry a drops-cause: <reason> marker and be justified in its test.

What a driver condition returns to the client

Four driver conditions are classified (classifyDriverError, internal) at DatabaseService and answered as typed errors, so a caller can instanceof them instead of matching message text:

ConditionThrown asServed status
The backend rejected a write because a concurrent transaction changed the same data (SQLSTATE class 40, Prisma P2034, Mongo TransientTransactionError, gRPC ABORTED, Cosmos 449)SerializationConflictError409
The database, its pool, or its network is temporarily unreachable (SQLSTATE class 08, 57P03, both node-postgres connect-timeout messages (timeout exceeded when trying to connect, Connection terminated due to connection timeout), MongoNetworkError/MongoServerSelectionError, gRPC UNAVAILABLE, Cosmos 429/503, the net errno ECONNREFUSED family)DatabaseUnavailableError503
node-postgres’s query_timeout fired on a query (the driver’s Query read timeout rejection)DatabaseTimeoutError504
The write would duplicate a primary key or a unique index (SQLSTATE 23505, Prisma P2002, MySQL ER_DUP_ENTRY, MongoDB 11000, Cosmos 409, SQLite and D1 UNIQUE constraint failed)DuplicateKeyError409

DuplicateKeyError is exported from @setu-ts/common, not from this package, because more than one package raises or recognises it (the D1 adapter in @setu-ts/cloudflare-plugin, and any store that records “already processed” with a unique insert). Unlike a serialization conflict it is not retryable: the same write fails the same way. The memory, DynamoDB and Bigtable adapters raise it themselves, and it carries the targeted entity when the failing call names one. A duplicate found at commit() (D1’s batch, a Cosmos transactional batch, a DynamoDB TransactWriteItems cancelled by a create’s guard, a buffered memory or Bigtable create) is mapped too; every other commit rejection is still rethrown unchanged, because its outcome can be unknown.

All four carry the original driver error as cause — the SQLSTATE and the failing statement stay reachable for the log (M90j) — and none serves driver text: the body’s detail is a fixed sentence. Unlike a DatabaseUnavailableError, a DatabaseTimeoutError does not say the operation did not happen: the timed-out statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent. No classified answer carries Retry-After: the hint channel has no header, and the framework holds no honest horizon for a pool’s saturation. Catch-then-throw sites and the typed Drizzle builder (getDrizzleDatabase) bypass the classifier — the operator work is M90j’s row.

What a refused query returns to the client

The three query-shape refusals — UnsupportedQueryFeatureError, UnsupportedFilterOperatorError and UnsupportedRawQueryError — are answered 501 Not Implemented when @setu-ts/exceptions’ errorHandler is registered. The status and the detail sentence are the invariant; the body’s shape is the format that application configured. Under format: 'rfc9457':

{
  "type": "about:blank",
  "title": "Not Implemented",
  "status": 501,
  "detail": "Query feature 'orderBy' is not supported by the 'dynamodb' database adapter."
}

Under the default format the same refusal reads {"statusCode":501,"message":"Not Implemented","details":{"detail":"…"}}, and with no errorHandler registered, {"error":"Not Implemented","detail":"…"}.

501 because the condition is permanent and the backend genuinely does not implement what the query asked for. This is the shape you meet when switching backends, which is what the portable contract is for: an application that works on MongoDB will answer 501 on an ordered endpoint under DynamoDB rather than the 500 Internal Server Error it used to (M89b).

A cursor the backend cannot use is not one of these. A ?cursor= token that is malformed, that was made under a different sort (a sort-fingerprint mismatch), or whose decoded key-value list is the wrong shape for the target is refused with InvalidCursorError (from @setu-ts/common), answered 400 Bad Request with the fixed detail The pagination cursor is not valid for this query., before any backend call. The error’s message names the entity and which check failed, and never the token or its values. On Drizzle and Prisma, a cursor that decodes cleanly but carries a value of the wrong type for its sort column — PostgreSQL refuses it with 22P02, 22003, 22007 or 22008, Prisma with its own validation error — is answered the same 400; a page requested without a cursor keeps the driver’s error. 501 remains for a backend that cannot page the query at all.

The error’s message is never served. It is the full diagnostic — it names entities, columns and sort keys — and it reaches the log alone, where errorHandler records it unmasked along with the cause chain. The served detail is composed from the framework’s own identifiers, which is what lets a refusal be readable without becoming a disclosure channel.

migrate() also answers 501, through the separately named UnsupportedMigrationError. Programmatic migration is unsupported by every current adapter and belongs to that adapter’s CLI; it is a framework capability boundary, not a raw query or query-shape refusal.

Not every UnsupportedQueryFeatureError is branded, and that is deliberate. The class is shared by two kinds of refusal: a caller-caused query shape, and a configuration refusal. Only the former is answered 501; the latter keeps the masked 500 that is correct for an internal fault. Branding the constructor unconditionally made a blank columnFamily — a value the developer wrote — answer every request 501 "Query feature 'mapping' is not supported by the 'bigtable' database adapter.", which is a lie twice over. The split is an allowlist of feature values (attribute-value, composite-key, cursor-pagination, key, nested-path, offset, order-by/orderBy, row-key, update); mapping, endpoint, date-encoding and transaction are excluded, and an unclassified value is not branded — so a feature name added later keeps its masked 500 until someone decides otherwise.

The transaction and concurrency errors (MongoTransactionUnavailableError, CosmosTransactionScopeError, CosmosConcurrentModificationError, BigtableTransactionScopeError) deliberately keep the masked 500: they may quote backend state, and a concurrency conflict is transient rather than permanent. Branch on them with instanceof — every one is exported.

Bounding a call

The plugin adds no call deadline of its own. A JavaScript deadline cannot cancel the statement — the connection stays occupied, so a deadline only moves the hang from the caller to pool exhaustion — and inside a transaction it rejects while the statement may still commit. The bound is the driver’s own, enforced by the driver that owns the connection; on node-postgres a query that hits query_timeout rejects with the driver’s Query read timeout, and the timed-out client is released with the error and removed from the pool, so the bound does not leak a connection. A call that hits the bound is answered 504 Gateway Timeout with DatabaseTimeoutError; the statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent.

AdapterThe driver’s own call boundStatus
Drizzle on node-postgresSet query_timeout and connectionTimeoutMillis on the Pool you pass Drizzleverified (M113g)
Prismano per-call option; Prisma 7 runs over a driver adapter, so set that driver’s bound (for @prisma/adapter-pg, the same pg Pool options)unverified
MongoDBMongoClient’s timeoutMS (client-side operation timeout), or a per-operation maxTimeMSunverified
DynamoDBthe AWS SDK v3 NodeHttpHandler requestTimeoutunverified
Cosmos DBconnectionPolicy.requestTimeout on the CosmosClient optionsunverified
Bigtablethe gRPC call deadline (timeout in google-gax call options)unverified
D1none the binding exposes; only the platform’s own query limits applyunverified

Inside service.transaction(...) the bound applies twice. The timed-out statement rejects after query_timeout, and the ROLLBACK that follows waits behind it on the same connection until its own bound expires, so the caller receives DatabaseTimeoutError after about twice query_timeout (measured: about 2.03 s with query_timeout: 1000 against a paused PostgreSQL 16), still while the database is paused. It is a bound on each statement, not a deadline on the transaction.

Health reporting

The database indicator gates on the service’s lifecycle first (a closed database reads down immediately), then reports reachability from the adapter’s own probe. Since M95b the payload carries reachable whenever the adapter can probe:

reachableStatus/ready
trueup200
falsedown503
'unknown'degraded503
'unknown' while the pool is saturated and queries are completingup200
omittedup200

The rows are the whole decision. Since M101a a saturated pool is not an outage. When the Drizzle adapter’s poolStats reports every connection busy with callers waiting (idle === 0 and waiting > 0), the probe queues no SELECT 1 behind the callers it would be measuring, and the indicator reports up with reachable: 'unknown' and the snapshot in data.capacity — failing /ready there would pull every saturated replica out of rotation at once. A full pool is ALSO what a hung database looks like (every connection stuck on a server that never answers, callers piling up), so the row additionally requires that a query through the adapter completed within the last 10 seconds. The indicator samples the adapter’s completed-query count on each poll, so a hang is detected at most one poll interval plus that window after the last completion; a saturated pool whose queries have all stopped completing reports degraded and /ready answers 503. Only queries the adapter runs are counted (query(), repositories, the Unit of Work’s data sources): once getDrizzleDatabase or getDrizzleTransaction has handed out the native instance, its queries bypass the adapter, so progress is unobservable and a saturated pool keeps reading up — the indicator cannot then tell a hang from load. Queries the application runs on its own Drizzle instance are not counted either; an application that queries mainly that way should expect a busy pool to read degraded. That row needs poolStats: without it the indicator cannot tell saturation from trouble, so a pool connection timeout or a probe that does not answer reports degraded. A pool connection timeout used to report down (it answered false); it is now degraded, because the database never answered either way. A probe that exists and did not answer inside the 2-second bound is evidence of trouble and reports degraded — never up, which is the claim that let a stopped database keep taking traffic (X51-1: the indicator used to read the lifecycle isReady(), which only says connect() once succeeded). An adapter that ships no probe is the opposite case: a probe that was never written is evidence of nothing, so the payload omits reachable and the status is today’s — mapping a missing probe to degraded would fail /ready for every healthy Cosmos, Bigtable and DynamoDB application on upgrade.

Shipped probes: MongoDB (db.command({ ping: 1 }) through the optional IMongoDatabase.command? facade member), Prisma and Drizzle (SELECT 1; Drizzle omits the probe for instances without execute()), memory (its own connection state — the process is the backend). Cosmos, Bigtable and DynamoDB omit the probe until each gains one optional member on its own client facade; their payloads are unchanged. A custom adapter opts in by declaring isHealthy?(): Promise<boolean> on the adapter — true when the backend answered, false when it refused, or omit the member rather than inventing an answer. The lifecycle read still gates the probe, so an adapter whose probe forgets to re-derive isReady() cannot report up for a connection it has already dropped; a probe SHOULD still answer false once disconnected, because DatabaseService.reachability() is also read directly.

Transactions

IUnitOfWork groups repository work into one transaction. Prisma exposes only callback-style $transaction with a default timeout, so the adapter bridges that model rather than offering imperative begin/commit.

Exports

ExportKind
createDatabaseGrantSourcefunction
createDatabaseIdempotencyStorefunction
createDatabaseInboxStorefunction
createDatabaseOutboxStorefunction
createDatabaseRoleSourcefunction
createDatabaseTenantDataStorefunction
createDrizzleDatabasefunction
createDrizzleDataSourcefunction
createInjectedBigtableLoaderfunction
createInjectedDynamoLoaderfunction
createLazyBigtableLoaderfunction
createLazyDynamoLoaderfunction
createMigrationLedgerfunction
createPrismaDataSourcefunction
DatabasePluginfunction
decodeCursorfunction
encodeCursorfunction
getDrizzleDatabasefunction
getDrizzleTransactionfunction
keysetPredicatefunction
withIsolationSupportfunction
BaseRepositoryclass
BigtableAdapterclass
BigtableTransactionScopeErrorclass
CosmosAdapterclass
CosmosConcurrentModificationErrorclass
CosmosTransactionScopeErrorclass
DatabaseServiceclass
DatabaseTenantDataStoreclass
DatabaseTimeoutErrorclass
DatabaseUnavailableErrorclass
DrizzleAdapterclass
DrizzleRepositoryclass
DynamoAdapterclass
InboxStoreUnavailableErrorclass
MemoryAdapterclass
MigrationLedgerErrorclass
MissingKeyColumnErrorclass
MongoAdapterclass
MongoTransactionUnavailableErrorclass
OutboxStoreUnavailableErrorclass
PrismaAdapterclass
PrismaRepositoryclass
SerializationConflictErrorclass
TenantColumnUnresolvedErrorclass
TenantStoreStrategyUnsupportedErrorclass
TransactionalStoreUnavailableErrorclass
UnitOfWorkclass
UnknownPayloadFieldErrorclass
UnsupportedFilterOperatorErrorclass
UnsupportedIsolationLevelErrorclass
UnsupportedMigrationErrorclass
UnsupportedQueryFeatureErrorclass
UnsupportedRawQueryErrorclass
MIGRATION_LEDGER_ENTITYconst
BigtableAdapterOptionsBaseinterface
BigtableCellinterface
BigtableClientConfigurationinterface
BigtableClientLoaderinterface
BigtableDatabaseOptionsinterface
BigtableEntityMappinginterface
BigtableReadOptionsinterface
BigtableReadRowinterface
BigtableRowBoundaryinterface
BigtableRowKeyMappinginterface
BigtableRowRangeinterface
BigtableValueRangeinterface
CosmosAccessConditioninterface
CosmosAdapterOptionsBaseinterface
CosmosBatchDeleteOperationinterface
CosmosBatchInsertOperationinterface
CosmosBatchPatchOperationinterface
CosmosBatchReplaceOperationinterface
CosmosBatchResponseinterface
CosmosContainerDefinitioninterface
CosmosDatabaseOptionsinterface
CosmosEntityMappinginterface
CosmosFeedResponseinterface
CosmosItemResponseinterface
CosmosPatchOperationinterface
CosmosQueryParameterinterface
CosmosQuerySpecinterface
CosmosRequestOptionsinterface
CountOptionsinterface
CursorPayloadinterface
CustomDatabaseOptionsinterface
DatabaseAdapterOptionsinterface
DatabaseConnectionOptionsinterface
DatabaseGrantSourceOptionsinterface
DatabaseIdempotencyStoreOptionsinterface
DatabaseInboxStoreOptionsinterface
DatabaseOutboxStoreOptionsinterface
DatabasePoolCapacityinterface
DatabaseReadinessOptionsinterface
DatabaseRoleSourceOptionsinterface
DrizzleAdapterOptionsinterface
DrizzleCompositeKeyOptionsinterface
DrizzleDatabaseinterface
DrizzleDatabaseIdentityinterface
DrizzleDatabaseOptionsinterface
DynamoAdapterOptionsBaseinterface
DynamoAttributeValueinterface
DynamoClientConfigurationinterface
DynamoClientLoaderinterface
DynamoConditionExpressioninterface
DynamoDatabaseOptionsinterface
DynamoDeleteItemCommandInputinterface
DynamoDeleteItemCommandOutputinterface
DynamoEntityMappinginterface
DynamoExpressionAttributesinterface
DynamoGetItemCommandInputinterface
DynamoGetItemCommandOutputinterface
DynamoIndexMappinginterface
DynamoPutItemCommandInputinterface
DynamoPutItemCommandOutputinterface
DynamoQueryCommandInputinterface
DynamoReadCommandInputinterface
DynamoReadCommandOutputinterface
DynamoSdkClientinterface
DynamoSdkCommandinterface
DynamoSdkModuleinterface
DynamoTransactDeleteinterface
DynamoTransactPutinterface
DynamoTransactUpdateinterface
DynamoTransactWriteIteminterface
DynamoTransactWriteItemsCommandInputinterface
DynamoUpdateItemCommandInputinterface
DynamoUpdateItemCommandOutputinterface
FindOptionsinterface
GrantFieldsinterface
IAdapterTransactioninterface
IBigtableClientinterface
IBigtableInstanceinterface
IBigtableRowinterface
IBigtableTableinterface
ICosmosClientinterface
ICosmosContainerinterface
ICosmosDatabaseinterface
ICosmosIteminterface
ICosmosItemsinterface
ICosmosQueryIteratorinterface
IDatabaseAdapterinterface
IDatabaseServiceinterface
IDataSourceinterface
IDynamoClientinterface
IMigrationLedgerinterface
IMongoClientinterface
IMongoCollectioninterface
IMongoCollectionFindOneAndUpdateOptionsinterface
IMongoCursorinterface
IMongoDatabaseinterface
IMongoObjectIdinterface
IMongoObjectIdCtorinterface
IMongoSessioninterface
IRepositoryinterface
IUnitOfWorkinterface
MemoryDatabaseOptionsinterface
MigrationLedgerOptionsinterface
MongoAdapterOptionsBaseinterface
MongoDatabaseOptionsinterface
MongoEntityMappinginterface
MongoOptionsinterface
NormalizedQueryinterface
Pageinterface
PageResultinterface
PrismaAdapterOptionsinterface
PrismaCompositeKeyOptionsinterface
PrismaDatabaseOptionsinterface
RoleFieldsinterface
TransactionOptionsinterface
BigtableAdapterOptionstype
BigtableFiltertype
BigtableMutationtype
BigtableRowDatatype
BigtableValueEncodingtype
BuiltInDatabaseOptionstype
CosmosAdapterOptionstype
CosmosBatchOperationtype
CosmosPartitionKeyValuetype
CursorValuetype
DatabaseAdapterTypetype
DatabasePluginOptionstype
DataSourcetype
DrizzleTransactiontype
DrizzleTransactionBridgetype
DynamoAdapterOptionstype
DynamoAttributeMaptype
DynamoCommandConstructortype
DynamoDateEncodingtype
DynamoScanCommandInputtype
DynamoTransactWriteItemsCommandOutputtype
EntityKeytype
FilterComparisontype
FilterExpressiontype
FilterOperatortype
MongoAdapterOptionstype
MongoWriteOptionstype
OrderDirectiontype
PageOptionstype
PrismaSqlProvidertype
SqlJsonDialecttype
TransactionIsolationLeveltype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

Readiness and dependency outages (M113b)

The database driver’s existing unavailable-errno classification uses the shared isConnectionFaultCode(code) predicate from @setu-ts/common. The eight-code set is module-private; status classification is unchanged.

API reference

DatabasePlugin() (@setu-ts/database-plugin)

Portable data refusals and tenant resolution (M119e, since 0.11.0)

UnknownPayloadFieldError is defined in @setu-ts/common and re-exported unchanged here. MissingKeyColumnError(entity: string, column: string) names a missing Drizzle primary-key column and the entities.<Entity>.primaryKey remedy. Explicit configured keys are checked at connect for registered tables; query-only tables without a key override are checked when a repository is requested. TenantColumnUnresolvedError(entity: string, column: string) names a tenant column that resolves to no property or multiple properties, with a ColumnStrategy remedy. Both configuration errors carry no HTTP hint (masked 500). The tenant bridge resolves its own configured column to the Drizzle property once per entity; caller payload keys are never translated.

The database driver’s existing unavailable-errno classification uses the shared isConnectionFaultCode(code) predicate from @setu-ts/common. The eight-code set is module-private; status classification is unchanged.

Provides database access with repository pattern and unit of work.

Migration ledger and schema readiness (M113o)

createMigrationLedger(database: IDatabaseService, options?: MigrationLedgerOptions): IMigrationLedger uses the repository surface. MigrationLedgerOptions.entity defaults to the exported MIGRATION_LEDGER_ENTITY = 'setu_migrations'. IMigrationLedger.applied(): Promise<readonly string[]> reads only id with a 10,001-row limit; more than 10,000 rows throws the exported MigrationLedgerError (reason: 'oversized'). record(name: string, appliedAt: string): Promise<void> inserts { id: name, appliedAt } and propagates duplicate-create errors. remove(name: string): Promise<void> resolves even when absent. Entity options must be non-empty strings.

Every DatabaseConnectionOptions arm accepts readiness?: DatabaseReadinessOptions: { readonly migrations: readonly string[]; readonly ledgerEntity?: string }. Expected names must be unique non-empty strings, at most 10,000; ledgerEntity defaults to MIGRATION_LEDGER_ENTITY, and createMigrationLedger(database) without an explicit entity uses the ledgerEntity that connection was registered with, so the generated runner writes where readiness reads. Invalid readiness options throw TypeError at plugin construction, naming the option without echoing values. Absent readiness preserves the indicator’s status and data. Otherwise, after a non-down result, a separate 5-second cached, 2-second bounded ledger read adds data.migrations: unreadable/rejected/timed-out/oversized → down with { state: 'unreadable' }; missing expected names → degraded with { state: 'pending', pending: count }; current → unchanged status with { state: 'current', applied: count }, adding unknown: count for names this build does not list. Unknown names preserve rolling-deploy readiness. Already-down results bypass the ledger. Only counts, never names, appear in data. See the database-plugin README for application-owned SQL DDL, entity mappings, measured duplicate errors and the process-local memory caveat.

Registration

import { DatabasePlugin } from '@setu-ts/database-plugin';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.ts';

// Prisma 7 removed the Rust query engine, so a driver adapter is REQUIRED —
// `new PrismaClient()` with no argument does not compile.
const prismaClient = new PrismaClient({
  adapter: new PrismaPg({ connectionString: config.getOrThrow('DATABASE_URL') }),
});

app.register(
  DatabasePlugin({
    type: 'prisma',
    options: {
      prismaClient,
      logQueries: config.get('NODE_ENV') === 'development',
    },
  }),
);

DatabasePluginOptions is a union discriminated on type, and each arm names the options its adapter cannot run without: 'prisma' requires options.prismaClient, 'drizzle' requires both options.drizzleInstance and options.drizzleTables, 'mongodb' requires options.url (or an injected options.client), 'dynamodb' requires options.region (or an injected options.client), and 'custom' requires adapter. Omitting one is a compile error rather than a startup throw. The exported arms are MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptions and CustomDatabaseOptions; BuiltInDatabaseOptions is the union of the five built-in arms and keeps its published name, so an existing annotation carrying a memory configuration still compiles. PrismaAdapterOptions and DrizzleAdapterOptions narrow DatabaseAdapterOptions for their arm; MongoAdapterOptions and DynamoAdapterOptions are their arms’ dedicated driver option bags.

Three Prisma 7 prerequisites are the application’s to supply and are documented in the package README: the driver-adapter package, a prisma.config.ts (Prisma 7 rejects url in schema.prisma), and new PrismaPg({ connectionString }, { schema }) for a non-public PostgreSQL schema.

DatabaseAdapterOptions.transactionTimeout (ms, default 30000) raises Prisma’s ~5 s interactive-transaction default, which is too short for a full Unit of Work. It is read by the Prisma adapter only; Memory and Drizzle ignore it.

Prisma v7 clients are generated into an application-selected output path, so the Prisma adapter requires an application-created options.prismaClient; it never imports or constructs that client. DatabaseAdapterOptions.url remains source-compatible but is deprecated for Prisma configuration. DatabaseAdapterOptions.provider (type PrismaSqlProvider) names the SQL connector the injected client is bound to. Only the contains filter is connector-sensitive: it is escaped on PostgreSQL/MySQL/SQL Server/CockroachDB, passed through unchanged on MongoDB (whose contains compiles to a $regex match, where % and _ are already literal so escaping them would be wrong), and refused on SQLite (see the contains note above). When omitted, the adapter reads the client’s active provider structurally at connect() time; if it cannot be determined, a contains filter throws UnsupportedFilterOperatorError naming this option, so pass it explicitly in that case. Transaction isolation is connector-specific too: when the provider cannot be determined, a requested isolation level is refused by name, so pass provider explicitly. Drizzle requires both options.drizzleInstance and options.drizzleTables. The instance is a opaque DrizzleDatabase<T> configuration created by createDrizzleDatabase(database, transactionBridge); the registry’s tables must carry an id column and the adapter translates every repository field to a real Drizzle column. Drizzle create, update, and delete require a driver with RETURNING support so their results are actual driver rows; an unsupported dialect throws a descriptive error. Promise-aware SQLite Proxy and libsql-shaped Drizzle instances without execute() are accepted for repository, transaction, and typed-builder use. Calling IDatabaseService.query() on such an instance rejects with guidance to use Drizzle’s typed query builder. That refusal is permanent rather than pending: those drivers do expose all(), but on a raw statement the proxy protocol answers with positional rows, because Drizzle has no field map for a statement it did not build — and query<T>() promises row objects.

Synchronous callback drivers (better-sqlite3, Bun SQLite, Expo SQLite, and OP SQLite) are unsupported: their native transaction closes when the callback returns, before awaited UoW work can run. createDrizzleDatabase() rejects those published types at compile time, and startup rejects an unwrapped structural instance. The explicit source-owned bridge is the positive capability that asserts the selected driver awaits its callback Promise; unknown adopting-Promise and thenable wrappers are rejected at startup before native transaction work begins.

PrismaSqlProvider still carries a 'mongodb' member and the PASSTHROUGH_PROVIDERS mechanism that holds it, but that arm is unreachable on Prisma v7: a Prisma client for MongoDB cannot be constructed (generation succeeds, construction fails — Prisma states MongoDB “did not make the Prisma 7 release” and returns in Prisma 8). The 'mongodb' adapter arm above is the supported MongoDB route; the 'mongodb' provider string on the Prisma union is retained only because removing it is a published export (AI_GUIDELINES §9.2), and the contains-to-$regex reasoning behind PASSTHROUGH_PROVIDERS stays correct for any future Prisma-Mongo client.

Transactional idempotency store (M109b)

createDatabaseIdempotencyStore(options?: DatabaseIdempotencyStoreOptions) returns a RegistryFactory<ITransactionalIdempotencyStore> for IdempotencyPlugin’s transactional.store. entity defaults to 'Idempotency'; database selects database.<name>, or the default CAPABILITIES.DATABASE when omitted or 'default'. The factory resolves the database and runtime services at initialization. The record and business work must use the same database.

run creates a claim (id), executes business work through that transaction’s IUnitOfWork, creates the encoded result (${id}.r), and commits. It uses two creates, with no update or delete. find requires both rows with the idempotency kind and their correct roles. purge preserves incomplete, foreign-kind and invalid-envelope records, and deletes a complete record only when an in-transaction re-read still finds it expired. That re-read narrows but does not close one race: two concurrent purges can delete a record a client re-created between them (closed on MongoDB; about one round trip on PostgreSQL READ COMMITTED; the deferred commit on DynamoDB and D1). The portable repository does offer a conditional delete (deleteWhere(id, where), M105), but the purge does not use it: a record is two rows (id and ${id}.r) that must go together, and deferred-write transaction sources omit conditional members. verify runs the two-create pattern and rolls it back before the first call.

TransactionalStoreUnavailableError carries entity and reason: 'cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone' or 'entity-unavailable'. An adapter refusal can be retained as its cause. A missing/unreadable entity fails startup.

BackendTier-C behavior
MemorySupported in one process; a loser is refused at commit and replays.
PostgreSQL / DrizzleSupported; the claim insert blocks the loser until the winner commits, then the loser replays. Each blocked call holds a connection.
PostgreSQL / PrismaAs Drizzle: a concurrent loser blocks on the winner’s claim insert before running fn, then replays. Prisma’s transactionTimeout does not interrupt that wait (measured with a 1 s loser timeout against a winner holding the key 8 s: the loser waited and replayed). A loser whose own work exceeds its timeout answers 'store-failed' (503); there is no timeout-to-409 mapping.
SQLite / DrizzleSupported with a Promise-aware transaction bridge; one writer at a time, with replay or busy/locked conflict depending on timing.
D1Supported via the custom adapter arm; deferred batch commits one business write, but concurrent calls execute fn twice and its outside effects run twice.
MongoDB replica setSupported with pre-existing collections; the concurrent loser gets conflict, then replays on retry.
DynamoDBSupported with deferred TransactWriteItems; a concurrent loser replays or gets conflict. Both calls execute fn; database work commits once, outside effects run twice. TransactionConflictException is documented-unverified on DynamoDB Local (M90f). Eventually consistent reads can also miss a just-committed record; commit uniqueness still prevents a second business write.
MongoDB standaloneRefused with mongodb-standalone.
Cosmos DBRefused with cosmos-unsupported: the record cannot share the business partition.
BigtableRefused with bigtable-unsupported.

Not verified: DynamoDB’s overlapping-service TransactionConflictException outcome on DynamoDB Local (M90f). Tier C was driven against a real Prisma 7.10 client on PostgreSQL once, by hand; the committed real-backend suite uses Drizzle.

Repository Pattern

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

app.router.get('/users', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const userRepo = db.getRepository<User>('User');

  const users = await userRepo.findAll({
    where: { active: true },
    orderBy: { createdAt: 'desc' },
    limit: 20,
    offset: 0,
  });

  return ctx.response.json(users);
});

app.router.get('/users/:id', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const user = await db.getRepository<User>('User').findById(ctx.params.id);

  if (!user) {
    return ctx.response.status(404).json({ error: 'User not found' });
  }

  return ctx.response.json(user);
});

app.router.post('/users', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');
  const body = await ctx.request.json<{ name: string; email: string }>();

  const user = await db.getRepository<User>('User').create({
    name: body.name,
    email: body.email,
  });

  return ctx.response.status(201).json(user);
});

Unit of Work (Transactions)

app.router.post('/orders', async (ctx) => {
  const db = ctx.services.get<IDatabaseService>('database');

  const order = await db.transaction(async (uow) => {
    const orderRepo = uow.getRepository<Order>('Order');
    const inventoryRepo = uow.getRepository<Inventory>('Inventory');
    const paymentRepo = uow.getRepository<Payment>('Payment');

    // All operations in same transaction
    const newOrder = await orderRepo.create(await ctx.request.json());

    const stock = await inventoryRepo.findById(newOrder.productId);
    await inventoryRepo.update(newOrder.productId, {
      quantity: stock!.quantity - newOrder.quantity,
    });

    await paymentRepo.create({ orderId: newOrder.id, amount: newOrder.total });

    return newOrder;
  });

  return ctx.response.status(201).json(order);
});

Isolation

transaction(work, options?) accepts TransactionOptions, whose optional isolation is one of 'read-uncommitted', 'read-committed', 'repeatable-read', or 'serializable'. Omission keeps the adapter default. A requested level is honoured or refused by name; it is never silently downgraded. Database-plugin adapters throw the caller-safe 501 UnsupportedIsolationLevelError. D1 throws its own CloudflareUnsupportedError, since the Cloudflare plugin cannot depend on another plugin’s error class.

AdapterHonoured levels
PrismaPostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none
Drizzleall four only when the application wraps its bridge with withIsolationSupport()
Memoryserializable (one process only)
MongoDBnone
D1, DynamoDB, Cosmos DB, Bigtablenone

withIsolationSupport() is an application declaration that its bridge forwards options. The type system cannot verify its body uses them; an unbranded bridge refuses every requested level rather than silently dropping it.

For a custom adapter, declare transactionIsolationLevels on IDatabaseAdapter; without that explicit capability the database service refuses every requested isolation level rather than assuming a zero-argument beginTransaction() honours it.

Typed Drizzle queries

interface DrizzleDatabase<TDatabase extends object> { /* package-owned typed witness */ }
interface DrizzleDatabaseIdentity { /* erased opaque configuration identity */ }
type DrizzleTransaction<TDatabase extends object> = /* Drizzle transaction callback parameter */;
type DrizzleTransactionBridge<TDatabase extends object> = <T>(
  database: TDatabase,
  work: (transaction: DrizzleTransaction<TDatabase>) => Promise<T>,
) => Promise<T>;

function createDrizzleDatabase<const TDatabase extends object>(
  database: TDatabase,
  transaction: DrizzleTransactionBridge<TDatabase>,
): DrizzleDatabase<TDatabase>;
function getDrizzleDatabase<TDatabase extends object>(
  service: IDatabaseService,
  database: DrizzleDatabase<TDatabase>,
): TDatabase;
function getDrizzleTransaction<TDatabase extends object>(
  unitOfWork: IUnitOfWork,
  database: DrizzleDatabase<TDatabase>,
): DrizzleTransaction<TDatabase>;

Pass the same package-created opaque configuration supplied in options.drizzleInstance. getDrizzleDatabase() infers and returns that complete configured type. getDrizzleTransaction() structurally derives Drizzle’s native transaction callback parameter from it, so schema and selected-row inference survive while outer-only operations (for example SQLite Proxy’s batch()) are absent. Native joins and repository writes still participate in the same commit or rollback:

import { eq } from 'drizzle-orm';
import {
  createDrizzleDatabase,
  getDrizzleDatabase,
  getDrizzleTransaction,
} from '@setu-ts/database-plugin';

const drizzleDatabase = createDrizzleDatabase(drizzleDb, (database, work) =>
  database.transaction(work),
);
const outer = getDrizzleDatabase(db, drizzleDatabase);
const rows = await outer.select().from(users);

await db.transaction(async (uow) => {
  await uow.getRepository<User>('User').create(newUser);

  const tx = getDrizzleTransaction(uow, drizzleDatabase);
  const joined = await tx
    .select({ userId: users.id, teamName: teams.name })
    .from(users)
    .innerJoin(teams, eq(users.teamId, teams.id));
});

The opaque configuration carries compile-time correlation while its database, bridge, and validity remain in package-private storage. It is frozen with a null prototype as defense in depth; mutation, spread, assignment, cloning, prototype inheritance, and cross-instance reuse do not create accepted identities. A caller cannot select a forged generic at the UoW call, and a configuration from another database throws. Use getDrizzleDatabase() for outer-only operations; getDrizzleTransaction() intentionally returns only the transaction-safe callback surface. The distinct functions also keep a service widened structurally to IUnitOfWork runtime-truthful. Memory, Prisma, and custom services/UoWs throw Drizzle query access requires adapter 'drizzle'; configured adapter is '<type>'. A structural service or Unit of Work not created by this package throws Drizzle query access requires a database-plugin service or unit of work.

Health status

Since M90b the database indicator gates on the service’s lifecycle FIRST, uncached — a closed database reads down immediately, never from an outcome cached before close. The gate is re-read after the probe settles as well, so a poll already in flight when close() begins reports down rather than publishing the answer the probe had cached a moment earlier. That gate reads DatabaseService.isClosed, a lifecycle-only member that reaches no adapter, so every IDatabaseAdapter.isReady() call happens behind it, inside a cached, bounded probe — 5-second TTL, 2-second bound, built on createCachedProbe — and an adapter whose readiness performs I/O cannot hang /health past the bound. Repeated polls cost at most one readiness read per TTL; gating on isHealthy() instead read the adapter on the one path deliberately outside that bound, and cost two reads per poll. The consequence is the ordinary TTL trade-off, and it is the same one every other M90b indicator makes: closing the database reads down on the very next poll, while an adapter that loses readiness on its own — a pool that ends itself — is served from the cache for up to the TTL before the indicator reports it. A Drizzle registration that supplies DrizzleAdapterOptions.poolStats — an application-owned callback reading the driver’s own documented pool API — also publishes the returned DatabasePoolCapacity snapshot ({ total, idle, waiting }) under data.capacity. Omitted, the payload carries no capacity fields. Capacity is data, not policy: no threshold is applied, and the snapshot changes one row only. Since M101a, when it shows every connection busy with callers waiting (idle === 0 and waiting > 0), the probe queues no SELECT 1 and the indicator reports up with reachable: 'unknown' while queries through the adapter are still completing (one completed within the last 10 seconds; with none, a full pool is a hung database and reports degraded; once getDrizzleDatabase/getDrizzleTransaction has handed out the native instance, whose queries bypass the adapter, progress is unobservable and a saturated pool reads up) — saturation, not an outage, so /ready does not pull every saturated replica at once. Without poolStats that row cannot be read, so a pool connection timeout or an unanswered probe reports degraded (a pool connection timeout reported down before M101a: the database never answered either way). Caller-facing pool-timeout status mapping is M90f. A snapshot the callback returns in a malformed shape is dropped exactly like an absent one — a broken reading is never published as a number. A callback that throws, or one whose counters violate the documented shape (a negative count, or idle exceeding total, which counts idle + in use), is dropped the same way: capacity is omitted for that poll and the indicator’s own lifecycle and reachability answer stands.

Since M95b the payload also carries reachable whenever the adapter carries a liveness probe (the optional IDatabaseAdapter.isHealthy?()): true — the backend answered; false — down; a probe that exists and did not answer inside the 2-second bound reports 'unknown' and the status degraded, never up — the claim that put a dead-database pod back in rotation (X51-1: the indicator used to read the lifecycle isReady(), which reports that connect() once succeeded and says nothing about the backend now). An adapter that ships NO probe reports up with reachable OMITTED and /ready answering 200 — the deliberate no-change row: a probe that was never written is evidence of nothing, and mapping a missing probe to degraded would fail /ready for every healthy Cosmos, Bigtable and DynamoDB application on upgrade (degraded already answers 503). Shipped probes: MongoDB (db.command({ ping: 1 })), Prisma and Drizzle (SELECT 1; Drizzle omits the probe for instances without execute()), memory (its own connection state — the process is the backend). Cosmos, Bigtable and DynamoDB omit the probe pending one optional member on their own client facades; their payload is unchanged. The concrete DatabaseService exposes the seam the indicator reads: hasReachabilityProbe (a synchronous presence read) and reachability() (Promise<boolean | undefined> — the bounded probe, undefined when no answer was produced), the database twin of IMessageBroker.reachability(). The lifecycle read still gates the probe, so an adapter whose own probe does not re-derive isReady() cannot report up for a connection it has already dropped.

Database Interface

interface IDatabaseService {
  getRepository<Entity>(entity: string): IRepository<Entity>;
  transaction<T>(work: (uow: IUnitOfWork) => Promise<T>): Promise<T>;
  query<T>(sql: string, params?: unknown[]): Promise<T[]>;
  migrate(): Promise<void>;
  isHealthy(): Promise<boolean>;
  close(): Promise<void>;
}

interface IRepository<Entity, Id extends EntityKey = string> {
  findById(id: Id): Promise<Entity | null>;
  findAll(options?: FindOptions): Promise<Entity[]>;
  findOne(options?: FindOptions): Promise<Entity | null>;
  findPage(options: PageOptions): Promise<Page<Entity>>;
  create(data: Partial<Entity>): Promise<Entity>;
  update(id: Id, data: Partial<Entity>): Promise<Entity>;
  delete(id: Id): Promise<boolean>;
  updateWhere?(id: Id, where: WritePrecondition, data: Partial<Entity>): Promise<Entity | null>;
  deleteWhere?(id: Id, where: WritePrecondition): Promise<boolean>;
  exists(id: Id): Promise<boolean>;
  count(options?: CountOptions): Promise<number>;
}

Every member except updateWhere? and deleteWhere? is required — including findPage, which is easy to miss because the sibling IDataSource.findPage? is optional. A class implementing IRepository directly, without extending BaseRepository, must supply findPage; the in-repo reference implementor is packages/database-plugin/test/fixtures/repository-implementor.ts, and the reader-side step is in docs/upgrading.md.

The concrete DatabaseService additionally exposes readonly isClosed: boolean (since 0.5.0) — a lifecycle-only read that reaches no adapter, added for the health indicator’s uncached gate. isHealthy() answers lifecycle AND adapter readiness together; isClosed answers lifecycle alone.

query() is the existing backend-specific raw-SQL escape hatch. It requires a configured Drizzle instance with execute(); typed builders obtained through the Drizzle accessors do not.

Every adapter binds params and never interpolates them, and the statement carries the connector’s own placeholders — $1… on PostgreSQL, ? on MySQL and SQLite. Prisma and D1 forward the text verbatim; the Drizzle adapter splits it at its placeholders and rebuilds it through Drizzle’s own SQL chunks, which emits an ascending-placeholder statement byte-identically. A placeholder count that disagrees with params, a gap in the $N sequence, or both placeholder styles in one statement is refused before the driver is reached, because a mis-bound parameter is silent. On PostgreSQL, ?, ?| and ?& are also jsonb key-containment operators that no scanner can tell from a placeholder; such a statement is refused or fails at the database, never mis-bound — write it with $N placeholders. Programmatic migrate() is unsupported by all current adapters and rejects with UnsupportedMigrationError because each ORM owns migrations through its CLI; it is a permanent framework capability boundary and therefore answers 501 Not Implemented through errorHandler.

FindOptions.filter and CountOptions.filter accept a portable expression tree in addition to the existing equality-only where map. where and filter are conjoined, and every built-in adapter evaluates the same operators: eq, contains, gt, gte, lt, lte, and in, nested with and / or. findOne() applies the same options as findAll() and returns the first match or null.

const users = await userRepo.findAll({
  where: { active: true },
  filter: {
    type: 'or',
    filters: [
      { type: 'comparison', field: 'name', operator: 'contains', value: 'Ada' },
      { type: 'comparison', field: 'age', operator: 'gte', value: 18 },
    ],
  },
});

An in with an empty list matches nothing, and a list containing null matches rows whose column is null — SQL IN never matches NULL by itself, so the SQL adapters emit an explicit null branch. contains is a substring match whose % and _ are always data rather than wildcards; its case sensitivity is the database’s, not the framework’s — Memory, D1 and Mongo match case-sensitively, while a LIKE-based backend follows the column’s collation (case-sensitive on PostgreSQL, case-insensitive on SQLite and most MySQL collations). Mongo compiles to $regex, to which MongoDB does not apply collation, so a case-insensitive collection collation does not make it case-insensitive. Collation control is not part of this contract.

On the Prisma adapter the connector decides how that guarantee is met. On PostgreSQL, MySQL, SQL Server, and CockroachDB the value is escaped and matched literally, because their LIKE defaults its escape character to backslash. On SQLite the guarantee is not expressible through Prisma’s filter API (Prisma emits no ESCAPE clause and SQLite has no default escape character), so a contains filter throws UnsupportedFilterOperatorError rather than return wrong rows; pass a raw query, or use the Memory/Drizzle adapter (both honour contains as a literal substring). When the adapter cannot determine the connector it throws the same error naming the provider option; pass provider (e.g. provider: 'postgresql') in the adapter options to name it explicitly.

The Memory adapter’s guarantees

MemoryAdapter is the default and is never given a schema, which fixes what it can enforce. An unknown select or orderBy field — one that no stored row carries — is refused by name, matching what Drizzle answers for the same call; a field present on at least one row counts as known, and an entity holding no rows accepts anything, since there is nothing to observe and nothing to return. An unknown where or filter field is not refused: without a schema the adapter cannot tell an unknown column from one absent on every row, and matching nothing is a defensible answer.

A create whose caller-supplied primary key is already stored is refused — the key is the one constraint this adapter knows, and a duplicate would leave a row no lookup by id can address; for the same reason an update cannot change a primary-key value (MongoDB’s immutable _id rule). Other uniqueness, column types, foreign keys, checks and defaults are not enforced and cannot be by a schema-less store. Use this adapter for development and tests, and run integration tests against the backend you deploy on.

Custom Adapters (external backends)

DatabasePluginOptions is a union discriminated on type. The 'custom' arm accepts any IDatabaseAdapter from @setu-ts/common, which is how a backend implemented outside this package is registered — no plugin imports another plugin.

import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';

app.register(
  DatabasePlugin({
    type: 'custom',
    adapter: new D1Adapter(env.DB as ID1Database),
  }),
);

adapter is required by the union, so a 'custom' registration that omits it is a compile error rather than a startup throw. The plugin calls connect() on the adapter during register() and disconnect() on shutdown; it never constructs or replaces it. name and options apply as usual — logQueries routes a custom adapter’s data sources through the same single logging wrapper every built-in adapter uses.

The port to implement:

interface IDatabaseAdapter extends IOrmAdapter {
  transactionIsolationLevels?: readonly TransactionIsolationLevel[];
  isHealthy?(): Promise<boolean>;
  createDataSource(entity: string): IDataSource;
  beginTransaction(options?: TransactionOptions): Promise<IAdapterTransaction>;
  rawQuery<T>(sql: string, params?: unknown[]): Promise<T[]>;
}

interface IDataSource {
  findAll(query: NormalizedQuery): Promise<Record<string, unknown>[]>;
  findById(id: EntityKey): Promise<Record<string, unknown> | null>;
  create(data: Partial<Record<string, unknown>>): Promise<Record<string, unknown>>;
  update(id: EntityKey, data: Partial<Record<string, unknown>>): Promise<Record<string, unknown>>;
  delete(id: EntityKey): Promise<boolean>;
  updateWhere?(
    id: EntityKey,
    where: WritePrecondition,
    data: Partial<Record<string, unknown>>,
  ): Promise<Record<string, unknown> | null>;
  deleteWhere?(id: EntityKey, where: WritePrecondition): Promise<boolean>;
  findPage?(query: NormalizedQuery): Promise<PageResult>;
  count(where: Record<string, unknown>, filter?: FilterExpression): Promise<number>;
}

A data source owns query evaluation end to end — it applies where, orderBy, offset/limit and select itself, and BaseRepository must not re-apply any of them.

Conditional writes (M105, since 0.9.0). Both optional members address the supplied key AND require every predicate field to equal its value at the write. updateWhere returns the updated row or null; deleteWhere returns whether a row was deleted. An absent key and a predicate miss have the same outcome and write nothing. The key and predicate remain separate conjunctions even when the predicate names a key field.

WritePrecondition, exported by @setu-ts/common, is Readonly<Record<string, string | number>>. checkWritePrecondition(where, data?) returns a WritePreconditionCheck: { ok: false, problem }, or { ok: true, where, data } carrying private copies of the inputs. It requires a non-empty plain equality object, non-empty field names without a $ prefix or ., string or finite-number values, and, when supplied, a non-empty plain update payload. Each field is read once into the copy and the copy is what gets validated and written, so an input that changes between reads (a Proxy) cannot send something other than what passed. An own __proto__ key is refused, in the predicate and in the payload, because Node and Bun drop it from any object a backend rebuilds by assignment. Every implementation validates direct calls as well as repository calls. checkWritePrecondition’s refusal reasons quote no caller field name or value, and a Prisma validation refusal — whose message renders the whole query — becomes UnknownPayloadFieldError or InvalidFieldValueError with a fixed-message cause, on a direct data-source call as through a repository (M119e; it was UnsupportedQueryFeatureError before 0.11.0). No other guarantee is made about message text: key handling shared with findById/update/delete, adapter mapping and marshalling refusals, and driver or server errors (a type the column rejects, an unknown column) may quote caller keys, field names or values, as for every other repository operation. Responses mask them (X12-3), so they reach server logs only. Invalid inputs reject with UnsupportedQueryFeatureError feature 'write-precondition' (unbranded) — except a direct call to the D1 data source, which rejects with CloudflareUnsupportedError, since cloudflare-plugin cannot import database-plugin’s error. Through a repository the check runs in BaseRepository first, so every backend answers 'write-precondition' there. BaseRepository rejects missing support with UnsupportedQueryFeatureError feature 'conditional-write' (HTTP hint 501), before any I/O. Both members stay optional for implementors.

Data sourceWrite mechanism
Memory, outside transactionsSynchronous key lookup, predicate check and mutation
PrismaNative update/delete, key conjoined with AND: [where]; P2025 means no match
DrizzleKey and predicate joined with and(...), native RETURNING
MongoDB$and: [idFilter, whereFilter], findOneAndUpdate / deleteOne
D1Bound UPDATE / DELETE, WHERE key AND predicate, RETURNING
DynamoDBNative ConditionExpression, key existence AND aliased equality fields
Cosmos DBRead and check, then _etag IfMatch guarded replace / delete; retry 412 at most three rounds
BigtableCheckAndMutateRow with nested per-field conditions, newest cell capped before value comparison

Deferred transaction sources (memory overlay, D1 batch, DynamoDB buffer, Cosmos batch and Bigtable) omit both members: the result is unknown until commit, so the repository refuses 'conditional-write'. Prisma, Drizzle and MongoDB transaction sources carry them. The tenant and inbox bridges use the conditional path when offered and fall back only on absence or that named refusal. The outbox requires native conditional writes and refuses unsupported sources at startup. Other errors propagate. On that fallback the tenant bridge reads ownership and then writes by key, so a delete-and-recreate under a reused caller-supplied key in between can move the write onto another tenant’s row; generated keys avoid the reuse. Bigtable reads an updated row back separately; the tenant bridge checks the returned tenant on both paths before returning it.

__proto__ fields. The conditional members refuse an own __proto__ field in the predicate and the payload on every adapter. Cosmos DB and DynamoDB also refuse one in a plain create/update payload (DynamoDB at any depth, because the AWS SDK returns such an attribute without a value), and neither lets a stored __proto__ field set a read row’s prototype: Cosmos returns it as an ordinary field, DynamoDB omits the attribute the SDK cannot deliver.

Stored representation. Predicate scalars compare with the stored representation. DynamoDB date attributes encoded as ISO strings require the ISO string; Bigtable’s typed/json codecs use their configured scalar encoding. Neither member translates a Date or allows a document path or operator in the predicate.

Breaking for implementors as of M79. findById/update/delete moved from string | number to EntityKey, which adds a composite-record arm. A parameter is contravariant, so an adapter still declaring the scalar-only form is a compile error; callers passing a scalar are unaffected. The same release widened FilterComparison.field to string | readonly string[] (an array is a nested document path) and the gt/gte/lt/lte value to accept Date, so any code READING those fields must handle both forms.

findPage is optional. Every adapter this framework ships implements it; an adapter that cannot page by cursor omits it, and the repository refuses by name rather than returning an empty page — so absence means “this backend cannot page by cursor”, never “there are no more rows”.

isHealthy? is optional (M95b). It answers “is the backend reachable right now” — the liveness read the inherited lifecycle member isReady() was mistaken for. true means the backend answered; false means it was contacted and refused or failed; an adapter that cannot probe honestly omits the member, and the health indicator then reports up with reachable omitted rather than inventing an answer. The lifecycle read still gates the probe, so an adapter whose own probe does not re-derive isReady() cannot report up for a connection it has already dropped. Shipped probes: MongoDB (db.command({ ping: 1 }) through the optional IMongoDatabase.command? facade member), Prisma and Drizzle (rawQuery('SELECT 1')), memory (its own connection state).

Composite keys, nested paths and cursor pagination (M79)

@setu-ts/common exports the portable data-access contract these three features rest on, and is where a backend author imports it from — the codec lives there because cloudflare-plugin needs the identical encoding and a plugin may not import another plugin (AI_GUIDELINES §2.2).

@setu-ts/database-plugin re-exports the subset its own consumers reach for — EntityKey, PageResult, CursorPayload, CursorValue, encodeCursor, decodeCursor and keysetPredicate. sortFingerprint, mintNextCursor and resolveKeysetSort are not re-exported; import them from @setu-ts/common.

SymbolKindPurpose
EntityKeytypeA primary key: a scalar string/number, or a composite Readonly<Record<string, string | number>>. A record rather than an array, because a composite key is named columns and an array would make the caller depend on a column order the mapping owns.
PageResultinterface{ rows, nextCursor } returned by IDataSource.findPage. nextCursor is null exactly when the page is the last.
CursorPayloadinterfaceThe decoded contents of a cursor: orderedValues, keyValues, and a sortFingerprint.
CursorValuetypeA scalar a cursor can carry: string | number | Date. Dates survive the round trip as Date, not as ISO strings.
encodeCursorfunctionEncodes a CursorPayload as an opaque base64url token.
decodeCursorfunctionDecodes a token, or returns null when it is malformed — it never throws, because the token is untrusted caller input.
keysetPredicatefunctionBuilds the “row after this one” comparison as a portable FilterExpression.
sortFingerprintfunctionThe stable fingerprint embedded in every cursor; optional scope binds entity and key columns (since 0.11.0).
mintNextCursorfunctionMints the next-page cursor from the last row of a non-terminal page. Refuses a value that cannot survive the wire — null/undefined, a non-finite number, an invalid Date, or a non-scalar — naming the field, because each of those otherwise fails on the NEXT request or one frame from its cause.
resolveKeysetSortfunctionThe sort a keyset comparison is expanded over: the caller’s orderBy plus any key columns it lacks, appended ascending. A backend must order by this, not by orderBy — ordering by the caller’s fields alone leaves tied rows in an order the database picks freely, and the predicate then skips or repeats them. keysetPredicate derives its own comparison from the same function, so the two cannot disagree.

Composite keys

Scoped fingerprints (since 0.11.0). sortFingerprint(orderBy: Readonly<Record<string, OrderDirection>>, scope?: { readonly entity: string; readonly keyColumns: readonly string[] }): string returns the byte-identical legacy field:direction pairs without scope. With scope it returns JSON.stringify([scope.entity, scope.keyColumns, pairs]). Memory, Prisma, Drizzle, MongoDB, DynamoDB, Cosmos DB, Bigtable and D1 pass the resolved entity and key columns on mint and decode. Scope is a correctness check, not authorization; tenant predicates remain required.

Portable refusal classes (@setu-ts/common, since 0.11.0). UnknownPayloadFieldError(entity: string, field: string, options?: ErrorOptions) has name UnknownPayloadFieldError, a capped, single-line field diagnostic and the fixed hint { status: 400, title: 'Bad Request', detail: 'The request names a field this resource does not have.' }. InvalidFieldValueError(entity: string, options?: ErrorOptions) has name InvalidFieldValueError and hint { status: 400, title: 'Bad Request', detail: 'A supplied value does not fit the type of its field.' }. Its message is Entity '<entity>': the database refused a supplied value for its field type. No supplied value, key or field is included. Repository mappings retain a scrubbed driver error as cause. Drizzle PostgreSQL repositories map the four supplied-value SQLSTATE codes; D1 maps STRICT-table type refusals. Prisma validation errors containing Unknown argument `<field>` map to UnknownPayloadFieldError; other validation errors and known-request P2007 errors map to InvalidFieldValueError. This includes conditional writes, which now answer 400 rather than 501 for these refusals. A Prisma refusal cause is a new Error whose copied name and fixed message name only the original name and code, for example Prisma refused the input (PrismaClientKnownRequestError P2007).; the original object, query and payload are discarded.

findById, update and delete accept a composite record. Each adapter learns its key columns from a per-entity mapping whose primary-key field accepts a column list:

// Cloudflare D1
new D1Adapter(env.DB as ID1Database, {
  tables: { Membership: { table: 'memberships', primaryKey: ['tenant_id', 'user_id'] } },
});

// MongoDB — flat top-level key columns (the default for a composite key)
DatabasePlugin({
  type: 'mongodb',
  options: { url },
  mongoEntities: { Membership: { primaryKey: ['tenantId', 'userId'] } },
});

A repository declares its key type and addresses rows with a record:

const repo = db.getRepository<Membership, { tenantId: string; userId: string }>('Membership');
await repo.findById({ tenantId: 't1', userId: 'u1' });

A scalar key against a multi-column entity, or a record missing a column, is refused by name.

MongoDB idType: 'compound' stores the composite key as a subdocument _id. The subdocument is built in the mapping’s declared column order, never the caller’s key-object order: a Mongo subdocument _id is matched by exact, order-sensitive equality, so {userId, tenantId} does not match a document stored as {tenantId, userId}. The default for a composite key is flat top-level columns, which are order-independent and constrained by a unique index.

Prisma addresses a compound key through Prisma’s own compound-key field. The name is derived by joining the key columns with _, matching what Prisma generates for an unnamed @@id. A model declaring a named @@id must set compositeKeyName — Prisma rejects the derived name on such a model, so this is required rather than optional:

DatabasePlugin({
  type: 'prisma',
  options: { prismaClient },
  entities: {
    Membership: { keyColumns: ['tenantId', 'userId'] },
    Enrollment: { keyColumns: ['courseId', 'personId'], compositeKeyName: 'enrollmentKey' },
  },
});

Nested field paths

FilterComparison.field accepts a readonly string[] as a document path. A plain string is a flat field, unchanged. A dotted string is not a path — a column whose name legitimately contains a dot keeps its meaning.

await repo.findAll({
  filter: { type: 'comparison', field: ['address', 'city'], operator: 'eq', value: 'Kolkata' },
});

Every adapter that supports a path translates it natively; none filters in JavaScript. D1 is the explicit exception and refuses one by name. An empty path array is always refused — a filter that quietly matches everything is a data-exposure defect, not a no-op.

AdapterTranslation
Memorywalks the document
Mongothe native dotted key (address.city)
Prismathe JSON path filter — an array on PostgreSQL/CockroachDB, $.a.b on MySQL
Drizzleper dialect: PostgreSQL #>>, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract
D1refused by name (SQLite’s LIKE has no expressible escape character)

Two translation facts are worth knowing before relying on a path filter, both measured rather than inferred:

  • in is expanded, not translated. Neither Prisma’s JSON path filter nor any SQL dialect offers a path membership operator, so in becomes an OR of equality legs. An empty list compiles to a match-nothing predicate that binds no values.
  • Extraction normalises to text, and an ordered comparison against a number casts back. PostgreSQL’s #>> and MySQL’s JSON_UNQUOTE are text-valued while SQLite’s json_extract preserves the JSON type, so without normalising, one filter would mean two different things. Casting back for gt/gte/lt/lte on a number is what makes age > 9 match 30 — as text, '30' > '9' is false.

A Date is refused on a path filter on every SQL adapter: a JSON document has no date type, so the stored representation is whatever the writer chose. Compare an ISO string, or give the value its own column.

Prisma requires provider and Drizzle may require dialect for a path filter, because the syntax differs per connector and neither library publishes a discriminant the adapter can read reliably. Drizzle detects its dialect from the instance and needs the option only when detection fails; either way, an adapter that cannot determine the syntax refuses by name rather than emitting a guess — a guessed syntax returns wrong rows on the engine it guessed wrong about, where a refusal names the option that fixes it.

DatabasePlugin({
  type: 'drizzle',
  options: { drizzleInstance, drizzleTables, dialect: 'postgresql' },
});

Cursor pagination

let cursor: string | null = null;
do {
  const page = await repo.findPage({ orderBy: { createdAt: 'desc' }, limit: 50, cursor });
  handle(page.rows);
  cursor = page.nextCursor;
} while (cursor !== null);

offset is unchanged and not deprecated; the cursor is additive beside it. A query carrying both a non-zero offset and a cursor is refused, because the two express contradictory positions — “skip this many from the start” and “after this row”.

nextCursor carries one guarantee: it is non-null exactly when a further page exists — including a zero-row page that is not the last — and it is never derived from rows.length. Backends mint it by one of two mechanisms. The row-based adapters fetch limit + 1 rows and answer null precisely when the extra row is absent. The DynamoDB adapter instead carries the server’s own continuation key (LastEvaluatedKey) inside the token and answers null exactly when the server returned none: DynamoDB applies Limit before FilterExpression, so a filtered page can return fewer rows than limit — or none — while further matching rows remain, and a row-count probe would report the walk terminal and silently drop rows.

The cursor is opaque and carries a fingerprint of the sort it was minted under, so presenting it under a different orderBy is refused rather than served a silently wrong page. The resolved sort always ends in the entity’s key columns: over rows sharing a sort value, a walk without that tiebreaker silently loses rows — measured at four of six against live PostgreSQL and MongoDB.

ORDER BY on a Date column requires the Date arm of the ordered comparison, which is why that widening ships alongside. Cloudflare D1 refuses a Date by name: SQLite has no date type, so the adapter cannot know whether the column stores an ISO string or an epoch integer.

MongoDB backend ('mongodb' arm)

MongoAdapter is a document-store backend over the native mongodb driver (mongodb), registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'mongodb',
    options: {
      url: 'mongodb://127.0.0.1:27017/app',
      // or inject a constructed client so the driver is never imported:
      // client: new MongoClient(url),
    },
  }),
);

MongoAdapterOptions is the Mongo-specific option bag, and it is a union of two arms: one requires url (the connection string), the other requires client (an already-constructed IMongoClient). Supplying neither is a compile error rather than a connect() throw — the guarantee every other built-in arm gives. MongoAdapterOptionsBase is the half both arms share (objectIdCtor, database, collections). When client is present the lazy import('mongodb') never runs; the injected client is structural, so a driver of a different major that honours the same shapes is accepted. An injected client that uses ObjectId values supplies its objectIdCtor companion, so the adapter can convert 24-hex repository ids without importing the driver. database names the database; when absent the one encoded in url is used, and if neither yields a name connect() fails at startup naming the option. collections is a per-entity override ({ collection?, primaryKey?, idType? }); an unmapped entity uses its own name as the collection and 'id' as the primary key — the D1-shaped two-layer mapping, where the public surface is a per-entity override with a zero-config default and the internal target is unexported.

Identity maps _id to the configured primary key on read and back on write. Because a findOne({_id: "<24-hex>"}) misses when _id is an ObjectId, the driver id is converted with a 24-hex string test for an idType of 'auto' (the default), forced for 'objectId', and forbidden for 'raw'. The string half of that test is load-bearing rather than defensive: the driver’s own ObjectId.isValid answers true for any number while its constructor rejects one, so a collection keyed by application-assigned numbers is passed through verbatim — findById, update and delete accept string | number, and a numeric key must reach the driver unconverted. On read the inverse holds: an ObjectId is rendered as its 24-hex string, while a JSON scalar keeps its own type, so the value create() returns is the value findById() accepts. primaryKey may name _id itself, in which case the row keeps that field rather than having it renamed away. The primary key never travels in an update payload — MongoDB refuses a $set that would change _id, and update moves no row to a new key on any adapter.

find serves orderBy/offset/limit/select natively as sort/skip/limit/projection, and contains compiles to a $regex match with an escaped value (the inverse of the SQL contains), so % and _ in the searched value stay literal. An empty filter group compiles to its boolean identity — an empty and matches every document, an empty or matches none — because MongoDB refuses $and: []/$or: [] outright, and those are the answers Memory and Drizzle give.

rawQuery is refused by name with UnsupportedRawQueryError — MongoDB has no SQL, so an application reaches the injected client directly for native commands, exactly as it does for a Prisma raw query. Transactions use a startSession(); on a deployment that is not a replica set they are refused late, never at connect(). On the real driver the refusal surfaces at the first operation inside the transaction as the driver’s own unwrapped MongoServerError (code: 20, codeName: 'IllegalOperation'), not at beginTransaction() (measured, M107); MongoTransactionUnavailableError wraps only a startTransaction() that itself throws.

IMongoClient and IMongoObjectIdCtor are the exported injection seam, and the real driver implements their structural shapes: IMongoClient.connect() is Promise<unknown> because the driver’s own connect(): Promise<this> is not assignable to Promise<void>, and the adapter binds nothing; the same reconciliation admits the driver’s startTransaction(options?): void (the session member is (options?: unknown) => unknown) and its string-and-array Sort union (sort? is unknown on find/findOne, where the adapter builds a Record). The claim is pinned by a compile-time fixture — packages/database-plugin/test/types/mongo-seam.assert.ts assigns a real new MongoClient(...) to IMongoClient with NO cast under a static import of the driver, and fails pnpm run check the moment the seam drifts again. The types the client’s members reference — IMongoDatabase, IMongoSession, and the IMongoObjectId instance shape — are exported alongside them so the seam’s return types are nameable from the package entry, as are the collection-level shapes IMongoDatabase.collection() reaches (IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, and the MongoOptions/MongoWriteOptions option bags).

DynamoDB backend ('dynamodb' arm)

DynamoAdapter is a key-value backend over the native AWS SDK v3 client (@aws-sdk/client-dynamodb), registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'dynamodb',
    options: {
      region: 'us-east-1',
      // `endpoint` addresses the local emulator in development and tests; absent
      // in production, where the SDK's own endpoint resolution applies.
      entities: { Order: { partitionKey: 'tenantId', sortKey: 'orderId' } },
    },
  }),
);

DynamoAdapterOptions is the DynamoDB-specific option bag, and it is a union of two arms: one requires region (with an optional endpoint — the emulator address — and credentials), the other requires client, an already-constructed client behind the structural IDynamoClient facade. Supplying neither is a compile error rather than a connect() throw — the guarantee every other built-in arm gives. DynamoAdapterOptionsBase is the half both arms share (entities, maxPageFetches). When client is present the lazy import('@aws-sdk/client-dynamodb') never runs. The adapter owns marshalling and deliberately does not use lib-dynamodb’s DocumentClient: automatic marshalling would hide two facts the adapter must decide — DynamoDB has no date type, so the encoding of a Date comparison is the mapping’s decision, and a DynamoDB N is an arbitrary-precision decimal that Number() degrades.

entities is the per-entity mapping ({ table?, partitionKey, sortKey?, indexes?, dateAttributes? }); an unmapped entity uses its own name as the table and 'id' as the partition key. A scalar key is accepted only for a partition-only entity — against a sort-keyed entity it is refused by name, because a GetItem carrying only the partition key is a server-side ValidationException. A composite key record is projected down to exactly the resolved key columns (an extra attribute is the same server-side refusal), and a record missing a column, or carrying an empty-string value, is refused by name. The key map itself is order-insensitive — the opposite of a Mongo compound _id subdocument — so no canonical ordering is applied.

Both single-row writes are conditional, because the unguarded forms violate the IDataSource contracts silently — both measured against the real emulator. create carries attribute_not_exists(<partitionKey>): an unguarded PutItem on an existing key silently overwrites the item and drops every attribute absent from the new one, answering 200. update carries attribute_exists(<partitionKey>) with ReturnValues: 'ALL_NEW': an unguarded UpdateItem on a missing key upserts a ghost item and returns it as though it were an update, so the conditional failure (ConditionalCheckFailedException) is what produces the documented rejection. delete reads ReturnValues: 'ALL_OLD' for its boolean — no read-before-delete round trip, and no constant true.

The access path is resolved from the caller’s query: an equality on the entity’s partition key (or on a configured index’s partition key, selecting that GSI) is a Query; everything else is a Scan, because a Query without a partition-key equality is a server-side ValidationException — the selection is forced, not an optimisation. Every predicate not folded into the key condition becomes a FilterExpression, and every attribute name is aliased through a generated placeholder, so a reserved word like status cannot reach the server raw. A non-empty select is pushed down as a ProjectionExpression, so the server returns only the projected attributes rather than whole items; repeated and empty field names are dropped first, because DynamoDB refuses a projection naming one path twice (Two document paths overlap with each other, even under distinct aliases) and refuses an empty attribute name outright. Push-down is safe for cursor paging: LastEvaluatedKey is computed independently of the projection, so a projection omitting the key columns still resumes correctly. orderBy is served only when it names exactly the resolved access path’s sort key (as ScanIndexForward); a non-key or multi-field orderBy is refused by name with UnsupportedQueryFeatureError, and a non-zero offset is refused the same way — both because the SDK accepts and silently discards the unrecognised parameter, answering 200 with unordered or unskipped rows, so forwarding one returns confidently wrong rows with no diagnostic anywhere in the stack.

Pagination is the server’s own continuation token carried inside the portable cursor codec, and the invariant is exact: nextCursor is non-null if and only if the response carried a LastEvaluatedKey — never derived from rows.length, because Limit is applied before FilterExpression and a filtered page can return fewer rows than the limit, or zero, and still not be the last. A bounded fill loop (maxPageFetches, default 10) keeps fetching while a page is short and a token remains; at the bound it returns what it has with a non-null cursor, never a terminal page. maxPageFetches must be a positive integer and is validated at construction: the loop runs while fetches < maxPageFetches, so Infinity would silently remove the very bound the option imposes (a findPage whose filter matches nothing would scan the whole partition to exhaustion) and NaN would make the comparison false on the first pass, collapsing the loop to a single server page — neither raises anything on its own, so both are refused by name up front. count() loops to exhaustion for the same reason — a paged COUNT response under-reports past the first page. A Date comparison must be declared per attribute (dateAttributes: { createdAt: 'iso' | 'epochMs' }) or it is refused by name naming the option, and an out-of-range decimal N is preserved on read as its string rather than degraded through Number(). Transactions buffer writes and flush them as one TransactWriteItems at commit — refused by name past 100 items, or on two operations for one item key — and reads inside a transaction see committed state (no read-your-own-writes), documented rather than emulated.

IDynamoClient and DynamoSdkModule are the exported injection seam, with createInjectedDynamoLoader and createLazyDynamoLoader as its two arms. The command and attribute shapes the client’s members reference — DynamoAttributeValue, DynamoEntityMapping, DynamoIndexMapping, DynamoDateEncoding, and the Dynamo*CommandInput/Output/Transact* closure — are exported alongside them so the seam’s return types stay nameable from the package entry. The internal target resolution, expression builder, marshaller and access-path resolver are not exported.

Cosmos DB backend ('cosmos' arm)

CosmosAdapter serves Azure Cosmos DB’s NoSQL (SQL) API over @azure/cosmos, registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'cosmos',
    options: {
      endpoint: 'https://my-account.documents.azure.com:443/',
      key: cosmosKey,
      database: 'app',
      containers: { Order: { container: 'orders', partitionKey: 'tenantId' } },
    },
  }),
);

Cosmos DB’s MongoDB API is a different wire protocol; it is served by the 'mongodb' arm pointed at a Cosmos connection string. That route is documented and not verified against a live account: the emulator’s MongoDB endpoint tops out at API version 4.0 (wire version 7) while the mongodb driver this package pins requires wire version 8, so it cannot be exercised there.

CosmosAdapterOptions is a union of two arms: one requires endpoint + key, the other requires client (an already-constructed structural ICosmosClient, which is how an Entra ID or connection-string client reaches the adapter). database is required on BOTH arms, because a Cosmos endpoint encodes no database name and there is nothing to fall back to. CosmosAdapterOptionsBase is the half both arms share (database, containers, logQueries). containers is a per-entity override ({ container?, primaryKey?, partitionKey? }); an unmapped entity uses its own name as the container and 'id' as the primary key — the D1-shaped two-layer mapping.

The adapter provisions nothing. Cosmos creates no database or container implicitly, and throughput, partition-key and indexing choices belong to the application; a missing container is refused by name at first use.

The partition key is discovered from the container definition and read once per container. A declared partitionKey is validated against it and refused by name on a mismatch — a point read carrying the wrong partition key answers 404 rather than an error, so an unvalidated mapping would report “not found” for every row of a healthy container.

Identity. A document is addressed by (partition key, id). findById point-reads when the key carries the partition key, or when the container partitions by the primary-key field; otherwise it resolves the row by a cross-partition query, which costs more request units and is refused by name when the id matches two documents, since an id is unique only within a partition. A primary key must be a string — the service refuses any other type, and converting silently would change the type a caller gets back from create().

update merges through a server-side patch while the payload fits one request and through a read-merge-replace guarded by _etag beyond it, raising CosmosConcurrentModificationError on a lost race. A payload that would change a partition-key value is refused by name, because such a replace answers 404 rather than moving the item.

contains compiles to CONTAINS, a literal substring match (no %/_ escaping, case-sensitive); in compiles to ARRAY_CONTAINS with the list bound as one array parameter, so an empty list matches nothing natively. offset/limit are served natively, with the contract’s -1 sentinel translated rather than passed through (Cosmos refuses LIMIT -1, and refuses OFFSET without LIMIT).

Pagination uses the portable keyset cursor, not a Cosmos continuation token — this adapter’s design choice, matching what the tested backend supports: measured against the emulator, an ORDER BY query returns no continuation token even with maxItemCount passed as a query option. The claim is scoped to that measurement rather than asserted of every Cosmos deployment. On a real account, keyset paging therefore needs a composite index over (sort field, id) for each paged container, because the walk always adds the key column as its tiebreaker.

rawQuery is refused by name with UnsupportedRawQueryError: a Cosmos SQL query is scoped to one container and the signature names none, so an application reaches the injected client directly.

Transactions are a deferred batch. beginTransaction() buffers every write and flushes it as one transactional batch at commit; rollback() discards it and sends nothing. The batch is atomic within one container and one partition-key value and caps at 100 operations, and a write crossing any of those bounds raises CosmosTransactionScopeError at that write. Reads inside a transaction observe committed state only — the deferred-write clause IDataSource documents. A buffered update is sent as a patch, so it writes only the fields its payload names and two updates of one row compose; an update too wide for a single patch request falls back to a whole-document replace, which cannot compose, so buffering one for a row the transaction has already written is refused rather than silently discarding the earlier write. rollback() is idempotent (commit() is not), because the framework rolls back inside the same catch that sees a failed commit — refusing there would replace the batch’s own per-operation diagnostic with a complaint about rollback.

What the arm deliberately cannot do, split by who could close it. Two are platform limits: Cosmos rejects a cross-container join with a 400 (a query addresses one container; its own JOIN unwinds an array inside a single item), and returns no continuation token for an ORDER BY query on the tested emulator. Three are contract limits: grouping is absent from NormalizedQuery, which carries no aggregate beyond count — the Cosmos dialect does support GROUP BY, so closing that is a common widening every adapter must answer; rawQuery is refused because the committed signature names no container; and request units, consistency levels, TTL and index policy are outside the portable contract by design (the M79 exclusion). An application needing any of them reaches the injected client, as it does for a Prisma raw query.

ICosmosClient and the shapes it reaches (ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIterator, CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValue) are the exported injection seam, together with the response and operation shapes those members return and accept (CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation with its four arms CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation and CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinition) — so the seam’s own signatures are nameable from the package entry. The real SDK implements their structural shapes.

Cloud Bigtable backend ('bigtable' arm)

BigtableAdapter serves Google Cloud Bigtable over @google-cloud/bigtable, registered through the same discriminated union as every other backend:

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'bigtable',
    options: {
      projectId: 'my-project',
      instance: 'app-instance',
      tables: {
        Order: {
          table: 'orders',
          rowKey: { fields: ['tenantId', 'orderId'], separator: '#' },
          columnFamily: 'o',
          columns: { total: 'metrics:amount' },
        },
      },
    },
  }),
);

Bigtable inverts the DynamoDB problem. Its row key is a single lexicographically-sorted string, so findById fits it natively with no key object at all. What it lacks instead is everything around the key: there is no secondary index of any kind, so a predicate on a non-key column is a scan and orderBy is row-key order or nothing.

BigtableAdapterOptions is a union of two arms: one requires projectId (with an optional apiEndpoint for an emulator), the other requires client (an already-constructed structural IBigtableClient, which is how a client built with non-default credentials reaches the adapter). instance is required on BOTH arms, because a table is addressed as project/instance/table and neither a client nor a project encodes it. BigtableAdapterOptionsBase is the half both arms share (instance, tables, maxPageFetches, logQueries). tables maps each entity name to a BigtableEntityMapping — { table?, rowKey?, columnFamily?, columns?, valueEncoding? }, whose rowKey is a BigtableRowKeyMapping and whose valueEncoding is a BigtableValueEncoding; an unmapped entity uses its own name as the table, ['id'] as the row-key fields, 'cf' as the column family and tagged values. BigtableAdapter’s constructor also takes an optional second parameter, a BigtableClientLoader, which overrides the inject-or-lazy choice and whose owned flag decides whether disconnect() closes the client it produced.

The adapter provisions nothing and connect() issues no RPC. Column families, garbage-collection policies and split points belong to the application’s provisioning, and a missing table or instance is already reported by the service as 5 NOT_FOUND quoting the full resource path — while instance.getTables() is a table-ADMIN call a data-plane service account commonly cannot make, so probing at connect time would refuse a working configuration. Configuration mistakes are refused at construction instead, by name.

The row key is composed from logical fields. BigtableRowKeyMapping is { fields, separator?, prefix? }, defaulting to { fields: ['id'], separator: '#' }. A single-field key accepts a scalar EntityKey; a multi-field key requires a record naming every field and refuses a scalar, which cannot say which field it is. A key field’s type is not part of the row key — a numeric field renders as its decimal text, so 1 and '1' are one physical row and findById('1') answers the row stored under 1, whose id cell still decodes as the number. Choose one type per key field, and zero-pad a numeric one whose lexicographic order matters. A field value containing the separator is refused: two different logical keys would otherwise compose to one row key, so a write would silently overwrite an unrelated row. Composing a row key from several fields is a mapping concern rather than the composite-key contract concern, which is why this arm needs no common change — Bigtable’s key shares no type with DynamoDB’s partition/sort pair.

Key fields are written as ordinary cells AND recovered from the row key on read, with the cells winning. All three parts are load-bearing: a Bigtable row cannot exist with zero cells; the row key is bytes and records no type, so overlaying it would turn a numeric key field into a string; and a table written outside this framework has no key cells at all, which is what the parse-back serves.

Values are tagged by default. BigtableValueEncoding is 'tagged' | 'raw'. Tagged writes <tag>:<payload>, so a number, boolean, null, Date or object round-trips as itself; a cell carrying no recognised tag decodes as its raw string, which is the interop path. 'raw' writes String(value) and reads every cell as a string, removing that residual ambiguity for an application whose table is entirely foreign.

orderBy is the row key or nothing. An empty sort is honoured, and so is one naming exactly the mapped key fields, in order, all ascending — that IS the scan order. Everything else raises UnsupportedQueryFeatureError: a non-key field has no index, and descending is refused deliberately rather than shipped on reversed: true, because the emulator this adapter is tested against silently ignores that option (measured: it answered ascending with no error), so the path could not be verified. A non-zero offset is refused too — Bigtable has no row offset, and discarding scanned rows would read and bill them; findPage is the route.

Three narrowings reach the server, and nothing else. The row set (an eq on every key field is an exact key; an eq pinning a leading prefix is a prefix range with an exclusive successor end; a pinned prefix plus an in on the final field is an explicit key list), byte-exact value equality for each conjunctive non-key eq, and the column projection. Everything else is evaluated by the same evaluator the memory adapter uses as the portable reference, so the six backends cannot drift about what a FilterExpression means. The invariant is that a push-down may only ever match a superset of what the client-side evaluator keeps; every fallback widens. Two details are correctness requirements rather than tuning: a value test is an exact byte RANGE and never the SDK’s string form, which is a regex (measured, { value: 'a.*b' } matched both a.*b and axxb); and it is wrapped in a condition filter rather than chained, because a bare chain strips every non-matching cell and the row would come back carrying only the cell that matched. An ordered comparison on a key field is not pushed down either — the composed key is a string, so a numeric key field does not sort numerically inside it. The projection is interleaved with a one-cell arm rather than emitted bare: a filter that removes every cell of a row removes the row (the service answers with no empty row), so a bare projection would silently drop a row carrying none of the projected columns — unreachable for a row this adapter wrote, reachable for a table written elsewhere. count reuses that projection, restricted to the columns its predicate needs.

Pagination is a start-key cursor over the portable keyset codec, which is exactly Bigtable’s own continuation mechanism. nextCursor is non-null if and only if the page is non-terminal, never derived from rows.length: a client-side filter can empty a whole raw batch, so a page bounded by maxPageFetches (default 10) returns zero rows AND a cursor, minted from the last row scanned.

rawQuery is refused by name with UnsupportedRawQueryError: Bigtable has no query language behind query(sql, params) — its data plane is ReadRows, MutateRow and CheckAndMutateRow.

Transactions are one row. Bigtable’s only atomicity unit is the single row (a multi-row batch is atomic per entry, not as a whole), so beginTransaction() buffers writes, refuses a second row key at the write that crosses the bound with BigtableTransactionScopeError, and commits the buffer as ONE CheckAndMutateRow whose mutation list applies atomically and in order — a buffered delete followed by writes replaces the row wholesale. rollback() discards, sends nothing, and is idempotent. Reads inside a transaction observe committed state only. create and update are conditional writes: Bigtable’s insert is an upsert, so the CheckAndMutateRow match flag is what makes create refuse an existing row and update refuse an absent one — including inside a transaction, where a buffered create whose row turns out to exist is refused at commit.

What the arm deliberately cannot do. Platform limits: no secondary index of any kind, no row offset, no multi-row atomicity, and no descending scan this adapter can verify. Contract limits: grouping and joins are absent from NormalizedQuery, whose only aggregate is count; rawQuery has no Bigtable surface; and cell versioning plus garbage-collection policies are outside the portable contract by design — no other backend has a counterpart, so exposing them would invent a concept for one adapter. An application needing any of them reaches the injected client.

IBigtableClient and the shapes it reaches (IBigtableInstance, IBigtableTable, IBigtableRow, BigtableReadOptions, BigtableReadRow, BigtableRowData, BigtableCell, BigtableRowRange, BigtableRowBoundary, BigtableValueRange, BigtableFilter, BigtableMutation) are the exported injection seam, so an application implementing its own facade can name every signature. createInjectedBigtableLoader and createLazyBigtableLoader build the two loader arms; BigtableClientConfiguration is what the lazy one takes.

Exports

ExportKind
DatabasePluginfactory
DatabaseServiceclass
BaseRepository, UnitOfWorkclasses
MemoryAdapter, PrismaAdapter, DrizzleAdapter, MongoAdapter, DynamoAdapterclasses
PrismaRepository, DrizzleRepositoryclasses
UnsupportedFilterOperatorError, UnsupportedIsolationLevelError, UnsupportedMigrationError, UnsupportedRawQueryError, UnsupportedQueryFeatureErrorclasses
SerializationConflictError, DatabaseUnavailableError, DatabaseTimeoutError, UnknownPayloadFieldError, MissingKeyColumnError, TenantColumnUnresolvedErrorclasses
PageOptions, Pagetypes
PrismaCompositeKeyOptions, DrizzleCompositeKeyOptionstypes
EntityKey, PageResult, CursorPayload, CursorValue (re-exported from common)types
encodeCursor, decodeCursor, keysetPredicate (re-exported from common)functions
MongoTransactionUnavailableErrorclass
CosmosAdapterclass
CosmosTransactionScopeError, CosmosConcurrentModificationErrorclasses
CosmosDatabaseOptions, CosmosAdapterOptions, CosmosAdapterOptionsBase, CosmosEntityMappingtypes
ICosmosClient, ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIteratorinterfaces (Cosmos injection seam)
CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValuetypes (Cosmos query seam)
CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation, CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation, CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinitioninterfaces (Cosmos operation seam)
PrismaSqlProvidertype
SqlJsonDialecttype
createPrismaDataSource, createDrizzleDataSource, createDrizzleDatabase, getDrizzleDatabase, getDrizzleTransaction, withIsolationSupportfunctions
DrizzleDatabase, DrizzleDatabaseIdentity, DrizzleTransaction, DrizzleTransactionBridgetypes
IDatabaseService, IRepository, IUnitOfWorkinterfaces
DatabasePluginOptions, BuiltInDatabaseOptions, CustomDatabaseOptions, DatabaseConnectionOptionstypes
MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptionsinterfaces
MongoAdapterOptions (union of two arms), MongoAdapterOptionsBase, MongoEntityMappingtypes
PrismaAdapterOptions, DrizzleAdapterOptionsinterfaces
DatabaseAdapterType, DatabaseAdapterOptionstypes
FindOptions, CountOptions, OrderDirection, FilterOperator, FilterComparison, FilterExpressiontypes
IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery, TransactionIsolationLevel, TransactionOptionsre-exports from common
DataSourcedeprecated alias of IDataSource
IMongoClient, IMongoDatabase, IMongoObjectId, IMongoObjectIdCtor, IMongoSessioninterfaces (Mongo injection seam)
IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, MongoOptions, MongoWriteOptionsinterfaces (Mongo collection seam)
DynamoAdapterOptions (union of two arms), DynamoAdapterOptionsBase, DynamoEntityMapping, DynamoDateEncoding, DynamoIndexMappingtypes
IDynamoClient, DynamoAttributeValue, DynamoAttributeMap, DynamoExpressionAttributes, DynamoConditionExpression, and the Dynamo*CommandInput/Output/Transact* command-shape closureinterfaces (DynamoDB client seam)
DynamoClientConfiguration, DynamoClientLoader, DynamoCommandConstructor, DynamoSdkClient, DynamoSdkCommand, DynamoSdkModuletypes (DynamoDB SDK seam)
createInjectedDynamoLoader, createLazyDynamoLoaderfunctions

DataSource is retained under AI_GUIDELINES §9.2 — it is already published. It is now an alias of the promoted IDataSource (the same type), and will be removed in the next major version.

DatabaseAdapterType gained 'custom', then 'mongodb', then 'dynamodb', then 'cosmos', then 'bigtable'; DatabasePluginOptions became a union discriminated on type. All are additive for callers — every existing registration compiles unchanged. MongoDatabaseOptions, DynamoDatabaseOptions and CosmosDatabaseOptions each extend DatabaseConnectionOptions and carry their arm’s dedicated option bag (MongoAdapterOptions, DynamoAdapterOptions, CosmosAdapterOptions), as does BigtableDatabaseOptions with BigtableAdapterOptions, so a registration carrying a memory, Prisma, Drizzle or custom configuration still compiles unchanged.

Query refusals answer 501 Not Implemented (M89b, X19-1). The three query-shape errors — UnsupportedQueryFeatureError, UnsupportedFilterOperatorError and UnsupportedRawQueryError — carry an HttpStatusHint from @setu-ts/common, so @setu-ts/exceptions’ errorHandler answers them 501 with a caller-safe sentence naming the feature or operator and the adapter that refused it. Each is a condition the CALLER caused — the query asks for something the active backend does not implement, permanently — which is exactly what 501 states, and it is the shape a developer meets when SWITCHING BACKENDS: before this, an application that worked on Mongo answered 500 Internal Server Error on every ordered endpoint under DynamoDB, and the response said the server was broken.

The served detail is composed from this package’s own structured fields (feature, operator, connector, adapter), never from the error’s message — which remains the full diagnostic, is reachable only in the log, and is what errorHandler records. So the response gains a status and a sentence without gaining a disclosure channel; a hinted error is exempt from maskInternalErrors precisely because there is no driver output in its body to mask.

Driver conditions answer their classified status (M90f, X38-1/X35-2). DatabaseService classifies a driver rejection at its four interception sites (the repository wrappers, the beginTransaction() acquisition, the transaction catch, and query()) and throws SerializationConflictError — answered 409 Conflict, the canonical retry-me signal, for a backend-reported write conflict (PostgreSQL SQLSTATE class 40 including 40001, Prisma’s P2034 mapping of it, MongoDB’s TransientTransactionError label, DynamoDB’s TransactionConflictException name, Bigtable’s gRPC ABORTED, Cosmos 449) — or DatabaseUnavailableError — answered 503 Service Unavailable — for a transiently unreachable backend (SQLSTATE class 08, 57P03, the node-postgres pool timeout, MongoNetworkError/MongoServerSelectionError, gRPC UNAVAILABLE, Cosmos 429/503, and the net errno ECONNREFUSED family the AWS SDK surfaces as a bare error). Both carry the original driver error as cause — the SQLSTATE and the failing statement stay reachable for the log — and serve a fixed sentence, so the masking exemption stays narrow. Neither carries Retry-After: ErrorResponseInit has no header channel, and the framework holds no honest horizon for a pool’s saturation — 503 is itself the retryable signal (RFC 9110). The rate limiter’s Retry-After is the deliberate asymmetry (a fixed window genuinely knows when it resets). An unrecognised error keeps the masked 500, and an already-classified error is passed through untouched rather than wrapped twice. MongoDB’s conflict mapping is verified against a real replica set and PostgreSQL’s against a real server (SQLSTATE 40001 through Drizzle and Prisma’s P2034 alike); DynamoDB’s TransactionConflictException and Cosmos’s 449 are documented-unverified — neither was reproducible against DynamoDB Local or the local emulator, and Cosmos’s connect refusal is a startup failure that never reaches a client.

A timed-out query answers 504 Gateway Timeout (M113g, V9-33). DatabaseService classifies node-postgres’s query_timeout firing — the driver’s Query read timeout rejection — as a new timeout kind and throws DatabaseTimeoutError, branded 504 Gateway Timeout with the fixed detail “The database did not answer in time.”; the scrubbed driver error stays as cause for the log, and errorHandler reads the hint. It is not DatabaseUnavailableError: that class’s contract says the operation did not happen, and a timed-out statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent. The bound is the driver’s own — node-postgres’s query_timeout and connectionTimeoutMillis on the Pool the application hands Drizzle; the plugin adds no call deadline of its own, for the reasons the README’s Bounding a call section gives. The behaviour of a timed-out statement inside a transaction is measured in this milestone and documented there. Ships in 0.10.0.

A duplicate key answers 409 Conflict. A write that would duplicate a primary key or a unique index is thrown as DuplicateKeyError (exported from @setu-ts/common, so a package that may not import this one can raise or recognise it), where every backend used to reach the client as a masked 500. The signals, each measured against the real engine: PostgreSQL SQLSTATE 23505, Prisma’s P2002, MySQL’s ER_DUP_ENTRY (by probe; CI runs no MySQL), MongoDB code 11000, Cosmos 409, and SQLite’s UNIQUE constraint failed, which is the only signal Cloudflare D1 carries. The memory, DynamoDB and Bigtable adapters raise the class themselves. Numeric codes are read only under their own adapter, as for the conflict class. It is not retryable: the served detail says the conflicting write was rejected, never that it is safe to retry. The class carries the targeted entity when the failing repository call names one. At commit() a duplicate is the one class mapped, because the backends that detect it there (D1’s atomic batch, a Cosmos transactional batch, a DynamoDB TransactWriteItems whose cancellation reason sits on a create’s Put, a buffered memory or Bigtable create) apply nothing when they refuse; any other commit rejection is still rethrown unchanged.

Programmatic migrations answer 501 Not Implemented too. UnsupportedMigrationError names a distinct framework-capability refusal rather than pretending a migration is a raw query or a query shape. Every current adapter lacks programmatic migration support and delegates it to its own CLI, so the refusal is permanent and 501 is the honest response. Its hint carries a fixed sentence, never an operator-facing error message.

Not every UnsupportedQueryFeatureError is branded, and that is deliberate. The class is shared by two kinds of refusal: a caller-caused query shape, and a configuration refusal. Only the former is answered 501; the latter keeps the masked 500 that is correct for an internal fault. Branding the constructor unconditionally made a blank columnFamily — a value the developer wrote — answer every request 501 "Query feature 'mapping' is not supported by the 'bigtable' database adapter.", which is a lie twice over. The split is an allowlist of feature values (attribute-value, composite-key, cursor-pagination, key, nested-path, offset, order-by/orderBy, row-key, update); mapping, endpoint, date-encoding and transaction are excluded, and an unclassified value is not branded — so a feature name added later keeps its masked 500 until someone decides otherwise.

The four transaction and concurrency errors — MongoTransactionUnavailableError, CosmosTransactionScopeError, CosmosConcurrentModificationError and BigtableTransactionScopeError — are deliberately not branded and keep their masked 500: they may legitimately quote backend state, and a concurrency conflict is transient and retryable rather than permanent, which is a different contract statement deserving its own decision.

Transactional outbox store (M107)

createDatabaseOutboxStore(options?) returns a RegistryFactory<IOutboxStore> — the shipped implementation of the IOutboxStore port in @setu-ts/common over IDatabaseService (the M101c createDatabaseTenantDataStore shape). It is passed to the messaging plugin’s outbox.store option, which resolves it in onInit and runs verify() there.

import { createDatabaseOutboxStore } from '@setu-ts/database-plugin';

const store = createDatabaseOutboxStore({ entity: 'Outbox', database: 'orders' });
ExportKindNotes
createDatabaseOutboxStorefunctionResolves CAPABILITIES.DATABASE, or database.<name> for database: '<name>' ('default' is the bare token). Throws TypeError at the call for an empty entity
DatabaseOutboxStoreOptionsinterfaceentity? (default 'Outbox'), database? — must be the database the caller’s unit of work belongs to, or the row is never relayed by this store
OutboxStoreUnavailableErrorclassRejected by verify(); entity, reason ('bigtable', 'dynamodb-index', 'mongodb-replica-set', 'conditional-writes-unsupported', 'entity-unavailable'), the adapter error as cause
  • Discriminator. Every row carries kind: 'setu-outbox' (OUTBOX_RECORD_KIND); every read (scanPending, failedKeys, stats, purge) requires it, and a lookup by id before a transition treats a row of another kind as missing.
  • Conditional transitions. claim(id, { claimVersion, leaseUntil }) requires kind, pending and the read version, increments it by one and sets the lease. markSent/markFailure require the held version, including deleteNow through deleteWhere; a pending version miss is claim-lost. Status misses are not-pending or not-failed; another kind is missing. markInvalid(id, now) uses only kind/status, writes failed/invalid-row, clears the lease and preserves attempts. markFailure and release('retry') clear the lease; release preserves the version. Unexplained misses retry at most three rounds. Native conditional writes are required; no fallback remains. sentBy is only an operator diagnostic, absent from outcomes.
  • Columns. The row is the record’s own fields; an absent optional field is written as null and read back as absent. failedKeys selects only tenantId and orderingKey; purge deletes sent then discarded rows with settledAt < before, at most limit per status.
  • verify() runs scanPending(undefined, 1), then transaction(uow => uow.getRepository(entity).findAll({ where: { kind, status: 'pending' }, limit: 1 })), then updateWhere('setu-outbox-claim-probe', { kind, status: 'pending', claimVersion: -1 }, { claimVersion: 0 }) and deleteWhere with the same id and predicate — both, because retainSentMs: 0 deletes a sent row conditionally after it is published. No row can carry version -1 (append writes 0 and a claim only increments), so neither probe writes, even to a row appended under the probe id. The old SQL schema without claim_version is refused at the first step, the relay query. A conditional-write refusal is conditional-writes-unsupported. An UnsupportedQueryFeatureError from bigtable is 'bigtable'; one from dynamodb for orderBy is 'dynamodb-index' (a GSI { partitionKey: 'status', sortKey: 'position' }, projection ALL, is required); a cause chain carrying code: 20 with codeName: 'IllegalOperation' — what a standalone MongoDB answers at the first operation inside a transaction — is 'mongodb-replica-set'; anything else is 'entity-unavailable'. On memory and MongoDB a missing entity cannot be detected (both create lazily). Every refusal is a rejected promise.

Consumer inbox store (M108)

createDatabaseInboxStore(options?) returns a RegistryFactory<IInboxStore> — the shipped implementation of the IInboxStore port in @setu-ts/common over IDatabaseService. It is passed to the messaging plugin’s inbox.store option, which resolves it in onInit and runs verify() there.

import { createDatabaseInboxStore } from '@setu-ts/database-plugin';

const store = createDatabaseInboxStore({ entity: 'Inbox', database: 'orders' });
ExportKindNotes
createDatabaseInboxStorefunctionResolves CAPABILITIES.DATABASE (or database.<name>) and CAPABILITIES.RUNTIME. Throws TypeError at the call for an empty entity
DatabaseInboxStoreOptionsinterfaceentity? (default 'Inbox'), database? — must be the database the handlers write to
InboxStoreUnavailableErrorclassRejected by verify(); entity, reason ('cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone', 'transaction-scope', 'entity-unavailable'), the adapter error as cause when there is one
  • run opens one transaction, creates the marker FIRST, then runs the work with the unit of work; any rejection rolls both back. A duplicate marker id rejects (the adapter’s DuplicateKeyError, or a write conflict on a MongoDB replica set).
  • Discriminator. Every row carries kind: 'setu-inbox' (INBOX_RECORD_KIND); every read (find, parked, stats, purge) requires it, and a row of another kind is missing.
  • Conditional writes. recordFailure compares kind and the read attempts value in the write, retrying misses at most five rounds; release requires kind and status: 'parked'. Without conditional support, or for a non-safe-integer count, the two-call fallback can lose an increment under concurrent failures.
  • verify() refuses Cosmos DB and Bigtable by adapter arm or class, runs purge(0, 1), then a transaction that creates two probe rows and always rolls back: a BigtableTransactionScopeError at the second row is 'transaction-scope', a cause chain carrying code: 20 with codeName: 'IllegalOperation' is 'mongodb-standalone', anything else 'entity-unavailable'. Nothing the probe writes survives.

Scoped RBAC sources (M110b)

createDatabaseGrantSource(options) and createDatabaseRoleSource(options) return RegistryFactory values for AuthPlugin’s scopedRbac option — the grant source for a { kind: 'custom', source } arm and the role source for customRoles — resolved in AuthPlugin’s onInit, so DatabasePlugin may be registered in either order. Both read the default CAPABILITIES.DATABASE service through the repository surface and portable FilterExpressions, so they run on every adapter; no SQL is written and no value is interpolated.

import { AuthPlugin } from '@setu-ts/auth-plugin';
import { createDatabaseGrantSource, createDatabaseRoleSource } from '@setu-ts/database-plugin';

const auth = AuthPlugin({
  rbac: { roles: { approver: { permissions: ['invoices:approve'] } } },
  scopedRbac: {
    sources: [{ kind: 'custom', source: createDatabaseGrantSource({ entity: 'role_grants' }) }],
    customRoles: createDatabaseRoleSource({ entity: 'tenant_roles' }),
  },
});
OptionGrant source (DatabaseGrantSourceOptions)Role source (DatabaseRoleSourceOptions)
entityRequired: one row per grantRequired: one row per (scope, role, permission)
fieldsGrantFields — subject / role / scopeType / scopeId (those defaults)RoleFields — scopeType / scopeId / role / permission (those defaults)
nameLog name (default 'database-grants')Log name (default 'database-roles')
limitMost matching rows, 1–10 001 (default 10 001); more refuses the questionMost matching rows, 1–100 000 (default 100 000); more refuses the question

A grant row whose scopeType and scopeId are both null is a GLOBAL grant; a row with only one null is passed through and dropped by AuthPlugin’s validator. A chain question is ONE query — the subject, and either a global row or a row in one of the asked scopes; an all question (sign-in timing) reads every row of the subject. Each question reads at most limit + 1 rows, and more than limit matching rows REJECT it — the check then denies — rather than returning an arbitrary subset, which would under-grant unpredictably; AuthPlugin’s own maxGrantsPerPrincipal bounds the union afterwards. The role source groups rows into one definition per (scope, role) and skips a row missing any column. Every field name must be an identifier ([A-Za-z_][A-Za-z0-9_]*), so it can never be read as an operator; an empty entity, a non-identifier field, or an out-of-range limit throws TypeError when the factory is called. A query does not start once the evaluator’s deadline has passed. Verified against real PostgreSQL (Drizzle) and MongoDB.

Multiple Databases

// Each named connection injects its own application-generated Prisma client;
// `options.url` is deprecated and unread — a v7 client carries its own
// connection configuration.
app.register(
  DatabasePlugin({
    type: 'prisma',
    name: 'primary',
    options: { prismaClient: primaryPrismaClient },
  }),
);

app.register(
  DatabasePlugin({
    type: 'prisma',
    name: 'analytics',
    options: { prismaClient: analyticsPrismaClient },
  }),
);

// Access by name
app.router.get('/analytics', async (ctx) => {
  const primaryDb = ctx.services.get<IDatabaseService>('database.primary');
  const analyticsDb = ctx.services.get<IDatabaseService>('database.analytics');
  // ...
});

Agent card · TypeDoc API reference