A fresh Windows install reported the server failing around backups. createBackupService created the backups subdirectory once, synchronously, at startup with no error handling - any failure there (permissions, a path beyond Windows' MAX_PATH, a OneDrive/cloud-sync lock, antivirus) crashed the entire Node process before it started listening, taking down timing with it instead of just leaving backups unavailable. Directory creation is now retried lazily and non-fatally on each backup attempt, and a post-backup file rename retries briefly on EBUSY/EPERM since a freshly written file is sometimes momentarily locked by antivirus/cloud-sync scanning on Windows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
4.9 KiB
JavaScript
145 lines
4.9 KiB
JavaScript
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 };
|