mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(backup): add scheduled database backups
This commit is contained in:
@@ -31,6 +31,7 @@ import { useTauriEvents } from "@/composables/useTauriEvents";
|
||||
import { useCloseActionPrompt, type AppCloseAction, type AppCloseRequestOptions } from "@/composables/useCloseActionPrompt";
|
||||
import { useVisibilityChange } from "@/composables/useVisibilityChange";
|
||||
import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls";
|
||||
import "@/i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -293,6 +294,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
|
||||
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
|
||||
useVisibilityChange();
|
||||
useWebDavAutoUpload();
|
||||
useScheduledDatabaseBackups({ scheduler: true });
|
||||
|
||||
const appVersion = ref("");
|
||||
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
|
||||
@@ -307,6 +309,15 @@ function openSettings(initialTab = "appearance", initialSection?: string) {
|
||||
activateSettingsPage();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => settingsStore.settingsNavigationRequest,
|
||||
(request) => {
|
||||
if (!request) return;
|
||||
openSettings(request.tab, request.section);
|
||||
settingsStore.clearSettingsNavigationRequest(request.id);
|
||||
},
|
||||
);
|
||||
|
||||
function activateSettingsPage() {
|
||||
settingsPageTabOpen.value = true;
|
||||
settingsStore.settingsPageActive = true;
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } 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 { 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 * as api from "@/lib/backend/api";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { nextDatabaseBackupRunAt, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, runSchedule, cancelRun } = useScheduledDatabaseBackups();
|
||||
|
||||
const scheduleDialogOpen = ref(false);
|
||||
const deleteScheduleDialogOpen = ref(false);
|
||||
const deleteRunDialogOpen = ref(false);
|
||||
const editingScheduleId = ref("");
|
||||
const pendingDeleteSchedule = ref<DatabaseBackupSchedule | null>(null);
|
||||
const pendingDeleteRun = ref<DatabaseBackupRun | null>(null);
|
||||
const loadingDatabases = ref(false);
|
||||
const saving = ref(false);
|
||||
const databaseOptions = ref<string[]>([]);
|
||||
const allDatabases = ref(true);
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
const expandedRunIds = reactive(new Set<string>());
|
||||
|
||||
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
|
||||
const sortedRuns = computed(() => [...runs.value].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)));
|
||||
const weekdays = computed(() => [
|
||||
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
|
||||
{ value: 1, label: t("databaseBackup.weekdays.monday") },
|
||||
{ value: 2, label: t("databaseBackup.weekdays.tuesday") },
|
||||
{ value: 3, label: t("databaseBackup.weekdays.wednesday") },
|
||||
{ value: 4, label: t("databaseBackup.weekdays.thursday") },
|
||||
{ value: 5, label: t("databaseBackup.weekdays.friday") },
|
||||
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
|
||||
]);
|
||||
|
||||
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
|
||||
const now = new Date();
|
||||
const draft: DatabaseBackupSchedule = {
|
||||
id: generateDatabaseExportId(),
|
||||
name: t("databaseBackup.defaultScheduleName"),
|
||||
enabled: true,
|
||||
connectionId,
|
||||
databases: [],
|
||||
destinationDirectory: "",
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
timeOfDay: "02:00",
|
||||
weekday: 1,
|
||||
includeStructure: true,
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
dropTableIfExists: false,
|
||||
retentionCount: 10,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
nextRunAt: "",
|
||||
};
|
||||
draft.nextRunAt = nextDatabaseBackupRunAt(draft, now).toISOString();
|
||||
return draft;
|
||||
}
|
||||
|
||||
const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
|
||||
const canSave = computed(() => {
|
||||
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
|
||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && !saving.value && !loadingDatabases.value;
|
||||
});
|
||||
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
|
||||
|
||||
function connectionName(connectionId: string): string {
|
||||
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function frequencyLabel(schedule: DatabaseBackupSchedule): string {
|
||||
if (schedule.frequency === "hourly") return t("databaseBackup.everyHours", { count: schedule.intervalHours });
|
||||
if (schedule.frequency === "daily") return t("databaseBackup.dailyAt", { time: schedule.timeOfDay });
|
||||
return t("databaseBackup.weeklyAt", {
|
||||
weekday: weekdays.value.find((item) => item.value === schedule.weekday)?.label ?? "",
|
||||
time: schedule.timeOfDay,
|
||||
});
|
||||
}
|
||||
|
||||
function databaseScopeLabel(schedule: DatabaseBackupSchedule): string {
|
||||
if (schedule.databases.length === 0) return t("databaseBackup.allDatabases");
|
||||
if (schedule.databases.length === 1) return schedule.databases[0]!;
|
||||
return t("databaseBackup.databaseCount", { count: schedule.databases.length });
|
||||
}
|
||||
|
||||
function runStatusLabel(status: DatabaseBackupRun["status"]): string {
|
||||
return t(`databaseBackup.status.${status}`);
|
||||
}
|
||||
|
||||
function runStatusVariant(status: DatabaseBackupRun["status"]): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "success") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined {
|
||||
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, preserveSelection: boolean) {
|
||||
databaseOptions.value = [];
|
||||
if (!connectionId) return;
|
||||
loadingDatabases.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const names = (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||
databaseOptions.value = names;
|
||||
if (!preserveSelection) {
|
||||
selectedDatabases.value = [];
|
||||
allDatabases.value = true;
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
loadingDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreateSchedule() {
|
||||
editingScheduleId.value = "";
|
||||
draft.value = newScheduleDraft();
|
||||
allDatabases.value = true;
|
||||
selectedDatabases.value = [];
|
||||
scheduleDialogOpen.value = true;
|
||||
await loadDatabases(draft.value.connectionId, false);
|
||||
}
|
||||
|
||||
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
||||
editingScheduleId.value = schedule.id;
|
||||
draft.value = { ...schedule, databases: [...schedule.databases] };
|
||||
allDatabases.value = schedule.databases.length === 0;
|
||||
selectedDatabases.value = [...schedule.databases];
|
||||
scheduleDialogOpen.value = true;
|
||||
await loadDatabases(schedule.connectionId, true);
|
||||
}
|
||||
|
||||
async function changeConnection(connectionId: string) {
|
||||
draft.value.connectionId = connectionId;
|
||||
await loadDatabases(connectionId, false);
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string) {
|
||||
const selected = new Set(selectedDatabases.value);
|
||||
if (selected.has(database)) selected.delete(database);
|
||||
else selected.add(database);
|
||||
selectedDatabases.value = databaseOptions.value.filter((item) => selected.has(item));
|
||||
}
|
||||
|
||||
async function chooseDestination() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
|
||||
if (typeof selected === "string") draft.value.destinationDirectory = selected;
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
saveSchedule({
|
||||
...draft.value,
|
||||
databases: allDatabases.value ? [] : [...selectedDatabases.value],
|
||||
});
|
||||
scheduleDialogOpen.value = false;
|
||||
toast(t(editingScheduleId.value ? "databaseBackup.scheduleUpdated" : "databaseBackup.scheduleCreated"), 2500);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(schedule: DatabaseBackupSchedule) {
|
||||
try {
|
||||
const run = await runSchedule(schedule.id, "manual");
|
||||
if (!run) return;
|
||||
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
|
||||
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
|
||||
else toast(t("databaseBackup.runFailed", { error: run.error || t("databaseBackup.unknownError") }), 5000);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteSchedule(schedule: DatabaseBackupSchedule) {
|
||||
pendingDeleteSchedule.value = schedule;
|
||||
deleteScheduleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmDeleteSchedule() {
|
||||
if (!pendingDeleteSchedule.value) return;
|
||||
if (!deleteSchedule(pendingDeleteSchedule.value.id)) {
|
||||
toast(t("databaseBackup.cannotDeleteRunningSchedule"), 3000);
|
||||
return;
|
||||
}
|
||||
deleteScheduleDialogOpen.value = false;
|
||||
pendingDeleteSchedule.value = null;
|
||||
}
|
||||
|
||||
function requestDeleteRun(run: DatabaseBackupRun) {
|
||||
pendingDeleteRun.value = run;
|
||||
deleteRunDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteRun() {
|
||||
const run = pendingDeleteRun.value;
|
||||
if (!run) return;
|
||||
try {
|
||||
await deleteRun(run.id);
|
||||
deleteRunDialogOpen.value = false;
|
||||
pendingDeleteRun.value = null;
|
||||
toast(t("databaseBackup.backupDeleted"), 2500);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRunExpanded(runId: string) {
|
||||
if (expandedRunIds.has(runId)) expandedRunIds.delete(runId);
|
||||
else expandedRunIds.add(runId);
|
||||
}
|
||||
|
||||
async function revealBackup(file: DatabaseBackupFile) {
|
||||
try {
|
||||
await api.revealPathInFileManager(file.filePath);
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
connectionStore.sqlFileSource = {
|
||||
connectionId: run.connectionId,
|
||||
database: file.database,
|
||||
filePath: file.filePath,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold">{{ t("databaseBackup.schedules") }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
|
||||
</div>
|
||||
<Button size="sm" :disabled="sqlConnections.length === 0" @click="openCreateSchedule">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.addSchedule") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<div v-if="schedules.length === 0" class="flex min-h-36 flex-col items-center justify-center gap-2 px-4 py-8 text-center text-muted-foreground">
|
||||
<DatabaseBackup class="h-8 w-8 opacity-60" />
|
||||
<span class="text-sm">{{ t("databaseBackup.noSchedules") }}</span>
|
||||
</div>
|
||||
<div v-for="schedule in schedules" :key="schedule.id" class="grid gap-3 border-b border-border/70 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{{ frequencyLabel(schedule) }}</span>
|
||||
<span>{{ databaseScopeLabel(schedule) }}</span>
|
||||
<span>{{ t("databaseBackup.nextRun", { time: formatDate(schedule.nextRunAt) }) }}</span>
|
||||
<span>{{ t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}</span>
|
||||
</div>
|
||||
</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>
|
||||
<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" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeScheduleIds.has(schedule.id)" :title="t('databaseBackup.edit')" @click="openEditSchedule(schedule)">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive" :disabled="activeScheduleIds.has(schedule.id)" :title="t('databaseBackup.delete')" @click="requestDeleteSchedule(schedule)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-base font-semibold">{{ t("databaseBackup.history") }}</h3>
|
||||
<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 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" />
|
||||
<ChevronRight v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<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.scheduleName }}</span>
|
||||
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
|
||||
<Badge variant="outline" class="font-normal">{{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{{ run.connectionName || connectionName(run.connectionId) }}</span>
|
||||
<span>{{ formatDate(run.startedAt) }}</span>
|
||||
<span>{{ t("databaseBackup.fileCount", { count: run.files.length }) }}</span>
|
||||
<span v-if="run.error" class="break-all text-destructive">{{ run.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Loader2 v-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
<Button v-if="run.files[0]" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.revealFile')" @click="revealBackup(run.files[0])">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.deleteBackup')" @click="requestDeleteRun(run)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedRunIds.has(run.id) && run.files.length > 0" class="border-b border-border/70 bg-muted/20 px-4 py-2 last:border-b-0">
|
||||
<div v-for="file in run.files" :key="file.filePath" class="grid gap-2 border-b border-border/50 py-2 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-xs font-medium">{{ file.displayName }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground" :title="file.filePath">{{ file.filePath }}</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('databaseBackup.revealFile')" @click="revealBackup(file)">
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7" @click="restoreBackup(run, file)">
|
||||
<RotateCcw class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("databaseBackup.restore") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="scheduleDialogOpen">
|
||||
<DialogContent class="max-h-[min(760px,calc(100dvh-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingScheduleId ? t("databaseBackup.editSchedule") : t("databaseBackup.addSchedule") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-5 py-1">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
|
||||
<Input v-model="draft.name" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.connection") }}</Label>
|
||||
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => changeConnection(String(value))">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.destination") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
|
||||
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="chooseDestination">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label>{{ t("databaseBackup.databases") }}</Label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" />
|
||||
{{ t("databaseBackup.allDatabases") }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
|
||||
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
|
||||
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="toggleDatabase(database)" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.frequency") }}</Label>
|
||||
<Select v-model="draft.frequency">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">{{ t("databaseBackup.frequencyHourly") }}</SelectItem>
|
||||
<SelectItem value="daily">{{ t("databaseBackup.frequencyDaily") }}</SelectItem>
|
||||
<SelectItem value="weekly">{{ t("databaseBackup.frequencyWeekly") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="draft.frequency === 'hourly'" class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.intervalHours") }}</Label>
|
||||
<Input v-model.number="draft.intervalHours" type="number" min="1" max="168" />
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.time") }}</Label>
|
||||
<Input v-model="draft.timeOfDay" type="time" />
|
||||
</div>
|
||||
<div v-if="draft.frequency === 'weekly'" class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.weekday") }}</Label>
|
||||
<Select :model-value="String(draft.weekday)" @update:model-value="(value: any) => (draft.weekday = Number(value))">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="weekday in weekdays" :key="weekday.value" :value="String(weekday.value)">{{ weekday.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.retention") }}</Label>
|
||||
<Input v-model.number="draft.retentionCount" type="number" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label>{{ t("databaseBackup.contents") }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-t border-border/70 pt-4">
|
||||
<div>
|
||||
<Label>{{ t("databaseBackup.enabled") }}</Label>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.enabledHint") }}</div>
|
||||
</div>
|
||||
<Switch v-model="draft.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="scheduleDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="!canSave" @click="submitSchedule">
|
||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="deleteScheduleDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("databaseBackup.deleteSchedule") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.deleteScheduleConfirm", { name: pendingDeleteSchedule?.name || "" }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="deleteScheduleDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteSchedule">{{ t("databaseBackup.delete") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="deleteRunDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("databaseBackup.deleteBackup") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.deleteBackupConfirm", { count: pendingDeleteRun?.files.length || 0 }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="deleteRunDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteRun">{{ t("databaseBackup.delete") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -100,6 +100,7 @@ import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import AppLogo from "@/components/icons/AppLogo.vue";
|
||||
import ChangelogPanel from "@/components/settings/ChangelogPanel.vue";
|
||||
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
|
||||
import { APP_THEME_PALETTES, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
|
||||
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
|
||||
@@ -1279,13 +1280,14 @@ const appSupportInfoLabels = computed<AppSupportInfoLabels>(() => ({
|
||||
unknown: t("settings.supportInfoUnknown"),
|
||||
}));
|
||||
const appSupportInfoRows = computed(() => (appSupportInfo.value ? buildAppSupportInfoRows(appSupportInfo.value, appSupportInfoLabels.value) : []));
|
||||
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
|
||||
type SettingsCategory = "editor" | "formatter" | "appearance" | "navigation" | "data" | "backups" | "tunnels" | "shortcuts" | "snippets" | "sync" | "ai" | "mcp" | "security" | "about";
|
||||
const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[]>(() => [
|
||||
{ value: "appearance", label: t("settings.appearanceTab") },
|
||||
{ value: "editor", label: t("settings.editorTab") },
|
||||
{ value: "formatter", label: t("settings.sqlFormatterTab") },
|
||||
{ value: "navigation", label: t("settings.navigationTab") },
|
||||
{ value: "data", label: t("settings.dataTab") },
|
||||
...(isWeb ? [] : [{ value: "backups" as const, label: t("databaseBackup.title") }]),
|
||||
{ value: "tunnels", label: t("settings.tunnelsTab") },
|
||||
{ value: "shortcuts", label: t("settings.shortcutsTab") },
|
||||
{ value: "snippets", label: t("settings.snippetsTab") },
|
||||
@@ -4027,6 +4029,10 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSettingsTab === 'backups' && !isWeb" class="py-2">
|
||||
<ScheduledDatabaseBackupSettings />
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSettingsTab === 'sync'" class="py-2">
|
||||
<Tabs v-model="syncMethodTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
@@ -4953,7 +4959,7 @@ onUnmounted(cleanupPreviewEditor);
|
||||
</template>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'sync'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
<DialogFooter v-else-if="activeSettingsTab === 'sync' || activeSettingsTab === 'backups'" class="mx-0 mb-0 flex-row flex-wrap items-center justify-end gap-2 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 sm:flex-row sm:gap-2 [&>button]:w-auto [&>button]:shrink-0">
|
||||
<Button variant="outline" @click="closeSettings">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as api from "@/lib/backend/api";
|
||||
import type { ExportProgress } from "@/lib/backend/api";
|
||||
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { buildAllDatabaseExportPlan, generateDatabaseExportId, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
|
||||
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
|
||||
import { buildSelectedTablesPayload } from "@/lib/export/databaseExportSelection";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
@@ -111,21 +111,6 @@ function currentRowsExported(): number {
|
||||
return progress?.rowsExported ?? 0;
|
||||
}
|
||||
|
||||
async function runDatabaseExportUntilTerminal(request: api.DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
api
|
||||
.exportDatabaseSql(request, (progress) => {
|
||||
onProgress(progress);
|
||||
if (progress.status === "Done" || progress.status === "Cancelled") {
|
||||
resolve();
|
||||
} else if (progress.status === "Error") {
|
||||
reject(new Error(progress.error || "Export failed"));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDatabases(connId: string) {
|
||||
if (!connId) return;
|
||||
loadingMeta.value = true;
|
||||
|
||||
@@ -4325,6 +4325,10 @@ function openAllDatabasesExport() {
|
||||
};
|
||||
}
|
||||
|
||||
function openScheduledBackups() {
|
||||
settingsStore.requestSettingsNavigation("backups");
|
||||
}
|
||||
|
||||
function openTableImport() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
@@ -5297,6 +5301,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
}
|
||||
if (canExportAllDatabases.value) {
|
||||
items.push({ label: t("contextMenu.exportAllDatabases"), action: openAllDatabasesExport, icon: Upload });
|
||||
if (isTauriRuntime()) {
|
||||
items.push({ label: t("databaseBackup.title"), action: openScheduledBackups, icon: CalendarClock });
|
||||
}
|
||||
}
|
||||
if (canCreateDatabase.value) {
|
||||
items.push({
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ExportTask {
|
||||
|
||||
const taskMap = reactive<Map<string, ExportTask>>(new Map());
|
||||
const activeTransferRuns = new Set<string>();
|
||||
const taskCancelHandlers = new Map<string, () => void | Promise<void>>();
|
||||
|
||||
function normalizeExportStatus(status: string): BackgroundTaskStatus {
|
||||
if (status === "Writing" || status === "Done" || status === "Error" || status === "Cancelled") return status;
|
||||
@@ -283,20 +284,33 @@ export function useExportTracker() {
|
||||
|
||||
function removeTask(exportId: string) {
|
||||
taskMap.delete(exportId);
|
||||
taskCancelHandlers.delete(exportId);
|
||||
}
|
||||
|
||||
function clearFinished() {
|
||||
for (const [id, task] of taskMap) {
|
||||
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") {
|
||||
taskMap.delete(id);
|
||||
taskCancelHandlers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerTaskCancelHandler(exportId: string, handler: () => void | Promise<void>) {
|
||||
taskCancelHandlers.set(exportId, handler);
|
||||
}
|
||||
|
||||
function unregisterTaskCancelHandler(exportId: string) {
|
||||
taskCancelHandlers.delete(exportId);
|
||||
}
|
||||
|
||||
async function cancelTask(exportId: string) {
|
||||
const task = taskMap.get(exportId);
|
||||
try {
|
||||
if (task?.kind === "database-export") {
|
||||
const customHandler = taskCancelHandlers.get(exportId);
|
||||
if (customHandler) {
|
||||
await customHandler();
|
||||
} else if (task?.kind === "database-export") {
|
||||
await api.cancelDatabaseExport(exportId);
|
||||
} else if (task?.kind === "sql-file") {
|
||||
await api.cancelSqlFileExecution(exportId);
|
||||
@@ -323,6 +337,8 @@ export function useExportTracker() {
|
||||
updateDatabaseExportTask,
|
||||
updateSqlFileTask,
|
||||
updateDataTransferTask,
|
||||
registerTaskCancelHandler,
|
||||
unregisterTaskCancelHandler,
|
||||
removeTask,
|
||||
clearFinished,
|
||||
cancelTask,
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { appendDebugLog } from "@/lib/backend/debugLog";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal } from "@/lib/export/databaseExport";
|
||||
import {
|
||||
DATABASE_BACKUP_CONFIG_CHANGED_EVENT,
|
||||
DATABASE_BACKUP_RUNS_STORAGE_KEY,
|
||||
DATABASE_BACKUP_SCHEDULES_STORAGE_KEY,
|
||||
databaseBackupFilePath,
|
||||
databaseBackupRunsToPrune,
|
||||
databaseBackupScheduleIsDue,
|
||||
nextDatabaseBackupRunAt,
|
||||
normalizeDatabaseBackupSchedule,
|
||||
readDatabaseBackupRuns,
|
||||
readDatabaseBackupSchedules,
|
||||
resolveScheduledDatabaseBackupTargets,
|
||||
supportsScheduledDatabaseBackup,
|
||||
writeDatabaseBackupRuns,
|
||||
writeDatabaseBackupSchedules,
|
||||
type DatabaseBackupFile,
|
||||
type DatabaseBackupRun,
|
||||
type DatabaseBackupRunStatus,
|
||||
type DatabaseBackupRunTrigger,
|
||||
type DatabaseBackupSchedule,
|
||||
} from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { useExportTracker } from "@/composables/useExportTracker";
|
||||
|
||||
const SCHEDULER_INTERVAL_MS = 30_000;
|
||||
|
||||
const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
|
||||
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
|
||||
const activeScheduleIds = reactive(new Set<string>());
|
||||
const activeRunIds = reactive(new Set<string>());
|
||||
const activeExportIds = new Map<string, string>();
|
||||
const cancellationRequested = new Set<string>();
|
||||
|
||||
let schedulerTimer: ReturnType<typeof window.setInterval> | undefined;
|
||||
let schedulerRegistered = false;
|
||||
let processingDueSchedules = false;
|
||||
|
||||
function emitConfigChanged() {
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(DATABASE_BACKUP_CONFIG_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function persistSchedules() {
|
||||
writeDatabaseBackupSchedules(schedules.value);
|
||||
emitConfigChanged();
|
||||
}
|
||||
|
||||
function persistRuns() {
|
||||
writeDatabaseBackupRuns(runs.value);
|
||||
}
|
||||
|
||||
function replaceRun(run: DatabaseBackupRun) {
|
||||
runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)];
|
||||
persistRuns();
|
||||
}
|
||||
|
||||
function updateRun(runId: string, patch: Partial<DatabaseBackupRun>): DatabaseBackupRun | null {
|
||||
const existing = runs.value.find((run) => run.id === runId);
|
||||
if (!existing) return null;
|
||||
const updated = { ...existing, ...patch };
|
||||
replaceRun(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
function refreshFromStorage() {
|
||||
schedules.value = readDatabaseBackupSchedules();
|
||||
runs.value = readDatabaseBackupRuns();
|
||||
}
|
||||
|
||||
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const { addDatabaseExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
|
||||
|
||||
const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
|
||||
|
||||
function saveSchedule(value: DatabaseBackupSchedule): DatabaseBackupSchedule {
|
||||
const now = new Date();
|
||||
const existing = schedules.value.find((schedule) => schedule.id === value.id);
|
||||
const normalized = normalizeDatabaseBackupSchedule(
|
||||
{
|
||||
...value,
|
||||
createdAt: existing?.createdAt ?? value.createdAt,
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (!normalized) throw new Error("Invalid database backup schedule");
|
||||
|
||||
const timingChanged = !existing || existing.frequency !== normalized.frequency || existing.intervalHours !== normalized.intervalHours || existing.timeOfDay !== normalized.timeOfDay || existing.weekday !== normalized.weekday || (!existing.enabled && normalized.enabled);
|
||||
if (timingChanged) normalized.nextRunAt = nextDatabaseBackupRunAt(normalized, now).toISOString();
|
||||
if (existing) {
|
||||
normalized.lastRunAt = existing.lastRunAt;
|
||||
normalized.lastRunStatus = existing.lastRunStatus;
|
||||
}
|
||||
|
||||
schedules.value = [...schedules.value.filter((schedule) => schedule.id !== normalized.id), normalized].sort((left, right) => left.name.localeCompare(right.name));
|
||||
persistSchedules();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function setScheduleEnabled(scheduleId: string, enabled: boolean) {
|
||||
const schedule = schedules.value.find((item) => item.id === scheduleId);
|
||||
if (!schedule) return;
|
||||
saveSchedule({ ...schedule, enabled });
|
||||
}
|
||||
|
||||
function deleteSchedule(scheduleId: string): boolean {
|
||||
if (activeScheduleIds.has(scheduleId)) return false;
|
||||
schedules.value = schedules.value.filter((schedule) => schedule.id !== scheduleId);
|
||||
persistSchedules();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteRun(runId: string): Promise<void> {
|
||||
const run = runs.value.find((item) => item.id === runId);
|
||||
if (!run || activeRunIds.has(runId)) return;
|
||||
if (run.files.length > 0) await api.deleteDatabaseBackupFiles(run.files.map((file) => file.filePath));
|
||||
runs.value = runs.value.filter((item) => item.id !== runId);
|
||||
persistRuns();
|
||||
}
|
||||
|
||||
async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
|
||||
const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
|
||||
if (staleRuns.length === 0) return;
|
||||
const stalePaths = staleRuns.flatMap((run) => run.files.map((file) => file.filePath));
|
||||
try {
|
||||
if (stalePaths.length > 0) await api.deleteDatabaseBackupFiles(stalePaths);
|
||||
const staleIds = new Set(staleRuns.map((run) => run.id));
|
||||
runs.value = runs.value.filter((run) => !staleIds.has(run.id));
|
||||
persistRuns();
|
||||
} catch (error) {
|
||||
appendDebugLog("error", "[DBX][database-backup:retention-error]", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRun(runId: string) {
|
||||
if (!activeRunIds.has(runId)) return;
|
||||
cancellationRequested.add(runId);
|
||||
const exportId = activeExportIds.get(runId);
|
||||
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
|
||||
}
|
||||
|
||||
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
|
||||
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
|
||||
const schedule = schedules.value.find((item) => item.id === scheduleId);
|
||||
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
|
||||
const connection = connectionStore.getConfig(schedule.connectionId);
|
||||
|
||||
const startedAt = new Date();
|
||||
const runId = generateDatabaseExportId();
|
||||
const run: DatabaseBackupRun = {
|
||||
id: runId,
|
||||
scheduleId: schedule.id,
|
||||
scheduleName: schedule.name,
|
||||
connectionId: schedule.connectionId,
|
||||
connectionName: connection?.name ?? "",
|
||||
trigger,
|
||||
status: "running",
|
||||
startedAt: startedAt.toISOString(),
|
||||
files: [],
|
||||
};
|
||||
replaceRun(run);
|
||||
activeScheduleIds.add(schedule.id);
|
||||
activeRunIds.add(runId);
|
||||
cancellationRequested.delete(runId);
|
||||
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory);
|
||||
registerTaskCancelHandler(runId, () => cancelRun(runId));
|
||||
|
||||
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
|
||||
let finalError = "";
|
||||
let finishedRun: DatabaseBackupRun | null = null;
|
||||
const generatedPaths: string[] = [];
|
||||
try {
|
||||
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
|
||||
await connectionStore.ensureConnected(schedule.connectionId);
|
||||
const availableDatabases = (await api.listDatabases(schedule.connectionId)).map((database) => database.name);
|
||||
const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases);
|
||||
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
|
||||
|
||||
let exportIndex = 0;
|
||||
let totalExports = selectedDatabases.length;
|
||||
for (const database of selectedDatabases) {
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
|
||||
let snapshotCompleted = false;
|
||||
try {
|
||||
const databasePlan = buildAllDatabaseExportPlan({
|
||||
databases: [database],
|
||||
schemaAware: connection.db_type === "postgres",
|
||||
schemasByDatabase: { [database]: snapshot.schemas },
|
||||
});
|
||||
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
|
||||
totalExports += databasePlan.length - 1;
|
||||
|
||||
for (const item of databasePlan) {
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
exportIndex += 1;
|
||||
const childExportId = `${runId}-${exportIndex}`;
|
||||
activeExportIds.set(runId, childExportId);
|
||||
const filePath = databaseBackupFilePath(schedule.destinationDirectory, schedule.name, item.fileStem, startedAt, runId);
|
||||
generatedPaths.push(filePath);
|
||||
const terminal = await runDatabaseExportUntilTerminal(
|
||||
{
|
||||
exportId: childExportId,
|
||||
connectionId: schedule.connectionId,
|
||||
database: item.database,
|
||||
schema: item.schema,
|
||||
filePath,
|
||||
includeStructure: schedule.includeStructure,
|
||||
includeData: schedule.includeData,
|
||||
includeObjects: schedule.includeObjects,
|
||||
dropTableIfExists: schedule.dropTableIfExists,
|
||||
failOnError: true,
|
||||
snapshotSessionId: snapshot.sessionId,
|
||||
batchSize: 1000,
|
||||
},
|
||||
(progress) => {
|
||||
updateDatabaseExportTask(runId, {
|
||||
...progress,
|
||||
exportId: runId,
|
||||
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
|
||||
objectIndex: exportIndex - 1,
|
||||
totalObjects: totalExports,
|
||||
});
|
||||
},
|
||||
);
|
||||
activeExportIds.delete(runId);
|
||||
if (terminal.status === "Cancelled" || cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
}
|
||||
|
||||
const file: DatabaseBackupFile = {
|
||||
database: item.database,
|
||||
schema: item.schema,
|
||||
displayName: item.displayName,
|
||||
filePath,
|
||||
};
|
||||
run.files.push(file);
|
||||
updateRun(runId, { files: [...run.files] });
|
||||
}
|
||||
snapshotCompleted = finalStatus === "success";
|
||||
} finally {
|
||||
await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => {
|
||||
if (snapshotCompleted) throw error;
|
||||
});
|
||||
}
|
||||
if (finalStatus !== "success") break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
|
||||
finalError = error?.message || String(error);
|
||||
} finally {
|
||||
if (finalStatus !== "success" && generatedPaths.length > 0) {
|
||||
try {
|
||||
await api.deleteDatabaseBackupFiles(generatedPaths);
|
||||
run.files = [];
|
||||
} catch (error: any) {
|
||||
appendDebugLog("error", "[DBX][database-backup:partial-cleanup-error]", error);
|
||||
const cleanupError = error?.message || String(error);
|
||||
finalError = finalError ? `${finalError}; failed to remove partial backup files: ${cleanupError}` : cleanupError;
|
||||
}
|
||||
}
|
||||
const completedAt = new Date();
|
||||
activeExportIds.delete(runId);
|
||||
cancellationRequested.delete(runId);
|
||||
activeScheduleIds.delete(schedule.id);
|
||||
activeRunIds.delete(runId);
|
||||
unregisterTaskCancelHandler(runId);
|
||||
|
||||
finishedRun = updateRun(runId, {
|
||||
status: finalStatus,
|
||||
completedAt: completedAt.toISOString(),
|
||||
files: [...run.files],
|
||||
error: finalError || undefined,
|
||||
});
|
||||
updateDatabaseExportTask(runId, {
|
||||
exportId: runId,
|
||||
currentObject: schedule.name,
|
||||
objectIndex: finalStatus === "success" ? run.files.length : 0,
|
||||
totalObjects: run.files.length,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error",
|
||||
error: finalError || null,
|
||||
});
|
||||
|
||||
const latestSchedule = schedules.value.find((item) => item.id === schedule.id);
|
||||
if (latestSchedule) {
|
||||
schedules.value = schedules.value.map((item) =>
|
||||
item.id === schedule.id
|
||||
? {
|
||||
...item,
|
||||
lastRunAt: completedAt.toISOString(),
|
||||
lastRunStatus: finalStatus,
|
||||
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
persistSchedules();
|
||||
if (finalStatus === "success") await pruneScheduleRuns(latestSchedule);
|
||||
}
|
||||
|
||||
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
files: run.files.length,
|
||||
error: finalError || undefined,
|
||||
});
|
||||
}
|
||||
return finishedRun;
|
||||
}
|
||||
|
||||
async function processDueSchedules() {
|
||||
if (processingDueSchedules) return;
|
||||
processingDueSchedules = true;
|
||||
try {
|
||||
await connectionStore.initFromDisk();
|
||||
const now = new Date();
|
||||
const dueSchedules = schedules.value.filter((schedule) => databaseBackupScheduleIsDue(schedule, now));
|
||||
for (const schedule of dueSchedules) {
|
||||
await runSchedule(schedule.id, "scheduled").catch((error) => {
|
||||
appendDebugLog("error", "[DBX][database-backup:scheduler-error]", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
appendDebugLog("error", "[DBX][database-backup:scheduler-init-error]", error);
|
||||
} finally {
|
||||
processingDueSchedules = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key && event.key !== DATABASE_BACKUP_SCHEDULES_STORAGE_KEY && event.key !== DATABASE_BACKUP_RUNS_STORAGE_KEY) return;
|
||||
refreshFromStorage();
|
||||
void processDueSchedules();
|
||||
}
|
||||
|
||||
function onConfigChanged() {
|
||||
void processDueSchedules();
|
||||
}
|
||||
|
||||
if (options.scheduler) {
|
||||
onMounted(() => {
|
||||
if (schedulerRegistered || !isTauriRuntime()) return;
|
||||
schedulerRegistered = true;
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
|
||||
schedulerTimer = window.setInterval(() => void processDueSchedules(), SCHEDULER_INTERVAL_MS);
|
||||
void processDueSchedules();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!schedulerRegistered) return;
|
||||
schedulerRegistered = false;
|
||||
if (schedulerTimer) window.clearInterval(schedulerTimer);
|
||||
schedulerTimer = undefined;
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schedules,
|
||||
runs,
|
||||
activeScheduleIds,
|
||||
activeRunIds,
|
||||
activeRuns,
|
||||
saveSchedule,
|
||||
setScheduleEnabled,
|
||||
deleteSchedule,
|
||||
deleteRun,
|
||||
runSchedule,
|
||||
cancelRun,
|
||||
processDueSchedules,
|
||||
};
|
||||
}
|
||||
@@ -4030,6 +4030,82 @@ export default {
|
||||
exportCancelled: "Export cancelled",
|
||||
runInBackground: "Run in background",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "Database Backups",
|
||||
schedules: "Backup schedules",
|
||||
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
|
||||
addSchedule: "Add schedule",
|
||||
editSchedule: "Edit schedule",
|
||||
defaultScheduleName: "Database backup",
|
||||
noSchedules: "No backup schedules",
|
||||
scheduleName: "Schedule name",
|
||||
connection: "Connection",
|
||||
missingConnection: "Missing connection",
|
||||
destination: "Destination directory",
|
||||
selectDestination: "Select backup directory",
|
||||
databases: "Databases",
|
||||
allDatabases: "All databases",
|
||||
databaseCount: "{count} databases",
|
||||
frequency: "Frequency",
|
||||
frequencyHourly: "Every few hours",
|
||||
frequencyDaily: "Daily",
|
||||
frequencyWeekly: "Weekly",
|
||||
intervalHours: "Interval (hours)",
|
||||
time: "Time",
|
||||
weekday: "Day of week",
|
||||
retention: "Keep latest runs",
|
||||
contents: "Backup contents",
|
||||
enabled: "Schedule enabled",
|
||||
enabledHint: "The next run is calculated in your local time zone.",
|
||||
nextRun: "Next: {time}",
|
||||
nextRunPreview: "Next run: {time}",
|
||||
keepRuns: "Keep {count}",
|
||||
everyHours: "Every {count} hours",
|
||||
dailyAt: "Daily at {time}",
|
||||
weeklyAt: "{weekday} at {time}",
|
||||
never: "Never",
|
||||
enable: "Enable schedule",
|
||||
disable: "Disable schedule",
|
||||
runNow: "Back up now",
|
||||
cancel: "Cancel backup",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
scheduleCreated: "Backup schedule created",
|
||||
scheduleUpdated: "Backup schedule updated",
|
||||
deleteSchedule: "Delete schedule",
|
||||
deleteScheduleConfirm: 'Delete schedule "{name}"? Existing backup files will remain in history.',
|
||||
cannotDeleteRunningSchedule: "A running backup schedule cannot be deleted",
|
||||
history: "Backup history",
|
||||
noHistory: "No backups yet",
|
||||
showFiles: "Show backup files",
|
||||
scheduledTrigger: "Scheduled",
|
||||
manualTrigger: "Manual",
|
||||
fileCount: "{count} files",
|
||||
revealFile: "Show in file manager",
|
||||
restore: "Restore",
|
||||
deleteBackup: "Delete backup",
|
||||
deleteBackupConfirm: "Delete this backup and its {count} SQL files? This cannot be undone.",
|
||||
backupDeleted: "Backup deleted",
|
||||
runSuccess: "Backup completed with {count} files",
|
||||
runCancelled: "Backup cancelled",
|
||||
runFailed: "Backup failed: {error}",
|
||||
unknownError: "Unknown error",
|
||||
status: {
|
||||
running: "Running",
|
||||
success: "Completed",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "Sunday",
|
||||
monday: "Monday",
|
||||
tuesday: "Tuesday",
|
||||
wednesday: "Wednesday",
|
||||
thursday: "Thursday",
|
||||
friday: "Friday",
|
||||
saturday: "Saturday",
|
||||
},
|
||||
},
|
||||
mqTopics: {
|
||||
title: "Topics",
|
||||
searchPlaceholder: "Search topics",
|
||||
|
||||
@@ -4577,4 +4577,80 @@ export default withEnglishFallback({
|
||||
rawStatus: "Variables de estado",
|
||||
filterStatus: "Filtrar variables de estado",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "Copia de seguridad de la base de datos",
|
||||
schedules: "Planes de copia de seguridad",
|
||||
runtimeRequirement: "DBX ejecutará copias de seguridad programadas cuando esté abierto, incluso cuando esté minimizado en la bandeja.",
|
||||
addSchedule: "Nuevo plan",
|
||||
editSchedule: "Editar plan",
|
||||
defaultScheduleName: "Copia de seguridad de la base de datos",
|
||||
noSchedules: "No hay planes de copia de seguridad",
|
||||
scheduleName: "Nombre del plan",
|
||||
connection: "Conexión",
|
||||
missingConnection: "La conexión ya no existe",
|
||||
destination: "Directorio de copia de seguridad",
|
||||
selectDestination: "Seleccionar directorio de copia de seguridad",
|
||||
databases: "Bases de datos",
|
||||
allDatabases: "Todas las bases de datos",
|
||||
databaseCount: "{count} bases de datos",
|
||||
frequency: "Frecuencia de ejecución",
|
||||
frequencyHourly: "Cada hora",
|
||||
frequencyDaily: "Diariamente",
|
||||
frequencyWeekly: "Semanalmente",
|
||||
intervalHours: "Intervalo en horas",
|
||||
time: "Hora de ejecución",
|
||||
weekday: "Día de la semana",
|
||||
retention: "Conservar las últimas ejecuciones",
|
||||
contents: "Contenido de la copia de seguridad",
|
||||
enabled: "Plan habilitado",
|
||||
enabledHint: "La próxima ejecución se calcula según la zona horaria local.",
|
||||
nextRun: "Próxima: {time}",
|
||||
nextRunPreview: "Próxima ejecución: {time}",
|
||||
keepRuns: "Conservar {count} ejecuciones",
|
||||
everyHours: "Cada {count} horas",
|
||||
dailyAt: "Diariamente a las {time}",
|
||||
weeklyAt: "{weekday} a las {time}",
|
||||
never: "Nunca",
|
||||
enable: "Habilitar plan",
|
||||
disable: "Deshabilitar plan",
|
||||
runNow: "Ejecutar copia ahora",
|
||||
cancel: "Cancelar copia",
|
||||
edit: "Editar",
|
||||
delete: "Eliminar",
|
||||
scheduleCreated: "Plan de copia de seguridad creado",
|
||||
scheduleUpdated: "Plan de copia de seguridad actualizado",
|
||||
deleteSchedule: "Eliminar plan",
|
||||
deleteScheduleConfirm: "¿Está seguro de que desea eliminar el plan «{name}»? Los archivos de copia de seguridad existentes aún se conservarán en el historial.",
|
||||
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",
|
||||
showFiles: "Mostrar archivos de copia",
|
||||
scheduledTrigger: "Ejecución programada",
|
||||
manualTrigger: "Ejecución manual",
|
||||
fileCount: "{count} archivos",
|
||||
revealFile: "Mostrar en el administrador de archivos",
|
||||
restore: "Restaurar",
|
||||
deleteBackup: "Eliminar copia de seguridad",
|
||||
deleteBackupConfirm: "¿Está seguro de que desea eliminar esta copia de seguridad y sus {count} archivos SQL? Esta acción no se puede deshacer.",
|
||||
backupDeleted: "Copia de seguridad eliminada",
|
||||
runSuccess: "Copia de seguridad completada, se generaron {count} archivos",
|
||||
runCancelled: "Copia de seguridad cancelada",
|
||||
runFailed: "Copia de seguridad fallida: {error}",
|
||||
unknownError: "Error desconocido",
|
||||
status: {
|
||||
running: "En ejecución",
|
||||
success: "Completado",
|
||||
failed: "Fallido",
|
||||
cancelled: "Cancelado",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "Domingo",
|
||||
monday: "Lunes",
|
||||
tuesday: "Martes",
|
||||
wednesday: "Miércoles",
|
||||
thursday: "Jueves",
|
||||
friday: "Viernes",
|
||||
saturday: "Sábado",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4575,4 +4575,80 @@ export default withEnglishFallback({
|
||||
rawStatus: "Variabili di stato",
|
||||
filterStatus: "Filtra variabili di stato",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "Backup del database",
|
||||
schedules: "Pianificazioni di backup",
|
||||
runtimeRequirement: "I backup programmati vengono eseguiti quando DBX è aperto, anche se ridotto a icona nella barra delle applicazioni.",
|
||||
addSchedule: "Nuova pianificazione",
|
||||
editSchedule: "Modifica pianificazione",
|
||||
defaultScheduleName: "Backup del database",
|
||||
noSchedules: "Nessuna pianificazione di backup",
|
||||
scheduleName: "Nome pianificazione",
|
||||
connection: "Connessione",
|
||||
missingConnection: "Connessione non più esistente",
|
||||
destination: "Directory di backup",
|
||||
selectDestination: "Seleziona directory di backup",
|
||||
databases: "Database",
|
||||
allDatabases: "Tutti i database",
|
||||
databaseCount: "{count} database",
|
||||
frequency: "Frequenza",
|
||||
frequencyHourly: "Ogni ora",
|
||||
frequencyDaily: "Ogni giorno",
|
||||
frequencyWeekly: "Ogni settimana",
|
||||
intervalHours: "Intervallo in ore",
|
||||
time: "Ora di esecuzione",
|
||||
weekday: "Giorno della settimana",
|
||||
retention: "Conservazione recente",
|
||||
contents: "Contenuto del backup",
|
||||
enabled: "Pianificazione attiva",
|
||||
enabledHint: "La prossima esecuzione viene calcolata in base al fuso orario locale.",
|
||||
nextRun: "Prossima: {time}",
|
||||
nextRunPreview: "Prossima esecuzione: {time}",
|
||||
keepRuns: "Conserva {count} esecuzioni",
|
||||
everyHours: "Ogni {count} ore",
|
||||
dailyAt: "Ogni giorno alle {time}",
|
||||
weeklyAt: "{weekday} alle {time}",
|
||||
never: "Mai",
|
||||
enable: "Attiva pianificazione",
|
||||
disable: "Disattiva pianificazione",
|
||||
runNow: "Esegui backup ora",
|
||||
cancel: "Annulla backup",
|
||||
edit: "Modifica",
|
||||
delete: "Elimina",
|
||||
scheduleCreated: "Pianificazione di backup creata",
|
||||
scheduleUpdated: "Pianificazione di backup aggiornata",
|
||||
deleteSchedule: "Elimina pianificazione",
|
||||
deleteScheduleConfirm: 'Eliminare la pianificazione "{name}"? I file di backup esistenti rimarranno nella cronologia.',
|
||||
cannotDeleteRunningSchedule: "Impossibile eliminare una pianificazione di backup in esecuzione",
|
||||
history: "Cronologia backup",
|
||||
noHistory: "Nessun record di backup",
|
||||
showFiles: "Mostra file di backup",
|
||||
scheduledTrigger: "Esecuzione programmata",
|
||||
manualTrigger: "Esecuzione manuale",
|
||||
fileCount: "{count} file",
|
||||
revealFile: "Mostra nel file manager",
|
||||
restore: "Ripristina",
|
||||
deleteBackup: "Elimina backup",
|
||||
deleteBackupConfirm: "Eliminare questo backup e i suoi {count} file SQL? Questa operazione non può essere annullata.",
|
||||
backupDeleted: "Backup eliminato",
|
||||
runSuccess: "Backup completato, generati {count} file",
|
||||
runCancelled: "Backup annullato",
|
||||
runFailed: "Backup fallito: {error}",
|
||||
unknownError: "Errore sconosciuto",
|
||||
status: {
|
||||
running: "In esecuzione",
|
||||
success: "Completato",
|
||||
failed: "Fallito",
|
||||
cancelled: "Annullato",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "Domenica",
|
||||
monday: "Lunedì",
|
||||
tuesday: "Martedì",
|
||||
wednesday: "Mercoledì",
|
||||
thursday: "Giovedì",
|
||||
friday: "Venerdì",
|
||||
saturday: "Sabato",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4576,4 +4576,80 @@ export default withEnglishFallback({
|
||||
rawStatus: "ステータス変数",
|
||||
filterStatus: "ステータス変数の絞り込み",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "データベースバックアップ",
|
||||
schedules: "バックアップスケジュール",
|
||||
runtimeRequirement: "DBXが開いている状態(トレイに最小化したときも含む)で、定期バックアップが実行されます。",
|
||||
addSchedule: "新規スケジュール",
|
||||
editSchedule: "スケジュール編集",
|
||||
defaultScheduleName: "データベースバックアップ",
|
||||
noSchedules: "バックアップスケジュールがありません",
|
||||
scheduleName: "スケジュール名",
|
||||
connection: "接続",
|
||||
missingConnection: "接続が存在しません",
|
||||
destination: "バックアップ先",
|
||||
selectDestination: "バックアップ先を選択",
|
||||
databases: "データベース",
|
||||
allDatabases: "すべてのデータベース",
|
||||
databaseCount: "{count} 個のデータベース",
|
||||
frequency: "実行頻度",
|
||||
frequencyHourly: "毎時",
|
||||
frequencyDaily: "毎日",
|
||||
frequencyWeekly: "毎週",
|
||||
intervalHours: "間隔(時間)",
|
||||
time: "実行時刻",
|
||||
weekday: "曜日",
|
||||
retention: "保持世代数",
|
||||
contents: "バックアップ内容",
|
||||
enabled: "有効",
|
||||
enabledHint: "次回実行時刻はローカルタイムゾーンに基づきます。",
|
||||
nextRun: "次回:{time}",
|
||||
nextRunPreview: "次回実行:{time}",
|
||||
keepRuns: "{count} 回保持",
|
||||
everyHours: "{count} 時間ごと",
|
||||
dailyAt: "毎日 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
never: "なし",
|
||||
enable: "有効にする",
|
||||
disable: "無効にする",
|
||||
runNow: "今すぐバックアップ",
|
||||
cancel: "キャンセル",
|
||||
edit: "編集",
|
||||
delete: "削除",
|
||||
scheduleCreated: "バックアップスケジュールが作成されました",
|
||||
scheduleUpdated: "バックアップスケジュールが更新されました",
|
||||
deleteSchedule: "スケジュールを削除",
|
||||
deleteScheduleConfirm: "スケジュール「{name}」を削除してもよろしいですか?既存のバックアップファイルは履歴に残ります。",
|
||||
cannotDeleteRunningSchedule: "実行中のバックアップスケジュールは削除できません",
|
||||
history: "バックアップ履歴",
|
||||
noHistory: "バックアップ履歴がありません",
|
||||
showFiles: "バックアップファイルを表示",
|
||||
scheduledTrigger: "定期実行",
|
||||
manualTrigger: "手動実行",
|
||||
fileCount: "{count} ファイル",
|
||||
revealFile: "ファイルマネージャーで表示",
|
||||
restore: "復元",
|
||||
deleteBackup: "バックアップを削除",
|
||||
deleteBackupConfirm: "このバックアップとその {count} 個の SQL ファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
backupDeleted: "バックアップが削除されました",
|
||||
runSuccess: "バックアップが完了しました。{count} 個のファイルが生成されました。",
|
||||
runCancelled: "バックアップがキャンセルされました",
|
||||
runFailed: "バックアップに失敗しました:{error}",
|
||||
unknownError: "不明なエラー",
|
||||
status: {
|
||||
running: "実行中",
|
||||
success: "完了",
|
||||
failed: "失敗",
|
||||
cancelled: "キャンセル済み",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "日曜日",
|
||||
monday: "月曜日",
|
||||
tuesday: "火曜日",
|
||||
wednesday: "水曜日",
|
||||
thursday: "木曜日",
|
||||
friday: "金曜日",
|
||||
saturday: "土曜日",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4577,4 +4577,80 @@ export default withEnglishFallback({
|
||||
rawStatus: "Variáveis de estado",
|
||||
filterStatus: "Filtrar variáveis de estado",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "Backup de banco de dados",
|
||||
schedules: "Planos de backup",
|
||||
runtimeRequirement: "O DBX executará backups agendados quando estiver aberto, inclusive quando minimizado na bandeja.",
|
||||
addSchedule: "Novo plano",
|
||||
editSchedule: "Editar plano",
|
||||
defaultScheduleName: "Backup de banco de dados",
|
||||
noSchedules: "Nenhum plano de backup",
|
||||
scheduleName: "Nome do plano",
|
||||
connection: "Conexão",
|
||||
missingConnection: "Conexão não existe mais",
|
||||
destination: "Diretório de backup",
|
||||
selectDestination: "Selecionar diretório de backup",
|
||||
databases: "Bancos de dados",
|
||||
allDatabases: "Todos os bancos de dados",
|
||||
databaseCount: "{count} bancos de dados",
|
||||
frequency: "Frequência",
|
||||
frequencyHourly: "A cada hora",
|
||||
frequencyDaily: "Diariamente",
|
||||
frequencyWeekly: "Semanalmente",
|
||||
intervalHours: "Intervalo em horas",
|
||||
time: "Horário",
|
||||
weekday: "Dia da semana",
|
||||
retention: "Retenção",
|
||||
contents: "Conteúdo do backup",
|
||||
enabled: "Ativar plano",
|
||||
enabledHint: "O próximo horário de execução é calculado com base no fuso horário local.",
|
||||
nextRun: "Próximo: {time}",
|
||||
nextRunPreview: "Próxima execução: {time}",
|
||||
keepRuns: "Manter {count} execuções",
|
||||
everyHours: "A cada {count} horas",
|
||||
dailyAt: "Diariamente às {time}",
|
||||
weeklyAt: "{weekday} às {time}",
|
||||
never: "Nunca",
|
||||
enable: "Ativar",
|
||||
disable: "Desativar",
|
||||
runNow: "Fazer backup agora",
|
||||
cancel: "Cancelar backup",
|
||||
edit: "Editar",
|
||||
delete: "Excluir",
|
||||
scheduleCreated: "Plano de backup criado",
|
||||
scheduleUpdated: "Plano de backup atualizado",
|
||||
deleteSchedule: "Excluir plano",
|
||||
deleteScheduleConfirm: 'Tem certeza de que deseja excluir o plano "{name}"? Os arquivos de backup existentes ainda serão mantidos no histórico.',
|
||||
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",
|
||||
showFiles: "Mostrar arquivos de backup",
|
||||
scheduledTrigger: "Execução agendada",
|
||||
manualTrigger: "Execução manual",
|
||||
fileCount: "{count} arquivos",
|
||||
revealFile: "Mostrar no gerenciador de arquivos",
|
||||
restore: "Restaurar",
|
||||
deleteBackup: "Excluir backup",
|
||||
deleteBackupConfirm: "Tem certeza de que deseja excluir este backup e seus {count} arquivos SQL? Esta ação é irreversível.",
|
||||
backupDeleted: "Backup excluído",
|
||||
runSuccess: "Backup concluído, {count} arquivos gerados",
|
||||
runCancelled: "Backup cancelado",
|
||||
runFailed: "Falha no backup: {error}",
|
||||
unknownError: "Erro desconhecido",
|
||||
status: {
|
||||
running: "Em execução",
|
||||
success: "Concluído",
|
||||
failed: "Falhou",
|
||||
cancelled: "Cancelado",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "Domingo",
|
||||
monday: "Segunda-feira",
|
||||
tuesday: "Terça-feira",
|
||||
wednesday: "Quarta-feira",
|
||||
thursday: "Quinta-feira",
|
||||
friday: "Sexta-feira",
|
||||
saturday: "Sábado",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4000,6 +4000,82 @@ export default withEnglishFallback({
|
||||
exportCancelled: "导出已取消",
|
||||
runInBackground: "后台运行",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "数据库备份",
|
||||
schedules: "备份计划",
|
||||
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
|
||||
addSchedule: "新建计划",
|
||||
editSchedule: "编辑计划",
|
||||
defaultScheduleName: "数据库备份",
|
||||
noSchedules: "暂无备份计划",
|
||||
scheduleName: "计划名称",
|
||||
connection: "连接",
|
||||
missingConnection: "连接已不存在",
|
||||
destination: "备份目录",
|
||||
selectDestination: "选择备份目录",
|
||||
databases: "数据库",
|
||||
allDatabases: "全部数据库",
|
||||
databaseCount: "{count} 个数据库",
|
||||
frequency: "执行频率",
|
||||
frequencyHourly: "按小时",
|
||||
frequencyDaily: "每天",
|
||||
frequencyWeekly: "每周",
|
||||
intervalHours: "间隔小时数",
|
||||
time: "执行时间",
|
||||
weekday: "星期",
|
||||
retention: "保留最近次数",
|
||||
contents: "备份内容",
|
||||
enabled: "启用计划",
|
||||
enabledHint: "下次执行时间按本地时区计算。",
|
||||
nextRun: "下次:{time}",
|
||||
nextRunPreview: "下次执行:{time}",
|
||||
keepRuns: "保留 {count} 次",
|
||||
everyHours: "每 {count} 小时",
|
||||
dailyAt: "每天 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
never: "从未",
|
||||
enable: "启用计划",
|
||||
disable: "停用计划",
|
||||
runNow: "立即备份",
|
||||
cancel: "取消备份",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
scheduleCreated: "备份计划已创建",
|
||||
scheduleUpdated: "备份计划已更新",
|
||||
deleteSchedule: "删除计划",
|
||||
deleteScheduleConfirm: "确定删除计划「{name}」吗?已有备份文件仍会保留在历史记录中。",
|
||||
cannotDeleteRunningSchedule: "正在执行的备份计划不能删除",
|
||||
history: "备份历史",
|
||||
noHistory: "暂无备份记录",
|
||||
showFiles: "显示备份文件",
|
||||
scheduledTrigger: "定时执行",
|
||||
manualTrigger: "手动执行",
|
||||
fileCount: "{count} 个文件",
|
||||
revealFile: "在文件管理器中显示",
|
||||
restore: "恢复",
|
||||
deleteBackup: "删除备份",
|
||||
deleteBackupConfirm: "确定删除此备份及其 {count} 个 SQL 文件吗?此操作不可撤销。",
|
||||
backupDeleted: "备份已删除",
|
||||
runSuccess: "备份完成,共生成 {count} 个文件",
|
||||
runCancelled: "备份已取消",
|
||||
runFailed: "备份失败:{error}",
|
||||
unknownError: "未知错误",
|
||||
status: {
|
||||
running: "执行中",
|
||||
success: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "星期日",
|
||||
monday: "星期一",
|
||||
tuesday: "星期二",
|
||||
wednesday: "星期三",
|
||||
thursday: "星期四",
|
||||
friday: "星期五",
|
||||
saturday: "星期六",
|
||||
},
|
||||
},
|
||||
mqTopics: {
|
||||
title: "主题管理",
|
||||
searchPlaceholder: "搜索主题",
|
||||
|
||||
@@ -4577,4 +4577,80 @@ export default withEnglishFallback({
|
||||
rawStatus: "狀態變數",
|
||||
filterStatus: "篩選狀態變數",
|
||||
},
|
||||
databaseBackup: {
|
||||
title: "資料庫備份",
|
||||
schedules: "備份計畫",
|
||||
runtimeRequirement: "DBX 處於開啟狀態時會執行定時備份,包含最小化到托盤時。",
|
||||
addSchedule: "新建計畫",
|
||||
editSchedule: "編輯計畫",
|
||||
defaultScheduleName: "資料庫備份",
|
||||
noSchedules: "暫無備份計畫",
|
||||
scheduleName: "計畫名稱",
|
||||
connection: "連線",
|
||||
missingConnection: "連線已不存在",
|
||||
destination: "備份目錄",
|
||||
selectDestination: "選擇備份目錄",
|
||||
databases: "資料庫",
|
||||
allDatabases: "全部資料庫",
|
||||
databaseCount: "{count} 個資料庫",
|
||||
frequency: "執行頻率",
|
||||
frequencyHourly: "每小時",
|
||||
frequencyDaily: "每天",
|
||||
frequencyWeekly: "每週",
|
||||
intervalHours: "間隔小時數",
|
||||
time: "執行時間",
|
||||
weekday: "星期",
|
||||
retention: "保留最近次數",
|
||||
contents: "備份內容",
|
||||
enabled: "啟用計畫",
|
||||
enabledHint: "下次執行時間按本地時區計算。",
|
||||
nextRun: "下次:{time}",
|
||||
nextRunPreview: "下次執行:{time}",
|
||||
keepRuns: "保留 {count} 次",
|
||||
everyHours: "每 {count} 小時",
|
||||
dailyAt: "每天 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
never: "從未",
|
||||
enable: "啟用計畫",
|
||||
disable: "停用計畫",
|
||||
runNow: "立即備份",
|
||||
cancel: "取消備份",
|
||||
edit: "編輯",
|
||||
delete: "刪除",
|
||||
scheduleCreated: "備份計畫已建立",
|
||||
scheduleUpdated: "備份計畫已更新",
|
||||
deleteSchedule: "刪除計畫",
|
||||
deleteScheduleConfirm: "確定刪除計畫「{name}」嗎?已有備份檔案仍會保留在歷史記錄中。",
|
||||
cannotDeleteRunningSchedule: "正在執行的備份計畫不能刪除",
|
||||
history: "備份歷史",
|
||||
noHistory: "暫無備份記錄",
|
||||
showFiles: "顯示備份檔案",
|
||||
scheduledTrigger: "定時執行",
|
||||
manualTrigger: "手動執行",
|
||||
fileCount: "{count} 個檔案",
|
||||
revealFile: "在檔案管理員中顯示",
|
||||
restore: "回復",
|
||||
deleteBackup: "刪除備份",
|
||||
deleteBackupConfirm: "確定刪除此備份及其 {count} 個 SQL 檔案嗎?此操作不可撤銷。",
|
||||
backupDeleted: "備份已刪除",
|
||||
runSuccess: "備份完成,共產生 {count} 個檔案",
|
||||
runCancelled: "備份已取消",
|
||||
runFailed: "備份失敗:{error}",
|
||||
unknownError: "未知錯誤",
|
||||
status: {
|
||||
running: "執行中",
|
||||
success: "已完成",
|
||||
failed: "失敗",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
weekdays: {
|
||||
sunday: "星期日",
|
||||
monday: "星期一",
|
||||
tuesday: "星期二",
|
||||
wednesday: "星期三",
|
||||
thursday: "星期四",
|
||||
friday: "星期五",
|
||||
saturday: "星期六",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,6 +125,7 @@ export const deleteSavedSqlFile = forward("deleteSavedSqlFile");
|
||||
export const savedSqlStorageDir = forward("savedSqlStorageDir");
|
||||
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
|
||||
export const revealPathInFileManager = forward("revealPathInFileManager");
|
||||
export const deleteDatabaseBackupFiles = forward("deleteDatabaseBackupFiles");
|
||||
export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile");
|
||||
export const backupSqliteDatabase = forward("backupSqliteDatabase");
|
||||
export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
|
||||
@@ -326,6 +327,7 @@ export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
|
||||
// Database Export
|
||||
export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot");
|
||||
export const exportDatabaseSql = forward("exportDatabaseSql");
|
||||
export const cancelDatabaseExport = forward("cancelDatabaseExport");
|
||||
export const exportQueryResultCsv = forward("exportQueryResultCsv");
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
DatabaseBackupSnapshot,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportRequest,
|
||||
@@ -526,6 +527,10 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
|
||||
throw new Error("Reveal in file manager is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function deleteDatabaseBackupFiles(_paths: string[]): Promise<number> {
|
||||
throw new Error("Database backup file management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(_path: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -1561,6 +1566,10 @@ export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
// Database Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function beginDatabaseBackupSnapshot(_connectionId: string, _database: string): Promise<DatabaseBackupSnapshot> {
|
||||
throw new Error("Consistent database backup snapshots are only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
|
||||
// 1. POST to start the export
|
||||
const res = await fetch(apiUrl("/api/export/database"), {
|
||||
|
||||
@@ -1372,6 +1372,10 @@ export async function revealPathInFileManager(path: string): Promise<void> {
|
||||
return invoke("reveal_path_in_file_manager", { path });
|
||||
}
|
||||
|
||||
export async function deleteDatabaseBackupFiles(paths: string[]): Promise<number> {
|
||||
return invoke("delete_database_backup_files", { paths });
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(path: string): Promise<boolean> {
|
||||
return invoke("is_sqlite_database_file", { path });
|
||||
}
|
||||
@@ -2286,9 +2290,16 @@ export interface DatabaseExportRequest {
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
dropTableIfExists?: boolean;
|
||||
failOnError?: boolean;
|
||||
snapshotSessionId?: string;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupSnapshot {
|
||||
sessionId: string;
|
||||
schemas: string[];
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
exportId: string;
|
||||
currentObject: string;
|
||||
@@ -2454,6 +2465,10 @@ export async function cancelQueryResultExport(exportId: string, executionId?: st
|
||||
return invoke("cancel_query_result_export", { exportId, executionId: executionId || null });
|
||||
}
|
||||
|
||||
export async function beginDatabaseBackupSnapshot(connectionId: string, database: string): Promise<DatabaseBackupSnapshot> {
|
||||
return invoke("begin_database_backup_snapshot", { connectionId, database });
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
|
||||
const unlisten: UnlistenFn = await listen<ExportProgress>("database-export-progress", (event) => {
|
||||
if (event.payload.exportId === request.exportId) {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const DATABASE_BACKUP_SCHEDULES_STORAGE_KEY = "dbx-database-backup-schedules";
|
||||
export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
|
||||
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
|
||||
export const MAX_DATABASE_BACKUP_HISTORY = 200;
|
||||
|
||||
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
|
||||
export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled";
|
||||
export type DatabaseBackupRunTrigger = "manual" | "scheduled";
|
||||
|
||||
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
|
||||
|
||||
export function supportsScheduledDatabaseBackup(databaseType: string | undefined): boolean {
|
||||
return !!databaseType && CONSISTENT_BACKUP_DATABASE_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[]): string[] {
|
||||
const available = [...new Set(availableDatabases.map((database) => database.trim()).filter(Boolean))];
|
||||
if (configuredDatabases.length === 0) return available;
|
||||
|
||||
const missing = configuredDatabases.filter((database) => !available.includes(database));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Configured backup databases are unavailable: ${missing.join(", ")}`);
|
||||
}
|
||||
return [...configuredDatabases];
|
||||
}
|
||||
|
||||
export interface DatabaseBackupSchedule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
connectionId: string;
|
||||
databases: string[];
|
||||
destinationDirectory: string;
|
||||
frequency: DatabaseBackupFrequency;
|
||||
intervalHours: number;
|
||||
timeOfDay: string;
|
||||
weekday: number;
|
||||
includeStructure: boolean;
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
dropTableIfExists: boolean;
|
||||
retentionCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nextRunAt: string;
|
||||
lastRunAt?: string;
|
||||
lastRunStatus?: Exclude<DatabaseBackupRunStatus, "running">;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupFile {
|
||||
database: string;
|
||||
schema: string;
|
||||
displayName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupRun {
|
||||
id: string;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
trigger: DatabaseBackupRunTrigger;
|
||||
status: DatabaseBackupRunStatus;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
files: DatabaseBackupFile[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function recordValue(value: unknown): UnknownRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as UnknownRecord) : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.round(numberValue)));
|
||||
}
|
||||
|
||||
function validIsoDate(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupTime(value: unknown): string {
|
||||
const text = stringValue(value).trim();
|
||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : "02:00";
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupRetention(value: unknown): number {
|
||||
return boundedInteger(value, 10, 1, 100);
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()): DatabaseBackupSchedule | null {
|
||||
const input = recordValue(value);
|
||||
const id = stringValue(input.id).trim();
|
||||
const connectionId = stringValue(input.connectionId).trim();
|
||||
const destinationDirectory = stringValue(input.destinationDirectory).trim();
|
||||
if (!id || !connectionId || !destinationDirectory) return null;
|
||||
|
||||
const nowIso = now.toISOString();
|
||||
const frequency: DatabaseBackupFrequency = input.frequency === "daily" || input.frequency === "weekly" ? input.frequency : "hourly";
|
||||
const schedule: DatabaseBackupSchedule = {
|
||||
id,
|
||||
name: stringValue(input.name).trim() || "Database backup",
|
||||
enabled: booleanValue(input.enabled, true),
|
||||
connectionId,
|
||||
databases: Array.isArray(input.databases) ? [...new Set(input.databases.map((database) => stringValue(database).trim()).filter(Boolean))] : [],
|
||||
destinationDirectory,
|
||||
frequency,
|
||||
intervalHours: boundedInteger(input.intervalHours, 6, 1, 168),
|
||||
timeOfDay: normalizeDatabaseBackupTime(input.timeOfDay),
|
||||
weekday: boundedInteger(input.weekday, 1, 0, 6),
|
||||
includeStructure: booleanValue(input.includeStructure, true),
|
||||
includeData: booleanValue(input.includeData, true),
|
||||
includeObjects: booleanValue(input.includeObjects, true),
|
||||
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
|
||||
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
|
||||
createdAt: validIsoDate(input.createdAt, nowIso),
|
||||
updatedAt: validIsoDate(input.updatedAt, nowIso),
|
||||
nextRunAt: validIsoDate(input.nextRunAt, ""),
|
||||
lastRunAt: typeof input.lastRunAt === "string" && Number.isFinite(Date.parse(input.lastRunAt)) ? input.lastRunAt : undefined,
|
||||
lastRunStatus: input.lastRunStatus === "success" || input.lastRunStatus === "failed" || input.lastRunStatus === "cancelled" ? input.lastRunStatus : undefined,
|
||||
};
|
||||
|
||||
if (!schedule.nextRunAt) schedule.nextRunAt = nextDatabaseBackupRunAt(schedule, now).toISOString();
|
||||
return schedule;
|
||||
}
|
||||
|
||||
export function nextDatabaseBackupRunAt(schedule: Pick<DatabaseBackupSchedule, "frequency" | "intervalHours" | "timeOfDay" | "weekday">, after = new Date()): Date {
|
||||
if (schedule.frequency === "hourly") {
|
||||
return new Date(after.getTime() + boundedInteger(schedule.intervalHours, 6, 1, 168) * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const [hours, minutes] = normalizeDatabaseBackupTime(schedule.timeOfDay).split(":").map(Number);
|
||||
const next = new Date(after);
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(hours!, minutes!, 0, 0);
|
||||
|
||||
if (schedule.frequency === "daily") {
|
||||
if (next.getTime() <= after.getTime()) next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
const weekday = boundedInteger(schedule.weekday, 1, 0, 6);
|
||||
let daysAhead = (weekday - next.getDay() + 7) % 7;
|
||||
if (daysAhead === 0 && next.getTime() <= after.getTime()) daysAhead = 7;
|
||||
next.setDate(next.getDate() + daysAhead);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function databaseBackupScheduleIsDue(schedule: DatabaseBackupSchedule, now = new Date()): boolean {
|
||||
return schedule.enabled && Date.parse(schedule.nextRunAt) <= now.getTime();
|
||||
}
|
||||
|
||||
function normalizeDatabaseBackupFile(value: unknown): DatabaseBackupFile | null {
|
||||
const input = recordValue(value);
|
||||
const filePath = stringValue(input.filePath).trim();
|
||||
if (!filePath) return null;
|
||||
return {
|
||||
database: stringValue(input.database).trim(),
|
||||
schema: stringValue(input.schema).trim(),
|
||||
displayName: stringValue(input.displayName).trim() || stringValue(input.database).trim(),
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | null {
|
||||
const input = recordValue(value);
|
||||
const id = stringValue(input.id).trim();
|
||||
const scheduleId = stringValue(input.scheduleId).trim();
|
||||
const startedAt = validIsoDate(input.startedAt, "");
|
||||
if (!id || !scheduleId || !startedAt) return null;
|
||||
const status: DatabaseBackupRunStatus = input.status === "success" || input.status === "failed" || input.status === "cancelled" ? input.status : "failed";
|
||||
return {
|
||||
id,
|
||||
scheduleId,
|
||||
scheduleName: stringValue(input.scheduleName).trim() || "Database backup",
|
||||
connectionId: stringValue(input.connectionId).trim(),
|
||||
connectionName: stringValue(input.connectionName).trim(),
|
||||
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
|
||||
status,
|
||||
startedAt,
|
||||
completedAt: typeof input.completedAt === "string" && Number.isFinite(Date.parse(input.completedAt)) ? input.completedAt : undefined,
|
||||
files: Array.isArray(input.files) ? input.files.map(normalizeDatabaseBackupFile).filter((file): file is DatabaseBackupFile => !!file) : [],
|
||||
error: stringValue(input.error).trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredArray(key: string): unknown[] {
|
||||
const stored = safeLocalStorageGet(key);
|
||||
if (!stored) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function readDatabaseBackupSchedules(): DatabaseBackupSchedule[] {
|
||||
return parseStoredArray(DATABASE_BACKUP_SCHEDULES_STORAGE_KEY)
|
||||
.map((schedule) => normalizeDatabaseBackupSchedule(schedule))
|
||||
.filter((schedule): schedule is DatabaseBackupSchedule => !!schedule);
|
||||
}
|
||||
|
||||
export function writeDatabaseBackupSchedules(schedules: readonly DatabaseBackupSchedule[]) {
|
||||
safeLocalStorageSet(DATABASE_BACKUP_SCHEDULES_STORAGE_KEY, JSON.stringify(schedules));
|
||||
}
|
||||
|
||||
export function readDatabaseBackupRuns(): DatabaseBackupRun[] {
|
||||
return parseStoredArray(DATABASE_BACKUP_RUNS_STORAGE_KEY)
|
||||
.map(normalizeDatabaseBackupRun)
|
||||
.filter((run): run is DatabaseBackupRun => !!run)
|
||||
.slice(0, MAX_DATABASE_BACKUP_HISTORY);
|
||||
}
|
||||
|
||||
export function writeDatabaseBackupRuns(runs: readonly DatabaseBackupRun[]) {
|
||||
safeLocalStorageSet(DATABASE_BACKUP_RUNS_STORAGE_KEY, JSON.stringify(runs.slice(0, MAX_DATABASE_BACKUP_HISTORY)));
|
||||
}
|
||||
|
||||
export function databaseBackupRunsToPrune(runs: readonly DatabaseBackupRun[], scheduleId: string, retentionCount: number): DatabaseBackupRun[] {
|
||||
return runs
|
||||
.filter((run) => run.scheduleId === scheduleId && run.status === "success")
|
||||
.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))
|
||||
.slice(normalizeDatabaseBackupRetention(retentionCount));
|
||||
}
|
||||
|
||||
export function sanitizeDatabaseBackupFileSegment(value: string): string {
|
||||
const printableValue = Array.from(value, (character) => (character.charCodeAt(0) < 32 ? "_" : character)).join("");
|
||||
const sanitized = printableValue
|
||||
.replace(/[\\/:*?"<>|]+/g, "_")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.trim();
|
||||
return sanitized || "database";
|
||||
}
|
||||
|
||||
export function databaseBackupTimestamp(value: Date | string): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const part = (number: number) => String(number).padStart(2, "0");
|
||||
return `${date.getFullYear()}${part(date.getMonth() + 1)}${part(date.getDate())}-${part(date.getHours())}${part(date.getMinutes())}${part(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function joinDatabaseBackupPath(directory: string, fileName: string): string {
|
||||
const separator = directory.includes("\\") ? "\\" : "/";
|
||||
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`;
|
||||
return joinDatabaseBackupPath(directory, fileName);
|
||||
}
|
||||
@@ -85,6 +85,21 @@ export function generateDatabaseExportId(): string {
|
||||
return uuid();
|
||||
}
|
||||
|
||||
export async function runDatabaseExportUntilTerminal(request: api.DatabaseExportRequest, onProgress: (progress: api.ExportProgress) => void): Promise<api.ExportProgress> {
|
||||
return new Promise<api.ExportProgress>((resolve, reject) => {
|
||||
api
|
||||
.exportDatabaseSql(request, (progress) => {
|
||||
onProgress(progress);
|
||||
if (progress.status === "Done" || progress.status === "Cancelled") {
|
||||
resolve(progress);
|
||||
} else if (progress.status === "Error") {
|
||||
reject(new Error(progress.error || "Export failed"));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput): AllDatabaseExportPlanItem[] {
|
||||
return options.databases.flatMap((database) => {
|
||||
const schemas = options.schemaAware ? (options.schemasByDatabase?.[database] ?? []).filter((schema) => schema.trim()) : [database];
|
||||
|
||||
@@ -826,8 +826,15 @@ function saveEditorSettings(settings: EditorSettings) {
|
||||
void api.saveEditorSettings(settings).catch(() => {});
|
||||
}
|
||||
|
||||
export interface SettingsNavigationRequest {
|
||||
id: number;
|
||||
tab: string;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const settingsPageActive = ref(false);
|
||||
const settingsNavigationRequest = ref<SettingsNavigationRequest | null>(null);
|
||||
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
|
||||
const isAiConfigLoaded = ref(false);
|
||||
const aiConfigs = ref<AiConfigItem[]>([]);
|
||||
@@ -837,6 +844,18 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
|
||||
|
||||
function requestSettingsNavigation(tab: string, section?: string) {
|
||||
settingsNavigationRequest.value = {
|
||||
id: Date.now(),
|
||||
tab,
|
||||
section,
|
||||
};
|
||||
}
|
||||
|
||||
function clearSettingsNavigationRequest(id: number) {
|
||||
if (settingsNavigationRequest.value?.id === id) settingsNavigationRequest.value = null;
|
||||
}
|
||||
|
||||
async function initEditorSettings() {
|
||||
if (isEditorSettingsLoaded.value) return;
|
||||
const saved = await api.loadEditorSettings().catch(() => null);
|
||||
@@ -1132,6 +1151,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
|
||||
return {
|
||||
settingsPageActive,
|
||||
settingsNavigationRequest,
|
||||
requestSettingsNavigation,
|
||||
clearSettingsNavigationRequest,
|
||||
activeModel,
|
||||
isAiConfigLoaded,
|
||||
aiConfigs,
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use tokio::sync::RwLock;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::connection::task_client_session_id;
|
||||
use crate::models::connection::DatabaseType;
|
||||
@@ -36,9 +36,20 @@ pub struct DatabaseExportRequest {
|
||||
pub include_objects: bool,
|
||||
#[serde(default)]
|
||||
pub drop_table_if_exists: bool,
|
||||
#[serde(default)]
|
||||
pub fail_on_error: bool,
|
||||
#[serde(default)]
|
||||
pub snapshot_session_id: Option<String>,
|
||||
pub batch_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DatabaseBackupSnapshot {
|
||||
pub session_id: String,
|
||||
pub schemas: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportProgress {
|
||||
@@ -881,6 +892,7 @@ async fn list_postgres_export_sequences(
|
||||
schema: &str,
|
||||
selected_tables: &[String],
|
||||
include_objects: bool,
|
||||
fail_on_error: bool,
|
||||
) -> Result<Vec<PostgresExportSequence>, String> {
|
||||
let pool = {
|
||||
let connections = state.connections.read().await;
|
||||
@@ -946,7 +958,7 @@ async fn list_postgres_export_sequences(
|
||||
return Ok(sequences);
|
||||
}
|
||||
|
||||
if let Ok(rows) = client
|
||||
let last_values = client
|
||||
.query(
|
||||
"SELECT c.relname, pg_sequence_last_value(c.oid)::text \
|
||||
FROM pg_class c \
|
||||
@@ -954,36 +966,139 @@ async fn list_postgres_export_sequences(
|
||||
WHERE c.relkind = 'S' AND n.nspname = $1",
|
||||
&[&schema],
|
||||
)
|
||||
.await
|
||||
{
|
||||
for row in rows {
|
||||
let name: String = row.get(0);
|
||||
let last_value: Option<String> = row.get(1);
|
||||
if let Some(sequence) = sequences.iter_mut().find(|sequence| sequence.name == name) {
|
||||
sequence.last_value = last_value;
|
||||
.await;
|
||||
match last_values {
|
||||
Ok(rows) => {
|
||||
for row in rows {
|
||||
let name: String = row.get(0);
|
||||
let last_value: Option<String> = row.get(1);
|
||||
if let Some(sequence) = sequences.iter_mut().find(|sequence| sequence.name == name) {
|
||||
sequence.last_value = last_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if fail_on_error => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
Ok(sequences)
|
||||
}
|
||||
|
||||
pub fn is_export_cancelled_now(export_id: &str) -> bool {
|
||||
EXPORT_CANCELLED.read().map(|cancelled| cancelled.contains(export_id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn is_export_cancelled(export_id: &str) -> bool {
|
||||
EXPORT_CANCELLED.read().await.contains(export_id)
|
||||
is_export_cancelled_now(export_id)
|
||||
}
|
||||
|
||||
pub async fn set_export_cancelled(export_id: &str) {
|
||||
EXPORT_CANCELLED.write().await.insert(export_id.to_string());
|
||||
if let Ok(mut cancelled) = EXPORT_CANCELLED.write() {
|
||||
cancelled.insert(export_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_export_cancelled(export_id: &str) {
|
||||
EXPORT_CANCELLED.write().await.remove(export_id);
|
||||
if let Ok(mut cancelled) = EXPORT_CANCELLED.write() {
|
||||
cancelled.remove(export_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn begin_database_backup_snapshot_core(
|
||||
state: &crate::connection::AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<DatabaseBackupSnapshot, String> {
|
||||
let db_type = state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get(connection_id)
|
||||
.map(|config| config.db_type)
|
||||
.ok_or_else(|| format!("Connection config not found: {connection_id}"))?;
|
||||
if !matches!(db_type, DatabaseType::Mysql | DatabaseType::Postgres) {
|
||||
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) {
|
||||
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(
|
||||
state,
|
||||
&session_id,
|
||||
POSTGRES_BACKUP_SCHEMAS_SQL,
|
||||
database,
|
||||
None,
|
||||
Some(10_000),
|
||||
)
|
||||
.await?;
|
||||
results
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|result| {
|
||||
result
|
||||
.rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.into_iter().next())
|
||||
.filter_map(|value| value.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![database.to_string()]
|
||||
};
|
||||
if schemas.is_empty() {
|
||||
let _ = crate::query::rollback_manual_transaction(state, &session_id).await;
|
||||
return Err(format!("No schemas are available in database {database}"));
|
||||
}
|
||||
Ok(DatabaseBackupSnapshot { session_id, schemas })
|
||||
}
|
||||
|
||||
fn record_export_error(file: &mut std::fs::File, fail_on_error: bool, message: String) -> Result<(), String> {
|
||||
if fail_on_error {
|
||||
Err(message)
|
||||
} else {
|
||||
writeln!(file, "-- ERROR {message}").map_err(|error| format!("Failed to write file: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn database_export_select_sql(columns: &[String], table: &str, schema: &str, db_type: &DatabaseType) -> String {
|
||||
let columns = columns.iter().map(|column| quote_identifier(column, db_type)).collect::<Vec<_>>().join(", ");
|
||||
let table = crate::transfer::qualified_table(table, schema, db_type);
|
||||
format!("SELECT {columns} FROM {table}")
|
||||
}
|
||||
|
||||
fn write_database_export_rows(
|
||||
file: &mut std::fs::File,
|
||||
rows: &[Vec<Value>],
|
||||
columns: &[String],
|
||||
column_types: &[Option<String>],
|
||||
column_extras: &[Option<String>],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
) -> Result<(), String> {
|
||||
let mut insert_sql = crate::transfer::generate_insert_typed(columns, column_types, rows, table, schema, db_type);
|
||||
if *db_type == DatabaseType::Dameng && selected_columns_include_identity_extras(columns, column_extras) {
|
||||
insert_sql = wrap_dameng_identity_insert_sql(&insert_sql, table, schema);
|
||||
}
|
||||
if insert_sql.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if insert_sql.trim_end().ends_with(';') {
|
||||
writeln!(file, "{}\n", insert_sql).map_err(|error| format!("Failed to write file: {error}"))
|
||||
} else {
|
||||
writeln!(file, "{};\n", insert_sql).map_err(|error| format!("Failed to write file: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_database_sql_core(
|
||||
state: &crate::connection::AppState,
|
||||
request: &DatabaseExportRequest,
|
||||
on_progress: impl Fn(ExportProgress),
|
||||
on_progress: impl Fn(ExportProgress) + Sync,
|
||||
) -> Result<(), String> {
|
||||
// 1. Get database type
|
||||
let db_type = state
|
||||
@@ -1039,13 +1154,13 @@ pub async fn export_database_sql_core(
|
||||
&request.schema,
|
||||
&request.selected_tables,
|
||||
request.include_objects,
|
||||
request.fail_on_error,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sequences) => sequences,
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting sequences: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(&mut file, request.fail_on_error, format!("exporting sequences: {e}"))?;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -1057,7 +1172,7 @@ pub async fn export_database_sql_core(
|
||||
// exported before referencing (child) tables.
|
||||
if tables.len() > 1 {
|
||||
let table_names: Vec<String> = tables.iter().map(|t| t.name.clone()).collect();
|
||||
if let Ok(sorted_names) = crate::transfer::sort_tables_by_fk_dependency(
|
||||
match crate::transfer::sort_tables_by_fk_dependency(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
@@ -1067,7 +1182,13 @@ pub async fn export_database_sql_core(
|
||||
)
|
||||
.await
|
||||
{
|
||||
tables.sort_by_key(|t| sorted_names.iter().position(|n| n == &t.name).unwrap_or(usize::MAX));
|
||||
Ok(sorted_names) => {
|
||||
tables.sort_by_key(|t| sorted_names.iter().position(|n| n == &t.name).unwrap_or(usize::MAX));
|
||||
}
|
||||
Err(error) if request.fail_on_error => {
|
||||
return Err(format!("Failed to resolve table dependency order: {error}"));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1079,7 +1200,7 @@ pub async fn export_database_sql_core(
|
||||
let mut functions: Vec<String> = Vec::new();
|
||||
|
||||
if request.include_objects && request.selected_tables.is_empty() {
|
||||
if let Ok(objects) = crate::schema::list_objects_core(
|
||||
match crate::schema::list_objects_core(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
@@ -1091,14 +1212,18 @@ pub async fn export_database_sql_core(
|
||||
)
|
||||
.await
|
||||
{
|
||||
for obj in &objects {
|
||||
let ot = obj.object_type.to_uppercase();
|
||||
if ot.contains("PROCEDURE") {
|
||||
procedures.push(obj.name.clone());
|
||||
} else if ot.contains("FUNCTION") {
|
||||
functions.push(obj.name.clone());
|
||||
Ok(objects) => {
|
||||
for obj in &objects {
|
||||
let ot = obj.object_type.to_uppercase();
|
||||
if ot.contains("PROCEDURE") {
|
||||
procedures.push(obj.name.clone());
|
||||
} else if ot.contains("FUNCTION") {
|
||||
functions.push(obj.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if request.fail_on_error => return Err(format!("Failed to list database objects: {error}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
total_objects += procedures.len() + functions.len();
|
||||
}
|
||||
@@ -1199,8 +1324,11 @@ pub async fn export_database_sql_core(
|
||||
writeln!(file, "{};\n", ddl).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
}
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting table {table_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(
|
||||
&mut file,
|
||||
request.fail_on_error,
|
||||
format!("exporting table structure {table_name}: {e}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1219,8 +1347,11 @@ pub async fn export_database_sql_core(
|
||||
{
|
||||
Ok(cols) => cols,
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting table {table_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(
|
||||
&mut file,
|
||||
request.fail_on_error,
|
||||
format!("exporting columns for table {table_name}: {e}"),
|
||||
)?;
|
||||
object_index += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1230,24 +1361,115 @@ pub async fn export_database_sql_core(
|
||||
let col_extras = columns.iter().map(|c| c.extra.clone()).collect::<Vec<_>>();
|
||||
|
||||
if !col_names.is_empty() {
|
||||
// Get row count
|
||||
let count_query = crate::transfer::count_sql(table_name, &request.schema, &db_type);
|
||||
let total_rows = match crate::transfer::execute_read_on_pool(state, &pool_key, &count_query).await {
|
||||
Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v {
|
||||
serde_json::Value::Number(n) => n.as_u64(),
|
||||
serde_json::Value::String(s) => s.parse::<u64>().ok(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(snapshot_session_id) = request.snapshot_session_id.as_deref() {
|
||||
let sql = database_export_select_sql(&col_names, table_name, &request.schema, &db_type);
|
||||
let mut rows_exported = 0_u64;
|
||||
crate::query::stream_rows_in_manual_transaction(
|
||||
state,
|
||||
snapshot_session_id,
|
||||
&sql,
|
||||
batch_size,
|
||||
|rows| {
|
||||
if is_export_cancelled_now(&request.export_id) {
|
||||
return Err("Export cancelled".to_string());
|
||||
}
|
||||
write_database_export_rows(
|
||||
&mut file,
|
||||
&rows,
|
||||
&col_names,
|
||||
&col_types,
|
||||
&col_extras,
|
||||
table_name,
|
||||
&request.schema,
|
||||
&db_type,
|
||||
)?;
|
||||
rows_exported += rows.len() as u64;
|
||||
on_progress(ExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
current_object: table_name.clone(),
|
||||
object_index,
|
||||
total_objects,
|
||||
rows_exported,
|
||||
total_rows: None,
|
||||
status: ExportStatus::Running,
|
||||
error: None,
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let count_query = crate::transfer::count_sql(table_name, &request.schema, &db_type);
|
||||
let total_rows = match crate::transfer::execute_read_on_pool(state, &pool_key, &count_query).await {
|
||||
Ok(result) => {
|
||||
let count = result.rows.first().and_then(|row| row.first()).and_then(|value| match value {
|
||||
serde_json::Value::Number(number) => number.as_u64(),
|
||||
serde_json::Value::String(text) => text.parse::<u64>().ok(),
|
||||
_ => None,
|
||||
});
|
||||
if request.fail_on_error && count.is_none() {
|
||||
return Err(format!("Failed to read row count for table {table_name}"));
|
||||
}
|
||||
count
|
||||
}
|
||||
Err(error) if request.fail_on_error => {
|
||||
return Err(format!("Failed to read row count for table {table_name}: {error}"));
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let mut offset = 0_u64;
|
||||
let mut rows_exported = 0_u64;
|
||||
|
||||
// Loop batches
|
||||
let mut offset: u64 = 0;
|
||||
let mut rows_exported: u64 = 0;
|
||||
loop {
|
||||
if is_export_cancelled(&request.export_id).await {
|
||||
on_progress(ExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
current_object: table_name.clone(),
|
||||
object_index,
|
||||
total_objects,
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Cancelled,
|
||||
error: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
loop {
|
||||
// Check cancellation between batches
|
||||
if is_export_cancelled(&request.export_id).await {
|
||||
let sql = crate::transfer::pagination_sql(
|
||||
&col_names,
|
||||
table_name,
|
||||
&request.schema,
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
let result = match crate::transfer::execute_read_on_pool(state, &pool_key, &sql).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
record_export_error(
|
||||
&mut file,
|
||||
request.fail_on_error,
|
||||
format!("exporting data for table {table_name}: {error}"),
|
||||
)?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let row_count = result.rows.len();
|
||||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
write_database_export_rows(
|
||||
&mut file,
|
||||
&result.rows,
|
||||
&col_names,
|
||||
&col_types,
|
||||
&col_extras,
|
||||
table_name,
|
||||
&request.schema,
|
||||
&db_type,
|
||||
)?;
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
on_progress(ExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
current_object: table_name.clone(),
|
||||
@@ -1255,73 +1477,12 @@ pub async fn export_database_sql_core(
|
||||
total_objects,
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Cancelled,
|
||||
status: ExportStatus::Running,
|
||||
error: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = crate::transfer::pagination_sql(
|
||||
&col_names,
|
||||
table_name,
|
||||
&request.schema,
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
|
||||
let result = match crate::transfer::execute_read_on_pool(state, &pool_key, &sql).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting data for table {table_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
if row_count < batch_size {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let row_count = result.rows.len();
|
||||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut insert_sql = crate::transfer::generate_insert_typed(
|
||||
&col_names,
|
||||
&col_types,
|
||||
&result.rows,
|
||||
table_name,
|
||||
&request.schema,
|
||||
&db_type,
|
||||
);
|
||||
if db_type == DatabaseType::Dameng
|
||||
&& selected_columns_include_identity_extras(&col_names, &col_extras)
|
||||
{
|
||||
insert_sql = wrap_dameng_identity_insert_sql(&insert_sql, table_name, &request.schema);
|
||||
}
|
||||
|
||||
if !insert_sql.is_empty() {
|
||||
if insert_sql.trim_end().ends_with(';') {
|
||||
writeln!(file, "{}\n", insert_sql).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
} else {
|
||||
writeln!(file, "{};\n", insert_sql).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
|
||||
on_progress(ExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
current_object: table_name.clone(),
|
||||
object_index,
|
||||
total_objects,
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Running,
|
||||
error: None,
|
||||
});
|
||||
|
||||
if row_count < batch_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1382,8 +1543,7 @@ pub async fn export_database_sql_core(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting view {view_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(&mut file, request.fail_on_error, format!("exporting view {view_name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1429,8 +1589,11 @@ pub async fn export_database_sql_core(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting procedure {proc_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(
|
||||
&mut file,
|
||||
request.fail_on_error,
|
||||
format!("exporting procedure {proc_name}: {e}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,8 +1639,11 @@ pub async fn export_database_sql_core(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
writeln!(file, "-- ERROR exporting function {func_name}: {e}")
|
||||
.map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
record_export_error(
|
||||
&mut file,
|
||||
request.fail_on_error,
|
||||
format!("exporting function {func_name}: {e}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,8 +1692,8 @@ mod tests {
|
||||
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql,
|
||||
filter_selected_table_infos, format_export_sql_literal, generate_postgres_sequence_create_ddl,
|
||||
generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql, normalize_export_table_ddl,
|
||||
BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, PostgresExportSequence,
|
||||
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
|
||||
record_export_error, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql,
|
||||
PostgresExportSequence, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::TableInfo;
|
||||
@@ -2143,4 +2309,17 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_exports_return_object_errors_instead_of_writing_error_comments() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-strict-export-{}.sql", uuid::Uuid::new_v4()));
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
|
||||
let result = record_export_error(&mut file, true, "exporting table users: permission denied".to_string());
|
||||
drop(file);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "exporting table users: permission denied");
|
||||
assert!(std::fs::read_to_string(&path).unwrap().is_empty());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2893,6 +2893,41 @@ pub async fn begin_manual_transaction(
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
begin_transaction_session(state, connection_id, database, schema, false).await
|
||||
}
|
||||
|
||||
/// Start a read-only, repeatable snapshot for a database backup.
|
||||
pub async fn begin_database_backup_snapshot(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
) -> Result<String, String> {
|
||||
begin_transaction_session(state, connection_id, database, None, true).await
|
||||
}
|
||||
|
||||
fn postgres_transaction_begin_sql(consistent_snapshot: bool) -> &'static str {
|
||||
if consistent_snapshot {
|
||||
"BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
|
||||
} else {
|
||||
"BEGIN"
|
||||
}
|
||||
}
|
||||
|
||||
fn mysql_transaction_begin_sql(consistent_snapshot: bool) -> &'static str {
|
||||
if consistent_snapshot {
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY"
|
||||
} else {
|
||||
"START TRANSACTION"
|
||||
}
|
||||
}
|
||||
|
||||
async fn begin_transaction_session(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
schema: Option<&str>,
|
||||
consistent_snapshot: bool,
|
||||
) -> Result<String, String> {
|
||||
let pool_key = if database.is_empty() {
|
||||
connection_id.to_string()
|
||||
@@ -2918,7 +2953,8 @@ pub async fn begin_manual_transaction(
|
||||
let txn_conn = match pool_handle {
|
||||
TxnPoolHandle::Postgres(pg_pool) => {
|
||||
let conn = pg_pool.get().await.map_err(|e| format!("Failed to get Postgres connection: {e}"))?;
|
||||
conn.execute("BEGIN", &[]).await.map_err(|e| format!("BEGIN failed: {e}"))?;
|
||||
let begin_sql = postgres_transaction_begin_sql(consistent_snapshot);
|
||||
conn.execute(begin_sql, &[]).await.map_err(|e| format!("BEGIN failed: {e}"))?;
|
||||
if let Some(schema) = schema {
|
||||
conn.execute(&format!("SET LOCAL search_path TO {}", db::postgres::pg_quote_ident(schema)), &[])
|
||||
.await
|
||||
@@ -2928,7 +2964,8 @@ pub async fn begin_manual_transaction(
|
||||
}
|
||||
TxnPoolHandle::Mysql(mysql_pool) => {
|
||||
let mut conn = mysql_pool.get_conn().await.map_err(|e| format!("Failed to get MySQL connection: {e}"))?;
|
||||
conn.query_drop("START TRANSACTION").await.map_err(|e| format!("START TRANSACTION failed: {e}"))?;
|
||||
let begin_sql = mysql_transaction_begin_sql(consistent_snapshot);
|
||||
conn.query_drop(begin_sql).await.map_err(|e| format!("START TRANSACTION failed: {e}"))?;
|
||||
TxnConnection::Mysql(conn)
|
||||
}
|
||||
};
|
||||
@@ -3070,6 +3107,176 @@ pub async fn execute_in_manual_transaction(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Stream a read query through an existing transaction without materializing
|
||||
/// the whole result set. The callback runs once per batch while the same held
|
||||
/// connection and transaction snapshot remain active.
|
||||
pub async fn stream_rows_in_manual_transaction<F>(
|
||||
state: &AppState,
|
||||
txn_session_id: &str,
|
||||
sql: &str,
|
||||
batch_size: usize,
|
||||
mut on_batch: F,
|
||||
) -> Result<u64, String>
|
||||
where
|
||||
F: FnMut(Vec<Vec<serde_json::Value>>) -> Result<(), String> + Send,
|
||||
{
|
||||
const TXN_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
let expired_connection = {
|
||||
let mut sessions = state.transaction_sessions.write().await;
|
||||
let Some(session) = sessions.get_mut(txn_session_id) else {
|
||||
return Err(
|
||||
"Transaction session not found or expired; it may have been auto-rolled back due to inactivity"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
if session.busy {
|
||||
return Err("Transaction session is already executing".to_string());
|
||||
}
|
||||
if session.last_activity.elapsed() > TXN_IDLE_TIMEOUT {
|
||||
Some(sessions.remove(txn_session_id).expect("session exists").connection)
|
||||
} else {
|
||||
session.busy = true;
|
||||
session.last_activity = std::time::Instant::now();
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(connection) = expired_connection {
|
||||
let mut conn = connection.lock().await;
|
||||
let _ = rollback_manual_txn_connection(&mut conn).await;
|
||||
return Err("Transaction was auto-rolled back due to 5 minutes of inactivity".to_string());
|
||||
}
|
||||
|
||||
let connection = {
|
||||
let sessions = state.transaction_sessions.read().await;
|
||||
sessions
|
||||
.get(txn_session_id)
|
||||
.map(|session| Arc::clone(&session.connection))
|
||||
.ok_or("Transaction session not found or expired; it may have been auto-rolled back due to inactivity")?
|
||||
};
|
||||
let batch_size = batch_size.max(1);
|
||||
let mut conn = connection.lock().await;
|
||||
let stream_result = match &mut *conn {
|
||||
TxnConnection::Postgres(conn) => {
|
||||
let stmt = conn.prepare_cached(sql).await.map_err(|e| format!("Prepare failed: {e}"));
|
||||
match stmt {
|
||||
Ok(stmt) => {
|
||||
let column_types: Vec<String> =
|
||||
stmt.columns().iter().map(|column| column.type_().name().to_string()).collect();
|
||||
let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
||||
match conn.query_raw(&stmt, params).await {
|
||||
Ok(stream) => {
|
||||
tokio::pin!(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) => {
|
||||
let values = (0..row.columns().len())
|
||||
.map(|index| {
|
||||
db::postgres::pg_value_to_json(
|
||||
&row,
|
||||
index,
|
||||
column_types.get(index).map(String::as_str).unwrap_or(""),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
batch.push(values);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if error.is_none() && !batch.is_empty() {
|
||||
if let Err(err) = on_batch(batch) {
|
||||
error = Some(err);
|
||||
}
|
||||
}
|
||||
error.map_or(Ok(total_rows), Err)
|
||||
}
|
||||
Err(err) => Err(format!("Query failed: {err}")),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
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);
|
||||
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(err) => Err(format!("Query failed: {err}")),
|
||||
},
|
||||
Err(err) => Err(format!("Query failed: {err}")),
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(err) = &stream_result {
|
||||
let should_rollback = state.transaction_sessions.write().await.remove(txn_session_id).is_some();
|
||||
if should_rollback {
|
||||
let _ = rollback_manual_txn_connection(&mut conn).await;
|
||||
}
|
||||
return Err(format!("{err}. Transaction was auto-rolled back."));
|
||||
}
|
||||
drop(conn);
|
||||
|
||||
let should_watch = {
|
||||
let mut sessions = state.transaction_sessions.write().await;
|
||||
if let Some(session) = sessions.get_mut(txn_session_id) {
|
||||
session.busy = false;
|
||||
session.last_activity = std::time::Instant::now();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_watch {
|
||||
spawn_txn_idle_watcher(state, txn_session_id.to_string());
|
||||
}
|
||||
stream_result
|
||||
}
|
||||
|
||||
async fn rollback_manual_txn_connection(conn: &mut TxnConnection) -> Result<(), String> {
|
||||
match conn {
|
||||
TxnConnection::Postgres(conn) => {
|
||||
@@ -4732,4 +4939,10 @@ mod tests {
|
||||
let opts = QueryExecutionOptions { use_transaction: Some(false), ..Default::default() };
|
||||
assert_eq!(opts.use_transaction, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_backup_transactions_request_consistent_read_only_snapshots() {
|
||||
assert_eq!(postgres_transaction_begin_sql(true), "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");
|
||||
assert_eq!(mysql_transaction_begin_sql(true), "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY");
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, nextDatabaseBackupRunAt, normalizeDatabaseBackupSchedule, resolveScheduledDatabaseBackupTargets, supportsScheduledDatabaseBackup, type DatabaseBackupRun, type DatabaseBackupSchedule } from "../../apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts";
|
||||
|
||||
function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): DatabaseBackupSchedule {
|
||||
return {
|
||||
id: "schedule-1",
|
||||
name: "Nightly backup",
|
||||
enabled: true,
|
||||
connectionId: "connection-1",
|
||||
databases: [],
|
||||
destinationDirectory: "C:\\backups",
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
timeOfDay: "02:00",
|
||||
weekday: 1,
|
||||
includeStructure: true,
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
dropTableIfExists: false,
|
||||
retentionCount: 2,
|
||||
createdAt: "2026-07-16T00:00:00.000Z",
|
||||
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-17T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(id: string, startedAt: string): DatabaseBackupRun {
|
||||
return {
|
||||
id,
|
||||
scheduleId: "schedule-1",
|
||||
scheduleName: "Nightly backup",
|
||||
connectionId: "connection-1",
|
||||
connectionName: "Postgres",
|
||||
trigger: "scheduled",
|
||||
status: "success",
|
||||
startedAt,
|
||||
completedAt: startedAt,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("daily backup advances to the next configured local time", () => {
|
||||
const after = new Date(2026, 6, 16, 3, 30, 0);
|
||||
const next = nextDatabaseBackupRunAt(schedule(), after);
|
||||
|
||||
assert.equal(next.getFullYear(), 2026);
|
||||
assert.equal(next.getMonth(), 6);
|
||||
assert.equal(next.getDate(), 17);
|
||||
assert.equal(next.getHours(), 2);
|
||||
assert.equal(next.getMinutes(), 0);
|
||||
});
|
||||
|
||||
test("weekly backup selects the next matching weekday", () => {
|
||||
const after = new Date(2026, 6, 16, 12, 0, 0);
|
||||
const next = nextDatabaseBackupRunAt(schedule({ frequency: "weekly", weekday: 1, timeOfDay: "08:15" }), after);
|
||||
|
||||
assert.equal(next.getDay(), 1);
|
||||
assert.equal(next.getHours(), 8);
|
||||
assert.equal(next.getMinutes(), 15);
|
||||
assert.ok(next.getTime() > after.getTime());
|
||||
});
|
||||
|
||||
test("normalization deduplicates databases and bounds schedule values", () => {
|
||||
const normalized = normalizeDatabaseBackupSchedule({
|
||||
...schedule(),
|
||||
databases: ["app", "app", " analytics "],
|
||||
intervalHours: 999,
|
||||
retentionCount: 0,
|
||||
timeOfDay: "invalid",
|
||||
});
|
||||
|
||||
assert.ok(normalized);
|
||||
assert.deepEqual(normalized.databases, ["app", "analytics"]);
|
||||
assert.equal(normalized.intervalHours, 168);
|
||||
assert.equal(normalized.retentionCount, 1);
|
||||
assert.equal(normalized.timeOfDay, "02:00");
|
||||
});
|
||||
|
||||
test("due check respects enabled state and next run", () => {
|
||||
const now = new Date("2026-07-16T12:00:00.000Z");
|
||||
assert.equal(databaseBackupScheduleIsDue(schedule({ nextRunAt: "2026-07-16T11:59:00.000Z" }), now), true);
|
||||
assert.equal(databaseBackupScheduleIsDue(schedule({ enabled: false, nextRunAt: "2026-07-16T11:59:00.000Z" }), now), false);
|
||||
});
|
||||
|
||||
test("backup file names are unique and safe for schema-aware exports", () => {
|
||||
const path = databaseBackupFilePath("C:\\backups", "Nightly: prod", "app/private", new Date(2026, 6, 16, 2, 3, 4), "12345678-abcd");
|
||||
|
||||
assert.equal(path, "C:\\backups\\dbx-backup__Nightly_ prod__20260716-020304__app_private__12345678.sql");
|
||||
});
|
||||
|
||||
test("retention pruning keeps the newest successful runs", () => {
|
||||
const runs = [run("new", "2026-07-16T03:00:00.000Z"), run("middle", "2026-07-16T02:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];
|
||||
|
||||
assert.deepEqual(
|
||||
databaseBackupRunsToPrune(runs, "schedule-1", 2).map((item) => item.id),
|
||||
["old"],
|
||||
);
|
||||
});
|
||||
|
||||
test("all-database backups use the complete database list", () => {
|
||||
assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["visible", "hidden"]), ["visible", "hidden"]);
|
||||
});
|
||||
|
||||
test("explicit backup databases fail when any configured target is missing", () => {
|
||||
assert.throws(() => resolveScheduledDatabaseBackupTargets(["app", "renamed"], ["app"]), /renamed/);
|
||||
});
|
||||
|
||||
test("scheduled backups are limited to databases with consistent snapshot support", () => {
|
||||
assert.equal(supportsScheduledDatabaseBackup("postgres"), true);
|
||||
assert.equal(supportsScheduledDatabaseBackup("mysql"), true);
|
||||
assert.equal(supportsScheduledDatabaseBackup("sqlite"), false);
|
||||
assert.equal(supportsScheduledDatabaseBackup("sqlserver"), false);
|
||||
});
|
||||
|
||||
test("retention never selects failed backup runs", () => {
|
||||
const failed = { ...run("failed", "2026-07-16T04:00:00.000Z"), status: "failed" as const };
|
||||
const successful = [run("new", "2026-07-16T03:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];
|
||||
|
||||
assert.deepEqual(
|
||||
databaseBackupRunsToPrune([failed, ...successful], "schedule-1", 1).map((item) => item.id),
|
||||
["old"],
|
||||
);
|
||||
});
|
||||
@@ -3,12 +3,21 @@ use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
|
||||
pub use dbx_core::database_export::{DatabaseExportRequest, ExportProgress, ExportStatus};
|
||||
pub use dbx_core::database_export::{DatabaseBackupSnapshot, DatabaseExportRequest, ExportProgress, ExportStatus};
|
||||
|
||||
fn emit_progress(app: &AppHandle, progress: ExportProgress) {
|
||||
let _ = app.emit("database-export-progress", progress);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn begin_database_backup_snapshot(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
) -> Result<DatabaseBackupSnapshot, String> {
|
||||
dbx_core::database_export::begin_database_backup_snapshot_core(&state, &connection_id, &database).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_database_sql(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -82,6 +82,43 @@ pub async fn is_sqlite_database_file(path: String) -> Result<bool, String> {
|
||||
path_has_sqlite_header(&resolved)
|
||||
}
|
||||
|
||||
fn validate_database_backup_file(raw: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("backup file path is empty".to_string());
|
||||
}
|
||||
|
||||
let expanded = expand_tilde(trimmed);
|
||||
let path = PathBuf::from(&expanded);
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("backup file path is not absolute: {expanded}"));
|
||||
}
|
||||
if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("sql"))
|
||||
!= Some(true)
|
||||
{
|
||||
return Err(format!("backup file must use the .sql extension: {expanded}"));
|
||||
}
|
||||
let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
|
||||
if !file_name.starts_with("dbx-backup__") {
|
||||
return Err(format!("backup file name is not managed by DBX: {expanded}"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_database_backup_files(paths: Vec<String>) -> Result<usize, String> {
|
||||
let resolved = paths.iter().map(|path| validate_database_backup_file(path)).collect::<Result<Vec<_>, _>>()?;
|
||||
let mut deleted = 0;
|
||||
for path in resolved {
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => deleted += 1,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(format!("failed to delete backup file {}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -127,6 +164,17 @@ mod tests {
|
||||
assert_eq!(resolved, dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_backup_file_requires_absolute_sql_path() {
|
||||
assert!(validate_database_backup_file("relative/backup.sql").is_err());
|
||||
let invalid_extension = if cfg!(windows) { "C:/tmp/backup.txt" } else { "/tmp/backup.txt" };
|
||||
assert!(validate_database_backup_file(invalid_extension).is_err());
|
||||
let unmanaged = if cfg!(windows) { "C:/tmp/backup.sql" } else { "/tmp/backup.sql" };
|
||||
assert!(validate_database_backup_file(unmanaged).is_err());
|
||||
let valid = if cfg!(windows) { "C:/tmp/dbx-backup__nightly.SQL" } else { "/tmp/dbx-backup__nightly.SQL" };
|
||||
assert!(validate_database_backup_file(valid).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_header_is_detected() {
|
||||
let path = std::env::temp_dir().join(format!("dbx-sqlite-header-{}.conf", uuid::Uuid::new_v4()));
|
||||
|
||||
@@ -1279,6 +1279,7 @@ pub fn run() {
|
||||
commands::saved_sql::sync_saved_sql_directory,
|
||||
commands::fs_open::reveal_path_in_file_manager,
|
||||
commands::fs_open::is_sqlite_database_file,
|
||||
commands::fs_open::delete_database_backup_files,
|
||||
commands::sqlite_backup::backup_sqlite_database,
|
||||
commands::mongo_cmd::mongo_list_databases,
|
||||
commands::mongo_cmd::mongo_list_collections,
|
||||
@@ -1414,6 +1415,7 @@ pub fn run() {
|
||||
commands::transfer::start_transfer,
|
||||
commands::transfer::preview_transfer_ownership,
|
||||
commands::transfer::cancel_transfer,
|
||||
commands::database_export::begin_database_backup_snapshot,
|
||||
commands::database_export::export_database_sql,
|
||||
commands::database_export::cancel_database_export,
|
||||
commands::table_export::start_table_export,
|
||||
|
||||
Reference in New Issue
Block a user