Files
fusion/packages/core/src/backup.ts
gsxdsm c6be0b158b FN-8129: centralize database backup settings
Move database backup policy and scheduling to shared global configuration.

- Split project memory backups from cluster-wide database backup settings.
- Migrate legacy backup values and routines safely into central global storage.
- Schedule and dispatch one shared PostgreSQL backup routine across project engines.

Files changed:
 .changeset/fn-8129-backup-settings-scope-split.md  |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |  10 +-
 packages/cli/src/commands/backup.ts                |   3 +-
 .../__tests__/backup-settings-migration.test.ts    |  50 ++++++
 .../src/__tests__/backup-settings-scope.test.ts    |  27 +++
 packages/core/src/backup-settings-migration.ts     | 188 +++++++++++++++++++++
 packages/core/src/backup.ts                        |  77 +++++----
 packages/core/src/global-routine-store.ts          | 104 ++++++++++++
 packages/core/src/index.gate.ts                    |   6 +-
 packages/core/src/index.ts                         |   6 +-
 .../core/src/postgres/migrations/0000_initial.sql  |  19 +++
 .../postgres/migrations/0015_global_routines.sql   |  19 +++
 packages/core/src/postgres/schema-applier.ts       |  19 ++-
 packages/core/src/postgres/schema/central.ts       |  21 ++-
 packages/core/src/postgres/startup-factory.ts      |  11 ++
 packages/core/src/settings-schema.ts               |  14 +-
 packages/core/src/types.ts                         |  31 +++-
 .../dashboard/app/components/SettingsModal.tsx     |  10 +-
 .../settings/__tests__/section-keys.test.ts        |   1 +
 .../app/components/settings/save-split.ts          |   2 +
 .../search/__tests__/settings-search-index.test.ts |   1 +
 .../settings/search/entries.ts                     |   2 +
 .../app/components/settings/section-keys.ts        |   4 -
 .../settings/sections/BackupsSection.search.ts     |  40 -----
 .../settings/sections/BackupsSection.tsx           | 112 +-----------
 .../sections/DatabaseBackupsSection.search.ts      |  51 ++++++
 .../settings/sections/DatabaseBackupsSection.tsx   | 142 ++++++++++++++++
 .../settings-default-descriptions.test.tsx         |   1 +
 packages/dashboard/src/routes.ts                   |  12 +-
 .../src/routes/register-settings-memory-routes.ts  |  41 ++---
 .../engine/src/__tests__/routine-scheduler.test.ts |  55 +++++-
 packages/engine/src/cron-runner.ts                 |   4 +-
 packages/engine/src/routine-runner.ts              |  67 +++++---
 packages/engine/src/routine-scheduler.ts           |  35 +++-
 35 files changed, 929 insertions(+), 265 deletions(-)

Fusion-Task-Id: FN-8129

Fusion-Task-Lineage: af17f39a-7f1c-40ff-8a4a-cd63895cd532

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-16 16:34:09 -07:00

454 lines
16 KiB
TypeScript

import { join } from "node:path";
import { resolveGlobalDir } from "./global-settings.js";
import { CronExpressionParser } from "cron-parser";
import { getDefaultCentralDbPath } from "./central-db.js";
import { PgBackupManager, type PgBackupPair, type PgDumpResult } from "./postgres/pg-backup.js";
import { resolveBackend } from "./postgres/backend-resolver.js";
import { getActiveEmbeddedRuntimeUrl } from "./postgres/active-backend-registry.js";
import type { Settings } from "./types.js";
/**
* FNXC:SettingsBackups 2026-07-16-14:50:
* Database dumps represent the shared PostgreSQL cluster. Resolve their root once from
* the global settings directory, falling back to the canonical global directory so no
* caller can accidentally create per-project retention sets when that directory is unset.
*/
export function resolveGlobalBackupRoot(store: { getGlobalSettingsDir(): string | undefined }): string {
return store.getGlobalSettingsDir() ?? resolveGlobalDir();
}
export interface BackupFileInfo {
filename: string;
createdAt: string;
size: number;
path: string;
}
export interface BackupInfo extends BackupFileInfo {
centralBackup?:
| BackupFileInfo
| {
skipped: "missing" | "disabled";
}
| {
failed: string;
};
}
export interface BackupPairInfo {
timestamp: string;
project?: BackupFileInfo;
central?: BackupFileInfo;
}
export interface BackupOptions {
backupDir?: string;
retention?: number;
centralDbPath?: string;
includeCentralDb?: boolean;
/**
* FNXC:SqliteFinalRemoval 2026-06-26-00:15:
* PostgreSQL connection string. BackupManager always delegates to
* PgBackupManager (pg_dump/pg_restore). The legacy SQLite file-copy path
* was removed as part of the SQLite-to-PostgreSQL cutover.
*/
connectionString?: string;
}
/**
* FNXC:SqliteFinalRemoval 2026-06-26:
* BackupManager now exclusively delegates to PgBackupManager (pg_dump/pg_restore).
* The legacy SQLite file-copy path (copyLiveDatabase, verifyDatabaseIntegrity via
* PRAGMA quick_check, quarantineCorruptBackup, WAL snapshot copy) was removed as
* part of the SQLite-to-PostgreSQL cutover (VAL-REMOVAL-003/005). All production
* callers receive a connection string via createBackupManager's auto-resolution
* from the runtime backend.
*/
export class BackupManager {
private fusionDir: string;
private backupDir: string;
private retention: number;
private centralDbPath: string;
private includeCentralDb: boolean;
private readonly pgManager: PgBackupManager;
constructor(fusionDir: string, options?: BackupOptions) {
this.fusionDir = fusionDir;
this.backupDir = options?.backupDir ?? ".fusion/backups";
this.retention = options?.retention ?? 7;
this.centralDbPath = options?.centralDbPath ?? join(this.fusionDir, "..", ".fusion", "fusion-central.db");
this.includeCentralDb = options?.includeCentralDb ?? true;
const connectionString = options?.connectionString ?? resolveBackendConnectionString();
if (!connectionString) {
throw new Error(
"BackupManager requires a PostgreSQL connection string. The legacy SQLite file-copy path was removed. " +
"Pass connectionString explicitly or ensure DATABASE_URL / embedded backend is configured.",
);
}
this.pgManager = new PgBackupManager(connectionString, fusionDir, {
backupDir: this.backupDir,
retention: this.retention,
includeCentral: this.includeCentralDb,
});
}
private getBackupDirPath(): string {
return join(this.fusionDir, "..", this.backupDir);
}
async createBackup(): Promise<BackupInfo> {
const pair = await this.pgManager.createBackup();
return pgBackupPairToBackupInfo(pair);
}
async listBackups(): Promise<BackupFileInfo[]> {
const pairs = await this.pgManager.listBackups();
const results: BackupFileInfo[] = [];
for (const pair of pairs) {
if (pair.project) {
results.push(pgDumpResultToBackupFileInfo(pair.project));
}
if (pair.central && "filename" in pair.central) {
results.push(pgDumpResultToBackupFileInfo(pair.central));
}
}
return results;
}
/**
* FNXC:SqliteFinalRemoval 2026-06-26:
* List central backups from the backup directory. PgBackupManager stores
* central dumps alongside project dumps; this filters for central files.
*/
async listCentralBackups(): Promise<BackupFileInfo[]> {
const all = await this.listBackups();
return all.filter((b) => b.filename.includes("-central-") || b.filename.startsWith("fusion-central"));
}
async listBackupPairs(): Promise<BackupPairInfo[]> {
const projects = await this.listBackups();
const centrals = await this.listCentralBackups();
const pairs = new Map<string, BackupPairInfo>();
for (const project of projects) {
const key = getBackupPairKey(project.filename, false);
if (!key) continue;
const existing = pairs.get(key) ?? { timestamp: key };
existing.project = project;
pairs.set(key, existing);
}
for (const central of centrals) {
const key = getBackupPairKey(central.filename, true);
if (!key) continue;
const existing = pairs.get(key) ?? { timestamp: key };
existing.central = central;
pairs.set(key, existing);
}
return [...pairs.values()].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
async cleanupOldBackups(): Promise<number> {
const result = await this.pgManager.cleanupOldBackups();
return result.deleted.length;
}
/**
* FNXC:SqliteFinalRemoval 2026-06-26:
* Restore is delegated to PgBackupManager (pg_restore). The legacy SQLite
* file-copy restore (cp fusion.db, pre-restore snapshots) was removed.
*/
async restoreBackup(
filename: string,
_options?: { createPreRestoreBackup?: boolean; skipCentral?: boolean; centralOnly?: boolean }
): Promise<void> {
await this.pgManager.restoreBackup(filename);
}
}
export function currentBackupTimestamp(): string {
return formatTimestamp(new Date());
}
export function generateBackupFilename(timestamp = currentBackupTimestamp(), counter = 0): string {
return counter > 0 ? `fusion-${timestamp}-${counter}.db` : `fusion-${timestamp}.db`;
}
export function generateCentralBackupFilename(timestamp = currentBackupTimestamp(), counter = 0): string {
return counter > 0 ? `fusion-central-${timestamp}-${counter}.db` : `fusion-central-${timestamp}.db`;
}
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}`;
}
function getBackupPairKey(filename: string, isCentral: boolean): string | null {
const pattern = isCentral
? /^fusion-central(?:-pre-restore)?-(\d{4}-\d{2}-\d{2}-\d{6})(-\d+)?\.db$/
: /^(?:fusion|kb)(?:-pre-restore)?-(\d{4}-\d{2}-\d{2}-\d{6})(-\d+)?\.db$/;
const match = filename.match(pattern);
if (!match) return null;
return `${match[1]}${match[2] ?? ""}`;
}
export function validateBackupSchedule(schedule: string): boolean {
if (!schedule || schedule.trim() === "") {
return false;
}
try {
CronExpressionParser.parse(schedule);
return true;
} catch {
return false;
}
}
export function validateBackupRetention(retention: number): boolean {
return Number.isInteger(retention) && retention >= 1 && retention <= 100;
}
export function validateBackupDir(dir: string): boolean {
if (dir.startsWith("/") || dir.startsWith("\\")) {
return false;
}
if (dir.includes("..")) {
return false;
}
if (/^[a-zA-Z]:/.test(dir)) {
return false;
}
return true;
}
export function createBackupManager(
fusionDir: string,
settings?: Partial<Settings>,
connectionString?: string,
): BackupManager {
let centralDbPath: string;
try {
centralDbPath = getDefaultCentralDbPath();
} catch {
centralDbPath = join(fusionDir, "..", ".fusion", "fusion-central.db");
}
/*
* FNXC:SqliteFinalRemoval 2026-06-26:
* Auto-resolve the connection string from the runtime backend so production
* deployments always delegate to PgBackupManager (VAL-REMOVAL-003). The
* SQLite file-copy fallback was removed; an explicit connectionString
* argument always wins.
*/
const resolvedConnectionString =
connectionString ?? resolveBackendConnectionString();
return new BackupManager(fusionDir, {
backupDir: canonicalizeBackupDir(settings?.autoBackupDir),
retention: settings?.autoBackupRetention,
centralDbPath,
includeCentralDb: true,
connectionString: resolvedConnectionString,
});
}
/**
* FNXC:PostgresBackup 2026-07-16-12:40:
* External deployments resolve directly from DATABASE_URL. Embedded PostgreSQL
* learns its URL only during asynchronous startup, so use the active lifecycle
* registry as the synchronous fallback. It is intentionally undefined before
* boot or after owner shutdown, preserving BackupManager's actionable error.
*/
export function resolveBackendConnectionString(): string | undefined {
const backend = resolveBackend();
if (backend.mode === "external" && backend.runtimeUrl) {
return backend.runtimeUrl;
}
return getActiveEmbeddedRuntimeUrl();
}
/*
* FNXC:SqliteFinalRemoval 2026-06-26-00:30:
* Converters between PgBackupManager result shapes and BackupManager shapes.
*/
function pgDumpResultToBackupFileInfo(result: PgDumpResult): BackupFileInfo {
return {
filename: result.filename,
createdAt: result.createdAt,
size: result.sizeBytes,
path: result.path,
};
}
function pgBackupPairToBackupInfo(pair: PgBackupPair): BackupInfo {
const info: BackupInfo = pair.project
? pgDumpResultToBackupFileInfo(pair.project)
: { filename: "", createdAt: pair.timestamp, size: 0, path: "" };
if (pair.central) {
if ("filename" in pair.central) {
info.centralBackup = pgDumpResultToBackupFileInfo(pair.central);
} else {
info.centralBackup = pair.central; // { skipped: "disabled" | "missing" }
}
}
return info;
}
function canonicalizeBackupDir(dir: string | undefined): string | undefined {
if (dir === ".kb/backups") return ".fusion/backups";
return dir;
}
export async function runBackupCommand(
fusionDir: string,
settings: Settings
): Promise<{ success: boolean; output: string; backupPath?: string; deletedCount?: number }> {
if (settings.autoBackupSchedule && !validateBackupSchedule(settings.autoBackupSchedule)) {
return {
success: false,
output: `Invalid backup schedule: ${settings.autoBackupSchedule}`,
};
}
const manager = createBackupManager(fusionDir, settings);
try {
const backup = await manager.createBackup();
const deletedCount = await manager.cleanupOldBackups();
const removedClause = deletedCount > 0 ? ` Removed ${deletedCount} old backup(s).` : "";
const output = (() => {
if (backup.centralBackup && "filename" in backup.centralBackup) {
const total = backup.size + backup.centralBackup.size;
return `Backup created: ${backup.filename} + ${backup.centralBackup.filename} (${formatBytes(total)}).${removedClause}`.trim();
}
if (backup.centralBackup && "skipped" in backup.centralBackup) {
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Central DB skipped: ${backup.centralBackup.skipped}.${removedClause}`.trim();
}
if (backup.centralBackup && "failed" in backup.centralBackup) {
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}). Central DB backup failed: ${backup.centralBackup.failed}.${removedClause}`.trim();
}
return `Backup created: ${backup.filename} (${formatBytes(backup.size)}).${removedClause}`.trim();
})();
return {
success: true,
output,
backupPath: backup.path,
deletedCount,
};
} catch (err) {
return {
success: false,
output: `Backup failed: ${(err as Error).message}`,
};
}
}
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]}`;
}
export const BACKUP_SCHEDULE_NAME = "Database Backup";
export async function syncBackupAutomation(
automationStore: import("./automation-store.js").AutomationStore,
settings: Settings
): Promise<import("./automation.js").ScheduledTask | undefined> {
const { AutomationStore } = await import("./automation-store.js");
const schedules = await automationStore.listSchedules();
const existingSchedule = schedules.find(s => s.name === BACKUP_SCHEDULE_NAME);
if (!settings.autoBackupEnabled) {
if (existingSchedule) {
await automationStore.deleteSchedule(existingSchedule.id);
}
return undefined;
}
const schedule = settings.autoBackupSchedule || "0 2 * * *";
if (!AutomationStore.isValidCron(schedule)) {
throw new Error(`Invalid backup schedule: ${schedule}`);
}
const command = "fn backup --create";
if (existingSchedule) {
return await automationStore.updateSchedule(existingSchedule.id, {
scheduleType: "custom",
cronExpression: schedule,
command,
enabled: true,
});
} else {
return await automationStore.createSchedule({
name: BACKUP_SCHEDULE_NAME,
description: "Automatic database backup based on project settings",
scheduleType: "custom",
cronExpression: schedule,
command,
enabled: true,
});
}
}
export async function syncBackupRoutine(
routineStore: import("./routine-store.js").RoutineStore,
settings: Settings,
): Promise<import("./routine.js").Routine | undefined> {
const { RoutineStore } = await import("./routine-store.js");
const schedule = settings.autoBackupSchedule || "0 2 * * *";
if (!RoutineStore.isValidCron(schedule)) {
throw new Error(`Invalid backup schedule: ${schedule}`);
}
/* FNXC:SettingsBackups 2026-07-16-16:20: backend-mode routines are central so
independently opened projects cannot each schedule a dump of the shared cluster. */
if (routineStore.asyncLayer) {
const { GlobalRoutineStore } = await import("./global-routine-store.js");
const globalRoutines = new GlobalRoutineStore(routineStore.asyncLayer);
if (!settings.autoBackupEnabled) {
await globalRoutines.deleteByName(BACKUP_SCHEDULE_NAME);
return undefined;
}
return globalRoutines.syncBackup({
name: BACKUP_SCHEDULE_NAME,
description: "Automatic backup of the shared global PostgreSQL cluster",
agentId: "",
trigger: { type: "cron", cronExpression: schedule },
command: "fn backup --create",
enabled: true,
});
}
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 input = {
name: BACKUP_SCHEDULE_NAME,
description: "Automatic backup of the shared global PostgreSQL cluster",
agentId: "", trigger: { type: "cron" as const, cronExpression: schedule },
command: "fn backup --create", enabled: true, scope: "project" as const,
};
if (existingRoutine) return routineStore.updateRoutine(existingRoutine.id, { trigger: input.trigger, command: input.command, enabled: true });
return routineStore.createRoutine(input);
}