[E00-S03-T03] Migration ledger created #392

Merged
kpcto merged 2 commits from feature/178 into main 2026-08-29 23:37:17 +00:00
5 changed files with 105 additions and 6 deletions
Showing only changes of commit 7f6450410e - Show all commits
+4
View File
@@ -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
+1 -1
View File
@@ -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
+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);
}
}