498 lines
16 KiB
TypeScript
498 lines
16 KiB
TypeScript
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<BackupInfo> {
|
|
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<BackupInfo[]> {
|
|
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<number> {
|
|
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<void> {
|
|
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<ProjectSettings>
|
|
): 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<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
|
|
// 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<import("./routine.js").Routine | undefined> {
|
|
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);
|
|
}
|