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
+10 -23
View File
@@ -1,5 +1,4 @@
import cron, { type ScheduledTask } from 'node-cron';
import archiver from 'archiver';
import { readEnv } from './app-config';
import path from 'node:path';
import fs from 'node:fs';
@@ -7,7 +6,6 @@ import { logInfo, logError } from './nest/audit/audit-log.logger';
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
@@ -60,31 +58,20 @@ function saveSettings(settings: BackupSettings): void {
}
async function runBackup(): Promise<void> {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `auto-backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving
try { const { db } = require('./db/database'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) archive.file(dbPath, { name: 'travel.db' });
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
archive.finalize();
});
// Same archive the admin panel builds, only under the auto-backup name: it
// snapshots travel.db with VACUUM INTO instead of archiving the live file
// (the archiver reads its entries during finalize, so a WAL auto-checkpoint
// landing in between tears the copy), and it carries .encryption_key and the
// plugin trees — without the key a scheduled backup cannot be decrypted on
// another install. Imported lazily because backupService imports this module
// for the schedule settings.
const { createBackup } = await import('./services/backupService');
const { filename } = await createBackup('auto-backup');
logInfo(`Auto-Backup created: ${filename}`);
} catch (err: unknown) {
// createBackup removes its own half-written zip before rethrowing.
logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
+15 -4
View File
@@ -143,14 +143,25 @@ export function listBackups(): BackupInfo[] {
// Create backup
// ---------------------------------------------------------------------------
export async function createBackup(): Promise<BackupInfo> {
/**
* Writes a full backup zip and returns its BackupInfo.
*
* `prefix` picks the filename scheme. The scheduler passes 'auto-backup' because
* everything downstream tells the two apart by name: cleanupOldBackups() prunes
* only auto-backup-*.zip, and the admin panel badges them as automatic. Manual
* backups keep the default.
*/
export async function createBackup(prefix: 'backup' | 'auto-backup' = 'backup'): Promise<BackupInfo> {
ensureBackupsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.zip`;
const filename = `${prefix}-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
const pdataSnap = path.join(backupsDir, `.plugins-snap-${timestamp}`);
const dbSnap = path.join(backupsDir, `.travel-snap-${timestamp}.db`);
// The scratch names carry the prefix too: a scheduled run and a manual one that
// start in the same second would otherwise share a snapshot path, and the first
// to finish would delete the other's staging copy mid-archive.
const pdataSnap = path.join(backupsDir, `.plugins-snap-${prefix}-${timestamp}`);
const dbSnap = path.join(backupsDir, `.travel-snap-${prefix}-${timestamp}.db`);
try {
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}