Files
suaveplan/openspec/archive/2026-05-25-api-db-worker-proxy/proposal.md
2026-05-27 23:29:53 +01:00

4.1 KiB

@suaveplan/api-db-worker-proxy — worker-side proxy to main-process DB adapter

Package: @suaveplan/api-db-worker-proxy Status: PROPOSED Type: new-package Stage: 3 (Worker isolation infrastructure)

Why

Per design doc §5.2a, every capability ships two callable surfaces. db is special because (a) its operations return result sets that can be huge (favoring streaming over buffering), (b) its operations are transactional (transaction handles must be tied to a single connection lifetime that the worker can't naively shuttle around), and (c) tenant-scoping is a security boundary the worker MUST NOT be able to circumvent. api-db-worker-proxy provides the worker-side surface of AdminDatabaseLike / TenantScopedDatabaseLike whose method calls marshal across MessagePort to the main-process DB adapter (which lives in api-db-adapter, Stage 4).

The proxy preserves the PluginContext / RequestContext db-slot type contract: a PluginContext's db is AdminDatabaseLike; a RequestContext's db is TenantScopedDatabaseLike. Tenant scoping is enforced on the MAIN side before the call is dispatched — worker code cannot smuggle an admin DB into a request handler.

What changes

Net-new package. Worker-side only — the main-side counterpart lives in api-db-adapter (Stage 4).

ADDED exports:

  • ./client — createDbProxyClient(port, { surface: "admin" | "tenant-scoped" }): returns a WorkerProxyOf<AdminDatabaseLike> or WorkerProxyOf<TenantScopedDatabaseLike> typed per surface.
  • ./cursor — DB cursors marshalled as ReadableStream<Row> via api-worker-rpc's streaming primitive. Worker-side for await (const row of cursor) is identical to direct usage.
  • ./transaction — transaction handle proxy: client.transaction(async (tx) => { ... }) opens a transaction on the main side; the worker-side callback gets a tx whose methods route through a transaction-scoped sub-port. Calls outside the callback throw DB_PROXY_TX_OUT_OF_SCOPE.
  • ./error-codes — DB_PROXY_* codes (DB_PROXY_PORT_CLOSED, DB_PROXY_TX_OUT_OF_SCOPE, DB_PROXY_CURSOR_AFTER_CLOSE, DB_PROXY_SURFACE_MISMATCH).
  • ./testing — paired MessageChannel + a stub main-side server for unit tests; conformance harness runDbProxyClientConformance for downstream adapters.

Out of scope

  • Main-side adapter implementation — api-db-adapter (Stage 4) and api-db-sqlite-memory (Stage 5) implement the receiving side.
  • Tenant scoping enforcement — enforced on the main side BEFORE marshalling. This package is the transport, not the policy.
  • Engine-specific SQL — api-db-worker-proxy is engine-agnostic; the main-side adapter resolves the engine (postgres / sqlite / surrealdb).
  • Schema migrations — host metadata migrations live in api-host; plugin migrations live in api-db-adapter.

Source mapping

Genesis-original. Design doc sections: §5.1 (two-context separation of db slot), §5.2a (worker-RPC variants), §6 (capability scoping in step 7).

Requirement Design doc section
Two surfaces: AdminDatabaseLike vs TenantScopedDatabaseLike §5.1
Cursors streamed via Transferable §5.2a
Worker-side proxy preserves direct-surface ergonomics §5.2a
Tenant scoping enforced main-side, not worker-side §6

Verification approach

  • src/client/client.spec-conformance.test.ts — expectTypeOf proves admin surface and tenant surface are distinct; admin surface NOT assignable to tenant-scoped.
  • src/cursor/cursor.test.ts — for await over large result set; consumer cancel propagates to main side.
  • src/transaction/transaction.test.ts — tx only valid inside callback; out-of-scope call throws DB_PROXY_TX_OUT_OF_SCOPE.
  • tests/conformance/run-db-adapter-conformance.test.ts — runDbAdapterConformance("worker-proxy", makeFactory) from @suaveplan/api-db passes against this proxy + a stub main-side server. Required before Stage 4 can complete — every Stage-4 DB adapter MUST pass conformance under BOTH the direct and worker-proxy surfaces.

Migration impact

None — additive only.