|
|
|
@@ -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/,
|
|
|
|
|
);
|
|
|
|
|
});
|