diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index 5a7970cab6..eef9713635 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -20,14 +20,17 @@ import { useConnectionStore } from "@/stores/connectionStore"; const { t, locale } = useI18n(); const { toast } = useToast(); const connectionStore = useConnectionStore(); -const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, runSchedule, cancelRun } = useScheduledDatabaseBackups(); +const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, cancelRun } = useScheduledDatabaseBackups(); const scheduleDialogOpen = ref(false); const deleteScheduleDialogOpen = ref(false); const deleteRunDialogOpen = ref(false); +const renameRunDialogOpen = ref(false); const editingScheduleId = ref(""); const pendingDeleteSchedule = ref(null); const pendingDeleteRun = ref(null); +const pendingRenameRun = ref(null); +const renameRunName = ref(""); const loadingDatabases = ref(false); const saving = ref(false); const databaseOptions = ref([]); @@ -240,6 +243,22 @@ function requestDeleteRun(run: DatabaseBackupRun) { deleteRunDialogOpen.value = true; } +function requestRenameRun(run: DatabaseBackupRun) { + if (activeRunIds.has(run.id)) return; + pendingRenameRun.value = run; + renameRunName.value = run.displayName || run.scheduleName; + renameRunDialogOpen.value = true; +} + +function confirmRenameRun() { + const run = pendingRenameRun.value; + if (!run || !renameRunName.value.trim()) return; + if (!renameRun(run.id, renameRunName.value)) return; + renameRunDialogOpen.value = false; + pendingRenameRun.value = null; + toast(t("databaseBackup.backupRenamed"), 2500); +} + async function confirmDeleteRun() { const run = pendingDeleteRun.value; if (!run) return; @@ -338,7 +357,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
- {{ run.scheduleName }} + {{ run.displayName || run.scheduleName }} {{ runStatusLabel(run.status) }} {{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}
@@ -348,9 +367,18 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) { {{ t("databaseBackup.fileCount", { count: run.files.length }) }} {{ translateBackendError(t, run.error) }}
+
+
+
+
+ {{ run.progressPercent ?? 0 }}% +
+ @@ -542,4 +570,20 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) { + + + + {{ t("databaseBackup.renameBackup") }} +
+ + +
+ + + + +
+
diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue index 530593e958..26b60aaf70 100644 --- a/apps/desktop/src/components/export/ExportProgressPopover.vue +++ b/apps/desktop/src/components/export/ExportProgressPopover.vue @@ -38,6 +38,7 @@ const progressPercent = (totalRows: number | null, rowsExported: number) => { const progressValue = (task: ExportTask) => { if (task.kind === "database-export") { + if (task.overallPercent !== undefined) return task.overallPercent; if (!task.totalObjects || task.totalObjects <= 0) return 0; return Math.min(100, Math.round(((task.objectIndex ?? 0) / task.totalObjects) * 100)); } @@ -64,6 +65,7 @@ const taskTitle = (task: ExportTask) => { const rowsText = (task: ExportTask) => { if (task.kind === "database-export") { + if (task.overallPercent !== undefined) return `${task.overallPercent}%`; if (task.totalObjects) { return t("exportProgress.objectsCount", { current: (task.objectIndex ?? 0).toLocaleString(), @@ -175,7 +177,11 @@ function failureDetailCount(task: ExportTask) {
-
+
diff --git a/apps/desktop/src/composables/useExportTracker.ts b/apps/desktop/src/composables/useExportTracker.ts index afb06307d8..7ca6d54591 100644 --- a/apps/desktop/src/composables/useExportTracker.ts +++ b/apps/desktop/src/composables/useExportTracker.ts @@ -23,6 +23,7 @@ export interface ExportTask { errorMessage: string | null; objectIndex?: number; totalObjects?: number; + overallPercent?: number; statementIndex?: number; successCount?: number; failureCount?: number; @@ -381,7 +382,7 @@ export function useExportTracker() { task.errorMessage = progress.errorMessage || null; } - function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress) { + function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress & { overallPercent?: number }) { const task = taskMap.get(exportId); if (!task) return; // Keep the database label during metadata prefetch; only follow object names while writing. @@ -394,6 +395,9 @@ export function useExportTracker() { task.errorMessage = progress.error || null; task.objectIndex = progress.objectIndex; task.totalObjects = progress.totalObjects; + if (progress.overallPercent !== undefined) { + task.overallPercent = Math.max(0, Math.min(100, Math.round(progress.overallPercent))); + } } function updateSqlFileTask(executionId: string, progress: api.SqlFileProgress) { diff --git a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts index 1c379e0261..36ed4e51a2 100644 --- a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts +++ b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts @@ -8,7 +8,10 @@ import { DATABASE_BACKUP_CONFIG_CHANGED_EVENT, DATABASE_BACKUP_RUNS_STORAGE_KEY, DATABASE_BACKUP_SCHEDULES_STORAGE_KEY, + DatabaseBackupConnectionQueue, + databaseBackupAggregateExportStatus, databaseBackupFilePath, + databaseBackupProgressPercent, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, databaseBackupTableNamesAreCaseSensitive, @@ -30,6 +33,7 @@ import { import { useExportTracker } from "@/composables/useExportTracker"; const SCHEDULER_INTERVAL_MS = 30_000; +const databaseBackupConnectionQueue = new DatabaseBackupConnectionQueue(); const schedules = ref(readDatabaseBackupSchedules()); const runs = ref(readDatabaseBackupRuns()); @@ -55,16 +59,16 @@ function persistRuns() { writeDatabaseBackupRuns(runs.value); } -function replaceRun(run: DatabaseBackupRun) { +function replaceRun(run: DatabaseBackupRun, persist = true) { runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)]; - persistRuns(); + if (persist) persistRuns(); } -function updateRun(runId: string, patch: Partial): DatabaseBackupRun | null { +function updateRun(runId: string, patch: Partial, persist = true): DatabaseBackupRun | null { const existing = runs.value.find((run) => run.id === runId); if (!existing) return null; const updated = { ...existing, ...patch }; - replaceRun(updated); + replaceRun(updated, persist); return updated; } @@ -125,6 +129,14 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { persistRuns(); } + function renameRun(runId: string, displayName: string): boolean { + const run = runs.value.find((item) => item.id === runId); + if (!run || activeRunIds.has(runId)) return false; + const normalizedName = displayName.trim(); + updateRun(runId, { displayName: normalizedName && normalizedName !== run.scheduleName ? normalizedName : undefined }); + return true; + } + async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) { const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount); if (staleRuns.length === 0) return; @@ -164,6 +176,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { status: "running", startedAt: startedAt.toISOString(), files: [], + progressPercent: 0, }; replaceRun(run); activeScheduleIds.add(schedule.id); @@ -175,179 +188,205 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { let finalStatus: Exclude = "success"; let finalError = ""; let finishedRun: DatabaseBackupRun | null = null; + let lastProgressPercent = 0; 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, connection.db_type); - if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule."); - - let tableNamesCaseSensitive = true; - if (connection.db_type === "mysql" && schedule.tableFilterMode !== "all") { - try { - const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 }); - tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]); - } catch (error) { - appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error); - } - } - - let exportIndex = 0; - let totalExports = 0; - let includedTableCount = 0; - for (const database of selectedDatabases) { + await databaseBackupConnectionQueue.run(schedule.connectionId, async () => { + try { if (cancellationRequested.has(runId)) { finalStatus = "cancelled"; - break; + return; } - 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.`); - const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = []; - for (const item of databasePlan) { - if (schedule.tableFilterMode === "all") { - scopedDatabasePlan.push(item); - continue; - } - const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name); - const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive); - includedTableCount += scope.includedTables.length; - if (scope.includedTables.length === 0) continue; - scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables }); - } - totalExports += scopedDatabasePlan.length; + 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, connection.db_type); + if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule."); - for (const item of scopedDatabasePlan) { - if (cancellationRequested.has(runId)) { - finalStatus = "cancelled"; - break; + let tableNamesCaseSensitive = true; + if (connection.db_type === "mysql" && schedule.tableFilterMode !== "all") { + try { + const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 }); + tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]); + } catch (error) { + appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error); + } + } + + let exportIndex = 0; + let includedTableCount = 0; + for (const [databaseIndex, database] of selectedDatabases.entries()) { + 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.`); + const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = []; + for (const item of databasePlan) { + if (schedule.tableFilterMode === "all") { + scopedDatabasePlan.push(item); + continue; + } + const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name); + const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive); + includedTableCount += scope.includedTables.length; + if (scope.includedTables.length === 0) continue; + scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables }); } - 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, + for (const [planIndex, item] of scopedDatabasePlan.entries()) { + if (cancellationRequested.has(runId)) { + finalStatus = "cancelled"; + break; + } + exportIndex += 1; + const childExportId = `${runId}-${exportIndex}`; + activeExportIds.set(runId, childExportId); + const filePath = databaseBackupFilePath(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, + selectedTables: item.selectedTables, + excludedTables: item.excludedTables, + includeStructure: schedule.includeStructure, + includeData: schedule.includeData, + includeObjects: schedule.includeObjects, + dropTableIfExists: schedule.dropTableIfExists, + failOnError: true, + snapshotSessionId: snapshot.sessionId, + batchSize: 1000, + }, + (progress) => { + const progressPercent = databaseBackupProgressPercent({ + completedDatabases: databaseIndex, + totalDatabases: selectedDatabases.length, + completedExports: planIndex, + totalExports: scopedDatabasePlan.length, + currentObjectIndex: progress.objectIndex, + currentTotalObjects: progress.totalObjects, + currentExportComplete: progress.status === "Done", + }); + if (progressPercent !== lastProgressPercent) { + lastProgressPercent = progressPercent; + updateRun(runId, { progressPercent }, false); + } + updateDatabaseExportTask(runId, { + ...progress, + exportId: runId, + currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`, + status: databaseBackupAggregateExportStatus(progress.status, false), + overallPercent: progressPercent, + }); + }, + ); + 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, - selectedTables: item.selectedTables, - excludedTables: item.excludedTables, - 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; + }; + run.files.push(file); + updateRun(runId, { files: [...run.files] }); } - - 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; + }); } - snapshotCompleted = finalStatus === "success"; - } finally { - await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => { - if (snapshotCompleted) throw error; + if (finalStatus !== "success") break; + lastProgressPercent = databaseBackupProgressPercent({ + completedDatabases: databaseIndex + 1, + totalDatabases: selectedDatabases.length, + completedExports: 0, + totalExports: 0, }); + updateRun(runId, { progressPercent: lastProgressPercent }, false); } - if (finalStatus !== "success") break; - } - if (schedule.tableFilterMode !== "all" && includedTableCount === 0) { - throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`); - } - } 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; + if (schedule.tableFilterMode !== "all" && includedTableCount === 0) { + throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`); } + } 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], + progressPercent: finalStatus === "success" ? 100 : lastProgressPercent, + 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: databaseBackupAggregateExportStatus(finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error", true), + error: finalError || null, + overallPercent: finalStatus === "success" ? 100 : lastProgressPercent, + }); + + 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, + }); } - 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; } @@ -410,6 +449,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { setScheduleEnabled, deleteSchedule, deleteRun, + renameRun, runSchedule, cancelRun, processDueSchedules, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index b87a1ccb59..ca699eb064 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5430,6 +5430,10 @@ export default { scheduledTrigger: "Scheduled", manualTrigger: "Manual", fileCount: "{count} files", + progress: "Backup progress", + renameBackup: "Rename backup", + backupName: "Backup name", + backupRenamed: "Backup renamed", revealFile: "Show in file manager", restore: "Restore", deleteBackup: "Delete backup", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 29f6a5b6a5..ac3f36aa88 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6358,6 +6358,10 @@ export default withEnglishFallback({ scheduledTrigger: "Ejecución programada", manualTrigger: "Ejecución manual", fileCount: "{count} archivos", + progress: "Progreso de la copia de seguridad", + renameBackup: "Renombrar copia de seguridad", + backupName: "Nombre de la copia de seguridad", + backupRenamed: "Copia de seguridad renombrada", revealFile: "Mostrar en el administrador de archivos", restore: "Restaurar", deleteBackup: "Eliminar copia de seguridad", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 16d22f2a37..4299c3a5fa 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6358,6 +6358,10 @@ export default withEnglishFallback({ scheduledTrigger: "Esecuzione programmata", manualTrigger: "Esecuzione manuale", fileCount: "{count} file", + progress: "Avanzamento backup", + renameBackup: "Rinomina backup", + backupName: "Nome backup", + backupRenamed: "Backup rinominato", revealFile: "Mostra nel file manager", restore: "Ripristina", deleteBackup: "Elimina backup", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 6b823f8696..c93e511088 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6413,6 +6413,10 @@ export default withEnglishFallback({ scheduledTrigger: "定期実行", manualTrigger: "手動実行", fileCount: "{count} ファイル", + progress: "バックアップの進行状況", + renameBackup: "バックアップ名を変更", + backupName: "バックアップ名", + backupRenamed: "バックアップ名を変更しました", revealFile: "ファイルマネージャーで表示", restore: "復元", deleteBackup: "バックアップを削除", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e89cc65ebd..5b04dc86c6 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5157,6 +5157,10 @@ export default withEnglishFallback({ scheduledTrigger: "예약", manualTrigger: "수동", fileCount: "파일 {count}개", + progress: "백업 진행률", + renameBackup: "백업 이름 변경", + backupName: "백업 이름", + backupRenamed: "백업 이름이 변경되었습니다", revealFile: "파일 관리자에서 보기", restore: "복원", deleteBackup: "백업 삭제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e8ba177ea9..a55aafd176 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6360,6 +6360,10 @@ export default withEnglishFallback({ scheduledTrigger: "Execução agendada", manualTrigger: "Execução manual", fileCount: "{count} arquivos", + progress: "Progresso do backup", + renameBackup: "Renomear backup", + backupName: "Nome do backup", + backupRenamed: "Backup renomeado", revealFile: "Mostrar no gerenciador de arquivos", restore: "Restaurar", deleteBackup: "Excluir backup", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index de872dfbc4..0d894cfbd6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5429,6 +5429,10 @@ export default withEnglishFallback({ scheduledTrigger: "定时执行", manualTrigger: "手动执行", fileCount: "{count} 个文件", + progress: "备份进度", + renameBackup: "重命名备份", + backupName: "备份名称", + backupRenamed: "备份已重命名", revealFile: "在文件管理器中显示", restore: "恢复", deleteBackup: "删除备份", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a890e1f48f..3f67bbfd44 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6353,6 +6353,10 @@ export default withEnglishFallback({ scheduledTrigger: "定時執行", manualTrigger: "手動執行", fileCount: "{count} 個檔案", + progress: "備份進度", + renameBackup: "重新命名備份", + backupName: "備份名稱", + backupRenamed: "備份已重新命名", revealFile: "在檔案管理員中顯示", restore: "回復", deleteBackup: "刪除備份", diff --git a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts index 875ae71fab..3ea40ecab2 100644 --- a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts +++ b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts @@ -8,12 +8,39 @@ export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config- export const MAX_DATABASE_BACKUP_HISTORY = 200; export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly"; +export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled"; export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled"; export type DatabaseBackupRunTrigger = "manual" | "scheduled"; export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude"; const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]); +export class DatabaseBackupConnectionQueue { + private readonly tails = new Map>(); + + async run(connectionId: string, operation: () => Promise): Promise { + const previous = this.tails.get(connectionId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => gate); + this.tails.set(connectionId, tail); + + await previous; + try { + return await operation(); + } finally { + release(); + if (this.tails.get(connectionId) === tail) this.tails.delete(connectionId); + } + } +} + +export function databaseBackupAggregateExportStatus(status: DatabaseBackupExportStatus, isFinal: boolean): DatabaseBackupExportStatus { + return isFinal ? status : "Running"; +} + export function supportsScheduledDatabaseBackup(databaseType: string | undefined): boolean { return !!databaseType && CONSISTENT_BACKUP_DATABASE_TYPES.has(databaseType); } @@ -115,6 +142,7 @@ export interface DatabaseBackupRun { id: string; scheduleId: string; scheduleName: string; + displayName?: string; connectionId: string; connectionName: string; trigger: DatabaseBackupRunTrigger; @@ -122,9 +150,21 @@ export interface DatabaseBackupRun { startedAt: string; completedAt?: string; files: DatabaseBackupFile[]; + progressPercent?: number; error?: string; } +export interface DatabaseBackupProgressInput { + completedDatabases: number; + totalDatabases: number; + completedExports: number; + totalExports: number; + currentObjectIndex?: number; + currentTotalObjects?: number; + currentExportComplete?: boolean; + backupComplete?: boolean; +} + type UnknownRecord = Record; function recordValue(value: unknown): UnknownRecord { @@ -246,6 +286,7 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | id, scheduleId, scheduleName: stringValue(input.scheduleName).trim() || "Database backup", + displayName: stringValue(input.displayName).trim() || undefined, connectionId: stringValue(input.connectionId).trim(), connectionName: stringValue(input.connectionName).trim(), trigger: input.trigger === "scheduled" ? "scheduled" : "manual", @@ -253,10 +294,27 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | 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) : [], + progressPercent: input.progressPercent === undefined ? undefined : boundedInteger(input.progressPercent, 0, 0, 100), error: stringValue(input.error).trim() || undefined, }; } +export function databaseBackupProgressPercent(input: DatabaseBackupProgressInput): number { + if (input.backupComplete) return 100; + + const totalDatabases = Math.max(1, Math.round(input.totalDatabases)); + const completedDatabases = Math.max(0, Math.min(totalDatabases, Math.round(input.completedDatabases))); + const totalExports = Math.max(0, Math.round(input.totalExports)); + const completedExports = Math.max(0, Math.min(totalExports, Math.round(input.completedExports))); + const totalObjects = Math.max(0, Math.round(input.currentTotalObjects ?? 0)); + const objectIndex = Math.max(0, Math.min(totalObjects, Math.round(input.currentObjectIndex ?? 0))); + const currentExportFraction = input.currentExportComplete ? 1 : totalObjects > 0 ? objectIndex / totalObjects : 0; + const currentDatabaseFraction = totalExports > 0 ? Math.min(1, (completedExports + currentExportFraction) / totalExports) : 0; + const overallFraction = Math.min(1, (completedDatabases + currentDatabaseFraction) / totalDatabases); + + return Math.min(99, Math.max(0, Math.round(overallFraction * 100))); +} + function parseStoredArray(key: string): unknown[] { const stored = safeLocalStorageGet(key); if (!stored) return []; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 1e3cee8a6e..23a4866255 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -1272,6 +1272,17 @@ fn concurrent_metadata_prefetch_allowed(pool_kind: Option<&crate::connection::Po ) } +fn database_export_metadata_prefetch_concurrency(db_type: DatabaseType) -> usize { + // PostgreSQL exports can hold a snapshot connection while metadata and UI + // requests share the base pool. Keep enough capacity available for normal + // browsing instead of filling nearly the entire ten-connection pool. + if db_type == DatabaseType::Postgres { + 4 + } else { + 8 + } +} + fn record_export_error(file: &mut std::fs::File, fail_on_error: bool, message: String) -> Result<(), String> { if fail_on_error { Err(message) @@ -1598,7 +1609,6 @@ pub async fn export_database_sql_core( ddl: Option>, columns: Option, String>>, } - const EXPORT_METADATA_PREFETCH_CONCURRENCY: usize = 8; let mut prefetched_table_metadata: Vec> = Vec::new(); prefetched_table_metadata.resize_with(tables.len(), || None); // 防护门按「实际连接池种类」放行,而非数据库类型的能力标记:只有确认底层 @@ -1662,7 +1672,7 @@ pub async fn export_database_sql_core( }; (index, PrefetchedTableMetadata { ddl, columns }) })) - .buffer_unordered(EXPORT_METADATA_PREFETCH_CONCURRENCY); + .buffer_unordered(database_export_metadata_prefetch_concurrency(db_type)); while let Some((index, metadata)) = prefetch_stream.next().await { prefetched_table_metadata[index] = Some(metadata); if let Some(table_info) = tables.get(index) { @@ -2228,7 +2238,6 @@ fn build_database_export_object_source_sql( #[cfg(test)] mod tests { - use super::concurrent_metadata_prefetch_allowed; use super::{ build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements, database_export_total_objects, drop_table_if_exists_sql, filter_export_table_infos, format_export_sql_literal, @@ -2240,6 +2249,7 @@ mod tests { ExportedTableSql, PostgresExportExtension, PostgresExportSequence, PostgresExtensionMembers, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, }; + use super::{concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency}; use crate::models::connection::DatabaseType; use crate::types::{ObjectInfo, ObjectSourceKind, TableInfo}; use serde_json::{json, Value}; @@ -2388,6 +2398,12 @@ mod tests { assert!(!concurrent_metadata_prefetch_allowed(None)); } + #[test] + fn postgres_metadata_prefetch_reserves_pool_capacity() { + assert_eq!(database_export_metadata_prefetch_concurrency(DatabaseType::Postgres), 4); + assert_eq!(database_export_metadata_prefetch_concurrency(DatabaseType::Mysql), 8); + } + #[test] fn filters_export_tables_by_selected_names() { let tables = vec![table("users", "TABLE"), table("orders", "TABLE"), table("active_users", "VIEW")]; diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 2add896ece..494fd169dd 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -3979,6 +3979,30 @@ pub async fn list_foreign_keys(pool: &Pool, schema: &str, table: &str) -> Result .collect()) } +fn postgres_table_dependencies_sql() -> &'static str { + "SELECT DISTINCT child.relname AS table_name, parent.relname AS ref_table \ + FROM pg_catalog.pg_constraint con \ + JOIN pg_catalog.pg_class child ON child.oid = con.conrelid \ + JOIN pg_catalog.pg_namespace child_schema ON child_schema.oid = child.relnamespace \ + JOIN pg_catalog.pg_class parent ON parent.oid = con.confrelid \ + JOIN pg_catalog.pg_namespace parent_schema ON parent_schema.oid = parent.relnamespace \ + WHERE con.contype = 'f' \ + AND child_schema.nspname = $1 \ + AND parent_schema.nspname = $1 \ + ORDER BY child.relname, parent.relname" +} + +/// Fetch all same-schema table dependencies in one round trip. Whole-database +/// exports use this instead of issuing one information_schema query per table. +pub async fn list_table_dependencies(pool: &Pool, schema: &str) -> Result, String> { + let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; + let rows = postgres_query_cached(&client, postgres_table_dependencies_sql(), &[&schema]) + .await + .map_err(|e| e.to_string())?; + + Ok(rows.iter().map(|row| (pg_row_try_string(row, 0), pg_row_try_string(row, 1))).collect()) +} + pub async fn list_triggers(pool: &Pool, schema: &str, table: &str) -> Result, String> { let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?; let rows = postgres_query_cached( @@ -5253,6 +5277,17 @@ mod tests { assert!(sql.contains("information_schema.referential_constraints rc")); } + #[test] + fn postgres_table_dependencies_sql_batches_schema_foreign_keys() { + let sql = postgres_table_dependencies_sql(); + + assert!(sql.contains("pg_catalog.pg_constraint")); + assert!(sql.contains("con.contype = 'f'")); + assert!(sql.contains("child_schema.nspname = $1")); + assert!(sql.contains("parent_schema.nspname = $1")); + assert!(!sql.contains("information_schema")); + } + #[test] fn postgres_foreign_key_action_keeps_non_empty_action() { assert_eq!(postgres_foreign_key_action("CASCADE".to_string()), Some("CASCADE".to_string())); diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index dde1f31dfd..56e8057da5 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -5384,19 +5384,49 @@ pub async fn sort_tables_by_fk_dependency( return Ok(tables.to_vec()); } - let table_set: HashSet<&str> = tables.iter().map(|t| t.as_str()).collect(); + 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}"))?; + let postgres_pool = if db_type == DatabaseType::Postgres { + let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + { + let connections = state.connections.read().await; + native_postgres_dependency_pool(connections.get(&pool_key)) + } + } else { + None + }; + let dependencies = if let Some(pool) = postgres_pool { + db::postgres::list_table_dependencies(&pool, schema).await? + } else { + let mut dependencies = Vec::new(); + for table in tables { + let fks = crate::schema::list_foreign_keys_core(state, connection_id, database, schema, table).await?; + dependencies.extend(fks.into_iter().map(|fk| (table.clone(), fk.ref_table))); + } + dependencies + }; - // Gather FK relationships for every table. - let mut dependency_map: HashMap> = HashMap::new(); - for table in tables { - let fks = crate::schema::list_foreign_keys_core(state, connection_id, database, schema, table).await?; - let deps: Vec = fks - .iter() - .map(|fk| fk.ref_table.clone()) - .filter(|ref_table| table_set.contains(ref_table.as_str())) - .collect(); - dependency_map.insert(table.clone(), deps); + Ok(sort_table_names_by_dependencies(tables, &dependencies, parents_first)) +} + +fn native_postgres_dependency_pool(pool_kind: Option<&PoolKind>) -> Option { + match pool_kind { + Some(PoolKind::Postgres(pool)) => Some(pool.clone()), + _ => None, } +} + +fn sort_table_names_by_dependencies( + tables: &[String], + dependencies: &[(String, String)], + parents_first: bool, +) -> Vec { + let table_set: HashSet<&str> = tables.iter().map(|table| table.as_str()).collect(); // Build in-degree and dependents graph. // parents_first=true: edge ref_table → table (parent before child) @@ -5407,25 +5437,31 @@ pub async fn sort_tables_by_fk_dependency( for table in tables { in_degree.entry(table.as_str()).or_insert(0); } - for table in tables { - if let Some(deps) = dependency_map.get(table) { - for ref_table in deps { - if parents_first { - // FK-bearing table depends on ref_table — parent comes first. - *in_degree.entry(table.as_str()).or_insert(0) += 1; - dependents.entry(ref_table.as_str()).or_default().push(table.as_str()); - } else { - // ref_table depends on FK-bearing table — child comes first. - *in_degree.entry(ref_table.as_str()).or_insert(0) += 1; - dependents.entry(table.as_str()).or_default().push(ref_table.as_str()); - } - } + let mut seen_dependencies = HashSet::new(); + for (table, ref_table) in dependencies { + if !table_set.contains(table.as_str()) || !table_set.contains(ref_table.as_str()) { + continue; + } + if !seen_dependencies.insert((table.as_str(), ref_table.as_str())) { + continue; + } + if parents_first { + // FK-bearing table depends on ref_table — parent comes first. + *in_degree.entry(table.as_str()).or_insert(0) += 1; + dependents.entry(ref_table.as_str()).or_default().push(table.as_str()); + } else { + // ref_table depends on FK-bearing table — child comes first. + *in_degree.entry(ref_table.as_str()).or_insert(0) += 1; + dependents.entry(table.as_str()).or_default().push(ref_table.as_str()); } } // Kahn's algorithm. - let mut queue: std::collections::VecDeque<&str> = - in_degree.iter().filter(|(_, °)| deg == 0).map(|(&table, _)| table).collect(); + let mut queue: std::collections::VecDeque<&str> = tables + .iter() + .map(String::as_str) + .filter(|table| in_degree.get(table).copied().unwrap_or_default() == 0) + .collect(); let mut sorted: Vec = Vec::new(); while let Some(table) = queue.pop_front() { @@ -5453,7 +5489,7 @@ pub async fn sort_tables_by_fk_dependency( sorted.extend(remaining); } - Ok(sorted) + sorted } #[allow(clippy::too_many_arguments)] @@ -6684,6 +6720,44 @@ mod tests { } } + #[test] + fn table_dependency_sort_places_parents_before_children() { + let tables = vec!["audit".to_string(), "users".to_string(), "orders".to_string()]; + let dependencies = + vec![("audit".to_string(), "orders".to_string()), ("orders".to_string(), "users".to_string())]; + + assert_eq!( + sort_table_names_by_dependencies(&tables, &dependencies, true), + vec!["users".to_string(), "orders".to_string(), "audit".to_string()] + ); + assert_eq!( + sort_table_names_by_dependencies(&tables, &dependencies, false), + vec!["audit".to_string(), "orders".to_string(), "users".to_string()] + ); + } + + #[test] + fn table_dependency_sort_ignores_duplicates_and_out_of_scope_tables() { + let tables = vec!["orders".to_string(), "users".to_string(), "logs".to_string()]; + let dependencies = vec![ + ("orders".to_string(), "users".to_string()), + ("orders".to_string(), "users".to_string()), + ("logs".to_string(), "external_users".to_string()), + ]; + + assert_eq!( + sort_table_names_by_dependencies(&tables, &dependencies, true), + vec!["users".to_string(), "logs".to_string(), "orders".to_string()] + ); + } + + #[test] + fn postgres_dependency_batch_query_keeps_agent_fallback() { + let agent = PoolKind::agent(crate::db::agent_driver::AgentDriverClient::test_stub()); + + assert!(native_postgres_dependency_pool(Some(&agent)).is_none()); + } + async fn test_app_state() -> (AppState, std::path::PathBuf) { let dir = std::env::temp_dir().join(format!("dbx-transfer-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); diff --git a/packages/app-tests/scheduledDatabaseBackup.test.ts b/packages/app-tests/scheduledDatabaseBackup.test.ts index 9ae9d09970..667c5e2348 100644 --- a/packages/app-tests/scheduledDatabaseBackup.test.ts +++ b/packages/app-tests/scheduledDatabaseBackup.test.ts @@ -2,11 +2,15 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "vitest"; import { + DatabaseBackupConnectionQueue, + databaseBackupAggregateExportStatus, databaseBackupFilePath, + databaseBackupProgressPercent, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, databaseBackupTableNamesAreCaseSensitive, nextDatabaseBackupRunAt, + normalizeDatabaseBackupRun, normalizeDatabaseBackupSchedule, normalizeDatabaseBackupTablePatterns, resolveScheduledDatabaseBackupTableScope, @@ -57,6 +61,14 @@ function run(id: string, startedAt: string): DatabaseBackupRun { }; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + 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); @@ -130,6 +142,109 @@ test("retention pruning keeps the newest successful runs", () => { ); }); +test("backup history normalization preserves an independent display name and progress", () => { + const normalized = normalizeDatabaseBackupRun({ + ...run("named", "2026-07-16T03:00:00.000Z"), + displayName: "Before migration", + progressPercent: 125, + }); + + assert.equal(normalized?.displayName, "Before migration"); + assert.equal(normalized?.scheduleName, "Nightly backup"); + assert.equal(normalized?.progressPercent, 100); +}); + +test("backup progress combines databases, schema exports, and current objects", () => { + assert.equal( + databaseBackupProgressPercent({ + completedDatabases: 0, + totalDatabases: 2, + completedExports: 1, + totalExports: 4, + currentObjectIndex: 5, + currentTotalObjects: 10, + }), + 19, + ); + assert.equal( + databaseBackupProgressPercent({ + completedDatabases: 1, + totalDatabases: 2, + completedExports: 0, + totalExports: 1, + currentExportComplete: true, + }), + 99, + ); + assert.equal( + databaseBackupProgressPercent({ + completedDatabases: 2, + totalDatabases: 2, + completedExports: 0, + totalExports: 0, + backupComplete: true, + }), + 100, + ); +}); + +test("backup connection queue serializes two runs on the same connection", async () => { + const queue = new DatabaseBackupConnectionQueue(); + const firstStarted = deferred(); + const releaseFirst = deferred(); + const events: string[] = []; + + const first = queue.run("connection-1", async () => { + events.push("first:start"); + firstStarted.resolve(); + await releaseFirst.promise; + events.push("first:end"); + }); + await firstStarted.promise; + const second = queue.run("connection-1", async () => { + events.push("second:start"); + }); + + await Promise.resolve(); + assert.deepEqual(events, ["first:start"]); + releaseFirst.resolve(); + await Promise.all([first, second]); + assert.deepEqual(events, ["first:start", "first:end", "second:start"]); +}); + +test("backup connection queue allows different connections to run in parallel", async () => { + const queue = new DatabaseBackupConnectionQueue(); + const firstStarted = deferred(); + const secondStarted = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + const events: string[] = []; + + const first = queue.run("connection-1", async () => { + events.push("connection-1:start"); + firstStarted.resolve(); + await releaseFirst.promise; + }); + const second = queue.run("connection-2", async () => { + events.push("connection-2:start"); + secondStarted.resolve(); + await releaseSecond.promise; + }); + + await Promise.all([firstStarted.promise, secondStarted.promise]); + assert.deepEqual([...events].sort(), ["connection-1:start", "connection-2:start"]); + releaseFirst.resolve(); + releaseSecond.resolve(); + await Promise.all([first, second]); +}); + +test("multi-schema child completion stays running until the aggregate finishes", () => { + const childStatuses = ["Running", "Done", "Running", "Done"] as const; + const aggregateStatuses = [...childStatuses.map((status) => databaseBackupAggregateExportStatus(status, false)), databaseBackupAggregateExportStatus("Done", true)]; + + assert.deepEqual(aggregateStatuses, ["Running", "Running", "Running", "Running", "Done"]); +}); + test("all-database backups use the complete database list", () => { assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["visible", "hidden"]), ["visible", "hidden"]); }); @@ -211,3 +326,14 @@ test("scheduled backup history translates stable backend errors inline", () => { assert.match(source, /\{\{ translateBackendError\(t, run\.error\) \}\}/); assert.doesNotMatch(source, /\{\{ run\.error \}\}/); }); + +test("scheduled backup history exposes rename and overall percentage controls", () => { + const source = readFileSync("apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue", "utf8"); + const scheduler = readFileSync("apps/desktop/src/composables/useScheduledDatabaseBackups.ts", "utf8"); + + assert.match(source, /run\.displayName \|\| run\.scheduleName/); + assert.match(source, /role="progressbar"/); + assert.match(scheduler, /databaseBackupConnectionQueue\.run\(schedule\.connectionId/); + assert.match(scheduler, /status: databaseBackupAggregateExportStatus\(progress\.status, false\)/); + assert.match(scheduler, /overallPercent: progressPercent/); +});