# 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; selectApplied(table): Promise; insertApplied(table, record): Promise; deleteApplied(table, id): Promise; execute(content, signal?): Promise }`. 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: }` 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"`