feat(backup): support custom backup file name templates and path preview

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 00:29:15 +08:00
1 parent 38be5ba35c
commit ce3bd1c8ed
31 files changed
+744 -125

No files matched your search

@@ -77,6 +77,7 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
scheduleName: "First backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "success",
@@ -88,6 +89,7 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
scheduleName: "Second backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "failed",
@@ -100,7 +102,7 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
try {
await backup.deleteRuns([firstRun.id, secondRun.id]);
expect(mocks.deleteFiles).toHaveBeenCalledWith(["/backups/first.sql", "/backups/second.sql"]);
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 {
@@ -108,6 +110,35 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
}
});
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("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" });
@@ -121,9 +152,46 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
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("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("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("prevents a second one-shot while one is already running", async () => {
const backup = useScheduledDatabaseBackups();
const activeRun = {
@@ -181,7 +249,7 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)]);
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);
});
@@ -45,6 +45,7 @@ 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>();
let schedulerTimer: ReturnType<typeof window.setInterval> | undefined;
@@ -77,6 +78,18 @@ function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = t
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() {
@@ -133,9 +146,12 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
const selectedRuns = runs.value.filter((run) => ids.has(run.id) && !activeRunIds.has(run.id));
if (selectedRuns.length === 0) return;
const paths = selectedRuns.flatMap((run) => run.files.map((file) => file.filePath));
if (paths.length > 0) await api.deleteDatabaseBackupFiles(paths);
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();
}
@@ -155,10 +171,13 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
if (staleRuns.length === 0) return;
const stalePaths = staleRuns.flatMap((run) => run.files.map((file) => file.filePath));
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);
const staleIds = new Set(staleRuns.map((run) => run.id));
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) {
@@ -198,6 +217,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
destinationDirectory: config.destinationDirectory,
trigger: request.trigger,
source: request.source,
status: "running",
@@ -218,6 +238,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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)) {
@@ -299,49 +320,64 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(outputDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression);
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);
const 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,
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,
});
},
);
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";
@@ -379,6 +415,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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.
@@ -387,7 +424,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
});
if (finalStatus !== "success" && generatedPaths.length > 0) {
try {
await api.deleteDatabaseBackupFiles(generatedPaths);
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);