fix(backup): snapshot the database instead of archiving it live (#1675)

The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
This commit is contained in:
Maurice
2026-07-29 16:42:47 +02:00
committed by Maurice
parent 14bc41f2cf
commit e62dceb4bb
6 changed files with 250 additions and 32 deletions
+39 -3
View File
@@ -181,11 +181,47 @@ async function main() {
// Backup
const backupPath = `${dbPath}.backup-${Date.now()}`;
fs.copyFileSync(dbPath, backupPath);
console.log(`\nBackup created: ${backupPath}`);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// The rollback copy has to be a consistent snapshot, not a plain file copy:
// this script is documented as running against a live server, so in WAL mode
// the committed tail sits in the -wal sidecar a copy would leave behind.
// VACUUM INTO snapshots even while the server writes; the checkpoint plus
// copy is only the fallback for when it cannot (disk, lock).
try {
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
} catch {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
fs.copyFileSync(dbPath, backupPath);
}
console.log(`\nBackup created: ${backupPath}`);
// journal_mode lives in the file header, so hardcoding WAL here would quietly
// undo a deliberate DELETE/TRUNCATE setup (network storage) on the first key
// rotation. This script ships without src/ in the image and cannot import
// src/app-config, so it reads the same variables directly — keep the defaults
// in step with parsers.resolveDurability().
const journalModes = ['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF'];
const syncLevels = ['OFF', 'NORMAL', 'FULL', 'EXTRA'];
const wantedJournal = (process.env.TREK_DB_JOURNAL_MODE ?? '').trim().toUpperCase();
const journalMode = journalModes.includes(wantedJournal) ? wantedJournal : 'WAL';
if (wantedJournal && journalMode !== wantedJournal) {
console.warn(`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`);
}
const wantedSync = (process.env.TREK_DB_SYNCHRONOUS ?? '').trim().toUpperCase();
const defaultSync = journalMode === 'WAL' ? 'NORMAL' : 'FULL';
const synchronous = syncLevels.includes(wantedSync) ? wantedSync : defaultSync;
if (wantedSync && synchronous !== wantedSync) {
console.warn(`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`);
}
db.pragma(`journal_mode = ${journalMode}`);
db.pragma(`synchronous = ${synchronous}`);
console.log(`Journal mode: ${db.pragma('journal_mode', { simple: true })}\n`);
const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] };