Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,109 @@
{
"name": "@suaveplan/api-db-adapter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-4 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/factory/create-db-adapter.d.ts",
"default": "./dist/factory/create-db-adapter.js"
}
},
"./tenant-scoped": {
"import": {
"types": "./dist/tenant-scoped/create-tenant-scoped-wrapper.d.ts",
"default": "./dist/tenant-scoped/create-tenant-scoped-wrapper.js"
}
},
"./migrations": {
"import": {
"types": "./dist/migrations/run-migrations.d.ts",
"default": "./dist/migrations/run-migrations.js"
}
},
"./unsafe": {
"import": {
"types": "./dist/unsafe/unsafe-query-gate.d.ts",
"default": "./dist/unsafe/unsafe-query-gate.js"
}
},
"./engine-registry": {
"import": {
"types": "./dist/engine-registry/engine-registry.d.ts",
"default": "./dist/engine-registry/engine-registry.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./options": {
"import": {
"types": "./dist/options/db-adapter-options.d.ts",
"default": "./dist/options/db-adapter-options.js"
}
},
"./proxy": {
"import": {
"types": "./dist/proxy/create-db-adapter-worker-proxy.d.ts",
"default": "./dist/proxy/create-db-adapter-worker-proxy.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-db-worker-proxy": "workspace:^",
"@suaveplan/db": "workspace:^",
"@suaveplan/error": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,99 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
import {
buildEngineAdapter,
getEngineFactory,
listEngines,
registerEngine,
unregisterEngine,
} from "./engine-registry.js";
describe("engineRegistry", () => {
afterEach(() => {
for (const name of listEngines()) unregisterEngine(name);
});
it("register + lookup round trip", { timeout: 5000 }, () => {
const factory = async () => ({
engine: "x",
query: async () => [],
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
});
registerEngine("x", factory);
expect(getEngineFactory("x")).toBe(factory);
expect(listEngines()).toContain("x");
});
it("unregisterEngine returns true when removed and false otherwise", {
timeout: 5000,
}, () => {
registerEngine("y", async () => ({
engine: "y",
query: async () => [],
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
}));
expect(unregisterEngine("y")).toBe(true);
expect(unregisterEngine("never")).toBe(false);
});
it("buildEngineAdapter throws ENGINE_UNSUPPORTED when missing", {
timeout: 5000,
}, async () => {
await expect(
buildEngineAdapter({
engine: "mongodb",
url: "x",
tenantColumn: "tenant_id",
autoInjectInserts: true,
})
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.ENGINE_UNSUPPORTED,
});
});
it("buildEngineAdapter delegates to the factory", {
timeout: 5000,
}, async () => {
registerEngine("memory", async (opts) => ({
engine: opts.engine,
query: async () => [{ ok: 1 }] as unknown as never,
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
}));
const a = await buildEngineAdapter({
engine: "memory",
url: "x",
tenantColumn: "tenant_id",
autoInjectInserts: true,
});
expect(a.engine).toBe("memory");
});
it("DbAdapterError emitted on unsupported engine carries known names", {
timeout: 5000,
}, async () => {
registerEngine("a", async () => ({
engine: "a",
query: async () => [],
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
}));
try {
await buildEngineAdapter({
engine: "z",
url: "x",
tenantColumn: "tenant_id",
autoInjectInserts: true,
});
throw new Error("expected to throw");
} catch (e) {
expect(e).toBeInstanceOf(DbAdapterError);
const ctx = (e as DbAdapterError).context as {
known: ReadonlyArray<string>;
};
expect(ctx.known).toContain("a");
}
});
});

View File

@@ -0,0 +1,69 @@
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
import type { DbAdapterOptions } from "../options/index.js";
/**
* Concrete engine factory: build an `AdminDatabaseLike` from validated options.
*
* The factory is fully async — engines may need to handshake a connection
* before reporting ready. The returned object MUST honour `close()`.
*/
export type EngineFactory = (
options: DbAdapterOptions
) => Promise<AdminDatabaseLike>;
/**
* The open registry. Population happens via `registerEngine(name, factory)`.
* The package ships with no engines pre-installed — host code or downstream
* adapter packages call `registerEngine` to wire in concrete `@suaveplan/db`
* adapters. This keeps the package universally importable without dragging
* `bun:sqlite` / `pg` / `surrealdb.js` into every consumer's bundle.
*/
const engineRegistry = new Map<string, EngineFactory>();
/**
* Register an engine factory under `name`. Calling twice with the same name
* is allowed — the second call replaces the first. This is the intended
* extension hook for declaration-merging a new `DatabaseEngineRegistry`
* member.
*/
export function registerEngine(name: string, factory: EngineFactory): void {
engineRegistry.set(name, factory);
}
/**
* Remove an engine factory. Returns `true` if the name was present.
* Primarily for tests; production code should not need to deregister.
*/
export function unregisterEngine(name: string): boolean {
return engineRegistry.delete(name);
}
/** Lookup a previously-registered factory. */
export function getEngineFactory(name: string): EngineFactory | undefined {
return engineRegistry.get(name);
}
/** Snapshot of registered engine names. */
export function listEngines(): ReadonlyArray<string> {
return [...engineRegistry.keys()];
}
/**
* Build the engine adapter for `options.engine`. Throws
* `DB_ADAPTER.ENGINE_UNSUPPORTED` synchronously if no factory was registered
* for the requested engine name.
*/
export async function buildEngineAdapter(
options: DbAdapterOptions
): Promise<AdminDatabaseLike> {
const factory = engineRegistry.get(options.engine);
if (factory === undefined) {
throw new DbAdapterError(
DB_ADAPTER_CODES.ENGINE_UNSUPPORTED,
`engine '${options.engine}' is not registered — call registerEngine('${options.engine}', factory) first`,
{ context: { engine: options.engine, known: listEngines() } }
);
}
return await factory(options);
}

View File

@@ -0,0 +1,8 @@
export {
buildEngineAdapter,
type EngineFactory,
getEngineFactory,
listEngines,
registerEngine,
unregisterEngine,
} from "./engine-registry.js";

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,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_ADAPTER_CODES, DbAdapterError } from "./error-codes.js";
describe("DB_ADAPTER_CODES", () => {
it("registers five stable codes", { timeout: 5000 }, () => {
expect(Object.keys(DB_ADAPTER_CODES).sort()).toEqual(
[
"ENGINE_UNSUPPORTED",
"INVALID_OPTIONS",
"MIGRATION_FAILED",
"TENANT_SCOPE_VIOLATION",
"UNSAFE_NOT_PERMITTED",
].sort()
);
expect(DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION).toBe(
"DB_ADAPTER.TENANT_SCOPE_VIOLATION"
);
});
it("DbAdapterError carries the code + name", { timeout: 5000 }, () => {
const e = new DbAdapterError(
DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
"no cap"
);
expect(e).toBeInstanceOf(DbAdapterError);
expect(e.code).toBe(DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED);
expect(e.name).toBe("DbAdapterError");
expect(e.message).toBe("no cap");
});
it("honours cause + details", { timeout: 5000 }, () => {
const cause = new Error("root");
const e = new DbAdapterError(
DB_ADAPTER_CODES.MIGRATION_FAILED,
"boom",
{ cause, context: { file: "001.sql" } }
);
expect(e.cause).toBe(cause);
expect(e.context).toEqual({ file: "001.sql" });
});
});

View File

@@ -0,0 +1,45 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-db-adapter`.
*
* - `TENANT_SCOPE_VIOLATION` — a tenant-scoped wrapper observed a write with a
* conflicting `tenant_id` value, or a SELECT-by-id without a tenant
* parameter when `strictRead: true`.
* - `UNSAFE_NOT_PERMITTED` — `db.unsafe.query` invoked by a plugin whose
* manifest does not declare the `db.unsafe-queries` capability.
* - `MIGRATION_FAILED` — a migration file threw during application; the
* transaction (where supported) was rolled back and the plugin's schema
* state is unchanged.
* - `ENGINE_UNSUPPORTED` — `createDbAdapter` received an `engine` value that
* is not present in {@link DatabaseEngineRegistry} at construction time.
* - `INVALID_OPTIONS` — `DbAdapterOptions` failed Zod validation.
*/
export const DB_ADAPTER_CODES = defineCodes("DB_ADAPTER", [
"TENANT_SCOPE_VIOLATION",
"UNSAFE_NOT_PERMITTED",
"MIGRATION_FAILED",
"ENGINE_UNSUPPORTED",
"INVALID_OPTIONS",
] as const);
/** Union of every fully-qualified code in {@link DB_ADAPTER_CODES}. */
export type DbAdapterCode =
(typeof DB_ADAPTER_CODES)[keyof typeof DB_ADAPTER_CODES];
/**
* Typed error class for `@suaveplan/api-db-adapter`. Extends
* {@link SuaveplanError} so callers may discriminate via either
* `instanceof DbAdapterError` or `error.code` literal comparison.
*/
export class DbAdapterError extends SuaveplanError<DbAdapterCode> {
public constructor(
code: DbAdapterCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DbAdapterError";
}
}

View File

@@ -0,0 +1,5 @@
export {
DB_ADAPTER_CODES,
type DbAdapterCode,
DbAdapterError,
} from "./error-codes.js";

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,102 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryAdmin } from "../../tests/_helpers/memory-admin.js";
import {
listEngines,
registerEngine,
unregisterEngine,
} from "../engine-registry/index.js";
import { DB_ADAPTER_CODES } from "../error-codes/index.js";
import { createDbAdapter } from "./create-db-adapter.js";
const ACME = "acme" as TenantId;
describe("createDbAdapter", () => {
afterEach(() => {
for (const name of listEngines()) unregisterEngine(name);
});
it("admin surface returns rows across all tenants", {
timeout: 5000,
}, async () => {
const memory = createMemoryAdmin();
registerEngine("memory", async () => memory);
const db = await createDbAdapter({
engine: "memory",
url: ":memory:",
});
await db.admin.execute(
"INSERT INTO users (id, tenant_id) VALUES (?, ?)",
["u1", "acme"]
);
await db.admin.execute(
"INSERT INTO users (id, tenant_id) VALUES (?, ?)",
["u2", "bigcorp"]
);
const rows = await db.admin.query<unknown[]>("SELECT * FROM users");
expect(rows).toHaveLength(2);
await db.close();
});
it("scoped surface filters to current tenant", {
timeout: 5000,
}, async () => {
const memory = createMemoryAdmin();
registerEngine("memory", async () => memory);
const db = await createDbAdapter({
engine: "memory",
url: ":memory:",
});
await db.admin.execute(
"INSERT INTO users (id, tenant_id) VALUES (?, ?)",
["u1", "acme"]
);
await db.admin.execute(
"INSERT INTO users (id, tenant_id) VALUES (?, ?)",
["u2", "bigcorp"]
);
const acme = db.scoped(ACME);
const rows = await acme.query<{ id: string }[]>("SELECT * FROM users");
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe("u1");
await db.close();
});
it("rethrows ENGINE_UNSUPPORTED when engine missing", {
timeout: 5000,
}, async () => {
await expect(
createDbAdapter({ engine: "nope", url: ":memory:" })
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.ENGINE_UNSUPPORTED,
});
});
it("rethrows INVALID_OPTIONS on bad config", {
timeout: 5000,
}, async () => {
await expect(
createDbAdapter({ engine: "memory", url: "" })
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.INVALID_OPTIONS,
});
});
it("honours autoInjectInserts: false through scoped()", {
timeout: 5000,
}, async () => {
const memory = createMemoryAdmin();
registerEngine("memory", async () => memory);
const db = await createDbAdapter({
engine: "memory",
url: ":memory:",
autoInjectInserts: false,
});
await expect(
db.scoped(ACME).execute("INSERT INTO users (id) VALUES (?)", ["u1"])
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
});
await db.close();
});
});

View File

@@ -0,0 +1,51 @@
import type {
AdminDatabaseLike,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { buildEngineAdapter } from "../engine-registry/index.js";
import {
type DbAdapterOptionsInput,
parseDbAdapterOptions,
} from "../options/index.js";
import { createTenantScopedWrapper } from "../tenant-scoped/index.js";
/**
* Return type of {@link createDbAdapter}: the admin surface for migrations
* and operator tooling, plus a `scoped(tenantId)` factory that produces a
* `TenantScopedDatabaseLike` wrapper for use inside route handlers.
*/
export interface DbAdapter {
readonly admin: AdminDatabaseLike;
/** Build a fresh tenant-scoped surface over the same admin connection. */
scoped(tenantId: TenantId): TenantScopedDatabaseLike;
/** Close the underlying admin connection. */
close(): Promise<void>;
}
/**
* Top-level direct-surface factory. Parses + validates the options, looks
* up the engine factory in the open registry, constructs the admin adapter,
* and exposes a `scoped(tenantId)` helper that wraps the admin in the
* tenant-scope rewriter using the configured `tenantColumn` /
* `autoInjectInserts`.
*
* Throws `DB_ADAPTER.INVALID_OPTIONS` on schema failure;
* `DB_ADAPTER.ENGINE_UNSUPPORTED` when no factory is registered for the
* named engine; engine-specific errors otherwise.
*/
export async function createDbAdapter(
input: DbAdapterOptionsInput
): Promise<DbAdapter> {
const options = parseDbAdapterOptions(input);
const admin = await buildEngineAdapter(options);
return {
admin,
scoped: (tenantId) =>
createTenantScopedWrapper(admin, tenantId, {
tenantColumn: options.tenantColumn,
autoInjectInserts: options.autoInjectInserts,
}),
close: () => admin.close(),
};
}

View File

@@ -0,0 +1 @@
export { createDbAdapter, type DbAdapter } from "./create-db-adapter.js";

View File

@@ -0,0 +1,47 @@
export {
buildEngineAdapter,
type EngineFactory,
getEngineFactory,
listEngines,
registerEngine,
unregisterEngine,
} from "./engine-registry/index.js";
export {
DB_ADAPTER_CODES,
type DbAdapterCode,
DbAdapterError,
} from "./error-codes/index.js";
export { createDbAdapter, type DbAdapter } from "./factory/index.js";
export {
getMigrationInstruments,
type MigrationsFsReader,
type RunMigrationsOptions,
type RunMigrationsResult,
resetMigrationInstruments,
runMigrations,
} from "./migrations/index.js";
export {
type DbAdapterOptions,
type DbAdapterOptionsInput,
DbAdapterOptionsSchema,
type DbAdapterPoolOptions,
DbAdapterPoolSchema,
parseDbAdapterOptions,
} from "./options/index.js";
export {
createDbAdapterWorkerProxy,
type DbAdapterWorkerProxy,
type DbAdapterWorkerProxyOptions,
} from "./proxy/index.js";
export {
createTenantScopedWrapper,
rewriters,
type TenantScopedWrapperOptions,
} from "./tenant-scoped/index.js";
export {
createUnsafeGate,
getUnsafeGateInstruments,
resetUnsafeGateInstruments,
type UnsafeGateOptions,
type UnsafeQueryApi,
} from "./unsafe/index.js";

View File

@@ -0,0 +1,8 @@
export {
getMigrationInstruments,
type MigrationsFsReader,
type RunMigrationsOptions,
type RunMigrationsResult,
resetMigrationInstruments,
runMigrations,
} from "./run-migrations.js";

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,162 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryAdmin } from "../../tests/_helpers/memory-admin.js";
import { DB_ADAPTER_CODES } from "../error-codes/index.js";
import {
getMigrationInstruments,
resetMigrationInstruments,
runMigrations,
} from "./run-migrations.js";
const makeFs = (files: Record<string, string>) => ({
readdir: async () => Object.keys(files),
readFile: async (path: string) => {
const base = path.split("/").pop() ?? path;
const content = files[base];
if (content === undefined) throw new Error(`no such file: ${path}`);
return content;
},
});
describe("runMigrations", () => {
it("applies pending migrations in lexicographic order", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const fs = makeFs({
"002_b.sql": "CREATE TABLE IF NOT EXISTS b (id TEXT)",
"001_a.sql": "CREATE TABLE IF NOT EXISTS a (id TEXT)",
});
const r = await runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
});
expect(r.applied).toEqual(["001_a.sql", "002_b.sql"]);
expect(r.skipped).toEqual([]);
expect(admin.tables.has("a")).toBe(true);
expect(admin.tables.has("b")).toBe(true);
});
it("is idempotent on re-run", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const fs = makeFs({
"001.sql": "CREATE TABLE IF NOT EXISTS x (id TEXT)",
});
const first = await runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
});
expect(first.applied).toEqual(["001.sql"]);
const second = await runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
});
expect(second.applied).toEqual([]);
expect(second.skipped).toEqual(["001.sql"]);
});
it("rolls back and throws on migration failure", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin({
failOn: (sql) => sql.includes("BAD_TOKEN"),
});
const fs = makeFs({
"001.sql": "CREATE TABLE IF NOT EXISTS ok (id TEXT)",
"002_bad.sql": "BAD_TOKEN syntax",
});
await expect(
runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
})
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.MIGRATION_FAILED,
context: { file: "002_bad.sql", plugin: "p" },
});
// First migration recorded, second is NOT
const applied = (await admin.query(
"SELECT version FROM __plugin_migrations WHERE plugin = ?",
["p"]
)) as { version: string }[];
const versions = applied.map((r) => r.version).sort();
expect(versions).toEqual(["001.sql"]);
});
it("skips non-.sql entries", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const fs = makeFs({
"README.md": "irrelevant",
"001.sql": "CREATE TABLE IF NOT EXISTS x (id TEXT)",
});
const r = await runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
});
expect(r.applied).toEqual(["001.sql"]);
});
it("works on adapters without transaction support", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin({ noTransaction: true });
const fs = makeFs({
"001.sql": "CREATE TABLE IF NOT EXISTS x (id TEXT)",
});
const r = await runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
});
expect(r.applied).toEqual(["001.sql"]);
});
it("failure path on no-tx adapter wraps cause", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin({
noTransaction: true,
failOn: (sql) => sql.includes("BAD_TOKEN"),
});
const fs = makeFs({ "001.sql": "BAD_TOKEN" });
await expect(
runMigrations({
plugin: "p",
capabilities: [],
admin,
migrationsDir: "/m",
fs,
})
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.MIGRATION_FAILED,
});
});
it("getMigrationInstruments is cached + resettable", {
timeout: 5000,
}, () => {
resetMigrationInstruments();
const a = getMigrationInstruments();
const b = getMigrationInstruments();
expect(a).toBe(b);
resetMigrationInstruments();
const c = getMigrationInstruments();
expect(a).not.toBe(c);
});
});

View File

@@ -0,0 +1,146 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
/**
* Filesystem-like read surface required by the migration runner. We don't
* depend on `node:fs` at the type level so the runner is testable with an
* in-memory map of files.
*/
export interface MigrationsFsReader {
/** List entries in `dir` — file names only (basenames). */
readdir(dir: string): Promise<ReadonlyArray<string>>;
/** Read a file's contents as UTF-8. */
readFile(path: string): Promise<string>;
}
/** Input to {@link runMigrations}. */
export interface RunMigrationsOptions {
/** Plugin name — used as the row key in `__plugin_migrations`. */
readonly plugin: string;
/** Capability declarations from the plugin manifest. */
readonly capabilities: ReadonlyArray<string>;
/** Admin adapter to run migrations against. */
readonly admin: AdminDatabaseLike;
/** Directory containing `001_init.sql`, `002_*.sql`, ... */
readonly migrationsDir: string;
/** Filesystem reader. */
readonly fs: MigrationsFsReader;
}
/** Outcome of a {@link runMigrations} invocation. */
export interface RunMigrationsResult {
readonly applied: ReadonlyArray<string>;
readonly skipped: ReadonlyArray<string>;
}
interface CachedInstruments {
readonly meter: Meter;
readonly migrationsApplied: Counter;
readonly migrationDuration: Histogram;
}
let cached: CachedInstruments | undefined;
/** Lazily resolve OTel instruments — host may install a `MeterProvider` first. */
export function getMigrationInstruments(): CachedInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-db-adapter");
cached = {
meter,
migrationsApplied: meter.createCounter(
"api.db.migrations_applied_total",
{
description:
"Number of plugin migration files applied successfully.",
}
),
migrationDuration: meter.createHistogram(
"api.db.migration_duration_ms",
{ description: "Per-migration application time in ms." }
),
};
return cached;
}
/** Test helper: reset cached instruments so a new MeterProvider is picked up. */
export function resetMigrationInstruments(): void {
cached = undefined;
}
const CREATE_TABLE_SQL =
"CREATE TABLE IF NOT EXISTS __plugin_migrations (plugin TEXT NOT NULL, version TEXT NOT NULL, applied_at TEXT NOT NULL, PRIMARY KEY (plugin, version))";
/**
* Apply pending migrations from `migrationsDir` in lexicographic order.
* Records applied rows in `__plugin_migrations(plugin, version, applied_at)`.
* Idempotent: re-running with no new files is a no-op.
*
* On any migration failure the call throws `DB_ADAPTER.MIGRATION_FAILED` and
* (where the engine supports `transaction`) the failing migration is rolled
* back. The `__plugin_migrations` row for the failing version is NOT recorded.
*/
export async function runMigrations(
options: RunMigrationsOptions
): Promise<RunMigrationsResult> {
const { plugin, admin, migrationsDir, fs } = options;
await admin.execute(CREATE_TABLE_SQL);
const entries = await fs.readdir(migrationsDir);
const sqlFiles = entries
.filter((name) => name.endsWith(".sql"))
.sort((a, b) => a.localeCompare(b));
const appliedRows = (await admin.query(
"SELECT version FROM __plugin_migrations WHERE plugin = ?",
[plugin]
)) as ReadonlyArray<{ version: string }>;
const alreadyApplied = new Set(appliedRows.map((r) => r.version));
const applied: string[] = [];
const skipped: string[] = [];
const instruments = getMigrationInstruments();
for (const file of sqlFiles) {
if (alreadyApplied.has(file)) {
skipped.push(file);
continue;
}
const sql = await fs.readFile(`${migrationsDir}/${file}`);
const start = Date.now();
try {
if (admin.transaction !== undefined) {
await admin.transaction(async (tx) => {
await tx.execute(sql);
await tx.execute(
"INSERT INTO __plugin_migrations (plugin, version, applied_at) VALUES (?, ?, ?)",
[plugin, file, new Date().toISOString()]
);
});
} else {
await admin.execute(sql);
await admin.execute(
"INSERT INTO __plugin_migrations (plugin, version, applied_at) VALUES (?, ?, ?)",
[plugin, file, new Date().toISOString()]
);
}
} catch (cause) {
throw new DbAdapterError(
DB_ADAPTER_CODES.MIGRATION_FAILED,
`migration ${file} failed for plugin ${plugin}`,
{
cause: cause as Error,
context: { file, plugin },
}
);
}
const elapsed = Date.now() - start;
instruments.migrationDuration.record(elapsed, { plugin });
instruments.migrationsApplied.add(1, { plugin });
applied.push(file);
}
return { applied, skipped };
}

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,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
import { parseDbAdapterOptions } from "./db-adapter-options.js";
describe("parseDbAdapterOptions", () => {
it("parses with defaults", { timeout: 5000 }, () => {
const parsed = parseDbAdapterOptions({
engine: "sqlite",
url: ":memory:",
});
expect(parsed.tenantColumn).toBe("tenant_id");
expect(parsed.autoInjectInserts).toBe(true);
expect(parsed.pool).toBeUndefined();
expect(parsed.migrationsDir).toBeUndefined();
});
it("parses with pool overrides", { timeout: 5000 }, () => {
const parsed = parseDbAdapterOptions({
engine: "postgres",
url: "postgres://x",
pool: { min: 2, max: 16, idleMs: 60_000 },
});
expect(parsed.pool?.max).toBe(16);
});
it("applies defaults when pool object is empty", { timeout: 5000 }, () => {
const parsed = parseDbAdapterOptions({
engine: "postgres",
url: "postgres://x",
pool: {},
});
expect(parsed.pool?.min).toBe(0);
expect(parsed.pool?.max).toBe(10);
expect(parsed.pool?.idleMs).toBe(30_000);
});
it("throws DB_ADAPTER.INVALID_OPTIONS on bad url", {
timeout: 5000,
}, () => {
try {
parseDbAdapterOptions({
engine: "sqlite",
url: "",
});
throw new Error("expected to throw");
} catch (e) {
expect(e).toBeInstanceOf(DbAdapterError);
expect((e as DbAdapterError).code).toBe(
DB_ADAPTER_CODES.INVALID_OPTIONS
);
const ctx = (e as DbAdapterError).context as {
issues: ReadonlyArray<unknown>;
};
expect(ctx.issues.length).toBeGreaterThan(0);
}
});
it("throws on empty engine string", { timeout: 5000 }, () => {
expect(() => parseDbAdapterOptions({ engine: "", url: "x" })).toThrow(
DbAdapterError
);
});
it("applies autoInjectInserts default true and respects explicit false", {
timeout: 5000,
}, () => {
const a = parseDbAdapterOptions({
engine: "sqlite",
url: ":memory:",
});
const b = parseDbAdapterOptions({
engine: "sqlite",
url: ":memory:",
autoInjectInserts: false,
});
expect(a.autoInjectInserts).toBe(true);
expect(b.autoInjectInserts).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
import z from "zod";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
/**
* Pool tuning. Delegated to `@suaveplan/db`'s underlying connection layer; the
* adapter merely passes the values through. Optional — engines pick sensible
* defaults when absent.
*/
export const DbAdapterPoolSchema = z.object({
min: z.number().int().min(0).default(0),
max: z.number().int().min(1).default(10),
idleMs: z.number().int().min(0).default(30_000),
});
/** Inferred pool options. */
export type DbAdapterPoolOptions = z.infer<typeof DbAdapterPoolSchema>;
/**
* Top-level adapter options. `engine` is a literal string union — but the
* runtime check goes through {@link engineRegistry} so a declaration-merged
* third-party engine is also accepted at construction time.
*/
export const DbAdapterOptionsSchema = z.object({
engine: z.string().min(1),
url: z.string().min(1),
pool: DbAdapterPoolSchema.optional(),
tenantColumn: z.string().min(1).default("tenant_id"),
migrationsDir: z.string().min(1).optional(),
/**
* Strict-mode flag for `INSERT` payloads. When `true` (default) a payload
* lacking the tenant column is rejected with `DB_ADAPTER.TENANT_SCOPE_VIOLATION`.
* When `false` the wrapper auto-injects the resolved tenant id.
*/
autoInjectInserts: z.boolean().default(true),
});
/** Inferred adapter options (after parsing). */
export type DbAdapterOptions = z.infer<typeof DbAdapterOptionsSchema>;
/** Raw input shape (pre-parse). */
export type DbAdapterOptionsInput = z.input<typeof DbAdapterOptionsSchema>;
/**
* Parse raw options and convert any Zod failure into a {@link DbAdapterError}
* with code `DB_ADAPTER.INVALID_OPTIONS`. The Zod issues are attached to
* `error.context.issues` for diagnostic surfacing.
*/
export function parseDbAdapterOptions(
input: DbAdapterOptionsInput
): DbAdapterOptions {
const parsed = DbAdapterOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new DbAdapterError(
DB_ADAPTER_CODES.INVALID_OPTIONS,
"invalid DbAdapterOptions",
{ context: { issues: parsed.error.issues } }
);
}
return parsed.data;
}

View File

@@ -0,0 +1,8 @@
export {
type DbAdapterOptions,
type DbAdapterOptionsInput,
DbAdapterOptionsSchema,
type DbAdapterPoolOptions,
DbAdapterPoolSchema,
parseDbAdapterOptions,
} from "./db-adapter-options.js";

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,79 @@
import { createDbProxyServer } from "@suaveplan/api-db-worker-proxy/main-side";
import { createInMemoryAdminDb } from "@suaveplan/api-db-worker-proxy/testing";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDbAdapterWorkerProxy } from "./create-db-adapter-worker-proxy.js";
const ACME = "acme" as TenantId;
describe("createDbAdapterWorkerProxy", () => {
it("admin proxy round-trips queries over a MessagePort", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb({ seed: [["a", { v: 1 }]] });
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
try {
const proxy = createDbAdapterWorkerProxy({
port: ports.clientPort,
engine: db.engine,
});
const rows = await proxy.admin.query<unknown[]>("SELECT *");
expect(rows).toEqual([{ v: 1 }]);
proxy.close();
} finally {
server.close("test teardown");
ports.close();
}
});
it("scoped wrapper exposes engine + tenantId", { timeout: 5000 }, () => {
// Stand up our own server+port pair so we don't double-consume frames
const db = createInMemoryAdminDb();
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
try {
const proxy = createDbAdapterWorkerProxy({
port: ports.clientPort,
engine: "sqlite",
});
const scoped = proxy.scoped(ACME);
expect(scoped.engine).toBe("sqlite");
expect(scoped.tenantId).toBe(ACME);
proxy.close();
} finally {
server.close("test teardown");
ports.close();
}
});
it("close() severs the underlying client", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb();
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const proxy = createDbAdapterWorkerProxy({
port: ports.clientPort,
engine: "sqlite",
});
proxy.close();
await expect(proxy.admin.query("SELECT *")).rejects.toBeDefined();
server.close("test teardown");
ports.close();
});
it("accepts clientOptions passthrough", { timeout: 5000 }, () => {
const db = createInMemoryAdminDb();
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const proxy = createDbAdapterWorkerProxy({
port: ports.clientPort,
engine: "sqlite",
clientOptions: {},
});
expect(proxy.admin.engine).toBe("sqlite");
proxy.close();
server.close("test teardown");
ports.close();
});
});

View File

@@ -0,0 +1,58 @@
import { wrapTenantScoped } from "@suaveplan/api-db-worker-proxy/tenant-scoped";
import {
createDbProxyClient,
type DbProxyClient,
type DbProxyClientOptions,
} from "@suaveplan/api-db-worker-proxy/worker-side";
import type {
AdminDatabaseLike,
DatabaseEngine,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { PortLike } from "@suaveplan/api-worker-rpc";
/**
* Worker-side bundle returned by {@link createDbAdapterWorkerProxy}.
* Carries the raw proxy client (with cursor streaming) plus a
* tenant-scoped facade factory built over the same client.
*/
export interface DbAdapterWorkerProxy {
/** Admin-shaped proxy backed by an `@suaveplan/api-db-worker-proxy` port. */
readonly admin: DbProxyClient;
/** Build a fresh tenant-scoped facade (main-side enforces row scoping). */
scoped(tenantId: TenantId): TenantScopedDatabaseLike;
/** Tear down the proxy port. */
close(): void;
}
/** Options for {@link createDbAdapterWorkerProxy}. */
export interface DbAdapterWorkerProxyOptions {
readonly port: PortLike;
readonly engine: DatabaseEngine;
readonly clientOptions?: Omit<DbProxyClientOptions, "engine">;
}
/**
* Worker-side counterpart to {@link createDbAdapter}. Builds an
* `AdminDatabaseLike`-shaped proxy over an `@suaveplan/api-db-worker-proxy`
* port, and offers the same `scoped(tenantId)` facade as the direct surface.
*
* The proxy preserves cursor streams via `ReadableStream` over the
* underlying `MessagePort`, so large query results survive the boundary
* without buffering all rows in memory.
*/
export function createDbAdapterWorkerProxy(
options: DbAdapterWorkerProxyOptions
): DbAdapterWorkerProxy {
const client = createDbProxyClient(options.port, {
engine: options.engine,
...(options.clientOptions ?? {}),
});
return {
admin: client,
scoped: (tenantId) =>
wrapTenantScoped(client as unknown as AdminDatabaseLike, tenantId),
close: () => client.dispose(),
};
}

View File

@@ -0,0 +1,5 @@
export {
createDbAdapterWorkerProxy,
type DbAdapterWorkerProxy,
type DbAdapterWorkerProxyOptions,
} from "./create-db-adapter-worker-proxy.js";

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,356 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryAdmin } from "../../tests/_helpers/memory-admin.js";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
import {
createTenantScopedWrapper,
rewriters,
} from "./create-tenant-scoped-wrapper.js";
const ACME = "acme" as TenantId;
const BIG = "bigcorp" as TenantId;
describe("rewriters.injectWhere", () => {
it("appends WHERE when none present", { timeout: 5000 }, () => {
const r = rewriters.injectWhere(
"SELECT * FROM users",
"tenant_id",
"acme",
[]
);
expect(r.sql).toBe("SELECT * FROM users WHERE tenant_id = ?");
expect(r.params).toEqual(["acme"]);
});
it("appends AND when WHERE already present", { timeout: 5000 }, () => {
const r = rewriters.injectWhere(
"SELECT * FROM users WHERE active = ?",
"tenant_id",
"acme",
[true]
);
expect(r.sql).toContain("AND tenant_id = ?");
expect(r.params).toEqual([true, "acme"]);
});
it("preserves ORDER BY / LIMIT tails", { timeout: 5000 }, () => {
const r = rewriters.injectWhere(
"SELECT * FROM users ORDER BY id LIMIT 10",
"tenant_id",
"acme",
[]
);
expect(r.sql).toMatch(
/SELECT \* FROM users WHERE tenant_id = \? ORDER BY id LIMIT 10/
);
});
it("preserves RETURNING tail", { timeout: 5000 }, () => {
const r = rewriters.injectWhere(
"DELETE FROM users RETURNING id",
"tenant_id",
"acme",
[]
);
expect(r.sql).toBe(
"DELETE FROM users WHERE tenant_id = ? RETURNING id"
);
});
it("appends AND tenant_id before ORDER BY when both WHERE and tail present", {
timeout: 5000,
}, () => {
const r = rewriters.injectWhere(
"SELECT * FROM users WHERE active = ? ORDER BY id",
"tenant_id",
"acme",
[true]
);
expect(r.sql).toMatch(/AND tenant_id = \? ORDER BY id/);
});
it("preserves GROUP BY tail", { timeout: 5000 }, () => {
const r = rewriters.injectWhere(
"SELECT t FROM users GROUP BY t",
"tenant_id",
"acme",
[]
);
expect(r.sql).toBe(
"SELECT t FROM users WHERE tenant_id = ? GROUP BY t"
);
});
});
describe("rewriters.handleInsert", () => {
it("auto-injects when column absent and autoInject true", {
timeout: 5000,
}, () => {
const r = rewriters.handleInsert(
"INSERT INTO users (id, name) VALUES (?, ?)",
"tenant_id",
"acme",
["u1", "Ada"],
true
);
expect(r.sql).toBe(
"INSERT INTO users (id, name, tenant_id) VALUES (?, ?, ?)"
);
expect(r.params).toEqual(["u1", "Ada", "acme"]);
});
it("rejects when column absent and autoInject false", {
timeout: 5000,
}, () => {
expect(() =>
rewriters.handleInsert(
"INSERT INTO users (id) VALUES (?)",
"tenant_id",
"acme",
["u1"],
false
)
).toThrow(DbAdapterError);
});
it("rejects conflicting bound tenant", { timeout: 5000 }, () => {
try {
rewriters.handleInsert(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
"tenant_id",
"acme",
["bigcorp", "Ada"],
true
);
throw new Error("expected to throw");
} catch (e) {
expect((e as DbAdapterError).code).toBe(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION
);
}
});
it("accepts matching bound tenant", { timeout: 5000 }, () => {
const r = rewriters.handleInsert(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
"tenant_id",
"acme",
["acme", "Ada"],
true
);
expect(r.sql).toBe("INSERT INTO users (tenant_id, name) VALUES (?, ?)");
});
it("rejects conflicting literal tenant", { timeout: 5000 }, () => {
expect(() =>
rewriters.handleInsert(
"INSERT INTO users (tenant_id) VALUES ('bigcorp')",
"tenant_id",
"acme",
[],
true
)
).toThrow(DbAdapterError);
});
it("accepts matching literal tenant", { timeout: 5000 }, () => {
const r = rewriters.handleInsert(
"INSERT INTO users (tenant_id) VALUES ('acme')",
"tenant_id",
"acme",
[],
true
);
expect(r.sql).toContain("'acme'");
});
it("ignores undefined bound value (no conflict)", { timeout: 5000 }, () => {
const r = rewriters.handleInsert(
"INSERT INTO users (tenant_id, name) VALUES (?, ?)",
"tenant_id",
"acme",
// tenant slot undefined — wrapper treats it as no-conflict
// so insertion proceeds (host driver will reject NOT NULL).
[undefined, "Ada"],
true
);
expect(r.sql).toContain("VALUES");
});
it("rejects unsupported INSERT shape (multi-row)", {
timeout: 5000,
}, () => {
expect(() =>
rewriters.handleInsert(
"INSERT INTO users SELECT * FROM staging",
"tenant_id",
"acme",
[],
true
)
).toThrow(DbAdapterError);
});
it("accepts $N placeholder bound values", { timeout: 5000 }, () => {
const r = rewriters.handleInsert(
"INSERT INTO users (tenant_id, name) VALUES ($1, $2)",
"tenant_id",
"acme",
["acme", "Ada"],
true
);
expect(r.params).toEqual(["acme", "Ada"]);
});
});
describe("createTenantScopedWrapper", () => {
it("SELECT auto-scopes to current tenant", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
// seed two tenants' rows
await admin.execute("INSERT INTO users (id, tenant_id) VALUES (?, ?)", [
"u1",
"acme",
]);
await admin.execute("INSERT INTO users (id, tenant_id) VALUES (?, ?)", [
"u2",
"bigcorp",
]);
const scoped = createTenantScopedWrapper(admin, ACME);
const rows = await scoped.query<{ id: string }[]>(
"SELECT * FROM users"
);
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe("u1");
expect(admin.lastSql()).toBe("SELECT * FROM users WHERE tenant_id = ?");
});
it("INSERT with conflicting tenant_id is rejected", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
await expect(
scoped.execute("INSERT INTO users (tenant_id, id) VALUES (?, ?)", [
"bigcorp",
"u1",
])
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("INSERT auto-injects tenant_id when absent", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
await scoped.execute("INSERT INTO users (id) VALUES (?)", ["u1"]);
const rows = admin.tables.get("users") ?? [];
expect(rows[0]?.["tenant_id"]).toBe("acme");
});
it("strict mode rejects INSERT missing tenant column", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME, {
autoInjectInserts: false,
});
await expect(
scoped.execute("INSERT INTO users (id) VALUES (?)", ["u1"])
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("DDL is rejected at the boundary", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
await expect(
scoped.execute("CREATE TABLE x (id TEXT)")
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("named (record) params are rejected", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
await expect(
scoped.query("SELECT * FROM users", {
id: "u1",
} as unknown as never)
).rejects.toMatchObject({
code: DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
});
});
it("execute rewrites UPDATE and DELETE", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
await scoped.execute("UPDATE users SET name = ?", ["x"]);
expect(admin.lastSql()).toContain("WHERE tenant_id = ?");
await scoped.execute("DELETE FROM users");
expect(admin.lastSql()).toContain("WHERE tenant_id = ?");
});
it("transaction body is rewritten too", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
const result = await scoped.transaction!(async (tx) => {
await tx.execute("INSERT INTO users (id) VALUES (?)", ["u1"]);
const rows = await tx.query<unknown[]>("SELECT * FROM users");
await tx.commit();
await tx.rollback();
return rows.length;
});
expect(result).toBe(1);
expect(admin.lastSql()).toContain("WHERE tenant_id = ?");
});
it("prepared statement rewrites at execute time", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
const stmt = await scoped.prepare!<{ id: string }[]>(
"SELECT * FROM users"
);
const rows = await stmt.execute();
expect(rows).toEqual([]);
expect(admin.lastSql()).toContain("WHERE tenant_id = ?");
await stmt.finalize();
expect(stmt.id).toBeDefined();
});
it("omits prepare/transaction if admin lacks them", {
timeout: 5000,
}, () => {
const admin = createMemoryAdmin({ noTransaction: true });
// Manually strip prepare to exercise that branch too
const stripped = {
...admin,
prepare: undefined,
} as unknown as Parameters<typeof createTenantScopedWrapper>[0];
const scoped = createTenantScopedWrapper(stripped, ACME);
expect(scoped.prepare).toBeUndefined();
expect(scoped.transaction).toBeUndefined();
void BIG; // sentinel to keep BIG used for typing
});
it("custom tenantColumn flows through", { timeout: 5000 }, async () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME, {
tenantColumn: "org_id",
});
await scoped.query("SELECT * FROM users");
expect(admin.lastSql()).toContain("WHERE org_id = ?");
});
it("engine and tenantId surface on the wrapper", { timeout: 5000 }, () => {
const admin = createMemoryAdmin();
const scoped = createTenantScopedWrapper(admin, ACME);
expect(scoped.engine).toBe("sqlite");
expect(scoped.tenantId).toBe(ACME);
});
});

View File

@@ -0,0 +1,324 @@
import type {
AdminDatabaseLike,
DbParams,
DbTransaction,
PreparedStatement,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
/**
* Wrapper options. `autoInjectInserts: true` (default) means a payload lacking
* the tenant column is rewritten to include it; `false` rejects with
* `DB_ADAPTER.TENANT_SCOPE_VIOLATION`.
*/
export interface TenantScopedWrapperOptions {
readonly tenantColumn?: string;
readonly autoInjectInserts?: boolean;
}
const SELECT_RE = /^\s*select\b/i;
const UPDATE_RE = /^\s*update\b/i;
const DELETE_RE = /^\s*delete\b/i;
const INSERT_RE =
/^\s*insert\s+into\s+(\w+)\s*\(([^)]*)\)\s*values\s*\(([^)]*)\)/i;
interface RewriteResult {
readonly sql: string;
readonly params: ReadonlyArray<unknown>;
}
/**
* Pure SQL-rewrite helpers exposed for testing.
*/
export const rewriters = {
/**
* Inject `WHERE tenantColumn = ?` (or `AND tenantColumn = ?` when a WHERE
* already exists) into a SELECT/UPDATE/DELETE. The tenant value is
* appended to `params` as a positional bind. The injected SQL fragment
* is a single equality predicate using `?` to match SQLite, MySQL, and
* Postgres parameter syntax (driver-side rebinding handles `$N` if the
* engine requires it).
*/
injectWhere(
sql: string,
tenantColumn: string,
tenantId: string,
params: ReadonlyArray<unknown>
): RewriteResult {
const lower = sql.toLowerCase();
const idxWhere = lower.indexOf(" where ");
const idxReturning = lower.indexOf(" returning ");
const idxOrder = lower.indexOf(" order by ");
const idxLimit = lower.indexOf(" limit ");
const idxGroup = lower.indexOf(" group by ");
const tail = [idxReturning, idxOrder, idxLimit, idxGroup]
.filter((i) => i !== -1)
.sort((a, b) => a - b)[0];
let rewritten: string;
if (idxWhere !== -1) {
// Insert AND clause right after WHERE block start, preserving tail.
const whereEnd =
tail !== undefined && tail > idxWhere ? tail : sql.length;
const head = sql.slice(0, whereEnd).trimEnd();
const tailStr = sql.slice(whereEnd);
rewritten = `${head} AND ${tenantColumn} = ?${tailStr}`;
} else {
const cut = tail ?? sql.length;
const head = sql.slice(0, cut).trimEnd();
const tailStr = sql.slice(cut);
rewritten = `${head} WHERE ${tenantColumn} = ?${tailStr}`;
}
return {
sql: rewritten,
params: [...params, tenantId],
};
},
/**
* Inject (or verify) the tenant column on an INSERT. If `autoInject` is
* true and the column is absent, rewrite the column list + value list.
* If `autoInject` is false and the column is absent, throw. If the
* column is present with a value that conflicts with the resolved
* tenant id, throw.
*/
handleInsert(
sql: string,
tenantColumn: string,
tenantId: string,
params: ReadonlyArray<unknown>,
autoInject: boolean
): RewriteResult {
const m = sql.match(INSERT_RE);
if (m === null) {
// Bulk-insert / VALUES (..),(..) / INSERT ... SELECT — fall back
// to "no rewrite, no inspection" path; caller may use unsafe.
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
"INSERT shape not supported by tenant-scoped wrapper — use db.unsafe.query for multi-row INSERT or INSERT ... SELECT",
{ context: { sql } }
);
}
const [, table, colList, valList] = m as unknown as [
string,
string,
string,
string,
];
const cols = colList.split(",").map((c) => c.trim());
const vals = valList.split(",").map((v) => v.trim());
const colIdx = cols.findIndex(
(c) => c.toLowerCase() === tenantColumn.toLowerCase()
);
if (colIdx === -1) {
if (!autoInject) {
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
`INSERT into '${table}' is missing tenant column '${tenantColumn}' and autoInjectInserts is false`,
{ context: { sql, table } }
);
}
const newCols = [...cols, tenantColumn].join(", ");
const newVals = [...vals, "?"].join(", ");
return {
sql: sql.replace(
INSERT_RE,
`INSERT INTO ${table} (${newCols}) VALUES (${newVals})`
),
params: [...params, tenantId],
};
}
// Column present: confirm the bound value matches `tenantId`.
const placeholder = vals[colIdx] as string;
if (placeholder === "?" || /^\$\d+$/.test(placeholder)) {
// bind position colIdx in params array
const paramVal = params[colIdx];
if (paramVal !== undefined && paramVal !== tenantId) {
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
`INSERT bound tenant '${String(paramVal)}' does not match scope '${tenantId}'`,
{ context: { sql, bound: paramVal, expected: tenantId } }
);
}
} else {
// literal value
const literal = placeholder.replace(/^['"]|['"]$/g, "");
if (literal !== tenantId) {
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
`INSERT literal tenant '${literal}' does not match scope '${tenantId}'`,
{
context: {
sql,
literal,
expected: tenantId,
},
}
);
}
}
return { sql, params };
},
};
function rewriteForRead(
sql: string,
params: ReadonlyArray<unknown>,
tenantColumn: string,
tenantId: string
): RewriteResult {
return rewriters.injectWhere(sql, tenantColumn, tenantId, params);
}
function normalizeParams(params: DbParams | undefined): ReadonlyArray<unknown> {
if (params === undefined) return [];
if (Array.isArray(params)) return params;
// Record params — not supported by the tenant rewriter (positional only).
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
"tenant-scoped wrapper requires positional params; use db.unsafe.query for named-param SQL",
{ context: { paramsType: "record" } }
);
}
function dispatch(
sql: string,
params: DbParams | undefined,
tenantColumn: string,
tenantId: string,
autoInject: boolean
): RewriteResult {
const p = normalizeParams(params);
if (SELECT_RE.test(sql) || UPDATE_RE.test(sql) || DELETE_RE.test(sql)) {
return rewriteForRead(sql, p, tenantColumn, tenantId);
}
if (/^\s*insert\b/i.test(sql)) {
return rewriters.handleInsert(
sql,
tenantColumn,
tenantId,
p,
autoInject
);
}
// DDL or other — reject. Plugins must use migrations for DDL or unsafe.
throw new DbAdapterError(
DB_ADAPTER_CODES.TENANT_SCOPE_VIOLATION,
"tenant-scoped wrapper only accepts SELECT/INSERT/UPDATE/DELETE; use migrations for DDL or db.unsafe.query",
{ context: { sql } }
);
}
/**
* Wrap an admin adapter as a tenant-scoped surface. Every SELECT/UPDATE/DELETE
* has a `WHERE tenantColumn = ?` predicate appended; every INSERT either
* carries the tenant column with a matching value or is rejected (when
* `autoInjectInserts: false`) / auto-injected (when `true`).
*/
export function createTenantScopedWrapper(
admin: AdminDatabaseLike,
tenantId: TenantId,
options: TenantScopedWrapperOptions = {}
): TenantScopedDatabaseLike {
const tenantColumn = options.tenantColumn ?? "tenant_id";
const autoInject = options.autoInjectInserts ?? true;
const tenantString = tenantId as unknown as string;
const wrappedTx = (tx: DbTransaction): DbTransaction => ({
query: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await tx.query(r.sql, r.params);
},
execute: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await tx.execute(r.sql, r.params);
},
commit: () => tx.commit(),
rollback: () => tx.rollback(),
});
const scoped: TenantScopedDatabaseLike = {
engine: admin.engine,
tenantId,
query: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await admin.query(r.sql, r.params);
},
execute: async (sql, params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
return await admin.execute(r.sql, r.params);
},
};
if (admin.prepare !== undefined) {
const adminPrepare = admin.prepare.bind(admin);
const scopedPrepare = async <R>(
sql: string
): Promise<PreparedStatement<R>> => {
// Rewrite the SQL once at prepare time. The bound params provided
// at execute time must satisfy the rewriter's invariants, so we
// close over the rewriter and call it per execute.
const stmt = await adminPrepare<R>(sql);
return {
id: stmt.id,
execute: async (params) => {
const r = dispatch(
sql,
params,
tenantColumn,
tenantString,
autoInject
);
// SQL was rewritten, but the underlying prepared statement
// was created for the un-rewritten SQL. Fall back to a
// non-prepared execute on the admin surface for safety.
return (await admin.query(r.sql, r.params)) as R;
},
finalize: () => stmt.finalize(),
};
};
(scoped as { prepare?: typeof scopedPrepare }).prepare = scopedPrepare;
}
if (admin.transaction !== undefined) {
const adminTransaction = admin.transaction.bind(admin);
(
scoped as {
transaction?: <R>(
fn: (tx: DbTransaction) => Promise<R>
) => Promise<R>;
}
).transaction = async <R>(fn: (tx: DbTransaction) => Promise<R>) =>
await adminTransaction((tx) => fn(wrappedTx(tx)));
}
return scoped;
}

View File

@@ -0,0 +1,5 @@
export {
createTenantScopedWrapper,
rewriters,
type TenantScopedWrapperOptions,
} from "./create-tenant-scoped-wrapper.js";

View File

@@ -0,0 +1,7 @@
export {
createUnsafeGate,
getUnsafeGateInstruments,
resetUnsafeGateInstruments,
type UnsafeGateOptions,
type UnsafeQueryApi,
} from "./unsafe-query-gate.js";

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`".

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryAdmin } from "../../tests/_helpers/memory-admin.js";
import { DB_ADAPTER_CODES } from "../error-codes/index.js";
import {
createUnsafeGate,
getUnsafeGateInstruments,
resetUnsafeGateInstruments,
} from "./unsafe-query-gate.js";
describe("createUnsafeGate", () => {
it("throws when capability not declared (query)", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const gate = createUnsafeGate({
plugin: "audit",
capabilities: [],
admin,
});
await expect(gate.query("SELECT 1")).rejects.toMatchObject({
code: DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
});
});
it("throws when capability not declared (execute)", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const gate = createUnsafeGate({
plugin: "audit",
capabilities: [],
admin,
});
await expect(gate.execute("DELETE FROM x")).rejects.toMatchObject({
code: DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
});
});
it("emits plugin.unsafe_query_total when allowed (query)", {
timeout: 5000,
}, async () => {
resetUnsafeGateInstruments();
const admin = createMemoryAdmin();
await admin.execute("INSERT INTO t (id, tenant_id) VALUES (?, ?)", [
"a",
"x",
]);
const gate = createUnsafeGate({
plugin: "audit",
capabilities: ["db.unsafe-queries"],
admin,
});
const rows = await gate.query<unknown[]>("SELECT * FROM t");
expect(Array.isArray(rows)).toBe(true);
// counter exists & is wired
const i = getUnsafeGateInstruments();
expect(i.unsafeQueryTotal).toBeDefined();
});
it("execute path also counts and delegates", {
timeout: 5000,
}, async () => {
const admin = createMemoryAdmin();
const gate = createUnsafeGate({
plugin: "audit",
capabilities: ["db.unsafe-queries"],
admin,
});
const r = await gate.execute(
"INSERT INTO t (id, tenant_id) VALUES (?, ?)",
["a", "x"]
);
expect(r.rowsAffected).toBe(1);
});
it("getUnsafeGateInstruments is cached across calls", {
timeout: 5000,
}, () => {
resetUnsafeGateInstruments();
const a = getUnsafeGateInstruments();
const b = getUnsafeGateInstruments();
expect(a).toBe(b);
});
it("reset clears the cache", { timeout: 5000 }, () => {
const a = getUnsafeGateInstruments();
resetUnsafeGateInstruments();
const b = getUnsafeGateInstruments();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,90 @@
import type { Counter, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { AdminDatabaseLike, DbParams } from "@suaveplan/api-types/db";
import { DB_ADAPTER_CODES, DbAdapterError } from "../error-codes/index.js";
/**
* Public shape of `ctx.db.unsafe`. Always present on the tenant-scoped surface
* created by the host; calls are gated at runtime by the
* `db.unsafe-queries` capability.
*/
export interface UnsafeQueryApi {
query<R = unknown>(sql: string, params?: DbParams): Promise<R>;
execute(
sql: string,
params?: DbParams
): Promise<{ readonly rowsAffected: number }>;
}
/** Construction options for {@link createUnsafeGate}. */
export interface UnsafeGateOptions {
/** Plugin name — surfaced on the OTel counter label. */
readonly plugin: string;
/** Capability declarations from the plugin manifest. */
readonly capabilities: ReadonlyArray<string>;
/** Underlying admin adapter to delegate to when permitted. */
readonly admin: AdminDatabaseLike;
}
const UNSAFE_CAPABILITY = "db.unsafe-queries";
interface CachedInstruments {
readonly meter: Meter;
readonly unsafeQueryTotal: Counter;
}
let cached: CachedInstruments | undefined;
/** Lazily resolve OTel instruments — host may install a `MeterProvider` first. */
export function getUnsafeGateInstruments(): CachedInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-db-adapter");
cached = {
meter,
unsafeQueryTotal: meter.createCounter("plugin.unsafe_query_total", {
description:
"Number of db.unsafe.query / db.unsafe.execute calls made by a plugin.",
}),
};
return cached;
}
/** Test helper: reset the cached instruments so a new MeterProvider is picked up. */
export function resetUnsafeGateInstruments(): void {
cached = undefined;
}
/**
* Build the `db.unsafe` capability-gated escape hatch.
*
* - If `capabilities` does NOT include `db.unsafe-queries`, every call throws
* `DB_ADAPTER.UNSAFE_NOT_PERMITTED` without touching the engine.
* - If the capability IS declared, the call is delegated to `admin` and the
* `plugin.unsafe_query_total{plugin}` counter increments by 1.
*/
export function createUnsafeGate(opts: UnsafeGateOptions): UnsafeQueryApi {
const allowed = opts.capabilities.includes(UNSAFE_CAPABILITY);
const reject = (): never => {
throw new DbAdapterError(
DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
`plugin '${opts.plugin}' did not declare '${UNSAFE_CAPABILITY}' capability`,
{ context: { plugin: opts.plugin } }
);
};
return {
query: async <R>(sql: string, params?: DbParams): Promise<R> => {
if (!allowed) reject();
getUnsafeGateInstruments().unsafeQueryTotal.add(1, {
plugin: opts.plugin,
});
return await opts.admin.query<R>(sql, params);
},
execute: async (sql: string, params?: DbParams) => {
if (!allowed) reject();
getUnsafeGateInstruments().unsafeQueryTotal.add(1, {
plugin: opts.plugin,
});
return await opts.admin.execute(sql, params);
},
};
}

View File

@@ -0,0 +1,245 @@
import type {
AdminDatabaseLike,
DbParams,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
interface Row {
[k: string]: unknown;
}
/**
* Tiny SQL-shape-aware in-memory admin adapter for unit tests in
* `@suaveplan/api-db-adapter`. It does NOT implement real SQL; it parses
* the small set of statements emitted by the tenant-scope wrapper and the
* migration runner.
*
* Supported:
* - CREATE TABLE IF NOT EXISTS <name> (...columns...)
* - INSERT INTO <name> (cols) VALUES (?,?,?)
* - INSERT INTO <name> (cols) VALUES (?,?,?), (?,?,?), ... (multi-row not required)
* - SELECT * FROM <name> [WHERE col = ? [AND col = ?]*]
* - SELECT <col> FROM <name> WHERE plugin = ?
* - DELETE FROM <name> [WHERE ...]
* - UPDATE <name> SET col = ? [, ...] [WHERE ...]
* - ALTER TABLE <name> ADD COLUMN <col> <type>
* - arbitrary "fail-me" sentinel SQL via the `failOn` predicate
*/
export interface MemoryAdminOptions {
/** When called with the SQL, return `true` to throw. Used for failure tests. */
failOn?: (sql: string) => boolean;
/** Disable transaction support to exercise the no-tx code path. */
noTransaction?: boolean;
}
export function createMemoryAdmin(
options: MemoryAdminOptions = {}
): AdminDatabaseLike & {
readonly tables: ReadonlyMap<string, Row[]>;
lastSql(): string | undefined;
sqlLog(): ReadonlyArray<{ sql: string; params: ReadonlyArray<unknown> }>;
} {
const tables = new Map<string, Row[]>();
let isClosed = false;
const log: { sql: string; params: ReadonlyArray<unknown> }[] = [];
const trip = (sql: string): void => {
if (options.failOn?.(sql) === true) {
throw new Error(`memory-admin: failOn triggered for: ${sql}`);
}
};
const normParams = (p?: DbParams): unknown[] => {
if (p === undefined) return [];
if (Array.isArray(p)) return [...p];
return [];
};
const runStatement = (
sql: string,
params: unknown[]
): { rows?: Row[]; rowsAffected: number } => {
log.push({ sql, params });
trip(sql);
const trimmed = sql.trim();
// CREATE TABLE IF NOT EXISTS <name> ...
const createM = trimmed.match(
/^create\s+table\s+if\s+not\s+exists\s+(\w+)/i
);
if (createM !== null) {
const name = createM[1] ?? "";
if (!tables.has(name)) tables.set(name, []);
return { rowsAffected: 0 };
}
// ALTER TABLE <name> ADD COLUMN <col> <type>
const alterM = trimmed.match(
/^alter\s+table\s+(\w+)\s+add\s+column\s+(\w+)/i
);
if (alterM !== null) {
return { rowsAffected: 0 };
}
// INSERT INTO <name> (cols) VALUES (?, ?, ...)
const insertM = trimmed.match(
/^insert\s+into\s+(\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i
);
if (insertM !== null) {
const name = insertM[1] ?? "";
const cols = (insertM[2] ?? "").split(",").map((c) => c.trim());
const vals = (insertM[3] ?? "").split(",").map((v) => v.trim());
const row: Row = {};
let pi = 0;
for (let i = 0; i < cols.length; i++) {
const v = vals[i] ?? "?";
if (v === "?") {
row[cols[i] ?? ""] = params[pi++];
} else if (/^\$\d+$/.test(v)) {
const idx = Number(v.slice(1)) - 1;
row[cols[i] ?? ""] = params[idx];
} else {
row[cols[i] ?? ""] = v.replace(/^['"]|['"]$/g, "");
}
}
if (!tables.has(name)) tables.set(name, []);
tables.get(name)?.push(row);
return { rowsAffected: 1 };
}
// SELECT ... FROM <name> [WHERE col = ? [AND col = ?]*]
const selectM = trimmed.match(
/^select\s+(.+?)\s+from\s+(\w+)(?:\s+where\s+(.+?))?(?:\s+order\s+by\s+.+)?(?:\s+limit\s+\d+)?;?\s*$/i
);
if (selectM !== null) {
const select = selectM[1] ?? "*";
const name = selectM[2] ?? "";
const whereClause = selectM[3];
const rows = tables.get(name) ?? [];
let filtered = rows;
if (whereClause !== undefined) {
const preds = whereClause.split(/\s+and\s+/i).map((p) => {
const m = p.match(
/(\w+)\s*=\s*(\?|\$\d+|'[^']*'|"[^"]*"|\d+)/
);
return m === null
? null
: { col: m[1] ?? "", val: m[2] ?? "" };
});
let pi = 0;
const resolved = preds.map((pr) => {
if (pr === null) return null;
let v: unknown;
if (pr.val === "?") {
v = params[pi++];
} else if (/^\$\d+$/.test(pr.val)) {
v = params[Number(pr.val.slice(1)) - 1];
} else {
v = pr.val.replace(/^['"]|['"]$/g, "");
}
return { col: pr.col, v };
});
filtered = rows.filter((r) =>
resolved.every(
(pr) =>
pr === null || String(r[pr.col]) === String(pr.v)
)
);
}
if (select.trim() === "*") {
return { rows: filtered, rowsAffected: filtered.length };
}
const cols = select.split(",").map((c) => c.trim());
return {
rows: filtered.map((r) => {
const out: Row = {};
for (const c of cols) out[c] = r[c];
return out;
}),
rowsAffected: filtered.length,
};
}
// UPDATE — used by some tests
const updateM = trimmed.match(
/^update\s+(\w+)\s+set\s+(.+?)(?:\s+where\s+(.+))?$/i
);
if (updateM !== null) {
// For tests we only need to know that the rewriter appended a
// WHERE clause; we don't have to actually mutate rows.
return { rowsAffected: 0 };
}
// DELETE
const deleteM = trimmed.match(/^delete\s+from\s+(\w+)/i);
if (deleteM !== null) {
return { rowsAffected: 0 };
}
throw new Error(`memory-admin: unsupported SQL: ${sql}`);
};
const query = async <R = unknown>(
sql: string,
params?: DbParams
): Promise<R> => {
if (isClosed) throw new Error("memory-admin: closed");
const r = runStatement(sql, normParams(params));
return (r.rows ?? []) as unknown as R;
};
const execute = async (
sql: string,
params?: DbParams
): Promise<{ rowsAffected: number }> => {
if (isClosed) throw new Error("memory-admin: closed");
const r = runStatement(sql, normParams(params));
return { rowsAffected: r.rowsAffected };
};
const prepare = async <R = unknown>(
sql: string
): Promise<PreparedStatement<R>> => ({
id: `stmt:${sql}`,
execute: async (params) => (await query<R>(sql, params)) as R,
finalize: async () => undefined,
});
const transaction = async <R>(
fn: (tx: DbTransaction) => Promise<R>
): Promise<R> => {
const snapshot = new Map<string, Row[]>();
for (const [k, v] of tables) snapshot.set(k, [...v]);
const tx: DbTransaction = {
query,
execute,
commit: async () => undefined,
rollback: async () => undefined,
};
try {
return await fn(tx);
} catch (e) {
tables.clear();
for (const [k, v] of snapshot) tables.set(k, v);
throw e;
}
};
const noTx = options.noTransaction === true;
return {
engine: "sqlite",
query,
execute,
prepare,
...(noTx ? {} : { transaction }),
close: async () => {
isClosed = true;
},
get tables() {
return tables;
},
lastSql: () => log.at(-1)?.sql,
sqlLog: () => log,
};
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-db-adapter",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});