@eventcatalog/core 5.4.1 → 5.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.
Files changed (48) 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-GQQFHJSY.js → chunk-3TREOJOK.js} +1 -1
  6. package/dist/{chunk-WG276AIE.js → chunk-754U4T7K.js} +1 -1
  7. package/dist/{chunk-GAKU752C.js → chunk-RDF2OMEF.js} +1 -1
  8. package/dist/{chunk-VZNPE26F.js → chunk-UBDTKNFS.js} +1 -1
  9. package/dist/{chunk-NSVRGATQ.js → chunk-WPFA7AGD.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +2 -0
  19. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +8 -1
  20. package/eventcatalog/src/features/ai/chat-api.ts +6 -3
  21. package/eventcatalog/src/features/mcp/apps/architecture-diagram/shared.ts +3 -1
  22. package/eventcatalog/src/features/mcp/apps/architecture-diagram/view.tsx +6 -1
  23. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +78 -66
  24. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +85 -71
  25. package/eventcatalog/src/features/mcp/mcp-scoped-tools.ts +2 -1
  26. package/eventcatalog/src/features/mcp/mcp-server.ts +9 -8
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -1
  28. package/eventcatalog/src/features/studio/canvas-actions.ts +8 -2
  29. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +2 -0
  30. package/eventcatalog/src/features/tools/catalog-tools.ts +8 -1
  31. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +1 -0
  32. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -0
  33. package/eventcatalog/src/pages/visualiser/_mermaid-route.ts +48 -5
  34. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +1 -0
  35. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/[level].mermaid.ts +18 -0
  36. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +1 -0
  37. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/index.astro +1 -0
  38. package/eventcatalog/src/pages/visualiser/system-context-map/index.astro +1 -0
  39. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/[level].mermaid.ts +18 -0
  40. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/index.astro +1 -0
  41. package/eventcatalog/src/utils/node-graphs/architecture-diagram-types.ts +7 -1
  42. package/eventcatalog/src/utils/node-graphs/architecture-diagram.ts +5 -2
  43. package/eventcatalog/src/utils/node-graphs/compact-visualiser-graph.ts +2 -0
  44. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +246 -29
  45. package/eventcatalog/src/utils/node-graphs/system-levels-node-graph.ts +195 -9
  46. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +48 -5
  47. package/package.json +4 -4
  48. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -375
@@ -2,7 +2,15 @@ import { getDomains } from '@utils/collections/domains';
2
2
  import { getNodesAndEdges as getSystemNodesAndEdges } from './systems-node-graph';
3
3
  import { getNodesAndEdges as getServiceNodesAndEdges } from './services-node-graph';
4
4
  import { getNodesAndEdgesForDomainSystems as getDomainContextNodesAndEdges } from './system-context-node-graph';
5
- import { createEdge, generateIdForNode } from '@utils/node-graphs/utils/utils';
5
+ import { getServices } from '@utils/collections/services';
6
+ import { getAgents } from '@utils/collections/agents';
7
+ import { createTriggerMessageNode } from './message-node-graph';
8
+ import {
9
+ createEdge,
10
+ generateIdForNode,
11
+ getEdgeLabelForMessageAsSource,
12
+ getEdgeLabelForServiceAsTarget,
13
+ } from '@utils/node-graphs/utils/utils';
6
14
  import { layoutLevels } from '@utils/node-graphs/layout-node-graph';
7
15
  import { getMessagesLabel, hideMessageNodes, isCarrier } from '@eventcatalog/visualiser/layout';
8
16
 
@@ -27,6 +35,14 @@ const CONTEXT_EDGE_PREFIX = 'context-';
27
35
  const domainGroupId = (domain: any) => `domain-group-${domain.data.id}-${domain.data.version}`;
28
36
  const domainNodeId = (domain: any) => `domain-${domain.data.id}-${domain.data.version}`;
29
37
 
38
+ /** Whether a node is inside a group (a domain or system), at any depth */
39
+ const isInside = (nodes: Map<string, any>, nodeId: string, groupId: string) => {
40
+ for (let node = nodes.get(nodeId); node?.parentId; node = nodes.get(node.parentId)) {
41
+ if (node.parentId === groupId) return true;
42
+ }
43
+ return false;
44
+ };
45
+
30
46
  /** A system shown as a single node (not expanded into what's inside it) */
31
47
  const createSystemNode = (system: any, parentId?: string) => ({
32
48
  id: generateIdForNode(system),
@@ -78,13 +94,17 @@ const createDomainCard = (domain: any, { parentId, subdomain = false }: { parent
78
94
  * receive them from. Everything inside a system collapses into the system, and
79
95
  * anything else in a (sub)domain (e.g. a service not in a system) into the
80
96
  * domain, so edges connect domains, systems and actors. A subdomain without
81
- * systems is shown as a card.
97
+ * systems is shown as a card. Messages a (sub)domain sends or receives itself
98
+ * (`domainMessages`: by message, the boxes drawn with it) fold into it, as a
99
+ * 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).
82
101
  */
83
102
  export const collapseSystems = (
84
103
  graph: Graph,
85
104
  systems: any[],
86
105
  contextNodes: Map<string, any>,
87
- subdomains: Map<string, any>
106
+ subdomains: Map<string, any>,
107
+ domainMessages: Map<string, Set<string>> = new Map()
88
108
  ): Graph => {
89
109
  const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
90
110
  const systemsByGroupId = new Map<string, any>(systems.map((system: any) => [systemGroupId(system), system]));
@@ -106,6 +126,8 @@ export const collapseSystems = (
106
126
  // The node something is shown as: its system, the card of the subdomain it's
107
127
  // in, the (sub)domain it's in, or itself
108
128
  const representative = (nodeId: string): string => {
129
+ const boxes = domainMessages.get(nodeId);
130
+ if (boxes?.size === 1) return representative([...boxes][0]);
109
131
  for (let node = nodesById.get(nodeId); node; node = node.parentId ? nodesById.get(node.parentId) : undefined) {
110
132
  const system: any = systemsByGroupId.get(node.id);
111
133
  if (system) return generateIdForNode(system);
@@ -126,13 +148,6 @@ export const collapseSystems = (
126
148
  (node) => representative(node.id) === node.id && !systemsByGroupId.has(node.id) && node.type !== 'data'
127
149
  );
128
150
  const shownNodes = new Map([...otherNodes, ...systemNodes, ...cards.values()].map((node) => [node.id, node]));
129
- // Whether one shown node is inside another (e.g. a system in its domain)
130
- const isInside = (nodeId: string, groupId: string) => {
131
- for (let node = shownNodes.get(nodeId); node?.parentId; node = shownNodes.get(node.parentId)) {
132
- if (node.parentId === groupId) return true;
133
- }
134
- return false;
135
- };
136
151
 
137
152
  // Edges between systems / actors / domains: context relationships as they
138
153
  // are, edges to nodes kept as their first label, the rest labelled with the
@@ -148,8 +163,9 @@ export const collapseSystems = (
148
163
  source === target ||
149
164
  !shownNodes.has(source) ||
150
165
  !shownNodes.has(target) ||
151
- isInside(source, target) ||
152
- isInside(target, source)
166
+ // One shown node inside another (e.g. a system in its domain)
167
+ isInside(shownNodes, source, target) ||
168
+ isInside(shownNodes, target, source)
153
169
  )
154
170
  return;
155
171
  const key = `${source}->${target}`;
@@ -244,6 +260,8 @@ export const buildDomainGraph = ({
244
260
  looseServices,
245
261
  serviceGraphs,
246
262
  mode,
263
+ domains,
264
+ catalogServices,
247
265
  }: {
248
266
  tree: Tree;
249
267
  owners: Owners;
@@ -252,7 +270,11 @@ export const buildDomainGraph = ({
252
270
  systemGraphs: Graph[];
253
271
  looseServices: { service: any; groupId: string }[];
254
272
  serviceGraphs: Graph[];
255
- mode: string;
273
+ mode: 'simple' | 'full';
274
+ /** Every domain, for the ones sending or receiving the tree's domain-level messages themselves */
275
+ domains: any[];
276
+ /** Every service and agent, for the producers and consumers of the tree's domain-level messages */
277
+ catalogServices: any[];
256
278
  }) => {
257
279
  const groupOfDomain = new Map(tree.map((entry) => [entry.domain.data.id, entry.groupId]));
258
280
  const groupOfSystem = new Map(systems.map(({ system, groupId }) => [systemGroupId(system), groupId]));
@@ -399,23 +421,199 @@ export const buildDomainGraph = ({
399
421
  });
400
422
  });
401
423
 
424
+ const { otherDomainIds, domainMessages } = addDomainMessages({
425
+ tree,
426
+ owners,
427
+ domains,
428
+ catalogServices,
429
+ nodes,
430
+ edges,
431
+ addOtherDomain,
432
+ mode,
433
+ });
434
+
402
435
  // Other domains shown as a box (around their subdomains) rather than a card
403
436
  replacedBy.forEach((nodeId, replaced) => {
404
437
  const boxId = cardBoxes.get(nodeId);
405
438
  if (boxId) replacedBy.set(replaced, boxId);
406
439
  });
407
440
 
408
- const graphEdges = connectEdges(edges, replacedBy, nodes);
409
- placeMessagesWithPublishers(nodes, graphEdges);
410
- return { nodes, edges: graphEdges, otherDomainIds: new Set(replacedBy.values()) };
441
+ const graphEdges = connectEdges(edges, new Map([...cardBoxes, ...replacedBy]), nodes);
442
+ placeMessagesWithPublishers(nodes, graphEdges, new Set(domainMessages.keys()));
443
+ return {
444
+ nodes,
445
+ edges: graphEdges,
446
+ otherDomainIds: new Set([...replacedBy.values(), ...[...otherDomainIds].map((id) => cardBoxes.get(id) ?? id)]),
447
+ domainMessages,
448
+ };
449
+ };
450
+
451
+ /**
452
+ * Messages domains send or receive themselves (`sends` and `receives` in their
453
+ * frontmatter). The domain's (and its subdomains') are connected to its box,
454
+ * outside it, and joined to the other side outside it: services in other
455
+ * domains (shown as those domains) and services in no domain. A message one of
456
+ * the domain's services already sends (or receives) is left as it is. Other
457
+ * domains sending a message the domain receives themselves (or receiving one it
458
+ * sends) are connected to it, as those domains. Returns the other domains added
459
+ * and, by message, the (sub)domain boxes it's drawn sent from or received by.
460
+ */
461
+ const addDomainMessages = ({
462
+ tree,
463
+ owners,
464
+ domains,
465
+ catalogServices,
466
+ nodes,
467
+ edges,
468
+ addOtherDomain,
469
+ mode,
470
+ }: {
471
+ tree: Tree;
472
+ owners: Owners;
473
+ domains: any[];
474
+ catalogServices: any[];
475
+ nodes: Map<string, any>;
476
+ edges: any[];
477
+ addOtherDomain: (domain: any) => string;
478
+ mode: 'simple' | 'full';
479
+ }) => {
480
+ const treeDomainIds = new Set(tree.map((entry) => entry.domain.data.id));
481
+ const otherDomainIds = new Set<string>();
482
+ 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
+ // Whether a domain or service sends (or receives) the message (their messages are already looked up)
492
+ const lists = (resource: any, key: 'sends' | 'receives', messageId: string) =>
493
+ (resource.data[key] || []).some((message: any) => message.data && generateIdForNode(message) === messageId);
494
+ const addOther = (domain: any) => {
495
+ const nodeId = addOtherDomain(domain);
496
+ otherDomainIds.add(nodeId);
497
+ return nodeId;
498
+ };
499
+ // Whether a message's senders (or receivers) include something in a (sub)domain's box: a service, or the box
500
+ // itself, at the other end of its edges, through any channels
501
+ const carries = (nodeId: string) => nodes.has(nodeId) && isCarrier(nodes.get(nodeId));
502
+ const reaches = (messageId: string, direction: 'senders' | 'receivers', groupId: string) => {
503
+ const seen = new Set([messageId]);
504
+ for (let current = [messageId]; current.length > 0; ) {
505
+ const next = edges
506
+ .filter((edge) => current.includes(direction === 'receivers' ? edge.source : edge.target))
507
+ .map((edge) => (direction === 'receivers' ? edge.target : edge.source))
508
+ .filter((nodeId) => !seen.has(nodeId));
509
+ next.forEach((nodeId) => seen.add(nodeId));
510
+ if (next.some((nodeId) => !carries(nodeId) && (nodeId === groupId || isInside(nodes, nodeId, groupId)))) return true;
511
+ current = next.filter(carries);
512
+ }
513
+ return false;
514
+ };
515
+ // 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
518
+ const groupOfTreeDomain = new Map(tree.map((entry) => [entry.domain.data.id, entry.groupId]));
519
+ const declaredIn = (groupId: string, key: 'sends' | 'receives', messageId: string) =>
520
+ catalogServices.some((service) => {
521
+ if (!lists(service, key, messageId)) return false;
522
+ const ownerGroup = groupOfTreeDomain.get(owners.get(generateIdForNode(service))?.domain.data.id);
523
+ return !!ownerGroup && (ownerGroup === groupId || isInside(nodes, ownerGroup, groupId));
524
+ });
525
+ // The node standing for a service (or agent) on the other side, or none when it's in the domain
526
+ const otherSide = (service: any) => {
527
+ const serviceId = generateIdForNode(service);
528
+ const ownerDomain = owners.get(serviceId)?.domain ?? agentOwners.get(serviceId);
529
+ if (ownerDomain) return treeDomainIds.has(ownerDomain.data.id) ? undefined : addOther(ownerDomain);
530
+ if (!nodes.has(serviceId)) {
531
+ nodes.set(serviceId, {
532
+ id: serviceId,
533
+ type: service.collection,
534
+ position: { x: 0, y: 0 },
535
+ data: {
536
+ mode,
537
+ ...(service.collection === 'agents' ? { agent: { ...service.data } } : { service: { ...service.data } }),
538
+ },
539
+ });
540
+ }
541
+ return serviceId;
542
+ };
543
+ const sendEdge = (source: string, message: any) =>
544
+ createEdge({
545
+ id: `domain-sends-${source}-${generateIdForNode(message)}`,
546
+ source,
547
+ target: generateIdForNode(message),
548
+ label: getEdgeLabelForServiceAsTarget(message),
549
+ });
550
+ const receiveEdge = (message: any, target: string) =>
551
+ createEdge({
552
+ id: `domain-receives-${generateIdForNode(message)}-${target}`,
553
+ source: generateIdForNode(message),
554
+ target,
555
+ label: getEdgeLabelForMessageAsSource(message),
556
+ });
557
+
558
+ tree.forEach(({ domain, groupId }) => {
559
+ (['sends', 'receives'] as const).forEach((key) =>
560
+ (domain.data[key] || []).forEach((message: any) => {
561
+ if (!message.data) return;
562
+ const messageId = generateIdForNode(message);
563
+ const sends = key === 'sends';
564
+ if (reaches(messageId, sends ? 'senders' : 'receivers', groupId) || declaredIn(groupId, key, messageId)) return;
565
+ // Outside the box, as the box sends or receives it (e.g. not left in it by a service in it consuming it)
566
+ const node = nodes.get(messageId);
567
+ if (!node) nodes.set(messageId, createTriggerMessageNode(message, mode));
568
+ else if (isInside(nodes, messageId, groupId)) nodes.set(messageId, { ...node, parentId: undefined, extent: undefined });
569
+ domainMessages.set(messageId, (domainMessages.get(messageId) ?? new Set()).add(groupId));
570
+ edges.push(sends ? sendEdge(groupId, message) : receiveEdge(message, groupId));
571
+
572
+ // Who receives what the domain sends, and who sends what it receives
573
+ new Set(
574
+ catalogServices
575
+ .filter((service) => lists(service, sends ? 'receives' : 'sends', messageId))
576
+ .map(otherSide)
577
+ .filter((nodeId): nodeId is string => !!nodeId)
578
+ ).forEach((nodeId) => edges.push(sends ? receiveEdge(message, nodeId) : sendEdge(nodeId, message)));
579
+ })
580
+ );
581
+ });
582
+
583
+ // Other domains sending a message the domain receives themselves, or receiving one it sends
584
+ const listed = new Map<string, { message: any; sends: any[]; receives: any[] }>();
585
+ domains
586
+ .filter((domain) => !treeDomainIds.has(domain.data.id))
587
+ .forEach((domain) =>
588
+ (['sends', 'receives'] as const).forEach((key) =>
589
+ (domain.data[key] || []).forEach((message: any) => {
590
+ if (!message.data) return;
591
+ const messageId = generateIdForNode(message);
592
+ const entry = listed.get(messageId) ?? { message, sends: [], receives: [] };
593
+ entry[key].push(domain);
594
+ listed.set(messageId, entry);
595
+ })
596
+ )
597
+ );
598
+ const rootGroupId = tree[0].groupId;
599
+ listed.forEach(({ message, sends, receives }, messageId) => {
600
+ if (!MESSAGE_TYPES.has(nodes.get(messageId)?.type)) return;
601
+ if (sends.length && reaches(messageId, 'receivers', rootGroupId))
602
+ sends.forEach((domain) => edges.push(sendEdge(addOther(domain), message)));
603
+ if (receives.length && reaches(messageId, 'senders', rootGroupId))
604
+ receives.forEach((domain) => edges.push(receiveEdge(message, addOther(domain))));
605
+ });
606
+ return { otherDomainIds, domainMessages };
411
607
  };
412
608
 
413
609
  /**
414
610
  * Messages are placed in the first graph they appear in, which can be a
415
611
  * system consuming them. Moves each message into the system (or domain) of the
416
- * services publishing it, when they're all in the same one.
612
+ * services publishing it, when they're all in the same one. Messages a domain
613
+ * sends or receives itself (`kept`) stay outside its box, where its edge to them
614
+ * is drawn.
417
615
  */
418
- export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[]) => {
616
+ export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[], kept: Set<string> = new Set()) => {
419
617
  const publisherGroups = new Map<string, Set<string | undefined>>();
420
618
  edges.forEach((edge) => {
421
619
  const publisher = nodes.get(edge.source);
@@ -425,6 +623,7 @@ export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[
425
623
  publisherGroups.set(edge.target, groups);
426
624
  });
427
625
  publisherGroups.forEach((groups, messageId) => {
626
+ if (kept.has(messageId)) return;
428
627
  const [parentId] = [...groups];
429
628
  const message = nodes.get(messageId);
430
629
  if (groups.size === 1 && parentId && message.parentId !== parentId) {
@@ -438,12 +637,6 @@ export const placeMessagesWithPublishers = (nodes: Map<string, any>, edges: any[
438
637
  * nowhere and ones between a domain and a subdomain in it.
439
638
  */
440
639
  export const connectEdges = (edges: any[], replacedBy: Map<string, string>, nodes: Map<string, any>) => {
441
- const isInside = (nodeId: string, groupId: string) => {
442
- for (let node = nodes.get(nodeId); node?.parentId; node = nodes.get(node.parentId)) {
443
- if (node.parentId === groupId) return true;
444
- }
445
- return false;
446
- };
447
640
  const seen = new Set<string>();
448
641
  return edges
449
642
  .map((edge) => ({
@@ -457,8 +650,8 @@ export const connectEdges = (edges: any[], replacedBy: Map<string, string>, node
457
650
  edge.source === edge.target ||
458
651
  !nodes.has(edge.source) ||
459
652
  !nodes.has(edge.target) ||
460
- isInside(edge.source, edge.target) ||
461
- isInside(edge.target, edge.source) ||
653
+ isInside(nodes, edge.source, edge.target) ||
654
+ isInside(nodes, edge.target, edge.source) ||
462
655
  seen.has(key)
463
656
  )
464
657
  return false;
@@ -577,13 +770,34 @@ export const getNodesAndEdges = (props: NodesAndEdgesProps): Promise<DomainLevel
577
770
  return levelsCache.get(key)!;
578
771
  };
579
772
 
773
+ /**
774
+ * Each service and agent in the version a domain has (it can pin an older one),
775
+ * or its latest when no domain has it: who sends or receives a domain's own
776
+ * messages is matched against these.
777
+ */
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
+ ]);
783
+ const key = (resource: any) => `${resource.collection}:${resource.data.id}`;
784
+ const usedKeys = new Set(resources.filter((resource) => used.has(generateIdForNode(resource))).map(key));
785
+ return resources.filter((resource) =>
786
+ usedKeys.has(key(resource))
787
+ ? used.has(generateIdForNode(resource))
788
+ : (resource.data.latestVersion ?? resource.data.version) === resource.data.version
789
+ );
790
+ };
791
+
580
792
  const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: NodesAndEdgesProps): Promise<DomainLevels> => {
581
793
  // Each domain with only its own services (not its subdomains'): the version
582
794
  // shown (which may be an older one), and the latest of every domain for its
583
795
  // subdomains and the other domains around it
584
- const [allVersions, domains] = await Promise.all([
796
+ const [allVersions, domains, services, agents] = await Promise.all([
585
797
  getDomains({ getAllVersions: true, includeServicesInSubdomains: false }),
586
798
  getDomains({ getAllVersions: false, includeServicesInSubdomains: false }),
799
+ getServices({ getAllVersions: true }),
800
+ getAgents({ getAllVersions: true }),
587
801
  ]);
588
802
  const domain = allVersions.find((d) => d.data.id === id && d.data.version === version);
589
803
  if (!domain) return { nodes: [], edges: [], hiddenMessages: { nodes: [], edges: [] } };
@@ -646,7 +860,7 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
646
860
  ),
647
861
  ]);
648
862
 
649
- const { nodes, edges, otherDomainIds } = buildDomainGraph({
863
+ const { nodes, edges, otherDomainIds, domainMessages } = buildDomainGraph({
650
864
  tree,
651
865
  owners,
652
866
  parentDomainOf,
@@ -655,6 +869,8 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
655
869
  looseServices,
656
870
  serviceGraphs,
657
871
  mode,
872
+ domains,
873
+ catalogServices: inVersionsUsed([...services, ...agents], [...domains, ...tree.map((entry) => entry.domain)], owners),
658
874
  });
659
875
  const context = addContext(contexts, systems, nodes);
660
876
  const graph = { nodes: [...nodes.values(), ...context.nodes], edges: [...edges, ...context.edges] };
@@ -671,7 +887,8 @@ const buildLevels = async ({ id, version, mode = 'full', channelRenderMode }: No
671
887
  { ...graph, edges: detailedGraph.edges },
672
888
  systems.map(({ system }) => system),
673
889
  context.contextNodesById,
674
- new Map(tree.filter((entry) => entry.parentId).map((entry) => [entry.groupId, entry.domain]))
890
+ new Map(tree.filter((entry) => entry.parentId).map((entry) => [entry.groupId, entry.domain])),
891
+ domainMessages
675
892
  );
676
893
  const overview = hideMessageNodes(collapsed.nodes, collapsed.edges);
677
894
 
@@ -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
  };