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`.
|
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.
|
`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:
|
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`)
|
## 4) SQLite Tables Inventory (`packages/core/src/db.ts`)
|
||||||
|
|
||||||
| Table | Purpose |
|
| Table | Purpose |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
mockListBackups,
|
mockListBackups,
|
||||||
|
mockListBackupPairs,
|
||||||
mockRestoreBackup,
|
mockRestoreBackup,
|
||||||
mockCleanupOldBackups,
|
mockCleanupOldBackups,
|
||||||
mockGetSettings,
|
mockGetSettings,
|
||||||
@@ -9,6 +10,7 @@ const {
|
|||||||
mockResolveProject,
|
mockResolveProject,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockListBackups: vi.fn(),
|
mockListBackups: vi.fn(),
|
||||||
|
mockListBackupPairs: vi.fn(),
|
||||||
mockRestoreBackup: vi.fn(),
|
mockRestoreBackup: vi.fn(),
|
||||||
mockCleanupOldBackups: vi.fn(),
|
mockCleanupOldBackups: vi.fn(),
|
||||||
mockGetSettings: vi.fn(),
|
mockGetSettings: vi.fn(),
|
||||||
@@ -25,6 +27,7 @@ vi.mock("@fusion/core", () => ({
|
|||||||
})),
|
})),
|
||||||
createBackupManager: vi.fn(() => ({
|
createBackupManager: vi.fn(() => ({
|
||||||
listBackups: mockListBackups,
|
listBackups: mockListBackups,
|
||||||
|
listBackupPairs: mockListBackupPairs,
|
||||||
restoreBackup: mockRestoreBackup,
|
restoreBackup: mockRestoreBackup,
|
||||||
cleanupOldBackups: mockCleanupOldBackups,
|
cleanupOldBackups: mockCleanupOldBackups,
|
||||||
})),
|
})),
|
||||||
@@ -53,6 +56,7 @@ describe("backup commands", () => {
|
|||||||
mockGetSettings.mockResolvedValue({ autoBackupDir: ".fusion/backups" });
|
mockGetSettings.mockResolvedValue({ autoBackupDir: ".fusion/backups" });
|
||||||
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
|
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
|
||||||
mockListBackups.mockResolvedValue([]);
|
mockListBackups.mockResolvedValue([]);
|
||||||
|
mockListBackupPairs.mockResolvedValue([]);
|
||||||
mockRestoreBackup.mockResolvedValue(undefined);
|
mockRestoreBackup.mockResolvedValue(undefined);
|
||||||
mockCleanupOldBackups.mockResolvedValue(0);
|
mockCleanupOldBackups.mockResolvedValue(0);
|
||||||
mockResolveProject.mockResolvedValue({
|
mockResolveProject.mockResolvedValue({
|
||||||
@@ -77,10 +81,18 @@ describe("backup commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runBackupList uses resolved project store with --project", async () => {
|
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");
|
await runBackupList("demo-project");
|
||||||
expect(mockResolveProject).toHaveBeenCalledWith("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 () => {
|
it("runBackupRestore uses resolved project store with --project", async () => {
|
||||||
@@ -93,7 +105,7 @@ describe("backup commands", () => {
|
|||||||
mockCleanupOldBackups.mockResolvedValue(2);
|
mockCleanupOldBackups.mockResolvedValue(2);
|
||||||
await runBackupCleanup("demo-project");
|
await runBackupCleanup("demo-project");
|
||||||
expect(mockResolveProject).toHaveBeenCalledWith("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 () => {
|
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> {
|
export async function runBackupList(projectName?: string): Promise<void> {
|
||||||
const { manager } = await getBackupManager(projectName);
|
const { manager } = await getBackupManager(projectName);
|
||||||
|
|
||||||
const backups = await manager.listBackups();
|
const pairs = await manager.listBackupPairs();
|
||||||
|
|
||||||
if (backups.length === 0) {
|
if (pairs.length === 0) {
|
||||||
console.log("No backups found.");
|
console.log("No backups found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${backups.length} backup(s):\n`);
|
const totalSize = pairs.reduce((sum, pair) => sum + (pair.project?.size ?? 0) + (pair.central?.size ?? 0), 0);
|
||||||
|
|
||||||
// Calculate total size
|
|
||||||
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
|
|
||||||
const formattedTotal = formatBytes(totalSize);
|
const formattedTotal = formatBytes(totalSize);
|
||||||
|
|
||||||
console.log("Date Size Filename");
|
console.log("Date Size Filename");
|
||||||
console.log("-".repeat(60));
|
console.log("-".repeat(60));
|
||||||
|
|
||||||
for (const backup of backups) {
|
for (const pair of pairs) {
|
||||||
const date = new Date(backup.createdAt).toLocaleString();
|
if (pair.project) {
|
||||||
const size = formatBytes(backup.size).padEnd(8);
|
const date = formatListDate(pair.project.createdAt);
|
||||||
console.log(`${date} ${size} ${backup.filename}`);
|
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("-".repeat(60));
|
||||||
console.log(`Total: ${formattedTotal}`);
|
console.log(`Total: ${formattedTotal}`);
|
||||||
}
|
}
|
||||||
@@ -98,8 +108,13 @@ export async function runBackupRestore(filename: string, projectName?: string):
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await manager.restoreBackup(filename, { createPreRestoreBackup: true });
|
await manager.restoreBackup(filename, { createPreRestoreBackup: true });
|
||||||
console.log(`Successfully restored from ${filename}`);
|
if (filename.startsWith("fusion-central-")) {
|
||||||
console.log("Note: The pre-restore backup was saved in case you need to undo this operation.");
|
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) {
|
} catch (err) {
|
||||||
console.error(`Restore failed: ${(err as Error).message}`);
|
console.error(`Restore failed: ${(err as Error).message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -118,7 +133,7 @@ export async function runBackupCleanup(projectName?: string): Promise<void> {
|
|||||||
const deletedCount = await manager.cleanupOldBackups();
|
const deletedCount = await manager.cleanupOldBackups();
|
||||||
|
|
||||||
if (deletedCount > 0) {
|
if (deletedCount > 0) {
|
||||||
console.log(`Removed ${deletedCount} old backup(s).`);
|
console.log(`Removed ${deletedCount} old backup(s) and any paired central backup files.`);
|
||||||
} else {
|
} else {
|
||||||
console.log("No backups to clean up (within retention limit).");
|
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.
|
* 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 {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ describe("BackupManager", () => {
|
|||||||
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
tempDir = mkdtempSync(join(tmpdir(), "kb-backup-test-"));
|
||||||
fusionDir = join(tempDir, ".fusion");
|
fusionDir = join(tempDir, ".fusion");
|
||||||
await mkdir(fusionDir, { recursive: true });
|
await mkdir(fusionDir, { recursive: true });
|
||||||
// Create a dummy database file
|
// Create dummy project + central database files
|
||||||
writeFileSync(join(fusionDir, "fusion.db"), "dummy database content");
|
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 () => {
|
afterEach(async () => {
|
||||||
@@ -68,7 +71,10 @@ describe("BackupManager", () => {
|
|||||||
|
|
||||||
it("should create backup directory if it does not exist", async () => {
|
it("should create backup directory if it does not exist", async () => {
|
||||||
const customBackupDir = "custom-backups";
|
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);
|
const customBackupPath = join(tempDir, customBackupDir);
|
||||||
expect(existsSync(customBackupPath)).toBe(false);
|
expect(existsSync(customBackupPath)).toBe(false);
|
||||||
@@ -77,6 +83,68 @@ describe("BackupManager", () => {
|
|||||||
|
|
||||||
expect(existsSync(customBackupPath)).toBe(true);
|
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", () => {
|
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", () => {
|
describe("cleanupOldBackups", () => {
|
||||||
it("should not delete when backup count is within retention", async () => {
|
it("should not delete when backup count is within retention", async () => {
|
||||||
// Create 3 backups with retention of 7 by advancing time
|
// Create 3 backups with retention of 7 by advancing time
|
||||||
@@ -196,7 +278,7 @@ describe("BackupManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should delete oldest backups exceeding retention", async () => {
|
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
|
// Create 4 backups by advancing time deterministically
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
@@ -212,7 +294,7 @@ describe("BackupManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should keep the newest backups after cleanup", async () => {
|
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
|
// Create 4 backups and record their names by advancing time
|
||||||
const backupNames: string[] = [];
|
const backupNames: string[] = [];
|
||||||
@@ -233,6 +315,18 @@ describe("BackupManager", () => {
|
|||||||
expect(remainingNames).not.toContain(backupNames[0]);
|
expect(remainingNames).not.toContain(backupNames[0]);
|
||||||
expect(remainingNames).not.toContain(backupNames[1]);
|
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", () => {
|
describe("restoreBackup", () => {
|
||||||
@@ -272,6 +366,32 @@ describe("BackupManager", () => {
|
|||||||
expect(preRestoreBackup).toBeDefined();
|
expect(preRestoreBackup).toBeDefined();
|
||||||
expect(preRestoreBackup!.filename).toMatch(/^fusion-pre-restore-/);
|
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);
|
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 () => {
|
it("should return failure when database file is missing", async () => {
|
||||||
// Remove the database
|
// Remove the database
|
||||||
await rm(join(fusionDir, "fusion.db"));
|
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 { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { CronExpressionParser } from "cron-parser";
|
import { CronExpressionParser } from "cron-parser";
|
||||||
|
import { DatabaseSync } from "./sqlite-adapter.js";
|
||||||
|
import { getDefaultCentralDbPath } from "./central-db.js";
|
||||||
import type { ProjectSettings } from "./types.js";
|
import type { ProjectSettings } from "./types.js";
|
||||||
|
|
||||||
/**
|
export interface BackupFileInfo {
|
||||||
* Metadata for a database backup file.
|
|
||||||
*/
|
|
||||||
export interface BackupInfo {
|
|
||||||
/** Filename of the backup (e.g., "fusion-2026-03-31-020000.db") */
|
|
||||||
filename: string;
|
filename: string;
|
||||||
/** ISO-8601 timestamp when the backup was created */
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Size in bytes */
|
|
||||||
size: number;
|
size: number;
|
||||||
/** Full absolute path to the backup file */
|
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface BackupInfo extends BackupFileInfo {
|
||||||
* Options for configuring the backup manager.
|
centralBackup?:
|
||||||
*/
|
| BackupFileInfo
|
||||||
export interface BackupOptions {
|
| {
|
||||||
/** Directory for backup files, relative to the project root. Default: ".fusion/backups" */
|
skipped: "missing" | "disabled";
|
||||||
backupDir?: string;
|
}
|
||||||
/** Number of backups to retain. Default: 7 */
|
| {
|
||||||
retention?: number;
|
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 {
|
export class BackupManager {
|
||||||
private fusionDir: string;
|
private fusionDir: string;
|
||||||
private backupDir: string;
|
private backupDir: string;
|
||||||
private retention: number;
|
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) {
|
constructor(fusionDir: string, options?: BackupOptions) {
|
||||||
this.fusionDir = fusionDir;
|
this.fusionDir = fusionDir;
|
||||||
this.backupDir = options?.backupDir ?? ".fusion/backups";
|
this.backupDir = options?.backupDir ?? ".fusion/backups";
|
||||||
this.retention = options?.retention ?? 7;
|
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 {
|
private getBackupDirPath(): string {
|
||||||
// The backupDir is relative to project root, which is parent of fusionDir
|
|
||||||
return join(this.fusionDir, "..", this.backupDir);
|
return join(this.fusionDir, "..", this.backupDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a timestamped backup of the database.
|
|
||||||
* @returns BackupInfo for the newly created backup
|
|
||||||
*/
|
|
||||||
async createBackup(): Promise<BackupInfo> {
|
async createBackup(): Promise<BackupInfo> {
|
||||||
const sourcePath = join(this.fusionDir, "fusion.db");
|
const sourcePath = join(this.fusionDir, "fusion.db");
|
||||||
const backupDirPath = this.getBackupDirPath();
|
const backupDirPath = this.getBackupDirPath();
|
||||||
|
|
||||||
// Ensure backup directory exists
|
|
||||||
await mkdir(backupDirPath, { recursive: true });
|
await mkdir(backupDirPath, { recursive: true });
|
||||||
|
|
||||||
// Generate unique filename (handle collisions with counter suffix)
|
const timestamp = currentBackupTimestamp();
|
||||||
let filename = generateBackupFilename();
|
let counter = 0;
|
||||||
let targetPath = join(backupDirPath, filename);
|
|
||||||
let counter = 1;
|
while (true) {
|
||||||
|
const projectFilename = generateBackupFilename(timestamp, counter);
|
||||||
while (existsSync(targetPath)) {
|
const projectTargetPath = join(backupDirPath, projectFilename);
|
||||||
const baseName = filename.replace(/\.db$/, "");
|
const projectExists = existsSync(projectTargetPath);
|
||||||
filename = `${baseName}-${counter}.db`;
|
|
||||||
targetPath = join(backupDirPath, filename);
|
if (!this.includeCentralDb) {
|
||||||
counter++;
|
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 });
|
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||||
|
|
||||||
// Get file stats
|
|
||||||
const stats = await stat(targetPath);
|
const stats = await stat(targetPath);
|
||||||
|
const backup: BackupInfo = {
|
||||||
return {
|
|
||||||
filename,
|
filename,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
path: targetPath,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async listBackups(): Promise<BackupFileInfo[]> {
|
||||||
* Lists all backup files sorted by creation time (newest first).
|
|
||||||
* @returns Array of BackupInfo objects
|
|
||||||
*/
|
|
||||||
async listBackups(): Promise<BackupInfo[]> {
|
|
||||||
const backupDirPath = this.getBackupDirPath();
|
const backupDirPath = this.getBackupDirPath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await readdir(backupDirPath);
|
const files = await readdir(backupDirPath);
|
||||||
const backups: BackupInfo[] = [];
|
const backups: BackupFileInfo[] = [];
|
||||||
|
|
||||||
for (const filename of files) {
|
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$/)) {
|
if (!filename.match(/^(?:fusion|kb)(-pre-restore)?-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.db$/)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = join(backupDirPath, filename);
|
const filePath = join(backupDirPath, filename);
|
||||||
const stats = await stat(filePath);
|
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({
|
backups.push({
|
||||||
filename,
|
filename,
|
||||||
createdAt,
|
createdAt: parseBackupTimestamp(filename, stats.mtime.toISOString()),
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first), then by filename for deterministic ordering
|
return sortBackupsNewestFirst(backups);
|
||||||
return backups.sort((a, b) => {
|
|
||||||
const timeCompare = b.createdAt.localeCompare(a.createdAt);
|
|
||||||
if (timeCompare !== 0) return timeCompare;
|
|
||||||
return b.filename.localeCompare(a.filename);
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Directory doesn't exist or can't be read - return empty array
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async listCentralBackups(): Promise<BackupFileInfo[]> {
|
||||||
* Removes old backups to maintain the retention limit.
|
const backupDirPath = this.getBackupDirPath();
|
||||||
* Only removes regular backups, not pre-restore backups.
|
|
||||||
* @returns Number of backups deleted
|
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> {
|
async cleanupOldBackups(): Promise<number> {
|
||||||
const backups = await this.listBackups();
|
const backups = await this.listBackups();
|
||||||
|
const regularBackups = backups.filter((b) => !b.filename.includes("pre-restore"));
|
||||||
// Filter to only regular backups (not pre-restore)
|
|
||||||
const regularBackups = backups.filter(b => !b.filename.includes("pre-restore"));
|
|
||||||
|
|
||||||
if (regularBackups.length <= this.retention) {
|
if (regularBackups.length <= this.retention) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort ascending (oldest first) for deletion, using filename as secondary sort for determinism
|
|
||||||
const sorted = [...regularBackups].sort((a, b) => {
|
const sorted = [...regularBackups].sort((a, b) => {
|
||||||
const timeCompare = a.createdAt.localeCompare(b.createdAt);
|
const timeCompare = a.createdAt.localeCompare(b.createdAt);
|
||||||
if (timeCompare !== 0) return timeCompare;
|
if (timeCompare !== 0) return timeCompare;
|
||||||
@@ -173,56 +235,95 @@ export class BackupManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Ignore deletion errors
|
// 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;
|
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(
|
async restoreBackup(
|
||||||
filename: string,
|
filename: string,
|
||||||
options?: { createPreRestoreBackup?: boolean }
|
options?: { createPreRestoreBackup?: boolean; skipCentral?: boolean; centralOnly?: boolean }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const backupDirPath = this.getBackupDirPath();
|
const backupDirPath = this.getBackupDirPath();
|
||||||
const sourcePath = join(backupDirPath, filename);
|
const sourcePath = join(backupDirPath, filename);
|
||||||
const targetPath = join(this.fusionDir, "fusion.db");
|
|
||||||
|
|
||||||
// Verify source exists
|
|
||||||
try {
|
try {
|
||||||
await stat(sourcePath);
|
await stat(sourcePath);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Backup file not found: ${filename}`);
|
throw new Error(`Backup file not found: ${filename}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally create pre-restore backup
|
const createPreRestoreBackup = options?.createPreRestoreBackup ?? true;
|
||||||
if (options?.createPreRestoreBackup ?? true) {
|
const timestamp = formatTimestamp(new Date());
|
||||||
const preRestoreFilename = `fusion-pre-restore-${formatTimestamp(new Date())}.db`;
|
|
||||||
|
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);
|
const preRestorePath = join(backupDirPath, preRestoreFilename);
|
||||||
await mkdir(backupDirPath, { recursive: true });
|
await mkdir(backupDirPath, { recursive: true });
|
||||||
await cp(targetPath, preRestorePath, { preserveTimestamps: true });
|
await cp(targetPath, preRestorePath, { preserveTimestamps: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the backup
|
|
||||||
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
await cp(sourcePath, targetPath, { preserveTimestamps: true });
|
||||||
|
|
||||||
|
if (!options?.skipCentral) {
|
||||||
|
const centralSiblingFilename = toCentralSiblingFilename(filename);
|
||||||
|
if (centralSiblingFilename) {
|
||||||
|
await restoreCentral(centralSiblingFilename);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function currentBackupTimestamp(): string {
|
||||||
* Generates a backup filename with timestamp.
|
return formatTimestamp(new Date());
|
||||||
* Format: fusion-YYYY-MM-DD-HHmmss.db
|
}
|
||||||
*/
|
|
||||||
export function generateBackupFilename(): string {
|
export function generateBackupFilename(timestamp = currentBackupTimestamp(), counter = 0): string {
|
||||||
return `fusion-${formatTimestamp(new Date())}.db`;
|
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 {
|
function formatTimestamp(date: Date): string {
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
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}`;
|
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function checkpointWalBestEffort(dbPath: string): void {
|
||||||
* Validates a cron expression for backup scheduling.
|
if (!existsSync(dbPath)) return;
|
||||||
* @param schedule - Cron expression to validate
|
try {
|
||||||
* @returns True if valid, false otherwise
|
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 {
|
export function validateBackupSchedule(schedule: string): boolean {
|
||||||
if (!schedule || schedule.trim() === "") {
|
if (!schedule || schedule.trim() === "") {
|
||||||
return false;
|
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 {
|
export function validateBackupRetention(retention: number): boolean {
|
||||||
return Number.isInteger(retention) && retention >= 1 && retention <= 100;
|
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 {
|
export function validateBackupDir(dir: string): boolean {
|
||||||
// Must be relative (not start with / or \)
|
|
||||||
if (dir.startsWith("/") || dir.startsWith("\\")) {
|
if (dir.startsWith("/") || dir.startsWith("\\")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Must not contain parent directory traversal
|
|
||||||
if (dir.includes("..")) {
|
if (dir.includes("..")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Must not be absolute path with drive letter (Windows)
|
|
||||||
if (/^[a-zA-Z]:/.test(dir)) {
|
if (/^[a-zA-Z]:/.test(dir)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
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(
|
export function createBackupManager(
|
||||||
fusionDir: string,
|
fusionDir: string,
|
||||||
settings?: Partial<ProjectSettings>
|
settings?: Partial<ProjectSettings>
|
||||||
): BackupManager {
|
): BackupManager {
|
||||||
|
let centralDbPath: string;
|
||||||
|
try {
|
||||||
|
centralDbPath = getDefaultCentralDbPath();
|
||||||
|
} catch {
|
||||||
|
centralDbPath = join(fusionDir, "..", ".fusion", "fusion-central.db");
|
||||||
|
}
|
||||||
|
|
||||||
return new BackupManager(fusionDir, {
|
return new BackupManager(fusionDir, {
|
||||||
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
|
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
|
||||||
retention: settings?.autoBackupRetention,
|
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 {
|
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
|
||||||
if (dir === ".kb/backups") return ".fusion/backups";
|
if (dir === ".kb/backups") return ".fusion/backups";
|
||||||
return dir;
|
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(
|
export async function runBackupCommand(
|
||||||
fusionDir: string,
|
fusionDir: string,
|
||||||
settings: ProjectSettings
|
settings: ProjectSettings
|
||||||
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
|
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
|
||||||
// Validate schedule if provided (for logging purposes)
|
|
||||||
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
|
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -331,19 +445,29 @@ export async function runBackupCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create backup manager with settings
|
|
||||||
const manager = createBackupManager(fusionDir, settings);
|
const manager = createBackupManager(fusionDir, settings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the backup
|
|
||||||
const backup = await manager.createBackup();
|
const backup = await manager.createBackup();
|
||||||
|
|
||||||
// Cleanup old backups
|
|
||||||
const deletedCount = await manager.cleanupOldBackups();
|
const deletedCount = await manager.cleanupOldBackups();
|
||||||
|
const removedClause = deletedCount > 0 ? ` Removed ${deletedCount} old backup(s).` : "";
|
||||||
|
|
||||||
const output = deletedCount > 0
|
const output = (() => {
|
||||||
? `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).`
|
if (backup.centralBackup && "filename" in backup.centralBackup) {
|
||||||
: `Backup created: ${backup.filename} (${formatBytes(backup.size)})`;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -359,9 +483,6 @@ export async function runBackupCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats bytes as human-readable string.
|
|
||||||
*/
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -370,57 +491,32 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
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";
|
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(
|
export async function syncBackupAutomation(
|
||||||
automationStore: import("./automation-store.js").AutomationStore,
|
automationStore: import("./automation-store.js").AutomationStore,
|
||||||
settings: ProjectSettings
|
settings: ProjectSettings
|
||||||
): Promise<import("./automation.js").ScheduledTask | undefined> {
|
): Promise<import("./automation.js").ScheduledTask | undefined> {
|
||||||
const { AutomationStore } = await import("./automation-store.js");
|
const { AutomationStore } = await import("./automation-store.js");
|
||||||
|
|
||||||
// Find existing backup schedule by name
|
|
||||||
const schedules = await automationStore.listSchedules();
|
const schedules = await automationStore.listSchedules();
|
||||||
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
|
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
|
||||||
|
|
||||||
// If backups are disabled, delete existing schedule if present
|
|
||||||
if (!settings.autoBackupEnabled) {
|
if (!settings.autoBackupEnabled) {
|
||||||
if (existingSchedule) {
|
if (existingSchedule) {
|
||||||
await automationStore.deleteSchedule(existingSchedule.id);
|
await automationStore.deleteSchedule(existingSchedule.id);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the cron schedule
|
|
||||||
const schedule = settings.autoBackupSchedule || "0 2 * * *";
|
const schedule = settings.autoBackupSchedule || "0 2 * * *";
|
||||||
if (!AutomationStore.isValidCron(schedule)) {
|
if (!AutomationStore.isValidCron(schedule)) {
|
||||||
throw new Error(`Invalid backup schedule: ${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";
|
const command = "fn backup --create";
|
||||||
|
|
||||||
if (existingSchedule) {
|
if (existingSchedule) {
|
||||||
// Update existing schedule
|
|
||||||
return await automationStore.updateSchedule(existingSchedule.id, {
|
return await automationStore.updateSchedule(existingSchedule.id, {
|
||||||
scheduleType: "custom",
|
scheduleType: "custom",
|
||||||
cronExpression: schedule,
|
cronExpression: schedule,
|
||||||
@@ -428,7 +524,6 @@ export async function syncBackupAutomation(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new schedule
|
|
||||||
return await automationStore.createSchedule({
|
return await automationStore.createSchedule({
|
||||||
name: BACKUP_SCHEDULE_NAME,
|
name: BACKUP_SCHEDULE_NAME,
|
||||||
description: "Automatic database backup based on project settings",
|
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(
|
export async function syncBackupRoutine(
|
||||||
routineStore: import("./routine-store.js").RoutineStore,
|
routineStore: import("./routine-store.js").RoutineStore,
|
||||||
settings: ProjectSettings,
|
settings: ProjectSettings,
|
||||||
@@ -468,12 +556,6 @@ export async function syncBackupRoutine(
|
|||||||
throw new Error(`Invalid backup schedule: ${schedule}`);
|
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 command = "fn backup --create";
|
||||||
const input = {
|
const input = {
|
||||||
name: BACKUP_SCHEDULE_NAME,
|
name: BACKUP_SCHEDULE_NAME,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { join } from "node:path";
|
|||||||
import { mkdirSync, existsSync } from "node:fs";
|
import { mkdirSync, existsSync } from "node:fs";
|
||||||
import type { Statement } from "./db.js";
|
import type { Statement } from "./db.js";
|
||||||
import { resolveGlobalDir } from "./global-settings.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";
|
import type { CentralClaimStore, TaskClaimRow } from "./types.js";
|
||||||
|
|
||||||
// ── JSON Helpers (reused from db.ts) ─────────────────────────────────────
|
// ── JSON Helpers (reused from db.ts) ─────────────────────────────────────
|
||||||
|
|||||||
@@ -510,6 +510,8 @@ export {
|
|||||||
BackupManager,
|
BackupManager,
|
||||||
createBackupManager,
|
createBackupManager,
|
||||||
generateBackupFilename,
|
generateBackupFilename,
|
||||||
|
generateCentralBackupFilename,
|
||||||
|
currentBackupTimestamp,
|
||||||
validateBackupSchedule,
|
validateBackupSchedule,
|
||||||
validateBackupRetention,
|
validateBackupRetention,
|
||||||
validateBackupDir,
|
validateBackupDir,
|
||||||
@@ -518,7 +520,7 @@ export {
|
|||||||
syncBackupRoutine,
|
syncBackupRoutine,
|
||||||
BACKUP_SCHEDULE_NAME,
|
BACKUP_SCHEDULE_NAME,
|
||||||
} from "./backup.js";
|
} from "./backup.js";
|
||||||
export type { BackupInfo, BackupOptions } from "./backup.js";
|
export type { BackupInfo, BackupOptions, BackupFileInfo, BackupPairInfo } from "./backup.js";
|
||||||
export {
|
export {
|
||||||
MemoryBackupManager,
|
MemoryBackupManager,
|
||||||
createMemoryBackupManager,
|
createMemoryBackupManager,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const piModuleMocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
const coreModuleMocks = vi.hoisted(() => ({
|
const coreModuleMocks = vi.hoisted(() => ({
|
||||||
runMemoryBackupCommand: vi.fn(),
|
runMemoryBackupCommand: vi.fn(),
|
||||||
|
runBackupCommand: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../logger.js", () => ({
|
vi.mock("../logger.js", () => ({
|
||||||
@@ -37,6 +38,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
||||||
|
runBackupCommand: coreModuleMocks.runBackupCommand,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,6 +122,10 @@ describe("CronRunner", () => {
|
|||||||
success: true,
|
success: true,
|
||||||
output: "memory backup ok",
|
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(() => {
|
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", () => {
|
describe("memory backup in-process dispatch", () => {
|
||||||
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
||||||
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
||||||
|
|||||||
Reference in New Issue
Block a user