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').
This commit is contained in:
12
src/index.ts
Normal file
12
src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
applyMigrations,
|
||||
listMigrations,
|
||||
readAppliedRows,
|
||||
stampMigration,
|
||||
} from './migrate.js';
|
||||
export type {
|
||||
ApplyOptions,
|
||||
MigrationFile,
|
||||
MigrationRow,
|
||||
MigrationSummary,
|
||||
} from './migrate.js';
|
||||
180
src/migrate.ts
Normal file
180
src/migrate.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user