101 lines
3.3 KiB
JavaScript
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 };
|