feat: add migration ledger to database-postgres (E00-S03-T03)
MigrationLedger over the package-owned pg Pool: ensure() creates the schema_migrations table (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now()) with idempotent DDL; record() inserts an applied migration with a parameterized, idempotent statement (ON CONFLICT DO NOTHING — a rerun never double-applies); has()/applied() read the ledger back in apply order. Re-exported from the driver boundary (src/index.ts) so no other package needs the pg driver to touch migration state.
This commit is contained in:
@@ -8,13 +8,15 @@
|
||||
* package will expose — so no other package ever imports the driver directly.
|
||||
*
|
||||
* This module is the driver boundary: it imports the PostgreSQL driver (`pg`)
|
||||
* and Kysely and re-exports the pieces later stories build the adapter on
|
||||
* (migration ledger E00-S03-T03, advisory lock E00-S03-T04). Until then the
|
||||
* re-exports keep the driver reachable only from here — the isolation is real,
|
||||
* not a placeholder.
|
||||
* and Kysely and re-exports the pieces the adapter is built on — the driver
|
||||
* surface (E00-S03-T02) and the migration ledger (E00-S03-T03). The advisory
|
||||
* lock (E00-S03-T04) and failure diagnostic (E00-S03-T05) land in later
|
||||
* stories; until then the re-exports keep the driver reachable only from here
|
||||
* — the isolation is real, not a placeholder.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { Kysely, PostgresDialect } from 'kysely';
|
||||
|
||||
export { Pool, Kysely, PostgresDialect };
|
||||
export { MigrationLedger, MIGRATION_LEDGER_TABLE } from './ledger.js';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user