diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 9c69709494..298eff1bbc 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -597,7 +597,30 @@ export const useConnectionStore = defineStore("connection", () => { return pinnedTreeNodeIds.value.has(id); } + function isConnectionUtilityNode(node: TreeNode): boolean { + return node.type === "user-admin"; + } + + function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] { + return (children || []).filter((child) => !isConnectionUtilityNode(child)); + } + + function hasConnectionMetadataChildren(children: TreeNode[] | undefined): boolean { + return connectionMetadataChildren(children).length > 0; + } + + function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[]): TreeNode[] { + if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children; + + const existingMetadataChildren = connectionMetadataChildren(parent.children); + const nextUtilityChildren = children.filter(isConnectionUtilityNode); + if (existingMetadataChildren.length === 0 || nextUtilityChildren.length === 0) return children; + + return [...existingMetadataChildren, ...nextUtilityChildren]; + } + function setChildren(parent: TreeNode, children: TreeNode[]) { + children = preserveExistingConnectionMetadataChildren(parent, children); if (parent.children && parent.children.length > 0) { const oldMap = new Map(parent.children.map((c) => [c.id, c] as const)); children = children.map((child) => { @@ -637,7 +660,7 @@ export const useConnectionStore = defineStore("connection", () => { } function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { - const nonUtilityChildren = children.filter((child) => child.type !== "user-admin"); + const nonUtilityChildren = connectionMetadataChildren(children); const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode); return [...nonUtilityChildren, userAdminNode].filter(Boolean) as TreeNode[]; } @@ -865,6 +888,9 @@ export const useConnectionStore = defineStore("connection", () => { const config = node.connectionId ? getConfig(node.connectionId) : undefined; const cachedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)); const childrenWithLinkedServers = node.type === "connection" && node.connectionId ? ensureSqlServerLinkedRootNode(node.connectionId, cachedChildren, config) : cachedChildren; + if (node.type === "connection" && !hasConnectionMetadataChildren(childrenWithLinkedServers)) { + return { hit: false, isStale: false }; + } const normalizedChildren = sortSidebarTreeChildrenForParent(node, childrenWithLinkedServers, config?.db_type); setChildren(node, node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, normalizedChildren, node) : normalizedChildren); node.isExpanded = true; @@ -875,9 +901,19 @@ export const useConnectionStore = defineStore("connection", () => { await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined); } + async function savePersistedConnectionTreeChildren(cacheKey: string, children: TreeNode[]) { + const metadataChildren = connectionMetadataChildren(children); + if (metadataChildren.length === 0) return; + await savePersistedTreeChildren(cacheKey, metadataChildren); + } + function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; if (node.type === "connection" && node.connectionId) { + if (!hasConnectionMetadataChildren(node.children)) { + clearLoadedChildrenCache(node.id); + return false; + } const normalizedChildren = sortSidebarTreeChildrenForParent(node, withSavedSqlRoot(node.connectionId, node.children || [], node), getConfig(node.connectionId)?.db_type); setChildren(node, normalizedChildren); } @@ -1389,7 +1425,7 @@ export const useConnectionStore = defineStore("connection", () => { const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node); if (isSidebarSearchQueryChanged(options)) return; setChildren(node, children); - await savePersistedTreeChildren(cacheKey, children); + await savePersistedConnectionTreeChildren(cacheKey, node.children || children); } else if (config && connectionUsesVisibleSchemaFilter(config)) { const schemaFilterConfig = config; const effectiveDb = schemaFilterConfig.database || ""; @@ -1415,7 +1451,7 @@ export const useConnectionStore = defineStore("connection", () => { })); if (isSidebarSearchQueryChanged(options)) return; setChildren(node, withSavedSqlRoot(connectionId, schemaNodes, node)); - await savePersistedTreeChildren(cacheKey, schemaNodes); + await savePersistedConnectionTreeChildren(cacheKey, node.children || schemaNodes); } else { const cacheKey = schemaCacheKey(connectionId, "databases"); if (!options?.force) { @@ -1458,7 +1494,7 @@ export const useConnectionStore = defineStore("connection", () => { const children = withSavedSqlRoot(connectionId, databaseNodes, node); if (isSidebarSearchQueryChanged(options)) return; setChildren(node, children); - await savePersistedTreeChildren(cacheKey, children); + await savePersistedConnectionTreeChildren(cacheKey, node.children || children); } node.isExpanded = true; } catch (e) { diff --git a/docs/assets/pr-2182-sidebar-user-admin-only.png b/docs/assets/pr-2182-sidebar-user-admin-only.png new file mode 100644 index 0000000000..27ad5a9dac Binary files /dev/null and b/docs/assets/pr-2182-sidebar-user-admin-only.png differ diff --git a/packages/app-tests/connectionStoreErrorState.test.ts b/packages/app-tests/connectionStoreErrorState.test.ts index ac174c00de..350c9bae78 100644 --- a/packages/app-tests/connectionStoreErrorState.test.ts +++ b/packages/app-tests/connectionStoreErrorState.test.ts @@ -34,6 +34,10 @@ function conn(id: string): ConnectionConfig { }; } +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { status: 200, headers: { "Content-Type": "application/json" } }); +} + test("successful disconnect clears the connection error", async () => { const restoreStorage = installMemoryStorage(); const originalFetch = globalThis.fetch; @@ -368,6 +372,131 @@ test("hanging database metadata load times out and clears loading state", async } }); +test("utility-only connection cache is ignored and replaced with live metadata children", async () => { + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + const utilityOnlyCache = { + version: 2, + cachedAt: new Date().toISOString(), + children: [ + { + id: "conn-1:__user_admin", + label: "tree.userAdmin", + type: "user-admin", + connectionId: "conn-1", + database: "", + }, + ], + }; + let listDatabasesCalls = 0; + let savedPayload: any = null; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.startsWith("/api/schema/cache?")) { + return jsonResponse(utilityOnlyCache); + } + if (url.startsWith("/api/schema/databases?")) { + listDatabasesCalls++; + return jsonResponse([{ name: "app" }]); + } + if (url === "/api/schema/cache" && init?.method === "POST") { + savedPayload = JSON.parse(String(init.body ?? "{}")).payload; + return jsonResponse(null); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection({ ...conn("conn-1"), db_type: "mysql", port: 3306 }); + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + children: [], + }); + + await store.loadDatabases("conn-1"); + + assert.equal(listDatabasesCalls, 1); + assert.deepEqual( + store.treeNodes[0].children?.map((child) => child.type), + ["database", "user-admin"], + ); + assert.deepEqual( + savedPayload?.children?.map((child: any) => child.type), + ["database"], + ); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + +test("empty root metadata does not replace existing databases with only user admin", async () => { + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + let savedPayload: any = null; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.startsWith("/api/schema/cache?")) { + return jsonResponse(null); + } + if (url.startsWith("/api/schema/databases?")) { + return jsonResponse([]); + } + if (url === "/api/schema/cache" && init?.method === "POST") { + savedPayload = JSON.parse(String(init.body ?? "{}")).payload; + return jsonResponse(null); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + store.addEphemeralConnection({ ...conn("conn-1"), db_type: "mysql", port: 3306 }); + store.treeNodes.push({ + id: "conn-1", + label: "conn-1", + type: "connection", + connectionId: "conn-1", + children: [ + { + id: "conn-1:app", + label: "app", + type: "database", + connectionId: "conn-1", + database: "app", + children: [], + }, + ], + }); + + await store.loadDatabases("conn-1"); + + assert.deepEqual( + store.treeNodes[0].children?.map((child) => child.type), + ["database", "user-admin"], + ); + assert.deepEqual( + store.treeNodes[0].children?.map((child) => child.id), + ["conn-1:app", "conn-1:__user_admin"], + ); + assert.deepEqual( + savedPayload?.children?.map((child: any) => child.id), + ["conn-1:app"], + ); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test.each([ ["redis", "loadRedisDatabases", "/api/redis/list-databases", "Redis databases"], ["mq", "loadMqTenants", "/api/mq/tenants/list", "message queue tenants"],