feat(backup): pre-migration snapshot gated on pending migrations; checkpoint WAL on shutdown

This commit is contained in:
Jannis Braun
2026-06-20 02:26:51 +02:00
parent 48bcd69031
commit 154feb2dd6
4 changed files with 79 additions and 3 deletions
+21 -2
View File
@@ -5,7 +5,9 @@ import { config } from '../config.js';
import * as schema from './schema.js';
import { ensureDefaults } from './migrate.js';
import { setWorkerId } from '../utils/snowflake.js';
import { mkdirSync } from 'fs';
import { createSnapshot } from '../utils/backup.js';
import { hasPendingMigrations } from './pendingMigrations.js';
import { mkdirSync, existsSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
@@ -20,12 +22,29 @@ function ensureDirectory(filePath: string): void {
export function initDatabase() {
ensureDirectory(config.dbPath);
// Capture existence BEFORE opening — new Database() creates the file, so a
// post-open check would always report "exists" and snapshot a 0-row DB on first boot.
const dbExisted = existsSync(config.dbPath);
sqlite = new Database(config.dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Apply any pending Drizzle migrations
const migrationsFolder = resolve(__dirname, '../../drizzle');
// Snapshot before migrating — but only when there is a real DB AND a migration
// is actually pending. History is stable across most boots, so this avoids
// churning the pre-migration retention with identical copies on every restart.
if (!config.backup.disabled && dbExisted && hasPendingMigrations(sqlite, migrationsFolder)) {
try {
const snap = createSnapshot(sqlite, 'pre-migration');
console.log(`[backup] pre-migration snapshot written: ${snap}`);
} catch (err) {
console.error(`[backup] pre-migration snapshot FAILED — aborting migration to protect data: ${(err as Error).message}`);
throw err;
}
}
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder });
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { hasPendingMigrations } from './pendingMigrations.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const migrationsFolder = path.resolve(__dirname, '../../drizzle');
describe('hasPendingMigrations', () => {
it('returns true when __drizzle_migrations is missing', () => {
const db = new Database(':memory:');
expect(hasPendingMigrations(db, migrationsFolder)).toBe(true);
});
it('returns true when fewer rows than journal entries are applied', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)');
db.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('x', 1);
expect(hasPendingMigrations(db, migrationsFolder)).toBe(true);
});
it('returns false when applied count >= journal entries', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)');
const journal = require(path.join(migrationsFolder, 'meta/_journal.json'));
const insert = db.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)');
for (let i = 0; i < journal.entries.length; i++) insert.run(`h${i}`, i);
expect(hasPendingMigrations(db, migrationsFolder)).toBe(false);
});
});
@@ -0,0 +1,25 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
interface DrizzleJournal { entries: Array<{ idx: number; tag: string; when: number }>; }
/**
* True when migrations are pending. Drizzle's better-sqlite3 migrator appends one
* row per applied migration to `__drizzle_migrations`, in journal order. Comparing
* the applied row count to the journal entry count is sufficient to know whether
* `migrate()` will apply anything — without running it. A missing table means a
* pre-drizzle or empty DB: treat as pending.
*/
export function hasPendingMigrations(db: Database.Database, migrationsFolder: string): boolean {
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as DrizzleJournal;
const tableExists = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")
.get();
if (!tableExists) return true;
const applied = db.prepare('SELECT COUNT(*) AS n FROM __drizzle_migrations').get() as { n: number };
return applied.n < journal.entries.length;
}