@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 / condition | Guarantee |
|---|---|
Unknown payload field on Drizzle, Prisma or D1 create / update / updateWhere | UnknownPayloadFieldError (400); no field or value in the HTTP detail |
| Unknown payload field on Memory, MongoDB, DynamoDB, Cosmos DB or Bigtable | Stored and returned; restrict fields with the route’s body schema |
| PostgreSQL refused field value through Drizzle repositories | InvalidFieldValueError (400), including malformed UUID keys; fixed detail, scrubbed cause |
Prisma validation other than unknown arguments, or known-request P2007 | InvalidFieldValueError (400), including malformed UUID keys and conditional writes |
| D1 STRICT-table field type refusal | InvalidFieldValueError (400) |
| Tenant column on Drizzle | Resolve 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 table | MissingKeyColumnError 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.
| Method | Promise 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 release | Methods |
|---|---|
| 0.1.0 | findById, findAll, create, update, delete, exists, count |
| 0.2.0 | findOne, findPage |
| 0.9.0 | updateWhere, 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:
| Field | Type | Meaning |
|---|---|---|
where | Record<string, unknown> | Equality conditions |
filter | FilterExpression | Portable expression conjoined with where |
orderBy | Record<string, OrderDirection> | Field directions ('asc' / 'desc') |
limit | number | Maximum results |
offset | number | Results to skip |
select | readonly string[] | Projected fields |
cursor | string | Continuation 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 shape | Fields |
|---|---|
CountOptions | Optional 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.
| Adapter | Supported isolation |
|---|---|
| Prisma | PostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none |
| Drizzle | All four, when its bridge is explicitly wrapped with withIsolationSupport() |
| Memory | serializable, process-local only |
| MongoDB | None |
| D1, DynamoDB, Cosmos DB, Bigtable | None; 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:
| Backend | Verdict |
|---|---|
| memory | supported (single process) |
| Prisma | supported; the table must exist |
| Drizzle | supported; drizzleTables must register the outbox table |
| D1 | supported; the table must exist |
| MongoDB | supported on a replica set; a standalone server is refused (reason: 'mongodb-replica-set') |
| DynamoDB | supported with a GSI { partitionKey: 'status', sortKey: 'position' }, projection ALL; refused without it ('dynamodb-index') |
| Cosmos | supported 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 writes | refused ('conditional-writes-unsupported'): updateWhere or deleteWhere is absent, or either rejects conditional-write |
| Bigtable | refused ('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:
| Ledger | Indicator | Migration data |
|---|---|---|
| Expected names missing | degraded: /ready 503, /health 200 | { state: 'pending', pending: count } |
| All expected names present | Existing up or degraded status | { state: 'current', applied: count } |
| Read rejected, timed out, or over 10,000 rows | down | { 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
);
| Adapter | Application setup |
|---|---|
| Prisma | Create the SQL table and expose a setu_migrations model with id and appliedAt mapped to applied_at. |
| Drizzle | Create the SQL table and add setu_migrations to drizzleTables, with id: text('id').primaryKey() and appliedAt: text('applied_at').notNull(). |
| D1 | Create the SQLite table above, whose appliedAt column matches the repository field directly. |
| MongoDB | No DDL; the default entity collection is sufficient, or map it through collections. |
| DynamoDB | Provision a table with partition key id; map entities.setu_migrations to { table: 'your-table', partitionKey: 'id' }. |
| Cosmos | Provision a container with partition path /id; map containers.setu_migrations to { container: 'your-container', partitionKey: 'id' }. |
| Bigtable | Provision a table and map tables.setu_migrations with its row-key and column-family configuration, as for other entities. |
| Memory | No 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):
| Adapter | Duplicate record rejection | Preserved driver cause |
|---|---|---|
| Memory | DuplicateKeyError | None |
| Drizzle / real PostgreSQL | DuplicateKeyError | Drizzle Error → pg DatabaseError (name: 'error', SQLSTATE 23505) |
| MongoDB replica set | DuplicateKeyError | MongoServerError, code 11000 |
| DynamoDB Local | DuplicateKeyError | ConditionalCheckFailedException |
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
| Option | Type | Default | Description |
|---|---|---|---|
type | 'memory' | 'prisma' | 'drizzle' | 'mongodb' | 'dynamodb' | 'cosmos' | 'bigtable' | 'custom' | 'memory' | Backend adapter. |
name | string | 'default' | Named connection for multi-database use. |
options | per-arm (see type) | — | Adapter-specific configuration. |
readiness | DatabaseReadinessOptions | — | 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 field | Type | Default | Read by |
|---|---|---|---|
logQueries | boolean | false | The database service, so it applies to every adapter including 'custom' — logs entity, operation and monotonic duration. |
prismaClient | unknown | — | Prisma. Required. |
provider | PrismaSqlProvider | derived | Prisma. Names the connector when it cannot be detected; see contains below. |
drizzleInstance | DrizzleDatabaseIdentity | — | Drizzle. Required; created by createDrizzleDatabase(). |
drizzleTables | Record<string, unknown> | — | Drizzle. Required; entity name → real table definition. |
transactionTimeout | number (ms) | 30000 | Prisma only. Raises Prisma’s ~5 s interactive-transaction default, which is too short for a full Unit of Work. Unread by Memory and Drizzle. |
url | string | — | 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:
- 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 asadapter. schema.prismamay no longer carryurl. Prisma 7 answersP1012: The datasource property 'url' is no longer supported in schema files; the connection string moves to aprisma.config.ts.- A non-
publicPostgreSQL schema must be named on the adapter.?schema=…in the URL is read by Prisma Migrate only, so the driver adapter needsnew PrismaPg({ connectionString }, { schema }). Without it every query resolves againstpublicand fails withThe 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 field | Type | Default | Read by |
|---|---|---|---|
url | string | — | The lazy client path. Required unless client is supplied; also supplies database when that option is absent. |
client | IMongoClient | — | connect(). An already-constructed client; when present the lazy import('mongodb') never runs. |
objectIdCtor | IMongoObjectIdCtor | from the lazy load | The _id conversion. Supply it alongside an injected client whose collections use ObjectId keys. |
database | string | from url | The collection resolver. When neither this nor url yields a name, connect() fails at startup naming the option. |
collections | Record<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 field | Type | Default | Read by |
|---|---|---|---|
endpoint | string | — | The lazy client path. Required unless client is supplied. |
key | string | — | The lazy client path. Required unless client is supplied. |
client | ICosmosClient | — | connect(). When present the lazy import('@azure/cosmos') never runs — the route for an Entra ID (managed identity) client. |
database | string | — | Required on both arms. A Cosmos endpoint encodes no database name, so unlike a MongoDB URI there is nothing to fall back to. |
containers | Record<string, CosmosEntityMapping> | {} | Per-entity { container?, primaryKey?, partitionKey? }. An unmapped entity uses its own name and 'id'. |
logQueries | boolean | false | The 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
EntityKeyrecord 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
idis 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 available | Why |
|---|---|
| Cross-container joins | Impossible 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 / aggregation | Absent 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 tokens | Not 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 policy | Outside 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 field | Type | Default | Read by |
|---|---|---|---|
projectId | string | — | The lazy client path. Required unless client is supplied. |
instance | string | — | Required on both arms. A table is addressed as project/instance/table, and neither a client nor a project encodes it. |
apiEndpoint | string | — | The lazy client path. The emulator address; an endpoint alone reaches cbtemulator with no credentials. |
client | IBigtableClient | — | connect(). When present the lazy import('@google-cloud/bigtable') never runs. |
tables | Record<string, BigtableEntityMapping> | {} | Per-entity { table?, rowKey?, columnFamily?, columns?, valueEncoding? }. An unmapped entity uses its own name, ['id'] and cf. |
maxPageFetches | number | 10 | findPage’s fill loop, bounding the re-fetch when a client-side filter empties a raw batch. |
logQueries | boolean | false | The 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 available | Why |
|---|---|
| Any non-key sort or index | Absent 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 scans | Refused 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. |
offset | Absent 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 policies | Outside 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 transactions | Absent 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 joins | Absent 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 connector | contains behaviour |
|---|---|
postgresql / postgres, mysql, sqlserver, cockroachdb | Value escaped and matched literally — their LIKE defaults its escape character to backslash. |
mongodb | Value passed through unchanged — contains compiles to a $regex match, where % and _ are already literal. |
sqlite | Refused 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.
| Behaviour | Memory | Prisma / Drizzle |
|---|---|---|
Unknown select / orderBy column | Refused by name | Refused by name |
Unknown where / filter field | Matches nothing | Refused |
| Duplicate primary key | Refused (a caller-supplied key) | Refused |
Changing a primary key by update | Refused (PrimaryKeyChangeError, 400) | Refused (PrimaryKeyChangeError, 400) |
| Unique constraint | Not enforced — a duplicate value is accepted | Enforced |
| Column types | Not enforced — a string into an Int is stored | Enforced |
| Foreign keys, checks, defaults | Not enforced | Enforced |
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:
| Condition | Thrown as | Served 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) | SerializationConflictError | 409 |
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) | DatabaseUnavailableError | 503 |
node-postgres’s query_timeout fired on a query (the driver’s Query read timeout rejection) | DatabaseTimeoutError | 504 |
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) | DuplicateKeyError | 409 |
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.
| Adapter | The driver’s own call bound | Status |
|---|---|---|
| Drizzle on node-postgres | Set query_timeout and connectionTimeoutMillis on the Pool you pass Drizzle | verified (M113g) |
| Prisma | no 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 |
| MongoDB | MongoClient’s timeoutMS (client-side operation timeout), or a per-operation maxTimeMS | unverified |
| DynamoDB | the AWS SDK v3 NodeHttpHandler requestTimeout | unverified |
| Cosmos DB | connectionPolicy.requestTimeout on the CosmosClient options | unverified |
| Bigtable | the gRPC call deadline (timeout in google-gax call options) | unverified |
| D1 | none the binding exposes; only the platform’s own query limits apply | unverified |
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:
reachable | Status | /ready |
|---|---|---|
true | up | 200 |
false | down | 503 |
'unknown' | degraded | 503 |
'unknown' while the pool is saturated and queries are completing | up | 200 |
| omitted | up | 200 |
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
| Export | Kind |
|---|---|
createDatabaseGrantSource | function |
createDatabaseIdempotencyStore | function |
createDatabaseInboxStore | function |
createDatabaseOutboxStore | function |
createDatabaseRoleSource | function |
createDatabaseTenantDataStore | function |
createDrizzleDatabase | function |
createDrizzleDataSource | function |
createInjectedBigtableLoader | function |
createInjectedDynamoLoader | function |
createLazyBigtableLoader | function |
createLazyDynamoLoader | function |
createMigrationLedger | function |
createPrismaDataSource | function |
DatabasePlugin | function |
decodeCursor | function |
encodeCursor | function |
getDrizzleDatabase | function |
getDrizzleTransaction | function |
keysetPredicate | function |
withIsolationSupport | function |
BaseRepository | class |
BigtableAdapter | class |
BigtableTransactionScopeError | class |
CosmosAdapter | class |
CosmosConcurrentModificationError | class |
CosmosTransactionScopeError | class |
DatabaseService | class |
DatabaseTenantDataStore | class |
DatabaseTimeoutError | class |
DatabaseUnavailableError | class |
DrizzleAdapter | class |
DrizzleRepository | class |
DynamoAdapter | class |
InboxStoreUnavailableError | class |
MemoryAdapter | class |
MigrationLedgerError | class |
MissingKeyColumnError | class |
MongoAdapter | class |
MongoTransactionUnavailableError | class |
OutboxStoreUnavailableError | class |
PrismaAdapter | class |
PrismaRepository | class |
SerializationConflictError | class |
TenantColumnUnresolvedError | class |
TenantStoreStrategyUnsupportedError | class |
TransactionalStoreUnavailableError | class |
UnitOfWork | class |
UnknownPayloadFieldError | class |
UnsupportedFilterOperatorError | class |
UnsupportedIsolationLevelError | class |
UnsupportedMigrationError | class |
UnsupportedQueryFeatureError | class |
UnsupportedRawQueryError | class |
MIGRATION_LEDGER_ENTITY | const |
BigtableAdapterOptionsBase | interface |
BigtableCell | interface |
BigtableClientConfiguration | interface |
BigtableClientLoader | interface |
BigtableDatabaseOptions | interface |
BigtableEntityMapping | interface |
BigtableReadOptions | interface |
BigtableReadRow | interface |
BigtableRowBoundary | interface |
BigtableRowKeyMapping | interface |
BigtableRowRange | interface |
BigtableValueRange | interface |
CosmosAccessCondition | interface |
CosmosAdapterOptionsBase | interface |
CosmosBatchDeleteOperation | interface |
CosmosBatchInsertOperation | interface |
CosmosBatchPatchOperation | interface |
CosmosBatchReplaceOperation | interface |
CosmosBatchResponse | interface |
CosmosContainerDefinition | interface |
CosmosDatabaseOptions | interface |
CosmosEntityMapping | interface |
CosmosFeedResponse | interface |
CosmosItemResponse | interface |
CosmosPatchOperation | interface |
CosmosQueryParameter | interface |
CosmosQuerySpec | interface |
CosmosRequestOptions | interface |
CountOptions | interface |
CursorPayload | interface |
CustomDatabaseOptions | interface |
DatabaseAdapterOptions | interface |
DatabaseConnectionOptions | interface |
DatabaseGrantSourceOptions | interface |
DatabaseIdempotencyStoreOptions | interface |
DatabaseInboxStoreOptions | interface |
DatabaseOutboxStoreOptions | interface |
DatabasePoolCapacity | interface |
DatabaseReadinessOptions | interface |
DatabaseRoleSourceOptions | interface |
DrizzleAdapterOptions | interface |
DrizzleCompositeKeyOptions | interface |
DrizzleDatabase | interface |
DrizzleDatabaseIdentity | interface |
DrizzleDatabaseOptions | interface |
DynamoAdapterOptionsBase | interface |
DynamoAttributeValue | interface |
DynamoClientConfiguration | interface |
DynamoClientLoader | interface |
DynamoConditionExpression | interface |
DynamoDatabaseOptions | interface |
DynamoDeleteItemCommandInput | interface |
DynamoDeleteItemCommandOutput | interface |
DynamoEntityMapping | interface |
DynamoExpressionAttributes | interface |
DynamoGetItemCommandInput | interface |
DynamoGetItemCommandOutput | interface |
DynamoIndexMapping | interface |
DynamoPutItemCommandInput | interface |
DynamoPutItemCommandOutput | interface |
DynamoQueryCommandInput | interface |
DynamoReadCommandInput | interface |
DynamoReadCommandOutput | interface |
DynamoSdkClient | interface |
DynamoSdkCommand | interface |
DynamoSdkModule | interface |
DynamoTransactDelete | interface |
DynamoTransactPut | interface |
DynamoTransactUpdate | interface |
DynamoTransactWriteItem | interface |
DynamoTransactWriteItemsCommandInput | interface |
DynamoUpdateItemCommandInput | interface |
DynamoUpdateItemCommandOutput | interface |
FindOptions | interface |
GrantFields | interface |
IAdapterTransaction | interface |
IBigtableClient | interface |
IBigtableInstance | interface |
IBigtableRow | interface |
IBigtableTable | interface |
ICosmosClient | interface |
ICosmosContainer | interface |
ICosmosDatabase | interface |
ICosmosItem | interface |
ICosmosItems | interface |
ICosmosQueryIterator | interface |
IDatabaseAdapter | interface |
IDatabaseService | interface |
IDataSource | interface |
IDynamoClient | interface |
IMigrationLedger | interface |
IMongoClient | interface |
IMongoCollection | interface |
IMongoCollectionFindOneAndUpdateOptions | interface |
IMongoCursor | interface |
IMongoDatabase | interface |
IMongoObjectId | interface |
IMongoObjectIdCtor | interface |
IMongoSession | interface |
IRepository | interface |
IUnitOfWork | interface |
MemoryDatabaseOptions | interface |
MigrationLedgerOptions | interface |
MongoAdapterOptionsBase | interface |
MongoDatabaseOptions | interface |
MongoEntityMapping | interface |
MongoOptions | interface |
NormalizedQuery | interface |
Page | interface |
PageResult | interface |
PrismaAdapterOptions | interface |
PrismaCompositeKeyOptions | interface |
PrismaDatabaseOptions | interface |
RoleFields | interface |
TransactionOptions | interface |
BigtableAdapterOptions | type |
BigtableFilter | type |
BigtableMutation | type |
BigtableRowData | type |
BigtableValueEncoding | type |
BuiltInDatabaseOptions | type |
CosmosAdapterOptions | type |
CosmosBatchOperation | type |
CosmosPartitionKeyValue | type |
CursorValue | type |
DatabaseAdapterType | type |
DatabasePluginOptions | type |
DataSource | type |
DrizzleTransaction | type |
DrizzleTransactionBridge | type |
DynamoAdapterOptions | type |
DynamoAttributeMap | type |
DynamoCommandConstructor | type |
DynamoDateEncoding | type |
DynamoScanCommandInput | type |
DynamoTransactWriteItemsCommandOutput | type |
EntityKey | type |
FilterComparison | type |
FilterExpression | type |
FilterOperator | type |
MongoAdapterOptions | type |
MongoWriteOptions | type |
OrderDirection | type |
PageOptions | type |
PrismaSqlProvider | type |
SqlJsonDialect | type |
TransactionIsolationLevel | type |
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.
| Backend | Tier-C behavior |
|---|---|
| Memory | Supported in one process; a loser is refused at commit and replays. |
| PostgreSQL / Drizzle | Supported; the claim insert blocks the loser until the winner commits, then the loser replays. Each blocked call holds a connection. |
| PostgreSQL / Prisma | As 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 / Drizzle | Supported with a Promise-aware transaction bridge; one writer at a time, with replay or busy/locked conflict depending on timing. |
| D1 | Supported 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 set | Supported with pre-existing collections; the concurrent loser gets conflict, then replays on retry. |
| DynamoDB | Supported 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 standalone | Refused with mongodb-standalone. |
| Cosmos DB | Refused with cosmos-unsupported: the record cannot share the business partition. |
| Bigtable | Refused 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.
| Adapter | Honoured levels |
|---|---|
| Prisma | PostgreSQL, MySQL, and SQL Server: all four; CockroachDB and SQLite: serializable; MongoDB: none |
| Drizzle | all four only when the application wraps its bridge with withIsolationSupport() |
| Memory | serializable (one process only) |
| MongoDB | none |
| D1, DynamoDB, Cosmos DB, Bigtable | none |
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 source | Write mechanism |
|---|---|
| Memory, outside transactions | Synchronous key lookup, predicate check and mutation |
| Prisma | Native update/delete, key conjoined with AND: [where]; P2025 means no match |
| Drizzle | Key and predicate joined with and(...), native RETURNING |
| MongoDB | $and: [idFilter, whereFilter], findOneAndUpdate / deleteOne |
| D1 | Bound UPDATE / DELETE, WHERE key AND predicate, RETURNING |
| DynamoDB | Native ConditionExpression, key existence AND aliased equality fields |
| Cosmos DB | Read and check, then _etag IfMatch guarded replace / delete; retry 412 at most three rounds |
| Bigtable | CheckAndMutateRow 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.
| Symbol | Kind | Purpose |
|---|---|---|
EntityKey | type | A 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. |
PageResult | interface | { rows, nextCursor } returned by IDataSource.findPage. nextCursor is null exactly when the page is the last. |
CursorPayload | interface | The decoded contents of a cursor: orderedValues, keyValues, and a sortFingerprint. |
CursorValue | type | A scalar a cursor can carry: string | number | Date. Dates survive the round trip as Date, not as ISO strings. |
encodeCursor | function | Encodes a CursorPayload as an opaque base64url token. |
decodeCursor | function | Decodes a token, or returns null when it is malformed — it never throws, because the token is untrusted caller input. |
keysetPredicate | function | Builds the “row after this one” comparison as a portable FilterExpression. |
sortFingerprint | function | The stable fingerprint embedded in every cursor; optional scope binds entity and key columns (since 0.11.0). |
mintNextCursor | function | Mints 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. |
resolveKeysetSort | function | The 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.
| Adapter | Translation |
|---|---|
| Memory | walks the document |
| Mongo | the native dotted key (address.city) |
| Prisma | the JSON path filter — an array on PostgreSQL/CockroachDB, $.a.b on MySQL |
| Drizzle | per dialect: PostgreSQL #>>, MySQL JSON_UNQUOTE(JSON_EXTRACT(…)), SQLite json_extract |
| D1 | refused 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:
inis expanded, not translated. Neither Prisma’s JSON path filter nor any SQL dialect offers a path membership operator, soinbecomes anORof 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
numbercasts back. PostgreSQL’s#>>and MySQL’sJSON_UNQUOTEare text-valued while SQLite’sjson_extractpreserves the JSON type, so without normalising, one filter would mean two different things. Casting back forgt/gte/lt/lteon a number is what makesage > 9match30— 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
| Export | Kind |
|---|---|
DatabasePlugin | factory |
DatabaseService | class |
BaseRepository, UnitOfWork | classes |
MemoryAdapter, PrismaAdapter, DrizzleAdapter, MongoAdapter, DynamoAdapter | classes |
PrismaRepository, DrizzleRepository | classes |
UnsupportedFilterOperatorError, UnsupportedIsolationLevelError, UnsupportedMigrationError, UnsupportedRawQueryError, UnsupportedQueryFeatureError | classes |
SerializationConflictError, DatabaseUnavailableError, DatabaseTimeoutError, UnknownPayloadFieldError, MissingKeyColumnError, TenantColumnUnresolvedError | classes |
PageOptions, Page | types |
PrismaCompositeKeyOptions, DrizzleCompositeKeyOptions | types |
EntityKey, PageResult, CursorPayload, CursorValue (re-exported from common) | types |
encodeCursor, decodeCursor, keysetPredicate (re-exported from common) | functions |
MongoTransactionUnavailableError | class |
CosmosAdapter | class |
CosmosTransactionScopeError, CosmosConcurrentModificationError | classes |
CosmosDatabaseOptions, CosmosAdapterOptions, CosmosAdapterOptionsBase, CosmosEntityMapping | types |
ICosmosClient, ICosmosDatabase, ICosmosContainer, ICosmosItems, ICosmosItem, ICosmosQueryIterator | interfaces (Cosmos injection seam) |
CosmosQuerySpec, CosmosQueryParameter, CosmosPartitionKeyValue | types (Cosmos query seam) |
CosmosItemResponse, CosmosFeedResponse, CosmosRequestOptions, CosmosAccessCondition, CosmosPatchOperation, CosmosBatchOperation, CosmosBatchInsertOperation, CosmosBatchReplaceOperation, CosmosBatchPatchOperation, CosmosBatchDeleteOperation, CosmosBatchResponse, CosmosContainerDefinition | interfaces (Cosmos operation seam) |
PrismaSqlProvider | type |
SqlJsonDialect | type |
createPrismaDataSource, createDrizzleDataSource, createDrizzleDatabase, getDrizzleDatabase, getDrizzleTransaction, withIsolationSupport | functions |
DrizzleDatabase, DrizzleDatabaseIdentity, DrizzleTransaction, DrizzleTransactionBridge | types |
IDatabaseService, IRepository, IUnitOfWork | interfaces |
DatabasePluginOptions, BuiltInDatabaseOptions, CustomDatabaseOptions, DatabaseConnectionOptions | types |
MemoryDatabaseOptions, PrismaDatabaseOptions, DrizzleDatabaseOptions, MongoDatabaseOptions, DynamoDatabaseOptions | interfaces |
MongoAdapterOptions (union of two arms), MongoAdapterOptionsBase, MongoEntityMapping | types |
PrismaAdapterOptions, DrizzleAdapterOptions | interfaces |
DatabaseAdapterType, DatabaseAdapterOptions | types |
FindOptions, CountOptions, OrderDirection, FilterOperator, FilterComparison, FilterExpression | types |
IDatabaseAdapter, IAdapterTransaction, IDataSource, NormalizedQuery, TransactionIsolationLevel, TransactionOptions | re-exports from common |
DataSource | deprecated alias of IDataSource |
IMongoClient, IMongoDatabase, IMongoObjectId, IMongoObjectIdCtor, IMongoSession | interfaces (Mongo injection seam) |
IMongoCollection, IMongoCursor, IMongoCollectionFindOneAndUpdateOptions, MongoOptions, MongoWriteOptions | interfaces (Mongo collection seam) |
DynamoAdapterOptions (union of two arms), DynamoAdapterOptionsBase, DynamoEntityMapping, DynamoDateEncoding, DynamoIndexMapping | types |
IDynamoClient, DynamoAttributeValue, DynamoAttributeMap, DynamoExpressionAttributes, DynamoConditionExpression, and the Dynamo*CommandInput/Output/Transact* command-shape closure | interfaces (DynamoDB client seam) |
DynamoClientConfiguration, DynamoClientLoader, DynamoCommandConstructor, DynamoSdkClient, DynamoSdkCommand, DynamoSdkModule | types (DynamoDB SDK seam) |
createInjectedDynamoLoader, createLazyDynamoLoader | functions |
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' });
| Export | Kind | Notes |
|---|---|---|
createDatabaseOutboxStore | function | Resolves CAPABILITIES.DATABASE, or database.<name> for database: '<name>' ('default' is the bare token). Throws TypeError at the call for an empty entity |
DatabaseOutboxStoreOptions | interface | entity? (default 'Outbox'), database? — must be the database the caller’s unit of work belongs to, or the row is never relayed by this store |
OutboxStoreUnavailableError | class | Rejected 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 anotherkindasmissing. - Conditional transitions.
claim(id, { claimVersion, leaseUntil })requireskind,pendingand the read version, increments it by one and sets the lease.markSent/markFailurerequire the held version, includingdeleteNowthroughdeleteWhere; a pending version miss isclaim-lost. Status misses arenot-pendingornot-failed; another kind ismissing.markInvalid(id, now)uses only kind/status, writesfailed/invalid-row, clears the lease and preserves attempts.markFailureandrelease('retry')clear the lease; release preserves the version. Unexplained misses retry at most three rounds. Native conditional writes are required; no fallback remains.sentByis only an operator diagnostic, absent from outcomes. - Columns. The row is the record’s own fields; an absent optional field is written as
nulland read back as absent.failedKeysselects onlytenantIdandorderingKey;purgedeletessentthendiscardedrows withsettledAt < before, at mostlimitper status. verify()runsscanPending(undefined, 1), thentransaction(uow => uow.getRepository(entity).findAll({ where: { kind, status: 'pending' }, limit: 1 })), thenupdateWhere('setu-outbox-claim-probe', { kind, status: 'pending', claimVersion: -1 }, { claimVersion: 0 })anddeleteWherewith the same id and predicate — both, becauseretainSentMs: 0deletes a sent row conditionally after it is published. No row can carry version-1(appendwrites0and a claim only increments), so neither probe writes, even to a row appended under the probe id. The old SQL schema withoutclaim_versionis refused at the first step, the relay query. Aconditional-writerefusal isconditional-writes-unsupported. AnUnsupportedQueryFeatureErrorfrombigtableis'bigtable'; one fromdynamodbfororderByis'dynamodb-index'(a GSI{ partitionKey: 'status', sortKey: 'position' }, projectionALL, is required); a cause chain carryingcode: 20withcodeName: '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' });
| Export | Kind | Notes |
|---|---|---|
createDatabaseInboxStore | function | Resolves CAPABILITIES.DATABASE (or database.<name>) and CAPABILITIES.RUNTIME. Throws TypeError at the call for an empty entity |
DatabaseInboxStoreOptions | interface | entity? (default 'Inbox'), database? — must be the database the handlers write to |
InboxStoreUnavailableError | class | Rejected by verify(); entity, reason ('cosmos-unsupported', 'bigtable-unsupported', 'mongodb-standalone', 'transaction-scope', 'entity-unavailable'), the adapter error as cause when there is one |
runopens 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’sDuplicateKeyError, 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 anotherkindis missing. - Conditional writes.
recordFailurecompareskindand the readattemptsvalue in the write, retrying misses at most five rounds;releaserequireskindandstatus: '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, runspurge(0, 1), then a transaction that creates two probe rows and always rolls back: aBigtableTransactionScopeErrorat the second row is'transaction-scope', a cause chain carryingcode: 20withcodeName: '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' }),
},
});
| Option | Grant source (DatabaseGrantSourceOptions) | Role source (DatabaseRoleSourceOptions) |
|---|---|---|
entity | Required: one row per grant | Required: one row per (scope, role, permission) |
fields | GrantFields — subject / role / scopeType / scopeId (those defaults) | RoleFields — scopeType / scopeId / role / permission (those defaults) |
name | Log name (default 'database-grants') | Log name (default 'database-roles') |
limit | Most matching rows, 1–10 001 (default 10 001); more refuses the question | Most 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');
// ...
});