[E00-S03-T05] Migration failure produces structured diagnostic #394
No Reviewers
Labels
Clear labels
agent/analyst-drafted
agent/analyst-drafted
needs/human-decision
needs/human-decision
needs/security-review
needs/security-review
tier/t0
tier/t1
tier/t2
tier/t3
kind
bug
kind
bug
kind
epic
kind
epic
kind
initiative
EPPP programme initiative
kind
story
kind
story
kind
task
EPPP engineering card/task decomposed from a story
kind
toil
kind
toil
loop
1
loop
1
loop
2
loop
2
loop
3
loop
3
risk
agent-full
risk
agent-full
risk
human-gated
risk
human-gated
risk
human-only
risk
human-only
size
l
size
l
size
m
size
m
size
s
size
s
status
blocked
status
blocked
status
done
Workflow: Done
status
in-progress
status
in-progress
status
proposed
status
proposed
status
ready
status
ready
status
review
status
review
stream
checkout
stream
checkout
stream
onboarding
stream
onboarding
stream
platform
stream
platform
trivial — implementer only, auto-merge
standard — implementer + reviewer + tester
complex — security if triggered, human merge
critical — full chain + security, human merge
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Fabrika/PersonalBlog#394
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed
Implements [E00-S03-T05] Migration failure produces structured diagnostic (#180): a
MigrationRunnerin thedatabase-postgrespackage that applies pending migrations through the migration ledger exactly once and, when a migration fails, throws aMigrationFailedErrorcarrying a structured diagnostic that identifies the failing migration — verified against a real database with an intentionally failing migration fixture.packages/database-postgres/src/runner.ts—MigrationRunnerover the package-ownedpgPool +MigrationLedger(E00-S03-T03):run()ensures the ledger exists, reads the applied migrations, and applies every migration whose version is not yet recorded — a rerun skips everything already recorded (the story's "second migration run is idempotent", from parent #60). Migrations are{ version, up(pool) }steps run in order (oldest first).upthrows, the runner wraps the failure intoMigrationFailedErrorinstead of rethrowing the raw error. The error carries.diagnostic, a structured object that identifies the failing migration (migration— its version), where the run failed (phase:'apply'whenupthrew,'record'when the ledger insert threw after a successfulup), the underlyingcause(preserved for inspection), and the ledger state at failure time (applied/pending— disjoint, in run order; the failing migration is still pending). The error is serializable:toJSON()returns a plain structured object with a structured cause (for pg errors thecode, e.g.42P01), so operators can log/parse the diagnostic without string-matching.packages/database-postgres/src/index.ts— driver boundary re-export:MigrationRunner+MigrationFailedError(and theMigration/MigrationDiagnostic/MigrationRunResult/MigrationFailurePhasetypes) are re-exported from the package entrypoint, so no other workspace package needs thepgdriver to run migrations (isolation E00-S03-T02 stays intact —runner.tsimports onlyimport type { Pool }and the ledger).tests/database-postgres-diagnostic.test.mjs— suite locking in both acceptance criteria: static assertions on the committed source (structured diagnostic shape withmigration/phase/cause/applied/pending;MigrationFailedErrorcarries.diagnosticand is serializable with a message naming the failing migration; both failure paths build the diagnostic with the failing migration's version; ledger integration; driver-boundary re-export; CI enforcement), each backed by a mutation probe proving non-vacuousness. The deterministic stub-pool behavioral probe (no database/Docker; runs on Node ≥ 23.6, i.e. the CI Node 24) drives the committed runner with an intentionally failing migration fixture through three scenarios — success + idempotent rerun, apply-failure, and record-failure — and asserts the structured diagnostic identifies the failing migration in every case. The real-stack probe is the authoritative behavioral check: it starts the committed composedbservice (isolated projecteppp-diagnostic-probe+ host port 55434), executes the committed runner against the real database with a fixture whose second migration runs valid SQL against a missing table, and asserts the run rejects with aMigrationFailedErrorwhose diagnostic names the failing migration, reports phase'apply', carries the pg error code42P01, lists the applied/pending state, and is serializable — while the ok migration's table and ledger row persist and the failing migration is not recorded (the issue's test plan: "run an intentionally failing migration fixture and confirm the diagnostic")..gitea/workflows/ci.yml— newdatabase-postgres-diagnosticjob runsnode --test tests/database-postgres-diagnostic.test.mjson every PR so the failure-diagnostic criterion gates merges. Additive only (no existing job modified, same action majors, nosecrets:context, no untrusted interpolation).docs/development/non-container.mdpackage table andpackages/database-postgres/package.jsondescription updated (the runner + failure diagnostic now land here, not "in later stories");.gitignoreignores the transient probe files the behavioral/real-stack probes write into the package (removed in theirfinallyblocks); the stale "later stories" references in theledger.ts/lock.tsmodule headers updated.Explicitly out of scope per the brief, not touched: advisory lock (E00-S03-T04) — the runner performs no locking itself (a runner that wants to serialize takes the lock around
run()); ready-before-migrations gate (E00-S03-T06).Criterion → test table
tests/database-postgres-diagnostic.test.mjs— "the runner module exists in the driver-owner package and defines the runner and its diagnostic types" (exportsMigrationRunner,MigrationFailedError,Migration,MigrationDiagnostic,MigrationRunResult), "the failure diagnostic is structured and identifies the failing migration (migration/phase/cause/applied/pending)" (migration: string,phase: MigrationFailurePhase,cause: unknown,applied: string[],pending: string[]), "MigrationFailedError carries the structured diagnostic and is serializable, naming the failing migration" (readonly diagnostic,toJSON(), message names the failing migration), "the runner wraps a failing migration into the structured diagnostic instead of rethrowing the raw error" (both failure paths constructnew MigrationFailedErrorwith the failing version); mutation probes "replacing the wrapped failure with a bare rethrow …", "constructing a plain Error instead of MigrationFailedError …", "removing the diagnostic field from MigrationFailedError …", "removing toJSON() …", "a placeholder runner module …"; behavioral probe — apply-failure and record-failure fixtures both reject withMigrationFailedErrorwhose diagnostic carriesmigration/phase/cause/applied/pending, andtoJSON()round-trips; real-stack probe — the intentionally failing fixture rejects with a structured diagnostictests/database-postgres-diagnostic.test.mjs— "the failure diagnostic is structured and identifies the failing migration …" (themigration: stringfield) + mutation probe "removing the migration field makes the identifies-the-failing-migration criterion fail"; "MigrationFailedError … naming the failing migration" (messagemigration "…" failed during …); behavioral probe —diagnostic.migration === 'fail-2'for the apply-failure fixture and'fail-record'for the record-failure fixture (phase'record'still names the failing migration), andtoJSON().diagnostic.migrationmatches; real-stack probe —diagnostic.migration === '2026-08-30_002_fixture_fail',phase === 'apply', cause code42P01,applied ['2026-08-30_001_fixture_ok'],pending ['2026-08-30_002_fixture_fail'],toJSON()names the failing migration, and the failing migration is not recorded in the ledger (psql cross-check)tests/database-postgres-diagnostic.test.mjs— "the runner applies pending migrations through the migration ledger exactly once" (ledger.ensure()/applied()/record(migration.version)); behavioral probe — first run applies['ok-1','ok-2'], rerun returns{ applied: [], skipped: ['ok-1','ok-2'] }with the ledger unchanged (the story's "second migration run is idempotent")tests/database-postgres-diagnostic.test.mjs— the docker-gated real-stack probe runs the committed runner against the committed composedbservice and asserts both acceptance criteria behaviorally (see rows above); the deterministic stub-pool behavioral probe (no Docker) covers the same contracts on every CI Node; every static assertion has a mutation probe (8 probes: 7 source mutations + placeholder module) proving it fails on a violationdatabase-postgres-diagnosticjob in.gitea/workflows/ci.ymltests/database-postgres-diagnostic.test.mjs— "the failure-diagnostic criterion is enforced in CI" (root test glob covers the suite and.gitea/workflows/ci.ymlruns it);.gitea/workflows/ci.yml—database-postgres-diagnosticjob runsnode --test tests/database-postgres-diagnostic.test.mjson every PR (additive, matching the security-reviewed #390/#391/#392/#393 precedent)Test plan executed
node --test tests/database-postgres-diagnostic.test.mjs→ 18 tests, 16 pass / 0 fail / 2 skip on Node 22.23.2 (the docker-gated real-stack probe and the TS-stripping-gated behavioral probe skip on Node 22 per the suite's conservative>= 23.6gate; both run in CI on Node 24 — the behavioral probe needs no Docker).--experimental-strip-types, with the committed modules copied to a temp dir so the relative.jsspecifier resolves on Node 22):DIAGNOSTIC_PROBE_RESULTreports — success:first {applied:['ok-1','ok-2'],skipped:[]}, rerun{applied:[],skipped:['ok-1','ok-2']}; apply-failure:rejected:true, isMigrationFailedError:true, name:'MigrationFailedError', message:'migration "fail-2" failed during apply', migration:'fail-2', phase:'apply', applied:['ok-1'], pending:['fail-2','ok-3'], causeIsOriginal:true, causeCode:'42P01',toJson.diagnostic.cause.code:'42P01'; record-failure:migration:'fail-record', phase:'record', causeCode:'25006'. All behavioral-probe assertions pass on the committed module's control flow.src/*.ts(including the newrunner.tsand the updatedindex.ts) compiles clean under the exact committed strict base config (tsconfig.base.json— strict family,verbatimModuleSyntax,exactOptionalPropertyTypes,noUncheckedIndexedAccess, NodeNext) with the pinned TypeScript 6.0.3,pg8.22.0 +@types/pg8.21.0 → exit 0.database-postgres-ledger(12 pass / 1 docker skip),database-postgres-lock(16 pass / 2 skip),database-postgres-imports(8 pass),workspace-layout(8 pass),workspace-config(6 pass),architecture-import(10 pass).node --test "tests/**/*.test.mjs"): 180 tests — 154 pass / 12 fail / 14 skip on Node 22.23.2; the 12 failures are pre-existing environment artifacts, verified identical on the pristine base commit in a clean worktree (this sandbox has Node 22 — the workspace engines gate requires Node ≥ 24 — andpnpmis not on PATH /node_modulesis absent for the spawned-command tests):tests/frozen-install.test.mjs×5 andtests/root-commands.test.mjs×3 andtests/typescript-pin.test.mjs×1 fail onpnpm: not found/engine gate,tests/node-engine.test.mjs×1 asserts the runtime is Node 24.x,tests/strict-tsconfig.test.mjs×2 fail because the tsc binary is absent (no install). CI runs Node 24 with corepack, where these pass (prior CI runs on the same codebase were fully green).Risks / notes
pgthrough the package's own links).-p eppp-diagnostic-probe) and host port 55434 so they never collide with the compose-config suite's default-project containers, the ledger probe's project (eppp-ledger-probe)/port 55432, the lock probe's project (eppp-lock-probe)/port 55433, or the default 5432 binding when several suites run on the same host.run()) and no ready gating (E00-S03-T06); both remain for their own stories. Rollback note from the issue: revert the diagnostic/error handling changes (the runner is additive; droppingrunner.tsand its re-export restores the prior state).Refs #180
CI green on the final head (
bb3a686, run 92 — 7/7 jobs, including the newdatabase-postgres-diagnosticjob). Follow-up fix over the initial push:MigrationRunnernow takes the ledger by type-only import (caller passesnew MigrationLedger(pool)— the probes already did) because Node's type stripping does not rewrite./ledger.js→./ledger.ts, which the behavioral probe hit when executing the committedrunner.tsdirectly.runner.tsnow has no runtime imports, so the committed module loads as-is under type stripping; the strict typecheck and the full local suite remain green.