mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(backup): improve progress history and PostgreSQL exports
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6413,6 +6413,10 @@ export default withEnglishFallback({
|
||||
scheduledTrigger: "定期実行",
|
||||
manualTrigger: "手動実行",
|
||||
fileCount: "{count} ファイル",
|
||||
progress: "バックアップの進行状況",
|
||||
renameBackup: "バックアップ名を変更",
|
||||
backupName: "バックアップ名",
|
||||
backupRenamed: "バックアップ名を変更しました",
|
||||
revealFile: "ファイルマネージャーで表示",
|
||||
restore: "復元",
|
||||
deleteBackup: "バックアップを削除",
|
||||
|
||||
@@ -5157,6 +5157,10 @@ export default withEnglishFallback({
|
||||
scheduledTrigger: "예약",
|
||||
manualTrigger: "수동",
|
||||
fileCount: "파일 {count}개",
|
||||
progress: "백업 진행률",
|
||||
renameBackup: "백업 이름 변경",
|
||||
backupName: "백업 이름",
|
||||
backupRenamed: "백업 이름이 변경되었습니다",
|
||||
revealFile: "파일 관리자에서 보기",
|
||||
restore: "복원",
|
||||
deleteBackup: "백업 삭제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5429,6 +5429,10 @@ export default withEnglishFallback({
|
||||
scheduledTrigger: "定时执行",
|
||||
manualTrigger: "手动执行",
|
||||
fileCount: "{count} 个文件",
|
||||
progress: "备份进度",
|
||||
renameBackup: "重命名备份",
|
||||
backupName: "备份名称",
|
||||
backupRenamed: "备份已重命名",
|
||||
revealFile: "在文件管理器中显示",
|
||||
restore: "恢复",
|
||||
deleteBackup: "删除备份",
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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
@@ -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 == 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
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user