From d2dfa5f61bbf7a046dee40a6701829295f2959c2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 12 May 2026 02:16:16 -0700 Subject: [PATCH] feat: add migration runner with genesisProbeTable param Forward-only SQLite migration runner lifted from authd (src/db/migrate.ts, PR #14 / merge ced21ab) and parameterized for cross-project use. The single deliberate API change vs. authd's source: the previously-hardcoded GENESIS_PROBE_TABLE = 'users' is now an ApplyOptions.genesisProbeTable?: string option (default 'users'). --- src/index.ts | 12 ++++ src/migrate.ts | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/index.ts create mode 100644 src/migrate.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6b3ce8b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +export { + applyMigrations, + listMigrations, + readAppliedRows, + stampMigration, +} from './migrate.js'; +export type { + ApplyOptions, + MigrationFile, + MigrationRow, + MigrationSummary, +} from './migrate.js'; diff --git a/src/migrate.ts b/src/migrate.ts new file mode 100644 index 0000000..252a7ee --- /dev/null +++ b/src/migrate.ts @@ -0,0 +1,180 @@ +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import type Database from 'better-sqlite3'; + +export interface MigrationFile { + version: string; + name: string; + checksum: string; + body: string; +} + +export interface MigrationRow { + version: string; + name: string; + checksum: string; + applied_at: number; +} + +export interface MigrationSummary { + applied: number; + pending: number; + alreadyApplied: number; + stamped: string[]; +} + +export interface ApplyOptions { + logger?: (msg: string) => void; + stampGenesis?: boolean; + /** Default 'users'. Probe table that decides "is this a pre-existing prod DB needing genesis-stamping?". */ + genesisProbeTable?: string; +} + +const FILENAME_RE = /^(\d{4,})_([a-z0-9_]+)\.sql$/; +const GENESIS_VERSION = '0001'; + +const SCHEMA_MIGRATIONS_DDL = ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at INTEGER NOT NULL + ); +`; + +function sha256Hex(body: string): string { + return createHash('sha256').update(body).digest('hex'); +} + +function tableExists(db: Database.Database, name: string): boolean { + const row = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`) + .get(name); + return row !== undefined; +} + +export function listMigrations(migrationsDir: string): MigrationFile[] { + if (!existsSync(migrationsDir)) { + throw new Error(`migrations directory not found: ${migrationsDir}`); + } + const entries = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')); + const files: MigrationFile[] = []; + for (const filename of entries.sort()) { + const match = FILENAME_RE.exec(filename); + if (!match) { + throw new Error(`invalid migration filename: ${filename} (must match NNNN_name.sql)`); + } + const version = match[1]; + const name = filename.replace(/\.sql$/, ''); + const body = readFileSync(join(migrationsDir, filename), 'utf8'); + files.push({ version, name, checksum: sha256Hex(body), body }); + } + return files; +} + +export function readAppliedRows(db: Database.Database): MigrationRow[] { + if (!tableExists(db, 'schema_migrations')) { + return []; + } + return db + .prepare( + `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`, + ) + .all() as MigrationRow[]; +} + +export function applyMigrations( + db: Database.Database, + migrationsDir: string, + opts: ApplyOptions = {}, +): MigrationSummary { + const log = opts.logger ?? (() => {}); + const probeTable = opts.genesisProbeTable ?? 'users'; + const files = listMigrations(migrationsDir); + if (files.length === 0) { + return { applied: 0, pending: 0, alreadyApplied: 0, stamped: [] }; + } + + const schemaMigrationsExists = tableExists(db, 'schema_migrations'); + const stamped: string[] = []; + + const genesisFile = files.find((f) => f.version === GENESIS_VERSION); + const shouldStampGenesis = + !schemaMigrationsExists && + genesisFile !== undefined && + (opts.stampGenesis === true || tableExists(db, probeTable)); + + if (shouldStampGenesis && genesisFile) { + db.exec(SCHEMA_MIGRATIONS_DDL); + const reason = opts.stampGenesis + ? 'stampGenesis option set' + : `detected pre-existing '${probeTable}' table`; + log( + `WARN genesis-stamp: marked ${genesisFile.name} as applied without executing (${reason})`, + ); + db.prepare( + `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, + ).run(genesisFile.version, genesisFile.name, genesisFile.checksum, Date.now()); + stamped.push(genesisFile.version); + } else { + db.exec(SCHEMA_MIGRATIONS_DDL); + } + + const appliedRows = readAppliedRows(db); + const appliedByVersion = new Map(appliedRows.map((r) => [r.version, r])); + + let applied = 0; + let alreadyApplied = 0; + const insertRow = db.prepare( + `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, + ); + + for (const file of files) { + const existing = appliedByVersion.get(file.version); + if (existing) { + if (existing.checksum !== file.checksum) { + throw new Error( + `migration checksum mismatch: ${file.name}.sql (expected ${existing.checksum}, got ${file.checksum})`, + ); + } + alreadyApplied += 1; + continue; + } + + const runMigration = db.transaction(() => { + db.exec(file.body); + insertRow.run(file.version, file.name, file.checksum, Date.now()); + }); + runMigration(); + log(`applied migration ${file.name}`); + applied += 1; + } + + return { applied, pending: 0, alreadyApplied, stamped }; +} + +export function stampMigration( + db: Database.Database, + migrationsDir: string, + version: string, +): MigrationFile { + const files = listMigrations(migrationsDir); + const file = files.find((f) => f.version === version); + if (!file) { + throw new Error(`migration not found for version: ${version}`); + } + db.exec(SCHEMA_MIGRATIONS_DDL); + const existing = db + .prepare( + `SELECT version, name, checksum, applied_at FROM schema_migrations WHERE version = ?`, + ) + .get(file.version) as MigrationRow | undefined; + if (existing) { + throw new Error(`migration already applied: ${file.name}`); + } + db.prepare( + `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`, + ).run(file.version, file.name, file.checksum, Date.now()); + return file; +}