feat(backup): improve progress history and PostgreSQL exports

This commit is contained in:
CSXFanMeng
2026-08-02 19:35:44 +08:00
committed by GitHub
parent a299d7a294
commit b75e7094c2
17 changed files with 631 additions and 196 deletions
@@ -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<DatabaseBackupSchedule | null>(null);
const pendingDeleteRun = ref<DatabaseBackupRun | null>(null);
const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
const renameRunName = ref("");
const loadingDatabases = ref(false);
const saving = ref(false);
const databaseOptions = ref<string[]>([]);
@@ -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) {
</Button>
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ run.scheduleName }}</span>
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
</div>
@@ -348,9 +367,18 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<span>{{ t("databaseBackup.fileCount", { count: run.files.length }) }}</span>
<span v-if="run.error" class="break-all text-destructive">{{ translateBackendError(t, run.error) }}</span>
</div>
<div v-if="activeRunIds.has(run.id)" class="mt-2 flex items-center gap-2">
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-label="t('databaseBackup.progress')" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="run.progressPercent ?? 0">
<div class="h-full rounded-full bg-primary transition-[width] duration-300" :style="{ width: `${run.progressPercent ?? 0}%` }" />
</div>
<span class="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ run.progressPercent ?? 0 }}%</span>
</div>
</div>
<div class="flex items-center justify-end gap-1">
<Loader2 v-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
<Pencil class="h-4 w-4" />
</Button>
<Button v-if="run.files[0]" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.revealFile')" @click="revealBackup(run.files[0])">
<FolderOpen class="h-4 w-4" />
</Button>
@@ -542,4 +570,20 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="renameRunDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader
><DialogTitle>{{ t("databaseBackup.renameBackup") }}</DialogTitle></DialogHeader
>
<div class="space-y-2">
<Label>{{ t("databaseBackup.backupName") }}</Label>
<Input v-model="renameRunName" autofocus @keyup.enter="confirmRenameRun" />
</div>
<DialogFooter>
<Button variant="outline" @click="renameRunDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!renameRunName.trim()" @click="confirmRenameRun">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -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) {
<!-- Progress bar -->
<div v-if="isActive(task.status)" class="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div v-if="task.totalRows || (task.kind === 'database-export' && task.totalObjects) || (task.kind === 'data-transfer' && task.totalTables)" class="h-full bg-primary rounded-full transition-[width] duration-300" :style="{ width: `${progressValue(task)}%` }" />
<div
v-if="task.totalRows || (task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables)"
class="h-full bg-primary rounded-full transition-[width] duration-300"
:style="{ width: `${progressValue(task)}%` }"
/>
<div v-else class="h-full w-full overflow-hidden rounded-full">
<div class="export-progress-indeterminate h-full rounded-full bg-primary" />
</div>
@@ -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) {
@@ -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<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
const runs = ref<DatabaseBackupRun[]>(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>): DatabaseBackupRun | null {
function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = true): DatabaseBackupRun | null {
const existing = runs.value.find((run) => run.id === runId);
if (!existing) return null;
const updated = { ...existing, ...patch };
replaceRun(updated);
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<DatabaseBackupRunStatus, "running"> = "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,
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -6413,6 +6413,10 @@ export default withEnglishFallback({
scheduledTrigger: "定期実行",
manualTrigger: "手動実行",
fileCount: "{count} ファイル",
progress: "バックアップの進行状況",
renameBackup: "バックアップ名を変更",
backupName: "バックアップ名",
backupRenamed: "バックアップ名を変更しました",
revealFile: "ファイルマネージャーで表示",
restore: "復元",
deleteBackup: "バックアップを削除",
+4
View File
@@ -5157,6 +5157,10 @@ export default withEnglishFallback({
scheduledTrigger: "예약",
manualTrigger: "수동",
fileCount: "파일 {count}개",
progress: "백업 진행률",
renameBackup: "백업 이름 변경",
backupName: "백업 이름",
backupRenamed: "백업 이름이 변경되었습니다",
revealFile: "파일 관리자에서 보기",
restore: "복원",
deleteBackup: "백업 삭제",
+4
View File
@@ -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",
+4
View File
@@ -5429,6 +5429,10 @@ export default withEnglishFallback({
scheduledTrigger: "定时执行",
manualTrigger: "手动执行",
fileCount: "{count} 个文件",
progress: "备份进度",
renameBackup: "重命名备份",
backupName: "备份名称",
backupRenamed: "备份已重命名",
revealFile: "在文件管理器中显示",
restore: "恢复",
deleteBackup: "删除备份",
+4
View File
@@ -6353,6 +6353,10 @@ export default withEnglishFallback({
scheduledTrigger: "定時執行",
manualTrigger: "手動執行",
fileCount: "{count} 個檔案",
progress: "備份進度",
renameBackup: "重新命名備份",
backupName: "備份名稱",
backupRenamed: "備份已重新命名",
revealFile: "在檔案管理員中顯示",
restore: "回復",
deleteBackup: "刪除備份",
@@ -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<string, Promise<void>>();
async run<T>(connectionId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.tails.get(connectionId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((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<string, unknown>;
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 [];
+19 -3
View File
@@ -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<Result<String, String>>,
columns: Option<Result<Vec<crate::db::ColumnInfo>, String>>,
}
const EXPORT_METADATA_PREFETCH_CONCURRENCY: usize = 8;
let mut prefetched_table_metadata: Vec<Option<PrefetchedTableMetadata>> = 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")];
+35
View File
@@ -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<Vec<(String, String)>, 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<Vec<TriggerInfo>, 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()));
+101 -27
View File
@@ -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<String, Vec<String>> = HashMap::new();
for table in tables {
let fks = crate::schema::list_foreign_keys_core(state, connection_id, database, schema, table).await?;
let deps: Vec<String> = 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<deadpool_postgres::Pool> {
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<String> {
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)| 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<String> = 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();
+126
View File
@@ -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<void>((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/);
});