Files
PersonalBlog/packages/database-postgres/src/ledger.ts
T
implementer f6d407394d feat: add migration runner with structured failure diagnostic (E00-S03-T05)
MigrationRunner applies pending migrations through the migration ledger
exactly once; when a migration fails it throws a MigrationFailedError
whose diagnostic is a structured object identifying the failing migration
(version), the failure phase (apply/record), the underlying cause, and the
applied/pending ledger state, serializable via toJSON. Re-exported from
the driver boundary so no other package needs the pg driver to run
migrations. Advisory lock (T04) and ready gate (T06) remain out of scope.
2026-08-30 01:17:23 +00:00

94 lines
3.5 KiB
TypeScript

/**
* Migration ledger — [E00-S03-T03].
*
* The ledger is the PostgreSQL table that records applied migrations, so the
* 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
* allowed to import the PostgreSQL driver (E00-S03-T02) — and talks to the
* database exclusively through the package-owned `pg` Pool, so no other
* package needs the driver to read or write migration state.
*
* Table: `schema_migrations`
* version text — the migration identifier (table primary key)
* applied_at timestamptz — when the migration was applied (not null, now())
*
* Every operation is idempotent: `ensure()` only creates the table when it is
* missing, and `record()` is a conflict-tolerant insert, so a rerun never
* errors and never double-applies. The migration version is always passed as
* a bound parameter (`$1`) — the SQL never interpolates it, so the only
* interpolated value is the compile-time table-name constant. Rollback note
* from the issue: `DROP TABLE schema_migrations` resets migration state.
*/
import type { Pool } from 'pg';
/** Name of the migration ledger table. */
export const MIGRATION_LEDGER_TABLE = 'schema_migrations';
/**
* DDL that creates the migration ledger table. Idempotent (create-if-absent):
* the first run against an empty database creates the ledger ("migration
* ledger is created"); every later run is a no-op.
*/
export const MIGRATION_LEDGER_DDL = `
CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
`;
/**
* The migration ledger: records which migrations have been applied to the
* database, backed by the `schema_migrations` table. Instances are cheap and
* share the caller's pool; the ledger performs no locking (advisory lock is
* E00-S03-T04) and no diagnostics (E00-S03-T05).
*/
export class MigrationLedger {
private readonly pool: Pool;
/** @param pool The package-owned PostgreSQL pool (`pg.Pool`). */
constructor(pool: Pool) {
this.pool = pool;
}
/**
* Creates the ledger table if it does not exist. Idempotent: calling it on
* an empty database creates the ledger; calling it again is a no-op.
*/
async ensure(): Promise<void> {
await this.pool.query(MIGRATION_LEDGER_DDL);
}
/**
* Records a migration as applied. Idempotent: recording the same version
* twice keeps exactly one row, so a rerun never double-applies. The version
* is a bound parameter (`$1`).
*/
async record(version: string): Promise<void> {
await this.pool.query(
`INSERT INTO ${MIGRATION_LEDGER_TABLE} (version) VALUES ($1) ON CONFLICT (version) DO NOTHING`,
[version],
);
}
/** True when the given migration version is recorded in the ledger. */
async has(version: string): Promise<boolean> {
const result = await this.pool.query(
`SELECT 1 FROM ${MIGRATION_LEDGER_TABLE} WHERE version = $1`,
[version],
);
return (result.rowCount ?? 0) > 0;
}
/** Versions recorded in the ledger, oldest applied first. */
async applied(): Promise<string[]> {
const result = await this.pool.query(
`SELECT version FROM ${MIGRATION_LEDGER_TABLE} ORDER BY applied_at, version`,
);
return result.rows.map((row) => row.version as string);
}
}