From 7f6450410ebd60e7cc5b0e79adb9cfd55288ac5e Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 23:19:56 +0000 Subject: [PATCH] feat: add migration ledger to database-postgres (E00-S03-T03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 + docs/development/non-container.md | 2 +- packages/database-postgres/package.json | 2 +- packages/database-postgres/src/index.ts | 10 ++- packages/database-postgres/src/ledger.ts | 93 ++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 packages/database-postgres/src/ledger.ts diff --git a/.gitignore b/.gitignore index 1656103..c908d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,9 @@ coverage/ # Logs *.log +# Transient host-side probe file written by tests/database-postgres-ledger.test.mjs +# into the database-postgres package (removed in its finally block) +.ledger-probe-*.mjs + # OS / editor .DS_Store diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 10aea97..f86cec7 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -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 concrete adapter 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) is implemented here; the migration runner (advisory lock, failure diagnostics) lands in later stories. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | A dependency-boundary rule (`dependency-boundaries.json`, enforced by diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 5205781..223a180 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -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" diff --git a/packages/database-postgres/src/index.ts b/packages/database-postgres/src/index.ts index c7a8780..c25db95 100644 --- a/packages/database-postgres/src/index.ts +++ b/packages/database-postgres/src/index.ts @@ -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'; diff --git a/packages/database-postgres/src/ledger.ts b/packages/database-postgres/src/ledger.ts new file mode 100644 index 0000000..5594fd6 --- /dev/null +++ b/packages/database-postgres/src/ledger.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + } +}