feat(backup): support cross-platform background database backups
This commit is contained in:
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>
|
||||
|
||||
+83
-2
@@ -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();
|
||||
});
|
||||
+152
-272
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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が開いている状態(トレイに最小化したときも含む)で、定期バックアップが実行されます。",
|
||||
|
||||
@@ -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가 열려 있는 동안(트레이에 최소화된 경우 포함) 실행됩니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 处于打开状态时会执行定时备份,包括最小化到托盘时。",
|
||||
|
||||
@@ -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"}' }));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user