Files
ai-drama-platform/server/backup.mjs
T

64 lines
2.1 KiB
JavaScript

import { mkdir, readdir, stat, unlink } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import { db, dbPath } from "./db.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const backupRoot = resolve(projectRoot, "data", "backups");
const relativeBackupRoot = "data/backups";
function backupEntry(name, fileStat) {
return {
name,
relativePath: `${relativeBackupRoot}/${name}`,
bytes: Number(fileStat.size || 0),
createdAt: fileStat.birthtime?.toISOString?.() || fileStat.mtime?.toISOString?.() || null,
modifiedAt: fileStat.mtime?.toISOString?.() || null
};
}
export async function listDatabaseBackups() {
let names = [];
try {
names = await readdir(backupRoot);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const entries = await Promise.all(names.filter((name) => /^platform-[A-Z0-9-]+\.sqlite$/i.test(name)).map(async (name) => {
try {
return backupEntry(name, await stat(resolve(backupRoot, name)));
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}));
return entries.filter(Boolean).sort((left, right) => String(right.modifiedAt || "").localeCompare(String(left.modifiedAt || "")));
}
export async function backupSummary() {
const backups = await listDatabaseBackups();
return {
root: relativeBackupRoot,
source: dbPath,
count: backups.length,
latest: backups[0] || null,
backups
};
}
export async function createDatabaseBackup() {
await mkdir(backupRoot, { recursive: true });
const stamp = new Date().toISOString().replace(/[^0-9TZ]/g, "-");
const name = `platform-${stamp}-${randomBytes(4).toString("hex")}.sqlite`;
const target = resolve(backupRoot, name);
const escapedTarget = target.replaceAll("'", "''");
try {
db.exec(`VACUUM INTO '${escapedTarget}'`);
const entry = backupEntry(name, await stat(target));
return { backup: entry, ...(await backupSummary()) };
} catch (error) {
await unlink(target).catch(() => {});
throw error;
}
}