shinroute / src /lib /db /migrationRunner.ts
shinmentakezo07
Add OmniRoute codebase without binary assets
9e4583c
Raw
History Blame Contribute Delete
3.81 kB
/**
* Migration Runner — Versioned SQL Migrations for SQLite
*
* Reads numbered `.sql` files from the migrations directory and applies
* them sequentially, tracking applied versions in a `schema_migrations` table.
*
* Naming convention: `NNN_description.sql` (e.g., `001_initial_schema.sql`)
*
* All migrations run within a single transaction — all-or-nothing per file.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type Database from "better-sqlite3";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const MIGRATIONS_DIR = path.join(__dirname, "migrations");
/**
* Ensure the schema_migrations tracking table exists.
*/
function ensureMigrationsTable(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
/**
* Get all migration files sorted by version number.
*/
function getMigrationFiles(): Array<{ version: string; name: string; path: string }> {
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith(".sql"))
.sort()
.map((filename) => {
const match = filename.match(/^(\d+)_(.+)\.sql$/);
if (!match) return null;
return {
version: match[1],
name: match[2],
path: path.join(MIGRATIONS_DIR, filename),
};
})
.filter(Boolean) as Array<{ version: string; name: string; path: string }>;
}
/**
* Get list of already-applied migration versions.
*/
function getAppliedVersions(db: Database.Database): Set<string> {
const rows = db.prepare("SELECT version FROM _omniroute_migrations").all() as Array<{
version: string;
}>;
return new Set(rows.map((r) => r.version));
}
/**
* Run all pending migrations in order.
* Returns the number of migrations applied.
*/
export function runMigrations(db: Database.Database): number {
ensureMigrationsTable(db);
const files = getMigrationFiles();
const applied = getAppliedVersions(db);
let count = 0;
for (const migration of files) {
if (applied.has(migration.version)) continue;
const sql = fs.readFileSync(migration.path, "utf-8");
const applyMigration = db.transaction(() => {
db.exec(sql);
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
migration.version,
migration.name
);
});
try {
applyMigration();
count++;
console.log(`[Migration] Applied: ${migration.version}_${migration.name}`);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[Migration] FAILED: ${migration.version}_${migration.name}${message}`);
throw err; // Re-throw to prevent DB from starting in inconsistent state
}
}
if (count > 0) {
console.log(`[Migration] ${count} migration(s) applied successfully.`);
}
return count;
}
/**
* Get migration status for diagnostics.
*/
export function getMigrationStatus(db: Database.Database): {
applied: Array<{ version: string; name: string; applied_at: string }>;
pending: Array<{ version: string; name: string }>;
} {
ensureMigrationsTable(db);
const appliedRows = db
.prepare("SELECT version, name, applied_at FROM _omniroute_migrations ORDER BY version")
.all() as Array<{ version: string; name: string; applied_at: string }>;
const appliedVersions = new Set(appliedRows.map((r) => r.version));
const allFiles = getMigrationFiles();
const pending = allFiles.filter((f) => !appliedVersions.has(f.version));
return { applied: appliedRows, pending };
}