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

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().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"