From 7be11f54c9dab575a1b1ab4e0704fbcef7f2b408 Mon Sep 17 00:00:00 2001 From: yanguibao1997 <39548181+yanguibao1997@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:27:37 +0800 Subject: [PATCH] feat(metadata): persist and lazy-load table structure metadata --- apps/desktop/src/App.vue | 1 + .../src/components/editor/QueryEditor.vue | 47 ++--- .../src/components/objects/DdlViewDialog.vue | 60 ++++--- .../TableStructureEditor.primaryKey.spec.ts | 62 +++++++ .../structure/TableStructureEditor.vue | 119 +++++++++++-- .../contentAreaObjectBrowserCatalog.spec.ts | 4 +- .../tableStructureMetadataLoading.spec.ts | 14 +- .../editor/__tests__/hoverTableSql.spec.ts | 23 ++- apps/desktop/src/lib/editor/hoverTableSql.ts | 10 ++ .../metadata/__tests__/objectDdlCache.spec.ts | 100 +++++++++++ .../__tests__/objectMetadataCache.spec.ts | 63 +++++++ .../src/lib/metadata/objectDdlCache.ts | 165 ++++++++++++++++++ .../src/lib/metadata/objectMetadataCache.ts | 130 ++++++++++++++ .../table/tableStructureMetadataLoading.ts | 21 ++- apps/desktop/src/stores/connectionStore.ts | 17 +- 15 files changed, 747 insertions(+), 89 deletions(-) create mode 100644 apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts create mode 100644 apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts create mode 100644 apps/desktop/src/lib/metadata/objectDdlCache.ts create mode 100644 apps/desktop/src/lib/metadata/objectMetadataCache.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 03c028013..75bed0b06 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1510,6 +1510,7 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi function onQueryEditorObjectSourceSaved() { const target = queryEditorObjectSourceTarget.value; if (!target) return; + connectionStore.invalidateMetadataCache(target.connectionId, target.database, target.schema, target.name); connectionStore.invalidateCompletionCache(target.connectionId, target.database); contentAreaRef.value?.refreshQueryEditorCompletionCache(); } diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cb7f4e673..20df08697 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -47,8 +47,8 @@ import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletio import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { mergeSqlCompletionQualifierNames, resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; -import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; -import { buildHoverTableSql, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; +import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationSourceKind, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; +import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, @@ -81,7 +81,8 @@ import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; -import { loadTableMetadata, type TableMetadataLoadResult } from "@/lib/metadata/tableMetadataCache"; +import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; +import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; @@ -428,8 +429,7 @@ const cachedInsertValueHintColumnsByTable = new Map(); const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); -// Hover tooltip uses the shared table metadata cache (loadTableMetadata) -// which provides TTL, invalidation, and in-flight deduplication. +// Hover tooltip shares the persisted object cache with the DDL and structure views. let hoverSqlHighlighter: SqlHighlighter | null = null; function sqlCompletionDialectOptions() { @@ -1892,15 +1892,22 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) const hoverDatabase = hoverScope.database; const hoverSchema = hoverScope.schema ?? table.schema ?? ""; const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join("."); + const objectMetadataRequest = { + connectionId: props.connectionId, + database: hoverDatabase, + schema: hoverSchema, + tableName: table.name, + catalog: hoverScope.catalog, + objectType: sqlObjectNavigationSourceKind(table), + }; let sqlContent: string | undefined; let metadataLoadFailed = false; - // Primary path: the backend's raw getTableDdl (SHOW CREATE TABLE, pg_ddl, - // build_sqlserver_ddl, ...) is authoritative. Parse it into structured - // fields and rebuild with vertical field alignment (name, type, extra, - // default, nullable, comment), stripping charset/COLLATE noise. + // The persisted display DDL is canonical across the full-page and hover + // views. Hover only removes PostgreSQL's appended access-control tail. try { - const rawDdl = await api.getTableDdl(props.connectionId, hoverDatabase, hoverSchema, table.name, undefined, hoverScope.catalog); + const { ddl } = await loadObjectDdl(objectMetadataRequest); + const rawDdl = ddlForHoverPreview(ddl); if (rawDdl && rawDdl.trim()) { sqlContent = reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName)); } @@ -1915,24 +1922,20 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) let fullIndexes: IndexInfo[] = []; let tableComment: string | undefined; try { - const result: TableMetadataLoadResult = await loadTableMetadata({ - connectionId: props.connectionId, - database: hoverDatabase, - schema: hoverSchema, - tableName: table.name, - databaseType: props.databaseType ?? "", - catalog: hoverScope.catalog, - }); - fullColumns = result.metadata.columns; - fullIndexes = result.metadata.indexes; + const [columnsResult, indexesResult] = await Promise.all([ + loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)), + loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog).catch(() => [])), + ]); + fullColumns = columnsResult.value; + fullIndexes = indexesResult.value; } catch (error) { metadataLoadFailed = true; console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); } if (!metadataLoadFailed) { try { - const commentResult = await api.getTableComment(props.connectionId, hoverDatabase, hoverSchema, table.name, hoverScope.catalog); - if (commentResult) tableComment = commentResult; + const commentResult = await loadObjectMetadataFacet(objectMetadataRequest, "comment", () => api.getTableComment(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)); + if (commentResult.value) tableComment = commentResult.value; } catch (error) { console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); } diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index d42871095..2ce3a89de 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -9,7 +9,7 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import * as api from "@/lib/backend/api"; +import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue"; @@ -51,23 +51,38 @@ const ddlEditorContainer = ref(); const ddlSearchPanelRef = ref>(); const ddlEditorView = shallowRef(null); -/** Fetches the table DDL when the dialog opens. */ +async function loadDdl(force = false) { + ddlError.value = ""; + ddlLoading.value = true; + if (force) destroyDdlEditor(); + try { + const schema = props.schema || props.database; + const { ddl } = await loadObjectDdl( + { + connectionId: props.connectionId, + database: props.database, + schema, + tableName: props.tableName, + objectType: props.objectType, + catalog: props.catalog, + }, + { force }, + ); + ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); + } catch (e: any) { + ddlError.value = e?.message || String(e); + } finally { + ddlLoading.value = false; + } +} + +/** Loads the persisted table DDL when the dialog opens. */ watch( () => props.open, async (open) => { if (!open) return; ddlContent.value = ""; - ddlError.value = ""; - ddlLoading.value = true; - try { - const schema = props.schema || props.database; - const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog); - ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); - } catch (e: any) { - ddlError.value = e?.message || String(e); - } finally { - ddlLoading.value = false; - } + await loadDdl(); }, { immediate: true }, ); @@ -171,21 +186,8 @@ onUnmounted(() => { }); function retry() { - ddlError.value = ""; - ddlLoading.value = true; ddlContent.value = ""; - const schema = props.schema || props.database; - api - .getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType) - .then(async (ddl) => { - ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter); - }) - .catch((e: any) => { - ddlError.value = e?.message || String(e); - }) - .finally(() => { - ddlLoading.value = false; - }); + void loadDdl(true); } function onClose() { @@ -218,6 +220,10 @@ function onClose() { + diff --git a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts index 11759011f..400bd63d5 100644 --- a/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/contentAreaObjectBrowserCatalog.spec.ts @@ -25,8 +25,8 @@ describe("ContentArea external catalog wiring", () => { expect(openingTag(connectionTreeSource, "SidebarDdlViewDialog")).toContain(':catalog="sidebarDdlTarget.catalog"'); }); - it("forwards the DDL dialog catalog to the metadata API", () => { - expect(ddlViewDialogSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.objectType, props\.catalog\)/); + it("forwards the DDL dialog catalog to the persistent DDL loader", () => { + expect(ddlViewDialogSource).toMatch(/loadObjectDdl\([\s\S]*?objectType: props\.objectType,[\s\S]*?catalog: props\.catalog/); }); }); diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts index ebd349ab4..1c6c6b03b 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { shouldLoadTableStructureTriggers, visibleTableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading"; describe("table structure metadata loading", () => { - it("does not request triggers while opening the default columns tab", () => { - expect(visibleTableStructureRefreshScope("columns").triggers).toBe(false); - }); - - it("requests triggers when the structure editor opens on the trigger tab", () => { - expect(visibleTableStructureRefreshScope("triggers").triggers).toBe(true); + it.each([ + ["columns", { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true }], + ["indexes", { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true }], + ["foreignKeys", { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true }], + ["triggers", { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true }], + ["ddl", { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false }], + ] as const)("requests only the metadata required by the %s tab", (tab, expected) => { + expect(visibleTableStructureRefreshScope(tab)).toEqual(expected); }); it("loads trigger metadata once when the trigger tab becomes visible", () => { diff --git a/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts b/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts index 38f029cd0..7eb2834c3 100644 --- a/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/hoverTableSql.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildHoverTableSql, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql"; +import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql"; import type { ColumnInfo, IndexInfo } from "@/types/database"; type ColumnOverride = Partial & { name: string; data_type: string }; @@ -207,6 +207,27 @@ describe("buildHoverTableSql", () => { }); describe("reformatHoverDdl", () => { + it("removes the PostgreSQL access-control tail from canonical display DDL", () => { + const displayDdl = `CREATE TABLE "public"."users" ( + "id" bigint NOT NULL +); + +ALTER TABLE "public"."users" OWNER TO "app_owner"; + +SET ROLE "app_owner"; +GRANT SELECT ON TABLE "public"."users" TO "reporter"; +RESET ROLE;`; + + expect(ddlForHoverPreview(displayDdl)).toBe(`CREATE TABLE "public"."users" ( + "id" bigint NOT NULL +);`); + }); + + it("keeps non-PostgreSQL and structural companion statements unchanged", () => { + const ddl = "CREATE TABLE t (id int);\nCREATE INDEX ix_t_id ON t (id);"; + expect(ddlForHoverPreview(ddl)).toBe(ddl); + }); + it("preserves sanitized raw MySQL DDL when table options are present", () => { const raw = `CREATE TABLE \`users\` ( \`id\` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', diff --git a/apps/desktop/src/lib/editor/hoverTableSql.ts b/apps/desktop/src/lib/editor/hoverTableSql.ts index 977253ea6..8cf92153e 100644 --- a/apps/desktop/src/lib/editor/hoverTableSql.ts +++ b/apps/desktop/src/lib/editor/hoverTableSql.ts @@ -106,6 +106,16 @@ export function sanitizeHoverDdl(ddl: string): string { ); } +/** + * The persisted display DDL is the canonical object definition. Native + * PostgreSQL appends owner and grant statements after the structural DDL; + * quick-look hover keeps the structure and omits that access-control tail. + */ +export function ddlForHoverPreview(ddl: string): string { + const accessTail = /\n\s*ALTER\s+TABLE\s+[^\n;]+\s+OWNER\s+TO\s+[^\n;]+;/i.exec(ddl); + return accessTail ? ddl.slice(0, accessTail.index).trimEnd() : ddl; +} + /** * Display fields of a single column line, prior to vertical alignment. */ diff --git a/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts new file mode 100644 index 000000000..06ae9e54a --- /dev/null +++ b/apps/desktop/src/lib/metadata/__tests__/objectDdlCache.spec.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getTableDisplayDdl: vi.fn(), + saveSchemaCache: vi.fn(), + loadSchemaCache: vi.fn(), + deleteSchemaCachePrefix: vi.fn(), + persisted: new Map(), +})); + +vi.mock("@/lib/backend/api", () => mocks); + +import { invalidateObjectDdl, invalidateObjectDdlCache, loadObjectDdl, objectDdlCacheKey } from "@/lib/metadata/objectDdlCache"; + +const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const; + +describe("objectDdlCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.persisted.clear(); + mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null); + mocks.saveSchemaCache.mockImplementation(async (cacheKey: string, payload: unknown) => { + mocks.persisted.set(cacheKey, payload); + }); + mocks.deleteSchemaCachePrefix.mockImplementation(async (prefix: string) => { + for (const cacheKey of mocks.persisted.keys()) { + if (cacheKey === prefix || cacheKey.startsWith(prefix)) mocks.persisted.delete(cacheKey); + } + }); + }); + + it("returns persisted DDL without querying the database", async () => { + mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), ddl: "CREATE TABLE users (id int)" }); + + await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "CREATE TABLE users (id int)", cacheStatus: "disk" }); + expect(mocks.getTableDisplayDdl).not.toHaveBeenCalled(); + }); + + it("persists a remote cache miss", async () => { + mocks.getTableDisplayDdl.mockResolvedValue("CREATE TABLE users (id bigint)"); + + await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); + expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ version: 1, ddl: "CREATE TABLE users (id bigint)" })); + }); + + it("deduplicates concurrent remote loads", async () => { + let release: (ddl: string) => void = () => {}; + mocks.getTableDisplayDdl.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = loadObjectDdl(request); + const second = loadObjectDdl(request); + await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1)); + + release("CREATE TABLE users (id int)"); + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + }); + + it("force refresh bypasses disk and overwrites it", async () => { + mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), ddl: "old ddl" }); + mocks.getTableDisplayDdl.mockResolvedValue("new ddl"); + + await expect(loadObjectDdl(request, { force: true })).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" }); + expect(mocks.loadSchemaCache).not.toHaveBeenCalled(); + expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ ddl: "new ddl" })); + }); + + it("deletes the exact persisted entry", async () => { + await invalidateObjectDdl(request); + expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith(objectDdlCacheKey(request)); + }); + + it("reloads from the database after table-level persisted cache invalidation", async () => { + mocks.getTableDisplayDdl.mockResolvedValueOnce("old ddl").mockResolvedValueOnce("new ddl"); + + await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "old ddl", cacheStatus: "remote" }); + await invalidateObjectDdlCache({ connectionId: request.connectionId, database: request.database, schema: request.schema, tableName: request.tableName }); + await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" }); + expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2); + }); + + it("does not persist an in-flight result across invalidation", async () => { + let release: (ddl: string) => void = () => {}; + mocks.getTableDisplayDdl.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const load = loadObjectDdl(request); + await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1)); + + await invalidateObjectDdlCache({ connectionId: request.connectionId, database: request.database, schema: request.schema }); + release("stale ddl"); + await expect(load).resolves.toEqual({ ddl: "stale ddl", cacheStatus: "remote" }); + expect(mocks.saveSchemaCache).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts b/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts new file mode 100644 index 000000000..bd60306be --- /dev/null +++ b/apps/desktop/src/lib/metadata/__tests__/objectMetadataCache.spec.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + saveSchemaCache: vi.fn(), + loadSchemaCache: vi.fn(), + deleteSchemaCachePrefix: vi.fn(), + persisted: new Map(), +})); + +vi.mock("@/lib/backend/api", () => mocks); + +import { invalidateObjectMetadataCache, loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; + +const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const; + +describe("objectMetadataCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.persisted.clear(); + mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null); + mocks.saveSchemaCache.mockImplementation(async (cacheKey: string, payload: unknown) => { + mocks.persisted.set(cacheKey, payload); + }); + mocks.deleteSchemaCachePrefix.mockImplementation(async (prefix: string) => { + for (const cacheKey of mocks.persisted.keys()) { + if (cacheKey === prefix || cacheKey.startsWith(prefix)) mocks.persisted.delete(cacheKey); + } + }); + }); + + it("reads a facet from disk without invoking the loader", async () => { + mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: [{ name: "id" }] }); + const loader = vi.fn().mockResolvedValue([{ name: "remote" }]); + + await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toEqual({ value: [{ name: "id" }], cacheStatus: "disk" }); + expect(loader).not.toHaveBeenCalled(); + }); + + it("persists a remote facet and force bypasses disk", async () => { + mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: ["old"] }); + const loader = vi.fn().mockResolvedValue(["new"]); + + await expect(loadObjectMetadataFacet(request, "indexes", loader, { force: true })).resolves.toEqual({ value: ["new"], cacheStatus: "remote" }); + expect(loader).toHaveBeenCalledTimes(1); + expect(mocks.loadSchemaCache).not.toHaveBeenCalled(); + expect(mocks.saveSchemaCache).toHaveBeenCalledWith(expect.stringContaining("object-meta:v1:c1:app:public:users:analytics:indexes:"), expect.objectContaining({ value: ["new"] })); + }); + + it("invalidates only the requested object's facets", async () => { + await invalidateObjectMetadataCache(request); + expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:c1:app:public:users:"); + }); + + it("reloads a persisted facet after table-level invalidation", async () => { + const initialLoader = vi.fn().mockResolvedValue(["old"]); + const refreshedLoader = vi.fn().mockResolvedValue(["new"]); + + await expect(loadObjectMetadataFacet(request, "indexes", initialLoader)).resolves.toEqual({ value: ["old"], cacheStatus: "remote" }); + await invalidateObjectMetadataCache({ connectionId: request.connectionId, database: request.database, schema: request.schema, tableName: request.tableName }); + await expect(loadObjectMetadataFacet(request, "indexes", refreshedLoader)).resolves.toEqual({ value: ["new"], cacheStatus: "remote" }); + expect(refreshedLoader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/lib/metadata/objectDdlCache.ts b/apps/desktop/src/lib/metadata/objectDdlCache.ts new file mode 100644 index 000000000..c9767a759 --- /dev/null +++ b/apps/desktop/src/lib/metadata/objectDdlCache.ts @@ -0,0 +1,165 @@ +import * as api from "@/lib/backend/api"; +import type { ObjectSourceKind } from "@/types/database"; +import type { MetadataCacheInvalidation } from "./metadataResultCache"; +import { invalidateObjectMetadataCache } from "./objectMetadataCache"; + +const OBJECT_DDL_CACHE_PREFIX = "object-ddl:v1"; +const MAX_PERSISTED_DDL_CHARS = 5 * 1024 * 1024; + +interface ObjectDdlCacheEnvelope { + version: 1; + cachedAt: string; + ddl: string; +} + +interface InFlightDdlLoad { + force: boolean; + invalidated: boolean; + promise: Promise; +} + +export interface ObjectDdlRequest { + connectionId: string; + database: string; + schema: string; + tableName: string; + objectType?: ObjectSourceKind; + catalog?: string; +} + +export interface ObjectDdlLoadResult { + ddl: string; + cacheStatus: "disk" | "remote"; +} + +const remoteLoads = new Map(); +const pendingInvalidations = new Map>(); + +async function loadSchemaCacheSafe(cacheKey: string): Promise { + try { + return await api.loadSchemaCache(cacheKey); + } catch { + return null; + } +} + +async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise { + try { + await api.saveSchemaCache(cacheKey, payload); + } catch { + // Cache persistence is best effort and must not block DDL rendering. + } +} + +async function deleteSchemaCachePrefixSafe(prefix: string): Promise { + try { + await api.deleteSchemaCachePrefix(prefix); + } catch { + // Cache invalidation is best effort when running with a reduced backend. + } +} + +function cacheSegment(value: string | undefined): string { + return encodeURIComponent(value ?? ""); +} + +export function objectDdlCacheKey(request: ObjectDdlRequest): string { + return `${[OBJECT_DDL_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), cacheSegment(request.objectType ?? "TABLE")].join(":")}:`; +} + +function invalidationPrefix(match: MetadataCacheInvalidation): string { + const parts = [OBJECT_DDL_CACHE_PREFIX]; + if (!match.connectionId) return `${OBJECT_DDL_CACHE_PREFIX}:`; + parts.push(cacheSegment(match.connectionId ?? undefined)); + if (!match.database) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.database ?? undefined)); + if (!match.schema) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.schema ?? undefined)); + if (!match.tableName) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.tableName)); + return `${parts.join(":")}:`; +} + +function decodeCachedDdl(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const envelope = payload as Partial; + if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || typeof envelope.ddl !== "string") return null; + return envelope.ddl; +} + +async function waitForPendingInvalidations(cacheKey: string): Promise { + const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise); + if (pending.length) await Promise.all(pending); +} + +async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: boolean): Promise { + const existing = remoteLoads.get(cacheKey); + if (existing && (!force || existing.force)) return existing.promise; + if (existing) { + existing.invalidated = true; + remoteLoads.delete(cacheKey); + } + + const entry: InFlightDdlLoad = { force, invalidated: false, promise: Promise.resolve("") }; + entry.promise = api + .getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog) + .then(async (ddl) => { + if (!entry.invalidated && ddl.length <= MAX_PERSISTED_DDL_CHARS) { + const envelope: ObjectDdlCacheEnvelope = { version: 1, cachedAt: new Date().toISOString(), ddl }; + await saveSchemaCacheSafe(cacheKey, envelope); + } + return ddl; + }) + .finally(() => { + if (remoteLoads.get(cacheKey) === entry) remoteLoads.delete(cacheKey); + }); + remoteLoads.set(cacheKey, entry); + return entry.promise; +} + +export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force?: boolean }): Promise { + const cacheKey = objectDdlCacheKey(request); + await waitForPendingInvalidations(cacheKey); + + if (!options?.force) { + const cached = decodeCachedDdl(await loadSchemaCacheSafe(cacheKey)); + if (cached !== null) return { ddl: cached, cacheStatus: "disk" }; + } + + return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true), cacheStatus: "remote" }; +} + +export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation): Promise { + const prefix = invalidationPrefix(match); + for (const [cacheKey, entry] of remoteLoads) { + if (!cacheKey.startsWith(prefix)) continue; + entry.invalidated = true; + remoteLoads.delete(cacheKey); + } + + const existing = pendingInvalidations.get(prefix); + if (existing) return existing; + const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => { + if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix); + }); + pendingInvalidations.set(prefix, deletion); + await Promise.all([deletion, invalidateObjectMetadataCache(match)]); +} + +export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise { + const cacheKey = objectDdlCacheKey(request); + const entry = remoteLoads.get(cacheKey); + if (entry) { + entry.invalidated = true; + remoteLoads.delete(cacheKey); + } + await Promise.all([ + deleteSchemaCachePrefixSafe(cacheKey), + invalidateObjectMetadataCache({ + connectionId: request.connectionId, + database: request.database, + schema: request.schema, + tableName: request.tableName, + }), + ]); +} diff --git a/apps/desktop/src/lib/metadata/objectMetadataCache.ts b/apps/desktop/src/lib/metadata/objectMetadataCache.ts new file mode 100644 index 000000000..e62b70edd --- /dev/null +++ b/apps/desktop/src/lib/metadata/objectMetadataCache.ts @@ -0,0 +1,130 @@ +import * as api from "@/lib/backend/api"; +import type { MetadataCacheInvalidation } from "./metadataResultCache"; +import type { ObjectDdlRequest } from "./objectDdlCache"; + +const OBJECT_METADATA_CACHE_PREFIX = "object-meta:v1"; +const MAX_PERSISTED_METADATA_CHARS = 5 * 1024 * 1024; + +interface ObjectMetadataCacheEnvelope { + version: 1; + cachedAt: string; + value: T; +} + +interface InFlightLoad { + force: boolean; + invalidated: boolean; + promise: Promise; +} + +const inFlightLoads = new Map>(); +const pendingInvalidations = new Map>(); + +async function loadSchemaCacheSafe(cacheKey: string): Promise { + try { + return await api.loadSchemaCache(cacheKey); + } catch { + return null; + } +} + +async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise { + try { + await api.saveSchemaCache(cacheKey, payload); + } catch { + // Cache persistence is best effort and must not block metadata rendering. + } +} + +async function deleteSchemaCachePrefixSafe(prefix: string): Promise { + try { + await api.deleteSchemaCachePrefix(prefix); + } catch { + // Cache invalidation is best effort when running with a reduced backend. + } +} + +export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "triggers" | "comment"; + +function cacheSegment(value: string | undefined): string { + return encodeURIComponent(value ?? ""); +} + +function facetKey(request: ObjectDdlRequest, facet: ObjectMetadataFacet): string { + return `${[OBJECT_METADATA_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), facet].join(":")}:`; +} + +function invalidationPrefix(match: MetadataCacheInvalidation): string { + const parts = [OBJECT_METADATA_CACHE_PREFIX]; + if (!match.connectionId) return `${OBJECT_METADATA_CACHE_PREFIX}:`; + parts.push(cacheSegment(match.connectionId)); + if (!match.database) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.database)); + if (!match.schema) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.schema)); + if (!match.tableName) return `${parts.join(":")}:`; + parts.push(cacheSegment(match.tableName)); + return `${parts.join(":")}:`; +} + +function decodeEnvelope(payload: unknown): T | null { + if (!payload || typeof payload !== "object") return null; + const envelope = payload as Partial>; + if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || !("value" in envelope)) return null; + return envelope.value === undefined ? null : envelope.value; +} + +async function waitForPendingInvalidations(cacheKey: string): Promise { + const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise); + if (pending.length) await Promise.all(pending); +} + +export async function loadObjectMetadataFacet(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "disk" | "remote" }> { + const cacheKey = facetKey(request, facet); + await waitForPendingInvalidations(cacheKey); + + if (!options?.force) { + const cached = decodeEnvelope(await loadSchemaCacheSafe(cacheKey)); + if (cached !== null) return { value: cached, cacheStatus: "disk" }; + } + + const existing = inFlightLoads.get(cacheKey); + if (existing && (!options?.force || existing.force)) return { value: (await existing.promise) as T, cacheStatus: "remote" }; + if (existing) { + existing.invalidated = true; + inFlightLoads.delete(cacheKey); + } + + const entry: InFlightLoad = { force: options?.force === true, invalidated: false, promise: Promise.resolve(undefined as T) }; + entry.promise = loader() + .then(async (value) => { + const serialized = JSON.stringify(value); + if (!entry.invalidated && serialized.length <= MAX_PERSISTED_METADATA_CHARS) { + const envelope: ObjectMetadataCacheEnvelope = { version: 1, cachedAt: new Date().toISOString(), value }; + await saveSchemaCacheSafe(cacheKey, envelope); + } + return value; + }) + .finally(() => { + if (inFlightLoads.get(cacheKey) === entry) inFlightLoads.delete(cacheKey); + }); + inFlightLoads.set(cacheKey, entry as InFlightLoad); + return { value: await entry.promise, cacheStatus: "remote" }; +} + +export async function invalidateObjectMetadataCache(match: MetadataCacheInvalidation): Promise { + const prefix = invalidationPrefix(match); + for (const [cacheKey, entry] of inFlightLoads) { + if (!cacheKey.startsWith(prefix)) continue; + entry.invalidated = true; + inFlightLoads.delete(cacheKey); + } + + const existing = pendingInvalidations.get(prefix); + if (existing) return existing; + const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => { + if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix); + }); + pendingInvalidations.set(prefix, deletion); + return deletion; +} diff --git a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts index 894ca3708..2b8aa99a9 100644 --- a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts +++ b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts @@ -9,15 +9,18 @@ export interface TableStructureRefreshScope { } export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): TableStructureRefreshScope { - return { - columns: true, - indexes: true, - foreignKeys: true, - // Trigger definitions can contain large source bodies, so defer them until - // the trigger editor is actually visible. - triggers: activeTab === "triggers", - tableComment: true, - }; + switch (activeTab) { + case "columns": + return { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true }; + case "indexes": + return { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true }; + case "foreignKeys": + return { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true }; + case "triggers": + return { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true }; + case "ddl": + return { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false }; + } } export const TRIGGERS_ONLY_REFRESH_SCOPE: TableStructureRefreshScope = { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 95f8b522d..779760854 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -111,6 +111,7 @@ import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"; +import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache"; import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle"; @@ -1517,16 +1518,20 @@ export const useConnectionStore = defineStore("connection", () => { function invalidateMetadataCachesForNode(node: TreeNode) { if (!node.connectionId) return; const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined); - invalidateMetadataCaches({ + const match = { connectionId: node.connectionId, database: node.database || undefined, schema: node.schema || undefined, tableName, - }); + }; + invalidateMetadataCaches(match); + void invalidateObjectDdlCache(match); } function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) { - invalidateMetadataCaches({ connectionId, database, schema, tableName }); + const match = { connectionId, database, schema, tableName }; + invalidateMetadataCaches(match); + void invalidateObjectDdlCache(match); } function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode { @@ -2367,6 +2372,7 @@ export const useConnectionStore = defineStore("connection", () => { if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null; for (const id of removedIds) { invalidateCompletionCache(id); + void invalidateObjectDdlCache({ connectionId: id }); clearLoadedChildrenCache(id); void deleteTabResultSnapshotsForOwner(id); } @@ -2391,6 +2397,7 @@ export const useConnectionStore = defineStore("connection", () => { clearConnectionIdentifierQuote(config.id); clearConnectionHealthCheck(config.id); invalidateCompletionCache(config.id); + void invalidateObjectDdlCache({ connectionId: config.id }); clearLoadedChildrenCache(config.id); const node = findConnectionNode(config.id); if (node?.isExpanded) { @@ -5183,7 +5190,9 @@ export const useConnectionStore = defineStore("connection", () => { } async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string, catalog?: string) { - invalidateMetadataCaches({ connectionId, database, schema }); + const match = { connectionId, database, schema }; + invalidateMetadataCaches(match); + void invalidateObjectDdlCache(match); const shouldRefreshSchemaNode = !!schema && !catalog; const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null; if (node) {