first commit

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

View File

@@ -0,0 +1,112 @@
# @suaveplan/api-db-worker-proxy - Worker-side proxy for the api `db` capability. Marshals AdminDatabaseLike / TenantScopedDatabaseLike calls — including transactions and cursor streams — over @suaveplan/api-worker-rpc MessagePort frames. Tenant scoping is enforced main-side; this package is the transport.
Worker-side proxy for the api `db` capability. Marshals
`AdminDatabaseLike` and `TenantScopedDatabaseLike` calls — including
transactions and cursor streams — over `@suaveplan/api-worker-rpc`
`MessagePort` frames.
The main side mounts a real DB adapter and exposes it over a port;
the worker side imports a proxy whose API surface is identical to a
direct adapter. Worker plugin code never touches a real connection
and never sees plaintext credentials.
**Tenant scoping is enforced on the main side**, NOT in this package.
`wrapTenantScoped(adminProxy, tenantId)` is purely a type-level facade
that prevents an admin reference from being passed where a tenant
reference is required by the type system.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `DB_PROXY.*` stable error-code registry for `@suaveplan/api-db-worker-proxy`. Every failure that c…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `DB_PROXY.*` stable error-code registry for `@suaveplan/api-db-worker-proxy`. Every failure that c…
- [main-side](./src/main-side/main-side.md) — This module exposes `createDbProxyServer(port, db)` — the trusted-side endpoint of the worker DB proxy. It mounts an `A…
- [main-side](./src/main-side/main-side.md) — This module exposes `createDbProxyServer(port, db)` — the trusted-side endpoint of the worker DB proxy. It mounts an `A…
- [tenant-scoped](./src/tenant-scoped/tenant-scoped.md) — This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a worker-side type-level facade that re-presents an admi…
- [tenant-scoped](./src/tenant-scoped/tenant-scoped.md) — This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a worker-side type-level facade that re-presents an admi…
- [testing](./src/testing/testing.md) — This module provides the in-memory test harness for `@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb…
- [testing](./src/testing/testing.md) — This module provides the in-memory test harness for `@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb…
- [worker-side](./src/worker-side/worker-side.md) — This module exposes `createDbProxyClient(port, { engine })` — the untrusted-side endpoint of the worker DB proxy. It re…
- [worker-side](./src/worker-side/worker-side.md) — This module exposes `createDbProxyClient(port, { engine })` — the untrusted-side endpoint of the worker DB proxy. It re…
<!-- end:auto-docs -->
## Why
Per the `api-plugin` foundation design (§5.1, §5.2a, §6) every
capability ships in two surfaces: a direct in-process binding for
trusted plugins and a `MessagePort`-marshalled binding for
isolated / sandboxed plugins. The `db` capability is special because
1. its operations return result sets that can be huge (favouring
streaming over buffering),
2. its operations are transactional (transaction handles MUST be
bound to a single connection lifetime), and
3. tenant-scoping is a security boundary the worker MUST NOT be able
to circumvent.
This package is Stage-3 worker-isolation infrastructure: every
Stage-4 DB adapter (`api-db-adapter`, `api-db-sqlite-memory`, …) MUST
pass conformance under BOTH the direct surface AND this proxy
surface before can be declared complete.
## Install
```bash
bun add @suaveplan/api-db-worker-proxy
```
## Quick start
```ts
// main side
import { createDbProxyServer } from "@suaveplan/api-db-worker-proxy/main-side";
import { MessageChannel, Worker } from "node:worker_threads";
const channel = new MessageChannel();
createDbProxyServer(channel.port1, realAdminAdapter);
const worker = new Worker("./plugin-worker.js");
worker.postMessage({ port: channel.port2, engine: "sqlite" }, [channel.port2]);
```
```ts
// worker side
import { parentPort } from "node:worker_threads";
import { createDbProxyClient } from "@suaveplan/api-db-worker-proxy/worker-side";
import { wrapTenantScoped } from "@suaveplan/api-db-worker-proxy/tenant-scoped";
import type { TenantId } from "@suaveplan/api-types/tenant";
parentPort?.once("message", (boot) => {
const admin = createDbProxyClient(boot.port, { engine: boot.engine });
// request scope
const tenantScoped = wrapTenantScoped(admin, "acme" as TenantId);
void runHandler(tenantScoped);
});
```
## Surface
- `./main-side` — `createDbProxyServer(port, db)`: serves an
`AdminDatabaseLike` over `@suaveplan/api-worker-rpc`. Returns a
`DbProxyServer` with `close()`, `activeTransactionCount()`,
`activePreparedCount()`.
- `./worker-side` — `createDbProxyClient(port, { engine })`: returns
an `AdminDatabaseLike`-shaped object plus `cursor(sql, params)`
for streaming row iteration and `dispose()` for abrupt teardown.
- `./tenant-scoped` — `wrapTenantScoped(adminProxy, tenantId)`:
type-level facade. Tenant scoping is enforced main-side.
- `./error-codes` — `DB_PROXY.*` codes:
`UNSAFE_NOT_PERMITTED`, `TRANSACTION_LOST`, `STREAM_INTERRUPTED`,
`PROXY_DISPOSED`.
- `./testing` — `createInMemoryAdminDb` + `createDbProxyHarness` for
downstream conformance suites.
## Co-located docs
- [`src/main-side/main-side.md`](./src/main-side/main-side.md)
- [`src/worker-side/worker-side.md`](./src/worker-side/worker-side.md)
- [`src/tenant-scoped/tenant-scoped.md`](./src/tenant-scoped/tenant-scoped.md)
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md)
- [`src/testing/testing.md`](./src/testing/testing.md)

View File

@@ -0,0 +1,81 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the `DB_PROXY.*` stable error-code registry for
`@suaveplan/api-db-worker-proxy`. Every failure that crosses the public
surface — proxy disposed, transaction lost, cursor stream interrupted,
unsafe-call attempt against a tenant-scoped wrapper — is raised as a
`DbProxyError` carrying one of these four codes so consumers can
discriminate without string-matching messages.
The codes are stable across the package lifecycle: once published, the
literal value is frozen. New codes are added by extending the
`defineCodes` tuple; never by renaming.
## Features
- `DB_PROXY_CODES` — frozen registry of fully-qualified code strings.
- `DbProxyCode` — string-literal union of every registered code.
- `DbProxyError` — typed subclass of `SuaveplanError<DbProxyCode>`
carrying the code, cause chain, and Zod-wire-safe serialisation
inherited from `@suaveplan/error`.
## Usage: Basic
```ts
import {
DB_PROXY_CODES,
DbProxyError,
} from "@suaveplan/api-db-worker-proxy/error-codes";
throw new DbProxyError(
DB_PROXY_CODES.PROXY_DISPOSED,
"cannot call .query() after .close()"
);
```
## Usage: Advanced
Branch on the code, not the message. The code is part of the public
contract; messages are free text and change without notice.
```ts
try {
await proxy.query("SELECT 1");
} catch (err) {
if (err instanceof DbProxyError) {
switch (err.code) {
case DB_PROXY_CODES.PROXY_DISPOSED:
return reopenProxy();
case DB_PROXY_CODES.TRANSACTION_LOST:
return retryUnit();
case DB_PROXY_CODES.STREAM_INTERRUPTED:
return reissueCursor();
case DB_PROXY_CODES.UNSAFE_NOT_PERMITTED:
throw err; // never retry an unsafe path.
}
}
throw err;
}
```
## API Reference
- `DB_PROXY_CODES: { UNSAFE_NOT_PERMITTED, TRANSACTION_LOST, STREAM_INTERRUPTED, PROXY_DISPOSED }` — frozen mapping of short names to fully-qualified code strings (`"DB_PROXY.<NAME>"`).
- `type DbProxyCode = (typeof DB_PROXY_CODES)[keyof typeof DB_PROXY_CODES]`.
- `class DbProxyError extends SuaveplanError<DbProxyCode>` — constructor `(code, message, options?)` where `options` accepts a `cause` and arbitrary `details`.
## Implementation Notes
The registry is built via `defineCodes("DB_PROXY", [...] as const)` so
that adding a code without updating both the runtime registry and the
type union is a compile error. `DbProxyError`'s `name` is set to
`"DbProxyError"` so structured logs and `errorToEnvelope` round-trip
correctly across the worker boundary — `@suaveplan/api-worker-rpc`'s
error-reconstruction layer will reconstruct the class by `code` even
when the original prototype isn't reachable on the other side.

View File

@@ -0,0 +1,85 @@
# `main-side`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes `createDbProxyServer(port, db)` — the trusted-side
endpoint of the worker DB proxy. It mounts an `AdminDatabaseLike`
adapter on a `MessagePort` and turns every worker-issued RPC frame
into a real call on the wrapped adapter. The main side is the only
side that touches the underlying DB driver; the worker code that
imported `@suaveplan/api-db-worker-proxy/worker-side` never sees a
real connection, never sees plaintext credentials, and cannot
fabricate a tenant identity.
The server keeps a registry of live transaction handles and prepared
statements keyed by id so the worker can drive them across multiple
round-trips without leaking driver handles into the worker's address
space.
## Features
- Marshals `query`, `execute`, `prepare`, `transaction`, `cursor`,
`engine`, and `close` over `@suaveplan/api-worker-rpc`.
- Streams cursor rows as `stream-chunk` frames; worker-side break
triggers `cancel` which the handler observes via its `AbortSignal`.
- Bounds transaction lifetimes by port: closing the server (or
receiving `goodbye`) raises `TRANSACTION_LOST` on every dangling
handle rather than leaking driver state.
- Exposes diagnostic counters `activeTransactionCount` and
`activePreparedCount`.
## Usage: Basic
```ts
import { createDbProxyServer } from "@suaveplan/api-db-worker-proxy/main-side";
import { Worker, MessageChannel } from "node:worker_threads";
const channel = new MessageChannel();
const server = createDbProxyServer(channel.port1, realAdapter);
const worker = new Worker("./worker.js");
worker.postMessage({ port: channel.port2 }, [channel.port2]);
```
## Usage: Advanced
When the host needs to scope the adapter per request, pass an
already-tenant-scoped adapter to a per-request server. The proxy
itself does NOT rewrite SQL — that responsibility lives in the
adapter the host hands in here. `wrapTenantScoped` on the worker
side is purely a type-level facade.
```ts
const scoped = realAdapter.scopedFor(tenantId); // host responsibility
const server = createDbProxyServer(port, scoped);
// shut down at request end:
server.close("request finished");
```
## API Reference
- `createDbProxyServer(port: PortLike, db: AdminDatabaseLike): DbProxyServer`.
- `DbProxyServer.close(reason?: string): void` — closes the server,
rolling back live transactions and finalising prepared statements.
- `DbProxyServer.activeTransactionCount(): number`.
- `DbProxyServer.activePreparedCount(): number`.
## Implementation Notes
The cursor handler returns an `AsyncGenerator`; `serveWorkerRpc`'s
streaming path interprets the return as `AsyncIterable<unknown>` and
emits one `stream-chunk` per yield, then `stream-end ok`. Cancellation
from the worker comes in as an `AbortSignal.aborted = true` on the
handler's `signal`; the generator throws `STREAM_INTERRUPTED` which
the server marshals as `stream-end ok=false` carrying the
`DB_PROXY.STREAM_INTERRUPTED` code.
Transactions are implemented by holding the adapter's `transaction()`
promise open until either `tx.commit` or `tx.rollback` fires from the
worker. Rolling back is signalled by throwing inside the adapter
callback, which mirrors the adapter's own rollback semantics without
adding a parallel state machine.

View File

@@ -0,0 +1,81 @@
# `tenant-scoped`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes `wrapTenantScoped(adminProxy, tenantId)` — a
worker-side type-level facade that re-presents an admin-surface
`AdminDatabaseLike` as a `TenantScopedDatabaseLike`. The facade
matters because the two surface types are deliberately
non-assignable in `@suaveplan/api-types`: a request handler declared
to accept `TenantScopedDatabaseLike` MUST NOT accidentally receive
an unscoped admin proxy, and vice versa.
**Tenant scoping is enforced on the main side.** The actual SQL
rewrite / row-level security / connection routing all live in the
host's adapter. This wrapper does NOT add policy; it asserts a
shape so the type system can guarantee the slot in the request
context has been narrowed to a tenant.
## Features
- Returns a fresh facade object on every call so the same admin
proxy can back many concurrent request scopes.
- Exposes `tenantId` on the returned object for audit logging.
- Preserves the optional `prepare` / `transaction` capabilities only
if the admin proxy advertised them; absent capabilities throw
`DB_PROXY.UNSAFE_NOT_PERMITTED`.
- `close` is intentionally omitted because
`TenantScopedDatabaseLike` does not expose it — the admin proxy
retains lifecycle ownership.
## Usage: Basic
```ts
import { wrapTenantScoped } from "@suaveplan/api-db-worker-proxy/tenant-scoped";
import type { TenantId } from "@suaveplan/api-types/tenant";
const tenantId = "acme" as TenantId;
const scoped = wrapTenantScoped(adminProxy, tenantId);
const rows = await scoped.query("SELECT * FROM invoices");
```
## Usage: Advanced
Use the facade inside a request middleware that materialises
`RequestContext.db`. The middleware constructs the wrapper from the
already-resolved `tenantId` so route handlers only ever see the
tenant-scoped type.
```ts
function attachDb(adminProxy: AdminDatabaseLike) {
return async (c: Context, next: Next): Promise<void> => {
const tenantId = c.get("tenantId") as TenantId;
const scoped = wrapTenantScoped(adminProxy, tenantId);
c.set("db", scoped);
await next();
};
}
```
## API Reference
- `wrapTenantScoped(adminProxy: AdminDatabaseLike, tenantId: TenantId): TenantScopedDatabaseLike`.
- Throws `DB_PROXY.UNSAFE_NOT_PERMITTED` from `prepare` / `transaction`
when the underlying admin proxy doesn't expose that capability.
## Implementation Notes
The wrapper rebinds `prepare` and `transaction` so the underlying
admin proxy's `this`-context survives the indirection (the methods
are looked up off the proxy each call, not captured eagerly, so
admin-side disposal is observed on the next call rather than at wrap
time).
The `tenantId` field is set on the returned object purely so audit
loggers and `RequestContext` consumers can read it directly. The
field name and type match `TenantScopedDatabaseLike.tenantId`
exactly; both are `TenantId` (a branded string).

View File

@@ -0,0 +1,97 @@
# `testing`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides the in-memory test harness for
`@suaveplan/api-db-worker-proxy`. It exposes `createInMemoryAdminDb`
— a deterministic `AdminDatabaseLike` backed by a `Map` — and
`createDbProxyHarness`, which pairs that adapter with a real
`MessageChannel`, a `createDbProxyServer` on one port, and a
`createDbProxyClient` on the other. Tests can drive the full
client-port-server-adapter chain end-to-end without a real DB and
without any mocking framework.
The harness exists in `src/testing/` (rather than `tests/`) so it
can be imported by downstream Stage-4 adapter packages from the
package's `"./testing"` subpath export.
## Features
- `createInMemoryAdminDb({ engine?, seed?, cursorDelayMs? })` — a
full `AdminDatabaseLike` implementation including `prepare`,
`transaction`, and a synthetic `SELECT-CURSOR *` that returns an
`AsyncIterable<MemoryRow>` for cursor streaming tests.
- `createDbProxyHarness(opts)` — bundles adapter + ports + server +
client into one disposable handle.
- Tiny command DSL (`INSERT <id>`, `DELETE <id>`, `SELECT *`,
`SELECT-CURSOR *`, `COUNT`, `SELECT-SCALAR`, `THROW`) chosen so
every code path in the proxy can be exercised without parsing real
SQL.
## Usage: Basic
```ts
import { createDbProxyHarness } from "@suaveplan/api-db-worker-proxy/testing";
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
expect(await h.client.query("SELECT *")).toEqual([{ v: 1 }]);
} finally {
await h.client.close();
h.close();
}
```
## Usage: Advanced
To assert that cursor cancellation propagates, set a per-row delay
on the in-memory cursor and abort partway through:
```ts
const h = createDbProxyHarness({
seed: Array.from({ length: 100 }, (_, i) => [String(i), { i }]),
cursorDelayMs: 5,
});
try {
const ac = new AbortController();
let n = 0;
for await (const _row of h.client.cursor("SELECT-CURSOR *", undefined, {
signal: ac.signal,
})) {
if (++n >= 3) {
ac.abort();
break;
}
}
expect(n).toBe(3);
} finally {
await h.client.close();
h.close();
}
```
## API Reference
- `createInMemoryAdminDb(opts?): InMemoryAdminDb` — `AdminDatabaseLike`
plus `rows: ReadonlyMap<string, MemoryRow>` and `closed(): boolean`
for assertions.
- `createDbProxyHarness(opts?): DbProxyHarness` — `{ db, server,
client, ports, close() }`.
- `InMemoryAdminDbOptions` — `{ engine?, seed?, cursorDelayMs? }`.
## Implementation Notes
The in-memory adapter ignores parameter placeholders for `INSERT` /
`DELETE` and instead encodes the row key in the SQL string. This is
intentional — the harness exists to exercise the proxy plumbing, not
to validate a real query language. Concrete adapters (Stage 4) ship
their own conformance suites under their own engine semantics.
The harness's `close()` does NOT call `client.close()` so tests can
choose between observing graceful close (`await client.close()`) and
abrupt disconnect (`client.dispose()`, then `harness.close()`). Both
paths are first-class.

View File

@@ -0,0 +1,96 @@
# `worker-side`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes `createDbProxyClient(port, { engine })` — the
untrusted-side endpoint of the worker DB proxy. It returns an
`AdminDatabaseLike`-shaped object whose methods marshal across a
`MessagePort` to the main side's `createDbProxyServer`. Worker plugin
code uses the returned proxy exactly as it would use a direct adapter
— `await db.query(...)`, `await db.transaction(async (tx) => ...)` —
without knowing the real connection lives in another process.
The factory is synchronous because `AdminDatabaseLike.engine` is a
synchronous getter; the caller supplies the engine identifier
up-front (the host knows which engine it has provisioned, so this is
free information).
## Features
- Full `AdminDatabaseLike` surface: `query`, `execute`, `prepare`,
`transaction`, `close`, `engine`.
- Bonus `cursor(sql, params)` returns an `AsyncIterable<unknown>`
backed by `@suaveplan/api-worker-rpc` streaming; consumer `break`
cancels the main-side cursor.
- `transaction(fn)` auto-commits on resolve and auto-rolls-back on
reject; the inner `tx` handle becomes invalid after the callback
resolves and any subsequent call throws `DB_PROXY.TRANSACTION_LOST`.
- `dispose()` (alongside the interface's `close`) tears down the
underlying port without invoking the main-side `close()` handler.
## Usage: Basic
```ts
import { createDbProxyClient } from "@suaveplan/api-db-worker-proxy/worker-side";
const db = createDbProxyClient(port, { engine: "sqlite" });
const rows = await db.query<MyRow[]>("SELECT * FROM users");
```
## Usage: Advanced
Streaming a large cursor without buffering everything in worker
memory:
```ts
const ac = new AbortController();
let seen = 0;
for await (const row of db.cursor("SELECT-CURSOR *", undefined, {
signal: ac.signal,
})) {
if (++seen >= 100) {
ac.abort();
break;
}
process(row);
}
```
`db.transaction` wires commit/rollback through the same port, so the
main-side transaction is bound to the worker's logical scope:
```ts
await db.transaction(async (tx) => {
await tx.execute("INSERT user-1", { name: "Alice" });
await tx.execute("INSERT user-2", { name: "Bob" });
}); // auto-commit
```
## API Reference
- `createDbProxyClient(port, opts): DbProxyClient` — extends
`AdminDatabaseLike` with `cursor(...)` and `dispose()`.
- `DbProxyClientOptions.engine: DatabaseEngine` — supplied by host.
- `DbProxyClient.cursor(sql, params?, { signal? }): AsyncIterable<unknown>`.
- `DbProxyClient.dispose(): void` — close port without server round-trip.
## Implementation Notes
Cursor iteration wraps `streamRequest` from
`@suaveplan/api-worker-rpc` so the same backpressure policy (default
64-frame bounded queue) governs row delivery. The wrapper translates
any non-`DbProxyError` exception coming up from the stream into
`DB_PROXY.STREAM_INTERRUPTED` so consumers can branch uniformly on
the proxy's error namespace.
`transaction(fn)` uses a closure-flag (`inScope`) to gate every `tx`
method; after the callback resolves and the proxy issues `tx.commit`,
the flag flips and any leaked reference to `tx` throws
`DB_PROXY.TRANSACTION_LOST`. The auto-commit is suppressed if the
callback already invoked `tx.commit` itself (the second commit
would otherwise raise `TRANSACTION_LOST` from the main side, which
this layer catches and silences).