fix(quick-open): search unloaded tables after cold start

This commit is contained in:
zipg authored and GitHub committed 2026-08-05 14:58:03 +08:00
1 parent 605d20dd4f
commit c972dab3d0
6 files changed
+211 -29

No files matched your search

+121 -3
View File
@@ -780,7 +780,7 @@ describe("useQuickOpen", () => {
setQuery("ord");
await runDebouncedSearch();
expect(mockStore.listCompletionTables).toHaveBeenCalledWith("conn1", "app", "ord", 25, undefined, true);
expect(mockStore.listCompletionTables).toHaveBeenCalledWith("conn1", "app", "ord", 25, undefined, true, undefined, undefined, { activateConnection: false });
expect(filteredItems.value).toEqual(expect.arrayContaining([expect.objectContaining({ label: "orders", type: "table", database: "app" })]));
});
@@ -851,7 +851,7 @@ describe("useQuickOpen", () => {
expect(mockStore.listCompletionTables).not.toHaveBeenCalled();
});
it("does not request metadata from disconnected contexts", async () => {
it("searches disconnected contexts through lazy connection", async () => {
const mockStore = remoteSearchStore({ connectedIds: new Set<string>() });
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
@@ -859,7 +859,125 @@ describe("useQuickOpen", () => {
setQuery("users");
await runDebouncedSearch();
expect(mockStore.listCompletionTables).not.toHaveBeenCalled();
expect(mockStore.listCompletionTables).toHaveBeenCalledWith("conn1", "app", "users", 25, undefined, true, undefined, undefined, { activateConnection: false });
});
it("prioritizes the active connection before applying the remote request cap", async () => {
const connections = Array.from({ length: 9 }, (_, index) => ({ id: `conn${index}`, name: `Connection ${index}`, db_type: "mysql", database: `db${index}` }));
const mockStore = remoteSearchStore({
connections,
activeConnectionId: "conn8",
connectedIds: new Set(["conn8"]),
treeNodes: [],
});
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
const { setQuery } = useQuickOpen();
setQuery("users");
await runDebouncedSearch();
const requestedConnections = mockStore.listCompletionTables.mock.calls.map(([connectionId]) => connectionId);
expect(requestedConnections).toContain("conn8");
expect(requestedConnections).not.toContain("conn7");
expect(mockStore.listCompletionTables).toHaveBeenCalledTimes(8);
});
it("publishes active connection results without waiting for slow cold connections", async () => {
const slowSearch = deferred<Array<{ name: string; type: "table" }>>();
const mockStore = remoteSearchStore({
connections: [
{ id: "cold", name: "Cold", db_type: "mysql", database: "cold_db" },
{ id: "active", name: "Active", db_type: "mysql", database: "active_db" },
],
activeConnectionId: "active",
connectedIds: new Set(["active"]),
treeNodes: [],
listCompletionTables: vi.fn((connectionId) => (connectionId === "active" ? Promise.resolve([{ name: "active_users", type: "table" as const }]) : slowSearch.promise)),
});
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("users");
await runDebouncedSearch();
expect(filteredItems.value.map((item) => item.label)).toContain("active_users");
slowSearch.resolve([]);
await flushAsyncWork();
});
it("drops stale queued contexts before scheduling a newer query", async () => {
const alphaRequests: Array<ReturnType<typeof deferred<Array<{ name: string; type: "table" }>>>> = [];
const listCompletionTables = vi.fn((_connectionId, _database, query) => {
if (query === "alpha") {
const request = deferred<Array<{ name: string; type: "table" }>>();
alphaRequests.push(request);
return request.promise;
}
return Promise.resolve([{ name: "beta_table", type: "table" as const }]);
});
const mockStore = remoteSearchStore({
treeNodes: Array.from({ length: 4 }, (_, index) => ({
id: `conn1:db${index}`,
connectionId: "conn1",
type: "database",
database: `db${index}`,
label: `db${index}`,
})),
listCompletionTables,
});
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("alpha");
await runDebouncedSearch();
expect(listCompletionTables).toHaveBeenCalledTimes(2);
setQuery("beta");
await runDebouncedSearch();
expect(listCompletionTables).toHaveBeenCalledTimes(2);
alphaRequests[0]!.resolve([]);
await flushAsyncWork();
expect(listCompletionTables.mock.calls[2]?.[2]).toBe("beta");
expect(listCompletionTables.mock.calls.filter(([, , query]) => query === "alpha")).toHaveLength(2);
alphaRequests[1]!.resolve([]);
await flushAsyncWork();
expect(filteredItems.value.map((item) => item.label)).toContain("beta_table");
});
it("derives the SQLite main database before its tree is expanded", async () => {
const mockStore = remoteSearchStore({
connections: [{ id: "sqlite-1", name: "SQLite", db_type: "sqlite", host: "/tmp/app.sqlite" }],
connectedIds: new Set<string>(),
treeNodes: [],
listCompletionTables: vi.fn().mockResolvedValue([{ name: "scroll_test", type: "table" }]),
});
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("scroll_test");
await runDebouncedSearch();
expect(mockStore.listCompletionTables).toHaveBeenCalledWith("sqlite-1", "main", "scroll_test", 25, undefined, true, undefined, undefined, { activateConnection: false });
expect(filteredItems.value).toEqual(expect.arrayContaining([expect.objectContaining({ label: "scroll_test", type: "table", database: "main" })]));
});
it("searches the PostgreSQL backend default database before its tree is expanded", async () => {
const mockStore = remoteSearchStore({
connections: [{ id: "pg-1", name: "PostgreSQL", db_type: "postgres", database: "" }],
connectedIds: new Set<string>(),
treeNodes: [],
listCompletionTables: vi.fn().mockResolvedValue([{ name: "cold_start_table", type: "table" }]),
});
vi.mocked(useConnectionStore).mockReturnValue(mockStore as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("cold_start_table");
await runDebouncedSearch();
expect(mockStore.listCompletionTables).toHaveBeenCalledWith("pg-1", "postgres", "cold_start_table", 25, undefined, true, undefined, undefined, { activateConnection: false });
expect(filteredItems.value).toEqual(expect.arrayContaining([expect.objectContaining({ label: "cold_start_table", type: "table", database: "postgres" })]));
});
it("keeps local results when remote metadata search fails", async () => {
+47 -20
View File
@@ -1,6 +1,7 @@
import { computed, ref, watch } from "vue";
import type { ConnectionConfig } from "@/types/database";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api";
@@ -111,7 +112,7 @@ export function useQuickOpen() {
let remoteSearchGeneration = 0;
let remoteSearchTimer: ReturnType<typeof setTimeout> | undefined;
let activeRemoteRequests = 0;
const remoteRequestWaiters: Array<() => void> = [];
const remoteRequestWaiters: Array<{ generation: number; resolve: (acquired: boolean) => void }> = [];
let sqlFilesLoaded = false;
let sqlFilesLoadingPromise: Promise<void> | null = null;
let sqlFilesLoadGeneration = 0;
@@ -467,12 +468,20 @@ export function useQuickOpen() {
function remoteSearchContexts(): Array<{ conn: ConnectionConfig; database: string }> {
if (typeof connectionStore.listCompletionTables !== "function") return [];
const connectedIds = connectionStore.connectedIds;
if (!(connectedIds instanceof Set)) return [];
const databasesByConnection: Array<{ conn: ConnectionConfig; databases: string[] }> = [];
for (const conn of connectionStore.connections) {
if (!connectedIds.has(conn.id) || REMOTE_SEARCH_UNSUPPORTED_TYPES.has(conn.db_type)) continue;
const orderedConnections = [...connectionStore.connections].sort((left, right) => {
const priority = (conn: ConnectionConfig) => {
if (conn.id === connectionStore.activeConnectionId) return 0;
if (connectionStore.connectedIds.has(conn.id)) return 1;
return 2;
};
return priority(left) - priority(right);
});
for (const conn of orderedConnections) {
// listCompletionTables connects on demand. Keeping disconnected connections
// out here makes quick-open blind to unloaded tables after a cold start.
if (REMOTE_SEARCH_UNSUPPORTED_TYPES.has(conn.db_type)) continue;
const databases = new Set<string>();
collectConnectionDatabases(connectionStore.treeNodes, conn.id, databases);
if (conn.database?.trim()) databases.add(conn.database.trim());
@@ -482,6 +491,8 @@ export function useQuickOpen() {
for (const database of conn.attached_databases ?? []) {
if (database.name.trim()) databases.add(database.name.trim());
}
const defaultDatabase = resolveDefaultDatabase(conn, [...databases]);
if (defaultDatabase) databases.add(defaultDatabase);
if (databases.size > 0) databasesByConnection.push({ conn, databases: [...databases] });
}
@@ -500,39 +511,54 @@ export function useQuickOpen() {
return contexts;
}
async function acquireRemoteRequestSlot(): Promise<void> {
async function acquireRemoteRequestSlot(generation: number): Promise<boolean> {
if (generation !== remoteSearchGeneration) return false;
if (activeRemoteRequests < REMOTE_SEARCH_CONCURRENCY) {
activeRemoteRequests++;
return;
return true;
}
return new Promise<boolean>((resolve) => remoteRequestWaiters.push({ generation, resolve }));
}
function cancelStaleRemoteRequestWaiters(generation: number): void {
for (let index = remoteRequestWaiters.length - 1; index >= 0; index -= 1) {
const waiter = remoteRequestWaiters[index]!;
if (waiter.generation === generation) continue;
remoteRequestWaiters.splice(index, 1);
waiter.resolve(false);
}
await new Promise<void>((resolve) => remoteRequestWaiters.push(resolve));
}
function releaseRemoteRequestSlot(): void {
const next = remoteRequestWaiters.shift();
if (next) next();
let next = remoteRequestWaiters.shift();
while (next && next.generation !== remoteSearchGeneration) {
next.resolve(false);
next = remoteRequestWaiters.shift();
}
if (next) next.resolve(true);
else activeRemoteRequests--;
}
async function runRemoteSearch(query: string, generation: number, contexts: Array<{ conn: ConnectionConfig; database: string }>): Promise<void> {
const groups = await Promise.all(
contexts.map(async ({ conn, database }) => {
await acquireRemoteRequestSlot();
const groups = contexts.map(() => [] as QuickOpenItem[]);
await Promise.all(
contexts.map(async ({ conn, database }, index) => {
const acquired = await acquireRemoteRequestSlot(generation);
if (!acquired) return;
try {
// A newer query may supersede queued work before it reaches the metadata API.
if (generation !== remoteSearchGeneration) return [];
const tables = await connectionStore.listCompletionTables(conn.id, database, query, REMOTE_SEARCH_RESULTS_PER_REQUEST, undefined, true);
return tables.slice(0, REMOTE_SEARCH_RESULTS_PER_REQUEST).map((table) => remoteTableItem(table, conn, database));
if (generation !== remoteSearchGeneration) return;
const tables = await connectionStore.listCompletionTables(conn.id, database, query, REMOTE_SEARCH_RESULTS_PER_REQUEST, undefined, true, undefined, undefined, { activateConnection: false });
if (generation !== remoteSearchGeneration) return;
groups[index] = tables.slice(0, REMOTE_SEARCH_RESULTS_PER_REQUEST).map((table) => remoteTableItem(table, conn, database));
remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS);
} catch {
return [];
return;
} finally {
releaseRemoteRequestSlot();
}
}),
);
if (generation !== remoteSearchGeneration) return;
remoteItems.value = groups.flat().slice(0, REMOTE_SEARCH_MAX_RESULTS);
}
/**
@@ -550,6 +576,7 @@ export function useQuickOpen() {
searchQuery,
(query) => {
const generation = ++remoteSearchGeneration;
cancelStaleRemoteRequestWaiters(generation);
if (remoteSearchTimer) clearTimeout(remoteSearchTimer);
remoteItems.value = [];