mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(backup): add gzip compression and history filtering
This commit is contained in:
@@ -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") }}
|
||||
|
||||
+137
-2
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "手動実行",
|
||||
|
||||
@@ -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: "토픽",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "手动执行",
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user