feat: versioned migrations runner (phase 1 — no behaviour change)
Adds backend/src/db/migrations-runner.js: - Tracks applied migrations in _migrations table - Applies .sql files from src/db/migrations/ in alphabetical order - Each file runs in a transaction — fail-fast, no partial state - `migrate:bootstrap` marks 000_baseline.sql as applied on existing DBs 000_baseline.sql — full schema snapshot from prod DB (168 objects, 2026-05-06). Removed stale PostgreSQL migration files (001_init.sql, 002_constraints.sql) that used SERIAL/EXTENSION syntax incompatible with SQLite. npm scripts: migrate → migrations-runner.js (versioned) migrate:bootstrap → mark baseline applied (run once per env) migrate:legacy → legacy migrate.js (kept for reference) On prod DB after `migrate:bootstrap`: "Nothing to apply — schema is up to date". Legacy migrate.js still in place; tests still use it via setup.js (unchanged). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
|
||||
|
||||
function init() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function listFiles() {
|
||||
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
|
||||
return fs.readdirSync(MIGRATIONS_DIR)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function applied() {
|
||||
return new Set(db.prepare('SELECT filename FROM _migrations').all().map(r => r.filename));
|
||||
}
|
||||
|
||||
function run() {
|
||||
init();
|
||||
const done = applied();
|
||||
const files = listFiles();
|
||||
const pending = files.filter(f => !done.has(f));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('[migrate] Nothing to apply — schema is up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of pending) {
|
||||
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8');
|
||||
console.log(`[migrate] Applying ${file}...`);
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
db.exec(sql);
|
||||
db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run(file);
|
||||
db.exec('COMMIT');
|
||||
console.log(`[migrate] OK: ${file}`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error(`[migrate] FAILED at ${file}: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[migrate] Applied ${pending.length} migration(s)`);
|
||||
}
|
||||
|
||||
/* Mark 000_baseline.sql as applied on existing DBs without running it.
|
||||
Run once per environment after first deploy of this system. */
|
||||
function markBaseline() {
|
||||
init();
|
||||
const hasUsers = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
|
||||
if (hasUsers) {
|
||||
const done = applied();
|
||||
if (!done.has('000_baseline.sql')) {
|
||||
db.prepare('INSERT INTO _migrations (filename) VALUES (?)').run('000_baseline.sql');
|
||||
console.log('[migrate] Marked 000_baseline.sql as applied (existing DB — not re-run)');
|
||||
} else {
|
||||
console.log('[migrate] 000_baseline.sql already marked as applied');
|
||||
}
|
||||
} else {
|
||||
console.log('[migrate] No users table found — run `npm run migrate` first to initialize schema');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const cmd = process.argv[2];
|
||||
if (cmd === 'bootstrap') markBaseline();
|
||||
else run();
|
||||
}
|
||||
|
||||
module.exports = { run, markBaseline };
|
||||
Reference in New Issue
Block a user