fix(sidebar): cancel stale table search work across modes

This commit is contained in:
jearhe authored and GitHub committed 2026-08-16 14:09:50 +08:00
1 parent e671b3da7d
commit 71136377e5
8 files changed
+763 -24

No files matched your search

@@ -40,6 +40,7 @@ import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry"; import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
import { createSidebarTableSearchDebouncer, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
import TreeItem from "./TreeItem.vue"; import TreeItem from "./TreeItem.vue";
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
@@ -149,7 +150,10 @@ const searchRefreshedNodeIds = searchExpansionState.filteredNodeIds;
// touched group open and reloading it again. // touched group open and reloading it again.
const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds; const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds;
let searchTimer: number | undefined; let searchTimer: number | undefined;
const tableSearchTimers = new Map<string, number>(); 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<string, number>(); const tableSearchFocusRestoreTokens = new Map<string, number>();
let tableSearchFocusRestoreTokenSeq = 0; let tableSearchFocusRestoreTokenSeq = 0;
let latestTableSearchInteractionParentId: string | null = null; let latestTableSearchInteractionParentId: string | null = null;
@@ -244,8 +248,8 @@ watch(
if (parentNodeIds.length === 0) return; if (parentNodeIds.length === 0) return;
for (const parentNodeId of parentNodeIds) { for (const parentNodeId of parentNodeIds) {
window.clearTimeout(tableSearchTimers.get(parentNodeId)); remoteTableSearchDebouncer.cancel(parentNodeId);
tableSearchTimers.delete(parentNodeId); localTableSearchDebouncer.cancel(parentNodeId);
tableSearchFocusRestoreTokens.delete(parentNodeId); tableSearchFocusRestoreTokens.delete(parentNodeId);
store.setSidebarTableSearchQuery(parentNodeId, ""); store.setSidebarTableSearchQuery(parentNodeId, "");
} }
@@ -486,15 +490,17 @@ function clearSearchScopeFilter() {
} }
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) { function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) {
window.clearTimeout(tableSearchTimers.get(parentNodeId)); if (isTreeSearchFiltering.value) {
if (isTreeSearchFiltering.value) return; remoteTableSearchDebouncer.cancel(parentNodeId);
localTableSearchDebouncer.cancel(parentNodeId);
return;
}
const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0; const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
if (restoreToken) { if (restoreToken) {
tableSearchFocusRestoreTokens.clear(); tableSearchFocusRestoreTokens.clear();
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken); tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
} }
const timer = window.setTimeout(() => { scheduleExclusiveSidebarTableSearchDebounce(parentNodeId, remoteTableSearchDebouncer, localTableSearchDebouncer, () => {
tableSearchTimers.delete(parentNodeId);
void store.refreshSidebarTableSearch(parentNodeId).then(() => { void store.refreshSidebarTableSearch(parentNodeId).then(() => {
if (!restoreToken) return; if (!restoreToken) return;
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return; if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
@@ -503,8 +509,7 @@ function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { foc
if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return; if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return;
restoreTableSearchInput(focusRestore); restoreTableSearchInput(focusRestore);
}); });
}, 250); });
tableSearchTimers.set(parentNodeId, timer);
} }
function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore { function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore {
@@ -607,10 +612,35 @@ function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] {
return mergeSidebarRegexIndexScopes(nodes, matchingScopes); 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) { async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
try { try {
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId); // A missing persisted index (null) means this scope was never indexed, so
localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries }; // 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 { } finally {
if (focusRestore) restoreTableSearchInput(focusRestore); if (focusRestore) restoreTableSearchInput(focusRestore);
} }
@@ -1099,7 +1129,7 @@ provide(sidebarTreeContextKey, {
restoreTableSearchInput(focusRestore); restoreTableSearchInput(focusRestore);
localTableSearchFocusPending = false; localTableSearchFocusPending = false;
}); });
void loadLocalTableSearchResults(parentNodeId, false, focusRestore); scheduleLocalSidebarTableSearchRefresh(parentNodeId, focusRestore);
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore }); } else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
}, },
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true), refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
@@ -1999,10 +2029,8 @@ onUnmounted(() => {
resetSidebarTreeDialogState(); resetSidebarTreeDialogState();
window.removeEventListener("keydown", onWindowKeydown); window.removeEventListener("keydown", onWindowKeydown);
cancelPendingSidebarDataOpen(); cancelPendingSidebarDataOpen();
for (const timer of tableSearchTimers.values()) { remoteTableSearchDebouncer.cancelAll();
window.clearTimeout(timer); localTableSearchDebouncer.cancelAll();
}
tableSearchTimers.clear();
tableSearchFocusRestoreTokens.clear(); tableSearchFocusRestoreTokens.clear();
latestTableSearchInteractionParentId = null; latestTableSearchInteractionParentId = null;
latestTableSearchInteractionId = 0; latestTableSearchInteractionId = 0;
@@ -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<string, Promise<TableInfo[]>>();
function dedupeInFlightBuild(scopeKey: string, build: () => Promise<TableInfo[]>): Promise<TableInfo[]> {
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<TableInfo[] | null>, build: () => Promise<TableInfo[]>, refresh = false): Promise<TableInfo[] | null> {
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<string, ReturnType<typeof setTimeout>>();
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);
}
@@ -126,4 +126,42 @@ describe("connectionStore Doris catalog tree", () => {
["iceberg", "sales", "tree.queries"], ["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");
});
}); });
@@ -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 { function postgresConnection(): ConnectionConfig {
return { return {
id: "pg-1", id: "pg-1",
@@ -3165,7 +3170,7 @@ describe("connectionStore metadata loading", () => {
expect(tablesGroup.objectCount).toBe(5); expect(tablesGroup.objectCount).toBe(5);
expect(tablesGroup.isLoading).toBe(false); expect(tablesGroup.isLoading).toBe(false);
expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([ 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, 0], [undefined, 3, 0],
[undefined, 3, 2], [undefined, 3, 2],
@@ -3219,7 +3224,7 @@ describe("connectionStore metadata loading", () => {
store.sidebarSearchQuery = "orders"; store.sidebarSearchQuery = "orders";
await store.loadObjectGroupChildren(tablesGroup, { force: true }); 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"]); expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
let resolveAncestorLoad!: (tables: TableInfo[]) => void; let resolveAncestorLoad!: (tables: TableInfo[]) => void;
+32 -6
View File
@@ -198,6 +198,19 @@ function sidebarObjectGroupPageSize(): number {
return typeof size === "number" && size > 0 ? size : 500; 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 { function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false; if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true; 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, catalog: options.node.catalog,
}); });
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter); 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 fetchOffset = searchFilter ? undefined : options.offset;
const tables = await loadCachedMetadataListPage<TableInfo[]>( const tables = await loadCachedMetadataListPage<TableInfo[]>(
metadataListCacheScope({ metadataListCacheScope({
@@ -1964,7 +1984,11 @@ export const useConnectionStore = defineStore("connection", () => {
schema: options.effectiveSchema ?? options.querySchema, schema: options.effectiveSchema ?? options.querySchema,
nodeKind: "simple-tables", 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 fetchOffset = searchFilter ? undefined : options.offset;
const tables = await loadCachedMetadataListPage<TableInfo[]>( const tables = await loadCachedMetadataListPage<TableInfo[]>(
metadataListCacheScope({ metadataListCacheScope({
@@ -4674,6 +4698,8 @@ export const useConnectionStore = defineStore("connection", () => {
const configForScope = getConfig(connectionId); const configForScope = getConfig(connectionId);
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
const searchFilterForScope = activeTreeLoadSearchFilter(options);
const pageSizeForScope = sidebarObjectGroupPageSize();
return runTreeMetadataLoad( return runTreeMetadataLoad(
{ {
kind: "schema-tables", kind: "schema-tables",
@@ -4682,8 +4708,8 @@ export const useConnectionStore = defineStore("connection", () => {
schema: undefined, schema: undefined,
nodeKind: "database", nodeKind: "database",
objectTypes: objectTypesForScope, objectTypes: objectTypesForScope,
searchFilter: activeTreeLoadSearchFilter(options), searchFilter: searchFilterForScope,
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined, limit: simpleObjectDisplayForScope ? (searchFilterForScope ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSizeForScope + 1) : undefined,
offset: 0, offset: 0,
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped", sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
driverProfile: metadataDriverProfile(configForScope), driverProfile: metadataDriverProfile(configForScope),
@@ -4710,8 +4736,8 @@ export const useConnectionStore = defineStore("connection", () => {
return; return;
} }
} }
const pageSize = sidebarObjectGroupPageSize(); const pageSize = pageSizeForScope;
const fetchLimit = searchFilter ? pageSize : pageSize + 1; const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSize + 1;
const fetchOffset = searchFilter ? undefined : 0; const fetchOffset = searchFilter ? undefined : 0;
const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables"); const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables");
const hasMore = searchFilter ? false : tables.length > pageSize; const hasMore = searchFilter ? false : tables.length > pageSize;
@@ -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<string, string>();
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<typeof vi.fn>) {
const cachedPayloads = new Map<string, unknown>();
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<typeof store.loadObjectGroupChildren>[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);
});
@@ -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);
});
+200
View File
@@ -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();
}
});