This commit is contained in:
seb
2026-10-29 03:17:47 +01:00
parent b0521a4ea6
commit 75a83bdc28
21 changed files with 701 additions and 24 deletions

View File

@@ -0,0 +1,121 @@
/**
* SQLite schema versioning (see ../schemaMigrations.js).
*
* Step context: `{ db, exec, getVersion, assertVersion, setVersion }` where
* `db` is the raw better-sqlite3 Database and `exec(sql)` runs DDL/DML.
* Each step runs in `BEGIN IMMEDIATE … COMMIT` with foreign key enforcement
* switched off (SQLite's recommended procedure for table rebuilds);
* `PRAGMA foreign_key_check` must pass before commit.
*/
import {
BASE_SCHEMA_VERSION,
assertStepResult,
latestSchemaVersion,
makeVersionContext,
migrateSchema,
parseSchemaVersion,
} from '../schemaMigrations.js';
export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url);
export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url);
/** Schema version a fresh database is created at. */
export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR);
function hasTable(db, name) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
}
function hasAnyTable(db) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
}
/**
* Reads `meta.version`, or `null` when the meta table/row is missing.
* @param {import('better-sqlite3').Database} db
* @returns {number|null}
*/
export function readSchemaVersion(db) {
if (!hasTable(db, 'meta')) return null;
const row = db.prepare(`SELECT value FROM meta WHERE "key" = 'version'`).get();
return row ? parseSchemaVersion(row.value) : null;
}
function writeSchemaVersion(db, version) {
db.prepare(`INSERT INTO meta ("key", value) VALUES ('version', ?)
ON CONFLICT("key") DO UPDATE SET value = excluded.value`).run(String(version));
}
function makeRunStep(db) {
return async (step, fn) => {
const fkWasOn = db.pragma('foreign_keys', { simple: true }) === 1;
if (fkWasOn) db.pragma('foreign_keys = OFF');
db.exec('BEGIN IMMEDIATE');
try {
const ctx = {
db,
exec: (sql) => db.exec(sql),
...makeVersionContext(step.file, () => readSchemaVersion(db), (v) => writeSchemaVersion(db, v)),
};
await fn(ctx);
assertStepResult(step, readSchemaVersion(db));
const violations = db.pragma('foreign_key_check');
if (violations.length > 0) {
throw new Error(`Schema step ${step.file} left ${violations.length} foreign key violation(s)`);
}
db.exec('COMMIT');
} catch (err) {
if (db.inTransaction) db.exec('ROLLBACK');
throw err;
} finally {
if (fkWasOn) db.pragma('foreign_keys = ON');
}
};
}
/**
* Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the
* `schema.js` scripts so they never meet an outdated table shape.
* Fresh (empty) databases are skipped — `INIT_META` stamps them.
* A non-empty database without a version row is treated as version 1.
* @param {import('better-sqlite3').Database} db
* @param {(message: string) => void} [log]
*/
export async function upgradeSchema(db, log) {
if (!hasAnyTable(db)) return 0;
let current = readSchemaVersion(db);
if (current === null) {
db.exec(`CREATE TABLE IF NOT EXISTS meta ("key" TEXT PRIMARY KEY, value TEXT NOT NULL)`);
writeSchemaVersion(db, BASE_SCHEMA_VERSION);
current = BASE_SCHEMA_VERSION;
}
return migrateSchema({
direction: 'upgrade',
dirUrl: UPGRADE_DIR,
current,
target: SCHEMA_VERSION,
runStep: makeRunStep(db),
log,
});
}
/**
* Downgrades the database to `target` (CLI only, never at server start).
* @param {import('better-sqlite3').Database} db
* @param {number} target
* @param {(message: string) => void} [log]
*/
export async function downgradeSchema(db, target, log) {
const current = readSchemaVersion(db);
if (current === null) throw new Error('Database has no schema version (meta table missing)');
return migrateSchema({
direction: 'downgrade',
dirUrl: DOWNGRADE_DIR,
current,
target,
runStep: makeRunStep(db),
log,
});
}