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]);
}
},
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, expect, it, vi } from "vitest";
import type { SqlFilePreview } from "@/lib/backend/api";
const mocks = vi.hoisted(() => ({
store: {
connections: [],
sqlFileSource: null as { connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null,
},
}));
vi.mock("@/stores/connectionStore", async () => {
const { reactive } = await import("vue");
mocks.store = reactive(mocks.store);
return { useConnectionStore: () => mocks.store };
});
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
import { useDialogSources } from "@/composables/useDialogSources";
let app: App | undefined;
afterEach(() => {
app?.unmount();
document.body.innerHTML = "";
});
it("hands prepared Web previews to the dialog, clears them on close, and preserves desktop paths", async () => {
let dialogs!: ReturnType<typeof useDialogSources>;
app = createApp(
defineComponent({
setup() {
dialogs = useDialogSources();
return () => h("div");
},
}),
);
app.mount(document.body.appendChild(document.createElement("div")));
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.store.sqlFileSource = { connectionId: "mysql", database: "app", preview };
await nextTick();
expect(dialogs.showSqlFileDialog.value).toBe(true);
expect(dialogs.sqlFilePrefillPreview.value).toEqual(preview);
expect(dialogs.sqlFilePrefillFilePath.value).toBe("");
expect(mocks.store.sqlFileSource).toBeNull();
dialogs.showSqlFileDialog.value = false;
await nextTick();
expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
mocks.store.sqlFileSource = { connectionId: "mysql", database: "app", filePath: "/local/backup.sql" };
await nextTick();
expect(dialogs.sqlFilePrefillFilePath.value).toBe("/local/backup.sql");
expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
});
@@ -1,310 +1,190 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../lib/backup/scheduledDatabaseBackup";
import type { DatabaseBackupSnapshot } from "../../lib/backup/backgroundDatabaseBackup";
const mocks = vi.hoisted(() => ({
connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
listDatabases: vi.fn(async () => [{ name: "app" }]),
beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
rollbackSnapshot: vi.fn(async () => {}),
deleteFiles: vi.fn(async () => 0),
cancelExport: vi.fn(async () => {}),
clearExportCancellation: vi.fn(async () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
command: vi.fn(),
legacySchedules: vi.fn(() => []),
legacyRuns: vi.fn(() => []),
desktop: true,
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
markTaskCancelling: vi.fn(),
restoreTaskRunning: vi.fn(),
nextId: 0,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
clearDatabaseExportCancellation: mocks.clearExportCancellation,
}));
vi.mock("@/lib/export/databaseExport", () => ({
buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
generateDatabaseExportId: () => `run-${++mocks.nextId}`,
runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: () => mocks.connection,
ensureConnected: vi.fn(async () => {}),
initFromDisk: vi.fn(async () => {}),
}),
cancelTask: vi.fn(),
register: vi.fn(),
unregister: vi.fn(),
}));
vi.mock("vue", async () => ({ ...(await vi.importActual("vue")), onMounted: vi.fn(), onUnmounted: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ databaseBackupCommand: mocks.command }));
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/lib/backup/scheduledDatabaseBackup", () => ({ readDatabaseBackupSchedules: mocks.legacySchedules, readDatabaseBackupRuns: mocks.legacyRuns }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
markDatabaseExportTaskCancelling: mocks.cancelTask,
registerTaskCancelHandler: mocks.register,
unregisterTaskCancelHandler: mocks.unregister,
}),
}));
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
let snapshot: DatabaseBackupSnapshot;
function run(status: DatabaseBackupRun["status"] = "running"): DatabaseBackupRun {
return { id: "run-1", scheduleId: "schedule-1", scheduleName: "Daily", connectionId: "mysql", connectionName: "Local", destinationDirectory: "/backups", source: "scheduled", trigger: "manual", status, startedAt: "2026-09-12T00:00:00Z", files: [], progressPercent: 45 };
}
async function create() {
return (await import("../useScheduledDatabaseBackups")).useScheduledDatabaseBackups();
}
const config: DatabaseBackupExecutionConfig = {
connectionId: "mysql-1",
databases: ["app"],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "/backups",
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mocks.desktop = true;
snapshot = { schedules: [], runs: [], migrated: true, heartbeat: "2026-09-12T00:00:00Z", destinationRoot: null };
mocks.command.mockImplementation(async (command) => (command.action === "snapshot" ? structuredClone(snapshot) : null));
});
describe("useScheduledDatabaseBackups one-shot execution", () => {
it("deletes multiple completed runs and all of their backup files", async () => {
const backup = useScheduledDatabaseBackups();
const firstRun = {
id: "delete-many-first",
scheduleName: "First backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "success",
startedAt: "2026-09-08T01:00:00.000Z",
files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
} satisfies DatabaseBackupRun;
const secondRun = {
id: "delete-many-second",
scheduleName: "Second backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
destinationDirectory: "/backups",
trigger: "scheduled",
source: "scheduled",
status: "failed",
startedAt: "2026-09-08T02:00:00.000Z",
files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
} satisfies DatabaseBackupRun;
backup.runs.value.push(firstRun, secondRun);
mocks.deleteFiles.mockClear();
try {
await backup.deleteRuns([firstRun.id, secondRun.id]);
expect(mocks.deleteFiles).toHaveBeenCalledWith(["/backups/first.sql", "/backups/second.sql"], ["/backups"]);
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: secondRun.id }));
} finally {
backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
}
describe("backend-owned scheduled database backups", () => {
it("reconnects to running backups without starting another export", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
await backups.processDueSchedules();
expect(backups.activeRunIds.has("run-1")).toBe(true);
expect(backups.activeScheduleIds.has("schedule-1")).toBe(true);
expect(mocks.addTask).toHaveBeenCalledTimes(1);
expect(mocks.command.mock.calls.every(([c]) => c.action === "snapshot")).toBe(true);
expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Running", overallPercent: 45 }));
});
it("keeps a shared backup path when deleting one of its history records", async () => {
const backup = useScheduledDatabaseBackups();
const sharedPath = "/backups/before-migration__app.sql";
const firstRun = {
id: "shared-path-first",
scheduleName: "First backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "scheduled",
source: "scheduled",
status: "success",
startedAt: "2026-09-08T01:00:00.000Z",
files: [{ displayName: "first.sql", filePath: sharedPath }],
} satisfies DatabaseBackupRun;
const secondRun = { ...firstRun, id: "shared-path-second", startedAt: "2026-09-08T02:00:00.000Z" } satisfies DatabaseBackupRun;
backup.runs.value.push(firstRun, secondRun);
mocks.deleteFiles.mockClear();
try {
await backup.deleteRun(firstRun.id);
expect(mocks.deleteFiles).not.toHaveBeenCalled();
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
expect(backup.runs.value).toContainEqual(expect.objectContaining({ id: secondRun.id }));
} finally {
backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
}
it("reflects completion from a worker that runs independently of the UI", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
snapshot.runs[0] = { ...run("success"), progressPercent: 100 };
await backups.processDueSchedules();
expect(backups.activeRunIds.size).toBe(0);
expect(backups.activeScheduleIds.size).toBe(0);
expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Done", overallPercent: 100 }));
});
it("executes one-shot backups through the shared exporter without a schedule", async () => {
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
});
const backup = useScheduledDatabaseBackups();
const scheduleCountBefore = backup.schedules.value.length;
const run = await backup.runOneShot(config, "One-time backup");
expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
expect(run).not.toHaveProperty("nextRunAt");
expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
expect(mocks.runDatabaseExport).toHaveBeenCalledWith(expect.objectContaining({ preventOverwrite: true }), expect.any(Function));
expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
it("waits for durable cancellation acceptance before showing cancellation", async () => {
snapshot.runs = [run()];
const backups = await create();
await backups.processDueSchedules();
mocks.command.mockRejectedValueOnce(new Error("offline"));
await expect(backups.cancelRun("run-1")).rejects.toThrow("offline");
expect(backups.cancellingRunIds.size).toBe(0);
mocks.command.mockResolvedValueOnce(true);
expect(await backups.cancelRun("run-1")).toBe(true);
expect(backups.cancellingRunIds.has("run-1")).toBe(true);
expect(mocks.command).toHaveBeenLastCalledWith({ action: "cancel", id: "run-1" });
});
it("rejects a repeated one-shot when its custom file name resolves to an existing path", async () => {
mocks.runDatabaseExport.mockImplementation(async (_request: unknown, onProgress: (progress: unknown) => void) => {
const progress = { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
onProgress(progress);
return progress;
});
const backup = useScheduledDatabaseBackups();
const customConfig = { ...config, fileNamePattern: "before-migration" };
let runIds: string[] = [];
try {
const firstRun = await backup.runOneShot(customConfig, "One-time backup");
runIds = [firstRun?.id].filter((id): id is string => Boolean(id));
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
const secondRun = await backup.runOneShot(customConfig, "One-time backup");
if (secondRun?.id) runIds.push(secondRun.id);
expect(firstRun).toEqual(expect.objectContaining({ status: "success" }));
expect(secondRun).toEqual(expect.objectContaining({ status: "failed", files: [] }));
expect(secondRun?.error).toContain("already used by another run");
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
const ids = new Set(runIds);
backup.runs.value = backup.runs.value.filter((run) => !ids.has(run.id));
}
it("deletes by server record IDs, never by client supplied file paths", async () => {
snapshot.runs = [run("success")];
const backups = await create();
await backups.processDueSchedules();
await backups.deleteRuns(["run-1"]);
expect(mocks.command).toHaveBeenCalledWith({ action: "deleteRuns", ids: ["run-1"] });
});
it("does not delete a pre-existing file when the exporter rejects overwrite", async () => {
mocks.runDatabaseExport.mockRejectedValueOnce(new Error("Backup file already exists: /backups/before-migration__app.sql"));
const backup = useScheduledDatabaseBackups();
const result = await backup.runOneShot({ ...config, fileNamePattern: "before-migration" }, "One-time backup");
expect(result).toEqual(expect.objectContaining({ status: "failed", files: [] }));
expect(mocks.deleteFiles).not.toHaveBeenCalled();
it("preserves optimistic concurrency timestamps and saved time zones", async () => {
const schedule = { id: "schedule-1", name: "Daily", timeZone: "Asia/Tokyo", updatedAt: "2026-09-01T00:00:00Z" } as DatabaseBackupSchedule;
const backups = await create();
await backups.saveSchedule(schedule);
expect(mocks.command).toHaveBeenCalledWith({ action: "save", schedule });
});
it("prevents a second one-shot while one is already running", async () => {
const backup = useScheduledDatabaseBackups();
const activeRun = {
id: "active-one-shot",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: new Date().toISOString(),
files: [],
} satisfies DatabaseBackupRun;
backup.runs.value.push(activeRun);
backup.activeRunIds.add(activeRun.id);
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
try {
expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
backup.activeRunIds.delete(activeRun.id);
backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
}
it("does not overwrite local state after a failed save", async () => {
snapshot.schedules = [{ id: "schedule-1", name: "Original" } as DatabaseBackupSchedule];
const backups = await create();
await backups.processDueSchedules();
mocks.command.mockRejectedValueOnce(new Error("schedule changed"));
await expect(backups.saveSchedule({ ...snapshot.schedules[0], name: "New" })).rejects.toThrow("schedule changed");
expect(backups.schedules.value[0].name).toBe("Original");
});
it("cancels an active one-shot through the shared export cancellation path", async () => {
mocks.cancelExport.mockClear();
mocks.deleteFiles.mockClear();
let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
mocks.runDatabaseExport.mockImplementationOnce(
(_request: unknown, onProgress: (progress: unknown) => void) =>
new Promise((resolve) => {
resolveExport = (progress) => {
onProgress(progress);
resolve(progress);
};
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)], ["/backups"]);
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
});
it("stops after cancelling during database discovery without creating a snapshot", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveDatabases!: (databases: Array<{ name: string }>) => void;
mocks.listDatabases.mockImplementationOnce(
it("reloads after a save even when an older poll finishes later", async () => {
const backups = await create();
const oldSnapshot = structuredClone(snapshot);
let release!: (value: DatabaseBackupSnapshot) => void;
mocks.command.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDatabases = resolve;
new Promise<DatabaseBackupSnapshot>((resolve) => {
release = resolve;
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
resolveDatabases([{ name: "app" }]);
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.beginSnapshot).not.toHaveBeenCalled();
const polling = backups.processDueSchedules();
const schedule = { id: "schedule-1", name: "New", timeZone: "Asia/Shanghai" } as DatabaseBackupSchedule;
mocks.command.mockImplementation(async (command) => {
if (command.action === "save") {
snapshot.schedules = [schedule];
return schedule;
}
return structuredClone(snapshot);
});
const saving = backups.saveSchedule(schedule);
release(oldSnapshot);
await polling;
await saving;
expect(backups.schedules.value).toEqual([schedule]);
expect(mocks.command.mock.calls.filter(([command]) => command.action === "snapshot")).toHaveLength(2);
});
it("signals the backup run while snapshot creation is pending", async () => {
mocks.beginSnapshot.mockClear();
mocks.cancelExport.mockClear();
let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
mocks.beginSnapshot.mockImplementationOnce(
it("does not lose a newly queued run to a stale in-flight snapshot", async () => {
const backups = await create();
const oldSnapshot = structuredClone(snapshot);
let release!: (value: DatabaseBackupSnapshot) => void;
mocks.command.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
new Promise<DatabaseBackupSnapshot>((resolve) => {
release = resolve;
}),
);
const polling = backups.processDueSchedules();
mocks.command.mockImplementation(async (command) => {
if (command.action === "run") {
snapshot.runs = [run("success")];
return run();
}
return structuredClone(snapshot);
});
const running = backups.runSchedule("schedule-1");
release(oldSnapshot);
await polling;
expect(await running).toEqual(run("success"));
});
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
it("marks migration only after the backend accepts it", async () => {
snapshot.migrated = false;
const backups = await create();
mocks.command.mockImplementation(async (command) => {
if (command.action === "migrate") throw new Error("database locked");
return structuredClone(snapshot);
});
await expect(backups.processDueSchedules()).rejects.toThrow("database locked");
expect(mocks.legacySchedules).toHaveBeenCalledTimes(1);
expect(backups.error.value).toBe("database locked");
mocks.command.mockImplementation(async (command) => {
if (command.action === "migrate") {
snapshot.migrated = true;
return null;
}
return structuredClone(snapshot);
});
await backups.processDueSchedules();
await backups.processDueSchedules();
expect(mocks.legacySchedules).toHaveBeenCalledTimes(2);
});
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
resolveSnapshot({ sessionId: "snapshot-pending" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
it("never migrates browser-local paths into a server", async () => {
mocks.desktop = false;
snapshot.migrated = false;
const backups = await create();
await backups.processDueSchedules();
expect(mocks.legacySchedules).not.toHaveBeenCalled();
expect(mocks.legacyRuns).not.toHaveBeenCalled();
expect(mocks.command).toHaveBeenCalledWith({ action: "migrate", migration: { schedules: [], runs: [] } });
});
});
@@ -1,4 +1,5 @@
import { ref, watch } from "vue";
import type { SqlFilePreview } from "@/lib/backend/api";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -61,6 +62,7 @@ const dataCompareSessionId = ref<string | null>(null);
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const sqlFilePrefillFilePath = ref("");
const sqlFilePrefillPreview = ref<SqlFilePreview>();
const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
@@ -195,6 +197,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId.value = v.connectionId;
sqlFilePrefillDatabase.value = v.database;
sqlFilePrefillFilePath.value = v.filePath ?? "";
sqlFilePrefillPreview.value = v.preview;
showSqlFileDialog.value = true;
connectionStore.sqlFileSource = null;
}
@@ -207,7 +210,10 @@ export function useDialogSources() {
// when stale (they only preselect dropdowns), but a stale path triggers an
// async file read + preview render — a visible side effect.
watch(showSqlFileDialog, (open) => {
if (!open) sqlFilePrefillFilePath.value = "";
if (!open) {
sqlFilePrefillFilePath.value = "";
sqlFilePrefillPreview.value = undefined;
}
});
watch(
@@ -577,6 +583,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId,
sqlFilePrefillDatabase,
sqlFilePrefillFilePath,
sqlFilePrefillPreview,
diagramPrefillConnectionId,
diagramPrefillDatabase,
diagramPrefillSchema,
@@ -2,567 +2,169 @@ 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,
DatabaseBackupConnectionQueue,
databaseBackupAggregateExportStatus,
databaseBackupFilePath,
databaseBackupRunDirectory,
databaseBackupProgressPercent,
databaseBackupRunsToPrune,
databaseBackupScheduleIsDue,
databaseBackupTableNamesAreCaseSensitive,
nextDatabaseBackupRunAt,
normalizeDatabaseBackupSchedule,
readDatabaseBackupRuns,
readDatabaseBackupSchedules,
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
toDatabaseBackupExecutionConfig,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupExecutionConfig,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunSource,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
} from "@/lib/backup/scheduledDatabaseBackup";
import { readDatabaseBackupRuns, readDatabaseBackupSchedules, type DatabaseBackupExecutionConfig, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import type { DatabaseBackupSnapshot } from "@/lib/backup/backgroundDatabaseBackup";
import { useExportTracker } from "@/composables/useExportTracker";
const SCHEDULER_INTERVAL_MS = 30_000;
const databaseBackupConnectionQueue = new DatabaseBackupConnectionQueue();
const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
const schedules = ref<DatabaseBackupSchedule[]>([]);
const runs = ref<DatabaseBackupRun[]>([]);
const activeScheduleIds = reactive(new Set<string>());
const activeRunIds = reactive(new Set<string>());
const cancellingRunIds = reactive(new Set<string>());
const activeExportIds = new Map<string, string>();
const activeBackupPaths = new Set<string>();
const cancellationRequested = new Set<string>();
const heartbeat = ref<string | null>(null);
const destinationRoot = ref<string | null>(null);
const error = ref("");
const tracked = new Set<string>();
let refreshing: Promise<void> | undefined;
let subscribers = 0;
let timer: ReturnType<typeof setInterval> | undefined;
let schedulerTimer: ReturnType<typeof window.setInterval> | undefined;
let schedulerRegistered = false;
let processingDueSchedules = false;
export function useScheduledDatabaseBackups(_options: { scheduler?: boolean } = {}) {
const tracker = useExportTracker();
const activeRuns = computed(() => runs.value.filter((run) => run.status === "running"));
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, persist = true) {
runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)];
if (persist) persistRuns();
}
function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = true): DatabaseBackupRun | null {
const existing = runs.value.find((run) => run.id === runId);
if (!existing) return null;
const updated = { ...existing, ...patch };
replaceRun(updated, persist);
return updated;
}
function backupPathIsReferencedByAnotherRun(filePath: string, excludedRunIds: ReadonlySet<string>): boolean {
return runs.value.some((run) => !excludedRunIds.has(run.id) && run.files.some((file) => file.filePath === filePath));
}
function uniqueUnreferencedBackupPaths(paths: readonly string[], excludedRunIds: ReadonlySet<string>): string[] {
return [...new Set(paths)].filter((filePath) => !backupPathIsReferencedByAnotherRun(filePath, excludedRunIds));
}
function backupRootsForRuns(selectedRuns: readonly DatabaseBackupRun[], fallback?: string): string[] {
return [...new Set([...selectedRuns.map((run) => run.destinationDirectory), fallback].filter((root): root is string => Boolean(root)))];
}
type FinishedDatabaseBackupRun = Omit<DatabaseBackupRun, "status"> & { status: Exclude<DatabaseBackupRunStatus, "running"> };
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
}
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
const connectionStore = useConnectionStore();
const { addDatabaseExportTask, markDatabaseExportTaskCancelling, registerTaskCancelHandler, restoreDatabaseExportTaskRunning, 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 deleteRuns(runIds: readonly string[]): Promise<void> {
const ids = new Set(runIds);
const selectedRuns = runs.value.filter((run) => ids.has(run.id) && !activeRunIds.has(run.id));
if (selectedRuns.length === 0) return;
const deletedIds = new Set(selectedRuns.map((run) => run.id));
const paths = uniqueUnreferencedBackupPaths(
selectedRuns.flatMap((run) => run.files.map((file) => file.filePath)),
deletedIds,
);
if (paths.length > 0) await api.deleteDatabaseBackupFiles(paths, backupRootsForRuns(selectedRuns));
runs.value = runs.value.filter((run) => !deletedIds.has(run.id));
persistRuns();
}
async function deleteRun(runId: string): Promise<void> {
await deleteRuns([runId]);
}
function renameRun(runId: string, displayName: string): boolean {
const run = runs.value.find((item) => item.id === runId);
if (!run || activeRunIds.has(runId)) return false;
const normalizedName = displayName.trim();
updateRun(runId, { displayName: normalizedName && normalizedName !== run.scheduleName ? normalizedName : undefined });
return true;
}
async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
if (staleRuns.length === 0) return;
const staleIds = new Set(staleRuns.map((run) => run.id));
const stalePaths = uniqueUnreferencedBackupPaths(
staleRuns.flatMap((run) => run.files.map((file) => file.filePath)),
staleIds,
);
try {
if (stalePaths.length > 0) await api.deleteDatabaseBackupFiles(stalePaths, backupRootsForRuns(staleRuns, schedule.destinationDirectory));
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): Promise<boolean> {
if (!activeRunIds.has(runId)) return false;
if (cancellationRequested.has(runId)) return true;
cancellationRequested.add(runId);
cancellingRunIds.add(runId);
markDatabaseExportTaskCancelling(runId);
const exportIds = new Set([runId, activeExportIds.get(runId)].filter((id): id is string => Boolean(id)));
try {
await Promise.all([...exportIds].map((exportId) => api.cancelDatabaseExport(exportId)));
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:cancel-request-error]", error);
cancellationRequested.delete(runId);
cancellingRunIds.delete(runId);
restoreDatabaseExportTaskRunning(runId);
return false;
}
return true;
}
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string; runDirectoryPattern?: string }): Promise<FinishedDatabaseBackupRun | null> {
const backupName = request.displayName;
const connection = connectionStore.getConfig(config.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const outputDirectory = request.runDirectoryPattern ? databaseBackupRunDirectory(config.destinationDirectory, request.runDirectoryPattern, backupName, startedAt, runId) : config.destinationDirectory;
const run: DatabaseBackupRun = {
id: runId,
scheduleId: request.scheduleId,
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
destinationDirectory: config.destinationDirectory,
trigger: request.trigger,
source: request.source,
status: "running",
startedAt: startedAt.toISOString(),
files: [],
progressPercent: 0,
};
replaceRun(run);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, backupName, outputDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, async () => {
await cancelRun(runId);
});
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: FinishedDatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
const reservedPaths: string[] = [];
await databaseBackupConnectionQueue.run(config.connectionId, async () => {
try {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
await connectionStore.ensureConnected(config.connectionId);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
let tableNamesCaseSensitive = true;
if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(config.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]);
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
}
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
}
let exportIndex = 0;
let includedTableCount = 0;
for (const [databaseIndex, database] of selectedDatabases.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
schemasByDatabase,
function update(snapshot: DatabaseBackupSnapshot) {
schedules.value = snapshot.schedules;
runs.value = snapshot.runs;
heartbeat.value = snapshot.heartbeat;
destinationRoot.value = snapshot.destinationRoot;
activeScheduleIds.clear();
activeRunIds.clear();
for (const run of snapshot.runs) {
if (run.status === "running") {
activeRunIds.add(run.id);
if (run.scheduleId) activeScheduleIds.add(run.scheduleId);
if (!tracked.has(run.id)) {
tracked.add(run.id);
tracker.addDatabaseExportTask(run.id, run.displayName || run.scheduleName, run.destinationDirectory || "", run.source === "scheduled" ? "scheduled" : "manual");
tracker.registerTaskCancelHandler(run.id, async () => {
await cancelRun(run.id);
});
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (config.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
if (finalStatus === "cancelled") break;
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(outputDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression, config.fileNamePattern);
if (generatedPaths.includes(filePath) || activeBackupPaths.has(filePath) || backupPathIsReferencedByAnotherRun(filePath, new Set([runId]))) {
throw new Error("Backup file path is already used by another run. Include {runId} or another unique value in the file-name template.");
}
generatedPaths.push(filePath);
reservedPaths.push(filePath);
activeBackupPaths.add(filePath);
let terminal: Awaited<ReturnType<typeof runDatabaseExportUntilTerminal>>;
try {
terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: config.connectionId,
database: item.database,
schema: item.schema,
filePath,
selectedTables: item.selectedTables,
excludedTables: item.excludedTables,
includeStructure: config.includeStructure,
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
outputCompression: config.outputCompression,
failOnError: true,
preventOverwrite: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
},
(progress) => {
const progressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex,
totalDatabases: selectedDatabases.length,
completedExports: planIndex,
totalExports: scopedDatabasePlan.length,
currentObjectIndex: progress.objectIndex,
currentTotalObjects: progress.totalObjects,
currentExportComplete: progress.status === "Done",
});
if (progressPercent !== lastProgressPercent) {
lastProgressPercent = progressPercent;
updateRun(runId, { progressPercent }, false);
}
updateDatabaseExportTask(runId, {
...progress,
exportId: runId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
status: databaseBackupAggregateExportStatus(progress.status, false),
overallPercent: progressPercent,
});
},
);
} catch (error: any) {
if (String(error?.message || error).startsWith("Backup file already exists:")) {
const index = generatedPaths.lastIndexOf(filePath);
if (index >= 0) generatedPaths.splice(index, 1);
}
throw error;
}
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;
lastProgressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex + 1,
totalDatabases: selectedDatabases.length,
completedExports: 0,
totalExports: 0,
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (config.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${config.tableFilterMode} backup rules.`);
}
} catch (error: any) {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
finalError = error?.message || String(error);
} finally {
for (const filePath of reservedPaths) activeBackupPaths.delete(filePath);
// `runId` is also the cancellation key while a snapshot is waiting
// for a pool connection. Unlike child exports, it has no exporter
// task that can clear that key after completion.
await api.clearDatabaseExportCancellation(runId).catch((error) => {
appendDebugLog("warn", "[DBX][database-backup:cancel-clear-error]", error);
});
if (finalStatus !== "success" && generatedPaths.length > 0) {
try {
const cleanupPaths = uniqueUnreferencedBackupPaths(generatedPaths, new Set([runId]));
if (cleanupPaths.length > 0) await api.deleteDatabaseBackupFiles(cleanupPaths, [config.destinationDirectory]);
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);
cancellingRunIds.delete(runId);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
const updatedRun = updateRun(runId, {
status: finalStatus,
completedAt: completedAt.toISOString(),
files: [...run.files],
progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
error: finalError || undefined,
});
finishedRun = updatedRun && updatedRun.status !== "running" ? (updatedRun as FinishedDatabaseBackupRun) : null;
updateDatabaseExportTask(runId, {
exportId: runId,
currentObject: backupName,
objectIndex: finalStatus === "success" ? run.files.length : 0,
} else {
cancellingRunIds.delete(run.id);
tracker.unregisterTaskCancelHandler(run.id);
}
if (tracked.has(run.id)) {
tracker.updateDatabaseExportTask(run.id, {
exportId: run.id,
currentObject: run.displayName || run.scheduleName,
objectIndex: 0,
totalObjects: run.files.length,
rowsExported: 0,
totalRows: null,
status: databaseBackupAggregateExportStatus(finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error", true),
error: finalError || null,
overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
});
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
scheduleId: request.scheduleId,
runId,
files: run.files.length,
error: finalError || undefined,
status: run.status === "running" ? "Running" : run.status === "success" ? "Done" : run.status === "cancelled" ? "Cancelled" : "Error",
error: run.error || null,
overallPercent: run.progressPercent || 0,
});
if (run.status !== "running") tracked.delete(run.id);
}
});
return finishedRun;
}
async function finalizeScheduledRun(scheduleId: string, trigger: DatabaseBackupRunTrigger, run: FinishedDatabaseBackupRun) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
const completedAt = run.completedAt ? new Date(run.completedAt) : new Date();
schedules.value = schedules.value.map((item) =>
item.id === scheduleId
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: run.status,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (run.status === "success") await pruneScheduleRuns(schedule);
}
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
activeScheduleIds.add(scheduleId);
try {
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), {
source: "scheduled",
trigger,
scheduleId,
displayName: schedule.name,
runDirectoryPattern: schedule.runDirectoryPattern,
});
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
return run;
} finally {
activeScheduleIds.delete(scheduleId);
}
}
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup"): Promise<FinishedDatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("One-shot database backups are only available in the desktop app.");
if (activeRuns.value.some((run) => run.source === "one-shot")) return null;
return runBackup(config, { source: "one-shot", trigger: "manual", displayName });
async function refresh() {
if (refreshing) return refreshing;
refreshing = (async () => {
try {
let snapshot = await api.databaseBackupCommand<DatabaseBackupSnapshot>({ action: "snapshot" });
if (!snapshot.migrated) {
const legacySchedules = isTauriRuntime() ? readDatabaseBackupSchedules().map((schedule) => ({ ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone })) : [];
await api.databaseBackupCommand({ action: "migrate", migration: { schedules: legacySchedules, runs: isTauriRuntime() ? readDatabaseBackupRuns() : [] } });
// Keep legacy keys as a migration fallback. The backend marker prevents reimporting.
snapshot = await api.databaseBackupCommand<DatabaseBackupSnapshot>({ action: "snapshot" });
}
update(snapshot);
error.value = "";
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason);
appendDebugLog("error", "[DBX][database-backup:refresh]", reason);
throw reason;
}
})().finally(() => {
refreshing = undefined;
});
return refreshing;
}
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;
async function refreshAfterMutation() {
// A poll started before a mutation may still return the previous state.
await refreshing?.catch(() => {});
await refresh();
}
async function saveSchedule(schedule: DatabaseBackupSchedule) {
const saved = await api.databaseBackupCommand<DatabaseBackupSchedule>({ action: "save", schedule: { ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone } });
await refreshAfterMutation();
return saved;
}
async function setScheduleEnabled(id: string, enabled: boolean) {
const schedule = schedules.value.find((s) => s.id === id);
if (schedule) await saveSchedule({ ...schedule, enabled });
}
async function deleteSchedule(id: string) {
if (activeScheduleIds.has(id)) return false;
await api.databaseBackupCommand({ action: "deleteSchedule", id });
await refreshAfterMutation();
return true;
}
async function deleteRuns(ids: readonly string[]) {
await api.databaseBackupCommand({ action: "deleteRuns", ids: [...ids] });
await refreshAfterMutation();
}
async function renameRun(id: string, name: string) {
if (activeRunIds.has(id)) return false;
await api.databaseBackupCommand({ action: "rename", id, name: name.trim() });
await refreshAfterMutation();
return true;
}
async function cancelRun(id: string) {
const accepted = await api.databaseBackupCommand<boolean>({ action: "cancel", id });
if (accepted) {
cancellingRunIds.add(id);
tracker.markDatabaseExportTaskCancelling(id);
}
return accepted;
}
async function waitForRun(run: DatabaseBackupRun): Promise<DatabaseBackupRun | null> {
await refreshAfterMutation();
while (true) {
const current = runs.value.find((r) => r.id === run.id);
if (!current || current.status !== "running") return current || null;
await new Promise((resolve) => setTimeout(resolve, 1000));
await refresh();
}
}
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();
async function runSchedule(id: string) {
if (activeScheduleIds.has(id)) return null;
return waitForRun(await api.databaseBackupCommand<DatabaseBackupRun>({ action: "run", request: { scheduleId: id } }));
}
function onConfigChanged() {
void processDueSchedules();
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup") {
return waitForRun(await api.databaseBackupCommand<DatabaseBackupRun>({ action: "run", request: { config, displayName, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone } }));
}
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);
});
}
onMounted(() => {
subscribers += 1;
if (!timer)
timer = setInterval(() => {
void refresh().catch(() => {});
}, 2000);
void refresh().catch(() => {});
});
onUnmounted(() => {
subscribers -= 1;
if (!subscribers && timer) {
clearInterval(timer);
timer = undefined;
}
});
return {
schedules,
@@ -571,15 +173,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
activeRunIds,
cancellingRunIds,
activeRuns,
heartbeat,
destinationRoot,
error,
saveSchedule,
setScheduleEnabled,
deleteSchedule,
deleteRun,
deleteRuns,
deleteRun: (id: string) => deleteRuns([id]),
renameRun,
runSchedule,
runOneShot,
cancelRun,
processDueSchedules,
processDueSchedules: refresh,
};
}
+8
View File
@@ -8358,6 +8358,14 @@ export default withEnglishFallback({
runInBackground: "Arxa planda işlət",
},
databaseBackup: {
savedTimeZoneHint: "Növbəti icra planda saxlanılan saat qurşağına əsaslanır.",
backgroundWorker: "DBX bağlandıqdan sonra ehtiyat nüsxələməni davam etdir",
backgroundLoginScope: "İstifadəçi sessiyası açıq olduqda işləyir. Kompüter oyaq qalmalıdır.",
serverRuntime: "Brauzer bağlansa belə server ehtiyat nüsxələməni davam etdirir.",
workerLastSeen: "İşçinin son cavabı: {time}",
workerStarting: "Ehtiyat nüsxələmə işçisi gözlənilir",
serverDestination: "Server ehtiyat nüsxələrinin kök qovluğu: {path}",
timeZone: "Saat qurşağı (IANA)",
title: "Verilənlər bazalarının ehtiyat nüsxələri",
schedules: "Ehtiyat nüsxələmə cədvəlləri",
runtimeRequirement: "Planlaşdırılmış ehtiyat nüsxələmə DBX açıq olduqda, o cümlədən sistem panelinə kiçildildikdə işləyir.",
+8
View File
@@ -8912,6 +8912,14 @@ export default {
runInBackground: "Run in background",
},
databaseBackup: {
savedTimeZoneHint: "The next run uses the schedule's saved time zone.",
backgroundWorker: "Background backups after closing DBX",
backgroundLoginScope: "Runs while you are signed in. The computer must remain awake.",
serverRuntime: "The server runs backups even when the browser is closed.",
workerLastSeen: "Last worker heartbeat: {time}",
workerStarting: "Waiting for backup worker",
serverDestination: "Server backup root: {path}",
timeZone: "Time zone (IANA)",
title: "Database Backups",
schedules: "Backup schedules",
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
+8
View File
@@ -9824,6 +9824,14 @@ export default withEnglishFallback({
blockIoChart: "E/S de bloques",
},
databaseBackup: {
savedTimeZoneHint: "La próxima ejecución usa la zona horaria guardada en la programación.",
backgroundWorker: "Continuar copias al cerrar DBX",
backgroundLoginScope: "Funciona durante la sesión del usuario. El equipo debe permanecer activo.",
serverRuntime: "El servidor ejecuta las copias aunque se cierre el navegador.",
workerLastSeen: "Última respuesta del proceso: {time}",
workerStarting: "Esperando al proceso de copia",
serverDestination: "Directorio raíz de copias del servidor: {path}",
timeZone: "Zona horaria (IANA)",
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.",
+8
View File
@@ -9824,6 +9824,14 @@ export default withEnglishFallback({
blockIoChart: "I/O a blocchi",
},
databaseBackup: {
savedTimeZoneHint: "La prossima esecuzione usa il fuso orario salvato nella pianificazione.",
backgroundWorker: "Continua i backup dopo la chiusura di DBX",
backgroundLoginScope: "Funziona durante la sessione utente. Il computer deve restare attivo.",
serverRuntime: "Il server esegue i backup anche quando il browser è chiuso.",
workerLastSeen: "Ultima risposta del processo: {time}",
workerStarting: "In attesa del processo di backup",
serverDestination: "Directory principale dei backup sul server: {path}",
timeZone: "Fuso orario (IANA)",
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.",
+8
View File
@@ -9874,6 +9874,14 @@ export default withEnglishFallback({
blockIoChart: "ブロック I/O",
},
databaseBackup: {
savedTimeZoneHint: "次回の実行はスケジュールに保存されたタイムゾーンで計算されます。",
backgroundWorker: "DBX 終了後もバックアップを継続",
backgroundLoginScope: "ログイン中に動作します。コンピューターをスリープさせないでください。",
serverRuntime: "ブラウザーを閉じてもサーバーでバックアップを実行します。",
workerLastSeen: "ワーカーの最終応答: {time}",
workerStarting: "バックアップワーカーを待機中",
serverDestination: "サーバーのバックアップルート: {path}",
timeZone: "タイムゾーン (IANA)",
title: "データベースバックアップ",
schedules: "バックアップスケジュール",
runtimeRequirement: "DBXが開いている状態(トレイに最小化したときも含む)で、定期バックアップが実行されます。",
+8
View File
@@ -8025,6 +8025,14 @@ export default withEnglishFallback({
runInBackground: "백그라운드에서 실행",
},
databaseBackup: {
savedTimeZoneHint: "다음 실행 시간은 일정에 저장된 시간대로 계산됩니다.",
backgroundWorker: "DBX 종료 후에도 백업 실행",
backgroundLoginScope: "로그인한 동안 실행됩니다. 컴퓨터가 절전 상태가 아니어야 합니다.",
serverRuntime: "브라우저를 닫아도 서버에서 백업을 실행합니다.",
workerLastSeen: "작업자의 마지막 응답: {time}",
workerStarting: "백업 작업자 대기 중",
serverDestination: "서버 백업 루트: {path}",
timeZone: "시간대 (IANA)",
title: "데이터베이스 백업",
schedules: "백업 일정",
runtimeRequirement: "예약된 백업은 DBX가 열려 있는 동안(트레이에 최소화된 경우 포함) 실행됩니다.",
+8
View File
@@ -9825,6 +9825,14 @@ export default withEnglishFallback({
blockIoChart: "E/S de blocos",
},
databaseBackup: {
savedTimeZoneHint: "A próxima execução usa o fuso horário salvo no agendamento.",
backgroundWorker: "Continuar backups após fechar o DBX",
backgroundLoginScope: "Funciona durante a sessão do usuário. O computador deve permanecer ativo.",
serverRuntime: "O servidor executa os backups mesmo com o navegador fechado.",
workerLastSeen: "Última resposta do processo: {time}",
workerStarting: "Aguardando o processo de backup",
serverDestination: "Diretório raiz dos backups no servidor: {path}",
timeZone: "Fuso horário (IANA)",
title: "Backup de banco de dados",
schedules: "Planos de backup",
runtimeRequirement: "O DBX executará backups agendados quando estiver aberto, inclusive quando minimizado na bandeja.",
+8
View File
@@ -8228,6 +8228,14 @@ export default withEnglishFallback({
runInBackground: "Arka planda çalıştır",
},
databaseBackup: {
savedTimeZoneHint: "Sonraki çalıştırma, planda kayıtlı saat dilimini kullanır.",
backgroundWorker: "DBX kapatıldıktan sonra yedeklemeye devam et",
backgroundLoginScope: "Oturum açıkken çalışır. Bilgisayar uyanık kalmalıdır.",
serverRuntime: "Tarayıcı kapatılsa bile sunucu yedeklemeyi sürdürür.",
workerLastSeen: "Çalışanın son yanıtı: {time}",
workerStarting: "Yedekleme çalışanı bekleniyor",
serverDestination: "Sunucu yedekleme kök dizini: {path}",
timeZone: "Saat dilimi (IANA)",
title: "Veritabanı Yedekleri",
schedules: "Yedekleme zamanlamaları",
runtimeRequirement: "Zamanlanmış yedeklemeler, DBX açıkken (tepsiye küçültülmüş olsa bile) çalışır.",
+8
View File
@@ -8881,6 +8881,14 @@ export default withEnglishFallback({
runInBackground: "后台运行",
},
databaseBackup: {
savedTimeZoneHint: "下次执行时间按计划中保存的时区计算。",
backgroundWorker: "关闭 DBX 后继续备份",
backgroundLoginScope: "后台服务在登录期间运行,计算机须保持唤醒。",
serverRuntime: "备份由服务器执行,关闭浏览器不影响运行。",
workerLastSeen: "后台最近响应:{time}",
workerStarting: "等待备份后台启动",
serverDestination: "服务器备份根目录:{path}",
timeZone: "时区(IANA)",
title: "数据库备份",
schedules: "备份计划",
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
+8
View File
@@ -9822,6 +9822,14 @@ export default withEnglishFallback({
blockIoChart: "區塊 I/O",
},
databaseBackup: {
savedTimeZoneHint: "下次執行時間依計畫中儲存的時區計算。",
backgroundWorker: "關閉 DBX 後繼續備份",
backgroundLoginScope: "背景服務在登入期間執行,電腦須保持喚醒。",
serverRuntime: "備份由伺服器執行,關閉瀏覽器不影響執行。",
workerLastSeen: "背景最近回應:{time}",
workerStarting: "等待備份背景服務啟動",
serverDestination: "伺服器備份根目錄:{path}",
timeZone: "時區(IANA)",
title: "資料庫備份",
schedules: "備份計畫",
runtimeRequirement: "DBX 處於開啟狀態時會執行定時備份,包含最小化到托盤時。",
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
databaseBackupRunDirectory,
databaseBackupTimestamp,
sanitizeDatabaseBackupFileSegment,
databaseBackupRunsToPrune,
databaseBackupTableMatchesPattern,
@@ -28,6 +30,13 @@ function run(overrides: Record<string, unknown> = {}) {
}
describe("database backup run persistence", () => {
it("renders preview names in the saved zone rather than the browser zone", () => {
const instant = "2026-09-12T18:00:00Z";
expect(databaseBackupTimestamp(instant, "Asia/Shanghai")).toBe("20260913-020000");
expect(databaseBackupTimestamp(instant, "America/New_York")).toBe("20260912-140000");
expect(databaseBackupRunDirectory("/backups", "{date}/{timestamp}", "Daily", instant, "12345678", "Asia/Shanghai")).toBe("/backups/20260913/20260913020000");
expect(databaseBackupFilePath("/backups", "Daily", "app", instant, "12345678", "gzip", "{timestamp}", "Asia/Shanghai")).toBe("/backups/20260913-020000__app.sql.gz");
});
it("uses a gzip extension when the backup output is compressed", () => {
expect(databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "gzip")).toMatch(/\.sql\.gz$/);
});
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareDatabaseBackupRestore, previewSqlFile, releaseSqlFilePreview } from "@/lib/backend/http";
afterEach(() => vi.unstubAllGlobals());
describe("Web backup restore transport", () => {
it("returns the server preview from the record-authorized restore endpoint", async () => {
const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", cleanupToken: "restore-token" };
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => preview });
vi.stubGlobal("fetch", fetchMock);
await expect(prepareDatabaseBackupRestore("run/1", 2)).resolves.toEqual(preview);
expect(fetchMock).toHaveBeenCalledWith("/api/database-backups/run%2F1/files/2/restore", expect.objectContaining({ method: "POST", body: "{}" }));
});
it("continues rejecting arbitrary server paths without making a request", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(previewSqlFile("/etc/passwd")).rejects.toThrow("requires a File object");
expect(fetchMock).not.toHaveBeenCalled();
});
it("releases only by the opaque cleanup token", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => null });
vi.stubGlobal("fetch", fetchMock);
await releaseSqlFilePreview("restore-token");
expect(fetchMock).toHaveBeenCalledWith("/api/sql-file/preview/release", expect.objectContaining({ method: "POST", body: '{"cleanupToken":"restore-token"}' }));
});
});
+5
View File
@@ -177,6 +177,10 @@ export const savedSqlStorageDir = forward("savedSqlStorageDir");
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
export const revealPathInFileManager = forward("revealPathInFileManager");
export const deleteDatabaseBackupFiles = forward("deleteDatabaseBackupFiles");
export const databaseBackupCommand = forward("databaseBackupCommand");
export const databaseBackupBackground = forward("databaseBackupBackground");
export const downloadDatabaseBackupFile = forward("downloadDatabaseBackupFile");
export const prepareDatabaseBackupRestore = forward("prepareDatabaseBackupRestore");
export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile");
export const backupSqliteDatabase = forward("backupSqliteDatabase");
export const restoreSqliteDatabase = forward("restoreSqliteDatabase");
@@ -431,6 +435,7 @@ export const listLocalSshKeys = forward("listLocalSshKeys");
// SQL File Execution
export const previewSqlFile = forward("previewSqlFile");
export const releaseSqlFilePreview = forward("releaseSqlFilePreview");
export const executeSqlFile = forward("executeSqlFile");
export const executeSqlFiles = forward("executeSqlFiles");
export const cancelSqlFileExecution = forward("cancelSqlFileExecution");
+24
View File
@@ -1,6 +1,26 @@
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import type { DatabaseBackupCommand, DatabaseBackupBackgroundStatus } from "@/lib/backup/backgroundDatabaseBackup";
export function databaseBackupCommand<T = unknown>(command: DatabaseBackupCommand): Promise<T> {
return post("/api/database-backups", command);
}
export async function databaseBackupBackground(enabled?: boolean): Promise<DatabaseBackupBackgroundStatus> {
if (enabled !== undefined) throw new Error("The server manages background backups");
return { enabled: true, platform: "server" };
}
export async function downloadDatabaseBackupFile(runId: string, index: number): Promise<void> {
const anchor = document.createElement("a");
anchor.href = apiUrl(`/api/database-backups/${encodeURIComponent(runId)}/files/${index}`);
anchor.click();
}
export function prepareDatabaseBackupRestore(runId: string, index: number): Promise<SqlFilePreview> {
return post(`/api/database-backups/${encodeURIComponent(runId)}/files/${index}/restore`, {});
}
import type {
ConnectionConfig,
ConnectionTestResult,
@@ -2528,6 +2548,10 @@ export async function previewSqlFile(fileOrPath: string | File): Promise<SqlFile
return res.json();
}
export async function releaseSqlFilePreview(cleanupToken: string): Promise<void> {
return post("/api/sql-file/preview/release", { cleanupToken });
}
export async function executeSqlFile(request: SqlFileRequest): Promise<void> {
return post("/api/sql-file/execute", { request });
}
+20
View File
@@ -2,6 +2,23 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
import type { DatabaseBackupCommand, DatabaseBackupBackgroundStatus } from "@/lib/backup/backgroundDatabaseBackup";
export function databaseBackupCommand<T = unknown>(command: DatabaseBackupCommand): Promise<T> {
return invoke("database_backup_command", { command });
}
export function databaseBackupBackground(enabled?: boolean): Promise<DatabaseBackupBackgroundStatus> {
return invoke("database_backup_background", { enabled });
}
export async function downloadDatabaseBackupFile(_runId: string, _index: number): Promise<void> {
throw new Error("Use the file manager to access desktop backup files");
}
export function prepareDatabaseBackupRestore(id: string, index: number): Promise<string | SqlFilePreview> {
return invoke("database_backup_command", { command: { action: "file", id, index } });
}
import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
// Re-exported below so the HTTP transport shares one definition; imported here
@@ -5018,8 +5035,11 @@ export interface SqlFilePreview {
establishesDatabaseContext?: boolean;
packageFilePaths?: string[];
packagePartCount?: number;
cleanupToken?: string;
}
export async function releaseSqlFilePreview(_cleanupToken: string): Promise<void> {}
export interface SqlFileProgress {
executionId: string;
status: SqlFileStatus;
@@ -0,0 +1,25 @@
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun, DatabaseBackupSchedule } from "./scheduledDatabaseBackup";
export interface DatabaseBackupSnapshot {
schedules: DatabaseBackupSchedule[];
runs: DatabaseBackupRun[];
migrated: boolean;
heartbeat: string | null;
destinationRoot: string | null;
}
export type DatabaseBackupCommand =
| { action: "snapshot" }
| { action: "preview"; schedule: DatabaseBackupSchedule }
| { action: "save"; schedule: DatabaseBackupSchedule }
| { action: "deleteSchedule"; id: string }
| { action: "migrate"; migration: { schedules: DatabaseBackupSchedule[]; runs: DatabaseBackupRun[] } }
| { action: "run"; request: { scheduleId?: string; config?: DatabaseBackupExecutionConfig; displayName?: string; timeZone?: string } }
| { action: "cancel"; id: string }
| { action: "rename"; id: string; name: string }
| { action: "deleteRuns"; ids: string[] };
export interface DatabaseBackupBackgroundStatus {
enabled: boolean;
platform: string;
}
@@ -126,6 +126,7 @@ export interface DatabaseBackupExecutionConfig {
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
timeZone?: string;
id: string;
name: string;
enabled: boolean;
@@ -251,6 +252,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
frequency,
intervalHours: boundedInteger(input.intervalHours, 6, 1, 168),
timeOfDay: normalizeDatabaseBackupTime(input.timeOfDay),
timeZone: typeof input.timeZone === "string" ? input.timeZone : undefined,
weekday: boundedInteger(input.weekday, 1, 0, 6),
includeStructure: booleanValue(input.includeStructure, true),
includeData: booleanValue(input.includeData, true),
@@ -411,15 +413,15 @@ export function normalizeDatabaseBackupRunDirectoryPattern(value: unknown): stri
return stringValue(value).trim() || DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN;
}
function renderDatabaseBackupRunDirectoryPattern(pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
const timestamp = databaseBackupTimestamp(startedAt);
function renderDatabaseBackupRunDirectoryPattern(pattern: string, scheduleName: string, startedAt: Date | string, runId: string, timeZone?: string): string {
const timestamp = databaseBackupTimestamp(startedAt, timeZone);
const compactTimestamp = timestamp.replace("-", "");
return normalizeDatabaseBackupRunDirectoryPattern(pattern).replaceAll("{schedule}", sanitizeDatabaseBackupFileSegment(scheduleName)).replaceAll("{date}", timestamp.slice(0, 8)).replaceAll("{timestamp}", compactTimestamp).replaceAll("{runId}", sanitizeDatabaseBackupFileSegment(runId).slice(0, 8));
}
export function databaseBackupRunDirectory(directory: string, pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
export function databaseBackupRunDirectory(directory: string, pattern: string, scheduleName: string, startedAt: Date | string, runId: string, timeZone?: string): string {
const normalizedPattern = normalizeDatabaseBackupRunDirectoryPattern(pattern);
const rendered = renderDatabaseBackupRunDirectoryPattern(normalizedPattern, scheduleName, startedAt, runId);
const rendered = renderDatabaseBackupRunDirectoryPattern(normalizedPattern, scheduleName, startedAt, runId, timeZone);
if (!rendered || /^[\\/]|^[a-zA-Z]:/.test(rendered) || /[\\/]$/.test(rendered)) {
throw new Error("Backup run directory template must be a relative path.");
}
@@ -455,9 +457,9 @@ export function databaseBackupFileNamePatternIsValid(pattern: string): boolean {
return raw === raw.trim() && !/[\\/:*?"<>|]/.test(raw) && !/[. ]$/.test(raw);
}
function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string): string {
function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, timeZone?: string): string {
const normalized = normalizeDatabaseBackupFileNamePattern(pattern);
const timestamp = databaseBackupTimestamp(startedAt);
const timestamp = databaseBackupTimestamp(startedAt, timeZone);
const variables = {
"{schedule}": sanitizeDatabaseBackupFileSegment(scheduleName),
"{date}": timestamp.slice(0, 8),
@@ -470,8 +472,13 @@ function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: stri
return [sanitizeDatabaseBackupFileSegment(rendered), ...requiredSuffixes].join("__");
}
export function databaseBackupTimestamp(value: Date | string): string {
export function databaseBackupTimestamp(value: Date | string, timeZone?: string): string {
const date = value instanceof Date ? value : new Date(value);
if (timeZone) {
const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).formatToParts(date);
const part = (name: string) => parts.find((item) => item.type === name)?.value || "";
return `${part("year")}${part("month")}${part("day")}-${part("hour")}${part("minute")}${part("second")}`;
}
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())}`;
}
@@ -481,8 +488,8 @@ export function joinDatabaseBackupPath(directory: string, fileName: string): str
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string): string {
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string, timeZone?: string): string {
const suffix = outputCompression === "gzip" ? ".sql.gz" : ".sql";
const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId)}${suffix}`;
const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId, timeZone)}${suffix}`;
return joinDatabaseBackupPath(directory, fileName);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia";
import type { SqlFilePreview } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { ref, computed, watch, markRaw } from "vue";
@@ -575,7 +576,7 @@ export const useConnectionStore = defineStore("connection", () => {
schema?: string;
tableName?: string;
} | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null>(null);
const diagramSource = ref<{
connectionId: string;
database: string;