feat(backup): pre-migration snapshot gated on pending migrations; checkpoint WAL on shutdown
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user