Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user