Files
2026-05-27 23:29:53 +01:00

151 lines
8.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->