Merge pull request '[E00-S03-T05] Migration failure produces structured diagnostic' (#394) from feature/180 into main
CI / Frozen lockfile install (push) Successful in 44s
CI / Secrets not embedded (E00-S02-T08) (push) Successful in 25s
CI / Database-postgres import isolation (E00-S03-T02) (push) Successful in 30s
CI / Migration ledger (E00-S03-T03) (push) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (push) Successful in 46s
CI / Migration failure diagnostic (E00-S03-T05) (push) Successful in 43s
CI / Compose config (E00-S03-T01) (push) Successful in 29s

This commit was merged in pull request #394.
This commit is contained in:
2026-08-30 01:31:48 +00:00
9 changed files with 1049 additions and 10 deletions
+29
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+7 -4
View File
@@ -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';
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
*
+209
View File
@@ -0,0 +1,209 @@
/**
* 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 type { 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> | 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<string, unknown> {
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<string, unknown> {
if (cause instanceof Error) {
const structured: Record<string, unknown> = { 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`), handed to each
* migration's `up`.
* @param migrations The migrations to run, in apply order (oldest first).
* @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) {
this.pool = pool;
this.migrations = migrations;
this.ledger = ledger;
}
/**
* 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<MigrationRunResult> {
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<string>,
): 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 });
}
}
+797
View File
@@ -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/,
);
});