feat(compare): keep schema and data compares running in background

This commit is contained in:
jearhe
2026-09-07 14:04:39 +08:00
committed by GitHub
parent 29c6d1fbed
commit dc7b905220
15 changed files with 1643 additions and 535 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -13,60 +13,27 @@ import { useToast } from "@/composables/useToast";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase } from "@/lib/dataGrid/dataCompare";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import type { DataCompareCellValue, DataCompareSyncPlan } from "@/lib/dataGrid/dataCompare";
import { inferCompareKeyColumns } from "@/lib/dataGrid/dataCompare";
import {
buildDataCompareSyncPlanTables,
emptyDataCompareSyncPlan,
getDataCompareSession,
startDataCompareSession,
type CompareColumn,
type DataCompareSession,
type DataCompareTableResult,
type DataCompareTableStatus,
type DataCompareTableTask,
type DiffKind,
type SelectableDataCompareModifiedRow,
type SelectableDataCompareRow,
} from "@/composables/useDataCompareSession";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
type CompareColumn = ColumnInfo;
interface DataCompareTableTask {
sourceTable: string;
targetTable: string;
}
type DataCompareTableStatus = "different" | "same" | "error";
type DiffKind = "added" | "removed" | "modified";
interface SelectableDataCompareRow extends DataCompareRow {
selected: boolean;
}
interface SelectableDataCompareModifiedRow extends DataCompareModifiedRow {
selected: boolean;
}
interface SelectableDataCompareResult {
added: SelectableDataCompareRow[];
removed: SelectableDataCompareRow[];
modified: SelectableDataCompareModifiedRow[];
}
interface DataCompareTableResult {
sourceTable: string;
targetTable: string;
keyColumns: string[];
columns: string[];
columnInfo: CompareColumn[];
status: DataCompareTableStatus;
added: number;
removed: number;
modified: number;
sourceRowCount: number;
targetRowCount: number;
sourceTruncated: boolean;
targetTruncated: boolean;
databaseType?: DatabaseType;
preSyncStatements?: string[];
diff: SelectableDataCompareResult;
expanded: boolean;
showAll: Record<DiffKind, boolean>;
error?: string;
}
const PREVIEW_LIMIT_OPTIONS = [50, 100, 200, 500];
const SYNC_EXECUTE_BATCH_SIZE = 500;
@@ -80,6 +47,7 @@ const props = defineProps<{
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
sessionId?: string | null;
}>();
const sourceConnectionId = ref("");
@@ -102,7 +70,7 @@ const targetTables = ref<string[]>([]);
const keyColumnsText = ref("");
const detailPreviewLimit = ref(String(PREVIEW_LIMIT_OPTIONS[1]));
const batchResults = ref<DataCompareTableResult[]>([]);
const syncPlan = ref<DataCompareSyncPlan>(emptySyncPlan());
const syncPlan = ref<DataCompareSyncPlan>(emptyDataCompareSyncPlan());
const comparing = ref(false);
const planningSync = ref(false);
const compareProgressCurrent = ref(0);
@@ -116,8 +84,12 @@ const showAdded = ref(true);
const showRemoved = ref(true);
const showModified = ref(true);
const activeSessionId = ref<string | null>(props.sessionId ?? null);
let syncPlanRequestId = 0;
let initializingPrefill = false;
let initializingPrefillGeneration = 0;
let componentUnmounted = false;
let shownSessionError = "";
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
@@ -196,21 +168,6 @@ const compareProgressLabel = computed(() => {
});
});
function emptySyncPlan(): DataCompareSyncPlan {
return {
insertCount: 0,
updateCount: 0,
deleteCount: 0,
statementCount: 0,
syncStatements: [],
syncSql: "",
};
}
function targetDatabaseType(): DatabaseType | undefined {
return store.getConfig(targetConnectionId.value)?.db_type;
}
function resetSelectedSourceTables(nextTables: Iterable<string>) {
selectedSourceTables.value = new Set(nextTables);
}
@@ -229,16 +186,77 @@ function buildCompareTasks(): DataCompareTableTask[] {
function clearResult() {
batchResults.value = [];
syncPlan.value = emptySyncPlan();
syncPlan.value = emptyDataCompareSyncPlan();
syncErrors.value = [];
compareProgressCurrent.value = 0;
compareProgressTotal.value = 0;
compareProgressTable.value = "";
executedCount.value = 0;
executeTotal.value = 0;
planningSync.value = false;
syncPlanRequestId++;
}
function comparisonEndpointLabel(connectionId: string, database: string, schema: string): string {
const connection = store.getConfig(connectionId);
return [connection?.name || connectionId, database, schema].filter(Boolean).join(" / ");
}
async function restoreDataCompareSession(session: DataCompareSession): Promise<void> {
const config = session.config;
const generation = ++initializingPrefillGeneration;
initializingPrefill = true;
try {
sourceConnectionId.value = config.sourceConnectionId;
sourceDatabase.value = config.sourceDatabase;
sourceSchema.value = config.sourceSchema;
sourceDatabases.value = [...config.sourceDatabases];
sourceSchemas.value = [...config.sourceSchemas];
sourceTables.value = [...config.sourceTables];
resetSelectedSourceTables(config.selectedSourceTables);
sourceTable.value = config.selectedSourceTables.length === 1 ? (config.selectedSourceTables[0] ?? "") : "";
targetConnectionId.value = config.targetConnectionId;
targetDatabase.value = config.targetDatabase;
targetSchema.value = config.targetSchema;
targetDatabases.value = [...config.targetDatabases];
targetSchemas.value = [...config.targetSchemas];
targetTables.value = [...config.targetTables];
targetTable.value = config.targetTable;
keyColumnsText.value = config.keyColumns.join(", ");
batchResults.value = session.batchResults;
syncPlan.value = session.syncPlan;
syncErrors.value = [];
compareProgressCurrent.value = session.progress?.current ?? 0;
compareProgressTotal.value = session.progress?.total ?? 0;
compareProgressTable.value = session.progress?.table ?? "";
comparing.value = session.status === "running";
} finally {
await nextTick();
if (generation === initializingPrefillGeneration) initializingPrefill = false;
}
}
function applyDataCompareSession(session: DataCompareSession | undefined): void {
if (!session) return;
batchResults.value = session.batchResults;
syncPlan.value = session.syncPlan;
if (session.status === "running") {
comparing.value = true;
compareProgressCurrent.value = session.progress?.current ?? 0;
compareProgressTotal.value = session.progress?.total ?? 0;
compareProgressTable.value = session.progress?.table ?? "";
return;
}
comparing.value = false;
compareProgressCurrent.value = 0;
compareProgressTotal.value = 0;
compareProgressTable.value = "";
if (session.status === "failed" && session.error && shownSessionError !== session.error) {
shownSessionError = session.error;
toast(session.error, 5000);
}
}
function swapSourceTarget() {
const previousSelectedTables = [...selectedSourceTableNames.value];
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
@@ -393,32 +411,6 @@ function resultStatusClass(status: DataCompareTableStatus): string {
return "bg-destructive/15 text-destructive";
}
function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
return {
added: diff.added.map((row) => ({ ...row, selected: true })),
removed: diff.removed.map((row) => ({ ...row, selected: true })),
modified: diff.modified.map((row) => ({ ...row, selected: true })),
};
}
function buildSelectedDiff(table: DataCompareTableResult): DataCompareResult {
return {
added: table.diff.added.filter((row) => row.selected).map(stripSelectedRow),
removed: table.diff.removed.filter((row) => row.selected).map(stripSelectedRow),
modified: table.diff.modified.filter((row) => row.selected).map(stripSelectedModifiedRow),
};
}
function stripSelectedRow(row: SelectableDataCompareRow): DataCompareRow {
const { selected: _selected, ...rest } = row;
return rest;
}
function stripSelectedModifiedRow(row: SelectableDataCompareModifiedRow): DataCompareModifiedRow {
const { selected: _selected, ...rest } = row;
return rest;
}
function hasDiffRows(table: DataCompareTableResult, kind: DiffKind): boolean {
return table.diff[kind].length > 0;
}
@@ -480,27 +472,26 @@ function selectedDiffCount(kind: DiffKind) {
return batchResults.value.reduce((sum, table) => sum + selectedRows(table, kind), 0);
}
function buildSyncPlanTables(): DataCompareSyncPlanTableOptions[] {
return batchResults.value
.filter((table) => table.status === "different")
.map((table) => ({
tableName: table.targetTable,
schema: targetSchema.value,
columns: table.columns,
keyColumns: table.keyColumns,
columnInfo: table.columnInfo,
diff: buildSelectedDiff(table),
databaseType: table.databaseType,
preSyncStatements: table.preSyncStatements ?? [],
}))
.filter((table) => table.preSyncStatements.length > 0 || table.diff.added.length > 0 || table.diff.removed.length > 0 || table.diff.modified.length > 0);
function buildSyncPlanTables() {
return buildDataCompareSyncPlanTables(batchResults.value, targetSchema.value);
}
function updateSyncPlan(nextPlan: DataCompareSyncPlan, sessionId = activeSessionId.value): void {
if (!componentUnmounted) syncPlan.value = nextPlan;
const session = getDataCompareSession(sessionId);
if (session?.status === "completed") {
session.syncPlan = nextPlan;
session.batchResults = batchResults.value;
}
}
async function rebuildSyncPlan() {
if (componentUnmounted) return;
const requestId = ++syncPlanRequestId;
const sessionId = activeSessionId.value;
const tables = buildSyncPlanTables();
if (tables.length === 0) {
syncPlan.value = emptySyncPlan();
updateSyncPlan(emptyDataCompareSyncPlan(), sessionId);
planningSync.value = false;
return;
}
@@ -508,17 +499,17 @@ async function rebuildSyncPlan() {
try {
const plan = await api.buildDataCompareSyncPlan({ tables });
if (requestId !== syncPlanRequestId) return;
syncPlan.value = plan;
updateSyncPlan(plan, sessionId);
} catch (e: any) {
if (requestId !== syncPlanRequestId) return;
syncPlan.value = emptySyncPlan();
toast(e?.message || String(e), 5000);
updateSyncPlan(emptyDataCompareSyncPlan(), sessionId);
if (!componentUnmounted) toast(e?.message || String(e), 5000);
} finally {
if (requestId === syncPlanRequestId) planningSync.value = false;
if (!componentUnmounted && requestId === syncPlanRequestId) planningSync.value = false;
}
}
async function startCompare() {
function startCompare(): void {
if (!canCompare.value || comparing.value) return;
const tasks = buildCompareTasks();
if (tasks.length === 0) {
@@ -526,174 +517,39 @@ async function startCompare() {
return;
}
comparing.value = true;
clearResult();
compareProgressTotal.value = tasks.length;
const sourceColumnCache = new Map<string, CompareColumn[]>();
const targetColumnCache = new Map<string, CompareColumn[]>();
const results: DataCompareTableResult[] = [];
const currentTargetDatabaseType = targetDatabaseType();
try {
await Promise.all([store.ensureConnected(sourceConnectionId.value), store.ensureConnected(targetConnectionId.value)]);
for (const [index, task] of tasks.entries()) {
compareProgressCurrent.value = index + 1;
compareProgressTable.value = task.sourceTable;
try {
if (!targetTables.value.includes(task.targetTable)) {
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : [];
const sourceColumnNames = sourceColumns.map((column) => column.name);
const sourceKeyColumns = resolvedKeys.map((key) => matchColumnNameIgnoreCase(key, sourceColumnNames) ?? key);
const preparation = await api.prepareDataCompareMissingTarget({
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
sourceTable: task.sourceTable,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetTable: task.targetTable,
keyColumns: sourceKeyColumns,
});
results.push({
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns: sourceKeyColumns,
columns: sourceColumns.map((column) => column.name),
columnInfo: sourceColumns,
status: "different",
added: preparation.result.added.length,
removed: 0,
modified: 0,
sourceRowCount: preparation.sourceRowCount,
targetRowCount: 0,
sourceTruncated: preparation.sourceTruncated,
targetTruncated: false,
databaseType: currentTargetDatabaseType,
preSyncStatements: preparation.preSyncStatements,
diff: toSelectableDiff(preparation.result),
expanded: preparation.result.added.length > 0,
showAll: {
added: false,
removed: false,
modified: false,
},
});
continue;
}
const resolvedKeys = keyColumns.value.length > 0 ? keyColumns.value : await inferKeyColumnsForTable(task.sourceTable, sourceColumnCache);
if (resolvedKeys.length === 0) {
throw new Error(t("dataCompare.noKeyColumns"));
}
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, task.sourceTable);
const targetColumns = await loadColumnsWithCache(targetColumnCache, targetConnectionId.value, targetDatabase.value, targetSchema.value, task.targetTable);
const matched = intersectCompareColumns(sourceColumns, targetColumns);
const columns = matched.columns;
const columnInfo = columns.map((column) => targetColumns.find((target) => target.name === column)).filter((column): column is CompareColumn => !!column);
const canonicalKeyColumns: string[] = [];
const missingKeys: string[] = [];
for (const key of resolvedKeys) {
const canonical = matchColumnNameIgnoreCase(key, columns);
if (!canonical) {
missingKeys.push(key);
} else if (!canonicalKeyColumns.includes(canonical)) {
canonicalKeyColumns.push(canonical);
}
}
if (missingKeys.length > 0) {
throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") }));
}
if (columns.length === 0) {
throw new Error(t("dataCompare.noCommonColumns"));
}
const preparation = await api.prepareDataCompareFromTables({
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
sourceTable: task.sourceTable,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetTable: task.targetTable,
columns,
keyColumns: canonicalKeyColumns,
sourceColumns: matched.sourceColumns,
});
const added = preparation.result.added.length;
const removed = preparation.result.removed.length;
const modified = preparation.result.modified.length;
const status: DataCompareTableStatus = added || removed || modified ? "different" : "same";
results.push({
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns: canonicalKeyColumns,
columns,
columnInfo,
status,
added,
removed,
modified,
sourceRowCount: preparation.sourceRowCount,
targetRowCount: preparation.targetRowCount,
sourceTruncated: preparation.sourceTruncated,
targetTruncated: preparation.targetTruncated,
databaseType: currentTargetDatabaseType,
diff: toSelectableDiff(preparation.result),
expanded: status === "different",
showAll: {
added: false,
removed: false,
modified: false,
},
});
} catch (e: any) {
results.push({
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns: keyColumns.value,
columns: [],
columnInfo: [],
status: "error",
added: 0,
removed: 0,
modified: 0,
sourceRowCount: 0,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
databaseType: currentTargetDatabaseType,
preSyncStatements: [],
diff: { added: [], removed: [], modified: [] },
expanded: false,
showAll: {
added: false,
removed: false,
modified: false,
},
error: e?.message || String(e),
});
}
}
batchResults.value = results;
await rebuildSyncPlan();
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
comparing.value = false;
compareProgressCurrent.value = 0;
compareProgressTotal.value = 0;
compareProgressTable.value = "";
}
shownSessionError = "";
const session = startDataCompareSession(
{
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
sourceDatabases: [...sourceDatabases.value],
sourceSchemas: [...sourceSchemas.value],
sourceTables: [...sourceTables.value],
selectedSourceTables: [...selectedSourceTables.value],
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetDatabases: [...targetDatabases.value],
targetSchemas: [...targetSchemas.value],
targetTables: [...targetTables.value],
targetTable: targetTable.value,
keyColumns: [...keyColumns.value],
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
},
tasks,
{
ensureConnected: (connectionId) => store.ensureConnected(connectionId),
getConfig: (connectionId) => store.getConfig(connectionId),
formatError: (kind, columns) => {
if (kind === "missingKeyColumns") return t("dataCompare.missingKeyColumns", { columns: columns ?? "" });
return kind === "noCommonColumns" ? t("dataCompare.noCommonColumns") : t("dataCompare.noKeyColumns");
},
},
);
activeSessionId.value = session.id;
applyDataCompareSession(session);
}
async function copySql() {
@@ -798,6 +654,7 @@ watch(sourceConnectionId, (id) => {
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
});
watch(targetConnectionId, (id) => {
if (initializingPrefill) return;
clearResult();
targetDatabase.value = "";
targetSchema.value = "";
@@ -817,6 +674,7 @@ watch(sourceDatabase, () => {
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(targetDatabase, () => {
if (initializingPrefill) return;
clearResult();
targetSchema.value = "";
targetSchemas.value = [];
@@ -833,12 +691,14 @@ watch(sourceSchema, () => {
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
});
watch(targetSchema, () => {
if (initializingPrefill) return;
clearResult();
targetTables.value = [];
targetTable.value = "";
if (targetSchema.value) loadTables("target").catch((e) => toast(String(e), 5000));
});
watch(selectedSourceTableNames, (tables, previous) => {
if (initializingPrefill) return;
clearResult();
sourceTable.value = tables.length === 1 ? tables[0] : "";
if (tables.length !== 1) {
@@ -856,13 +716,27 @@ watch(selectedSourceTableNames, (tables, previous) => {
}
inferKeyColumns().catch(() => {});
});
watch(targetTable, () => clearResult());
watch(targetTable, () => {
if (initializingPrefill) return;
clearResult();
});
watch(
open,
async (value) => {
[() => open.value, () => props.sessionId],
async ([value, sessionId]) => {
if (!value) return;
clearResult();
shownSessionError = "";
const session = getDataCompareSession(sessionId);
if (session) {
activeSessionId.value = session.id;
await restoreDataCompareSession(session);
applyDataCompareSession(session);
return;
}
activeSessionId.value = null;
if (props.prefillConnectionId) {
const generation = ++initializingPrefillGeneration;
initializingPrefill = true;
try {
sourceConnectionId.value = props.prefillConnectionId;
@@ -877,12 +751,26 @@ watch(
}
}
} finally {
initializingPrefill = false;
await nextTick();
if (generation === initializingPrefillGeneration) initializingPrefill = false;
}
}
},
{ immediate: true },
);
watch(
() => {
const session = getDataCompareSession(activeSessionId.value);
return session ? { id: session.id, version: session.version } : null;
},
() => {
if (!componentUnmounted) applyDataCompareSession(getDataCompareSession(activeSessionId.value));
},
{ immediate: true },
);
onBeforeUnmount(() => {
componentUnmounted = true;
});
</script>
<template>
@@ -901,6 +789,7 @@ watch(
<Label class="text-xs font-medium">{{ t("diff.source") }}</Label>
<ConnectionTreeSelect
v-model="sourceConnectionId"
:disabled="comparing"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
@@ -915,7 +804,7 @@ watch(
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="!sourceDatabases.length"
:disabled="comparing || !sourceDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -924,6 +813,7 @@ watch(
v-if="sourceSchemas.length"
v-model="sourceSchema"
:options="sourceSchemas"
:disabled="comparing"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
@@ -938,11 +828,12 @@ watch(
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
:disabled="comparing"
/>
</div>
<div class="flex items-center pt-6">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" @click="swapSourceTarget">
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing" @click="swapSourceTarget">
<ArrowLeftRight class="w-3.5 h-3.5" />
</Button>
</div>
@@ -951,6 +842,7 @@ watch(
<Label class="text-xs font-medium">{{ t("diff.target") }}</Label>
<ConnectionTreeSelect
v-model="targetConnectionId"
:disabled="comparing"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('diff.selectConnection')"
@@ -965,7 +857,7 @@ watch(
:placeholder="t('diff.selectDatabase')"
:search-placeholder="t('diff.searchDatabase')"
:empty-text="t('common.noResults')"
:disabled="!targetDatabases.length"
:disabled="comparing || !targetDatabases.length"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -974,6 +866,7 @@ watch(
v-if="targetSchemas.length"
v-model="targetSchema"
:options="targetSchemas"
:disabled="comparing"
:placeholder="t('diff.selectSchema')"
:search-placeholder="t('diff.searchSchema')"
:empty-text="t('common.noResults')"
@@ -990,6 +883,7 @@ watch(
:placeholder="t('dataCompare.selectTable')"
:search-placeholder="t('dataCompare.searchTable')"
:empty-text="t('common.noResults')"
:disabled="comparing"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
@@ -1018,12 +912,17 @@ watch(
<div class="space-y-1">
<Label class="text-xs font-medium">{{ t("dataCompare.keyColumns") }}</Label>
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" />
<Input v-model="keyColumnsText" class="h-8 text-xs" :placeholder="t('dataCompare.keyColumnsPlaceholder')" :disabled="comparing" />
<div class="text-[11px] text-muted-foreground">
{{ t("dataCompare.keyColumnsAutoHint") }}
</div>
</div>
<div v-if="comparing" class="flex items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin text-primary" />
<span>{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
</div>
<div v-if="hasResults" class="space-y-3">
<div class="rounded-lg border p-3 text-sm space-y-2">
<div>{{ summary }}</div>
@@ -1211,7 +1110,8 @@ watch(
<DialogFooter v-else class="flex items-center gap-2">
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
<span v-if="executing" class="text-xs text-muted-foreground mr-auto">
<span v-if="comparing" class="text-xs text-muted-foreground mr-auto">{{ compareProgressLabel || t("diff.progress.comparing") }}</span>
<span v-else-if="executing" class="text-xs text-muted-foreground mr-auto">
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
</span>
<span v-else-if="planningSync" class="text-xs text-muted-foreground mr-auto">
@@ -18,18 +18,19 @@ import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad";
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
import { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { createSchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff";
import { getSchemaDiffSession, schemaDiffSessionObjects, startSchemaDiffSession, type SchemaDiffSession } from "@/composables/useSchemaDiffSession";
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
import {
convertToSchemaDiffObjects,
detectDestructiveSchemaDiffStatements,
groupDiffObjects,
injectColumnRenameSql,
schemaDiffDeployTargetSchema,
normalizeDialectKind,
databaseTypeToDialectKind,
findSchemaDiffObject,
flattenSchemaDiffObjects,
schemaDiffSelectionTargets,
@@ -39,8 +40,6 @@ import {
setSchemaDiffObjectSelected,
setSchemaDiffObjectSelectedWithDependencies,
summarizeSchemaDiffOperations,
databaseTypeToDialectKind,
normalizeDialectKind,
type OperationGroup,
type SchemaDiffObject,
type DiffOperationType,
@@ -53,7 +52,7 @@ import {
type DependencyGraph,
normalizeSchemaDiffDependencyGraph,
} from "@/lib/schema/schemaDiff";
import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
import { swapSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -71,6 +70,7 @@ const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
sessionId?: string | null;
}>();
// Wizard state
@@ -117,7 +117,10 @@ interface SchemaDiffProgress {
const loading = ref(false);
const schemaDiffProgress = ref<SchemaDiffProgress | null>(null);
const schemaDiffHasExtraObjectPhase = ref(false);
let comparisonRequestId = 0;
const activeSessionId = ref<string | null>(props.sessionId ?? null);
let appliedSessionResult: SchemaDiffPreparation | null = null;
let shownSessionError = "";
let componentUnmounted = false;
const diffObjects = ref<SchemaDiffObject[]>([]);
const diffGroups = ref<OperationGroup[]>([]);
const selectedObjectId = ref<string | null>(null);
@@ -221,6 +224,7 @@ let resizeObserver: ResizeObserver | null = null;
let saveTimeout: number | null = null;
function setupResizeObserver() {
if (componentUnmounted) return;
const el = document.querySelector('[data-slot="dialog-content"]') as HTMLElement;
if (!el) return;
@@ -254,7 +258,9 @@ watch(
() => step.value,
(newStep) => {
if (newStep === "result") {
setTimeout(setupResizeObserver, 100);
setTimeout(() => {
if (!componentUnmounted) setupResizeObserver();
}, 100);
} else {
teardownResizeObserver();
}
@@ -262,6 +268,7 @@ watch(
);
onBeforeUnmount(() => {
componentUnmounted = true;
teardownResizeObserver();
});
@@ -325,7 +332,6 @@ const schemaDiffNextProgressLabel = computed(() => {
});
function resetComparisonResultState() {
comparisonRequestId++;
selectedDeploySqlGeneration++;
focusedDeploySqlGeneration++;
loading.value = false;
@@ -354,32 +360,108 @@ function resetComparisonResultState() {
deployResult.value = null;
}
// Watch for prefilled values
function comparisonEndpointLabel(connectionId: string, database: string, schema: string): string {
const connection = store.getConfig(connectionId);
return [connection?.name || connectionId, database, schema].filter(Boolean).join(" / ");
}
function restoreSchemaDiffSession(session: SchemaDiffSession): void {
const config = session.config;
sourceConnectionId.value = config.sourceConnectionId;
sourceDatabase.value = config.sourceDatabase;
sourceSchema.value = config.sourceSchema;
targetConnectionId.value = config.targetConnectionId;
targetDatabase.value = config.targetDatabase;
targetSchema.value = config.targetSchema;
ignoreComments.value = config.ignoreComments;
ensureDefaultConfig(config.targetDbType);
updateActiveConfigConnection({
sourceConnectionId: config.sourceConnectionId,
sourceDatabase: config.sourceDatabase,
sourceSchema: config.sourceSchema,
targetConnectionId: config.targetConnectionId,
targetDatabase: config.targetDatabase,
targetSchema: config.targetSchema,
});
updateActiveConfigOptions(config.options);
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(config.targetDbType, config.options);
}
function applySchemaDiffSession(session: SchemaDiffSession | undefined): void {
if (!session) return;
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(session.config.targetDbType, session.config.options);
if (session.status === "running") {
loading.value = true;
step.value = "compare";
schemaDiffProgress.value = session.progress ?? { phase: "loading-table-lists" };
return;
}
loading.value = false;
schemaDiffProgress.value = null;
if (session.status === "failed") {
step.value = "config";
if (session.error && shownSessionError !== session.error) {
shownSessionError = session.error;
toast(session.error, 5000);
}
return;
}
if (!session.result) return;
step.value = "result";
if (appliedSessionResult === session.result) return;
appliedSessionResult = session.result;
rollbackSql.value = session.result.rollbackSyncSql ?? "";
rollbackCompleteness.value = session.result.rollbackCompleteness ?? "complete";
missingRollbackObjects.value = session.result.missingRollbackObjects ?? [];
renameCandidates.value = session.result.renameCandidates ?? [];
compatibilityWarnings.value = session.result.compatibilityWarnings ?? [];
permissionDiffs.value = session.result.permissionDiffs ?? [];
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(session.result.dependencyGraph);
diffObjects.value = schemaDiffSessionObjects(session.result);
diffGroups.value = groupDiffObjects(diffObjects.value);
lastDiffResult.value = session.result;
deploySqlMode.value = "forward";
void regenerateSelectedDeploySql();
void regenerateFocusedDeploySql();
}
// A Compare session deliberately outlives this v-if-mounted dialog. Closing the
// dialog only removes the view; the session runner keeps updating its task.
watch(
() => open.value,
(isOpen) => {
if (isOpen) {
resetComparisonResultState();
ensureDefaultConfig();
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
}
if (props.prefillSchema) {
sourceSchema.value = props.prefillSchema;
}
}
} else {
comparisonRequestId++;
loading.value = false;
schemaDiffProgress.value = null;
schemaDiffHasExtraObjectPhase.value = false;
[() => open.value, () => props.sessionId],
([isOpen]) => {
if (!isOpen) return;
const session = getSchemaDiffSession(props.sessionId);
activeSessionId.value = session?.id ?? null;
appliedSessionResult = null;
shownSessionError = "";
resetComparisonResultState();
if (session) {
restoreSchemaDiffSession(session);
applySchemaDiffSession(session);
return;
}
ensureDefaultConfig();
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
if (props.prefillSchema) sourceSchema.value = props.prefillSchema;
}
},
{ immediate: true },
);
watch(
() => {
const session = getSchemaDiffSession(activeSessionId.value);
return [session?.status, session?.progress, session?.result, session?.error] as const;
},
() => applySchemaDiffSession(getSchemaDiffSession(activeSessionId.value)),
);
// Config sync
watch([sourceConnectionId, sourceDatabase, sourceSchema, targetConnectionId, targetDatabase, targetSchema], ([srcConn, srcDb, srcSchema, tgtConn, tgtDb, tgtSchema]) => {
updateActiveConfigConnection({
@@ -462,195 +544,35 @@ function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
}
}
async function handleCompare() {
const requestId = ++comparisonRequestId;
const isCurrentRequest = () => requestId === comparisonRequestId;
function handleCompare(): void {
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
const targetDbType = (targetConfig?.db_type || "mysql") as DatabaseType;
const sourceDbType = sourceConfig?.db_type || targetDbType;
const options = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, targetDbType);
loading.value = true;
step.value = "compare";
schemaDiffProgress.value = { phase: "loading-table-lists" };
schemaDiffHasExtraObjectPhase.value = false;
// Reset Phase 4 state to prevent stale data from previous compares
rollbackSql.value = "";
rollbackCompleteness.value = "complete";
missingRollbackObjects.value = [];
renameCandidates.value = [];
compatibilityWarnings.value = [];
permissionDiffs.value = [];
dependencyGraph.value = null;
try {
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
const dbType = targetConfig?.db_type || "mysql";
const sourceDbType = sourceConfig?.db_type || dbType;
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
const isPostgresLike = isSchemaDiffPostgresLike(dbType);
const hasExtraObjectPhase = shouldLoadSchemaDiffExtraObjects(dbType, opts);
schemaDiffHasExtraObjectPhase.value = hasExtraObjectPhase;
const tableFilter = compileSchemaDiffTableFilter(opts);
const sourceTableIdentity: SchemaDiffTableIdentity = { connectionId: sourceConnectionId.value, database: sourceDatabase.value, schema: sourceSchema.value };
const targetTableIdentity: SchemaDiffTableIdentity = { connectionId: targetConnectionId.value, database: targetDatabase.value, schema: targetSchema.value };
// The dialog can remain open while either database changes, so Compare must not reuse
// the table list cached while configuring the comparison.
const [srcTables, tgtTables] = await Promise.all([schemaDiffTableListLoader.load(sourceTableIdentity, { refresh: true }), schemaDiffTableListLoader.load(targetTableIdentity, { refresh: true })]);
if (!isCurrentRequest()) return;
// Explicit (visual) table selection is applied here, BEFORE any per-table
// metadata details are loaded, so metadata requests only happen for the
// final table set. `undefined`/empty means no restriction (legacy path).
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts, opts.selectedTables);
schemaDiffProgress.value = { phase: "loading-source-details", current: 0, total: sourceTables.length };
const sourceDetails = await loadSchemaDetails(
sourceTables,
{
connectionId: sourceConnectionId.value,
database: sourceDatabase.value,
schema: sourceSchema.value,
dbType: sourceDbType,
options: opts,
onProgress: (progress) => {
if (isCurrentRequest()) schemaDiffProgress.value = { phase: "loading-source-details", ...progress };
},
},
api,
);
if (!isCurrentRequest()) return;
schemaDiffProgress.value = { phase: "loading-target-details", current: 0, total: targetTables.length };
const targetDetails = await loadSchemaDetails(
targetTables,
{
connectionId: targetConnectionId.value,
database: targetDatabase.value,
schema: targetSchema.value,
dbType,
options: opts,
onProgress: (progress) => {
if (isCurrentRequest()) schemaDiffProgress.value = { phase: "loading-target-details", ...progress };
},
},
api,
);
if (!isCurrentRequest()) return;
// Fetch new object types for PostgreSQL-like databases
const promises: Promise<any>[] = [];
if (isPostgresLike && opts?.functions) {
promises.push(api.listFunctions(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
promises.push(api.listFunctions(targetConnectionId.value, targetDatabase.value, targetSchema.value));
}
if (isPostgresLike && opts?.sequences) {
promises.push(api.listSequences(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, !!opts?.sequenceLastValues));
promises.push(api.listSequences(targetConnectionId.value, targetDatabase.value, targetSchema.value, !!opts?.sequenceLastValues));
}
if (isPostgresLike && opts?.rules) {
promises.push(api.listRules(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
promises.push(api.listRules(targetConnectionId.value, targetDatabase.value, targetSchema.value));
}
if (isPostgresLike && opts?.owners) {
promises.push(api.listOwners(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value));
promises.push(api.listOwners(targetConnectionId.value, targetDatabase.value, targetSchema.value));
}
if (hasExtraObjectPhase) schemaDiffProgress.value = { phase: "loading-extra-objects" };
const results = await Promise.all(promises);
if (!isCurrentRequest()) return;
let idx = 0;
const srcFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
const tgtFunctions = opts?.functions && isPostgresLike ? results[idx++] : [];
const srcSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
const tgtSequences = opts?.sequences && isPostgresLike ? results[idx++] : [];
const srcRules = opts?.rules && isPostgresLike ? results[idx++] : [];
const tgtRules = opts?.rules && isPostgresLike ? results[idx++] : [];
const srcOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
const tgtOwners = opts?.owners && isPostgresLike ? results[idx++] : [];
schemaDiffProgress.value = { phase: "comparing" };
const result = await api.prepareSchemaDiff({
sourceTables,
targetTables,
sourceDetails,
targetDetails,
sourceFunctions: srcFunctions,
targetFunctions: tgtFunctions,
sourceSequences: srcSequences,
targetSequences: tgtSequences,
sourceRules: srcRules,
targetRules: tgtRules,
sourceOwners: srcOwners,
targetOwners: tgtOwners,
// Mappings only apply to an explicit table selection; the all-tables path
// (selectedTables === undefined) must stay mapping-free so the Rust side
// cannot reclassify added/removed tables from stale persisted mappings.
tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings,
databaseType: dbType,
targetSchema: schemaDiffDeployTargetSchema(dbType, targetDatabase.value, targetSchema.value),
appliedSessionResult = null;
shownSessionError = "";
resetComparisonResultState();
schemaDiffHasExtraObjectPhase.value = shouldLoadSchemaDiffExtraObjects(targetDbType, options);
const session = startSchemaDiffSession(
{
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
sourceDbType,
targetDbType,
options,
ignoreComments: ignoreComments.value,
cascadeDelete: opts?.cascadeDelete ?? false,
compareColumnOrder: opts.compareColumnOrder,
ignoreTableNameCase: opts.ignoreTableNameCase,
ignoreColumnNameCase: opts.ignoreColumnNameCase,
detectRenames: opts?.detectRenames ?? false,
detectTableRenames: opts?.detectTableRenames ?? false,
renameThreshold: opts?.renameThreshold ?? 0.5,
enableRollback: opts?.enableRollback ?? false,
batchPatterns: opts?.batchPatterns
? opts.batchPatterns
.split(",")
.map((s: string) => s.trim())
.filter(Boolean)
: undefined,
sourceDialect: opts?.sourceDialect ? normalizeDialectKind(opts.sourceDialect) : sourceConfig?.db_type ? databaseTypeToDialectKind(sourceConfig.db_type) : undefined,
targetDialect: opts?.targetDialect ? normalizeDialectKind(opts.targetDialect) : targetConfig?.db_type ? databaseTypeToDialectKind(targetConfig.db_type) : undefined,
compatibilityThreshold: opts?.compatibilityThreshold ?? 0.5,
fieldMappings:
opts?.fieldMappings?.map((m: FieldMappingEntry) => ({
sourceType: m.sourceType,
targetType: m.targetType,
paramStrategy: m.paramStrategy ?? "preserve",
customParams: m.customParams,
})) || [],
});
if (!isCurrentRequest()) return;
// Extract new result fields
rollbackSql.value = result.rollbackSyncSql ?? "";
rollbackCompleteness.value = result.rollbackCompleteness ?? "complete";
missingRollbackObjects.value = result.missingRollbackObjects ?? [];
renameCandidates.value = result.renameCandidates ?? [];
compatibilityWarnings.value = result.compatibilityWarnings ?? [];
permissionDiffs.value = result.permissionDiffs ?? [];
dependencyGraph.value = normalizeSchemaDiffDependencyGraph(result.dependencyGraph);
// Convert to unified objects
diffObjects.value = convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates);
// Group by operation type and object kind
diffGroups.value = groupDiffObjects(diffObjects.value);
// Save the structured result; both script previews are generated from the
// current selection projection below rather than from the initial full SQL.
lastDiffResult.value = result;
deploySqlMode.value = "forward";
schemaDiffProgress.value = { phase: "generating" };
await regenerateSelectedDeploySql();
await regenerateFocusedDeploySql();
if (!isCurrentRequest()) return;
schemaDiffProgress.value = null;
step.value = "result";
} catch (e: any) {
if (!isCurrentRequest()) return;
schemaDiffProgress.value = null;
schemaDiffHasExtraObjectPhase.value = false;
toast(e?.message || String(e), 5000);
step.value = "config";
} finally {
if (isCurrentRequest()) loading.value = false;
}
label: `${comparisonEndpointLabel(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value)} → ${comparisonEndpointLabel(targetConnectionId.value, targetDatabase.value, targetSchema.value)}`,
},
{ tableListLoader: schemaDiffTableListLoader },
);
activeSessionId.value = session.id;
applySchemaDiffSession(session);
}
function handleToggleGroup(operationType: DiffOperationType) {
@@ -738,10 +660,10 @@ async function regenerateSelectedDeploySql() {
try {
plan = await api.generateSchemaSyncPlan(input, buildSchemaSyncPlanOptions(options));
} catch (error: any) {
if (generation === selectedDeploySqlGeneration) toast(error?.message || String(error), 5000);
if (!componentUnmounted && generation === selectedDeploySqlGeneration) toast(error?.message || String(error), 5000);
return;
}
if (generation !== selectedDeploySqlGeneration) return;
if (componentUnmounted || generation !== selectedDeploySqlGeneration) return;
const formatted = formatSchemaSyncPlan(plan, input, options);
rollbackCompleteness.value = plan.rollbackCompleteness ?? "complete";
@@ -767,10 +689,10 @@ async function regenerateFocusedDeploySql(objectId: string | null = selectedObje
try {
plan = await api.generateSchemaSyncPlan(input, buildSchemaSyncPlanOptions(options));
} catch (error: any) {
if (generation === focusedDeploySqlGeneration && selectedObjectId.value === objectId) toast(error?.message || String(error), 5000);
if (!componentUnmounted && generation === focusedDeploySqlGeneration && selectedObjectId.value === objectId) toast(error?.message || String(error), 5000);
return;
}
if (generation !== focusedDeploySqlGeneration || selectedObjectId.value !== objectId) return;
if (componentUnmounted || generation !== focusedDeploySqlGeneration || selectedObjectId.value !== objectId) return;
const formatted = formatSchemaSyncPlan(plan, input, options);
focusedForwardDeploySql.value = formatted.forwardSql;
@@ -4,6 +4,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
import type { DataCompareSession } from "@/composables/useDataCompareSession";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
@@ -11,8 +12,21 @@ const mocks = vi.hoisted(() => ({
listSchemas: vi.fn().mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]),
listTables: vi.fn().mockResolvedValue([{ name: "CODEX_7467_META", table_type: "TABLE" }]),
getColumns: vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_primary_key: true }]),
buildDataCompareSyncPlan: vi.fn(),
}));
const sessionMocks = vi.hoisted(() => ({
session: null as DataCompareSession | null,
}));
vi.mock("@/composables/useDataCompareSession", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataCompareSession")>();
return {
...actual,
getDataCompareSession: (id: string | null | undefined) => (id && sessionMocks.session?.id === id ? sessionMocks.session : undefined),
};
});
vi.mock("@/stores/connectionStore", () => {
const connections = [
{ id: "oracle-11g", name: "Oracle XE 11g", db_type: "oracle", driver_profile: "oracle", database: "XE" },
@@ -43,6 +57,7 @@ vi.mock("@/lib/backend/api", () => ({
listSchemas: mocks.listSchemas,
listTables: mocks.listTables,
getColumns: mocks.getColumns,
buildDataCompareSyncPlan: mocks.buildDataCompareSyncPlan,
}));
const mountedApps: App[] = [];
@@ -57,9 +72,91 @@ async function flushAsyncSetup() {
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.textContent = "";
sessionMocks.session = null;
vi.clearAllMocks();
});
function completedSession(): DataCompareSession {
return {
id: "compare-session",
version: 1,
status: "completed",
config: {
sourceConnectionId: "oracle-11g",
sourceDatabase: "DBX_TEST",
sourceSchema: "DBX_TEST",
sourceDatabases: ["DBX_TEST"],
sourceSchemas: ["DBX_TEST"],
sourceTables: ["ORDERS"],
selectedSourceTables: ["ORDERS"],
targetConnectionId: "oracle-jdbc-11g",
targetDatabase: "REPORTING",
targetSchema: "DBX_TEST",
targetDatabases: ["REPORTING"],
targetSchemas: ["DBX_TEST"],
targetTables: ["ORDERS"],
targetTable: "ORDERS",
keyColumns: ["ID"],
label: "Oracle source → Oracle target",
},
progress: null,
batchResults: [
{
sourceTable: "ORDERS",
targetTable: "ORDERS",
keyColumns: ["ID"],
columns: ["ID", "NAME"],
columnInfo: [],
status: "different",
added: 2,
removed: 0,
modified: 0,
sourceRowCount: 2,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
databaseType: "oracle",
diff: {
added: [
{ key: "1", keyValues: { ID: 1 }, values: { ID: 1, NAME: "first" }, selected: true },
{ key: "2", keyValues: { ID: 2 }, values: { ID: 2, NAME: "second" }, selected: true },
],
removed: [],
modified: [],
},
expanded: true,
showAll: { added: true, removed: true, modified: true },
},
],
syncPlan: {
insertCount: 2,
updateCount: 0,
deleteCount: 0,
statementCount: 2,
syncStatements: ["INSERT 1", "INSERT 2"],
syncSql: "INSERT 1;\nINSERT 2;",
},
error: null,
startedAt: 1,
finishedAt: 2,
};
}
function mountSessionDialog(session: DataCompareSession): App {
sessionMocks.session = session;
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () => h(DataCompareDialog, { open: true, sessionId: session.id }),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
return app;
}
describe("DataCompareDialog source prefill", () => {
it("keeps the Oracle source table after loading database and schema prefills", async () => {
const container = document.createElement("div");
@@ -148,3 +245,47 @@ describe("DataCompareDialog source prefill", () => {
expect(document.body.textContent).toContain("CODEX_7467_META");
});
});
describe("DataCompareDialog session restore", () => {
it("keeps restored fields, results, and sync SQL after queued watchers flush", async () => {
const session = completedSession();
mountSessionDialog(session);
await flushAsyncSetup();
expect(mocks.listSchemas).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("ORDERS");
expect(document.body.textContent).toContain("ID=1");
expect(document.querySelector<HTMLTextAreaElement>("textarea[readonly]")?.value).toBe("INSERT 1;\nINSERT 2;");
});
it("persists the latest selection plan when the dialog closes during planning", async () => {
const session = completedSession();
let resolvePlan!: (plan: DataCompareSession["syncPlan"]) => void;
mocks.buildDataCompareSyncPlan.mockReturnValueOnce(new Promise((resolve) => (resolvePlan = resolve)));
const app = mountSessionDialog(session);
await flushAsyncSetup();
const firstRow = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("ID=1"));
expect(firstRow).toBeDefined();
firstRow?.click();
await nextTick();
expect(mocks.buildDataCompareSyncPlan).toHaveBeenCalledOnce();
mountedApps.splice(mountedApps.indexOf(app), 1);
app.unmount();
resolvePlan({
insertCount: 1,
updateCount: 0,
deleteCount: 0,
statementCount: 1,
syncStatements: ["INSERT 2"],
syncSql: "INSERT 2;",
});
await flushAsyncSetup();
expect(session.batchResults[0]?.diff.added[0]?.selected).toBe(false);
expect(session.syncPlan.syncStatements).toEqual(["INSERT 2"]);
expect(session.syncPlan.syncSql).toBe("INSERT 2;");
});
});
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../SchemaDiffDialog.vue", import.meta.url), "utf8");
const sessionSource = readFileSync(new URL("../../../composables/useSchemaDiffSession.ts", import.meta.url), "utf8");
const configStepSource = readFileSync(new URL("../SchemaDiffConfigStep.vue", import.meta.url), "utf8");
describe("SchemaDiffDialog fullscreen layout", () => {
@@ -43,20 +44,20 @@ describe("SchemaDiffDialog fullscreen layout", () => {
it("clears stale result and progress state whenever a new dialog session opens", () => {
expect(dialogSource).toContain("function resetComparisonResultState() {");
expect(dialogSource).toMatch(/if \(isOpen\) \{\s+resetComparisonResultState\(\);/);
expect(dialogSource).toMatch(/if \(!isOpen\) return;[\s\S]*?resetComparisonResultState\(\);/);
expect(dialogSource).toContain("diffObjects.value = [];");
expect(dialogSource).toContain("lastDiffResult.value = null;");
expect(dialogSource).toContain("schemaDiffProgress.value = null;");
});
it("shows real metadata progress and indeterminate comparison phases without fabricated percentages", () => {
expect(dialogSource).toContain('phase: "loading-table-lists"');
expect(dialogSource).toContain('phase: "loading-source-details"');
expect(dialogSource).toContain('phase: "loading-target-details"');
expect(dialogSource).toContain('phase: "loading-extra-objects"');
expect(dialogSource).toContain('phase: "comparing"');
expect(dialogSource).toContain('phase: "generating"');
expect(dialogSource).toContain("onProgress: (progress) =>");
expect(sessionSource).toContain('phase: "loading-table-lists"');
expect(sessionSource).toContain('phase: "loading-source-details"');
expect(sessionSource).toContain('phase: "loading-target-details"');
expect(sessionSource).toContain('phase: "loading-extra-objects"');
expect(sessionSource).toContain('phase: "comparing"');
expect(sessionSource).toContain('phase: "generating"');
expect(sessionSource).toContain("onProgress: (progress) =>");
expect(dialogSource).toContain("schemaDiffProgressCount");
expect(dialogSource).toContain('role="progressbar"');
expect(dialogSource).toContain("schema-diff-progress-indeterminate");
@@ -72,15 +73,17 @@ describe("SchemaDiffDialog fullscreen layout", () => {
});
it("clears progress before preserving the existing comparison error flow", () => {
expect(dialogSource).toMatch(/\} catch \(e: any\) \{\s+if \(!isCurrentRequest\(\)\) return;\s+schemaDiffProgress\.value = null;/);
expect(dialogSource).toContain("toast(e?.message || String(e), 5000);");
expect(sessionSource).toContain("session.error = message;");
expect(sessionSource).toContain('session.status = "failed";');
expect(dialogSource).toContain("toast(session.error, 5000);");
expect(dialogSource).toContain('step.value = "config";');
});
it("invalidates an active comparison when the dialog closes", () => {
expect(dialogSource).toContain("const requestId = ++comparisonRequestId;");
expect(dialogSource).toContain("if (!isCurrentRequest()) return;");
expect(dialogSource).toMatch(/\} else \{\s+comparisonRequestId\+\+;\s+loading\.value = false;\s+schemaDiffProgress\.value = null;/);
it("keeps an active comparison running after the dialog unmounts", () => {
expect(dialogSource).toContain("const session = startSchemaDiffSession(");
expect(dialogSource).toContain("onBeforeUnmount(() => {");
expect(dialogSource).toContain("componentUnmounted = true;");
expect(dialogSource).not.toContain("comparisonRequestId");
});
it("does not restore persisted table matches before source and target identities are ready", () => {
@@ -111,9 +114,9 @@ describe("SchemaDiffDialog fullscreen layout", () => {
expect(configStepSource).toContain('@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"');
expect(configStepSource).toContain("tableMatchStatus.${match.kind}");
expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"');
expect(dialogSource).toContain("tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings");
expect(dialogSource).toContain("ignoreTableNameCase: opts.ignoreTableNameCase");
expect(dialogSource).toContain("ignoreColumnNameCase: opts.ignoreColumnNameCase");
expect(sessionSource).toContain("tableMappings: options.selectedTables === undefined ? undefined : options.tableMappings");
expect(sessionSource).toContain("ignoreTableNameCase: options.ignoreTableNameCase");
expect(sessionSource).toContain("ignoreColumnNameCase: options.ignoreColumnNameCase");
expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);");
});
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -81,6 +81,11 @@ const progressValue = (task: ExportTask) => {
if (!task.multiDbTotal) return 0;
return Math.min(95, Math.round(((task.multiDbCompleted ?? 0) / task.multiDbTotal) * 100));
}
if (task.kind === "schema-diff" || task.kind === "data-compare") {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") return 100;
if (!task.compareTotal) return 0;
return Math.min(95, Math.round(((task.compareCurrent ?? 0) / task.compareTotal) * 100));
}
return progressPercent(task.totalRows, task.rowsExported);
};
@@ -105,10 +110,48 @@ const taskTitle = (task: ExportTask) => {
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
if (task.kind === "schema-diff") return t("exportProgress.schemaDiffTitle", { name: task.tableName });
if (task.kind === "data-compare") return t("exportProgress.dataCompareTitle", { name: task.tableName });
return taskFileName(task) || `${task.tableName}.${task.format}`;
};
const comparePhaseText = (phase?: string) => {
if (!phase) return "";
const phaseKey: Record<string, string> = {
"loading-table-lists": "exportProgress.comparePhase.loadingTableLists",
"loading-source-details": "exportProgress.comparePhase.loadingSourceDetails",
"loading-target-details": "exportProgress.comparePhase.loadingTargetDetails",
"loading-extra-objects": "exportProgress.comparePhase.loadingExtraObjects",
comparing: "exportProgress.comparePhase.comparing",
generating: "exportProgress.comparePhase.generating",
complete: "exportProgress.comparePhase.complete",
};
return t(phaseKey[phase] ?? phase);
};
const rowsText = (task: ExportTask) => {
if (task.kind === "schema-diff") {
if (isActive(task.status)) {
const progress = task.compareTotal ? `${task.compareCurrent ?? 0}/${task.compareTotal}` : "";
return [comparePhaseText(task.comparePhase), task.compareCurrentObject, progress].filter(Boolean).join(" · ");
}
return t("exportProgress.schemaDiffSummary", { count: task.compareResultCount ?? 0 });
}
if (task.kind === "data-compare") {
if (isActive(task.status)) {
const progress = task.compareTotal ? `${task.compareCurrent ?? 0}/${task.compareTotal}` : "";
return [comparePhaseText(task.comparePhase), progress, task.compareCurrentObject].filter(Boolean).join(" · ");
}
return t("exportProgress.dataCompareSummary", {
tables: task.compareTotal ?? 0,
same: task.compareSameCount ?? 0,
different: task.compareDifferentCount ?? 0,
failed: task.compareFailedCount ?? 0,
added: task.compareAddedCount ?? 0,
removed: task.compareRemovedCount ?? 0,
modified: task.compareModifiedCount ?? 0,
});
}
if (task.kind === "database-export") {
if (task.overallPercent !== undefined) return `${task.overallPercent}%`;
if (task.totalObjects) {
@@ -149,7 +192,14 @@ const rowsText = (task: ExportTask) => {
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const taskStatusText = (task: ExportTask) => (task.status === "Cancelling" ? t("databaseBackup.cancelling") : "");
const taskStatusText = (task: ExportTask) => {
if (task.status === "Cancelling") return t("databaseBackup.cancelling");
if (task.kind !== "schema-diff" && task.kind !== "data-compare") return "";
if (task.status === "Running") return t("exportProgress.compareRunning");
if (task.status === "Done") return t("exportProgress.compareDone");
if (task.status === "Error") return t("exportProgress.compareError");
return t("exportProgress.compareCancelled");
};
const elapsedText = (task: ExportTask) => {
if (task.startedAt === undefined) return "";
@@ -175,6 +225,7 @@ const statusIcon = (task: ExportTask) => {
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
if (task.kind === "multi-db-execution") return Layers3;
if (task.kind === "schema-diff" || task.kind === "data-compare") return GitCompareArrows;
}
switch (task.status) {
case "Running":
@@ -276,6 +327,7 @@ async function copyFailureDetail(text: string, key: string): Promise<void> {
}
function openTask(task: ExportTask): void {
open.value = false;
task.onOpen?.();
}
</script>
@@ -308,7 +360,13 @@ function openTask(task: ExportTask): void {
<!-- 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.overallPercent !== undefined)) || (task.kind === 'data-transfer' && task.totalTables) || (task.kind === 'multi-db-execution' && task.multiDbTotal)"
v-if="
task.totalRows ||
(task.kind === 'database-export' && (task.totalObjects || task.overallPercent !== undefined)) ||
(task.kind === 'data-transfer' && task.totalTables) ||
(task.kind === 'multi-db-execution' && task.multiDbTotal) ||
((task.kind === 'schema-diff' || task.kind === 'data-compare') && task.compareTotal)
"
class="h-full bg-primary rounded-full transition-[width] duration-300"
:style="{ width: `${progressValue(task)}%` }"
/>
@@ -388,14 +446,14 @@ function openTask(task: ExportTask): void {
<!-- Actions: reveal folder for finished exports, stop/cancel for active, delete for finished -->
<div class="flex shrink-0 pt-4">
<button v-if="task.kind === 'multi-db-execution' && task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<button v-if="task.onOpen" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.openTask')" @click.stop="openTask(task)">
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button
v-if="isActive(task.status)"
v-if="isActive(task.status) && task.canCancel !== false"
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:cursor-not-allowed"
:disabled="task.status === 'Cancelling'"
:title="task.status === 'Cancelling' ? t('databaseBackup.cancelling') : t('exportProgress.cancel')"
@@ -404,7 +462,7 @@ function openTask(task: ExportTask): void {
<Loader2 v-if="task.status === 'Cancelling'" class="h-3.5 w-3.5 animate-spin text-primary" />
<X v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>
<button v-else class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
<button v-else-if="!isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
@@ -255,7 +255,14 @@ watch(
:prefill-target-database="dialogs.transferPrefillTargetDatabase.value"
:prefill-target-schema="dialogs.transferPrefillTargetSchema.value"
/>
<SchemaDiffDialog v-if="dialogs.showSchemaDiffDialog.value" v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" :prefill-schema="dialogs.schemaDiffPrefillSchema.value" />
<SchemaDiffDialog
v-if="dialogs.showSchemaDiffDialog.value"
v-model:open="dialogs.showSchemaDiffDialog.value"
:prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value"
:prefill-database="dialogs.schemaDiffPrefillDatabase.value"
:prefill-schema="dialogs.schemaDiffPrefillSchema.value"
:session-id="dialogs.schemaDiffSessionId.value"
/>
<DataCompareDialog
v-if="dialogs.showDataCompareDialog.value"
v-model:open="dialogs.showDataCompareDialog.value"
@@ -263,6 +270,7 @@ watch(
:prefill-database="dialogs.dataComparePrefillDatabase.value"
:prefill-schema="dialogs.dataComparePrefillSchema.value"
:prefill-table="dialogs.dataComparePrefillTable.value"
:session-id="dialogs.dataCompareSessionId.value"
/>
<SqlFileExecutionDialog v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" :prefill-file-path="dialogs.sqlFilePrefillFilePath.value" />
<SchemaDiagramDialog
@@ -0,0 +1,96 @@
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
const apiMock = vi.hoisted(() => ({
buildDataCompareSyncPlan: vi.fn(),
getColumns: vi.fn(),
prepareDataCompareFromTables: vi.fn(),
prepareDataCompareMissingTarget: vi.fn(),
}));
const openMock = vi.hoisted(() => vi.fn());
const trackerMock = vi.hoisted(() => ({
addDataCompareTask: vi.fn(),
updateCompareTask: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/composables/useDialogSources", () => ({ openDataCompareSession: openMock }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => trackerMock }));
const { startDataCompareSession } = await import("../useDataCompareSession.ts");
test("runs a data compare session independently of the dialog and retains its result", async () => {
apiMock.getColumns.mockResolvedValue([
{ name: "id", is_primary_key: true },
{ name: "name", is_primary_key: false },
]);
apiMock.prepareDataCompareFromTables.mockResolvedValue({
result: {
added: [{ key: "1", keyValues: { id: 1 }, values: { id: 1, name: "new" } }],
removed: [],
modified: [],
},
syncStatements: ["INSERT INTO users (id, name) VALUES (1, 'new')"],
syncSql: "INSERT INTO users (id, name) VALUES (1, 'new')",
preSyncStatements: [],
sourceRowCount: 1,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
});
apiMock.buildDataCompareSyncPlan.mockResolvedValue({
insertCount: 1,
updateCount: 0,
deleteCount: 0,
statementCount: 1,
syncStatements: ["INSERT"],
syncSql: "INSERT",
});
const session = startDataCompareSession(
{
sourceConnectionId: "source",
sourceDatabase: "app",
sourceSchema: "public",
sourceDatabases: ["app"],
sourceSchemas: ["public"],
sourceTables: ["users"],
selectedSourceTables: ["users"],
targetConnectionId: "target",
targetDatabase: "warehouse",
targetSchema: "public",
targetDatabases: ["warehouse"],
targetSchemas: ["public"],
targetTables: ["users"],
targetTable: "users",
keyColumns: ["id"],
label: "app → warehouse",
},
[{ sourceTable: "users", targetTable: "users" }],
{
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: () => ({ db_type: "mysql" }),
},
);
for (let attempt = 0; attempt < 20 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.equal(session.status, "completed");
assert.equal(session.batchResults.length, 1);
assert.equal(session.batchResults[0]?.status, "different");
assert.equal(session.batchResults[0]?.diff.added[0]?.selected, true);
assert.equal(session.syncPlan.statementCount, 1);
assert.equal(trackerMock.addDataCompareTask.mock.calls.length, 1);
assert.equal(trackerMock.updateCompareTask.mock.calls.at(-1)?.[1].status, "Done");
const onOpen = trackerMock.addDataCompareTask.mock.calls[0]?.[2] as (() => void) | undefined;
onOpen?.();
assert.equal(openMock.mock.calls.at(-1)?.[0], session.id);
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -0,0 +1,66 @@
import { strict as assert } from "node:assert";
import { test, vi } from "vitest";
const apiMock = vi.hoisted(() => ({
prepareSchemaDiff: vi.fn(),
}));
const openMock = vi.hoisted(() => vi.fn());
const trackerMock = vi.hoisted(() => ({
addSchemaDiffTask: vi.fn(),
updateCompareTask: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/composables/useDialogSources", () => ({ openSchemaDiffSession: openMock }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => trackerMock }));
vi.mock("@/lib/schema/schemaDiffMetadataLoad", () => ({ loadSchemaDetails: vi.fn().mockResolvedValue([]) }));
const { startSchemaDiffSession } = await import("../useSchemaDiffSession.ts");
test("runs a schema diff session after the dialog is closed and retains the prepared result", async () => {
apiMock.prepareSchemaDiff.mockResolvedValue({
diffs: [],
functionDiffs: [],
sequenceDiffs: [],
ruleDiffs: [],
ownerDiffs: [],
renameCandidates: [],
syncSql: "",
rollbackSyncSql: "",
});
const tableListLoader = {
load: vi.fn().mockResolvedValue([]),
};
const session = startSchemaDiffSession(
{
sourceConnectionId: "source",
sourceDatabase: "app",
sourceSchema: "public",
targetConnectionId: "target",
targetDatabase: "warehouse",
targetSchema: "public",
sourceDbType: "mysql",
targetDbType: "mysql",
options: {},
ignoreComments: false,
label: "app → warehouse",
},
{ tableListLoader },
);
for (let attempt = 0; attempt < 20 && session.status === "running"; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.equal(session.status, "completed");
assert.deepEqual(session.result?.diffs, []);
assert.equal(tableListLoader.load.mock.calls.length, 2);
assert.equal(tableListLoader.load.mock.calls[0]?.[1]?.refresh, true);
assert.equal(trackerMock.addSchemaDiffTask.mock.calls.length, 1);
assert.equal(trackerMock.updateCompareTask.mock.calls.at(-1)?.[1].status, "Done");
const onOpen = trackerMock.addSchemaDiffTask.mock.calls[0]?.[2] as (() => void) | undefined;
onOpen?.();
assert.equal(openMock.mock.calls.at(-1)?.[0], session.id);
});
@@ -0,0 +1,436 @@
import { reactive, shallowReactive } from "vue";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import { openDataCompareSession } from "@/composables/useDialogSources";
import { useExportTracker } from "@/composables/useExportTracker";
import { inferCompareKeyColumns, intersectCompareColumns, matchColumnNameIgnoreCase, type DataCompareCellValue, type DataCompareFromTablesPreparation, type DataCompareResult, type DataCompareSyncPlan, type DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import type { ColumnInfo, DatabaseType } from "@/types/database";
export type CompareColumn = ColumnInfo;
export interface DataCompareTableTask {
sourceTable: string;
targetTable: string;
}
export type DataCompareTableStatus = "different" | "same" | "error";
export type DiffKind = "added" | "removed" | "modified";
export interface SelectableDataCompareRow {
key: string;
keyValues: Record<string, DataCompareCellValue>;
values: Record<string, DataCompareCellValue>;
selected: boolean;
}
export interface SelectableDataCompareModifiedRow {
key: string;
keyValues: Record<string, DataCompareCellValue>;
sourceValues: Record<string, DataCompareCellValue>;
targetValues: Record<string, DataCompareCellValue>;
changes: { column: string; source: DataCompareCellValue; target: DataCompareCellValue }[];
selected: boolean;
}
export interface SelectableDataCompareResult {
added: SelectableDataCompareRow[];
removed: SelectableDataCompareRow[];
modified: SelectableDataCompareModifiedRow[];
}
export interface DataCompareTableResult {
sourceTable: string;
targetTable: string;
keyColumns: string[];
columns: string[];
columnInfo: CompareColumn[];
status: DataCompareTableStatus;
added: number;
removed: number;
modified: number;
sourceRowCount: number;
targetRowCount: number;
sourceTruncated: boolean;
targetTruncated: boolean;
databaseType?: DatabaseType;
preSyncStatements?: string[];
diff: SelectableDataCompareResult;
expanded: boolean;
showAll: Record<DiffKind, boolean>;
error?: string;
}
export interface DataCompareSessionConfig {
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
sourceDatabases: string[];
sourceSchemas: string[];
sourceTables: string[];
selectedSourceTables: string[];
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
targetDatabases: string[];
targetSchemas: string[];
targetTables: string[];
targetTable: string;
keyColumns: string[];
label: string;
}
export interface DataCompareSessionProgress {
current: number;
total: number;
table: string;
}
export type DataCompareSessionStatus = "running" | "completed" | "failed";
export type DataCompareSessionErrorKind = "noKeyColumns" | "missingKeyColumns" | "noCommonColumns";
export interface DataCompareSession {
id: string;
version: number;
status: DataCompareSessionStatus;
config: DataCompareSessionConfig;
progress: DataCompareSessionProgress | null;
batchResults: DataCompareTableResult[];
syncPlan: DataCompareSyncPlan;
error: string | null;
startedAt: number;
finishedAt?: number;
}
export interface DataCompareSessionDependencies {
ensureConnected(connectionId: string): Promise<unknown>;
getConfig(connectionId: string): { db_type?: DatabaseType } | undefined;
formatError?: (kind: DataCompareSessionErrorKind, columns?: string) => string;
}
const sessions = reactive(new Map<string, DataCompareSession>());
export function emptyDataCompareSyncPlan(): DataCompareSyncPlan {
return {
insertCount: 0,
updateCount: 0,
deleteCount: 0,
statementCount: 0,
syncStatements: [],
syncSql: "",
};
}
function cloneConfig(config: DataCompareSessionConfig): DataCompareSessionConfig {
return {
...config,
sourceDatabases: [...config.sourceDatabases],
sourceSchemas: [...config.sourceSchemas],
sourceTables: [...config.sourceTables],
selectedSourceTables: [...config.selectedSourceTables],
targetDatabases: [...config.targetDatabases],
targetSchemas: [...config.targetSchemas],
targetTables: [...config.targetTables],
keyColumns: [...config.keyColumns],
};
}
function sessionError(error: unknown): string {
return error instanceof Error ? error.message : (error as { message?: string } | null)?.message || String(error);
}
function compareErrorMessage(dependencies: DataCompareSessionDependencies, kind: DataCompareSessionErrorKind, columns?: string): string {
return dependencies.formatError?.(kind, columns) ?? (kind === "missingKeyColumns" ? `Missing key columns: ${columns ?? ""}` : kind === "noCommonColumns" ? "No common columns" : "No key columns available");
}
function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
return {
added: diff.added.map((row) => ({ ...row, selected: true })),
removed: diff.removed.map((row) => ({ ...row, selected: true })),
modified: diff.modified.map((row) => ({ ...row, selected: true })),
};
}
export function buildDataCompareSyncPlanTables(results: DataCompareTableResult[], targetSchema: string): DataCompareSyncPlanTableOptions[] {
return results
.filter((table) => table.status === "different")
.map((table) => ({
tableName: table.targetTable,
schema: targetSchema,
columns: table.columns,
keyColumns: table.keyColumns,
columnInfo: table.columnInfo,
diff: {
added: table.diff.added.filter((row) => row.selected).map(({ selected: _selected, ...row }) => row),
removed: table.diff.removed.filter((row) => row.selected).map(({ selected: _selected, ...row }) => row),
modified: table.diff.modified.filter((row) => row.selected).map(({ selected: _selected, ...row }) => row),
},
databaseType: table.databaseType,
preSyncStatements: table.preSyncStatements ?? [],
}))
.filter((table) => table.preSyncStatements.length > 0 || table.diff.added.length > 0 || table.diff.removed.length > 0 || table.diff.modified.length > 0);
}
function publishProgress(session: DataCompareSession, progress: DataCompareSessionProgress): void {
session.version += 1;
session.progress = progress;
const results = session.batchResults;
const failedTableCount = results.filter((item) => item.status === "error").length;
const tracker = useExportTracker();
tracker.updateCompareTask(session.id, {
status: "Running",
compareCurrent: progress.current,
compareTotal: progress.total,
compareCurrentObject: progress.table,
compareResultCount: results.length,
compareSameCount: results.filter((item) => item.status === "same").length,
compareDifferentCount: results.filter((item) => item.status === "different").length,
compareFailedCount: failedTableCount,
compareAddedCount: results.reduce((sum, item) => sum + item.added, 0),
compareRemovedCount: results.reduce((sum, item) => sum + item.removed, 0),
compareModifiedCount: results.reduce((sum, item) => sum + item.modified, 0),
});
}
function updateResults(session: DataCompareSession, results: DataCompareTableResult[]): void {
session.batchResults = [...results];
}
async function loadColumnsWithCache(cache: Map<string, CompareColumn[]>, connectionId: string, database: string, schema: string, table: string): Promise<CompareColumn[]> {
const key = `${connectionId}:${database}:${schema}:${table}`;
const cached = cache.get(key);
if (cached) return cached;
const columns = (await api.getColumns(connectionId, database, schema, table)) as CompareColumn[];
cache.set(key, columns);
return columns;
}
async function inferKeyColumnsForTable(input: DataCompareSessionConfig, table: string, sourceColumnCache: Map<string, CompareColumn[]>): Promise<string[]> {
if (!input.sourceConnectionId || !input.sourceDatabase || !input.sourceSchema || !table) return [];
const columns = await loadColumnsWithCache(sourceColumnCache, input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, table);
return inferCompareKeyColumns(columns);
}
function buildTableResult(task: DataCompareTableTask, preparation: DataCompareFromTablesPreparation, keyColumns: string[], columns: string[], columnInfo: CompareColumn[], databaseType: DatabaseType | undefined): DataCompareTableResult {
const added = preparation.result.added.length;
const removed = preparation.result.removed.length;
const modified = preparation.result.modified.length;
const status: DataCompareTableStatus = added || removed || modified ? "different" : "same";
return {
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns,
columns,
columnInfo,
status,
added,
removed,
modified,
sourceRowCount: preparation.sourceRowCount,
targetRowCount: preparation.targetRowCount,
sourceTruncated: preparation.sourceTruncated,
targetTruncated: preparation.targetTruncated,
databaseType,
preSyncStatements: preparation.preSyncStatements,
diff: toSelectableDiff(preparation.result),
expanded: status === "different",
showAll: { added: false, removed: false, modified: false },
};
}
function buildMissingTargetResult(task: DataCompareTableTask, preparation: DataCompareFromTablesPreparation, sourceColumns: CompareColumn[], keyColumns: string[], databaseType: DatabaseType | undefined): DataCompareTableResult {
return {
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns,
columns: sourceColumns.map((column) => column.name),
columnInfo: sourceColumns,
status: "different",
added: preparation.result.added.length,
removed: 0,
modified: 0,
sourceRowCount: preparation.sourceRowCount,
targetRowCount: 0,
sourceTruncated: preparation.sourceTruncated,
targetTruncated: false,
databaseType,
preSyncStatements: preparation.preSyncStatements,
diff: toSelectableDiff(preparation.result),
expanded: preparation.result.added.length > 0,
showAll: { added: false, removed: false, modified: false },
};
}
function buildErrorResult(task: DataCompareTableTask, keyColumns: string[], databaseType: DatabaseType | undefined, error: unknown): DataCompareTableResult {
return {
sourceTable: task.sourceTable,
targetTable: task.targetTable,
keyColumns,
columns: [],
columnInfo: [],
status: "error",
added: 0,
removed: 0,
modified: 0,
sourceRowCount: 0,
targetRowCount: 0,
sourceTruncated: false,
targetTruncated: false,
databaseType,
preSyncStatements: [],
diff: { added: [], removed: [], modified: [] },
expanded: false,
showAll: { added: false, removed: false, modified: false },
error: sessionError(error),
};
}
async function runDataCompareSession(session: DataCompareSession, tasks: DataCompareTableTask[], dependencies: DataCompareSessionDependencies): Promise<void> {
const input = session.config;
const sourceColumnCache = new Map<string, CompareColumn[]>();
const targetColumnCache = new Map<string, CompareColumn[]>();
const results: DataCompareTableResult[] = [];
const databaseType = dependencies.getConfig(input.targetConnectionId)?.db_type;
try {
publishProgress(session, { current: 0, total: tasks.length, table: "" });
await Promise.all([dependencies.ensureConnected(input.sourceConnectionId), dependencies.ensureConnected(input.targetConnectionId)]);
for (const [index, task] of tasks.entries()) {
publishProgress(session, { current: index + 1, total: tasks.length, table: task.sourceTable });
let keyColumns = input.keyColumns;
try {
if (input.targetTables.includes(task.targetTable)) {
const resolvedKeys = input.keyColumns.length > 0 ? input.keyColumns : await inferKeyColumnsForTable(input, task.sourceTable, sourceColumnCache);
if (resolvedKeys.length === 0) throw new Error(compareErrorMessage(dependencies, "noKeyColumns"));
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, task.sourceTable);
const targetColumns = await loadColumnsWithCache(targetColumnCache, input.targetConnectionId, input.targetDatabase, input.targetSchema, task.targetTable);
const matched = intersectCompareColumns(sourceColumns, targetColumns);
const columns = matched.columns;
const columnInfo = columns.map((column) => targetColumns.find((target) => target.name === column)).filter((column): column is CompareColumn => !!column);
const canonicalKeyColumns: string[] = [];
const missingKeys: string[] = [];
for (const key of resolvedKeys) {
const canonical = matchColumnNameIgnoreCase(key, columns);
if (!canonical) missingKeys.push(key);
else if (!canonicalKeyColumns.includes(canonical)) canonicalKeyColumns.push(canonical);
}
if (missingKeys.length > 0) throw new Error(compareErrorMessage(dependencies, "missingKeyColumns", missingKeys.join(", ")));
if (columns.length === 0) throw new Error(compareErrorMessage(dependencies, "noCommonColumns"));
const preparation = await api.prepareDataCompareFromTables({
sourceConnectionId: input.sourceConnectionId,
sourceDatabase: input.sourceDatabase,
sourceSchema: input.sourceSchema,
sourceTable: task.sourceTable,
targetConnectionId: input.targetConnectionId,
targetDatabase: input.targetDatabase,
targetSchema: input.targetSchema,
targetTable: task.targetTable,
columns,
keyColumns: canonicalKeyColumns,
sourceColumns: matched.sourceColumns,
});
keyColumns = canonicalKeyColumns;
results.push(buildTableResult(task, preparation, canonicalKeyColumns, columns, columnInfo, databaseType));
} else {
const sourceColumns = await loadColumnsWithCache(sourceColumnCache, input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, task.sourceTable);
const resolvedKeys = input.keyColumns.length > 0 ? input.keyColumns : [];
const sourceColumnNames = sourceColumns.map((column) => column.name);
keyColumns = resolvedKeys.map((key) => matchColumnNameIgnoreCase(key, sourceColumnNames) ?? key);
const preparation = await api.prepareDataCompareMissingTarget({
sourceConnectionId: input.sourceConnectionId,
sourceDatabase: input.sourceDatabase,
sourceSchema: input.sourceSchema,
sourceTable: task.sourceTable,
targetConnectionId: input.targetConnectionId,
targetDatabase: input.targetDatabase,
targetSchema: input.targetSchema,
targetTable: task.targetTable,
keyColumns,
});
results.push(buildMissingTargetResult(task, preparation, sourceColumns, keyColumns, databaseType));
}
} catch (error: unknown) {
results.push(buildErrorResult(task, keyColumns, databaseType, error));
}
updateResults(session, results);
publishProgress(session, { current: index + 1, total: tasks.length, table: task.sourceTable });
}
const plan = await api.buildDataCompareSyncPlan({ tables: buildDataCompareSyncPlanTables(results, input.targetSchema) });
session.syncPlan = plan;
session.progress = null;
session.status = "completed";
session.version += 1;
session.finishedAt = Date.now();
const sameCount = results.filter((item) => item.status === "same").length;
const differentCount = results.filter((item) => item.status === "different").length;
const failedCount = results.filter((item) => item.status === "error").length;
useExportTracker().updateCompareTask(session.id, {
status: failedCount > 0 ? "Error" : "Done",
compareCurrent: tasks.length,
compareTotal: tasks.length,
compareCurrentObject: "",
compareResultCount: results.length,
compareSameCount: sameCount,
compareDifferentCount: differentCount,
compareFailedCount: failedCount,
compareAddedCount: results.reduce((sum, item) => sum + item.added, 0),
compareRemovedCount: results.reduce((sum, item) => sum + item.removed, 0),
compareModifiedCount: results.reduce((sum, item) => sum + item.modified, 0),
errorMessage: failedCount > 0 ? `${failedCount} table(s) failed` : null,
});
} catch (error: unknown) {
const message = sessionError(error);
const lastProgress = session.progress;
session.error = message;
session.progress = null;
session.status = "failed";
session.version += 1;
session.finishedAt = Date.now();
useExportTracker().updateCompareTask(session.id, {
status: "Error",
errorMessage: message,
compareCurrent: lastProgress?.current,
compareTotal: lastProgress?.total,
compareCurrentObject: lastProgress?.table,
});
}
}
export function startDataCompareSession(input: DataCompareSessionConfig, tasks: DataCompareTableTask[], dependencies: DataCompareSessionDependencies): DataCompareSession {
const id = uuid();
const session = shallowReactive<DataCompareSession>({
id,
version: 0,
status: "running",
config: cloneConfig(input),
progress: null,
batchResults: [],
syncPlan: emptyDataCompareSyncPlan(),
error: null,
startedAt: Date.now(),
});
sessions.set(id, session);
const tracker = useExportTracker();
tracker.addDataCompareTask(
id,
input.label,
() => openDataCompareSession(id),
() => removeDataCompareSession(id),
);
void runDataCompareSession(session, tasks, dependencies);
return session;
}
export function getDataCompareSession(id: string | null | undefined): DataCompareSession | undefined {
return id ? sessions.get(id) : undefined;
}
export function removeDataCompareSession(id: string): boolean {
const session = sessions.get(id);
if (session?.status === "running") return false;
return sessions.delete(id);
}
@@ -45,10 +45,12 @@ const transferPrefillTargetSchema = ref("");
const schemaDiffPrefillConnectionId = ref("");
const schemaDiffPrefillDatabase = ref("");
const schemaDiffPrefillSchema = ref("");
const schemaDiffSessionId = ref<string | null>(null);
const dataComparePrefillConnectionId = ref("");
const dataComparePrefillDatabase = ref("");
const dataComparePrefillSchema = ref("");
const dataComparePrefillTable = ref("");
const dataCompareSessionId = ref<string | null>(null);
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const sqlFilePrefillFilePath = ref("");
@@ -96,6 +98,16 @@ function clearTransferPrefill() {
transferPrefillTargetSchema.value = "";
}
export function openSchemaDiffSession(sessionId: string): void {
schemaDiffSessionId.value = sessionId;
showSchemaDiffDialog.value = true;
}
export function openDataCompareSession(sessionId: string): void {
dataCompareSessionId.value = sessionId;
showDataCompareDialog.value = true;
}
export function useDialogSources() {
const { t } = useI18n();
const connectionStore = useConnectionStore();
@@ -134,6 +146,7 @@ export function useDialogSources() {
schemaDiffPrefillConnectionId.value = v.connectionId;
schemaDiffPrefillDatabase.value = v.database;
schemaDiffPrefillSchema.value = v.schema ?? "";
schemaDiffSessionId.value = null;
showSchemaDiffDialog.value = true;
connectionStore.schemaDiffSource = null;
}
@@ -148,12 +161,21 @@ export function useDialogSources() {
dataComparePrefillDatabase.value = v.database;
dataComparePrefillSchema.value = v.schema ?? "";
dataComparePrefillTable.value = v.tableName ?? "";
dataCompareSessionId.value = null;
showDataCompareDialog.value = true;
connectionStore.dataCompareSource = null;
}
},
);
watch(showSchemaDiffDialog, (open) => {
if (!open) schemaDiffSessionId.value = null;
});
watch(showDataCompareDialog, (open) => {
if (!open) dataCompareSessionId.value = null;
});
watch(
() => connectionStore.sqlFileSource,
(v) => {
@@ -502,10 +524,12 @@ export function useDialogSources() {
schemaDiffPrefillConnectionId,
schemaDiffPrefillDatabase,
schemaDiffPrefillSchema,
schemaDiffSessionId,
dataComparePrefillConnectionId,
dataComparePrefillDatabase,
dataComparePrefillSchema,
dataComparePrefillTable,
dataCompareSessionId,
sqlFilePrefillConnectionId,
sqlFilePrefillDatabase,
sqlFilePrefillFilePath,
@@ -2,7 +2,7 @@ import { reactive, computed } from "vue";
import * as api from "@/lib/backend/api";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution";
export type BackgroundTaskKind = "table-export" | "database-export" | "sql-file" | "data-transfer" | "multi-db-execution" | "schema-diff" | "data-compare";
export type BackgroundTaskStatus = "Running" | "Writing" | "Cancelling" | "Done" | "Error" | "Cancelled";
export type DatabaseExportSource = "manual" | "scheduled";
@@ -70,7 +70,36 @@ export interface ExportTask {
multiDbSkippedCount?: number;
multiDbNotExecutedCount?: number;
currentTarget?: { connectionId: string; catalog?: string; database: string; schema?: string };
comparePhase?: string;
compareCurrent?: number;
compareTotal?: number;
compareCurrentObject?: string;
compareResultCount?: number;
compareSameCount?: number;
compareDifferentCount?: number;
compareFailedCount?: number;
compareAddedCount?: number;
compareRemovedCount?: number;
compareModifiedCount?: number;
canCancel?: boolean;
onOpen?: () => void;
onRemove?: () => void;
}
export interface CompareTaskProgress {
status: Extract<BackgroundTaskStatus, "Running" | "Done" | "Error" | "Cancelled">;
comparePhase?: string;
compareCurrent?: number;
compareTotal?: number;
compareCurrentObject?: string;
compareResultCount?: number;
compareSameCount?: number;
compareDifferentCount?: number;
compareFailedCount?: number;
compareAddedCount?: number;
compareRemovedCount?: number;
compareModifiedCount?: number;
errorMessage?: string | null;
}
export interface MultiDbExecutionTaskProgress {
@@ -458,6 +487,37 @@ export function useExportTracker() {
return task;
}
function addCompareTask(kind: "schema-diff" | "data-compare", sessionId: string, label: string, onOpen?: () => void, onRemove?: () => void): ExportTask {
const task = reactive<ExportTask>({
exportId: sessionId,
kind,
tableName: label,
format: "compare",
filePath: "",
rowsExported: 0,
totalRows: null,
status: "Running",
errorMessage: null,
compareCurrent: 0,
compareTotal: 0,
compareCurrentObject: "",
canCancel: false,
onOpen,
onRemove,
startedAt: Date.now(),
});
taskMap.set(sessionId, task);
return task;
}
function addSchemaDiffTask(sessionId: string, label: string, onOpen?: () => void, onRemove?: () => void): ExportTask {
return addCompareTask("schema-diff", sessionId, label, onOpen, onRemove);
}
function addDataCompareTask(sessionId: string, label: string, onOpen?: () => void, onRemove?: () => void): ExportTask {
return addCompareTask("data-compare", sessionId, label, onOpen, onRemove);
}
function addMultiDbExecutionTask(batchId: string, label: string, sourceTabId: string, onOpen?: () => void): ExportTask {
const task = reactive<ExportTask>({
exportId: batchId,
@@ -642,7 +702,36 @@ export function useExportTracker() {
task.totalRows = progress.totalRows ?? task.totalRows;
}
function updateCompareTask(sessionId: string, progress: CompareTaskProgress): void {
const task = taskMap.get(sessionId);
if (!task || (task.kind !== "schema-diff" && task.kind !== "data-compare")) return;
task.status = progress.status;
task.comparePhase = progress.comparePhase;
task.compareCurrent = progress.compareCurrent;
task.compareTotal = progress.compareTotal;
task.compareCurrentObject = progress.compareCurrentObject;
task.compareResultCount = progress.compareResultCount;
task.compareSameCount = progress.compareSameCount;
task.compareDifferentCount = progress.compareDifferentCount;
task.compareFailedCount = progress.compareFailedCount;
task.compareAddedCount = progress.compareAddedCount;
task.compareRemovedCount = progress.compareRemovedCount;
task.compareModifiedCount = progress.compareModifiedCount;
task.errorMessage = progress.errorMessage ?? null;
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
}
function cleanupFinishedTask(task: ExportTask): void {
try {
task.onRemove?.();
} catch {
// cleanup hooks must not prevent the task from being removed
}
}
function removeTask(exportId: string) {
const task = taskMap.get(exportId);
if (task && (task.status === "Done" || task.status === "Error" || task.status === "Cancelled")) cleanupFinishedTask(task);
taskMap.delete(exportId);
taskCancelHandlers.delete(exportId);
transferFailureStates.delete(exportId);
@@ -652,6 +741,7 @@ export function useExportTracker() {
function clearFinished() {
for (const [id, task] of taskMap) {
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") {
cleanupFinishedTask(task);
taskMap.delete(id);
taskCancelHandlers.delete(id);
transferFailureStates.delete(id);
@@ -670,6 +760,7 @@ export function useExportTracker() {
async function cancelTask(exportId: string) {
const task = taskMap.get(exportId);
if (task?.canCancel === false) return;
try {
const customHandler = taskCancelHandlers.get(exportId);
if (customHandler) {
@@ -696,6 +787,8 @@ export function useExportTracker() {
addDatabaseExportTask,
addSqlFileTask,
addDataTransferTask,
addSchemaDiffTask,
addDataCompareTask,
addMultiDbExecutionTask,
updateMultiDbExecutionTask,
startDataTransferTask,
@@ -705,6 +798,7 @@ export function useExportTracker() {
restoreDatabaseExportTaskRunning,
updateSqlFileTask,
updateDataTransferTask,
updateCompareTask,
registerTaskCancelHandler,
unregisterTaskCancelHandler,
removeTask,
@@ -0,0 +1,284 @@
import { reactive, shallowReactive } from "vue";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import { openSchemaDiffSession } from "@/composables/useDialogSources";
import { useExportTracker } from "@/composables/useExportTracker";
import { filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter";
import { compileSchemaDiffTableFilter } from "@/lib/schema/schemaDiffTableFilter";
import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad";
import type { SchemaDiffTableIdentity, SchemaDiffTableListLoader } from "@/lib/schema/schemaDiffTableList";
import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress";
import { convertToSchemaDiffObjects, databaseTypeToDialectKind, normalizeDialectKind, schemaDiffDeployTargetSchema, type SchemaDiffObject } from "@/lib/schema/schemaDiff";
import { normalizeSchemaDiffCompareOptions, type SchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { DatabaseType } from "@/types/database";
import type { SchemaDiffPreparation } from "@/lib/schema/schemaDiff";
export interface SchemaDiffSessionProgress {
phase: SchemaDiffProgressPhase;
current?: number;
total?: number;
objectName?: string;
}
export interface SchemaDiffSessionConfig {
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
sourceDbType: string;
targetDbType: DatabaseType;
options: SchemaDiffCompareOptions;
ignoreComments: boolean;
label: string;
}
export type SchemaDiffSessionStatus = "running" | "completed" | "failed";
export interface SchemaDiffSession {
id: string;
version: number;
status: SchemaDiffSessionStatus;
config: SchemaDiffSessionConfig;
progress: SchemaDiffSessionProgress | null;
result: SchemaDiffPreparation | null;
error: string | null;
startedAt: number;
finishedAt?: number;
}
export interface SchemaDiffSessionDependencies {
tableListLoader: SchemaDiffTableListLoader;
}
const sessions = reactive(new Map<string, SchemaDiffSession>());
function cloneOptions(options: SchemaDiffCompareOptions): SchemaDiffCompareOptions {
return {
...options,
selectedTables: options.selectedTables ? [...options.selectedTables] : options.selectedTables,
tableMappings: options.tableMappings?.map((mapping) => ({ ...mapping })),
fieldMappings: options.fieldMappings?.map((mapping) => ({ ...mapping })),
};
}
function resultObjectCount(result: SchemaDiffPreparation): number {
return result.diffs.length + (result.functionDiffs?.length ?? 0) + (result.sequenceDiffs?.length ?? 0) + (result.ruleDiffs?.length ?? 0) + (result.ownerDiffs?.length ?? 0);
}
function sessionError(error: unknown): string {
return error instanceof Error ? error.message : (error as { message?: string } | null)?.message || String(error);
}
function publishProgress(session: SchemaDiffSession, progress: SchemaDiffSessionProgress): void {
session.version += 1;
session.progress = progress;
useExportTracker().updateCompareTask(session.id, {
status: "Running",
comparePhase: progress.phase,
compareCurrent: progress.current,
compareTotal: progress.total,
compareCurrentObject: progress.objectName,
});
}
async function runSchemaDiffSession(session: SchemaDiffSession, dependencies: SchemaDiffSessionDependencies): Promise<void> {
const input = session.config;
const tracker = useExportTracker();
const options = normalizeSchemaDiffCompareOptions(input.options, input.targetDbType);
const isPostgresLike = isSchemaDiffPostgresLike(input.targetDbType);
const hasExtraObjectPhase = shouldLoadSchemaDiffExtraObjects(input.targetDbType, options);
const tableFilter = compileSchemaDiffTableFilter(options);
try {
publishProgress(session, { phase: "loading-table-lists" });
const sourceTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.sourceConnectionId,
database: input.sourceDatabase,
schema: input.sourceSchema,
};
const targetTableIdentity: SchemaDiffTableIdentity = {
connectionId: input.targetConnectionId,
database: input.targetDatabase,
schema: input.targetSchema,
};
const [sourceTableList, targetTableList] = await Promise.all([dependencies.tableListLoader.load(sourceTableIdentity, { refresh: true }), dependencies.tableListLoader.load(targetTableIdentity, { refresh: true })]);
const { sourceTables, targetTables } = filterSchemaDiffTables(sourceTableList, targetTableList, tableFilter, options, options.selectedTables);
publishProgress(session, { phase: "loading-source-details", current: 0, total: sourceTables.length });
const sourceDetails = await loadSchemaDetails(
sourceTables,
{
connectionId: input.sourceConnectionId,
database: input.sourceDatabase,
schema: input.sourceSchema,
dbType: input.sourceDbType,
options,
onProgress: (progress) => publishProgress(session, { phase: "loading-source-details", ...progress }),
},
api,
);
publishProgress(session, { phase: "loading-target-details", current: 0, total: targetTables.length });
const targetDetails = await loadSchemaDetails(
targetTables,
{
connectionId: input.targetConnectionId,
database: input.targetDatabase,
schema: input.targetSchema,
dbType: input.targetDbType,
options,
onProgress: (progress) => publishProgress(session, { phase: "loading-target-details", ...progress }),
},
api,
);
const promises: Promise<unknown>[] = [];
if (isPostgresLike && options.functions) {
promises.push(api.listFunctions(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listFunctions(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
if (isPostgresLike && options.sequences) {
promises.push(api.listSequences(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema, !!options.sequenceLastValues));
promises.push(api.listSequences(input.targetConnectionId, input.targetDatabase, input.targetSchema, !!options.sequenceLastValues));
}
if (isPostgresLike && options.rules) {
promises.push(api.listRules(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listRules(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
if (isPostgresLike && options.owners) {
promises.push(api.listOwners(input.sourceConnectionId, input.sourceDatabase, input.sourceSchema));
promises.push(api.listOwners(input.targetConnectionId, input.targetDatabase, input.targetSchema));
}
if (hasExtraObjectPhase) publishProgress(session, { phase: "loading-extra-objects" });
const extraObjects = await Promise.all(promises);
let index = 0;
const sourceFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listFunctions>>) : [];
const targetFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listFunctions>>) : [];
const sourceSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const targetSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listSequences>>) : [];
const sourceRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const targetRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listRules>>) : [];
const sourceOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
const targetOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited<ReturnType<typeof api.listOwners>>) : [];
publishProgress(session, { phase: "comparing" });
const result = await api.prepareSchemaDiff({
sourceTables,
targetTables,
sourceDetails,
targetDetails,
sourceFunctions,
targetFunctions,
sourceSequences,
targetSequences,
sourceRules,
targetRules,
sourceOwners,
targetOwners,
tableMappings: options.selectedTables === undefined ? undefined : options.tableMappings,
databaseType: input.targetDbType,
targetSchema: schemaDiffDeployTargetSchema(input.targetDbType, input.targetDatabase, input.targetSchema),
ignoreComments: input.ignoreComments,
cascadeDelete: options.cascadeDelete ?? false,
compareColumnOrder: options.compareColumnOrder,
ignoreTableNameCase: options.ignoreTableNameCase,
ignoreColumnNameCase: options.ignoreColumnNameCase,
detectRenames: options.detectRenames ?? false,
detectTableRenames: options.detectTableRenames ?? false,
renameThreshold: options.renameThreshold ?? 0.5,
enableRollback: options.enableRollback ?? false,
batchPatterns: options.batchPatterns
? options.batchPatterns
.split(",")
.map((value) => value.trim())
.filter(Boolean)
: undefined,
sourceDialect: options.sourceDialect ? normalizeDialectKind(options.sourceDialect) : input.sourceDbType ? databaseTypeToDialectKind(input.sourceDbType as DatabaseType) : undefined,
targetDialect: options.targetDialect ? normalizeDialectKind(options.targetDialect) : input.targetDbType ? databaseTypeToDialectKind(input.targetDbType) : undefined,
compatibilityThreshold: options.compatibilityThreshold ?? 0.5,
fieldMappings:
options.fieldMappings?.map((mapping) => ({
sourceType: mapping.sourceType,
targetType: mapping.targetType,
paramStrategy: mapping.paramStrategy ?? "preserve",
customParams: mapping.customParams,
})) || [],
});
publishProgress(session, { phase: "generating" });
session.result = result;
session.progress = { phase: "complete" };
session.version += 1;
session.status = "completed";
session.finishedAt = Date.now();
tracker.updateCompareTask(session.id, {
status: "Done",
comparePhase: "complete",
compareCurrent: 1,
compareTotal: 1,
compareResultCount: resultObjectCount(result),
});
} catch (error: unknown) {
const message = sessionError(error);
session.error = message;
session.progress = null;
session.version += 1;
session.status = "failed";
session.finishedAt = Date.now();
tracker.updateCompareTask(session.id, {
status: "Error",
errorMessage: message,
});
}
}
export function startSchemaDiffSession(input: Omit<SchemaDiffSessionConfig, "options"> & { options: SchemaDiffCompareOptions }, dependencies: SchemaDiffSessionDependencies): SchemaDiffSession {
const id = uuid();
const session = shallowReactive<SchemaDiffSession>({
id,
version: 0,
status: "running",
config: {
...input,
options: cloneOptions(input.options),
},
progress: null,
result: null,
error: null,
startedAt: Date.now(),
});
sessions.set(id, session);
const tracker = useExportTracker();
tracker.addSchemaDiffTask(
id,
input.label,
() => openSchemaDiffSession(id),
() => removeSchemaDiffSession(id),
);
void runSchemaDiffSession(session, dependencies);
return session;
}
export function getSchemaDiffSession(id: string | null | undefined): SchemaDiffSession | undefined {
return id ? sessions.get(id) : undefined;
}
export function removeSchemaDiffSession(id: string): boolean {
const session = sessions.get(id);
if (session?.status === "running") return false;
return sessions.delete(id);
}
export function schemaDiffSessionNextProgressStep(session: SchemaDiffSession): string | null {
return getSchemaDiffNextProgressStep(session.progress?.phase, shouldLoadSchemaDiffExtraObjects(session.config.targetDbType, session.config.options));
}
export function schemaDiffSessionObjects(result: SchemaDiffPreparation | null): SchemaDiffObject[] {
if (!result) return [];
return convertToSchemaDiffObjects(result.diffs, result.functionDiffs, result.sequenceDiffs, result.ruleDiffs, result.ownerDiffs, result.renameCandidates);
}
+17
View File
@@ -2253,6 +2253,23 @@ export default {
sqlFileTitle: "SQL file: {name}",
dataTransferTitle: "Data transfer: {name}",
multiDbExecutionTitle: "Multi-database execution: {name}",
schemaDiffTitle: "Schema compare: {name}",
dataCompareTitle: "Data compare: {name}",
compareRunning: "Running",
compareDone: "Done",
compareError: "Error",
compareCancelled: "Cancelled",
comparePhase: {
loadingTableLists: "Loading table lists",
loadingSourceDetails: "Loading source details",
loadingTargetDetails: "Loading target details",
loadingExtraObjects: "Loading extra objects",
comparing: "Comparing",
generating: "Generating result",
complete: "Completed",
},
schemaDiffSummary: "{count} differing object(s)",
dataCompareSummary: "{tables} table(s): {same} same, {different} different, {failed} failed · +{added} / -{removed} / ~{modified}",
multiDbTargets: "Targets {completed}/{total}, succeeded {success}, failed {failed}",
openTask: "Open task",
objectsCount: "{current} / {total} objects",
+17
View File
@@ -2187,6 +2187,23 @@ export default withEnglishFallback({
failureDetailCopied: "失败详情已复制",
failureDetailCopyFailed: "复制失败详情失败:{message}",
multiDbExecutionTitle: "多库执行:{name}",
schemaDiffTitle: "结构对比:{name}",
dataCompareTitle: "数据对比:{name}",
compareRunning: "运行中",
compareDone: "已完成",
compareError: "失败",
compareCancelled: "已取消",
comparePhase: {
loadingTableLists: "正在加载表列表",
loadingSourceDetails: "正在加载源端详情",
loadingTargetDetails: "正在加载目标端详情",
loadingExtraObjects: "正在加载其他对象",
comparing: "正在比较",
generating: "正在生成结果",
complete: "已完成",
},
schemaDiffSummary: "发现 {count} 个差异对象",
dataCompareSummary: "{tables} 张表:相同 {same},不同 {different},失败 {failed} · 新增 {added} / 删除 {removed} / 修改 {modified}",
multiDbTargets: "目标 {completed}/{total},成功 {success},失败 {failed}",
openTask: "打开任务",
},
+42
View File
@@ -405,3 +405,45 @@ test("blocks concurrent data transfers that write the same target table", () =>
terminal: true,
});
});
test("tracks compare tasks without exposing cancellation and removes completed sessions", async () => {
const tracker = useExportTracker();
const onOpen = vi.fn();
const onRemove = vi.fn();
const task = tracker.addDataCompareTask("data-compare-1", "app → warehouse", onOpen, onRemove);
assert.equal(task.kind, "data-compare");
assert.equal(task.canCancel, false);
await tracker.cancelTask(task.exportId);
assert.equal(
apiMock.cancelTableExport.mock.calls.some(([id]) => id === task.exportId),
false,
);
tracker.updateCompareTask(task.exportId, {
status: "Running",
compareCurrent: 1,
compareTotal: 2,
compareCurrentObject: "users",
compareResultCount: 1,
compareDifferentCount: 1,
});
assert.equal(task.status, "Running");
assert.equal(task.compareCurrentObject, "users");
tracker.updateCompareTask(task.exportId, {
status: "Done",
compareCurrent: 2,
compareTotal: 2,
compareResultCount: 2,
});
assert.equal(task.status, "Done");
task.onOpen?.();
assert.equal(onOpen.mock.calls.length, 1);
tracker.removeTask(task.exportId);
assert.equal(onRemove.mock.calls.length, 1);
assert.equal(
tracker.tasks.value.some((item) => item.exportId === task.exportId),
false,
);
});