test: lock in the migration failure diagnostic (E00-S03-T05)
CI / Frozen lockfile install (pull_request) Successful in 44s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 51s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Failing after 51s
CI / Frozen lockfile install (pull_request) Successful in 44s
CI / Secrets not embedded (E00-S02-T08) (pull_request) Successful in 28s
CI / Database-postgres import isolation (E00-S03-T02) (pull_request) Successful in 26s
CI / Migration ledger (E00-S03-T03) (pull_request) Successful in 44s
CI / Migration advisory lock (E00-S03-T04) (pull_request) Successful in 51s
CI / Compose config (E00-S03-T01) (pull_request) Successful in 25s
CI / Migration failure diagnostic (E00-S03-T05) (pull_request) Failing after 51s
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user