From f6d407394d6c5cad1eb52210c58ec490212d2336 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 01:17:23 +0000 Subject: [PATCH 1/3] feat: add migration runner with structured failure diagnostic (E00-S03-T05) MigrationRunner applies pending migrations through the migration ledger exactly once; when a migration fails it throws a MigrationFailedError whose diagnostic is a structured object identifying the failing migration (version), the failure phase (apply/record), the underlying cause, and the applied/pending ledger state, serializable via toJSON. Re-exported from the driver boundary so no other package needs the pg driver to run migrations. Advisory lock (T04) and ready gate (T06) remain out of scope. --- docs/development/non-container.md | 2 +- packages/database-postgres/package.json | 2 +- packages/database-postgres/src/index.ts | 11 +- packages/database-postgres/src/ledger.ts | 4 +- packages/database-postgres/src/lock.ts | 4 +- packages/database-postgres/src/runner.ts | 206 +++++++++++++++++++++++ 6 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 packages/database-postgres/src/runner.ts diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 813a18b..f60b933 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,7 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | -| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03) and the migration advisory lock (E00-S03-T04) are implemented here; the migration runner (failure diagnostics) lands in later stories. | +| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the migration ledger (`schema_migrations`, E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | A dependency-boundary rule (`dependency-boundaries.json`, enforced by diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 016de03..e555c9b 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03) and the migration advisory lock (E00-S03-T04) are implemented here; the migration runner (failure diagnostics) lands in later stories.", + "description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the migration ledger (E00-S03-T03), the migration advisory lock (E00-S03-T04) and the migration runner with its failure diagnostic (E00-S03-T05) are implemented here.", "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/packages/database-postgres/src/index.ts b/packages/database-postgres/src/index.ts index df7ec9e..62817f5 100644 --- a/packages/database-postgres/src/index.ts +++ b/packages/database-postgres/src/index.ts @@ -9,10 +9,11 @@ * * This module is the driver boundary: it imports the PostgreSQL driver (`pg`) * and Kysely and re-exports the pieces the adapter is built on — the driver - * surface (E00-S03-T02), the migration ledger (E00-S03-T03) and the migration - * advisory lock (E00-S03-T04). The failure diagnostic (E00-S03-T05) lands in - * a later story; until then the re-exports keep the driver reachable only - * from here — the isolation is real, not a placeholder. + * surface (E00-S03-T02), the migration ledger (E00-S03-T03), the migration + * advisory lock (E00-S03-T04) and the migration runner with its failure + * diagnostic (E00-S03-T05). Keeping every re-export here means the driver + * stays reachable only from this package — the isolation is real, not a + * placeholder. */ import { Pool } from 'pg'; @@ -21,3 +22,5 @@ import { Kysely, PostgresDialect } from 'kysely'; export { Pool, Kysely, PostgresDialect }; export { MigrationLedger, MIGRATION_LEDGER_TABLE } from './ledger.js'; export { MigrationLock, MIGRATION_LOCK_KEY } from './lock.js'; +export { MigrationRunner, MigrationFailedError } from './runner.js'; +export type { Migration, MigrationDiagnostic, MigrationRunResult, MigrationFailurePhase } from './runner.js'; diff --git a/packages/database-postgres/src/ledger.ts b/packages/database-postgres/src/ledger.ts index 5594fd6..37a0dc1 100644 --- a/packages/database-postgres/src/ledger.ts +++ b/packages/database-postgres/src/ledger.ts @@ -2,8 +2,8 @@ * Migration ledger — [E00-S03-T03]. * * The ledger is the PostgreSQL table that records applied migrations, so the - * migration runner (built on this boundary in later stories — advisory lock - * E00-S03-T04, failure diagnostic E00-S03-T05) can tell which migrations have + * migration runner (built on this boundary — the advisory lock E00-S03-T04 + * and the failure diagnostic E00-S03-T05) can tell which migrations have * already been applied and apply the rest exactly once. * * The ledger lives in `database-postgres` — the single workspace package diff --git a/packages/database-postgres/src/lock.ts b/packages/database-postgres/src/lock.ts index d00e52d..77ce2eb 100644 --- a/packages/database-postgres/src/lock.ts +++ b/packages/database-postgres/src/lock.ts @@ -3,8 +3,8 @@ * * The advisory lock is the PostgreSQL-side guarantee that concurrent migration * runners cannot run at the same time: the migration runner (built on this - * boundary in later stories — failure diagnostic E00-S03-T05, ready gate - * E00-S03-T06) takes the lock before applying migrations, so a second runner + * boundary — the failure diagnostic E00-S03-T05; the ready gate E00-S03-T06) + * takes the lock before applying migrations, so a second runner * either waits (`acquire()`) or fails fast (`tryAcquire()`) while the first * holds it. * diff --git a/packages/database-postgres/src/runner.ts b/packages/database-postgres/src/runner.ts new file mode 100644 index 0000000..8d65f0a --- /dev/null +++ b/packages/database-postgres/src/runner.ts @@ -0,0 +1,206 @@ +/** + * Migration runner with failure diagnostic — [E00-S03-T05]. + * + * The migration runner applies pending migrations to the database exactly + * once and, when a migration fails, produces a structured diagnostic that + * identifies the failing migration. It is built on the boundary that came + * before it: the migration ledger (E00-S03-T03) records applied migrations, + * so a rerun never double-applies. The advisory lock (E00-S03-T04) serializes + * concurrent runners, but wiring the lock into the runner is out of scope for + * this story — the runner performs no locking itself; a runner that wants to + * serialize takes the lock (E00-S03-T04) around `run()`. + * + * Failure model: + * - migrations run in the order given, oldest first; a migration whose + * version is already recorded in the ledger is skipped; + * - when a migration's `up` throws, the runner wraps the failure into a + * `MigrationFailedError` whose `diagnostic` is a structured object that + * identifies the failing migration (`migration` — its version), where the + * run failed (`phase`: 'apply' when the migration's `up` threw, 'record' + * when the ledger insert threw after a successful `up`), the underlying + * cause, and the ledger state at failure time (`applied`/`pending` — + * `applied` + `pending` cover the runner's migrations exactly); + * - the error is serializable: `toJSON()` returns a plain structured object + * (including a structured cause — for pg errors the `code`, e.g. `42P01`), + * so operators can log/parse the diagnostic without string-matching. + * + * The runner lives in `database-postgres` — the single workspace package + * allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the + * database exclusively through the package-owned `pg` Pool and the + * `MigrationLedger`, so no other package needs the driver to run migrations. + * + * Rollback note from the issue: revert the diagnostic/error handling changes. + */ + +import type { Pool } from 'pg'; +import { MigrationLedger } from './ledger.js'; + +/** + * A single migration step: an identifier (recorded in the ledger once the + * step has been applied) and the apply function. `up` receives the + * package-owned pool, so a migration can run any SQL (and multi-statement + * work) through the same driver boundary the runner itself uses. + */ +export interface Migration { + version: string; + up(pool: Pool): Promise | void; +} + +/** + * Where a migration run failed: 'apply' when the migration's `up` threw, or + * 'record' when the ledger insert threw after a successful `up`. + */ +export type MigrationFailurePhase = 'apply' | 'record'; + +/** + * The structured diagnostic produced when a migration fails. `migration` + * identifies the failing migration; `applied` and `pending` are disjoint and + * together cover the runner's migration list in run order. + */ +export interface MigrationDiagnostic { + /** Version of the migration that failed — identifies the failing migration. */ + migration: string; + /** Where the run failed: 'apply' (the migration's `up` threw) or 'record' (the ledger insert threw). */ + phase: MigrationFailurePhase; + /** The underlying failure (e.g. the pg error), preserved for inspection. */ + cause: unknown; + /** Versions recorded in the ledger when the failure happened, in run order. */ + applied: string[]; + /** Versions not yet recorded when the failure happened, in run order — includes the failing migration. */ + pending: string[]; +} + +/** Result of a successful migration run. */ +export interface MigrationRunResult { + /** Versions applied by this run, in run order (oldest first). */ + applied: string[]; + /** Versions skipped because they were already recorded in the ledger. */ + skipped: string[]; +} + +/** + * The error thrown when a migration fails. Carries the structured diagnostic + * (`.diagnostic`) and is serializable (`toJSON()`), so callers and operators + * can inspect and parse the failure without string-matching the message. + */ +export class MigrationFailedError extends Error { + readonly diagnostic: MigrationDiagnostic; + + constructor(diagnostic: MigrationDiagnostic) { + super(`migration "${diagnostic.migration}" failed during ${diagnostic.phase}`); + this.name = 'MigrationFailedError'; + this.diagnostic = diagnostic; + } + + /** Serializable form of the error and its structured diagnostic. */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + diagnostic: { + migration: this.diagnostic.migration, + phase: this.diagnostic.phase, + applied: [...this.diagnostic.applied], + pending: [...this.diagnostic.pending], + cause: structuredCause(this.diagnostic.cause), + }, + }; + } +} + +/** + * Reduces the underlying cause to a structured, serializable shape: for an + * `Error` the name/message (plus the pg error `code` when present, e.g. + * `42P01`); for anything else a `{ value }` wrapper, so `toJSON()` never + * stringifies to an empty object. + */ +function structuredCause(cause: unknown): Record { + if (cause instanceof Error) { + const structured: Record = { name: cause.name, message: cause.message }; + const code = (cause as Error & { code?: unknown }).code; + if (code !== undefined) structured.code = code; + return structured; + } + return { value: cause }; +} + +/** + * The migration runner: applies pending migrations in order, exactly once, + * through the migration ledger. Instances are cheap and share the caller's + * pool and ledger; the runner performs no locking (advisory lock is + * E00-S03-T04) and no ready gating (E00-S03-T06). + */ +export class MigrationRunner { + private readonly pool: Pool; + private readonly ledger: MigrationLedger; + private readonly migrations: readonly Migration[]; + + /** + * @param pool The package-owned PostgreSQL pool (`pg.Pool`). + * @param migrations The migrations to run, in apply order (oldest first). + * @param ledger The migration ledger; defaults to one sharing `pool`. + */ + constructor(pool: Pool, migrations: readonly Migration[], ledger?: MigrationLedger) { + this.pool = pool; + this.migrations = migrations; + this.ledger = ledger ?? new MigrationLedger(pool); + } + + /** + * Runs the pending migrations: creates the ledger if missing, then applies + * every migration whose version is not yet recorded, recording each one as + * it completes. Idempotent: a rerun skips everything already recorded, so a + * second run never double-applies. When a migration fails — its `up` throws + * ('apply') or the ledger insert throws ('record') — the runner throws a + * `MigrationFailedError` whose `diagnostic` identifies the failing migration + * and the ledger state at failure time. + */ + async run(): Promise { + await this.ledger.ensure(); + const recorded = new Set(await this.ledger.applied()); + const applied: string[] = []; + const skipped: string[] = []; + + for (const migration of this.migrations) { + if (recorded.has(migration.version)) { + skipped.push(migration.version); + continue; + } + try { + await migration.up(this.pool); + } catch (error) { + throw this.failure(migration.version, 'apply', error, recorded); + } + try { + await this.ledger.record(migration.version); + } catch (error) { + throw this.failure(migration.version, 'record', error, recorded); + } + recorded.add(migration.version); + applied.push(migration.version); + } + + return { applied, skipped }; + } + + /** + * Builds the structured diagnostic for a failing migration: `migration` is + * the failing version, `applied`/`pending` are the ledger state at failure + * time scoped to this runner's migrations (disjoint, in run order — the + * failing migration is still pending, since it was never recorded). + */ + private failure( + version: string, + phase: MigrationFailurePhase, + cause: unknown, + recorded: ReadonlySet, + ): MigrationFailedError { + const applied: string[] = []; + const pending: string[] = []; + for (const migration of this.migrations) { + if (recorded.has(migration.version)) applied.push(migration.version); + else pending.push(migration.version); + } + return new MigrationFailedError({ migration: version, phase, cause, applied, pending }); + } +} From 52190d082c886d9002b97841c8c9d17660bf4a02 Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 01:17:23 +0000 Subject: [PATCH 2/3] test: lock in the migration failure diagnostic (E00-S03-T05) Static assertions + mutation probes on the committed runner source, a deterministic stub-pool behavioral probe (intentionally failing migration fixture -> structured diagnostic naming the failing migration, apply and record phases), a docker-gated real-stack probe against a real database (the issue's test plan), and CI enforcement via the additive database-postgres-diagnostic job. --- .gitea/workflows/ci.yml | 29 + .gitignore | 1 + tests/database-postgres-diagnostic.test.mjs | 797 ++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 tests/database-postgres-diagnostic.test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9d4a05e..63d87e7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -105,6 +105,35 @@ jobs: - name: Run migration advisory lock test suite run: node --test tests/database-postgres-lock.test.mjs + # E00-S03-T05: the static assertions of tests/database-postgres-diagnostic.test.mjs + # gate every PR — the suite locks in the migration failure diagnostic (a + # structured MigrationFailedError whose diagnostic identifies the failing + # migration, the failure phase, the underlying cause, and the applied/pending + # ledger state, serializable via toJSON) with mutation probes, and a + # deterministic stub-pool behavioral probe (intentionally failing migration + # fixture -> structured diagnostic naming the failing migration) runs on + # Node 24; the docker-gated real-stack probe (the issue's test plan: "run an + # intentionally failing migration fixture and confirm the diagnostic") runs + # where a Docker daemon is available and skips cleanly otherwise. The job + # installs the frozen workspace because the probes execute the committed + # runner module from the host (it imports `pg` through the package's own + # links). + database-postgres-diagnostic: + name: Migration failure diagnostic (E00-S03-T05) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable pnpm (corepack, pinned to 11.23.0 via packageManager) + run: corepack enable + - name: Install dependencies (frozen lockfile) + run: pnpm install --frozen-lockfile + - name: Run migration failure diagnostic test suite + run: node --test tests/database-postgres-diagnostic.test.mjs + # E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db # image pinned to postgres:18.6-bookworm, health gate, volume persistence, # build platforms) gate every PR (the docker-gated real-stack probes inside diff --git a/.gitignore b/.gitignore index 5a3b2f5..5352da1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ coverage/ # suites into the package (removed in their finally blocks) .ledger-probe-*.mjs .lock-probe-*.mjs +.diagnostic-probe-*.mjs # OS / editor .DS_Store diff --git a/tests/database-postgres-diagnostic.test.mjs b/tests/database-postgres-diagnostic.test.mjs new file mode 100644 index 0000000..6c044a7 --- /dev/null +++ b/tests/database-postgres-diagnostic.test.mjs @@ -0,0 +1,797 @@ +/** + * Migration failure diagnostic test — locks in the [E00-S03-T05] structured + * diagnostic produced when a migration fails. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "migration failure produces a structured diagnostic" → the committed + * `packages/database-postgres/src/runner.ts` defines a `MigrationRunner` + * that applies pending migrations through the migration ledger (E00-S03-T03) + * and, when a migration fails, throws a `MigrationFailedError` whose + * `.diagnostic` is a structured object (`migration`, `phase`, `cause`, + * `applied`, `pending`); the error is serializable (`toJSON()` returns a + * plain object, including a structured cause — for pg errors the `code`). + * The deterministic stub-pool behavioral probe drives the committed + * module with an intentionally failing migration fixture and confirms the + * structured diagnostic, and the docker-gated real-stack probe does the + * same against a real database (the issue's test plan: "run an + * intentionally failing migration fixture and confirm the diagnostic"). + * - "the diagnostic identifies the failing migration" → the diagnostic's + * `migration` field is the failing migration's version (also named in the + * error `message` and in `toJSON()`), and `phase` distinguishes 'apply' + * (the migration's `up` threw) from 'record' (the ledger insert threw + * after a successful `up`); `applied`/`pending` carry the ledger state at + * failure time (disjoint, in run order — the failing migration is still + * pending). Locked in statically (mutation probes prove non-vacuity) and + * behaviorally (stub-pool probe: apply-failure and record-failure + * fixtures both produce a diagnostic naming the failing migration; + * real-stack probe: a fixture whose `up` runs invalid SQL fails with a + * diagnostic naming that migration and carrying the pg error code). + * - the runner is part of the driver boundary: `src/index.ts` re-exports + * `MigrationRunner` + `MigrationFailedError` (and the migration types), so + * no other package needs the `pg` driver to run migrations. + * + * Run: `node --test tests/database-postgres-diagnostic.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed runner module and the driver boundary that re-exports it. */ +const RUNNER_SRC = 'packages/database-postgres/src/runner.ts'; +const INDEX_SRC = 'packages/database-postgres/src/index.ts'; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the failure-diagnostic criterion on every PR. */ +const CI_JOB = 'database-postgres-diagnostic'; + +/** Versions used by the real-stack probe's intentionally failing migration fixture. */ +const OK_MIGRATION = '2026-08-30_001_fixture_ok'; +const FAIL_MIGRATION = '2026-08-30_002_fixture_fail'; + +/** Table the real-stack probe's ok migration creates (and its missing sibling for the failing one). */ +const FIXTURE_TABLE = 'diagnostic_fixture_ok'; + +// --------------------------------------------------------------------------- +// Static assertions on the committed runner source +// --------------------------------------------------------------------------- + +/** + * Asserts the runner module exists with the documented shape: a + * `MigrationRunner` class, a `MigrationFailedError` class, and the + * `Migration`/`MigrationDiagnostic`/`MigrationRunResult` types. Fails fast on + * a missing module; the mutation probes below prove the assertions are + * non-vacuous. + */ +function assertRunnerModule(src) { + assert.match(src, /export class MigrationRunner/, 'the runner module must export the MigrationRunner class'); + assert.match(src, /export class MigrationFailedError/, 'the runner module must export the MigrationFailedError class'); + assert.match(src, /export interface Migration \{/, 'the runner module must export the Migration step interface'); + assert.match(src, /up\(pool: Pool\)/, 'a migration step must apply through an up(pool) function'); + assert.match(src, /export interface MigrationDiagnostic/, 'the runner module must export the MigrationDiagnostic interface'); + assert.match(src, /export interface MigrationRunResult/, 'the runner module must export the MigrationRunResult interface'); +} + +/** + * Asserts the diagnostic is structured and identifies the failing migration: + * the `MigrationDiagnostic` interface carries `migration` (the failing + * migration's version), `phase` ('apply' | 'record'), `cause`, `applied` and + * `pending`. Fails fast on a missing field; the mutation probes prove the + * assertions are non-vacuous. + */ +function assertDiagnosticShape(src) { + const diagSrc = src.slice( + src.indexOf('export interface MigrationDiagnostic'), + src.indexOf('export interface MigrationRunResult'), + ); + assert.match( + diagSrc, + /migration:\s*string/, + 'the diagnostic must identify the failing migration (migration: string)', + ); + assert.match( + diagSrc, + /phase:\s*MigrationFailurePhase/, + 'the diagnostic must record where the run failed (phase: MigrationFailurePhase — apply | record)', + ); + assert.match(diagSrc, /cause:\s*unknown/, 'the diagnostic must carry the underlying cause (cause: unknown)'); + assert.match( + diagSrc, + /applied:\s*string\[\]/, + 'the diagnostic must list the migrations applied before the failure (applied: string[])', + ); + assert.match( + diagSrc, + /pending:\s*string\[\]/, + 'the diagnostic must list the migrations still pending at failure time (pending: string[])', + ); +} + +/** + * Asserts `MigrationFailedError` carries the structured diagnostic and is + * serializable: a `readonly diagnostic: MigrationDiagnostic` field, a + * `toJSON()` method, and a message naming the failing migration. + */ +function assertFailedError(src) { + const errSrc = src.slice( + src.indexOf('export class MigrationFailedError'), + src.indexOf('export class MigrationRunner'), + ); + assert.match( + errSrc, + /readonly diagnostic:\s*MigrationDiagnostic/, + 'MigrationFailedError must carry the structured diagnostic (readonly diagnostic: MigrationDiagnostic)', + ); + assert.match(errSrc, /toJSON\(\)/, 'the error must be serializable (toJSON())'); + assert.match( + errSrc, + /migration "\$\{diagnostic\.migration\}" failed during/, + 'the error message must name the failing migration', + ); +} + +/** + * Asserts the runner wraps a failing migration into the structured diagnostic + * instead of rethrowing the raw error: both failure paths (apply and record) + * build the diagnostic with the failing migration's version, and the + * diagnostic error is constructed (new MigrationFailedError). + */ +function assertWrapsFailure(src) { + const runnerBody = src.slice(src.indexOf('export class MigrationRunner')); + assert.match( + runnerBody, + /new MigrationFailedError\(\{/, + 'the runner must construct the structured diagnostic error (new MigrationFailedError({ ... }))', + ); + assert.match( + runnerBody, + /failure\(migration\.version, 'apply', error, recorded\)/, + "the apply-failure path must build the diagnostic with the failing migration's version", + ); + assert.match( + runnerBody, + /failure\(migration\.version, 'record', error, recorded\)/, + "the record-failure path must build the diagnostic with the failing migration's version", + ); +} + +/** Asserts the runner applies pending migrations through the migration ledger (exactly once). */ +function assertLedgerIntegration(src) { + const runnerBody = src.slice(src.indexOf('export class MigrationRunner')); + assert.match( + runnerBody, + /await this\.ledger\.ensure\(\);/, + 'the runner must ensure the ledger exists before applying (self-sufficient on an empty database)', + ); + assert.match( + runnerBody, + /await this\.ledger\.applied\(\)/, + 'the runner must read the applied migrations from the ledger before applying (never double-applies)', + ); + assert.match( + runnerBody, + /await this\.ledger\.record\(migration\.version\);/, + 'the runner must record each applied migration in the ledger', + ); +} + +/** Asserts the driver boundary re-exports the runner + diagnostic from the package entrypoint. */ +function assertBoundaryReexport(src) { + assert.match( + src, + /export \{ MigrationRunner, MigrationFailedError \} from '\.\/runner\.js'/, + 'the driver boundary must re-export the runner (src/index.ts → ./runner.js)', + ); + assert.match( + src, + /export type \{ Migration, MigrationDiagnostic, MigrationRunResult, MigrationFailurePhase \} from '\.\/runner\.js'/, + 'the driver boundary must re-export the runner types (src/index.ts → ./runner.js)', + ); +} + +// --------------------------------------------------------------------------- +// Docker probe helpers (integration test skips cleanly without Docker) +// --------------------------------------------------------------------------- + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { + encoding: 'utf8', + timeout: 600_000, + ...opts, + }); +} + +/** True when the `docker` CLI with the Compose plugin is on PATH. */ +function dockerComposeAvailable() { + try { + return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +/** True when a reachable Docker daemon exists. */ +function dockerDaemonAvailable() { + try { + return run('docker', ['info'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +/** Parses `docker compose ps --format json` (JSON array or one object per line). */ +function parsePsJson(stdout) { + const text = String(stdout).trim(); + if (!text) return []; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); + } +} + +/** Tolerant field lookup across compose ps JSON shapes. */ +function field(container, ...names) { + for (const name of names) { + if (container[name] !== undefined) return container[name]; + } + return undefined; +} + +/** + * Polls `docker compose ps` until the db container of the given project + * reports healthy (or the deadline passes), so the probe never races a + * still-booting database. + */ +function waitForDbHealthy(project, deadlineMs = 60_000) { + const deadline = Date.now() + deadlineMs; + let last = ''; + while (Date.now() < deadline) { + const ps = run('docker', ['compose', '-p', project, 'ps', '--format', 'json'], { + cwd: REPO_ROOT, + timeout: 15_000, + }); + if (ps.status === 0) { + last = ps.stdout; + const db = parsePsJson(ps.stdout).find((c) => field(c, 'Service', 'service') === 'db'); + if (db && /healthy/i.test(String(field(db, 'Health', 'health') ?? ''))) return; + } + run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // db still booting — retry + } + throw new Error(`the db container did not become healthy within ${deadlineMs}ms (last ps: "${last.trim()}")`); +} + +// --------------------------------------------------------------------------- +// Deterministic behavioral probe (no database, no Docker) — the issue's test +// plan: "run an intentionally failing migration fixture and confirm the +// diagnostic", driven against a stub pool so it runs anywhere Node can strip +// types (Node >= 23.6, i.e. the CI Node 24). +// --------------------------------------------------------------------------- + +/** + * The host-side probe body: drives the committed `MigrationRunner` with a + * stub pg pool (the same SQL shapes the committed ledger module uses, plus an + * insert that can be made to fail for versions starting with 'fail') through + * three scenarios — a successful run + idempotent rerun, an apply-failure + * fixture (the intentionally failing migration), and a record-failure fixture + * — and reports the structured diagnostics. Written to a temp file inside + * `packages/database-postgres/` so `pg` resolves through the package's own + * dependency links, then removed. + */ +const PROBE_SOURCE = ` +import { MigrationRunner, MigrationFailedError } from './src/runner.ts'; +import { MigrationLedger } from './src/ledger.ts'; + +// A stub pg pool: tracks the migration ledger in memory (the same SQL shapes +// the committed ledger module issues) and fails a ledger insert for versions +// starting with 'fail' — so the committed runner is driven deterministically +// with no database and no Docker. +const makeStubPool = () => { + const store = new Map(); + const queries = []; + return { + store, + queries, + async query(text, values) { + queries.push({ text, values }); + if (/^INSERT INTO schema_migrations/.test(text)) { + const version = values[0]; + if (version.startsWith('fail')) { + const err = new Error('the ledger insert failed (read-only transaction)'); + err.code = '25006'; + throw err; + } + store.set(version, new Date().toISOString()); + return { rows: [], rowCount: 1 }; + } + if (/^SELECT version FROM schema_migrations/.test(text)) { + const versions = [...store.keys()].sort(); + return { rows: versions.map((version) => ({ version })), rowCount: versions.length }; + } + return { rows: [], rowCount: 0 }; + }, + }; +}; + +const ok = (version) => ({ version, up: async (pool) => { await pool.query('SELECT 1'); } }); +const results = {}; + +// Scenario 1 — success: all migrations apply in order; a rerun skips them +// (the story's "second migration run is idempotent"). +{ + const pool = makeStubPool(); + const ledger = new MigrationLedger(pool); + const runner = new MigrationRunner(pool, [ok('ok-1'), ok('ok-2')], ledger); + const first = await runner.run(); + const second = await runner.run(); + results.success = { first, second, ledger: [...pool.store.keys()] }; +} + +// Scenario 2 — apply failure: the intentionally failing migration fixture. +{ + const pool = makeStubPool(); + const ledger = new MigrationLedger(pool); + const cause = new Error('relation "diagnostic_missing_table" does not exist'); + cause.code = '42P01'; + const failing = { version: 'fail-2', up: async () => { throw cause; } }; + const runner = new MigrationRunner(pool, [ok('ok-1'), failing, ok('ok-3')], ledger); + let outcome = { rejected: false }; + try { + await runner.run(); + } catch (error) { + outcome = { + rejected: true, + isMigrationFailedError: error instanceof MigrationFailedError, + name: error.name, + message: error.message, + migration: error.diagnostic.migration, + phase: error.diagnostic.phase, + applied: error.diagnostic.applied, + pending: error.diagnostic.pending, + causeIsOriginal: error.diagnostic.cause === cause, + causeName: error.diagnostic.cause.name, + causeCode: error.diagnostic.cause.code, + toJson: JSON.parse(JSON.stringify(error)), + }; + } + results.applyFailure = { ...outcome, ledger: [...pool.store.keys()] }; +} + +// Scenario 3 — record failure: the migration's up succeeds but the ledger +// insert fails — the diagnostic must still identify the failing migration, +// with phase 'record'. +{ + const pool = makeStubPool(); + const ledger = new MigrationLedger(pool); + const runner = new MigrationRunner(pool, [ok('ok-1'), ok('fail-record'), ok('ok-3')], ledger); + let outcome = { rejected: false }; + try { + await runner.run(); + } catch (error) { + outcome = { + rejected: true, + name: error.name, + migration: error.diagnostic.migration, + phase: error.diagnostic.phase, + applied: error.diagnostic.applied, + pending: error.diagnostic.pending, + causeCode: error.diagnostic.cause.code, + toJson: JSON.parse(JSON.stringify(error)), + }; + } + results.recordFailure = { ...outcome, ledger: [...pool.store.keys()] }; +} + +console.log('DIAGNOSTIC_PROBE_RESULT ' + JSON.stringify(results)); +`; + +// --------------------------------------------------------------------------- +// Real-stack probe — the issue's test plan against a real database +// --------------------------------------------------------------------------- + +/** + * The host-side probe body: runs the committed `MigrationRunner` against a + * real database with an intentionally failing migration fixture — the ok + * migration creates a table, the failing migration runs valid SQL against a + * table that does not exist (pg error 42P01) — and reports the structured + * diagnostic plus the ledger state and whether the ok migration took effect. + * Written to a temp file inside `packages/database-postgres/` so `pg` + * resolves through the package's own dependency links, then removed. + */ +const REAL_PROBE_SOURCE = ` +import { Pool } from 'pg'; +import { MigrationRunner, MigrationFailedError } from './src/runner.ts'; +import { MigrationLedger } from './src/ledger.ts'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const result = {}; +try { + const ledger = new MigrationLedger(pool); + const runner = new MigrationRunner(pool, [ + { + version: '${OK_MIGRATION}', + up: async (p) => { + await p.query('CREATE TABLE ${FIXTURE_TABLE} (id integer)'); + }, + }, + { + version: '${FAIL_MIGRATION}', + // The intentionally failing migration fixture: valid SQL against a + // table that does not exist -> pg error 42P01 (undefined_table). + up: async (p) => { + await p.query('SELECT * FROM ${FIXTURE_TABLE}_missing'); + }, + }, + ], ledger); + + try { + await runner.run(); + } catch (error) { + result.rejected = true; + result.isMigrationFailedError = error instanceof MigrationFailedError; + result.name = error.name; + result.message = error.message; + result.migration = error.diagnostic.migration; + result.phase = error.diagnostic.phase; + result.applied = error.diagnostic.applied; + result.pending = error.diagnostic.pending; + result.causeName = error.diagnostic.cause && error.diagnostic.cause.name; + result.causeCode = error.diagnostic.cause && error.diagnostic.cause.code; + result.toJson = JSON.parse(JSON.stringify(error)); + } + result.ledgerApplied = await ledger.applied(); + const tableClass = await pool.query('SELECT to_regclass($1) AS cls', ['public.' + '${FIXTURE_TABLE}']); + result.fixtureTable = tableClass.rows[0] ? tableClass.rows[0].cls : null; + console.log('DIAGNOSTIC_PROBE_RESULT ' + JSON.stringify(result)); +} finally { + await pool.end(); +} +`; + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the runner module exists in the driver-owner package and defines the runner and its diagnostic types', () => { + assert.ok(existsSync(path.join(REPO_ROOT, RUNNER_SRC)), `committed ${RUNNER_SRC} must exist`); + assertRunnerModule(read(RUNNER_SRC)); +}); + +test('the failure diagnostic is structured and identifies the failing migration (migration/phase/cause/applied/pending)', () => { + assertDiagnosticShape(read(RUNNER_SRC)); +}); + +test('MigrationFailedError carries the structured diagnostic and is serializable, naming the failing migration', () => { + assertFailedError(read(RUNNER_SRC)); +}); + +test('the runner wraps a failing migration into the structured diagnostic instead of rethrowing the raw error', () => { + assertWrapsFailure(read(RUNNER_SRC)); +}); + +test('the runner applies pending migrations through the migration ledger exactly once', () => { + assertLedgerIntegration(read(RUNNER_SRC)); +}); + +test('the driver boundary re-exports the runner and the diagnostic from the package entrypoint', () => { + const src = read(INDEX_SRC); + assert.match( + src, + /from '\.\/runner\.js'/, + 'the driver boundary must import the runner module (from \'./runner.js\')', + ); + assertBoundaryReexport(src); +}); + +test('the failure-diagnostic criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/database-postgres-diagnostic.test.mjs`), + `CI must run the failure-diagnostic suite (job "${CI_JOB}") on every PR`, + ); +}); + +// --------------------------------------------------------------------------- +// Behavioral probe — the issue's test plan: "run an intentionally failing +// migration fixture and confirm the diagnostic" (deterministic, no Docker) +// --------------------------------------------------------------------------- + +/** True when this Node can execute the committed `.ts` runner module (>= 23.6, type stripping). */ +const TS_STRIPPING = (() => { + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 23 || (major === 23 && minor >= 6); +})(); + +test('an intentionally failing migration produces a structured diagnostic identifying it (behavioral probe)', { skip: !TS_STRIPPING }, () => { + // The issue's test plan, driven deterministically: the committed runner is + // exercised with a stub pool through three scenarios — a successful run and + // idempotent rerun, an apply-failure fixture (the intentionally failing + // migration), and a record-failure fixture — and the structured diagnostic + // is asserted to identify the failing migration in every case. + const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.diagnostic-probe-${process.pid}.mjs`); + try { + writeFileSync(probeFile, PROBE_SOURCE); + const probe = run(process.execPath, [probeFile], { cwd: REPO_ROOT, timeout: 30_000 }); + assert.equal( + probe.status, + 0, + `the diagnostic probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(), + ); + const resultLine = (probe.stdout || '') + .split('\n') + .map((l) => l.trim()) + .find((l) => l.startsWith('DIAGNOSTIC_PROBE_RESULT')); + assert.ok(resultLine, `the diagnostic probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`); + const result = JSON.parse(resultLine.slice('DIAGNOSTIC_PROBE_RESULT'.length).trim()); + + // Scenario 1 — success: all migrations apply in order, and a rerun skips + // them (the story's "second migration run is idempotent"). + assert.deepEqual( + result.success.first, + { applied: ['ok-1', 'ok-2'], skipped: [] }, + 'a successful run must apply every migration in order', + ); + assert.deepEqual( + result.success.second, + { applied: [], skipped: ['ok-1', 'ok-2'] }, + 'a rerun must skip everything already recorded in the ledger (never double-applies)', + ); + assert.deepEqual(result.success.ledger, ['ok-1', 'ok-2'], 'the ledger must record the applied migrations'); + + // Scenario 2 — apply failure: the intentionally failing migration fixture + // (fail-2's up throws a pg-like error with code 42P01). + assert.equal(result.applyFailure.rejected, true, 'run() must reject when a migration fails'); + assert.equal(result.applyFailure.isMigrationFailedError, true, 'the failure must be a MigrationFailedError'); + assert.equal(result.applyFailure.name, 'MigrationFailedError', 'the error name must be MigrationFailedError'); + assert.match(result.applyFailure.message, /fail-2/, 'the error message must name the failing migration'); + assert.equal(result.applyFailure.migration, 'fail-2', 'the diagnostic must identify the failing migration'); + assert.equal(result.applyFailure.phase, 'apply', 'the diagnostic must report the apply phase'); + assert.deepEqual(result.applyFailure.applied, ['ok-1'], 'the diagnostic must list the migrations applied before the failure'); + assert.deepEqual(result.applyFailure.pending, ['fail-2', 'ok-3'], 'the diagnostic must list the still-pending migrations, including the failing one'); + assert.equal(result.applyFailure.causeIsOriginal, true, 'the diagnostic must preserve the original underlying cause'); + assert.equal(result.applyFailure.causeName, 'Error', 'the diagnostic cause must carry the error name'); + assert.equal(result.applyFailure.causeCode, '42P01', 'the diagnostic cause must carry the pg error code'); + assert.equal(result.applyFailure.toJson.diagnostic.migration, 'fail-2', 'toJSON() must identify the failing migration'); + assert.equal(result.applyFailure.toJson.diagnostic.phase, 'apply', 'toJSON() must carry the failure phase'); + assert.equal(result.applyFailure.toJson.diagnostic.cause.code, '42P01', 'toJSON() must carry a structured cause'); + assert.deepEqual(result.applyFailure.ledger, ['ok-1'], 'only the migrations before the failure may be recorded'); + + // Scenario 3 — record failure: the migration's up succeeds but the ledger + // insert fails — the diagnostic must still identify the failing migration, + // with phase 'record'. + assert.equal(result.recordFailure.rejected, true, 'run() must reject when recording a migration fails'); + assert.equal(result.recordFailure.name, 'MigrationFailedError', 'a record failure must also be a MigrationFailedError'); + assert.equal(result.recordFailure.migration, 'fail-record', 'a record failure must identify the failing migration'); + assert.equal(result.recordFailure.phase, 'record', 'the diagnostic must report the record phase'); + assert.deepEqual(result.recordFailure.applied, ['ok-1'], 'the diagnostic must list the migrations applied before the failure'); + assert.deepEqual(result.recordFailure.pending, ['fail-record', 'ok-3'], 'the failing migration must still be pending (never recorded)'); + assert.equal(result.recordFailure.causeCode, '25006', 'the diagnostic must carry the ledger-insert error code'); + assert.equal(result.recordFailure.toJson.diagnostic.migration, 'fail-record', 'toJSON() must identify the failing migration on a record failure'); + assert.deepEqual(result.recordFailure.ledger, ['ok-1'], 'the failed record must not be in the ledger'); + } finally { + rmSync(probeFile, { force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Real-stack probe — the issue's test plan against a real database +// --------------------------------------------------------------------------- + +const DOCKER_COMPOSE = dockerComposeAvailable(); +const DOCKER_DAEMON = dockerDaemonAvailable(); + +// An isolated compose project + non-default host port so this probe never +// collides with the compose-config suite's default-project containers, the +// ledger probe's project/port, the lock probe's project/port, or the default +// 5432 binding when several suites run on the same host. +const COMPOSE_PROJECT = 'eppp-diagnostic-probe'; +const POSTGRES_HOST_PORT = '55434'; +const DATABASE_URL = `postgres://eppp:eppp@127.0.0.1:${POSTGRES_HOST_PORT}/eppp`; + +test('an intentionally failing migration fixture produces a structured diagnostic identifying it (real stack)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON || !TS_STRIPPING }, () => { + // The issue's test plan: "run an intentionally failing migration fixture + // and confirm the diagnostic". The probe starts the committed compose `db` + // service (its own project + host port), runs the committed runner against + // the real database with a fixture whose second migration executes valid + // SQL against a missing table, and asserts the run rejects with a + // MigrationFailedError whose diagnostic identifies the failing migration, + // reports phase 'apply', carries the pg error code (42P01), lists the + // applied/pending state, and is serializable — while the ok migration's + // effect (its table) and ledger row persist and the failing migration is + // not recorded. Rollback: drop the fixture table and ledger (issue rollback + // note) and `docker compose -p eppp-diagnostic-probe down`. + const composeEnv = { ...process.env, POSTGRES_PORT: POSTGRES_HOST_PORT }; + const compose = (args, opts = {}) => + run('docker', ['compose', '-p', COMPOSE_PROJECT, ...args], { cwd: REPO_ROOT, env: composeEnv, ...opts }); + const execPsql = (args, opts = {}) => + compose(['exec', '-T', 'db', 'psql', '-U', 'eppp', '-d', 'eppp', ...args], opts); + + const probeFile = path.join(REPO_ROOT, 'packages/database-postgres', `.diagnostic-probe-real-${process.pid}.mjs`); + + try { + const up = compose(['up', '-d', 'db'], { timeout: 180_000 }); + assert.equal( + up.status, + 0, + `"docker compose up -d db" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), + ); + waitForDbHealthy(COMPOSE_PROJECT); + + // Clean slate (also recovers from a previously interrupted run): drop the + // fixture table and the ledger so the database is empty of migration state. + const clean = execPsql([ + '-v', 'ON_ERROR_STOP=1', + '-c', `DROP TABLE IF EXISTS ${FIXTURE_TABLE}; DROP TABLE IF EXISTS schema_migrations;`, + ]); + assert.equal( + clean.status, + 0, + `dropping leftover fixture/ledger tables must succeed:\n${(clean.stdout || '')}\n${(clean.stderr || '')}`.trim(), + ); + + // Run the committed runner module against the real database. + writeFileSync(probeFile, REAL_PROBE_SOURCE); + const probe = run(process.execPath, [probeFile], { + cwd: REPO_ROOT, + env: { ...process.env, DATABASE_URL }, + timeout: 60_000, + }); + assert.equal( + probe.status, + 0, + `the diagnostic real-stack probe must exit 0:\n${(probe.stdout || '')}\n${(probe.stderr || '')}`.trim(), + ); + const resultLine = (probe.stdout || '') + .split('\n') + .map((l) => l.trim()) + .find((l) => l.startsWith('DIAGNOSTIC_PROBE_RESULT')); + assert.ok(resultLine, `the diagnostic real-stack probe must report a result line (stdout: "${(probe.stdout || '').trim()}")`); + const result = JSON.parse(resultLine.slice('DIAGNOSTIC_PROBE_RESULT'.length).trim()); + + // "migration failure produces a structured diagnostic": the run rejects + // with a MigrationFailedError carrying a structured diagnostic. + assert.equal(result.rejected, true, 'run() must reject when the failing migration fixture fails'); + assert.equal(result.isMigrationFailedError, true, 'the failure must be a MigrationFailedError'); + assert.equal(result.name, 'MigrationFailedError', 'the error name must be MigrationFailedError'); + assert.match(result.message, /002_fixture_fail/, 'the error message must name the failing migration'); + + // "the diagnostic identifies the failing migration": the failing fixture + // migration is named in the diagnostic and serialized form, phase is + // 'apply', and the pg cause (code 42P01) is preserved. + assert.equal(result.migration, FAIL_MIGRATION, 'the diagnostic must identify the failing migration'); + assert.equal(result.phase, 'apply', 'the diagnostic must report the apply phase'); + assert.deepEqual(result.applied, [OK_MIGRATION], 'the diagnostic must list the migration applied before the failure'); + assert.deepEqual(result.pending, [FAIL_MIGRATION], 'the diagnostic must list the still-pending migrations'); + assert.equal(result.causeName, 'error', 'the diagnostic cause must be the pg error'); + assert.equal(result.causeCode, '42P01', 'the diagnostic must carry the pg error code (undefined_table)'); + assert.equal(result.toJson.diagnostic.migration, FAIL_MIGRATION, 'toJSON() must identify the failing migration'); + assert.equal(result.toJson.diagnostic.phase, 'apply', 'toJSON() must carry the failure phase'); + assert.equal(result.toJson.diagnostic.cause.code, '42P01', 'toJSON() must carry a structured cause'); + + // The ok migration's effect persists: it is recorded in the ledger and its + // table exists; the failing migration is not recorded. + assert.deepEqual(result.ledgerApplied, [OK_MIGRATION], 'only the ok migration may be recorded in the ledger'); + assert.ok(result.fixtureTable, 'the ok migration must have taken effect (fixture table exists)'); + + // Cross-check from inside the database container. + const ledgerVersions = execPsql(['-tA', '-c', 'SELECT version FROM schema_migrations ORDER BY version;']); + assert.equal( + ledgerVersions.status, + 0, + `ledger rows query via psql must succeed:\n${(ledgerVersions.stdout || '')}\n${(ledgerVersions.stderr || '')}`.trim(), + ); + assert.match( + ledgerVersions.stdout, + new RegExp(OK_MIGRATION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + 'the ok migration must be recorded in the ledger', + ); + assert.doesNotMatch( + ledgerVersions.stdout, + new RegExp(FAIL_MIGRATION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + 'the failing migration must not be recorded in the ledger', + ); + } finally { + // Rollback note from the issue: revert the diagnostic/error handling + // changes — here, drop the fixture table and reset migration state, then + // tear down the isolated project. + try { + execPsql(['-c', `DROP TABLE IF EXISTS ${FIXTURE_TABLE}; DROP TABLE IF EXISTS schema_migrations;`]); + } catch { + // container may already be gone — the compose down below still cleans up + } + compose(['down', '-v'], { timeout: 120_000 }); + rmSync(probeFile, { force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('removing the migration field makes the identifies-the-failing-migration criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const withoutMigration = src.replace(/ migration: string;\n/, ''); + assert.notEqual(withoutMigration, src, 'the mutation must actually remove the migration field'); + assert.throws(() => assertDiagnosticShape(withoutMigration), /migration/); +}); + +test('removing the phase field makes the structured-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const withoutPhase = src.replace(/ phase: MigrationFailurePhase;\n/, ''); + assert.notEqual(withoutPhase, src, 'the mutation must actually remove the phase field'); + assert.throws(() => assertDiagnosticShape(withoutPhase), /MigrationFailurePhase/); +}); + +test('removing the pending field makes the structured-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const withoutPending = src.replace(/ pending: string\[\];\n/, ''); + assert.notEqual(withoutPending, src, 'the mutation must actually remove the pending field'); + assert.throws(() => assertDiagnosticShape(withoutPending), /pending/); +}); + +test('replacing the wrapped failure with a bare rethrow makes the structured-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const bareRethrow = src + .replace(/throw this\.failure\(migration\.version, 'apply', error, recorded\);\n/, 'throw error;\n') + .replace(/throw this\.failure\(migration\.version, 'record', error, recorded\);\n/, 'throw error;\n'); + assert.notEqual(bareRethrow, src, 'the mutation must actually replace the wrapped failures'); + assert.throws(() => assertWrapsFailure(bareRethrow), /apply-failure path/); +}); + +test('constructing a plain Error instead of MigrationFailedError makes the structured-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const plainError = src.replace(/new MigrationFailedError\(\{/, 'new Error({'); + assert.notEqual(plainError, src, 'the mutation must actually replace the MigrationFailedError construction'); + assert.throws(() => assertWrapsFailure(plainError), /new MigrationFailedError/); +}); + +test('removing the diagnostic field from MigrationFailedError makes the structured-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const withoutField = src.replace(/ readonly diagnostic: MigrationDiagnostic;\n/, ''); + assert.notEqual(withoutField, src, 'the mutation must actually remove the diagnostic field'); + assert.throws(() => assertFailedError(withoutField), /readonly diagnostic/); +}); + +test('removing toJSON() makes the serializable-diagnostic criterion fail (mutation probe)', () => { + const src = read(RUNNER_SRC); + const withoutToJson = src.replaceAll('toJSON()', 'toJson()'); + assert.notEqual(withoutToJson, src, 'the mutation must actually rename toJSON()'); + assert.throws(() => assertFailedError(withoutToJson), /toJSON\(\)/); +}); + +test('dropping the runner re-export from the boundary fails the boundary criterion (mutation probe)', () => { + const src = read(INDEX_SRC); + const withoutReexport = src.replace(/export \{ MigrationRunner, MigrationFailedError \} from '\.\/runner\.js';\n/, ''); + assert.notEqual(withoutReexport, src, 'the mutation must actually remove the runner re-export'); + assert.throws(() => assertBoundaryReexport(withoutReexport), /re-export/); +}); + +test('a placeholder runner module fails the runner-module criterion (mutation probe)', () => { + assert.throws( + () => assertRunnerModule('export class MigrationRunner {}\n'), + /MigrationFailedError/, + ); +}); From bb3a68648a66c4049fcd4125a096b51b3c45a01a Mon Sep 17 00:00:00 2001 From: implementer Date: Sun, 30 Aug 2026 01:24:14 +0000 Subject: [PATCH 3/3] fix: inject the ledger into MigrationRunner (type-only import) so probes load the committed module under type stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's type stripping does not rewrite './ledger.js' to './ledger.ts', so the runner's runtime import of the ledger could not resolve when the behavioral probes execute the committed runner.ts directly (CI failure on Node 24). The ledger is now imported type-only and the caller passes the instance (new MigrationLedger(pool)) — the probes already do. runner.ts has no runtime imports left, so type stripping erases them and the committed module loads as-is. --- packages/database-postgres/src/runner.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/database-postgres/src/runner.ts b/packages/database-postgres/src/runner.ts index 8d65f0a..f2f36c5 100644 --- a/packages/database-postgres/src/runner.ts +++ b/packages/database-postgres/src/runner.ts @@ -33,7 +33,7 @@ */ import type { Pool } from 'pg'; -import { MigrationLedger } from './ledger.js'; +import type { MigrationLedger } from './ledger.js'; /** * A single migration step: an identifier (recorded in the ledger once the @@ -136,14 +136,17 @@ export class MigrationRunner { private readonly migrations: readonly Migration[]; /** - * @param pool The package-owned PostgreSQL pool (`pg.Pool`). + * @param pool The package-owned PostgreSQL pool (`pg.Pool`), handed to each + * migration's `up`. * @param migrations The migrations to run, in apply order (oldest first). - * @param ledger The migration ledger; defaults to one sharing `pool`. + * @param ledger The migration ledger (E00-S03-T03) the runner reads applied + * versions from and records applied migrations into; constructed by the + * caller from the same pool (`new MigrationLedger(pool)`). */ - constructor(pool: Pool, migrations: readonly Migration[], ledger?: MigrationLedger) { + constructor(pool: Pool, migrations: readonly Migration[], ledger: MigrationLedger) { this.pool = pool; this.migrations = migrations; - this.ledger = ledger ?? new MigrationLedger(pool); + this.ledger = ledger; } /**