13 KiB
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<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().idledecreases by 1
Scenario: Acquire above min lazily creates up to max
- WHEN the pool currently has
< maxadapters and no idle ones - THEN
acquire()constructs a new adapter, connects it, marks it in-use, andsize().totalincreases by 1
Scenario: Acquire at max times out
- WHEN all
maxadapters are in use and a caller invokesacquire()without a free slot withinacquireTimeoutMs - 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)andacquire()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
fnresolves on its first call - THEN
withRetryreturns the value without sleeping
Scenario: Retries on transient failure
- WHEN
fnrejects twice and then resolves - THEN
withRetryinvokesfnthree times and returns the eventual value
Scenario: Throws the last error when exhausted
- WHEN
fnrejects every attempt up tomaxAttempts - THEN
withRetrythrows the last error
Scenario: Non-retryable predicate bails out
- WHEN
isRetryablereturns false - THEN
withRetrythrows 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
acquireLockreturnsfalse
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
acquireLockreturnstrue
Scenario: Runner applies pending migrations under the lock
- WHEN the
.surqldirectory contains pending files andrunMigrationsis called - THEN every pending file is applied in lexicographic order, the ledger records each, and
applied[].namelists them in order
Scenario: Lock contention surfaces as DATABASE.LOCK_CONTENDED
- WHEN the lock is held by another holder and
runMigrationscannot acquire - THEN the result includes the discovered files under
skippedand 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
sharedandprovisionTenantis called - THEN the adapter receives a
DEFINE FIELD … tenant_idquery
Scenario: Namespace strategy provisions a tenant namespace
- WHEN the config strategy is
namespace-per-tenant - THEN the adapter receives a
DEFINE NAMESPACEquery 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 DATABASEquery with the encoded tenant id
Scenario: Invalid tenant id throws TENANT_INVALID
- WHEN the supplied tenant id violates the validation regex
- THEN
provisionTenantthrowsDatabaseError(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
respondProblemJsonis invoked with aDatabaseError - THEN the response carries
application/problem+jsonand a body whosetypeis the registered URI for that code
Scenario: Unknown throwables fall through to 500
- WHEN
respondProblemJsonis invoked with a plainError - 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 underlyingZodError
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, anddefaultsis the merged Zod-validated config
Scenario: onInit registers the db service
- WHEN the plugin lifecycle runs through
onInitagainst a stub adapter factory - THEN
ctx.getService("db")returns the pooledDatabaseAdapter
Scenario: onClose drains the pool
- WHEN the plugin's
onCloseruns - 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
createDatabasePluginthrough 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"