@eventcatalog/core 5.7.2 → 5.7.5

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 (39) 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-OR4VBY3F.js → chunk-3I4MO4G6.js} +1 -1
  6. package/dist/{chunk-B3FUBDJN.js → chunk-NNR5RP47.js} +1 -1
  7. package/dist/{chunk-EPI5BHEU.js → chunk-Q6ZCTZM3.js} +1 -1
  8. package/dist/{chunk-TYZENGGJ.js → chunk-SAB3WVWU.js} +1 -1
  9. package/dist/{chunk-NNTWQNQ5.js → chunk-SR6NZ4QL.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/01-what-is-eventcatalog-assistant.md +8 -0
  13. package/dist/docs/development/ask-your-architecture/02-eventcatalog-assistant/02-configuration.md +24 -0
  14. package/dist/docs/development/guides/domains/06-visualize-domains/01-domain-diagram.md +2 -1
  15. package/dist/docs/development/studio/01-introduction.md +2 -1
  16. package/dist/docs/development/studio/04-collaborate-on-a-canvas.md +10 -2
  17. package/dist/docs/development/studio/05-connect-your-agent.md +4 -0
  18. package/dist/docs/development/studio/07-reference.md +40 -7
  19. package/dist/docs/development/studio/draw-with-ai.md +96 -0
  20. package/dist/eventcatalog.cjs +1 -1
  21. package/dist/eventcatalog.js +5 -5
  22. package/dist/generate.cjs +1 -1
  23. package/dist/generate.js +3 -3
  24. package/dist/utils/cli-logger.cjs +1 -1
  25. package/dist/utils/cli-logger.js +2 -2
  26. package/eventcatalog/src/components/MDX/Steps/Step.astro +4 -2
  27. package/eventcatalog/src/components/SchemaExplorer/DiffViewer.tsx +2 -103
  28. package/eventcatalog/src/components/SchemaExplorer/diff-dark-styles.ts +104 -0
  29. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +68 -68
  30. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_ChangelogPage.astro +297 -0
  31. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.spec.ts +101 -0
  32. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +76 -47
  33. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +4 -168
  34. package/eventcatalog/src/pages/docs/services/[id]/[version]/changelog/index.astro +14 -0
  35. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +3 -3
  36. package/eventcatalog/src/utils/node-graphs/channel-node-graph.ts +2 -2
  37. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +248 -71
  38. package/eventcatalog/src/utils/node-graphs/message-node-graph.ts +0 -87
  39. package/package.json +3 -3
@@ -4,6 +4,7 @@ import { getNodesAndEdges as getServiceNodesAndEdges } from './services-node-gra
4
4
  import { getNodesAndEdgesForDomainSystems as getDomainContextNodesAndEdges } from './system-context-node-graph';
5
5
  import { getServices } from '@utils/collections/services';
6
6
  import { getAgents } from '@utils/collections/agents';
7
+ import { versionMatches } from '@utils/collections/util';
7
8
  import { createTriggerMessageNode } from './message-node-graph';
8
9
  import {
9
10
  createEdge,
@@ -28,6 +29,8 @@ type Tree = { domain: any; groupId: string; parentId?: string }[];
28
29
  type Owners = Map<string, { domain: any; system?: any }>;
29
30
 
30
31
  const MESSAGE_TYPES = new Set(['events', 'commands', 'queries']);
32
+ // Commands and queries belong to what receives them (it handles them), as events do to what sends them
33
+ const RECEIVER_OWNED_TYPES = new Set(['commands', 'queries']);
31
34
  const SERVICE_TYPES = new Set(['services', 'agents']);
32
35
 
33
36
  const systemGroupId = (system: any) => `system-group-${system.data.id}-${system.data.version}`;
@@ -43,6 +46,45 @@ const isInside = (nodes: Map<string, any>, nodeId: string, groupId: string) => {
43
46
  return false;
44
47
  };
45
48
 
49
+ /**
50
+ * Whether a node (or the services another domain stands for, `receivedBy`) says
51
+ * it receives a message: its id, and its version (or range, or latest). Undefined
52
+ * when it doesn't say what it receives
53
+ */
54
+ const saysItReceives = (node: any, message: any, receivedBy?: any[]): boolean | undefined => {
55
+ const receives = (node?.data?.service ?? node?.data?.agent)?.receives ?? receivedBy;
56
+ if (!receives) return undefined;
57
+ const { id, version } = message?.data?.message ?? {};
58
+ return receives.some((pointer: any) => {
59
+ const received = pointer.data ?? pointer;
60
+ return received.id === id && versionMatches(version ?? '', received.version ?? 'latest');
61
+ });
62
+ };
63
+
64
+ /**
65
+ * What a message reaches, through any channels (not the channels themselves).
66
+ * Past a channel, only what doesn't say it receives something else: a channel
67
+ * can carry other messages, to consumers of their own
68
+ */
69
+ const receiversThroughChannels = (messageId: string, nodes: Map<string, any>, receiversOf: Map<string, string[]>) => {
70
+ const message = nodes.get(messageId);
71
+ const receivers: string[] = [];
72
+ const seen = new Set([messageId]);
73
+ for (let current = [messageId]; current.length > 0; ) {
74
+ const throughChannel = current[0] !== messageId;
75
+ const next = current.flatMap((nodeId) => receiversOf.get(nodeId) ?? []).filter((nodeId) => !seen.has(nodeId));
76
+ next.forEach((nodeId) => seen.add(nodeId));
77
+ receivers.push(
78
+ ...next.filter(
79
+ (nodeId) =>
80
+ nodes.get(nodeId)?.type !== 'channels' && (!throughChannel || saysItReceives(nodes.get(nodeId), message) !== false)
81
+ )
82
+ );
83
+ current = next.filter((nodeId) => nodes.get(nodeId)?.type === 'channels');
84
+ }
85
+ return receivers;
86
+ };
87
+
46
88
  /** A system shown as a single node (not expanded into what's inside it) */
47
89
  const createSystemNode = (system: any, parentId?: string) => ({
48
90
  id: generateIdForNode(system),
@@ -97,14 +139,16 @@ const createDomainCard = (domain: any, { parentId, subdomain = false }: { parent
97
139
  * systems is shown as a card. Messages a (sub)domain sends or receives itself
98
140
  * (`domainMessages`: by message, the boxes drawn with it) fold into it, as a
99
141
  * system's do into the system, when only one does (more than one, they connect
100
- * through the message instead, rather than to each other).
142
+ * through the message instead, rather than to each other). `receivedBy` has
143
+ * what the services other domains stand for receive, by the node shown for them.
101
144
  */
102
145
  export const collapseSystems = (
103
146
  graph: Graph,
104
147
  systems: any[],
105
148
  contextNodes: Map<string, any>,
106
149
  subdomains: Map<string, any>,
107
- domainMessages: Map<string, Set<string>> = new Map()
150
+ domainMessages: Map<string, Set<string>> = new Map(),
151
+ receivedBy: Map<string, any[]> = new Map()
108
152
  ): Graph => {
109
153
  const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
110
154
  const systemsByGroupId = new Map<string, any>(systems.map((system: any) => [systemGroupId(system), system]));
@@ -123,11 +167,32 @@ export const collapseSystems = (
123
167
  cards.set(groupId, createDomainCard(subdomain, { parentId: nodesById.get(groupId)?.parentId, subdomain: true }));
124
168
  });
125
169
 
170
+ const sendersOf = new Map<string, string[]>();
171
+ graph.edges.forEach((edge) => sendersOf.set(edge.target, [...(sendersOf.get(edge.target) ?? []), edge.source]));
172
+ const carries = (nodeId: string) => nodesById.has(nodeId) && isCarrier(nodesById.get(nodeId));
173
+ const isMessage = (nodeId: string) => MESSAGE_TYPES.has(nodesById.get(nodeId)?.type);
174
+ const isChannel = (nodeId: string) => nodesById.get(nodeId)?.type === 'channels';
175
+
176
+ // A message is shown as what sends it, not where it's drawn, which can be with
177
+ // one of its consumers. Sent from more than one, it's left as it is (connecting
178
+ // each sender to what receives it, rather than one to the rest). None when nothing sends it
179
+ const senderHomes = new Map<string, string | undefined>();
180
+ const senderHome = (nodeId: string): string | undefined => {
181
+ if (!senderHomes.has(nodeId)) {
182
+ const senders = new Set((sendersOf.get(nodeId) ?? []).filter((sender) => !carries(sender)).map(representative));
183
+ const [sender] = [...senders];
184
+ senderHomes.set(nodeId, senders.size > 1 ? nodeId : sender);
185
+ }
186
+ return senderHomes.get(nodeId);
187
+ };
188
+
126
189
  // The node something is shown as: its system, the card of the subdomain it's
127
190
  // in, the (sub)domain it's in, or itself
128
191
  const representative = (nodeId: string): string => {
129
192
  const boxes = domainMessages.get(nodeId);
130
193
  if (boxes?.size === 1) return representative([...boxes][0]);
194
+ const home = isMessage(nodeId) ? senderHome(nodeId) : undefined;
195
+ if (home) return home;
131
196
  for (let node = nodesById.get(nodeId); node; node = node.parentId ? nodesById.get(node.parentId) : undefined) {
132
197
  const system: any = systemsByGroupId.get(node.id);
133
198
  if (system) return generateIdForNode(system);
@@ -149,6 +214,29 @@ export const collapseSystems = (
149
214
  );
150
215
  const shownNodes = new Map([...otherNodes, ...systemNodes, ...cards.values()].map((node) => [node.id, node]));
151
216
 
217
+ // The messages sent through a channel (and the channels before it)
218
+ const messagesThrough = (channelId: string, seen = new Set<string>()): string[] => {
219
+ if (seen.has(channelId)) return [];
220
+ seen.add(channelId);
221
+ return (sendersOf.get(channelId) ?? []).flatMap((sender) =>
222
+ isMessage(sender) ? [sender] : isChannel(sender) ? messagesThrough(sender, seen) : []
223
+ );
224
+ };
225
+ const receives = (nodeId: string, messageId: string) =>
226
+ saysItReceives(nodesById.get(nodeId), nodesById.get(messageId), receivedBy.get(nodeId)) ?? false;
227
+
228
+ // Where an edge is from and the messages it carries. A channel is one node for
229
+ // all its messages, so an edge out of one is from each message sent through it
230
+ // that its target receives (all of them when that isn't known), as those
231
+ // messages are shown. Edges into a channel are shown by the edges out of it
232
+ const edgeSources = (edge: any): { from: string; messages: string[] }[] => {
233
+ if (isChannel(edge.target) && carries(edge.source)) return [];
234
+ const through = isChannel(edge.source) ? messagesThrough(edge.source) : [];
235
+ if (through.length === 0) return [{ from: edge.source, messages: [edge.source, edge.target].filter(isMessage) }];
236
+ const sent = through.filter((messageId) => receives(edge.target, messageId));
237
+ return (sent.length > 0 ? sent : through).map((messageId) => ({ from: messageId, messages: [messageId] }));
238
+ };
239
+
152
240
  // Edges between systems / actors / domains: context relationships as they
153
241
  // are, edges to nodes kept as their first label, the rest labelled with the
154
242
  // messages they carry
@@ -156,28 +244,30 @@ export const collapseSystems = (
156
244
  string,
157
245
  { source: string; target: string; messages: Map<string, string>; relationship?: any; label?: string; crossDomain?: boolean }
158
246
  >();
159
- graph.edges.forEach((edge) => {
160
- const source = representative(edge.source);
161
- const target = representative(edge.target);
162
- if (
163
- source === target ||
164
- !shownNodes.has(source) ||
165
- !shownNodes.has(target) ||
166
- // One shown node inside another (e.g. a system in its domain)
167
- isInside(shownNodes, source, target) ||
168
- isInside(shownNodes, target, source)
169
- )
170
- return;
171
- const key = `${source}->${target}`;
172
- const connection = connections.get(key) ?? { source, target, messages: new Map<string, string>() };
173
- if (edge.id.startsWith(CONTEXT_EDGE_PREFIX)) connection.relationship = edge;
174
- if (edge.data?.crossDomain) connection.crossDomain = true;
175
- [edge.source, edge.target].forEach((nodeId) => {
176
- const node = nodesById.get(nodeId);
177
- if (MESSAGE_TYPES.has(node?.type)) connection.messages.set(node.data?.message?.name ?? nodeId, node.type);
178
- });
179
- connections.set(key, connection);
180
- });
247
+ graph.edges.forEach((edge) =>
248
+ edgeSources(edge).forEach(({ from, messages }) => {
249
+ const source = representative(from);
250
+ const target = representative(edge.target);
251
+ if (
252
+ source === target ||
253
+ !shownNodes.has(source) ||
254
+ !shownNodes.has(target) ||
255
+ // One shown node inside another (e.g. a system in its domain)
256
+ isInside(shownNodes, source, target) ||
257
+ isInside(shownNodes, target, source)
258
+ )
259
+ return;
260
+ const key = `${source}->${target}`;
261
+ const connection = connections.get(key) ?? { source, target, messages: new Map<string, string>() };
262
+ if (edge.id.startsWith(CONTEXT_EDGE_PREFIX)) connection.relationship = edge;
263
+ if (edge.data?.crossDomain) connection.crossDomain = true;
264
+ messages.forEach((messageId) => {
265
+ const node = nodesById.get(messageId);
266
+ connection.messages.set(node.data?.message?.name ?? messageId, node.type);
267
+ });
268
+ connections.set(key, connection);
269
+ })
270
+ );
181
271
 
182
272
  // A relationship already connects the pair (either way), so skip messages
183
273
  const related = new Set(
@@ -222,7 +312,7 @@ export const buildDomainTree = (domain: any, findDomain: (id: string, version?:
222
312
  return tree;
223
313
  };
224
314
 
225
- /** Which domain (and system) each service belongs to, and the domain each subdomain is in */
315
+ /** Which domain (and system) each service or agent belongs to, and the domain each subdomain is in */
226
316
  export const collectOwners = (domains: any[]) => {
227
317
  const owners: Owners = new Map();
228
318
  const parentDomainOf = new Map<string, any>();
@@ -234,9 +324,9 @@ export const collectOwners = (domains: any[]) => {
234
324
  if (!parentDomainOf.has(subdomain.data.id)) parentDomainOf.set(subdomain.data.id, d);
235
325
  });
236
326
  });
237
- // Services outside systems, unless a system already claims them
327
+ // Services outside systems, unless a system already claims them, and agents (systems don't have them)
238
328
  domains.forEach((d: any) =>
239
- (d.data.services || []).forEach((service: any) => {
329
+ [...(d.data.services || []), ...(d.data.agents || [])].forEach((service: any) => {
240
330
  const serviceId = generateIdForNode(service);
241
331
  if (!owners.has(serviceId)) owners.set(serviceId, { domain: d });
242
332
  })
@@ -298,8 +388,10 @@ export const buildDomainGraph = ({
298
388
  ])
299
389
  );
300
390
  const edges: any[] = [];
301
- // Nodes replaced by another (services in other domains become the domain)
391
+ // Nodes replaced by another (services in other domains become the domain),
392
+ // and what each of them receives
302
393
  const replacedBy = new Map<string, string>();
394
+ const replacedReceives = new Map<string, any[]>();
303
395
 
304
396
  // Other domains are shown as a single card, with counts of what's in them. A
305
397
  // subdomain of another domain is shown inside a box for that domain (and
@@ -364,6 +456,7 @@ export const buildDomainGraph = ({
364
456
  const owner = SERVICE_TYPES.has(node.type) ? owners.get(node.id) : undefined;
365
457
  if (owner && !groupOfDomain.has(owner.domain.data.id)) {
366
458
  replacedBy.set(node.id, addOtherDomain(owner.domain));
459
+ replacedReceives.set(node.id, (node.data?.service ?? node.data?.agent)?.receives ?? []);
367
460
  return;
368
461
  }
369
462
  if (owner && owner.system) {
@@ -384,7 +477,12 @@ export const buildDomainGraph = ({
384
477
  }
385
478
  // Messages, channels, data stores: kept in the first graph they appear in
386
479
  if (nodes.has(node.id)) return;
387
- nodes.set(node.id, node.parentId || !inside || SERVICE_TYPES.has(node.type) ? node : { ...node, parentId: inside });
480
+ // A service or agent in no domain isn't in the system whose graph it came from
481
+ if (SERVICE_TYPES.has(node.type)) {
482
+ nodes.set(node.id, { ...node, parentId: undefined, extent: undefined });
483
+ return;
484
+ }
485
+ nodes.set(node.id, node.parentId || !inside ? node : { ...node, parentId: inside });
388
486
  });
389
487
 
390
488
  systems.forEach(({ system, groupId: domainGroup }, index) => {
@@ -438,13 +536,19 @@ export const buildDomainGraph = ({
438
536
  if (boxId) replacedBy.set(replaced, boxId);
439
537
  });
440
538
 
441
- const graphEdges = connectEdges(edges, new Map([...cardBoxes, ...replacedBy]), nodes);
442
- placeMessagesWithPublishers(nodes, graphEdges, new Set(domainMessages.keys()));
539
+ const graphEdges = dropReceivesThroughChannels(connectEdges(edges, new Map([...cardBoxes, ...replacedBy]), nodes), nodes);
540
+ placeMessagesWithOwners(nodes, graphEdges, new Set(domainMessages.keys()));
541
+ // What the services each other domain stands for receive, by the node shown for the domain
542
+ const receivedBy = new Map<string, any[]>();
543
+ replacedBy.forEach((nodeId, replaced) =>
544
+ receivedBy.set(nodeId, [...(receivedBy.get(nodeId) ?? []), ...(replacedReceives.get(replaced) ?? [])])
545
+ );
443
546
  return {
444
547
  nodes,
445
548
  edges: graphEdges,
446
549
  otherDomainIds: new Set([...replacedBy.values(), ...[...otherDomainIds].map((id) => cardBoxes.get(id) ?? id)]),
447
550
  domainMessages,
551
+ receivedBy,
448
552
  };
449
553
  };
450
554
 
@@ -480,14 +584,6 @@ const addDomainMessages = ({
480
584
  const treeDomainIds = new Set(tree.map((entry) => entry.domain.data.id));
481
585
  const otherDomainIds = new Set<string>();
482
586
  const domainMessages = new Map<string, Set<string>>();
483
- // The domain each agent is in (owners only has services): the tree's versions first
484
- const agentOwners = new Map<string, any>();
485
- [...tree.map((entry) => entry.domain), ...domains].forEach((domain) =>
486
- (domain.data.agents || []).forEach((agent: any) => {
487
- const agentId = generateIdForNode(agent);
488
- if (!agentOwners.has(agentId)) agentOwners.set(agentId, domain);
489
- })
490
- );
491
587
  // Whether a domain or service sends (or receives) the message (their messages are already looked up)
492
588
  const lists = (resource: any, key: 'sends' | 'receives', messageId: string) =>
493
589
  (resource.data[key] || []).some((message: any) => message.data && generateIdForNode(message) === messageId);
@@ -513,19 +609,19 @@ const addDomainMessages = ({
513
609
  return false;
514
610
  };
515
611
  // Whether a service in a (sub)domain's box says it sends (or receives) the message: e.g. one it sends in a
516
- // message group, which its graph shows as the group rather than the message. Services only: agents in a
517
- // domain aren't drawn on its diagram
612
+ // message group, which its graph shows as the group rather than the message. Services only: a domain's
613
+ // agents are only drawn when another graph brings them in
518
614
  const groupOfTreeDomain = new Map(tree.map((entry) => [entry.domain.data.id, entry.groupId]));
519
615
  const declaredIn = (groupId: string, key: 'sends' | 'receives', messageId: string) =>
520
616
  catalogServices.some((service) => {
521
- if (!lists(service, key, messageId)) return false;
617
+ if (service.collection === 'agents' || !lists(service, key, messageId)) return false;
522
618
  const ownerGroup = groupOfTreeDomain.get(owners.get(generateIdForNode(service))?.domain.data.id);
523
619
  return !!ownerGroup && (ownerGroup === groupId || isInside(nodes, ownerGroup, groupId));
524
620
  });
525
621
  // The node standing for a service (or agent) on the other side, or none when it's in the domain
526
622
  const otherSide = (service: any) => {
527
623
  const serviceId = generateIdForNode(service);
528
- const ownerDomain = owners.get(serviceId)?.domain ?? agentOwners.get(serviceId);
624
+ const ownerDomain = owners.get(serviceId)?.domain;
529
625
  if (ownerDomain) return treeDomainIds.has(ownerDomain.data.id) ? undefined : addOther(ownerDomain);
530
626
  if (!nodes.has(serviceId)) {
531
627
  nodes.set(serviceId, {
@@ -609,27 +705,77 @@ const addDomainMessages = ({
609
705
  /**
610
706
  * Messages are placed in the first graph they appear in, which can be a
611
707
  * system consuming them. Moves each message into the system (or domain) of the
612
- * services publishing it, when they're all in the same one. Messages a domain
708
+ * services it belongs to, when they're all in the same one: an event's
709
+ * publishers, a command's or query's receivers (through any channels). Each
710
+ * channel goes with the messages sent through it. Messages a domain
613
711
  * sends or receives itself (`kept`) stay outside its box, where its edge to them
614
712
  * is drawn.
615
713
  */
616
- export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[], kept: Set<string> = new Set()) => {
617
- const publisherGroups = new Map<string, Set<string | undefined>>();
714
+ export const placeMessagesWithOwners = (nodes: Map<string, any>, edges: any[], kept: Set<string> = new Set()) => {
715
+ const ownerGroups = new Map<string, Set<string | undefined>>();
716
+ const addOwner = (messageId: string, owner: any) =>
717
+ ownerGroups.set(messageId, (ownerGroups.get(messageId) ?? new Set()).add(owner.parentId));
718
+ const receiversOf = new Map<string, string[]>();
719
+ edges.forEach((edge) => receiversOf.set(edge.source, [...(receiversOf.get(edge.source) ?? []), edge.target]));
618
720
  edges.forEach((edge) => {
619
721
  const publisher = nodes.get(edge.source);
620
- if (!MESSAGE_TYPES.has(nodes.get(edge.target)?.type) || !SERVICE_TYPES.has(publisher?.type)) return;
621
- const groups = publisherGroups.get(edge.target) || new Set();
622
- groups.add(publisher.parentId);
623
- publisherGroups.set(edge.target, groups);
722
+ const type = nodes.get(edge.target)?.type;
723
+ if (!MESSAGE_TYPES.has(type) || RECEIVER_OWNED_TYPES.has(type) || !SERVICE_TYPES.has(publisher?.type)) return;
724
+ addOwner(edge.target, publisher);
725
+ });
726
+ nodes.forEach((message, messageId) => {
727
+ if (!RECEIVER_OWNED_TYPES.has(message.type)) return;
728
+ receiversThroughChannels(messageId, nodes, receiversOf)
729
+ .filter((nodeId) => SERVICE_TYPES.has(nodes.get(nodeId)?.type))
730
+ .forEach((nodeId) => addOwner(messageId, nodes.get(nodeId)));
624
731
  });
625
- publisherGroups.forEach((groups, messageId) => {
732
+ ownerGroups.forEach((groups, messageId) => {
626
733
  if (kept.has(messageId)) return;
627
734
  const [parentId] = [...groups];
628
735
  const message = nodes.get(messageId);
629
- if (groups.size === 1 && parentId && message.parentId !== parentId) {
736
+ // Owners are drawn nodes, so no parent means outside every box (e.g. a service or agent in no domain)
737
+ if (groups.size === 1 && message.parentId !== parentId) {
630
738
  nodes.set(messageId, { ...message, parentId, extent: undefined });
631
739
  }
632
740
  });
741
+
742
+ // Channels go with what sends through them (messages, or channels before them), when that's all in one place
743
+ const sendersOf = new Map<string, string[]>();
744
+ edges.forEach((edge) => sendersOf.set(edge.target, [...(sendersOf.get(edge.target) ?? []), edge.source]));
745
+ for (let moved = true; moved; ) {
746
+ moved = false;
747
+ nodes.forEach((channel, channelId) => {
748
+ if (channel.type !== 'channels' || kept.has(channelId)) return;
749
+ const senders = (sendersOf.get(channelId) ?? []).filter((sender) => nodes.has(sender));
750
+ const groups = new Set(senders.map((sender) => nodes.get(sender).parentId));
751
+ const [parentId] = [...groups];
752
+ if (groups.size === 1 && channel.parentId !== parentId) {
753
+ nodes.set(channelId, { ...channel, parentId, extent: undefined });
754
+ moved = true;
755
+ }
756
+ });
757
+ }
758
+ };
759
+
760
+ /**
761
+ * Drops the edges drawn for domains receiving a message themselves (`domain-receives-`)
762
+ * when the message already reaches them through its channels, e.g. another
763
+ * domain listing a command in its `receives` whose service receives it from a
764
+ * queue: the domain is drawn receiving it from the queue, not from the command too.
765
+ */
766
+ const dropReceivesThroughChannels = (edges: any[], nodes: Map<string, any>) => {
767
+ const isChannel = (nodeId: string) => nodes.get(nodeId)?.type === 'channels';
768
+ const reachesThroughChannels = (messageId: string, targetId: string) => {
769
+ const seen = new Set<string>();
770
+ for (let current = [messageId]; current.length > 0; ) {
771
+ const next = edges.filter((edge) => current.includes(edge.source) && isChannel(edge.target) && !seen.has(edge.target));
772
+ if (next.some((edge) => edges.some((out) => out.source === edge.target && out.target === targetId))) return true;
773
+ next.forEach((edge) => seen.add(edge.target));
774
+ current = next.map((edge) => edge.target);
775
+ }
776
+ return false;
777
+ };
778
+ return edges.filter((edge) => !edge.id.startsWith('domain-receives-') || !reachesThroughChannels(edge.source, edge.target));
633
779
  };
634
780
 
635
781
  /**
@@ -683,15 +829,19 @@ export const addContext = (contexts: Graph[], systems: { system: any }[], nodes:
683
829
  };
684
830
 
685
831
  /**
686
- * Marks cross-domain communication. Messages and channels only other domains
687
- * send to (e.g. a message this domain consumes, and the topic it comes
832
+ * Marks cross-domain communication. Events and channels only other domains
833
+ * send to (e.g. an event this domain consumes, and the topic it comes
688
834
  * through) are another domain's: they sit outside this domain, between the
689
835
  * domain sending them and their consumers, rather than in the system that
690
- * consumes them. They, and this domain's messages and channels that reach
836
+ * consumes them. Commands and queries belong to what receives them, so those
837
+ * (and the channels only they're sent through) only other domains receive are
838
+ * another domain's, and those this domain receives are its own, whoever sends
839
+ * them. Messages and channels other domains send, and this domain's that reach
691
840
  * other domains, are on a cross-domain path (`data.crossDomain`), as are the
692
841
  * edges to and from them and other domains.
693
842
  */
694
843
  export const markCrossDomain = (graph: Graph, otherDomainIds: Set<string>): Graph => {
844
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
695
845
  const sendersOf = new Map<string, string[]>();
696
846
  const receiversOf = new Map<string, string[]>();
697
847
  graph.edges.forEach((edge) => {
@@ -718,16 +868,45 @@ export const markCrossDomain = (graph: Graph, otherDomainIds: Set<string>): Grap
718
868
  return reached;
719
869
  };
720
870
  // Sent only by other domains (or their messages and channels)
721
- const external = spread(new Set(otherDomainIds), (nodeId, reached) => {
871
+ const sentByOthers = spread(new Set(otherDomainIds), (nodeId, reached) => {
722
872
  const senders = sendersOf.get(nodeId) || [];
723
873
  return senders.length > 0 && senders.every((sender) => reached.has(sender));
724
874
  });
875
+ // Received only by other domains (or their messages and channels)
876
+ const receivedByOthers = spread(new Set(otherDomainIds), (nodeId, reached) => {
877
+ const receivers = receiversOf.get(nodeId) || [];
878
+ return receivers.length > 0 && receivers.every((receiver) => reached.has(receiver));
879
+ });
725
880
  // Reaching other domains (directly, or through messages and channels that do)
726
- const outgoing = spread(new Set(external), (nodeId, reached) =>
881
+ const outgoing = spread(new Set(sentByOthers), (nodeId, reached) =>
727
882
  (receiversOf.get(nodeId) || []).some((receiver) => reached.has(receiver))
728
883
  );
729
884
 
730
- const isExternalCarrier = (node: any) => external.has(node.id) && !otherDomainIds.has(node.id);
885
+ // A command or query, or a channel only they're sent through (by channel, worked out once: routes can split and
886
+ // join again)
887
+ const receiverOwnedChannels = new Map<string, boolean>();
888
+ const isReceiverOwned = (nodeId: string): boolean => {
889
+ const node = nodesById.get(nodeId);
890
+ if (MESSAGE_TYPES.has(node?.type)) return RECEIVER_OWNED_TYPES.has(node.type);
891
+ if (node?.type !== 'channels') return false;
892
+ if (!receiverOwnedChannels.has(nodeId)) {
893
+ // Not the receiver's while it's being worked out, so a loop of channels ends
894
+ receiverOwnedChannels.set(nodeId, false);
895
+ const carried = (sendersOf.get(nodeId) || []).filter((sender) => nodesById.has(sender) && isCarrier(nodesById.get(sender)));
896
+ receiverOwnedChannels.set(nodeId, carried.length > 0 && carried.every(isReceiverOwned));
897
+ }
898
+ return receiverOwnedChannels.get(nodeId)!;
899
+ };
900
+ // A command or query only other domains receive (not counting consumers of other messages on its channels)
901
+ const receivedOnlyByOthers = (messageId: string) => {
902
+ const receivers = receiversThroughChannels(messageId, nodesById, receiversOf);
903
+ return receivers.length > 0 && receivers.every((receiver) => otherDomainIds.has(receiver));
904
+ };
905
+ const isExternalCarrier = (node: any) => {
906
+ if (!isCarrier(node) || otherDomainIds.has(node.id)) return false;
907
+ if (RECEIVER_OWNED_TYPES.has(node.type)) return receivedOnlyByOthers(node.id);
908
+ return (isReceiverOwned(node.id) ? receivedByOthers : sentByOthers).has(node.id);
909
+ };
731
910
  const onCrossDomainPath = (nodeId: string) => outgoing.has(nodeId);
732
911
  return {
733
912
  nodes: graph.nodes.map((node) => {
@@ -775,11 +954,8 @@ export const getNodesAndEdges = (props: NodesAndEdgesProps): Promise<DomainLevel
775
954
  * or its latest when no domain has it: who sends or receives a domain's own
776
955
  * messages is matched against these.
777
956
  */
778
- const inVersionsUsed = (resources: any[], domains: any[], owners: Owners) => {
779
- const used = new Set([
780
- ...owners.keys(),
781
- ...domains.flatMap((domain) => (domain.data.agents || []).map((agent: any) => generateIdForNode(agent))),
782
- ]);
957
+ const inVersionsUsed = (resources: any[], owners: Owners) => {
958
+ const used = new Set(owners.keys());
783
959
  const key = (resource: any) => `${resource.collection}:${resource.data.id}`;
784
960
  const usedKeys = new Set(resources.filter((resource) => used.has(generateIdForNode(resource))).map(key));
785
961
  return resources.filter((resource) =>
@@ -809,11 +985,11 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
809
985
  const tree = buildDomainTree(domain, findDomain);
810
986
  // The tree's domains last, so the versions shown own their services
811
987
  const { owners, parentDomainOf } = collectOwners([...domains, ...tree.map((entry) => entry.domain)]);
812
- // A service the domain (or a subdomain) lists itself is shown as its own,
813
- // even when a system in another domain has it too
988
+ // A service (or agent) the domain or a subdomain lists itself is shown as its
989
+ // own, even when a system in another domain has it too
814
990
  const treeDomainIds = new Set(tree.map((entry) => entry.domain.data.id));
815
991
  tree.forEach(({ domain: d }) =>
816
- (d.data.services || []).forEach((service: any) => {
992
+ [...(d.data.services || []), ...(d.data.agents || [])].forEach((service: any) => {
817
993
  const serviceId = generateIdForNode(service);
818
994
  if (!treeDomainIds.has(owners.get(serviceId)?.domain.data.id)) owners.set(serviceId, { domain: d });
819
995
  })
@@ -860,7 +1036,7 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
860
1036
  ),
861
1037
  ]);
862
1038
 
863
- const { nodes, edges, otherDomainIds, domainMessages } = buildDomainGraph({
1039
+ const { nodes, edges, otherDomainIds, domainMessages, receivedBy } = buildDomainGraph({
864
1040
  tree,
865
1041
  owners,
866
1042
  parentDomainOf,
@@ -870,7 +1046,7 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
870
1046
  serviceGraphs,
871
1047
  mode,
872
1048
  domains,
873
- catalogServices: inVersionsUsed([...services, ...agents], [...domains, ...tree.map((entry) => entry.domain)], owners),
1049
+ catalogServices: inVersionsUsed([...services, ...agents], owners),
874
1050
  });
875
1051
  const context = addContext(contexts, systems, nodes);
876
1052
  const graph = { nodes: [...nodes.values(), ...context.nodes], edges: [...edges, ...context.edges] };
@@ -881,14 +1057,15 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
881
1057
  // by services not in one) are hidden too, with the nodes either side connected
882
1058
  // directly. A domain without systems or subdomains has no level 1.
883
1059
  if (systems.length === 0 && tree.length === 1) return layoutLevels(detailedGraph);
884
- // Built from the graph before messages only other domains send were moved
885
- // out of the systems consuming them, so they collapse into those systems
1060
+ // Built from the graph before other domains' messages were moved out of the
1061
+ // systems they were drawn in, so they collapse into those systems
886
1062
  const collapsed = collapseSystems(
887
1063
  { ...graph, edges: detailedGraph.edges },
888
1064
  systems.map(({ system }) => system),
889
1065
  context.contextNodesById,
890
1066
  new Map(tree.filter((entry) => entry.parentId).map((entry) => [entry.groupId, entry.domain])),
891
- domainMessages
1067
+ domainMessages,
1068
+ receivedBy
892
1069
  );
893
1070
  const overview = hideMessageNodes(collapsed.nodes, collapsed.edges);
894
1071