Initial Commit
This commit is contained in:
68
openspec/archive/2026-05-26-api-db-adapter/proposal.md
Normal file
68
openspec/archive/2026-05-26-api-db-adapter/proposal.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# `@suaveplan/api-db-adapter` — Bridge from `@suaveplan/db` to plugin DatabaseLike
|
||||
|
||||
**Package**: `@suaveplan/api-db-adapter`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Stage**: 4 (Base adapters) — see umbrella `openspec/changes/api-plugin-foundation/`
|
||||
|
||||
## Why
|
||||
|
||||
Plugins consume `ctx.db` typed as either `AdminDatabaseLike` (in `PluginContext` — for migrations + admin work) or `TenantScopedDatabaseLike` (in `RequestContext` — same field, narrower type). Per design doc §5.1, the tenant-scoped wrapper injects `WHERE tenant_id = $1` on reads and `tenant_id` on writes — plugins literally cannot read another tenant's rows from a route handler. Admin access from a route is the explicit exception via the `db.unsafe-queries` capability flag in the plugin's manifest; every such call emits `plugin.unsafe_query_total`.
|
||||
|
||||
`api-db-adapter` is the bridge between the genesis `@suaveplan/db` adapter foundation and the plugin host. It implements:
|
||||
|
||||
- The `AdminDatabaseLike` surface (raw SQL/SurQL plus prepared statements).
|
||||
- The tenant-scoped wrapper (rejects unscoped queries at the boundary).
|
||||
- A per-engine migration runner (Postgres / SQLite / Surreal) consuming each plugin's `manifest.database.migrations` directory.
|
||||
- The `unsafe` escape hatch — `db.unsafe.query()` — gated by manifest capability declaration.
|
||||
|
||||
Stage 4 invariant: ships direct AND worker-proxy surfaces; both pass `runDbAdapterConformance` (§5.2a, §13.4). The proxy uses transferable `ReadableStream` for cursor streaming (large query results survive the boundary without buffering).
|
||||
|
||||
## What changes
|
||||
|
||||
Net-new package depending on `@suaveplan/api-db` (contract), `@suaveplan/api-types`, `@suaveplan/api-worker-rpc`, `@suaveplan/db`, `@suaveplan/error`, `zod`.
|
||||
|
||||
- ADDED: `createDbAdapter(opts)` — direct-surface factory; returns `{ admin: AdminDatabaseLike; scoped(tenantId): TenantScopedDatabaseLike }`.
|
||||
- ADDED: `createDbAdapterWorkerProxy(port, opts)` — proxy-surface factory.
|
||||
- ADDED: `DbAdapterOptions` — Zod schema with `engine: "postgres" | "sqlite" | "surrealdb"`, `url: string`, `pool: { min, max, idleMs }`, `tenantColumn?: string` (default `tenant_id`), `migrationsDir?: string`.
|
||||
- ADDED: `createTenantScopedWrapper(admin, tenantId, options)` — injects `WHERE tenantColumn = $1` on every read; rejects writes whose payload lacks `tenantColumn` (or auto-injects, configurable).
|
||||
- ADDED: `runMigrations(plugin, capabilities)` — per-engine migration runner consuming `manifest.database.migrations` directory; tracks applied versions in `__plugin_migrations` table.
|
||||
- ADDED: `db.unsafe.query(sql, params)` — capability-gated; throws `DB_UNSAFE_NOT_PERMITTED` if `plugin.capabilities` lacks `db.unsafe-queries`. On allowed calls emits `plugin.unsafe_query_total{plugin}` counter.
|
||||
- ADDED: `DB_ADAPTER_*` error codes (`DB_ADAPTER_TENANT_SCOPE_VIOLATION`, `DB_ADAPTER_UNSAFE_NOT_PERMITTED`, `DB_ADAPTER_MIGRATION_FAILED`, `DB_ADAPTER_ENGINE_UNSUPPORTED`, `DB_ADAPTER_INVALID_OPTIONS`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The `@suaveplan/db` adapter foundation itself (already exists at v0).
|
||||
- Surreal-specific features beyond CRUD + migrations — `api-db-surreal-graph` future work.
|
||||
- Connection pooling internals — delegated to `@suaveplan/db`.
|
||||
- 2PC participant role — `api-2pc` Stage 6.
|
||||
- Saga compensation — `api-saga` Stage 6.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
|
||||
|
||||
| Requirement | Design doc section |
|
||||
|---|---|
|
||||
| Same field, different type per ctx | §5.1 |
|
||||
| Tenant-scoped wrapper rejects unscoped reads | §5.1 (paragraph after `RequestContext`) |
|
||||
| `db.unsafe-queries` capability gating | §5.1 (last paragraph), §2.2 (sandbox row) |
|
||||
| `plugin.unsafe_query_total` counter | §5.1 (last paragraph) |
|
||||
| Per-engine migration runner | §3 (manifest `database.migrations`), §4 (FSM `installing` step) |
|
||||
| Worker-proxy variant + transferable streams | §5.2a |
|
||||
| Conformance both surfaces | §13.4 |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/factory/create-db-adapter.test.ts` — admin surface against in-memory SQLite (real `bun:sqlite`); read/write/transaction.
|
||||
- `src/factory/create-db-adapter.spec-conformance.test.ts` — `runDbAdapterConformance("db-direct", ...)`.
|
||||
- `src/proxy/create-db-adapter-worker-proxy.test.ts` — proxy round-trip; `ReadableStream` cursor preserved across `MessageChannel`.
|
||||
- `src/proxy/create-db-adapter-worker-proxy.spec-conformance.test.ts` — `runDbAdapterConformance("db-worker-proxy", ...)`.
|
||||
- `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts` — `SELECT` injection, `INSERT` injection, cross-tenant read rejection.
|
||||
- `src/migrations/run-migrations.test.ts` — per-engine: Postgres (testcontainer), SQLite (memory), Surreal (testcontainer). Forward + idempotent re-run.
|
||||
- `src/unsafe/unsafe-query-gate.test.ts` — capability check + counter emission.
|
||||
- `tests/integration/postgres.test.ts` — real Postgres via `@suaveplan/testing-integration-postgres`.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only.
|
||||
@@ -0,0 +1,117 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-db-adapter (api-db-adapter)
|
||||
|
||||
**Package**: `@suaveplan/api-db-adapter`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin surface exposes raw DatabaseLike
|
||||
|
||||
The package SHALL expose `AdminDatabaseLike` (per `@suaveplan/api-types`) with `query`, `execute`, `transaction`, `prepare` (for engines that support it). Admin surface is used for migrations and operator tooling; it does NOT enforce tenant scoping.
|
||||
|
||||
#### Scenario: admin query returns full result set
|
||||
|
||||
- **WHEN** admin runs `SELECT * FROM users` against a fixture seeded with rows across two tenants
|
||||
- **THEN** the result contains rows from both tenants (no filter applied)
|
||||
|
||||
**Verified by:** `src/factory/create-db-adapter.test.ts::"admin surface returns rows across all tenants"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tenant-scoped wrapper rejects unscoped queries
|
||||
|
||||
`createTenantScopedWrapper(admin, tenantId, options)` SHALL return a `TenantScopedDatabaseLike` that:
|
||||
|
||||
1. Injects `WHERE tenantColumn = $1` (default column `tenant_id`) on every SELECT/UPDATE/DELETE.
|
||||
2. Injects `tenantColumn` on every INSERT — if the payload lacks the column AND `autoInjectInserts: true`, inject the value; if `autoInjectInserts: false`, reject with `DB_ADAPTER_TENANT_SCOPE_VIOLATION`.
|
||||
3. Rejects raw SQL that doesn't reference the tenant column on a tenant-scoped table.
|
||||
|
||||
#### Scenario: SELECT auto-scopes to current tenant
|
||||
|
||||
- **WHEN** a route handler runs `SELECT * FROM users` against the scoped wrapper for `tenant="acme"`
|
||||
- **THEN** the executed SQL contains `WHERE tenant_id = 'acme'` (or equivalent prepared parameter) AND no rows from other tenants are returned
|
||||
|
||||
#### Scenario: cross-tenant write is rejected
|
||||
|
||||
- **WHEN** the scoped wrapper for `tenant="acme"` is asked to `INSERT INTO users (tenant_id) VALUES ('bigcorp')`
|
||||
- **THEN** the call throws `DB_ADAPTER_TENANT_SCOPE_VIOLATION`
|
||||
|
||||
**Verified by:** `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"SELECT auto-scopes to current tenant"` AND `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts::"INSERT with conflicting tenant_id is rejected"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: db.unsafe escape hatch is capability-gated
|
||||
|
||||
The scoped wrapper SHALL expose `db.unsafe.query(sql, params)` which bypasses tenant injection. Calls MUST be allowed ONLY if the plugin's manifest declares the `db.unsafe-queries` capability; otherwise throw `DB_ADAPTER_UNSAFE_NOT_PERMITTED`. Every allowed call MUST emit `plugin.unsafe_query_total{plugin}`.
|
||||
|
||||
#### Scenario: plugin without declaration is blocked
|
||||
|
||||
- **WHEN** a route handler whose plugin's manifest lacks `db.unsafe-queries` calls `ctx.db.unsafe.query(...)`
|
||||
- **THEN** the call throws `DB_ADAPTER_UNSAFE_NOT_PERMITTED` AND nothing is executed against the engine
|
||||
|
||||
#### Scenario: plugin with declaration is allowed and counted
|
||||
|
||||
- **WHEN** a plugin declaring `db.unsafe-queries` calls `ctx.db.unsafe.query("SELECT * FROM audit_log")`
|
||||
- **THEN** the query runs AND `plugin.unsafe_query_total{plugin=<name>}` increments by 1
|
||||
|
||||
**Verified by:** `src/unsafe/unsafe-query-gate.test.ts::"throws when capability not declared"` AND `src/unsafe/unsafe-query-gate.test.ts::"emits plugin.unsafe_query_total when allowed"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-engine migration runner
|
||||
|
||||
`runMigrations(plugin, capabilities)` SHALL apply pending migrations from `manifest.database.migrations` in lexicographic order, recording applied versions in `__plugin_migrations(plugin, version, applied_at)`. Re-running with no new files MUST be a no-op (idempotent).
|
||||
|
||||
Supported engines: Postgres, SQLite, Surreal. Each engine SHALL apply migrations within a transaction where the engine supports it; on failure the migration rolls back AND `DB_ADAPTER_MIGRATION_FAILED` is thrown with the failing file path.
|
||||
|
||||
#### Scenario: forward migration applies once
|
||||
|
||||
- **WHEN** `runMigrations` is called with migrations `[001_init.sql, 002_add_invoices.sql]` AND `__plugin_migrations` is empty
|
||||
- **THEN** both files run AND `__plugin_migrations` records two rows AND `api.db.migrations_applied_total{plugin}` records 2
|
||||
|
||||
#### Scenario: idempotent re-run
|
||||
|
||||
- **WHEN** `runMigrations` is called twice in a row with the same file set
|
||||
- **THEN** the second call applies zero migrations AND `api.db.migrations_applied_total` does not increment on the second call
|
||||
|
||||
#### Scenario: failure rolls back
|
||||
|
||||
- **WHEN** `002_add_invoices.sql` contains a syntax error
|
||||
- **THEN** the call throws `DB_ADAPTER_MIGRATION_FAILED` with `details.file = "002_add_invoices.sql"` AND `__plugin_migrations` does NOT record version 002
|
||||
|
||||
**Verified by:** `src/migrations/run-migrations.test.ts::"applies pending migrations in lexicographic order"` AND `src/migrations/run-migrations.test.ts::"is idempotent on re-run"` AND `src/migrations/run-migrations.test.ts::"rolls back and throws on migration failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Direct and worker-proxy surfaces both pass conformance
|
||||
|
||||
The worker-proxy variant MUST preserve `ReadableStream`-based cursors across the `MessagePort` boundary (transferable, zero-copy where the runtime supports it).
|
||||
|
||||
#### Scenario: direct conforms
|
||||
|
||||
- **WHEN** `runDbAdapterConformance("db-direct", () => createDbAdapter(...))` runs against an in-memory SQLite
|
||||
- **THEN** every invariant passes
|
||||
|
||||
#### Scenario: worker-proxy conforms with streaming cursors
|
||||
|
||||
- **WHEN** `runDbAdapterConformance("db-worker-proxy", ...)` runs over a real `MessageChannel` AND a query returns a cursor of 10_000 rows
|
||||
- **THEN** every invariant passes AND the cursor is consumed on the proxy side without buffering all rows in memory at once
|
||||
|
||||
**Verified by:** `src/factory/create-db-adapter.spec-conformance.test.ts::"db-direct conforms to Db contract"` AND `src/proxy/create-db-adapter-worker-proxy.spec-conformance.test.ts::"db-worker-proxy conforms to Db contract with streaming cursors"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Engine selection is Zod-validated
|
||||
|
||||
`DbAdapterOptions.engine` MUST be one of `"postgres" | "sqlite" | "surrealdb"`. Any other value MUST throw `DB_ADAPTER_ENGINE_UNSUPPORTED` at construction.
|
||||
|
||||
#### Scenario: unsupported engine throws
|
||||
|
||||
- **WHEN** `createDbAdapter({ engine: "mongodb" as never, url: "..." })`
|
||||
- **THEN** the factory throws `DB_ADAPTER_ENGINE_UNSUPPORTED` synchronously
|
||||
|
||||
**Verified by:** `src/options/db-adapter-options.test.ts::"throws on unsupported engine value"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
74
openspec/archive/2026-05-26-api-db-adapter/tasks.md
Normal file
74
openspec/archive/2026-05-26-api-db-adapter/tasks.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Tasks — `@suaveplan/api-db-adapter`
|
||||
|
||||
> 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 (§3, §5.1, §5.2a, §13.4)
|
||||
- [x] 1.2 Read `openspec/changes/api-db-worker-proxy/`, `@suaveplan/db` README for downstream contract
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md`
|
||||
- [x] 1.5 Draft `specs/api-db-adapter/spec.md`
|
||||
- [x] 1.6 Every requirement carries `**Verified by:**`
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Scaffold `packages/api/api-db-adapter/` (deps: api-db, api-types, api-worker-rpc, db, error, zod)
|
||||
- [ ] 2.2 `src/options/db-adapter-options.ts` — Zod schema
|
||||
- [ ] 2.3 `src/factory/create-db-adapter.ts` — direct surface (admin + scoped)
|
||||
- [ ] 2.4 `src/proxy/create-db-adapter-worker-proxy.ts` — proxy with transferable `ReadableStream` cursors
|
||||
- [ ] 2.5 `src/tenant-scoping/create-tenant-scoped-wrapper.ts` — `WHERE`/`INSERT` injection + read rejection
|
||||
- [ ] 2.6 `src/migrations/run-migrations.ts` — per-engine migration runner with `__plugin_migrations` table
|
||||
- [ ] 2.7 `src/engines/postgres-engine.ts`, `src/engines/sqlite-engine.ts`, `src/engines/surrealdb-engine.ts` — per-engine adapters
|
||||
- [ ] 2.8 `src/unsafe/unsafe-query-gate.ts` — capability check + `plugin.unsafe_query_total` counter
|
||||
- [ ] 2.9 `src/error-codes.ts` — `DB_ADAPTER_*` codes via `defineCodes`
|
||||
- [ ] 2.10 `src/index.ts` — explicit named re-exports
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `src/factory/create-db-adapter.test.ts` — against in-memory SQLite
|
||||
- [ ] 3.2 `src/factory/create-db-adapter.spec-conformance.test.ts` — direct conformance
|
||||
- [ ] 3.3 `src/proxy/create-db-adapter-worker-proxy.test.ts` — proxy + ReadableStream cursor
|
||||
- [ ] 3.4 `src/proxy/create-db-adapter-worker-proxy.spec-conformance.test.ts` — proxy conformance
|
||||
- [ ] 3.5 `src/tenant-scoping/create-tenant-scoped-wrapper.test.ts`
|
||||
- [ ] 3.6 `src/migrations/run-migrations.test.ts` — per-engine forward + idempotency
|
||||
- [ ] 3.7 `src/unsafe/unsafe-query-gate.test.ts`
|
||||
- [ ] 3.8 `tests/integration/postgres.test.ts` — real Postgres
|
||||
- [ ] 3.9 `tests/integration/surrealdb.test.ts` — real Surreal
|
||||
- [ ] 3.10 Every `it(...)` carries `{ timeout: 5000 }` (30000 for integration)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` per implementation `.ts`
|
||||
- [ ] 4.2 README documents tenant-scoped wrapper semantics, the unsafe escape hatch, and the per-engine migration directory layout
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — clean
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-db-adapter` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-db-adapter` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-db-adapter` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-db-adapter` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded through `AdminDatabaseLike` / `TenantScopedDatabaseLike`; `expectTypeOf` asserts the two surfaces are not interchangeable
|
||||
- [ ] 6.2 Zod schemas at options + manifest boundaries
|
||||
- [ ] 6.3 Every `DB_ADAPTER_*` code thrown by at least one production path
|
||||
- [ ] 6.4 Strict-mode (auto-inject vs reject on missing tenant column) tested both ways
|
||||
- [ ] 6.5 OTel instruments (`api.db.query_duration_ms`, `api.db.migrations_applied_total`, `plugin.unsafe_query_total`) asserted with in-memory `MeterProvider`
|
||||
- [ ] 6.6 Migration idempotency tested (running twice = same version state)
|
||||
- [ ] 6.7 No retroactive tick
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold into `openspec/specs/api-db-adapter/spec.md`
|
||||
- [ ] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-db-adapter/`
|
||||
- [ ] 7.3 Tick Stage 4 box in umbrella tasks
|
||||
- [ ] 7.4 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user