Initial Commit
This commit is contained in:
194
openspec/specs/api-migrations/spec.md
Normal file
194
openspec/specs/api-migrations/spec.md
Normal file
@@ -0,0 +1,194 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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** `_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"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SurrealQL adapter records the ledger row under SurrealDB v3
|
||||
|
||||
`createSurrealqlAdapter.insertApplied(table, record)` SHALL produce a record-id reference in the form `<table>:\`<escaped-id>\`` where `<escaped-id>` is `record.id` with every backtick doubled. The adapter SHALL NOT use the `${table}:$name` form — SurrealDB v3 parses `$param` in 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 successful `insertApplied` invocation, a subsequent `selectApplied(table)` on the same connection SHALL return a row whose `name`,`checksum`, and`duration` match the inserted record.
|
||||
|
||||
`createSurrealqlAdapter.deleteApplied(table, id)` SHALL produce the matching `<table>:\`<escaped-id>\`` reference so the round-trip from `selectApplied` → `deleteApplied` correctly targets the row that `insertApplied` wrote, regardless of whether `id` 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` (NOT `CREATE _migrations:$name SET ...`)
|
||||
|
||||
#### Scenario: backticks in id are escaped
|
||||
|
||||
- **WHEN** `record.id` is `weird\`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 _migrations` on the same connection returns exactly one row with the migration name and a 64-char-hex `checksum`
|
||||
|
||||
#### 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)"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user