first commit
This commit is contained in:
128
docs/api-stack/api/api-db-adapter/README.md
Normal file
128
docs/api-stack/api/api-db-adapter/README.md
Normal 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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`".
|
||||
Reference in New Issue
Block a user