13 KiB
Capability Spec: api-migrations
Package: @suaveplan/api-migrations
Status: ACTIVE
ADDED Requirements
Requirement: MigrationAdapter is the per-dialect surface
The package SHALL export MigrationAdapter interface with { dialect: MigrationDialect; ensureTable(name): Promise<void>; selectApplied(table): Promise<MigrationRecord[]>; insertApplied(table, record): Promise<void>; deleteApplied(table, id): Promise<void>; execute(content, signal?): Promise<void> }. Each dialect (surrealql/postgres/sqlite) ships its own ensureTable DDL — no hard-coded SurrealQL in the runner. MigrationRunner consumes the adapter, not a generic DatabaseLike.
Scenario: surrealql adapter emits SurrealQL DDL on ensureTable
- WHEN
createSurrealqlAdapter(client).ensureTable("_migrations")is called - THEN the adapter executes
DEFINE TABLE IF NOT EXISTS _migrations SCHEMAFULL; DEFINE FIELD ...
Scenario: postgres adapter emits standard SQL DDL on ensureTable
- WHEN
createPostgresAdapter(pgClient).ensureTable("_migrations")is called - THEN the adapter executes
CREATE TABLE IF NOT EXISTS _migrations (id text primary key, name text unique not null, applied_at timestamptz not null default now(), checksum text not null, duration_ms int not null)
Verified by: src/adapters/surrealql/surrealql.test.ts::"ensureTable emits SurrealQL DDL" AND src/adapters/postgres/postgres.test.ts::"ensureTable emits standard SQL DDL"
Requirement: Runner applies pending migrations in numerical order
MigrationRunner.scanDirectory(dir) SHALL return files matching fileExtension, parsing leading digits from filenames (001_initial.sql → order 1) and sorting ascending. run(files) SHALL filter out already-applied (by name match against selectApplied), apply each pending migration via adapter.execute(content), record via adapter.insertApplied(table, {id, name, appliedAt, checksum, duration}), and halt on first failure with MigrationError(MIGRATION_APPLY_FAILED).
Scenario: pending applied in order
- WHEN
_migrationscontains record001_aand directory contains002_b.sql,003_c.sql - THEN
runFromDirectory(dir)applies002_bthen003_c
Scenario: failure halts the apply loop
- WHEN
003_cthrows and there are 4+ pending migrations - THEN the runner returns
[result_for_002_b, result_for_003_c_with_success: false]and does NOT apply004_d
Verified by: src/runner/runner.test.ts::"applies pending in numerical order" AND src/runner/runner.test.ts::"halts on first failure"
Requirement: Checksums are actually validated
When validateChecksums: true (default), BEFORE applying any pending migration, the runner SHALL re-read every already-applied migration's file content, compute SHA-256, and compare against the stored checksum. Mismatch SHALL raise MigrationError(MIGRATION_CHECKSUM_DRIFT) with context.{name, expected, actual} — apply halts.
Scenario: checksum drift halts apply
- WHEN
001_initial.sqlwas applied with checksumabc, the file is modified post-apply, andrunFromDirectory(dir)is called - THEN the call rejects with
MigrationError(MIGRATION_CHECKSUM_DRIFT)BEFORE attempting any pending migration
Scenario: validateChecksums: false skips drift check
- WHEN the same scenario runs with
validateChecksums: false - THEN apply proceeds without drift detection
Verified by: src/runner/runner.test.ts::"checksum drift raises MIGRATION_CHECKSUM_DRIFT" AND src/runner/runner.test.ts::"validateChecksums false skips drift check"
Requirement: AbortSignal cancels between migrations
runFromDirectory(dir, { signal }) SHALL check signal.aborted before each pending migration. If aborted, the in-progress migration completes (per the adapter's execute semantics) and the runner returns the partial results array. No further migrations are applied. The runner does NOT inject the signal into the adapter — that's per-adapter discretion.
Scenario: pre-aborted signal returns empty
- WHEN
runFromDirectory(dir, { signal: AbortSignal.abort() })is awaited - THEN the result array is empty (no migration applied)
Scenario: abort mid-loop returns partial result
- WHEN abort fires after 002 succeeds but before 003 starts
- THEN the result array contains only the 002 entry
Verified by: src/runner/runner.test.ts::"pre-aborted signal applies nothing" AND src/runner/runner.test.ts::"abort mid-loop returns partial result"
Requirement: Dry-run logs but does not apply
dryRun: true SHALL cause runMigration(file) to log [DRY RUN] Would apply: ${name} and return {name, success: true, duration: <small>} without invoking adapter.execute or adapter.insertApplied.
Scenario: dry-run does not call adapter
- WHEN
runFromDirectory(dir, { dryRun: true })is invoked and 3 pending migrations exist - THEN
adapter.executeis not called,adapter.insertAppliedis not called, and the result array has 3 entries withsuccess: true
Verified by: src/runner/runner.test.ts::"dryRun does not call adapter.execute or insertApplied"
Requirement: OpenTelemetry counter + histogram per apply
The package SHALL register counter migrations.applied_total (attributes dialect, outcome) and histogram migrations.apply_duration_ms (attribute name). Both SHALL emit per migration regardless of success/failure.
Scenario: counter increments on success
- WHEN a successful apply runs against a real in-memory
MeterProvider - THEN
migrations.applied_total{dialect, outcome: "success"}has 1 observation
Scenario: counter increments on failure
- WHEN an apply fails
- THEN
migrations.applied_total{dialect, outcome: "error"}has 1 observation
Verified by: src/otel/otel.test.ts::"applied_total emits success" AND src/otel/otel.test.ts::"applied_total emits error" AND src/otel/otel.test.ts::"apply_duration_ms records per migration"
Requirement: Rollback removes the last tracking record only
rollback() SHALL fetch the most recently applied migration record (sort by appliedAt desc, take 1), call adapter.deleteApplied(table, lastRecord.id), and return {name, success: true, duration: 0}. It does NOT attempt to revert the schema — that's application policy. If no records exist, returns null.
Scenario: rollback removes last record
- WHEN records
001,002,003are applied androllback()is awaited - THEN
adapter.deleteAppliedis called with id of003only
Scenario: no records → returns null
- WHEN the migrations table is empty and
rollback()is awaited - THEN the result is
null
Verified by: src/runner/runner.test.ts::"rollback removes last record" AND src/runner/runner.test.ts::"rollback empty returns null"
Requirement: Every MIGRATION_* code is thrown from a production path
MIGRATION_CODES SHALL define exactly 6 codes (MIGRATION_TABLE_BOOTSTRAP_FAILED, MIGRATION_APPLY_FAILED, MIGRATION_CHECKSUM_DRIFT, MIGRATION_DIRECTORY_NOT_FOUND, MIGRATION_INVALID_FILE_NAME, MIGRATION_ROLLBACK_NOT_SUPPORTED). Every code SHALL be raised by at least one production call site.
Verified by: src/api-migrations.spec-conformance.test.ts::"every MIGRATION_* code is thrown from a production path"
Requirement: Server-tier package shape with per-dialect subpaths
The package SHALL run in Bun + Node. package.json SHALL declare subpath exports for the runner (./runner/runner), every shipped adapter (./adapters/surrealql/surrealql, ./adapters/postgres/postgres, ./adapters/sqlite/sqlite), the OTel layer, and the error codes. Every implementation .ts SHALL have a sibling .md ≥200 words. No export * in feature barrels. 100/100/100/100 coverage.
Scenario: each dialect adapter is independently importable
- WHEN a consumer imports
@suaveplan/api-migrations/adapters/postgres/postgres - THEN the import succeeds without dragging in the SurrealQL adapter
Verified by: tests/dep-graph.test.ts::"each adapter has its own subpath" AND tests/dep-graph.test.ts::"subpath exports cover every feature module" AND tests/dep-graph.test.ts::"every impl .ts has a sibling .md"
Requirement: SurrealQL adapter records the ledger row under SurrealDB v3
createSurrealqlAdapter.insertApplied(table, record) SHALL produce a record-id reference in the form <table>:\`whereisrecord.idwith every backtick doubled. The adapter SHALL NOT use the${table}:$nameform — SurrealDB v3 parses$paramin the record-id position as a literal ident (not a parameter substitution), so the CREATE is acknowledged but silently records no row, leaving the ledger empty and causing the runner to re-execute completed migrations. After a successfulinsertAppliedinvocation, a subsequentselectApplied(table)on the same connection SHALL return a row whosename,checksum, andduration` match the inserted record.
createSurrealqlAdapter.deleteApplied(table, id) SHALL produce the matching <table>:\`reference so the round-trip fromselectApplied→deleteAppliedcorrectly targets the row thatinsertAppliedwrote, regardless of whetherid` is a bare SurrealQL ident.
createSurrealqlAdapter.selectApplied(table) SHALL normalise the SurrealDB-returned id field to the bare migration name — strip the ${table}: prefix and any wrapping backticks, and for object-form RecordId { tb, id } return the inner id string when present. This guarantees rollback()'s call to deleteApplied(table, last.id) produces the same record-ref shape as the original insertApplied.
createSurrealqlAdapter.insertApplied SHALL bind record.appliedAt as the raw Date value, NOT as an ISO 8601 string. SurrealDB v3's TYPE datetime column refuses to coerce a string into datetime and rejects the CREATE with InternalError: Couldn't coerce value for field 'appliedAt'. The SDK CBOR-encodes the Date binding as a SurrealDB datetime value.
Scenario: insertApplied uses backticked record-id literal (v3-safe)
- WHEN
createSurrealqlAdapter(client).insertApplied("_migrations", { id: "001", name: "001", appliedAt, checksum: "h", duration: 5 })is called - THEN the issued SurrealQL is
CREATE _migrations:\001` SET name = $name, appliedAt = $appliedAt, checksum = $checksum, duration = $duration(NOTCREATE _migrations:$name SET ...`)
Scenario: backticks in id are escaped
- WHEN
record.idisweird\id` - THEN the issued SurrealQL contains
CREATE _migrations:\weird``id` SET ...`
Scenario: ledger row is queryable after the migration body succeeds (live v3)
- WHEN
runFromDirectory(dir)applies a single migration against a real SurrealDB v3.x testcontainer - THEN
SELECT name, checksum, duration FROM _migrationson the same connection returns exactly one row with the migration name and a 64-char-hexchecksum
Scenario: second run sees the ledger and skips already-applied migrations (live v3)
- WHEN
runFromDirectory(dir)runs a second time after the first has applied one migration - THEN the result array is empty AND the ledger still contains exactly one row
Scenario: rollback removes the ledger row written by insertApplied (live v3)
- WHEN
runner.rollback()is awaited after one successful apply - THEN the rollback result is
{ name, success: true }AND the ledger table is empty
Scenario: selectApplied normalises returned ids to the bare migration name
- WHEN SurrealDB returns
id: "_migrations:\001_initial`"` for a row - THEN
selectApplied("_migrations")returns{ id: "001_initial", ... }
Verified by: src/adapters/surrealql/surrealql.test.ts::"insertApplied uses backticked record-id literal (v3-safe)" AND src/adapters/surrealql/surrealql.test.ts::"insertApplied escapes backticks inside the migration id" AND src/adapters/surrealql/surrealql.test.ts::"insertApplied binds appliedAt as a Date (not a string)" AND src/adapters/surrealql/surrealql.test.ts::"deleteApplied uses backticked record-id literal (v3-safe)" AND src/adapters/surrealql/surrealql.test.ts::"selectApplied strips backtick-quoted id form" AND src/adapters/surrealql/surrealql.test.ts::"selectApplied strips table prefix from string ids" AND tests/integration/surrealql-ledger.integration.test.ts::"ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)" AND tests/integration/surrealql-ledger.integration.test.ts::"second run sees the ledger and skips already-applied migrations" AND tests/integration/surrealql-ledger.integration.test.ts::"rollback() removes the ledger row written by insertApplied (round-trip)"