first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
# @suaveplan/api-db-adapter — bridge from @suaveplan/db engine adapters to the plugin DatabaseLike surface. Provides AdminDatabaseLike + TenantScopedDatabaseLike factories, a per-engine migration runner, the db.unsafe-queries capability gate, an open DatabaseEngineRegistry, and a worker-proxy variant for the worker host. Both direct + proxy surfaces pass the same conformance suite.
Stage-4 bridge from the `@suaveplan/db` engine adapters to the
plugin `AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces exposed by
`@suaveplan/api-types/db`.
The package contains five orthogonal pieces:
1. `options` — Zod-validated `DbAdapterOptions` (`engine`, `url`, `pool?`,
`tenantColumn`, `migrationsDir?`, `autoInjectInserts`).
2. `engine-registry` — open registry of engine factories. The package
itself ships no engines pre-installed; host code (or a downstream
adapter package) calls `registerEngine("sqlite" | "postgres" |
"surrealdb" | <merged>, factory)` once at boot. The shape keeps the
package universally importable.
3. `tenant-scoped` — the SQL rewriter that injects `WHERE tenant_id = ?`
on every SELECT/UPDATE/DELETE, verifies / auto-injects the tenant
column on INSERT, and rejects DDL or unsupported SQL with
`DB_ADAPTER.TENANT_SCOPE_VIOLATION`. Strict / lenient INSERT mode is
`autoInjectInserts` on the options.
4. `migrations` — per-engine migration runner consuming a directory of
`*.sql` files; tracks applied versions in `__plugin_migrations` and
is idempotent on re-run. Failed migrations roll back where the engine
supports transactions.
5. `unsafe` — the `db.unsafe` escape hatch, gated on the
`db.unsafe-queries` capability flag in the plugin manifest. Every
permitted call increments `plugin.unsafe_query_total{plugin}`.
The package also ships a worker-side counterpart (`createDbAdapterWorkerProxy`)
that wires the worker-side `api-db-worker-proxy` client into the same surface
so direct + proxy variants are interchangeable in plugin code.
<!-- begin:auto-docs -->
## Documentation tree
- [engine-registry](./src/engine-registry/engine-registry.md) — The engine registry is the extension point that lets `api-db-adapter` stay universally importable while still supportin…
- [engine-registry](./src/engine-registry/engine-registry.md) — The engine registry is the extension point that lets `api-db-adapter` stay universally importable while still supportin…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every public failure code raised by `@suaveplan/api-db-adapter`. Codes ar…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every public failure code raised by `@suaveplan/api-db-adapter`. Codes ar…
- [factory](./src/factory/create-db-adapter.md) — `createDbAdapter` is the public entry point for host code that wants to wire the `@suaveplan/db` engine adapter…
- [create-db-adapter](./src/factory/create-db-adapter.md) — `createDbAdapter` is the public entry point for host code that wants to wire the `@suaveplan/db` engine adapter…
- [migrations](./src/migrations/run-migrations.md) — `runMigrations` applies a plugin's pending migrations from a directory of `*.sql` files, recording applied versions in…
- [run-migrations](./src/migrations/run-migrations.md) — `runMigrations` applies a plugin's pending migrations from a directory of `*.sql` files, recording applied versions in…
- [options](./src/options/db-adapter-options.md) — `DbAdapterOptions` is the single config object accepted by every public factory in this package — `createDbAdapter`, `c…
- [db-adapter-options](./src/options/db-adapter-options.md) — `DbAdapterOptions` is the single config object accepted by every public factory in this package — `createDbAdapter`, `c…
- [proxy](./src/proxy/create-db-adapter-worker-proxy.md) — `createDbAdapterWorkerProxy` is the worker-side counterpart to `createDbAdapter`. Inside an `api-worker-host` worker, p…
- [create-db-adapter-worker-proxy](./src/proxy/create-db-adapter-worker-proxy.md) — `createDbAdapterWorkerProxy` is the worker-side counterpart to `createDbAdapter`. Inside an `api-worker-host` worker, p…
- [tenant-scoped](./src/tenant-scoped/create-tenant-scoped-wrapper.md) — This module is the load-bearing safety boundary between a plugin route handler and the underlying admin database surfac…
- [create-tenant-scoped-wrapper](./src/tenant-scoped/create-tenant-scoped-wrapper.md) — This module is the load-bearing safety boundary between a plugin route handler and the underlying admin database surfac…
- [unsafe](./src/unsafe/unsafe-query-gate.md) — `createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant- scoped surface. The gate is the only sanctio…
- [unsafe-query-gate](./src/unsafe/unsafe-query-gate.md) — `createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant- scoped surface. The gate is the only sanctio…
<!-- end:auto-docs -->
## Quick start
```ts
import { createDbAdapter } from "@suaveplan/api-db-adapter/adapter";
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
import { runMigrations } from "@suaveplan/api-db-adapter/migrations";
registerEngine("sqlite", async (opts) => myBunSqliteFactory(opts));
const db = await createDbAdapter({ engine: "sqlite", url: ":memory:" });
await runMigrations({
plugin: "audit",
capabilities: [],
admin: db.admin,
migrationsDir: "./db/migrations",
fs: nodeFsReader,
});
const scoped = db.scoped("acme" as TenantId);
const rows = await scoped.query<User[]>("SELECT * FROM users");
// actual SQL: SELECT * FROM users WHERE tenant_id = ?
// bound: ["acme"]
```
## Tenant-scope semantics
The wrapper rewrites SQL based on the leading keyword (case-insensitive):
| Shape | Rewrite |
|---|---|
| `SELECT ... [WHERE ...]` | append `WHERE`/`AND tenant_id = ?` before any ORDER BY / LIMIT / RETURNING / GROUP BY tail |
| `UPDATE ... [WHERE ...]` | same |
| `DELETE ... [WHERE ...]` | same |
| `INSERT INTO t (cols) VALUES (vals)` w/o `tenant_id` and `autoInjectInserts: true` | inject column + value |
| same w/o `tenant_id` and `autoInjectInserts: false` | throw `TENANT_SCOPE_VIOLATION` |
| `INSERT ...` w/ conflicting `tenant_id` (literal or bound) | throw `TENANT_SCOPE_VIOLATION` |
| anything else (DDL, INSERT...SELECT, multi-row VALUES, record-shaped params) | throw `TENANT_SCOPE_VIOLATION` — use a migration or `db.unsafe` |
## `db.unsafe` escape hatch
`createUnsafeGate({ plugin, capabilities, admin })` returns an
`UnsafeQueryApi` (`query`, `execute`). The capability check is array
membership on `db.unsafe-queries`; absent → throw
`DB_ADAPTER.UNSAFE_NOT_PERMITTED`. Permitted calls increment
`plugin.unsafe_query_total{plugin}`.
## Migration directory layout
Files are read from `migrationsDir` and applied lexicographically. The
recommended naming convention is `NNN_<short-description>.sql`:
```
db/migrations/
001_init.sql
002_add_invoices.sql
003_index_invoices_on_org.sql
```
The runner records each applied file in `__plugin_migrations(plugin,
version, applied_at)` and is idempotent — re-running with no new files is
a no-op.
## Error codes
- `DB_ADAPTER.TENANT_SCOPE_VIOLATION` — scoped wrapper saw conflicting
tenant value, DDL, or unsupported SQL shape.
- `DB_ADAPTER.UNSAFE_NOT_PERMITTED` — `db.unsafe.*` invoked without
`db.unsafe-queries` capability.
- `DB_ADAPTER.MIGRATION_FAILED` — migration body threw; rolled back where
supported.
- `DB_ADAPTER.ENGINE_UNSUPPORTED` — `engine` value has no registered
factory.
- `DB_ADAPTER.INVALID_OPTIONS` — Zod validation failed; issues attached
to `error.context.issues`.

View File

@@ -0,0 +1,88 @@
# `engine-registry` — open registry mapping engine names to factories
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The engine registry is the extension point that lets `api-db-adapter` stay
universally importable while still supporting per-engine concrete adapters
(SQLite, Postgres, SurrealDB, plus declaration-merged third-party engines).
The package itself ships zero engines pre-installed. Host code or the
downstream adapter package calls `registerEngine(name, factory)` once at
process boot; every subsequent `createDbAdapter({ engine: name, ... })` call
delegates to that factory.
This shape avoids three failure modes: pulling `bun:sqlite` into a browser
bundle, pulling `pg` into a worker, and pulling `surrealdb.js` into a CLI.
## Features
- `registerEngine(name, factory)` — register or replace a factory.
- `unregisterEngine(name)` — primarily for test isolation.
- `getEngineFactory(name)` — read-only lookup.
- `listEngines()` — snapshot of all currently-registered names.
- `buildEngineAdapter(options)` — high-level: look up + invoke + return the
constructed `AdminDatabaseLike`. Throws `DB_ADAPTER.ENGINE_UNSUPPORTED`
if the engine name has no factory.
## Usage: Basic
```ts
import {
registerEngine,
buildEngineAdapter,
} from "@suaveplan/api-db-adapter/engine-registry";
import { SqliteAdapter } from "@suaveplan/db-sqlite";
registerEngine("sqlite", async (options) => {
const adapter = new SqliteAdapter({ filename: options.url });
await adapter.connect();
return adaptToAdminDatabaseLike(adapter, "sqlite");
});
const admin = await buildEngineAdapter({
engine: "sqlite",
url: ":memory:",
tenantColumn: "tenant_id",
autoInjectInserts: true,
});
```
## Usage: Advanced
```ts
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
// declaration-merge a new engine
declare module "@suaveplan/api-types/db" {
interface DatabaseEngineRegistry {
readonly cockroach: true;
}
}
registerEngine("cockroach", async (options) => {
// build a CockroachDB-backed AdminDatabaseLike from options
return buildCockroachAdmin(options);
});
```
## API Reference
- `EngineFactory: (options: DbAdapterOptions) => Promise<AdminDatabaseLike>`.
- `registerEngine(name, factory): void` — register/replace.
- `unregisterEngine(name): boolean` — remove; returns `true` if removed.
- `getEngineFactory(name): EngineFactory | undefined`.
- `listEngines(): readonly string[]`.
- `buildEngineAdapter(options): Promise<AdminDatabaseLike>` — throws
`DbAdapterError` with code `DB_ADAPTER.ENGINE_UNSUPPORTED` if the engine
name was never registered.
## Implementation Notes
The registry is a process-wide singleton kept in module scope. That is the
correct shape for a sealed-once-at-boot capability map. Tests use
`unregisterEngine` for isolation; concurrent registration with the same name
is well-defined (last writer wins). The error thrown on lookup miss includes
`details.known: string[]` so operator log lines surface the typo immediately.

View File

@@ -0,0 +1,77 @@
# `error-codes` — `DB_ADAPTER.*` registry + `DbAdapterError`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single source of truth for every public failure code raised
by `@suaveplan/api-db-adapter`. Codes are namespaced under `DB_ADAPTER.*` and
exposed through both a `const` registry (`DB_ADAPTER_CODES`) and a typed
`SuaveplanError` subclass (`DbAdapterError`).
Consumers branch on the literal value at runtime — never on the message text —
which means the wire format of every error is stable across releases. The
registry is consumed by the migration runner, the tenant-scoped wrapper, the
options validator, the engine registry, and the `db.unsafe-queries` capability
gate.
## Features
- Five stable codes covering every public failure path: `TENANT_SCOPE_VIOLATION`,
`UNSAFE_NOT_PERMITTED`, `MIGRATION_FAILED`, `ENGINE_UNSUPPORTED`,
`INVALID_OPTIONS`.
- Single `DbAdapterError` class extending `SuaveplanError` — discriminable by
`instanceof` or by code string.
- `SuaveplanErrorOptions.cause` is honoured, so engine-side throws (e.g. a
Postgres protocol error during migration) are preserved on the cause chain.
- Compatible with the standard `SuaveplanError` wire format — no per-package
deviations.
## Usage: Basic
```ts
import {
DB_ADAPTER_CODES,
DbAdapterError,
} from "@suaveplan/api-db-adapter/error-codes";
throw new DbAdapterError(
DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
"plugin 'audit' does not declare db.unsafe-queries"
);
```
## Usage: Advanced
```ts
import {
DB_ADAPTER_CODES,
DbAdapterError,
} from "@suaveplan/api-db-adapter/error-codes";
try {
await runMigrations(plugin, capabilities);
} catch (cause) {
throw new DbAdapterError(
DB_ADAPTER_CODES.MIGRATION_FAILED,
`migration ${cause.file} failed`,
{ cause, context: { file: cause.file } }
);
}
```
## API Reference
- `DB_ADAPTER_CODES` — `const` registry of the five codes.
- `DbAdapterCode` — union of all registry values.
- `DbAdapterError` — subclass of `SuaveplanError<DbAdapterCode>`.
## Implementation Notes
The codes are declared via `defineCodes("DB_ADAPTER", [...])` which produces a
frozen mapping `{ TENANT_SCOPE_VIOLATION: "DB_ADAPTER.TENANT_SCOPE_VIOLATION",
... }`. Comparisons against the registry value never drift even if a future
change reorders the input array because the keys are spelled identically to the
prefixed value's tail.

View File

@@ -0,0 +1,84 @@
# `factory/create-db-adapter` — direct-surface adapter factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDbAdapter` is the public entry point for host code that wants to wire
the `@suaveplan/db` engine adapters to the plugin
`AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces. It composes three
pieces from this package — the options validator, the engine registry, and
the tenant-scope wrapper — into a single callable that delegates engine
identity to the open `DatabaseEngineRegistry`.
The result object exposes:
- `admin` — the unscoped `AdminDatabaseLike` (used by migrations and admin
endpoints).
- `scoped(tenantId)` — produces a fresh `TenantScopedDatabaseLike` wrapper
over the same admin connection. The wrapper is stateless beyond the
rewrite contract, so one admin connection can back many tenant scopes
concurrently.
- `close()` — closes the underlying admin connection.
## Features
- Options are parsed once at factory time; the rest of the API is typed
against the *post-parse* `DbAdapterOptions`.
- Engine lookup is delegated to `buildEngineAdapter`, so any
declaration-merged engine works automatically.
- Tenant scoping config (`tenantColumn`, `autoInjectInserts`) flows from
the parsed options into every `scoped()` call — no per-call repetition.
## Usage: Basic
```ts
import { createDbAdapter } from "@suaveplan/api-db-adapter/adapter";
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
registerEngine("sqlite", buildSqliteEngineFactory());
const db = await createDbAdapter({ engine: "sqlite", url: ":memory:" });
const admin = db.admin;
const acme = db.scoped("acme" as TenantId);
const rows = await acme.query("SELECT * FROM users");
await db.close();
```
## Usage: Advanced
```ts
const db = await createDbAdapter({
engine: "postgres",
url: "postgres://app@db.local/app",
tenantColumn: "org_id",
autoInjectInserts: false,
pool: { min: 2, max: 16, idleMs: 60_000 },
});
const tx = await db.scoped(tenantId).transaction!(async (tx) => {
await tx.execute(
"INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)",
["inv_1", tenantId, 100]
);
return await tx.query<{ id: string }[]>("SELECT id FROM invoices");
});
```
## API Reference
- `createDbAdapter(input: DbAdapterOptionsInput): Promise<DbAdapter>`.
- `DbAdapter.admin: AdminDatabaseLike`.
- `DbAdapter.scoped(tenantId: TenantId): TenantScopedDatabaseLike`.
- `DbAdapter.close(): Promise<void>`.
## Implementation Notes
The factory does NOT register engines on import — it expects the host or a
downstream adapter package to populate the registry before the first call.
This shape keeps `@suaveplan/api-db-adapter` universally importable and
prevents engine deps from leaking into bundles that never use them. Errors
surface synchronously where possible (options validation, engine lookup);
engine handshake errors arrive as the promise rejection.

View File

@@ -0,0 +1,103 @@
# `migrations/run-migrations` — per-engine migration runner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runMigrations` applies a plugin's pending migrations from a directory of
`*.sql` files, recording applied versions in the engine-side
`__plugin_migrations(plugin, version, applied_at)` table. It is the single
entry point used by `installing` step of the plugin lifecycle FSM; the
plugin host calls it once per `(plugin, engine)` pair during boot.
The runner is engine-neutral: it issues only standard SQL (`CREATE TABLE
IF NOT EXISTS`, `SELECT`, `INSERT`, plus the migration body). When the
underlying adapter exposes `transaction`, each migration is applied
inside an atomic transaction; on failure the transaction rolls back and
the row in `__plugin_migrations` is not written.
## Features
- Lexicographic file ordering (`001_init.sql` before `002_*.sql`).
- Idempotent re-run — applied versions are tracked in
`__plugin_migrations`; a second call with the same file set applies
zero migrations.
- Per-migration `transaction { body + INSERT }` for engines that support
transactions; flat `execute + execute` fall-back for those that
don't (e.g. some Surreal modes).
- OTel instruments: `api.db.migrations_applied_total{plugin}` counter +
`api.db.migration_duration_ms{plugin}` histogram.
- Injectable `fs: MigrationsFsReader` — tests use an in-memory map,
production wires `node:fs/promises`.
- On any failure: `DB_ADAPTER.MIGRATION_FAILED` with cause + details
including the failing file name.
## Usage: Basic
```ts
import { runMigrations } from "@suaveplan/api-db-adapter/migrations";
import { promises as fs } from "node:fs";
const result = await runMigrations({
plugin: "audit",
capabilities: [],
admin,
migrationsDir: "./db/migrations",
fs: {
readdir: (dir) => fs.readdir(dir),
readFile: (path) => fs.readFile(path, "utf8"),
},
});
// result.applied = ["001_init.sql", "002_add_invoices.sql"]
// result.skipped = [] // first run
```
## Usage: Advanced
```ts
const memFs = {
readdir: async () => ["001.sql", "002.sql"],
readFile: async (path: string) =>
path.endsWith("001.sql")
? "CREATE TABLE users(id TEXT)"
: "ALTER TABLE users ADD COLUMN email TEXT",
};
const first = await runMigrations({
plugin: "user",
capabilities: [],
admin,
migrationsDir: "/dev/null",
fs: memFs,
});
// first.applied.length === 2
const second = await runMigrations({
plugin: "user",
capabilities: [],
admin,
migrationsDir: "/dev/null",
fs: memFs,
});
// second.applied.length === 0, second.skipped.length === 2
```
## API Reference
- `runMigrations(options): Promise<RunMigrationsResult>`.
- `RunMigrationsOptions` — `{ plugin, capabilities, admin, migrationsDir, fs }`.
- `RunMigrationsResult` — `{ applied: string[]; skipped: string[] }`.
- `MigrationsFsReader` — `{ readdir(dir); readFile(path) }`.
- `getMigrationInstruments() / resetMigrationInstruments()`.
## Implementation Notes
The runner intentionally does not parse SQL inside the migration body — that
is the engine's job. Failed migrations rely on `transaction` for atomic
rollback; on engines without transactions, partial state is possible if the
body succeeds but the `INSERT` into `__plugin_migrations` fails. That is
out of scope for the runner and the responsibility of operator runbooks
(`Surrealdb` mostly). The `applied_at` column is an ISO-8601 timestamp,
matching the `MigrationCursor.appliedAt` shape on `@suaveplan/api-types/db`.

View File

@@ -0,0 +1,76 @@
# `db-adapter-options` — Zod-validated configuration for `createDbAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DbAdapterOptions` is the single config object accepted by every public factory
in this package — `createDbAdapter`, `createDbAdapterWorkerProxy`, and
`runMigrations` all derive from it. The schema is Zod-driven so that host code
catches configuration mistakes at construction time, before any TCP socket is
opened.
The schema is intentionally narrow: engine, url, optional pool tuning, the
tenant column name, an optional migrations directory, and a strict-mode flag
that decides whether the tenant-scoped wrapper rejects or auto-injects `INSERT`
payloads.
## Features
- Zod schema with sensible defaults (`tenantColumn = "tenant_id"`,
`autoInjectInserts = true`, pool max `10`, idle `30_000` ms).
- `parseDbAdapterOptions(input)` — convenience wrapper that converts every Zod
failure into a typed `DbAdapterError` with code `INVALID_OPTIONS`. Issues
are surfaced through `error.context.issues`.
- `engine` is `z.string().min(1)` — the *literal value* check happens against
the open `DatabaseEngineRegistry` so declaration-merged engines pass.
- `pool` is an optional sub-schema with its own defaults; absent values are
forwarded as `undefined` to the engine layer rather than guessed.
## Usage: Basic
```ts
import { parseDbAdapterOptions } from "@suaveplan/api-db-adapter/options";
const options = parseDbAdapterOptions({
engine: "sqlite",
url: ":memory:",
});
// options.tenantColumn === "tenant_id"
// options.autoInjectInserts === true
```
## Usage: Advanced
```ts
import { parseDbAdapterOptions } from "@suaveplan/api-db-adapter/options";
const options = parseDbAdapterOptions({
engine: "postgres",
url: "postgres://app@db.local:5432/app",
tenantColumn: "org_id",
autoInjectInserts: false,
pool: { min: 2, max: 16, idleMs: 60_000 },
migrationsDir: "./db/migrations",
});
```
## API Reference
- `DbAdapterOptionsSchema` — top-level Zod schema.
- `DbAdapterPoolSchema` — pool sub-schema with `min`, `max`, `idleMs`.
- `DbAdapterOptions` — inferred output type (post-parse).
- `DbAdapterOptionsInput` — inferred input type (pre-parse).
- `parseDbAdapterOptions(input): DbAdapterOptions` — throws `DbAdapterError`
with code `DB_ADAPTER.INVALID_OPTIONS` on failure.
## Implementation Notes
The defaults are applied via `.default(...)` on each field so the output type is
non-optional after parsing. Pool fields have their own defaults so `pool: {}`
parses to the full default block; total absence (`pool` undefined) is preserved
because it lets the engine layer pick its own defaults. Engine identity is
validated separately by the engine registry so this module stays decoupled from
the concrete `DatabaseEngineRegistry` keys.

View File

@@ -0,0 +1,81 @@
# `proxy/create-db-adapter-worker-proxy` — worker-side adapter factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createDbAdapterWorkerProxy` is the worker-side counterpart to
`createDbAdapter`. Inside an `api-worker-host` worker, plugin code receives a
proxy that looks identical to the direct surface but marshals every call
across an `@suaveplan/api-db-worker-proxy` `MessagePort`. The host side
still owns the real engine connection and the tenant-scope rewriter; this
package is the worker-side facade.
Cursor results survive the boundary as `ReadableStream` (transferable per
the worker-rpc layer), so a 10_000-row query streams row-by-row without
buffering. The tenant-scoped facade is structurally identical to the
direct-surface one because the host's `wrapTenantScoped` already maps
`AdminDatabaseLike` onto `TenantScopedDatabaseLike` without further SQL
rewriting (rewriting happens main-side).
## Features
- Identical public shape to `DbAdapter` for parity: `admin`, `scoped()`,
`close()`.
- Built on `createDbProxyClient` from `api-db-worker-proxy/worker-side`,
which manages RPC framing, cursor streaming, and stream cancellation.
- Engine identity is supplied synchronously at construction (it would
require a round-trip otherwise).
## Usage: Basic
```ts
import { createDbAdapterWorkerProxy } from "@suaveplan/api-db-adapter/proxy";
const proxy = createDbAdapterWorkerProxy({
port: workerPort,
engine: "sqlite",
});
const rows = await proxy.scoped("acme" as TenantId).query("SELECT * FROM users");
proxy.close();
```
## Usage: Advanced
```ts
import { createDbProxyHarness } from "@suaveplan/api-db-worker-proxy/testing";
import { createDbAdapterWorkerProxy } from "@suaveplan/api-db-adapter/proxy";
const harness = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
const proxy = createDbAdapterWorkerProxy({
port: harness.ports.clientPort,
engine: harness.db.engine,
});
const rows = await proxy.admin.query("SELECT *");
proxy.close();
} finally {
harness.close();
}
```
## API Reference
- `createDbAdapterWorkerProxy(options): DbAdapterWorkerProxy`.
- `DbAdapterWorkerProxyOptions` — `{ port, engine, clientOptions? }`.
- `DbAdapterWorkerProxy.admin: DbProxyClient` (admin + `cursor()`).
- `DbAdapterWorkerProxy.scoped(tenantId): TenantScopedDatabaseLike`.
- `DbAdapterWorkerProxy.close(): void`.
## Implementation Notes
The proxy intentionally exposes the underlying `DbProxyClient` (extends
`AdminDatabaseLike`) as `admin` so cursor streaming is reachable to
consumers that need it. Tenant-scope rewriting is the main side's job; this
facade only injects the `tenantId` brand into the worker-side type so route
handlers cannot accidentally pass an admin reference where a scoped one is
expected. `close()` calls `client.dispose()` which rejects in-flight calls
with `DB_PROXY.PROXY_DISPOSED`.

View File

@@ -0,0 +1,91 @@
# `tenant-scoped/create-tenant-scoped-wrapper` — SQL rewriter for row-level tenancy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the load-bearing safety boundary between a plugin route handler
and the underlying admin database surface. Every SELECT/UPDATE/DELETE that the
route handler issues is rewritten in flight to carry a `WHERE tenant_id = ?`
predicate; every INSERT either carries the tenant column or is rejected (or
auto-injected, configurable). The contract is enforced *at the wrapper*, so a
route handler literally cannot read another tenant's rows even if the plugin
author is malicious.
The wrapper does not perform full SQL parsing; it performs a fast,
case-insensitive lexical pass that handles the shapes a route handler
realistically writes (simple FROM table, optional WHERE, ORDER BY, LIMIT,
RETURNING). Anything beyond — multi-row INSERT, INSERT...SELECT, DDL, named
params — is rejected with `TENANT_SCOPE_VIOLATION`, forcing the plugin author
to either move the statement into a migration or declare the
`db.unsafe-queries` capability.
## Features
- Auto-injects `WHERE tenant_id = ?` (or `AND tenant_id = ?` when WHERE
already exists) on SELECT/UPDATE/DELETE. Tail clauses (ORDER BY, LIMIT,
RETURNING, GROUP BY) are preserved.
- INSERT handling: detect tenant column; if absent and `autoInjectInserts:
true`, rewrite to include it; if absent and `false`, reject; if present
with a conflicting value (literal or bound), reject.
- Transactions: every `tx.query` and `tx.execute` inside the transaction
callback is rewritten identically to top-level calls.
- Prepared statements: rewriting happens at execute time (so the bound
params are visible), falling back to a non-prepared execute on the
admin surface.
- Pure-function `rewriters` export for direct testing.
## Usage: Basic
```ts
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
const scoped = createTenantScopedWrapper(admin, "acme" as TenantId);
const rows = await scoped.query<User[]>("SELECT * FROM users");
// executed SQL: SELECT * FROM users WHERE tenant_id = ?
// bound params: ["acme"]
```
## Usage: Advanced
```ts
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
const strict = createTenantScopedWrapper(admin, tenantId, {
tenantColumn: "org_id",
autoInjectInserts: false,
});
// INSERT must explicitly carry org_id matching the resolved tenant
await strict.execute(
"INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)",
["inv_1", tenantId, 100]
);
// rejected: literal doesn't match scope
await strict
.execute("INSERT INTO invoices (org_id) VALUES ('other')")
.catch((e) => e.code === "DB_ADAPTER.TENANT_SCOPE_VIOLATION");
```
## API Reference
- `createTenantScopedWrapper(admin, tenantId, options?): TenantScopedDatabaseLike`.
- `TenantScopedWrapperOptions` — `{ tenantColumn?, autoInjectInserts? }`.
- `rewriters.injectWhere(sql, tenantColumn, tenantId, params)` — appends a
WHERE/AND predicate.
- `rewriters.handleInsert(sql, tenantColumn, tenantId, params, autoInject)`
— verifies or auto-injects.
## Implementation Notes
The rewriter uses positional `?` parameters because they are accepted by
SQLite, MySQL, and (with driver-side rebinding) Postgres. Engines requiring
`$N` placeholders are expected to rebind at the driver layer. Record-shaped
params and multi-row INSERTs are rejected explicitly rather than guessed;
that pushes complex SQL into either a migration (which runs on the admin
surface) or `db.unsafe.query` (capability-gated), keeping the rewriter
narrow and auditable. Transaction callbacks receive a wrapped `DbTransaction`
so the rewrite contract holds across the whole tx body.

View File

@@ -0,0 +1,84 @@
# `unsafe/unsafe-query-gate` — capability-gated escape hatch
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant-
scoped surface. The gate is the only sanctioned path for a plugin to issue
raw SQL that bypasses the tenant-scope rewriter — and the only path that is
required to be auditable. The capability declaration is read from the
plugin manifest at construction time; calls without the declaration throw
synchronously, before the engine sees the SQL.
Each permitted call increments `plugin.unsafe_query_total{plugin}` so operators
can graph which plugins are exercising the escape hatch and at what rate. A
high count is an audit signal, not an error.
## Features
- Capability check is a literal array membership on `db.unsafe-queries`.
- `plugin.unsafe_query_total` counter, attribute `plugin` carrying the
plugin name from `UnsafeGateOptions`.
- Lazy `getUnsafeGateInstruments()` so test harnesses can install a custom
`MeterProvider` BEFORE the gate is constructed.
- `resetUnsafeGateInstruments()` for tests that swap meter providers
between cases.
- Both `query` and `execute` are gated; the gate is symmetric across
statement shapes.
## Usage: Basic
```ts
import { createUnsafeGate } from "@suaveplan/api-db-adapter/unsafe";
const unsafe = createUnsafeGate({
plugin: "audit",
capabilities: ["db.unsafe-queries"],
admin,
});
const rows = await unsafe.query("SELECT * FROM audit_log");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
createUnsafeGate,
resetUnsafeGateInstruments,
} from "@suaveplan/api-db-adapter/unsafe";
// Install a test MeterProvider FIRST.
metrics.setGlobalMeterProvider(testProvider);
resetUnsafeGateInstruments();
const denied = createUnsafeGate({
plugin: "user",
capabilities: [], // no db.unsafe-queries
admin,
});
await denied
.execute("DELETE FROM secrets")
.catch((e) => e.code === "DB_ADAPTER.UNSAFE_NOT_PERMITTED");
```
## API Reference
- `createUnsafeGate(opts: UnsafeGateOptions): UnsafeQueryApi`.
- `UnsafeQueryApi.query<R>(sql, params?): Promise<R>`.
- `UnsafeQueryApi.execute(sql, params?): Promise<{ rowsAffected }>`.
- `getUnsafeGateInstruments(): { meter, unsafeQueryTotal }`.
- `resetUnsafeGateInstruments(): void` — test only.
## Implementation Notes
The capability check happens at gate construction and again on each call (so a
permissions change applied between requests does not require the host to swap
the gate). Counter attributes use `plugin` rather than the prefixed
`db.plugin` to match the metric naming in the design doc. The gate never
inspects the SQL — that is the host's audit-sink job; the gate's contract is
"capability or `UNSAFE_NOT_PERMITTED`".