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:
implementer
2026-08-29 23:19:56 +00:00
parent 33ac04e795
commit 7f6450410e
5 changed files with 105 additions and 6 deletions
+1 -1
View File
@@ -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 concrete adapter (migration ledger, advisory lock) 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) is implemented here; the migration runner (advisory lock, diagnostics) lands in later stories.",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
+6 -4
View File
@@ -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';
+93
View File
@@ -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);
}
}