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.
94 lines
3.5 KiB
TypeScript
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);
|
|
}
|
|
}
|