feat(backup): add one-shot database backups

This commit is contained in:
jearhe
2026-08-18 15:30:44 +08:00
committed by GitHub
parent f0cc5aefcf
commit 0facea2740
17 changed files with 888 additions and 179 deletions
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { FolderOpen, Loader2 } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
withDefaults(
defineProps<{
draft: DatabaseBackupExecutionConfig;
connections: Array<Pick<ConnectionConfig, "id" | "name">>;
allDatabases: boolean;
selectedDatabases: string[];
databaseOptions: string[];
tablePatternsInput: string;
loadingDatabases: boolean;
}>(),
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [] },
);
const emit = defineEmits<{
changeConnection: [connectionId: string];
chooseDestination: [];
toggleDatabase: [database: string];
"update:allDatabases": [value: boolean];
"update:tablePatternsInput": [value: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => emit('changeConnection', String(value))">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in connections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<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')">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input :checked="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" @change="emit('update:allDatabases', ($event.target as HTMLInputElement).checked)" />
{{ t("databaseBackup.allDatabases") }}
</label>
</div>
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="emit('toggleDatabase', database)" />
<span class="truncate">{{ database }}</span>
</label>
</div>
</div>
<div class="space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
<div class="space-y-2">
<Label>{{ t("databaseBackup.tableScope") }}</Label>
<Select v-model="draft.tableFilterMode">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
<Input :model-value="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" @update:model-value="(value: any) => emit('update:tablePatternsInput', String(value))" />
</div>
</div>
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
</div>
<div class="space-y-3">
<Label>{{ t("databaseBackup.contents") }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
</div>
</div>
</div>
</template>
@@ -11,19 +11,21 @@ import { Switch } from "@/components/ui/switch";
import { ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupExecutionConfig, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import { useConnectionStore } from "@/stores/connectionStore";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, cancelRun } = useScheduledDatabaseBackups();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const scheduleDialogOpen = ref(false);
const oneShotDialogOpen = ref(false);
const deleteScheduleDialogOpen = ref(false);
const deleteRunDialogOpen = ref(false);
const renameRunDialogOpen = ref(false);
@@ -34,6 +36,7 @@ const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
const renameRunName = ref("");
const loadingDatabases = ref(false);
const saving = ref(false);
const oneShotStarting = ref(false);
const databaseOptions = ref<string[]>([]);
const allDatabases = ref(true);
const selectedDatabases = ref<string[]>([]);
@@ -53,25 +56,31 @@ const weekdays = computed(() => [
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
]);
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
const now = new Date();
const draft: DatabaseBackupSchedule = {
id: generateDatabaseExportId(),
name: t("databaseBackup.defaultScheduleName"),
enabled: true,
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
return {
connectionId,
databases: [],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "",
frequency: "daily",
intervalHours: 6,
timeOfDay: "02:00",
weekday: 1,
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
};
}
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
const now = new Date();
const draft: DatabaseBackupSchedule = {
...newBackupConfig(connectionId),
id: generateDatabaseExportId(),
name: t("databaseBackup.defaultScheduleName"),
enabled: true,
frequency: "daily",
intervalHours: 6,
timeOfDay: "02:00",
weekday: 1,
retentionCount: 10,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
@@ -82,6 +91,18 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
}
const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
const oneShotDraft = ref<DatabaseBackupExecutionConfig>(newBackupConfig());
const activeDraft = computed<DatabaseBackupExecutionConfig>(() => (oneShotDialogOpen.value ? oneShotDraft.value : draft.value));
const activeOneShotRun = computed(() => activeRuns.value.find((run) => run.source === "one-shot"));
type BackupDialogKind = "schedule" | "one-shot";
let databaseLoadGeneration = 0;
function databaseLoadIsCurrent(generation: number, dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, connectionId: string): boolean {
if (generation !== databaseLoadGeneration) return false;
if (dialog === "one-shot") return oneShotDialogOpen.value && oneShotDraft.value === targetDraft && targetDraft.connectionId === connectionId;
return scheduleDialogOpen.value && draft.value === targetDraft && targetDraft.connectionId === connectionId;
}
const canSave = computed(() => {
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
@@ -89,6 +110,12 @@ const canSave = computed(() => {
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
});
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
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;
});
function connectionName(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
@@ -135,55 +162,67 @@ function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
}
async function loadDatabases(connectionId: string, preserveSelection: boolean) {
async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, preserveSelection: boolean) {
const generation = ++databaseLoadGeneration;
const connectionId = targetDraft.connectionId;
databaseOptions.value = [];
if (!connectionId) return;
if (!preserveSelection) {
selectedDatabases.value = [];
allDatabases.value = true;
targetDraft.tableFilterMode = "all";
targetDraft.tablePatterns = [];
tablePatternsInput.value = "";
}
if (!connectionId) {
loadingDatabases.value = false;
return;
}
loadingDatabases.value = true;
try {
await connectionStore.ensureConnected(connectionId);
const config = connectionStore.getConfig(connectionId);
const names = config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name);
if (!databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) return;
databaseOptions.value = names;
if (preserveSelection) {
const selected = new Set(selectedDatabases.value);
selectedDatabases.value = names.filter((database) => selected.has(database));
} else {
selectedDatabases.value = [];
allDatabases.value = true;
draft.value.tableFilterMode = "all";
draft.value.tablePatterns = [];
tablePatternsInput.value = "";
}
} catch (error: any) {
toast(error?.message || String(error), 5000);
if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) toast(error?.message || String(error), 5000);
} finally {
loadingDatabases.value = false;
if (generation === databaseLoadGeneration) loadingDatabases.value = false;
}
}
async function openCreateSchedule() {
oneShotDialogOpen.value = false;
editingScheduleId.value = "";
draft.value = newScheduleDraft();
const nextDraft = newScheduleDraft();
draft.value = nextDraft;
allDatabases.value = true;
selectedDatabases.value = [];
tablePatternsInput.value = "";
scheduleDialogOpen.value = true;
await loadDatabases(draft.value.connectionId, false);
await loadDatabases("schedule", draft.value, false);
}
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
oneShotDialogOpen.value = false;
editingScheduleId.value = schedule.id;
draft.value = { ...schedule, databases: [...schedule.databases], tablePatterns: [...schedule.tablePatterns] };
allDatabases.value = schedule.databases.length === 0;
selectedDatabases.value = [...schedule.databases];
tablePatternsInput.value = schedule.tablePatterns.join(", ");
scheduleDialogOpen.value = true;
await loadDatabases(schedule.connectionId, true);
await loadDatabases("schedule", draft.value, true);
}
async function changeConnection(connectionId: string) {
draft.value.connectionId = connectionId;
await loadDatabases(connectionId, false);
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
targetDraft.connectionId = connectionId;
await loadDatabases(dialog, targetDraft, false);
}
function toggleDatabase(database: string) {
@@ -196,7 +235,7 @@ function toggleDatabase(database: string) {
async function chooseDestination() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
if (typeof selected === "string") draft.value.destinationDirectory = selected;
if (typeof selected === "string") activeDraft.value.destinationDirectory = selected;
}
async function submitSchedule() {
@@ -218,6 +257,47 @@ async function submitSchedule() {
}
}
async function openOneShotBackup() {
scheduleDialogOpen.value = false;
const nextDraft = newBackupConfig();
oneShotDraft.value = nextDraft;
allDatabases.value = true;
selectedDatabases.value = [];
tablePatternsInput.value = "";
oneShotDialogOpen.value = true;
await loadDatabases("one-shot", oneShotDraft.value, false);
}
async function startOneShotBackup() {
if (!canStartOneShot.value) return;
oneShotStarting.value = true;
try {
await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
const run = await runOneShot(
{
...oneShotDraft.value,
databases: allDatabases.value ? [] : [...selectedDatabases.value],
tablePatterns: oneShotDraft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
},
t("databaseBackup.oneShotName"),
);
if (!run) return;
oneShotDialogOpen.value = false;
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
else toast(t("databaseBackup.runFailed", { error: run.error ? translateBackendError(t, run.error) : t("databaseBackup.unknownError") }), 5000);
} catch (error: any) {
toast(translateBackendError(t, error), 5000);
} finally {
oneShotStarting.value = false;
}
}
async function cancelActiveOneShotBackup() {
const run = activeOneShotRun.value;
if (run) await cancelRun(run.id);
}
async function runNow(schedule: DatabaseBackupSchedule) {
try {
const run = await runSchedule(schedule.id, "manual");
@@ -309,16 +389,29 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
</div>
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
<Plus class="mr-2 h-4 w-4" />
{{ t("databaseBackup.addSchedule") }}
</Button>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.oneShotBackup') : t('databaseBackup.noSupportedConnections')" @click="openOneShotBackup">
<Play class="mr-2 h-4 w-4" />
{{ t("databaseBackup.oneShotBackup") }}
</Button>
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
<Plus class="mr-2 h-4 w-4" />
{{ t("databaseBackup.addSchedule") }}
</Button>
</div>
</div>
<div class="overflow-hidden rounded-md border border-border/70">
<div v-if="schedules.length === 0" class="flex min-h-36 flex-col items-center justify-center gap-2 px-4 py-8 text-center text-muted-foreground">
<div v-if="schedules.length === 0" class="flex min-h-44 flex-col items-center justify-center gap-3 px-4 py-8 text-center text-muted-foreground">
<DatabaseBackup class="h-8 w-8 opacity-60" />
<span class="text-sm">{{ t("databaseBackup.noSchedules") }}</span>
<div>
<div class="text-sm font-medium text-foreground">{{ t("databaseBackup.noSchedules") }}</div>
<p class="mt-1 text-sm">{{ t("databaseBackup.noSchedulesHint") }}</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" @click="openOneShotBackup"><Play class="mr-2 h-4 w-4" />{{ t("databaseBackup.oneShotBackup") }}</Button>
<Button size="sm" :disabled="!canCreateSchedule" @click="openCreateSchedule"><Plus class="mr-2 h-4 w-4" />{{ t("databaseBackup.addSchedule") }}</Button>
</div>
</div>
<div v-for="schedule in schedules" :key="schedule.id" class="grid gap-3 border-b border-border/70 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div class="min-w-0">
@@ -367,7 +460,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.source === "one-shot" ? t("databaseBackup.oneShotTrigger") : run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ run.connectionName || connectionName(run.connectionId) }}</span>
@@ -383,7 +476,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="flex items-center justify-end gap-1">
<Loader2 v-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button v-if="activeRunIds.has(run.id) && run.source === 'one-shot'" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(run.id)">
<Square class="h-4 w-4" />
</Button>
<Loader2 v-else-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
<Pencil class="h-4 w-4" />
</Button>
@@ -424,69 +520,25 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogHeader>
<div class="grid gap-5 py-1">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => changeConnection(String(value))">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<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="chooseDestination">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input v-model="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" />
{{ t("databaseBackup.allDatabases") }}
</label>
</div>
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="toggleDatabase(database)" />
<span class="truncate">{{ database }}</span>
</label>
</div>
</div>
<div class="space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
<div class="space-y-2">
<Label>{{ t("databaseBackup.tableScope") }}</Label>
<Select v-model="draft.tableFilterMode">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
<Input v-model="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" />
</div>
</div>
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
</div>
<DatabaseBackupConfigFields
:draft="draft"
:connections="sqlConnections"
:all-databases="allDatabases"
:selected-databases="selectedDatabases"
:database-options="databaseOptions"
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="(value: boolean) => (allDatabases = value)"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
/>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-2">
@@ -524,16 +576,6 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
<div class="space-y-3">
<Label>{{ t("databaseBackup.contents") }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
</div>
</div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 pt-4">
<div>
<Label>{{ t("databaseBackup.enabled") }}</Label>
@@ -553,6 +595,41 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogContent>
</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">
<DialogHeader>
<DialogTitle>{{ t("databaseBackup.oneShotBackup") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.oneShotDescription") }}</p>
</DialogHeader>
<DatabaseBackupConfigFields
:draft="oneShotDraft"
:connections="sqlConnections"
:all-databases="allDatabases"
:selected-databases="selectedDatabases"
:database-options="databaseOptions"
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="(value: boolean) => (allDatabases = value)"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
/>
<DialogFooter>
<Button variant="outline" @click="oneShotDialogOpen = false">{{ oneShotStarting ? t("common.close") : t("common.cancel") }}</Button>
<Button v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun" :title="t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
<Square class="mr-2 h-4 w-4" />
{{ t("databaseBackup.cancel") }}
</Button>
<Button v-else :disabled="!canStartOneShot" @click="startOneShotBackup">
{{ t("databaseBackup.startBackup") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="deleteScheduleDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader
@@ -2,15 +2,18 @@
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import type { DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import i18n from "../../../i18n";
import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.vue";
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
connections: [] as Array<{ id: string; name: string; db_type: string }>,
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
activeRunIds: new Set<string>(),
activeRuns: [] as DatabaseBackupRun[],
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async () => [{ name: "app" }]),
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
toast: vi.fn(),
saveSchedule: vi.fn(),
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
runSchedule: vi.fn(),
runOneShot: vi.fn(),
cancelRun: vi.fn(),
}));
@@ -33,15 +37,16 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
schedules: { __v_isRef: true, value: mocks.schedules },
runs: { __v_isRef: true, value: [] },
runs: { __v_isRef: true, value: mocks.runs },
activeScheduleIds: new Set<string>(),
activeRunIds: new Set<string>(),
activeRuns: { __v_isRef: true, value: [] },
activeRunIds: mocks.activeRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
runSchedule: mocks.runSchedule,
runOneShot: mocks.runOneShot,
cancelRun: mocks.cancelRun,
}),
}));
@@ -50,6 +55,10 @@ vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => "/backups"),
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
@@ -75,6 +84,58 @@ async function mountSettings() {
await flush();
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function currentDialog(): HTMLElement {
const dialog = document.body.querySelector<HTMLElement>('[data-slot="dialog-content"]');
if (!dialog) throw new Error("Dialog not found");
return dialog;
}
async function selectDialogOption(triggerIndex: number, optionText: string) {
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find((item) => item.textContent?.trim() === optionText);
if (!option) throw new Error(`Select option not found: ${optionText}`);
option.focus();
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flush();
}
async function setTablePattern(pattern: string) {
await selectDialogOption(1, String(i18n.global.t("databaseBackup.includeTables")));
const input = currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`);
if (!input) throw new Error("Table pattern input not found");
input.value = pattern;
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
}
async function showDatabaseOptions() {
const label = String(i18n.global.t("databaseBackup.allDatabases"));
const checkbox = Array.from(currentDialog().querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find((item) => item.parentElement?.textContent?.includes(label));
if (!checkbox) throw new Error("All databases checkbox not found");
checkbox.checked = false;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
await flush();
}
function buttonWithText(text: string): HTMLButtonElement {
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
function addScheduleButton(): HTMLButtonElement {
const label = String(i18n.global.t("databaseBackup.addSchedule"));
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
@@ -127,12 +188,19 @@ afterEach(() => {
document.body.innerHTML = "";
mocks.connections.splice(0);
mocks.schedules.splice(0);
mocks.runs.splice(0);
mocks.activeRuns.splice(0);
mocks.activeRunIds.clear();
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockClear();
mocks.listDatabases.mockReset();
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
mocks.recordDatabaseExportDestination.mockReset();
mocks.recordDatabaseExportDestination.mockResolvedValue(undefined);
mocks.toast.mockClear();
mocks.saveSchedule.mockClear();
mocks.cancelRun.mockClear();
mocks.runOneShot.mockReset();
mocks.runOneShot.mockResolvedValue(null);
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
@@ -153,6 +221,142 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
});
it("opens an independent one-shot dialog without schedule fields", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.oneShotDescription")));
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.connection")));
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.contents")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.scheduleName")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.frequency")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.retention")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.enabled")));
});
it("validates one-shot fields and starts without creating a schedule", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.runOneShot.mockResolvedValueOnce({ id: "run-1", status: "success", files: [], scheduleName: "One-time backup" });
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
const startLabel = String(i18n.global.t("databaseBackup.startBackup"));
const startButton = Array.from(dialog?.querySelectorAll("button") ?? []).find((item) => item.textContent?.trim() === startLabel) as HTMLButtonElement | undefined;
expect(startButton?.disabled).toBe(true);
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
expect(startButton?.disabled).toBe(false);
startButton?.click();
await flush();
expect(mocks.schedules).toHaveLength(0);
expect(mocks.saveSchedule).not.toHaveBeenCalled();
expect(mocks.runOneShot).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "mysql-1", destinationDirectory: "/backups", databases: [] }), String(i18n.global.t("databaseBackup.oneShotName")));
});
it("keeps the newer connection database list when an older response finishes last", async () => {
mocks.connections.push({ id: "mysql-a", name: "MySQL A", db_type: "mysql" }, { id: "mysql-b", name: "MySQL B", db_type: "mysql" });
const firstLoad = deferred<Array<{ name: string }>>();
const secondLoad = deferred<Array<{ name: string }>>();
mocks.listDatabases.mockImplementation((connectionId: string) => (connectionId === "mysql-a" ? firstLoad.promise : secondLoad.promise));
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
await selectDialogOption(0, "MySQL B");
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
secondLoad.resolve([{ name: "new_database" }]);
await flush();
await showDatabaseOptions();
expect(currentDialog().textContent).toContain("new_database");
firstLoad.resolve([{ name: "stale_database" }]);
await flush();
expect(currentDialog().textContent).toContain("new_database");
expect(currentDialog().textContent).not.toContain("stale_database");
});
it("does not let a closed one-shot request clear a schedule draft", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
const oneShotLoad = deferred<Array<{ name: string }>>();
const scheduleLoad = deferred<Array<{ name: string }>>();
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise).mockImplementationOnce(() => scheduleLoad.promise);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(1));
const closeOneShot = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.cancel")));
closeOneShot?.click();
await flush();
addScheduleButton().click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
await setTablePattern("schedule_*");
oneShotLoad.resolve([{ name: "one_shot_database" }]);
await flush();
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
scheduleLoad.resolve([{ name: "schedule_database" }]);
await flush();
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
});
it("keeps closing the one-shot dialog separate from cancelling the active run", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
const activeRun = {
id: "one-shot-active",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
progressPercent: 25,
} satisfies DatabaseBackupRun;
mocks.runs.push(activeRun);
mocks.activeRuns.push(activeRun);
mocks.activeRunIds.add(activeRun.id);
const pendingRun = deferred<DatabaseBackupRun>();
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
const startButton = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("databaseBackup.startBackup")));
startButton?.click();
await flush();
const runningDialog = currentDialog();
const closeButton = Array.from(runningDialog.querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.close")));
const cancelButton = Array.from(runningDialog.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.title === String(i18n.global.t("databaseBackup.cancel")));
expect(closeButton).toBeTruthy();
expect(cancelButton?.disabled).toBe(false);
closeButton?.click();
await flush();
expect(mocks.cancelRun).not.toHaveBeenCalled();
buttonWithTitle(String(i18n.global.t("databaseBackup.cancel"))).click();
await flush();
expect(mocks.cancelRun).toHaveBeenCalledWith(activeRun.id);
pendingRun.resolve({ ...activeRun, status: "cancelled", completedAt: "2026-08-18T00:01:00.000Z" });
await flush();
});
it("disables create schedule when there are no supported backup connections", async () => {
mocks.connections.push({ id: "oracle-1", name: "Oracle", db_type: "oracle" });
await mountSettings();
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
listDatabases: vi.fn(async () => [{ name: "app" }]),
beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
rollbackSnapshot: vi.fn(async () => {}),
deleteFiles: vi.fn(async () => 0),
cancelExport: vi.fn(async () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
nextId: 0,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
}));
vi.mock("@/lib/export/databaseExport", () => ({
buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
generateDatabaseExportId: () => `run-${++mocks.nextId}`,
runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: () => mocks.connection,
ensureConnected: vi.fn(async () => {}),
initFromDisk: vi.fn(async () => {}),
}),
}));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
}),
}));
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
const config: DatabaseBackupExecutionConfig = {
connectionId: "mysql-1",
databases: ["app"],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "/backups",
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
};
describe("useScheduledDatabaseBackups one-shot execution", () => {
it("executes one-shot backups through the shared exporter without a schedule", async () => {
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
});
const backup = useScheduledDatabaseBackups();
const scheduleCountBefore = backup.schedules.value.length;
const run = await backup.runOneShot(config, "One-time backup");
expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
expect(run).not.toHaveProperty("nextRunAt");
expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
});
it("prevents a second one-shot while one is already running", async () => {
const backup = useScheduledDatabaseBackups();
const activeRun = {
id: "active-one-shot",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: new Date().toISOString(),
files: [],
} satisfies DatabaseBackupRun;
backup.runs.value.push(activeRun);
backup.activeRunIds.add(activeRun.id);
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
try {
expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
backup.activeRunIds.delete(activeRun.id);
backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
}
});
it("cancels an active one-shot through the shared export cancellation path", async () => {
mocks.cancelExport.mockClear();
mocks.deleteFiles.mockClear();
let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
mocks.runDatabaseExport.mockImplementationOnce(
(_request: unknown, onProgress: (progress: unknown) => void) =>
new Promise((resolve) => {
resolveExport = (progress) => {
onProgress(progress);
resolve(progress);
};
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)]);
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
});
});
@@ -22,10 +22,13 @@ import {
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
toDatabaseBackupExecutionConfig,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupExecutionConfig,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunSource,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
@@ -72,6 +75,8 @@ function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = t
return updated;
}
type FinishedDatabaseBackupRun = Omit<DatabaseBackupRun, "status"> & { status: Exclude<DatabaseBackupRunStatus, "running"> };
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
@@ -158,54 +163,52 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
}
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
const connection = connectionStore.getConfig(schedule.connectionId);
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
const backupName = request.displayName;
const connection = connectionStore.getConfig(config.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const run: DatabaseBackupRun = {
id: runId,
scheduleId: schedule.id,
scheduleName: schedule.name,
connectionId: schedule.connectionId,
scheduleId: request.scheduleId,
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
trigger,
trigger: request.trigger,
source: request.source,
status: "running",
startedAt: startedAt.toISOString(),
files: [],
progressPercent: 0,
};
replaceRun(run);
activeScheduleIds.add(schedule.id);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory, "scheduled");
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, () => cancelRun(runId));
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: DatabaseBackupRun | null = null;
let finishedRun: FinishedDatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
await databaseBackupConnectionQueue.run(schedule.connectionId, async () => {
await databaseBackupConnectionQueue.run(config.connectionId, async () => {
try {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
await connectionStore.ensureConnected(schedule.connectionId);
const availableDatabases = (await api.listDatabases(schedule.connectionId)).map((database) => database.name);
const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases, connection.db_type);
await connectionStore.ensureConnected(config.connectionId);
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
let tableNamesCaseSensitive = true;
if (connection.db_type === "mysql" && schedule.tableFilterMode !== "all") {
if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
const result = await api.executeQuery(config.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]);
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
@@ -219,7 +222,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
@@ -228,18 +231,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (schedule.tableFilterMode === "all") {
if (config.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
@@ -250,21 +253,21 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(schedule.destinationDirectory, schedule.name, item.fileStem, startedAt, runId);
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId);
generatedPaths.push(filePath);
const terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: schedule.connectionId,
connectionId: config.connectionId,
database: item.database,
schema: item.schema,
filePath,
selectedTables: item.selectedTables,
excludedTables: item.excludedTables,
includeStructure: schedule.includeStructure,
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
includeStructure: config.includeStructure,
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
failOnError: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
@@ -322,8 +325,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (schedule.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`);
if (config.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${config.tableFilterMode} backup rules.`);
}
} catch (error: any) {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
@@ -342,20 +345,20 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
activeScheduleIds.delete(schedule.id);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
finishedRun = updateRun(runId, {
const updatedRun = updateRun(runId, {
status: finalStatus,
completedAt: completedAt.toISOString(),
files: [...run.files],
progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
error: finalError || undefined,
});
finishedRun = updatedRun && updatedRun.status !== "running" ? (updatedRun as FinishedDatabaseBackupRun) : null;
updateDatabaseExportTask(runId, {
exportId: runId,
currentObject: schedule.name,
currentObject: backupName,
objectIndex: finalStatus === "success" ? run.files.length : 0,
totalObjects: run.files.length,
rowsExported: 0,
@@ -365,24 +368,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
});
const latestSchedule = schedules.value.find((item) => item.id === schedule.id);
if (latestSchedule) {
schedules.value = schedules.value.map((item) =>
item.id === schedule.id
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: finalStatus,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (finalStatus === "success") await pruneScheduleRuns(latestSchedule);
}
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
scheduleId: schedule.id,
scheduleId: request.scheduleId,
runId,
files: run.files.length,
error: finalError || undefined,
@@ -392,6 +379,44 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
return finishedRun;
}
async function finalizeScheduledRun(scheduleId: string, trigger: DatabaseBackupRunTrigger, run: FinishedDatabaseBackupRun) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
const completedAt = run.completedAt ? new Date(run.completedAt) : new Date();
schedules.value = schedules.value.map((item) =>
item.id === scheduleId
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: run.status,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (run.status === "success") await pruneScheduleRuns(schedule);
}
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
activeScheduleIds.add(scheduleId);
try {
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), { source: "scheduled", trigger, scheduleId, displayName: schedule.name });
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
return run;
} finally {
activeScheduleIds.delete(scheduleId);
}
}
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup"): Promise<FinishedDatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("One-shot database backups are only available in the desktop app.");
if (activeRuns.value.some((run) => run.source === "one-shot")) return null;
return runBackup(config, { source: "one-shot", trigger: "manual", displayName });
}
async function processDueSchedules() {
if (processingDueSchedules) return;
processingDueSchedules = true;
@@ -453,6 +478,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
deleteRun,
renameRun,
runSchedule,
runOneShot,
cancelRun,
processDueSchedules,
};
+6
View File
@@ -6567,10 +6567,15 @@ export default {
schedules: "Backup schedules",
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
addSchedule: "Add schedule",
oneShotBackup: "Back up now",
oneShotDescription: "Run a one-time database backup. It will appear in backup history when complete.",
startBackup: "Start backup",
noSupportedConnections: "Create a MySQL or PostgreSQL connection before adding a backup schedule.",
editSchedule: "Edit schedule",
defaultScheduleName: "Database backup",
oneShotName: "One-time backup",
noSchedules: "No backup schedules",
noSchedulesHint: "Create a recurring backup schedule, or run a one-time backup now.",
scheduleName: "Schedule name",
connection: "Connection",
missingConnection: "Missing connection",
@@ -6622,6 +6627,7 @@ export default {
showFiles: "Show backup files",
scheduledTrigger: "Scheduled",
manualTrigger: "Manual",
oneShotTrigger: "One-time",
fileCount: "{count} files",
progress: "Backup progress",
renameBackup: "Rename backup",
+6
View File
@@ -7710,6 +7710,12 @@ export default withEnglishFallback({
friday: "Viernes",
saturday: "Sábado",
},
oneShotBackup: "Copia de seguridad inmediata",
oneShotDescription: "Ejecutar una copia de seguridad de la base de datos inmediatamente; después de completarse, aparecerá en el historial de copias de seguridad.",
startBackup: "Iniciar copia de seguridad",
oneShotName: "Copia de seguridad única",
noSchedulesHint: "Puede crear un plan de copia de seguridad programado o ejecutar una copia de seguridad inmediata.",
oneShotTrigger: "Copia de seguridad única",
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
+6
View File
@@ -7710,6 +7710,12 @@ export default withEnglishFallback({
friday: "Venerdì",
saturday: "Sabato",
},
oneShotBackup: "Backup immediato",
oneShotDescription: "Esegui immediatamente un backup del database; al termine apparirà nella cronologia dei backup.",
startBackup: "Avvia backup",
oneShotName: "Backup una tantum",
noSchedulesHint: "Puoi creare pianificazioni di backup periodiche o eseguire subito un backup.",
oneShotTrigger: "Backup una tantum",
},
mqExchanges: {
searchPlaceholder: "Cerca exchange",
+6
View File
@@ -7763,6 +7763,12 @@ export default withEnglishFallback({
friday: "金曜日",
saturday: "土曜日",
},
oneShotBackup: "今すぐバックアップ",
oneShotDescription: "データベースのバックアップを今すぐ実行します。完了すると、バックアップ履歴に表示されます。",
startBackup: "バックアップを開始",
oneShotName: "ワンタイムバックアップ",
noSchedulesHint: "定期的なバックアップスケジュールを作成するか、今すぐバックアップを実行できます。",
oneShotTrigger: "ワンタイムバックアップ",
},
mqExchanges: {
searchPlaceholder: "エクスチェンジを検索",
+6
View File
@@ -7712,6 +7712,12 @@ export default withEnglishFallback({
friday: "Sexta-feira",
saturday: "Sábado",
},
oneShotBackup: "Backup imediato",
oneShotDescription: "Execute um backup do banco de dados imediatamente; quando concluído, ele aparecerá no histórico de backups.",
startBackup: "Iniciar backup",
oneShotName: "Backup único",
noSchedulesHint: "Você pode criar um agendamento de backup recorrente ou executar um backup imediatamente.",
oneShotTrigger: "Backup único",
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
+6
View File
@@ -6552,10 +6552,15 @@ export default withEnglishFallback({
schedules: "备份计划",
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
addSchedule: "新建计划",
oneShotBackup: "立即备份",
oneShotDescription: "立即执行一次数据库备份,完成后将出现在备份历史中。",
startBackup: "开始备份",
noSupportedConnections: "请先创建 MySQL 或 PostgreSQL 连接,再新建备份计划。",
editSchedule: "编辑计划",
defaultScheduleName: "数据库备份",
oneShotName: "一次性备份",
noSchedules: "暂无备份计划",
noSchedulesHint: "你可以创建定时备份计划,或立即执行一次备份。",
scheduleName: "计划名称",
connection: "连接",
missingConnection: "连接已不存在",
@@ -6607,6 +6612,7 @@ export default withEnglishFallback({
showFiles: "显示备份文件",
scheduledTrigger: "定时执行",
manualTrigger: "手动执行",
oneShotTrigger: "一次性备份",
fileCount: "{count} 个文件",
progress: "备份进度",
renameBackup: "重命名备份",
+6
View File
@@ -7703,6 +7703,12 @@ export default withEnglishFallback({
friday: "星期五",
saturday: "星期六",
},
oneShotBackup: "立即備份",
oneShotDescription: "立即執行一次資料庫備份,完成後將出現在備份歷史中。",
startBackup: "開始備份",
oneShotName: "一次性備份",
noSchedulesHint: "你可以建立定時備份計劃,或立即執行一次備份。",
oneShotTrigger: "一次性備份",
},
mqExchanges: {
searchPlaceholder: "搜尋交換機",
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { databaseBackupRunsToPrune, normalizeDatabaseBackupRun, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
function run(overrides: Record<string, unknown> = {}) {
return {
id: "run-1",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "success",
startedAt,
files: [],
...overrides,
};
}
describe("database backup run persistence", () => {
it("accepts a one-shot run without a schedule id", () => {
const normalized = normalizeDatabaseBackupRun(run());
expect(normalized).toEqual(
expect.objectContaining({
id: "run-1",
scheduleId: undefined,
source: "one-shot",
trigger: "manual",
}),
);
});
it("keeps legacy runs compatible and treats them as scheduled history", () => {
const normalized = normalizeDatabaseBackupRun(run({ scheduleId: "schedule-1", source: undefined, scheduleName: "Nightly backup" }));
expect(normalized).toEqual(expect.objectContaining({ scheduleId: "schedule-1", source: "scheduled", scheduleName: "Nightly backup" }));
});
it("does not include one-shot runs in schedule retention pruning", () => {
const scheduledRuns = [run({ id: "scheduled-old", scheduleId: "schedule-1", source: "scheduled", startedAt: "2026-08-10T00:00:00.000Z" }), run({ id: "scheduled-new", scheduleId: "schedule-1", source: "scheduled", startedAt: "2026-08-11T00:00:00.000Z" })];
const oneShotRun = run({ id: "one-shot", scheduleId: undefined, source: "one-shot", startedAt: "2026-08-12T00:00:00.000Z" });
const normalizedRuns = [...scheduledRuns, oneShotRun].map((value) => normalizeDatabaseBackupRun(value)!).filter((value): value is DatabaseBackupRun => !!value);
expect(databaseBackupRunsToPrune(normalizedRuns, "schedule-1", 1).map((value) => value.id)).toEqual(["scheduled-old"]);
});
});
@@ -11,6 +11,7 @@ export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled";
export type DatabaseBackupRunTrigger = "manual" | "scheduled";
export type DatabaseBackupRunSource = "scheduled" | "one-shot";
export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude";
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
@@ -106,23 +107,26 @@ export function resolveScheduledDatabaseBackupTableScope(mode: DatabaseBackupTab
};
}
export interface DatabaseBackupSchedule {
id: string;
name: string;
enabled: boolean;
export interface DatabaseBackupExecutionConfig {
connectionId: string;
databases: string[];
tableFilterMode: DatabaseBackupTableFilterMode;
tablePatterns: string[];
destinationDirectory: string;
frequency: DatabaseBackupFrequency;
intervalHours: number;
timeOfDay: string;
weekday: number;
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;
dropTableIfExists: boolean;
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
id: string;
name: string;
enabled: boolean;
frequency: DatabaseBackupFrequency;
intervalHours: number;
timeOfDay: string;
weekday: number;
retentionCount: number;
createdAt: string;
updatedAt: string;
@@ -131,6 +135,20 @@ export interface DatabaseBackupSchedule {
lastRunStatus?: Exclude<DatabaseBackupRunStatus, "running">;
}
export function toDatabaseBackupExecutionConfig(schedule: DatabaseBackupSchedule): DatabaseBackupExecutionConfig {
return {
connectionId: schedule.connectionId,
databases: [...schedule.databases],
tableFilterMode: schedule.tableFilterMode,
tablePatterns: [...schedule.tablePatterns],
destinationDirectory: schedule.destinationDirectory,
includeStructure: schedule.includeStructure,
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
};
}
export interface DatabaseBackupFile {
database: string;
schema: string;
@@ -140,12 +158,13 @@ export interface DatabaseBackupFile {
export interface DatabaseBackupRun {
id: string;
scheduleId: string;
scheduleId?: string;
scheduleName: string;
displayName?: string;
connectionId: string;
connectionName: string;
trigger: DatabaseBackupRunTrigger;
source: DatabaseBackupRunSource;
status: DatabaseBackupRunStatus;
startedAt: string;
completedAt?: string;
@@ -278,9 +297,9 @@ function normalizeDatabaseBackupFile(value: unknown): DatabaseBackupFile | null
export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | null {
const input = recordValue(value);
const id = stringValue(input.id).trim();
const scheduleId = stringValue(input.scheduleId).trim();
const scheduleId = stringValue(input.scheduleId).trim() || undefined;
const startedAt = validIsoDate(input.startedAt, "");
if (!id || !scheduleId || !startedAt) return null;
if (!id || !startedAt) return null;
const status: DatabaseBackupRunStatus = input.status === "success" || input.status === "failed" || input.status === "cancelled" ? input.status : "failed";
return {
id,
@@ -290,6 +309,7 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun |
connectionId: stringValue(input.connectionId).trim(),
connectionName: stringValue(input.connectionName).trim(),
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
source: input.source === "one-shot" ? "one-shot" : "scheduled",
status,
startedAt,
completedAt: typeof input.completedAt === "string" && Number.isFinite(Date.parse(input.completedAt)) ? input.completedAt : undefined,
+25
View File
@@ -5,6 +5,8 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
定时数据库备份使用 DBX 的数据库导出引擎,为 MySQL 和 PostgreSQL 连接生成 SQL 文件。它适合开发、测试和中小型数据库的周期性逻辑备份,并提供表范围、保留数量、运行历史和手动执行。
如果只需要立即备份一次,可以在设置中使用**立即备份**,不必创建计划。配置连接、目录、数据库、表范围和备份内容后点击**开始备份**。一次性备份不会创建或修改计划,不会计算下次执行时间,并会出现在统一的备份历史中。
<Callout type="warn">定时备份当前仅支持 DBX 桌面端,并要求应用保持运行。它不是数据库服务器上的独立调度器,也不能替代物理备份、Point-in-Time Recovery、WAL/binlog 归档或云厂商备份服务。</Callout>
## 支持范围
@@ -19,6 +21,29 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
| 表范围 | 全部、按模式包含、按模式排除 |
| 保留 | 按计划保留最近若干次成功备份文件 |
## 立即执行一次备份
<Steps>
<Step>
### 打开一次性备份表单
在设置中进入数据库备份页面,点击**立即备份**。
</Step>
<Step>
### 配置备份范围
选择 MySQL 或 PostgreSQL 连接、备份目录、数据库范围、表范围和备份内容。一次性备份不包含计划名称、执行频率、执行时间、保留数量或启用状态。
</Step>
<Step>
### 开始并确认结果
点击**开始备份**。任务会使用与定时备份相同的导出引擎、进度、取消和错误处理,并在**备份历史**中显示。
</Step>
</Steps>
## 创建计划
<Steps>
+25
View File
@@ -5,6 +5,8 @@ description: Create manual, hourly, daily, or weekly SQL backup schedules for My
Scheduled database backups use the DBX database-export engine to generate SQL files for MySQL and PostgreSQL connections. They are useful for recurring logical backups of development, test, and small or medium databases and include table scope, retention, run history, and manual execution.
For a backup that should run only once, use **Back up now** in Settings instead of creating a schedule. Configure the same connection, destination, database, table, and content scope, then choose **Start backup**. This one-shot run does not create or change a schedule, does not calculate a next run, and appears in the same backup history as scheduled runs.
<Callout type="warn">Scheduled backups currently require DBX Desktop to remain running. They are not an independent server scheduler and do not replace physical backups, point-in-time recovery, WAL/binlog archiving, or cloud-provider backup services.</Callout>
## Support Scope
@@ -19,6 +21,29 @@ Scheduled database backups use the DBX database-export engine to generate SQL fi
| Table scope | All, include by pattern, or exclude by pattern |
| Retention | Keep a configured number of recent successful backup files |
## Run a One-time Backup
<Steps>
<Step>
### Open the one-shot backup form
Open the database backup page in Settings and choose **Back up now**.
</Step>
<Step>
### Configure the backup
Select a MySQL or PostgreSQL connection, destination directory, database scope, table scope, and backup contents. A one-shot backup has no schedule name, frequency, retention, or enabled state.
</Step>
<Step>
### Start and verify
Choose **Start backup**. The run uses the shared database-export engine and appears in **Backup history**, where progress, cancellation, generated files, and errors use the same behavior as scheduled runs.
</Step>
</Steps>
## Create a Schedule
<Steps>
+3 -3
View File
@@ -333,7 +333,7 @@ test("scheduled backup history exposes rename and overall percentage controls",
assert.match(source, /run\.displayName \|\| run\.scheduleName/);
assert.match(source, /role="progressbar"/);
assert.match(scheduler, /databaseBackupConnectionQueue\.run\(schedule\.connectionId/);
assert.match(scheduler, /databaseBackupConnectionQueue\.run\(config\.connectionId/);
assert.match(scheduler, /status: databaseBackupAggregateExportStatus\(progress\.status, false\)/);
assert.match(scheduler, /overallPercent: progressPercent/);
});
@@ -341,8 +341,8 @@ test("scheduled backup history exposes rename and overall percentage controls",
test("scheduled backups prepare table scope before opening a consistent snapshot", () => {
const scheduler = readFileSync("apps/desktop/src/composables/useScheduledDatabaseBackups.ts", "utf8");
const exportCore = readFileSync("crates/dbx-core/src/database_export.rs", "utf8");
const schemaIndex = scheduler.indexOf("await api.listSchemas(schedule.connectionId, database)");
const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(schedule.connectionId, database)");
const schemaIndex = scheduler.indexOf("await api.listSchemas(config.connectionId, database)");
const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(config.connectionId, database)");
const exportIndex = scheduler.indexOf("await runDatabaseExportUntilTerminal(");
assert.ok(schemaIndex >= 0);