8.3 KiB
Capability Spec Delta: api-migrations (add-api-migrations)
Package: @suaveplan/api-migrations
Status: PROPOSED
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"