diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index b53dd1a821..06c2c07515 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -13,11 +13,13 @@ import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, + findNodePathByIdentity, mergeSidebarRegexIndexScopes, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes, type SidebarRegexIndexScope, + type SidebarRegexScopeIdentity, } from "@/lib/sidebar/sidebarSearchTree"; import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex"; @@ -51,7 +53,7 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime"; import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry"; import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; -import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex"; +import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex"; import TreeItem from "./TreeItem.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; @@ -579,6 +581,9 @@ function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) { const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode)); const localTableSearchResults = ref>({}); +const localTableSearchRequestRevisions = new Map(); +type InvalidatedTableSearchScope = SidebarRegexScopeIdentity & { parentNodeId: string }; +const pendingInvalidatedTableSearchScopes = new Map(); const regexTableSearchScopes = shallowRef([]); const localTableSearchParentTypes = new Set(["database", "schema", "linked-server-schema", "group-tables"]); @@ -636,7 +641,13 @@ function scheduleLocalSidebarTableSearchRefresh(parentNodeId: string, focusResto }); } -async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) { +function invalidatedTableSearchScopeKey(scope: InvalidatedTableSearchScope): string { + return [scope.connectionId, scope.catalog ?? "", scope.database, scope.schema ?? "", scope.nodeType, scope.parentNodeId].join("\0"); +} + +async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore, identity?: SidebarRegexScopeIdentity) { + const requestRevision = (localTableSearchRequestRevisions.get(parentNodeId) ?? 0) + 1; + localTableSearchRequestRevisions.set(parentNodeId, requestRevision); try { // A missing persisted index (null) means this scope was never indexed, so // only the currently loaded first page of children is searchable. That @@ -649,16 +660,49 @@ async function loadLocalTableSearchResults(parentNodeId: string, refresh = false const entries = await loadOrBuildSidebarTableSearchIndex( parentNodeId, query, - () => store.loadSidebarTableSearchIndex(parentNodeId), - () => store.refreshSidebarTableSearchIndex(parentNodeId), + () => store.loadSidebarTableSearchIndex(parentNodeId, identity), + () => store.refreshSidebarTableSearchIndex(parentNodeId, identity), refresh, ); + if (localTableSearchRequestRevisions.get(parentNodeId) !== requestRevision) return; if (query || refresh) localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries }; } finally { if (focusRestore) restoreTableSearchInput(focusRestore); } } +function refreshPendingInvalidatedTableSearchScopes() { + for (const [scopeKey, scope] of pendingInvalidatedTableSearchScopes) { + const query = store.sidebarTableSearchQueries[scope.parentNodeId]?.trim() ?? ""; + if (!settingsStore.editorSettings.sidebarTableSearchLocal || !query) { + pendingInvalidatedTableSearchScopes.delete(scopeKey); + continue; + } + if (!store.connectedIds.has(scope.connectionId)) continue; + if (!findNodePathByIdentity(store.treeNodes, scope.parentNodeId, scope)) continue; + pendingInvalidatedTableSearchScopes.delete(scopeKey); + void loadLocalTableSearchResults(scope.parentNodeId, true, undefined, scope).catch((error) => { + console.debug("[DBX][sidebar-table-search:index-rebuild-failed]", { scope, error }); + }); + } +} + +watch( + () => store.sidebarTableSearchIndexInvalidation, + (invalidation) => { + if (!invalidation.scopes.length) return; + const nextResults = { ...localTableSearchResults.value }; + for (const scope of invalidation.scopes) { + delete nextResults[scope.parentNodeId]; + localTableSearchDebouncer.cancel(scope.parentNodeId); + invalidateSidebarTableSearchBuild(scope.parentNodeId); + pendingInvalidatedTableSearchScopes.set(invalidatedTableSearchScopeKey(scope), scope); + } + localTableSearchResults.value = nextResults; + refreshPendingInvalidatedTableSearchScopes(); + }, +); + const filteredNodes = computed(() => { let nodes = displayedTreeNodes.value; if (showConnectedConnectionsOnly.value) { @@ -701,6 +745,8 @@ const flatNodes = computed(() => }), ); +watch(flatNodes, refreshPendingInvalidatedTableSearchScopes, { flush: "post" }); + const sidebarCommentLabelWidths = shallowRef(new Map()); let sidebarCommentMeasureFrame = 0; const sidebarTreeContentWidth = ref(0); @@ -2156,6 +2202,8 @@ onUnmounted(() => { cancelPendingSidebarDataOpen(); remoteTableSearchDebouncer.cancelAll(); localTableSearchDebouncer.cancelAll(); + localTableSearchRequestRevisions.clear(); + pendingInvalidatedTableSearchScopes.clear(); tableSearchFocusRestoreTokens.clear(); latestTableSearchInteractionParentId = null; latestTableSearchInteractionId = 0; diff --git a/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts b/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts index ac7256ee90..973846ccec 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts @@ -19,14 +19,26 @@ import type { TableInfo } from "@/types/database"; */ const inFlightSidebarTableSearchBuilds = new Map>(); +/** + * Stop a later explicit refresh from reusing a build that started before the + * underlying table list was refreshed. The old promise may still settle, but + * callers can start a new build for the same scope immediately. + */ +export function invalidateSidebarTableSearchBuild(scopeKey: string): void { + inFlightSidebarTableSearchBuilds.delete(scopeKey); +} + function dedupeInFlightBuild(scopeKey: string, build: () => Promise): Promise { const existing = inFlightSidebarTableSearchBuilds.get(scopeKey); if (existing) return existing; - const pending = (async () => { + let pending!: Promise; + pending = (async () => { try { return await build(); } finally { - inFlightSidebarTableSearchBuilds.delete(scopeKey); + if (inFlightSidebarTableSearchBuilds.get(scopeKey) === pending) { + inFlightSidebarTableSearchBuilds.delete(scopeKey); + } } })(); inFlightSidebarTableSearchBuilds.set(scopeKey, pending); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts index 41a2b51499..1fec5edf62 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.disconnectDataTabMetadata.spec.ts @@ -42,7 +42,9 @@ describe("connectionStore disconnect data-tab metadata freshness", () => { checkConnectionHealth, disconnectDb, deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + loadSchemaCache: vi.fn().mockResolvedValue(null), saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), })); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index e049a5dd5e..35edf1c93d 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -175,7 +175,14 @@ describe("connectionStore metadata loading", () => { fallback_used: false, }); vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); - vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch, deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), listInstalledAgents: vi.fn().mockResolvedValue([]) })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + })); const { useConnectionStore } = await import("@/stores/connectionStore"); const store = useConnectionStore(); @@ -314,6 +321,8 @@ describe("connectionStore metadata loading", () => { completionAssistantSearch, deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), getCustomTypeDetails, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), })); const { useConnectionStore } = await import("@/stores/connectionStore"); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts index eb7ff81621..39c07c3509 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.timeout.spec.ts @@ -674,7 +674,9 @@ describe("connectionStore timeout recovery", () => { connectDb, deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), disconnectDb, + loadSchemaCache: vi.fn().mockResolvedValue(null), saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), })); @@ -736,7 +738,9 @@ describe("connectionStore timeout recovery", () => { deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), disconnectDb, listInstalledAgents: vi.fn().mockResolvedValue([]), + loadSchemaCache: vi.fn().mockResolvedValue(null), saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), })); @@ -776,7 +780,9 @@ describe("connectionStore timeout recovery", () => { connectDb, deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), disconnectDb, + loadSchemaCache: vi.fn().mockResolvedValue(null), saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), saveSidebarLayout: vi.fn().mockResolvedValue(undefined), })); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index cd6f2e1886..308893580a 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -461,6 +461,12 @@ export const useConnectionStore = defineStore("connection", () => { const sidebarTableSearchIndexCache = new Map(); const sidebarTableSearchIndexInFlight = new Map>(); const sidebarTableSearchIndexConnectionGenerations = new Map(); + const sidebarTableSearchIndexScopeGenerations = new Map(); + const sidebarTableSearchIndexPersistenceQueues = new Map>(); + const sidebarTableSearchIndexInvalidation = ref<{ + revision: number; + scopes: Array; + }>({ revision: 0, scopes: [] }); let sidebarTableSearchIndexManifest: TableSearchIndexManifestEntry[] | null = null; let sidebarTableSearchIndexManifestInFlight: Promise | null = null; let sidebarTableSearchIndexManifestWriteQueue: Promise = Promise.resolve(); @@ -2398,6 +2404,109 @@ export const useConnectionStore = defineStore("connection", () => { return sidebarTableSearchIndexConnectionGenerations.get(connectionId) ?? 0; } + function sidebarTableSearchIndexScopeGeneration(cacheKey: string): number { + return sidebarTableSearchIndexScopeGenerations.get(cacheKey) ?? 0; + } + + function sidebarTableSearchIndexGenerationIsCurrent(connectionId: string, cacheKey: string, connectionGeneration: number, scopeGeneration: number): boolean { + return connectionGeneration === sidebarTableSearchIndexConnectionGeneration(connectionId) && scopeGeneration === sidebarTableSearchIndexScopeGeneration(cacheKey); + } + + async function serializeSidebarTableSearchIndexPersistence(cacheKey: string, operation: () => Promise): Promise { + const previous = sidebarTableSearchIndexPersistenceQueues.get(cacheKey) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(operation); + sidebarTableSearchIndexPersistenceQueues.set(cacheKey, pending); + try { + await pending; + } finally { + if (sidebarTableSearchIndexPersistenceQueues.get(cacheKey) === pending) { + sidebarTableSearchIndexPersistenceQueues.delete(cacheKey); + } + } + } + + type SidebarTableSearchIndexScope = SidebarRegexScopeIdentity & { + parentNodeId: string; + cacheKey: string; + }; + + function sidebarTableSearchIndexScopeForNode(parent: TreeNode): SidebarTableSearchIndexScope | null { + if (!parent.connectionId || !parent.database) return null; + const cacheKey = sidebarTableSearchIndexCacheKey(parent); + if (!cacheKey) return null; + return { + parentNodeId: parent.id, + connectionId: parent.connectionId, + database: parent.database, + schema: parent.schema, + catalog: parent.catalog, + nodeType: parent.type, + cacheKey, + }; + } + + function manifestTableSearchScopeMatchesTreeRefresh(scope: TableSearchIndexManifestEntry, node: TreeNode): boolean { + if (!node.connectionId || scope.connectionId !== node.connectionId) return false; + const pathMatch = scope.path?.some((pathNode) => pathNode.id === node.id && pathNode.type === node.type && pathNode.connectionId === node.connectionId); + if (pathMatch) return true; + if (node.type === "connection") return true; + if (node.type === "doris-catalog") return scope.catalog === node.catalog; + if (node.type === "database") return scope.database === node.database && scope.catalog === node.catalog; + if (node.type === "schema") return scope.database === node.database && scope.schema === node.schema && scope.catalog === node.catalog; + if (node.type === "linked-server-schema") return scope.parentNodeId === node.id && scope.nodeType === node.type; + if (node.type === "group-tables") return scope.parentNodeId === node.id && scope.nodeType === node.type; + return false; + } + + function collectActiveTableSearchScopes(node: TreeNode): SidebarTableSearchIndexScope[] { + const scopes: SidebarTableSearchIndexScope[] = []; + const visit = (current: TreeNode) => { + if (sidebarTableSearchQueries.value[current.id]?.trim()) { + const scope = sidebarTableSearchIndexScopeForNode(current); + if (scope) scopes.push(scope); + } + for (const child of current.children ?? []) visit(child); + }; + visit(node); + return scopes; + } + + function publishSidebarTableSearchIndexInvalidation(scopes: SidebarTableSearchIndexScope[]) { + if (scopes.length === 0) return; + sidebarTableSearchIndexInvalidation.value = { + revision: sidebarTableSearchIndexInvalidation.value.revision + 1, + scopes: scopes.map(({ cacheKey: _cacheKey, ...scope }) => scope), + }; + } + + async function invalidateSidebarTableSearchIndexScopes(scopes: SidebarTableSearchIndexScope[], removeManifestScope: (scope: TableSearchIndexManifestEntry) => boolean): Promise { + const uniqueScopes = [...new Map(scopes.map((scope) => [scope.cacheKey, scope])).values()]; + if (uniqueScopes.length === 0) return; + const cacheKeys = new Set(uniqueScopes.map((scope) => scope.cacheKey)); + for (const cacheKey of cacheKeys) { + sidebarTableSearchIndexScopeGenerations.set(cacheKey, sidebarTableSearchIndexScopeGeneration(cacheKey) + 1); + sidebarTableSearchIndexCache.delete(cacheKey); + sidebarTableSearchIndexInFlight.delete(cacheKey); + } + + const deletePersisted = Promise.all( + [...cacheKeys].map((cacheKey) => + serializeSidebarTableSearchIndexPersistence(cacheKey, async () => { + await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined); + }), + ), + ); + sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => { + const manifest = await loadSidebarTableSearchIndexManifest(); + const nextManifest = manifest.filter((scope) => !cacheKeys.has(scope.cacheKey) && !removeManifestScope(scope)); + if (nextManifest.length === manifest.length) return; + sidebarTableSearchIndexManifest = nextManifest; + await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(nextManifest)).catch(() => undefined); + }); + await Promise.all([deletePersisted, sidebarTableSearchIndexManifestWriteQueue]); + publishSidebarTableSearchIndexInvalidation(uniqueScopes); + } + async function invalidateSidebarTableSearchIndexesForConnection(connectionId: string): Promise { sidebarTableSearchIndexConnectionGenerations.set(connectionId, sidebarTableSearchIndexConnectionGeneration(connectionId) + 1); const rawPrefix = `${connectionId}:`; @@ -2409,14 +2518,38 @@ export const useConnectionStore = defineStore("connection", () => { for (const cacheKey of sidebarTableSearchIndexInFlight.keys()) { if (matchesConnectionCacheKey(cacheKey)) sidebarTableSearchIndexInFlight.delete(cacheKey); } - sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => { - const manifest = await loadSidebarTableSearchIndexManifest(); - const nextManifest = manifest.filter((scope) => scope.connectionId !== connectionId); - if (nextManifest.length === manifest.length) return; - sidebarTableSearchIndexManifest = nextManifest; - await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(nextManifest)).catch(() => undefined); - }); - await sidebarTableSearchIndexManifestWriteQueue; + const manifest = await loadSidebarTableSearchIndexManifest(); + const manifestScopes = manifest + .filter((scope) => scope.connectionId === connectionId) + .map((scope) => ({ + parentNodeId: scope.parentNodeId, + connectionId: scope.connectionId, + database: scope.database, + schema: scope.schema, + catalog: scope.catalog, + nodeType: scope.nodeType, + cacheKey: scope.cacheKey, + })); + const connectionNode = findConnectionNode(connectionId); + const activeScopes = connectionNode ? collectActiveTableSearchScopes(connectionNode) : []; + await invalidateSidebarTableSearchIndexScopes([...manifestScopes, ...activeScopes], (scope) => scope.connectionId === connectionId); + } + + async function invalidateSidebarTableSearchIndexesForTreeRefresh(node: TreeNode): Promise { + const manifest = await loadSidebarTableSearchIndexManifest(); + const manifestScopes = manifest + .filter((scope) => manifestTableSearchScopeMatchesTreeRefresh(scope, node)) + .map((scope) => ({ + parentNodeId: scope.parentNodeId, + connectionId: scope.connectionId, + database: scope.database, + schema: scope.schema, + catalog: scope.catalog, + nodeType: scope.nodeType, + cacheKey: scope.cacheKey, + })); + const activeScopes = collectActiveTableSearchScopes(node); + await invalidateSidebarTableSearchIndexScopes([...manifestScopes, ...activeScopes], (scope) => manifestTableSearchScopeMatchesTreeRefresh(scope, node)); } async function registerSidebarTableSearchIndexScope(parent: TreeNode, cacheKey: string): Promise { @@ -2439,12 +2572,13 @@ export const useConnectionStore = defineStore("connection", () => { if (sidebarTableSearchIndexCache.has(cacheKey)) return sidebarTableSearchIndexCache.get(cacheKey) ?? null; const pending = sidebarTableSearchIndexInFlight.get(cacheKey); if (pending) return pending; - const generation = sidebarTableSearchIndexConnectionGeneration(connectionId); + const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId); + const scopeGeneration = sidebarTableSearchIndexScopeGeneration(cacheKey); const read = (async () => { const decoded = decodeSchemaTreeCache(await api.loadSchemaCache(cacheKey).catch(() => null)); const index = decoded?.tableSearchIndex; const entries = index ? index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })) : null; - if (generation !== sidebarTableSearchIndexConnectionGeneration(connectionId)) return null; + if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return null; sidebarTableSearchIndexCache.set(cacheKey, entries); return entries; })(); @@ -2486,18 +2620,20 @@ export const useConnectionStore = defineStore("connection", () => { async function refreshSidebarTableSearchIndex(parentNodeId: string, identity?: SidebarRegexScopeIdentity): Promise { const parent = identity ? findSidebarTreeNodeByIdentity(parentNodeId, identity) : findNode(treeNodes.value, parentNodeId); if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return []; + const connectionId = parent.connectionId; const cacheKey = sidebarTableSearchIndexCacheKey(parent); if (!cacheKey) return []; - const generation = sidebarTableSearchIndexConnectionGeneration(parent.connectionId); - await ensureConnected(parent.connectionId); - const config = getConfig(parent.connectionId); + const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId); + const scopeGeneration = sidebarTableSearchIndexScopeGeneration(cacheKey); + await ensureConnected(connectionId); + const config = getConfig(connectionId); const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema); const objectTypes = parent.type === "group-tables" ? (objectTypesForGroupNode(parent.type) ?? undefined) : undefined; const pageSize = sidebarObjectGroupPageSize(); const entries: TableInfo[] = []; for (let offset = 0; ; offset += pageSize) { - if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) return []; - const page = await listTablesWithOptionalTableNameFilter(parent.connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog); + if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return []; + const page = await listTablesWithOptionalTableNameFilter(connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog); entries.push(...page); if (page.length < pageSize) break; } @@ -2507,12 +2643,18 @@ export const useConnectionStore = defineStore("connection", () => { indexedAt: new Date().toISOString(), entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })), }; - if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) return []; - await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache([], Date.now(), tableSearchIndex)); - if (generation !== sidebarTableSearchIndexConnectionGeneration(parent.connectionId)) { - await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined); - return []; - } + if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return []; + let persisted = false; + await serializeSidebarTableSearchIndexPersistence(cacheKey, async () => { + if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return; + await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache([], Date.now(), tableSearchIndex)); + if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) { + await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined); + return; + } + persisted = true; + }); + if (!persisted || !sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return []; sidebarTableSearchIndexCache.set(cacheKey, deduped); await registerSidebarTableSearchIndexScope(parent, cacheKey); return deduped; @@ -3541,6 +3683,7 @@ export const useConnectionStore = defineStore("connection", () => { } invalidateCompletionCache(connectionId); invalidateObjectBrowserRowsCache({ connectionId }); + await invalidateSidebarTableSearchIndexesForConnection(connectionId); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); // 断开连接是明确的元数据新鲜度边界:数据标签页保留展示/编辑状态,但 @@ -6305,6 +6448,7 @@ export const useConnectionStore = defineStore("connection", () => { if (objectTypesForGroupNode(node.type)) { clearLoadedChildrenCache(node.id, { deletePersisted: false }); await loadObjectGroupChildren(node, { force: true }); + await invalidateSidebarTableSearchIndexesForTreeRefresh(node); return; } @@ -6339,6 +6483,7 @@ export const useConnectionStore = defineStore("connection", () => { await loadTreeNodeChildren(node, { force: true }); if (isCurrentRefresh()) { await restoreExpandedChildren(node, expandedIds, { force: true }, isCurrentRefresh); + if (isCurrentRefresh()) await invalidateSidebarTableSearchIndexesForTreeRefresh(node); } } catch (error) { // A stale failure must never overwrite a newer successful (including empty) result. @@ -8616,6 +8761,7 @@ export const useConnectionStore = defineStore("connection", () => { databaseExportSource, sidebarSearchQuery, sidebarTableSearchQueries, + sidebarTableSearchIndexInvalidation, sidebarTableNameFilters, tableNameFilterScopeKey, tableNameFilterForScope, diff --git a/packages/app-tests/sidebarRegexIndexStore.test.ts b/packages/app-tests/sidebarRegexIndexStore.test.ts index ecea090727..0821cf4e7c 100644 --- a/packages/app-tests/sidebarRegexIndexStore.test.ts +++ b/packages/app-tests/sidebarRegexIndexStore.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { beforeEach, test, vi } from "vitest"; import { createPinia, setActivePinia } from "pinia"; -import { encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "../../apps/desktop/src/lib/metadata/schemaTreeCache.ts"; +import { decodeSchemaTreeCache, encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "../../apps/desktop/src/lib/metadata/schemaTreeCache.ts"; import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts"; const MANIFEST_CACHE_KEY = "dbx:sidebar-table-search-index-manifest-v1"; @@ -101,6 +101,51 @@ function connectionNodeWithDatabases(...databases: string[]): TreeNode { }; } +function connectionNodeWithTableGroups(...databases: string[]): { root: TreeNode; groups: Record } { + const groups: Record = {}; + const root: TreeNode = { + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + isExpanded: true, + children: databases.map((database) => { + const group: TreeNode = { + id: `conn-1:${database}:public:__tables`, + label: "tree.tables", + type: "group-tables", + connectionId: "conn-1", + database, + schema: "public", + isExpanded: true, + children: [], + }; + groups[database] = group; + return { + id: `conn-1:${database}`, + label: database, + type: "database", + connectionId: "conn-1", + database, + isExpanded: true, + children: [ + { + id: `conn-1:${database}:public`, + label: "public", + type: "schema", + connectionId: "conn-1", + database, + schema: "public", + isExpanded: true, + children: [group], + }, + ], + } satisfies TreeNode; + }), + }; + return { root, groups }; +} + function indexEnvelope(entries: Array<{ name: string; tableType: string }>, cachedAt = Date.now()) { return encodeSchemaTreeCache([], cachedAt, { complete: true, @@ -500,3 +545,164 @@ test("updating a connection invalidates its in-memory index and manifest scopes" restoreStorage(); } }); + +test("refreshing a table group invalidates only its local index across rename and delete", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + const { root, groups } = connectionNodeWithTableGroups("app", "audit"); + store.treeNodes.push(root); + const tables = new Map>([ + ["app", [{ name: "old_orders", table_type: "TABLE" }]], + ["audit", [{ name: "audit_log", table_type: "TABLE" }]], + ]); + listTablesMock.mockImplementation(async (_connectionId: string, database: string, _schema: string, _filter: unknown, limit = 500, offset = 0) => (tables.get(database) ?? []).slice(offset, offset + limit)); + + await store.refreshSidebarTableSearchIndex(groups.app!.id); + await store.refreshSidebarTableSearchIndex(groups.audit!.id); + store.setSidebarTableSearchQuery(groups.app!.id, "orders"); + + tables.set("app", [{ name: "renamed_orders", table_type: "TABLE" }]); + await store.refreshTreeNode(groups.app!); + + assert.deepEqual( + groups.app!.children?.map((node) => node.label), + ["renamed_orders"], + ); + assert.equal(await store.loadSidebarTableSearchIndex(groups.app!.id), null); + assert.deepEqual( + (await store.loadSidebarTableSearchIndex(groups.audit!.id))?.map((entry) => entry.name), + ["audit_log"], + ); + assert.deepEqual( + store.sidebarTableSearchIndexInvalidation.scopes.map((scope) => scope.parentNodeId), + [groups.app!.id], + ); + + assert.deepEqual( + (await store.refreshSidebarTableSearchIndex(groups.app!.id)).map((entry) => entry.name), + ["renamed_orders"], + ); + tables.set("app", []); + await store.refreshTreeNode(groups.app!); + assert.deepEqual(await store.refreshSidebarTableSearchIndex(groups.app!.id), []); + assert.equal( + (await store.loadSidebarTableSearchIndex(groups.app!.id))?.some((entry) => entry.name === "renamed_orders"), + false, + ); + assert.deepEqual( + (await store.loadSidebarTableSearchIndex(groups.audit!.id))?.map((entry) => entry.name), + ["audit_log"], + ); + } finally { + restoreStorage(); + } +}); + +test("a table refresh prevents an older in-flight index build from restoring stale names", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + const { root, groups } = connectionNodeWithTableGroups("app"); + store.treeNodes.push(root); + store.setSidebarTableSearchQuery(groups.app!.id, "orders"); + let releaseStale!: () => void; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + let callCount = 0; + listTablesMock.mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + await staleGate; + return [{ name: "stale_orders", table_type: "TABLE" }]; + } + return [{ name: "fresh_orders", table_type: "TABLE" }]; + }); + + const staleBuild = store.refreshSidebarTableSearchIndex(groups.app!.id); + await vi.waitFor(() => assert.equal(callCount, 1)); + await store.refreshTreeNode(groups.app!); + releaseStale(); + + assert.deepEqual(await staleBuild, []); + assert.deepEqual( + groups.app!.children?.map((node) => node.label), + ["fresh_orders"], + ); + assert.equal(await store.loadSidebarTableSearchIndex(groups.app!.id), null); + const invalidatedCacheKey = deleteSchemaCachePrefixMock.mock.calls.at(-1)?.[0]; + assert.ok(typeof invalidatedCacheKey === "string"); + assert.equal(persistedCache.has(invalidatedCacheKey), false); + } finally { + restoreStorage(); + } +}); + +test("stale index persistence cannot delete a replacement built after refresh", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection(conn("conn-1")); + const { root, groups } = connectionNodeWithTableGroups("app"); + store.treeNodes.push(root); + store.setSidebarTableSearchQuery(groups.app!.id, "orders"); + + let listCallCount = 0; + listTablesMock.mockImplementation(async () => { + listCallCount += 1; + return [{ name: listCallCount === 1 ? "stale_orders" : "fresh_orders", table_type: "TABLE" }]; + }); + + let releaseStaleSave!: () => void; + let markStaleSaveStarted!: () => void; + const staleSaveStarted = new Promise((resolve) => { + markStaleSaveStarted = resolve; + }); + const staleSaveGate = new Promise((resolve) => { + releaseStaleSave = resolve; + }); + let indexSaveCount = 0; + let indexCacheKey = ""; + saveSchemaCacheMock.mockImplementation(async (cacheKey: string, payload: unknown) => { + if (cacheKey !== MANIFEST_CACHE_KEY) { + indexCacheKey = cacheKey; + indexSaveCount += 1; + if (indexSaveCount === 1) { + markStaleSaveStarted(); + await staleSaveGate; + } + } + persistedCache.set(cacheKey, payload); + }); + + const staleBuild = store.refreshSidebarTableSearchIndex(groups.app!.id); + await staleSaveStarted; + const treeRefresh = store.refreshTreeNode(groups.app!); + await vi.waitFor(() => assert.ok(listCallCount >= 2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + const freshBuild = store.refreshSidebarTableSearchIndex(groups.app!.id); + + releaseStaleSave(); + assert.deepEqual(await staleBuild, []); + await treeRefresh; + assert.deepEqual( + (await freshBuild).map((entry) => entry.name), + ["fresh_orders"], + ); + + assert.ok(indexCacheKey); + const persisted = decodeSchemaTreeCache(persistedCache.get(indexCacheKey)); + assert.deepEqual( + persisted?.tableSearchIndex?.entries.map((entry) => entry.name), + ["fresh_orders"], + ); + } finally { + restoreStorage(); + } +}); diff --git a/packages/app-tests/sidebarTableSearchIndex.test.ts b/packages/app-tests/sidebarTableSearchIndex.test.ts index 7ec85509c1..5ca0e1a225 100644 --- a/packages/app-tests/sidebarTableSearchIndex.test.ts +++ b/packages/app-tests/sidebarTableSearchIndex.test.ts @@ -12,7 +12,7 @@ // and never for an empty (cleared) query. import { expect, test, vi } from "vitest"; import assert from "node:assert/strict"; -import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts"; +import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts"; import type { TableInfo } from "../../apps/desktop/src/types/database.ts"; const TARGET: TableInfo = { name: "T_Erp_Nc_SuPlan_List", table_type: "TABLE", comment: null }; @@ -64,10 +64,7 @@ test("concurrent first-use searches share a single in-flight build", async () => await new Promise((resolve) => setTimeout(resolve, 20)); return [TARGET]; }); - const [first, second] = await Promise.all([ - loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build), - loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build), - ]); + const [first, second] = await Promise.all([loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build), loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build)]); assert.deepEqual(first, [TARGET]); assert.deepEqual(second, [TARGET]); expect(build).toHaveBeenCalledTimes(1); @@ -77,10 +74,7 @@ test("parallel builds for different scopes run independently", async () => { const read = async () => null; const buildA = vi.fn(async () => [TARGET]); const buildB = vi.fn(async () => [{ name: "other", table_type: "TABLE", comment: null }]); - const [a, b] = await Promise.all([ - loadOrBuildSidebarTableSearchIndex("scope-a", "erpncs", read, buildA), - loadOrBuildSidebarTableSearchIndex("scope-b", "erpncs", read, buildB), - ]); + const [a, b] = await Promise.all([loadOrBuildSidebarTableSearchIndex("scope-a", "erpncs", read, buildA), loadOrBuildSidebarTableSearchIndex("scope-b", "erpncs", read, buildB)]); assert.deepEqual(a, [TARGET]); assert.deepEqual(b, [{ name: "other", table_type: "TABLE", comment: null }]); expect(buildA).toHaveBeenCalledTimes(1); @@ -101,14 +95,44 @@ test("concurrent refreshes for the same scope share a single in-flight build", a await new Promise((resolve) => setTimeout(resolve, 20)); return [TARGET]; }); - const results = await Promise.all([ - loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true), - loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true), - ]); + const results = await Promise.all([loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true), loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true)]); assert.deepEqual(results, [[TARGET], [TARGET]]); expect(build).toHaveBeenCalledTimes(1); }); +test("invalidating a scope lets a replacement build bypass stale in-flight work", async () => { + let releaseStale!: () => void; + const staleGate = new Promise((resolve) => { + releaseStale = resolve; + }); + let releaseFresh!: () => void; + const freshGate = new Promise((resolve) => { + releaseFresh = resolve; + }); + const staleBuild = vi.fn(async () => { + await staleGate; + return [TARGET]; + }); + const fresh = { name: "fresh", table_type: "TABLE", comment: null } satisfies TableInfo; + const freshBuild = vi.fn(async () => { + await freshGate; + return [fresh]; + }); + + const staleRequest = loadOrBuildSidebarTableSearchIndex("scope-invalidated", "old", async () => null, staleBuild, true); + await vi.waitFor(() => expect(staleBuild).toHaveBeenCalledTimes(1)); + invalidateSidebarTableSearchBuild("scope-invalidated"); + const freshRequest = loadOrBuildSidebarTableSearchIndex("scope-invalidated", "fresh", async () => null, freshBuild, true); + + await vi.waitFor(() => expect(freshBuild).toHaveBeenCalledTimes(1)); + releaseStale(); + await expect(staleRequest).resolves.toEqual([TARGET]); + const concurrentFreshRequest = loadOrBuildSidebarTableSearchIndex("scope-invalidated", "fresh", async () => null, freshBuild, true); + expect(freshBuild).toHaveBeenCalledTimes(1); + releaseFresh(); + await expect(Promise.all([freshRequest, concurrentFreshRequest])).resolves.toEqual([[fresh], [fresh]]); +}); + test("rapid consecutive schedules coalesce into a single run", () => { vi.useFakeTimers(); try {