const fs = require("fs"); const path = require("path"); 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"); fs.mkdirSync(backupDir, { recursive: true }); 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 listBackups() { if (!fs.existsSync(backupDir)) 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 = "" } = {}) { 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); fs.renameSync(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 };