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

This commit is contained in:
二丫讲梵
2026-09-10 00:29:15 +08:00
committed by GitHub
parent 38be5ba35c
commit ce3bd1c8ed
31 changed files with 744 additions and 125 deletions
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch, type ComponentPublicInstance } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { HelpTooltip } from "@/components/ui/tooltip";
import { Check, ChevronDown, FolderOpen, Loader2, Search } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import { databaseBackupFileNamePatternIsValid, type DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
const props = withDefaults(
@@ -20,8 +21,11 @@ const props = withDefaults(
tablePatternsInput: string;
loadingDatabases: boolean;
databaseLoadError?: string;
runDirectoryPattern?: string;
runDirectoryPatternValid?: boolean;
outputPathPreview?: string;
}>(),
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "" },
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "", runDirectoryPatternValid: true, outputPathPreview: "" },
);
const emit = defineEmits<{
@@ -30,12 +34,15 @@ const emit = defineEmits<{
toggleDatabase: [database: string];
"update:allDatabases": [value: boolean];
"update:tablePatternsInput": [value: string];
"update:runDirectoryPattern": [value: string];
}>();
const { t } = useI18n();
const connectionPickerOpen = ref(false);
const connectionSearch = ref("");
const databaseSearch = ref("");
const connectionPickerTrigger = ref<ComponentPublicInstance | null>(null);
const connectionPickerWidth = ref<number>();
const selectedConnectionName = computed(() => props.connections.find((connection) => connection.id === props.draft.connectionId)?.name || props.draft.connectionId);
const filteredConnections = computed(() => {
@@ -48,14 +55,39 @@ const filteredDatabaseOptions = computed(() => {
if (!query) return props.databaseOptions;
return props.databaseOptions.filter((database) => database.toLocaleLowerCase().includes(query));
});
const runDirectoryTemplateVariables = computed(() => [
{ token: "{schedule}", description: t("databaseBackup.templateVariableSchedule") },
{ token: "{date}", description: t("databaseBackup.templateVariableDate") },
{ token: "{timestamp}", description: t("databaseBackup.templateVariableTimestamp") },
{ token: "{runId}", description: t("databaseBackup.templateVariableRunId") },
]);
const fileNameTemplateVariables = computed(() => [
{ token: "{schedule}", description: t("databaseBackup.templateVariableSchedule") },
{ token: "{date}", description: t("databaseBackup.templateVariableDate") },
{ token: "{timestamp}", description: t("databaseBackup.templateVariableTimestamp") },
{ token: "{database}", description: t("databaseBackup.templateVariableDatabase") },
{ token: "{runId}", description: t("databaseBackup.templateVariableRunId") },
]);
function selectConnection(connectionId: string) {
emit("changeConnection", connectionId);
connectionPickerOpen.value = false;
}
watch(connectionPickerOpen, (open) => {
if (!open) connectionSearch.value = "";
function syncConnectionPickerWidth() {
const element = connectionPickerTrigger.value?.$el;
if (!(element instanceof HTMLElement)) return;
const width = Math.ceil(element.getBoundingClientRect().width);
if (width > 0) connectionPickerWidth.value = width;
}
watch(connectionPickerOpen, async (open) => {
if (!open) {
connectionSearch.value = "";
return;
}
await nextTick();
syncConnectionPickerWidth();
});
watch(
@@ -67,17 +99,17 @@ watch(
</script>
<template>
<div class="grid gap-5 py-1">
<div class="backup-config-fields grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Popover v-model:open="connectionPickerOpen">
<PopoverTrigger as-child>
<Button data-backup-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="connectionPickerOpen" class="w-full justify-between font-normal">
<Button ref="connectionPickerTrigger" data-backup-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="connectionPickerOpen" class="w-full justify-between font-normal" @click="syncConnectionPickerWidth">
<span class="truncate">{{ selectedConnectionName }}</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[var(--reka-popover-trigger-width)] p-1">
<PopoverContent align="start" :collision-padding="24" class="w-[var(--reka-popover-trigger-width)] max-w-[calc(100vw-48px)] p-1" :style="connectionPickerWidth ? { width: `${connectionPickerWidth}px` } : undefined">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="connectionSearch" data-backup-connection-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchConnections')" :placeholder="t('databaseBackup.searchConnections')" />
@@ -101,14 +133,59 @@ watch(
<div class="space-y-2">
<Label>{{ t("databaseBackup.destination") }}</Label>
<div class="flex gap-2">
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
<div class="backup-destination-field">
<Input v-model="draft.destinationDirectory" readonly :title="draft.destinationDirectory" />
<Button variant="outline" size="icon" class="backup-destination-picker" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-4">
<Label>{{ t("databaseBackup.outputPathTemplate") }}</Label>
<div v-if="runDirectoryPattern !== undefined" class="space-y-2">
<div class="flex items-center gap-1.5">
<Label class="text-sm font-normal">{{ t("databaseBackup.runDirectoryPattern") }}</Label>
<HelpTooltip :label="t('databaseBackup.templateVariablesHelp')" side="right" content-class="w-80 max-w-[calc(100vw-32px)]">
<div class="space-y-2">
<div class="font-medium">{{ t("databaseBackup.templateVariablesTitle") }}</div>
<div v-for="variable in runDirectoryTemplateVariables" :key="variable.token" class="flex items-start gap-2">
<code class="w-20 shrink-0 font-mono font-medium">{{ variable.token }}</code>
<span>{{ variable.description }}</span>
</div>
</div>
</HelpTooltip>
</div>
<Input :model-value="runDirectoryPattern" data-backup-run-directory-pattern :aria-invalid="!runDirectoryPatternValid" @update:model-value="(value: string | number) => emit('update:runDirectoryPattern', String(value))" />
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.runDirectoryPatternHint") }}</p>
<p v-if="!runDirectoryPatternValid" class="text-xs text-destructive">{{ t("databaseBackup.runDirectoryPatternInvalid") }}</p>
</div>
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label class="text-sm font-normal">{{ t("databaseBackup.fileNamePattern") }}</Label>
<HelpTooltip :label="t('databaseBackup.templateVariablesHelp')" side="right" content-class="w-80 max-w-[calc(100vw-32px)]">
<div class="space-y-2">
<div class="font-medium">{{ t("databaseBackup.templateVariablesTitle") }}</div>
<div v-for="variable in fileNameTemplateVariables" :key="variable.token" class="flex items-start gap-2">
<code class="w-20 shrink-0 font-mono font-medium">{{ variable.token }}</code>
<span>{{ variable.description }}</span>
</div>
</div>
</HelpTooltip>
</div>
<Input v-model="draft.fileNamePattern" data-backup-file-name-pattern :aria-invalid="!databaseBackupFileNamePatternIsValid(draft.fileNamePattern || '')" />
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.fileNamePatternHint") }}</p>
<p v-if="!databaseBackupFileNamePatternIsValid(draft.fileNamePattern || '')" class="text-xs text-destructive">{{ t("databaseBackup.fileNamePatternInvalid") }}</p>
</div>
<div v-if="outputPathPreview" data-backup-output-path-preview class="backup-output-path-preview space-y-1 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div class="text-xs font-medium text-muted-foreground">{{ t("databaseBackup.outputPathPreview") }}</div>
<div class="backup-output-path-value font-mono text-xs text-foreground" :title="outputPathPreview">{{ outputPathPreview }}</div>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.compression") }}</Label>
<label class="flex items-center gap-2 text-sm">
@@ -176,3 +253,62 @@ watch(
</div>
</div>
</template>
<style scoped>
.backup-config-fields {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: hidden;
}
.backup-config-fields > * {
min-width: 0;
max-width: 100%;
}
.backup-destination-field {
position: relative;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
padding-right: 2.5rem;
}
.backup-destination-field > [data-slot="input"] {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.backup-destination-picker {
position: absolute;
top: 0;
right: 0;
width: 2rem;
height: 2rem;
padding: 0;
}
.backup-output-path-preview {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.backup-output-path-value {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
</style>
@@ -18,6 +18,9 @@ import { translateBackendError } from "@/i18n/backend-errors";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import {
DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN,
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
databaseBackupRunDirectory,
databaseBackupRunDirectoryPatternIsValid,
nextDatabaseBackupRunAt,
@@ -114,6 +117,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
fileNamePattern: DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN,
};
}
@@ -155,18 +159,34 @@ const canSave = computed(() => {
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
return (
!!draft.value.name.trim() &&
!!draft.value.connectionId &&
!!draft.value.destinationDirectory.trim() &&
databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") &&
databaseBackupFileNamePatternIsValid(draft.value.fileNamePattern || "") &&
hasContent &&
hasDatabaseScope &&
hasTableScope &&
!saving.value &&
!loadingDatabases.value
);
});
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
const runDirectoryPreview = computed(() => {
if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "")) return "";
return databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01");
const scheduleOutputPathPreview = computed(() => {
if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") || !databaseBackupFileNamePatternIsValid(draft.value.fileNamePattern || "")) return "";
const directory = databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01");
return databaseBackupFilePath(directory, draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), "database", nextRunPreview.value, "preview01", draft.value.outputCompression, draft.value.fileNamePattern);
});
const oneShotOutputPathPreview = computed(() => {
if (!oneShotDraft.value.destinationDirectory.trim() || !databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "")) return "";
return databaseBackupFilePath(oneShotDraft.value.destinationDirectory, t("databaseBackup.oneShotName"), "database", new Date(), "preview01", oneShotDraft.value.outputCompression, oneShotDraft.value.fileNamePattern);
});
const canStartOneShot = computed(() => {
const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects;
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
const hasTableScope = oneShotDraft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
return !!oneShotDraft.value.connectionId && !!oneShotDraft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !oneShotStarting.value && !loadingDatabases.value;
return !!oneShotDraft.value.connectionId && !!oneShotDraft.value.destinationDirectory.trim() && databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "") && hasContent && hasDatabaseScope && hasTableScope && !oneShotStarting.value && !loadingDatabases.value;
});
function connectionName(connectionId: string): string {
@@ -771,12 +791,12 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<Dialog v-model:open="scheduleDialogOpen">
<DialogContent class="dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
<DialogContent class="dbx-backup-dialog dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-x-hidden overflow-y-auto pr-8 [scrollbar-gutter:stable]">
<DialogHeader>
<DialogTitle>{{ editingScheduleId ? t("databaseBackup.editSchedule") : t("databaseBackup.addSchedule") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-5 py-1">
<div class="backup-schedule-form grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
@@ -791,24 +811,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
:database-load-error="databaseLoadError"
:run-directory-pattern="draft.runDirectoryPattern"
:run-directory-pattern-valid="databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')"
:output-path-preview="scheduleOutputPathPreview"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="setAllDatabases"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
@update:run-directory-pattern="(value: string) => (draft.runDirectoryPattern = value)"
/>
<div class="space-y-2">
<Label>{{ t("databaseBackup.runDirectoryPattern") }}</Label>
<Input v-model="draft.runDirectoryPattern" data-backup-run-directory-pattern :aria-invalid="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" />
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.runDirectoryPatternHint") }}</p>
<div v-if="runDirectoryPreview" data-backup-run-directory-preview class="space-y-1 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div class="text-xs font-medium text-muted-foreground">{{ t("databaseBackup.runDirectoryPreview") }}</div>
<div class="break-all font-mono text-xs text-foreground">{{ runDirectoryPreview }}</div>
</div>
<p v-if="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" class="text-xs text-destructive">{{ t("databaseBackup.runDirectoryPatternInvalid") }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{{ t("databaseBackup.frequency") }}</Label>
@@ -865,7 +878,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</Dialog>
<Dialog v-model:open="oneShotDialogOpen">
<DialogContent class="dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
<DialogContent class="dbx-backup-dialog dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-x-hidden overflow-y-auto pr-8 [scrollbar-gutter:stable]">
<DialogHeader>
<DialogTitle>{{ t("databaseBackup.oneShotBackup") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.oneShotDescription") }}</p>
@@ -880,6 +893,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
:database-load-error="databaseLoadError"
:output-path-preview="oneShotOutputPathPreview"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@@ -949,3 +963,12 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogContent>
</Dialog>
</template>
<style scoped>
.backup-schedule-form {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
max-width: 100%;
}
</style>
@@ -437,7 +437,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.listDatabases).not.toHaveBeenCalled();
});
it("previews the rendered run directory and updates it when the template changes", async () => {
it("previews the rendered output path and updates it when the directory template changes", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
@@ -446,7 +446,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]");
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-output-path-preview]");
expect(preview?.textContent).toContain("/backups/dbx-backup__");
expect(preview?.textContent).toContain("preview0");
@@ -457,7 +457,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
await flush();
expect(currentDialog().textContent).toContain("{timestamp}");
expect(currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]")?.textContent).toContain("/backups/archive/");
expect(currentDialog().querySelector<HTMLElement>("[data-backup-output-path-preview]")?.textContent).toContain("/backups/archive/");
});
it("opens an independent one-shot dialog without schedule fields", async () => {
@@ -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);
+12
View File
@@ -7849,6 +7849,18 @@ export default {
missingConnection: "Missing connection",
destination: "Destination directory",
selectDestination: "Select backup directory",
outputPathTemplate: "Output path template",
outputPathPreview: "Output path preview",
fileNamePattern: "File name template",
fileNamePatternHint: "Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'}, and {'{'}runId{'}'}. DBX adds a missing {'{'}database{'}'} value and adds the extension automatically.",
templateVariablesHelp: "View available variable descriptions",
templateVariablesTitle: "Available variables",
templateVariableSchedule: "Backup schedule name",
templateVariableDate: "Backup date in YYYYMMDD format",
templateVariableTimestamp: "Timestamp used in the backup output path",
templateVariableDatabase: "Current database backup target name",
templateVariableRunId: "First 8 characters of this backup run ID",
fileNamePatternInvalid: "Use a file name without path separators, reserved characters, or a trailing dot or space.",
runDirectoryPattern: "Run directory template",
runDirectoryPatternHint: "A relative path under the destination. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, and {'{'}runId{'}'}. The default includes {'{'}runId{'}'} for extra isolation. Example: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
runDirectoryPreview: "Directory preview",
+12
View File
@@ -8869,6 +8869,18 @@ export default withEnglishFallback({
missingConnection: "La conexión ya no existe",
destination: "Directorio de copia de seguridad",
selectDestination: "Seleccionar directorio de copia de seguridad",
outputPathTemplate: "Plantilla de ruta de salida",
outputPathPreview: "Vista previa de la ruta de salida",
fileNamePattern: "Plantilla de nombre de archivo",
fileNamePatternHint: "Usa {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'} y {'{'}runId{'}'}. DBX agrega el valor {'{'}database{'}'} si falta y añade la extensión automáticamente.",
templateVariablesHelp: "Ver la descripción de las variables disponibles",
templateVariablesTitle: "Variables disponibles",
templateVariableSchedule: "Nombre del plan de copia de seguridad",
templateVariableDate: "Fecha de la copia en formato YYYYMMDD",
templateVariableTimestamp: "Marca de tiempo usada en la ruta de salida de la copia",
templateVariableDatabase: "Nombre del destino de base de datos actual",
templateVariableRunId: "Primeros 8 caracteres del ID de esta ejecución",
fileNamePatternInvalid: "Use un nombre de archivo sin separadores de ruta, caracteres reservados ni punto o espacio final.",
runDirectoryPattern: "Plantilla de directorio de ejecución",
runDirectoryPatternHint:
"Ruta relativa dentro del directorio de destino. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} y {'{'}runId{'}'}. La plantilla predeterminada incluye {'{'}runId{'}'} para un mejor aislamiento. Ejemplo: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
+12
View File
@@ -8870,6 +8870,18 @@ export default withEnglishFallback({
missingConnection: "Connessione non più esistente",
destination: "Directory di backup",
selectDestination: "Seleziona directory di backup",
outputPathTemplate: "Modello percorso di output",
outputPathPreview: "Anteprima percorso di output",
fileNamePattern: "Modello nome file",
fileNamePatternHint: "Usa {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'} e {'{'}runId{'}'}. DBX aggiunge il valore {'{'}database{'}'} mancante e aggiunge automaticamente l'estensione.",
templateVariablesHelp: "Mostra la descrizione delle variabili disponibili",
templateVariablesTitle: "Variabili disponibili",
templateVariableSchedule: "Nome della pianificazione di backup",
templateVariableDate: "Data del backup nel formato YYYYMMDD",
templateVariableTimestamp: "Timestamp usato nel percorso di output del backup",
templateVariableDatabase: "Nome della destinazione database corrente",
templateVariableRunId: "Primi 8 caratteri dell'ID di questa esecuzione",
fileNamePatternInvalid: "Usa un nome file senza separatori di percorso, caratteri riservati, punto o spazio finale.",
runDirectoryPattern: "Modello directory di esecuzione",
runDirectoryPatternHint:
"Percorso relativo nella directory di destinazione. Usa {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} e {'{'}runId{'}'}. Il modello predefinito include {'{'}runId{'}'} per un maggiore isolamento. Esempio: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
+12
View File
@@ -8921,6 +8921,18 @@ export default withEnglishFallback({
missingConnection: "接続が存在しません",
destination: "バックアップ先",
selectDestination: "バックアップ先を選択",
outputPathTemplate: "出力パステンプレート",
outputPathPreview: "出力パスのプレビュー",
fileNamePattern: "ファイル名テンプレート",
fileNamePatternHint: "{'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'}、{'{'}database{'}'}、{'{'}runId{'}'} を使用できます。DBX は不足している {'{'}database{'}'} を追加し、拡張子も自動で追加します。",
templateVariablesHelp: "使用可能な変数の説明を表示",
templateVariablesTitle: "使用可能な変数",
templateVariableSchedule: "バックアップスケジュール名",
templateVariableDate: "バックアップ日(YYYYMMDD 形式)",
templateVariableTimestamp: "バックアップ出力パスに使用するタイムスタンプ",
templateVariableDatabase: "現在のデータベースバックアップ対象名",
templateVariableRunId: "今回のバックアップ実行 ID の先頭 8 文字",
fileNamePatternInvalid: "パス区切り文字、予約文字、末尾のピリオドまたは空白を含まないファイル名を使用してください。",
runDirectoryPattern: "実行ディレクトリテンプレート",
runDirectoryPatternHint:
"バックアップ先配下の相対パスです。{'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'}、{'{'}runId{'}'} を使用できます。既定のテンプレートには、より確実に分離するため {'{'}runId{'}'} が含まれます。例:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
+12
View File
@@ -7123,6 +7123,18 @@ export default withEnglishFallback({
missingConnection: "연결 누락",
destination: "대상 디렉터리",
selectDestination: "백업 디렉터리 선택",
outputPathTemplate: "출력 경로 템플릿",
outputPathPreview: "출력 경로 미리 보기",
fileNamePattern: "파일 이름 템플릿",
fileNamePatternHint: "{'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'}, {'{'}runId{'}'}를 사용할 수 있습니다. DBX는 누락된 {'{'}database{'}'}를 추가하고 확장자도 자동으로 추가합니다.",
templateVariablesHelp: "사용 가능한 변수 설명 보기",
templateVariablesTitle: "사용 가능한 변수",
templateVariableSchedule: "백업 일정 이름",
templateVariableDate: "YYYYMMDD 형식의 백업 날짜",
templateVariableTimestamp: "백업 출력 경로에 사용하는 타임스탬프",
templateVariableDatabase: "현재 데이터베이스 백업 대상 이름",
templateVariableRunId: "이번 백업 실행 ID의 앞 8자",
fileNamePatternInvalid: "경로 구분 기호, 예약 문자, 끝의 마침표나 공백이 없는 파일 이름을 사용하세요.",
runDirectoryPattern: "실행 디렉터리 템플릿",
runDirectoryPatternHint:
"대상 디렉터리 아래의 상대 경로입니다. {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}runId{'}'}를 사용할 수 있습니다. 기본 템플릿에는 더 나은 분리를 위해 {'{'}runId{'}'}가 포함됩니다. 예: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
+12
View File
@@ -8871,6 +8871,18 @@ export default withEnglishFallback({
missingConnection: "Conexão não existe mais",
destination: "Diretório de backup",
selectDestination: "Selecionar diretório de backup",
outputPathTemplate: "Modelo de caminho de saída",
outputPathPreview: "Prévia do caminho de saída",
fileNamePattern: "Modelo de nome de arquivo",
fileNamePatternHint: "Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'} e {'{'}runId{'}'}. O DBX acrescenta o valor {'{'}database{'}'} ausente e adiciona a extensão automaticamente.",
templateVariablesHelp: "Ver descrições das variáveis disponíveis",
templateVariablesTitle: "Variáveis disponíveis",
templateVariableSchedule: "Nome do agendamento de backup",
templateVariableDate: "Data do backup no formato YYYYMMDD",
templateVariableTimestamp: "Carimbo de data e hora usado no caminho de saída do backup",
templateVariableDatabase: "Nome do destino de banco de dados atual",
templateVariableRunId: "Primeiros 8 caracteres do ID desta execução",
fileNamePatternInvalid: "Use um nome de arquivo sem separadores de caminho, caracteres reservados ou ponto ou espaço final.",
runDirectoryPattern: "Modelo de diretório de execução",
runDirectoryPatternHint:
"Um caminho relativo dentro do diretório de destino. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} e {'{'}runId{'}'}. O modelo padrão inclui {'{'}runId{'}'} para maior isolamento. Exemplo: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
+12
View File
@@ -7708,6 +7708,18 @@ export default withEnglishFallback({
missingConnection: "Bağlantı eksik",
destination: "Hedef dizin",
selectDestination: "Yedekleme dizinini seçin",
outputPathTemplate: "Çıktı yolu şablonu",
outputPathPreview: "Çıktı yolu önizlemesi",
fileNamePattern: "Dosya adı şablonu",
fileNamePatternHint: "{'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}database{'}'} ve {'{'}runId{'}'} kullanın. DBX eksik {'{'}database{'}'} değerini ekler ve uzantıyı otomatik ekler.",
templateVariablesHelp: "Kullanılabilir değişken açıklamalarını görüntüle",
templateVariablesTitle: "Kullanılabilir değişkenler",
templateVariableSchedule: "Yedekleme planı adı",
templateVariableDate: "YYYYMMDD biçiminde yedekleme tarihi",
templateVariableTimestamp: "Yedekleme çıktı yolunda kullanılan zaman damgası",
templateVariableDatabase: "Geçerli veritabanı yedekleme hedefinin adı",
templateVariableRunId: "Bu yedekleme çalıştırma kimliğinin ilk 8 karakteri",
fileNamePatternInvalid: "Yol ayırıcıları, ayrılmış karakterler veya sonunda nokta ya da boşluk olmayan bir dosya adı kullanın.",
runDirectoryPattern: "Çalıştırma dizini şablonu",
runDirectoryPatternHint: "Hedef dizin altındaki göreli yol. {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} ve {'{'}runId{'}'} kullanın. Varsayılan şablon daha iyi yalıtım için {'{'}runId{'}'} içerir. Örnek: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
runDirectoryPreview: "Dizin önizlemesi",
+12
View File
@@ -7830,6 +7830,18 @@ export default withEnglishFallback({
missingConnection: "连接已不存在",
destination: "备份目录",
selectDestination: "选择备份目录",
outputPathTemplate: "输出路径模板",
outputPathPreview: "输出路径预览",
fileNamePattern: "文件名模板",
fileNamePatternHint: "可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'}、{'{'}database{'}'} 和 {'{'}runId{'}'}。DBX 会自动补齐缺失的数据库名,并自动添加扩展名。",
templateVariablesHelp: "查看可用变量说明",
templateVariablesTitle: "可用变量",
templateVariableSchedule: "备份计划名称",
templateVariableDate: "备份日期,格式为 YYYYMMDD",
templateVariableTimestamp: "备份输出路径中使用的时间戳",
templateVariableDatabase: "当前备份的数据库目标名称",
templateVariableRunId: "本次备份运行 ID 的前 8 位",
fileNamePatternInvalid: "请输入不含路径分隔符、保留字符及末尾句点或空格的文件名。",
runDirectoryPattern: "运行目录模板",
runDirectoryPatternHint: "相对于备份目录的路径。可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'} 和 {'{'}runId{'}'}。默认模板保留 {'{'}runId{'}'} 以获得更好的隔离性。例如:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
runDirectoryPreview: "目录预览",
+12
View File
@@ -8859,6 +8859,18 @@ export default withEnglishFallback({
missingConnection: "連線已不存在",
destination: "備份目錄",
selectDestination: "選擇備份目錄",
outputPathTemplate: "輸出路徑範本",
outputPathPreview: "輸出路徑預覽",
fileNamePattern: "檔案名稱範本",
fileNamePatternHint: "可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'}、{'{'}database{'}'} 和 {'{'}runId{'}'}。DBX 會自動補上缺少的資料庫名稱,並自動加入副檔名。",
templateVariablesHelp: "查看可用變數說明",
templateVariablesTitle: "可用變數",
templateVariableSchedule: "備份排程名稱",
templateVariableDate: "備份日期,格式為 YYYYMMDD",
templateVariableTimestamp: "備份輸出路徑中使用的時間戳",
templateVariableDatabase: "目前備份的資料庫目標名稱",
templateVariableRunId: "本次備份執行 ID 的前 8 個字元",
fileNamePatternInvalid: "請輸入不含路徑分隔符、保留字元及結尾句點或空白的檔案名稱。",
runDirectoryPattern: "執行目錄範本",
runDirectoryPatternHint: "相對於備份目錄的路徑。可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'} 和 {'{'}runId{'}'}。預設範本保留 {'{'}runId{'}'} 以獲得更好的隔離性。例如:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
runDirectoryPreview: "目錄預覽",
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupTableMatchesPattern, normalizeDatabaseBackupRun, resolveScheduledDatabaseBackupTableScope, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
import {
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
sanitizeDatabaseBackupFileSegment,
databaseBackupRunsToPrune,
databaseBackupTableMatchesPattern,
normalizeDatabaseBackupRun,
resolveScheduledDatabaseBackupTableScope,
type DatabaseBackupRun,
} from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
@@ -23,6 +32,24 @@ describe("database backup run persistence", () => {
expect(databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "gzip")).toMatch(/\.sql\.gz$/);
});
it("uses a custom file-name template without implicit run ID suffixes", () => {
const path = databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "none", "before-migration");
expect(path).toBe("/backups/before-migration__app.sql");
});
it("renders all file-name template variables without duplicating suffixes", () => {
const path = databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "none", "{schedule}__{date}__{database}__{runId}");
expect(path).toBe("/backups/Nightly__20260812__app__run-1234.sql");
});
it("rejects file-name templates that could form a path", () => {
expect(databaseBackupFileNamePatternIsValid("before-migration")).toBe(true);
expect(databaseBackupFileNamePatternIsValid("../before-migration")).toBe(false);
expect(databaseBackupFileNamePatternIsValid("before-migration ")).toBe(false);
});
it("accepts a one-shot run without a schedule id", () => {
const normalized = normalizeDatabaseBackupRun(run());
@@ -36,6 +63,12 @@ describe("database backup run persistence", () => {
);
});
it("keeps the backup destination root in persisted run records", () => {
const normalized = normalizeDatabaseBackupRun(run({ destinationDirectory: "/backups" }));
expect(normalized?.destinationDirectory).toBe("/backups");
});
it("keeps legacy runs compatible and treats them as scheduled history", () => {
const normalized = normalizeDatabaseBackupRun(run({ scheduleId: "schedule-1", source: undefined, scheduleName: "Nightly backup" }));
@@ -70,3 +103,16 @@ describe("database backup table pattern matching", () => {
expect(scope.excludedTables).toEqual(["vector_data"]);
});
});
describe("database backup file name segment sanitization", () => {
it("appends an underscore to Windows reserved device names", () => {
for (const reserved of ["con", "PRN", "Aux", "nul", "com3", "LPT9"]) {
expect(sanitizeDatabaseBackupFileSegment(reserved)).toBe(`${reserved}_`);
}
});
it("keeps ordinary segment names unchanged", () => {
expect(sanitizeDatabaseBackupFileSegment("nightly-full")).toBe("nightly-full");
expect(sanitizeDatabaseBackupFileSegment("con_backup")).toBe("con_backup");
});
});
+1 -1
View File
@@ -765,7 +765,7 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
throw new Error("Reveal in file manager is only available in the desktop app.");
}
export async function deleteDatabaseBackupFiles(_paths: string[]): Promise<number> {
export async function deleteDatabaseBackupFiles(_paths: string[], _allowedRoots: string[] = []): Promise<number> {
throw new Error("Database backup file management is only available in the desktop app.");
}
+3 -2
View File
@@ -2427,8 +2427,8 @@ export async function revealPathInFileManager(path: string): Promise<void> {
return invoke("reveal_path_in_file_manager", { path });
}
export async function deleteDatabaseBackupFiles(paths: string[]): Promise<number> {
return invoke("delete_database_backup_files", { paths });
export async function deleteDatabaseBackupFiles(paths: string[], allowedRoots: string[] = []): Promise<number> {
return invoke("delete_database_backup_files", { paths, allowedRoots });
}
export async function isSqliteDatabaseFile(path: string): Promise<boolean> {
@@ -4862,6 +4862,7 @@ export interface DatabaseExportRequest {
dropTableIfExists?: boolean;
omitAutoIncrement?: boolean;
failOnError?: boolean;
preventOverwrite?: boolean;
outputCompression?: "none" | "gzip";
snapshotSessionId?: string;
batchSize: number;
@@ -7,6 +7,7 @@ export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
export const MAX_DATABASE_BACKUP_HISTORY = 200;
export const DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN = "dbx-backup__{schedule}__{timestamp}__{runId}";
export const DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN = "dbx-backup__{schedule}__{timestamp}__{database}__{runId}";
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
@@ -120,6 +121,8 @@ export interface DatabaseBackupExecutionConfig {
includeObjects: boolean;
dropTableIfExists: boolean;
outputCompression: DatabaseBackupOutputCompression;
/** File-name template for each database target. The final extension is added automatically. */
fileNamePattern?: string;
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
@@ -152,6 +155,7 @@ export function toDatabaseBackupExecutionConfig(schedule: DatabaseBackupSchedule
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
outputCompression: schedule.outputCompression,
fileNamePattern: schedule.fileNamePattern,
};
}
@@ -169,6 +173,8 @@ export interface DatabaseBackupRun {
displayName?: string;
connectionId: string;
connectionName: string;
/** Selected root used to constrain cleanup of custom-named backup files. */
destinationDirectory?: string;
trigger: DatabaseBackupRunTrigger;
source: DatabaseBackupRunSource;
status: DatabaseBackupRunStatus;
@@ -251,6 +257,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
includeObjects: booleanValue(input.includeObjects, true),
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
outputCompression: input.outputCompression === "gzip" ? "gzip" : "none",
fileNamePattern: normalizeDatabaseBackupFileNamePattern(input.fileNamePattern),
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
runDirectoryPattern: normalizeDatabaseBackupRunDirectoryPattern(input.runDirectoryPattern),
createdAt: validIsoDate(input.createdAt, nowIso),
@@ -316,6 +323,7 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun |
displayName: stringValue(input.displayName).trim() || undefined,
connectionId: stringValue(input.connectionId).trim(),
connectionName: stringValue(input.connectionName).trim(),
destinationDirectory: stringValue(input.destinationDirectory).trim() || undefined,
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
source: input.source === "one-shot" ? "one-shot" : "scheduled",
status,
@@ -388,6 +396,10 @@ export function sanitizeDatabaseBackupFileSegment(value: string): string {
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/[. ]+$/g, "")
.trim();
// Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)不能作为文件名主体,补下划线规避。
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(sanitized)) {
return `${sanitized}_`;
}
return sanitized || "database";
}
@@ -429,6 +441,35 @@ export function databaseBackupRunDirectoryPatternIsValid(pattern: string): boole
}
}
/**
* A file-name template stays within the selected output directory. Empty and
* legacy configurations retain the previous file-name format.
*/
export function normalizeDatabaseBackupFileNamePattern(value: unknown): string {
return stringValue(value).trim() || DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN;
}
export function databaseBackupFileNamePatternIsValid(pattern: string): boolean {
const raw = stringValue(pattern);
if (!raw.trim()) return true;
return raw === raw.trim() && !/[\\/:*?"<>|]/.test(raw) && !/[. ]$/.test(raw);
}
function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string): string {
const normalized = normalizeDatabaseBackupFileNamePattern(pattern);
const timestamp = databaseBackupTimestamp(startedAt);
const variables = {
"{schedule}": sanitizeDatabaseBackupFileSegment(scheduleName),
"{date}": timestamp.slice(0, 8),
"{timestamp}": timestamp,
"{database}": sanitizeDatabaseBackupFileSegment(fileStem),
"{runId}": sanitizeDatabaseBackupFileSegment(runId).slice(0, 8),
};
const rendered = Object.entries(variables).reduce((value, [token, replacement]) => value.replaceAll(token, replacement), normalized);
const requiredSuffixes = [normalized.includes("{database}") ? "" : variables["{database}"]].filter(Boolean);
return [sanitizeDatabaseBackupFileSegment(rendered), ...requiredSuffixes].join("__");
}
export function databaseBackupTimestamp(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
const part = (number: number) => String(number).padStart(2, "0");
@@ -440,8 +481,8 @@ export function joinDatabaseBackupPath(directory: string, fileName: string): str
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none"): string {
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string): string {
const suffix = outputCompression === "gzip" ? ".sql.gz" : ".sql";
const fileName = `dbx-backup__${sanitizeDatabaseBackupFileSegment(scheduleName)}__${databaseBackupTimestamp(startedAt)}__${sanitizeDatabaseBackupFileSegment(fileStem)}__${sanitizeDatabaseBackupFileSegment(runId).slice(0, 8)}${suffix}`;
const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId)}${suffix}`;
return joinDatabaseBackupPath(directory, fileName);
}
@@ -16,6 +16,7 @@ const connectionDialogSource = readFileSync(new URL("../../components/connection
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const activeConnectionFilterSource = readFileSync(new URL("../../components/sidebar/ActiveConnectionFilterButton.vue", import.meta.url), "utf8");
const scheduledDatabaseBackupSource = readFileSync(new URL("../../components/backup/ScheduledDatabaseBackupSettings.vue", import.meta.url), "utf8");
const databaseBackupConfigFieldsSource = readFileSync(new URL("../../components/backup/DatabaseBackupConfigFields.vue", import.meta.url), "utf8");
const driverStoreDialogSource = readFileSync(new URL("../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
const tunnelProfileManagerSource = readFileSync(new URL("../../components/connection/TunnelProfileManager.vue", import.meta.url), "utf8");
const changelogPanelSource = readFileSync(new URL("../../components/settings/ChangelogPanel.vue", import.meta.url), "utf8");
@@ -88,6 +89,18 @@ describe("legacy WebView CSS fallbacks", () => {
expect(fallback).toContain(".space-y-2\\.5 > * + *");
expect(scheduledDatabaseBackupSource).toContain("dbx-form-dialog dbx-form-dialog--lg");
expect(scheduledDatabaseBackupSource).toContain("max-w-[min(720px,calc(100vw-32px))]");
expect(scheduledDatabaseBackupSource).toContain("overflow-x-hidden overflow-y-auto pr-8 [scrollbar-gutter:stable]");
expect(scheduledDatabaseBackupSource).toContain("dbx-backup-dialog");
expect(scheduledDatabaseBackupSource).toContain('class="backup-schedule-form grid gap-5 py-1"');
expect(databaseBackupConfigFieldsSource).toContain('class="backup-config-fields grid gap-5 py-1"');
expect(databaseBackupConfigFieldsSource).toContain('class="backup-destination-field"');
expect(databaseBackupConfigFieldsSource).toContain('class="backup-destination-picker"');
expect(databaseBackupConfigFieldsSource).toContain("padding-right: 2.5rem;");
expect(databaseBackupConfigFieldsSource).toContain("position: absolute;");
expect(databaseBackupConfigFieldsSource).toContain("grid-template-columns: minmax(0, 1fr);");
expect(databaseBackupConfigFieldsSource).toContain("overflow-wrap: anywhere;");
expect(databaseBackupConfigFieldsSource).toContain("word-break: break-all;");
expect(fallback).toContain('[data-slot="dialog-content"].dbx-backup-dialog > *');
expect(fallback).toContain('[data-slot="dialog-content"].dbx-form-dialog');
expect(fallback).toContain('[data-slot="dialog-content"].dbx-form-dialog--lg');
expect(fallback).toContain("max-width: 45rem !important;");
+6
View File
@@ -1908,6 +1908,12 @@ html.dbx-legacy-webview [data-slot="dialog-content"].dbx-form-dialog--lg {
max-width: 45rem !important;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-backup-dialog > * {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
}
html.dbx-legacy-webview [data-slot="dialog-content"].dbx-form-dialog [data-slot="select-trigger"] {
height: 2rem !important;
min-height: 2rem !important;
+43 -7
View File
@@ -117,6 +117,10 @@ pub struct DatabaseExportRequest {
pub omit_auto_increment: bool,
#[serde(default)]
pub fail_on_error: bool,
/// Refuse to truncate an existing destination. Scheduled backups enable
/// this because user-defined templates may resolve to a previous file.
#[serde(default)]
pub prevent_overwrite: bool,
#[serde(default)]
pub output_compression: DatabaseExportOutputCompression,
#[serde(default)]
@@ -2698,8 +2702,20 @@ async fn create_database_export_writer(
if let Some(parent) = export_destination_parent_dir(&request.file_path) {
expected_destination_identity = ensure_export_destination_dir(state, parent).await?;
}
let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?;
// The directory check above and this `File::create` are separate
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(!request.prevent_overwrite)
.create_new(request.prevent_overwrite)
.open(&request.file_path)
.map_err(|error| {
if request.prevent_overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
format!("Backup file already exists: {}", request.file_path)
} else {
format!("Failed to write file: {error}")
}
})?;
// The directory check above and this file open are separate
// operations: the mount can disappear and be replaced by something else
// at the same path in between. Re-check the identity of the handle we
// actually opened, not just the path, and refuse to keep a backup that
@@ -3869,11 +3885,11 @@ mod tests {
};
use super::{
build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements,
database_export_query_options_for_timeout, database_export_select_sql, database_export_total_objects,
drop_table_if_exists_sql, ensure_export_destination_dir, export_destination_identity_mismatch,
filter_export_table_infos, format_export_sql_literal, format_export_table_ddl,
format_mysql_spatial_export_literal, format_xugu_spatial_export_literal, generate_postgres_extension_ddl,
generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
create_database_export_writer, database_export_query_options_for_timeout, database_export_select_sql,
database_export_total_objects, drop_table_if_exists_sql, ensure_export_destination_dir,
export_destination_identity_mismatch, filter_export_table_infos, format_export_sql_literal,
format_export_table_ddl, format_mysql_spatial_export_literal, format_xugu_spatial_export_literal,
generate_postgres_extension_ddl, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
generate_postgres_sequence_setval_sql, is_postgres_extension_member_routine, mysql_database_export_preamble,
mysql_view_dependencies_from_rows, mysql_view_dependencies_sql, normalize_export_table_ddl,
record_export_destination_identity, record_export_error, replace_database_export_select_list,
@@ -4078,6 +4094,7 @@ mod tests {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: false,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -4101,6 +4118,25 @@ mod tests {
assert_eq!(output, "SELECT 1;\n");
}
#[tokio::test]
async fn backup_writer_does_not_overwrite_an_existing_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("existing.sql");
std::fs::write(&path, b"keep me").unwrap();
let state = Arc::new(test_app_state(directory.path()).await);
let mut request = export_request(true, true, true, Vec::new());
request.file_path = path.to_string_lossy().to_string();
request.prevent_overwrite = true;
let error = match create_database_export_writer(&state, &request).await {
Ok(_) => panic!("existing backup should not be overwritten"),
Err(error) => error,
};
assert!(error.contains("already exists"));
assert_eq!(std::fs::read(&path).unwrap(), b"keep me");
}
#[test]
fn export_progress_total_counts_only_requested_object_categories() {
let counts = DatabaseExportObjectCounts {
@@ -137,6 +137,7 @@ async fn run_database_export_of_partition_tree_has_no_duplicates_and_replays() {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -76,6 +76,7 @@ async fn run_database_export_writes_structure_and_data_for_all_tables() {
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -329,6 +329,7 @@ async fn live_postgres_backup_snapshot_exports_wide_jsonb_then_next_table_inner(
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1000,
@@ -442,6 +443,7 @@ async fn live_postgres_backup_snapshot_cancel_interrupts_pending_row_inner() {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1,
@@ -557,6 +559,7 @@ async fn live_mysql_database_backup_refreshes_an_idle_snapshot_before_export() {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 100,
@@ -78,6 +78,7 @@ async fn live_mysql_database_export_restores_dependent_views() {
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -212,6 +213,7 @@ async fn run_live_mysql_database_export_handles_many_tables_including_empty_tabl
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: false,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -305,6 +307,7 @@ async fn live_mysql_database_export_creates_missing_destination_directory() {
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -370,6 +373,7 @@ async fn live_mysql_database_export_refuses_to_recreate_a_destination_that_disap
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -456,6 +460,7 @@ async fn live_mysql_database_export_refuses_a_destination_that_vanished_before_i
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -101,6 +101,7 @@ async fn live_mysql_database_export_table_order_is_not_alphabetical_when_fk_reor
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
@@ -110,6 +110,7 @@ async fn live_postgres_all_schema_export_restores_one_sql_file() {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: true,
prevent_overwrite: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
+2 -2
View File
@@ -15,7 +15,7 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
| --- | --- |
| 数据库 | MySQL、PostgreSQL |
| 平台 | DBX 桌面端 |
| 输出 | 默认模板为每次计划运行创建独立目录;目录内按数据库/Schema 生成 `.sql` 文件,且可通过模板自定义目录结构 |
| 输出 | 默认模板为每次计划运行创建独立目录;目录内按数据库/Schema 生成 `.sql` 文件,且可通过模板自定义目录与文件名 |
| 频率 | 每隔若干小时、每天指定时间、每周指定星期和时间 |
| 内容 | 表结构、表数据、数据库对象、可选 `DROP TABLE IF EXISTS` |
| 表范围 | 全部、按模式包含、按模式排除 |
@@ -68,7 +68,7 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
<Step>
### 设置目录与频率
选择本机目标目录,设置相对运行目录模板,并配置每小时、每天或每周计划。模板支持 `{schedule}`、`{date}`、`{timestamp}` 和 `{runId}`。默认模板保留 `{runId}` 以获得更好的隔离性;也可仅使用时间戳,例如 `dbx-backup_{timestamp}`。界面会预览下次运行时间。
选择本机目标目录,设置由运行目录模板和文件名模板组成的输出路径模板,并配置每小时、每天或每周计划。目录模板支持 `{schedule}`、`{date}`、`{timestamp}` 和 `{runId}`;文件名模板还支持 `{database}`。DBX 会预览完整输出路径,并自动补齐文件名中缺失的数据库目标。运行 ID 只有在模板中显式填写 `{runId}` 时才会拼接;如果模板解析出的路径已被其他运行使用,DBX 会拒绝本次运行,避免覆盖已有备份。
</Step>
<Step>
+2 -2
View File
@@ -15,7 +15,7 @@ For a backup that should run only once, use **Back up now** in Settings instead
| --- | --- |
| Databases | MySQL and PostgreSQL |
| Platform | DBX Desktop |
| Output | The default template creates one directory per scheduled run, containing one `.sql` file per database/schema target; the directory structure can be customized with a template |
| Output | The default template creates one directory per scheduled run, containing one `.sql` file per database/schema target; directory and file-name templates can be customized |
| Frequency | Every N hours, daily at a time, or weekly on a weekday and time |
| Contents | Table structure, data, database objects, and optional `DROP TABLE IF EXISTS` |
| Table scope | All, include by pattern, or exclude by pattern |
@@ -68,7 +68,7 @@ For a backup that should run only once, use **Back up now** in Settings instead
<Step>
### Set directory and frequency
Select a local destination directory, choose a relative run-directory template, and configure hourly, daily, or weekly timing. Templates support `{schedule}`, `{date}`, `{timestamp}`, and `{runId}`. The default template includes `{runId}` for extra isolation, but you can use timestamp-only directories such as `dbx-backup_{timestamp}`. DBX previews the next run.
Select a local destination directory and configure the output path template, which combines a relative run-directory template with a file-name template. Directory templates support `{schedule}`, `{date}`, `{timestamp}`, and `{runId}`. File-name templates also support `{database}`. DBX previews the complete output path and automatically adds a missing database target. A run ID is included only when `{runId}` is explicitly present in the template; if a template resolves to a path already used by another run, DBX rejects the new run instead of overwriting the existing backup.
</Step>
<Step>
+7 -7
View File
@@ -135,14 +135,14 @@ test("backup file names are unique and safe for schema-aware exports", () => {
assert.equal(path, "C:\\backups\\dbx-backup__Nightly_ prod__20260716-020304__app_private__12345678.sql");
});
test("backup file names retain the target suffix without an implicit run suffix", () => {
const path = databaseBackupFilePath("C:\\backups", "Nightly", "app", new Date(2026, 6, 16, 2, 3, 4), "12345678-abcd", "gzip", "before-migration");
assert.equal(path, "C:\\backups\\before-migration__app.sql.gz");
});
test("scheduled runs render a user-defined relative directory template", () => {
const directory = databaseBackupRunDirectory(
"C:\\backups",
"archives/{schedule}/{date}/{timestamp}-{runId}",
"Nightly: prod",
new Date(2026, 6, 16, 2, 3, 4),
"12345678-abcd",
);
const directory = databaseBackupRunDirectory("C:\\backups", "archives/{schedule}/{date}/{timestamp}-{runId}", "Nightly: prod", new Date(2026, 6, 16, 2, 3, 4), "12345678-abcd");
assert.equal(directory, "C:\\backups\\archives\\Nightly_ prod\\20260716\\20260716020304-12345678");
assert.equal(databaseBackupRunDirectoryPatternIsValid("{schedule}/{runId}"), true);
+109 -18
View File
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use dbx_core::db::sqlite::path_has_sqlite_header;
use dbx_core::path_utils::expand_tilde;
@@ -81,7 +81,16 @@ pub async fn is_sqlite_database_file(path: String) -> Result<bool, String> {
path_has_sqlite_header(&resolved)
}
fn validate_database_backup_file(raw: &str) -> Result<PathBuf, String> {
fn validate_database_backup_root(raw: &str) -> Result<PathBuf, String> {
let expanded = expand_tilde(raw.trim());
let root = PathBuf::from(&expanded);
if !root.is_absolute() || root.components().any(|component| matches!(component, Component::ParentDir)) {
return Err(format!("backup root is not an absolute normalized path: {expanded}"));
}
std::fs::canonicalize(&root).map_err(|error| format!("failed to resolve backup root {}: {error}", root.display()))
}
fn validate_database_backup_file(raw: &str, allowed_roots: &[PathBuf]) -> Result<Option<PathBuf>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("backup file path is empty".to_string());
@@ -92,22 +101,47 @@ fn validate_database_backup_file(raw: &str) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("backup file path is not absolute: {expanded}"));
}
if path.components().any(|component| matches!(component, Component::ParentDir)) {
return Err(format!("backup file path is not normalized: {expanded}"));
}
let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
let lower_file_name = file_name.to_ascii_lowercase();
if !(lower_file_name.ends_with(".sql") || lower_file_name.ends_with(".sql.gz")) {
return Err(format!("backup file must use the .sql or .sql.gz extension: {expanded}"));
}
if !file_name.starts_with("dbx-backup__") {
return Err(format!("backup file name is not managed by DBX: {expanded}"));
if file_name.chars().any(char::is_control) {
return Err(format!("backup file name contains control characters: {expanded}"));
}
Ok(path)
if !file_name.starts_with("dbx-backup__") {
let resolved = match std::fs::canonicalize(&path) {
Ok(resolved) => resolved,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// 文件已不存在(例如目标目录已断开):能确认在受管根目录内的仍返回以便删除,
// 无法确认归属的直接跳过(Ok(None)),避免让同批其他运行记录的清理整体失败。
if allowed_roots.iter().any(|root| path.starts_with(root) && path != *root) {
return Ok(Some(path));
}
return Ok(None);
}
Err(error) => return Err(format!("failed to resolve custom backup file {}: {error}", path.display())),
};
if !allowed_roots.iter().any(|root| resolved.starts_with(root) && resolved != *root) {
return Err(format!("custom backup file is outside the managed backup directories: {expanded}"));
}
}
Ok(Some(path))
}
#[tauri::command]
pub async fn delete_database_backup_files(paths: Vec<String>) -> Result<usize, String> {
let resolved = paths.iter().map(|path| validate_database_backup_file(path)).collect::<Result<Vec<_>, _>>()?;
pub async fn delete_database_backup_files(paths: Vec<String>, allowed_roots: Vec<String>) -> Result<usize, String> {
// A disconnected destination must not prevent cleanup of files from the
// other selected runs. Invalid or unavailable roots simply grant no
// authority for custom-named files.
let roots = allowed_roots.iter().filter_map(|root| validate_database_backup_root(root).ok()).collect::<Vec<_>>();
let resolved =
paths.iter().map(|path| validate_database_backup_file(path, &roots)).collect::<Result<Vec<_>, _>>()?;
let mut deleted = 0;
for path in resolved {
for path in resolved.into_iter().flatten() {
match tokio::fs::remove_file(&path).await {
Ok(()) => deleted += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
@@ -163,30 +197,87 @@ mod tests {
}
#[test]
fn database_backup_file_requires_absolute_managed_sql_path() {
assert!(validate_database_backup_file("relative/backup.sql").is_err());
fn database_backup_file_requires_absolute_sql_path() {
assert!(validate_database_backup_file("relative/backup.sql", &[]).is_err());
let invalid_extension = if cfg!(windows) { "C:/tmp/backup.txt" } else { "/tmp/backup.txt" };
assert!(validate_database_backup_file(invalid_extension).is_err());
let unmanaged = if cfg!(windows) { "C:/tmp/backup.sql" } else { "/tmp/backup.sql" };
assert!(validate_database_backup_file(unmanaged).is_err());
assert!(validate_database_backup_file(invalid_extension, &[]).is_err());
// 不存在的自定义命名备份:归属无法确认时按跳过处理(Ok(None))而不是报错。
let missing = format!("before-migration__{}.sql", uuid::Uuid::new_v4().simple(),);
let custom_missing = if cfg!(windows) { format!("C:/tmp/{missing}") } else { format!("/tmp/{missing}") };
assert_eq!(validate_database_backup_file(&custom_missing, &[]), Ok(None));
let control_character =
if cfg!(windows) { "C:/tmp/before\n-migration.sql" } else { "/tmp/before\n-migration.sql" };
assert!(validate_database_backup_file(control_character, &[]).is_err());
let valid = if cfg!(windows) { "C:/tmp/dbx-backup__nightly.SQL" } else { "/tmp/dbx-backup__nightly.SQL" };
assert!(validate_database_backup_file(valid).is_ok());
assert!(validate_database_backup_file(valid, &[]).is_ok());
let valid_gzip =
if cfg!(windows) { "C:/tmp/dbx-backup__nightly.SQL.GZ" } else { "/tmp/dbx-backup__nightly.SQL.GZ" };
assert!(validate_database_backup_file(valid_gzip).is_ok());
assert!(validate_database_backup_file(valid_gzip, &[]).is_ok());
}
#[tokio::test]
async fn managed_gzip_backup_file_can_be_deleted() {
let path = std::env::temp_dir().join(format!("dbx-backup__delete-test-{}.sql.gz", uuid::Uuid::new_v4()));
async fn disconnected_root_does_not_abort_batch_cleanup() {
let scratch = std::env::temp_dir().join(format!("dbx-backup-mixed-{}", uuid::Uuid::new_v4()));
let connected = scratch.join("connected");
let disconnected = scratch.join("disconnected");
std::fs::create_dir_all(&connected).unwrap();
std::fs::create_dir_all(&disconnected).unwrap();
let kept = connected.join("before-migration__kept.sql");
std::fs::write(&kept, b"kept placeholder").unwrap();
let gone = disconnected.join("before-migration__gone.sql");
std::fs::write(&gone, b"gone placeholder").unwrap();
// 目标目录断开:整个目录被移除,文件路径随之失效。
std::fs::remove_dir_all(&disconnected).unwrap();
let deleted = delete_database_backup_files(
vec![kept.to_string_lossy().to_string(), gone.to_string_lossy().to_string()],
vec![connected.to_string_lossy().to_string(), disconnected.to_string_lossy().to_string()],
)
.await
.unwrap();
assert_eq!(deleted, 1);
assert!(!kept.exists());
let _ = std::fs::remove_dir_all(scratch);
}
#[tokio::test]
async fn custom_gzip_backup_file_can_be_deleted() {
let path = std::env::temp_dir().join(format!("before-migration__app-{}.sql.gz", uuid::Uuid::new_v4()));
std::fs::write(&path, b"gzip placeholder").unwrap();
let deleted = delete_database_backup_files(vec![path.to_string_lossy().to_string()]).await.unwrap();
let deleted = delete_database_backup_files(
vec![path.to_string_lossy().to_string()],
vec![std::env::temp_dir().to_string_lossy().to_string()],
)
.await
.unwrap();
assert_eq!(deleted, 1);
assert!(!path.exists());
}
#[tokio::test]
async fn custom_backup_outside_allowed_root_is_rejected() {
let scratch = std::env::temp_dir().join(format!("dbx-backup-root-test-{}", uuid::Uuid::new_v4()));
let allowed = scratch.join("allowed");
let outside = scratch.join("outside");
std::fs::create_dir_all(&allowed).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let path = outside.join("before-migration__app.sql");
std::fs::write(&path, b"do not delete").unwrap();
let result = delete_database_backup_files(
vec![path.to_string_lossy().to_string()],
vec![allowed.to_string_lossy().to_string()],
)
.await;
assert!(result.is_err());
assert!(path.exists());
let _ = std::fs::remove_dir_all(scratch);
}
#[test]
fn sqlite_header_is_detected() {
let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4()));