feat(backup): support cross-platform background database backups

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-09-23 12:29:56 +08:00
1 parent 250e0c0a10
commit 2f960f104c
56 files changed
+4195 -962

No files matched your search

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { computed, nextTick, ref, watch, type ComponentPublicInstance } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -136,8 +137,8 @@ watch(
<div class="space-y-2">
<Label>{{ t("databaseBackup.destination") }}</Label>
<div class="backup-destination-field">
<Input v-model="draft.destinationDirectory" readonly :title="draft.destinationDirectory" />
<Button variant="outline" size="icon" class="backup-destination-picker" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
<Input v-model="draft.destinationDirectory" :readonly="isTauriRuntime()" :title="draft.destinationDirectory" />
<Button v-if="isTauriRuntime()" variant="outline" size="icon" class="backup-destination-picker" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -9,7 +10,7 @@ import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, Download, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
@@ -37,7 +38,34 @@ import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOpt
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, heartbeat, destinationRoot, error: backupError, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const desktop = isTauriRuntime();
const backgroundEnabled = ref(false);
const backgroundBusy = ref(false);
onMounted(async () => {
try {
backgroundEnabled.value = (await api.databaseBackupBackground()).enabled;
} catch (error) {
toast(String(error), 5000);
}
});
async function changeBackground(enabled: boolean) {
backgroundBusy.value = true;
try {
backgroundEnabled.value = (await api.databaseBackupBackground(enabled)).enabled;
} catch (error) {
toast(String(error), 5000);
} finally {
backgroundBusy.value = false;
}
}
async function changeScheduleEnabled(id: string, enabled: boolean) {
try {
await setScheduleEnabled(id, enabled);
} catch (error) {
toast(String(error), 5000);
}
}
const scheduleDialogOpen = ref(false);
const oneShotDialogOpen = ref(false);
@@ -111,7 +139,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
databases: [],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "",
destinationDirectory: destinationRoot.value || "",
includeStructure: true,
includeData: true,
includeObjects: true,
@@ -131,6 +159,7 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
frequency: "daily",
intervalHours: 6,
timeOfDay: "02:00",
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
weekday: 1,
retentionCount: 10,
runDirectoryPattern: DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
@@ -172,11 +201,33 @@ const canSave = computed(() => {
!loadingDatabases.value
);
});
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
const nextRunPreview = ref(new Date());
const previewError = ref("");
const previewZone = ref(draft.value.timeZone);
watch(
() => [draft.value.frequency, draft.value.intervalHours, draft.value.timeOfDay, draft.value.weekday, draft.value.timeZone],
async (_, __, onCleanup) => {
let current = true;
onCleanup(() => {
current = false;
});
try {
const time = await api.databaseBackupCommand<string>({ action: "preview", schedule: draft.value });
if (current) {
nextRunPreview.value = new Date(time);
previewZone.value = draft.value.timeZone;
previewError.value = "";
}
} catch (error) {
if (current) previewError.value = String(error);
}
},
{ immediate: true },
);
const scheduleOutputPathPreview = computed(() => {
if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") || !databaseBackupFileNamePatternIsValid(draft.value.fileNamePattern || "")) return "";
const directory = databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01");
return databaseBackupFilePath(directory, draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), "database", nextRunPreview.value, "preview01", draft.value.outputCompression, draft.value.fileNamePattern);
const directory = databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01", previewZone.value);
return databaseBackupFilePath(directory, draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), "database", nextRunPreview.value, "preview01", draft.value.outputCompression, draft.value.fileNamePattern, previewZone.value);
});
const oneShotOutputPathPreview = computed(() => {
if (!oneShotDraft.value.destinationDirectory.trim() || !databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "")) return "";
@@ -381,11 +432,11 @@ async function chooseDestination() {
}
async function submitSchedule() {
if (!canSave.value) return;
if (!canSave.value || previewError.value) return;
saving.value = true;
try {
await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
saveSchedule({
if (desktop) await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
await saveSchedule({
...draft.value,
databases: allDatabases.value ? [] : [...selectedDatabases.value],
tablePatterns: draft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
@@ -411,7 +462,7 @@ async function startOneShotBackup() {
if (!canStartOneShot.value) return;
oneShotStarting.value = true;
try {
await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
if (desktop) await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
const run = await runOneShot(
{
...oneShotDraft.value,
@@ -442,6 +493,7 @@ async function requestCancelRun(runId: string) {
}
async function confirmLegacyDestination(schedule: DatabaseBackupSchedule): Promise<DatabaseBackupSchedule | null> {
if (!desktop) return schedule;
if (!(await api.databaseExportDestinationNeedsConfirmation(schedule.destinationDirectory))) return schedule;
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -462,7 +514,7 @@ async function runNow(schedule: DatabaseBackupSchedule) {
try {
const confirmedSchedule = await confirmLegacyDestination(schedule);
if (!confirmedSchedule) return;
const run = await runSchedule(confirmedSchedule.id, "manual");
const run = await runSchedule(confirmedSchedule.id);
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);
@@ -477,14 +529,18 @@ function requestDeleteSchedule(schedule: DatabaseBackupSchedule) {
deleteScheduleDialogOpen.value = true;
}
function confirmDeleteSchedule() {
async function confirmDeleteSchedule() {
if (!pendingDeleteSchedule.value) return;
if (!deleteSchedule(pendingDeleteSchedule.value.id)) {
toast(t("databaseBackup.cannotDeleteRunningSchedule"), 3000);
return;
try {
if (!(await deleteSchedule(pendingDeleteSchedule.value.id))) {
toast(t("databaseBackup.cannotDeleteRunningSchedule"), 3000);
return;
}
deleteScheduleDialogOpen.value = false;
pendingDeleteSchedule.value = null;
} catch (error) {
toast(String(error), 5000);
}
deleteScheduleDialogOpen.value = false;
pendingDeleteSchedule.value = null;
}
function toggleRunSelected(runId: string, selected: boolean) {
@@ -518,13 +574,17 @@ function requestRenameRun(run: DatabaseBackupRun) {
renameRunDialogOpen.value = true;
}
function confirmRenameRun() {
async function confirmRenameRun() {
const run = pendingRenameRun.value;
if (!run || !renameRunName.value.trim()) return;
if (!renameRun(run.id, renameRunName.value)) return;
renameRunDialogOpen.value = false;
pendingRenameRun.value = null;
toast(t("databaseBackup.backupRenamed"), 2500);
try {
if (!(await renameRun(run.id, renameRunName.value))) return;
renameRunDialogOpen.value = false;
pendingRenameRun.value = null;
toast(t("databaseBackup.backupRenamed"), 2500);
} catch (error) {
toast(String(error), 5000);
}
}
async function confirmDeleteRuns() {
@@ -551,18 +611,28 @@ function toggleRunExpanded(runId: string) {
async function revealBackup(file: DatabaseBackupFile) {
try {
if (!desktop) {
const run = runs.value.find((item) => item.files.includes(file));
if (run) await api.downloadDatabaseBackupFile(run.id, run.files.indexOf(file));
return;
}
await api.revealPathInFileManager(file.filePath);
} catch (error: any) {
toast(translateBackendError(t, error), 5000);
}
}
function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
connectionStore.sqlFileSource = {
connectionId: run.connectionId,
database: file.database,
filePath: file.filePath,
};
async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
try {
const prepared = desktop ? file.filePath : await api.prepareDatabaseBackupRestore(run.id, run.files.indexOf(file));
connectionStore.sqlFileSource = {
connectionId: run.connectionId,
database: file.database,
...(typeof prepared === "string" ? { filePath: prepared } : { preview: prepared }),
};
} catch (error) {
toast(String(error), 5000);
}
}
</script>
@@ -571,7 +641,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<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>
<p class="mt-1 text-sm text-muted-foreground">{{ t(desktop ? "databaseBackup.backgroundLoginScope" : "databaseBackup.serverRuntime") }}</p>
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
@@ -586,6 +656,15 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
<div class="min-w-0 space-y-1">
<Label>{{ t("databaseBackup.backgroundWorker") }}</Label>
<p class="text-xs text-muted-foreground">{{ heartbeat ? t("databaseBackup.workerLastSeen", { time: formatDate(heartbeat) }) : t("databaseBackup.workerStarting") }}</p>
<p v-if="destinationRoot" class="break-all text-xs text-muted-foreground">{{ t("databaseBackup.serverDestination", { path: destinationRoot }) }}</p>
</div>
<Switch v-if="desktop" :model-value="backgroundEnabled" :disabled="backgroundBusy" :aria-label="t('databaseBackup.backgroundWorker')" @update:model-value="changeBackground" />
</div>
<p v-if="backupError" class="break-words text-sm text-destructive">{{ backupError }}</p>
<div class="overflow-hidden rounded-md border border-border/70">
<div v-if="schedules.length === 0" class="flex min-h-44 flex-col items-center justify-center gap-3 px-4 py-8 text-center text-muted-foreground">
<DatabaseBackup class="h-8 w-8 opacity-60" />
@@ -614,7 +693,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</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)" />
<Switch :model-value="schedule.enabled" :disabled="activeScheduleIds.has(schedule.id)" :title="schedule.enabled ? t('databaseBackup.disable') : t('databaseBackup.enable')" @update:model-value="(value: boolean) => changeScheduleEnabled(schedule.id, value)" />
<Button
v-if="activeRunForSchedule(schedule.id)"
variant="ghost"
@@ -760,8 +839,8 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
<Pencil class="h-4 w-4" />
</Button>
<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 v-if="run.files[0]" variant="ghost" size="icon" class="h-8 w-8" :title="t(desktop ? 'databaseBackup.revealFile' : 'common.download')" @click="revealBackup(run.files[0])">
<component :is="desktop ? FolderOpen : Download" 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" />
@@ -775,10 +854,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<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 variant="ghost" size="icon" class="h-7 w-7" :title="t(desktop ? 'databaseBackup.revealFile' : 'common.download')" @click="revealBackup(file)">
<component :is="desktop ? FolderOpen : Download" class="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" class="h-7" @click="restoreBackup(run, file)">
<Button variant="outline" size="sm" class="h-7" :disabled="run.status !== 'success'" @click="restoreBackup(run, file)">
<RotateCcw class="mr-1.5 h-3.5 w-3.5" />
{{ t("databaseBackup.restore") }}
</Button>
@@ -856,12 +935,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<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-2">
<Label>{{ t("databaseBackup.timeZone") }}</Label>
<Input v-model="draft.timeZone" :aria-invalid="!!previewError" />
<p v-if="previewError" class="text-xs text-destructive">{{ previewError }}</p>
<div v-else class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</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 class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.savedTimeZoneHint") }}</div>
</div>
<Switch v-model="draft.enabled" />
</div>
@@ -7,6 +7,9 @@ import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
desktop: true,
sqlFileSource: null as any,
prepareDatabaseBackupRestore: vi.fn(),
connections: [] as Array<{ id: string; name: string; db_type: string }>,
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
@@ -25,6 +28,7 @@ const mocks = vi.hoisted(() => ({
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
deleteRuns: vi.fn(),
renameRun: vi.fn(),
runSchedule: vi.fn(),
runOneShot: vi.fn(),
cancelRun: vi.fn(),
@@ -36,9 +40,15 @@ vi.mock("@/stores/connectionStore", () => ({
ensureConnected: mocks.ensureConnected,
recordConnectionLostError: mocks.recordConnectionLostError,
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
sqlFileSource: null,
get sqlFileSource() {
return mocks.sqlFileSource;
},
set sqlFileSource(value) {
mocks.sqlFileSource = value;
},
}),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
@@ -48,11 +58,15 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
activeRunIds: mocks.activeRunIds,
cancellingRunIds: mocks.cancellingRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
heartbeat: { __v_isRef: true, value: null },
destinationRoot: { __v_isRef: true, value: null },
error: { __v_isRef: true, value: "" },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
deleteRuns: mocks.deleteRuns,
renameRun: mocks.renameRun,
runSchedule: mocks.runSchedule,
runOneShot: mocks.runOneShot,
cancelRun: mocks.cancelRun,
@@ -78,6 +92,9 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({
}));
vi.mock("@/lib/backend/api", () => ({
prepareDatabaseBackupRestore: mocks.prepareDatabaseBackupRestore,
databaseBackupBackground: vi.fn(async () => ({ enabled: false, platform: "windows" })),
databaseBackupCommand: vi.fn(async () => "2026-09-13T02:00:00Z"),
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
revealPathInFileManager: vi.fn(),
@@ -257,6 +274,9 @@ function scheduleRunNowButton(): HTMLButtonElement {
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
mocks.desktop = true;
mocks.sqlFileSource = null;
mocks.prepareDatabaseBackupRestore.mockReset();
mocks.connections.splice(0);
mocks.schedules.splice(0);
mocks.runs.splice(0);
@@ -278,6 +298,7 @@ afterEach(() => {
mocks.saveSchedule.mockClear();
mocks.deleteRun.mockClear();
mocks.deleteRuns.mockClear();
mocks.renameRun.mockReset();
mocks.cancelRun.mockClear();
mocks.runSchedule.mockReset();
mocks.runSchedule.mockResolvedValue(null);
@@ -286,6 +307,61 @@ afterEach(() => {
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
it.each([true, false])("restores with the correct desktop=%s source", async (desktop) => {
mocks.desktop = desktop;
const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", sizeBytes: 9, canExecuteWithoutSelectedDatabase: true, cleanupToken: "restore-token" };
mocks.prepareDatabaseBackupRestore.mockResolvedValue(preview);
mocks.runs.push({
id: "restore-run",
scheduleName: "Nightly",
connectionId: "mysql-1",
trigger: "manual",
source: "scheduled",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [{ displayName: "backup.sql", filePath: "/backups/backup.sql", database: "app" }],
});
await mountSettings();
buttonWithTitle(String(i18n.global.t("databaseBackup.showFiles"))).click();
await flush();
buttonWithText(String(i18n.global.t("databaseBackup.restore"))).click();
await flush();
expect(mocks.sqlFileSource).toEqual({ connectionId: "mysql-1", database: "app", ...(desktop ? { filePath: "/backups/backup.sql" } : { preview }) });
if (desktop) expect(mocks.prepareDatabaseBackupRestore).not.toHaveBeenCalled();
else expect(mocks.prepareDatabaseBackupRestore).toHaveBeenCalledWith("restore-run", 0);
});
it("shows a backup display name and saves edits through the rename dialog", async () => {
mocks.runs.push({
id: "renamed-run",
scheduleName: "Nightly backup",
displayName: "Before migration",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "scheduled",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
});
mocks.renameRun.mockResolvedValue(true);
await mountSettings();
expect(document.body.textContent).toContain("Before migration");
buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).click();
await flush();
const input = currentDialog().querySelector<HTMLInputElement>("input")!;
expect(input.value).toBe("Before migration");
input.value = "After migration";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
saveScheduleButton().click();
await flush();
expect(mocks.renameRun).toHaveBeenCalledWith("renamed-run", "After migration");
expect(document.body.querySelector('[data-slot="dialog-content"]')).toBeNull();
});
it("reconnects once when loading databases finds a closed connection", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed")).mockResolvedValueOnce([{ name: "app" }]);
@@ -616,6 +692,11 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
await mountSettings();
const progress = document.body.querySelector('[role="progressbar"]');
expect(progress?.getAttribute("aria-valuenow")).toBe("25");
expect(document.body.textContent).toContain("25%");
expect(buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).disabled).toBe(true);
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
@@ -674,7 +755,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.databaseExportDestinationNeedsConfirmation).toHaveBeenCalledWith("/backups");
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/backups" }));
expect(mocks.recordDatabaseExportDestination).toHaveBeenCalledWith("/backups");
expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1", "manual");
expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1");
});
it("does not run a legacy schedule when destination confirmation is cancelled", async () => {
@@ -2914,7 +2914,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
{ 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: "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") },
@@ -8616,7 +8616,7 @@ LIMIT 100;</pre
</div>
</section>
<section v-else-if="activeSettingsTab === 'backups' && !isWeb" data-settings-search-id="backups" :class="['py-2', settingsSearchTargetClass('backups')]">
<section v-else-if="activeSettingsTab === 'backups'" data-settings-search-id="backups" :class="['py-2', settingsSearchTargetClass('backups')]">
<ScheduledDatabaseBackupSettings />
</section>
@@ -262,7 +262,13 @@ watch(
:prefill-table="dialogs.dataComparePrefillTable.value"
:session-id="dialogs.dataCompareSessionId.value"
/>
<SqlFileExecutionDialog v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" :prefill-file-path="dialogs.sqlFilePrefillFilePath.value" />
<SqlFileExecutionDialog
v-model:open="dialogs.showSqlFileDialog.value"
:prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value"
:prefill-database="dialogs.sqlFilePrefillDatabase.value"
:prefill-file-path="dialogs.sqlFilePrefillFilePath.value"
:prefill-preview="dialogs.sqlFilePrefillPreview.value"
/>
<SchemaDiagramDialog
v-if="dialogs.showDiagramDialog.value"
v-model:open="dialogs.showDiagramDialog.value"
@@ -4,6 +4,9 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
desktop: true,
releaseSqlFilePreview: vi.fn(),
previewWebSqlFile: vi.fn(),
addSqlFileTask: vi.fn(),
beginManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
@@ -40,7 +43,14 @@ function passthrough(tag: string) {
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/lib/backend/http", () => ({
previewSqlFile: mocks.previewWebSqlFile,
loadSqlFileUploadMaxBytes: vi.fn(async () => 200 * 1024 * 1024),
}));
vi.mock("@/lib/sql/httpSqlFileProgress", () => ({
listenSqlFileProgressById: (_id: string, handler: (progress: Record<string, unknown>) => void) => mocks.listenSqlFileProgress(handler),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: mocks.highlight }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
@@ -69,6 +79,7 @@ vi.mock("@/lib/backend/api", () => ({
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
previewSqlFile: mocks.previewSqlFile,
releaseSqlFilePreview: mocks.releaseSqlFilePreview,
inspectSqlFileTables: mocks.inspectSqlFileTables,
}));
vi.mock("@lucide/vue", () => {
@@ -176,6 +187,8 @@ function deferred() {
beforeEach(() => {
vi.clearAllMocks();
mocks.desktop = true;
mocks.releaseSqlFilePreview.mockResolvedValue(undefined);
mocks.progressHandler = undefined;
mocks.highlighterState = reactive({ ready: false, appearance: "light" });
mocks.highlight.mockReset().mockImplementation((sql: string) => (mocks.highlighterState!.ready ? `<span data-theme="${mocks.highlighterState!.appearance}">${sql}</span>` : sql));
@@ -245,6 +258,57 @@ afterEach(() => {
});
describe("SqlFileExecutionDialog retries", () => {
async function mountRestoredPreview() {
mocks.desktop = false;
const preview = {
fileName: "backup.sql",
filePath: "/server/tmp/sql_file/restore-token/backup.sql",
preview: "SELECT 42;",
sizeBytes: 10,
canExecuteWithoutSelectedDatabase: true,
cleanupToken: "restore-token",
};
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true, prefillPreview: preview });
app.mount(root);
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
expect(root.textContent).toContain("SELECT 42;");
expect(mocks.previewSqlFile).not.toHaveBeenCalled();
expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
return preview;
}
it("renders a prepared Web preview without uploading a server path and releases it on unmount", async () => {
await mountRestoredPreview();
app!.unmount();
app = undefined;
await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
});
it.each(["done", "error", "cancelled"])("executes the prepared server object and releases it after %s", async (status) => {
const preview = await mountRestoredPreview();
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
expect(mocks.releaseSqlFilePreview).not.toHaveBeenCalled();
mocks.progressHandler?.(progress(request.executionId, status, status === "error" ? { error: "restore failed" } : {}));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ filePath: preview.filePath }), [preview.filePath]));
await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
});
it("keeps browser uploads on the File-only preview path", async () => {
await mountRestoredPreview();
const file = new File(["SELECT 2;"], "upload.sql", { type: "text/plain" });
mocks.previewWebSqlFile.mockResolvedValueOnce({ fileName: file.name, filePath: "/server/tmp/sql_file/upload.sql", sizeBytes: file.size, preview: "SELECT 2;", canExecuteWithoutSelectedDatabase: true });
const input = root!.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(input, "files", { value: [file] });
input.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(mocks.previewWebSqlFile).toHaveBeenCalledWith(file));
expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token");
});
it("reuses the unchanged preview across progress and failure updates while keeping controls responsive", async () => {
const onOpenChange = vi.fn();
await mountReadyDialog(onOpenChange);
@@ -39,6 +39,7 @@ const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillFilePath?: string;
prefillPreview?: SqlFilePreview;
}>();
const store = useConnectionStore();
@@ -239,6 +240,7 @@ watch(canUseManualTransaction, (supported) => {
});
onBeforeUnmount(() => {
disposed = true;
if (!running.value) void releaseManagedPreviews();
if (running.value && manualTransaction.value) {
cancelRequested.value = true;
if (executionStarted.value) void cancelSqlFileExecution(executionId.value).catch(() => {});
@@ -469,6 +471,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
async function loadPreviews(filesOrPaths: Array<string | File>) {
if (executionLocked.value) return;
await releaseManagedPreviews();
loadingPreview.value = true;
previews.value = [];
resetExecution();
@@ -485,6 +488,18 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
}
}
async function releaseManagedPreviews() {
const tokens = previews.value.flatMap((preview) => (preview.cleanupToken ? [preview.cleanupToken] : []));
if (!tokens.length) return;
previews.value = previews.value.filter((preview) => !preview.cleanupToken);
try {
const { releaseSqlFilePreview } = await import("@/lib/backend/api");
await Promise.all(tokens.map((token) => releaseSqlFilePreview(token)));
} catch (error: any) {
toast(error?.message || String(error), 5000);
}
}
async function selectFile() {
if (executionLocked.value) return;
if (!isTauriRuntime()) {
@@ -721,6 +736,7 @@ async function startExecution() {
running.value = false;
cancelling.value = false;
executionStarted.value = false;
await releaseManagedPreviews();
}
}
@@ -763,6 +779,7 @@ watch(
open,
(value) => {
if (!value) {
if (!running.value) void releaseManagedPreviews();
if (manualTransaction.value && running.value) void cancelExecution();
else if (txnSessionId.value) void finishTransaction(false);
return;
@@ -774,7 +791,9 @@ watch(
}
// When opened from the SQL Files panel with a pre-selected file, load its
// preview automatically so the user can review statements before running.
if (props.prefillFilePath) {
if (props.prefillPreview) {
previews.value = [props.prefillPreview];
} else if (props.prefillFilePath) {
void loadPreviews([props.prefillFilePath]);
}
},