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 type { ProjectSettings } from "./types.js"; /** * Legacy backup directory default value from the old .kb storage structure. * Projects that were created before the .fusion rename may still have this * value persisted in their config. It is canonicalized to the new default * so that all backup operations use a consistent directory. */ const LEGACY_BACKUP_DIR = ".kb/backups"; /** * Canonicalizes the backup directory from legacy defaults. * Only the exact legacy default alias is transformed — custom paths are preserved. */ function canonicalizeBackupDir(dir: string | undefined): string | undefined { if (dir === LEGACY_BACKUP_DIR) { return ".fusion/backups"; } return dir; } /** * Metadata for a database backup file. */ export interface BackupInfo { /** Filename of the backup (e.g., "fusion-2026-03-31-020000.db") */ 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; } /** * Manages database backup operations including creation, listing, * cleanup of old backups, and restoration. */ export class BackupManager { private kbDir: string; private backupDir: string; private retention: number; /** * Creates a new BackupManager instance. * @param kbDir - Absolute path to the .fusion directory * @param options - Backup configuration options */ constructor(kbDir: string, options?: BackupOptions) { this.kbDir = kbDir; this.backupDir = options?.backupDir ?? ".fusion/backups"; this.retention = options?.retention ?? 7; } /** * Gets the absolute path to the backup directory. */ private getBackupDirPath(): string { // The backupDir is relative to project root, which is parent of kbDir return join(this.kbDir, "..", this.backupDir); } /** * Creates a timestamped backup of the database. * @returns BackupInfo for the newly created backup */ async createBackup(): Promise { const sourcePath = join(this.kbDir, "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++; } // Copy the database file await cp(sourcePath, targetPath, { preserveTimestamps: true }); // Get file stats const stats = await stat(targetPath); return { filename, createdAt: new Date().toISOString(), size: stats.size, path: targetPath, }; } /** * Lists all backup files sorted by creation time (newest first). * @returns Array of BackupInfo objects */ async listBackups(): Promise { const backupDirPath = this.getBackupDirPath(); try { const files = await readdir(backupDirPath); const backups: BackupInfo[] = []; for (const filename of files) { // Match both new 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-YYYY-MM-DD-HHmmss.db, kb-YYYY-MM-DD-HHmmss-N.db, // kb-pre-restore-YYYY-MM-DD-HHmmss.db 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, supporting both fusion-* and kb-* prefixes // 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[2]}-${match[3]}-${match[4]}T${match[5]}:${match[6]}:${match[7]}Z` : stats.mtime.toISOString(); backups.push({ filename, createdAt, 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); }); } 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 cleanupOldBackups(): Promise { const backups = await this.listBackups(); // Filter to only regular backups (not 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; return a.filename.localeCompare(b.filename); }); const toDelete = sorted.slice(0, sorted.length - this.retention); let deletedCount = 0; for (const backup of toDelete) { try { await unlink(backup.path); deletedCount++; } catch { // Ignore deletion 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 } ): Promise { const backupDirPath = this.getBackupDirPath(); const sourcePath = join(backupDirPath, filename); const targetPath = join(this.kbDir, "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 preRestorePath = join(backupDirPath, preRestoreFilename); await mkdir(backupDirPath, { recursive: true }); await cp(targetPath, preRestorePath, { preserveTimestamps: true }); } // Restore the backup await cp(sourcePath, targetPath, { preserveTimestamps: true }); } } /** * Generates a backup filename with timestamp. * Format: fusion-YYYY-MM-DD-HHmmss.db */ export function generateBackupFilename(): string { return `fusion-${formatTimestamp(new Date())}.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"); const day = String(date.getUTCDate()).padStart(2, "0"); const hours = String(date.getUTCHours()).padStart(2, "0"); const minutes = String(date.getUTCMinutes()).padStart(2, "0"); const seconds = String(date.getUTCSeconds()).padStart(2, "0"); 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 */ export function validateBackupSchedule(schedule: string): boolean { if (!schedule || schedule.trim() === "") { return false; } try { CronExpressionParser.parse(schedule); return true; } catch { return false; } } /** * 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. * Applies defensive canonicalization for the legacy backup directory default. * @param kbDir - Absolute path to the .fusion directory * @param settings - Project settings containing backup configuration * @returns Configured BackupManager instance */ export function createBackupManager( kbDir: string, settings?: Partial ): BackupManager { return new BackupManager(kbDir, { backupDir: canonicalizeBackupDir(settings?.autoBackupDir), retention: settings?.autoBackupRetention, }); } /** * 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 kbDir - Absolute path to the .fusion directory * @param settings - Project settings * @returns Result of the backup operation */ export async function runBackupCommand( kbDir: 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, output: `Invalid backup schedule: ${settings.autoBackupSchedule}`, }; } // Create backup manager with settings const manager = createBackupManager(kbDir, settings); try { // Create the backup const backup = await manager.createBackup(); // Cleanup old backups const deletedCount = await manager.cleanupOldBackups(); const output = deletedCount > 0 ? `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Removed ${deletedCount} old backup(s).` : `Backup created: ${backup.filename} (${formatBytes(backup.size)})`; return { success: true, output, backupPath: backup.path, deletedCount, }; } catch (err) { return { success: false, output: `Backup failed: ${(err as Error).message}`, }; } } /** * Formats bytes as human-readable string. */ function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); 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 { 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 // The CLI command will be: fn backup --auto // The --auto flag indicates this is an automated run (can add special handling if needed) const command = "fn backup --create"; if (existingSchedule) { // Update existing schedule return await automationStore.updateSchedule(existingSchedule.id, { scheduleType: "custom", cronExpression: schedule, command, enabled: true, }); } else { // Create new schedule return await automationStore.createSchedule({ name: BACKUP_SCHEDULE_NAME, description: "Automatic database backup based on project settings", scheduleType: "custom", cronExpression: schedule, command, enabled: true, }); } } /** * 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, ): Promise { const { RoutineStore } = await import("./routine-store.js"); const routines = await routineStore.listRoutines(); const existingRoutine = routines.find((routine) => routine.name === BACKUP_SCHEDULE_NAME); if (!settings.autoBackupEnabled) { if (existingRoutine) { await routineStore.deleteRoutine(existingRoutine.id); } return undefined; } const schedule = settings.autoBackupSchedule || "0 2 * * *"; if (!RoutineStore.isValidCron(schedule)) { throw new Error(`Invalid backup schedule: ${schedule}`); } const command = "fn backup --create"; const input = { name: BACKUP_SCHEDULE_NAME, description: "Automatic database backup based on project settings", agentId: "", trigger: { type: "cron" as const, cronExpression: schedule }, command, enabled: true, scope: "project" as const, }; if (existingRoutine) { return await routineStore.updateRoutine(existingRoutine.id, { trigger: input.trigger, command, enabled: true, }); } return await routineStore.createRoutine(input); }