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