import { readdirSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; 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 ); `; const INSERT_MIGRATION_SQL = `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`; function sha256Hex(body) { return createHash('sha256').update(body).digest('hex'); } function tableExists(db, name) { const row = db .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`) .get(name); return row !== undefined; } export function listMigrations(migrationsDir) { if (!existsSync(migrationsDir)) { throw new Error(`migrations directory not found: ${migrationsDir}`); } const entries = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')); const files = []; 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) { if (!tableExists(db, 'schema_migrations')) { return []; } return db .prepare(`SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`) .all(); } export function applyMigrations(db, migrationsDir, opts = {}) { 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 genesisFile = files.find((f) => f.version === GENESIS_VERSION); const shouldStampGenesis = !schemaMigrationsExists && genesisFile !== undefined && (opts.stampGenesis === true || tableExists(db, probeTable)); db.exec(SCHEMA_MIGRATIONS_DDL); const insertRow = db.prepare(INSERT_MIGRATION_SQL); const stamped = []; if (shouldStampGenesis && genesisFile) { const reason = opts.stampGenesis ? 'stampGenesis option set' : `detected pre-existing '${probeTable}' table`; log(`WARN genesis-stamp: marked ${genesisFile.name} as applied without executing (${reason})`); insertRow.run(genesisFile.version, genesisFile.name, genesisFile.checksum, Date.now()); stamped.push(genesisFile.version); } const appliedByVersion = new Map(readAppliedRows(db).map((r) => [r.version, r])); let applied = 0; let alreadyApplied = 0; 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, migrationsDir, version) { 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 1 FROM schema_migrations WHERE version = ?`) .get(file.version); if (existing) { throw new Error(`migration already applied: ${file.name}`); } db.prepare(INSERT_MIGRATION_SQL).run(file.version, file.name, file.checksum, Date.now()); return file; }