Files
suaveplan/openspec/archive/2026-05-18-add-api-migrations/specs/api-migrations/spec.md
2026-05-27 23:29:53 +01:00

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 _migrations contains record 001_a and directory contains 002_b.sql, 003_c.sql
  • THEN runFromDirectory(dir) applies 002_b then 003_c

Scenario: failure halts the apply loop

  • WHEN 003_c throws and there are 4+ pending migrations
  • THEN the runner returns [result_for_002_b, result_for_003_c_with_success: false] and does NOT apply 004_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.sql was applied with checksum abc, the file is modified post-apply, and runFromDirectory(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.execute is not called, adapter.insertApplied is not called, and the result array has 3 entries with success: 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, 003 are applied and rollback() is awaited
  • THEN adapter.deleteApplied is called with id of 003 only

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"