setup_sql/01_schema.sql was an idempotent bootstrap script — CREATE TABLE IF NOT EXISTS plus a tail of ALTER ... ADD COLUMN IF NOT EXISTS. It had no record of what any given database had applied, which is how a branch could declare col_meta.in_grain while the running database lacked it, with nothing able to detect the mismatch. The symptom would have been a confusing 'column "in_grain" does not exist' inside an unrelated request. Migrations-only, no hand-maintained current-state file to drift: - setup_sql/migrations/*.sql applied in filename order, recorded in pf.schema_version with a checksum. Split along the schema's actual evolution, so each column is declared exactly once — 01_schema.sql had grown to declare dim_group, dim_period_col and in_grain twice each. - lib/migrations.js holds the bookkeeping, shared by the CLI and the boot check. scripts/migrate.js provides up | status | baseline. - server.js refuses to start when the database is behind, listing what is pending. This converts silent drift into a clear boot message, which was the whole point. PF_SKIP_MIGRATION_CHECK=1 bypasses. - Four integrity guards, each verified to fire: a migration modified after being applied, one recorded as applied but missing from disk, one that would apply out of order, and a re-run when already current. - No IF NOT EXISTS on new migrations. The bookkeeping already guarantees one run each, and the guards hide ordering mistakes — that is exactly why 01_schema.sql had ALTERs sitting above the CREATE TABLE they depended on, broken for anyone installing from scratch. 0004 keeps the guard only because it was applied by hand before migrations existed. Verified: replaying all four migrations into a throwaway schema reproduces the live pf schema exactly, 41 columns, column for column. schema.generated.sql is a pg_dump snapshot for reading, refreshed by npm run schema:dump. It excludes the runtime fc_* tables and strips pg_dump's random \restrict token and version banner, so regenerating an unchanged schema yields an identical file rather than a spurious diff. pf.dim_period stays out of migrations — it is a parameterised data load (fiscal year start month), not a schema change. The dev database (ubm) has been baselined at all four migrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.0 KiB
JavaScript
159 lines
6.0 KiB
JavaScript
// Migration bookkeeping shared by the runner (scripts/migrate.js) and the
|
|
// startup check in server.js.
|
|
//
|
|
// Migrations are plain .sql files in setup_sql/migrations, applied in filename
|
|
// order and recorded in pf.schema_version. They are immutable once applied: the
|
|
// runner stores a checksum and refuses to proceed if a file has changed, since
|
|
// editing an applied migration means databases silently disagree about what the
|
|
// schema is.
|
|
//
|
|
// pf.dim_period is deliberately not a migration — it is a parameterised data
|
|
// load (fiscal year start month), so it stays in setup_sql/gen_dim_period.sql.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const MIGRATIONS_DIR = path.join(__dirname, '..', 'setup_sql', 'migrations');
|
|
|
|
// Bookkeeping table has to exist before the first migration can be recorded, so
|
|
// the runner creates it rather than a migration doing so.
|
|
const BOOKKEEPING_DDL = `
|
|
CREATE SCHEMA IF NOT EXISTS pf;
|
|
CREATE TABLE IF NOT EXISTS pf.schema_version (
|
|
filename text PRIMARY KEY,
|
|
checksum text NOT NULL,
|
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
applied_by text
|
|
);
|
|
`;
|
|
|
|
function checksum(sql) {
|
|
return crypto.createHash('sha256').update(sql).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
// every .sql file on disk, in apply order
|
|
function readMigrations() {
|
|
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
|
|
return fs.readdirSync(MIGRATIONS_DIR)
|
|
.filter(f => f.endsWith('.sql'))
|
|
.sort()
|
|
.map(filename => {
|
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, filename), 'utf8');
|
|
return { filename, sql, checksum: checksum(sql) };
|
|
});
|
|
}
|
|
|
|
async function ensureBookkeeping(client) {
|
|
await client.query(BOOKKEEPING_DDL);
|
|
}
|
|
|
|
async function readApplied(client) {
|
|
const { rows } = await client.query(
|
|
`SELECT filename, checksum, applied_at FROM pf.schema_version ORDER BY filename`
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
// Compare disk against the database. Returns pending migrations plus any
|
|
// integrity problems worth refusing to run on.
|
|
async function status(client) {
|
|
await ensureBookkeeping(client);
|
|
const onDisk = readMigrations();
|
|
const applied = await readApplied(client);
|
|
const appliedBy = new Map(applied.map(r => [r.filename, r]));
|
|
const diskBy = new Map(onDisk.map(m => [m.filename, m]));
|
|
|
|
const pending = onDisk.filter(m => !appliedBy.has(m.filename));
|
|
// an applied file whose contents changed — the schema is now undefined
|
|
const modified = onDisk
|
|
.filter(m => appliedBy.has(m.filename) && appliedBy.get(m.filename).checksum !== m.checksum)
|
|
.map(m => m.filename);
|
|
// recorded as applied but no longer on disk — someone deleted or renamed it
|
|
const missing = applied.filter(r => !diskBy.has(r.filename)).map(r => r.filename);
|
|
// a pending migration ordered before an applied one would apply out of sequence
|
|
const lastApplied = applied.length ? applied[applied.length - 1].filename : null;
|
|
const outOfOrder = lastApplied ? pending.filter(m => m.filename < lastApplied).map(m => m.filename) : [];
|
|
|
|
return { onDisk, applied, pending, modified, missing, outOfOrder };
|
|
}
|
|
|
|
// Apply pending migrations, each in its own transaction so a failure leaves
|
|
// earlier ones committed and the failing one fully rolled back.
|
|
async function migrate(client, { user, log = console.log } = {}) {
|
|
const st = await status(client);
|
|
if (st.modified.length) {
|
|
throw new Error(
|
|
`Applied migrations have been modified: ${st.modified.join(', ')}. ` +
|
|
`Migrations are immutable — revert the edit and add a new migration instead.`
|
|
);
|
|
}
|
|
if (st.missing.length) {
|
|
throw new Error(
|
|
`Migrations recorded as applied are missing from disk: ${st.missing.join(', ')}.`
|
|
);
|
|
}
|
|
if (st.outOfOrder.length) {
|
|
throw new Error(
|
|
`Migrations would apply out of order: ${st.outOfOrder.join(', ')} sort before ` +
|
|
`already-applied migrations. Renumber them after the latest applied migration.`
|
|
);
|
|
}
|
|
if (!st.pending.length) {
|
|
log('Schema is up to date — no migrations to apply.');
|
|
return [];
|
|
}
|
|
|
|
const done = [];
|
|
for (const m of st.pending) {
|
|
log(`applying ${m.filename} …`);
|
|
try {
|
|
await client.query('BEGIN');
|
|
await client.query(m.sql);
|
|
await client.query(
|
|
`INSERT INTO pf.schema_version (filename, checksum, applied_by) VALUES ($1, $2, $3)`,
|
|
[m.filename, m.checksum, user || null]
|
|
);
|
|
await client.query('COMMIT');
|
|
done.push(m.filename);
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
throw new Error(`${m.filename} failed: ${err.message}`);
|
|
}
|
|
}
|
|
log(`Applied ${done.length} migration(s).`);
|
|
return done;
|
|
}
|
|
|
|
// Record migrations as applied without running them — for a database whose schema
|
|
// already matches, from before migrations existed.
|
|
async function baseline(client, { user, upTo, log = console.log } = {}) {
|
|
const st = await status(client);
|
|
const target = upTo
|
|
? st.pending.filter(m => m.filename <= upTo)
|
|
: st.pending;
|
|
if (!target.length) {
|
|
log('Nothing to baseline — no pending migrations.');
|
|
return [];
|
|
}
|
|
await client.query('BEGIN');
|
|
try {
|
|
for (const m of target) {
|
|
await client.query(
|
|
`INSERT INTO pf.schema_version (filename, checksum, applied_by)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (filename) DO NOTHING`,
|
|
[m.filename, m.checksum, user ? `${user} (baseline)` : 'baseline']
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
throw err;
|
|
}
|
|
log(`Marked ${target.length} migration(s) as applied without running them.`);
|
|
return target.map(m => m.filename);
|
|
}
|
|
|
|
module.exports = { MIGRATIONS_DIR, readMigrations, status, migrate, baseline, checksum };
|