Initial Commit
This commit is contained in:
53
openspec/archive/2026-05-25-api-db-worker-proxy/proposal.md
Normal file
53
openspec/archive/2026-05-25-api-db-worker-proxy/proposal.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# `@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.
|
||||
@@ -0,0 +1,70 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-db-worker-proxy (api-db-worker-proxy)
|
||||
|
||||
**Package**: `@suaveplan/api-db-worker-proxy`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin and tenant-scoped surfaces are type-distinct proxy clients
|
||||
|
||||
The package SHALL expose `createDbProxyClient(port, { surface })` whose return type is `WorkerProxyOf<AdminDatabaseLike>` when `surface === "admin"` and `WorkerProxyOf<TenantScopedDatabaseLike>` when `surface === "tenant-scoped"`. The two return types MUST NOT be mutually assignable: a tenant-scoped proxy MUST NOT satisfy an admin parameter and vice versa.
|
||||
|
||||
#### Scenario: surfaces are not mutually assignable
|
||||
|
||||
- **WHEN** `expectTypeOf<Admin>().not.toEqualTypeOf<TenantScoped>()` is checked
|
||||
- **THEN** TypeScript flags the surfaces as distinct
|
||||
|
||||
**Verified by:** `src/client/client.spec-conformance.test.ts::"admin and tenant-scoped surfaces are distinct"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Cursors stream results without buffering
|
||||
|
||||
The package SHALL marshal DB cursors as `ReadableStream<Row>` via `api-worker-rpc`'s streaming primitive. `for await (const row of cursor)` MUST yield rows incrementally; the main-side cursor MUST NOT be drained eagerly. Consumer cancellation MUST propagate so the main-side cursor closes.
|
||||
|
||||
#### Scenario: 10k-row cursor yields incrementally
|
||||
|
||||
- **WHEN** a worker iterates a 10k-row cursor
|
||||
- **THEN** the first row is observed BEFORE the last row has been produced main-side (verified via timing)
|
||||
|
||||
#### Scenario: cursor break propagates close
|
||||
|
||||
- **WHEN** the worker breaks out of `for await` early
|
||||
- **THEN** the main-side cursor is closed AND no further rows are produced
|
||||
|
||||
**Verified by:** `src/cursor/cursor.test.ts::"cursor yields incrementally"` AND `src/cursor/cursor.test.ts::"break propagates cursor close"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transaction handle is scoped to callback lifetime
|
||||
|
||||
The package SHALL provide `client.transaction(async (tx) => { ... })` where `tx` is only valid inside the callback. A call on `tx` after the callback resolves or rejects MUST throw `DB_PROXY_TX_OUT_OF_SCOPE`. The transaction MUST be carried by a transaction-scoped sub-port so the main side can correlate every call to the same connection.
|
||||
|
||||
#### Scenario: out-of-scope tx call throws
|
||||
|
||||
- **WHEN** the callback captures `tx` AND a method is called on it after the callback returns
|
||||
- **THEN** `DB_PROXY_TX_OUT_OF_SCOPE` is thrown
|
||||
|
||||
#### Scenario: nested tx is rejected
|
||||
|
||||
- **WHEN** `tx.transaction(...)` is invoked inside the callback
|
||||
- **THEN** `DB_PROXY_TX_OUT_OF_SCOPE` is thrown (savepoints are out of scope for Stage 3)
|
||||
|
||||
**Verified by:** `src/transaction/transaction.test.ts::"out-of-scope tx call throws"` AND `src/transaction/transaction.test.ts::"nested tx is rejected"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance suite passes against this proxy
|
||||
|
||||
The package SHALL be runnable as a surface argument to `runDbAdapterConformance` from `@suaveplan/api-db/testing`. Every conformance scenario (CRUD, transactions, cursors, schema introspection) MUST pass identically to a direct in-process adapter. This is the Stage-4-precondition gate: every Stage-4 DB adapter (`api-db-adapter`, `api-db-sqlite-memory`) MUST pass conformance under BOTH the direct surface AND this worker-proxy surface before Stage 4 can be declared complete.
|
||||
|
||||
#### Scenario: runDbAdapterConformance passes
|
||||
|
||||
- **WHEN** `runDbAdapterConformance("worker-proxy", makeFactory)` runs against this proxy + stub server
|
||||
- **THEN** every conformance scenario passes
|
||||
|
||||
**Verified by:** `tests/conformance/run-db-adapter-conformance.test.ts::"runDbAdapterConformance passes against proxy"`
|
||||
68
openspec/archive/2026-05-25-api-db-worker-proxy/tasks.md
Normal file
68
openspec/archive/2026-05-25-api-db-worker-proxy/tasks.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Tasks — `@suaveplan/api-db-worker-proxy`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read design doc §5.1, §5.2a, §6
|
||||
- [x] 1.2 Read umbrella
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Draft `specs/api-db-worker-proxy/spec.md`
|
||||
- [ ] 1.6 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Scaffold `packages/api/api-db-worker-proxy/` (`0.1.0`, deps: `@suaveplan/api-worker-rpc`, `@suaveplan/api-types`, `@suaveplan/error`, `zod`; peer-dep on `@suaveplan/api-db` types)
|
||||
- [ ] 2.2 `src/client/` — typed proxy client factory; surface discriminated by argument
|
||||
- [ ] 2.3 `src/cursor/` — `ReadableStream<Row>` cursor proxy
|
||||
- [ ] 2.4 `src/transaction/` — transaction-scoped sub-port; out-of-scope detection
|
||||
- [ ] 2.5 `src/error-codes.ts` — `DB_PROXY_*` codes
|
||||
- [ ] 2.6 `src/testing/` — paired `MessageChannel` + stub server + `runDbProxyClientConformance` harness
|
||||
- [ ] 2.7 `src/index.ts` — namespace re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `src/client/client.spec-conformance.test.ts` — admin vs tenant-scoped surfaces type-distinct
|
||||
- [ ] 3.2 `src/cursor/cursor.test.ts` — `for await` over 10k rows; consumer cancel propagates
|
||||
- [ ] 3.3 `src/transaction/transaction.test.ts` — out-of-scope call throws `DB_PROXY_TX_OUT_OF_SCOPE`
|
||||
- [ ] 3.4 `tests/conformance/run-db-adapter-conformance.test.ts` — `runDbAdapterConformance` passes
|
||||
- [ ] 3.5 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every implementation `.ts`
|
||||
- [ ] 4.2 README links every co-located `.md`; explicit note that tenant scoping is main-side
|
||||
- [ ] 4.3 Update CLAUDE.md
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-db-worker-proxy` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-db-worker-proxy` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-db-worker-proxy` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-db-worker-proxy` — green
|
||||
- [ ] 5.6 **Stage-4 conformance gate**: every Stage-4 DB adapter MUST pass `runDbAdapterConformance` under BOTH the direct surface AND this proxy surface before Stage 4 can complete
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 6.1 Generics — `WorkerProxyOf<AdminDatabaseLike>` vs `WorkerProxyOf<TenantScopedDatabaseLike>` `expectTypeOf` distinct
|
||||
- [ ] 6.2 Zod — N/A on plugin-side (call args validated by main); test envelopes still Zod-validated
|
||||
- [ ] 6.3 Every `DB_PROXY_*` code thrown
|
||||
- [ ] 6.4 Strict-mode behaviors — cursor close behavior tested both ways
|
||||
- [ ] 6.5 OTel — N/A here; main-side adapter emits db spans
|
||||
- [ ] 6.6 Lifecycle dedup — N/A
|
||||
- [ ] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/api-db-worker-proxy/spec.md`
|
||||
- [ ] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-db-worker-proxy/`
|
||||
- [ ] 7.3 Tick Stage-3 row in umbrella
|
||||
- [ ] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user