feat(backup): add one-shot database backups

This commit is contained in:
jearhe authored and GitHub committed 2026-08-18 15:30:44 +08:00
1 parent f0cc5aefcf
commit 0facea2740
17 files changed
+888 -179

No files matched your search

@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
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 () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
nextId: 0,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
}));
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 () => {}),
}),
}));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
}),
}));
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
const config: DatabaseBackupExecutionConfig = {
connectionId: "mysql-1",
databases: ["app"],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "/backups",
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
};
describe("useScheduledDatabaseBackups one-shot execution", () => {
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.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
});
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("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}-1`);
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!)]);
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
});
});
@@ -22,10 +22,13 @@ import {
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
toDatabaseBackupExecutionConfig,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupExecutionConfig,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunSource,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
@@ -72,6 +75,8 @@ function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = t
return updated;
}
type FinishedDatabaseBackupRun = Omit<DatabaseBackupRun, "status"> & { status: Exclude<DatabaseBackupRunStatus, "running"> };
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
@@ -158,54 +163,52 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
}
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;
const connection = connectionStore.getConfig(schedule.connectionId);
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
const backupName = request.displayName;
const connection = connectionStore.getConfig(config.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const run: DatabaseBackupRun = {
id: runId,
scheduleId: schedule.id,
scheduleName: schedule.name,
connectionId: schedule.connectionId,
scheduleId: request.scheduleId,
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
trigger,
trigger: request.trigger,
source: request.source,
status: "running",
startedAt: startedAt.toISOString(),
files: [],
progressPercent: 0,
};
replaceRun(run);
activeScheduleIds.add(schedule.id);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory, "scheduled");
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, () => cancelRun(runId));
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: DatabaseBackupRun | null = null;
let finishedRun: FinishedDatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
await databaseBackupConnectionQueue.run(schedule.connectionId, async () => {
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(schedule.connectionId);
const availableDatabases = (await api.listDatabases(schedule.connectionId)).map((database) => database.name);
const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases, connection.db_type);
await connectionStore.ensureConnected(config.connectionId);
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
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" && schedule.tableFilterMode !== "all") {
if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
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);
@@ -219,7 +222,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
@@ -228,18 +231,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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 (schedule.tableFilterMode === "all") {
if (config.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
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 });
}
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
@@ -250,21 +253,21 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(schedule.destinationDirectory, schedule.name, item.fileStem, startedAt, runId);
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId);
generatedPaths.push(filePath);
const terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: schedule.connectionId,
connectionId: config.connectionId,
database: item.database,
schema: item.schema,
filePath,
selectedTables: item.selectedTables,
excludedTables: item.excludedTables,
includeStructure: schedule.includeStructure,
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
includeStructure: config.includeStructure,
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
failOnError: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
@@ -322,8 +325,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (schedule.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`);
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";
@@ -342,20 +345,20 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
activeScheduleIds.delete(schedule.id);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
finishedRun = updateRun(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: schedule.name,
currentObject: backupName,
objectIndex: finalStatus === "success" ? run.files.length : 0,
totalObjects: run.files.length,
rowsExported: 0,
@@ -365,24 +368,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
});
const latestSchedule = schedules.value.find((item) => item.id === schedule.id);
if (latestSchedule) {
schedules.value = schedules.value.map((item) =>
item.id === schedule.id
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: finalStatus,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (finalStatus === "success") await pruneScheduleRuns(latestSchedule);
}
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
scheduleId: schedule.id,
scheduleId: request.scheduleId,
runId,
files: run.files.length,
error: finalError || undefined,
@@ -392,6 +379,44 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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 });
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 processDueSchedules() {
if (processingDueSchedules) return;
processingDueSchedules = true;
@@ -453,6 +478,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
deleteRun,
renameRun,
runSchedule,
runOneShot,
cancelRun,
processDueSchedules,
};