89 lines
5.5 KiB
Markdown
89 lines
5.5 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: `api-db-worker-proxy`
|
|
|
|
**Package**: `@suaveplan/api-db-worker-proxy`
|
|
**Status**: SHIPPED (2026-05-25, v0.1.0)
|
|
|
|
> Every requirement carries a `**Verified by:**` line.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Admin and tenant-scoped surfaces are type-distinct
|
|
|
|
The package SHALL expose two distinct worker-side surface types: `DbProxyClient` (extends `AdminDatabaseLike`) returned by `createDbProxyClient(port, { engine })`, and `TenantScopedDatabaseLike` returned by `wrapTenantScoped(adminProxy, tenantId)`. The two return types MUST NOT be mutually assignable: a tenant-scoped proxy MUST NOT satisfy an admin parameter and vice versa. Tenant scoping is enforced on the MAIN side; the worker-side facade is the type-level guarantee that the request slot has been narrowed to a tenant.
|
|
|
|
#### Scenario: surfaces are not mutually assignable
|
|
|
|
- **WHEN** `expectTypeOf<DbProxyClient>().not.toMatchTypeOf<TenantScopedDatabaseLike>()` is checked
|
|
- **THEN** TypeScript flags the surfaces as distinct
|
|
|
|
**Verified by:** `src/worker-side/worker-side.spec-conformance.test.ts::"DbProxyClient is NOT assignable to TenantScopedDatabaseLike"` AND `src/worker-side/worker-side.spec-conformance.test.ts::"wrapTenantScoped output is TenantScopedDatabaseLike, NOT AdminDatabaseLike"`
|
|
|
|
---
|
|
|
|
### Requirement: Cursors stream results without buffering
|
|
|
|
The package SHALL marshal DB cursors as `AsyncIterable<Row>` via `@suaveplan/api-worker-rpc`'s `streamRequest` primitive. `for await (const row of cursor)` MUST yield rows incrementally; the main-side cursor MUST NOT be drained eagerly. Consumer cancellation via `AbortSignal` MUST propagate so the main-side cursor observes `signal.aborted` and stops producing rows.
|
|
|
|
#### Scenario: large cursor yields incrementally
|
|
|
|
- **WHEN** a worker iterates a 1000-row cursor
|
|
- **THEN** every row is observed in order without preallocating the full result
|
|
|
|
#### Scenario: cursor cancellation propagates
|
|
|
|
- **WHEN** the worker aborts an `AbortController` mid-iteration
|
|
- **THEN** the main-side cursor observes the abort and ceases yielding
|
|
|
|
**Verified by:** `src/main-side/main-side.test.ts::"cursor streams rows in order"` AND `src/main-side/main-side.test.ts::"cursor cancellation aborts the main-side iterator"` AND `tests/conformance/run-db-adapter-conformance.test.ts::"cursor: 1000-row stream yields incrementally"`
|
|
|
|
---
|
|
|
|
### 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 MUST throw `DB_PROXY.TRANSACTION_LOST`. Auto-commit fires on callback resolve; auto-rollback fires on callback reject. Explicit `tx.commit()` or `tx.rollback()` inside the callback suppresses the auto-action.
|
|
|
|
#### 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.TRANSACTION_LOST` is thrown
|
|
|
|
#### Scenario: callback throw triggers rollback
|
|
|
|
- **WHEN** the callback throws
|
|
- **THEN** the worker-side wrapper invokes `tx.rollback` on the main side AND re-throws the original error
|
|
|
|
**Verified by:** `src/worker-side/worker-side.test.ts::"tx handle leaked outside callback throws TRANSACTION_LOST"` AND `src/worker-side/worker-side.test.ts::"transaction auto-rolls-back when callback throws"` AND `src/worker-side/worker-side.test.ts::"auto-rollback path swallows rollback errors"`
|
|
|
|
---
|
|
|
|
### Requirement: Error namespace is the `DB_PROXY.*` registry
|
|
|
|
The package SHALL expose a stable error-code registry `DB_PROXY_CODES` containing exactly four codes: `UNSAFE_NOT_PERMITTED`, `TRANSACTION_LOST`, `STREAM_INTERRUPTED`, `PROXY_DISPOSED`. Every public failure path MUST raise a `DbProxyError` carrying one of these codes; consumers branch on the literal value, not the message.
|
|
|
|
#### Scenario: PROXY_DISPOSED after dispose
|
|
|
|
- **WHEN** `dispose()` is called and a subsequent method is invoked
|
|
- **THEN** the method throws `DbProxyError` with code `DB_PROXY.PROXY_DISPOSED`
|
|
|
|
#### Scenario: UNSAFE_NOT_PERMITTED when adapter lacks capability
|
|
|
|
- **WHEN** the underlying adapter does not implement `prepare` or `transaction`
|
|
- **THEN** the proxy throws `DbProxyError` with code `DB_PROXY.UNSAFE_NOT_PERMITTED`
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts::"exposes the four documented codes"` AND `src/worker-side/worker-side.test.ts::"dispose() rejects subsequent calls with PROXY_DISPOSED"` AND `src/main-side/main-side.test.ts::"rejects prepare when adapter does not implement it"` AND `src/main-side/main-side.test.ts::"rejects transaction when adapter does not implement it"`
|
|
|
|
---
|
|
|
|
### Requirement: Conformance harness backs Stage-4 adapter testing
|
|
|
|
The package SHALL ship an in-process test harness (`createDbProxyHarness`) that pairs a `MessageChannel` with a `createDbProxyServer` on one port and a `createDbProxyClient` on the other, backed by an `InMemoryAdminDb` for deterministic conformance scenarios. Stage-4 DB adapters MUST pass conformance under BOTH the direct surface AND this proxy surface before Stage 4 can be declared complete.
|
|
|
|
#### Scenario: harness drives a CRUD round-trip
|
|
|
|
- **WHEN** a Stage-4 adapter test drives `createDbProxyHarness({ seed })` through INSERT / SELECT / DELETE
|
|
- **THEN** every observation matches the direct-surface adapter
|
|
|
|
**Verified by:** `tests/conformance/run-db-adapter-conformance.test.ts::"CRUD round-trip via the proxy"` AND `src/testing/testing.test.ts::"wires the in-memory db to a real proxy round-trip"`
|