feat(backup): add gzip compression and history filtering

This commit is contained in:
二丫讲梵
2026-08-31 09:02:20 +08:00
committed by GitHub
parent c305325e37
commit b3fcb3a6f1
36 changed files with 1144 additions and 137 deletions
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } 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 { FolderOpen, Loader2 } from "@lucide/vue";
import { Check, ChevronDown, FolderOpen, Loader2, Search } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
withDefaults(
const props = withDefaults(
defineProps<{
draft: DatabaseBackupExecutionConfig;
connections: Array<Pick<ConnectionConfig, "id" | "name">>;
@@ -30,18 +32,70 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const connectionPickerOpen = ref(false);
const connectionSearch = ref("");
const databaseSearch = ref("");
const selectedConnectionName = computed(() => props.connections.find((connection) => connection.id === props.draft.connectionId)?.name || props.draft.connectionId);
const filteredConnections = computed(() => {
const query = connectionSearch.value.trim().toLocaleLowerCase();
if (!query) return props.connections;
return props.connections.filter((connection) => connection.name.toLocaleLowerCase().includes(query));
});
const filteredDatabaseOptions = computed(() => {
const query = databaseSearch.value.trim().toLocaleLowerCase();
if (!query) return props.databaseOptions;
return props.databaseOptions.filter((database) => database.toLocaleLowerCase().includes(query));
});
function selectConnection(connectionId: string) {
emit("changeConnection", connectionId);
connectionPickerOpen.value = false;
}
watch(connectionPickerOpen, (open) => {
if (!open) connectionSearch.value = "";
});
watch(
() => props.draft.connectionId,
() => {
databaseSearch.value = "";
},
);
</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>
<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">
<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">
<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')" />
</div>
<div class="max-h-60 overflow-y-auto py-1">
<button
v-for="connection in filteredConnections"
:key="connection.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="selectConnection(connection.id)"
>
<Check class="h-4 w-4 shrink-0" :class="connection.id === draft.connectionId ? 'opacity-100' : 'opacity-0'" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</button>
<div v-if="filteredConnections.length === 0" class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingConnections") }}</div>
</div>
</PopoverContent>
</Popover>
</div>
<div class="space-y-2">
@@ -54,6 +108,14 @@ const { t } = useI18n();
</div>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.compression") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input :checked="draft.outputCompression === 'gzip'" type="checkbox" class="h-4 w-4 accent-primary" @change="draft.outputCompression = ($event.target as HTMLInputElement).checked ? 'gzip' : 'none'" />
{{ t("databaseBackup.compressionGzip") }}
</label>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
@@ -62,12 +124,21 @@ const { t } = useI18n();
{{ 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 v-if="!allDatabases" class="space-y-2">
<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="databaseSearch" data-backup-database-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchDatabases')" :placeholder="t('databaseBackup.searchDatabases')" />
</div>
<div 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>
<template v-else-if="filteredDatabaseOptions.length">
<label v-for="database in filteredDatabaseOptions" :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>
</template>
<div v-else class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingDatabases") }}</div>
</div>
</div>
</div>
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 { Switch } from "@/components/ui/switch";
import { ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Square, Trash2 } from "@lucide/vue";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
@@ -22,7 +23,7 @@ import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOpt
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const scheduleDialogOpen = ref(false);
const oneShotDialogOpen = ref(false);
@@ -42,10 +43,35 @@ const allDatabases = ref(true);
const selectedDatabases = ref<string[]>([]);
const tablePatternsInput = ref("");
const expandedRunIds = reactive(new Set<string>());
const historyConnectionId = ref("");
const historyConnectionPickerOpen = ref(false);
const historyConnectionSearch = ref("");
const historyBackupMethod = ref<"all" | "manual" | "scheduled" | "one-shot">("all");
const historyStatus = ref<"all" | DatabaseBackupRun["status"]>("all");
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
const canCreateSchedule = computed(() => sqlConnections.value.length > 0);
const sortedRuns = computed(() => [...runs.value].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)));
const historyConnections = computed(() => {
const connectionById = new Map<string, string>();
for (const run of sortedRuns.value) {
if (!connectionById.has(run.connectionId)) connectionById.set(run.connectionId, run.connectionName || connectionName(run.connectionId));
}
return [...connectionById].map(([id, name]) => ({ id, name })).sort((left, right) => left.name.localeCompare(right.name, locale.value));
});
const filteredHistoryConnections = computed(() => {
const query = historyConnectionSearch.value.trim().toLocaleLowerCase();
if (!query) return historyConnections.value;
return historyConnections.value.filter((connection) => connection.name.toLocaleLowerCase().includes(query));
});
const filteredRuns = computed(() =>
sortedRuns.value.filter((run) => {
if (historyConnectionId.value && run.connectionId !== historyConnectionId.value) return false;
if (historyBackupMethod.value !== "all" && historyBackupMethod.value !== runBackupMethod(run)) return false;
return historyStatus.value === "all" || historyStatus.value === run.status;
}),
);
const selectedHistoryConnectionName = computed(() => historyConnections.value.find((connection) => connection.id === historyConnectionId.value)?.name || t("databaseBackup.allConnections"));
const weekdays = computed(() => [
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
{ value: 1, label: t("databaseBackup.weekdays.monday") },
@@ -56,6 +82,10 @@ const weekdays = computed(() => [
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
]);
watch(historyConnectionPickerOpen, (open) => {
if (!open) historyConnectionSearch.value = "";
});
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
return {
connectionId,
@@ -67,6 +97,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
};
}
@@ -121,6 +152,17 @@ function connectionName(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
}
function selectHistoryConnection(connectionId: string) {
historyConnectionId.value = connectionId;
historyConnectionPickerOpen.value = false;
historyConnectionSearch.value = "";
}
function runBackupMethod(run: DatabaseBackupRun): "manual" | "scheduled" | "one-shot" {
if (run.source === "one-shot") return "one-shot";
return run.trigger;
}
function formatDate(value?: string): string {
if (!value || !Number.isFinite(Date.parse(value))) return t("databaseBackup.never");
return new Intl.DateTimeFormat(locale.value, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
@@ -151,6 +193,10 @@ function runStatusLabel(status: DatabaseBackupRun["status"]): string {
return t(`databaseBackup.status.${status}`);
}
function displayedRunStatusLabel(run: DatabaseBackupRun): string {
return cancellingRunIds.has(run.id) ? t("databaseBackup.cancelling") : runStatusLabel(run.status);
}
function runStatusVariant(status: DatabaseBackupRun["status"]): "default" | "secondary" | "destructive" | "outline" {
if (status === "success") return "default";
if (status === "failed") return "destructive";
@@ -162,6 +208,15 @@ function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
}
function scheduleCancellationRequested(scheduleId: string): boolean {
const run = activeRunForSchedule(scheduleId);
return !!run && cancellingRunIds.has(run.id);
}
function oneShotCancellationRequested(): boolean {
return !!activeOneShotRun.value && cancellingRunIds.has(activeOneShotRun.value.id);
}
async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, preserveSelection: boolean) {
const generation = ++databaseLoadGeneration;
const connectionId = targetDraft.connectionId;
@@ -295,7 +350,11 @@ async function startOneShotBackup() {
async function cancelActiveOneShotBackup() {
const run = activeOneShotRun.value;
if (run) await cancelRun(run.id);
if (run && (await cancelRun(run.id))) toast(t("databaseBackup.cancelRequested"), 2500);
}
async function requestCancelRun(runId: string) {
if (await cancelRun(runId)) toast(t("databaseBackup.cancelRequested"), 2500);
}
async function runNow(schedule: DatabaseBackupSchedule) {
@@ -418,7 +477,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">{{ schedule.name }}</span>
<Badge variant="outline" class="font-normal">{{ connectionName(schedule.connectionId) }}</Badge>
<Badge v-if="activeScheduleIds.has(schedule.id)" variant="secondary" class="font-normal">{{ t("databaseBackup.status.running") }}</Badge>
<Badge v-if="activeScheduleIds.has(schedule.id)" variant="secondary" class="font-normal">{{ scheduleCancellationRequested(schedule.id) ? t("databaseBackup.cancelling") : t("databaseBackup.status.running") }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ frequencyLabel(schedule) }}</span>
@@ -430,8 +489,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="flex items-center justify-end gap-1">
<Switch :model-value="schedule.enabled" :disabled="activeScheduleIds.has(schedule.id)" :title="schedule.enabled ? t('databaseBackup.disable') : t('databaseBackup.enable')" @update:model-value="(value: boolean) => setScheduleEnabled(schedule.id, value)" />
<Button v-if="activeRunForSchedule(schedule.id)" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(activeRunForSchedule(schedule.id)!.id)">
<Square class="h-4 w-4" />
<Button
v-if="activeRunForSchedule(schedule.id)"
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="scheduleCancellationRequested(schedule.id)"
:title="scheduleCancellationRequested(schedule.id) ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')"
@click="requestCancelRun(activeRunForSchedule(schedule.id)!.id)"
>
<Loader2 v-if="scheduleCancellationRequested(schedule.id)" class="h-4 w-4 animate-spin" />
<Square v-else class="h-4 w-4" />
</Button>
<Button v-else variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.runNow')" @click="runNow(schedule)">
<Play class="h-4 w-4" />
@@ -447,10 +515,64 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="flex flex-col gap-3">
<h3 class="text-base font-semibold">{{ t("databaseBackup.history") }}</h3>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-base font-semibold">{{ t("databaseBackup.history") }}</h3>
<div class="flex flex-wrap items-center justify-end gap-2">
<Popover v-model:open="historyConnectionPickerOpen">
<PopoverTrigger as-child>
<Button data-backup-history-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="historyConnectionPickerOpen" class="min-w-52 justify-between font-normal">
<span class="truncate">{{ selectedHistoryConnectionName }}</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-[var(--reka-popover-trigger-width)] p-1">
<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="historyConnectionSearch" data-backup-history-connection-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchHistoryConnections')" :placeholder="t('databaseBackup.searchHistoryConnections')" />
</div>
<div class="max-h-60 overflow-y-auto py-1">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none" @click="selectHistoryConnection('')">
<Check class="h-4 w-4 shrink-0" :class="historyConnectionId ? 'opacity-0' : 'opacity-100'" />
<span class="min-w-0 flex-1 truncate">{{ t("databaseBackup.allConnections") }}</span>
</button>
<button
v-for="connection in filteredHistoryConnections"
:key="connection.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="selectHistoryConnection(connection.id)"
>
<Check class="h-4 w-4 shrink-0" :class="connection.id === historyConnectionId ? 'opacity-100' : 'opacity-0'" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</button>
<div v-if="filteredHistoryConnections.length === 0" class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingConnections") }}</div>
</div>
</PopoverContent>
</Popover>
<Select v-model="historyBackupMethod">
<SelectTrigger class="w-36" :aria-label="t('databaseBackup.backupMethod')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allBackupMethods") }}</SelectItem>
<SelectItem value="manual">{{ t("databaseBackup.manualTrigger") }}</SelectItem>
<SelectItem value="scheduled">{{ t("databaseBackup.scheduledTrigger") }}</SelectItem>
<SelectItem value="one-shot">{{ t("databaseBackup.oneShotTrigger") }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="historyStatus">
<SelectTrigger class="w-32" :aria-label="t('databaseBackup.backupStatus')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allStatuses") }}</SelectItem>
<SelectItem value="running">{{ t("databaseBackup.status.running") }}</SelectItem>
<SelectItem value="success">{{ t("databaseBackup.status.success") }}</SelectItem>
<SelectItem value="failed">{{ t("databaseBackup.status.failed") }}</SelectItem>
<SelectItem value="cancelled">{{ t("databaseBackup.status.cancelled") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="overflow-hidden rounded-md border border-border/70">
<div v-if="sortedRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ t("databaseBackup.noHistory") }}</div>
<template v-for="run in sortedRuns" :key="run.id">
<div v-if="filteredRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}</div>
<template v-for="run in filteredRuns" :key="run.id">
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="run.files.length === 0" :title="t('databaseBackup.showFiles')" @click="toggleRunExpanded(run.id)">
<ChevronDown v-if="expandedRunIds.has(run.id)" class="h-4 w-4" />
@@ -459,7 +581,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="min-w-0">
<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="runStatusVariant(run.status)" class="font-normal">{{ displayedRunStatusLabel(run) }}</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">
@@ -476,8 +598,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="flex items-center justify-end gap-1">
<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
v-if="activeRunIds.has(run.id) && run.source === 'one-shot'"
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="cancellingRunIds.has(run.id)"
:title="cancellingRunIds.has(run.id) ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')"
@click="requestCancelRun(run.id)"
>
<Loader2 v-if="cancellingRunIds.has(run.id)" class="h-4 w-4 animate-spin" />
<Square v-else 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)">
@@ -619,9 +750,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<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 v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun || oneShotCancellationRequested()" :title="oneShotCancellationRequested() ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
<Loader2 v-if="oneShotCancellationRequested()" class="mr-2 h-4 w-4 animate-spin" />
<Square v-else class="mr-2 h-4 w-4" />
{{ oneShotCancellationRequested() ? t("databaseBackup.cancelling") : t("databaseBackup.cancel") }}
</Button>
<Button v-else :disabled="!canStartOneShot" @click="startOneShotBackup">
{{ t("databaseBackup.startBackup") }}
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
activeRunIds: new Set<string>(),
cancellingRunIds: new Set<string>(),
activeRuns: [] as DatabaseBackupRun[],
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
@@ -40,6 +41,7 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
runs: { __v_isRef: true, value: mocks.runs },
activeScheduleIds: new Set<string>(),
activeRunIds: mocks.activeRunIds,
cancellingRunIds: mocks.cancellingRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
@@ -113,7 +115,7 @@ async function selectDialogOption(triggerIndex: number, optionText: string) {
}
async function setTablePattern(pattern: string) {
await selectDialogOption(1, String(i18n.global.t("databaseBackup.includeTables")));
await selectDialogOption(0, 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;
@@ -121,6 +123,50 @@ async function setTablePattern(pattern: string) {
await flush();
}
async function selectBackupConnection(connectionName: string) {
const trigger = currentDialog().querySelector<HTMLButtonElement>("[data-backup-connection-picker]");
if (!trigger) throw new Error("Backup connection picker not found");
trigger.click();
await flush();
const search = document.body.querySelector<HTMLInputElement>("[data-backup-connection-search]");
if (!search) throw new Error("Backup connection search not found");
search.value = connectionName;
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === connectionName);
if (!option) throw new Error(`Backup connection option not found: ${connectionName}`);
option.click();
await flush();
}
async function selectHistoryConnection(connectionName: string) {
const trigger = document.body.querySelector<HTMLButtonElement>("[data-backup-history-connection-picker]");
if (!trigger) throw new Error("History connection picker not found");
trigger.click();
await flush();
const search = document.body.querySelector<HTMLInputElement>("[data-backup-history-connection-search]");
if (!search) throw new Error("History connection search not found");
search.value = connectionName;
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === connectionName);
if (!option) throw new Error(`History connection option not found: ${connectionName}`);
option.click();
await flush();
}
async function selectHistoryFilter(triggerIndex: number, optionText: string) {
const trigger = document.body.querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`History filter 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(`History filter option not found: ${optionText}`);
option.focus();
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: 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));
@@ -161,6 +207,7 @@ function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): DatabaseBack
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
retentionCount: 10,
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
@@ -191,6 +238,7 @@ afterEach(() => {
mocks.runs.splice(0);
mocks.activeRuns.splice(0);
mocks.activeRunIds.clear();
mocks.cancellingRunIds.clear();
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockReset();
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
@@ -204,6 +252,73 @@ afterEach(() => {
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
it("filters backup history by a searched connection", async () => {
mocks.runs.push(
{
id: "run-primary",
scheduleName: "Primary backup",
connectionId: "mysql-primary",
connectionName: "Primary MySQL",
trigger: "manual",
source: "schedule",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
},
{
id: "run-archive",
scheduleName: "Archive backup",
connectionId: "mysql-archive",
connectionName: "Archive MySQL",
trigger: "manual",
source: "schedule",
status: "success",
startedAt: "2026-08-19T00:00:00.000Z",
files: [],
},
);
await mountSettings();
await selectHistoryConnection("Archive MySQL");
expect(document.body.textContent).toContain("Archive backup");
expect(document.body.textContent).not.toContain("Primary backup");
});
it("combines backup method and status filters for history", async () => {
mocks.runs.push(
{
id: "run-manual-success",
scheduleName: "Manual success backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "scheduled",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
},
{
id: "run-scheduled-failed",
scheduleName: "Scheduled failed backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "scheduled",
source: "scheduled",
status: "failed",
startedAt: "2026-08-19T00:00:00.000Z",
files: [],
},
);
await mountSettings();
await selectHistoryFilter(0, String(i18n.global.t("databaseBackup.scheduledTrigger")));
await selectHistoryFilter(1, String(i18n.global.t("databaseBackup.status.failed")));
expect(document.body.textContent).toContain("Scheduled failed backup");
expect(document.body.textContent).not.toContain("Manual success backup");
});
it("opens the create schedule dialog for supported SQL connections", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
@@ -270,7 +385,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
await selectDialogOption(0, "MySQL B");
await selectBackupConnection("MySQL B");
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
secondLoad.resolve([{ name: "new_database" }]);
@@ -284,6 +399,26 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(currentDialog().textContent).not.toContain("stale_database");
});
it("filters database choices locally without changing the loaded options", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.listDatabases.mockResolvedValue([{ name: "analytics" }, { name: "application" }, { name: "billing" }]);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
await showDatabaseOptions();
const search = currentDialog().querySelector<HTMLInputElement>("[data-backup-database-search]");
if (!search) throw new Error("Backup database search not found");
search.value = "bill";
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(currentDialog().textContent).toContain("billing");
expect(currentDialog().textContent).not.toContain("analytics");
expect(currentDialog().textContent).not.toContain("application");
});
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 }>>();
@@ -40,7 +40,7 @@ const visibleTasks = computed(() => {
const hasMore = computed(() => tasks.value.length > MAX_VISIBLE);
const isActive = (status: string) => status === "Running" || status === "Writing";
const isActive = (status: string) => status === "Running" || status === "Writing" || status === "Cancelling";
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
@@ -142,6 +142,8 @@ const rowsText = (task: ExportTask) => {
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const taskStatusText = (task: ExportTask) => (task.status === "Cancelling" ? t("databaseBackup.cancelling") : "");
const elapsedText = (task: ExportTask) => {
if (task.startedAt === undefined) return "";
const finishedAt = task.finishedAt ?? currentTime.value;
@@ -170,6 +172,7 @@ const statusIcon = (task: ExportTask) => {
switch (task.status) {
case "Running":
case "Writing":
case "Cancelling":
return Loader2;
case "Done":
return CheckCircle2;
@@ -186,6 +189,7 @@ const statusColor = (status: string) => {
switch (status) {
case "Running":
case "Writing":
case "Cancelling":
return "text-primary";
case "Done":
return "text-green-500";
@@ -257,7 +261,7 @@ function openTask(task: ExportTask): void {
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-start gap-3 border-b px-4 py-3 text-xs last:border-b-0">
<div class="flex-1 min-w-0 flex flex-col gap-1.5">
<div class="flex items-center gap-1.5">
<component :is="statusIcon(task)" :class="[statusColor(task.status), task.kind === 'table-export' && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<component :is="statusIcon(task)" :class="[statusColor(task.status), (task.kind === 'table-export' || task.status === 'Cancelling') && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate font-medium" :title="task.filePath || undefined">{{ taskTitle(task) }}</span>
</div>
@@ -283,6 +287,7 @@ function openTask(task: ExportTask): void {
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
<span v-if="taskStatusText(task)" class="ml-1 font-medium text-primary">{{ taskStatusText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage)">
{{ translateBackendError(t, task.errorMessage) }}
</span>
@@ -311,8 +316,15 @@ function openTask(task: ExportTask): void {
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.cancel')" @click="cancelTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
<button
v-if="isActive(task.status)"
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:cursor-not-allowed"
:disabled="task.status === 'Cancelling'"
:title="task.status === 'Cancelling' ? t('databaseBackup.cancelling') : t('exportProgress.cancel')"
@click="cancelTask(task.exportId)"
>
<Loader2 v-if="task.status === 'Cancelling'" class="h-3.5 w-3.5 animate-spin text-primary" />
<X v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>
<button v-else class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
@@ -145,6 +145,29 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).toContain("Preparing: t_0001");
});
it("keeps a cancelling backup visible as cancelling until its terminal event arrives", async () => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask("cancelling-backup", "Nightly", "/tmp/backups", "scheduled");
tracker.markDatabaseExportTaskCancelling(task.exportId);
tracker.updateDatabaseExportTask(task.exportId, {
exportId: task.exportId,
currentObject: "app.users",
objectIndex: 8,
totalObjects: 27,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: false,
});
await mountPopover();
expect(tracker.tasks.value.find((item) => item.exportId === task.exportId)?.status).toBe("Cancelling");
expect(document.body.textContent).toContain("Cancelling…");
expect(document.body.querySelector<HTMLButtonElement>("button[disabled]")).not.toBeNull();
});
it.each(["Done", "Error", "Cancelled"] as const)("hides stale database object text after the task reaches %s", async (status) => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask(`terminal-${status}`, "Nightly", "/tmp/backups", "scheduled");
@@ -308,7 +308,7 @@ async function selectFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: true,
filters: [{ name: "SQL", extensions: ["sql"] }],
filters: [{ name: "SQL", extensions: ["sql", "gz"] }],
});
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
if (paths.length > 0) {
@@ -570,7 +570,7 @@ watch(
</div>
<div class="flex items-center gap-2">
<input ref="fileInput" type="file" accept=".sql,text/sql" multiple class="hidden" @change="handleFileInputChange" />
<input ref="fileInput" type="file" accept=".sql,.sql.gz,text/sql,application/gzip" multiple class="hidden" @change="handleFileInputChange" />
<Input :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
@@ -8,12 +8,15 @@ const mocks = vi.hoisted(() => ({
rollbackSnapshot: vi.fn(async () => {}),
deleteFiles: vi.fn(async () => 0),
cancelExport: vi.fn(async () => {}),
clearExportCancellation: vi.fn(async () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
markTaskCancelling: vi.fn(),
restoreTaskRunning: vi.fn(),
nextId: 0,
}));
@@ -23,6 +26,7 @@ vi.mock("@/lib/backend/api", () => ({
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
clearDatabaseExportCancellation: mocks.clearExportCancellation,
}));
vi.mock("@/lib/export/databaseExport", () => ({
@@ -45,6 +49,8 @@ vi.mock("@/composables/useExportTracker", () => ({
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
}),
}));
@@ -60,6 +66,7 @@ const config: DatabaseBackupExecutionConfig = {
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
};
describe("useScheduledDatabaseBackups one-shot execution", () => {
@@ -126,12 +133,72 @@ describe("useScheduledDatabaseBackups one-shot execution", () => {
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
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: [] }));
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
});
it("stops after cancelling during database discovery without creating a snapshot", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveDatabases!: (databases: Array<{ name: string }>) => void;
mocks.listDatabases.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDatabases = resolve;
}),
);
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);
resolveDatabases([{ name: "app" }]);
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.beginSnapshot).not.toHaveBeenCalled();
});
it("signals the backup run while snapshot creation is pending", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
mocks.beginSnapshot.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
}),
);
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();
expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
resolveSnapshot({ sessionId: "snapshot-pending" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
});
});
@@ -3,7 +3,7 @@ import * as api from "@/lib/backend/api";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution";
export type BackgroundTaskStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
export type BackgroundTaskStatus = "Running" | "Writing" | "Cancelling" | "Done" | "Error" | "Cancelled";
export type DatabaseExportSource = "manual" | "scheduled";
export interface DataTransferFailure {
@@ -249,7 +249,7 @@ function findActiveOverlappingTransfer(request: api.TransferRequest): string[] {
export function useExportTracker() {
const tasks = computed(() => Array.from(taskMap.values()));
const activeCount = computed(() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing").length);
const activeCount = computed(() => tasks.value.filter((t) => t.status === "Running" || t.status === "Writing" || t.status === "Cancelling").length);
const hasActive = computed(() => activeCount.value > 0);
@@ -470,7 +470,8 @@ export function useExportTracker() {
task.preparing = !!progress.preparing;
task.rowsExported = progress.rowsExported;
task.totalRows = progress.totalRows;
task.status = normalizeExportStatus(progress.status);
const nextStatus = normalizeExportStatus(progress.status);
task.status = task.status === "Cancelling" && nextStatus === "Running" ? "Cancelling" : nextStatus;
task.errorMessage = progress.error || null;
task.objectIndex = progress.objectIndex;
task.totalObjects = progress.totalObjects;
@@ -480,6 +481,18 @@ export function useExportTracker() {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
}
function markDatabaseExportTaskCancelling(exportId: string) {
const task = taskMap.get(exportId);
if (!task || task.kind !== "database-export" || task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return;
task.status = "Cancelling";
task.preparing = false;
}
function restoreDatabaseExportTaskRunning(exportId: string) {
const task = taskMap.get(exportId);
if (task?.kind === "database-export" && task.status === "Cancelling") task.status = "Running";
}
function updateSqlFileTask(executionId: string, progress: api.SqlFileProgress) {
const task = taskMap.get(executionId);
if (!task) return;
@@ -577,6 +590,8 @@ export function useExportTracker() {
startDataTransferTask,
updateTableExportTask,
updateDatabaseExportTask,
markDatabaseExportTaskCancelling,
restoreDatabaseExportTaskRunning,
updateSqlFileTask,
updateDataTransferTask,
registerTaskCancelHandler,
@@ -42,6 +42,7 @@ const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
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 cancellationRequested = new Set<string>();
@@ -84,7 +85,7 @@ function refreshFromStorage() {
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
const connectionStore = useConnectionStore();
const { addDatabaseExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
const { addDatabaseExportTask, markDatabaseExportTaskCancelling, registerTaskCancelHandler, restoreDatabaseExportTaskRunning, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
@@ -156,11 +157,23 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
}
}
async function cancelRun(runId: string) {
if (!activeRunIds.has(runId)) return;
async function cancelRun(runId: string): Promise<boolean> {
if (!activeRunIds.has(runId)) return false;
if (cancellationRequested.has(runId)) return true;
cancellationRequested.add(runId);
const exportId = activeExportIds.get(runId);
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
cancellingRunIds.add(runId);
markDatabaseExportTaskCancelling(runId);
const exportIds = new Set([runId, activeExportIds.get(runId)].filter((id): id is string => Boolean(id)));
try {
await Promise.all([...exportIds].map((exportId) => api.cancelDatabaseExport(exportId)));
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:cancel-request-error]", error);
cancellationRequested.delete(runId);
cancellingRunIds.delete(runId);
restoreDatabaseExportTaskRunning(runId);
return false;
}
return true;
}
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
@@ -186,7 +199,9 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, () => cancelRun(runId));
registerTaskCancelHandler(runId, async () => {
await cancelRun(runId);
});
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
@@ -201,7 +216,15 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
}
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
await connectionStore.ensureConnected(config.connectionId);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
@@ -213,6 +236,10 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
}
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
}
let exportIndex = 0;
@@ -223,6 +250,10 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
@@ -236,13 +267,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
continue;
}
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
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 });
}
if (finalStatus === "cancelled") break;
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database);
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
@@ -253,7 +289,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId);
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression);
generatedPaths.push(filePath);
const terminal = await runDatabaseExportUntilTerminal(
{
@@ -268,6 +304,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
outputCompression: config.outputCompression,
failOnError: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
@@ -332,6 +369,12 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
finalError = error?.message || String(error);
} finally {
// `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.
await api.clearDatabaseExportCancellation(runId).catch((error) => {
appendDebugLog("warn", "[DBX][database-backup:cancel-clear-error]", error);
});
if (finalStatus !== "success" && generatedPaths.length > 0) {
try {
await api.deleteDatabaseBackupFiles(generatedPaths);
@@ -345,6 +388,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
cancellingRunIds.delete(runId);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
@@ -471,6 +515,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
runs,
activeScheduleIds,
activeRunIds,
cancellingRunIds,
activeRuns,
saveSchedule,
setScheduleEnabled,
+16
View File
@@ -7211,6 +7211,13 @@ export default {
missingConnection: "Missing connection",
destination: "Destination directory",
selectDestination: "Select backup directory",
compression: "Compression",
compressionNone: "No compression (.sql)",
compressionGzip: "Gzip (.sql.gz)",
searchConnections: "Search connections…",
noMatchingConnections: "No matching connections",
searchDatabases: "Search databases…",
noMatchingDatabases: "No matching databases",
databases: "Databases",
allDatabases: "All databases",
databaseCount: "{count} databases",
@@ -7245,6 +7252,8 @@ export default {
disable: "Disable schedule",
runNow: "Back up now",
cancel: "Cancel backup",
cancelling: "Cancelling…",
cancelRequested: "Cancellation requested. Stopping the current operation.",
edit: "Edit",
delete: "Delete",
scheduleCreated: "Backup schedule created",
@@ -7254,6 +7263,13 @@ export default {
cannotDeleteRunningSchedule: "A running backup schedule cannot be deleted",
history: "Backup history",
noHistory: "No backups yet",
allConnections: "All connections",
searchHistoryConnections: "Search connections…",
backupMethod: "Backup method",
allBackupMethods: "All methods",
backupStatus: "Backup status",
allStatuses: "All statuses",
noFilteredHistory: "No matching backup records",
showFiles: "Show backup files",
scheduledTrigger: "Scheduled",
manualTrigger: "Manual",
+16
View File
@@ -8304,6 +8304,13 @@ export default withEnglishFallback({
missingConnection: "La conexión ya no existe",
destination: "Directorio de copia de seguridad",
selectDestination: "Seleccionar directorio de copia de seguridad",
compression: "Compresión",
compressionNone: "Sin compresión (.sql)",
compressionGzip: "Compresión Gzip (.sql.gz)",
searchConnections: "Buscar conexiones…",
noMatchingConnections: "No hay conexiones coincidentes",
searchDatabases: "Buscar bases de datos…",
noMatchingDatabases: "No hay bases de datos coincidentes",
databases: "Bases de datos",
allDatabases: "Todas las bases de datos",
databaseCount: "{count} bases de datos",
@@ -8338,6 +8345,8 @@ export default withEnglishFallback({
disable: "Deshabilitar plan",
runNow: "Ejecutar copia ahora",
cancel: "Cancelar copia",
cancelling: "Cancelando…",
cancelRequested: "Se solicitó la cancelación. Se está deteniendo la operación actual.",
edit: "Editar",
delete: "Eliminar",
scheduleCreated: "Plan de copia de seguridad creado",
@@ -8347,6 +8356,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "No se puede eliminar un plan de copia de seguridad en ejecución",
history: "Historial de copias de seguridad",
noHistory: "No hay registros de copia de seguridad",
allConnections: "Todas las conexiones",
searchHistoryConnections: "Buscar conexiones…",
backupMethod: "Método de copia",
allBackupMethods: "Todos los métodos",
backupStatus: "Estado de la copia",
allStatuses: "Todos los estados",
noFilteredHistory: "No hay registros de copia de seguridad coincidentes",
showFiles: "Mostrar archivos de copia",
scheduledTrigger: "Ejecución programada",
manualTrigger: "Ejecución manual",
+16
View File
@@ -8305,6 +8305,13 @@ export default withEnglishFallback({
missingConnection: "Connessione non più esistente",
destination: "Directory di backup",
selectDestination: "Seleziona directory di backup",
compression: "Compressione",
compressionNone: "Nessuna compressione (.sql)",
compressionGzip: "Compressione Gzip (.sql.gz)",
searchConnections: "Cerca connessioni…",
noMatchingConnections: "Nessuna connessione corrispondente",
searchDatabases: "Cerca database…",
noMatchingDatabases: "Nessun database corrispondente",
databases: "Database",
allDatabases: "Tutti i database",
databaseCount: "{count} database",
@@ -8339,6 +8346,8 @@ export default withEnglishFallback({
disable: "Disattiva pianificazione",
runNow: "Esegui backup ora",
cancel: "Annulla backup",
cancelling: "Annullamento…",
cancelRequested: "Annullamento richiesto. Arresto dell'operazione corrente.",
edit: "Modifica",
delete: "Elimina",
scheduleCreated: "Pianificazione di backup creata",
@@ -8348,6 +8357,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "Impossibile eliminare una pianificazione di backup in esecuzione",
history: "Cronologia backup",
noHistory: "Nessun record di backup",
allConnections: "Tutte le connessioni",
searchHistoryConnections: "Cerca connessioni…",
backupMethod: "Metodo di backup",
allBackupMethods: "Tutti i metodi",
backupStatus: "Stato del backup",
allStatuses: "Tutti gli stati",
noFilteredHistory: "Nessun record di backup corrispondente",
showFiles: "Mostra file di backup",
scheduledTrigger: "Esecuzione programmata",
manualTrigger: "Esecuzione manuale",
+16
View File
@@ -8356,6 +8356,13 @@ export default withEnglishFallback({
missingConnection: "接続が存在しません",
destination: "バックアップ先",
selectDestination: "バックアップ先を選択",
compression: "圧縮",
compressionNone: "圧縮なし(.sql)",
compressionGzip: "Gzip 圧縮(.sql.gz)",
searchConnections: "接続を検索…",
noMatchingConnections: "一致する接続がありません",
searchDatabases: "データベースを検索…",
noMatchingDatabases: "一致するデータベースがありません",
databases: "データベース",
allDatabases: "すべてのデータベース",
databaseCount: "{count} 個のデータベース",
@@ -8390,6 +8397,8 @@ export default withEnglishFallback({
disable: "無効にする",
runNow: "今すぐバックアップ",
cancel: "キャンセル",
cancelling: "キャンセル中…",
cancelRequested: "キャンセルを受け付けました。現在の操作を停止しています。",
edit: "編集",
delete: "削除",
scheduleCreated: "バックアップスケジュールが作成されました",
@@ -8399,6 +8408,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "実行中のバックアップスケジュールは削除できません",
history: "バックアップ履歴",
noHistory: "バックアップ履歴がありません",
allConnections: "すべての接続",
searchHistoryConnections: "接続を検索…",
backupMethod: "バックアップ方法",
allBackupMethods: "すべての方法",
backupStatus: "バックアップ状態",
allStatuses: "すべての状態",
noFilteredHistory: "一致するバックアップ記録がありません",
showFiles: "バックアップファイルを表示",
scheduledTrigger: "定期実行",
manualTrigger: "手動実行",
+16
View File
@@ -6644,6 +6644,22 @@ export default withEnglishFallback({
friday: "금요일",
saturday: "토요일",
},
compression: "압축",
compressionNone: "압축 안 함(.sql)",
compressionGzip: "Gzip 압축(.sql.gz)",
searchConnections: "연결 검색…",
noMatchingConnections: "일치하는 연결을 찾을 수 없습니다.",
searchDatabases: "데이터베이스 검색…",
noMatchingDatabases: "일치하는 데이터베이스를 찾을 수 없습니다.",
cancelling: "취소 중…",
cancelRequested: "백업 취소가 요청되었습니다. 현재 작업을 중지하는 중입니다.",
allConnections: "모든 연결",
searchHistoryConnections: "연결 검색…",
backupMethod: "백업 방식",
allBackupMethods: "모든 방식",
backupStatus: "백업 상태",
allStatuses: "모든 상태",
noFilteredHistory: "일치하는 백업 기록이 없습니다.",
},
mqTopics: {
title: "토픽",
+16
View File
@@ -8306,6 +8306,13 @@ export default withEnglishFallback({
missingConnection: "Conexão não existe mais",
destination: "Diretório de backup",
selectDestination: "Selecionar diretório de backup",
compression: "Compactação",
compressionNone: "Sem compactação (.sql)",
compressionGzip: "Compactação Gzip (.sql.gz)",
searchConnections: "Pesquisar conexões…",
noMatchingConnections: "Nenhuma conexão correspondente",
searchDatabases: "Pesquisar bancos de dados…",
noMatchingDatabases: "Nenhum banco de dados correspondente",
databases: "Bancos de dados",
allDatabases: "Todos os bancos de dados",
databaseCount: "{count} bancos de dados",
@@ -8340,6 +8347,8 @@ export default withEnglishFallback({
disable: "Desativar",
runNow: "Fazer backup agora",
cancel: "Cancelar backup",
cancelling: "Cancelando…",
cancelRequested: "Cancelamento solicitado. Interrompendo a operação atual.",
edit: "Editar",
delete: "Excluir",
scheduleCreated: "Plano de backup criado",
@@ -8349,6 +8358,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "Um plano de backup em execução não pode ser excluído",
history: "Histórico de backup",
noHistory: "Nenhum histórico de backup",
allConnections: "Todas as conexões",
searchHistoryConnections: "Pesquisar conexões…",
backupMethod: "Método de backup",
allBackupMethods: "Todos os métodos",
backupStatus: "Status do backup",
allStatuses: "Todos os status",
noFilteredHistory: "Nenhum registro de backup correspondente",
showFiles: "Mostrar arquivos de backup",
scheduledTrigger: "Execução agendada",
manualTrigger: "Execução manual",
+16
View File
@@ -7192,6 +7192,13 @@ export default withEnglishFallback({
missingConnection: "连接已不存在",
destination: "备份目录",
selectDestination: "选择备份目录",
compression: "压缩",
compressionNone: "不压缩(.sql)",
compressionGzip: "Gzip 压缩(.sql.gz)",
searchConnections: "搜索连接…",
noMatchingConnections: "未找到匹配的连接",
searchDatabases: "搜索数据库…",
noMatchingDatabases: "未找到匹配的数据库",
databases: "数据库",
allDatabases: "全部数据库",
databaseCount: "{count} 个数据库",
@@ -7226,6 +7233,8 @@ export default withEnglishFallback({
disable: "停用计划",
runNow: "立即备份",
cancel: "取消备份",
cancelling: "正在取消…",
cancelRequested: "已请求取消备份,正在停止当前操作。",
edit: "编辑",
delete: "删除",
scheduleCreated: "备份计划已创建",
@@ -7235,6 +7244,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "正在执行的备份计划不能删除",
history: "备份历史",
noHistory: "暂无备份记录",
allConnections: "全部连接",
searchHistoryConnections: "搜索连接…",
backupMethod: "备份方式",
allBackupMethods: "全部方式",
backupStatus: "备份状态",
allStatuses: "全部状态",
noFilteredHistory: "没有匹配的备份记录",
showFiles: "显示备份文件",
scheduledTrigger: "定时执行",
manualTrigger: "手动执行",
+16
View File
@@ -8294,6 +8294,13 @@ export default withEnglishFallback({
missingConnection: "連線已不存在",
destination: "備份目錄",
selectDestination: "選擇備份目錄",
compression: "壓縮",
compressionNone: "不壓縮(.sql)",
compressionGzip: "Gzip 壓縮(.sql.gz)",
searchConnections: "搜尋連線…",
noMatchingConnections: "找不到相符的連線",
searchDatabases: "搜尋資料庫…",
noMatchingDatabases: "找不到相符的資料庫",
databases: "資料庫",
allDatabases: "全部資料庫",
databaseCount: "{count} 個資料庫",
@@ -8328,6 +8335,8 @@ export default withEnglishFallback({
disable: "停用計畫",
runNow: "立即備份",
cancel: "取消備份",
cancelling: "正在取消…",
cancelRequested: "已要求取消備份,正在停止目前操作。",
edit: "編輯",
delete: "刪除",
scheduleCreated: "備份計畫已建立",
@@ -8337,6 +8346,13 @@ export default withEnglishFallback({
cannotDeleteRunningSchedule: "正在執行的備份計畫不能刪除",
history: "備份歷史",
noHistory: "暫無備份記錄",
allConnections: "所有連線",
searchHistoryConnections: "搜尋連線…",
backupMethod: "備份方式",
allBackupMethods: "所有方式",
backupStatus: "備份狀態",
allStatuses: "所有狀態",
noFilteredHistory: "沒有相符的備份記錄",
showFiles: "顯示備份檔案",
scheduledTrigger: "定時執行",
manualTrigger: "手動執行",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { databaseBackupRunsToPrune, databaseBackupTableMatchesPattern, normalizeDatabaseBackupRun, resolveScheduledDatabaseBackupTableScope, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupTableMatchesPattern, normalizeDatabaseBackupRun, resolveScheduledDatabaseBackupTableScope, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
@@ -19,6 +19,10 @@ function run(overrides: Record<string, unknown> = {}) {
}
describe("database backup run persistence", () => {
it("uses a gzip extension when the backup output is compressed", () => {
expect(databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "gzip")).toMatch(/\.sql\.gz$/);
});
it("accepts a one-shot run without a schedule id", () => {
const normalized = normalizeDatabaseBackupRun(run());
+1
View File
@@ -448,6 +448,7 @@ export const releaseTableImportSource = forward("releaseTableImportSource");
export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot");
export const exportDatabaseSql = forward("exportDatabaseSql");
export const cancelDatabaseExport = forward("cancelDatabaseExport");
export const clearDatabaseExportCancellation = forward("clearDatabaseExportCancellation");
export const recordDatabaseExportDestination = forward("recordDatabaseExportDestination");
export const exportQueryResultCsv = forward("exportQueryResultCsv");
export const exportTableDataCsv = forward("exportTableDataCsv");
+5 -1
View File
@@ -2376,7 +2376,7 @@ export async function releaseTableImportSource(sourceRef: string): Promise<boole
// Database Export
// ---------------------------------------------------------------------------
export async function beginDatabaseBackupSnapshot(_connectionId: string, _database: string): Promise<DatabaseBackupSnapshot> {
export async function beginDatabaseBackupSnapshot(_connectionId: string, _database: string, _exportId?: string): Promise<DatabaseBackupSnapshot> {
throw new Error("Consistent database backup snapshots are only available in the desktop app.");
}
@@ -2422,6 +2422,10 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
await post("/api/export/database/cancel", { exportId });
}
export async function clearDatabaseExportCancellation(_exportId: string): Promise<void> {
// The web exporter owns and clears its cancellation marker on completion.
}
export async function recordDatabaseExportDestination(_directory: string): Promise<void> {
throw new Error("Scheduled database backups are only available in the desktop app.");
}
+7 -2
View File
@@ -4675,6 +4675,7 @@ export interface DatabaseExportRequest {
dropTableIfExists?: boolean;
omitAutoIncrement?: boolean;
failOnError?: boolean;
outputCompression?: "none" | "gzip";
snapshotSessionId?: string;
batchSize: number;
}
@@ -4866,8 +4867,8 @@ export async function cancelQueryResultExport(exportId: string, executionId?: st
});
}
export async function beginDatabaseBackupSnapshot(connectionId: string, database: string): Promise<DatabaseBackupSnapshot> {
return invoke("begin_database_backup_snapshot", { connectionId, database });
export async function beginDatabaseBackupSnapshot(connectionId: string, database: string, exportId?: string): Promise<DatabaseBackupSnapshot> {
return invoke("begin_database_backup_snapshot", { connectionId, database, exportId: exportId || null });
}
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
@@ -4891,6 +4892,10 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
await invoke("cancel_database_export", { exportId });
}
export async function clearDatabaseExportCancellation(exportId: string): Promise<void> {
await invoke("clear_database_export_cancellation", { exportId });
}
export async function recordDatabaseExportDestination(directory: string): Promise<void> {
await invoke("record_database_export_destination", { directory });
}
@@ -13,6 +13,7 @@ export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancel
export type DatabaseBackupRunTrigger = "manual" | "scheduled";
export type DatabaseBackupRunSource = "scheduled" | "one-shot";
export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude";
export type DatabaseBackupOutputCompression = "none" | "gzip";
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
@@ -117,6 +118,7 @@ export interface DatabaseBackupExecutionConfig {
includeData: boolean;
includeObjects: boolean;
dropTableIfExists: boolean;
outputCompression: DatabaseBackupOutputCompression;
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
@@ -146,6 +148,7 @@ export function toDatabaseBackupExecutionConfig(schedule: DatabaseBackupSchedule
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
outputCompression: schedule.outputCompression,
};
}
@@ -244,6 +247,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
includeData: booleanValue(input.includeData, true),
includeObjects: booleanValue(input.includeObjects, true),
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
outputCompression: input.outputCompression === "gzip" ? "gzip" : "none",
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
createdAt: validIsoDate(input.createdAt, nowIso),
updatedAt: validIsoDate(input.updatedAt, nowIso),
@@ -394,7 +398,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): string {
const fileName = `dbx-backup__${sanitizeDatabaseBackupFileSegment(scheduleName)}__${databaseBackupTimestamp(startedAt)}__${sanitizeDatabaseBackupFileSegment(fileStem)}__${sanitizeDatabaseBackupFileSegment(runId).slice(0, 8)}.sql`;
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none"): string {
const suffix = outputCompression === "gzip" ? ".sql.gz" : ".sql";
const fileName = `dbx-backup__${sanitizeDatabaseBackupFileSegment(scheduleName)}__${databaseBackupTimestamp(startedAt)}__${sanitizeDatabaseBackupFileSegment(fileStem)}__${sanitizeDatabaseBackupFileSegment(runId).slice(0, 8)}${suffix}`;
return joinDatabaseBackupPath(directory, fileName);
}
+3 -1
View File
@@ -193,7 +193,9 @@ enum ConnectionDatabaseInfoSource {
/// Held connection for a manual transaction session
pub enum TxnConnection {
Postgres(Box<deadpool_postgres::Object>),
Mysql(mysql_async::Conn),
/// A dedicated MySQL connection. Cancellation may consume and discard this
/// connection instead of trying to reuse it after an interrupted result set.
Mysql(Option<mysql_async::Conn>),
/// Dedicated agent multi_session workload client with an open sticky TX.
Agent {
client: Arc<db::agent_driver::PooledAgentClient>,
+173 -19
View File
@@ -8,6 +8,9 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::connection::task_client_session_id;
use crate::models::connection::DatabaseType;
use crate::mysql_ddl_normalize::DdlNormalizeOptions;
@@ -30,6 +33,14 @@ pub fn database_export_client_session_id(export_id: &str) -> String {
task_client_session_id("database-export", export_id)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DatabaseExportOutputCompression {
#[default]
None,
Gzip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseExportRequest {
@@ -60,10 +71,43 @@ pub struct DatabaseExportRequest {
#[serde(default)]
pub fail_on_error: bool,
#[serde(default)]
pub output_compression: DatabaseExportOutputCompression,
#[serde(default)]
pub snapshot_session_id: Option<String>,
pub batch_size: usize,
}
enum DatabaseExportWriter {
Plain(BufWriter<std::fs::File>),
Gzip(Box<GzEncoder<BufWriter<std::fs::File>>>),
}
impl Write for DatabaseExportWriter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
match self {
Self::Plain(writer) => writer.write(buffer),
Self::Gzip(writer) => writer.write(buffer),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Self::Plain(writer) => writer.flush(),
Self::Gzip(writer) => writer.flush(),
}
}
}
impl DatabaseExportWriter {
fn finish(self) -> Result<(), String> {
match self {
Self::Plain(mut writer) => writer.flush(),
Self::Gzip(writer) => writer.finish().and_then(|mut output| output.flush()),
}
.map_err(|error| format!("Failed to finalize export file: {error}"))
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct DatabaseExportObjectCounts {
tables: usize,
@@ -1624,10 +1668,19 @@ async fn get_export_table_ddl_isolated(
database: String,
schema: String,
table: String,
client_session_id: String,
) -> Result<String, String> {
let task = tokio::spawn(async move {
crate::schema::get_table_relation_export_ddl_core(&state, &connection_id, &database, &schema, &table, None)
.await
crate::schema::get_table_relation_export_ddl_core_for_session(
&state,
&connection_id,
&database,
&schema,
&table,
None,
Some(&client_session_id),
)
.await
});
let _abort_on_drop = AbortExportTaskOnDrop(task.abort_handle());
task.await.map_err(|error| format!("Database export metadata task failed: {error}"))?
@@ -1639,9 +1692,18 @@ async fn get_export_table_columns_isolated(
database: String,
schema: String,
table: String,
client_session_id: String,
) -> Result<Vec<crate::db::ColumnInfo>, String> {
let task = tokio::spawn(async move {
crate::schema::get_columns_core(&state, &connection_id, &database, &schema, &table).await
crate::schema::get_columns_core_for_session(
&state,
&connection_id,
&database,
&schema,
&table,
Some(&client_session_id),
)
.await
});
let _abort_on_drop = AbortExportTaskOnDrop(task.abort_handle());
task.await.map_err(|error| format!("Database export metadata task failed: {error}"))?
@@ -1691,6 +1753,20 @@ pub async fn begin_database_backup_snapshot_core(
state: &crate::connection::AppState,
connection_id: &str,
database: &str,
) -> Result<DatabaseBackupSnapshot, String> {
begin_database_backup_snapshot_core_for_export(state, connection_id, database, None).await
}
/// Opens the consistent-snapshot transaction used by a scheduled backup.
///
/// When the snapshot is being created for an export run, `export_id` keeps
/// pool checkout and transaction creation cancellable even before a child
/// database export has been created.
pub async fn begin_database_backup_snapshot_core_for_export(
state: &crate::connection::AppState,
connection_id: &str,
database: &str,
export_id: Option<&str>,
) -> Result<DatabaseBackupSnapshot, String> {
let db_type = state
.configs
@@ -1703,22 +1779,51 @@ pub async fn begin_database_backup_snapshot_core(
return Err("Consistent database backup snapshots are only supported for MySQL and PostgreSQL".to_string());
}
let session_id = crate::query::begin_database_backup_snapshot(state, connection_id, database).await?;
let schemas = if matches!(db_type, DatabaseType::Postgres) {
let session_id = if let Some(export_id) = export_id {
// Do not use `await_export_operation` here: if the transaction is
// created at exactly the same time as cancellation, we still need the
// returned session id to roll it back rather than leaking it.
let operation = crate::query::begin_database_backup_snapshot(state, connection_id, database);
tokio::pin!(operation);
loop {
if is_export_cancelled_now(export_id) {
return Err(EXPORT_CANCELLED_ERROR.to_string());
}
tokio::select! {
biased;
result = &mut operation => {
let session_id = result?;
if is_export_cancelled_now(export_id) {
let _ = crate::query::rollback_manual_transaction(state, &session_id).await;
return Err(EXPORT_CANCELLED_ERROR.to_string());
}
break session_id;
},
_ = tokio::time::sleep(EXPORT_CANCEL_POLL_INTERVAL) => {}
}
}
} else {
crate::query::begin_database_backup_snapshot(state, connection_id, database).await?
};
let schemas_result = if matches!(db_type, DatabaseType::Postgres) {
const POSTGRES_BACKUP_SCHEMAS_SQL: &str = "SELECT n.nspname FROM pg_catalog.pg_namespace n \
WHERE n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \
AND n.nspname NOT LIKE 'pg_toast_temp_%' \
AND n.nspname NOT LIKE 'pg_temp_%' ORDER BY n.nspname";
let results = crate::query::execute_in_manual_transaction(
let operation = crate::query::execute_in_manual_transaction(
state,
&session_id,
POSTGRES_BACKUP_SCHEMAS_SQL,
database,
None,
Some(10_000),
)
.await?;
results
);
let results = match export_id {
Some(export_id) => await_export_operation(export_id, Box::pin(operation)).await,
None => operation.await,
}?;
Ok(results
.into_iter()
.next()
.map(|result| {
@@ -1729,9 +1834,20 @@ pub async fn begin_database_backup_snapshot_core(
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
.unwrap_or_default())
} else {
vec![database.to_string()]
Ok(vec![database.to_string()])
};
let schemas = match schemas_result {
Ok(schemas) => schemas,
Err(error) => {
let _ = crate::query::rollback_manual_transaction(state, &session_id).await;
return Err(error);
}
};
if export_id.map(is_export_cancelled_now).unwrap_or(false) {
let _ = crate::query::rollback_manual_transaction(state, &session_id).await;
return Err(EXPORT_CANCELLED_ERROR.to_string());
};
if schemas.is_empty() {
let _ = crate::query::rollback_manual_transaction(state, &session_id).await;
@@ -2152,6 +2268,16 @@ pub async fn export_database_sql_core(
// caller future small, this prevents the metadata-prefetch locals from
// exhausting the bounded stack used by test and runtime worker threads.
let result = Box::pin(export_database_sql_core_inner(state, request, &on_progress)).await;
let metadata_session_id = database_export_client_session_id(&request.export_id);
if let Err(error) =
state.close_metadata_session_pool(&request.connection_id, Some(&request.database), &metadata_session_id).await
{
log::warn!(
"[database-export] failed to close metadata session '{}' for '{}': {error}",
metadata_session_id,
request.connection_id
);
}
if result.as_ref().err().is_some_and(|error| error == EXPORT_CANCELLED_ERROR) {
// Every caller (Tauri and web SSE) needs a terminal Cancelled event;
// returning the marker as an error would leave the web EventSource
@@ -2231,7 +2357,12 @@ async fn export_database_sql_core_inner(
request.file_path
));
}
let mut file = BufWriter::new(file);
let mut file = match request.output_compression {
DatabaseExportOutputCompression::None => DatabaseExportWriter::Plain(BufWriter::new(file)),
DatabaseExportOutputCompression::Gzip => {
DatabaseExportWriter::Gzip(Box::new(GzEncoder::new(BufWriter::new(file), Compression::default())))
}
};
let create_database_preamble = if request.include_create_database && matches!(db_type, DatabaseType::Mysql) {
Some(mysql_database_export_preamble_for_request(state, request).await)
@@ -2455,6 +2586,7 @@ async fn export_database_sql_core_inner(
let prefetch_targets: Vec<(usize, String)> =
tables.iter().enumerate().map(|(index, table_info)| (index, table_info.name.clone())).collect();
let mut prefetch_stream = futures::stream::iter(prefetch_targets.into_iter().map(|(index, table_name)| {
let client_session_id = client_session_id.clone();
Box::pin(async move {
if is_export_cancelled_now(&request.export_id) {
return (index, PrefetchedTableMetadata { ddl: None, columns: None });
@@ -2469,6 +2601,7 @@ async fn export_database_sql_core_inner(
request.database.clone(),
request.schema.clone(),
table_name.clone(),
client_session_id.clone(),
)),
)
.await,
@@ -2489,6 +2622,7 @@ async fn export_database_sql_core_inner(
request.database.clone(),
request.schema.clone(),
table_name.clone(),
client_session_id.clone(),
)),
)
.await,
@@ -2641,6 +2775,7 @@ async fn export_database_sql_core_inner(
request.database.clone(),
request.schema.clone(),
table_name.clone(),
client_session_id.clone(),
)),
)
.await
@@ -2688,6 +2823,7 @@ async fn export_database_sql_core_inner(
request.database.clone(),
request.schema.clone(),
table_name.clone(),
client_session_id.clone(),
)),
)
.await
@@ -2726,9 +2862,8 @@ async fn export_database_sql_core_inner(
batch_size,
Some(cancel_token.clone()),
|rows| {
// PostgreSQL cancellation must unwind through its CancelRequest path so the
// connection reaches ReadyForQuery before rollback. Other snapshot streams do
// not consume the token yet, so retain their existing next-batch cancellation.
// PostgreSQL and MySQL consume the token while waiting for rows. Retain this
// batch-boundary check so cancellation also wins while a batch is being written.
if snapshot_batch_cancelled(&db_type, &request.export_id) {
return Err(EXPORT_CANCELLED_ERROR.to_string());
}
@@ -3062,7 +3197,7 @@ async fn export_database_sql_core_inner(
writeln!(file, "SET FOREIGN_KEY_CHECKS = 1;").map_err(|e| format!("Failed to write file: {e}"))?;
}
file.flush().map_err(|e| format!("Failed to finalize export file: {e}"))?;
file.finish()?;
// Emit Done progress
on_progress(ExportProgress {
@@ -3139,9 +3274,9 @@ mod tests {
mysql_view_dependencies_sql, normalize_export_table_ddl, record_export_destination_identity,
record_export_error, replace_database_export_select_list, sort_export_views_by_dependencies,
split_postgres_export_table_triggers, write_database_export_rows, BuildDatabaseSqlExportOptions,
BuildExportInsertStatementsOptions, DatabaseExportObjectCounts, DatabaseExportRequest, DdlNormalizeOptions,
ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers,
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
BuildExportInsertStatementsOptions, DatabaseExportObjectCounts, DatabaseExportRequest, DatabaseExportWriter,
DdlNormalizeOptions, ExportedTableSql, PostgresExportExtension, PostgresExportSequence,
PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
};
use crate::connection::AppState;
use crate::models::connection::DatabaseType;
@@ -3149,6 +3284,7 @@ mod tests {
use crate::types::SpatialColumn;
use crate::types::{ObjectInfo, ObjectSourceKind, TableInfo};
use serde_json::{json, Value};
use std::io::{Read, Write};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
@@ -3324,11 +3460,29 @@ mod tests {
drop_table_if_exists: false,
omit_auto_increment: false,
fail_on_error: false,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
}
}
#[test]
fn gzip_export_writer_finishes_a_readable_stream() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("backup.sql.gz");
let file = std::fs::File::create(&path).unwrap();
let mut writer = DatabaseExportWriter::Gzip(Box::new(flate2::write::GzEncoder::new(
std::io::BufWriter::new(file),
flate2::Compression::default(),
)));
writer.write_all(b"SELECT 1;\n").unwrap();
writer.finish().unwrap();
let mut output = String::new();
flate2::read::GzDecoder::new(std::fs::File::open(path).unwrap()).read_to_string(&mut output).unwrap();
assert_eq!(output, "SELECT 1;\n");
}
#[test]
fn export_progress_total_counts_only_requested_object_categories() {
let counts = DatabaseExportObjectCounts {
+107 -37
View File
@@ -4750,7 +4750,7 @@ async fn begin_transaction_session(
if !syntax_errors.is_empty() {
return Err(format!("START TRANSACTION failed for all compatible forms: {}", syntax_errors.join("; ")));
}
(TxnConnection::Mysql(conn), probe_pool_key.clone())
(TxnConnection::Mysql(Some(conn)), probe_pool_key.clone())
}
TxnPoolHandle::Agent => {
let client_session_id = format!("manual-txn-{}", uuid::Uuid::new_v4());
@@ -5072,7 +5072,14 @@ pub async fn execute_in_manual_transaction_with_options(
TxnConnection::Postgres(conn) => {
execute_manual_txn_postgres_statement(conn.as_ref(), statement, row_limit).await
}
TxnConnection::Mysql(conn) => execute_manual_txn_mysql_statement(conn, statement, row_limit).await,
TxnConnection::Mysql(conn) => {
execute_manual_txn_mysql_statement(
conn.as_mut().ok_or_else(|| MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string())?,
statement,
row_limit,
)
.await
}
TxnConnection::Agent { client, .. } => {
execute_manual_txn_agent_statement(
client,
@@ -5235,45 +5242,85 @@ where
Err(error) => Err(error),
}
}
TxnConnection::Mysql(conn) => match conn.query_iter(sql).await {
Ok(mut result) => match result.stream::<mysql_async::Row>().await {
Ok(Some(mut stream)) => {
let mut batch = Vec::with_capacity(batch_size);
let mut total_rows = 0_u64;
let mut error = None;
while let Some(row_result) = stream.next().await {
match row_result {
Ok(row) => {
batch.push(
(0..row.len()).map(|index| db::mysql::mysql_value_to_json(&row, index)).collect(),
);
total_rows += 1;
if batch.len() >= batch_size {
if let Err(err) = on_batch(std::mem::take(&mut batch)) {
error = Some(err);
TxnConnection::Mysql(Some(conn)) => {
let query_result = match cancel_token.as_ref() {
Some(cancel_token) => {
tokio::select! {
_ = cancel_token.cancelled() => Err(QUERY_CANCELED.to_string()),
result = conn.query_iter(sql) => result.map_err(|error| format!("Query failed: {error}")),
}
}
None => conn.query_iter(sql).await.map_err(|error| format!("Query failed: {error}")),
};
match query_result {
Ok(mut result) => {
let stream_result = match cancel_token.as_ref() {
Some(cancel_token) => {
tokio::select! {
_ = cancel_token.cancelled() => Err(QUERY_CANCELED.to_string()),
stream = result.stream::<mysql_async::Row>() => stream.map_err(|error| format!("Query failed: {error}")),
}
}
None => {
result.stream::<mysql_async::Row>().await.map_err(|error| format!("Query failed: {error}"))
}
};
match stream_result {
Ok(Some(mut stream)) => {
let mut batch = Vec::with_capacity(batch_size);
let mut total_rows = 0_u64;
let mut error = None;
loop {
let next_row = match cancel_token.as_ref() {
Some(cancel_token) => {
tokio::select! {
_ = cancel_token.cancelled() => {
error = Some(QUERY_CANCELED.to_string());
break;
}
row = stream.next() => row,
}
}
None => stream.next().await,
};
let Some(row_result) = next_row else { break };
match row_result {
Ok(row) => {
batch.push(
(0..row.len())
.map(|index| db::mysql::mysql_value_to_json(&row, index))
.collect(),
);
total_rows += 1;
if batch.len() >= batch_size {
if let Err(err) = on_batch(std::mem::take(&mut batch)) {
error = Some(err);
break;
}
batch = Vec::with_capacity(batch_size);
}
}
Err(err) => {
error = Some(format!("Query failed: {err}"));
break;
}
batch = Vec::with_capacity(batch_size);
}
}
Err(err) => {
error = Some(format!("Query failed: {err}"));
break;
if error.is_none() && !batch.is_empty() {
if let Err(err) = on_batch(batch) {
error = Some(err);
}
}
error.map_or(Ok(total_rows), Err)
}
Ok(None) => Err("Empty result set stream".to_string()),
Err(error) => Err(error),
}
if error.is_none() && !batch.is_empty() {
if let Err(err) = on_batch(batch) {
error = Some(err);
}
}
error.map_or(Ok(total_rows), Err)
}
Ok(None) => Err("Empty result set stream".to_string()),
Err(err) => Err(format!("Query failed: {err}")),
},
Err(err) => Err(format!("Query failed: {err}")),
},
Err(error) => Err(error),
}
}
TxnConnection::Mysql(None) => Err(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string()),
TxnConnection::Agent { .. } => {
Err("Streaming rows inside an agent manual transaction is not supported".to_string())
}
@@ -5288,9 +5335,12 @@ where
sessions.remove(txn_session_id)
};
if let Some(session) = removed {
let rollback_result =
let rollback_result = if err == QUERY_CANCELED {
discard_mysql_manual_txn_connection(&mut conn, operation_budget.cleanup_timeout).await
} else {
rollback_manual_txn_connection_with_postgres_timeout(&mut conn, Some(operation_budget.cleanup_timeout))
.await;
.await
};
release_manual_txn_session_pool(state, &session.connection_id, &mut conn).await;
if let Err(rollback_error) = rollback_result {
return Err(format!("{err}. Transaction cleanup failed: {rollback_error}"));
@@ -5334,9 +5384,10 @@ async fn rollback_manual_txn_connection_with_postgres_timeout(
conn.execute_typed("ROLLBACK", &[]).await.map_err(|e| format!("ROLLBACK failed: {e}"))?;
}
}
TxnConnection::Mysql(conn) => {
TxnConnection::Mysql(Some(conn)) => {
conn.query_drop("ROLLBACK").await.map_err(|e| format!("ROLLBACK failed: {e}"))?;
}
TxnConnection::Mysql(None) => return Ok(()),
TxnConnection::Agent { client, .. } => {
let mut locked = client.lock().await;
match locked.rollback_manual_transaction::<serde_json::Value>().await {
@@ -5366,6 +5417,24 @@ async fn rollback_manual_txn_connection_with_postgres_timeout(
Ok(())
}
/// A cancelled MySQL row stream may still have unread result packets. Do not
/// send ROLLBACK on that connection: mysql_async only consumes a dropped stream
/// when the next result set is requested. Discarding the dedicated connection
/// makes MySQL roll the transaction back and prevents protocol desynchronization.
async fn discard_mysql_manual_txn_connection(conn: &mut TxnConnection, timeout: Duration) -> Result<(), String> {
let TxnConnection::Mysql(conn) = conn else {
return rollback_manual_txn_connection_with_postgres_timeout(conn, Some(timeout)).await;
};
let Some(conn) = conn.take() else {
return Ok(());
};
match tokio::time::timeout(timeout, conn.disconnect()).await {
Ok(Ok(())) => Ok(()),
Ok(Err(error)) => Err(format!("Failed to discard cancelled MySQL connection: {error}")),
Err(_) => Err(format!("Discarding cancelled MySQL connection timed out after {} seconds", timeout.as_secs())),
}
}
async fn release_manual_txn_session_pool(state: &AppState, connection_id: &str, conn: &mut TxnConnection) {
let (client_session_id, database, cleanup_guard) = match conn {
TxnConnection::Agent { client_session_id, database, cleanup_guard, .. }
@@ -5628,9 +5697,10 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) -
TxnConnection::Postgres(conn) => {
conn.execute_typed("COMMIT", &[]).await.map_err(|e| format!("COMMIT failed: {e}"))?;
}
TxnConnection::Mysql(conn) => {
TxnConnection::Mysql(Some(conn)) => {
conn.query_drop("COMMIT").await.map_err(|e| format!("COMMIT failed: {e}"))?;
}
TxnConnection::Mysql(None) => return Err(MANUAL_TRANSACTION_SESSION_NOT_FOUND_ERROR.to_string()),
TxnConnection::Agent { client, .. } => {
let mut locked = client.lock().await;
locked
+22 -6
View File
@@ -7443,6 +7443,7 @@ pub async fn get_table_ddl_core(
table,
object_type,
TableDdlOptions::SINGLE_RELATION,
None,
)
.await
}
@@ -7455,17 +7456,27 @@ pub async fn get_table_export_ddl_core(
table: &str,
object_type: Option<db::ObjectSourceKind>,
) -> Result<String, String> {
get_table_ddl_core_with_options(state, connection_id, database, schema, table, object_type, TableDdlOptions::EXPORT)
.await
get_table_ddl_core_with_options(
state,
connection_id,
database,
schema,
table,
object_type,
TableDdlOptions::EXPORT,
None,
)
.await
}
pub(crate) async fn get_table_relation_export_ddl_core(
pub(crate) async fn get_table_relation_export_ddl_core_for_session(
state: &AppState,
connection_id: &str,
database: &str,
schema: &str,
table: &str,
object_type: Option<db::ObjectSourceKind>,
client_session_id: Option<&str>,
) -> Result<String, String> {
get_table_ddl_core_with_options(
state,
@@ -7475,6 +7486,7 @@ pub(crate) async fn get_table_relation_export_ddl_core(
table,
object_type,
TableDdlOptions::RELATION_EXPORT,
client_session_id,
)
.await
}
@@ -7495,6 +7507,7 @@ pub async fn get_table_display_ddl_core(
table,
object_type,
TableDdlOptions::DISPLAY,
None,
)
.await
}
@@ -7507,6 +7520,7 @@ async fn get_table_ddl_core_with_options(
table: &str,
object_type: Option<db::ObjectSourceKind>,
options: TableDdlOptions,
client_session_id: Option<&str>,
) -> Result<String, String> {
if crate::sql_dialect::parse_sqlserver_linked_schema_ref(schema).is_some() {
return Err("DDL is not supported for SQL Server linked server tables".to_string());
@@ -7551,8 +7565,8 @@ async fn get_table_ddl_core_with_options(
return Ok(source.source);
}
retry_metadata_connection(state, connection_id, Some(database), || {
get_table_ddl_once(state, connection_id, database, schema, table, options)
retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || {
get_table_ddl_once(state, connection_id, database, schema, table, options, client_session_id)
})
.await
}
@@ -7579,8 +7593,10 @@ async fn get_table_ddl_once(
schema: &str,
table: &str,
options: TableDdlOptions,
client_session_id: Option<&str>,
) -> Result<String, String> {
let pool_key = state.get_or_create_metadata_pool_for_session(connection_id, Some(database), None).await?;
let pool_key =
state.get_or_create_metadata_pool_for_session(connection_id, Some(database), client_session_id).await?;
let db_config = connection_config(state, connection_id).await;
{
+99 -12
View File
@@ -1,4 +1,6 @@
use std::io::Read as StdRead;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, BufReader};
@@ -535,12 +537,68 @@ pub async fn read_sql_file_preview(file_path: &Path, max_chars: usize) -> Result
}
struct SqlFileStreamDecoder {
reader: BufReader<tokio::fs::File>,
reader: SqlFileByteReader,
decoder: encoding_rs::Decoder,
pending_bytes: Vec<u8>,
reached_eof: bool,
}
enum SqlFileByteReader {
Plain(BufReader<tokio::fs::File>),
Gzip(Arc<Mutex<flate2::read::GzDecoder<std::io::BufReader<std::fs::File>>>>),
}
impl SqlFileByteReader {
async fn open(file_path: &Path) -> Result<Self, String> {
if is_gzip_sql_file_path(file_path) {
let path = file_path.to_path_buf();
let reader = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&path).map_err(|error| error.to_string())?;
Ok::<_, String>(flate2::read::GzDecoder::new(std::io::BufReader::new(file)))
})
.await
.map_err(|error| format!("Failed to open compressed SQL file: {error}"))??;
return Ok(Self::Gzip(Arc::new(Mutex::new(reader))));
}
let file = tokio::fs::File::open(file_path).await.map_err(|error| error.to_string())?;
Ok(Self::Plain(BufReader::with_capacity(SQL_FILE_READ_CHUNK_BYTES, file)))
}
async fn read(&mut self, buffer: &mut [u8]) -> Result<usize, String> {
match self {
Self::Plain(reader) => reader.read(buffer).await.map_err(|error| error.to_string()),
Self::Gzip(reader) => {
let reader = reader.clone();
let capacity = buffer.len();
let (chunk, read) = tokio::task::spawn_blocking(move || {
let mut chunk = vec![0; capacity];
let mut reader =
reader.lock().map_err(|_| "Compressed SQL reader lock was poisoned".to_string())?;
let read =
reader.read(&mut chunk).map_err(|error| format!("Failed to decompress SQL file: {error}"))?;
Ok::<_, String>((chunk, read))
})
.await
.map_err(|error| format!("Failed to read compressed SQL file: {error}"))??;
buffer[..read].copy_from_slice(&chunk[..read]);
Ok(read)
}
}
}
}
fn is_gzip_sql_file_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("gz"))
&& path
.file_stem()
.and_then(|stem| Path::new(stem).extension())
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("sql"))
}
impl SqlFileStreamDecoder {
async fn open(file_path: &Path) -> Result<Self, String> {
Self::open_with_detection_limit(file_path, None).await
@@ -548,18 +606,13 @@ impl SqlFileStreamDecoder {
async fn open_with_detection_limit(file_path: &Path, detection_limit: Option<usize>) -> Result<Self, String> {
let (encoding, bom_len) = detect_sql_file_encoding(file_path, detection_limit).await?;
let mut file = tokio::fs::File::open(file_path).await.map_err(|error| error.to_string())?;
let mut reader = SqlFileByteReader::open(file_path).await?;
let mut prefix = [0u8; 3];
let prefix_len = file.read(&mut prefix).await.map_err(|error| error.to_string())?;
let prefix_len = reader.read(&mut prefix).await?;
let prefix = &prefix[..prefix_len];
let mut pending_bytes = prefix[bom_len..].to_vec();
pending_bytes.reserve(SQL_FILE_READ_CHUNK_BYTES);
Ok(Self {
reader: BufReader::with_capacity(SQL_FILE_READ_CHUNK_BYTES, file),
decoder: encoding.new_decoder_without_bom_handling(),
pending_bytes,
reached_eof: false,
})
Ok(Self { reader, decoder: encoding.new_decoder_without_bom_handling(), pending_bytes, reached_eof: false })
}
async fn next_chunk(&mut self) -> Result<Option<String>, String> {
@@ -568,7 +621,7 @@ impl SqlFileStreamDecoder {
}
while !self.reached_eof && self.pending_bytes.len() < SQL_FILE_READ_CHUNK_BYTES {
let mut buffer = vec![0u8; SQL_FILE_READ_CHUNK_BYTES - self.pending_bytes.len()];
let read = self.reader.read(&mut buffer).await.map_err(|error| error.to_string())?;
let read = self.reader.read(&mut buffer).await?;
if read == 0 {
self.reached_eof = true;
break;
@@ -596,7 +649,7 @@ async fn detect_sql_file_encoding(
file_path: &Path,
detection_limit: Option<usize>,
) -> Result<(&'static encoding_rs::Encoding, usize), String> {
let mut file = tokio::fs::File::open(file_path).await.map_err(|error| error.to_string())?;
let mut file = SqlFileByteReader::open(file_path).await?;
let mut prefix = [0u8; 3];
let prefix_len = file.read(&mut prefix).await.map_err(|error| error.to_string())?;
let prefix = &prefix[..prefix_len];
@@ -623,7 +676,7 @@ async fn detect_sql_file_encoding(
let remaining =
detection_limit.map(|limit| limit.saturating_sub(inspected_bytes)).unwrap_or(SQL_FILE_READ_CHUNK_BYTES);
let mut buffer = vec![0u8; SQL_FILE_READ_CHUNK_BYTES.min(remaining.max(1))];
let read = file.read(&mut buffer).await.map_err(|error| error.to_string())?;
let read = file.read(&mut buffer).await?;
if read == 0 {
reached_eof = true;
} else {
@@ -1512,6 +1565,27 @@ mod tests {
path
}
async fn temporary_gzip_sql_file(bytes: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"dbx-sql-file-{}-{}.sql.gz",
std::process::id(),
TEMP_SQL_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
let bytes = bytes.to_vec();
let output = path.clone();
tokio::task::spawn_blocking(move || {
use std::io::Write;
let file = std::fs::File::create(&output).unwrap();
let mut writer = flate2::write::GzEncoder::new(file, flate2::Compression::default());
writer.write_all(&bytes).unwrap();
writer.finish().unwrap();
})
.await
.unwrap();
path
}
fn test_progress(status: SqlFileStatus, statement_index: usize) -> SqlFileProgress {
SqlFileProgress {
execution_id: "test-execution".to_string(),
@@ -1707,6 +1781,19 @@ mod tests {
assert_eq!(decoded, "SELECT '中文';");
}
#[tokio::test]
async fn streaming_decoder_reads_gzip_sql_files() {
let path = temporary_gzip_sql_file(b"SELECT 'compressed';\n").await;
let mut decoder = SqlFileStreamDecoder::open(&path).await.unwrap();
let mut decoded = String::new();
while let Some(chunk) = decoder.next_chunk().await.unwrap() {
decoded.push_str(&chunk);
}
tokio::fs::remove_file(path).await.unwrap();
assert_eq!(decoded, "SELECT 'compressed';\n");
}
#[test]
fn non_sqlserver_sql_file_keeps_statement_splitting_behavior() {
assert_eq!(
@@ -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,
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,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
@@ -251,6 +251,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,
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1000,
};
@@ -363,6 +364,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,
output_compression: Default::default(),
snapshot_session_id: Some(snapshot.session_id.clone()),
batch_size: 1,
};
@@ -477,6 +479,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,
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,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
@@ -211,6 +212,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,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
@@ -303,6 +305,7 @@ async fn live_mysql_database_export_creates_missing_destination_directory() {
drop_table_if_exists: true,
omit_auto_increment: false,
fail_on_error: true,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
@@ -367,6 +370,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,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
@@ -452,6 +456,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,
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,
output_compression: Default::default(),
snapshot_session_id: None,
batch_size: 1000,
};
+11 -5
View File
@@ -9,7 +9,9 @@ use axum::http::{header, StatusCode};
use axum::response::sse::{Event, Sse};
use axum::response::Response;
use axum::Json;
use dbx_core::database_export::{self, DatabaseExportRequest, ExportProgress, ExportStatus};
use dbx_core::database_export::{
self, DatabaseExportOutputCompression, DatabaseExportRequest, ExportProgress, ExportStatus,
};
use futures::stream::Stream;
use serde::Deserialize;
@@ -42,16 +44,18 @@ pub async fn start_database_export(
// endpoint after the export completes.
let tmp_dir = state.data_dir.join("tmp");
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?;
let tmp_file = tmp_dir.join(format!("database_export_{export_id}.sql"));
let extension = if req.output_compression == DatabaseExportOutputCompression::Gzip { "sql.gz" } else { "sql" };
let tmp_file = tmp_dir.join(format!("database_export_{export_id}.{extension}"));
let tmp_file_path = tmp_file.to_string_lossy().to_string();
req.file_path = tmp_file_path.clone();
let download_filename = format!("{}.sql", sanitize_export_filename(&req.database));
let download_filename = format!("{}.{}", sanitize_export_filename(&req.database), extension);
let content_format = extension.to_string();
// Store export file mapping for download
state.export_files.write().await.insert(
export_id.clone(),
WebExportFile { file_path: tmp_file_path.clone(), download_filename, format: "sql".to_string() },
WebExportFile { file_path: tmp_file_path.clone(), download_filename, format: content_format },
);
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
@@ -162,9 +166,11 @@ pub async fn database_export_download(
// Clean up temp file
let _ = tokio::fs::remove_file(&export_file.file_path).await;
let content_type =
if export_file.format == "sql.gz" { "application/gzip" } else { "application/sql; charset=utf-8" };
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/sql; charset=utf-8")
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, attachment_content_disposition(&export_file.download_filename))
.body(Body::from(data))
.map_err(|e| AppError::from(e.to_string()))
+1 -1
View File
@@ -343,7 +343,7 @@ 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(config.connectionId, database)");
const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(config.connectionId, database)");
const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId)");
const exportIndex = scheduler.indexOf("await runDatabaseExportUntilTerminal(");
assert.ok(schemaIndex >= 0);
+14 -1
View File
@@ -14,8 +14,15 @@ pub async fn begin_database_backup_snapshot(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
export_id: Option<String>,
) -> Result<DatabaseBackupSnapshot, String> {
dbx_core::database_export::begin_database_backup_snapshot_core(&state, &connection_id, &database).await
dbx_core::database_export::begin_database_backup_snapshot_core_for_export(
&state,
&connection_id,
&database,
export_id.as_deref(),
)
.await
}
#[tauri::command]
@@ -66,6 +73,12 @@ pub async fn cancel_database_export(export_id: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn clear_database_export_cancellation(export_id: String) -> Result<(), String> {
dbx_core::database_export::clear_export_cancelled(&export_id).await;
Ok(())
}
/// Records a scheduled backup destination's filesystem identity as soon as
/// the schedule is saved, not just after its first successful export. See
/// `record_export_destination_identity` for why this eager recording is
+1
View File
@@ -2446,6 +2446,7 @@ pub fn run() {
commands::database_export::begin_database_backup_snapshot,
commands::database_export::export_database_sql,
commands::database_export::cancel_database_export,
commands::database_export::clear_database_export_cancellation,
commands::database_export::record_database_export_destination,
commands::table_export::start_table_export,
commands::table_export::cancel_table_export,