diff --git a/apps/desktop/src/lib/schemaTreeCache.ts b/apps/desktop/src/lib/schemaTreeCache.ts index ccc97ebd4e..ebe7ae5124 100644 --- a/apps/desktop/src/lib/schemaTreeCache.ts +++ b/apps/desktop/src/lib/schemaTreeCache.ts @@ -1,7 +1,7 @@ export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000; export interface SchemaTreeCacheEnvelope { - version: 1; + version: 2; cachedAt: string; children: T; } @@ -13,7 +13,7 @@ export interface DecodedSchemaTreeCache { export function encodeSchemaTreeCache(children: T, nowMs = Date.now()): SchemaTreeCacheEnvelope { return { - version: 1, + version: 2, cachedAt: new Date(nowMs).toISOString(), children, }; @@ -31,7 +31,7 @@ export function decodeSchemaTreeCache( if (!payload || typeof payload !== "object") return null; const envelope = payload as Partial>; - if (envelope.version !== 1 || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") { + if (envelope.version !== 2 || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") { return null; } diff --git a/apps/desktop/src/lib/tableTree.ts b/apps/desktop/src/lib/tableTree.ts index 5c437ccac5..b7156898b5 100644 --- a/apps/desktop/src/lib/tableTree.ts +++ b/apps/desktop/src/lib/tableTree.ts @@ -96,10 +96,15 @@ export function buildGroupedObjectTreeNodes({ objects: ObjectInfo[]; }): TreeNode[] { const buckets = new Map(); + const seen = new Set(); for (const obj of objects) { const name = normalizeDatabaseObjectName(obj.name); if (!name) continue; const t = normalizeObjectType(obj.object_type); + const objectSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema || ""; + const key = `${t}\0${objectSchema.toLowerCase()}\0${name.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); const arr = buckets.get(t) ?? []; arr.push({ ...obj, name, schema: obj.schema ? normalizeDatabaseObjectName(obj.schema) : obj.schema }); buckets.set(t, arr); diff --git a/packages/app-tests/schemaTreeCache.test.ts b/packages/app-tests/schemaTreeCache.test.ts index b7916eda73..48d9a9119b 100644 --- a/packages/app-tests/schemaTreeCache.test.ts +++ b/packages/app-tests/schemaTreeCache.test.ts @@ -11,7 +11,7 @@ const now = Date.parse("2026-05-17T10:00:00.000Z"); test("wraps tree children with a cache timestamp", () => { assert.deepEqual(encodeSchemaTreeCache(children, now), { - version: 1, + version: 2, cachedAt: "2026-05-17T10:00:00.000Z", children, }); @@ -43,5 +43,6 @@ test("keeps legacy array cache readable but stale", () => { }); test("rejects invalid schema tree cache payloads", () => { - assert.equal(decodeSchemaTreeCache({ version: 1, cachedAt: "bad", children: "nope" }, now), null); + assert.equal(decodeSchemaTreeCache({ version: 2, cachedAt: "bad", children: "nope" }, now), null); + assert.equal(decodeSchemaTreeCache({ version: 1, cachedAt: "2026-05-17T10:00:00.000Z", children }, now), null); }); diff --git a/packages/app-tests/tableTree.test.ts b/packages/app-tests/tableTree.test.ts index c5afffd608..090295b08d 100644 --- a/packages/app-tests/tableTree.test.ts +++ b/packages/app-tests/tableTree.test.ts @@ -2,11 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFileSync } from "node:fs"; import { + buildGroupedObjectTreeNodes, buildTableTreeNodes, expandCachedObjectBrowserNodes, objectGroupRefreshParentId, } from "../../apps/desktop/src/lib/tableTree.ts"; -import type { TableInfo } from "../../apps/desktop/src/types/database.ts"; +import type { ObjectInfo, TableInfo } from "../../apps/desktop/src/types/database.ts"; const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); const connectionStoreSource = readFileSync("apps/desktop/src/stores/connectionStore.ts", "utf8"); @@ -15,6 +16,14 @@ function table(name: string, tableType: "TABLE" | "VIEW" = "TABLE"): TableInfo { return { name, table_type: tableType }; } +function obj(name: string, objectType = "TABLE", schema = "public"): ObjectInfo { + return { + name, + object_type: objectType, + schema, + }; +} + test("keeps every table as a sidebar node instead of truncating to object browser", () => { const tables: TableInfo[] = Array.from({ length: 16 }, (_, index) => table(`table_${index + 1}`)); @@ -69,6 +78,36 @@ test("normalizes padded table names from database drivers", () => { ); }); +test("object tree groups count unique objects when metadata returns duplicates", () => { + const nodes = buildGroupedObjectTreeNodes({ + nodeId: "conn:app:public", + connectionId: "conn", + database: "app", + schema: "public", + objects: [ + obj("orders"), + obj("orders"), + obj("customers"), + obj("active_orders", "VIEW"), + obj("active_orders", "VIEW"), + ], + }); + + const tableGroup = nodes.find((node) => node.type === "group-tables"); + assert.equal(tableGroup?.objectCount, 2); + assert.deepEqual( + tableGroup?.children?.map((child) => child.label), + ["orders", "customers"], + ); + + const viewGroup = nodes.find((node) => node.type === "group-views"); + assert.equal(viewGroup?.objectCount, 1); + assert.deepEqual( + viewGroup?.children?.map((child) => child.label), + ["active_orders"], + ); +}); + test("expands cached object-browser nodes back into regular table nodes", () => { const nodes = expandCachedObjectBrowserNodes([ {