mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
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:
+10
-23
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
Reference in New Issue
Block a user