feat(FN-5407): add paired central backup support to backup command and stor
FN-5407 adds paired central backup support to the Fusion task management system, with both the core backup engine and CLI commands updated to handle central database backup pairs. Documentation was updated to reflect the new capability, and two stabilization fixes were included to handle central bac Fusion-Task-Id: FN-5407
This commit is contained in:
committed by
gsxdsm
parent
1a5aff9c44
commit
2baaad743a
10
.changeset/FN-5407-central-db-backup.md
Normal file
10
.changeset/FN-5407-central-db-backup.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
`fn backup` now also snapshots the central database (`~/.fusion/fusion-central.db`)
|
||||
alongside the per-project database. Scheduled and manual backups produce a paired
|
||||
`fusion-central-<timestamp>.db` file in `.fusion/backups/`; `fn backup --list`,
|
||||
`--cleanup`, and `--restore` operate on the pair. Restoring a `fusion-central-*`
|
||||
file restores only the central DB. A missing central DB is skipped silently and
|
||||
does not fail the project backup.
|
||||
@@ -32,6 +32,8 @@ Core tables:
|
||||
|
||||
Per-project task data remains in each repo’s `.fusion/fusion.db`.
|
||||
|
||||
Backups now include this central DB alongside project backups: each `fn backup --create` run writes a paired `fusion-central-<timestamp>(-N).db` next to `fusion-<timestamp>(-N).db` under `.fusion/backups/` in the active project. Restore operations create a central pre-restore snapshot `fusion-central-pre-restore-<timestamp>.db` before replacing `~/.fusion/fusion-central.db`.
|
||||
|
||||
`taskClaims` is the central cross-node lease mutex introduced by FN-4819 §2: claim acquisition/renewal/release happen in `~/.fusion/fusion-central.db`, while per-project lease fields mirror the central winner for local scheduler/runtime consumption.
|
||||
|
||||
Peer/mesh coordination spans core + engine, with startup ownership in CLI process entrypoints:
|
||||
|
||||
@@ -309,6 +309,14 @@ Additional backend notes:
|
||||
|
||||
---
|
||||
|
||||
### Backup pairing behavior (project + central DB)
|
||||
|
||||
Backups in `.fusion/backups/` now capture the project DB and (when present) the global central DB as a pair using the same timestamp/counter:
|
||||
- `fusion-<timestamp>(-N).db` (project)
|
||||
- `fusion-central-<timestamp>(-N).db` (central, from `~/.fusion/fusion-central.db`)
|
||||
|
||||
`BackupManager` supports `includeCentralDb` (default `true`). If central DB is missing or disabled, project backup still succeeds and records a skip reason. Retention (`autoBackupRetention`) is still computed from project backups; when an old project backup is pruned, its matching `fusion-central-*` sibling is pruned too. Restoring a project backup also restores the paired central backup when available; restoring a `fusion-central-*` file restores the central DB only. Pre-restore snapshots use `fusion-pre-restore-<timestamp>.db` and `fusion-central-pre-restore-<timestamp>.db`.
|
||||
|
||||
## 4) SQLite Tables Inventory (`packages/core/src/db.ts`)
|
||||
|
||||
| Table | Purpose |
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const {
|
||||
mockListBackups,
|
||||
mockListBackupPairs,
|
||||
mockRestoreBackup,
|
||||
mockCleanupOldBackups,
|
||||
mockGetSettings,
|
||||
@@ -9,6 +10,7 @@ const {
|
||||
mockResolveProject,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListBackups: vi.fn(),
|
||||
mockListBackupPairs: vi.fn(),
|
||||
mockRestoreBackup: vi.fn(),
|
||||
mockCleanupOldBackups: vi.fn(),
|
||||
mockGetSettings: vi.fn(),
|
||||
@@ -25,6 +27,7 @@ vi.mock("@fusion/core", () => ({
|
||||
})),
|
||||
createBackupManager: vi.fn(() => ({
|
||||
listBackups: mockListBackups,
|
||||
listBackupPairs: mockListBackupPairs,
|
||||
restoreBackup: mockRestoreBackup,
|
||||
cleanupOldBackups: mockCleanupOldBackups,
|
||||
})),
|
||||
@@ -53,6 +56,7 @@ describe("backup commands", () => {
|
||||
mockGetSettings.mockResolvedValue({ autoBackupDir: ".fusion/backups" });
|
||||
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
|
||||
mockListBackups.mockResolvedValue([]);
|
||||
mockListBackupPairs.mockResolvedValue([]);
|
||||
mockRestoreBackup.mockResolvedValue(undefined);
|
||||
mockCleanupOldBackups.mockResolvedValue(0);
|
||||
mockResolveProject.mockResolvedValue({
|
||||
@@ -77,10 +81,18 @@ describe("backup commands", () => {
|
||||
});
|
||||
|
||||
it("runBackupList uses resolved project store with --project", async () => {
|
||||
mockListBackups.mockResolvedValue([{ filename: "fusion.db.bak", size: 1024, createdAt: new Date().toISOString() }]);
|
||||
mockListBackupPairs.mockResolvedValue([
|
||||
{ timestamp: "2026-01-01-000000", project: { filename: "fusion-2026-01-01-000000.db", size: 1024, createdAt: "2026-01-01T00:00:00.000Z" }, central: { filename: "fusion-central-2026-01-01-000000.db", size: 512, createdAt: "2026-01-01T00:00:00.000Z" } },
|
||||
{ timestamp: "2026-01-01-000001", central: { filename: "fusion-central-2026-01-01-000001.db", size: 256, createdAt: "2026-01-01T00:00:01.000Z" } },
|
||||
]);
|
||||
await runBackupList("demo-project");
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 backup"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Date"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2026-01-01 00:00:00"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("fusion-2026-01-01-000000.db"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("└─ fusion-central-2026-01-01-000000.db"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("orphan central backup"));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Total: 1.75 KB"));
|
||||
});
|
||||
|
||||
it("runBackupRestore uses resolved project store with --project", async () => {
|
||||
@@ -93,7 +105,7 @@ describe("backup commands", () => {
|
||||
mockCleanupOldBackups.mockResolvedValue(2);
|
||||
await runBackupCleanup("demo-project");
|
||||
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
|
||||
expect(logSpy).toHaveBeenCalledWith("Removed 2 old backup(s).");
|
||||
expect(logSpy).toHaveBeenCalledWith("Removed 2 old backup(s) and any paired central backup files.");
|
||||
});
|
||||
|
||||
it("runBackupList without project uses shared resolution flow", async () => {
|
||||
|
||||
@@ -60,28 +60,38 @@ export async function runBackupCreate(projectName?: string): Promise<void> {
|
||||
export async function runBackupList(projectName?: string): Promise<void> {
|
||||
const { manager } = await getBackupManager(projectName);
|
||||
|
||||
const backups = await manager.listBackups();
|
||||
|
||||
if (backups.length === 0) {
|
||||
const pairs = await manager.listBackupPairs();
|
||||
|
||||
if (pairs.length === 0) {
|
||||
console.log("No backups found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${backups.length} backup(s):\n`);
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
|
||||
|
||||
const totalSize = pairs.reduce((sum, pair) => sum + (pair.project?.size ?? 0) + (pair.central?.size ?? 0), 0);
|
||||
const formattedTotal = formatBytes(totalSize);
|
||||
|
||||
console.log("Date Size Filename");
|
||||
|
||||
console.log("Date Size Filename");
|
||||
console.log("-".repeat(60));
|
||||
|
||||
for (const backup of backups) {
|
||||
const date = new Date(backup.createdAt).toLocaleString();
|
||||
const size = formatBytes(backup.size).padEnd(8);
|
||||
console.log(`${date} ${size} ${backup.filename}`);
|
||||
|
||||
for (const pair of pairs) {
|
||||
if (pair.project) {
|
||||
const date = formatListDate(pair.project.createdAt);
|
||||
const pairSize = formatBytes((pair.project?.size ?? 0) + (pair.central?.size ?? 0)).padEnd(10);
|
||||
const noSibling = pair.central ? "" : " (no central sibling)";
|
||||
console.log(`${date} ${pairSize} ${pair.project.filename}${noSibling}`);
|
||||
if (pair.central) {
|
||||
console.log(`${" ".repeat(28)}${formatBytes(pair.central.size).padEnd(10)} └─ ${pair.central.filename}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.central) {
|
||||
const date = formatListDate(pair.central.createdAt);
|
||||
const size = formatBytes(pair.central.size).padEnd(10);
|
||||
console.log(`${date} ${size} ${pair.central.filename} (orphan central backup)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log("-".repeat(60));
|
||||
console.log(`Total: ${formattedTotal}`);
|
||||
}
|
||||
@@ -98,8 +108,13 @@ export async function runBackupRestore(filename: string, projectName?: string):
|
||||
|
||||
try {
|
||||
await manager.restoreBackup(filename, { createPreRestoreBackup: true });
|
||||
console.log(`Successfully restored from ${filename}`);
|
||||
console.log("Note: The pre-restore backup was saved in case you need to undo this operation.");
|
||||
if (filename.startsWith("fusion-central-")) {
|
||||
console.log(`Successfully restored central database from ${filename}`);
|
||||
console.log("Created pre-restore snapshot: fusion-central-pre-restore-<timestamp>.db");
|
||||
} else {
|
||||
console.log(`Successfully restored project database from ${filename}`);
|
||||
console.log("Created pre-restore snapshots: fusion-pre-restore-<timestamp>.db and (if paired) fusion-central-pre-restore-<timestamp>.db");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Restore failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
@@ -118,7 +133,7 @@ export async function runBackupCleanup(projectName?: string): Promise<void> {
|
||||
const deletedCount = await manager.cleanupOldBackups();
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log(`Removed ${deletedCount} old backup(s).`);
|
||||
console.log(`Removed ${deletedCount} old backup(s) and any paired central backup files.`);
|
||||
} else {
|
||||
console.log("No backups to clean up (within retention limit).");
|
||||
}
|
||||
@@ -127,6 +142,17 @@ export async function runBackupCleanup(projectName?: string): Promise<void> {
|
||||
/**
|
||||
* Format bytes as human-readable string.
|
||||
*/
|
||||
function formatListDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hours = String(d.getUTCHours()).padStart(2, "0");
|
||||
const minutes = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const seconds = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
|
||||
@@ -30,9 +30,12 @@ describe("BackupManager", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
await mkdir(fusionDir, { recursive: true });
|
||||
// Create a dummy database file
|
||||
// Create dummy project + central database files
|
||||
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
|
||||
backupManager = new BackupManager(fusionDir);
|
||||
writeFileSync(join(fusionDir, "fusion-central.db"), "dummy central database content");
|
||||
backupManager = new BackupManager(fusionDir, {
|
||||
centralDbPath: join(fusionDir, "fusion-central.db"),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -68,7 +71,10 @@ describe("BackupManager", () => {
|
||||
|
||||
it("should create backup directory if it does not exist", async () => {
|
||||
const customBackupDir = "custom-backups";
|
||||
const manager = new BackupManager(fusionDir, { backupDir: customBackupDir });
|
||||
const manager = new BackupManager(fusionDir, {
|
||||
backupDir: customBackupDir,
|
||||
centralDbPath: join(fusionDir, "fusion-central.db"),
|
||||
});
|
||||
|
||||
const customBackupPath = join(tempDir, customBackupDir);
|
||||
expect(existsSync(customBackupPath)).toBe(false);
|
||||
@@ -77,6 +83,68 @@ describe("BackupManager", () => {
|
||||
|
||||
expect(existsSync(customBackupPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("creates paired project and central backups with shared timestamp", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
expect(backup.centralBackup && "filename" in backup.centralBackup).toBe(true);
|
||||
if (backup.centralBackup && "filename" in backup.centralBackup) {
|
||||
expect(backup.centralBackup.filename).toBe(backup.filename.replace(/^fusion-/, "fusion-central-"));
|
||||
}
|
||||
});
|
||||
|
||||
it("skips central backup when central DB is missing", async () => {
|
||||
const manager = new BackupManager(fusionDir, {
|
||||
centralDbPath: join(fusionDir, "does-not-exist.db"),
|
||||
});
|
||||
const backup = await manager.createBackup();
|
||||
expect(backup.centralBackup).toEqual({ skipped: "missing" });
|
||||
});
|
||||
|
||||
it("skips central backup when includeCentralDb is false", async () => {
|
||||
const manager = new BackupManager(fusionDir, {
|
||||
centralDbPath: join(fusionDir, "fusion-central.db"),
|
||||
includeCentralDb: false,
|
||||
});
|
||||
const backup = await manager.createBackup();
|
||||
expect(backup.centralBackup).toEqual({ skipped: "disabled" });
|
||||
});
|
||||
|
||||
it("applies collision counter symmetrically for project and central backups", async () => {
|
||||
const backupDir = join(tempDir, ".fusion/backups");
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
await writeFile(join(backupDir, "fusion-2026-01-01-000000.db"), "exists");
|
||||
const backup = await backupManager.createBackup();
|
||||
expect(backup.filename).toBe("fusion-2026-01-01-000000-1.db");
|
||||
expect(existsSync(join(backupDir, "fusion-central-2026-01-01-000000-1.db"))).toBe(true);
|
||||
});
|
||||
|
||||
it("avoids central orphan collision by bumping shared counter", async () => {
|
||||
const backupDir = join(tempDir, ".fusion/backups");
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
await writeFile(join(backupDir, "fusion-central-2026-01-01-000000.db"), "orphan");
|
||||
const backup = await backupManager.createBackup();
|
||||
expect(backup.filename).toBe("fusion-2026-01-01-000000-1.db");
|
||||
expect(readFileSync(join(backupDir, "fusion-central-2026-01-01-000000.db"), "utf-8")).toBe("orphan");
|
||||
expect(existsSync(join(backupDir, "fusion-central-2026-01-01-000000-1.db"))).toBe(true);
|
||||
});
|
||||
|
||||
it("continues central copy when checkpoint open fails", async () => {
|
||||
const notSqlitePath = join(fusionDir, "not-sqlite.db");
|
||||
writeFileSync(notSqlitePath, "definitely not sqlite");
|
||||
const manager = new BackupManager(fusionDir, { centralDbPath: notSqlitePath });
|
||||
const backup = await manager.createBackup();
|
||||
expect(backup.centralBackup && "filename" in backup.centralBackup).toBe(true);
|
||||
if (backup.centralBackup && "filename" in backup.centralBackup) {
|
||||
expect(readFileSync(backup.centralBackup.path, "utf-8")).toBe("definitely not sqlite");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps project backup when central copy fails", async () => {
|
||||
const manager = new BackupManager(fusionDir, { centralDbPath: fusionDir });
|
||||
const backup = await manager.createBackup();
|
||||
expect(existsSync(backup.path)).toBe(true);
|
||||
expect(backup.centralBackup && "failed" in backup.centralBackup).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBackups", () => {
|
||||
@@ -180,6 +248,20 @@ describe("BackupManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listBackupPairs", () => {
|
||||
it("shows paired and singleton backups", async () => {
|
||||
const backupDir = join(tempDir, ".fusion/backups");
|
||||
await mkdir(backupDir, { recursive: true });
|
||||
await writeFile(join(backupDir, "fusion-2026-01-01-000000.db"), "project");
|
||||
await writeFile(join(backupDir, "fusion-central-2026-01-01-000000.db"), "central");
|
||||
await writeFile(join(backupDir, "fusion-2025-12-31-000000.db"), "legacy-only");
|
||||
|
||||
const pairs = await backupManager.listBackupPairs();
|
||||
expect(pairs[0]).toMatchObject({ project: expect.anything(), central: expect.anything() });
|
||||
expect(pairs.some((pair) => pair.project && !pair.central)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOldBackups", () => {
|
||||
it("should not delete when backup count is within retention", async () => {
|
||||
// Create 3 backups with retention of 7 by advancing time
|
||||
@@ -196,7 +278,7 @@ describe("BackupManager", () => {
|
||||
});
|
||||
|
||||
it("should delete oldest backups exceeding retention", async () => {
|
||||
const manager = new BackupManager(fusionDir, { retention: 2 });
|
||||
const manager = new BackupManager(fusionDir, { retention: 2, centralDbPath: join(fusionDir, "fusion-central.db") });
|
||||
|
||||
// Create 4 backups by advancing time deterministically
|
||||
for (let i = 0; i < 4; i++) {
|
||||
@@ -212,7 +294,7 @@ describe("BackupManager", () => {
|
||||
});
|
||||
|
||||
it("should keep the newest backups after cleanup", async () => {
|
||||
const manager = new BackupManager(fusionDir, { retention: 2 });
|
||||
const manager = new BackupManager(fusionDir, { retention: 2, centralDbPath: join(fusionDir, "fusion-central.db") });
|
||||
|
||||
// Create 4 backups and record their names by advancing time
|
||||
const backupNames: string[] = [];
|
||||
@@ -233,6 +315,18 @@ describe("BackupManager", () => {
|
||||
expect(remainingNames).not.toContain(backupNames[0]);
|
||||
expect(remainingNames).not.toContain(backupNames[1]);
|
||||
});
|
||||
|
||||
it("deletes sibling central backup when deleting project backup", async () => {
|
||||
const manager = new BackupManager(fusionDir, { retention: 1, centralDbPath: join(fusionDir, "fusion-central.db") });
|
||||
await manager.createBackup();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.000Z"));
|
||||
await manager.createBackup();
|
||||
const backupDir = join(tempDir, ".fusion/backups");
|
||||
const oldestCentral = join(backupDir, "fusion-central-2026-01-01-000000.db");
|
||||
expect(existsSync(oldestCentral)).toBe(true);
|
||||
await manager.cleanupOldBackups();
|
||||
expect(existsSync(oldestCentral)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreBackup", () => {
|
||||
@@ -272,6 +366,32 @@ describe("BackupManager", () => {
|
||||
expect(preRestoreBackup).toBeDefined();
|
||||
expect(preRestoreBackup!.filename).toMatch(/^fusion-pre-restore-/);
|
||||
});
|
||||
|
||||
it("restores paired central backup when restoring project backup", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
await writeFile(join(fusionDir, "fusion.db"), "project modified");
|
||||
await writeFile(join(fusionDir, "fusion-central.db"), "central modified");
|
||||
|
||||
await backupManager.restoreBackup(backup.filename, { createPreRestoreBackup: true });
|
||||
|
||||
expect(readFileSync(join(fusionDir, "fusion.db"), "utf-8")).toBe("dummy database content");
|
||||
expect(readFileSync(join(fusionDir, "fusion-central.db"), "utf-8")).toBe("dummy central database content");
|
||||
const backups = await readdir(join(tempDir, ".fusion/backups"));
|
||||
expect(backups.some((name) => name.startsWith("fusion-pre-restore-"))).toBe(true);
|
||||
expect(backups.some((name) => name.startsWith("fusion-central-pre-restore-"))).toBe(true);
|
||||
});
|
||||
|
||||
it("restores central-only backup when filename is fusion-central-*", async () => {
|
||||
const backup = await backupManager.createBackup();
|
||||
if (!backup.centralBackup || !("filename" in backup.centralBackup)) {
|
||||
throw new Error("expected central backup file");
|
||||
}
|
||||
await writeFile(join(fusionDir, "fusion-central.db"), "central modified");
|
||||
await backupManager.restoreBackup(backup.centralBackup.filename, { centralOnly: true, createPreRestoreBackup: true });
|
||||
expect(readFileSync(join(fusionDir, "fusion-central.db"), "utf-8")).toBe("dummy central database content");
|
||||
const backups = await readdir(join(tempDir, ".fusion/backups"));
|
||||
expect(backups.some((name) => name.startsWith("fusion-central-pre-restore-"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -669,6 +789,25 @@ describe("runBackupCommand", () => {
|
||||
expect(result.deletedCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should report central copy failure while keeping success true", async () => {
|
||||
const settings: ProjectSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
autoBackupEnabled: true,
|
||||
};
|
||||
|
||||
await rm(join(fusionDir, "fusion-central.db"), { force: true });
|
||||
await mkdir(join(fusionDir, "fusion-central.db"), { recursive: true });
|
||||
|
||||
const result = await runBackupCommand(fusionDir, settings);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain("Central DB backup failed");
|
||||
});
|
||||
|
||||
it("should return failure when database file is missing", async () => {
|
||||
// Remove the database
|
||||
await rm(join(fusionDir, "fusion.db"));
|
||||
|
||||
@@ -2,162 +2,224 @@ import { cp, mkdir, readdir, stat, unlink } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { DatabaseSync } from "./sqlite-adapter.js";
|
||||
import { getDefaultCentralDbPath } from "./central-db.js";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
|
||||
/**
|
||||
* Metadata for a database backup file.
|
||||
*/
|
||||
export interface BackupInfo {
|
||||
/** Filename of the backup (e.g., "fusion-2026-03-31-020000.db") */
|
||||
export interface BackupFileInfo {
|
||||
filename: string;
|
||||
/** ISO-8601 timestamp when the backup was created */
|
||||
createdAt: string;
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
/** Full absolute path to the backup file */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for configuring the backup manager.
|
||||
*/
|
||||
export interface BackupOptions {
|
||||
/** Directory for backup files, relative to the project root. Default: ".fusion/backups" */
|
||||
backupDir?: string;
|
||||
/** Number of backups to retain. Default: 7 */
|
||||
retention?: number;
|
||||
export interface BackupInfo extends BackupFileInfo {
|
||||
centralBackup?:
|
||||
| BackupFileInfo
|
||||
| {
|
||||
skipped: "missing" | "disabled";
|
||||
}
|
||||
| {
|
||||
failed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BackupPairInfo {
|
||||
timestamp: string;
|
||||
project?: BackupFileInfo;
|
||||
central?: BackupFileInfo;
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
backupDir?: string;
|
||||
retention?: number;
|
||||
centralDbPath?: string;
|
||||
includeCentralDb?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages database backup operations including creation, listing,
|
||||
* cleanup of old backups, and restoration.
|
||||
*/
|
||||
export class BackupManager {
|
||||
private fusionDir: string;
|
||||
private backupDir: string;
|
||||
private retention: number;
|
||||
private centralDbPath: string;
|
||||
private includeCentralDb: boolean;
|
||||
|
||||
/**
|
||||
* Creates a new BackupManager instance.
|
||||
* @param fusionDir - Absolute path to the .fusion directory
|
||||
* @param options - Backup configuration options
|
||||
*/
|
||||
constructor(fusionDir: string, options?: BackupOptions) {
|
||||
this.fusionDir = fusionDir;
|
||||
this.backupDir = options?.backupDir ?? ".fusion/backups";
|
||||
this.retention = options?.retention ?? 7;
|
||||
this.centralDbPath = options?.centralDbPath ?? join(this.fusionDir, "..", ".fusion", "fusion-central.db");
|
||||
this.includeCentralDb = options?.includeCentralDb ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute path to the backup directory.
|
||||
*/
|
||||
private getBackupDirPath(): string {
|
||||
// The backupDir is relative to project root, which is parent of fusionDir
|
||||
return join(this.fusionDir, "..", this.backupDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timestamped backup of the database.
|
||||
* @returns BackupInfo for the newly created backup
|
||||
*/
|
||||
async createBackup(): Promise<BackupInfo> {
|
||||
const sourcePath = join(this.fusionDir, "fusion.db");
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
|
||||
// Ensure backup directory exists
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
|
||||
// Generate unique filename (handle collisions with counter suffix)
|
||||
let filename = generateBackupFilename();
|
||||
let targetPath = join(backupDirPath, filename);
|
||||
let counter = 1;
|
||||
|
||||
while (existsSync(targetPath)) {
|
||||
const baseName = filename.replace(/\.db$/, "");
|
||||
filename = `${baseName}-${counter}.db`;
|
||||
targetPath = join(backupDirPath, filename);
|
||||
counter++;
|
||||
|
||||
const timestamp = currentBackupTimestamp();
|
||||
let counter = 0;
|
||||
|
||||
while (true) {
|
||||
const projectFilename = generateBackupFilename(timestamp, counter);
|
||||
const projectTargetPath = join(backupDirPath, projectFilename);
|
||||
const projectExists = existsSync(projectTargetPath);
|
||||
|
||||
if (!this.includeCentralDb) {
|
||||
if (!projectExists) break;
|
||||
counter += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const centralFilename = generateCentralBackupFilename(timestamp, counter);
|
||||
const centralTargetPath = join(backupDirPath, centralFilename);
|
||||
const centralExists = existsSync(centralTargetPath);
|
||||
|
||||
if (!projectExists && !centralExists) {
|
||||
break;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
// Copy the database file
|
||||
const filename = generateBackupFilename(timestamp, counter);
|
||||
const targetPath = join(backupDirPath, filename);
|
||||
|
||||
checkpointWalBestEffort(sourcePath);
|
||||
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||
|
||||
// Get file stats
|
||||
const stats = await stat(targetPath);
|
||||
|
||||
return {
|
||||
const backup: BackupInfo = {
|
||||
filename,
|
||||
createdAt: new Date().toISOString(),
|
||||
size: stats.size,
|
||||
path: targetPath,
|
||||
};
|
||||
|
||||
if (!this.includeCentralDb) {
|
||||
backup.centralBackup = { skipped: "disabled" };
|
||||
return backup;
|
||||
}
|
||||
|
||||
if (!existsSync(this.centralDbPath)) {
|
||||
backup.centralBackup = { skipped: "missing" };
|
||||
return backup;
|
||||
}
|
||||
|
||||
const centralFilename = generateCentralBackupFilename(timestamp, counter);
|
||||
const centralTargetPath = join(backupDirPath, centralFilename);
|
||||
|
||||
try {
|
||||
checkpointWalBestEffort(this.centralDbPath);
|
||||
await cp(this.centralDbPath, centralTargetPath, { preserveTimestamps: true });
|
||||
|
||||
const centralStats = await stat(centralTargetPath);
|
||||
backup.centralBackup = {
|
||||
filename: centralFilename,
|
||||
createdAt: new Date().toISOString(),
|
||||
size: centralStats.size,
|
||||
path: centralTargetPath,
|
||||
};
|
||||
} catch (err) {
|
||||
backup.centralBackup = {
|
||||
failed: (err as Error).message,
|
||||
};
|
||||
}
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all backup files sorted by creation time (newest first).
|
||||
* @returns Array of BackupInfo objects
|
||||
*/
|
||||
async listBackups(): Promise<BackupInfo[]> {
|
||||
async listBackups(): Promise<BackupFileInfo[]> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
|
||||
try {
|
||||
const files = await readdir(backupDirPath);
|
||||
const backups: BackupInfo[] = [];
|
||||
const backups: BackupFileInfo[] = [];
|
||||
|
||||
for (const filename of files) {
|
||||
// Match fusion-* and legacy kb-* backup patterns:
|
||||
// fusion-YYYY-MM-DD-HHmmss.db, fusion-YYYY-MM-DD-HHmmss-N.db,
|
||||
// fusion-pre-restore-YYYY-MM-DD-HHmmss.db
|
||||
// kb-* variants kept for the ongoing kb → fn rename (see memory).
|
||||
if (!filename.match(/^(?:fusion|kb)(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(backupDirPath, filename);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Parse timestamp from filename. Also handles counter suffix: fusion-YYYY-MM-DD-HHmmss-N.db
|
||||
const match = filename.match(/^(?:fusion|kb)(?:-pre-restore)?-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
|
||||
const createdAt = match
|
||||
? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`
|
||||
: stats.mtime.toISOString();
|
||||
|
||||
backups.push({
|
||||
filename,
|
||||
createdAt,
|
||||
createdAt: parseBackupTimestamp(filename, stats.mtime.toISOString()),
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first), then by filename for deterministic ordering
|
||||
return backups.sort((a, b) => {
|
||||
const timeCompare = b.createdAt.localeCompare(a.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
return b.filename.localeCompare(a.filename);
|
||||
});
|
||||
return sortBackupsNewestFirst(backups);
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read - return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes old backups to maintain the retention limit.
|
||||
* Only removes regular backups, not pre-restore backups.
|
||||
* @returns Number of backups deleted
|
||||
*/
|
||||
async listCentralBackups(): Promise<BackupFileInfo[]> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
|
||||
try {
|
||||
const files = await readdir(backupDirPath);
|
||||
const backups: BackupFileInfo[] = [];
|
||||
|
||||
for (const filename of files) {
|
||||
if (!filename.match(/^fusion-central(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(backupDirPath, filename);
|
||||
const stats = await stat(filePath);
|
||||
backups.push({
|
||||
filename,
|
||||
createdAt: parseCentralBackupTimestamp(filename, stats.mtime.toISOString()),
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return sortBackupsNewestFirst(backups);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async listBackupPairs(): Promise<BackupPairInfo[]> {
|
||||
const projects = await this.listBackups();
|
||||
const centrals = await this.listCentralBackups();
|
||||
const pairs = new Map<string, BackupPairInfo>();
|
||||
|
||||
for (const project of projects) {
|
||||
const key = getBackupPairKey(project.filename, false);
|
||||
if (!key) continue;
|
||||
const existing = pairs.get(key) ?? { timestamp: key };
|
||||
existing.project = project;
|
||||
pairs.set(key, existing);
|
||||
}
|
||||
|
||||
for (const central of centrals) {
|
||||
const key = getBackupPairKey(central.filename, true);
|
||||
if (!key) continue;
|
||||
const existing = pairs.get(key) ?? { timestamp: key };
|
||||
existing.central = central;
|
||||
pairs.set(key, existing);
|
||||
}
|
||||
|
||||
return [...pairs.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
}
|
||||
|
||||
async cleanupOldBackups(): Promise<number> {
|
||||
const backups = await this.listBackups();
|
||||
|
||||
// Filter to only regular backups (not pre-restore)
|
||||
const regularBackups = backups.filter(b => !b.filename.includes("pre-restore"));
|
||||
const regularBackups = backups.filter((b) => !b.filename.includes("pre-restore"));
|
||||
|
||||
if (regularBackups.length <= this.retention) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Sort ascending (oldest first) for deletion, using filename as secondary sort for determinism
|
||||
const sorted = [...regularBackups].sort((a, b) => {
|
||||
const timeCompare = a.createdAt.localeCompare(b.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
@@ -173,56 +235,95 @@ export class BackupManager {
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
|
||||
const siblingCentralFilename = toCentralSiblingFilename(backup.filename);
|
||||
if (!siblingCentralFilename) continue;
|
||||
const siblingCentralPath = join(this.getBackupDirPath(), siblingCentralFilename);
|
||||
if (existsSync(siblingCentralPath)) {
|
||||
try {
|
||||
await unlink(siblingCentralPath);
|
||||
} catch {
|
||||
// Ignore sibling cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup to become the main database.
|
||||
* Optionally creates a pre-restore backup of the current database.
|
||||
* @param filename - Name of the backup file to restore
|
||||
* @param options - Restore options
|
||||
*/
|
||||
async restoreBackup(
|
||||
filename: string,
|
||||
options?: { createPreRestoreBackup?: boolean }
|
||||
options?: { createPreRestoreBackup?: boolean; skipCentral?: boolean; centralOnly?: boolean }
|
||||
): Promise<void> {
|
||||
const backupDirPath = this.getBackupDirPath();
|
||||
const sourcePath = join(backupDirPath, filename);
|
||||
const targetPath = join(this.fusionDir, "fusion.db");
|
||||
|
||||
// Verify source exists
|
||||
try {
|
||||
await stat(sourcePath);
|
||||
} catch {
|
||||
throw new Error(`Backup file not found: ${filename}`);
|
||||
}
|
||||
|
||||
// Optionally create pre-restore backup
|
||||
if (options?.createPreRestoreBackup ?? true) {
|
||||
const preRestoreFilename = `fusion-pre-restore-${formatTimestamp(new Date())}.db`;
|
||||
const createPreRestoreBackup = options?.createPreRestoreBackup ?? true;
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
|
||||
const restoreCentral = async (centralFilename: string, centralSourcePath = join(backupDirPath, centralFilename)) => {
|
||||
try {
|
||||
await stat(centralSourcePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options?.centralOnly && !existsSync(this.centralDbPath)) {
|
||||
throw new Error(`Central database path not found: ${this.centralDbPath}`);
|
||||
}
|
||||
|
||||
if (createPreRestoreBackup && existsSync(this.centralDbPath)) {
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
const preRestoreFilename = `fusion-central-pre-restore-${timestamp}.db`;
|
||||
await cp(this.centralDbPath, join(backupDirPath, preRestoreFilename), { preserveTimestamps: true });
|
||||
}
|
||||
|
||||
await cp(centralSourcePath, this.centralDbPath, { preserveTimestamps: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
if (filename.startsWith("fusion-central-")) {
|
||||
await restoreCentral(filename, sourcePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = join(this.fusionDir, "fusion.db");
|
||||
if (createPreRestoreBackup) {
|
||||
const preRestoreFilename = `fusion-pre-restore-${timestamp}.db`;
|
||||
const preRestorePath = join(backupDirPath, preRestoreFilename);
|
||||
await mkdir(backupDirPath, { recursive: true });
|
||||
await cp(targetPath, preRestorePath, { preserveTimestamps: true });
|
||||
}
|
||||
|
||||
// Restore the backup
|
||||
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||
|
||||
if (!options?.skipCentral) {
|
||||
const centralSiblingFilename = toCentralSiblingFilename(filename);
|
||||
if (centralSiblingFilename) {
|
||||
await restoreCentral(centralSiblingFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a backup filename with timestamp.
|
||||
* Format: fusion-YYYY-MM-DD-HHmmss.db
|
||||
*/
|
||||
export function generateBackupFilename(): string {
|
||||
return `fusion-${formatTimestamp(new Date())}.db`;
|
||||
export function currentBackupTimestamp(): string {
|
||||
return formatTimestamp(new Date());
|
||||
}
|
||||
|
||||
export function generateBackupFilename(timestamp = currentBackupTimestamp(), counter = 0): string {
|
||||
return counter > 0 ? `fusion-${timestamp}-${counter}.db` : `fusion-${timestamp}.db`;
|
||||
}
|
||||
|
||||
export function generateCentralBackupFilename(timestamp = currentBackupTimestamp(), counter = 0): string {
|
||||
return counter > 0 ? `fusion-central-${timestamp}-${counter}.db` : `fusion-central-${timestamp}.db`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as YYYY-MM-DD-HHmmss in UTC.
|
||||
*/
|
||||
function formatTimestamp(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
@@ -233,11 +334,53 @@ function formatTimestamp(date: Date): string {
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a cron expression for backup scheduling.
|
||||
* @param schedule - Cron expression to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
function checkpointWalBestEffort(dbPath: string): void {
|
||||
if (!existsSync(dbPath)) return;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath);
|
||||
try {
|
||||
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
// Best effort only; plain file copy fallback remains valid.
|
||||
}
|
||||
}
|
||||
|
||||
function sortBackupsNewestFirst(backups: BackupFileInfo[]): BackupFileInfo[] {
|
||||
return backups.sort((a, b) => {
|
||||
const timeCompare = b.createdAt.localeCompare(a.createdAt);
|
||||
if (timeCompare !== 0) return timeCompare;
|
||||
return b.filename.localeCompare(a.filename);
|
||||
});
|
||||
}
|
||||
|
||||
function parseBackupTimestamp(filename: string, fallback: string): string {
|
||||
const match = filename.match(/^(?:fusion|kb)(?:-pre-restore)?-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z` : fallback;
|
||||
}
|
||||
|
||||
function parseCentralBackupTimestamp(filename: string, fallback: string): string {
|
||||
const match = filename.match(/^fusion-central(?:-pre-restore)?-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})(?:-\d+)?\.db$/);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z` : fallback;
|
||||
}
|
||||
|
||||
function toCentralSiblingFilename(projectFilename: string): string | null {
|
||||
const match = projectFilename.match(/^(?:fusion|kb)-(\d{4}-\d{2}-\d{2}-\d{6})(-\d+)?\.db$/);
|
||||
if (!match) return null;
|
||||
return `fusion-central-${match[1]}${match[2] ?? ""}.db`;
|
||||
}
|
||||
|
||||
function getBackupPairKey(filename: string, isCentral: boolean): string | null {
|
||||
const pattern = isCentral
|
||||
? /^fusion-central(?:-pre-restore)?-(\d{4}-\d{2}-\d{2}-\d{6})(-\d+)?\.db$/
|
||||
: /^(?:fusion|kb)(?:-pre-restore)?-(\d{4}-\d{2}-\d{2}-\d{6})(-\d+)?\.db$/;
|
||||
const match = filename.match(pattern);
|
||||
if (!match) return null;
|
||||
return `${match[1]}${match[2] ?? ""}`;
|
||||
}
|
||||
|
||||
export function validateBackupSchedule(schedule: string): boolean {
|
||||
if (!schedule || schedule.trim() === "") {
|
||||
return false;
|
||||
@@ -250,80 +393,51 @@ export function validateBackupSchedule(schedule: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the backup retention count.
|
||||
* @param retention - Number of backups to retain
|
||||
* @returns True if valid (1-100), false otherwise
|
||||
*/
|
||||
export function validateBackupRetention(retention: number): boolean {
|
||||
return Number.isInteger(retention) && retention >= 1 && retention <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the backup directory path.
|
||||
* Must be relative and not contain parent directory traversal.
|
||||
* @param dir - Directory path to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function validateBackupDir(dir: string): boolean {
|
||||
// Must be relative (not start with / or \)
|
||||
if (dir.startsWith("/") || dir.startsWith("\\")) {
|
||||
return false;
|
||||
}
|
||||
// Must not contain parent directory traversal
|
||||
if (dir.includes("..")) {
|
||||
return false;
|
||||
}
|
||||
// Must not be absolute path with drive letter (Windows)
|
||||
if (/^[a-zA-Z]:/.test(dir)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a BackupManager with project settings.
|
||||
* @param fusionDir - Absolute path to the .fusion directory
|
||||
* @param settings - Project settings containing backup configuration
|
||||
* @returns Configured BackupManager instance
|
||||
*/
|
||||
export function createBackupManager(
|
||||
fusionDir: string,
|
||||
settings?: Partial<ProjectSettings>
|
||||
): BackupManager {
|
||||
let centralDbPath: string;
|
||||
try {
|
||||
centralDbPath = getDefaultCentralDbPath();
|
||||
} catch {
|
||||
centralDbPath = join(fusionDir, "..", ".fusion", "fusion-central.db");
|
||||
}
|
||||
|
||||
return new BackupManager(fusionDir, {
|
||||
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
|
||||
retention: settings?.autoBackupRetention,
|
||||
centralDbPath,
|
||||
includeCentralDb: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a legacy `.kb/backups` value to `.fusion/backups`.
|
||||
* The kb → fn rename left some persisted settings pointing at the old path;
|
||||
* we rewrite it on read so existing projects keep working.
|
||||
*/
|
||||
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
|
||||
if (dir === ".kb/backups") return ".fusion/backups";
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the backup command with settings from the project.
|
||||
* This is the main entry point for scheduled backup automation.
|
||||
*
|
||||
* NOTE: This function does NOT check autoBackupEnabled - that check should happen
|
||||
* at the automation/scheduler level. This allows manual backups via CLI even when
|
||||
* auto-backup is disabled.
|
||||
*
|
||||
* @param fusionDir - Absolute path to the .fusion directory
|
||||
* @param settings - Project settings
|
||||
* @returns Result of the backup operation
|
||||
*/
|
||||
export async function runBackupCommand(
|
||||
fusionDir: string,
|
||||
settings: ProjectSettings
|
||||
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
|
||||
// Validate schedule if provided (for logging purposes)
|
||||
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -331,19 +445,29 @@ export async function runBackupCommand(
|
||||
};
|
||||
}
|
||||
|
||||
// Create backup manager with settings
|
||||
const manager = createBackupManager(fusionDir, settings);
|
||||
|
||||
try {
|
||||
// Create the backup
|
||||
const backup = await manager.createBackup();
|
||||
|
||||
// Cleanup old backups
|
||||
const deletedCount = await manager.cleanupOldBackups();
|
||||
const removedClause = deletedCount > 0 ? ` Removed ${deletedCount} old backup(s).` : "";
|
||||
|
||||
const output = deletedCount > 0
|
||||
? `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).`
|
||||
: `Backup created: ${backup.filename} (${formatBytes(backup.size)})`;
|
||||
const output = (() => {
|
||||
if (backup.centralBackup && "filename" in backup.centralBackup) {
|
||||
const total = backup.size + backup.centralBackup.size;
|
||||
return `Backup created: ${backup.filename} + ${backup.centralBackup.filename} (${formatBytes(total)}).${removedClause}`.trim();
|
||||
}
|
||||
|
||||
if (backup.centralBackup && "skipped" in backup.centralBackup) {
|
||||
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Central DB skipped: ${backup.centralBackup.skipped}.${removedClause}`.trim();
|
||||
}
|
||||
|
||||
if (backup.centralBackup && "failed" in backup.centralBackup) {
|
||||
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Central DB backup failed: ${backup.centralBackup.failed}.${removedClause}`.trim();
|
||||
}
|
||||
|
||||
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}).${removedClause}`.trim();
|
||||
})();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -359,9 +483,6 @@ export async function runBackupCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats bytes as human-readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
@@ -370,57 +491,32 @@ function formatBytes(bytes: number): string {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant name for the backup automation schedule.
|
||||
* Used to identify and manage the backup schedule in the automation store.
|
||||
*/
|
||||
export const BACKUP_SCHEDULE_NAME = "Database Backup";
|
||||
|
||||
/**
|
||||
* Synchronizes the backup automation schedule with project settings.
|
||||
* Creates, updates, or deletes the backup schedule based on settings.
|
||||
*
|
||||
* @param automationStore - The AutomationStore instance
|
||||
* @param settings - Current project settings
|
||||
* @returns The created/updated schedule, or undefined if deleted/disabled
|
||||
*/
|
||||
export async function syncBackupAutomation(
|
||||
automationStore: import("./automation-store.js").AutomationStore,
|
||||
settings: ProjectSettings
|
||||
): Promise<import("./automation.js").ScheduledTask | undefined> {
|
||||
const { AutomationStore } = await import("./automation-store.js");
|
||||
|
||||
// Find existing backup schedule by name
|
||||
|
||||
const schedules = await automationStore.listSchedules();
|
||||
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
|
||||
|
||||
// If backups are disabled, delete existing schedule if present
|
||||
|
||||
if (!settings.autoBackupEnabled) {
|
||||
if (existingSchedule) {
|
||||
await automationStore.deleteSchedule(existingSchedule.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate the cron schedule
|
||||
|
||||
const schedule = settings.autoBackupSchedule || "0 2 * * *";
|
||||
if (!AutomationStore.isValidCron(schedule)) {
|
||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
||||
}
|
||||
|
||||
// Build the backup command.
|
||||
// Uses `npx runfusion.ai` so backups work even when only the zero-install
|
||||
// path (`npx runfusion.ai`) has been used and `fn` is not on PATH.
|
||||
// Sentinel command intercepted in-process by the engine's cron/routine
|
||||
// runner (see `isInProcessBackupCommand` in @fusion/engine). Stored as a
|
||||
// command rather than a step so existing UI listings still display it as
|
||||
// a single-line action. Falls back to the npx shell-out only when read by
|
||||
// a runner that does not implement the in-process interception (e.g.
|
||||
// outdated globally-installed binaries running an older fusion engine).
|
||||
|
||||
const command = "fn backup --create";
|
||||
|
||||
if (existingSchedule) {
|
||||
// Update existing schedule
|
||||
return await automationStore.updateSchedule(existingSchedule.id, {
|
||||
scheduleType: "custom",
|
||||
cronExpression: schedule,
|
||||
@@ -428,7 +524,6 @@ export async function syncBackupAutomation(
|
||||
enabled: true,
|
||||
});
|
||||
} else {
|
||||
// Create new schedule
|
||||
return await automationStore.createSchedule({
|
||||
name: BACKUP_SCHEDULE_NAME,
|
||||
description: "Automatic database backup based on project settings",
|
||||
@@ -440,13 +535,6 @@ export async function syncBackupAutomation(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the database backup routine with project settings.
|
||||
*
|
||||
* This is the combined routine/schedule model: cron scheduling comes from the
|
||||
* routine trigger, while the backup action is stored as a command on the same
|
||||
* record.
|
||||
*/
|
||||
export async function syncBackupRoutine(
|
||||
routineStore: import("./routine-store.js").RoutineStore,
|
||||
settings: ProjectSettings,
|
||||
@@ -468,12 +556,6 @@ export async function syncBackupRoutine(
|
||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
||||
}
|
||||
|
||||
// Sentinel command intercepted in-process by the engine's cron/routine
|
||||
// runner (see `isInProcessBackupCommand` in @fusion/engine). Stored as a
|
||||
// command rather than a step so existing UI listings still display it as
|
||||
// a single-line action. Falls back to the npx shell-out only when read by
|
||||
// a runner that does not implement the in-process interception (e.g.
|
||||
// outdated globally-installed binaries running an older fusion engine).
|
||||
const command = "fn backup --create";
|
||||
const input = {
|
||||
name: BACKUP_SCHEDULE_NAME,
|
||||
|
||||
@@ -15,6 +15,10 @@ import { join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import type { Statement } from "./db.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
|
||||
export function getDefaultCentralDbPath(globalDir?: string): string {
|
||||
return join(resolveGlobalDir(globalDir), "fusion-central.db");
|
||||
}
|
||||
import type { CentralClaimStore, TaskClaimRow } from "./types.js";
|
||||
|
||||
// ── JSON Helpers (reused from db.ts) ─────────────────────────────────────
|
||||
|
||||
@@ -510,6 +510,8 @@ export {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
generateBackupFilename,
|
||||
generateCentralBackupFilename,
|
||||
currentBackupTimestamp,
|
||||
validateBackupSchedule,
|
||||
validateBackupRetention,
|
||||
validateBackupDir,
|
||||
@@ -518,7 +520,7 @@ export {
|
||||
syncBackupRoutine,
|
||||
BACKUP_SCHEDULE_NAME,
|
||||
} from "./backup.js";
|
||||
export type { BackupInfo, BackupOptions } from "./backup.js";
|
||||
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
||||
export {
|
||||
MemoryBackupManager,
|
||||
createMemoryBackupManager,
|
||||
|
||||
@@ -17,6 +17,7 @@ const piModuleMocks = vi.hoisted(() => ({
|
||||
|
||||
const coreModuleMocks = vi.hoisted(() => ({
|
||||
runMemoryBackupCommand: vi.fn(),
|
||||
runBackupCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
@@ -37,6 +38,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
||||
runBackupCommand: coreModuleMocks.runBackupCommand,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -120,6 +122,10 @@ describe("CronRunner", () => {
|
||||
success: true,
|
||||
output: "memory backup ok",
|
||||
});
|
||||
coreModuleMocks.runBackupCommand.mockResolvedValue({
|
||||
success: true,
|
||||
output: "Backup created: fusion-2026-01-01-000000.db + fusion-central-2026-01-01-000000.db (1 MB).",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1888,6 +1894,22 @@ describe("CronRunner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("backup in-process dispatch", () => {
|
||||
it("routes fn backup --create through runBackupCommand", async () => {
|
||||
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
||||
store.getFusionDir = vi.fn().mockReturnValue("/tmp/.fusion");
|
||||
const schedule = createMockSchedule({ id: "db-bk", command: "fn backup --create" });
|
||||
runner = new CronRunner(store, createMockAutomationStore());
|
||||
|
||||
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
||||
.executeLegacyCommand(schedule, new Date().toISOString());
|
||||
|
||||
expect(coreModuleMocks.runBackupCommand).toHaveBeenCalledWith("/tmp/.fusion", expect.any(Object));
|
||||
expect(runResult.success).toBe(true);
|
||||
expect(runResult.output).toContain("fusion-central-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory backup in-process dispatch", () => {
|
||||
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
||||
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
||||
|
||||
Reference in New Issue
Block a user