const fs = require("fs"); const path = require("path"); const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // A file that was just written is sometimes still briefly held open by antivirus/cloud-sync // scanning on Windows, so a rename right after can fail with EBUSY/EPERM even though nothing // in this process still has it open. A short retry clears that up without surfacing a false // failure to the user. async function renameWithRetry(source, destination, attempts = 5, delayMs = 200) { for (let attempt = 1; attempt <= attempts; attempt += 1) { try { fs.renameSync(source, destination); return; } catch (error) { const retryable = error && (error.code === "EBUSY" || error.code === "EPERM"); if (!retryable || attempt === attempts) throw error; await pause(delayMs); } } } function safeSegment(value, fallback = "backup") { const normalized = String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); return normalized || fallback; } function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Date() }) { if (!db || !dataDir) throw new Error("Backup service requires db and dataDir"); const backupDir = path.join(dataDir, "backups"); // Creating the backups directory can fail on a real install (no write permission, // a path beyond Windows' MAX_PATH, a cloud-sync folder like OneDrive locking it, // antivirus interference). That must never take down the whole server at startup - // backups become unavailable, but timing and everything else keeps working. function ensureBackupDir() { try { fs.mkdirSync(backupDir, { recursive: true }); return true; } catch (error) { console.error(`[backup] Could not create backup directory ${backupDir}: ${error.message}`); return false; } } ensureBackupDir(); function metadataPath(fileName) { return path.join(backupDir, fileName.replace(/\.sqlite$/i, ".json")); } function readMetadata(fileName) { try { return JSON.parse(fs.readFileSync(metadataPath(fileName), "utf8")); } catch { return {}; } } function backupDirReady() { try { return fs.statSync(backupDir).isDirectory(); } catch { return false; } } function listBackups() { if (!backupDirReady()) return []; return fs.readdirSync(backupDir) .filter((fileName) => fileName.endsWith(".sqlite")) .map((fileName) => { const filePath = path.join(backupDir, fileName); const stat = fs.statSync(filePath); const metadata = readMetadata(fileName); return { fileName, createdAt: metadata.createdAt || stat.mtime.toISOString(), reason: metadata.reason || "unknown", sessionId: metadata.sessionId || "", eventId: metadata.eventId || "", sizeBytes: stat.size, }; }) .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt))); } function pruneBackups() { listBackups().slice(Math.max(1, maxBackups)).forEach((backup) => { fs.rmSync(path.join(backupDir, backup.fileName), { force: true }); fs.rmSync(metadataPath(backup.fileName), { force: true }); }); } async function createBackupNow({ reason = "manual", sessionId = "", eventId = "" } = {}) { if (!backupDirReady() && !ensureBackupDir()) { throw new Error(`Backup directory is unavailable: ${backupDir}`); } const createdAt = now().toISOString(); const timestamp = createdAt.replace(/[:.]/g, "-"); const stem = `${timestamp}__${safeSegment(reason)}__${safeSegment(sessionId, "all")}`; let fileName = `${stem}.sqlite`; let sequence = 1; while (fs.existsSync(path.join(backupDir, fileName))) { fileName = `${stem}-${sequence}.sqlite`; sequence += 1; } const finalPath = path.join(backupDir, fileName); const temporaryPath = `${finalPath}.tmp`; try { await db.backup(temporaryPath); await renameWithRetry(temporaryPath, finalPath); const metadata = { fileName, createdAt, reason: safeSegment(reason), sessionId: String(sessionId || ""), eventId: String(eventId || ""), }; fs.writeFileSync(metadataPath(fileName), JSON.stringify(metadata, null, 2), "utf8"); pruneBackups(); return { ...metadata, sizeBytes: fs.statSync(finalPath).size }; } catch (error) { fs.rmSync(temporaryPath, { force: true }); fs.rmSync(finalPath, { force: true }); throw error; } } let backupQueue = Promise.resolve(); function createBackup(options = {}) { const pending = backupQueue.then(() => createBackupNow(options)); backupQueue = pending.catch(() => {}); return pending; } return { backupDir, createBackup, listBackups }; } module.exports = { createBackupService, safeSegment };