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
+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) {}