feat(backup): add one-shot database backups
This commit is contained in:
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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user