feat(backup): support cross-platform background database backups

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-09-23 12:29:56 +08:00
1 parent 250e0c0a10
commit 2f960f104c
56 files changed
+4195 -962

No files matched your search

@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, expect, it, vi } from "vitest";
import type { SqlFilePreview } from "@/lib/backend/api";
const mocks = vi.hoisted(() => ({
store: {
connections: [],
sqlFileSource: null as { connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null,
},
}));
vi.mock("@/stores/connectionStore", async () => {
const { reactive } = await import("vue");
mocks.store = reactive(mocks.store);
return { useConnectionStore: () => mocks.store };
});
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
import { useDialogSources } from "@/composables/useDialogSources";
let app: App | undefined;
afterEach(() => {
app?.unmount();
document.body.innerHTML = "";
});
it("hands prepared Web previews to the dialog, clears them on close, and preserves desktop paths", async () => {
let dialogs!: ReturnType<typeof useDialogSources>;
app = createApp(
defineComponent({
setup() {
dialogs = useDialogSources();
return () => h("div");
},
}),
);
app.mount(document.body.appendChild(document.createElement("div")));
const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", sizeBytes: 9, canExecuteWithoutSelectedDatabase: true, cleanupToken: "restore-token" };
mocks.store.sqlFileSource = { connectionId: "mysql", database: "app", preview };
await nextTick();
expect(dialogs.showSqlFileDialog.value).toBe(true);
expect(dialogs.sqlFilePrefillPreview.value).toEqual(preview);
expect(dialogs.sqlFilePrefillFilePath.value).toBe("");
expect(mocks.store.sqlFileSource).toBeNull();
dialogs.showSqlFileDialog.value = false;
await nextTick();
expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
mocks.store.sqlFileSource = { connectionId: "mysql", database: "app", filePath: "/local/backup.sql" };
await nextTick();
expect(dialogs.sqlFilePrefillFilePath.value).toBe("/local/backup.sql");
expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
});
@@ -1,310 +1,190 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../lib/backup/scheduledDatabaseBackup";
import type { DatabaseBackupSnapshot } from "../../lib/backup/backgroundDatabaseBackup";
const mocks = vi.hoisted(() => ({
connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
listDatabases: vi.fn(async () => [{ name: "app" }]),
beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
rollbackSnapshot: vi.fn(async () => {}),
deleteFiles: vi.fn(async () => 0),
cancelExport: vi.fn(async () => {}),
clearExportCancellation: vi.fn(async () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
command: vi.fn(),
legacySchedules: vi.fn(() => []),
legacyRuns: vi.fn(() => []),
desktop: true,
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
markTaskCancelling: vi.fn(),
restoreTaskRunning: vi.fn(),
nextId: 0,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
clearDatabaseExportCancellation: mocks.clearExportCancellation,
}));
vi.mock("@/lib/export/databaseExport", () => ({
buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
generateDatabaseExportId: () => `run-${++mocks.nextId}`,
runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: () => mocks.connection,
ensureConnected: vi.fn(async () => {}),
initFromDisk: vi.fn(async () => {}),
}),
cancelTask: vi.fn(),
register: vi.fn(),
unregister: vi.fn(),
}));
vi.mock("vue", async () => ({ ...(await vi.importActual("vue")), onMounted: vi.fn(), onUnmounted: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ databaseBackupCommand: mocks.command }));
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/lib/backup/scheduledDatabaseBackup", () => ({ readDatabaseBackupSchedules: mocks.legacySchedules, readDatabaseBackupRuns: mocks.legacyRuns }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
markDatabaseExportTaskCancelling: mocks.cancelTask,
registerTaskCancelHandler: mocks.register,
unregisterTaskCancelHandler: mocks.unregister,
}),
}));
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
let snapshot: DatabaseBackupSnapshot;
function run(status: DatabaseBackupRun["status"] = "running"): DatabaseBackupRun {
return { id: "run-1", scheduleId: "schedule-1", scheduleName: "Daily", connectionId: "mysql", connectionName: "Local", destinationDirectory: "/backups", source: "scheduled", trigger: "manual", status, startedAt: "2026-09-12T00:00:00Z", files: [], progressPercent: 45 };
}
async function create() {
return (await import("../useScheduledDatabaseBackups")).useScheduledDatabaseBackups();
}
const config: DatabaseBackupExecutionConfig = {
connectionId: "mysql-1",
databases: ["app"],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "/backups",
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.desktop = true;
snapshot = { schedules: [], runs: [], migrated: true, heartbeat: "2026-09-12T00:00:00Z", destinationRoot: null };
mocks.command.mockImplementation(async (command) => (command.action === "snapshot" ? structuredClone(snapshot) : null));
});
describe("useScheduledDatabaseBackups one-shot execution", () => {
it("deletes multiple completed runs and all of their backup files", async () => {
const backup = useScheduledDatabaseBackups();
const firstRun = {
id: "delete-many-first",
scheduleName: "First backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "success",
startedAt: "2026-09-08T01:00:00.000Z",
files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
} satisfies DatabaseBackupRun;
const secondRun = {
id: "delete-many-second",
scheduleName: "Second backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "failed",
startedAt: "2026-09-08T02:00:00.000Z",
files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
} satisfies DatabaseBackupRun;
backup.runs.value.push(firstRun, secondRun);
mocks.deleteFiles.mockClear();
try {
await backup.deleteRuns([firstRun.id, secondRun.id]);
expect(mocks.deleteFiles).toHaveBeenCalledWith(["/backups/first.sql", "/backups/second.sql"], ["/backups"]);
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: secondRun.id }));
} finally {
backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
}
describe("backend-owned scheduled database backups", () => {
it("reconnects to running backups without starting another export", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
await backups.processDueSchedules();
expect(backups.activeRunIds.has("run-1")).toBe(true);
expect(backups.activeScheduleIds.has("schedule-1")).toBe(true);
expect(mocks.addTask).toHaveBeenCalledTimes(1);
expect(mocks.command.mock.calls.every(([c]) => c.action === "snapshot")).toBe(true);
expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Running", overallPercent: 45 }));
});
it("keeps a shared backup path when deleting one of its history records", async () => {
const backup = useScheduledDatabaseBackups();
const sharedPath = "/backups/before-migration__app.sql";
const firstRun = {
id: "shared-path-first",
scheduleName: "First backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "scheduled",
source: "scheduled",
status: "success",
startedAt: "2026-09-08T01:00:00.000Z",
files: [{ displayName: "first.sql", filePath: sharedPath }],
} satisfies DatabaseBackupRun;
const secondRun = { ...firstRun, id: "shared-path-second", startedAt: "2026-09-08T02:00:00.000Z" } satisfies DatabaseBackupRun;
backup.runs.value.push(firstRun, secondRun);
mocks.deleteFiles.mockClear();
try {
await backup.deleteRun(firstRun.id);
expect(mocks.deleteFiles).not.toHaveBeenCalled();
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
expect(backup.runs.value).toContainEqual(expect.objectContaining({ id: secondRun.id }));
} finally {
backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
}
it("reflects completion from a worker that runs independently of the UI", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
snapshot.runs[0] = { ...run("success"), progressPercent: 100 };
await backups.processDueSchedules();
expect(backups.activeRunIds.size).toBe(0);
expect(backups.activeScheduleIds.size).toBe(0);
expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Done", overallPercent: 100 }));
});
it("executes one-shot backups through the shared exporter without a schedule", async () => {
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
});
const backup = useScheduledDatabaseBackups();
const scheduleCountBefore = backup.schedules.value.length;
const run = await backup.runOneShot(config, "One-time backup");
expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
expect(run).not.toHaveProperty("nextRunAt");
expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
expect(mocks.runDatabaseExport).toHaveBeenCalledWith(expect.objectContaining({ preventOverwrite: true }), expect.any(Function));
expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
it("waits for durable cancellation acceptance before showing cancellation", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
mocks.command.mockRejectedValueOnce(new Error("offline"));
await expect(backups.cancelRun("run-1")).rejects.toThrow("offline");
expect(backups.cancellingRunIds.size).toBe(0);
mocks.command.mockResolvedValueOnce(true);
expect(await backups.cancelRun("run-1")).toBe(true);
expect(backups.cancellingRunIds.has("run-1")).toBe(true);
expect(mocks.command).toHaveBeenLastCalledWith({ action: "cancel", id: "run-1" });
});
it("rejects a repeated one-shot when its custom file name resolves to an existing path", async () => {
mocks.runDatabaseExport.mockImplementation(async (_request: unknown, onProgress: (progress: unknown) => void) => {
const progress = { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
onProgress(progress);
return progress;
});
const backup = useScheduledDatabaseBackups();
const customConfig = { ...config, fileNamePattern: "before-migration" };
let runIds: string[] = [];
try {
const firstRun = await backup.runOneShot(customConfig, "One-time backup");
runIds = [firstRun?.id].filter((id): id is string => Boolean(id));
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
const secondRun = await backup.runOneShot(customConfig, "One-time backup");
if (secondRun?.id) runIds.push(secondRun.id);
expect(firstRun).toEqual(expect.objectContaining({ status: "success" }));
expect(secondRun).toEqual(expect.objectContaining({ status: "failed", files: [] }));
expect(secondRun?.error).toContain("already used by another run");
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
const ids = new Set(runIds);
backup.runs.value = backup.runs.value.filter((run) => !ids.has(run.id));
}
it("deletes by server record IDs, never by client supplied file paths", async () => {
snapshot.runs = [run("success")];
const backups = await create();
await backups.processDueSchedules();
await backups.deleteRuns(["run-1"]);
expect(mocks.command).toHaveBeenCalledWith({ action: "deleteRuns", ids: ["run-1"] });
});
it("does not delete a pre-existing file when the exporter rejects overwrite", async () => {
mocks.runDatabaseExport.mockRejectedValueOnce(new Error("Backup file already exists: /backups/before-migration__app.sql"));
const backup = useScheduledDatabaseBackups();
const result = await backup.runOneShot({ ...config, fileNamePattern: "before-migration" }, "One-time backup");
expect(result).toEqual(expect.objectContaining({ status: "failed", files: [] }));
expect(mocks.deleteFiles).not.toHaveBeenCalled();
it("preserves optimistic concurrency timestamps and saved time zones", async () => {
const schedule = { id: "schedule-1", name: "Daily", timeZone: "Asia/Tokyo", updatedAt: "2026-09-01T00:00:00Z" } as DatabaseBackupSchedule;
const backups = await create();
await backups.saveSchedule(schedule);
expect(mocks.command).toHaveBeenCalledWith({ action: "save", schedule });
});
it("prevents a second one-shot while one is already running", async () => {
const backup = useScheduledDatabaseBackups();
const activeRun = {
id: "active-one-shot",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: new Date().toISOString(),
files: [],
} satisfies DatabaseBackupRun;
backup.runs.value.push(activeRun);
backup.activeRunIds.add(activeRun.id);
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
try {
expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
backup.activeRunIds.delete(activeRun.id);
backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
}
it("does not overwrite local state after a failed save", async () => {
snapshot.schedules = [{ id: "schedule-1", name: "Original" } as DatabaseBackupSchedule];
const backups = await create();
await backups.processDueSchedules();
mocks.command.mockRejectedValueOnce(new Error("schedule changed"));
await expect(backups.saveSchedule({ ...snapshot.schedules[0], name: "New" })).rejects.toThrow("schedule changed");
expect(backups.schedules.value[0].name).toBe("Original");
});
it("cancels an active one-shot through the shared export cancellation path", async () => {
mocks.cancelExport.mockClear();
mocks.deleteFiles.mockClear();
let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
mocks.runDatabaseExport.mockImplementationOnce(
(_request: unknown, onProgress: (progress: unknown) => void) =>
new Promise((resolve) => {
resolveExport = (progress) => {
onProgress(progress);
resolve(progress);
};
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)], ["/backups"]);
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
});
it("stops after cancelling during database discovery without creating a snapshot", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveDatabases!: (databases: Array<{ name: string }>) => void;
mocks.listDatabases.mockImplementationOnce(
it("reloads after a save even when an older poll finishes later", async () => {
const backups = await create();
const oldSnapshot = structuredClone(snapshot);
let release!: (value: DatabaseBackupSnapshot) => void;
mocks.command.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDatabases = resolve;
new Promise<DatabaseBackupSnapshot>((resolve) => {
release = resolve;
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
resolveDatabases([{ name: "app" }]);
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.beginSnapshot).not.toHaveBeenCalled();
const polling = backups.processDueSchedules();
const schedule = { id: "schedule-1", name: "New", timeZone: "Asia/Shanghai" } as DatabaseBackupSchedule;
mocks.command.mockImplementation(async (command) => {
if (command.action === "save") {
snapshot.schedules = [schedule];
return schedule;
}
return structuredClone(snapshot);
});
const saving = backups.saveSchedule(schedule);
release(oldSnapshot);
await polling;
await saving;
expect(backups.schedules.value).toEqual([schedule]);
expect(mocks.command.mock.calls.filter(([command]) => command.action === "snapshot")).toHaveLength(2);
});
it("signals the backup run while snapshot creation is pending", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
mocks.beginSnapshot.mockImplementationOnce(
it("does not lose a newly queued run to a stale in-flight snapshot", async () => {
const backups = await create();
const oldSnapshot = structuredClone(snapshot);
let release!: (value: DatabaseBackupSnapshot) => void;
mocks.command.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
new Promise<DatabaseBackupSnapshot>((resolve) => {
release = resolve;
}),
);
const polling = backups.processDueSchedules();
mocks.command.mockImplementation(async (command) => {
if (command.action === "run") {
snapshot.runs = [run("success")];
return run();
}
return structuredClone(snapshot);
});
const running = backups.runSchedule("schedule-1");
release(oldSnapshot);
await polling;
expect(await running).toEqual(run("success"));
});
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
it("marks migration only after the backend accepts it", async () => {
snapshot.migrated = false;
const backups = await create();
mocks.command.mockImplementation(async (command) => {
if (command.action === "migrate") throw new Error("database locked");
return structuredClone(snapshot);
});
await expect(backups.processDueSchedules()).rejects.toThrow("database locked");
expect(mocks.legacySchedules).toHaveBeenCalledTimes(1);
expect(backups.error.value).toBe("database locked");
mocks.command.mockImplementation(async (command) => {
if (command.action === "migrate") {
snapshot.migrated = true;
return null;
}
return structuredClone(snapshot);
});
await backups.processDueSchedules();
await backups.processDueSchedules();
expect(mocks.legacySchedules).toHaveBeenCalledTimes(2);
});
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
resolveSnapshot({ sessionId: "snapshot-pending" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
it("never migrates browser-local paths into a server", async () => {
mocks.desktop = false;
snapshot.migrated = false;
const backups = await create();
await backups.processDueSchedules();
expect(mocks.legacySchedules).not.toHaveBeenCalled();
expect(mocks.legacyRuns).not.toHaveBeenCalled();
expect(mocks.command).toHaveBeenCalledWith({ action: "migrate", migration: { schedules: [], runs: [] } });
});
});
@@ -1,4 +1,5 @@
import { ref, watch } from "vue";
import type { SqlFilePreview } from "@/lib/backend/api";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -61,6 +62,7 @@ const dataCompareSessionId = ref<string | null>(null);
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const sqlFilePrefillFilePath = ref("");
const sqlFilePrefillPreview = ref<SqlFilePreview>();
const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
@@ -195,6 +197,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId.value = v.connectionId;
sqlFilePrefillDatabase.value = v.database;
sqlFilePrefillFilePath.value = v.filePath ?? "";
sqlFilePrefillPreview.value = v.preview;
showSqlFileDialog.value = true;
connectionStore.sqlFileSource = null;
}
@@ -207,7 +210,10 @@ export function useDialogSources() {
// when stale (they only preselect dropdowns), but a stale path triggers an
// async file read + preview render — a visible side effect.
watch(showSqlFileDialog, (open) => {
if (!open) sqlFilePrefillFilePath.value = "";
if (!open) {
sqlFilePrefillFilePath.value = "";
sqlFilePrefillPreview.value = undefined;
}
});
watch(
@@ -577,6 +583,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId,
sqlFilePrefillDatabase,
sqlFilePrefillFilePath,
sqlFilePrefillPreview,
diagramPrefillConnectionId,
diagramPrefillDatabase,
diagramPrefillSchema,
@@ -2,567 +2,169 @@ import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import * as api from "@/lib/backend/api";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal } from "@/lib/export/databaseExport";
import {
DATABASE_BACKUP_CONFIG_CHANGED_EVENT,
DATABASE_BACKUP_RUNS_STORAGE_KEY,
DATABASE_BACKUP_SCHEDULES_STORAGE_KEY,
DatabaseBackupConnectionQueue,
databaseBackupAggregateExportStatus,
databaseBackupFilePath,
databaseBackupRunDirectory,
databaseBackupProgressPercent,
databaseBackupRunsToPrune,
databaseBackupScheduleIsDue,
databaseBackupTableNamesAreCaseSensitive,
nextDatabaseBackupRunAt,
normalizeDatabaseBackupSchedule,
readDatabaseBackupRuns,
readDatabaseBackupSchedules,
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
toDatabaseBackupExecutionConfig,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupExecutionConfig,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunSource,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
} from "@/lib/backup/scheduledDatabaseBackup";
import { readDatabaseBackupRuns, readDatabaseBackupSchedules, type DatabaseBackupExecutionConfig, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import type { DatabaseBackupSnapshot } from "@/lib/backup/backgroundDatabaseBackup";
import { useExportTracker } from "@/composables/useExportTracker";
const SCHEDULER_INTERVAL_MS = 30_000;
const databaseBackupConnectionQueue = new DatabaseBackupConnectionQueue();
const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
const schedules = ref<DatabaseBackupSchedule[]>([]);
const runs = ref<DatabaseBackupRun[]>([]);
const activeScheduleIds = reactive(new Set<string>());
const activeRunIds = reactive(new Set<string>());
const cancellingRunIds = reactive(new Set<string>());
const activeExportIds = new Map<string, string>();
const activeBackupPaths = new Set<string>();
const cancellationRequested = new Set<string>();
const heartbeat = ref<string | null>(null);
const destinationRoot = ref<string | null>(null);
const error = ref("");
const tracked = new Set<string>();
let refreshing: Promise<void> | undefined;
let subscribers = 0;
let timer: ReturnType<typeof setInterval> | undefined;
let schedulerTimer: ReturnType<typeof window.setInterval> | undefined;
let schedulerRegistered = false;
let processingDueSchedules = false;
export function useScheduledDatabaseBackups(_options: { scheduler?: boolean } = {}) {
const tracker = useExportTracker();
const activeRuns = computed(() => runs.value.filter((run) => run.status === "running"));
function emitConfigChanged() {
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(DATABASE_BACKUP_CONFIG_CHANGED_EVENT));
}
function persistSchedules() {
writeDatabaseBackupSchedules(schedules.value);
emitConfigChanged();
}
function persistRuns() {
writeDatabaseBackupRuns(runs.value);
}
function replaceRun(run: DatabaseBackupRun, persist = true) {
runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)];
if (persist) persistRuns();
}
function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = true): DatabaseBackupRun | null {
const existing = runs.value.find((run) => run.id === runId);
if (!existing) return null;
const updated = { ...existing, ...patch };
replaceRun(updated, persist);
return updated;
}
function backupPathIsReferencedByAnotherRun(filePath: string, excludedRunIds: ReadonlySet<string>): boolean {
return runs.value.some((run) => !excludedRunIds.has(run.id) && run.files.some((file) => file.filePath === filePath));
}
function uniqueUnreferencedBackupPaths(paths: readonly string[], excludedRunIds: ReadonlySet<string>): string[] {
return [...new Set(paths)].filter((filePath) => !backupPathIsReferencedByAnotherRun(filePath, excludedRunIds));
}
function backupRootsForRuns(selectedRuns: readonly DatabaseBackupRun[], fallback?: string): string[] {
return [...new Set([...selectedRuns.map((run) => run.destinationDirectory), fallback].filter((root): root is string => Boolean(root)))];
}
type FinishedDatabaseBackupRun = Omit<DatabaseBackupRun, "status"> & { status: Exclude<DatabaseBackupRunStatus, "running"> };
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
}
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
const connectionStore = useConnectionStore();
const { addDatabaseExportTask, markDatabaseExportTaskCancelling, registerTaskCancelHandler, restoreDatabaseExportTaskRunning, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
function saveSchedule(value: DatabaseBackupSchedule): DatabaseBackupSchedule {
const now = new Date();
const existing = schedules.value.find((schedule) => schedule.id === value.id);
const normalized = normalizeDatabaseBackupSchedule(
{
...value,
createdAt: existing?.createdAt ?? value.createdAt,
updatedAt: now.toISOString(),
},
now,
);
if (!normalized) throw new Error("Invalid database backup schedule");
const timingChanged = !existing || existing.frequency !== normalized.frequency || existing.intervalHours !== normalized.intervalHours || existing.timeOfDay !== normalized.timeOfDay || existing.weekday !== normalized.weekday || (!existing.enabled && normalized.enabled);
if (timingChanged) normalized.nextRunAt = nextDatabaseBackupRunAt(normalized, now).toISOString();
if (existing) {
normalized.lastRunAt = existing.lastRunAt;
normalized.lastRunStatus = existing.lastRunStatus;
}
schedules.value = [...schedules.value.filter((schedule) => schedule.id !== normalized.id), normalized].sort((left, right) => left.name.localeCompare(right.name));
persistSchedules();
return normalized;
}
function setScheduleEnabled(scheduleId: string, enabled: boolean) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
saveSchedule({ ...schedule, enabled });
}
function deleteSchedule(scheduleId: string): boolean {
if (activeScheduleIds.has(scheduleId)) return false;
schedules.value = schedules.value.filter((schedule) => schedule.id !== scheduleId);
persistSchedules();
return true;
}
async function deleteRuns(runIds: readonly string[]): Promise<void> {
const ids = new Set(runIds);
const selectedRuns = runs.value.filter((run) => ids.has(run.id) && !activeRunIds.has(run.id));
if (selectedRuns.length === 0) return;
const deletedIds = new Set(selectedRuns.map((run) => run.id));
const paths = uniqueUnreferencedBackupPaths(
selectedRuns.flatMap((run) => run.files.map((file) => file.filePath)),
deletedIds,
);
if (paths.length > 0) await api.deleteDatabaseBackupFiles(paths, backupRootsForRuns(selectedRuns));
runs.value = runs.value.filter((run) => !deletedIds.has(run.id));
persistRuns();
}
async function deleteRun(runId: string): Promise<void> {
await deleteRuns([runId]);
}
function renameRun(runId: string, displayName: string): boolean {
const run = runs.value.find((item) => item.id === runId);
if (!run || activeRunIds.has(runId)) return false;
const normalizedName = displayName.trim();
updateRun(runId, { displayName: normalizedName && normalizedName !== run.scheduleName ? normalizedName : undefined });
return true;
}
async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
if (staleRuns.length === 0) return;
const staleIds = new Set(staleRuns.map((run) => run.id));
const stalePaths = uniqueUnreferencedBackupPaths(
staleRuns.flatMap((run) => run.files.map((file) => file.filePath)),
staleIds,
);
try {
if (stalePaths.length > 0) await api.deleteDatabaseBackupFiles(stalePaths, backupRootsForRuns(staleRuns, schedule.destinationDirectory));
runs.value = runs.value.filter((run) => !staleIds.has(run.id));
persistRuns();
} catch (error) {
appendDebugLog("error", "[DBX][database-backup:retention-error]", error);
}
}
async function cancelRun(runId: string): Promise<boolean> {
if (!activeRunIds.has(runId)) return false;
if (cancellationRequested.has(runId)) return true;
cancellationRequested.add(runId);
cancellingRunIds.add(runId);
markDatabaseExportTaskCancelling(runId);
const exportIds = new Set([runId, activeExportIds.get(runId)].filter((id): id is string => Boolean(id)));
try {
await Promise.all([...exportIds].map((exportId) => api.cancelDatabaseExport(exportId)));
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:cancel-request-error]", error);
cancellationRequested.delete(runId);
cancellingRunIds.delete(runId);
restoreDatabaseExportTaskRunning(runId);
return false;
}
return true;
}
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string; runDirectoryPattern?: string }): Promise<FinishedDatabaseBackupRun | null> {
const backupName = request.displayName;
const connection = connectionStore.getConfig(config.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const outputDirectory = request.runDirectoryPattern ? databaseBackupRunDirectory(config.destinationDirectory, request.runDirectoryPattern, backupName, startedAt, runId) : config.destinationDirectory;
const run: DatabaseBackupRun = {
id: runId,
scheduleId: request.scheduleId,
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
destinationDirectory: config.destinationDirectory,
trigger: request.trigger,
source: request.source,
status: "running",
startedAt: startedAt.toISOString(),
files: [],
progressPercent: 0,
};
replaceRun(run);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, backupName, outputDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, async () => {
await cancelRun(runId);
});
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: FinishedDatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
const reservedPaths: string[] = [];
await databaseBackupConnectionQueue.run(config.connectionId, async () => {
try {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
await connectionStore.ensureConnected(config.connectionId);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
let tableNamesCaseSensitive = true;
if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(config.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]);
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
}
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
}
let exportIndex = 0;
let includedTableCount = 0;
for (const [databaseIndex, database] of selectedDatabases.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
schemasByDatabase,
function update(snapshot: DatabaseBackupSnapshot) {
schedules.value = snapshot.schedules;
runs.value = snapshot.runs;
heartbeat.value = snapshot.heartbeat;
destinationRoot.value = snapshot.destinationRoot;
activeScheduleIds.clear();
activeRunIds.clear();
for (const run of snapshot.runs) {
if (run.status === "running") {
activeRunIds.add(run.id);
if (run.scheduleId) activeScheduleIds.add(run.scheduleId);
if (!tracked.has(run.id)) {
tracked.add(run.id);
tracker.addDatabaseExportTask(run.id, run.displayName || run.scheduleName, run.destinationDirectory || "", run.source === "scheduled" ? "scheduled" : "manual");
tracker.registerTaskCancelHandler(run.id, async () => {
await cancelRun(run.id);
});
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (config.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
if (finalStatus === "cancelled") break;
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(outputDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression, config.fileNamePattern);
if (generatedPaths.includes(filePath) || activeBackupPaths.has(filePath) || backupPathIsReferencedByAnotherRun(filePath, new Set([runId]))) {
throw new Error("Backup file path is already used by another run. Include {runId} or another unique value in the file-name template.");
}
generatedPaths.push(filePath);
reservedPaths.push(filePath);
activeBackupPaths.add(filePath);
let terminal: Awaited<ReturnType<typeof runDatabaseExportUntilTerminal>>;
try {
terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: config.connectionId,
database: item.database,
schema: item.schema,
filePath,
selectedTables: item.selectedTables,
excludedTables: item.excludedTables,
includeStructure: config.includeStructure,
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
outputCompression: config.outputCompression,
failOnError: true,
preventOverwrite: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
},
(progress) => {
const progressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex,
totalDatabases: selectedDatabases.length,
completedExports: planIndex,
totalExports: scopedDatabasePlan.length,
currentObjectIndex: progress.objectIndex,
currentTotalObjects: progress.totalObjects,
currentExportComplete: progress.status === "Done",
});
if (progressPercent !== lastProgressPercent) {
lastProgressPercent = progressPercent;
updateRun(runId, { progressPercent }, false);
}
updateDatabaseExportTask(runId, {
...progress,
exportId: runId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
status: databaseBackupAggregateExportStatus(progress.status, false),
overallPercent: progressPercent,
});
},
);
} catch (error: any) {
if (String(error?.message || error).startsWith("Backup file already exists:")) {
const index = generatedPaths.lastIndexOf(filePath);
if (index >= 0) generatedPaths.splice(index, 1);
}
throw error;
}
activeExportIds.delete(runId);
if (terminal.status === "Cancelled" || cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const file: DatabaseBackupFile = {
database: item.database,
schema: item.schema,
displayName: item.displayName,
filePath,
};
run.files.push(file);
updateRun(runId, { files: [...run.files] });
}
snapshotCompleted = finalStatus === "success";
} finally {
await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => {
if (snapshotCompleted) throw error;
});
}
if (finalStatus !== "success") break;
lastProgressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex + 1,
totalDatabases: selectedDatabases.length,
completedExports: 0,
totalExports: 0,
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (config.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${config.tableFilterMode} backup rules.`);
}
} catch (error: any) {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
finalError = error?.message || String(error);
} finally {
for (const filePath of reservedPaths) activeBackupPaths.delete(filePath);
// `runId` is also the cancellation key while a snapshot is waiting
// for a pool connection. Unlike child exports, it has no exporter
// task that can clear that key after completion.
await api.clearDatabaseExportCancellation(runId).catch((error) => {
appendDebugLog("warn", "[DBX][database-backup:cancel-clear-error]", error);
});
if (finalStatus !== "success" && generatedPaths.length > 0) {
try {
const cleanupPaths = uniqueUnreferencedBackupPaths(generatedPaths, new Set([runId]));
if (cleanupPaths.length > 0) await api.deleteDatabaseBackupFiles(cleanupPaths, [config.destinationDirectory]);
run.files = [];
} catch (error: any) {
appendDebugLog("error", "[DBX][database-backup:partial-cleanup-error]", error);
const cleanupError = error?.message || String(error);
finalError = finalError ? `${finalError}; failed to remove partial backup files: ${cleanupError}` : cleanupError;
}
}
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
cancellingRunIds.delete(runId);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
const updatedRun = updateRun(runId, {
status: finalStatus,
completedAt: completedAt.toISOString(),
files: [...run.files],
progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
error: finalError || undefined,
});
finishedRun = updatedRun && updatedRun.status !== "running" ? (updatedRun as FinishedDatabaseBackupRun) : null;
updateDatabaseExportTask(runId, {
exportId: runId,
currentObject: backupName,
objectIndex: finalStatus === "success" ? run.files.length : 0,
} else {
cancellingRunIds.delete(run.id);
tracker.unregisterTaskCancelHandler(run.id);
}
if (tracked.has(run.id)) {
tracker.updateDatabaseExportTask(run.id, {
exportId: run.id,
currentObject: run.displayName || run.scheduleName,
objectIndex: 0,
totalObjects: run.files.length,
rowsExported: 0,
totalRows: null,
status: databaseBackupAggregateExportStatus(finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error", true),
error: finalError || null,
overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
});
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
scheduleId: request.scheduleId,
runId,
files: run.files.length,
error: finalError || undefined,
status: run.status === "running" ? "Running" : run.status === "success" ? "Done" : run.status === "cancelled" ? "Cancelled" : "Error",
error: run.error || null,
overallPercent: run.progressPercent || 0,
});
if (run.status !== "running") tracked.delete(run.id);
}
});
return finishedRun;
}
async function finalizeScheduledRun(scheduleId: string, trigger: DatabaseBackupRunTrigger, run: FinishedDatabaseBackupRun) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
const completedAt = run.completedAt ? new Date(run.completedAt) : new Date();
schedules.value = schedules.value.map((item) =>
item.id === scheduleId
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: run.status,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (run.status === "success") await pruneScheduleRuns(schedule);
}
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
activeScheduleIds.add(scheduleId);
try {
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), {
source: "scheduled",
trigger,
scheduleId,
displayName: schedule.name,
runDirectoryPattern: schedule.runDirectoryPattern,
});
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
return run;
} finally {
activeScheduleIds.delete(scheduleId);
}
}
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup"): Promise<FinishedDatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("One-shot database backups are only available in the desktop app.");
if (activeRuns.value.some((run) => run.source === "one-shot")) return null;
return runBackup(config, { source: "one-shot", trigger: "manual", displayName });
async function refresh() {
if (refreshing) return refreshing;
refreshing = (async () => {
try {
let snapshot = await api.databaseBackupCommand<DatabaseBackupSnapshot>({ action: "snapshot" });
if (!snapshot.migrated) {
const legacySchedules = isTauriRuntime() ? readDatabaseBackupSchedules().map((schedule) => ({ ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone })) : [];
await api.databaseBackupCommand({ action: "migrate", migration: { schedules: legacySchedules, runs: isTauriRuntime() ? readDatabaseBackupRuns() : [] } });
// Keep legacy keys as a migration fallback. The backend marker prevents reimporting.
snapshot = await api.databaseBackupCommand<DatabaseBackupSnapshot>({ action: "snapshot" });
}
update(snapshot);
error.value = "";
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason);
appendDebugLog("error", "[DBX][database-backup:refresh]", reason);
throw reason;
}
})().finally(() => {
refreshing = undefined;
});
return refreshing;
}
async function processDueSchedules() {
if (processingDueSchedules) return;
processingDueSchedules = true;
try {
await connectionStore.initFromDisk();
const now = new Date();
const dueSchedules = schedules.value.filter((schedule) => databaseBackupScheduleIsDue(schedule, now));
for (const schedule of dueSchedules) {
await runSchedule(schedule.id, "scheduled").catch((error) => {
appendDebugLog("error", "[DBX][database-backup:scheduler-error]", error);
});
}
} catch (error) {
appendDebugLog("error", "[DBX][database-backup:scheduler-init-error]", error);
} finally {
processingDueSchedules = false;
async function refreshAfterMutation() {
// A poll started before a mutation may still return the previous state.
await refreshing?.catch(() => {});
await refresh();
}
async function saveSchedule(schedule: DatabaseBackupSchedule) {
const saved = await api.databaseBackupCommand<DatabaseBackupSchedule>({ action: "save", schedule: { ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone } });
await refreshAfterMutation();
return saved;
}
async function setScheduleEnabled(id: string, enabled: boolean) {
const schedule = schedules.value.find((s) => s.id === id);
if (schedule) await saveSchedule({ ...schedule, enabled });
}
async function deleteSchedule(id: string) {
if (activeScheduleIds.has(id)) return false;
await api.databaseBackupCommand({ action: "deleteSchedule", id });
await refreshAfterMutation();
return true;
}
async function deleteRuns(ids: readonly string[]) {
await api.databaseBackupCommand({ action: "deleteRuns", ids: [...ids] });
await refreshAfterMutation();
}
async function renameRun(id: string, name: string) {
if (activeRunIds.has(id)) return false;
await api.databaseBackupCommand({ action: "rename", id, name: name.trim() });
await refreshAfterMutation();
return true;
}
async function cancelRun(id: string) {
const accepted = await api.databaseBackupCommand<boolean>({ action: "cancel", id });
if (accepted) {
cancellingRunIds.add(id);
tracker.markDatabaseExportTaskCancelling(id);
}
return accepted;
}
async function waitForRun(run: DatabaseBackupRun): Promise<DatabaseBackupRun | null> {
await refreshAfterMutation();
while (true) {
const current = runs.value.find((r) => r.id === run.id);
if (!current || current.status !== "running") return current || null;
await new Promise((resolve) => setTimeout(resolve, 1000));
await refresh();
}
}
function onStorage(event: StorageEvent) {
if (event.key && event.key !== DATABASE_BACKUP_SCHEDULES_STORAGE_KEY && event.key !== DATABASE_BACKUP_RUNS_STORAGE_KEY) return;
refreshFromStorage();
void processDueSchedules();
async function runSchedule(id: string) {
if (activeScheduleIds.has(id)) return null;
return waitForRun(await api.databaseBackupCommand<DatabaseBackupRun>({ action: "run", request: { scheduleId: id } }));
}
function onConfigChanged() {
void processDueSchedules();
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup") {
return waitForRun(await api.databaseBackupCommand<DatabaseBackupRun>({ action: "run", request: { config, displayName, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone } }));
}
if (options.scheduler) {
onMounted(() => {
if (schedulerRegistered || !isTauriRuntime()) return;
schedulerRegistered = true;
window.addEventListener("storage", onStorage);
window.addEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
schedulerTimer = window.setInterval(() => void processDueSchedules(), SCHEDULER_INTERVAL_MS);
void processDueSchedules();
});
onUnmounted(() => {
if (!schedulerRegistered) return;
schedulerRegistered = false;
if (schedulerTimer) window.clearInterval(schedulerTimer);
schedulerTimer = undefined;
window.removeEventListener("storage", onStorage);
window.removeEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
});
}
onMounted(() => {
subscribers += 1;
if (!timer)
timer = setInterval(() => {
void refresh().catch(() => {});
}, 2000);
void refresh().catch(() => {});
});
onUnmounted(() => {
subscribers -= 1;
if (!subscribers && timer) {
clearInterval(timer);
timer = undefined;
}
});
return {
schedules,
@@ -571,15 +173,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
activeRunIds,
cancellingRunIds,
activeRuns,
heartbeat,
destinationRoot,
error,
saveSchedule,
setScheduleEnabled,
deleteSchedule,
deleteRun,
deleteRuns,
deleteRun: (id: string) => deleteRuns([id]),
renameRun,
runSchedule,
runOneShot,
cancelRun,
processDueSchedules,
processDueSchedules: refresh,
};
}