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 * as schema from './schema.js';
|
||||||
import { ensureDefaults } from './migrate.js';
|
import { ensureDefaults } from './migrate.js';
|
||||||
import { setWorkerId } from '../utils/snowflake.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 { dirname, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
@@ -20,12 +22,29 @@ function ensureDirectory(filePath: string): void {
|
|||||||
|
|
||||||
export function initDatabase() {
|
export function initDatabase() {
|
||||||
ensureDirectory(config.dbPath);
|
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 = new Database(config.dbPath);
|
||||||
sqlite.pragma('journal_mode = WAL');
|
sqlite.pragma('journal_mode = WAL');
|
||||||
sqlite.pragma('foreign_keys = ON');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
// Apply any pending Drizzle migrations
|
|
||||||
const migrationsFolder = resolve(__dirname, '../../drizzle');
|
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 });
|
const db = drizzle(sqlite, { schema });
|
||||||
migrate(db, { migrationsFolder });
|
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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import rateLimit from '@fastify/rate-limit';
|
|||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { config } from './config.js';
|
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 { checkFfmpeg } from './utils/thumbnail.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
import { userRoutes } from './routes/users.js';
|
import { userRoutes } from './routes/users.js';
|
||||||
@@ -181,6 +181,7 @@ async function main(): Promise<void> {
|
|||||||
console.log('Shutting down...');
|
console.log('Shutting down...');
|
||||||
stopFederationWorkers();
|
stopFederationWorkers();
|
||||||
await app.close();
|
await app.close();
|
||||||
|
closeDatabase(); // checkpoints WAL — leaves a complete on-disk file
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user