From 154feb2dd6aee6d78f5605e5eb6c080eadd69b18 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:26:51 +0200 Subject: [PATCH] feat(backup): pre-migration snapshot gated on pending migrations; checkpoint WAL on shutdown --- packages/server/src/db/index.ts | 23 ++++++++++++-- .../server/src/db/pendingMigrations.test.ts | 31 +++++++++++++++++++ packages/server/src/db/pendingMigrations.ts | 25 +++++++++++++++ packages/server/src/index.ts | 3 +- 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/db/pendingMigrations.test.ts create mode 100644 packages/server/src/db/pendingMigrations.ts diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index ffa0682f..4ebcbdb7 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -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 }); diff --git a/packages/server/src/db/pendingMigrations.test.ts b/packages/server/src/db/pendingMigrations.test.ts new file mode 100644 index 00000000..468c1f1c --- /dev/null +++ b/packages/server/src/db/pendingMigrations.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/db/pendingMigrations.ts b/packages/server/src/db/pendingMigrations.ts new file mode 100644 index 00000000..90fae727 --- /dev/null +++ b/packages/server/src/db/pendingMigrations.ts @@ -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; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b138694f..f1278349 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -4,7 +4,7 @@ import rateLimit from '@fastify/rate-limit'; import websocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import { config } from './config.js'; -import { getDb, getRawDb } from './db/index.js'; +import { getDb, getRawDb, closeDatabase } from './db/index.js'; import { checkFfmpeg } from './utils/thumbnail.js'; import { authRoutes } from './routes/auth.js'; import { userRoutes } from './routes/users.js'; @@ -181,6 +181,7 @@ async function main(): Promise { console.log('Shutting down...'); stopFederationWorkers(); await app.close(); + closeDatabase(); // checkpoints WAL — leaves a complete on-disk file process.exit(0); };