# Capability Spec Delta: api-plugin-database (add-api-plugin-database) **Package**: `@suaveplan/api-plugin-database` **Status**: SHIPPED > 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`. 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`. `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"`