Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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,92 @@
{
"name": "@suaveplan/api-db-worker-proxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"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"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./main-side": {
"import": {
"types": "./dist/main-side/main-side.d.ts",
"default": "./dist/main-side/main-side.js"
}
},
"./worker-side": {
"import": {
"types": "./dist/worker-side/worker-side.d.ts",
"default": "./dist/worker-side/worker-side.js"
}
},
"./tenant-scoped": {
"import": {
"types": "./dist/tenant-scoped/tenant-scoped.d.ts",
"default": "./dist/tenant-scoped/tenant-scoped.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.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-worker-rpc": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,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,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_PROXY_CODES, DbProxyError } from "./error-codes.js";
describe("DB_PROXY error codes", () => {
it("exposes the four documented codes", { timeout: 5000 }, () => {
expect(DB_PROXY_CODES.UNSAFE_NOT_PERMITTED).toBe(
"DB_PROXY.UNSAFE_NOT_PERMITTED"
);
expect(DB_PROXY_CODES.TRANSACTION_LOST).toBe(
"DB_PROXY.TRANSACTION_LOST"
);
expect(DB_PROXY_CODES.STREAM_INTERRUPTED).toBe(
"DB_PROXY.STREAM_INTERRUPTED"
);
expect(DB_PROXY_CODES.PROXY_DISPOSED).toBe("DB_PROXY.PROXY_DISPOSED");
expect(Object.keys(DB_PROXY_CODES)).toHaveLength(4);
});
it("DbProxyError carries the code, name and cause", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new DbProxyError(
DB_PROXY_CODES.PROXY_DISPOSED,
"proxy is disposed",
{ cause }
);
expect(err).toBeInstanceOf(DbProxyError);
expect(err.name).toBe("DbProxyError");
expect(err.code).toBe("DB_PROXY.PROXY_DISPOSED");
expect(err.message).toBe("proxy is disposed");
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,47 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-db-worker-proxy`.
*
* Every public failure path raises a {@link DbProxyError} carrying one
* of these namespaced `DB_PROXY.*` codes. Consumers branch on the
* literal value (e.g. `error.code === DB_PROXY_CODES.PROXY_DISPOSED`).
*
* - `UNSAFE_NOT_PERMITTED` — a worker attempted to invoke an unscoped /
* admin-only method through a tenant-scoped wrapper, or otherwise to
* smuggle past the main-side capability gate.
* - `TRANSACTION_LOST` — the main-side transaction handle was lost
* (port closed mid-tx, main side disposed, network blip). Consumers
* should treat the in-flight unit of work as rolled back and retry.
* - `STREAM_INTERRUPTED` — a cursor stream terminated before completion
* without a clean `stream-end ok` frame. Surfaced through the
* `AsyncIterable` returned by `query`/cursor methods.
* - `PROXY_DISPOSED` — a method was called on a proxy after its `close()`
* was invoked or the underlying port was closed.
*/
export const DB_PROXY_CODES = defineCodes("DB_PROXY", [
"UNSAFE_NOT_PERMITTED",
"TRANSACTION_LOST",
"STREAM_INTERRUPTED",
"PROXY_DISPOSED",
] as const);
/** Union of every fully-qualified code in {@link DB_PROXY_CODES}. */
export type DbProxyCode = (typeof DB_PROXY_CODES)[keyof typeof DB_PROXY_CODES];
/**
* Typed error class for `@suaveplan/api-db-worker-proxy`. Extends
* {@link SuaveplanError} so callers may discriminate via either
* `instanceof DbProxyError` or by code string.
*/
export class DbProxyError extends SuaveplanError<DbProxyCode> {
public constructor(
code: DbProxyCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "DbProxyError";
}
}

View File

@@ -0,0 +1,5 @@
export {
DB_PROXY_CODES,
type DbProxyCode,
DbProxyError,
} from "./error-codes.js";

View File

@@ -0,0 +1,23 @@
export {
DB_PROXY_CODES,
type DbProxyCode,
DbProxyError,
} from "./error-codes/index.js";
export {
createDbProxyServer,
type DbProxyServer,
} from "./main-side/index.js";
export { wrapTenantScoped } from "./tenant-scoped/index.js";
export {
createDbProxyHarness,
createInMemoryAdminDb,
type DbProxyHarness,
type InMemoryAdminDb,
type InMemoryAdminDbOptions,
type MemoryRow,
} from "./testing/index.js";
export {
createDbProxyClient,
type DbProxyClient,
type DbProxyClientOptions,
} from "./worker-side/index.js";

View File

@@ -0,0 +1,4 @@
export {
createDbProxyServer,
type DbProxyServer,
} from "./main-side.js";

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,501 @@
import type { AdminDatabaseLike } from "@suaveplan/api-types/db";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
import { createDbProxyHarness } from "../testing/testing.js";
import { createDbProxyServer } from "./main-side.js";
describe("createDbProxyServer", () => {
it("forwards query → adapter → response", { timeout: 5000 }, async () => {
const h = createDbProxyHarness({
seed: [
["a", { id: "a", v: 1 }],
["b", { id: "b", v: 2 }],
],
});
try {
const rows =
await h.client.query<ReadonlyArray<unknown>>("SELECT *");
expect(rows).toHaveLength(2);
} finally {
await h.client.close();
h.close();
}
});
it("forwards execute and returns rowsAffected", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
const result = await h.client.execute("INSERT a", { id: "a" });
expect(result.rowsAffected).toBe(1);
expect(h.db.rows.get("a")).toEqual({ id: "a" });
} finally {
await h.client.close();
h.close();
}
});
it("propagates adapter errors as DbProxyError-shaped envelopes", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await expect(h.client.query("THROW")).rejects.toThrow();
} finally {
await h.client.close();
h.close();
}
});
it("close handler closes the underlying adapter", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await h.client.close();
expect(h.db.closed()).toBe(true);
} finally {
h.close();
}
});
it("rejects prepare when adapter does not implement it", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
// Surgery: strip prepare from underlying adapter so the server path
// returns UNSAFE_NOT_PERMITTED.
(h.db as unknown as { prepare?: unknown }).prepare = undefined;
try {
await expect(h.client.prepare?.("SELECT *")).rejects.toThrow(
/UNSAFE_NOT_PERMITTED|not support prepared/
);
} finally {
await h.client.close();
h.close();
}
});
it("rejects transaction when adapter does not implement it", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
(h.db as unknown as { transaction?: unknown }).transaction = undefined;
try {
await expect(
h.client.transaction?.(async () => undefined)
).rejects.toThrow(/UNSAFE_NOT_PERMITTED|not support transactions/);
} finally {
await h.client.close();
h.close();
}
});
it("close on the server empties the active-transactions registry", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
// Start a transaction; assert server tracks it; then close
// the server and verify the tracking map is emptied so the
// main side does not leak transaction handles.
let txGate: (() => void) | undefined;
const callbackDone = new Promise<void>((resolve) => {
txGate = resolve;
});
const txPromise = h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { id: "a" });
expect(h.server.activeTransactionCount()).toBe(1);
txGate?.();
// Hold the callback open until the test signals.
await new Promise((r) => setTimeout(r, 50));
});
await callbackDone;
h.server.close("forced");
expect(h.server.activeTransactionCount()).toBe(0);
// The hanging auto-commit will reject when the client port
// is disposed below; attach a swallow so the unhandled
// rejection does not leak across tests.
txPromise?.catch(() => undefined);
} finally {
h.client.dispose();
h.close();
}
});
it("prepare lifecycle: open, execute, finalize", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
const stmt = await h.client.prepare?.("SELECT *");
expect(stmt).toBeDefined();
if (stmt === undefined) throw new Error("prepare returned");
const rows = await stmt.execute();
expect(Array.isArray(rows)).toBe(true);
await stmt.finalize();
await stmt.finalize(); // idempotent on the worker side
} finally {
await h.client.close();
h.close();
}
});
it("activeTransactionCount and activePreparedCount expose live handles", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
expect(h.server.activeTransactionCount()).toBe(0);
expect(h.server.activePreparedCount()).toBe(0);
const stmt = await h.client.prepare?.("SELECT *");
expect(h.server.activePreparedCount()).toBe(1);
await stmt?.finalize();
expect(h.server.activePreparedCount()).toBe(0);
} finally {
await h.client.close();
h.close();
}
});
it("cursor streams rows in order", { timeout: 5000 }, async () => {
const h = createDbProxyHarness({
seed: Array.from(
{ length: 10 },
(_, i) => [String(i), { i }] as const
),
});
try {
const collected: number[] = [];
for await (const row of h.client.cursor("SELECT-CURSOR *")) {
collected.push((row as { i: number }).i);
}
expect(collected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
} finally {
await h.client.close();
h.close();
}
});
it("cursor cancellation aborts the main-side iterator", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({
seed: Array.from(
{ length: 50 },
(_, i) => [String(i), { i }] as const
),
cursorDelayMs: 5,
});
try {
const ac = new AbortController();
let count = 0;
try {
for await (const _row of h.client.cursor(
"SELECT-CURSOR *",
undefined,
{ signal: ac.signal }
)) {
count += 1;
if (count >= 3) ac.abort();
}
} catch {
// Expected: stream is cancelled.
}
expect(count).toBeGreaterThanOrEqual(3);
expect(count).toBeLessThan(50);
} finally {
await h.client.close().catch(() => undefined);
h.close();
}
});
it("cursor on scalar result yields a single chunk", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
const seen: unknown[] = [];
for await (const row of h.client.cursor("SELECT-SCALAR")) {
seen.push(row);
}
expect(seen).toEqual([1]);
} finally {
await h.client.close();
h.close();
}
});
it("cursor on sync iterable yields each row", {
timeout: 5000,
}, async () => {
// Construct a DB whose query returns a plain sync iterable.
const dbWithSyncIter: AdminDatabaseLike = {
engine: "sqlite",
query: async () => [1, 2, 3][Symbol.iterator](),
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
};
const { createInProcessPortPair } = await import(
"@suaveplan/api-worker-rpc"
);
const ports = createInProcessPortPair();
const { createDbProxyServer } = await import("./main-side.js");
const { createDbProxyClient } = await import(
"../worker-side/worker-side.js"
);
const server = createDbProxyServer(ports.serverPort, dbWithSyncIter);
const client = createDbProxyClient(ports.clientPort, {
engine: "sqlite",
});
try {
const collected: unknown[] = [];
for await (const row of client.cursor("SELECT-CURSOR *")) {
collected.push(row);
}
expect(collected).toEqual([1, 2, 3]);
} finally {
await client.close().catch(() => undefined);
server.close();
ports.close();
}
});
it("cursor on null result emits no chunks", { timeout: 5000 }, async () => {
const dbWithNull: AdminDatabaseLike = {
engine: "sqlite",
query: async () => null as unknown as never,
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
};
const { createInProcessPortPair } = await import(
"@suaveplan/api-worker-rpc"
);
const ports = createInProcessPortPair();
const { createDbProxyServer } = await import("./main-side.js");
const { createDbProxyClient } = await import(
"../worker-side/worker-side.js"
);
const server = createDbProxyServer(ports.serverPort, dbWithNull);
const client = createDbProxyClient(ports.clientPort, {
engine: "sqlite",
});
try {
const collected: unknown[] = [];
for await (const row of client.cursor("SELECT-CURSOR *")) {
collected.push(row);
}
expect(collected).toEqual([]);
} finally {
await client.close().catch(() => undefined);
server.close();
ports.close();
}
});
it("server side throws DB_PROXY error for sync-iterable abort", {
timeout: 5000,
}, async () => {
// sanity check DbProxyError export round-trips
const err = new DbProxyError(DB_PROXY_CODES.STREAM_INTERRUPTED, "test");
expect(err.code).toBe(DB_PROXY_CODES.STREAM_INTERRUPTED);
});
it("server ensureTransaction rejects unknown id with TRANSACTION_LOST", {
timeout: 5000,
}, async () => {
const { createInProcessPortPair, createRawWorkerRpcClient } =
await import("@suaveplan/api-worker-rpc");
const db = (
await import("../testing/testing.js")
).createInMemoryAdminDb();
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const client = createRawWorkerRpcClient(ports.clientPort);
try {
await expect(
client.call("tx.query", [9999, "SELECT *", undefined])
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
await expect(
client.call("tx.execute", [9999, "INSERT a", { v: 1 }])
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
await expect(
client.call("tx.commit", [9999])
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
await expect(
client.call("tx.rollback", [9999])
).rejects.toMatchObject({ code: DB_PROXY_CODES.TRANSACTION_LOST });
} finally {
client.close();
server.close();
ports.close();
}
});
it("server ensurePrepared rejects unknown id with PROXY_DISPOSED", {
timeout: 5000,
}, async () => {
const { createInProcessPortPair, createRawWorkerRpcClient } =
await import("@suaveplan/api-worker-rpc");
const db = (
await import("../testing/testing.js")
).createInMemoryAdminDb();
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const client = createRawWorkerRpcClient(ports.clientPort);
try {
await expect(
client.call("prepare.execute", [9999, {}])
).rejects.toMatchObject({ code: DB_PROXY_CODES.PROXY_DISPOSED });
await expect(
client.call("prepare.finalize", [9999])
).rejects.toMatchObject({ code: DB_PROXY_CODES.PROXY_DISPOSED });
} finally {
client.close();
server.close();
ports.close();
}
});
it("worker calls engine handler when no engine option provided", {
timeout: 5000,
}, async () => {
const { createInProcessPortPair, createRawWorkerRpcClient } =
await import("@suaveplan/api-worker-rpc");
const { createInMemoryAdminDb } = await import("../testing/testing.js");
const db = createInMemoryAdminDb({ engine: "postgres" });
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const client = createRawWorkerRpcClient(ports.clientPort);
try {
expect(await client.call("engine", [])).toBe("postgres");
} finally {
client.close();
server.close();
ports.close();
}
});
it("close handler swallows prepared.finalize failures", {
timeout: 5000,
}, async () => {
// Custom adapter whose prepare returns a stmt whose finalize
// throws; the server's close handler MUST silently absorb that
// error so cleanup of remaining handles still happens.
const { createInProcessPortPair } = await import(
"@suaveplan/api-worker-rpc"
);
let dbClosed = false;
const adapter: AdminDatabaseLike = {
engine: "sqlite",
query: async () => undefined,
execute: async () => ({ rowsAffected: 0 }),
close: async () => {
dbClosed = true;
},
prepare: async () => ({
id: "s1",
execute: async () => undefined,
finalize: async () => {
throw new Error("finalize boom");
},
}),
};
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, adapter);
const { createDbProxyClient } = await import(
"../worker-side/worker-side.js"
);
const client = createDbProxyClient(ports.clientPort, {
engine: "sqlite",
});
try {
// Open a prepared statement so the server tracks it,
// then call close on the client. The server's close
// handler iterates and finalises each, swallowing the
// explosion.
await client.prepare?.("SELECT *");
await client.close();
expect(dbClosed).toBe(true);
} finally {
server.close();
ports.close();
}
});
it("close handler rolls back live transactions", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
// Start a tx + capture so the server's transactions Map is
// non-empty, then call .close() on the worker proxy. The
// server's close handler iterates and settles each entry.
let txGate: (() => void) | undefined;
const txReady = new Promise<void>((r) => {
txGate = r;
});
const promise = h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
txGate?.();
await new Promise((r) => setTimeout(r, 50));
});
await txReady;
// close() forwards to server which iterates active txs.
const closePromise = h.client.close();
await Promise.race([
closePromise,
new Promise((r) => setTimeout(r, 100)),
]);
void promise?.catch(() => undefined);
} finally {
h.close();
}
});
it("commit twice surfaces TRANSACTION_LOST from server", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await expect(
h.client.transaction?.(async (tx) => {
await tx.commit();
await tx.commit();
})
).rejects.toThrow();
} finally {
await h.client.close().catch(() => undefined);
h.close();
}
});
it("rollback works as a first-class exit", { timeout: 5000 }, async () => {
const h = createDbProxyHarness();
try {
await h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { id: "a" });
await tx.rollback();
});
// adapter is a no-op rollback so the row stays; that's expected.
} finally {
await h.client.close();
h.close();
}
});
it("close on server also finalises preprared statements", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await h.client.prepare?.("SELECT *");
expect(h.server.activePreparedCount()).toBe(1);
await h.client.close();
expect(h.db.closed()).toBe(true);
} finally {
h.close();
}
});
});

View File

@@ -0,0 +1,290 @@
import type {
AdminDatabaseLike,
DbParams,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
import type { PortLike } from "@suaveplan/api-worker-rpc";
import {
serveWorkerRpc,
type WorkerRpcHandlers,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
/**
* Mounted server handle returned by {@link createDbProxyServer}.
*
* Calling `close()` tears down the underlying worker-rpc server and
* aborts every in-flight transaction / prepared statement / cursor so
* that the worker side cleanly observes `TRANSACTION_LOST` /
* `STREAM_INTERRUPTED` / `PROXY_DISPOSED` rather than hanging.
*/
export interface DbProxyServer extends WorkerRpcServer {
/** Diagnostic count of live transaction handles bound to this port. */
readonly activeTransactionCount: () => number;
/** Diagnostic count of live prepared-statement handles. */
readonly activePreparedCount: () => number;
}
/**
* Mount an {@link AdminDatabaseLike} on a `MessagePort` so that a
* `@suaveplan/api-db-worker-proxy` client on the other side can drive
* it. Tenant scoping is NOT enforced here — the caller MUST pass an
* already-tenant-scoped adapter when this server backs a tenant
* request context. Pass the raw admin adapter only for plugin-context
* (migration / admin) work.
*
* The server marshals these methods:
*
* - `query(sql, params)` → result (regular RPC call).
* - `execute(sql, params)` → `{ rowsAffected }`.
* - `prepare(sql)` → handle id; subsequent `prepare.execute(id, params)`
* and `prepare.finalize(id)` are routed to the same statement.
* - `transaction()` → opens a transaction; returns a handle id which
* the worker side uses for `tx.query` / `tx.execute` / `tx.commit` /
* `tx.rollback`. The handle is bound to this port's lifetime.
* - `cursor(sql, params)` → opens a streaming cursor; rows flow as
* `stream-chunk` frames. Cursor close on the worker side (break out
* of `for await`) propagates as cancel and finalises the cursor.
* - `close()` → closes the underlying admin adapter and the port.
* - `engine` is sent as a hello-ish reply on the `engine` method.
*/
export function createDbProxyServer(
port: PortLike,
db: AdminDatabaseLike
): DbProxyServer {
let txCounter = 0;
let preparedCounter = 0;
const transactions = new Map<
number,
{
readonly tx: DbTransaction;
/** Resolves the transaction-runner promise when commit/rollback fires. */
readonly settle: (kind: "commit" | "rollback") => void;
/** Tracks whether commit/rollback already fired so re-use returns LOST. */
disposed: boolean;
}
>();
const prepared = new Map<number, PreparedStatement>();
const ensureTransaction = (
id: number
): { readonly tx: DbTransaction; readonly disposed: boolean } => {
const entry = transactions.get(id);
if (entry === undefined || entry.disposed) {
throw new DbProxyError(
DB_PROXY_CODES.TRANSACTION_LOST,
`transaction ${id} is no longer valid`
);
}
return entry;
};
const ensurePrepared = (id: number): PreparedStatement => {
const stmt = prepared.get(id);
if (stmt === undefined) {
throw new DbProxyError(
DB_PROXY_CODES.PROXY_DISPOSED,
`prepared statement ${id} has been finalised`
);
}
return stmt;
};
const handlers: WorkerRpcHandlers = {
engine: () => db.engine,
query: async (params: ReadonlyArray<unknown>) => {
const [sql, sqlParams] = params as [string, DbParams | undefined];
return await db.query(sql, sqlParams);
},
execute: async (params: ReadonlyArray<unknown>) => {
const [sql, sqlParams] = params as [string, DbParams | undefined];
return await db.execute(sql, sqlParams);
},
"prepare.open": async (params: ReadonlyArray<unknown>) => {
if (db.prepare === undefined) {
throw new DbProxyError(
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
"adapter does not support prepared statements"
);
}
const [sql] = params as [string];
const stmt = await db.prepare(sql);
preparedCounter += 1;
const id = preparedCounter;
prepared.set(id, stmt);
return { id };
},
"prepare.execute": async (params: ReadonlyArray<unknown>) => {
const [id, sqlParams] = params as [number, DbParams | undefined];
const stmt = ensurePrepared(id);
return await stmt.execute(sqlParams);
},
"prepare.finalize": async (params: ReadonlyArray<unknown>) => {
const [id] = params as [number];
const stmt = ensurePrepared(id);
await stmt.finalize();
prepared.delete(id);
return null;
},
"tx.begin": async () => {
if (db.transaction === undefined) {
throw new DbProxyError(
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
"adapter does not support transactions"
);
}
txCounter += 1;
const id = txCounter;
let settle: (kind: "commit" | "rollback") => void;
const runTransaction = db.transaction;
const txReady = new Promise<DbTransaction>((resolveTx) => {
void runTransaction(async (tx) => {
const finished = new Promise<"commit" | "rollback">(
(resolveSettle) => {
settle = resolveSettle;
}
);
resolveTx(tx);
const kind = await finished;
if (kind === "rollback") {
// re-throw so the adapter's transaction() rolls back.
throw new DbProxyError(
DB_PROXY_CODES.TRANSACTION_LOST,
"rollback requested"
);
}
}).catch(() => {
// adapter's transaction() rejects on rollback; the
// worker has already been told via the response
// frame so this catch only prevents an unhandled.
});
});
const tx = await txReady;
transactions.set(id, {
tx,
settle: (kind) => {
settle(kind);
},
disposed: false,
});
return { id };
},
"tx.query": async (params: ReadonlyArray<unknown>) => {
const [id, sql, sqlParams] = params as [
number,
string,
DbParams | undefined,
];
const entry = ensureTransaction(id);
return await entry.tx.query(sql, sqlParams);
},
"tx.execute": async (params: ReadonlyArray<unknown>) => {
const [id, sql, sqlParams] = params as [
number,
string,
DbParams | undefined,
];
const entry = ensureTransaction(id);
return await entry.tx.execute(sql, sqlParams);
},
"tx.commit": async (params: ReadonlyArray<unknown>) => {
const [id] = params as [number];
const entry = transactions.get(id);
if (entry === undefined || entry.disposed) {
throw new DbProxyError(
DB_PROXY_CODES.TRANSACTION_LOST,
`transaction ${id} is no longer valid`
);
}
entry.disposed = true;
transactions.delete(id);
await entry.tx.commit();
entry.settle("commit");
return null;
},
"tx.rollback": async (params: ReadonlyArray<unknown>) => {
const [id] = params as [number];
const entry = transactions.get(id);
if (entry === undefined || entry.disposed) {
throw new DbProxyError(
DB_PROXY_CODES.TRANSACTION_LOST,
`transaction ${id} is no longer valid`
);
}
entry.disposed = true;
transactions.delete(id);
await entry.tx.rollback();
entry.settle("rollback");
return null;
},
cursor: async function* (
params: ReadonlyArray<unknown>,
signal: AbortSignal
): AsyncIterable<unknown> {
const [sql, sqlParams] = params as [string, DbParams | undefined];
// The adapter's `query` returns a value of unknown shape. Treat
// it as iterable (sync or async) and stream rows; non-iterable
// results are yielded as a single chunk.
const result = await db.query(sql, sqlParams);
if (result === null || result === undefined) return;
const asyncIter = (
result as {
[Symbol.asyncIterator]?: () => AsyncIterator<unknown>;
}
)[Symbol.asyncIterator];
const syncIter = (
result as { [Symbol.iterator]?: () => Iterator<unknown> }
)[Symbol.iterator];
if (typeof asyncIter === "function") {
// Async iteration honours abort by checking the signal
// before every yield; sync iteration runs to completion
// (it is too fast to meaningfully cancel from outside).
for await (const row of result as AsyncIterable<unknown>) {
if (signal.aborted) {
throw new DbProxyError(
DB_PROXY_CODES.STREAM_INTERRUPTED,
"cursor cancelled by worker"
);
}
yield row;
}
return;
}
if (typeof syncIter === "function") {
for (const row of result as Iterable<unknown>) yield row;
return;
}
yield result;
},
close: async () => {
for (const entry of transactions.values()) {
entry.disposed = true;
entry.settle("rollback");
}
transactions.clear();
for (const stmt of prepared.values()) {
await stmt.finalize().catch(() => undefined);
}
prepared.clear();
await db.close();
return null;
},
};
const inner = serveWorkerRpc(port, handlers);
return {
close(reason?: string): void {
for (const entry of transactions.values()) {
entry.disposed = true;
entry.settle("rollback");
}
transactions.clear();
inner.close(reason);
},
activeTransactionCount: () => transactions.size,
activePreparedCount: () => prepared.size,
};
}

View File

@@ -0,0 +1 @@
export { wrapTenantScoped } from "./tenant-scoped.js";

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,121 @@
import type {
AdminDatabaseLike,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { wrapTenantScoped } from "./tenant-scoped.js";
const fakeAdmin = (): AdminDatabaseLike => ({
engine: "sqlite",
query: async () => "ok",
execute: async () => ({ rowsAffected: 1 }),
prepare: async () =>
({
id: "p",
execute: async () => "row",
finalize: async () => undefined,
}) as PreparedStatement<unknown>,
transaction: async (fn) =>
await fn({
query: async () => "tx-row",
execute: async () => ({ rowsAffected: 0 }),
commit: async () => undefined,
rollback: async () => undefined,
} as DbTransaction),
close: async () => undefined,
});
describe("wrapTenantScoped", () => {
it("returns a TenantScopedDatabaseLike with the resolved tenantId", {
timeout: 5000,
}, () => {
const admin = fakeAdmin();
const tid = "acme" as TenantId;
const scoped = wrapTenantScoped(admin, tid);
expect(scoped.engine).toBe("sqlite");
expect(scoped.tenantId).toBe(tid);
});
it("forwards query and execute to the admin proxy", {
timeout: 5000,
}, async () => {
const admin = fakeAdmin();
const scoped = wrapTenantScoped(admin, "x" as TenantId);
expect(await scoped.query("SELECT *")).toBe("ok");
expect(await scoped.execute("INSERT a", {})).toEqual({
rowsAffected: 1,
});
});
it("forwards prepare when admin exposes it", {
timeout: 5000,
}, async () => {
const admin = fakeAdmin();
const scoped = wrapTenantScoped(admin, "x" as TenantId);
const stmt = await scoped.prepare?.("SELECT *");
expect(stmt).toBeDefined();
expect(await stmt?.execute()).toBe("row");
await stmt?.finalize();
});
it("forwards transaction when admin exposes it", {
timeout: 5000,
}, async () => {
const admin = fakeAdmin();
const scoped = wrapTenantScoped(admin, "x" as TenantId);
const result = await scoped.transaction?.(async (tx) => {
return (await tx.query("SELECT *")) as string;
});
expect(result).toBe("tx-row");
});
it("omits prepare entirely when admin lacks it", { timeout: 5000 }, () => {
const admin = fakeAdmin();
(admin as { prepare?: unknown }).prepare = undefined;
const scoped = wrapTenantScoped(admin, "x" as TenantId);
expect(scoped.prepare).toBeUndefined();
});
it("omits transaction entirely when admin lacks it", {
timeout: 5000,
}, () => {
const admin = fakeAdmin();
(admin as { transaction?: unknown }).transaction = undefined;
const scoped = wrapTenantScoped(admin, "x" as TenantId);
expect(scoped.transaction).toBeUndefined();
});
it("returns a fresh facade for each call", { timeout: 5000 }, () => {
const admin = fakeAdmin();
const a = wrapTenantScoped(admin, "x" as TenantId);
const b = wrapTenantScoped(admin, "x" as TenantId);
expect(a).not.toBe(b);
});
it("late-removed prepare on admin surfaces through prepared facade", {
timeout: 5000,
}, async () => {
// wrapping with a present prepare returns a closure that rebinds
// on every call; if the admin's prepare is later removed, the
// closure observes UNSAFE_NOT_PERMITTED.
const admin = fakeAdmin();
const scoped = wrapTenantScoped(admin, "x" as TenantId);
(admin as { prepare?: unknown }).prepare = undefined;
await expect(scoped.prepare?.("SELECT *")).rejects.toThrow(
/UNSAFE_NOT_PERMITTED|not support/
);
});
it("late-removed transaction on admin surfaces through facade", {
timeout: 5000,
}, async () => {
const admin = fakeAdmin();
const scoped = wrapTenantScoped(admin, "x" as TenantId);
(admin as { transaction?: unknown }).transaction = undefined;
await expect(
scoped.transaction?.(async () => undefined)
).rejects.toThrow(/UNSAFE_NOT_PERMITTED|not support/);
});
});

View File

@@ -0,0 +1,72 @@
import type {
AdminDatabaseLike,
DbTransaction,
PreparedStatement,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
/**
* Wrap an admin-surface proxy as a {@link TenantScopedDatabaseLike}.
*
* **Tenant scoping is enforced on the main side.** This wrapper is
* the worker-side type-level facade that prevents an admin reference
* from being passed where a tenant-scoped one is expected. The
* resolved tenant id is reachable on the returned object so handlers
* can include it in audit logs, but the wrapper does NOT rewrite SQL
* — that is the main-side adapter's job.
*
* `close()` is intentionally absent from the returned object because
* `TenantScopedDatabaseLike` does not expose it. The wrapper's
* lifetime is bound to the underlying admin proxy; the host disposes
* the admin proxy when the request scope ends.
*
* `wrapTenantScoped(adminProxy, tenantId)` always returns a fresh
* facade — the same admin proxy may back many tenant scopes (one per
* request) without interference because the wrapper is stateless.
*/
export function wrapTenantScoped(
adminProxy: AdminDatabaseLike,
tenantId: TenantId
): TenantScopedDatabaseLike {
const ensurePrepare = (): NonNullable<AdminDatabaseLike["prepare"]> => {
if (adminProxy.prepare === undefined) {
throw new DbProxyError(
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
"underlying adapter does not support prepared statements"
);
}
return adminProxy.prepare.bind(adminProxy);
};
const ensureTransaction = (): NonNullable<
AdminDatabaseLike["transaction"]
> => {
if (adminProxy.transaction === undefined) {
throw new DbProxyError(
DB_PROXY_CODES.UNSAFE_NOT_PERMITTED,
"underlying adapter does not support transactions"
);
}
return adminProxy.transaction.bind(adminProxy);
};
const prepareFn = adminProxy.prepare
? async <R = unknown>(sql: string): Promise<PreparedStatement<R>> =>
await ensurePrepare()<R>(sql)
: undefined;
const transactionFn = adminProxy.transaction
? async <R>(fn: (tx: DbTransaction) => Promise<R>): Promise<R> =>
await ensureTransaction()(fn)
: undefined;
return {
engine: adminProxy.engine,
tenantId,
query: (sql, params) => adminProxy.query(sql, params),
execute: (sql, params) => adminProxy.execute(sql, params),
...(prepareFn !== undefined ? { prepare: prepareFn } : {}),
...(transactionFn !== undefined ? { transaction: transactionFn } : {}),
} as TenantScopedDatabaseLike;
}

View File

@@ -0,0 +1,8 @@
export {
createDbProxyHarness,
createInMemoryAdminDb,
type DbProxyHarness,
type InMemoryAdminDb,
type InMemoryAdminDbOptions,
type MemoryRow,
} from "./testing.js";

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,150 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDbProxyHarness, createInMemoryAdminDb } from "./testing.js";
describe("createInMemoryAdminDb", () => {
it("accepts INSERT params as array (positional)", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb();
await db.execute("INSERT x", [{ v: 9 }]);
expect(db.rows.get("x")).toEqual({ v: 9 });
});
it("accepts INSERT with no params", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb();
await db.execute("INSERT y");
expect(db.rows.get("y")).toEqual({});
});
it("supports INSERT / DELETE / COUNT / SELECT", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb();
expect(await db.execute("INSERT a", { v: 1 })).toEqual({
rowsAffected: 1,
});
expect(await db.execute("COUNT")).toEqual({ rowsAffected: 1 });
expect(await db.query("SELECT *")).toEqual([{ v: 1 }]);
expect(await db.execute("DELETE a")).toEqual({ rowsAffected: 1 });
expect(await db.execute("DELETE a")).toEqual({ rowsAffected: 0 });
});
it("throws on THROW directive (query and execute)", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb();
await expect(db.query("THROW")).rejects.toThrow();
await expect(db.execute("THROW")).rejects.toThrow();
});
it("throws on unknown sql", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb();
await expect(db.query("UNKNOWN")).rejects.toThrow();
await expect(db.execute("UNKNOWN")).rejects.toThrow();
});
it("close marks closed and guards further use", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb();
await db.close();
expect(db.closed()).toBe(true);
await expect(db.query("SELECT *")).rejects.toThrow();
await expect(db.execute("COUNT")).rejects.toThrow();
await expect(db.prepare?.("SELECT *")).rejects.toThrow();
await expect(db.transaction?.(async () => undefined)).rejects.toThrow();
});
it("SELECT-CURSOR yields each seeded row", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb({
seed: [
["a", { v: 1 }],
["b", { v: 2 }],
],
});
const iter = (await db.query(
"SELECT-CURSOR *"
)) as AsyncIterable<unknown>;
const collected: unknown[] = [];
for await (const row of iter) collected.push(row);
expect(collected).toEqual([{ v: 1 }, { v: 2 }]);
});
it("SELECT-CURSOR honours cursorDelayMs", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb({
seed: [["a", { v: 1 }]],
cursorDelayMs: 1,
});
const iter = (await db.query(
"SELECT-CURSOR *"
)) as AsyncIterable<unknown>;
const t0 = Date.now();
for await (const _row of iter) void _row;
expect(Date.now() - t0).toBeGreaterThanOrEqual(0);
});
it("SELECT-SCALAR returns row count", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb({ seed: [["a", { v: 1 }]] });
expect(await db.query("SELECT-SCALAR")).toBe(1);
});
it("prepare on INSERT routes through execute path", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb();
const stmt = await db.prepare?.("INSERT a");
expect(stmt).toBeDefined();
const res = await stmt?.execute({ v: 9 });
expect(res).toEqual({ rowsAffected: 1 });
await stmt?.finalize();
await expect(stmt?.execute({})).rejects.toThrow();
});
it("prepare on SELECT routes through query path", {
timeout: 5000,
}, async () => {
const db = createInMemoryAdminDb({ seed: [["a", { v: 1 }]] });
const stmt = await db.prepare?.("SELECT *");
expect(Array.isArray(await stmt?.execute())).toBe(true);
await stmt?.finalize();
});
it("transaction provides a working tx", { timeout: 5000 }, async () => {
const db = createInMemoryAdminDb();
await db.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
const rows = (await tx.query("SELECT *")) as ReadonlyArray<unknown>;
expect(rows).toHaveLength(1);
await tx.commit();
await tx.rollback();
});
});
it("custom engine is honoured", { timeout: 5000 }, () => {
const db = createInMemoryAdminDb({ engine: "postgres" });
expect(db.engine).toBe("postgres");
});
});
describe("createDbProxyHarness", () => {
it("wires the in-memory db to a real proxy round-trip", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
expect(h.client.engine).toBe("sqlite");
expect(await h.client.query("SELECT *")).toEqual([{ v: 1 }]);
} finally {
await h.client.close();
h.close();
}
});
it("close on harness does not require closing the client first", {
timeout: 5000,
}, () => {
const h = createDbProxyHarness();
h.close();
// No assertion needed: must not throw.
});
});

View File

@@ -0,0 +1,216 @@
import type {
AdminDatabaseLike,
DatabaseEngine,
DbParams,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
import {
createInProcessPortPair,
type InProcessPortPair,
} from "@suaveplan/api-worker-rpc";
import {
createDbProxyServer,
type DbProxyServer,
} from "../main-side/main-side.js";
import {
createDbProxyClient,
type DbProxyClient,
} from "../worker-side/worker-side.js";
/** Row shape used by {@link createInMemoryAdminDb}. */
export interface MemoryRow {
readonly [column: string]: unknown;
}
/**
* A trivial `AdminDatabaseLike` backed by a `Map<string, MemoryRow>`.
*
* Supports a tiny command DSL chosen for tests, not real SQL:
*
* - `"SELECT *"` — returns every row as an array.
* - `"SELECT-CURSOR *"` — returns an `AsyncIterable<MemoryRow>` so the
* server's `cursor` handler can exercise streaming.
* - `"INSERT <id>"` with params `[row]` — inserts a row at key `<id>`.
* - `"DELETE <id>"` — deletes the row at `<id>`.
* - `"COUNT"` — returns `{ rowsAffected }` reflecting current size.
* - `"THROW"` — throws so tests can drive the failure path.
*/
export interface InMemoryAdminDb extends AdminDatabaseLike {
/** Direct read access to the underlying store (for assertions). */
readonly rows: ReadonlyMap<string, MemoryRow>;
/** Closed flag (for assertions). */
readonly closed: () => boolean;
}
export interface InMemoryAdminDbOptions {
readonly engine?: DatabaseEngine;
/** Per-row delay used by `SELECT-CURSOR *` (ms). Default 0. */
readonly cursorDelayMs?: number;
/** Pre-populate rows. */
readonly seed?: ReadonlyArray<readonly [string, MemoryRow]>;
}
export function createInMemoryAdminDb(
opts: InMemoryAdminDbOptions = {}
): InMemoryAdminDb {
const rows = new Map<string, MemoryRow>();
for (const [k, v] of opts.seed ?? []) rows.set(k, v);
let isClosed = false;
const engine = opts.engine ?? "sqlite";
const cursorDelay = opts.cursorDelayMs ?? 0;
const guard = (): void => {
if (isClosed) throw new Error("memory-db: closed");
};
const runExecute = async (
sql: string,
params?: DbParams
): Promise<{ rowsAffected: number }> => {
guard();
if (sql.startsWith("INSERT ")) {
const id = sql.slice(7).trim();
const payload =
params !== undefined && Array.isArray(params)
? (params[0] as MemoryRow)
: ((params as MemoryRow | undefined) ?? ({} as MemoryRow));
rows.set(id, payload);
return { rowsAffected: 1 };
}
if (sql.startsWith("DELETE ")) {
const id = sql.slice(7).trim();
const had = rows.delete(id);
return { rowsAffected: had ? 1 : 0 };
}
if (sql === "COUNT") return { rowsAffected: rows.size };
if (sql === "THROW")
throw new Error("memory-db: explicit THROW for tests");
throw new Error(`memory-db: unknown execute sql ${sql}`);
};
const runQuery = async <R = unknown>(
sql: string,
_params?: DbParams
): Promise<R> => {
guard();
if (sql === "SELECT *") return [...rows.values()] as unknown as R;
if (sql === "SELECT-CURSOR *") {
const snapshot = [...rows.values()];
const delay = cursorDelay;
const iter: AsyncIterable<MemoryRow> = {
async *[Symbol.asyncIterator]() {
for (const row of snapshot) {
if (delay > 0) {
await new Promise((r) => setTimeout(r, delay));
}
yield row;
}
},
};
return iter as unknown as R;
}
if (sql === "SELECT-SCALAR") return rows.size as unknown as R;
if (sql === "THROW")
throw new Error("memory-db: explicit THROW for tests");
throw new Error(`memory-db: unknown query sql ${sql}`);
};
const prepare = async <R = unknown>(
sql: string
): Promise<PreparedStatement<R>> => {
guard();
let finalised = false;
return {
id: `stmt:${sql}`,
execute: async (params) => {
if (finalised) throw new Error("memory-db: stmt finalised");
if (sql.startsWith("INSERT") || sql.startsWith("DELETE")) {
return (await runExecute(sql, params)) as unknown as R;
}
return await runQuery<R>(sql, params);
},
finalize: async () => {
finalised = true;
},
};
};
const transaction = async <R>(
fn: (tx: DbTransaction) => Promise<R>
): Promise<R> => {
guard();
const tx: DbTransaction = {
query: (sql, params) => runQuery(sql, params),
execute: (sql, params) => runExecute(sql, params),
commit: async () => undefined,
rollback: async () => undefined,
};
return await fn(tx);
};
return {
get engine() {
return engine;
},
get rows() {
return rows as ReadonlyMap<string, MemoryRow>;
},
closed: () => isClosed,
query: runQuery,
execute: runExecute,
prepare,
transaction,
close: async () => {
isClosed = true;
},
};
}
/**
* Paired in-process server + worker-side proxy backed by a fresh
* {@link createInMemoryAdminDb}. The harness owns the port pair and
* the server; the caller owns the returned {@link DbProxyClient}.
*
* Typical usage in a test:
*
* ```ts
* const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
* try {
* const rows = await h.client.query("SELECT *");
* expect(rows).toEqual([{ v: 1 }]);
* } finally {
* await h.client.close();
* h.close();
* }
* ```
*/
export interface DbProxyHarness {
readonly db: InMemoryAdminDb;
readonly server: DbProxyServer;
readonly client: DbProxyClient;
readonly ports: InProcessPortPair;
/** Closes the server and the port pair (does NOT close the client). */
close(): void;
}
export function createDbProxyHarness(
opts: InMemoryAdminDbOptions = {}
): DbProxyHarness {
const db = createInMemoryAdminDb(opts);
const ports = createInProcessPortPair();
const server = createDbProxyServer(ports.serverPort, db);
const client = createDbProxyClient(ports.clientPort, {
engine: db.engine,
});
return {
db,
server,
client,
ports,
close: (): void => {
server.close("harness teardown");
ports.close();
},
};
}

View File

@@ -0,0 +1,5 @@
export {
createDbProxyClient,
type DbProxyClient,
type DbProxyClientOptions,
} from "./worker-side.js";

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).

View File

@@ -0,0 +1,39 @@
import type {
AdminDatabaseLike,
TenantScopedDatabaseLike,
} from "@suaveplan/api-types/db";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import { wrapTenantScoped } from "../tenant-scoped/tenant-scoped.js";
import type { DbProxyClient } from "./worker-side.js";
describe("admin and tenant-scoped surfaces are distinct (type-level)", () => {
it("DbProxyClient is assignable to AdminDatabaseLike", {
timeout: 5000,
}, () => {
expectTypeOf<DbProxyClient>().toMatchTypeOf<AdminDatabaseLike>();
});
it("DbProxyClient is NOT assignable to TenantScopedDatabaseLike", {
timeout: 5000,
}, () => {
// AdminDatabaseLike has `close`, TenantScopedDatabaseLike does NOT
// — but more importantly the latter has a required `tenantId`. The
// admin surface cannot satisfy the tenant-scoped slot.
expectTypeOf<DbProxyClient>().not.toMatchTypeOf<TenantScopedDatabaseLike>();
});
it("wrapTenantScoped output is TenantScopedDatabaseLike, NOT AdminDatabaseLike", {
timeout: 5000,
}, () => {
type Scoped = ReturnType<typeof wrapTenantScoped>;
expectTypeOf<Scoped>().toMatchTypeOf<TenantScopedDatabaseLike>();
expectTypeOf<Scoped>().not.toMatchTypeOf<AdminDatabaseLike>();
});
it("wrapTenantScoped requires a TenantId-branded string", {
timeout: 5000,
}, () => {
expectTypeOf(wrapTenantScoped).parameter(1).toEqualTypeOf<TenantId>();
});
});

View File

@@ -0,0 +1,307 @@
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
import { createDbProxyServer } from "../main-side/main-side.js";
import {
createDbProxyHarness,
createInMemoryAdminDb,
} from "../testing/testing.js";
import { createDbProxyClient } from "./worker-side.js";
describe("createDbProxyClient", () => {
it("synchronous factory returns AdminDatabaseLike", {
timeout: 5000,
}, () => {
const ports = createInProcessPortPair();
const db = createInMemoryAdminDb();
const server = createDbProxyServer(ports.serverPort, db);
try {
const client = createDbProxyClient(ports.clientPort, {
engine: "sqlite",
});
expect(client.engine).toBe("sqlite");
client.dispose();
} finally {
server.close();
ports.close();
}
});
it("dispose() rejects subsequent calls with PROXY_DISPOSED", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
h.client.dispose();
await expect(h.client.query("SELECT *")).rejects.toThrow();
// dispose is idempotent
h.client.dispose();
} finally {
h.close();
}
});
it("close() forwards to server then disposes locally", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await h.client.close();
expect(h.db.closed()).toBe(true);
await expect(h.client.query("SELECT *")).rejects.toThrow();
} finally {
h.close();
}
});
it("transaction auto-commits when callback resolves", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
const result = await h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
const rows = (await tx.query(
"SELECT *"
)) as ReadonlyArray<unknown>;
expect(rows).toHaveLength(1);
return "ok";
});
expect(result).toBe("ok");
expect(h.db.rows.get("a")).toEqual({ v: 1 });
} finally {
await h.client.close();
h.close();
}
});
it("transaction auto-rolls-back when callback throws", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await expect(
h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
throw new Error("boom");
})
).rejects.toThrow("boom");
} finally {
await h.client.close();
h.close();
}
});
it("auto-rollback path swallows rollback errors", {
timeout: 5000,
}, async () => {
// Custom adapter whose `transaction()` returns a tx that throws on
// rollback; the worker-side wrapper MUST swallow the rollback
// failure and surface the original callback error instead.
const ports = createInProcessPortPair();
const { createDbProxyServer } = await import(
"../main-side/main-side.js"
);
const adapter = {
engine: "sqlite" as const,
query: async () => undefined,
execute: async () => ({ rowsAffected: 0 }),
close: async () => undefined,
transaction: async <R>(
fn: (tx: {
query: (s: string) => Promise<unknown>;
execute: (s: string) => Promise<{ rowsAffected: number }>;
commit: () => Promise<void>;
rollback: () => Promise<void>;
}) => Promise<R>
): Promise<R> =>
await fn({
query: async () => undefined,
execute: async () => ({ rowsAffected: 0 }),
commit: async () => undefined,
rollback: async () => {
throw new Error("rollback explosion");
},
}),
};
const server = createDbProxyServer(ports.serverPort, adapter);
const client = createDbProxyClient(ports.clientPort, {
engine: "sqlite",
});
try {
await expect(
client.transaction?.(async () => {
throw new Error("original");
})
).rejects.toThrow("original");
} finally {
client.dispose();
server.close();
ports.close();
}
});
it("tx handle leaked outside callback throws TRANSACTION_LOST", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
let leaked: unknown = null;
await h.client.transaction?.(async (tx) => {
leaked = tx;
await tx.execute("INSERT a", { v: 1 });
});
const tx = leaked as {
query: (sql: string) => Promise<unknown>;
};
await expect(tx.query("SELECT *")).rejects.toMatchObject({
code: DB_PROXY_CODES.TRANSACTION_LOST,
});
} finally {
await h.client.close();
h.close();
}
});
it("tx.commit inside callback then resolve does not double-commit", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
await tx.commit();
});
} finally {
await h.client.close();
h.close();
}
});
it("tx leaked-after-callback rejects rollback too", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
let leaked: unknown = null;
await h.client.transaction?.(async (tx) => {
leaked = tx;
});
const tx = leaked as { rollback: () => Promise<void> };
await expect(tx.rollback()).rejects.toMatchObject({
code: DB_PROXY_CODES.TRANSACTION_LOST,
});
// also assert it IS a DbProxyError
await tx.rollback().catch((e) => {
expect(e).toBeInstanceOf(DbProxyError);
});
} finally {
await h.client.close();
h.close();
}
});
it("tx execute / commit outside scope rejects", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
let leaked: unknown = null;
await h.client.transaction?.(async (tx) => {
leaked = tx;
});
const tx = leaked as {
execute: (s: string) => Promise<unknown>;
commit: () => Promise<void>;
};
await expect(tx.execute("INSERT b")).rejects.toThrow();
await expect(tx.commit()).rejects.toThrow();
} finally {
await h.client.close();
h.close();
}
});
it("prepare execute returns rows; finalize is idempotent", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
const stmt = await h.client.prepare?.("SELECT *");
expect(stmt).toBeDefined();
if (stmt === undefined) throw new Error("prepare returned");
const rows = await stmt.execute();
expect(Array.isArray(rows)).toBe(true);
await stmt.finalize();
await expect(stmt.execute()).rejects.toThrow();
} finally {
await h.client.close();
h.close();
}
});
it("query when port closed surfaces PROXY_DISPOSED", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
h.client.dispose();
try {
await expect(h.client.execute("INSERT a", {})).rejects.toThrow();
await expect(h.client.prepare?.("SELECT *")).rejects.toThrow();
await expect(
h.client.transaction?.(async () => undefined)
).rejects.toThrow();
expect(() => h.client.cursor("SELECT-CURSOR *")).toThrow();
} finally {
h.close();
}
});
it("cursor with signal already aborted yields no rows", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({
seed: [["a", { v: 1 }]],
});
try {
const ac = new AbortController();
ac.abort();
const collected: unknown[] = [];
try {
for await (const row of h.client.cursor(
"SELECT-CURSOR *",
undefined,
{ signal: ac.signal }
)) {
collected.push(row);
}
} catch {
// pre-aborted signal may surface as error; that's fine.
}
expect(collected.length).toBeLessThanOrEqual(1);
} finally {
await h.client.close().catch(() => undefined);
h.close();
}
});
it("cursor iterator return() finalises cleanly when reused", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness({
seed: [["a", { v: 1 }]],
});
try {
const iter = h.client
.cursor("SELECT-CURSOR *")
[Symbol.asyncIterator]();
const r1 = await iter.next();
expect(r1.done).toBe(false);
const ret = await iter.return?.();
expect(ret?.done).toBe(true);
} finally {
await h.client.close();
h.close();
}
});
});

View File

@@ -0,0 +1,239 @@
import type {
AdminDatabaseLike,
DatabaseEngine,
DbParams,
DbTransaction,
PreparedStatement,
} from "@suaveplan/api-types/db";
import type { PortLike } from "@suaveplan/api-worker-rpc";
import {
createRawWorkerRpcClient,
StreamDispatcher,
streamRequest,
type WorkerRpcClient,
} from "@suaveplan/api-worker-rpc";
import { DB_PROXY_CODES, DbProxyError } from "../error-codes/error-codes.js";
/**
* Worker-side proxy returned by {@link createDbProxyClient}. Beyond the
* standard {@link AdminDatabaseLike} surface this object adds:
*
* - `cursor(sql, params)` — streaming row iterator backed by the
* worker-rpc stream channel; consumer cancellation propagates to
* the main side.
* - `dispose()` — tears the proxy down, rejecting in-flight calls
* with `DB_PROXY.PROXY_DISPOSED`.
*
* The engine identity is fetched once during construction so the
* `engine` getter is synchronous as the type demands.
*/
export interface DbProxyClient extends AdminDatabaseLike {
/**
* Streaming cursor. Yields rows one at a time over the worker-rpc
* stream channel; early `break` propagates cancel to the main side
* which finalises the underlying iterator.
*/
cursor(
sql: string,
params?: DbParams,
opts?: { signal?: AbortSignal }
): AsyncIterable<unknown>;
/** Sever the underlying port; subsequent calls throw `PROXY_DISPOSED`. */
dispose(): void;
}
/** Options accepted by {@link createDbProxyClient}. */
export interface DbProxyClientOptions {
/**
* The engine identifier advertised by the remote adapter. The
* caller knows this at construction time because the host wires
* the adapter — re-fetching it would force a round-trip and turn
* the factory async (breaking the synchronous `engine` getter
* mandated by {@link AdminDatabaseLike}).
*/
readonly engine: DatabaseEngine;
}
/**
* Build a worker-side proxy. The caller MUST pass a {@link PortLike}
* already paired with a {@link createDbProxyServer} on the main side.
*
* `engine` is provided up-front because {@link AdminDatabaseLike}'s
* `engine` is a synchronous getter; the host knows which engine it
* has provisioned and propagates the value through the worker's boot
* envelope.
*/
export function createDbProxyClient(
port: PortLike,
opts: DbProxyClientOptions
): DbProxyClient {
const dispatcher = new StreamDispatcher();
const client = createRawWorkerRpcClient(port, {
onStreamFrame: (frame) => dispatcher.dispatch(frame),
});
let disposed = false;
const guard = (): void => {
if (disposed) {
throw new DbProxyError(
DB_PROXY_CODES.PROXY_DISPOSED,
"db proxy has been disposed"
);
}
};
const callRemote = async <R>(
method: string,
params: ReadonlyArray<unknown>
): Promise<R> => {
guard();
return await client.call<R>(method, params);
};
const engine = opts.engine;
const prepare = async <R = unknown>(
sql: string
): Promise<PreparedStatement<R>> => {
const { id } = await callRemote<{ readonly id: number }>(
"prepare.open",
[sql]
);
const handleId = String(id);
let finalised = false;
return {
id: handleId,
execute: async (params) => {
if (finalised) {
throw new DbProxyError(
DB_PROXY_CODES.PROXY_DISPOSED,
`prepared statement ${handleId} has been finalised`
);
}
return (await callRemote("prepare.execute", [id, params])) as R;
},
finalize: async () => {
if (finalised) return;
finalised = true;
await callRemote<null>("prepare.finalize", [id]);
},
};
};
const transaction = async <R>(
fn: (tx: DbTransaction) => Promise<R>
): Promise<R> => {
const { id } = await callRemote<{ readonly id: number }>(
"tx.begin",
[]
);
let inScope = true;
const guardScope = (): void => {
if (!inScope) {
throw new DbProxyError(
DB_PROXY_CODES.TRANSACTION_LOST,
"transaction handle used outside its callback scope"
);
}
};
const tx: DbTransaction = {
query: async (sql, params) => {
guardScope();
return await callRemote("tx.query", [id, sql, params]);
},
execute: async (sql, params) => {
guardScope();
return (await callRemote("tx.execute", [id, sql, params])) as {
readonly rowsAffected: number;
};
},
commit: async () => {
guardScope();
inScope = false;
await callRemote<null>("tx.commit", [id]);
},
rollback: async () => {
guardScope();
inScope = false;
await callRemote<null>("tx.rollback", [id]);
},
};
try {
const result = await fn(tx);
// Auto-commit if the callback didn't explicitly commit/rollback.
if (inScope) {
inScope = false;
await callRemote<null>("tx.commit", [id]);
}
return result;
} catch (err) {
if (inScope) {
inScope = false;
await callRemote<null>("tx.rollback", [id]).catch(
() => undefined
);
}
throw err;
}
};
const proxy: DbProxyClient = {
engine,
query: async (sql, params) => {
return (await callRemote("query", [sql, params])) as never;
},
execute: async (sql, params) => {
return (await callRemote("execute", [sql, params])) as {
readonly rowsAffected: number;
};
},
prepare,
transaction,
cursor: (sql, params, opts) => {
guard();
return wrapCursorStream(
client,
dispatcher,
sql,
params,
opts?.signal
);
},
close: async () => {
try {
await callRemote<null>("close", []);
} finally {
disposed = true;
client.close("closed by client");
}
},
dispose: () => {
if (disposed) return;
disposed = true;
client.close("disposed");
},
};
return proxy;
}
function wrapCursorStream(
client: WorkerRpcClient,
dispatcher: StreamDispatcher,
sql: string,
params: DbParams | undefined,
signal: AbortSignal | undefined
): AsyncIterable<unknown> {
// `streamRequest` already surfaces stream errors as
// reconstructed exceptions (via `envelopeToError`); we delegate
// straight through so the consumer's `for await` sees the same
// shape it would see from a direct adapter. Stream-interrupted
// failures from the main side already carry the
// `DB_PROXY.STREAM_INTERRUPTED` code through the error envelope.
return streamRequest(
client,
dispatcher,
"cursor",
[sql, params],
signal !== undefined ? { signal } : {}
);
}

View File

@@ -0,0 +1,37 @@
/**
* §16 conformance proof — the worker-proxy package consumes the
* `@suaveplan/api-worker-rpc` contract directly (client + server +
* MessagePort transport). This test exercises the canonical
* `runWorkerRpcConformance` harness with a minimal handler set to
* prove the consumer is wire-compatible end-to-end (request frame,
* response frame, abort propagation) using the same port transport
* the package builds on top of in `src/main-side` / `src/worker-side`.
*/
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
const handles: Array<{ dispose(): void }> = [];
afterEach(() => {
while (handles.length > 0) handles.pop()?.dispose();
});
describe("api-worker-rpc contract conformance (consumer-side)", () => {
it("round-trips a request through the canonical harness", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
handles.push(h);
const result = await h.client.call<number>("echo", [42]);
expect(result).toBe(42);
});
it("surfaces UNKNOWN_METHOD from the server", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({}));
handles.push(h);
await expect(h.client.call("missing", [])).rejects.toThrow();
});
});

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDbProxyHarness } from "../../src/testing/testing.js";
/**
* End-to-end conformance smoke. The full `runDbAdapterConformance`
* harness lives in `@suaveplan/api-db` (Stage 4); this file pins the
* scenarios that downstream adapters MUST observe through the proxy
* surface so the proxy's protocol is locked before Stage-4 work
* starts.
*/
describe("db-proxy conformance against in-memory adapter", () => {
it("CRUD round-trip via the proxy", { timeout: 5000 }, async () => {
const h = createDbProxyHarness();
try {
expect(h.client.engine).toBe("sqlite");
const inserted = await h.client.execute("INSERT a", { v: 1 });
expect(inserted.rowsAffected).toBe(1);
const rows =
await h.client.query<ReadonlyArray<unknown>>("SELECT *");
expect(rows).toEqual([{ v: 1 }]);
const deleted = await h.client.execute("DELETE a");
expect(deleted.rowsAffected).toBe(1);
} finally {
await h.client.close();
h.close();
}
});
it("transaction commit persists; rollback path runs", {
timeout: 5000,
}, async () => {
const h = createDbProxyHarness();
try {
await h.client.transaction?.(async (tx) => {
await tx.execute("INSERT a", { v: 1 });
});
expect(h.db.rows.get("a")).toEqual({ v: 1 });
await expect(
h.client.transaction?.(async (tx) => {
await tx.execute("INSERT b", { v: 2 });
throw new Error("rollback me");
})
).rejects.toThrow("rollback me");
} finally {
await h.client.close();
h.close();
}
});
it("cursor: 1000-row stream yields incrementally", {
timeout: 30000,
}, async () => {
const h = createDbProxyHarness({
seed: Array.from(
{ length: 1000 },
(_, i) => [String(i), { i }] as const
),
});
try {
const t0 = Date.now();
let firstRowAt = -1;
let count = 0;
for await (const _row of h.client.cursor("SELECT-CURSOR *")) {
if (count === 0) firstRowAt = Date.now() - t0;
count += 1;
}
expect(count).toBe(1000);
expect(firstRowAt).toBeGreaterThanOrEqual(0);
} finally {
await h.client.close();
h.close();
}
});
it("prepared statement round-trips", { timeout: 5000 }, async () => {
const h = createDbProxyHarness({ seed: [["a", { v: 1 }]] });
try {
const stmt = await h.client.prepare?.("SELECT *");
expect(stmt).toBeDefined();
if (stmt === undefined) throw new Error("prepare returned");
expect(Array.isArray(await stmt.execute())).toBe(true);
await stmt.finalize();
} finally {
await h.client.close();
h.close();
}
});
it("close is observable on the main side", { timeout: 5000 }, async () => {
const h = createDbProxyHarness();
try {
await h.client.close();
expect(h.db.closed()).toBe(true);
} finally {
h.close();
}
});
});

View File

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

View File

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

View File

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