@eventcatalog/core 5.4.0 → 5.4.2

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.
Files changed (30) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-TERJXCLK.js → chunk-CQ4L4SMB.js} +1 -1
  6. package/dist/{chunk-PAS36QHK.js → chunk-EXRIIYEP.js} +1 -1
  7. package/dist/{chunk-OT2U5OES.js → chunk-HV4ONKTM.js} +1 -1
  8. package/dist/{chunk-OX6TSVHY.js → chunk-QJ7KKN2D.js} +1 -1
  9. package/dist/{chunk-EPDVXHC7.js → chunk-VSBMYXLL.js} +3 -3
  10. package/dist/{chunk-O2K4VPDO.js → chunk-XB6FJMI3.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +9 -9
  15. package/dist/federation/federate.js +3 -3
  16. package/dist/federation/source-provider.js +2 -2
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/utils/cli-logger.cjs +1 -1
  20. package/dist/utils/cli-logger.js +2 -2
  21. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +3 -1
  22. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -70
  23. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +68 -68
  24. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -1
  25. package/eventcatalog/src/features/studio/canvas-actions.ts +8 -2
  26. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +4 -3
  27. package/eventcatalog/src/utils/node-graphs/system-levels-node-graph.ts +195 -9
  28. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +48 -5
  29. package/package.json +4 -4
  30. package/dist/{chunk-6K7XZAYI.js → chunk-A2RZR3U4.js} +3 -3
@@ -20,7 +20,9 @@ import type { SignedInUser } from '../server/sign-in';
20
20
  * "edges"?: [...] }, starting with the nodes and edges given, if any (as the
21
21
  * add_to_canvas MCP tool takes them: catalog resources or new components, x/y
22
22
  * their centres, and only the connections in edges are made, each drawn
23
- * along its "route" if given: { "points": [{x, y}, ...], "label"?: {x, y} }),
23
+ * along its "route" if given: { "points": [{x, y}, ...], "label"?: {x, y},
24
+ * "sourceHeight"?: number, "targetHeight"?: number }, the heights its nodes
25
+ * were laid out at),
24
26
  * and "levels" (L1 and L2 laid out, e.g. as a diagram shows them, by the
25
27
  * nodes' refs), shown as given until what's on the canvas changes
26
28
  * GET /api/studio/canvases/:id One canvas
@@ -96,9 +96,15 @@ const pointSchema = z.object({ x: z.number(), y: z.number() });
96
96
  /**
97
97
  * Edges for a new canvas (through the Studio API): as add_to_canvas takes them, and how each is drawn, if known
98
98
  * (e.g. copied from a diagram): the points it goes through (canvas coordinates, at right angles) and where its
99
- * label goes. It follows its nodes when they're moved.
99
+ * label goes, and how tall its nodes were laid out (for its ends to land on them as they render). It follows its
100
+ * nodes when they're moved.
100
101
  */
101
- const routeSchema = z.object({ points: z.array(pointSchema).min(2).max(200), label: pointSchema.optional() });
102
+ const routeSchema = z.object({
103
+ points: z.array(pointSchema).min(2).max(200),
104
+ label: pointSchema.optional(),
105
+ sourceHeight: z.number().positive().optional(),
106
+ targetHeight: z.number().positive().optional(),
107
+ });
102
108
  export const newCanvasEdgesSchema = z.array(edgeSpecsSchema.element.extend({ route: routeSchema.optional() }));
103
109
  export type NewCanvasEdges = z.output<typeof newCanvasEdgesSchema>;
104
110
 
@@ -46,7 +46,7 @@ type ModelContextTool = {
46
46
  title?: string;
47
47
  description: string;
48
48
  inputSchema?: object;
49
- annotations?: { readOnlyHint?: boolean; consequentialHint?: boolean };
49
+ annotations?: { readOnlyHint?: boolean; consequentialHint?: boolean; untrustedContentHint?: boolean };
50
50
  execute: (input: unknown, options?: { signal?: AbortSignal }) => Promise<ToolResult>;
51
51
  };
52
52
  type ModelContext = { registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => Promise<void> | void };
@@ -204,7 +204,8 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
204
204
  description:
205
205
  'Reads the collaborative canvas open on this page: its nodes (ids, names, positions, the catalog resource they stand for), connections, comment threads, and who is on it. Call it first, and again to see what people changed.',
206
206
  input: z.object({}),
207
- annotations: { readOnlyHint: true },
207
+ // Names, notes and comments come from people
208
+ annotations: { readOnlyHint: true, untrustedContentHint: true },
208
209
  execute: () =>
209
210
  withDoc((doc) => ({
210
211
  ...describeCanvas(doc),
@@ -342,7 +343,7 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
342
343
  description:
343
344
  'Selects nodes and moves the view to them, to point the user at something. Leave out nodeIds to show the whole canvas.',
344
345
  input: z.object({ nodeIds: z.array(z.string()).optional() }),
345
- annotations: { readOnlyHint: true },
346
+ // Not read only: it changes the user's selection and view
346
347
  execute: ({ nodeIds }) => {
347
348
  if (nodeIds?.length) get().select(nodeIds);
348
349
  get().fitView(nodeIds);
@@ -1,6 +1,8 @@
1
+ import { getCollection } from 'astro:content';
1
2
  import { getNodesAndEdges as getSystemContextNodesAndEdges } from './system-context-node-graph';
2
- import { getNodesAndEdges as getSystemNodesAndEdges } from './systems-node-graph';
3
+ import { getNodesAndEdges as getSystemNodesAndEdges, memberServiceNodeIds, SERVICE_NODE_TYPES } from './systems-node-graph';
3
4
  import { layoutLevels } from '@utils/node-graphs/layout-node-graph';
5
+ import { createVersionedMap, findInMap } from '@utils/collections/util';
4
6
 
5
7
  interface NodesAndEdgesProps {
6
8
  id: string;
@@ -22,6 +24,145 @@ export const getNodesAndEdges = (props: NodesAndEdgesProps) => {
22
24
  return levelsCache.get(key)!;
23
25
  };
24
26
 
27
+ const nodeIdOf = (resource: { data: { id: string; version?: string } }) => `${resource.data.id}-${resource.data.version}`;
28
+
29
+ const isSameResource = (a: { data: { id: string; version?: string } }, b: { data: { id: string; version?: string } }) =>
30
+ a.data.id === b.data.id && a.data.version === b.data.version;
31
+
32
+ /**
33
+ * Every system that lists a service, keyed by the service's resolved node id
34
+ * (`id-version`). Pointers use the same version matching as the rest of the
35
+ * catalog (`latest`, exact versions, semver ranges), so the key is the
36
+ * concrete service the pointer selects, not the pointer text.
37
+ */
38
+ export const collectServiceOwners = (systems: any[], serviceMap: Map<string, any[]>) => {
39
+ const owners = new Map<string, any[]>();
40
+ for (const system of systems) {
41
+ for (const ref of system.data.services || []) {
42
+ const service = findInMap(serviceMap, ref.id, ref.version);
43
+ if (!service) continue;
44
+ const id = nodeIdOf(service);
45
+ const list = owners.get(id) ?? [];
46
+ if (!list.some((owner) => isSameResource(owner, system))) list.push(system);
47
+ owners.set(id, list);
48
+ }
49
+ }
50
+ return owners;
51
+ };
52
+
53
+ /**
54
+ * Which of a service's systems stands for it on this diagram.
55
+ * A direct relationship wins, in the order the focused system declares them
56
+ * (their version pointers resolved the same way as the context diagram).
57
+ * Otherwise the lowest system id, then version, so catalog order doesn't matter.
58
+ * Systems that aren't already on the diagram are not candidates: the service
59
+ * is drawn on its own instead of inventing a system box.
60
+ */
61
+ const chooseOwningSystem = (owners: any[], contextSystemIds: Set<string>, focused: any, systemMap: Map<string, any[]>) => {
62
+ const inGraph = owners.filter((system) => !isSameResource(system, focused) && contextSystemIds.has(nodeIdOf(system)));
63
+ if (inGraph.length === 0) return undefined;
64
+
65
+ const inGraphIds = new Set(inGraph.map(nodeIdOf));
66
+ for (const relationship of focused.data.relationships || []) {
67
+ const related = findInMap(systemMap, relationship.id, relationship.version);
68
+ if (related && inGraphIds.has(nodeIdOf(related))) return related;
69
+ }
70
+
71
+ return [...inGraph].sort((a, b) => {
72
+ const byId = a.data.id.localeCompare(b.data.id);
73
+ return byId !== 0 ? byId : String(a.data.version).localeCompare(String(b.data.version));
74
+ })[0];
75
+ };
76
+
77
+ const detachFromGroup = (node: any) => {
78
+ const { parentId, extent, ...rest } = node;
79
+ return rest;
80
+ };
81
+
82
+ const edgePair = (source: string, target: string) => `${source}->${target}`;
83
+
84
+ /**
85
+ * Drops edges that repeat a relationship (either direction) and edges that
86
+ * repeat the same source, target and label. Relationship edges themselves stay.
87
+ */
88
+ export const dedupeEdges = (edges: any[], relationshipPairs: Set<string>) => {
89
+ const seen = new Set<string>();
90
+ return edges.filter((edge) => {
91
+ const isRelationship = String(edge.id).startsWith('context-');
92
+ const pair = edgePair(edge.source, edge.target);
93
+ if (!isRelationship && relationshipPairs.has(pair)) return false;
94
+ if (isRelationship) return true;
95
+ const key = `${pair}->${edge.label ?? ''}`;
96
+ if (seen.has(key)) return false;
97
+ seen.add(key);
98
+ return true;
99
+ });
100
+ };
101
+
102
+ /**
103
+ * Services that belong to another system already on the diagram are removed
104
+ * and their edges point at that system. Services in no system, or whose system
105
+ * isn't on the diagram, are kept but not inside this system's group. A service
106
+ * this system lists stays in the group even when another system lists it too.
107
+ */
108
+ export const rehomeServicesInOtherSystems = ({
109
+ nodes,
110
+ edges,
111
+ groupId,
112
+ focusedSystem,
113
+ allSystems,
114
+ allServices,
115
+ contextSystemIds,
116
+ relationshipPairs,
117
+ }: {
118
+ nodes: any[];
119
+ edges: any[];
120
+ groupId: string;
121
+ focusedSystem: any;
122
+ allSystems: any[];
123
+ allServices: any[];
124
+ contextSystemIds: Set<string>;
125
+ relationshipPairs: Set<string>;
126
+ }) => {
127
+ const serviceMap = createVersionedMap(allServices);
128
+ const systemMap = createVersionedMap(allSystems);
129
+ const owners = collectServiceOwners(allSystems, serviceMap);
130
+ const members = memberServiceNodeIds(focusedSystem, serviceMap);
131
+ const replacedBy = new Map<string, string>();
132
+
133
+ const nextNodes = nodes
134
+ .map((node) => {
135
+ if (node.id === groupId || !SERVICE_NODE_TYPES.has(node.type)) return node;
136
+ const nodeOwners = owners.get(node.id) ?? [];
137
+ // Listed here, or resolved as a member: it belongs inside this group
138
+ if (members.has(node.id) || nodeOwners.some((system) => isSameResource(system, focusedSystem))) {
139
+ return node.parentId === groupId ? node : { ...node, parentId: groupId, extent: 'parent' };
140
+ }
141
+ const owner = chooseOwningSystem(nodeOwners, contextSystemIds, focusedSystem, systemMap);
142
+ if (owner) {
143
+ replacedBy.set(node.id, nodeIdOf(owner));
144
+ return null;
145
+ }
146
+ return detachFromGroup(node);
147
+ })
148
+ .filter((node): node is any => !!node);
149
+
150
+ const seen = new Set<string>();
151
+ const nextEdges = edges.flatMap((edge) => {
152
+ const source = replacedBy.get(edge.source) ?? edge.source;
153
+ const target = replacedBy.get(edge.target) ?? edge.target;
154
+ if (source === target) return [];
155
+ const pair = edgePair(source, target);
156
+ if (relationshipPairs.has(pair)) return [];
157
+ const key = `${pair}->${edge.label ?? ''}`;
158
+ if (seen.has(key)) return [];
159
+ seen.add(key);
160
+ return [{ ...edge, source, target }];
161
+ });
162
+
163
+ return { nodes: nextNodes, edges: nextEdges, replacedBy };
164
+ };
165
+
25
166
  /**
26
167
  * A system's resource diagram shown inside its context diagram: the system's node
27
168
  * is replaced by the expanded system group (services, data stores, messages...),
@@ -30,17 +171,27 @@ export const getNodesAndEdges = (props: NodesAndEdgesProps) => {
30
171
  * its messages and channels (`hiddenMessages`).
31
172
  */
32
173
  const buildLevels = async ({ id, version, mode = 'simple', channelRenderMode }: NodesAndEdgesProps) => {
33
- const [context, system] = await Promise.all([
174
+ const [context, system, allSystems, allServices] = await Promise.all([
34
175
  getSystemContextNodesAndEdges({ id, version, mode, layout: false }),
35
- getSystemNodesAndEdges({ id, version, mode, channelRenderMode, wrapInSystemGroup: true, layout: false }),
176
+ getSystemNodesAndEdges({
177
+ id,
178
+ version,
179
+ mode,
180
+ channelRenderMode,
181
+ wrapInSystemGroup: true,
182
+ confineToMembers: true,
183
+ layout: false,
184
+ }),
185
+ getCollection('systems'),
186
+ getCollection('services'),
36
187
  ]);
37
188
 
38
189
  const group = system.nodes.find((node: any) => node.type === 'system-group');
39
190
  const rootSystemNode = context.nodes.find((node: any) => node.data?.isFocused);
40
191
  if (!group || !rootSystemNode) return layoutLevels(system, context);
41
192
 
42
- const systemNodeIds = new Set(system.nodes.map((node: any) => node.id));
43
- const contextNodes = context.nodes.filter((node: any) => node.id !== rootSystemNode.id && !systemNodeIds.has(node.id));
193
+ const focusedSystem = allSystems.find((systemEntry) => systemEntry.data.id === id && systemEntry.data.version === version);
194
+
44
195
  const toGroup = (nodeId: string) => (nodeId === rootSystemNode.id ? group.id : nodeId);
45
196
  const contextEdges = context.edges.map((edge: any) => ({
46
197
  ...edge,
@@ -48,12 +199,47 @@ const buildLevels = async ({ id, version, mode = 'simple', channelRenderMode }:
48
199
  source: toGroup(edge.source),
49
200
  target: toGroup(edge.target),
50
201
  }));
202
+ const relationshipPairs = new Set<string>();
203
+ for (const edge of contextEdges) {
204
+ relationshipPairs.add(edgePair(edge.source, edge.target));
205
+ relationshipPairs.add(edgePair(edge.target, edge.source));
206
+ }
207
+
208
+ const contextSystemIds = new Set(
209
+ context.nodes.filter((node: any) => node.type === 'systems' && node.id !== rootSystemNode.id).map((node: any) => node.id)
210
+ );
51
211
 
52
- // Lay the context and the expanded system out together
212
+ const rehomed = focusedSystem
213
+ ? rehomeServicesInOtherSystems({
214
+ nodes: system.nodes,
215
+ edges: system.edges,
216
+ groupId: group.id,
217
+ focusedSystem,
218
+ allSystems,
219
+ allServices,
220
+ contextSystemIds,
221
+ relationshipPairs,
222
+ })
223
+ : { nodes: system.nodes, edges: system.edges };
224
+
225
+ const systemNodeIds = new Set(rehomed.nodes.map((node: any) => node.id));
226
+ const contextNodes = context.nodes.filter((node: any) => node.id !== rootSystemNode.id && !systemNodeIds.has(node.id));
227
+
228
+ // Lay the context and the expanded system out together. Redirects happen
229
+ // before this, so level 2 (messages hidden) bridges onto the system boxes.
53
230
  const detailed = {
54
231
  // Parent must come before its children in the array for React Flow
55
- nodes: [group, ...contextNodes, ...system.nodes.filter((node: any) => node.id !== group.id)],
56
- edges: [...system.edges, ...contextEdges],
232
+ nodes: [group, ...contextNodes, ...rehomed.nodes.filter((node: any) => node.id !== group.id)],
233
+ edges: [...rehomed.edges, ...contextEdges],
234
+ };
235
+ const laidOut = await layoutLevels(detailed, context);
236
+ // Hiding messages can bridge two systems that a relationship already joins
237
+ return {
238
+ ...laidOut,
239
+ edges: dedupeEdges(laidOut.edges, relationshipPairs),
240
+ hiddenMessages: {
241
+ ...laidOut.hiddenMessages,
242
+ edges: dedupeEdges(laidOut.hiddenMessages.edges, relationshipPairs),
243
+ },
57
244
  };
58
- return layoutLevels(detailed, context);
59
245
  };
@@ -15,6 +15,11 @@ interface NodesAndEdgesProps {
15
15
  wrapInSystemGroup?: boolean;
16
16
  channelRenderMode?: 'single' | 'flat';
17
17
  layout?: boolean;
18
+ // Only parent this system's own services. Services pulled in because they
19
+ // send or receive the same messages stay in the graph, but outside the group.
20
+ // The domain diagram leaves this off: it uses the parent to tell which
21
+ // system's graph a service came from.
22
+ confineToMembers?: boolean;
18
23
  }
19
24
 
20
25
  export const getNodesAndEdges = async ({
@@ -25,6 +30,7 @@ export const getNodesAndEdges = async ({
25
30
  wrapInSystemGroup = false,
26
31
  channelRenderMode = 'flat',
27
32
  layout = true,
33
+ confineToMembers = false,
28
34
  }: NodesAndEdgesProps) => {
29
35
  let nodes = new Map(),
30
36
  edges = new Map();
@@ -118,21 +124,52 @@ export const getNodesAndEdges = async ({
118
124
  });
119
125
  }
120
126
 
121
- // Wrap everything inside a single parent "system group" boundary node, so all
122
- // the services / data stores / messages visibly belong to one system
127
+ // Wrap the graph in one parent "system group" boundary node. Messages,
128
+ // channels and data stores stay in it. With confineToMembers, only this
129
+ // system's own services are children; other services are left outside.
123
130
  const graph = {
124
- nodes: wrapInSystemGroup && nodes.size > 0 ? wrapNodesInSystemGroup([...nodes.values()], system) : [...nodes.values()],
131
+ nodes:
132
+ wrapInSystemGroup && nodes.size > 0
133
+ ? wrapNodesInSystemGroup(
134
+ [...nodes.values()],
135
+ system,
136
+ confineToMembers ? memberServiceNodeIds(system, serviceMap) : undefined
137
+ )
138
+ : [...nodes.values()],
125
139
  edges: [...edges.values()],
126
140
  };
127
141
 
128
142
  return layout ? layoutNodeGraph(graph) : graph;
129
143
  };
130
144
 
145
+ /** Service and agent nodes. Anything else in the graph (messages, channels, data stores) stays with the system. */
146
+ export const SERVICE_NODE_TYPES = new Set(['services', 'agents']);
147
+
148
+ /** Node ids of the services a system lists, with `latest` and semver ranges resolved to a real version. */
149
+ export const memberServiceNodeIds = (
150
+ system: { data: { services?: { id: string; version?: string }[] } },
151
+ serviceMap: Map<string, { data: { id: string; version?: string } }[]>
152
+ ) => {
153
+ const ids = new Set<string>();
154
+ for (const ref of system.data.services || []) {
155
+ const service = findInMap(serviceMap, ref.id, ref.version);
156
+ if (service) ids.add(`${service.data.id}-${service.data.version}`);
157
+ }
158
+ return ids;
159
+ };
160
+
161
+ const withoutGroupParent = (node: any) => {
162
+ const { parentId, extent, ...rest } = node;
163
+ return rest;
164
+ };
165
+
131
166
  /**
132
167
  * Wraps a system's nodes in one parent `system-group` node (sized to fit them
133
168
  * when the graph is laid out), so they visibly belong to the system.
169
+ * When `memberIds` is set, service and agent nodes that aren't members are
170
+ * left outside the group.
134
171
  */
135
- const wrapNodesInSystemGroup = (systemNodes: any[], system: any) => {
172
+ const wrapNodesInSystemGroup = (systemNodes: any[], system: any, memberIds?: Set<string>) => {
136
173
  const groupId = `system-group-${system.data.id}-${system.data.version}`;
137
174
 
138
175
  const groupNode = {
@@ -149,5 +186,11 @@ const wrapNodesInSystemGroup = (systemNodes: any[], system: any) => {
149
186
  };
150
187
 
151
188
  // Parent must come before its children in the array for React Flow
152
- return [groupNode, ...systemNodes.map((node) => ({ ...node, parentId: groupId, extent: 'parent' }))];
189
+ return [
190
+ groupNode,
191
+ ...systemNodes.map((node) => {
192
+ if (memberIds && SERVICE_NODE_TYPES.has(node.type) && !memberIds.has(node.id)) return withoutGroupParent(node);
193
+ return { ...node, parentId: groupId, extent: 'parent' };
194
+ }),
195
+ ];
153
196
  };
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.4.0",
10
+ "version": "5.4.2",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -141,11 +141,11 @@
141
141
  "y-protocols": "^1.0.7",
142
142
  "yjs": "^13.6.33",
143
143
  "zod": "^4.6.6",
144
+ "@eventcatalog/exporters": "0.1.2",
144
145
  "@eventcatalog/fitness": "0.1.0",
145
- "@eventcatalog/sdk": "2.31.2",
146
146
  "@eventcatalog/linter": "1.2.4",
147
- "@eventcatalog/exporters": "0.1.2",
148
- "@eventcatalog/visualiser": "^5.0.0"
147
+ "@eventcatalog/sdk": "2.31.2",
148
+ "@eventcatalog/visualiser": "^5.0.1"
149
149
  },
150
150
  "devDependencies": {
151
151
  "@astrojs/check": "^0.9.10",
@@ -1,9 +1,9 @@
1
- import {
2
- createGitHubSourceProvider
3
- } from "./chunk-352FGP6W.js";
4
1
  import {
5
2
  createFileSystemSourceProvider
6
3
  } from "./chunk-SDZQJTJW.js";
4
+ import {
5
+ createGitHubSourceProvider
6
+ } from "./chunk-352FGP6W.js";
7
7
 
8
8
  // src/federation/source-provider.ts
9
9
  var createFederationSourceProvider = (projectDirectory, providers = {}) => {