[E00-S03-T05] Migration failure produces structured diagnostic #394
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Migration runner with failure diagnostic — [E00-S03-T05].
|
||||
*
|
||||
* The migration runner applies pending migrations to the database exactly
|
||||
* once and, when a migration fails, produces a structured diagnostic that
|
||||
* identifies the failing migration. It is built on the boundary that came
|
||||
* before it: the migration ledger (E00-S03-T03) records applied migrations,
|
||||
* so a rerun never double-applies. The advisory lock (E00-S03-T04) serializes
|
||||
* concurrent runners, but wiring the lock into the runner is out of scope for
|
||||
* this story — the runner performs no locking itself; a runner that wants to
|
||||
* serialize takes the lock (E00-S03-T04) around `run()`.
|
||||
*
|
||||
* Failure model:
|
||||
* - migrations run in the order given, oldest first; a migration whose
|
||||
* version is already recorded in the ledger is skipped;
|
||||
* - when a migration's `up` throws, the runner wraps the failure into a
|
||||
* `MigrationFailedError` whose `diagnostic` is a structured object that
|
||||
* identifies the failing migration (`migration` — its version), where the
|
||||
* run failed (`phase`: 'apply' when the migration's `up` threw, 'record'
|
||||
* when the ledger insert threw after a successful `up`), the underlying
|
||||
* cause, and the ledger state at failure time (`applied`/`pending` —
|
||||
* `applied` + `pending` cover the runner's migrations exactly);
|
||||
* - the error is serializable: `toJSON()` returns a plain structured object
|
||||
* (including a structured cause — for pg errors the `code`, e.g. `42P01`),
|
||||
* so operators can log/parse the diagnostic without string-matching.
|
||||
*
|
||||
* The runner lives in `database-postgres` — the single workspace package
|
||||
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
|
||||
* database exclusively through the package-owned `pg` Pool and the
|
||||
* `MigrationLedger`, so no other package needs the driver to run migrations.
|
||||
*
|
||||
* Rollback note from the issue: revert the diagnostic/error handling changes.
|
||||
*/
|
||||
|
||||
import type { Pool } from 'pg';
|
||||
import { MigrationLedger } from './ledger.js';
|
||||
|
||||
/**
|
||||
* A single migration step: an identifier (recorded in the ledger once the
|
||||
* step has been applied) and the apply function. `up` receives the
|
||||
* package-owned pool, so a migration can run any SQL (and multi-statement
|
||||
* work) through the same driver boundary the runner itself uses.
|
||||
*/
|
||||
export interface Migration {
|
||||
version: string;
|
||||
up(pool: Pool): Promise<void> | 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`).
|
||||
* @param migrations The migrations to run, in apply order (oldest first).
|
||||
* @param ledger The migration ledger; defaults to one sharing `pool`.
|
||||
*/
|
||||
constructor(pool: Pool, migrations: readonly Migration[], ledger?: MigrationLedger) {
|
||||
this.pool = pool;
|
||||
this.migrations = migrations;
|
||||
this.ledger = ledger ?? new MigrationLedger(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the pending migrations: creates the ledger if missing, then applies
|
||||
* every migration whose version is not yet recorded, recording each one as
|
||||
* it completes. Idempotent: a rerun skips everything already recorded, so a
|
||||
* second run never double-applies. When a migration fails — its `up` throws
|
||||
* ('apply') or the ledger insert throws ('record') — the runner throws a
|
||||
* `MigrationFailedError` whose `diagnostic` identifies the failing migration
|
||||
* and the ledger state at failure time.
|
||||
*/
|
||||
async run(): Promise<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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user