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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user