@eventcatalog/core 4.1.4 → 4.2.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 (44) 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-RAB6LGHN.js → chunk-47LY7DY2.js} +1 -1
  6. package/dist/{chunk-TCK7WEEE.js → chunk-4FWALA6J.js} +1 -1
  7. package/dist/{chunk-CAYWT7E4.js → chunk-J7T5HUHY.js} +1 -1
  8. package/dist/{chunk-B3HQOT3D.js → chunk-KPDU47YJ.js} +1 -1
  9. package/dist/{chunk-ZLQUICJD.js → chunk-QKPPJU43.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/public/android-chrome-192x192.png +0 -0
  19. package/eventcatalog/public/android-chrome-512x512.png +0 -0
  20. package/eventcatalog/public/apple-touch-icon.png +0 -0
  21. package/eventcatalog/public/favicon-16x16.png +0 -0
  22. package/eventcatalog/public/favicon-32x32.png +0 -0
  23. package/eventcatalog/public/favicon-48x48.png +0 -0
  24. package/eventcatalog/public/favicon-96x96.png +0 -0
  25. package/eventcatalog/public/favicon.ico +0 -0
  26. package/eventcatalog/public/favicon.svg +8 -0
  27. package/eventcatalog/src/components/Lineage/LineageScenarioSwitcher.tsx +65 -0
  28. package/eventcatalog/src/components/Seo.astro +5 -1
  29. package/eventcatalog/src/content.config.ts +9 -0
  30. package/eventcatalog/src/enterprise/auth/error.astro +3 -1
  31. package/eventcatalog/src/enterprise/auth/login.astro +3 -1
  32. package/eventcatalog/src/pages/studio.astro +3 -1
  33. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/_index.data.ts +66 -0
  34. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +201 -0
  35. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +31 -1
  36. package/eventcatalog/src/stores/sidebar-store/state.ts +22 -4
  37. package/eventcatalog/src/utils/collections/commands.ts +1 -1
  38. package/eventcatalog/src/utils/collections/message-triggers.ts +118 -0
  39. package/eventcatalog/src/utils/node-graphs/container-node-graph.ts +2 -1
  40. package/eventcatalog/src/utils/node-graphs/message-lineage.ts +131 -0
  41. package/eventcatalog/src/utils/node-graphs/message-node-graph.ts +119 -1
  42. package/eventcatalog/src/utils/node-graphs/services-node-graph.ts +15 -1
  43. package/eventcatalog/src/utils/node-graphs/utils/utils.ts +14 -4
  44. package/package.json +4 -4
@@ -14,6 +14,7 @@ import {
14
14
  import { isVisualiserEnabled, isChangelogEnabled } from '@utils/feature';
15
15
  import { iconFieldsForResource } from '@utils/icon';
16
16
  import { collectionToResourceMap } from '@utils/collections/util';
17
+ import type { MessageTrigger } from '@utils/collections/message-triggers';
17
18
 
18
19
  type MessageSchemaEntry = CollectionEntry<'schemas'>;
19
20
 
@@ -47,16 +48,22 @@ export const buildMessageNode = (
47
48
  owners: any[],
48
49
  context: ResourceGroupContext,
49
50
  hasFieldUsage: boolean = false,
50
- flowRefs: string[] = []
51
+ flowRefs: string[] = [],
52
+ messageTriggers: { triggers: MessageTrigger[]; triggeredBy: MessageTrigger[] } = { triggers: [], triggeredBy: [] }
51
53
  ): NavNode => {
52
54
  const producers = message.data.producers || [];
53
55
  const consumers = message.data.consumers || [];
54
56
  const collection = message.collection;
57
+ const triggerRefs = [...new Set(messageTriggers.triggers.map(({ message }) => getProducerConsumerPageRef(message)))];
58
+ const triggeredByRefs = [...new Set(messageTriggers.triggeredBy.map(({ message }) => getProducerConsumerPageRef(message)))];
55
59
 
56
60
  const renderProducers = producers.length > 0 && shouldRenderSideBarSection(message, 'producers');
57
61
  const renderConsumers = consumers.length > 0 && shouldRenderSideBarSection(message, 'consumers');
62
+ const renderTriggers = triggerRefs.length > 0 && shouldRenderSideBarSection(message, 'triggers');
63
+ const renderTriggeredBy = triggeredByRefs.length > 0 && shouldRenderSideBarSection(message, 'triggeredBy');
58
64
  const renderFlows = flowRefs.length > 0 && shouldRenderSideBarSection(message, 'flows');
59
65
  const renderRepository = message.data.repository && shouldRenderSideBarSection(message, 'repository');
66
+ const hasTriggerPaths = messageTriggers.triggers.length > 0 || messageTriggers.triggeredBy.length > 0;
60
67
 
61
68
  // Determine badge based on collection type
62
69
  const badgeMap: Record<string, string> = {
@@ -124,6 +131,15 @@ export const buildMessageNode = (
124
131
  title: 'Map',
125
132
  href: buildUrl(`/visualiser/${collection}/${message.data.id}/${message.data.version}`),
126
133
  },
134
+ ...(hasTriggerPaths
135
+ ? [
136
+ {
137
+ type: 'item' as const,
138
+ title: 'Trigger paths',
139
+ href: buildUrl(`/triggers/${collection}/${message.data.id}/${message.data.version}`),
140
+ },
141
+ ]
142
+ : []),
127
143
  ],
128
144
  },
129
145
  hasDiagrams && {
@@ -163,6 +179,20 @@ export const buildMessageNode = (
163
179
  pages: consumers.map(getProducerConsumerPageRef),
164
180
  visible: consumers.length > 0,
165
181
  },
182
+ renderTriggeredBy && {
183
+ type: 'group',
184
+ title: 'Triggered by',
185
+ icon: 'Mail',
186
+ pages: triggeredByRefs,
187
+ visible: triggeredByRefs.length > 0,
188
+ },
189
+ renderTriggers && {
190
+ type: 'group',
191
+ title: 'Triggers',
192
+ icon: 'Mail',
193
+ pages: triggerRefs,
194
+ visible: triggerRefs.length > 0,
195
+ },
166
196
  renderFlows && {
167
197
  type: 'group',
168
198
  title: 'Appears in flows',
@@ -8,6 +8,7 @@ import { getDomains } from '@utils/collections/domains';
8
8
  import { getSystems } from '@utils/collections/systems';
9
9
  import { getServices } from '@utils/collections/services';
10
10
  import { getMessages, pluralizeMessageType } from '@utils/collections/messages';
11
+ import { getTriggeredByOfMessage, getTriggersOfMessage } from '@utils/collections/message-triggers';
11
12
  import { getOwner } from '@utils/collections/owners';
12
13
  import { getFlows } from '@utils/collections/flows';
13
14
  import { getUsers } from '@utils/collections/users';
@@ -451,10 +452,14 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
451
452
  {} as Record<string, NavNode | string>
452
453
  );
453
454
 
454
- const rawAgents = await getCollection('agents');
455
+ const [rawAgents, rawServices, rawDomains] = await Promise.all([
456
+ getCollection('agents'),
457
+ getCollection('services'),
458
+ getCollection('domains'),
459
+ ]);
460
+ const messageReceivers = [...rawServices, ...rawAgents, ...rawDomains];
455
461
 
456
462
  // Compute channels for each service from raw sends[].to / receives[].from pointers
457
- const rawServices = await getCollection('services');
458
463
  const channelMap = createVersionedMap(channels);
459
464
  const serviceChannelsMap = new Map<string, CollectionEntry<'channels'>[]>();
460
465
 
@@ -560,7 +565,7 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
560
565
  {} as Record<string, NavNode | string>
561
566
  );
562
567
 
563
- // Build a set of message IDs that have field usage declared by any service or agent.
568
+ // Build a set of message IDs that have field usage declared by any resource that can receive messages.
564
569
  // We use raw collections because the hydrated resources replace
565
570
  // sends/receives pointers with resolved message entries, which strips the fields property.
566
571
  const messagesWithFieldUsage = new Set<string>();
@@ -580,6 +585,14 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
580
585
  if (pointer.fields?.length) messagesWithFieldUsage.add(pointer.id);
581
586
  }
582
587
  }
588
+ for (const domain of rawDomains) {
589
+ for (const pointer of domain.data.sends || []) {
590
+ if (pointer.fields?.length) messagesWithFieldUsage.add(pointer.id);
591
+ }
592
+ for (const pointer of domain.data.receives || []) {
593
+ if (pointer.fields?.length) messagesWithFieldUsage.add(pointer.id);
594
+ }
595
+ }
583
596
 
584
597
  const flowRefsByMessage = buildFlowReferencesByMessage({ flows, events, commands, queries });
585
598
 
@@ -588,8 +601,13 @@ export const getNestedSideBarData = async (): Promise<NavigationData> => {
588
601
  const type = pluralizeMessageType(message as any);
589
602
  const versionedKey = `${type}:${message.data.id}:${message.data.version}`;
590
603
  const hasFieldUsage = messagesWithFieldUsage.has(message.data.id);
604
+ const triggers = getTriggersOfMessage(messageReceivers, message, messages);
605
+ const triggeredBy = getTriggeredByOfMessage(messageReceivers, message, messages);
591
606
  acc[versionedKey] = withArchitectureDecisionsSection(
592
- buildMessageNode(message, owners, context, hasFieldUsage, flowRefsByMessage.get(versionedKey) || []),
607
+ buildMessageNode(message, owners, context, hasFieldUsage, flowRefsByMessage.get(versionedKey) || [], {
608
+ triggers,
609
+ triggeredBy,
610
+ }),
593
611
  message,
594
612
  adrs
595
613
  );
@@ -20,7 +20,7 @@ export const getCommands = async ({ getAllVersions = true, hydrateServices = tru
20
20
  const cacheKey = `${getAllVersions ? 'allVersions' : 'currentVersions'}-${hydrateServices ? 'hydrated' : 'minimal'}`;
21
21
 
22
22
  // Check cache
23
- if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0) {
23
+ if (memoryCache[cacheKey] && memoryCache[cacheKey].length > 0 && CACHE_ENABLED) {
24
24
  // console.timeEnd('✅ New getCommands');
25
25
  return memoryCache[cacheKey];
26
26
  }
@@ -0,0 +1,118 @@
1
+ import type { CollectionEntry } from 'astro:content';
2
+ import type { CollectionMessageTypes } from '@types';
3
+ import { getItemsFromCollectionByIdAndSemverOrLatest, versionMatches } from './util';
4
+
5
+ type Message = CollectionEntry<CollectionMessageTypes>;
6
+ export type MessageReceiver = CollectionEntry<'services'> | CollectionEntry<'domains'> | CollectionEntry<'agents'>;
7
+
8
+ interface TriggerPointer {
9
+ id: string;
10
+ version?: string;
11
+ condition?: string;
12
+ }
13
+
14
+ interface ReceivePointer {
15
+ id: string;
16
+ version?: string;
17
+ triggers?: TriggerPointer[];
18
+ }
19
+
20
+ export interface MessageTrigger {
21
+ receiver: MessageReceiver;
22
+ message: Message;
23
+ condition?: string;
24
+ }
25
+
26
+ const messageMatchesPointer = (message: Message, pointer: { id: string; version?: string }) => {
27
+ if (pointer.id !== message.data.id) return false;
28
+
29
+ if (!pointer.version || pointer.version === 'latest') {
30
+ return !message.data.latestVersion || message.data.version === message.data.latestVersion;
31
+ }
32
+
33
+ return versionMatches(message.data.version, pointer.version);
34
+ };
35
+
36
+ /**
37
+ * Returns the messages triggered when a resource receives the given message.
38
+ *
39
+ * Receivers must be raw collection entries because hydration replaces receive
40
+ * pointers with messages and therefore removes trigger metadata.
41
+ */
42
+ export const getTriggersOfMessage = (
43
+ receivers: MessageReceiver[],
44
+ message: Message,
45
+ allMessages: Message[]
46
+ ): MessageTrigger[] => {
47
+ const triggers: MessageTrigger[] = [];
48
+
49
+ for (const receiver of receivers) {
50
+ const receives = (receiver.data.receives as ReceivePointer[]) ?? [];
51
+
52
+ for (const receive of receives) {
53
+ if (!receive.triggers?.length || !messageMatchesPointer(message, receive)) continue;
54
+
55
+ for (const trigger of receive.triggers) {
56
+ const target = getItemsFromCollectionByIdAndSemverOrLatest(allMessages, trigger.id, trigger.version)[0];
57
+ if (!target) continue;
58
+
59
+ triggers.push({ receiver, message: target, condition: trigger.condition });
60
+ }
61
+ }
62
+ }
63
+
64
+ return triggers;
65
+ };
66
+
67
+ /**
68
+ * Returns the messages whose handling can trigger the given message.
69
+ *
70
+ * Receivers must be raw collection entries because hydration replaces receive
71
+ * pointers with messages and therefore removes trigger metadata.
72
+ */
73
+ export const getTriggeredByOfMessage = (
74
+ receivers: MessageReceiver[],
75
+ message: Message,
76
+ allMessages: Message[]
77
+ ): MessageTrigger[] => {
78
+ const triggeredBy: MessageTrigger[] = [];
79
+
80
+ for (const receiver of receivers) {
81
+ const receives = (receiver.data.receives as ReceivePointer[]) ?? [];
82
+
83
+ for (const receive of receives) {
84
+ for (const trigger of receive.triggers ?? []) {
85
+ if (!messageMatchesPointer(message, trigger)) continue;
86
+
87
+ const source = getItemsFromCollectionByIdAndSemverOrLatest(allMessages, receive.id, receive.version)[0];
88
+ if (!source) continue;
89
+
90
+ triggeredBy.push({ receiver, message: source, condition: trigger.condition });
91
+ }
92
+ }
93
+ }
94
+
95
+ return triggeredBy;
96
+ };
97
+
98
+ const getMessageKey = (message: Message) => `${message.collection}:${message.data.id}:${message.data.version}`;
99
+
100
+ /**
101
+ * Returns only messages that participate in at least one resolved trigger path.
102
+ * Both the source and target are included so empty trigger pages are never generated.
103
+ */
104
+ export const getMessagesWithTriggerPaths = (receivers: MessageReceiver[], allMessages: Message[]): Message[] => {
105
+ const messageKeysWithPaths = new Set<string>();
106
+
107
+ for (const source of allMessages) {
108
+ const triggers = getTriggersOfMessage(receivers, source, allMessages);
109
+ if (triggers.length === 0) continue;
110
+
111
+ messageKeysWithPaths.add(getMessageKey(source));
112
+ for (const trigger of triggers) {
113
+ messageKeysWithPaths.add(getMessageKey(trigger.message));
114
+ }
115
+ }
116
+
117
+ return allMessages.filter((message) => messageKeysWithPaths.has(getMessageKey(message)));
118
+ };
@@ -143,11 +143,12 @@ export const getNodesAndEdges = async ({
143
143
  targetPosition: 'left',
144
144
  data: {
145
145
  mode,
146
+ isFocused: true,
146
147
  data: {
147
148
  ...container.data,
148
149
  },
149
150
  contextMenu: buildContextMenuForResource({
150
- collection: 'entities',
151
+ collection: 'containers',
151
152
  id: container.data.id,
152
153
  version: container.data.version,
153
154
  }),
@@ -0,0 +1,131 @@
1
+ import type { CollectionEntry } from 'astro:content';
2
+ import type { Edge, Node } from '@xyflow/react';
3
+ import type { CollectionMessageTypes } from '@types';
4
+ import type { MessageTrigger } from '@utils/collections/message-triggers';
5
+ import {
6
+ createEdge,
7
+ createNode,
8
+ generateIdForNode,
9
+ generatedIdForEdge,
10
+ getColorFromString,
11
+ getEdgeLabelForMessageAsSource,
12
+ getEdgeLabelForServiceAsTarget,
13
+ } from './utils/utils';
14
+ import { createTriggerMessageNode, getTriggerReceiverNodeData } from './message-node-graph';
15
+
16
+ type Message = CollectionEntry<CollectionMessageTypes>;
17
+
18
+ export type MessageLineageDirection = 'triggeredBy' | 'triggers';
19
+
20
+ export interface MessageLineageGraph {
21
+ direction: MessageLineageDirection;
22
+ condition?: string;
23
+ source: Message;
24
+ receiver: MessageTrigger['receiver'];
25
+ target: Message;
26
+ relatedMessage: Message;
27
+ nodes: Node[];
28
+ edges: Edge[];
29
+ }
30
+
31
+ export interface MessageLineageScenario {
32
+ condition?: string;
33
+ }
34
+
35
+ export interface MessageLineageGroup extends Omit<MessageLineageGraph, 'condition'> {
36
+ scenarios: MessageLineageScenario[];
37
+ }
38
+
39
+ const createLineageEdge = (source: any, target: any, label: string, colorMessage: Message): Edge =>
40
+ createEdge({
41
+ id: generatedIdForEdge(source, target),
42
+ source: generateIdForNode(source),
43
+ target: generateIdForNode(target),
44
+ label,
45
+ animated: false,
46
+ data: {
47
+ animated: false,
48
+ customColor: getColorFromString(colorMessage.data.id),
49
+ rootSourceAndTarget: {
50
+ source: { id: generateIdForNode(source), collection: source.collection },
51
+ target: { id: generateIdForNode(target), collection: target.collection },
52
+ },
53
+ },
54
+ });
55
+
56
+ export const buildMessageLineageGraph = ({
57
+ currentMessage,
58
+ relation,
59
+ direction,
60
+ }: {
61
+ currentMessage: Message;
62
+ relation: MessageTrigger;
63
+ direction: MessageLineageDirection;
64
+ }): MessageLineageGraph => {
65
+ const source = direction === 'triggeredBy' ? relation.message : currentMessage;
66
+ const target = direction === 'triggeredBy' ? currentMessage : relation.message;
67
+ const mode = 'simple';
68
+
69
+ const nodes: Node[] = [
70
+ {
71
+ ...createTriggerMessageNode(source, mode, source === currentMessage),
72
+ position: { x: 0, y: 0 },
73
+ },
74
+ createNode({
75
+ id: generateIdForNode(relation.receiver),
76
+ type: relation.receiver.collection,
77
+ data: getTriggerReceiverNodeData(relation.receiver, mode),
78
+ position: { x: 320, y: 0 },
79
+ }),
80
+ {
81
+ ...createTriggerMessageNode(target, mode, target === currentMessage),
82
+ position: { x: 640, y: 0 },
83
+ },
84
+ ];
85
+
86
+ return {
87
+ direction,
88
+ condition: relation.condition,
89
+ source,
90
+ receiver: relation.receiver,
91
+ target,
92
+ relatedMessage: relation.message,
93
+ nodes,
94
+ edges: [
95
+ createLineageEdge(source, relation.receiver, getEdgeLabelForMessageAsSource(source), source),
96
+ createLineageEdge(relation.receiver, target, getEdgeLabelForServiceAsTarget(target), target),
97
+ ],
98
+ };
99
+ };
100
+
101
+ const getResourceKey = (resource: Message | MessageTrigger['receiver']) =>
102
+ `${resource.collection}:${resource.data.id}:${resource.data.version}`;
103
+
104
+ export const groupMessageLineageGraphs = (graphs: MessageLineageGraph[]): MessageLineageGroup[] => {
105
+ const groups = new Map<string, MessageLineageGroup>();
106
+
107
+ for (const graph of graphs) {
108
+ const key = [
109
+ graph.direction,
110
+ getResourceKey(graph.source),
111
+ getResourceKey(graph.receiver),
112
+ getResourceKey(graph.target),
113
+ ].join('>');
114
+ const existingGroup = groups.get(key);
115
+
116
+ if (existingGroup) {
117
+ if (!existingGroup.scenarios.some((scenario) => scenario.condition === graph.condition)) {
118
+ existingGroup.scenarios.push({ condition: graph.condition });
119
+ }
120
+ continue;
121
+ }
122
+
123
+ const { condition, ...lineage } = graph;
124
+ groups.set(key, {
125
+ ...lineage,
126
+ scenarios: [{ condition }],
127
+ });
128
+ }
129
+
130
+ return [...groups.values()];
131
+ };
@@ -1,6 +1,6 @@
1
1
  // import { getColor } from '@utils/colors';
2
2
  import { getEvents } from '@utils/collections/events';
3
- import type { CollectionEntry } from 'astro:content';
3
+ import { getCollection, type CollectionEntry } from 'astro:content';
4
4
  import dagre from 'dagre';
5
5
  import {
6
6
  calculatedNodes,
@@ -42,6 +42,7 @@ import {
42
42
  import { getNodesAndEdgesForChannelChain } from './channel-node-graph';
43
43
  import { getChannelChain, isChannelsConnected } from '@utils/collections/channels';
44
44
  import { getChannels } from '@utils/collections/channels';
45
+ import { getTriggeredByOfMessage, getTriggersOfMessage, type MessageReceiver } from '@utils/collections/message-triggers';
45
46
 
46
47
  type DagreGraph = any;
47
48
  type RoutableResource = CollectionEntry<'agents'> | CollectionEntry<'services'>;
@@ -166,6 +167,52 @@ const getDataProductNodeData = (resource: CollectionEntry<'data-products'>, mode
166
167
  contextMenu: buildContextMenuForResource({ collection: 'data-products', id: resource.data.id, version: resource.data.version }),
167
168
  });
168
169
 
170
+ export const getTriggerReceiverNodeData = (receiver: MessageReceiver, mode: 'simple' | 'full') => {
171
+ if (receiver.collection !== 'domains') return getRoutableNodeData(receiver, mode);
172
+
173
+ return {
174
+ title: receiver.data.id,
175
+ mode,
176
+ domain: {
177
+ ...receiver,
178
+ data: {
179
+ ...receiver.data,
180
+ // Raw domain entries contain service pointers, while the visualiser's
181
+ // domain node expects hydrated services in full mode.
182
+ services: [],
183
+ },
184
+ },
185
+ contextMenu: buildContextMenuForResource({
186
+ collection: 'domains',
187
+ id: receiver.data.id,
188
+ version: receiver.data.version,
189
+ }),
190
+ };
191
+ };
192
+
193
+ export const createTriggerMessageNode = (
194
+ message: CollectionEntry<CollectionMessageTypes>,
195
+ mode: 'simple' | 'full',
196
+ isFocused = false
197
+ ) =>
198
+ createNode({
199
+ id: generateIdForNode(message),
200
+ type: message.collection,
201
+ data: {
202
+ mode,
203
+ ...(isFocused ? { isFocused: true } : {}),
204
+ message: { ...message.data, ...getOperationFields(message.data) },
205
+ contextMenu: buildContextMenuForMessage({
206
+ id: message.data.id,
207
+ version: message.data.version,
208
+ name: message.data.name,
209
+ collection: message.collection,
210
+ schemaPath: (message.data as any).schemaPath,
211
+ }),
212
+ },
213
+ position: { x: 0, y: 0 },
214
+ });
215
+
169
216
  const getNodesAndEdges = async ({
170
217
  id,
171
218
  version,
@@ -194,6 +241,19 @@ const getNodesAndEdges = async ({
194
241
  // Pre-calculate channel map for O(1) lookups
195
242
  const channelMap = createVersionedMap(channels);
196
243
 
244
+ // Trigger metadata lives on raw receive pointers. Hydrated resources replace
245
+ // those pointers with messages, so load the raw collections for this relation.
246
+ const [events, commands, queries, services, agents, domains] = await Promise.all([
247
+ getCollection('events'),
248
+ getCollection('commands'),
249
+ getCollection('queries'),
250
+ getCollection('services'),
251
+ getCollection('agents'),
252
+ getCollection('domains'),
253
+ ]);
254
+ const allMessages = [...events, ...commands, ...queries] as CollectionEntry<CollectionMessageTypes>[];
255
+ const messageReceivers = [...services, ...agents, ...domains] as MessageReceiver[];
256
+
197
257
  // We always render the message itself
198
258
  nodes.push({
199
259
  id: generateIdForNode(message),
@@ -201,6 +261,7 @@ const getNodesAndEdges = async ({
201
261
  targetPosition: 'left',
202
262
  data: {
203
263
  mode,
264
+ isFocused: true,
204
265
  message: {
205
266
  ...message.data,
206
267
  ...getOperationFields(message.data),
@@ -564,6 +625,63 @@ const getNodesAndEdges = async ({
564
625
  }
565
626
  });
566
627
 
628
+ const appendNode = (node: Node) => {
629
+ if (!nodes.some((existingNode: Node) => existingNode.id === node.id)) nodes.push(node);
630
+ };
631
+ const appendEdge = (source: any, target: any, label: string, colorMessage: CollectionEntry<CollectionMessageTypes>) => {
632
+ const id = generatedIdForEdge(source, target);
633
+ if (edges.some((edge: Edge) => edge.id === id)) return;
634
+
635
+ edges.push(
636
+ createEdge({
637
+ id,
638
+ source: generateIdForNode(source),
639
+ target: generateIdForNode(target),
640
+ label,
641
+ data: {
642
+ customColor: getColorFromString(colorMessage.data.id),
643
+ rootSourceAndTarget: {
644
+ source: { id: generateIdForNode(source), collection: source.collection },
645
+ target: { id: generateIdForNode(target), collection: target.collection },
646
+ },
647
+ },
648
+ })
649
+ );
650
+ };
651
+ const appendReceiver = (receiver: MessageReceiver) => {
652
+ const receiverId = generateIdForNode(receiver);
653
+ if (nodes.some((node: Node) => node.id === receiverId)) return;
654
+
655
+ appendNode(
656
+ createNode({
657
+ id: receiverId,
658
+ type: receiver.collection,
659
+ data: getTriggerReceiverNodeData(receiver, mode),
660
+ position: { x: 0, y: 0 },
661
+ })
662
+ );
663
+
664
+ if (receiver.collection !== 'domains') {
665
+ appendAgentToolNodesAndEdges({ agent: receiver, nodes, edges, mode });
666
+ }
667
+ };
668
+
669
+ // Complete both sides of a trigger chain around the focused message:
670
+ // triggering message -> receiver -> triggered message.
671
+ for (const relation of getTriggeredByOfMessage(messageReceivers, message, allMessages)) {
672
+ appendNode(createTriggerMessageNode(relation.message, mode));
673
+ appendReceiver(relation.receiver);
674
+ appendEdge(relation.message, relation.receiver, getEdgeLabelForMessageAsSource(relation.message), relation.message);
675
+ appendEdge(relation.receiver, message, getEdgeLabelForServiceAsTarget(message), message);
676
+ }
677
+
678
+ for (const relation of getTriggersOfMessage(messageReceivers, message, allMessages)) {
679
+ appendReceiver(relation.receiver);
680
+ appendNode(createTriggerMessageNode(relation.message, mode));
681
+ appendEdge(message, relation.receiver, getEdgeLabelForMessageAsSource(message), message);
682
+ appendEdge(relation.receiver, relation.message, getEdgeLabelForServiceAsTarget(relation.message), relation.message);
683
+ }
684
+
567
685
  nodes.forEach((node: any) => {
568
686
  flow.setNode(node.id, { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT });
569
687
  });
@@ -347,7 +347,10 @@ export const getNodesAndEdges = async ({
347
347
  id: generateIdForNode(service),
348
348
  sourcePosition: 'right',
349
349
  targetPosition: 'left',
350
- data: getResourceNodeData(service, mode),
350
+ data: {
351
+ ...getResourceNodeData(service, mode),
352
+ isFocused: service.collection === 'services',
353
+ },
351
354
  type: service.collection,
352
355
  });
353
356
 
@@ -606,6 +609,17 @@ export const getNodesAndEdges = async ({
606
609
  uniqueNodesById.set(node.id, node);
607
610
  }
608
611
  });
612
+
613
+ // Inbound message processing may create the service node before the primary
614
+ // node is added. Mark the surviving copy after deduplication so the service
615
+ // currently being viewed always retains its focus treatment.
616
+ if (service.collection === 'services') {
617
+ const focusedServiceNode = uniqueNodesById.get(generateIdForNode(service));
618
+ if (focusedServiceNode) {
619
+ focusedServiceNode.data = { ...focusedServiceNode.data, isFocused: true };
620
+ }
621
+ }
622
+
609
623
  const uniqueNodes = Array.from(uniqueNodesById.values());
610
624
 
611
625
  uniqueNodes.forEach((node: any) => {
@@ -162,7 +162,10 @@ export const buildContextMenuForMessage = ({
162
162
  collection: string;
163
163
  schemaPath?: string;
164
164
  }): ContextMenuItem[] => {
165
- const items: ContextMenuItem[] = [{ label: 'Read documentation', href: buildUrl(`/docs/${collection}/${id}/${version}`) }];
165
+ const items: ContextMenuItem[] = [
166
+ { label: 'Read documentation', href: buildUrl(`/docs/${collection}/${id}/${version}`) },
167
+ { label: 'Focus node', href: buildUrl(`/visualiser/${collection}/${id}/${version}`) },
168
+ ];
166
169
 
167
170
  if (schemaPath) {
168
171
  items.push({
@@ -250,7 +253,10 @@ export const buildContextMenuForService = ({
250
253
  specifications?: unknown;
251
254
  repository?: { url: string };
252
255
  }): ContextMenuItem[] => {
253
- const items: ContextMenuItem[] = [{ label: 'Read documentation', href: buildUrl(`/docs/services/${id}/${version}`) }];
256
+ const items: ContextMenuItem[] = [
257
+ { label: 'Read documentation', href: buildUrl(`/docs/services/${id}/${version}`) },
258
+ { label: 'Focus node', href: buildUrl(`/visualiser/services/${id}/${version}`) },
259
+ ];
254
260
 
255
261
  const specItems = getSpecMenuItems(specifications, id, version);
256
262
  items.push(...specItems);
@@ -283,7 +289,10 @@ export const buildContextMenuForAgent = ({
283
289
  version: string;
284
290
  repository?: { url: string };
285
291
  }): ContextMenuItem[] => {
286
- const items: ContextMenuItem[] = [{ label: 'Read documentation', href: buildUrl(`/docs/agents/${id}/${version}`) }];
292
+ const items: ContextMenuItem[] = [
293
+ { label: 'Read documentation', href: buildUrl(`/docs/agents/${id}/${version}`) },
294
+ { label: 'Focus node', href: buildUrl(`/visualiser/agents/${id}/${version}`) },
295
+ ];
287
296
 
288
297
  if (repository?.url) {
289
298
  items.push({
@@ -315,6 +324,7 @@ export const buildContextMenuForResource = ({
315
324
  }): ContextMenuItem[] => {
316
325
  return [
317
326
  { label: 'Read documentation', href: buildUrl(`/docs/${collection}/${id}/${version}`) },
327
+ { label: 'Focus node', href: buildUrl(`/visualiser/${collection}/${id}/${version}`) },
318
328
  {
319
329
  label: 'Read changelog',
320
330
  href: buildUrl(`/docs/${collection}/${id}/${version}/changelog`),
@@ -331,7 +341,7 @@ export const buildContextMenuForResource = ({
331
341
  export const buildContextMenuForSystem = ({ id, version }: { id: string; version: string }): ContextMenuItem[] => {
332
342
  return [
333
343
  { label: 'Read documentation', href: buildUrl(`/docs/systems/${id}/${version}`) },
334
- { label: 'View map', href: buildUrl(`/visualiser/systems/${id}/${version}`) },
344
+ { label: 'Focus node', href: buildUrl(`/visualiser/systems/${id}/${version}`) },
335
345
  { label: 'View context', href: buildUrl(`/visualiser/systems/${id}/${version}/context`) },
336
346
  ];
337
347
  };
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "SEE LICENSE IN LICENSE",
9
9
  "type": "module",
10
- "version": "4.1.4",
10
+ "version": "4.2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -116,9 +116,9 @@
116
116
  "update-notifier": "^7.3.1",
117
117
  "uuid": "^10.0.0",
118
118
  "zod": "^4.3.6",
119
- "@eventcatalog/linter": "1.1.4",
120
- "@eventcatalog/sdk": "2.25.0",
121
- "@eventcatalog/visualiser": "^4.0.1"
119
+ "@eventcatalog/linter": "1.1.5",
120
+ "@eventcatalog/sdk": "2.26.0",
121
+ "@eventcatalog/visualiser": "^4.1.0"
122
122
  },
123
123
  "devDependencies": {
124
124
  "@astrojs/check": "^0.9.9",