diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index d707cca842..f00ffd7c03 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -3714,7 +3714,14 @@ async function confirmDropDatabase() { const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); if (!executed) return; toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000); - await connectionStore.loadDatabases(connectionId, { force: true }); + try { + await connectionStore.loadDatabases(connectionId, { force: true }); + } catch { + // The database was already dropped successfully above; if this refresh + // itself fails (e.g. a network blip), evict the node directly instead + // of leaving the deleted database showing in the tree. + connectionStore.removeTreeNode(node.id); + } showDropDatabaseConfirm.value = false; } catch (e: any) { toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 6c727b1c83..7f965cba7b 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1365,8 +1365,16 @@ export const useConnectionStore = defineStore("connection", () => { return connectionMetadataChildren(children).length > 0; } - function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[]): TreeNode[] { + // `trustEmptyMetadataChildren` distinguishes two very different reasons this + // reload's metadata children can come back empty: the backend's raw fetch + // returned nothing at all (still ambiguous/possibly transient — keep the + // stale-preserve protection below), vs. the raw fetch had data but our own + // visible-databases/visible-schemas filter deterministically reduced it to + // zero (e.g. the only visible database was just dropped) — that emptiness + // is legitimate and must not be overridden by stale cached children. + function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[], trustEmptyMetadataChildren = false): TreeNode[] { if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children; + if (trustEmptyMetadataChildren) return children; const existingMetadataChildren = connectionMetadataChildren(parent.children); const nextUtilityChildren = children.filter(isConnectionUtilityNode); @@ -1467,9 +1475,9 @@ export const useConnectionStore = defineStore("connection", () => { return deduped; } - function setChildren(parent: TreeNode, children: TreeNode[]) { + function setChildren(parent: TreeNode, children: TreeNode[], options?: { trustEmptyConnectionChildren?: boolean }) { // Compare markers against the resolved child list (after connection preserve), not the raw loader payload. - children = preserveExistingConnectionMetadataChildren(parent, children); + children = preserveExistingConnectionMetadataChildren(parent, children, options?.trustEmptyConnectionChildren === true); children = decorateDatabaseSavedSqlTreeNodes(children, savedSqlFilesByDatabase, parent.children); if (parent.type === "database") { children = withDatabaseSavedSqlRoot(parent, children, savedSqlFilesByDatabase); @@ -4130,7 +4138,7 @@ export const useConnectionStore = defineStore("connection", () => { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames(connectionId, databaseNames); - setChildren(targetNode, children); + setChildren(targetNode, children, { trustEmptyConnectionChildren: databaseNames.length > 0 && visibleNames.length === 0 }); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else if (config && connectionUsesVisibleSchemaFilter(config)) { const schemaFilterConfig = config; @@ -4161,7 +4169,7 @@ export const useConnectionStore = defineStore("connection", () => { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames(connectionId, schemas); - setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode)); + setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode), { trustEmptyConnectionChildren: schemas.length > 0 && visibleSchemas.length === 0 }); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || schemaNodes); } else { // Doris / StarRocks multi-catalog: when external catalogs exist, @@ -4252,7 +4260,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId, databases.map((database) => database.name), ); - setChildren(targetNode, children); + setChildren(targetNode, children, { trustEmptyConnectionChildren: databases.length > 0 && visibleNames.length === 0 }); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } } @@ -4683,6 +4691,7 @@ export const useConnectionStore = defineStore("connection", () => { })), targetNode, ), + { trustEmptyConnectionChildren: dbs.length > 0 && visibleDbs.length === 0 }, ); targetNode.isExpanded = true; } catch (e) { diff --git a/packages/app-tests/connectionStoreErrorState.test.ts b/packages/app-tests/connectionStoreErrorState.test.ts index e8be781885..cda78b1309 100644 --- a/packages/app-tests/connectionStoreErrorState.test.ts +++ b/packages/app-tests/connectionStoreErrorState.test.ts @@ -498,6 +498,63 @@ test("empty root metadata does not replace existing databases with only user adm } }); +test("dropping the last visible database removes the stale node instead of preserving it", async () => { + const restoreStorage = installMemoryStorage(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.startsWith("/api/schema/cache?")) { + return jsonResponse(null); + } + if (url.startsWith("/api/schema/databases?")) { + // The backend still reports its own system databases; only the + // previously-visible user database ("app") is gone after the drop. + return jsonResponse([{ name: "information_schema" }, { name: "mysql" }, { name: "performance_schema" }, { name: "sys" }]); + } + if (url === "/api/schema/cache" && init?.method === "POST") { + 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, visible_databases: ["app"] }); + 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), + ["user-admin"], + ); + assert.equal( + store.treeNodes[0].children?.some((child) => child.id === "conn-1:app"), + false, + ); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test.each([ ["redis", "loadRedisDatabases", "/api/redis/list-databases", "Redis databases"], ["mq", "loadMqTenants", "/api/mq/tenants/list", "message queue tenants"],