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>
189 lines
8.3 KiB
TypeScript
189 lines
8.3 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { BACKUP_SCHEDULE_NAME, validateBackupSchedule } from "./backup.js";
|
|
import { GlobalRoutineStore } from "./global-routine-store.js";
|
|
import type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict, GlobalSettings } from "./types.js";
|
|
import type { GlobalSettingsStore } from "./global-settings.js";
|
|
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
|
|
|
export const BACKUP_SETTINGS_MIGRATION_KEY = "backupSettingsProjectToGlobal:v1";
|
|
export const BACKUP_SETTING_KEYS = [
|
|
"autoBackupEnabled",
|
|
"autoBackupSchedule",
|
|
"autoBackupRetention",
|
|
"autoBackupDir",
|
|
] as const;
|
|
|
|
export type BackupSettingKey = typeof BACKUP_SETTING_KEYS[number];
|
|
export type { BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js";
|
|
|
|
interface MigrationSnapshot {
|
|
phase: "snapshot" | "global-write" | "routine-sync" | "cleanup" | "completed";
|
|
values: Partial<Record<BackupSettingKey, unknown>>;
|
|
conflicts: BackupSettingsMigrationConflict[];
|
|
}
|
|
|
|
function sameValue(left: unknown, right: unknown): boolean {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
/** Derive the durable adoption/conflict decision without mutating either store. */
|
|
export function planBackupSettingsMigration(
|
|
projectSettings: Array<{ projectId: string; settings: Record<string, unknown> }>,
|
|
globalRaw: Record<string, unknown>,
|
|
recordedAt = new Date().toISOString(),
|
|
): Omit<MigrationSnapshot, "phase"> {
|
|
const values: Partial<Record<BackupSettingKey, unknown>> = {};
|
|
const conflicts: BackupSettingsMigrationConflict[] = [];
|
|
for (const key of BACKUP_SETTING_KEYS) {
|
|
const candidates: BackupSettingsMigrationCandidate[] = [];
|
|
if (Object.prototype.hasOwnProperty.call(globalRaw, key)) {
|
|
candidates.push({ source: "global", value: globalRaw[key] });
|
|
}
|
|
for (const project of projectSettings) {
|
|
if (Object.prototype.hasOwnProperty.call(project.settings, key)) {
|
|
candidates.push({ source: "project", projectId: project.projectId, value: project.settings[key] });
|
|
}
|
|
}
|
|
if (candidates.length === 0) continue;
|
|
const unique = candidates.filter((candidate, index) =>
|
|
candidates.findIndex((other) => sameValue(other.value, candidate.value)) === index,
|
|
);
|
|
if (unique.length === 1) {
|
|
values[key] = candidates[0]!.value;
|
|
} else {
|
|
const globalCandidate = candidates.find((candidate) => candidate.source === "global");
|
|
if (globalCandidate) values[key] = globalCandidate.value;
|
|
conflicts.push({ key, candidates, recordedAt });
|
|
}
|
|
}
|
|
return { values, conflicts };
|
|
}
|
|
|
|
async function readMarker(tx: DbTransaction): Promise<MigrationSnapshot | null> {
|
|
const rows = await tx.execute(sql`
|
|
SELECT value FROM central.__meta WHERE key = ${BACKUP_SETTINGS_MIGRATION_KEY}
|
|
`) as unknown as Array<{ value: string | null }>;
|
|
const value = rows[0]?.value;
|
|
if (!value) return null;
|
|
try { return JSON.parse(value) as MigrationSnapshot; } catch { return null; }
|
|
}
|
|
|
|
async function writeMarker(tx: DbTransaction, snapshot: MigrationSnapshot): Promise<void> {
|
|
await tx.execute(sql`
|
|
INSERT INTO central.__meta (key, value) VALUES (${BACKUP_SETTINGS_MIGRATION_KEY}, ${JSON.stringify(snapshot)})
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
`);
|
|
}
|
|
|
|
async function withMigrationLock<T>(layer: AsyncDataLayer, action: (tx: DbTransaction) => Promise<T>): Promise<T> {
|
|
return layer.transactionImmediate(async (tx) => {
|
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('fusion:backup-settings-project-to-global'))`);
|
|
return action(tx);
|
|
});
|
|
}
|
|
|
|
async function syncMigratedBackupRoutine(layer: AsyncDataLayer, settings: GlobalSettings): Promise<void> {
|
|
const globalRoutines = new GlobalRoutineStore(layer);
|
|
if (!settings.autoBackupEnabled) {
|
|
await globalRoutines.deleteByName(BACKUP_SCHEDULE_NAME);
|
|
return;
|
|
}
|
|
const schedule = settings.autoBackupSchedule || "0 2 * * *";
|
|
if (!validateBackupSchedule(schedule)) throw new Error(`Invalid backup schedule: ${schedule}`);
|
|
await 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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* FNXC:SettingsBackups 2026-07-16-22:00:
|
|
* Database-backup keys moved from project config to global settings because one PostgreSQL
|
|
* cluster contains every project. Each database marker read/write uses the transaction handle
|
|
* that owns the advisory lock, which keeps a poolMax:1 startup connection from deadlocking.
|
|
* The replacement central routine is synchronized and marker-recorded before legacy project
|
|
* routines and settings are removed, so an enabled migrated backup never has a no-schedule gap.
|
|
*/
|
|
export async function migrateBackupSettingsToGlobalOnce(
|
|
layer: AsyncDataLayer | null,
|
|
globalSettingsStore: GlobalSettingsStore,
|
|
): Promise<void> {
|
|
if (!layer) return;
|
|
let snapshot = await withMigrationLock(layer, async (tx) => {
|
|
const existing = await readMarker(tx);
|
|
if (existing) return existing;
|
|
const projects = await tx.execute(sql`SELECT id FROM central.projects`) as unknown as Array<{ id: string }>;
|
|
const rows = await tx.execute(sql`SELECT project_id, settings FROM project.config`) as unknown as Array<{ project_id: string; settings: Record<string, unknown> | null }>;
|
|
const configs = projects.map((project) => ({
|
|
projectId: project.id,
|
|
settings: rows.find((row) => row.project_id === project.id)?.settings ?? {},
|
|
}));
|
|
const created: MigrationSnapshot = { phase: "snapshot", ...planBackupSettingsMigration(configs, await globalSettingsStore.readRaw()) };
|
|
await writeMarker(tx, created);
|
|
return created;
|
|
});
|
|
if (snapshot.phase === "completed") return;
|
|
|
|
if (snapshot.phase === "snapshot") {
|
|
await globalSettingsStore.updateSettings({
|
|
...snapshot.values,
|
|
backupSettingsMigrationConflicts: snapshot.conflicts.length ? snapshot.conflicts : null,
|
|
} as Partial<GlobalSettings> & Record<string, unknown>);
|
|
snapshot = await withMigrationLock(layer, async (tx) => {
|
|
const current = await readMarker(tx);
|
|
if (!current || current.phase !== "snapshot") return current ?? snapshot;
|
|
const advanced = { ...current, phase: "global-write" as const };
|
|
await writeMarker(tx, advanced);
|
|
return advanced;
|
|
});
|
|
}
|
|
|
|
if (snapshot.phase === "global-write") {
|
|
await syncMigratedBackupRoutine(layer, await globalSettingsStore.getSettings());
|
|
snapshot = await withMigrationLock(layer, async (tx) => {
|
|
const current = await readMarker(tx);
|
|
if (!current || current.phase !== "global-write") return current ?? snapshot;
|
|
const advanced = { ...current, phase: "routine-sync" as const };
|
|
await writeMarker(tx, advanced);
|
|
return advanced;
|
|
});
|
|
}
|
|
|
|
if (snapshot.phase === "routine-sync") {
|
|
snapshot = await withMigrationLock(layer, async (tx) => {
|
|
const current = await readMarker(tx);
|
|
if (!current || current.phase !== "routine-sync") return current ?? snapshot;
|
|
await tx.execute(sql.raw(`UPDATE project.config SET settings = COALESCE(settings, '{}'::jsonb) - 'autoBackupEnabled' - 'autoBackupSchedule' - 'autoBackupRetention' - 'autoBackupDir'`));
|
|
await tx.execute(sql`DELETE FROM project.routines WHERE name = ${BACKUP_SCHEDULE_NAME}`);
|
|
const advanced = { ...current, phase: "cleanup" as const };
|
|
await writeMarker(tx, advanced);
|
|
return advanced;
|
|
});
|
|
}
|
|
|
|
if (snapshot.phase === "cleanup") {
|
|
await withMigrationLock(layer, async (tx) => {
|
|
const current = await readMarker(tx);
|
|
if (current?.phase === "cleanup") await writeMarker(tx, { ...current, phase: "completed" });
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function resolveBackupSettingsMigrationConflict(
|
|
globalSettingsStore: GlobalSettingsStore,
|
|
key: BackupSettingKey,
|
|
candidate: BackupSettingsMigrationCandidate,
|
|
): Promise<void> {
|
|
const settings = await globalSettingsStore.getSettings();
|
|
const conflicts = settings.backupSettingsMigrationConflicts ?? [];
|
|
const next = conflicts.filter((conflict) => conflict.key !== key);
|
|
await globalSettingsStore.updateSettings({
|
|
[key]: candidate.value,
|
|
backupSettingsMigrationConflicts: next.length ? next : null,
|
|
} as Partial<GlobalSettings> & Record<string, unknown>);
|
|
}
|