new raceday and backup
This commit is contained in:
100
server/backup_service.js
Normal file
100
server/backup_service.js
Normal file
@@ -0,0 +1,100 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user