From 71136377e5a7d69cd50bd23845ffdf48cd2f0ef1 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 16 Aug 2026 14:09:50 +0800 Subject: [PATCH] fix(sidebar): cancel stale table search work across modes --- .../src/components/sidebar/ConnectionTree.vue | 60 +++- .../lib/sidebar/sidebarTableSearchIndex.ts | 103 +++++++ .../connectionStore.dorisCatalog.spec.ts | 38 +++ .../connectionStore.metadataLoading.spec.ts | 9 +- apps/desktop/src/stores/connectionStore.ts | 38 ++- ...arLocalTableSearchIndexFirstSearch.test.ts | 273 ++++++++++++++++++ .../sidebarLocalTableSearchMatcher.test.ts | 66 +++++ .../app-tests/sidebarTableSearchIndex.test.ts | 200 +++++++++++++ 8 files changed, 763 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts create mode 100644 packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts create mode 100644 packages/app-tests/sidebarLocalTableSearchMatcher.test.ts create mode 100644 packages/app-tests/sidebarTableSearchIndex.test.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 480a39d263..bd862179a3 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -40,6 +40,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 TreeItem from "./TreeItem.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; @@ -149,7 +150,10 @@ const searchRefreshedNodeIds = searchExpansionState.filteredNodeIds; // touched group open and reloading it again. const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds; let searchTimer: number | undefined; -const tableSearchTimers = new Map(); +const remoteTableSearchDebouncer = createSidebarTableSearchDebouncer(); +// Local-mode table searches debounce like remote ones: rapid typing coalesces +// into a single index load/build instead of one full build per keystroke. +const localTableSearchDebouncer = createSidebarTableSearchDebouncer(); const tableSearchFocusRestoreTokens = new Map(); let tableSearchFocusRestoreTokenSeq = 0; let latestTableSearchInteractionParentId: string | null = null; @@ -244,8 +248,8 @@ watch( if (parentNodeIds.length === 0) return; for (const parentNodeId of parentNodeIds) { - window.clearTimeout(tableSearchTimers.get(parentNodeId)); - tableSearchTimers.delete(parentNodeId); + remoteTableSearchDebouncer.cancel(parentNodeId); + localTableSearchDebouncer.cancel(parentNodeId); tableSearchFocusRestoreTokens.delete(parentNodeId); store.setSidebarTableSearchQuery(parentNodeId, ""); } @@ -486,15 +490,17 @@ function clearSearchScopeFilter() { } function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) { - window.clearTimeout(tableSearchTimers.get(parentNodeId)); - if (isTreeSearchFiltering.value) return; + if (isTreeSearchFiltering.value) { + remoteTableSearchDebouncer.cancel(parentNodeId); + localTableSearchDebouncer.cancel(parentNodeId); + return; + } const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0; if (restoreToken) { tableSearchFocusRestoreTokens.clear(); tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken); } - const timer = window.setTimeout(() => { - tableSearchTimers.delete(parentNodeId); + scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, remoteTableSearchDebouncer, localTableSearchDebouncer, () => { void store.refreshSidebarTableSearch(parentNodeId).then(() => { if (!restoreToken) return; if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return; @@ -503,8 +509,7 @@ function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { foc if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return; restoreTableSearchInput(focusRestore); }); - }, 250); - tableSearchTimers.set(parentNodeId, timer); + }); } function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore { @@ -607,10 +612,35 @@ function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] { return mergeSidebarRegexIndexScopes(nodes, matchingScopes); } +function scheduleLocalSidebarTableSearchRefresh(parentNodeId: string, focusRestore?: TableSearchFocusRestore) { + if (isTreeSearchFiltering.value) { + localTableSearchDebouncer.cancel(parentNodeId); + remoteTableSearchDebouncer.cancel(parentNodeId); + return; + } + scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, localTableSearchDebouncer, remoteTableSearchDebouncer, () => { + void loadLocalTableSearchResults(parentNodeId, false, focusRestore); + }); +} + async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) { try { - const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId); - localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries }; + // A missing persisted index (null) means this scope was never indexed, so + // only the currently loaded first page of children is searchable. That + // silently misses alphabetically-late tables (e.g. "T_Erp_Nc_SuPlan_List" + // for "erpncs") until an explicit index refresh happens. Build the index + // on the first search so results always cover the complete table set, + // matching the behavior of an explicit refresh. The scope key deduplicates + // concurrent full builds, and an empty (cleared) query never builds. + const query = store.sidebarTableSearchQueries[parentNodeId]?.trim() ?? ""; + const entries = await loadOrBuildSidebarTableSearchIndex( + parentNodeId, + query, + () => store.loadSidebarTableSearchIndex(parentNodeId), + () => store.refreshSidebarTableSearchIndex(parentNodeId), + refresh, + ); + if (query || refresh) localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries }; } finally { if (focusRestore) restoreTableSearchInput(focusRestore); } @@ -1099,7 +1129,7 @@ provide(sidebarTreeContextKey, { restoreTableSearchInput(focusRestore); localTableSearchFocusPending = false; }); - void loadLocalTableSearchResults(parentNodeId, false, focusRestore); + scheduleLocalSidebarTableSearchRefresh(parentNodeId, focusRestore); } else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore }); }, refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true), @@ -1999,10 +2029,8 @@ onUnmounted(() => { resetSidebarTreeDialogState(); window.removeEventListener("keydown", onWindowKeydown); cancelPendingSidebarDataOpen(); - for (const timer of tableSearchTimers.values()) { - window.clearTimeout(timer); - } - tableSearchTimers.clear(); + remoteTableSearchDebouncer.cancelAll(); + localTableSearchDebouncer.cancelAll(); tableSearchFocusRestoreTokens.clear(); latestTableSearchInteractionParentId = null; latestTableSearchInteractionId = 0; diff --git a/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts b/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts new file mode 100644 index 0000000000..ac7256ee90 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarTableSearchIndex.ts @@ -0,0 +1,103 @@ +import type { TableInfo } from "@/types/database"; + +/** + * In-flight full-index builds, keyed by sidebar scope (parent node id). + * + * A missing persisted index (read returns null) means the scope was never + * indexed, so only the currently loaded first page of children would be + * searchable. That silently misses alphabetically-late tables — e.g. + * "T_Erp_Nc_SuPlan_List" for the fuzzy query "erpncs" — until an explicit + * index refresh happens (the refresh button). Building the index on the first + * search makes the complete table set searchable from the start, matching the + * behavior of an explicit refresh. + * + * Building pages through every table of a scope, so rapid consecutive first + * searches must never start concurrent full builds: while one build for a + * scope is in flight, further requests reuse the same promise instead of + * spawning a duplicate scan. Entries are removed once the build settles + * (success or failure) so a later search can retry. + */ +const inFlightSidebarTableSearchBuilds = new Map>(); + +function dedupeInFlightBuild(scopeKey: string, build: () => Promise): Promise { + const existing = inFlightSidebarTableSearchBuilds.get(scopeKey); + if (existing) return existing; + const pending = (async () => { + try { + return await build(); + } finally { + inFlightSidebarTableSearchBuilds.delete(scopeKey); + } + })(); + inFlightSidebarTableSearchBuilds.set(scopeKey, pending); + return pending; +} + +/** + * Load the local table search index for a sidebar scope, building it on first + * use — exactly once per scope, even under rapid consecutive input. + * + * - An empty (or cleared) query filters nothing, so no read or build happens: + * clearing the input must not scan every page of the table set for nothing. + * - An explicit refresh (`refresh = true`) always rebuilds, but still through + * the in-flight lock so double-clicks cannot start duplicate full builds. + * - A persisted index is reused without rebuilding; a missing one is built + * through the lock, and concurrent callers share the same build promise. + */ +export async function loadOrBuildSidebarTableSearchIndex(scopeKey: string, query: string, read: () => Promise, build: () => Promise, refresh = false): Promise { + if (!refresh && !query.trim()) return null; + if (refresh) return dedupeInFlightBuild(scopeKey, build); + const entries = await read(); + if (entries) return entries; + return dedupeInFlightBuild(scopeKey, build); +} + +export interface SidebarTableSearchDebouncer { + /** Debounce a per-key load; consecutive calls for the same key within the window coalesce. */ + schedule(key: string, run: () => void): void; + /** Cancel a pending (scheduled, not yet run) load for a key. */ + cancel(key: string): void; + /** Cancel every pending load. */ + cancelAll(): void; + /** Number of pending (scheduled, not yet run) loads. */ + pendingCount(): number; +} + +/** + * Per-key debouncer used to coalesce rapid table-search input into a single + * load/build. Each schedule() for the same key resets the window, so fast + * typing issues one load instead of one full index build per keystroke. + */ +export function createSidebarTableSearchDebouncer(delayMs = 250): SidebarTableSearchDebouncer { + const timers = new Map>(); + return { + schedule(key, run) { + const existing = timers.get(key); + if (existing !== undefined) clearTimeout(existing); + const timer = setTimeout(() => { + timers.delete(key); + run(); + }, delayMs); + timers.set(key, timer); + }, + cancel(key) { + const existing = timers.get(key); + if (existing !== undefined) { + clearTimeout(existing); + timers.delete(key); + } + }, + cancelAll() { + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }, + pendingCount() { + return timers.size; + }, + }; +} + +export function scheduleExclusiveSidebarTableSearchDebounce(key: string, active: SidebarTableSearchDebouncer, inactive: SidebarTableSearchDebouncer, run: () => void): void { + inactive.cancel(key); + active.schedule(key, run); +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts index 9e88caf431..c96cac9b44 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts @@ -126,4 +126,42 @@ describe("connectionStore Doris catalog tree", () => { ["iceberg", "sales", "tree.queries"], ]); }); + + it("uses the bounded fuzzy-search budget for external catalog tables", async () => { + const listTables = vi.fn().mockResolvedValue([{ name: "late_orders", table_type: "BASE TABLE", comment: null }]); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "simple"; + settingsStore.desktopSettings.sidebar_table_page_size = 500; + const connection = starRocksConnection(); + const database = databaseNode(connection.id, "hive", "sales"); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [catalogNode(connection.id, "hive", "Hive", [database])] }]; + store.setSidebarTableSearchQuery(database.id, "orders"); + + await store.loadTreeNodeChildren(database, { + force: true, + searchFilter: "orders", + sidebarTableSearchParentId: database.id, + expectedSidebarTableSearchQuery: "orders", + }); + + expect(listTables.mock.calls[0]?.[4]).toBe(2000); + expect(listTables.mock.calls[0]?.[5]).toBeUndefined(); + expect(listTables.mock.calls[0]?.[7]).toBe("hive"); + }); }); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index fa31406694..30bfb6e258 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -12,6 +12,11 @@ function installLocalStorage() { }); } +// Mirrors SIDEBAR_TABLE_SEARCH_RESULT_BUDGET in connectionStore.ts (4× the +// default sidebar_table_page_size of 500). Kept as a local constant because +// connectionStore must stay dynamically imported for vi.doMock isolation. +const SIDEBAR_SEARCH_RESULT_BUDGET = 2000; + function postgresConnection(): ConnectionConfig { return { id: "pg-1", @@ -3165,7 +3170,7 @@ describe("connectionStore metadata loading", () => { expect(tablesGroup.objectCount).toBe(5); expect(tablesGroup.isLoading).toBe(false); expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([ - ["0003", 2, undefined], + ["0003", SIDEBAR_SEARCH_RESULT_BUDGET, undefined], [undefined, 3, 0], [undefined, 3, 0], [undefined, 3, 2], @@ -3219,7 +3224,7 @@ describe("connectionStore metadata loading", () => { store.sidebarSearchQuery = "orders"; await store.loadObjectGroupChildren(tablesGroup, { force: true }); - expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", 200, undefined, ["TABLE"]); + expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", SIDEBAR_SEARCH_RESULT_BUDGET, undefined, ["TABLE"]); expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]); let resolveAncestorLoad!: (tables: TableInfo[]) => void; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index d190a39011..482c433645 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -198,6 +198,19 @@ function sidebarObjectGroupPageSize(): number { return typeof size === "number" && size > 0 ? size : 500; } +/** + * Upper bound for a single remote fuzzy table-search result set. + * + * Every fuzzy match travels database → IPC → store → tree rendering, so an + * unbounded result set (e.g. a single-letter query against a large schema) + * would push thousands of rows through the whole pipeline. The budget is 4× + * the default page size (500) and comfortably covers the reported #6190 + * schema (801 fuzzy matches) while keeping a single IPC payload bounded. + * Queries whose fuzzy match set exceeds the budget are truncated; narrowing + * the query reaches later tables. + */ +export const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000; + function isFlatMqConnection(config: ConnectionConfig | undefined): boolean { if (!config || config.db_type !== "mq") return false; if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true; @@ -1855,7 +1868,14 @@ export const useConnectionStore = defineStore("connection", () => { catalog: options.node.catalog, }); const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter); - const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1; + // A search must never truncate the fuzzy result set to the first page: the + // target table can sort beyond it (e.g. "T_Erp_Nc_SuPlan_List" for + // "erpncs" in a large ERP schema), which silently drops it from the first + // search even though later, narrower queries succeed. Results are bounded + // by SIDEBAR_TABLE_SEARCH_RESULT_BUDGET so a wide fuzzy query cannot push + // an unbounded result set through database → IPC → store → tree rendering. + // Unfiltered loads keep the page+1 probe used for load-more detection. + const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ @@ -1964,7 +1984,11 @@ export const useConnectionStore = defineStore("connection", () => { schema: options.effectiveSchema ?? options.querySchema, nodeKind: "simple-tables", }); - const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1; + // A search must never truncate the fuzzy result set to the first page (see + // loadPagedTableGroupChildren); results are bounded by + // SIDEBAR_TABLE_SEARCH_RESULT_BUDGET, and unfiltered loads keep the + // page+1 probe. + const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ @@ -4674,6 +4698,8 @@ export const useConnectionStore = defineStore("connection", () => { const configForScope = getConfig(connectionId); const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; + const searchFilterForScope = activeTreeLoadSearchFilter(options); + const pageSizeForScope = sidebarObjectGroupPageSize(); return runTreeMetadataLoad( { kind: "schema-tables", @@ -4682,8 +4708,8 @@ export const useConnectionStore = defineStore("connection", () => { schema: undefined, nodeKind: "database", objectTypes: objectTypesForScope, - searchFilter: activeTreeLoadSearchFilter(options), - limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined, + searchFilter: searchFilterForScope, + limit: simpleObjectDisplayForScope ? (searchFilterForScope ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSizeForScope + 1) : undefined, offset: 0, sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped", driverProfile: metadataDriverProfile(configForScope), @@ -4710,8 +4736,8 @@ export const useConnectionStore = defineStore("connection", () => { return; } } - const pageSize = sidebarObjectGroupPageSize(); - const fetchLimit = searchFilter ? pageSize : pageSize + 1; + const pageSize = pageSizeForScope; + const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSize + 1; const fetchOffset = searchFilter ? undefined : 0; const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables"); const hasMore = searchFilter ? false : tables.length > pageSize; diff --git a/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts b/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts new file mode 100644 index 0000000000..9c91c1a3e1 --- /dev/null +++ b/packages/app-tests/sidebarLocalTableSearchIndexFirstSearch.test.ts @@ -0,0 +1,273 @@ +// Regression test for t8y2/dbx #6190. +// +// Local-mode sidebar table search (sidebarTableSearchLocal, the default) only +// searches the persisted table search index. When that index has never been +// built, the first search falls back to the currently loaded first page of +// children, which silently misses alphabetically-late tables such as +// "T_Erp_Nc_SuPlan_List" (sorted after hundreds of "A_Erp_*"/"T_Bas_*" names) +// for the fuzzy query "erpncs". This test locks in the fixed behavior: the +// first search builds the index so the complete table set is searchable. +// +// Remote searches must stay bounded: every fuzzy match travels +// database → IPC → store → tree rendering, so the result set is capped by +// SIDEBAR_TABLE_SEARCH_RESULT_BUDGET (mirrored from connectionStore.ts). +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts"; +import type { ConnectionConfig, TableInfo, TreeNode } from "@/types/database"; + +// Mirrors SIDEBAR_TABLE_SEARCH_RESULT_BUDGET in connectionStore.ts (4× the +// default sidebar_table_page_size of 500). Kept as a local constant because +// connectionStore must stay dynamically imported for vi.doMock isolation. +const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function sqlServerConnection(): ConnectionConfig { + return { + id: "mssql-1", + name: "MSSQL", + db_type: "sqlserver", + host: "127.0.0.1", + port: 1433, + username: "sa", + password: "", + database: "erp", + } as ConnectionConfig; +} + +// Simulate the SQL Server backend: SQL-side fuzzy (contains OR subsequence), +// name-ordered, with limit applied last (SELECT TOP semantics). +function subsequence(text: string, q: string): boolean { + const lower = text.toLowerCase(); + let j = 0; + for (let i = 0; i < lower.length && j < q.length; i++) if (lower[i] === q[j]) j++; + return j === q.length; +} + +// A large ERP-like schema: the target table sorts after hundreds of other +// tables, so it is absent from the first unfiltered page (page size 500). +function buildTables(): TableInfo[] { + const tables: TableInfo[] = []; + for (let i = 0; i < 700; i++) tables.push({ name: `A_Erp_Nc_Sys_${i}`, table_type: "TABLE", comment: null }); + for (let i = 0; i < 100; i++) tables.push({ name: `T_Erp_Nc_Su_Table_${i}`, table_type: "TABLE", comment: null }); + for (let i = 0; i < 300; i++) tables.push({ name: `T_Bas_Customer_${i}`, table_type: "TABLE", comment: null }); + for (let i = 0; i < 300; i++) tables.push({ name: `T_Fin_Account_${i}`, table_type: "TABLE", comment: null }); + tables.push({ name: "T_Erp_Nc_SuPlan_List", table_type: "TABLE", comment: null }); + return tables; +} + +function listTablesFor(allTables: TableInfo[]) { + return vi.fn(async (_conn: string, _db: string, _schema: string, filter?: string, limit?: number, offset?: number) => { + const q = filter?.trim().toLowerCase(); + const sorted = [...allTables].sort((a, b) => a.name.localeCompare(b.name)); + let matched = sorted; + if (q) matched = sorted.filter((t) => t.name.toLowerCase().includes(q) || (q.length >= 2 && subsequence(t.name, q))); + const start = offset ?? 0; + return matched.slice(start, start + (limit ?? matched.length)); + }); +} + +async function installStore(listTables: ReturnType) { + const cachedPayloads = new Map(); + const loadSchemaCache = vi.fn(async (key: string) => { + const payload = cachedPayloads.get(key) ?? null; + await Promise.resolve(); + return payload == null ? null : structuredClone(payload); + }); + const saveSchemaCache = vi.fn(async (key: string, payload: unknown) => { + cachedPayloads.set(key, structuredClone(payload)); + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listTables, + loadSchemaCache, + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache, + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + useSettingsStore().desktopSettings.sidebar_table_page_size = 500; + + const connection = sqlServerConnection(); + const tablesGroup: TreeNode = { + id: "mssql-1:erp:dbo:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "erp", + schema: "dbo", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mssql-1:erp", + label: "erp", + type: "database", + connectionId: connection.id, + database: "erp", + isExpanded: true, + children: [ + { + id: "mssql-1:erp:dbo", + label: "dbo", + type: "schema", + connectionId: connection.id, + database: "erp", + schema: "dbo", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ], + }, + ]; + return { store, tablesGroup }; +} + +describe("sidebar local table search first-search index build (#6190)", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("builds the local search index on the first search so late-sorted tables are found", async () => { + const allTables = buildTables(); + const listTables = listTablesFor(allTables); + const { store, tablesGroup } = await installStore(listTables); + + // 1. Expand the tables group: the first page does not contain the target. + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + const firstPage = (tablesGroup.children ?? []).map((node) => node.label); + expect(firstPage).not.toContain("T_Erp_Nc_SuPlan_List"); + + // 2. Before the fix, the persisted index is missing, so the first search + // only sees the loaded first page and misses the target. + expect(await store.loadSidebarTableSearchIndex(tablesGroup.id)).toBeNull(); + // Pre-fix UI path: filterLocallySearchedTables with indexed === null falls + // back to the loaded children, so the target is invisible on first search. + const preFixResults = firstPage.filter((name) => !!matchSidebarLabel(name, "erpncs")); + expect(preFixResults).not.toContain("T_Erp_Nc_SuPlan_List"); + + // 3. The fix: the first search builds the index (refreshSidebarTableSearchIndex + // pages through the complete table set), which then contains the target. + const index = await store.refreshSidebarTableSearchIndex(tablesGroup.id); + const indexNames = index.map((entry) => entry.name); + expect(indexNames).toContain("T_Erp_Nc_SuPlan_List"); + + // 4. The UI filter used by filterLocallySearchedTables (indexed branch) + // matches the target for the reported queries, on the first try. + const erpncs = index.filter((entry) => !!matchSidebarLabel(entry.name, "erpncs")); + expect(erpncs.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List"); + + const terpncs = index.filter((entry) => !!matchSidebarLabel(entry.name, "terpncs")); + expect(terpncs.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List"); + + // 5. Case variants behave identically. + for (const query of ["ERPnCS", "ERPNCS"]) { + const matches = index.filter((entry) => !!matchSidebarLabel(entry.name, query)); + expect(matches.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List"); + } + + // 6. Repeat searches are order-independent: the built index is served from + // the persisted cache on subsequent searches. + const cached = await store.loadSidebarTableSearchIndex(tablesGroup.id); + expect(cached?.map((entry) => entry.name)).toContain("T_Erp_Nc_SuPlan_List"); + }, 30000); + + it("remote search returns the complete fuzzy result set within the result budget", async () => { + const allTables = buildTables(); + const listTables = listTablesFor(allTables); + const { store, tablesGroup } = await installStore(listTables); + + const searchOptions = (searchFilter: string): Parameters[1] => ({ + force: true, + searchFilter, + sidebarTableSearchParentId: tablesGroup.id, + expectedSidebarTableSearchQuery: searchFilter, + }); + // Mirrors the real input path: the query is committed before the refresh. + const search = async (searchFilter: string) => { + store.setSidebarTableSearchQuery(tablesGroup.id, searchFilter); + await store.loadObjectGroupChildren(tablesGroup, searchOptions(searchFilter)); + }; + + // First remote search: the backend receives the result budget as the + // limit — 4× the page size, comfortably above the 801 fuzzy matches of + // this schema — so the alphabetically-late target is never truncated. + await search("erpncs"); + const firstResults = (tablesGroup.children ?? []).map((node) => node.label); + expect(firstResults).toContain("T_Erp_Nc_SuPlan_List"); + expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === SIDEBAR_TABLE_SEARCH_RESULT_BUDGET)).toBe(true); + + // Repeated searches are order-independent. + await search("terpncs"); + expect((tablesGroup.children ?? []).map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List"); + await search("erpncs"); + expect((tablesGroup.children ?? []).map((node) => node.label)).toContain("T_Erp_Nc_SuPlan_List"); + }, 30000); + + it("remote search results never exceed the result budget", async () => { + // 3000 tables whose names all contain "erpncs": an unbounded search would + // return all 3000; the budget caps a single result set at 2000. + const allTables: TableInfo[] = []; + for (let i = 0; i < 3000; i++) allTables.push({ name: `ErpNcS${i}`, table_type: "TABLE", comment: null }); + const listTables = listTablesFor(allTables); + const { store, tablesGroup } = await installStore(listTables); + + await store.loadObjectGroupChildren(tablesGroup, { + force: true, + searchFilter: "erpncs", + sidebarTableSearchParentId: tablesGroup.id, + expectedSidebarTableSearchQuery: "erpncs", + }); + + const children = tablesGroup.children ?? []; + expect(children.length).toBeLessThanOrEqual(SIDEBAR_TABLE_SEARCH_RESULT_BUDGET); + expect(listTables.mock.calls.some((call) => call[3] === "erpncs" && call[4] === SIDEBAR_TABLE_SEARCH_RESULT_BUDGET)).toBe(true); + }, 30000); + + it("an empty (cleared) query never issues a remote fuzzy search", async () => { + const allTables = buildTables(); + const listTables = listTablesFor(allTables); + const { store, tablesGroup } = await installStore(listTables); + + // Expand without a query: normal paginated load (pageSize + 1 probe). + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(listTables.mock.calls.at(-1)?.[4]).toBe(501); + + // Clear the query and reload: must stay a plain paginated load, never a + // fuzzy search with the result budget. + store.setSidebarTableSearchQuery(tablesGroup.id, ""); + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(listTables.mock.calls.at(-1)?.[3]).toBeUndefined(); + expect(listTables.mock.calls.at(-1)?.[4]).toBe(501); + }, 30000); +}); diff --git a/packages/app-tests/sidebarLocalTableSearchMatcher.test.ts b/packages/app-tests/sidebarLocalTableSearchMatcher.test.ts new file mode 100644 index 0000000000..19d4a134b0 --- /dev/null +++ b/packages/app-tests/sidebarLocalTableSearchMatcher.test.ts @@ -0,0 +1,66 @@ +// Regression test for t8y2/dbx #6190. +// +// The sidebar label matcher itself matches "erpncs"/"terpncs" against +// "T_Erp_Nc_SuPlan_List" (via the separator-blind tiers). The reported bug is +// not a matcher defect: it is that local-mode first searches only had the +// currently loaded first page of children available. This test locks in both +// halves of the behavior so the fix cannot regress. +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { matchSidebarLabel } from "../../apps/desktop/src/lib/sidebar/sidebarSearch.ts"; + +const TARGET = "T_Erp_Nc_SuPlan_List"; + +function erpTables(): string[] { + const names: string[] = []; + for (let i = 0; i < 700; i++) names.push(`A_Erp_Nc_Sys_${i}`); + for (let i = 0; i < 100; i++) names.push(`T_Erp_Nc_Su_Table_${i}`); + for (let i = 0; i < 300; i++) names.push(`T_Bas_Customer_${i}`); + for (let i = 0; i < 300; i++) names.push(`T_Fin_Account_${i}`); + names.push(TARGET); + return names; +} + +function filterNames(names: string[], query: string): string[] { + return names.filter((name) => !!matchSidebarLabel(name, query)); +} + +test("matcher hits the target for erpncs / terpncs and case variants", () => { + for (const query of ["erpncs", "terpncs", "ERPnCS", "ERPNCS"]) { + assert.ok(matchSidebarLabel(TARGET, query), `expected ${query} to match ${TARGET}`); + } +}); + +test("first page of a large schema does not contain the target (pre-fix fallback failed)", () => { + const all = erpTables(); + const sorted = [...all].sort((a, b) => a.localeCompare(b)); + const firstPage = sorted.slice(0, 501); + assert.equal(firstPage.includes(TARGET), false, "target must sort beyond the first page"); + // The pre-fix first search only had these children: even a correct matcher + // cannot surface a table that is not in the candidate set. + assert.equal(filterNames(firstPage, "erpncs").includes(TARGET), false); + assert.equal(filterNames(firstPage, "terpncs").includes(TARGET), false); +}); + +test("complete index search finds the target for erpncs / terpncs on first search", () => { + const all = erpTables(); + assert.ok(filterNames(all, "erpncs").includes(TARGET)); + assert.ok(filterNames(all, "terpncs").includes(TARGET)); +}); + +test("existing fuzzy behavior is unchanged (per-word subsequence, not cross-word)", () => { + // Per-word subsequence: "odr" inside "orders" matches, but a sequence that + // would cross a word separator does not (deliberate anti-overmatch design, + // see sidebarSearch.test.ts "resets at _"). The #6190 fix does not alter the + // matcher, so these tiers must stay untouched. + assert.equal(matchSidebarLabel("orders", "odr")?.kind, "fuzzy"); + assert.equal(matchSidebarLabel("system_user", "sysu"), null); + // Separator-blind tiers still work for the target table itself. + assert.equal(matchSidebarLabel("T_Erp_Nc_SuPlan_List", "erpncs")?.kind, "substring"); +}); + +test("single-character query does not enable loose fuzzy on the target", () => { + // "u" only matches via plain substring containment. + assert.equal(matchSidebarLabel("T_Erp_Nc_SuPlan_List", "u")?.kind, "substring"); + assert.equal(matchSidebarLabel("orders", "u"), null); +}); diff --git a/packages/app-tests/sidebarTableSearchIndex.test.ts b/packages/app-tests/sidebarTableSearchIndex.test.ts new file mode 100644 index 0000000000..7ec85509c1 --- /dev/null +++ b/packages/app-tests/sidebarTableSearchIndex.test.ts @@ -0,0 +1,200 @@ +// Unit tests for the local table search index loading helper introduced for +// t8y2/dbx #6190. +// +// Pre-fix behavior: local-mode first search only read the persisted index +// (loadSidebarTableSearchIndex). When it had never been built (null), the UI +// fell back to the currently loaded first page of children, silently missing +// alphabetically-late tables (T_Erp_Nc_SuPlan_List for "erpncs"). +// +// Post-fix behavior: a missing index is built on first use so the complete +// table set is searchable immediately — exactly once per scope even under +// rapid consecutive input (in-flight build deduplication + input debounce), +// 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 type { TableInfo } from "../../apps/desktop/src/types/database.ts"; + +const TARGET: TableInfo = { name: "T_Erp_Nc_SuPlan_List", table_type: "TABLE", comment: null }; + +test("builds the index when the persisted index is missing (first search)", async () => { + const read = async () => null; + const build = vi.fn(async () => [TARGET]); + const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build); + assert.deepEqual(result, [TARGET]); + expect(build).toHaveBeenCalledTimes(1); +}); + +test("reuses the persisted index without rebuilding", async () => { + const read = async () => [TARGET]; + const build = vi.fn(async () => []); + const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build); + assert.deepEqual(result, [TARGET]); + expect(build).not.toHaveBeenCalled(); +}); + +test("an empty persisted index (no tables) is not treated as missing", async () => { + const read = async () => []; + const build = vi.fn(async () => [TARGET]); + const result = await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build); + assert.deepEqual(result, []); + expect(build).not.toHaveBeenCalled(); +}); + +test("an explicit refresh always rebuilds", async () => { + const read = vi.fn(async () => [TARGET]); + const build = vi.fn(async () => [{ name: "fresh", table_type: "TABLE", comment: null }]); + const result = await loadOrBuildSidebarTableSearchIndex("scope", "", read, build, true); + assert.deepEqual(result, [{ name: "fresh", table_type: "TABLE", comment: null }]); + expect(build).toHaveBeenCalledTimes(1); +}); + +test("an empty query never reads or builds (clearing the input is a no-op)", async () => { + const read = vi.fn(async () => null); + const build = vi.fn(async () => [TARGET]); + const result = await loadOrBuildSidebarTableSearchIndex("scope", " ", read, build); + assert.equal(result, null); + expect(read).not.toHaveBeenCalled(); + expect(build).not.toHaveBeenCalled(); +}); + +test("concurrent first-use searches share a single in-flight build", async () => { + const read = async () => null; + const build = vi.fn(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), + ]); + assert.deepEqual(first, [TARGET]); + assert.deepEqual(second, [TARGET]); + expect(build).toHaveBeenCalledTimes(1); +}); + +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), + ]); + assert.deepEqual(a, [TARGET]); + assert.deepEqual(b, [{ name: "other", table_type: "TABLE", comment: null }]); + expect(buildA).toHaveBeenCalledTimes(1); + expect(buildB).toHaveBeenCalledTimes(1); +}); + +test("a settled build is released so a later search can rebuild", async () => { + const read = async () => null; + const build = vi.fn(async () => [TARGET]); + await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build); + await loadOrBuildSidebarTableSearchIndex("scope", "erpncs", read, build); + expect(build).toHaveBeenCalledTimes(2); +}); + +test("concurrent refreshes for the same scope share a single in-flight build", async () => { + const read = async () => null; + const build = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return [TARGET]; + }); + 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("rapid consecutive schedules coalesce into a single run", () => { + vi.useFakeTimers(); + try { + const debouncer = createSidebarTableSearchDebouncer(250); + const run = vi.fn(); + debouncer.schedule("parent-1", () => run("parent-1")); + debouncer.schedule("parent-1", () => run("parent-1")); + debouncer.schedule("parent-1", () => run("parent-1")); + expect(run).not.toHaveBeenCalled(); + vi.advanceTimersByTime(249); + expect(run).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("parent-1"); + expect(debouncer.pendingCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test("different keys debounce independently", () => { + vi.useFakeTimers(); + try { + const debouncer = createSidebarTableSearchDebouncer(100); + const run = vi.fn(); + debouncer.schedule("parent-1", () => run("parent-1")); + debouncer.schedule("parent-2", () => run("parent-2")); + vi.advanceTimersByTime(100); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenNthCalledWith(1, "parent-1"); + expect(run).toHaveBeenNthCalledWith(2, "parent-2"); + } finally { + vi.useRealTimers(); + } +}); + +test("cancel drops a pending schedule", () => { + vi.useFakeTimers(); + try { + const debouncer = createSidebarTableSearchDebouncer(250); + const run = vi.fn(); + debouncer.schedule("parent-1", () => run()); + expect(debouncer.pendingCount()).toBe(1); + debouncer.cancel("parent-1"); + vi.advanceTimersByTime(1000); + expect(run).not.toHaveBeenCalled(); + expect(debouncer.pendingCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test("cancelAll drops every pending schedule", () => { + vi.useFakeTimers(); + try { + const debouncer = createSidebarTableSearchDebouncer(100); + const run = vi.fn(); + debouncer.schedule("parent-1", () => run()); + debouncer.schedule("parent-2", () => run()); + expect(debouncer.pendingCount()).toBe(2); + debouncer.cancelAll(); + vi.advanceTimersByTime(1000); + expect(run).not.toHaveBeenCalled(); + expect(debouncer.pendingCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test("switching search modes cancels the pending debounce from the other mode", () => { + vi.useFakeTimers(); + try { + const local = createSidebarTableSearchDebouncer(250); + const remote = createSidebarTableSearchDebouncer(250); + const localRun = vi.fn(); + const remoteRun = vi.fn(); + + scheduleExclusiveSidebarTableSearchDebounce("parent-1", local, remote, localRun); + scheduleExclusiveSidebarTableSearchDebounce("parent-1", remote, local, remoteRun); + vi.advanceTimersByTime(250); + + expect(localRun).not.toHaveBeenCalled(); + expect(remoteRun).toHaveBeenCalledTimes(1); + expect(local.pendingCount()).toBe(0); + expect(remote.pendingCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +});