perf(diagram): load only FK closure for focused table diagrams

This commit is contained in:
Satrio Ponco Sushadi authored and GitHub committed 2026-10-27 15:05:30 +08:00
1 parent f84a1fa965
commit b9e2eb89af
16 files changed
+1096 -196

No files matched your search

@@ -179,7 +179,7 @@ function connectionIconType(id: string) {
{{ t("diagram.copyJoinSql") }}
</Button>
<Button v-if="focusTableName && tablesCount > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
<Button v-if="focusTableName && tablesCount > 0" :disabled="loadingDiagram" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
</Button>
@@ -1,3 +1,8 @@
<script lang="ts">
// Reopening a cached closure also reuses its namespace choices, avoiding discovery requests.
const diagramNamespaceSnapshots = new Map<string, { databases: string[]; schemas: string[]; defaultSchema: string }>();
</script>
<script setup lang="ts">
import { computed, markRaw, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@@ -15,6 +20,9 @@ import "@vue-flow/minimap/dist/style.css";
import { useConnectionStore } from "@/stores/connectionStore";
import { useGraphStore } from "@/lib/diagram/graph-store";
import * as api from "@/lib/backend/api";
import { loadFocusedDiagram, diagramIdentityKey, diagramIndexes, type FocusedDiagramLoadRequest } from "@/lib/diagram/diagramMetadataLoader";
import { focusedDiagramCacheKey, getFocusedDiagramCache, loadFocusedDiagramCached } from "@/lib/diagram/diagramMetadataCache";
import { saveFocusedDiagramState } from "@/lib/diagram/draft-storage";
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/database/databaseCapabilities";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
import { buildDiagramJoinSql, buildDiagramRelationships, diagramTableId, filterDiagramTables, mergeRelationshipsWithInferred, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramTable, isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
@@ -138,9 +146,9 @@ function deepCloneJson<T>(obj: T): T {
function captureHistorySnapshot(): HistorySnapshot {
const diagramNodes = visibleTables.value.map((table) => ({
id: table.name,
id: getTableKey(table),
type: "table",
position: positions.value[table.name] || { x: 0, y: 0 },
position: positions.value[getTableKey(table)] || { x: 0, y: 0 },
data: { table },
}));
return {
@@ -163,9 +171,9 @@ function recordHistory() {
function syncGraphStoreFromPositions() {
const diagramNodes = visibleTables.value.map((table) => ({
id: table.name,
id: getTableKey(table),
type: "table",
position: positions.value[table.name] || { x: 0, y: 0 },
position: positions.value[getTableKey(table)] || { x: 0, y: 0 },
data: { table },
}));
graphStore.setNodes(diagramNodes);
@@ -209,10 +217,14 @@ function handleRedo() {
if (restored) applyHistorySnapshot(restored);
}
function canLayoutLayer(layer: { tableNames: string[] }): boolean {
return loadedDiagramScope.value !== "focused" || layer.tableNames.every((name) => tables.value.some((table) => getTableKey(table) === name || (table.name === name && table.schema === selectedSchemas.value[0])));
}
function fitAllFreeLayers() {
const heights = tableHeightsMap();
for (const layer of layerStore.layers) {
if ((layer.layoutMode ?? "auto") === "free") {
if (canLayoutLayer(layer) && (layer.layoutMode ?? "auto") === "free") {
sizeLayerToFit(layer, positions.value, heights);
}
}
@@ -222,7 +234,7 @@ function fitAllFreeLayers() {
function fitAllLayers() {
const heights = tableHeightsMap();
for (const layer of layerStore.layers) {
sizeLayerToFit(layer, positions.value, heights);
if (canLayoutLayer(layer)) sizeLayerToFit(layer, positions.value, heights);
}
}
@@ -275,6 +287,7 @@ const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillCatalog?: string;
focusTableName?: string;
focusTableNames?: string[];
}>();
@@ -314,6 +327,16 @@ const totalTableCount = ref(0);
const failedTableCount = ref(0);
const positions = ref<Record<string, DiagramPosition>>({});
const showAllTables = ref(false);
type DiagramLoadMode = "focused" | "full";
const loadedDiagramScope = ref<DiagramLoadMode>("full");
let persistedTableSnapshot: DiagramTable[] = [];
let diagramLoadGeneration = 0;
let initializationGeneration = 0;
let namespaceGeneration = 0;
const focusedRoots = computed(() => {
const names = props.focusTableNames?.length ? props.focusTableNames : props.focusTableName ? [props.focusTableName] : [];
return names.filter((name) => name.trim()).map((name) => ({ name, schema: selectedSchemas.value[0] || schema.value || database.value }));
});
const diagramMode = ref<"table" | "engineering">("table");
const FIT_VIEW_OPTIONS = { padding: 0.15, duration: 150, minZoom: 0.05, maxZoom: 2 } as const;
@@ -404,12 +427,22 @@ const isSchemaAware = computed(() => isSchemaAwareDatabase(selectedConnection.va
const isAllSchemasSelected = computed(() => isSchemaAware.value && schemas.value.length > 0 && schemas.value.every((s) => selectedSchemas.value.includes(s)));
const isMultiSchema = computed(() => isSchemaAware.value && (selectedSchemas.value.length > 1 || isAllSchemasSelected.value));
const isMultiSchema = computed(() => new Set(tables.value.map((table) => table.schema ?? "")).size > 1 || (isSchemaAware.value && (selectedSchemas.value.length > 1 || isAllSchemasSelected.value)));
const tableNameCounts = computed(() => {
const counts = new Map<string, number>();
for (const table of tables.value) counts.set(table.name, (counts.get(table.name) ?? 0) + 1);
return counts;
});
function getTableKey(table: { name: string; schema?: string }): string {
return diagramTableId(table, isMultiSchema.value);
}
function canUseUnqualifiedTableName(table: DiagramTable): boolean {
return tableNameCounts.value.get(table.name) === 1 && (loadedDiagramScope.value !== "focused" || !table.schema || table.schema === selectedSchemas.value[0]);
}
const effectiveSchemaKey = computed(() => {
// Non-schema-aware drivers historically persisted with the database name
// in this slot (loadSchemas assigned schema = database); keep legacy keys.
@@ -431,33 +464,11 @@ const tableMap = computed(() => {
return map;
});
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value, isMultiSchema.value));
const relatedTableNames = computed(() => {
const selected = props.focusTableNames?.filter((name) => name.trim());
if (selected && selected.length > 1) return new Set(selected);
const focus = props.focusTableName;
const names = new Set<string>();
if (!focus) return names;
names.add(focus);
for (const relationship of allRelationships.value) {
if (relationship.sourceTable === focus) names.add(relationship.targetTable);
if (relationship.targetTable === focus) names.add(relationship.sourceTable);
}
return names;
});
const visibleTables = computed(() => {
const filtered = filterDiagramTables(
tables.value.filter((table) => !table.pendingDrop),
tableSearch.value,
);
if ((props.focusTableNames?.length ?? 0) > 1 && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
}
if (props.focusTableName && !showAllTables.value && !tableSearch.value.trim()) {
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
}
return filtered;
});
@@ -619,7 +630,9 @@ function handleLayerChanged() {
}
async function applyAutoLayout(options?: { skipHistory?: boolean }) {
const generation = diagramLoadGeneration;
await nextTick();
if (!open.value || generation !== diagramLoadGeneration) return;
const layoutTables = canvasVisibleTables.value;
if (layoutTables.length === 0) return;
@@ -642,8 +655,9 @@ async function applyAutoLayout(options?: { skipHistory?: boolean }) {
}));
// Only pass visible layers into ELK so hidden layers are not reflowed
const layoutLayers = layerStore.layers.filter((l) => l.visible);
const layoutLayers = layerStore.layers.filter((l) => l.visible && canLayoutLayer(l));
const result = await computeLayoutWithLayers(diagramNodes, diagramEdges, layoutLayers);
if (!open.value || generation !== diagramLoadGeneration) return;
// Freeze positions for tables in hidden layers
const nextPositions: Record<string, DiagramPosition> = { ...positions.value };
@@ -744,6 +758,11 @@ const canSyncStructure = computed(() => supportsTableStructureEditing(selectedCo
function persistDraftAndLayers() {
if (!connectionId.value || !database.value) return;
const schemaKey = effectiveSchemaKey.value;
if (loadedDiagramScope.value === "focused") {
saveFocusedDiagramState(tables.value, persistedTableSnapshot, positions.value, layerStore.toJSON(), connectionId.value, database.value, schemaKey, selectedSchemas.value[0]);
persistedTableSnapshot = deepCloneJson(tables.value);
return;
}
saveDraftTables(tables.value, connectionId.value, database.value, schemaKey);
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
savePersistedLayers(layerStore.toJSON(), connectionId.value, database.value, schemaKey);
@@ -976,7 +995,7 @@ function handleInspectorRemoveRelationship(id: string) {
async function handleSyncedDraftTables(_names: string[]) {
toast(t("diagram.syncToDatabase"), 2000);
await loadDiagram();
await loadDiagram({ force: true });
}
function syncVueFlowNodes() {
@@ -1290,25 +1309,26 @@ async function copyJoinSql() {
async function loadDatabases(id: string) {
if (!id) return;
const generation = namespaceGeneration;
const active = () => open.value && generation === namespaceGeneration && id === connectionId.value;
loadingDatabases.value = true;
databases.value = [];
try {
await store.ensureConnected(id);
if (!active()) return;
const config = store.getConfig(id);
if (config?.db_type === "dameng") {
// 达梦的"数据库"概念对应 schema,使用 fetchNamespaceOptionsForConnection
databases.value = await fetchNamespaceOptionsForConnection(id, config);
} else {
const dbs = await api.listDatabases(id);
databases.value = databaseOptionsForConnection(
dbs.map((db) => db.name),
config,
);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
const names =
config?.db_type === "dameng"
? await fetchNamespaceOptionsForConnection(id, config)
: databaseOptionsForConnection(
(await api.listDatabases(id)).map((db) => db.name),
config,
);
if (active()) databases.value = names;
} catch (error: any) {
if (active()) toast(error?.message || String(error), 5000);
} finally {
loadingDatabases.value = false;
if (active()) loadingDatabases.value = false;
}
}
@@ -1322,60 +1342,72 @@ async function loadSchemas() {
selectedSchemas.value = [database.value];
return;
}
const id = connectionId.value;
const db = database.value;
const catalog = props.prefillCatalog;
const generation = namespaceGeneration;
const active = () => open.value && generation === namespaceGeneration && id === connectionId.value && db === database.value && catalog === props.prefillCatalog;
loadingSchemas.value = true;
try {
const names = await api.listSchemas(connectionId.value, database.value);
const names = await api.listSchemas(id, db);
if (!active()) return;
schemas.value = names;
if (props.prefillSchema && names.includes(props.prefillSchema)) {
schema.value = props.prefillSchema;
selectedSchemas.value = [props.prefillSchema];
} else {
const defaultSchema = names.includes("public") ? "public" : (names[0] ?? "");
schema.value = defaultSchema;
selectedSchemas.value = defaultSchema ? [defaultSchema] : [];
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
const defaultSchema = props.prefillSchema && names.includes(props.prefillSchema) ? props.prefillSchema : names.includes("public") ? "public" : (names[0] ?? "");
schema.value = defaultSchema;
selectedSchemas.value = defaultSchema ? [defaultSchema] : [];
} catch (error: any) {
if (active()) toast(error?.message || String(error), 5000);
} finally {
loadingSchemas.value = false;
if (active()) loadingSchemas.value = false;
}
}
async function setConnection(id: string) {
namespaceGeneration++;
connectionId.value = id;
database.value = "";
schema.value = "";
selectedSchemas.value = [];
tables.value = [];
persistedTableSnapshot = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
matchIgnores.value = [];
matchResult.value = { relationships: [], conflicts: [], pending: [], stats: { total: 0, high: 0, medium: 0 } };
const generation = namespaceGeneration;
await loadDatabases(id);
if (!open.value || generation !== namespaceGeneration || id !== connectionId.value) return;
if (databases.value.length === 1) {
await setDatabase(databases.value[0]);
}
}
async function setDatabase(value: string) {
namespaceGeneration++;
database.value = value;
tables.value = [];
persistedTableSnapshot = [];
selectedSchemas.value = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
matchIgnores.value = [];
matchResult.value = { relationships: [], conflicts: [], pending: [], stats: { total: 0, high: 0, medium: 0 } };
const id = connectionId.value;
const generation = namespaceGeneration;
await loadSchemas();
if (!open.value || generation !== namespaceGeneration || id !== connectionId.value || value !== database.value) return;
diagramNamespaceSnapshots.set(JSON.stringify([id, value, props.prefillCatalog]), { databases: [...databases.value], schemas: [...schemas.value], defaultSchema: schema.value });
if (diagramReady.value) await loadDiagram();
}
async function setSchema(value: string) {
namespaceGeneration++;
schema.value = value;
selectedSchemas.value = value ? [value] : [];
tables.value = [];
persistedTableSnapshot = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
@@ -1385,9 +1417,11 @@ async function setSchema(value: string) {
}
async function setSelectedSchemas(values: string[]) {
namespaceGeneration++;
selectedSchemas.value = [...values];
schema.value = values.length === 1 ? values[0] : "";
tables.value = [];
persistedTableSnapshot = [];
customRelationships.value = [];
positions.value = {};
matchConfirms.value = [];
@@ -1396,135 +1430,156 @@ async function setSelectedSchemas(values: string[]) {
if (diagramReady.value) await loadDiagram();
}
async function loadTableDiagramData(tableName: string, querySchema: string, tableComment?: string | null): Promise<DiagramTable> {
try {
const [columns, foreignKeys, indexes] = await Promise.all([
api.getColumns(connectionId.value, database.value, querySchema, tableName),
api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => []),
api.listIndexes(connectionId.value, database.value, querySchema, tableName).catch(() => []),
]);
return {
name: tableName,
schema: querySchema,
comment: tableComment ?? null,
columns,
foreignKeys,
indexes: indexes.map((index) => ({
id: index.name,
name: index.name,
columns: [...index.columns],
isUnique: index.is_unique,
isPrimary: index.is_primary,
filter: index.filter ?? "",
indexType: index.index_type ?? "",
includedColumns: index.included_columns ?? [],
comment: index.comment ?? "",
markedForDrop: false,
original: index,
})),
};
} catch (e) {
failedTableCount.value += 1;
console.warn(`[diagram] failed to load table metadata: ${querySchema}.${tableName}`, e);
return { name: tableName, schema: querySchema, comment: tableComment ?? null, columns: [], foreignKeys: [], indexes: [] };
}
}
async function loadDiagram() {
if (!diagramReady.value) return;
loadingDiagram.value = true;
tables.value = [];
positions.value = {};
loadedTableCount.value = 0;
totalTableCount.value = 0;
failedTableCount.value = 0;
try {
await store.ensureConnected(connectionId.value);
const targetSchemas = isSchemaAware.value ? selectedSchemas.value : [database.value];
const schemaTableLists = await Promise.all(
targetSchemas.map(async (sc) => {
async function loadFullDiagram(request: FocusedDiagramLoadRequest, targetSchemas: string[], active: () => boolean): Promise<DiagramTable[]> {
const { connectionId: id, database: db, catalog } = request;
const lists = await Promise.all(
targetSchemas.map(async (sc) => {
try {
const list = await api.listTables(id, db, sc, undefined, undefined, undefined, undefined, catalog);
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc, comment: table.comment ?? null }));
} catch (error) {
console.warn(`[diagram] failed to list tables for schema ${sc}`, error);
return [];
}
}),
);
const baseTables = lists.flat().sort((a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name));
if (active()) totalTableCount.value = baseTables.length;
const result: DiagramTable[] = [];
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
if (!active()) return result;
const batch = await Promise.all(
baseTables.slice(index, index + METADATA_BATCH_SIZE).map(async (table) => {
try {
const list = await api.listTables(connectionId.value, database.value, sc);
// Keep the table comment so the card can render the table description.
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc, comment: table.comment ?? null }));
} catch (e) {
console.warn(`[diagram] failed to list tables for schema ${sc}`, e);
return [];
const [columns, foreignKeys, indexes] = await Promise.all([api.getColumns(id, db, table.schema, table.name, catalog), api.listForeignKeys(id, db, table.schema, table.name, catalog).catch(() => []), api.listIndexes(id, db, table.schema, table.name, catalog).catch(() => [])]);
return { ...table, columns, foreignKeys, indexes: diagramIndexes(indexes) };
} catch (error) {
if (active()) failedTableCount.value += 1;
console.warn(`[diagram] failed to load table metadata: ${table.schema}.${table.name}`, error);
return { ...table, columns: [], foreignKeys: [], indexes: [] };
}
}),
);
const baseTables = schemaTableLists.flat().sort((a, b) => {
if (a.schema !== b.schema) return a.schema.localeCompare(b.schema);
return a.name.localeCompare(b.name);
});
totalTableCount.value = baseTables.length;
result.push(...batch);
if (active()) loadedTableCount.value = result.length;
}
return result;
}
const loadedTables: DiagramTable[] = [];
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
const batchTables = await Promise.all(batch.map((item) => loadTableDiagramData(item.name, item.schema, item.comment)));
loadedTables.push(...batchTables);
loadedTableCount.value = loadedTables.length;
}
function diagramContextKey(): string {
return JSON.stringify([connectionId.value, database.value, props.prefillCatalog, schema.value, selectedSchemas.value, focusedRoots.value]);
}
tables.value = loadedTables;
const schemaKey = effectiveSchemaKey.value;
const drafts = loadDraftTables(connectionId.value, database.value, schemaKey);
const liveKeys = new Set(loadedTables.map((t) => getTableKey(t)));
const pendingDrafts = drafts.filter((d) => !liveKeys.has(getTableKey(d)) && !liveKeys.has(d.name));
if (pendingDrafts.length) {
tables.value = [...loadedTables, ...pendingDrafts];
}
const livePatches = loadLiveTablePatches(connectionId.value, database.value, schemaKey);
tables.value = applyLiveTablePatches(tables.value, livePatches);
saveDraftTables(tables.value.filter(isDraftTable), connectionId.value, database.value, schemaKey);
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
const savedLayers = loadPersistedLayers(connectionId.value, database.value, schemaKey);
if (savedLayers.length) {
layerStore.loadLayers(savedLayers);
if (isMultiSchema.value) {
const rawNameCounts = new Map<string, number>();
for (const table of tables.value) {
rawNameCounts.set(table.name, (rawNameCounts.get(table.name) ?? 0) + 1);
}
for (const layer of layerStore.layers) {
layer.tableNames = layer.tableNames.map((name) => {
if (!name.includes(".")) {
const matchedTable = tables.value.find((t) => t.name === name);
if (matchedTable && rawNameCounts.get(name) === 1 && matchedTable.schema) {
return `${matchedTable.schema}.${matchedTable.name}`;
}
}
return name;
});
}
async function loadDiagram(options: { mode?: DiagramLoadMode; force?: boolean } = {}): Promise<void> {
if (!diagramReady.value) return;
const generation = ++diagramLoadGeneration;
const context = diagramContextKey();
const active = () => open.value && generation === diagramLoadGeneration && context === diagramContextKey();
const mode = options.mode ?? (focusedRoots.value.length && !showAllTables.value ? "focused" : "full");
if (tables.value.length === 0) loadedDiagramScope.value = mode;
const request: FocusedDiagramLoadRequest = {
connectionId: connectionId.value,
database: database.value,
catalog: props.prefillCatalog,
roots: focusedRoots.value,
concurrency: METADATA_BATCH_SIZE,
onProgress: (loaded, discovered) => {
if (!active()) return;
loadedTableCount.value = loaded;
totalTableCount.value = discovered;
},
};
const key = focusedDiagramCacheKey(request);
const cached = mode === "focused" && !options.force ? getFocusedDiagramCache(key) : undefined;
// Check memory before connecting or showing the remote loading state.
loadingDiagram.value = !cached;
loadedTableCount.value = cached?.length ?? 0;
totalTableCount.value = cached?.length ?? 0;
failedTableCount.value = 0;
try {
let liveTables = cached;
if (!liveTables) {
const { connectionId: id, database: db, catalog } = request;
if (mode === "focused") {
liveTables = await loadFocusedDiagramCached(
key,
async () => {
await store.ensureConnected(id);
const result = await loadFocusedDiagram(request, {
getColumns: (table) => api.getColumns(id, db, table.schema, table.name, catalog),
listForeignKeys: (table) => api.listForeignKeys(id, db, table.schema, table.name, catalog),
listIndexes: (table) => api.listIndexes(id, db, table.schema, table.name, catalog),
getTableComment: (table) => api.getTableComment(id, db, table.schema, table.name, catalog),
});
return result.tables;
},
{ force: options.force },
);
} else {
const targetSchemas = isSchemaAware.value ? [...selectedSchemas.value] : [db];
await store.ensureConnected(id);
if (!active()) return;
liveTables = await loadFullDiagram(request, targetSchemas, active);
}
}
loadCustomRelationships();
loadMatchData();
updateRelationshipDraftDefaults();
showAllTables.value = false;
if (failedTableCount.value > 0) {
toast(t("diagram.partialError", { count: failedTableCount.value }), 5000);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
if (!active()) return;
loadedTableCount.value = totalTableCount.value = liveTables.length;
await applyLoadedDiagramTables(liveTables, mode, active);
if (active() && failedTableCount.value > 0) toast(t("diagram.partialError", { count: failedTableCount.value }), 5000);
} catch (error: any) {
if (active()) toast(error?.message || String(error), 5000);
} finally {
loadingDiagram.value = false;
}
if (tables.value.length > 0) {
await nextTick();
await restoreOrInitializeLayout();
graphStore.clearHistory();
} else {
syncVueFlowNodes();
if (active()) loadingDiagram.value = false;
}
}
async function applyLoadedDiagramTables(liveTables: DiagramTable[], mode: DiagramLoadMode, active: () => boolean) {
const schemaKey = effectiveSchemaKey.value;
const drafts = loadDraftTables(connectionId.value, database.value, schemaKey);
const identity = (table: DiagramTable) => diagramIdentityKey({ ...table, schema: table.schema ?? selectedSchemas.value[0] ?? schema.value });
const liveKeys = new Set(liveTables.map(identity));
const pendingDrafts = drafts.filter((draft) => !liveKeys.has(identity(draft)));
const patches = loadLiveTablePatches(connectionId.value, database.value, schemaKey);
const nextTables = applyLiveTablePatches([...liveTables, ...pendingDrafts], patches, selectedSchemas.value.length === 1 ? selectedSchemas.value[0] : undefined);
tables.value = nextTables;
loadedDiagramScope.value = mode;
showAllTables.value = mode === "full" && focusedRoots.value.length > 0;
positions.value = {};
// Metadata reconciliation must not be mistaken for an edit to a saved live patch.
persistedTableSnapshot = deepCloneJson(nextTables);
if (mode === "full") {
saveDraftTables(nextTables, connectionId.value, database.value, schemaKey);
saveLiveTablePatches(nextTables, connectionId.value, database.value, schemaKey);
} else if (pendingDrafts.length !== drafts.length) {
// Only remove drafts whose exact identities were confirmed live by this load.
saveDraftTables(pendingDrafts, connectionId.value, database.value, schemaKey);
}
layerStore.loadLayers(loadPersistedLayers(connectionId.value, database.value, schemaKey));
if (isMultiSchema.value) {
for (const layer of layerStore.layers) {
layer.tableNames = layer.tableNames.map((name) => {
const matches = tables.value.filter((table) => table.name === name && (mode !== "focused" || !table.schema || table.schema === selectedSchemas.value[0]));
return matches.length === 1 ? getTableKey(matches[0]) : name;
});
}
}
loadCustomRelationships();
loadMatchData();
updateRelationshipDraftDefaults();
loadingDiagram.value = false;
await nextTick();
if (!active()) return;
await restoreOrInitializeLayout(active);
if (active()) graphStore.clearHistory();
}
async function handleToggleShowAllTables(): Promise<void> {
if (loadingDiagram.value || !focusedRoots.value.length) return;
await loadDiagram({ mode: showAllTables.value ? "focused" : "full" });
}
/** Restore saved table positions, or run layer-aware LTR layout when none exist. */
async function restoreOrInitializeLayout() {
async function restoreOrInitializeLayout(active: () => boolean = () => true) {
const schemaKey = effectiveSchemaKey.value;
const tableKeys = tables.value.map((t) => getTableKey(t));
const tableNames = tables.value.map((t) => t.name);
@@ -1534,7 +1589,7 @@ async function restoreOrInitializeLayout() {
const next: Record<string, DiagramPosition> = {};
for (const table of tables.value) {
const key = getTableKey(table);
const pos = savedPositions[key] || savedPositions[table.name];
const pos = savedPositions[key] || (canUseUnqualifiedTableName(table) ? savedPositions[table.name] : undefined);
if (pos) next[key] = { ...pos };
}
positions.value = next;
@@ -1547,13 +1602,13 @@ async function restoreOrInitializeLayout() {
return;
}
await applyInitialLayerAwareLayout();
await applyInitialLayerAwareLayout(active);
}
/** Place tables that have no saved position into their layer (or canvas grid). */
function fillMissingTablePositions() {
const heights = tableHeightsMap();
const missing = tables.value.filter((t) => !positions.value[getTableKey(t)] && !positions.value[t.name]);
const missing = tables.value.filter((t) => !positions.value[getTableKey(t)]);
if (missing.length === 0) return;
for (const table of missing) {
@@ -1596,8 +1651,9 @@ function fillMissingTablePositions() {
}
}
async function applyInitialLayerAwareLayout() {
async function applyInitialLayerAwareLayout(active: () => boolean = () => true) {
await nextTick();
if (!active()) return;
const layoutTables = canvasVisibleTables.value;
if (layoutTables.length === 0) {
syncVueFlowNodes();
@@ -1606,7 +1662,7 @@ async function applyInitialLayerAwareLayout() {
// Free layers with no member positions cannot preserve relative layout — treat as auto for init.
const layersForLayout = layerStore.layers
.filter((l) => l.visible)
.filter((l) => l.visible && canLayoutLayer(l))
.map((l) => {
const hasMemberPos = l.tableNames.some((name) => positions.value[name]);
if ((l.layoutMode ?? "auto") === "free" && !hasMemberPos) {
@@ -1616,7 +1672,7 @@ async function applyInitialLayerAwareLayout() {
});
const result = computeLtrAutoLayout({
tables: layoutTables,
tables: layoutTables.map((table) => ({ ...table, name: getTableKey(table) })),
positions: positions.value,
layers: layersForLayout,
paneWidth: diagramPaneWidth(),
@@ -1632,7 +1688,7 @@ async function applyInitialLayerAwareLayout() {
if (result.positions[table.name] && !nextPositions[key]) {
nextPositions[key] = result.positions[table.name];
}
if (result.positions[key] && !nextPositions[table.name]) {
if (result.positions[key] && !nextPositions[table.name] && canUseUnqualifiedTableName(table)) {
nextPositions[table.name] = result.positions[key];
}
}
@@ -1660,10 +1716,17 @@ function requestRefreshDiagram() {
function confirmRefreshDiagram() {
showRefreshConfirm.value = false;
void loadDiagram();
void loadDiagram({ force: true });
}
async function initialize() {
namespaceGeneration++;
const generation = ++initializationGeneration;
diagramLoadGeneration++;
loadingDiagram.value = false;
loadingDatabases.value = false;
loadingSchemas.value = false;
persistedTableSnapshot = [];
connectionId.value = "";
database.value = "";
schema.value = "";
@@ -1687,9 +1750,26 @@ async function initialize() {
graphStore.setNodes([]);
graphStore.setEdges([]);
if (props.prefillConnectionId && props.prefillDatabase && (props.focusTableName || props.focusTableNames?.length)) {
const names = props.focusTableNames?.length ? props.focusTableNames : [props.focusTableName!];
const namespace = diagramNamespaceSnapshots.get(JSON.stringify([props.prefillConnectionId, props.prefillDatabase, props.prefillCatalog]));
const rootSchema = props.prefillSchema || namespace?.defaultSchema || props.prefillDatabase;
const request = { connectionId: props.prefillConnectionId, database: props.prefillDatabase, catalog: props.prefillCatalog, roots: names.map((name) => ({ name, schema: rootSchema })) };
if (namespace && getFocusedDiagramCache(focusedDiagramCacheKey(request))) {
connectionId.value = request.connectionId;
database.value = request.database;
schema.value = rootSchema;
databases.value = [...namespace.databases];
schemas.value = [...namespace.schemas];
selectedSchemas.value = [rootSchema];
await loadDiagram();
return;
}
}
if (props.prefillConnectionId) {
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
if (!open.value || generation !== initializationGeneration) return;
const initialDatabase = props.prefillDatabase && databases.value.includes(props.prefillDatabase) ? props.prefillDatabase : props.prefillDatabase || databases.value[0] || "";
if (initialDatabase) await setDatabase(initialDatabase);
return;
@@ -2051,6 +2131,22 @@ function handleKeyup(e: KeyboardEvent) {
}
}
watch(
diagramContextKey,
() => {
diagramLoadGeneration++;
loadingDiagram.value = false;
},
{ flush: "sync" },
);
watch(
() => [props.prefillConnectionId, props.prefillDatabase, props.prefillSchema, props.prefillCatalog, props.focusTableName, JSON.stringify(props.focusTableNames)],
() => {
if (open.value) void initialize();
},
);
watch(
open,
(value) => {
@@ -2058,6 +2154,10 @@ watch(
void initialize();
void setupFullscreenListeners();
} else {
diagramLoadGeneration++;
initializationGeneration++;
namespaceGeneration++;
loadingDiagram.value = false;
teardownFullscreenListeners();
if (diagramOwnedFullscreen) {
void exitFullscreen();
@@ -2108,6 +2208,9 @@ onMounted(() => {
});
onUnmounted(() => {
diagramLoadGeneration++;
initializationGeneration++;
namespaceGeneration++;
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("keyup", handleKeyup);
if (engineeringViewportFrame !== null) {
@@ -2156,7 +2259,7 @@ onUnmounted(() => {
:show-match-panel="showMatchPanel"
:show-layers-panel="showLayersPanel"
:show-all-tables="showAllTables"
:focus-table-name="focusTableName ?? ''"
:focus-table-name="focusedRoots[0]?.name ?? ''"
:generated-join-sql="generatedJoinSql"
:is-fullscreen="isFullscreen"
:draft-table-count="draftTableCount"
@@ -2171,7 +2274,7 @@ onUnmounted(() => {
@toggle-match-panel="handleToggleMatchPanel"
@toggle-layers-panel="showLayersPanel = !showLayersPanel"
@copy-join-sql="copyJoinSql"
@toggle-show-all-tables="showAllTables = !showAllTables"
@toggle-show-all-tables="handleToggleShowAllTables"
@export-format="exportDiagram"
@refresh="requestRefreshDiagram"
@zoom-out="zoomOut"
@@ -2200,7 +2303,7 @@ onUnmounted(() => {
</div>
<ResizerHandle v-if="showLayersPanel && diagramReady" @resize="handleLeftResize" />
<div class="min-h-0 flex-1 overflow-hidden">
<div v-if="loadingDiagram" class="h-full flex items-center justify-center text-sm text-muted-foreground">
<div v-if="loadingDiagram && tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ loadingText }}
</div>
@@ -82,6 +82,7 @@ afterEach(() => {
function mountToolbar() {
const search = ref("");
const updates: string[] = [];
const refreshes: number[] = [];
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
@@ -100,7 +101,7 @@ function mountToolbar() {
loadingDatabases: false,
loadingSchemas: false,
loadingDiagram: false,
diagramReady: false,
diagramReady: true,
tablesCount: 0,
relationshipsCount: 0,
customRelationshipCount: 0,
@@ -116,13 +117,14 @@ function mountToolbar() {
updates.push(value);
search.value = value;
},
onRefresh: () => refreshes.push(Date.now()),
});
},
}),
);
app.mount(host);
mountedApps.push({ app, host });
return { host, search, updates };
return { host, search, updates, refreshes };
}
describe("DiagramToolbar", () => {
@@ -142,4 +144,16 @@ describe("DiagramToolbar", () => {
expect(search.value).toBe("user");
expect(input.value).toBe("user");
});
it("renders exactly one existing Refresh control and emits one refresh action", async () => {
const { host, refreshes } = mountToolbar();
await nextTick();
const refreshButtons = host.querySelectorAll<HTMLButtonElement>('button[title="diagram.refresh"]');
expect(refreshButtons).toHaveLength(1);
refreshButtons[0].click();
await nextTick();
expect(refreshes).toHaveLength(1);
});
});
@@ -0,0 +1,298 @@
// @vitest-environment happy-dom
import { createApp, nextTick, reactive, type App } from "vue";
import { createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearFocusedDiagramCacheForTests } from "@/lib/diagram/diagramMetadataCache";
import { loadDraftTables, loadLiveTablePatches, loadPersistedLayers, loadPersistedPositions, saveDraftTables, saveLiveTablePatches, savePersistedLayers, savePersistedPositions } from "@/lib/diagram/draft-storage";
import type { DiagramTable } from "@/lib/diagram/erDiagram";
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(async () => {}),
toast: vi.fn(),
listDatabases: vi.fn(async () => [{ name: "db" }]),
listSchemas: vi.fn(async () => ["public", "other"]),
listTables: vi.fn(async () => ["A", "B", "C", "unrelated"].map((name) => ({ name, table_type: "TABLE" }))),
getColumns: vi.fn(async () => [{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true, column_default: null, extra: null }]),
listForeignKeys: vi.fn(async (_id: string, _db: string, _schema: string, name: string): Promise<ForeignKeyInfo[]> => (name === "A" || name === "B" ? [{ name: "fk", column: "id", ref_table: name === "A" ? "B" : "C", ref_column: "id" }] : [])),
listForeignKeysForDatabase: vi.fn(async () => ({})),
listIndexes: vi.fn(async () => []),
getTableComment: vi.fn(async () => null),
}));
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [{ id: "c", db_type: "postgres" }], getConfig: () => ({ id: "c", db_type: "postgres" }), ensureConnected: mocks.ensureConnected }) }));
vi.mock("@vue-flow/core", async () => {
const { ref } = await import("vue");
return {
VueFlow: {},
useVueFlow: () => {
const nodes = ref([]);
const edges = ref([]);
return {
nodes,
edges,
setNodes: (value: []) => {
nodes.value = value;
},
setEdges: (value: []) => {
edges.value = value;
},
fitView: vi.fn(),
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
};
},
};
});
vi.mock("../DiagramInspector.vue", () => ({ default: {} }));
vi.mock("../DiagramSyncDialog.vue", () => ({ default: {} }));
vi.mock("../CreateDraftTableDialog.vue", () => ({ default: {} }));
vi.mock("../TableNode.vue", () => ({ default: {} }));
vi.mock("../LayerNode.vue", () => ({ default: {} }));
vi.mock("../RelationshipEdge.vue", () => ({ default: {} }));
vi.mock("../MatchPanel.vue", () => ({ default: {} }));
vi.mock("../DiagramToolbar.vue", () => ({ default: {} }));
vi.mock("../LayerPanel.vue", () => ({ default: {} }));
import SchemaDiagramDialog from "../SchemaDiagramDialog.vue";
const mounted: App[] = [];
async function settle() {
for (let i = 0; i < 40; i++) {
await Promise.resolve();
await nextTick();
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((a, b) => {
resolve = a;
reject = b;
});
return { promise, resolve, reject };
}
async function mountDialog(focused = true, overrides: { focusTableNames?: string[]; prefillSchema?: string } = {}) {
const props = reactive({ open: true, prefillConnectionId: "c", prefillDatabase: "db", prefillSchema: "public", prefillCatalog: "catalog", focusTableName: focused ? "A" : "", ...overrides });
// Exercise the actual SFC setup, watchers, loaders and layout/persistence with the canvas renderer omitted.
const app = createApp({ ...SchemaDiagramDialog, render: () => null }, props);
app.use(createPinia());
const instance = app.mount(document.createElement("div"));
mounted.push(app);
const state = instance.$.setupState as any;
await settle();
return { app, state, props };
}
beforeEach(() => {
clearFocusedDiagramCacheForTests();
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
for (const app of mounted.splice(0)) app.unmount();
});
describe("diagram loading integration", () => {
it("loads the recursive closure without listing tables and passes catalog", async () => {
const { state } = await mountDialog();
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A", "B", "C"]);
expect(state.visibleTables).toHaveLength(3);
expect(mocks.listTables).not.toHaveBeenCalled();
expect(mocks.getColumns).toHaveBeenCalledWith("c", "db", "public", "C", "catalog");
expect(mocks.listForeignKeys).toHaveBeenCalledTimes(3);
expect(mocks.listForeignKeysForDatabase).not.toHaveBeenCalled();
expect(state.loadingDiagram).toBe(false);
});
it("reopens from cache without any remote metadata or connection requests", async () => {
await mountDialog();
vi.clearAllMocks();
const { state } = await mountDialog();
expect(state.tables).toHaveLength(3);
expect(state.loadedTableCount).toBe(3);
expect(state.totalTableCount).toBe(3);
expect(state.loadingDiagram).toBe(false);
for (const method of [mocks.getColumns, mocks.listForeignKeys, mocks.listIndexes, mocks.getTableComment, mocks.listTables, mocks.listDatabases, mocks.listSchemas, mocks.ensureConnected]) expect(method).not.toHaveBeenCalled();
});
it("retains full-schema loading when opened without a root", async () => {
const { state } = await mountDialog(false);
expect(mocks.listTables).toHaveBeenCalledTimes(1);
expect(state.tables).toHaveLength(4);
});
it("loads a deduplicated union of multiple roots", async () => {
const { state } = await mountDialog(true, { focusTableNames: ["A", "B", "unrelated"] });
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A", "B", "C", "unrelated"]);
expect(mocks.getColumns).toHaveBeenCalledTimes(4);
expect(mocks.listTables).not.toHaveBeenCalled();
});
it("reuses cached default-schema roots when no schema was prefilled", async () => {
await mountDialog(true, { prefillSchema: undefined });
vi.clearAllMocks();
expect((await mountDialog(true, { prefillSchema: undefined })).state.tables).toHaveLength(3);
expect(mocks.listSchemas).not.toHaveBeenCalled();
expect(mocks.getColumns).not.toHaveBeenCalled();
});
it("initial metadata failure leaves an empty diagram and retries without a partial cache", async () => {
mocks.listIndexes.mockRejectedValueOnce(new Error("indexes offline"));
const { state } = await mountDialog();
expect(state.tables).toEqual([]);
expect(state.loadingDiagram).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("indexes offline", 5000);
vi.clearAllMocks();
expect((await mountDialog()).state.tables).toHaveLength(3);
expect(mocks.getColumns).toHaveBeenCalledTimes(3);
});
it("Show all tables loads full metadata and switching back reuses the focused cache", async () => {
const { state } = await mountDialog();
await state.handleToggleShowAllTables();
expect(state.showAllTables).toBe(true);
expect(state.tables).toHaveLength(4);
expect(mocks.listTables).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
await state.handleToggleShowAllTables();
expect(state.showAllTables).toBe(false);
expect(state.tables).toHaveLength(3);
expect(mocks.getColumns).not.toHaveBeenCalled();
});
it("confirmation bypasses cache while failure keeps the previous diagram and cache", async () => {
const { state } = await mountDialog();
const oldTables = state.tables;
const pending = deferred<never>();
mocks.listForeignKeys.mockImplementationOnce(() => pending.promise);
state.requestRefreshDiagram();
expect(state.showRefreshConfirm).toBe(true);
state.confirmRefreshDiagram();
await settle();
expect(state.tables).toBe(oldTables);
expect(state.loadingDiagram).toBe(true);
pending.reject(new Error("offline"));
await settle();
expect(state.tables).toBe(oldTables);
expect(state.loadingDiagram).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("offline", 5000);
vi.clearAllMocks();
const reopened = await mountDialog();
expect(reopened.state.tables).toHaveLength(3);
expect(mocks.getColumns).not.toHaveBeenCalled();
});
it("successful refresh replaces the cached closure", async () => {
const { state } = await mountDialog();
mocks.listForeignKeys.mockResolvedValueOnce([]);
state.confirmRefreshDiagram();
await settle();
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A"]);
vi.clearAllMocks();
expect((await mountDialog()).state.tables).toHaveLength(1);
expect(mocks.getColumns).not.toHaveBeenCalled();
});
it("refresh fetches and applies the latest columns, indexes, and foreign keys before replacing the cache", async () => {
const { state } = await mountDialog();
vi.clearAllMocks();
const refreshedColumns: ColumnInfo[] = [
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true, column_default: null, extra: null },
{ name: "refreshed_column", data_type: "text", is_nullable: true, is_primary_key: false, column_default: null, extra: null },
];
const refreshedIndexes: IndexInfo[] = [{ name: "ux_refreshed", columns: ["refreshed_column"], is_unique: true, is_primary: false }];
const refreshedForeignKeys: ForeignKeyInfo[] = [{ name: "fk_refreshed", column: "id", ref_table: "D", ref_column: "id" }];
mocks.getColumns.mockResolvedValueOnce(refreshedColumns);
mocks.listIndexes.mockResolvedValueOnce(refreshedIndexes);
mocks.listForeignKeys.mockResolvedValueOnce(refreshedForeignKeys);
state.confirmRefreshDiagram();
await settle();
expect(mocks.getColumns).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
expect(mocks.listIndexes).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
expect(mocks.listForeignKeys).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
expect(mocks.listForeignKeysForDatabase).not.toHaveBeenCalled();
const refreshedRoot = state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
expect(refreshedRoot.columns.map((column) => column.name)).toEqual(["id", "refreshed_column"]);
expect(refreshedRoot.indexes?.map((index) => index.name)).toEqual(["ux_refreshed"]);
expect(refreshedRoot.foreignKeys).toEqual(refreshedForeignKeys);
expect(state.visibleRelationships).toEqual([expect.objectContaining({ name: "fk_refreshed", sourceTable: "A", targetTable: "D" })]);
vi.clearAllMocks();
const reopened = await mountDialog();
const cachedRoot = reopened.state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
expect(cachedRoot.columns.map((column) => column.name)).toEqual(["id", "refreshed_column"]);
expect(cachedRoot.indexes?.map((index) => index.name)).toEqual(["ux_refreshed"]);
expect(cachedRoot.foreignKeys).toEqual(refreshedForeignKeys);
for (const method of [mocks.getColumns, mocks.listIndexes, mocks.listForeignKeys, mocks.listForeignKeysForDatabase]) expect(method).not.toHaveBeenCalled();
});
it("qualifies same-name nodes from different schemas", async () => {
mocks.listForeignKeys.mockResolvedValueOnce([{ name: "fk", column: "id", ref_table: "A", ref_schema: "other", ref_column: "id" }]);
mocks.listForeignKeys.mockResolvedValueOnce([]);
const { state } = await mountDialog();
expect(state.isMultiSchema).toBe(true);
expect(state.tables.map((table: DiagramTable) => state.getTableKey(table))).toEqual(["other.A", "public.A"]);
expect(state.visibleRelationships[0]).toMatchObject({ sourceTable: "public.A", targetTable: "other.A" });
expect(state.positions["public.A"]).toBeDefined();
expect(state.positions["other.A"]).toBeDefined();
expect(state.positions["public.A"]).not.toEqual(state.positions["other.A"]);
});
it("a late response cannot replace a newer schema result or its loading state", async () => {
const { state } = await mountDialog();
const pending = deferred<never[]>();
mocks.getColumns.mockImplementationOnce(() => pending.promise);
const old = state.loadDiagram({ force: true });
await settle();
await state.setSchema("other");
expect(state.tables.every((table: DiagramTable) => table.schema === "other")).toBe(true);
const current = state.tables;
pending.resolve([]);
await old;
expect(state.tables).toBe(current);
expect(state.loadingDiagram).toBe(false);
});
it("ignores a pending result after closing the dialog", async () => {
const { state } = await mountDialog();
const pending = deferred<never[]>();
mocks.getColumns.mockImplementationOnce(() => pending.promise);
const load = state.loadDiagram({ force: true });
await settle();
state.open = false;
await settle();
const old = state.tables;
pending.resolve([]);
await load;
expect(state.tables).toBe(old);
expect(state.loadingDiagram).toBe(false);
});
it("opening, dragging, editing, and refreshing preserves unrelated persisted data", async () => {
const draft: DiagramTable = { name: "draft_elsewhere", schema: "public", origin: "draft", columns: [], foreignKeys: [] };
saveDraftTables([draft], "c", "db", "public");
saveLiveTablePatches([{ ...draft, name: "unrelated", origin: "live", pendingDrop: true }], "c", "db", "public");
savePersistedPositions({ unrelated: { x: 900, y: 800 } }, "c", "db", "public");
savePersistedLayers([{ id: "layer", name: "saved", color: "red", visible: true, collapsed: false, tableNames: ["unrelated"] }], "c", "db", "public");
const { state } = await mountDialog();
await state.handleNodeDragStop({ node: { id: "A", type: "table", position: { x: 12, y: 34 } } });
const root = state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
const pendingColumn: ColumnInfo = { name: "pending_note", data_type: "text", is_nullable: true, is_primary_key: false, column_default: null, extra: null };
state.handleInspectorUpdateTable({ ...root, columns: [...root.columns, pendingColumn], pendingColumnNames: [pendingColumn.name] });
await state.loadDiagram({ force: true });
expect(loadPersistedPositions("c", "db", "public").A).toEqual({ x: 12, y: 34 });
expect(
loadLiveTablePatches("c", "db", "public")
.find((patch) => patch.tableName === "A")
?.pendingColumns.map((column) => column.name),
).toEqual(["pending_note"]);
expect(loadDraftTables("c", "db", "public").map((table) => table.name)).toContain("draft_elsewhere");
expect(loadLiveTablePatches("c", "db", "public").find((patch) => patch.tableName === "unrelated")?.pendingDrop).toBe(true);
expect(loadPersistedPositions("c", "db", "public").unrelated).toEqual({ x: 900, y: 800 });
expect(loadPersistedLayers("c", "db", "public")[0].tableNames).toContain("unrelated");
});
});
@@ -284,6 +284,7 @@ watch(
:prefill-connection-id="dialogs.diagramPrefillConnectionId.value"
:prefill-database="dialogs.diagramPrefillDatabase.value"
:prefill-schema="dialogs.diagramPrefillSchema.value"
:prefill-catalog="dialogs.diagramPrefillCatalog.value"
:focus-table-name="dialogs.diagramFocusTableName.value"
:focus-table-names="dialogs.diagramFocusTableNames.value"
@open-target="emit('openDiagramTarget', $event)"
@@ -3225,6 +3225,7 @@ function openDiagram(row: ObjectBrowserRow) {
connectionStore.diagramSource = {
connectionId: props.connection.id,
database: props.database,
catalog: props.catalog,
schema: row.schema || selectedSchema.value,
tableName: row.type === "TABLE" ? row.name : undefined,
};
@@ -75,6 +75,7 @@ const sqlFilePrefillPreview = ref<SqlFilePreview>();
const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
const diagramPrefillCatalog = ref<string>();
const diagramFocusTableName = ref("");
const diagramFocusTableNames = ref<string[]>([]);
const docsPrefillConnectionId = ref("");
@@ -257,6 +258,7 @@ export function useDialogSources() {
diagramPrefillConnectionId.value = v.connectionId;
diagramPrefillDatabase.value = v.database;
diagramPrefillSchema.value = v.schema ?? "";
diagramPrefillCatalog.value = v.catalog;
diagramFocusTableName.value = v.tableName ?? "";
diagramFocusTableNames.value = v.tableNames ?? (v.tableName ? [v.tableName] : []);
showDiagramDialog.value = true;
@@ -668,6 +670,7 @@ export function useDialogSources() {
diagramPrefillConnectionId,
diagramPrefillDatabase,
diagramPrefillSchema,
diagramPrefillCatalog,
diagramFocusTableName,
diagramFocusTableNames,
docsPrefillConnectionId,
@@ -82,6 +82,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
connectionStore.diagramSource = {
connectionId: node.connectionId,
database: node.database,
catalog: node.catalog,
schema: node.schema,
tableName: tableNames?.length ? tableNames[0] : node.type === "table" ? node.label : undefined,
tableNames,
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyLiveTablePatches, loadDraftTables, loadLiveTablePatches, loadPersistedLayers, loadPersistedPositions, saveDraftTables, saveFocusedDiagramState, saveLiveTablePatches, savePersistedLayers, savePersistedPositions } from "../draft-storage";
import type { DiagramTable } from "../erDiagram";
const context = ["c", "db", "public"] as const;
const live = (name: string, schema = "public"): DiagramTable => ({ name, schema, columns: [], foreignKeys: [] });
const draft = (name: string): DiagramTable => ({ ...live(name), origin: "draft" });
beforeEach(() => {
const values = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
});
afterEach(() => vi.unstubAllGlobals());
describe("focused persistence", () => {
it("opening, moving, and refreshing a subset preserves unrelated drafts, patches, layers, and positions", () => {
saveDraftTables([draft("unrelated_draft")], ...context);
saveLiveTablePatches([{ ...live("unrelated"), pendingDrop: true }], ...context);
savePersistedPositions({ unrelated: { x: 100, y: 200 } }, ...context);
const layers = [{ id: "layer", name: "saved", tableNames: ["root", "unrelated"], color: "red", visible: true, collapsed: false }];
savePersistedLayers(layers, ...context);
const root = live("root");
for (const positions of [{}, { root: { x: 12, y: 34 } }, {}]) saveFocusedDiagramState([root], [root], positions, [{ ...layers[0], tableNames: ["root"] }], ...context);
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated_draft"]);
expect(loadLiveTablePatches(...context)[0].tableName).toBe("unrelated");
expect(loadPersistedPositions(...context)).toEqual({ unrelated: { x: 100, y: 200 }, root: { x: 12, y: 34 } });
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["root", "unrelated"]);
});
it("upserts and removes only the edited live patch, keeping same-name patches in other schemas", () => {
const root = live("root");
saveLiveTablePatches(
[
{ ...live("root", "other"), pendingDrop: true },
{ ...live("unrelated"), pendingDrop: true },
],
...context,
);
const edited = { ...root, pendingDrop: true };
saveFocusedDiagramState([edited], [root], {}, [], ...context);
expect(loadLiveTablePatches(...context)).toHaveLength(3);
saveFocusedDiagramState([root], [edited], {}, [], ...context);
expect(loadLiveTablePatches(...context).map((patch) => [patch.schema, patch.tableName])).toEqual([
["other", "root"],
["public", "unrelated"],
]);
});
it("creates, renames and deletes only the affected draft", () => {
saveDraftTables([draft("unrelated")], ...context);
const first = draft("new");
const renamed = draft("renamed");
saveFocusedDiagramState([first], [], {}, [], ...context);
saveFocusedDiagramState([renamed], [first], {}, [], ...context);
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated", "renamed"]);
saveFocusedDiagramState([], [renamed], {}, [], ...context);
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated"]);
});
it("retains unloaded membership even when the visible layer is removed", () => {
savePersistedLayers([{ id: "layer", name: "saved", tableNames: ["root", "unrelated"], color: "red", visible: true, collapsed: false }], ...context);
saveFocusedDiagramState([live("root")], [live("root")], {}, [], ...context);
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["unrelated"]);
});
it("applies qualified and legacy patches only to their schema", () => {
const tables = [live("root"), live("root", "other")];
const patches = [{ tableName: "root", pendingColumns: [], pendingDrop: true }];
expect(applyLiveTablePatches(tables, patches, "public").map((table) => !!table.pendingDrop)).toEqual([true, false]);
expect(applyLiveTablePatches(tables, [{ ...patches[0], schema: "other" }]).map((table) => !!table.pendingDrop)).toEqual([false, true]);
});
it("cross-schema edits preserve legacy state belonging to an unloaded root-schema table", () => {
const stored = { ...live("same"), schema: undefined, pendingDrop: true };
saveLiveTablePatches([stored], ...context);
savePersistedPositions({ same: { x: 100, y: 200 } }, ...context);
savePersistedLayers([{ id: "layer", name: "saved", tableNames: ["same"], color: "red", visible: true, collapsed: false }], ...context);
const other = live("same", "other");
saveFocusedDiagramState([{ ...other, pendingDrop: true }], [other], { same: { x: 1, y: 2 }, "other.same": { x: 3, y: 4 } }, [], ...context);
expect(loadLiveTablePatches(...context)).toHaveLength(2);
expect(loadPersistedPositions(...context).same).toEqual({ x: 100, y: 200 });
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["same"]);
});
});
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearFocusedDiagramCacheForTests, DIAGRAM_METADATA_CACHE_TTL_MS, focusedDiagramCacheKey, getFocusedDiagramCache, invalidateFocusedDiagramCache, loadFocusedDiagramCached, setFocusedDiagramCache } from "../diagramMetadataCache";
import type { DiagramTable } from "../erDiagram";
const tables = (name = "A"): DiagramTable[] => [{ name, schema: "public", columns: [], foreignKeys: [], indexes: [] }];
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
beforeEach(() => {
clearFocusedDiagramCacheForTests();
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => vi.useRealTimers());
describe("five minute closure cache", () => {
it("loads once within TTL and expires exactly at five minutes", async () => {
const loader = vi.fn(async () => tables());
await loadFocusedDiagramCached("key", loader);
vi.advanceTimersByTime(DIAGRAM_METADATA_CACHE_TTL_MS - 1);
await loadFocusedDiagramCached("key", loader);
expect(loader).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await loadFocusedDiagramCached("key", loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent callers and gives each a separate snapshot", async () => {
const pending = deferred<DiagramTable[]>();
const loader = vi.fn(() => pending.promise);
const a = loadFocusedDiagramCached("key", loader);
const b = loadFocusedDiagramCached("key", loader);
pending.resolve(tables());
const [first, second] = await Promise.all([a, b]);
expect(loader).toHaveBeenCalledTimes(1);
first[0].name = "edited";
expect(second[0].name).toBe("A");
expect(getFocusedDiagramCache("key")?.[0].name).toBe("A");
});
it("clones on storing and reading, including nested metadata", () => {
const original = tables();
setFocusedDiagramCache("key", original);
original[0].name = "changed";
const read = getFocusedDiagramCache("key")!;
read[0].foreignKeys.push({ name: "fk" } as never);
expect(getFocusedDiagramCache("key")).toEqual(tables());
});
it("does not cache errors and retries the next call", async () => {
const loader = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(tables());
await expect(loadFocusedDiagramCached("key", loader)).rejects.toThrow("offline");
expect(getFocusedDiagramCache("key")).toBeUndefined();
await loadFocusedDiagramCached("key", loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("force bypasses cache, joins forced loads, and replaces only after success", async () => {
setFocusedDiagramCache("key", tables("old"));
const pending = deferred<DiagramTable[]>();
const loader = vi.fn(() => pending.promise);
const a = loadFocusedDiagramCached("key", loader, { force: true });
const b = loadFocusedDiagramCached("key", loader, { force: true });
expect(await loadFocusedDiagramCached("key", loader)).toEqual(tables("old"));
pending.resolve(tables("fresh"));
await Promise.all([a, b]);
expect(loader).toHaveBeenCalledTimes(1);
expect(getFocusedDiagramCache("key")).toEqual(tables("fresh"));
});
it("preserves valid cache after failed forced refresh", async () => {
setFocusedDiagramCache("key", tables("old"));
await expect(
loadFocusedDiagramCached(
"key",
async () => {
throw new Error("offline");
},
{ force: true },
),
).rejects.toThrow("offline");
expect(getFocusedDiagramCache("key")).toEqual(tables("old"));
});
it("an older ordinary load cannot overwrite a newer forced result", async () => {
const pending = deferred<DiagramTable[]>();
const old = loadFocusedDiagramCached("key", () => pending.promise);
await loadFocusedDiagramCached("key", async () => tables("fresh"), { force: true });
pending.resolve(tables("old"));
await old;
expect(getFocusedDiagramCache("key")).toEqual(tables("fresh"));
});
it("invalidating in-flight work prevents later cache commits", async () => {
const pending = deferred<DiagramTable[]>();
const load = loadFocusedDiagramCached("key", () => pending.promise);
invalidateFocusedDiagramCache();
pending.resolve(tables());
await load;
expect(getFocusedDiagramCache("key")).toBeUndefined();
});
it("keys isolate connection, database, catalog, schemas, names, and root sets", () => {
const base = {
connectionId: "c",
database: "db",
roots: [
{ schema: "public", name: "A" },
{ schema: "public", name: "B" },
],
};
const key = focusedDiagramCacheKey(base);
expect(focusedDiagramCacheKey({ ...base, roots: [...base.roots].reverse().concat(base.roots) })).toBe(key);
for (const change of [{ connectionId: "other" }, { database: "other" }, { catalog: "other" }, { roots: [{ schema: "other", name: "A" }] }, { roots: [base.roots[0]] }, { roots: [{ schema: "public", name: "a" }] }]) {
expect(focusedDiagramCacheKey({ ...base, ...change })).not.toBe(key);
}
expect(focusedDiagramCacheKey({ ...base, roots: [{ schema: "a.b", name: "c" }] })).not.toBe(focusedDiagramCacheKey({ ...base, roots: [{ schema: "a", name: "b.c" }] }));
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { diagramIdentityKey, loadFocusedDiagram, type DiagramMetadataAdapter, type FocusedDiagramLoadRequest } from "../diagramMetadataLoader";
import type { ForeignKeyInfo } from "@/types/database";
const request = (names = ["A"]): FocusedDiagramLoadRequest => ({ connectionId: "c", database: "db", roots: names.map((name) => ({ schema: "public", name })) });
const fk = (name: string, schema?: string): ForeignKeyInfo => ({ name: "fk", column: "id", ref_table: name, ref_schema: schema, ref_column: "id", on_update: "NO ACTION", on_delete: "NO ACTION" });
function adapter(graph: Record<string, ForeignKeyInfo[]> = {}): DiagramMetadataAdapter {
return { getColumns: vi.fn(async () => []), listIndexes: vi.fn(async () => []), listForeignKeys: vi.fn(async (table) => graph[`${table.schema}.${table.name}`] ?? []), getTableComment: vi.fn(async () => "comment") };
}
describe("focused outbound metadata traversal", () => {
it.each([
["leaf", {}, ["A"]],
["chain", { "public.A": [fk("B")], "public.B": [fk("C")] }, ["A", "B", "C"]],
["branch", { "public.A": [fk("C"), fk("B")] }, ["A", "B", "C"]],
["cycle", { "public.A": [fk("B")], "public.B": [fk("A")] }, ["A", "B"]],
["self reference", { "public.A": [fk("A")] }, ["A"]],
["composite rows", { "public.A": [fk("B"), fk("B")] }, ["A", "B"]],
] as const)("loads %s once without requesting unrelated tables", async (_name, graph, expected) => {
const metadata = adapter(graph);
const result = await loadFocusedDiagram(request(), metadata);
expect(result.tables.map((table) => table.name)).toEqual(expected);
for (const method of [metadata.getColumns, metadata.listForeignKeys, metadata.listIndexes, metadata.getTableComment]) expect(method).toHaveBeenCalledTimes(expected.length);
expect(result.rootKeys).toEqual([diagramIdentityKey({ schema: "public", name: "A" })]);
});
it("unions roots, deduplicates them, and preserves cross-schema and case identities", async () => {
const metadata = adapter({ "public.A": [fk("B"), fk("A", "other")], "public.B": [fk("A"), fk("a")] });
const result = await loadFocusedDiagram(request(["B", "A", "A"]), metadata);
expect(result.tables.map((table) => [table.schema, table.name])).toEqual([
["other", "A"],
["public", "A"],
["public", "B"],
["public", "a"],
]);
expect(metadata.getColumns).toHaveBeenCalledTimes(4);
});
it.each(["getColumns", "listForeignKeys", "listIndexes"] as const)("rejects required %s failures", async (method) => {
const metadata = adapter();
metadata[method] = vi.fn().mockRejectedValue(new Error("offline"));
await expect(loadFocusedDiagram(request(), metadata)).rejects.toThrow("offline");
});
it("allows optional comment failures", async () => {
const metadata = adapter();
metadata.getTableComment = vi.fn().mockRejectedValue(new Error("unsupported"));
expect((await loadFocusedDiagram(request(), metadata)).tables[0].comment).toBeNull();
});
it("bounds table concurrency and reports a growing discovered count", async () => {
const metadata = adapter({ "public.A": [fk("B"), fk("C"), fk("D"), fk("E")], "public.B": [fk("F")] });
let running = 0;
let peak = 0;
metadata.getColumns = vi.fn(async () => {
running++;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 1));
running--;
return [];
});
const onProgress = vi.fn();
await loadFocusedDiagram({ ...request(), concurrency: 2, onProgress }, metadata);
expect(peak).toBe(2);
expect(onProgress.mock.calls).toEqual([
[0, 1],
[1, 5],
[3, 6],
[5, 6],
[6, 6],
]);
});
});
@@ -0,0 +1,66 @@
import type { DiagramTable } from "./erDiagram";
import { diagramIdentityKey, normalizedDiagramRoots, type FocusedDiagramLoadRequest } from "./diagramMetadataLoader";
export const DIAGRAM_METADATA_CACHE_TTL_MS = 5 * 60 * 1000;
interface CacheEntry {
cachedAt: number;
expiresAt: number;
tables: DiagramTable[];
}
interface PendingLoad {
force: boolean;
promise: Promise<DiagramTable[]>;
}
const entries = new Map<string, CacheEntry>();
const inFlight = new Map<string, PendingLoad>();
// Metadata is JSON data; this also accepts Vue reactive snapshots without proxy cloning errors.
const clone = (tables: DiagramTable[]): DiagramTable[] => JSON.parse(JSON.stringify(tables));
export function focusedDiagramCacheKey(request: FocusedDiagramLoadRequest): string {
return JSON.stringify({ version: 1, connectionId: request.connectionId, database: request.database, catalog: request.catalog ?? "", roots: normalizedDiagramRoots(request.roots).map(diagramIdentityKey), traversal: "outbound-fk" });
}
export function getFocusedDiagramCache(key: string, now = Date.now()): DiagramTable[] | undefined {
const entry = entries.get(key);
if (!entry) return undefined;
if (now >= entry.expiresAt) {
entries.delete(key);
return undefined;
}
return clone(entry.tables);
}
export function setFocusedDiagramCache(key: string, tables: DiagramTable[], now = Date.now()): void {
entries.set(key, { cachedAt: now, expiresAt: now + DIAGRAM_METADATA_CACHE_TTL_MS, tables: clone(tables) });
}
export async function loadFocusedDiagramCached(key: string, loader: () => Promise<DiagramTable[]>, options: { force?: boolean } = {}): Promise<DiagramTable[]> {
if (!options.force) {
const cached = getFocusedDiagramCache(key);
if (cached) return cached;
}
const existing = inFlight.get(key);
if (existing && (!options.force || existing.force)) return clone(await existing.promise);
const pending: PendingLoad = { force: !!options.force, promise: Promise.resolve([]) };
pending.promise = Promise.resolve()
.then(loader)
.then((tables) => {
// A forced request can supersede an older ordinary request without deleting its valid snapshot.
if (inFlight.get(key) === pending) setFocusedDiagramCache(key, tables);
return tables;
})
.finally(() => {
if (inFlight.get(key) === pending) inFlight.delete(key);
});
inFlight.set(key, pending);
return clone(await pending.promise);
}
export function invalidateFocusedDiagramCache(match: (key: string) => boolean = () => true): void {
for (const key of entries.keys()) if (match(key)) entries.delete(key);
for (const key of inFlight.keys()) if (match(key)) inFlight.delete(key);
}
export function clearFocusedDiagramCacheForTests(): void {
invalidateFocusedDiagramCache();
}
@@ -0,0 +1,92 @@
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
import type { DiagramTable } from "./erDiagram";
export interface DiagramTableIdentity {
schema: string;
name: string;
}
export interface FocusedDiagramLoadRequest {
connectionId: string;
database: string;
catalog?: string;
roots: DiagramTableIdentity[];
concurrency?: number;
onProgress?: (loaded: number, discovered: number) => void;
}
export interface FocusedDiagramLoadResult {
tables: DiagramTable[];
rootKeys: string[];
}
export interface DiagramMetadataAdapter {
getColumns(table: DiagramTableIdentity): Promise<ColumnInfo[]>;
listForeignKeys(table: DiagramTableIdentity): Promise<ForeignKeyInfo[]>;
listIndexes(table: DiagramTableIdentity): Promise<IndexInfo[]>;
getTableComment?(table: DiagramTableIdentity): Promise<string | null>;
}
/** JSON tuples preserve case and cannot collide on dots in quoted identifiers. */
export function diagramIdentityKey(table: { schema?: string; name: string }): string {
return JSON.stringify([table.schema ?? "", table.name]);
}
export function normalizedDiagramRoots(roots: DiagramTableIdentity[]): DiagramTableIdentity[] {
return [...new Map(roots.filter((root) => root.name.trim()).map((root) => [diagramIdentityKey(root), { ...root }])).values()].sort(compareIdentities);
}
function compareIdentities(a: DiagramTableIdentity, b: DiagramTableIdentity): number {
return a.schema < b.schema ? -1 : a.schema > b.schema ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
/** Keep the same editable index representation used by the structure inspector. */
export function diagramIndexes(indexes: IndexInfo[]): DiagramTable["indexes"] {
return indexes.map((index) => ({
id: index.name,
name: index.name,
columns: [...index.columns],
isUnique: index.is_unique,
isPrimary: index.is_primary,
filter: index.filter ?? "",
indexType: index.index_type ?? "",
includedColumns: index.included_columns ?? [],
comment: index.comment ?? "",
markedForDrop: false,
original: index,
}));
}
/** Bounded breadth-first outbound traversal. Required metadata errors reject the closure. */
export async function loadFocusedDiagram(request: FocusedDiagramLoadRequest, metadata: DiagramMetadataAdapter): Promise<FocusedDiagramLoadResult> {
const roots = normalizedDiagramRoots(request.roots);
const rootKeys = roots.map(diagramIdentityKey);
const discovered = new Set(rootKeys);
const pending = [...roots];
const tables: DiagramTable[] = [];
const concurrency = Number.isFinite(request.concurrency) ? Math.max(1, Math.floor(request.concurrency!)) : 4;
request.onProgress?.(0, discovered.size);
while (pending.length) {
const batch = pending.splice(0, concurrency);
const batchTables = await Promise.all(
batch.map(async (table) => {
const [columns, foreignKeys, indexes, comment] = await Promise.all([metadata.getColumns(table), metadata.listForeignKeys(table), metadata.listIndexes(table), metadata.getTableComment?.(table).catch(() => null) ?? null]);
return { ...table, columns, foreignKeys, indexes: diagramIndexes(indexes), comment };
}),
);
for (const table of batchTables) {
tables.push(table);
for (const fk of table.foreignKeys) {
const target = { schema: fk.ref_schema || table.schema, name: fk.ref_table };
const key = diagramIdentityKey(target);
if (!discovered.has(key)) {
discovered.add(key);
pending.push(target);
}
}
}
request.onProgress?.(tables.length, discovered.size);
}
tables.sort((a, b) => compareIdentities({ schema: a.schema ?? "", name: a.name }, { schema: b.schema ?? "", name: b.name }));
return { tables, rootKeys };
}
+47 -4
View File
@@ -3,9 +3,11 @@ import type { ColumnInfo } from "@/types/database";
import type { DiagramPosition, DiagramTable } from "./erDiagram";
import { isLiveTable, needsDiagramSync } from "./erDiagram";
import type { DiagramLayer } from "@/types/diagram";
import { diagramIdentityKey } from "./diagramMetadataLoader";
export interface LiveTablePatch {
tableName: string;
schema?: string;
pendingColumns: ColumnInfo[];
droppedColumnNames?: string[];
pendingDrop?: boolean;
@@ -123,6 +125,7 @@ export function loadLiveTablePatches(connectionId: string, database: string, sch
const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
const next: LiveTablePatch = {
tableName: patch.tableName,
schema: patch.schema,
pendingColumns: pendingColumns.map((col) => ({ ...col })),
};
if (droppedColumnNames.length) next.droppedColumnNames = droppedColumnNames;
@@ -134,6 +137,10 @@ export function loadLiveTablePatches(connectionId: string, database: string, sch
export function saveLiveTablePatches(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
if (!connectionId || !database) return;
const key = storageKey("live-patches", connectionId, database, schema);
safeLocalStorageSet(key, JSON.stringify(liveTablePatches(tables)));
}
function liveTablePatches(tables: DiagramTable[]): LiveTablePatch[] {
const patches: LiveTablePatch[] = [];
for (const table of tables) {
if (!isLiveTable(table) || !needsDiagramSync(table)) continue;
@@ -143,22 +150,58 @@ export function saveLiveTablePatches(tables: DiagramTable[], connectionId: strin
if (pendingColumns.length === 0 && droppedColumnNames.length === 0 && !table.pendingDrop) continue;
const patch: LiveTablePatch = {
tableName: table.name,
schema: table.schema,
pendingColumns: pendingColumns.map((col) => ({ ...col })),
};
if (droppedColumnNames.length) patch.droppedColumnNames = droppedColumnNames;
if (table.pendingDrop) patch.pendingDrop = true;
patches.push(patch);
}
safeLocalStorageSet(key, JSON.stringify(patches));
return patches;
}
/** Persist only edits since the last applied snapshot, never the absent part of a focused schema. */
export function saveFocusedDiagramState(tables: DiagramTable[], previousTables: DiagramTable[], positions: Record<string, DiagramPosition>, layers: DiagramLayer[], connectionId: string, database: string, schema: string, legacySchema = schema): void {
if (!connectionId || !database) return;
const previous = new Map(previousTables.map((table) => [diagramIdentityKey(table), table]));
const current = new Map(tables.map((table) => [diagramIdentityKey(table), table]));
const changed = new Set([...previous.keys(), ...current.keys()].filter((key) => JSON.stringify(previous.get(key)) !== JSON.stringify(current.get(key))));
if (changed.size) {
const drafts = loadDraftTables(connectionId, database, schema).filter((table) => !changed.has(diagramIdentityKey(table)));
saveDraftTables([...drafts, ...tables.filter((table) => changed.has(diagramIdentityKey(table)))], connectionId, database, schema);
const oldPatches = loadLiveTablePatches(connectionId, database, schema).filter(
(patch) =>
![...changed].some((key) => {
const table = current.get(key) ?? previous.get(key)!;
return patch.tableName === table.name && (patch.schema === table.schema || (patch.schema === undefined && (!table.schema || table.schema === legacySchema)));
}),
);
const newPatches = liveTablePatches(tables.filter((table) => changed.has(diagramIdentityKey(table))));
safeLocalStorageSet(storageKey("live-patches", connectionId, database, schema), JSON.stringify([...oldPatches, ...newPatches]));
}
const loadedNames = new Set([...previousTables, ...tables].flatMap((table) => (!table.schema || table.schema === legacySchema ? [table.name, table.schema ? `${table.schema}.${table.name}` : table.name] : [`${table.schema}.${table.name}`])));
const scopedPositions = Object.fromEntries(Object.entries(positions).filter(([name]) => loadedNames.has(name)));
savePersistedPositions({ ...loadPersistedPositions(connectionId, database, schema), ...scopedPositions }, connectionId, database, schema);
const mergedLayers = layers.map((layer) => ({ ...layer, tableNames: [...layer.tableNames] }));
for (const stored of loadPersistedLayers(connectionId, database, schema)) {
const unloadedMembers = stored.tableNames.filter((name) => !loadedNames.has(name));
const next = mergedLayers.find((layer) => layer.id === stored.id);
if (next) next.tableNames = [...new Set([...next.tableNames, ...unloadedMembers])];
else if (unloadedMembers.length || stored.tableNames.length === 0) mergedLayers.push({ ...stored, tableNames: unloadedMembers });
}
savePersistedLayers(mergedLayers, connectionId, database, schema);
}
/** Merge saved pending adds/drops onto live tables loaded from DB metadata. */
export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[]): DiagramTable[] {
export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[], legacySchema?: string): DiagramTable[] {
if (patches.length === 0) return tables;
const byName = new Map(patches.map((p) => [p.tableName, p]));
const byName = new Map(patches.map((p) => [diagramIdentityKey({ name: p.tableName, schema: p.schema ?? legacySchema }), p]));
const nameCounts = new Map<string, number>();
for (const table of tables) nameCounts.set(table.name, (nameCounts.get(table.name) ?? 0) + 1);
return tables.map((table) => {
if (!isLiveTable(table)) return table;
const patch = byName.get(table.name);
const unambiguous = nameCounts.get(table.name) === 1;
const patch = byName.get(diagramIdentityKey(table)) ?? (unambiguous ? byName.get(diagramIdentityKey({ name: table.name })) : undefined);
if (!patch) return table;
const existing = new Set(table.columns.map((c) => c.name.toLowerCase()));
@@ -641,6 +641,7 @@ export const useConnectionStore = defineStore("connection", () => {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
tableName?: string;
tableNames?: string[];
} | null>(null);
+2 -5
View File
@@ -15,10 +15,7 @@ describe("diagram refresh confirmation wiring", () => {
assert.match(dialogSource, /function requestRefreshDiagram\(\)/);
assert.match(dialogSource, /showRefreshConfirm\.value = true/);
assert.match(dialogSource, /@refresh="requestRefreshDiagram"/);
assert.match(
dialogSource,
/DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/,
);
assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\)/);
assert.match(dialogSource, /DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/);
assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\{ force: true \}\)/);
});
});