@eventcatalog/core 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ ---
2
+ import VisualiserLayout from '@layouts/VisualiserLayout.astro';
3
+ import CatalogForceGraph from '@components/CatalogGraph/CatalogForceGraph';
4
+ import { getCatalogForceGraph } from '@utils/node-graphs/catalog-force-graph';
5
+ import { isArchitectureGraphEnabled } from '@utils/feature';
6
+
7
+ // Bail before building the graph — on very large catalogs the whole point of
8
+ // disabling this feature is to skip that work entirely
9
+ if (!isArchitectureGraphEnabled()) {
10
+ return new Response(null, { status: 404 });
11
+ }
12
+
13
+ const { nodes, links } = await getCatalogForceGraph();
14
+
15
+ // Compact wire format: index-based links and client-derived URLs keep the
16
+ // island props Astro serialises into the page small for large catalogs.
17
+ const nodeIndexById = new Map(nodes.map((node, index) => [node.id, index]));
18
+ const linkLabels = [...new Set(links.map((link) => link.label))];
19
+ const labelIndexByLabel = new Map(linkLabels.map((label, index) => [label, index]));
20
+
21
+ const wireNodes = nodes.map(({ id, label, collection, version }) => ({
22
+ // Node ids are `${collection}/${resourceId}` — send only the resource id, the client rebuilds the key
23
+ id: id.slice(collection.length + 1),
24
+ label,
25
+ collection,
26
+ version,
27
+ }));
28
+
29
+ const wireLinks = links.map((link) => [
30
+ nodeIndexById.get(link.source)!,
31
+ nodeIndexById.get(link.target)!,
32
+ labelIndexByLabel.get(link.label)!,
33
+ ]);
34
+ ---
35
+
36
+ <VisualiserLayout title="Visualiser | Architecture Graph" description="Force-directed graph of every resource in the catalog">
37
+ <div class="m-4">
38
+ <div class="h-[calc(100vh-130px)] w-full relative border border-[rgb(var(--ec-page-border))] rounded-md overflow-hidden">
39
+ <CatalogForceGraph nodes={wireNodes} links={wireLinks} linkLabels={linkLabels} client:only="react" />
40
+ </div>
41
+ </div>
42
+ </VisualiserLayout>
@@ -40,7 +40,7 @@ import {
40
40
  shouldRenderSideBarSection,
41
41
  withArchitectureDecisionsSection,
42
42
  } from './builders/shared';
43
- import { isChangelogEnabled } from '@utils/feature';
43
+ import { isArchitectureGraphEnabled, isChangelogEnabled } from '@utils/feature';
44
44
 
45
45
  export type { NavigationData, NavNode, ChildRef };
46
46
 
@@ -839,18 +839,34 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
839
839
  };
840
840
  }
841
841
 
842
+ // The System Context Map needs systems to exist; the Architecture Graph works
843
+ // for any catalog — the group renders when either item has something to show
844
+ const architectureGraphEnabled = isArchitectureGraphEnabled();
842
845
  const topLevelDiagramsNode =
843
- visualiserEnabled && systems.length > 0
846
+ visualiserEnabled && (systems.length > 0 || architectureGraphEnabled)
844
847
  ? {
845
848
  type: 'group' as const,
846
849
  title: 'Top level diagrams',
847
850
  icon: 'Workflow',
848
851
  pages: [
849
- {
850
- type: 'item' as const,
851
- title: 'System Context Map',
852
- href: buildUrl('/visualiser/system-context-map'),
853
- },
852
+ ...(systems.length > 0
853
+ ? [
854
+ {
855
+ type: 'item' as const,
856
+ title: 'System Context Map',
857
+ href: buildUrl('/visualiser/system-context-map'),
858
+ },
859
+ ]
860
+ : []),
861
+ ...(architectureGraphEnabled
862
+ ? [
863
+ {
864
+ type: 'item' as const,
865
+ title: 'Architecture Graph',
866
+ href: buildUrl('/visualiser/graph'),
867
+ },
868
+ ]
869
+ : []),
854
870
  ],
855
871
  }
856
872
  : undefined;
@@ -3,6 +3,9 @@ import config from '../../eventcatalog.config.js';
3
3
  // Open-source feature flags
4
4
  export const isSSR = () => config?.output === 'server';
5
5
  export const isVisualiserEnabled = () => config?.visualiser?.enabled ?? true;
6
+ // Opt-in while in beta — building the whole-catalog graph is unproven on very large catalogs
7
+ export const isArchitectureGraphEnabled = () =>
8
+ isVisualiserEnabled() && (config?.visualiser?.architectureGraph?.enabled ?? false);
6
9
  export const isChangelogEnabled = () => config?.changelog?.enabled ?? false;
7
10
  export const isRSSEnabled = () => config?.rss?.enabled ?? false;
8
11
  export const isLLMSTxtEnabled = () => config?.llmsTxt?.enabled ?? true;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Builds the data for the catalog-wide force-directed graph (/visualiser/graph).
3
+ *
4
+ * Unlike the ReactFlow node-graphs (which render one resource and its
5
+ * neighbourhood), this flattens the whole catalog into simple `{ nodes, links }`
6
+ * for a D3 force simulation. Only the latest version of each resource is
7
+ * included, and links are resolved by resource id so pinned-version references
8
+ * still connect to the latest node.
9
+ */
10
+ import { getDomains } from '@utils/collections/domains';
11
+ import { getServices } from '@utils/collections/services';
12
+ import { getAgents } from '@utils/collections/agents';
13
+ import { getEvents } from '@utils/collections/events';
14
+ import { getCommands } from '@utils/collections/commands';
15
+ import { getQueries } from '@utils/collections/queries';
16
+ import { getFlows } from '@utils/collections/flows';
17
+ import { getEntities } from '@utils/collections/entities';
18
+ import { getContainers } from '@utils/collections/containers';
19
+ import { getDataProducts } from '@utils/collections/data-products';
20
+ import { getSystems } from '@utils/collections/systems';
21
+ import { getTeams } from '@utils/collections/teams';
22
+ import { buildUrl } from '@utils/url-builder';
23
+
24
+ export interface CatalogGraphNode {
25
+ id: string;
26
+ label: string;
27
+ collection: string;
28
+ version?: string;
29
+ url?: string;
30
+ }
31
+
32
+ export interface CatalogGraphLink {
33
+ source: string;
34
+ target: string;
35
+ label: string;
36
+ }
37
+
38
+ type AnyEntry = {
39
+ collection?: string;
40
+ data: { id: string; name?: string; version?: string; latestVersion?: string; visualiser?: boolean };
41
+ };
42
+
43
+ const nodeKey = (collection: string, id: string) => `${collection}/${id}`;
44
+
45
+ // Relationship values come in three shapes depending on hydration: a raw string
46
+ // id, a pointer/reference ({ id }) or a full collection entry ({ data: { id } }).
47
+ const refId = (ref: unknown): string | undefined => {
48
+ if (!ref) return undefined;
49
+ if (typeof ref === 'string') return ref;
50
+ if (Array.isArray(ref)) return refId(ref[0]);
51
+ const value = ref as { data?: { id?: string }; id?: string };
52
+ return value.data?.id ?? value.id;
53
+ };
54
+
55
+ const messageLabels: Record<string, { sends: string; receives: string }> = {
56
+ events: { sends: 'publishes', receives: 'subscribed by' },
57
+ commands: { sends: 'invokes', receives: 'accepts' },
58
+ queries: { sends: 'requests', receives: 'accepts' },
59
+ };
60
+
61
+ export const getCatalogForceGraph = async (): Promise<{ nodes: CatalogGraphNode[]; links: CatalogGraphLink[] }> => {
62
+ const [domains, services, agents, events, commands, queries, flows, entities, containers, dataProducts, systems] =
63
+ await Promise.all([
64
+ // Strict containment: a parent domain should not also claim its subdomains' services
65
+ getDomains({ includeServicesInSubdomains: false }),
66
+ getServices(),
67
+ getAgents(),
68
+ getEvents(),
69
+ getCommands(),
70
+ getQueries(),
71
+ getFlows(),
72
+ getEntities(),
73
+ getContainers(),
74
+ getDataProducts(),
75
+ getSystems(),
76
+ ]);
77
+ const teams = await getTeams();
78
+
79
+ // Latest version of each resource only, honouring the resource-level
80
+ // `visualiser: false` opt-out (matching the other visualiser views)
81
+ const latestOnly = <T extends AnyEntry>(items: T[]): T[] =>
82
+ items.filter(
83
+ (item) => item.data.visualiser !== false && (!item.data.latestVersion || item.data.version === item.data.latestVersion)
84
+ );
85
+
86
+ const collections: Record<string, AnyEntry[]> = {
87
+ domains: latestOnly(domains as AnyEntry[]),
88
+ services: latestOnly(services as AnyEntry[]),
89
+ agents: latestOnly(agents as AnyEntry[]),
90
+ events: latestOnly(events as AnyEntry[]),
91
+ commands: latestOnly(commands as AnyEntry[]),
92
+ queries: latestOnly(queries as AnyEntry[]),
93
+ flows: latestOnly(flows as AnyEntry[]),
94
+ entities: latestOnly(entities as AnyEntry[]),
95
+ containers: latestOnly(containers as AnyEntry[]),
96
+ 'data-products': latestOnly(dataProducts as AnyEntry[]),
97
+ systems: latestOnly(systems as AnyEntry[]),
98
+ teams: teams as AnyEntry[],
99
+ };
100
+
101
+ const nodes = new Map<string, CatalogGraphNode>();
102
+ const links = new Map<string, CatalogGraphLink>();
103
+
104
+ for (const [collection, items] of Object.entries(collections)) {
105
+ for (const item of items) {
106
+ const { id, name, version } = item.data;
107
+ const url =
108
+ collection === 'teams'
109
+ ? buildUrl(`/docs/${collection}/${id}`)
110
+ : buildUrl(`/docs/${collection}/${id}/${version ?? 'latest'}`);
111
+ nodes.set(nodeKey(collection, id), { id: nodeKey(collection, id), label: name || id, collection, version, url });
112
+ }
113
+ }
114
+
115
+ const addLink = (source: string | undefined, target: string | undefined, label: string) => {
116
+ if (!source || !target || source === target) return;
117
+ if (!nodes.has(source) || !nodes.has(target)) return;
118
+ // One rendered link per direction, but distinct relationships between the
119
+ // same pair (e.g. two entity properties with different relationTypes) keep
120
+ // all their labels rather than the last one silently winning
121
+ const key = `${source}->${target}`;
122
+ const existing = links.get(key);
123
+ if (existing) {
124
+ if (!existing.label.split(' / ').includes(label)) existing.label = `${existing.label} / ${label}`;
125
+ return;
126
+ }
127
+ links.set(key, { source, target, label });
128
+ };
129
+
130
+ // Individual users are deliberately not rendered — only team ownership is,
131
+ // so owner pointers at users resolve to nothing here.
132
+ const teamIds = new Set(collections.teams.map((team) => team.data.id));
133
+ const ownerKey = (ref: unknown) => {
134
+ const id = refId(ref);
135
+ return id && teamIds.has(id) ? nodeKey('teams', id) : undefined;
136
+ };
137
+
138
+ // Data product inputs/outputs are polymorphic — resolve the collection by id.
139
+ // Precedence mirrors the data product node-graph, which merges message, service
140
+ // and container maps in that order with later maps overriding: containers win
141
+ // over services, which win over messages.
142
+ const polymorphicKey = (ref: unknown) => {
143
+ const id = refId(ref);
144
+ if (!id) return undefined;
145
+ for (const collection of ['containers', 'services', 'events', 'commands', 'queries']) {
146
+ const key = nodeKey(collection, id);
147
+ if (nodes.has(key)) return key;
148
+ }
149
+ return undefined;
150
+ };
151
+
152
+ const addMessageLinks = (sourceKey: string, data: any) => {
153
+ for (const message of data.sends ?? []) {
154
+ const collection = (message as AnyEntry).collection ?? 'events';
155
+ addLink(sourceKey, nodeKey(collection, refId(message)!), messageLabels[collection]?.sends ?? 'sends');
156
+ }
157
+ for (const message of data.receives ?? []) {
158
+ const collection = (message as AnyEntry).collection ?? 'events';
159
+ addLink(nodeKey(collection, refId(message)!), sourceKey, messageLabels[collection]?.receives ?? 'received by');
160
+ }
161
+ };
162
+
163
+ for (const domain of collections.domains) {
164
+ const key = nodeKey('domains', domain.data.id);
165
+ const data = domain.data as any;
166
+ for (const service of data.services ?? []) addLink(key, nodeKey('services', refId(service)!), 'contains');
167
+ for (const agent of data.agents ?? []) addLink(key, nodeKey('agents', refId(agent)!), 'contains');
168
+ for (const subdomain of data.domains ?? []) addLink(key, nodeKey('domains', refId(subdomain)!), 'contains');
169
+ for (const system of data.systems ?? []) addLink(key, nodeKey('systems', refId(system)!), 'contains');
170
+ for (const flow of data.flows ?? []) addLink(key, nodeKey('flows', refId(flow)!), 'contains');
171
+ for (const dataProduct of data['data-products'] ?? [])
172
+ addLink(key, nodeKey('data-products', refId(dataProduct)!), 'contains');
173
+ for (const entity of data.entities ?? []) addLink(key, nodeKey('entities', refId(entity)!), 'owns');
174
+ addMessageLinks(key, data);
175
+ }
176
+
177
+ for (const serviceLike of [...collections.services, ...collections.agents]) {
178
+ const key = nodeKey(serviceLike.collection!, serviceLike.data.id);
179
+ const data = serviceLike.data as any;
180
+ addMessageLinks(key, data);
181
+ for (const container of data.writesTo ?? []) addLink(key, nodeKey('containers', refId(container)!), 'writes to');
182
+ for (const container of data.readsFrom ?? []) addLink(nodeKey('containers', refId(container)!), key, 'read by');
183
+ for (const entity of data.entities ?? []) addLink(key, nodeKey('entities', refId(entity)!), 'owns');
184
+ for (const flow of data.flows ?? []) addLink(key, nodeKey('flows', refId(flow)!), 'part of');
185
+ }
186
+
187
+ for (const system of collections.systems) {
188
+ const key = nodeKey('systems', system.data.id);
189
+ const data = system.data as any;
190
+ for (const service of data.services ?? []) addLink(key, nodeKey('services', refId(service)!), 'contains');
191
+ for (const container of data.containers ?? []) addLink(key, nodeKey('containers', refId(container)!), 'contains');
192
+ for (const entity of data.entities ?? []) addLink(key, nodeKey('entities', refId(entity)!), 'contains');
193
+ for (const flow of data.flows ?? []) addLink(key, nodeKey('flows', refId(flow)!), 'contains');
194
+ for (const relationship of data.relationships ?? []) {
195
+ // An unlabelled relationship means "include in my context diagram" without
196
+ // asserting a relationship — the context map draws no edge for it, so
197
+ // neither do we (matching system-context-node-graph.ts)
198
+ if (!relationship.label) continue;
199
+ addLink(key, nodeKey('systems', refId(relationship)!), relationship.label);
200
+ }
201
+ }
202
+
203
+ for (const flow of collections.flows) {
204
+ const key = nodeKey('flows', flow.data.id);
205
+ for (const step of (flow.data as any).steps ?? []) {
206
+ addLink(key, nodeKey('services', refId(step.service)!), 'references');
207
+ addLink(key, nodeKey('agents', refId(step.agent)!), 'references');
208
+ addLink(key, nodeKey('flows', refId(step.flow)!), 'has sub-flow');
209
+ addLink(key, nodeKey('containers', refId(step.container)!), 'references');
210
+ addLink(key, nodeKey('data-products', refId(step.dataProduct)!), 'references');
211
+ const messageId = refId(step.message);
212
+ if (messageId) {
213
+ for (const collection of ['events', 'commands', 'queries']) {
214
+ if (nodes.has(nodeKey(collection, messageId))) {
215
+ addLink(key, nodeKey(collection, messageId), 'references');
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ for (const entity of collections.entities) {
224
+ const key = nodeKey('entities', entity.data.id);
225
+ for (const property of (entity.data as any).properties ?? []) {
226
+ // Same inference as the entity map: an explicit `references`, or an array
227
+ // property whose item type names another entity (an implicit hasMany)
228
+ const referencedId =
229
+ property?.references ??
230
+ (property?.type === 'array' && nodes.has(nodeKey('entities', property?.items?.type)) ? property.items.type : undefined);
231
+ if (!referencedId) continue;
232
+ const label = property.relationType || (property.type === 'array' ? 'hasMany' : 'references');
233
+ addLink(key, nodeKey('entities', referencedId), label);
234
+ }
235
+ }
236
+
237
+ for (const dataProduct of collections['data-products']) {
238
+ const key = nodeKey('data-products', dataProduct.data.id);
239
+ const data = dataProduct.data as any;
240
+ for (const input of data.inputs ?? []) addLink(polymorphicKey(input), key, 'input');
241
+ for (const output of data.outputs ?? []) addLink(key, polymorphicKey(output), 'output');
242
+ }
243
+
244
+ for (const [collection, items] of Object.entries(collections)) {
245
+ if (collection === 'teams') continue;
246
+ for (const item of items) {
247
+ for (const owner of (item.data as any).owners ?? []) {
248
+ addLink(nodeKey(collection, item.data.id), ownerKey(owner), 'owned by');
249
+ }
250
+ }
251
+ }
252
+
253
+ return { nodes: [...nodes.values()], links: [...links.values()] };
254
+ };
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "SEE LICENSE IN LICENSE",
9
9
  "type": "module",
10
- "version": "4.4.0",
10
+ "version": "4.5.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -40,7 +40,7 @@
40
40
  "@asyncapi/avro-schema-parser": "3.0.24",
41
41
  "@asyncapi/parser": "^3.6.0",
42
42
  "@asyncapi/react-component": "3.1.0",
43
- "@auth/core": "^0.37.4",
43
+ "@auth/core": "^0.41.3",
44
44
  "@eventcatalog/license": "^0.0.7",
45
45
  "@fontsource/inter": "^5.2.5",
46
46
  "@headlessui/react": "^2.0.3",
@@ -68,6 +68,10 @@
68
68
  "boxen": "^8.0.1",
69
69
  "commander": "^12.1.0",
70
70
  "cross-env": "^7.0.3",
71
+ "d3-drag": "3.0.0",
72
+ "d3-force": "3.0.0",
73
+ "d3-selection": "3.0.0",
74
+ "d3-zoom": "3.0.0",
71
75
  "dagre": "^0.8.5",
72
76
  "diff": "^8.0.3",
73
77
  "diff2html": "^3.4.56",
@@ -115,12 +119,16 @@
115
119
  "update-notifier": "^7.3.1",
116
120
  "uuid": "^10.0.0",
117
121
  "zod": "^4.3.6",
118
- "@eventcatalog/sdk": "2.26.3",
122
+ "@eventcatalog/linter": "1.1.8",
119
123
  "@eventcatalog/visualiser": "^4.1.1",
120
- "@eventcatalog/linter": "1.1.8"
124
+ "@eventcatalog/sdk": "2.26.3"
121
125
  },
122
126
  "devDependencies": {
123
127
  "@astrojs/check": "^0.9.9",
128
+ "@types/d3-drag": "^3.0.7",
129
+ "@types/d3-force": "^3.0.10",
130
+ "@types/d3-selection": "^3.0.11",
131
+ "@types/d3-zoom": "^3.0.8",
124
132
  "@types/dagre": "^0.7.52",
125
133
  "@types/diff": "^5.2.2",
126
134
  "@types/js-yaml": "^4.0.9",