2026-04-09 18:23:46 +08:00
|
|
|
/**
|
|
|
|
|
* BackupManager: generic file/directory backup utility.
|
|
|
|
|
*
|
|
|
|
|
* Provides two backup modes:
|
|
|
|
|
* - `backupFile(src, category, tag, maxKeep)` — copy a single file
|
|
|
|
|
* - `backupDirectory(src, category, tag, maxKeep)` — copy an entire directory
|
|
|
|
|
*
|
|
|
|
|
* All backups land under `<backupRoot>/<category>/` with timestamped names.
|
|
|
|
|
* After each backup, entries beyond `maxKeep` are automatically pruned
|
|
|
|
|
* (oldest first, by lexicographic order on the timestamp-embedded name).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
|
|
|
|
|
export class BackupManager {
|
|
|
|
|
private backupRoot: string;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param backupRoot - Absolute path to the root backup directory
|
|
|
|
|
* (e.g. `<dataDir>/.backup`).
|
|
|
|
|
*/
|
|
|
|
|
constructor(backupRoot: string) {
|
|
|
|
|
this.backupRoot = backupRoot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Backup a single file.
|
|
|
|
|
*
|
|
|
|
|
* Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>.<ext>`
|
|
|
|
|
*
|
|
|
|
|
* @param srcFile - Absolute path to the source file
|
|
|
|
|
* @param category - Logical grouping (e.g. "persona")
|
|
|
|
|
* @param tag - Additional identifier (e.g. "offset42")
|
|
|
|
|
* @param maxKeep - Max backup files to retain in this category (0 = unlimited)
|
|
|
|
|
*/
|
|
|
|
|
async backupFile(
|
|
|
|
|
srcFile: string,
|
|
|
|
|
category: string,
|
|
|
|
|
tag: string,
|
|
|
|
|
maxKeep: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(srcFile);
|
|
|
|
|
} catch {
|
|
|
|
|
return; // Source file doesn't exist, nothing to backup
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const destDir = path.join(this.backupRoot, category);
|
|
|
|
|
await fs.mkdir(destDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const ext = path.extname(srcFile); // e.g. ".md"
|
|
|
|
|
const timestamp = formatTimestamp(new Date());
|
|
|
|
|
const destName = `${category}_${timestamp}_${tag}${ext}`;
|
|
|
|
|
await fs.copyFile(srcFile, path.join(destDir, destName));
|
|
|
|
|
|
|
|
|
|
if (maxKeep > 0) {
|
|
|
|
|
await pruneOldEntries(destDir, maxKeep, "file");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Backup an entire directory (shallow copy of all files).
|
|
|
|
|
*
|
|
|
|
|
* Destination: `<backupRoot>/<category>/<category>_<timestamp>_<tag>/`
|
|
|
|
|
*
|
|
|
|
|
* @param srcDir - Absolute path to the source directory
|
|
|
|
|
* @param category - Logical grouping (e.g. "scene_blocks")
|
|
|
|
|
* @param tag - Additional identifier (e.g. "offset42")
|
|
|
|
|
* @param maxKeep - Max backup directories to retain in this category (0 = unlimited)
|
|
|
|
|
*/
|
|
|
|
|
async backupDirectory(
|
|
|
|
|
srcDir: string,
|
|
|
|
|
category: string,
|
|
|
|
|
tag: string,
|
|
|
|
|
maxKeep: number,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
let entries: import("node:fs").Dirent[];
|
|
|
|
|
try {
|
|
|
|
|
entries = await fs.readdir(srcDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return; // Source directory doesn't exist
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only backup regular files (skip subdirectories to avoid EISDIR errors)
|
|
|
|
|
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
|
|
|
if (files.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const parentDir = path.join(this.backupRoot, category);
|
|
|
|
|
const timestamp = formatTimestamp(new Date());
|
|
|
|
|
const destDir = path.join(parentDir, `${category}_${timestamp}_${tag}`);
|
|
|
|
|
await fs.mkdir(destDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
await fs.copyFile(path.join(srcDir, file), path.join(destDir, file));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (maxKeep > 0) {
|
|
|
|
|
await pruneOldEntries(parentDir, maxKeep, "directory");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-27 21:45:25 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find the latest backup directory for a category.
|
|
|
|
|
*
|
|
|
|
|
* Backup directory names are `<category>_<timestamp>_<tag>` where the
|
|
|
|
|
* timestamp is `YYYYMMDD_HHmmss` (lexicographic order = chronological order),
|
|
|
|
|
* so the lexicographically largest entry is the most recent one.
|
|
|
|
|
*
|
|
|
|
|
* @param category - Logical grouping (e.g. "scene_blocks")
|
|
|
|
|
* @returns Absolute path to the latest backup directory, or undefined if none.
|
|
|
|
|
*/
|
|
|
|
|
async findLatestBackup(category: string): Promise<string | undefined> {
|
|
|
|
|
const parentDir = path.join(this.backupRoot, category);
|
|
|
|
|
let entries: import("node:fs").Dirent[];
|
|
|
|
|
try {
|
|
|
|
|
entries = await fs.readdir(parentDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return undefined; // No backup directory yet
|
|
|
|
|
}
|
|
|
|
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
|
|
|
if (dirs.length === 0) return undefined;
|
|
|
|
|
dirs.sort(); // ascending — oldest first; last = newest
|
|
|
|
|
return path.join(parentDir, dirs[dirs.length - 1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Restore the latest backup of `category` into `destDir`.
|
|
|
|
|
*
|
|
|
|
|
* Strategy:
|
|
|
|
|
* 1. Find the latest backup directory; if none exists, do nothing
|
|
|
|
|
* (fail-soft: never clobber the destination when there is no
|
|
|
|
|
* ground truth to restore from).
|
|
|
|
|
* 2. Wipe `destDir` and recreate it.
|
|
|
|
|
* 3. Copy every regular file from the backup directory into `destDir`.
|
|
|
|
|
*
|
|
|
|
|
* @param category - Logical grouping (e.g. "scene_blocks")
|
|
|
|
|
* @param destDir - Absolute path to the directory to restore into
|
|
|
|
|
* @returns `{ restored: true, from }` when a backup was applied,
|
|
|
|
|
* `{ restored: false }` when no backup was found.
|
|
|
|
|
* @throws Lets fs errors during wipe/copy propagate so callers can decide
|
|
|
|
|
* whether to fail-soft (log) or fail-hard.
|
|
|
|
|
*/
|
|
|
|
|
async restoreLatestDirectory(
|
|
|
|
|
category: string,
|
|
|
|
|
destDir: string,
|
|
|
|
|
): Promise<{ restored: boolean; from?: string }> {
|
|
|
|
|
const from = await this.findLatestBackup(category);
|
|
|
|
|
if (!from) return { restored: false };
|
|
|
|
|
|
|
|
|
|
// Wipe the destination first so any partial LLM writes are removed,
|
|
|
|
|
// then recreate the directory and copy regular files back.
|
|
|
|
|
await fs.rm(destDir, { recursive: true, force: true });
|
|
|
|
|
await fs.mkdir(destDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const entries = await fs.readdir(from, { withFileTypes: true });
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isFile()) continue;
|
|
|
|
|
await fs.copyFile(path.join(from, entry.name), path.join(destDir, entry.name));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { restored: true, from };
|
|
|
|
|
}
|
2026-04-09 18:23:46 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================
|
|
|
|
|
// Helpers
|
|
|
|
|
// ============================
|
|
|
|
|
|
|
|
|
|
function formatTimestamp(d: Date): string {
|
|
|
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
|
|
return [
|
|
|
|
|
d.getFullYear(),
|
|
|
|
|
pad(d.getMonth() + 1),
|
|
|
|
|
pad(d.getDate()),
|
|
|
|
|
"_",
|
|
|
|
|
pad(d.getHours()),
|
|
|
|
|
pad(d.getMinutes()),
|
|
|
|
|
pad(d.getSeconds()),
|
|
|
|
|
].join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Keep only the newest `maxKeep` entries in a directory.
|
|
|
|
|
* Entries are sorted by name ascending (oldest first) since backup names
|
|
|
|
|
* embed timestamps, so lexicographic order = chronological order.
|
|
|
|
|
*
|
|
|
|
|
* @param dir - Directory containing the backup entries
|
|
|
|
|
* @param maxKeep - Number of entries to retain
|
|
|
|
|
* @param kind - "file" to unlink, "directory" to rm -rf
|
|
|
|
|
*/
|
|
|
|
|
async function pruneOldEntries(
|
|
|
|
|
dir: string,
|
|
|
|
|
maxKeep: number,
|
|
|
|
|
kind: "file" | "directory",
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
let entries: string[];
|
|
|
|
|
try {
|
|
|
|
|
entries = await fs.readdir(dir);
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.sort(); // ascending — oldest first
|
|
|
|
|
const toRemove = entries.slice(0, Math.max(0, entries.length - maxKeep));
|
|
|
|
|
|
|
|
|
|
for (const name of toRemove) {
|
|
|
|
|
try {
|
|
|
|
|
if (kind === "file") {
|
|
|
|
|
await fs.unlink(path.join(dir, name));
|
|
|
|
|
} else {
|
|
|
|
|
await fs.rm(path.join(dir, name), { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// best-effort
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|