From 83fd046dba110ace96ee030ce4e720b985c00ba7 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Tue, 20 Oct 2026 08:02:33 -0700 Subject: [PATCH 1/2] feat(studio): group the event models page by namespace owner Signed-off-by: Erik Nilsen --- .../studio/src/components/NamespacesPage.tsx | 8 +- .../src/components/OverviewPage.test.tsx | 46 ++++++ .../studio/src/components/OverviewPage.tsx | 143 +++++++++++------- .../studio/src/lib/namespace-owner.test.ts | 95 ++++++++++++ .../webapps/studio/src/lib/namespace-owner.ts | 58 +++++++ 5 files changed, 288 insertions(+), 62 deletions(-) create mode 100644 tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts create mode 100644 tsworkspace/webapps/studio/src/lib/namespace-owner.ts diff --git a/tsworkspace/webapps/studio/src/components/NamespacesPage.tsx b/tsworkspace/webapps/studio/src/components/NamespacesPage.tsx index daecae7f..ef86c114 100644 --- a/tsworkspace/webapps/studio/src/components/NamespacesPage.tsx +++ b/tsworkspace/webapps/studio/src/components/NamespacesPage.tsx @@ -25,13 +25,7 @@ import { Button } from '@/components/ui/button'; import { EmptyState } from '@/components/ui/empty-state'; import { Input } from '@/components/ui/input'; import { api, type NamespaceRegistryRow } from '@/lib/api'; - -/** The owner a namespace gets when none is named. Matches the server. */ -const DEFAULT_OWNER = 'default'; - -function ownerLabel(row: NamespaceRegistryRow): string { - return row.parent === '' ? DEFAULT_OWNER : row.parent; -} +import { ownerLabel } from '@/lib/namespace-owner'; export function NamespacesPage() { const [rows, setRows] = useState(null); diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx index 447d2c53..d593ff9e 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx @@ -5,6 +5,7 @@ vi.mock('@/lib/api', () => ({ api: { overview: vi.fn(), search: vi.fn(), + namespaceRegistry: vi.fn(), }, })); @@ -71,6 +72,7 @@ describe('OverviewPage', () => { window.history.replaceState(null, '', '/'); (api.overview as ReturnType).mockResolvedValue(EMPTY_OVERVIEW); (api.search as ReturnType).mockResolvedValue({ results: [] }); + (api.namespaceRegistry as ReturnType).mockResolvedValue({ namespaces: [] }); }); afterEach(() => { @@ -348,6 +350,50 @@ describe('OverviewPage', () => { }); }); + describe('models grouped by owner', () => { + const em = (namespace: string, slug: string) => ({ + eventModel: { id: { namespace, slug, version: 1 }, title: slug }, + }); + const reg = (name: string, parent: string) => ({ id: name, name, parent, entityCount: 1, registered: true }); + + beforeEach(() => { + (api.overview as ReturnType).mockResolvedValue({ + entities: [em('acme-orders', 'checkout'), em('acme-probe', 'probe'), em('other-ns', 'misc')], + }); + }); + + it('puts namespaces that share a registry parent under one owner section', async () => { + (api.namespaceRegistry as ReturnType).mockResolvedValue({ + namespaces: [reg('acme-orders', 'acme'), reg('acme-probe', 'acme'), reg('other-ns', '')], + }); + const { container } = render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const sections = [...container.querySelectorAll('details')]; + expect(sections.map((d) => d.querySelector('summary')?.firstChild?.textContent)).toEqual(['acme', 'default']); + expect([...sections[0].querySelectorAll('h3')].map((h) => h.firstChild?.textContent)).toEqual([ + 'acme-orders', + 'acme-probe', + ]); + expect(sections[1].textContent).toContain('other-ns'); + }); + + it('still lists every namespace when the registry cannot be read', async () => { + (api.namespaceRegistry as ReturnType).mockRejectedValue(new Error('forbidden')); + const { container } = render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container.querySelectorAll('details')).toHaveLength(0); + expect([...container.querySelectorAll('h3')].map((h) => h.firstChild?.textContent)).toEqual([ + 'acme-orders', + 'acme-probe', + 'other-ns', + ]); + }); + }); + it('surfaces a domain charter when a domain chart node is selected', async () => { (api.overview as ReturnType).mockResolvedValue({ entities: [ diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx index dd748255..1931f076 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx @@ -1,8 +1,8 @@ // Overview, the studio's landing surface (Decision: the studio is navigated, // not filtered). One view at a time: -// - Models: the EventModel index, grouped by namespace, each card linking -// to /em/... This is the landing view, because picking a model is what -// most visits are for. +// - Models: the EventModel index, grouped by namespace owner and then by +// namespace, each card linking to /em/... This is the landing view, +// because picking a model is what most visits are for. // - Domain chart: the problem-space knowledge graph (lightweight). // - Contexts: the context map. // Renders without loading the whole store: only EventModel entities and @@ -17,7 +17,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary'; import { buttonVariants } from '@/components/ui/button'; import { EmptyState } from '@/components/ui/empty-state'; import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'; -import { api } from '@/lib/api'; +import { api, type NamespaceRegistryRow } from '@/lib/api'; import { readBranchFromUrl, withBranchQuery } from '@/lib/branch'; import { asId, @@ -29,6 +29,7 @@ import { realizesOf, wireKind, } from '@/lib/model'; +import { groupByOwner } from '@/lib/namespace-owner'; import { cn } from '@/lib/utils'; const STORAGE_KEY = 'overview:filter:domains'; @@ -125,6 +126,7 @@ function persistMode(mode: OverviewMode) { export function OverviewPage() { const [chart, setChart] = useState(); const [eventModels, setEventModels] = useState([]); + const [registry, setRegistry] = useState(); const [overviewError, setOverviewError] = useState(); const [searchError, setSearchError] = useState(); const [searchQuery, setSearchQuery] = useState(''); @@ -182,6 +184,19 @@ export function OverviewPage() { // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only, see above }, []); + // Owners only shape the grouping, so a failed registry read keeps the + // page usable with plain namespace groups. + useEffect(() => { + const ctrl = new AbortController(); + api + .namespaceRegistry({ signal: ctrl.signal }) + .then((res) => { + if (!ctrl.signal.aborted) setRegistry(res.namespaces ?? []); + }) + .catch(() => {}); + return () => ctrl.abort(); + }, []); + useEffect(() => { const q = searchQuery.trim(); setSearchError(undefined); @@ -351,18 +366,17 @@ export function OverviewPage() { }, [chart, selectedDomains]); // Memoize so `mode` / `error` / unrelated state toggles don't rerun the - // O(n) group + sort. Only changes to the actual event-model set should - // trigger recompute. - const grouped = useMemo(() => { - const map = new Map(); - for (const em of visibleEventModels) { - const list = map.get(em.id.namespace) ?? []; - list.push(em); - map.set(em.id.namespace, list); - } - return map; - }, [visibleEventModels]); - const sortedNamespaces = useMemo(() => [...grouped.keys()].sort(), [grouped]); + // O(n) group + sort. Only changes to the event-model set or the registry + // should trigger recompute. + const ownerGroups = useMemo( + () => + groupByOwner( + [...visibleEventModels].sort((a, b) => a.title.localeCompare(b.title)), + (em) => em.id.namespace, + registry, + ), + [visibleEventModels, registry], + ); const chartFocusEntity = chartFocusKey && visibleChart ? visibleChart.byKey.get(chartFocusKey) : undefined; const searchOpen = searchQuery.trim().length >= 2; @@ -575,53 +589,72 @@ export function OverviewPage() { {selectedDomains.length > 0 ? ` of ${eventModels.length}` : ''})
- {sortedNamespaces.map((ns) => { - const list = (grouped.get(ns) ?? []).sort((a, b) => a.title.localeCompare(b.title)); + {ownerGroups.map((group) => { + const count = group.namespaces.reduce((n, g) => n + g.items.length, 0); + const namespaces = group.namespaces.map(({ namespace: ns, items: list }) => { + return ( +
+

+ {ns} + · + + {list.length} model{list.length === 1 ? '' : 's'} + +

+ +
+ ); + }); + if (registry === undefined) + return ( +
+ {namespaces} +
+ ); return ( -
-

- {ns} - · - - {list.length} model{list.length === 1 ? '' : 's'} +
+ + {group.owner ?? 'no owner'} + + {`${group.namespaces.length} namespace${group.namespaces.length === 1 ? '' : 's'} · ${count} model${count === 1 ? '' : 's'}`} -

- -
+ +
{namespaces}
+ ); })}
- {chart && sortedNamespaces.length === 0 ? ( + {chart && ownerGroups.length === 0 ? (
{eventModels.length === 0 ? ( - Event models show up here grouped by namespace as soon as the store has one. Each card opens its - board. + Event models show up here grouped by owner and namespace as soon as the store has one. Each card opens + its board. ) : ( & { name: string }): NamespaceRegistryRow { + return { id: overrides.name, parent: '', entityCount: 1, registered: true, ...overrides }; +} + +const model = (namespace: string, slug: string) => ({ namespace, slug }); +const shape = (groups: ReturnType>) => + groups.map((g) => ({ + owner: g.owner, + namespaces: g.namespaces.map((n) => [n.namespace, n.items.map((m) => m.slug)]), + })); + +describe('ownerLabel', () => { + it('names the server default when a row has no parent', () => { + expect(ownerLabel(row({ name: 'acme-orders' }))).toBe(DEFAULT_OWNER); + expect(ownerLabel(row({ name: 'acme-orders', parent: 'acme' }))).toBe('acme'); + }); +}); + +describe('groupByOwner', () => { + const registry = [ + row({ name: 'acme-orders', parent: 'acme' }), + row({ name: 'acme-probe', parent: 'acme' }), + row({ name: 'other-ns' }), + ]; + + it('puts namespaces that share a parent under one owner', () => { + const items = [ + model('other-ns', 'x'), + model('acme-probe', 'p'), + model('acme-orders', 'b'), + model('acme-orders', 'a'), + ]; + expect(shape(groupByOwner(items, (m) => m.namespace, registry))).toEqual([ + { + owner: 'acme', + namespaces: [ + ['acme-orders', ['b', 'a']], + ['acme-probe', ['p']], + ], + }, + { owner: DEFAULT_OWNER, namespaces: [['other-ns', ['x']]] }, + ]); + }); + + it('matches a minted id, since entity keys carry the id and not the name', () => { + const minted = [row({ id: 'ns_01', name: 'acme-billing', parent: 'acme' })]; + expect(shape(groupByOwner([model('ns_01', 'a')], (m) => m.namespace, minted))).toEqual([ + { owner: 'acme', namespaces: [['ns_01', ['a']]] }, + ]); + }); + + it('lists namespaces without an owner last, in a group no owner name can claim', () => { + const rows = [...registry, row({ id: '', name: 'legacy', registered: false })]; + const items = [model('legacy', 'l'), model('missing', 'm'), model('acme-orders', 'a')]; + expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([ + { owner: 'acme', namespaces: [['acme-orders', ['a']]] }, + { + owner: null, + namespaces: [ + ['legacy', ['l']], + ['missing', ['m']], + ], + }, + ]); + }); + + it('keeps an owner literally named unregistered apart from the no-owner group', () => { + const rows = [ + row({ name: 'acme-orders', parent: 'unregistered' }), + row({ id: '', name: 'legacy', registered: false }), + ]; + const items = [model('legacy', 'l'), model('acme-orders', 'a')]; + expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([ + { owner: 'unregistered', namespaces: [['acme-orders', ['a']]] }, + { owner: null, namespaces: [['legacy', ['l']]] }, + ]); + }); + + it('keeps one ownerless group when the registry could not be read', () => { + const items = [model('b-ns', 'b'), model('a-ns', 'a')]; + expect(shape(groupByOwner(items, (m) => m.namespace, undefined))).toEqual([ + { + owner: null, + namespaces: [ + ['a-ns', ['a']], + ['b-ns', ['b']], + ], + }, + ]); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/namespace-owner.ts b/tsworkspace/webapps/studio/src/lib/namespace-owner.ts new file mode 100644 index 00000000..44526607 --- /dev/null +++ b/tsworkspace/webapps/studio/src/lib/namespace-owner.ts @@ -0,0 +1,58 @@ +import type { NamespaceRegistryRow } from './api'; + +/** The owner a namespace gets when none is named. Matches the server. */ +export const DEFAULT_OWNER = 'default'; + +export function ownerLabel(row: NamespaceRegistryRow): string { + return row.parent === '' ? DEFAULT_OWNER : row.parent; +} + +export interface OwnerGroup { + /** + * Null for namespaces with no recorded owner, or for everything when the + * registry could not be read. Null keeps a real owner named "unregistered" apart. + */ + owner: string | null; + namespaces: { namespace: string; items: T[] }[]; +} + +/** + * Groups items by registry owner, then by namespace, both sorted, with the + * ownerless group last. Item order inside a namespace is kept. Without a + * registry everything lands in that one ownerless group, so the caller can + * fall back to the plain by-namespace list. + */ +export function groupByOwner( + items: readonly T[], + namespaceOf: (item: T) => string, + registry: readonly NamespaceRegistryRow[] | undefined, +): OwnerGroup[] { + // Entity keys carry the namespace id; unregistered rows have no id, only a name. + const ownerOf = new Map(); + for (const r of registry ?? []) { + const owner = r.registered ? ownerLabel(r) : null; + if (r.id !== '') ownerOf.set(r.id, owner); + if (!ownerOf.has(r.name)) ownerOf.set(r.name, owner); + } + + const tree = new Map>(); + for (const item of items) { + const ns = namespaceOf(item); + const owner = ownerOf.get(ns) ?? null; + const byNs = tree.get(owner) ?? new Map(); + const list = byNs.get(ns) ?? []; + list.push(item); + byNs.set(ns, list); + tree.set(owner, byNs); + } + + return [...tree.keys()] + .sort((a, b) => (a === null ? 1 : b === null ? -1 : a.localeCompare(b))) + .map((owner) => { + const byNs = tree.get(owner) ?? new Map(); + return { + owner, + namespaces: [...byNs.keys()].sort().map((namespace) => ({ namespace, items: byNs.get(namespace) ?? [] })), + }; + }); +} From f3c94e3f00436919e36b4f08c131a1b4064054d0 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Tue, 20 Oct 2026 08:31:34 -0700 Subject: [PATCH 2/2] fix(studio): keep a minted namespace name from claiming an unregistered namespace Signed-off-by: Erik Nilsen --- .../webapps/studio/src/lib/namespace-owner.test.ts | 12 ++++++++++++ .../webapps/studio/src/lib/namespace-owner.ts | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts b/tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts index 8f50bab0..47b463fe 100644 --- a/tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts +++ b/tsworkspace/webapps/studio/src/lib/namespace-owner.test.ts @@ -68,6 +68,18 @@ describe('groupByOwner', () => { ]); }); + it('leaves an unregistered name ownerless when a minted row shares that name', () => { + const rows = [ + row({ id: 'ns_01', name: 'acme-billing', parent: 'acme' }), + row({ id: '', name: 'acme-billing', registered: false }), + ]; + const items = [model('acme-billing', 'u'), model('ns_01', 'm')]; + expect(shape(groupByOwner(items, (m) => m.namespace, rows))).toEqual([ + { owner: 'acme', namespaces: [['ns_01', ['m']]] }, + { owner: null, namespaces: [['acme-billing', ['u']]] }, + ]); + }); + it('keeps an owner literally named unregistered apart from the no-owner group', () => { const rows = [ row({ name: 'acme-orders', parent: 'unregistered' }), diff --git a/tsworkspace/webapps/studio/src/lib/namespace-owner.ts b/tsworkspace/webapps/studio/src/lib/namespace-owner.ts index 44526607..ddcc6d10 100644 --- a/tsworkspace/webapps/studio/src/lib/namespace-owner.ts +++ b/tsworkspace/webapps/studio/src/lib/namespace-owner.ts @@ -27,12 +27,12 @@ export function groupByOwner( namespaceOf: (item: T) => string, registry: readonly NamespaceRegistryRow[] | undefined, ): OwnerGroup[] { - // Entity keys carry the namespace id; unregistered rows have no id, only a name. + // Entity keys carry the namespace id, so a minted row's name must not claim + // a key; only unregistered rows, which have no id, are matched by name. const ownerOf = new Map(); for (const r of registry ?? []) { - const owner = r.registered ? ownerLabel(r) : null; - if (r.id !== '') ownerOf.set(r.id, owner); - if (!ownerOf.has(r.name)) ownerOf.set(r.name, owner); + if (r.id !== '') ownerOf.set(r.id, r.registered ? ownerLabel(r) : null); + else if (!ownerOf.has(r.name)) ownerOf.set(r.name, null); } const tree = new Map>();