feat(backup): support custom backup file name templates and path preview
This commit is contained in:
31 files changed
+744
-125
No files matched your search
+70
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user