{{ summary }}
@@ -1211,7 +1110,8 @@ watch(
-
+ {{ compareProgressLabel || t("diff.progress.comparing") }}
+
{{ t("diff.syncProgress", { current: executedCount, total: executeTotal }) }}
diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue
index 52412bae2d..00459776e4 100644
--- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue
+++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue
@@ -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(null);
const schemaDiffHasExtraObjectPhase = ref(false);
-let comparisonRequestId = 0;
+const activeSessionId = ref(props.sessionId ?? null);
+let appliedSessionResult: SchemaDiffPreparation | null = null;
+let shownSessionError = "";
+let componentUnmounted = false;
const diffObjects = ref([]);
const diffGroups = ref([]);
const selectedObjectId = ref(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[] = [];
- 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;
diff --git a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
index cd2512d5b8..96b8c96cfb 100644
--- a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
+++ b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
@@ -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();
+ 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("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("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;");
+ });
+});
diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts
index 86a8bb1607..83b5ee835a 100644
--- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts
+++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts
@@ -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 ?? []);");
});
diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue
index 1c10930553..57f8421da6 100644
--- a/apps/desktop/src/components/export/ExportProgressPopover.vue
+++ b/apps/desktop/src/components/export/ExportProgressPopover.vue
@@ -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 = {
+ "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 {
}
function openTask(task: ExportTask): void {
+ open.value = false;
task.onOpen?.();
}
@@ -308,7 +360,13 @@ function openTask(task: ExportTask): void {
@@ -388,14 +446,14 @@ function openTask(task: ExportTask): void {
-
-
+
diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue
index 91639a2ba4..1143576e5f 100644
--- a/apps/desktop/src/components/layout/AppDialogs.vue
+++ b/apps/desktop/src/components/layout/AppDialogs.vue
@@ -255,7 +255,14 @@ watch(
:prefill-target-database="dialogs.transferPrefillTargetDatabase.value"
:prefill-target-schema="dialogs.transferPrefillTargetSchema.value"
/>
-
+
({
+ 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();
+});
diff --git a/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts
new file mode 100644
index 0000000000..ba488d0978
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/useSchemaDiffSession.spec.ts
@@ -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);
+});
diff --git a/apps/desktop/src/composables/useDataCompareSession.ts b/apps/desktop/src/composables/useDataCompareSession.ts
new file mode 100644
index 0000000000..39f48a5622
--- /dev/null
+++ b/apps/desktop/src/composables/useDataCompareSession.ts
@@ -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;
+ values: Record;
+ selected: boolean;
+}
+
+export interface SelectableDataCompareModifiedRow {
+ key: string;
+ keyValues: Record;
+ sourceValues: Record;
+ targetValues: Record;
+ 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;
+ 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;
+ getConfig(connectionId: string): { db_type?: DatabaseType } | undefined;
+ formatError?: (kind: DataCompareSessionErrorKind, columns?: string) => string;
+}
+
+const sessions = reactive(new Map());
+
+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, connectionId: string, database: string, schema: string, table: string): Promise {
+ 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): Promise {
+ 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 {
+ const input = session.config;
+ const sourceColumnCache = new Map();
+ const targetColumnCache = new Map();
+ 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({
+ 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);
+}
diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts
index 4668a05b79..a56a2a1266 100644
--- a/apps/desktop/src/composables/useDialogSources.ts
+++ b/apps/desktop/src/composables/useDialogSources.ts
@@ -45,10 +45,12 @@ const transferPrefillTargetSchema = ref("");
const schemaDiffPrefillConnectionId = ref("");
const schemaDiffPrefillDatabase = ref("");
const schemaDiffPrefillSchema = ref("");
+const schemaDiffSessionId = ref(null);
const dataComparePrefillConnectionId = ref("");
const dataComparePrefillDatabase = ref("");
const dataComparePrefillSchema = ref("");
const dataComparePrefillTable = ref("");
+const dataCompareSessionId = ref(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,
diff --git a/apps/desktop/src/composables/useExportTracker.ts b/apps/desktop/src/composables/useExportTracker.ts
index ca3bac514e..9d3143cb83 100644
--- a/apps/desktop/src/composables/useExportTracker.ts
+++ b/apps/desktop/src/composables/useExportTracker.ts
@@ -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;
+ 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({
+ 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({
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,
diff --git a/apps/desktop/src/composables/useSchemaDiffSession.ts b/apps/desktop/src/composables/useSchemaDiffSession.ts
new file mode 100644
index 0000000000..c6a1988ba6
--- /dev/null
+++ b/apps/desktop/src/composables/useSchemaDiffSession.ts
@@ -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());
+
+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 {
+ 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[] = [];
+ 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>) : [];
+ const targetFunctions = options.functions && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const sourceSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const targetSequences = options.sequences && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const sourceRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const targetRules = options.rules && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const sourceOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+ const targetOwners = options.owners && isPostgresLike ? (extraObjects[index++] as Awaited>) : [];
+
+ 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 & { options: SchemaDiffCompareOptions }, dependencies: SchemaDiffSessionDependencies): SchemaDiffSession {
+ const id = uuid();
+ const session = shallowReactive({
+ 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);
+}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index ef6370af41..636c65d233 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -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",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 52548eabce..860ce27218 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -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: "打开任务",
},
diff --git a/packages/app-tests/useExportTracker.test.ts b/packages/app-tests/useExportTracker.test.ts
index a771d7a7de..a9a266aeff 100644
--- a/packages/app-tests/useExportTracker.test.ts
+++ b/packages/app-tests/useExportTracker.test.ts
@@ -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,
+ );
+});