feat(diff): show schema comparison progress and next step

Closes #7484
This commit is contained in:
skyler authored and GitHub committed 2026-08-30 03:54:15 +08:00
1 parent d5f4eadf7a
commit 1d71a5fc23
13 files changed
+473 -67

No files matched your search

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan, shouldFetchSchemaDiffDdl } from "@/lib/schema/schemaDiffMetadataLoad";
import { createConcurrencyLimiter, loadSchemaDetails, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan, shouldFetchSchemaDiffDdl, type SchemaDiffMetadataApi, type SchemaDiffMetadataProgress } from "../../schema/schemaDiffMetadataLoad";
import { getSchemaDiffNextProgressStep, shouldLoadSchemaDiffExtraObjects } from "../../schema/schemaDiffProgress";
import { DEFAULT_MYSQL_OPTIONS, DEFAULT_POSTGRES_OPTIONS } from "../../../types/schemaDiff";
import type { TableInfo } from "../../../types/database";
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -100,6 +103,40 @@ describe("schemaDiffMetadataLoad", () => {
expect(maxActive).toBeLessThanOrEqual(2);
});
it("reports each completed table while loading metadata", async () => {
const tables = ["orders", "customers", "events"].map((name) => ({ name, table_type: "TABLE" }) as TableInfo);
const progress: SchemaDiffMetadataProgress[] = [];
const api: SchemaDiffMetadataApi = {
getTableDdl: async (_connectionId, _database, _schema, table) => {
await wait(table === "orders" ? 10 : table === "customers" ? 1 : 5);
return `ddl:${table}`;
},
getColumns: async () => [],
listIndexes: async () => [],
listForeignKeys: async () => [],
listTriggers: async () => [],
};
const details = await loadSchemaDetails(
tables,
{
connectionId: "source",
database: "db",
schema: "public",
dbType: "mysql",
options: { ...DEFAULT_MYSQL_OPTIONS },
onProgress: (value) => progress.push(value),
},
api,
);
expect(details.map((detail) => detail.name)).toEqual(["orders", "customers", "events"]);
expect(details.map((detail) => detail.ddl)).toEqual(["ddl:orders", "ddl:customers", "ddl:events"]);
expect(progress.map((value) => value.current)).toEqual([1, 2, 3]);
expect(progress.map((value) => value.total)).toEqual([3, 3, 3]);
expect(new Set(progress.map((value) => value.objectName))).toEqual(new Set(["orders", "customers", "events"]));
});
it("propagates the first worker error and stops scheduling new work", async () => {
const started: number[] = [];
@@ -134,4 +171,31 @@ describe("schemaDiffMetadataLoad", () => {
expect(result).toEqual([0, 1, 2, 3]);
expect(maxActive).toBeLessThanOrEqual(2);
});
it("only enables extra objects for PostgreSQL-like databases with relevant options", () => {
expect(shouldLoadSchemaDiffExtraObjects("mysql", { ...DEFAULT_MYSQL_OPTIONS, functions: true })).toBe(false);
expect(shouldLoadSchemaDiffExtraObjects("postgres", DEFAULT_POSTGRES_OPTIONS)).toBe(true);
expect(shouldLoadSchemaDiffExtraObjects("opengauss", DEFAULT_POSTGRES_OPTIONS)).toBe(true);
expect(
shouldLoadSchemaDiffExtraObjects("postgres", {
...DEFAULT_POSTGRES_OPTIONS,
functions: false,
sequences: false,
rules: false,
owners: false,
}),
).toBe(false);
});
it("maps phases to the next step on the actual comparison path", () => {
expect(getSchemaDiffNextProgressStep("loading-table-lists", false)).toBe("nextSourceDetails");
expect(getSchemaDiffNextProgressStep("loading-source-details", false)).toBe("nextTargetDetails");
expect(getSchemaDiffNextProgressStep("loading-target-details", false)).toBe("nextComparing");
expect(getSchemaDiffNextProgressStep("loading-target-details", true)).toBe("nextExtraObjects");
expect(getSchemaDiffNextProgressStep("loading-extra-objects", true)).toBe("nextComparing");
expect(getSchemaDiffNextProgressStep("comparing", false)).toBe("nextGenerating");
expect(getSchemaDiffNextProgressStep("generating", false)).toBe("nextComplete");
expect(getSchemaDiffNextProgressStep("complete", false)).toBeNull();
expect(getSchemaDiffNextProgressStep(undefined, false)).toBeNull();
});
});
@@ -1,4 +1,7 @@
import type { ColumnInfo, ForeignKeyInfo, IndexInfo, ObjectSourceKind, TableInfo, TriggerInfo } from "@/types/database";
import { isSchemaDiffView } from "@/lib/schema/schemaDiffTableFilter";
import type { SchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { TableSchemaDetail } from "@/lib/schema/schemaDiff";
const MYSQL_LARGE_SCHEMA_DIFF_METADATA_CONCURRENCY = 2;
const MYSQL_SMALL_SCHEMA_DIFF_METADATA_CONCURRENCY = 4;
@@ -115,3 +118,60 @@ export function createConcurrencyLimiter(limit: number) {
}
};
}
export interface SchemaDiffMetadataApi {
getTableDdl(connectionId: string, database: string, schema: string, table: string, objectType?: ObjectSourceKind): Promise<string>;
getColumns(connectionId: string, database: string, schema: string, table: string): Promise<ColumnInfo[]>;
listIndexes(connectionId: string, database: string, schema: string, table: string): Promise<IndexInfo[]>;
listForeignKeys(connectionId: string, database: string, schema: string, table: string): Promise<ForeignKeyInfo[]>;
listTriggers(connectionId: string, database: string, schema: string, table: string): Promise<TriggerInfo[]>;
}
export interface SchemaDiffMetadataProgress {
current: number;
total: number;
objectName: string;
}
export interface SchemaDetailLoadContext {
connectionId: string;
database: string;
schema: string;
dbType: string;
options: SchemaDiffCompareOptions;
onProgress?: (progress: SchemaDiffMetadataProgress) => void;
}
function isViewOrMaterializedView(tableType: string): ObjectSourceKind | undefined {
switch (tableType.toUpperCase().replace(/\s+/g, "_")) {
case "VIEW":
return "VIEW";
case "MATERIALIZED_VIEW":
return "MATERIALIZED_VIEW";
default:
return undefined;
}
}
export async function loadSchemaDetails(tables: TableInfo[], context: SchemaDetailLoadContext, api: SchemaDiffMetadataApi): Promise<TableSchemaDetail[]> {
const concurrency = schemaDiffMetadataConcurrency(context.dbType, tables.length);
const runMetadataQuery = createConcurrencyLimiter(concurrency);
let completed = 0;
return mapWithConcurrency(tables, concurrency, async (table) => {
const objectType = isViewOrMaterializedView(table.table_type);
const loadPlan = schemaDiffMetadataLoadPlan(isSchemaDiffView(table), context.options);
const ddlPromise = loadPlan.ddl ? runMetadataQuery(() => api.getTableDdl(context.connectionId, context.database, context.schema, table.name, objectType)) : Promise.resolve("");
const [columns, indexes, foreignKeys, triggers, ddl] = await Promise.all([
loadPlan.columns ? runMetadataQuery(() => api.getColumns(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
loadPlan.indexes ? runMetadataQuery(() => api.listIndexes(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
loadPlan.foreignKeys ? runMetadataQuery(() => api.listForeignKeys(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
loadPlan.triggers ? runMetadataQuery(() => api.listTriggers(context.connectionId, context.database, context.schema, table.name)) : Promise.resolve([]),
ddlPromise,
]);
const detail = { name: table.name, columns, indexes, foreignKeys, triggers, ddl };
context.onProgress?.({ current: ++completed, total: tables.length, objectName: table.name });
return detail;
});
}
@@ -0,0 +1,33 @@
import type { SchemaDiffCompareOptions } from "@/types/schemaDiff";
export type SchemaDiffProgressPhase = "loading-table-lists" | "loading-source-details" | "loading-target-details" | "loading-extra-objects" | "comparing" | "generating" | "complete";
export type SchemaDiffNextProgressStep = "nextSourceDetails" | "nextTargetDetails" | "nextExtraObjects" | "nextComparing" | "nextGenerating" | "nextComplete";
export function isSchemaDiffPostgresLike(dbType: string | null | undefined): boolean {
const normalizedDbType = (dbType || "").toLowerCase();
return normalizedDbType === "postgres" || normalizedDbType === "opengauss";
}
export function shouldLoadSchemaDiffExtraObjects(dbType: string | null | undefined, options: Pick<SchemaDiffCompareOptions, "functions" | "sequences" | "rules" | "owners">): boolean {
return isSchemaDiffPostgresLike(dbType) && (options.functions || options.sequences || options.rules || options.owners);
}
export function getSchemaDiffNextProgressStep(phase: SchemaDiffProgressPhase | undefined, hasExtraObjectPhase: boolean): SchemaDiffNextProgressStep | null {
switch (phase) {
case "loading-table-lists":
return "nextSourceDetails";
case "loading-source-details":
return "nextTargetDetails";
case "loading-target-details":
return hasExtraObjectPhase ? "nextExtraObjects" : "nextComparing";
case "loading-extra-objects":
return "nextComparing";
case "comparing":
return "nextGenerating";
case "generating":
return "nextComplete";
default:
return null;
}
}