Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin-database/specs/api-plugin-database/spec.md
2026-05-27 23:29:53 +01:00

234 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-database (add-api-plugin-database)
**Package**: `@suaveplan/api-plugin-database`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Stable error-code registry
The package SHALL register a `DATABASE` namespace via `defineCodes` and expose a `DatabaseError` class extending `SuaveplanError<DatabaseErrorCode>`. Every code SHALL be thrown from at least one production call site.
#### Scenario: Code list is stable
- **WHEN** consumers import `DATABASE_CODES`
- **THEN** the export contains exactly: `CONNECTION_FAILED`, `POOL_EXHAUSTED`, `POOL_CLOSED`, `QUERY_FAILED`, `MIGRATION_FAILED`, `LOCK_CONTENDED`, `TENANT_INVALID`, `INVALID_CONFIG`
#### Scenario: Every code is reachable
- **WHEN** the package's source tree is scanned
- **THEN** every code is thrown from a non-test production path
**Verified by:** `src/error-codes/error-codes.test.ts::"defineCodes produces the expected DATABASE_* namespace"` AND `src/api-plugin-database.spec-conformance.test.ts::"every DATABASE_* code is thrown from a production path"`
---
### Requirement: Connection pool over SurrealAdapter
The package SHALL expose `ConnectionPool` that owns a bounded set of `SurrealAdapter` instances. `acquire()` SHALL return an idle adapter, lazily grow to `max`, or queue on a waiter when at capacity. `release(adapter)` SHALL hand the adapter to the next waiter or return it to the idle set. `acquireTimeoutMs` SHALL reject queued waiters with `DATABASE.POOL_EXHAUSTED`. `close()` SHALL drain idle adapters and reject every pending waiter with `DATABASE.POOL_CLOSED`.
#### Scenario: Acquire under capacity returns existing idle adapter
- **WHEN** the pool has idle adapters and a caller invokes `acquire()`
- **THEN** the call resolves with that adapter and `size().idle` decreases by 1
#### Scenario: Acquire above min lazily creates up to max
- **WHEN** the pool currently has `< max` adapters and no idle ones
- **THEN** `acquire()` constructs a new adapter, connects it, marks it in-use, and `size().total` increases by 1
#### Scenario: Acquire at max times out
- **WHEN** all `max` adapters are in use and a caller invokes `acquire()` without a free slot within `acquireTimeoutMs`
- **THEN** the call rejects with `DatabaseError(DATABASE.POOL_EXHAUSTED)`
#### Scenario: Release wakes a waiter
- **WHEN** there is a queued waiter and a caller invokes `release(adapter)`
- **THEN** the waiter's promise resolves with that adapter
#### Scenario: Close drains and rejects pending acquires
- **WHEN** waiters are queued and the pool is closed
- **THEN** every waiter rejects with `DatabaseError(DATABASE.POOL_CLOSED)` and `acquire()` afterwards rejects with the same code
**Verified by:** `src/pool/pool.test.ts::"acquire returns an idle adapter"` AND `src/pool/pool.test.ts::"acquire lazily grows up to max"` AND `src/pool/pool.test.ts::"acquire rejects with POOL_EXHAUSTED past acquireTimeoutMs"` AND `src/pool/pool.test.ts::"release hands the adapter to a queued waiter"` AND `src/pool/pool.test.ts::"close drains adapters and rejects pending waiters"`
---
### Requirement: Retry with exponential backoff
The package SHALL expose `withRetry(fn, options)` that re-invokes `fn` up to `maxAttempts` times, sleeping `min(baseBackoffMs * 2^(attempt-1), maxBackoffMs)` (optionally jittered) between attempts. The caller MAY provide `isRetryable(err, attempt)` to bail out early.
#### Scenario: First-attempt success returns immediately
- **WHEN** `fn` resolves on its first call
- **THEN** `withRetry` returns the value without sleeping
#### Scenario: Retries on transient failure
- **WHEN** `fn` rejects twice and then resolves
- **THEN** `withRetry` invokes `fn` three times and returns the eventual value
#### Scenario: Throws the last error when exhausted
- **WHEN** `fn` rejects every attempt up to `maxAttempts`
- **THEN** `withRetry` throws the last error
#### Scenario: Non-retryable predicate bails out
- **WHEN** `isRetryable` returns false
- **THEN** `withRetry` throws immediately without further attempts
**Verified by:** `src/retry/retry.test.ts::"first-attempt success returns the value"` AND `src/retry/retry.test.ts::"retries on transient failure"` AND `src/retry/retry.test.ts::"throws after maxAttempts exhausted"` AND `src/retry/retry.test.ts::"isRetryable false bails out early"`
---
### Requirement: Migration runner with advisory lock
The package SHALL run `.surql` migrations through `@suaveplan/api-migrations`' `createSurrealqlAdapter` + `createMigrationRunner` while holding an advisory lock backed by a SurrealDB row whose `expiresAt` acts as a lease. A second concurrent runner SHALL observe the lock and skip work. Lease expiry SHALL allow takeover by another holder.
#### Scenario: Advisory lock excludes a concurrent holder
- **WHEN** holder A holds an unexpired lock and holder B attempts `acquireLock`
- **THEN** B's `acquireLock` returns `false`
#### Scenario: Expired lock is taken over
- **WHEN** the recorded lease is in the past and a new holder attempts `acquireLock`
- **THEN** the new holder records its own lease and `acquireLock` returns `true`
#### Scenario: Runner applies pending migrations under the lock
- **WHEN** the `.surql` directory contains pending files and `runMigrations` is called
- **THEN** every pending file is applied in lexicographic order, the ledger records each, and `applied[].name` lists them in order
#### Scenario: Lock contention surfaces as DATABASE.LOCK_CONTENDED
- **WHEN** the lock is held by another holder and `runMigrations` cannot acquire
- **THEN** the result includes the discovered files under `skipped` and no migrations are applied
**Verified by:** `src/migrations/lock.test.ts::"acquireLock returns false when another holder is active"` AND `src/migrations/lock.test.ts::"expired lock can be taken over"` AND `src/migrations/runner.test.ts::"applies pending migrations in order and records ledger entries"` AND `src/migrations/runner.test.ts::"returns skipped list when lock cannot be acquired"`
---
### Requirement: Tenancy provisioning callables
The package SHALL expose `provisionTenant(adapter, config, tenantId)` and `decommissionTenant(adapter, config, tenantId)` callables that honour all three strategies (`shared`, `namespace-per-tenant`, `database-per-tenant`). Tenant IDs SHALL be validated against `^[A-Za-z0-9_-]{1,64}$`; invalid IDs SHALL throw `DatabaseError(DATABASE.TENANT_INVALID)`.
#### Scenario: Shared strategy adds the discriminator field
- **WHEN** the config strategy is `shared` and `provisionTenant` is called
- **THEN** the adapter receives a `DEFINE FIELD … tenant_id` query
#### Scenario: Namespace strategy provisions a tenant namespace
- **WHEN** the config strategy is `namespace-per-tenant`
- **THEN** the adapter receives a `DEFINE NAMESPACE` query with the encoded tenant id
#### Scenario: Database strategy provisions a tenant database
- **WHEN** the config strategy is `database-per-tenant`
- **THEN** the adapter receives a `DEFINE DATABASE` query with the encoded tenant id
#### Scenario: Invalid tenant id throws TENANT_INVALID
- **WHEN** the supplied tenant id violates the validation regex
- **THEN** `provisionTenant` throws `DatabaseError(DATABASE.TENANT_INVALID)` before any query is issued
**Verified by:** `src/tenancy/tenancy.test.ts::"shared strategy adds the tenant_id discriminator"` AND `src/tenancy/tenancy.test.ts::"namespace-per-tenant strategy issues DEFINE NAMESPACE"` AND `src/tenancy/tenancy.test.ts::"database-per-tenant strategy issues DEFINE DATABASE"` AND `src/tenancy/tenancy.test.ts::"invalid tenant id throws TENANT_INVALID"`
---
### Requirement: RFC 7807 problem-json renderer
The package SHALL expose `respondProblemJson(ctx, err)` that delegates to `@suaveplan/error`'s formatter and pins the response content-type to `application/problem+json`.
#### Scenario: DatabaseError is rendered as problem+json
- **WHEN** `respondProblemJson` is invoked with a `DatabaseError`
- **THEN** the response carries `application/problem+json` and a body whose `type` is the registered URI for that code
#### Scenario: Unknown throwables fall through to 500
- **WHEN** `respondProblemJson` is invoked with a plain `Error`
- **THEN** the response status is 500 and the body is RFC 7807-shaped
**Verified by:** `src/routes/problem-json.test.ts::"DatabaseError renders as problem+json with the registered status"` AND `src/routes/problem-json.test.ts::"unknown throwables fall through to a 500 problem+json"`
---
### Requirement: Zod-validated configuration boundary
The package SHALL validate every public options bundle through Zod schemas at the call boundary. Invalid input SHALL throw `DatabaseError(DATABASE.INVALID_CONFIG)` with the `ZodError` carried as `cause`.
#### Scenario: Canonical input parses
- **WHEN** `DatabasePluginOptionsSchema.parse({ url: "ws://x/rpc" })` is invoked
- **THEN** every default field is populated (pool, retry, tenancy, migrations, query)
#### Scenario: Out-of-range pool max is rejected
- **WHEN** `createDatabasePlugin({ pool: { max: 0 } as never })` is invoked
- **THEN** the factory throws `DatabaseError(DATABASE.INVALID_CONFIG)` with the underlying `ZodError`
**Verified by:** `src/config/config-schema.test.ts::"parses canonical input with defaults"` AND `src/config/config-schema.test.ts::"rejects out-of-range pool max"` AND `src/plugin/plugin.test.ts::"createDatabasePlugin throws INVALID_CONFIG for bad options"`
---
### Requirement: OTel instrumentation
The package SHALL emit OpenTelemetry instruments through `@opentelemetry/api`'s global meter. The bundle SHALL include: `db.pool.checkouts_total` counter, `db.pool.checkins_total` counter, `db.pool.saturation` up-down-counter, `db.query.duration_ms` histogram (labelled by `{ outcome: "success" | "error" }`), and a delegation to `@suaveplan/api-migrations`' `migrations.applied_total`. A `resetDatabaseInstruments()` helper SHALL clear the cached bundle for test reuse.
#### Scenario: Every instrument emits against an in-memory MeterProvider
- **WHEN** the test setup installs an in-memory `MeterProvider`, calls every recorder, and force-flushes
- **THEN** the exporter snapshot contains a metric with each declared name
#### Scenario: Bundle is cached across calls
- **WHEN** `getDatabaseInstruments()` is invoked twice
- **THEN** the second call returns the same object as the first (referential equality)
**Verified by:** `src/otel/instruments.test.ts::"every instrument emits against the in-memory exporter"` AND `src/otel/instruments.test.ts::"caches the bundle across calls"`
---
### Requirement: Plugin factory wires pool, migrations, and service registry
The package SHALL expose `createDatabasePlugin(options?)` returning a `PluginDescriptor<DatabasePluginConfig>`. `onInit` SHALL construct the pool, eagerly create `min` adapters, and register the pool's primary adapter under `ServiceRegistry["db"]`. `onReady` SHALL run migrations under advisory lock when `migrations.autoRun` is `true`. `onClose` SHALL drain the pool. Construction-time Zod validation MUST gate every option.
#### Scenario: Plugin descriptor exposes the canonical contract
- **WHEN** consumers call `createDatabasePlugin({ url: "ws://x/rpc" })`
- **THEN** the descriptor has `name === "database"`, `version === "0.1.0"`, four lifecycle hooks defined, and `defaults` is the merged Zod-validated config
#### Scenario: onInit registers the db service
- **WHEN** the plugin lifecycle runs through `onInit` against a stub adapter factory
- **THEN** `ctx.getService("db")` returns the pooled `DatabaseAdapter`
#### Scenario: onClose drains the pool
- **WHEN** the plugin's `onClose` runs
- **THEN** the pool's `close()` is invoked and the adapter is released
**Verified by:** `src/plugin/plugin.test.ts::"descriptor exposes the canonical name/version/hooks"` AND `src/plugin/plugin.test.ts::"onInit constructs the pool and registers the db service"` AND `src/plugin/plugin.test.ts::"onClose drains the pool"`
---
### Requirement: Real SurrealDB integration
The package SHALL ship at least one integration test against a real SurrealDB instance via `@suaveplan/testing-integration-surrealdb`. The test SHALL exercise the full lifecycle (pool acquire → run migrations → tenant provision → release → close).
#### Scenario: Full roundtrip against a real container
- **WHEN** the integration test boots a real SurrealDB container, drives `createDatabasePlugin` through its lifecycle, and provisions a tenant
- **THEN** every step succeeds and the pool reports zero leaks at shutdown
**Verified by:** `tests/integration/full-boot.test.ts::"full pool + migration + tenancy roundtrip against real SurrealDB"`