first commit
This commit is contained in:
112
docs/api-stack/api/api-db-worker-proxy/README.md
Normal file
112
docs/api-stack/api/api-db-worker-proxy/README.md
Normal 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)
|
||||
|
||||
@@ -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