Files
Live_RC/server/backup_service.js
2026-09-01 07:07:23 +02:00

101 lines
3.3 KiB
JavaScript

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 };