Initial Commit
This commit is contained in:
128
packages/api-stack/api/api-db-adapter/README.md
Normal file
128
packages/api-stack/api/api-db-adapter/README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# @suaveplan/api-db-adapter — bridge from @suaveplan/db engine adapters to the plugin DatabaseLike surface. Provides AdminDatabaseLike + TenantScopedDatabaseLike factories, a per-engine migration runner, the db.unsafe-queries capability gate, an open DatabaseEngineRegistry, and a worker-proxy variant for the worker host. Both direct + proxy surfaces pass the same conformance suite.
|
||||
|
||||
Stage-4 bridge from the `@suaveplan/db` engine adapters to the
|
||||
plugin `AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces exposed by
|
||||
`@suaveplan/api-types/db`.
|
||||
|
||||
The package contains five orthogonal pieces:
|
||||
|
||||
1. `options` — Zod-validated `DbAdapterOptions` (`engine`, `url`, `pool?`,
|
||||
`tenantColumn`, `migrationsDir?`, `autoInjectInserts`).
|
||||
2. `engine-registry` — open registry of engine factories. The package
|
||||
itself ships no engines pre-installed; host code (or a downstream
|
||||
adapter package) calls `registerEngine("sqlite" | "postgres" |
|
||||
"surrealdb" | <merged>, factory)` once at boot. The shape keeps the
|
||||
package universally importable.
|
||||
3. `tenant-scoped` — the SQL rewriter that injects `WHERE tenant_id = ?`
|
||||
on every SELECT/UPDATE/DELETE, verifies / auto-injects the tenant
|
||||
column on INSERT, and rejects DDL or unsupported SQL with
|
||||
`DB_ADAPTER.TENANT_SCOPE_VIOLATION`. Strict / lenient INSERT mode is
|
||||
`autoInjectInserts` on the options.
|
||||
4. `migrations` — per-engine migration runner consuming a directory of
|
||||
`*.sql` files; tracks applied versions in `__plugin_migrations` and
|
||||
is idempotent on re-run. Failed migrations roll back where the engine
|
||||
supports transactions.
|
||||
5. `unsafe` — the `db.unsafe` escape hatch, gated on the
|
||||
`db.unsafe-queries` capability flag in the plugin manifest. Every
|
||||
permitted call increments `plugin.unsafe_query_total{plugin}`.
|
||||
|
||||
The package also ships a worker-side counterpart (`createDbAdapterWorkerProxy`)
|
||||
that wires the worker-side `api-db-worker-proxy` client into the same surface
|
||||
so direct + proxy variants are interchangeable in plugin code.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [engine-registry](./src/engine-registry/engine-registry.md) — The engine registry is the extension point that lets `api-db-adapter` stay universally importable while still supportin…
|
||||
- [engine-registry](./src/engine-registry/engine-registry.md) — The engine registry is the extension point that lets `api-db-adapter` stay universally importable while still supportin…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every public failure code raised by `@suaveplan/api-db-adapter`. Codes ar…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every public failure code raised by `@suaveplan/api-db-adapter`. Codes ar…
|
||||
- [factory](./src/factory/create-db-adapter.md) — `createDbAdapter` is the public entry point for host code that wants to wire the `@suaveplan/db` engine adapter…
|
||||
- [create-db-adapter](./src/factory/create-db-adapter.md) — `createDbAdapter` is the public entry point for host code that wants to wire the `@suaveplan/db` engine adapter…
|
||||
- [migrations](./src/migrations/run-migrations.md) — `runMigrations` applies a plugin's pending migrations from a directory of `*.sql` files, recording applied versions in…
|
||||
- [run-migrations](./src/migrations/run-migrations.md) — `runMigrations` applies a plugin's pending migrations from a directory of `*.sql` files, recording applied versions in…
|
||||
- [options](./src/options/db-adapter-options.md) — `DbAdapterOptions` is the single config object accepted by every public factory in this package — `createDbAdapter`, `c…
|
||||
- [db-adapter-options](./src/options/db-adapter-options.md) — `DbAdapterOptions` is the single config object accepted by every public factory in this package — `createDbAdapter`, `c…
|
||||
- [proxy](./src/proxy/create-db-adapter-worker-proxy.md) — `createDbAdapterWorkerProxy` is the worker-side counterpart to `createDbAdapter`. Inside an `api-worker-host` worker, p…
|
||||
- [create-db-adapter-worker-proxy](./src/proxy/create-db-adapter-worker-proxy.md) — `createDbAdapterWorkerProxy` is the worker-side counterpart to `createDbAdapter`. Inside an `api-worker-host` worker, p…
|
||||
- [tenant-scoped](./src/tenant-scoped/create-tenant-scoped-wrapper.md) — This module is the load-bearing safety boundary between a plugin route handler and the underlying admin database surfac…
|
||||
- [create-tenant-scoped-wrapper](./src/tenant-scoped/create-tenant-scoped-wrapper.md) — This module is the load-bearing safety boundary between a plugin route handler and the underlying admin database surfac…
|
||||
- [unsafe](./src/unsafe/unsafe-query-gate.md) — `createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant- scoped surface. The gate is the only sanctio…
|
||||
- [unsafe-query-gate](./src/unsafe/unsafe-query-gate.md) — `createUnsafeGate` builds the `ctx.db.unsafe` API exposed on every tenant- scoped surface. The gate is the only sanctio…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createDbAdapter } from "@suaveplan/api-db-adapter/adapter";
|
||||
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
|
||||
import { runMigrations } from "@suaveplan/api-db-adapter/migrations";
|
||||
|
||||
registerEngine("sqlite", async (opts) => myBunSqliteFactory(opts));
|
||||
|
||||
const db = await createDbAdapter({ engine: "sqlite", url: ":memory:" });
|
||||
await runMigrations({
|
||||
plugin: "audit",
|
||||
capabilities: [],
|
||||
admin: db.admin,
|
||||
migrationsDir: "./db/migrations",
|
||||
fs: nodeFsReader,
|
||||
});
|
||||
|
||||
const scoped = db.scoped("acme" as TenantId);
|
||||
const rows = await scoped.query<User[]>("SELECT * FROM users");
|
||||
// actual SQL: SELECT * FROM users WHERE tenant_id = ?
|
||||
// bound: ["acme"]
|
||||
```
|
||||
|
||||
## Tenant-scope semantics
|
||||
|
||||
The wrapper rewrites SQL based on the leading keyword (case-insensitive):
|
||||
|
||||
| Shape | Rewrite |
|
||||
|---|---|
|
||||
| `SELECT ... [WHERE ...]` | append `WHERE`/`AND tenant_id = ?` before any ORDER BY / LIMIT / RETURNING / GROUP BY tail |
|
||||
| `UPDATE ... [WHERE ...]` | same |
|
||||
| `DELETE ... [WHERE ...]` | same |
|
||||
| `INSERT INTO t (cols) VALUES (vals)` w/o `tenant_id` and `autoInjectInserts: true` | inject column + value |
|
||||
| same w/o `tenant_id` and `autoInjectInserts: false` | throw `TENANT_SCOPE_VIOLATION` |
|
||||
| `INSERT ...` w/ conflicting `tenant_id` (literal or bound) | throw `TENANT_SCOPE_VIOLATION` |
|
||||
| anything else (DDL, INSERT...SELECT, multi-row VALUES, record-shaped params) | throw `TENANT_SCOPE_VIOLATION` — use a migration or `db.unsafe` |
|
||||
|
||||
## `db.unsafe` escape hatch
|
||||
|
||||
`createUnsafeGate({ plugin, capabilities, admin })` returns an
|
||||
`UnsafeQueryApi` (`query`, `execute`). The capability check is array
|
||||
membership on `db.unsafe-queries`; absent → throw
|
||||
`DB_ADAPTER.UNSAFE_NOT_PERMITTED`. Permitted calls increment
|
||||
`plugin.unsafe_query_total{plugin}`.
|
||||
|
||||
## Migration directory layout
|
||||
|
||||
Files are read from `migrationsDir` and applied lexicographically. The
|
||||
recommended naming convention is `NNN_<short-description>.sql`:
|
||||
|
||||
```
|
||||
db/migrations/
|
||||
001_init.sql
|
||||
002_add_invoices.sql
|
||||
003_index_invoices_on_org.sql
|
||||
```
|
||||
|
||||
The runner records each applied file in `__plugin_migrations(plugin,
|
||||
version, applied_at)` and is idempotent — re-running with no new files is
|
||||
a no-op.
|
||||
|
||||
## Error codes
|
||||
|
||||
- `DB_ADAPTER.TENANT_SCOPE_VIOLATION` — scoped wrapper saw conflicting
|
||||
tenant value, DDL, or unsupported SQL shape.
|
||||
- `DB_ADAPTER.UNSAFE_NOT_PERMITTED` — `db.unsafe.*` invoked without
|
||||
`db.unsafe-queries` capability.
|
||||
- `DB_ADAPTER.MIGRATION_FAILED` — migration body threw; rolled back where
|
||||
supported.
|
||||
- `DB_ADAPTER.ENGINE_UNSUPPORTED` — `engine` value has no registered
|
||||
factory.
|
||||
- `DB_ADAPTER.INVALID_OPTIONS` — Zod validation failed; issues attached
|
||||
to `error.context.issues`.
|
||||
|
||||
109
packages/api-stack/api/api-db-adapter/package.json
Normal file
109
packages/api-stack/api/api-db-adapter/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# `engine-registry` — open registry mapping engine names to factories
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The engine registry is the extension point that lets `api-db-adapter` stay
|
||||
universally importable while still supporting per-engine concrete adapters
|
||||
(SQLite, Postgres, SurrealDB, plus declaration-merged third-party engines).
|
||||
The package itself ships zero engines pre-installed. Host code or the
|
||||
downstream adapter package calls `registerEngine(name, factory)` once at
|
||||
process boot; every subsequent `createDbAdapter({ engine: name, ... })` call
|
||||
delegates to that factory.
|
||||
|
||||
This shape avoids three failure modes: pulling `bun:sqlite` into a browser
|
||||
bundle, pulling `pg` into a worker, and pulling `surrealdb.js` into a CLI.
|
||||
|
||||
## Features
|
||||
|
||||
- `registerEngine(name, factory)` — register or replace a factory.
|
||||
- `unregisterEngine(name)` — primarily for test isolation.
|
||||
- `getEngineFactory(name)` — read-only lookup.
|
||||
- `listEngines()` — snapshot of all currently-registered names.
|
||||
- `buildEngineAdapter(options)` — high-level: look up + invoke + return the
|
||||
constructed `AdminDatabaseLike`. Throws `DB_ADAPTER.ENGINE_UNSUPPORTED`
|
||||
if the engine name has no factory.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerEngine,
|
||||
buildEngineAdapter,
|
||||
} from "@suaveplan/api-db-adapter/engine-registry";
|
||||
import { SqliteAdapter } from "@suaveplan/db-sqlite";
|
||||
|
||||
registerEngine("sqlite", async (options) => {
|
||||
const adapter = new SqliteAdapter({ filename: options.url });
|
||||
await adapter.connect();
|
||||
return adaptToAdminDatabaseLike(adapter, "sqlite");
|
||||
});
|
||||
|
||||
const admin = await buildEngineAdapter({
|
||||
engine: "sqlite",
|
||||
url: ":memory:",
|
||||
tenantColumn: "tenant_id",
|
||||
autoInjectInserts: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
|
||||
|
||||
// declaration-merge a new engine
|
||||
declare module "@suaveplan/api-types/db" {
|
||||
interface DatabaseEngineRegistry {
|
||||
readonly cockroach: true;
|
||||
}
|
||||
}
|
||||
|
||||
registerEngine("cockroach", async (options) => {
|
||||
// build a CockroachDB-backed AdminDatabaseLike from options
|
||||
return buildCockroachAdmin(options);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `EngineFactory: (options: DbAdapterOptions) => Promise<AdminDatabaseLike>`.
|
||||
- `registerEngine(name, factory): void` — register/replace.
|
||||
- `unregisterEngine(name): boolean` — remove; returns `true` if removed.
|
||||
- `getEngineFactory(name): EngineFactory | undefined`.
|
||||
- `listEngines(): readonly string[]`.
|
||||
- `buildEngineAdapter(options): Promise<AdminDatabaseLike>` — throws
|
||||
`DbAdapterError` with code `DB_ADAPTER.ENGINE_UNSUPPORTED` if the engine
|
||||
name was never registered.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is a process-wide singleton kept in module scope. That is the
|
||||
correct shape for a sealed-once-at-boot capability map. Tests use
|
||||
`unregisterEngine` for isolation; concurrent registration with the same name
|
||||
is well-defined (last writer wins). The error thrown on lookup miss includes
|
||||
`details.known: string[]` so operator log lines surface the typo immediately.
|
||||
@@ -0,0 +1,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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
buildEngineAdapter,
|
||||
type EngineFactory,
|
||||
getEngineFactory,
|
||||
listEngines,
|
||||
registerEngine,
|
||||
unregisterEngine,
|
||||
} from "./engine-registry.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `error-codes` — `DB_ADAPTER.*` registry + `DbAdapterError`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the single source of truth for every public failure code raised
|
||||
by `@suaveplan/api-db-adapter`. Codes are namespaced under `DB_ADAPTER.*` and
|
||||
exposed through both a `const` registry (`DB_ADAPTER_CODES`) and a typed
|
||||
`SuaveplanError` subclass (`DbAdapterError`).
|
||||
|
||||
Consumers branch on the literal value at runtime — never on the message text —
|
||||
which means the wire format of every error is stable across releases. The
|
||||
registry is consumed by the migration runner, the tenant-scoped wrapper, the
|
||||
options validator, the engine registry, and the `db.unsafe-queries` capability
|
||||
gate.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes covering every public failure path: `TENANT_SCOPE_VIOLATION`,
|
||||
`UNSAFE_NOT_PERMITTED`, `MIGRATION_FAILED`, `ENGINE_UNSUPPORTED`,
|
||||
`INVALID_OPTIONS`.
|
||||
- Single `DbAdapterError` class extending `SuaveplanError` — discriminable by
|
||||
`instanceof` or by code string.
|
||||
- `SuaveplanErrorOptions.cause` is honoured, so engine-side throws (e.g. a
|
||||
Postgres protocol error during migration) are preserved on the cause chain.
|
||||
- Compatible with the standard `SuaveplanError` wire format — no per-package
|
||||
deviations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DB_ADAPTER_CODES,
|
||||
DbAdapterError,
|
||||
} from "@suaveplan/api-db-adapter/error-codes";
|
||||
|
||||
throw new DbAdapterError(
|
||||
DB_ADAPTER_CODES.UNSAFE_NOT_PERMITTED,
|
||||
"plugin 'audit' does not declare db.unsafe-queries"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DB_ADAPTER_CODES,
|
||||
DbAdapterError,
|
||||
} from "@suaveplan/api-db-adapter/error-codes";
|
||||
|
||||
try {
|
||||
await runMigrations(plugin, capabilities);
|
||||
} catch (cause) {
|
||||
throw new DbAdapterError(
|
||||
DB_ADAPTER_CODES.MIGRATION_FAILED,
|
||||
`migration ${cause.file} failed`,
|
||||
{ cause, context: { file: cause.file } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DB_ADAPTER_CODES` — `const` registry of the five codes.
|
||||
- `DbAdapterCode` — union of all registry values.
|
||||
- `DbAdapterError` — subclass of `SuaveplanError<DbAdapterCode>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are declared via `defineCodes("DB_ADAPTER", [...])` which produces a
|
||||
frozen mapping `{ TENANT_SCOPE_VIOLATION: "DB_ADAPTER.TENANT_SCOPE_VIOLATION",
|
||||
... }`. Comparisons against the registry value never drift even if a future
|
||||
change reorders the input array because the keys are spelled identically to the
|
||||
prefixed value's tail.
|
||||
@@ -0,0 +1,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" });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DB_ADAPTER_CODES,
|
||||
type DbAdapterCode,
|
||||
DbAdapterError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,84 @@
|
||||
# `factory/create-db-adapter` — direct-surface adapter factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createDbAdapter` is the public entry point for host code that wants to wire
|
||||
the `@suaveplan/db` engine adapters to the plugin
|
||||
`AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces. It composes three
|
||||
pieces from this package — the options validator, the engine registry, and
|
||||
the tenant-scope wrapper — into a single callable that delegates engine
|
||||
identity to the open `DatabaseEngineRegistry`.
|
||||
|
||||
The result object exposes:
|
||||
|
||||
- `admin` — the unscoped `AdminDatabaseLike` (used by migrations and admin
|
||||
endpoints).
|
||||
- `scoped(tenantId)` — produces a fresh `TenantScopedDatabaseLike` wrapper
|
||||
over the same admin connection. The wrapper is stateless beyond the
|
||||
rewrite contract, so one admin connection can back many tenant scopes
|
||||
concurrently.
|
||||
- `close()` — closes the underlying admin connection.
|
||||
|
||||
## Features
|
||||
|
||||
- Options are parsed once at factory time; the rest of the API is typed
|
||||
against the *post-parse* `DbAdapterOptions`.
|
||||
- Engine lookup is delegated to `buildEngineAdapter`, so any
|
||||
declaration-merged engine works automatically.
|
||||
- Tenant scoping config (`tenantColumn`, `autoInjectInserts`) flows from
|
||||
the parsed options into every `scoped()` call — no per-call repetition.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDbAdapter } from "@suaveplan/api-db-adapter/adapter";
|
||||
import { registerEngine } from "@suaveplan/api-db-adapter/engine-registry";
|
||||
|
||||
registerEngine("sqlite", buildSqliteEngineFactory());
|
||||
|
||||
const db = await createDbAdapter({ engine: "sqlite", url: ":memory:" });
|
||||
const admin = db.admin;
|
||||
const acme = db.scoped("acme" as TenantId);
|
||||
const rows = await acme.query("SELECT * FROM users");
|
||||
await db.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const db = await createDbAdapter({
|
||||
engine: "postgres",
|
||||
url: "postgres://app@db.local/app",
|
||||
tenantColumn: "org_id",
|
||||
autoInjectInserts: false,
|
||||
pool: { min: 2, max: 16, idleMs: 60_000 },
|
||||
});
|
||||
|
||||
const tx = await db.scoped(tenantId).transaction!(async (tx) => {
|
||||
await tx.execute(
|
||||
"INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)",
|
||||
["inv_1", tenantId, 100]
|
||||
);
|
||||
return await tx.query<{ id: string }[]>("SELECT id FROM invoices");
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDbAdapter(input: DbAdapterOptionsInput): Promise<DbAdapter>`.
|
||||
- `DbAdapter.admin: AdminDatabaseLike`.
|
||||
- `DbAdapter.scoped(tenantId: TenantId): TenantScopedDatabaseLike`.
|
||||
- `DbAdapter.close(): Promise<void>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory does NOT register engines on import — it expects the host or a
|
||||
downstream adapter package to populate the registry before the first call.
|
||||
This shape keeps `@suaveplan/api-db-adapter` universally importable and
|
||||
prevents engine deps from leaking into bundles that never use them. Errors
|
||||
surface synchronously where possible (options validation, engine lookup);
|
||||
engine handshake errors arrive as the promise rejection.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createDbAdapter, type DbAdapter } from "./create-db-adapter.js";
|
||||
47
packages/api-stack/api/api-db-adapter/src/index.ts
Normal file
47
packages/api-stack/api/api-db-adapter/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
getMigrationInstruments,
|
||||
type MigrationsFsReader,
|
||||
type RunMigrationsOptions,
|
||||
type RunMigrationsResult,
|
||||
resetMigrationInstruments,
|
||||
runMigrations,
|
||||
} from "./run-migrations.js";
|
||||
@@ -0,0 +1,103 @@
|
||||
# `migrations/run-migrations` — per-engine migration runner
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runMigrations` applies a plugin's pending migrations from a directory of
|
||||
`*.sql` files, recording applied versions in the engine-side
|
||||
`__plugin_migrations(plugin, version, applied_at)` table. It is the single
|
||||
entry point used by `installing` step of the plugin lifecycle FSM; the
|
||||
plugin host calls it once per `(plugin, engine)` pair during boot.
|
||||
|
||||
The runner is engine-neutral: it issues only standard SQL (`CREATE TABLE
|
||||
IF NOT EXISTS`, `SELECT`, `INSERT`, plus the migration body). When the
|
||||
underlying adapter exposes `transaction`, each migration is applied
|
||||
inside an atomic transaction; on failure the transaction rolls back and
|
||||
the row in `__plugin_migrations` is not written.
|
||||
|
||||
## Features
|
||||
|
||||
- Lexicographic file ordering (`001_init.sql` before `002_*.sql`).
|
||||
- Idempotent re-run — applied versions are tracked in
|
||||
`__plugin_migrations`; a second call with the same file set applies
|
||||
zero migrations.
|
||||
- Per-migration `transaction { body + INSERT }` for engines that support
|
||||
transactions; flat `execute + execute` fall-back for those that
|
||||
don't (e.g. some Surreal modes).
|
||||
- OTel instruments: `api.db.migrations_applied_total{plugin}` counter +
|
||||
`api.db.migration_duration_ms{plugin}` histogram.
|
||||
- Injectable `fs: MigrationsFsReader` — tests use an in-memory map,
|
||||
production wires `node:fs/promises`.
|
||||
- On any failure: `DB_ADAPTER.MIGRATION_FAILED` with cause + details
|
||||
including the failing file name.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runMigrations } from "@suaveplan/api-db-adapter/migrations";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
const result = await runMigrations({
|
||||
plugin: "audit",
|
||||
capabilities: [],
|
||||
admin,
|
||||
migrationsDir: "./db/migrations",
|
||||
fs: {
|
||||
readdir: (dir) => fs.readdir(dir),
|
||||
readFile: (path) => fs.readFile(path, "utf8"),
|
||||
},
|
||||
});
|
||||
// result.applied = ["001_init.sql", "002_add_invoices.sql"]
|
||||
// result.skipped = [] // first run
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const memFs = {
|
||||
readdir: async () => ["001.sql", "002.sql"],
|
||||
readFile: async (path: string) =>
|
||||
path.endsWith("001.sql")
|
||||
? "CREATE TABLE users(id TEXT)"
|
||||
: "ALTER TABLE users ADD COLUMN email TEXT",
|
||||
};
|
||||
|
||||
const first = await runMigrations({
|
||||
plugin: "user",
|
||||
capabilities: [],
|
||||
admin,
|
||||
migrationsDir: "/dev/null",
|
||||
fs: memFs,
|
||||
});
|
||||
// first.applied.length === 2
|
||||
|
||||
const second = await runMigrations({
|
||||
plugin: "user",
|
||||
capabilities: [],
|
||||
admin,
|
||||
migrationsDir: "/dev/null",
|
||||
fs: memFs,
|
||||
});
|
||||
// second.applied.length === 0, second.skipped.length === 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runMigrations(options): Promise<RunMigrationsResult>`.
|
||||
- `RunMigrationsOptions` — `{ plugin, capabilities, admin, migrationsDir, fs }`.
|
||||
- `RunMigrationsResult` — `{ applied: string[]; skipped: string[] }`.
|
||||
- `MigrationsFsReader` — `{ readdir(dir); readFile(path) }`.
|
||||
- `getMigrationInstruments() / resetMigrationInstruments()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runner intentionally does not parse SQL inside the migration body — that
|
||||
is the engine's job. Failed migrations rely on `transaction` for atomic
|
||||
rollback; on engines without transactions, partial state is possible if the
|
||||
body succeeds but the `INSERT` into `__plugin_migrations` fails. That is
|
||||
out of scope for the runner and the responsibility of operator runbooks
|
||||
(`Surrealdb` mostly). The `applied_at` column is an ISO-8601 timestamp,
|
||||
matching the `MigrationCursor.appliedAt` shape on `@suaveplan/api-types/db`.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# `db-adapter-options` — Zod-validated configuration for `createDbAdapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DbAdapterOptions` is the single config object accepted by every public factory
|
||||
in this package — `createDbAdapter`, `createDbAdapterWorkerProxy`, and
|
||||
`runMigrations` all derive from it. The schema is Zod-driven so that host code
|
||||
catches configuration mistakes at construction time, before any TCP socket is
|
||||
opened.
|
||||
|
||||
The schema is intentionally narrow: engine, url, optional pool tuning, the
|
||||
tenant column name, an optional migrations directory, and a strict-mode flag
|
||||
that decides whether the tenant-scoped wrapper rejects or auto-injects `INSERT`
|
||||
payloads.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod schema with sensible defaults (`tenantColumn = "tenant_id"`,
|
||||
`autoInjectInserts = true`, pool max `10`, idle `30_000` ms).
|
||||
- `parseDbAdapterOptions(input)` — convenience wrapper that converts every Zod
|
||||
failure into a typed `DbAdapterError` with code `INVALID_OPTIONS`. Issues
|
||||
are surfaced through `error.context.issues`.
|
||||
- `engine` is `z.string().min(1)` — the *literal value* check happens against
|
||||
the open `DatabaseEngineRegistry` so declaration-merged engines pass.
|
||||
- `pool` is an optional sub-schema with its own defaults; absent values are
|
||||
forwarded as `undefined` to the engine layer rather than guessed.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseDbAdapterOptions } from "@suaveplan/api-db-adapter/options";
|
||||
|
||||
const options = parseDbAdapterOptions({
|
||||
engine: "sqlite",
|
||||
url: ":memory:",
|
||||
});
|
||||
// options.tenantColumn === "tenant_id"
|
||||
// options.autoInjectInserts === true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseDbAdapterOptions } from "@suaveplan/api-db-adapter/options";
|
||||
|
||||
const options = parseDbAdapterOptions({
|
||||
engine: "postgres",
|
||||
url: "postgres://app@db.local:5432/app",
|
||||
tenantColumn: "org_id",
|
||||
autoInjectInserts: false,
|
||||
pool: { min: 2, max: 16, idleMs: 60_000 },
|
||||
migrationsDir: "./db/migrations",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DbAdapterOptionsSchema` — top-level Zod schema.
|
||||
- `DbAdapterPoolSchema` — pool sub-schema with `min`, `max`, `idleMs`.
|
||||
- `DbAdapterOptions` — inferred output type (post-parse).
|
||||
- `DbAdapterOptionsInput` — inferred input type (pre-parse).
|
||||
- `parseDbAdapterOptions(input): DbAdapterOptions` — throws `DbAdapterError`
|
||||
with code `DB_ADAPTER.INVALID_OPTIONS` on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defaults are applied via `.default(...)` on each field so the output type is
|
||||
non-optional after parsing. Pool fields have their own defaults so `pool: {}`
|
||||
parses to the full default block; total absence (`pool` undefined) is preserved
|
||||
because it lets the engine layer pick its own defaults. Engine identity is
|
||||
validated separately by the engine registry so this module stays decoupled from
|
||||
the concrete `DatabaseEngineRegistry` keys.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type DbAdapterOptions,
|
||||
type DbAdapterOptionsInput,
|
||||
DbAdapterOptionsSchema,
|
||||
type DbAdapterPoolOptions,
|
||||
DbAdapterPoolSchema,
|
||||
parseDbAdapterOptions,
|
||||
} from "./db-adapter-options.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `proxy/create-db-adapter-worker-proxy` — worker-side adapter factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createDbAdapterWorkerProxy` is the worker-side counterpart to
|
||||
`createDbAdapter`. Inside an `api-worker-host` worker, plugin code receives a
|
||||
proxy that looks identical to the direct surface but marshals every call
|
||||
across an `@suaveplan/api-db-worker-proxy` `MessagePort`. The host side
|
||||
still owns the real engine connection and the tenant-scope rewriter; this
|
||||
package is the worker-side facade.
|
||||
|
||||
Cursor results survive the boundary as `ReadableStream` (transferable per
|
||||
the worker-rpc layer), so a 10_000-row query streams row-by-row without
|
||||
buffering. The tenant-scoped facade is structurally identical to the
|
||||
direct-surface one because the host's `wrapTenantScoped` already maps
|
||||
`AdminDatabaseLike` onto `TenantScopedDatabaseLike` without further SQL
|
||||
rewriting (rewriting happens main-side).
|
||||
|
||||
## Features
|
||||
|
||||
- Identical public shape to `DbAdapter` for parity: `admin`, `scoped()`,
|
||||
`close()`.
|
||||
- Built on `createDbProxyClient` from `api-db-worker-proxy/worker-side`,
|
||||
which manages RPC framing, cursor streaming, and stream cancellation.
|
||||
- Engine identity is supplied synchronously at construction (it would
|
||||
require a round-trip otherwise).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDbAdapterWorkerProxy } from "@suaveplan/api-db-adapter/proxy";
|
||||
|
||||
const proxy = createDbAdapterWorkerProxy({
|
||||
port: workerPort,
|
||||
engine: "sqlite",
|
||||
});
|
||||
|
||||
const rows = await proxy.scoped("acme" as TenantId).query("SELECT * FROM users");
|
||||
proxy.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createDbProxyHarness } from "@suaveplan/api-db-worker-proxy/testing";
|
||||
import { createDbAdapterWorkerProxy } from "@suaveplan/api-db-adapter/proxy";
|
||||
|
||||
const harness = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
|
||||
try {
|
||||
const proxy = createDbAdapterWorkerProxy({
|
||||
port: harness.ports.clientPort,
|
||||
engine: harness.db.engine,
|
||||
});
|
||||
const rows = await proxy.admin.query("SELECT *");
|
||||
proxy.close();
|
||||
} finally {
|
||||
harness.close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createDbAdapterWorkerProxy(options): DbAdapterWorkerProxy`.
|
||||
- `DbAdapterWorkerProxyOptions` — `{ port, engine, clientOptions? }`.
|
||||
- `DbAdapterWorkerProxy.admin: DbProxyClient` (admin + `cursor()`).
|
||||
- `DbAdapterWorkerProxy.scoped(tenantId): TenantScopedDatabaseLike`.
|
||||
- `DbAdapterWorkerProxy.close(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The proxy intentionally exposes the underlying `DbProxyClient` (extends
|
||||
`AdminDatabaseLike`) as `admin` so cursor streaming is reachable to
|
||||
consumers that need it. Tenant-scope rewriting is the main side's job; this
|
||||
facade only injects the `tenantId` brand into the worker-side type so route
|
||||
handlers cannot accidentally pass an admin reference where a scoped one is
|
||||
expected. `close()` calls `client.dispose()` which rejects in-flight calls
|
||||
with `DB_PROXY.PROXY_DISPOSED`.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
5
packages/api-stack/api/api-db-adapter/src/proxy/index.ts
Normal file
5
packages/api-stack/api/api-db-adapter/src/proxy/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createDbAdapterWorkerProxy,
|
||||
type DbAdapterWorkerProxy,
|
||||
type DbAdapterWorkerProxyOptions,
|
||||
} from "./create-db-adapter-worker-proxy.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `tenant-scoped/create-tenant-scoped-wrapper` — SQL rewriter for row-level tenancy
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is the load-bearing safety boundary between a plugin route handler
|
||||
and the underlying admin database surface. Every SELECT/UPDATE/DELETE that the
|
||||
route handler issues is rewritten in flight to carry a `WHERE tenant_id = ?`
|
||||
predicate; every INSERT either carries the tenant column or is rejected (or
|
||||
auto-injected, configurable). The contract is enforced *at the wrapper*, so a
|
||||
route handler literally cannot read another tenant's rows even if the plugin
|
||||
author is malicious.
|
||||
|
||||
The wrapper does not perform full SQL parsing; it performs a fast,
|
||||
case-insensitive lexical pass that handles the shapes a route handler
|
||||
realistically writes (simple FROM table, optional WHERE, ORDER BY, LIMIT,
|
||||
RETURNING). Anything beyond — multi-row INSERT, INSERT...SELECT, DDL, named
|
||||
params — is rejected with `TENANT_SCOPE_VIOLATION`, forcing the plugin author
|
||||
to either move the statement into a migration or declare the
|
||||
`db.unsafe-queries` capability.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-injects `WHERE tenant_id = ?` (or `AND tenant_id = ?` when WHERE
|
||||
already exists) on SELECT/UPDATE/DELETE. Tail clauses (ORDER BY, LIMIT,
|
||||
RETURNING, GROUP BY) are preserved.
|
||||
- INSERT handling: detect tenant column; if absent and `autoInjectInserts:
|
||||
true`, rewrite to include it; if absent and `false`, reject; if present
|
||||
with a conflicting value (literal or bound), reject.
|
||||
- Transactions: every `tx.query` and `tx.execute` inside the transaction
|
||||
callback is rewritten identically to top-level calls.
|
||||
- Prepared statements: rewriting happens at execute time (so the bound
|
||||
params are visible), falling back to a non-prepared execute on the
|
||||
admin surface.
|
||||
- Pure-function `rewriters` export for direct testing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
|
||||
|
||||
const scoped = createTenantScopedWrapper(admin, "acme" as TenantId);
|
||||
const rows = await scoped.query<User[]>("SELECT * FROM users");
|
||||
// executed SQL: SELECT * FROM users WHERE tenant_id = ?
|
||||
// bound params: ["acme"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
|
||||
|
||||
const strict = createTenantScopedWrapper(admin, tenantId, {
|
||||
tenantColumn: "org_id",
|
||||
autoInjectInserts: false,
|
||||
});
|
||||
|
||||
// INSERT must explicitly carry org_id matching the resolved tenant
|
||||
await strict.execute(
|
||||
"INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)",
|
||||
["inv_1", tenantId, 100]
|
||||
);
|
||||
|
||||
// rejected: literal doesn't match scope
|
||||
await strict
|
||||
.execute("INSERT INTO invoices (org_id) VALUES ('other')")
|
||||
.catch((e) => e.code === "DB_ADAPTER.TENANT_SCOPE_VIOLATION");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createTenantScopedWrapper(admin, tenantId, options?): TenantScopedDatabaseLike`.
|
||||
- `TenantScopedWrapperOptions` — `{ tenantColumn?, autoInjectInserts? }`.
|
||||
- `rewriters.injectWhere(sql, tenantColumn, tenantId, params)` — appends a
|
||||
WHERE/AND predicate.
|
||||
- `rewriters.handleInsert(sql, tenantColumn, tenantId, params, autoInject)`
|
||||
— verifies or auto-injects.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rewriter uses positional `?` parameters because they are accepted by
|
||||
SQLite, MySQL, and (with driver-side rebinding) Postgres. Engines requiring
|
||||
`$N` placeholders are expected to rebind at the driver layer. Record-shaped
|
||||
params and multi-row INSERTs are rejected explicitly rather than guessed;
|
||||
that pushes complex SQL into either a migration (which runs on the admin
|
||||
surface) or `db.unsafe.query` (capability-gated), keeping the rewriter
|
||||
narrow and auditable. Transaction callbacks receive a wrapped `DbTransaction`
|
||||
so the rewrite contract holds across the whole tx body.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTenantScopedWrapper,
|
||||
rewriters,
|
||||
type TenantScopedWrapperOptions,
|
||||
} from "./create-tenant-scoped-wrapper.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createUnsafeGate,
|
||||
getUnsafeGateInstruments,
|
||||
resetUnsafeGateInstruments,
|
||||
type UnsafeGateOptions,
|
||||
type UnsafeQueryApi,
|
||||
} from "./unsafe-query-gate.js";
|
||||
@@ -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`".
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
12
packages/api-stack/api/api-db-adapter/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-db-adapter/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-db-adapter/vite.config.ts
Normal file
30
packages/api-stack/api/api-db-adapter/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-db-adapter/vitest.config.ts
Normal file
30
packages/api-stack/api/api-db-adapter/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user