@eventcatalog/core 5.0.0-beta.1 → 5.0.0-beta.11

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 (172) hide show
  1. package/LICENSE +227 -128
  2. package/README.md +4 -4
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +71 -35
  8. package/dist/analytics/log-build.js +5 -4
  9. package/dist/analytics/telemetry.cjs +42 -0
  10. package/dist/analytics/telemetry.d.cts +20 -0
  11. package/dist/analytics/telemetry.d.ts +20 -0
  12. package/dist/analytics/telemetry.js +8 -0
  13. package/dist/{chunk-KYJZJLBD.js → chunk-4YFM63DS.js} +9 -3
  14. package/dist/{chunk-UDS3SYLZ.js → chunk-76DAAJLV.js} +56 -33
  15. package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
  16. package/dist/chunk-CPW6G62V.js +17 -0
  17. package/dist/{chunk-K2ACDO7Y.js → chunk-LQZ7IG7B.js} +1 -1
  18. package/dist/{chunk-S2QGT7ZF.js → chunk-MFEMKQZ6.js} +1 -1
  19. package/dist/{chunk-Q6SD5SNQ.js → chunk-WM4QYMIA.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +204 -124
  23. package/dist/eventcatalog.config.d.cts +59 -1
  24. package/dist/eventcatalog.config.d.ts +59 -1
  25. package/dist/eventcatalog.js +30 -11
  26. package/dist/federation/federate.js +2 -2
  27. package/dist/generate.cjs +1 -1
  28. package/dist/generate.js +3 -3
  29. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  30. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  31. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  32. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  33. package/dist/runtime-dependencies/manifest.json +8 -1
  34. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  35. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  36. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  37. package/dist/runtime-dependencies/yjs.mjs +3 -0
  38. package/dist/utils/cli-logger.cjs +1 -1
  39. package/dist/utils/cli-logger.js +2 -2
  40. package/dist/utils/license-status.cjs +71 -45
  41. package/dist/utils/license-status.d.cts +14 -5
  42. package/dist/utils/license-status.d.ts +14 -5
  43. package/dist/utils/license-status.js +1 -1
  44. package/eventcatalog/astro.config.mjs +5 -0
  45. package/eventcatalog/integrations/storage.mjs +20 -0
  46. package/eventcatalog/integrations/studio-server.mjs +32 -0
  47. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  48. package/eventcatalog/src/components/Header.astro +3 -3
  49. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  50. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  51. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +16 -7
  52. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +10 -0
  53. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  54. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  55. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  56. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  57. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  58. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  59. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  60. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  61. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  62. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  63. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +276 -0
  64. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +210 -0
  65. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  66. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  67. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  68. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  69. package/eventcatalog/src/features/server/start.ts +25 -0
  70. package/eventcatalog/src/features/storage/database.ts +88 -0
  71. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  72. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  73. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  74. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  75. package/eventcatalog/src/features/storage/sql.ts +28 -0
  76. package/eventcatalog/src/features/studio/agent-choreography.ts +381 -0
  77. package/eventcatalog/src/features/studio/api/canvases-api.ts +170 -0
  78. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  79. package/eventcatalog/src/features/studio/api/client.ts +35 -0
  80. package/eventcatalog/src/features/studio/canvas-actions.ts +414 -0
  81. package/eventcatalog/src/features/studio/canvas-doc.ts +414 -0
  82. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  83. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  84. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  85. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  86. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +154 -0
  87. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +370 -0
  88. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  89. package/eventcatalog/src/features/studio/components/Comments.tsx +770 -0
  90. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +281 -0
  91. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  92. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  93. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +81 -0
  94. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  95. package/eventcatalog/src/features/studio/components/JoinForm.tsx +34 -0
  96. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +314 -0
  97. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  98. package/eventcatalog/src/features/studio/components/Presence.tsx +286 -0
  99. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +212 -0
  100. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  101. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1216 -0
  102. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  103. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +255 -0
  104. package/eventcatalog/src/features/studio/components/icons.ts +53 -0
  105. package/eventcatalog/src/features/studio/components/status.ts +53 -0
  106. package/eventcatalog/src/features/studio/edges.ts +56 -0
  107. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  108. package/eventcatalog/src/features/studio/grouping.ts +226 -0
  109. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  110. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  111. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +347 -0
  112. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  113. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +104 -0
  114. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +589 -0
  115. package/eventcatalog/src/features/studio/identity.ts +37 -0
  116. package/eventcatalog/src/features/studio/layout.ts +42 -0
  117. package/eventcatalog/src/features/studio/levels.ts +282 -0
  118. package/eventcatalog/src/features/studio/node-types.ts +205 -0
  119. package/eventcatalog/src/features/studio/pages/[id].astro +68 -0
  120. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  121. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  122. package/eventcatalog/src/features/studio/placement.ts +197 -0
  123. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +510 -0
  124. package/eventcatalog/src/features/studio/server/runtime.ts +491 -0
  125. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  126. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  127. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  128. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  129. package/eventcatalog/src/features/studio/studio.css +273 -0
  130. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  131. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +10 -5
  132. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  133. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  134. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  135. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  136. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  137. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  138. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  139. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  140. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  141. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  142. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  143. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  144. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  145. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  146. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  147. package/eventcatalog/src/styles/tailwind.css +2 -2
  148. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  149. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  150. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  151. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  152. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  153. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  154. package/eventcatalog/src/utils/collections/util.ts +31 -0
  155. package/eventcatalog/src/utils/feature.ts +8 -0
  156. package/eventcatalog/src/utils/git.ts +11 -5
  157. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  158. package/eventcatalog/src/utils/trial.ts +1 -1
  159. package/package.json +17 -12
  160. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  161. package/eventcatalog/public/studio-bg.png +0 -0
  162. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  163. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  164. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  165. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  166. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  167. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  168. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  169. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  170. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  171. package/eventcatalog/src/pages/studio.astro +0 -316
  172. package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
@@ -0,0 +1,414 @@
1
+ import * as Y from 'yjs';
2
+ import type { Connection, Edge, Node, XYPosition } from '@xyflow/react';
3
+ import { createEdge, getEdgeLabel } from './edges';
4
+ import { fitGroup, getAbsolutePosition, withDescendants, withParent } from './grouping';
5
+ import { getNodeDefinition, getNodeSize, isGroupType } from './node-types';
6
+
7
+ /**
8
+ * Everything a canvas holds, as operations on its Yjs document. The same functions run in the
9
+ * browser (people, WebMCP) and on the server (MCP tools), so humans and agents edit a canvas the same way.
10
+ */
11
+
12
+ export type Author = {
13
+ name: string;
14
+ color: string;
15
+ agent?: boolean;
16
+ /** A picture from their sign-in provider (SSO), shown over their initials */
17
+ picture?: string;
18
+ };
19
+ export type Message = { id: string; text: string; author: Author; createdAt: number };
20
+ /** Where a comment is pinned: a canvas position, or a node (and the offset from its top left) so it moves with it */
21
+ export type CommentAnchor = { position: XYPosition; nodeId?: string; offset?: XYPosition };
22
+ export type Thread = CommentAnchor & { id: string; author: Author; createdAt: number; resolved: boolean; messages: Message[] };
23
+ /** Where a canvas's design is: being worked on, up for review, agreed, or decided against */
24
+ export const CANVAS_STATUSES = ['draft', 'proposed', 'accepted', 'rejected'] as const;
25
+ export type CanvasStatus = (typeof CANVAS_STATUSES)[number];
26
+ /** A canvas's status being changed: by whom, when, and why (e.g. "Agreed in the architecture review") */
27
+ export type StatusChange = { status: CanvasStatus; by: Author; at: number; note?: string };
28
+ export type CanvasMeta = {
29
+ title?: string;
30
+ /** Draft until someone changes it */
31
+ status?: CanvasStatus;
32
+ /** Every status change, oldest first (the last few hundred) */
33
+ statusHistory?: StatusChange[];
34
+ createdAt?: number;
35
+ createdBy?: string;
36
+ /** When the canvas was last laid out, so everyone's canvas glides the nodes into place */
37
+ layoutAt?: number;
38
+ };
39
+
40
+ // Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
41
+ const DOCUMENT_PREFIX = 'design:';
42
+ export const canvasDocumentName = (canvasId: string) => `${DOCUMENT_PREFIX}${canvasId}`;
43
+ export const canvasIdFromDocumentName = (name: string) =>
44
+ name.startsWith(DOCUMENT_PREFIX) ? name.slice(DOCUMENT_PREFIX.length) : undefined;
45
+ /** Why the collaboration server refuses a canvas that was deleted (told to anyone who still has it open) */
46
+ export const CANVAS_DELETED_REASON = 'canvas-deleted';
47
+
48
+ export const shortId = () => crypto.randomUUID().slice(0, 8);
49
+
50
+ export const getCanvasMaps = (doc: Y.Doc) => ({
51
+ nodes: doc.getMap<Node>('nodes'),
52
+ edges: doc.getMap<Edge>('edges'),
53
+ threads: doc.getMap<Y.Map<unknown>>('threads'),
54
+ meta: doc.getMap<unknown>('meta'),
55
+ });
56
+
57
+ // Only the design is shared. Selection, measured sizes and drag state stay with each user.
58
+ export const toSharedNode = ({ id, type, position, data, width, height, parentId, zIndex }: Node): Node => ({
59
+ id,
60
+ type,
61
+ position,
62
+ data,
63
+ ...(width ? { width } : {}),
64
+ ...(height ? { height } : {}),
65
+ // Inside a container (its position is then relative to the container)
66
+ ...(parentId ? { parentId } : {}),
67
+ ...(zIndex !== undefined ? { zIndex } : {}),
68
+ });
69
+
70
+ /** A new node of a type, centred on a canvas position */
71
+ export const buildNode = (type: string, data: Record<string, unknown>, center: XYPosition): Node => {
72
+ const definition = getNodeDefinition(type);
73
+ const size = getNodeSize(type);
74
+ return toSharedNode({
75
+ id: `${type}-${shortId()}`,
76
+ type,
77
+ position: { x: center.x - size.width / 2, y: center.y - size.height / 2 },
78
+ data,
79
+ ...(definition?.defaultSize ? (definition.autoHeight ? { width: size.width } : size) : {}),
80
+ // Containers sit behind what's in them and the connections between them
81
+ ...(isGroupType(type) && { zIndex: -1 }),
82
+ });
83
+ };
84
+
85
+ // ---- Meta ----
86
+
87
+ /** Canvas titles are at most this long */
88
+ export const MAX_TITLE_LENGTH = 200;
89
+
90
+ export const readMeta = (doc: Y.Doc) => getCanvasMaps(doc).meta.toJSON() as CanvasMeta;
91
+
92
+ export const setMeta = (doc: Y.Doc, meta: Partial<CanvasMeta>) =>
93
+ doc.transact(() => {
94
+ const map = getCanvasMaps(doc).meta;
95
+ Object.entries(meta).forEach(([key, value]) => (value === undefined ? map.delete(key) : map.set(key, value)));
96
+ });
97
+
98
+ // ---- Status ----
99
+
100
+ const MAX_STATUS_HISTORY = 200;
101
+
102
+ export const getCanvasStatus = (meta: CanvasMeta): CanvasStatus => meta.status ?? 'draft';
103
+
104
+ /** Changes a canvas's status (and records who did it). Returns false if it already had that status. */
105
+ export const setCanvasStatus = (doc: Y.Doc, status: CanvasStatus, by: Author, note?: string) => {
106
+ let changed = false;
107
+ doc.transact(() => {
108
+ const meta = readMeta(doc);
109
+ if (getCanvasStatus(meta) === status) return;
110
+ const change: StatusChange = { status, by, at: Date.now(), ...(note?.trim() && { note: note.trim() }) };
111
+ const map = getCanvasMaps(doc).meta;
112
+ map.set('status', status);
113
+ map.set('statusHistory', [...(meta.statusHistory ?? []), change].slice(-MAX_STATUS_HISTORY));
114
+ changed = true;
115
+ });
116
+ return changed;
117
+ };
118
+
119
+ /** Accepted and rejected canvases are decided: changing what's on them makes them a draft again */
120
+ export const isDecided = (status: CanvasStatus) => status === 'accepted' || status === 'rejected';
121
+
122
+ /** A decided canvas whose nodes or edges were changed goes back to draft, recorded as changed by `by` */
123
+ export const reopenIfDecided = (doc: Y.Doc, by: Author) => {
124
+ const status = getCanvasStatus(readMeta(doc));
125
+ if (isDecided(status)) setCanvasStatus(doc, 'draft', by, `Changed after it was ${status}`);
126
+ };
127
+
128
+ /**
129
+ * Runs `fn` (which may change the canvas), and if it changed nodes or edges, sends a decided canvas back to draft.
130
+ * Comments don't count: reviewing an accepted design keeps it accepted.
131
+ */
132
+ export const reopeningIfEdited = <T>(doc: Y.Doc, by: Author, fn: () => T): T => {
133
+ let result!: T;
134
+ // In one transaction (the one it's already in, if it is): what it changed is known before it ends
135
+ doc.transact((transaction) => {
136
+ result = fn();
137
+ if (changesNodesOrEdges(transaction)) reopenIfDecided(doc, by);
138
+ });
139
+ return result;
140
+ };
141
+
142
+ /** Whether a transaction changed the canvas's nodes or edges (not just its comments or meta) */
143
+ export const changesNodesOrEdges = (transaction: Y.Transaction) => {
144
+ const { nodes, edges } = getCanvasMaps(transaction.doc);
145
+ return [...transaction.changed.keys()].some((type) => type === (nodes as unknown) || type === (edges as unknown));
146
+ };
147
+
148
+ // ---- Nodes and edges ----
149
+
150
+ export const addNodes = (doc: Y.Doc, nodes: Node[], edges: Edge[] = []) =>
151
+ doc.transact(() => {
152
+ const maps = getCanvasMaps(doc);
153
+ nodes.forEach((node) => maps.nodes.set(node.id, toSharedNode(node)));
154
+ edges.forEach((edge) => maps.edges.set(edge.id, edge));
155
+ });
156
+
157
+ export const moveNode = (doc: Y.Doc, id: string, position: XYPosition) => {
158
+ const { nodes } = getCanvasMaps(doc);
159
+ const node = nodes.get(id);
160
+ if (node) nodes.set(id, { ...node, position });
161
+ return !!node;
162
+ };
163
+
164
+ /** Resize a node: both sides, or just its width or height (e.g. text, whose height follows what's in it) */
165
+ export const resizeNode = (
166
+ doc: Y.Doc,
167
+ id: string,
168
+ size: { width: number; height: number },
169
+ attributes: true | 'width' | 'height' = true
170
+ ) => {
171
+ const { nodes } = getCanvasMaps(doc);
172
+ const node = nodes.get(id);
173
+ if (node)
174
+ nodes.set(id, {
175
+ ...node,
176
+ ...(attributes !== 'height' && { width: size.width }),
177
+ ...(attributes !== 'width' && { height: size.height }),
178
+ });
179
+ return !!node;
180
+ };
181
+
182
+ /** Grow a container to fit what's in it (moving them clear of its header if needed), never shrinking it */
183
+ /**
184
+ * The sizes nodes are rendered at (in the browser), by id. The document doesn't have them, so without them
185
+ * containers are fitted to the size each type renders at at most.
186
+ */
187
+ export type RenderedSizes = ReadonlyMap<string, { width: number; height: number }>;
188
+
189
+ const withRenderedSizes = (nodes: Node[], sizes?: RenderedSizes) =>
190
+ sizes ? nodes.map((node) => (sizes.has(node.id) ? { ...node, measured: sizes.get(node.id) } : node)) : nodes;
191
+
192
+ export const fitGroupToChildren = (doc: Y.Doc, groupId: string, sizes?: RenderedSizes) =>
193
+ doc.transact(() => {
194
+ const { nodes } = getCanvasMaps(doc);
195
+ const fit = fitGroup(groupId, withRenderedSizes(Array.from(nodes.values()), sizes));
196
+ const group = nodes.get(groupId);
197
+ if (!fit || !group) return;
198
+ if (fit.shift.x || fit.shift.y) {
199
+ // Children move right/down and the container left/up by the same amount: nothing moves on the canvas
200
+ nodes.forEach((child, id) => {
201
+ if (child.parentId === groupId)
202
+ nodes.set(id, { ...child, position: { x: child.position.x + fit.shift.x, y: child.position.y + fit.shift.y } });
203
+ });
204
+ }
205
+ nodes.set(groupId, {
206
+ ...group,
207
+ position: { x: group.position.x - fit.shift.x, y: group.position.y - fit.shift.y },
208
+ width: fit.width,
209
+ height: fit.height,
210
+ });
211
+ });
212
+
213
+ /** The containers a node is in grow to fit it (innermost first), e.g. after it's added or resized */
214
+ export const fitContainersAround = (doc: Y.Doc, nodeId: string, sizes?: RenderedSizes) =>
215
+ doc.transact(() => {
216
+ const { nodes } = getCanvasMaps(doc);
217
+ for (let id = nodes.get(nodeId)?.parentId; id; id = nodes.get(id)?.parentId) fitGroupToChildren(doc, id, sizes);
218
+ });
219
+
220
+ /** Put a node in a container (or take it out, without a parentId), keeping it where it is on the canvas */
221
+ export const setNodeParent = (doc: Y.Doc, nodeId: string, parentId: string | undefined, sizes?: RenderedSizes) =>
222
+ doc.transact(() => {
223
+ const { nodes } = getCanvasMaps(doc);
224
+ const node = nodes.get(nodeId);
225
+ if (!node || node.parentId === parentId) return false;
226
+ nodes.set(nodeId, toSharedNode(withParent(node, parentId, Array.from(nodes.values()))));
227
+ if (parentId) fitGroupToChildren(doc, parentId, sizes);
228
+ return true;
229
+ });
230
+
231
+ export const updateNodeData = (doc: Y.Doc, id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => {
232
+ const { nodes } = getCanvasMaps(doc);
233
+ const node = nodes.get(id);
234
+ if (node) nodes.set(id, { ...node, data: updater(node.data) });
235
+ return !!node;
236
+ };
237
+
238
+ /** Connect two nodes with an EventCatalog-labelled edge. Returns the edge, or why it couldn't be made. */
239
+ export const connectNodes = (
240
+ doc: Y.Doc,
241
+ connection: Pick<Connection, 'source' | 'target'> & Partial<Connection>,
242
+ label?: string
243
+ ): Edge | { error: string } => {
244
+ const { nodes, edges } = getCanvasMaps(doc);
245
+ const source = nodes.get(connection.source);
246
+ const target = nodes.get(connection.target);
247
+ if (!source) return { error: `Node "${connection.source}" is not on the canvas` };
248
+ if (!target) return { error: `Node "${connection.target}" is not on the canvas` };
249
+ if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
250
+ const existing = Array.from(edges.values()).find((edge) => edge.source === source.id && edge.target === target.id);
251
+ if (existing) return existing;
252
+ const edge = createEdge(
253
+ {
254
+ source: source.id,
255
+ target: target.id,
256
+ sourceHandle: connection.sourceHandle ?? null,
257
+ targetHandle: connection.targetHandle ?? null,
258
+ },
259
+ source.type,
260
+ target.type
261
+ );
262
+ const created = label === undefined ? edge : { ...edge, label };
263
+ edges.set(created.id, created);
264
+ return created;
265
+ };
266
+
267
+ /**
268
+ * Moves a connection to new ends (dragged to another node), keeping it the same connection. Its label follows
269
+ * the new ends if it was EventCatalog's wording, and is kept if someone wrote it.
270
+ */
271
+ export const reconnectEdge = (doc: Y.Doc, edgeId: string, connection: Connection): Edge | { error: string } =>
272
+ doc.transact(() => {
273
+ const { nodes, edges } = getCanvasMaps(doc);
274
+ const edge = edges.get(edgeId);
275
+ const source = nodes.get(connection.source);
276
+ const target = nodes.get(connection.target);
277
+ if (!edge) return { error: `Connection "${edgeId}" is not on the canvas` };
278
+ if (!source || !target) return { error: 'Connections join two nodes on the canvas' };
279
+ if (source.id === target.id) return { error: 'A node cannot be connected to itself' };
280
+ const duplicate = Array.from(edges.values()).find(
281
+ (other) => other.id !== edgeId && other.source === source.id && other.target === target.id
282
+ );
283
+ if (duplicate) return { error: 'Those nodes are already connected' };
284
+
285
+ const defaultLabel = getEdgeLabel(nodes.get(edge.source)?.type, nodes.get(edge.target)?.type);
286
+ const moved = createEdge(connection, source.type, target.type);
287
+ const reconnected: Edge = {
288
+ ...moved,
289
+ id: edge.id,
290
+ ...(edge.label !== undefined && edge.label !== defaultLabel && { label: edge.label }),
291
+ };
292
+ edges.set(edgeId, reconnected);
293
+ return reconnected;
294
+ });
295
+
296
+ export const deleteEdges = (doc: Y.Doc, ids: string[]) =>
297
+ doc.transact(() => ids.forEach((id) => getCanvasMaps(doc).edges.delete(id)));
298
+
299
+ /** Delete nodes (containers with what's in them), the edges to them, and leave their comments where they were */
300
+ export const deleteNodes = (doc: Y.Doc, ids: string[]) =>
301
+ doc.transact(() => {
302
+ const { nodes, edges, threads } = getCanvasMaps(doc);
303
+ const all = Array.from(nodes.values());
304
+ const lookup = new Map(all.map((node) => [node.id, node]));
305
+ const removed = withDescendants(ids, all);
306
+ threads.forEach((thread) => {
307
+ const nodeId = thread.get('nodeId') as string | undefined;
308
+ const node = nodeId && removed.has(nodeId) ? nodes.get(nodeId) : undefined;
309
+ if (!node) return;
310
+ const offset = (thread.get('offset') as XYPosition) ?? { x: 0, y: 0 };
311
+ const position = getAbsolutePosition(node, lookup);
312
+ thread.set('position', { x: position.x + offset.x, y: position.y + offset.y });
313
+ thread.delete('nodeId');
314
+ thread.delete('offset');
315
+ });
316
+ removed.forEach((id) => nodes.delete(id));
317
+ edges.forEach((edge, id) => {
318
+ if (removed.has(edge.source) || removed.has(edge.target)) edges.delete(id);
319
+ });
320
+ });
321
+
322
+ // ---- Comments ----
323
+ // Each thread is a nested Y.Map and its messages a Y.Array, so concurrent replies (and resolving while
324
+ // someone replies) all merge.
325
+
326
+ export const readThreads = (doc: Y.Doc) =>
327
+ Array.from(getCanvasMaps(doc).threads.values())
328
+ .map((thread) => thread.toJSON() as Thread)
329
+ .sort((a, b) => a.createdAt - b.createdAt);
330
+
331
+ const anchorEntries = (anchor: CommentAnchor): [string, unknown][] => [
332
+ ['position', anchor.position],
333
+ ...(anchor.nodeId && anchor.offset
334
+ ? ([
335
+ ['nodeId', anchor.nodeId],
336
+ ['offset', anchor.offset],
337
+ ] as [string, unknown][])
338
+ : []),
339
+ ];
340
+
341
+ export const createThread = (doc: Y.Doc, anchor: CommentAnchor, text: string, author: Author) => {
342
+ const id = shortId();
343
+ const now = Date.now();
344
+ const messages = new Y.Array<Message>();
345
+ messages.push([{ id: shortId(), text, author, createdAt: now }]);
346
+ const thread = new Y.Map<unknown>([
347
+ ['id', id],
348
+ ...anchorEntries(anchor),
349
+ ['author', author],
350
+ ['createdAt', now],
351
+ ['resolved', false],
352
+ ['messages', messages],
353
+ ]);
354
+ getCanvasMaps(doc).threads.set(id, thread);
355
+ return id;
356
+ };
357
+
358
+ const getThread = (doc: Y.Doc, id: string) => getCanvasMaps(doc).threads.get(id);
359
+
360
+ export const replyToThread = (doc: Y.Doc, threadId: string, text: string, author: Author) => {
361
+ const messages = getThread(doc, threadId)?.get('messages') as Y.Array<Message> | undefined;
362
+ messages?.push([{ id: shortId(), text, author, createdAt: Date.now() }]);
363
+ return !!messages;
364
+ };
365
+
366
+ export const setThreadResolved = (doc: Y.Doc, threadId: string, resolved: boolean) => {
367
+ const thread = getThread(doc, threadId);
368
+ thread?.set('resolved', resolved);
369
+ return !!thread;
370
+ };
371
+
372
+ /** Move a thread's pin, attaching it to a node (anchor.nodeId) or to the canvas */
373
+ export const moveThread = (doc: Y.Doc, threadId: string, anchor: CommentAnchor) => {
374
+ const thread = getThread(doc, threadId);
375
+ if (!thread) return false;
376
+ doc.transact(() => {
377
+ thread.set('position', anchor.position);
378
+ if (anchor.nodeId && anchor.offset) {
379
+ thread.set('nodeId', anchor.nodeId);
380
+ thread.set('offset', anchor.offset);
381
+ } else {
382
+ thread.delete('nodeId');
383
+ thread.delete('offset');
384
+ }
385
+ });
386
+ return true;
387
+ };
388
+
389
+ export const deleteThread = (doc: Y.Doc, threadId: string) => {
390
+ const { threads } = getCanvasMaps(doc);
391
+ const exists = threads.has(threadId);
392
+ threads.delete(threadId);
393
+ return exists;
394
+ };
395
+
396
+ /** Deletes comment threads in one change (one undo brings them all back) */
397
+ export const deleteThreads = (doc: Y.Doc, threadIds: string[]) =>
398
+ doc.transact(() => threadIds.forEach((id) => deleteThread(doc, id)));
399
+
400
+ /** Moves comment threads in one change, e.g. ones that moved with what was dragged */
401
+ export const moveThreads = (doc: Y.Doc, moves: { threadId: string; anchor: CommentAnchor }[]) =>
402
+ doc.transact(() => moves.forEach(({ threadId, anchor }) => moveThread(doc, threadId, anchor)));
403
+
404
+ // ---- Reading a whole canvas ----
405
+
406
+ export const readCanvas = (doc: Y.Doc) => {
407
+ const { nodes, edges } = getCanvasMaps(doc);
408
+ return {
409
+ meta: readMeta(doc),
410
+ nodes: Array.from(nodes.values()),
411
+ edges: Array.from(edges.values()),
412
+ threads: readThreads(doc),
413
+ };
414
+ };
@@ -0,0 +1,53 @@
1
+ import type { Edge, Node } from '@xyflow/react';
2
+ import { getAbsolutePosition, sizeOf } from './grouping';
3
+ import { isGroupType } from './node-types';
4
+
5
+ /**
6
+ * A canvas drawn small, for lists of canvases: each node as a box where it is on the canvas (containers as outlines
7
+ * behind what's in them) and each connection as a line between the boxes' middles. In the canvas's own coordinates,
8
+ * starting at 0,0, to draw in an SVG with a viewBox of its width and height.
9
+ */
10
+ export type CanvasPreview = {
11
+ width: number;
12
+ height: number;
13
+ shapes: { x: number; y: number; width: number; height: number; type: string; container: boolean }[];
14
+ lines: { x1: number; y1: number; x2: number; y2: number }[];
15
+ };
16
+
17
+ /** Big canvases are drawn from their first few hundred nodes and connections: enough for a thumbnail */
18
+ const MAX_SHAPES = 300;
19
+ const MAX_LINES = 300;
20
+
21
+ export const getCanvasPreview = (nodes: Node[], edges: Edge[]): CanvasPreview | null => {
22
+ if (nodes.length === 0) return null;
23
+ const lookup = new Map(nodes.map((node) => [node.id, node]));
24
+ const boxes = new Map(
25
+ nodes.slice(0, MAX_SHAPES).map((node) => {
26
+ const { x, y } = getAbsolutePosition(node, lookup);
27
+ return [node.id, { x, y, ...sizeOf(node), type: node.type ?? 'node', container: isGroupType(node.type) }];
28
+ })
29
+ );
30
+ const all = [...boxes.values()];
31
+ const left = Math.min(...all.map((box) => box.x));
32
+ const top = Math.min(...all.map((box) => box.y));
33
+ const right = Math.max(...all.map((box) => box.x + box.width));
34
+ const bottom = Math.max(...all.map((box) => box.y + box.height));
35
+ const middle = (id: string) => {
36
+ const box = boxes.get(id);
37
+ return box && { x: box.x - left + box.width / 2, y: box.y - top + box.height / 2 };
38
+ };
39
+
40
+ return {
41
+ width: Math.max(right - left, 1),
42
+ height: Math.max(bottom - top, 1),
43
+ // Containers first, so what's in them is drawn over them
44
+ shapes: all
45
+ .map((box) => ({ ...box, x: box.x - left, y: box.y - top }))
46
+ .sort((a, b) => Number(b.container) - Number(a.container)),
47
+ lines: edges.slice(0, MAX_LINES).flatMap((edge) => {
48
+ const from = middle(edge.source);
49
+ const to = middle(edge.target);
50
+ return from && to ? [{ x1: from.x, y1: from.y, x2: to.x, y2: to.y }] : [];
51
+ }),
52
+ };
53
+ };
@@ -0,0 +1,155 @@
1
+ import { getDomains } from '@utils/collections/domains';
2
+ import { getSystems } from '@utils/collections/systems';
3
+ import { getServices } from '@utils/collections/services';
4
+ import { getEvents } from '@utils/collections/events';
5
+ import { getCommands } from '@utils/collections/commands';
6
+ import { getQueries } from '@utils/collections/queries';
7
+ import { getChannels } from '@utils/collections/channels';
8
+ import { getContainers } from '@utils/collections/containers';
9
+ import { buildUrl } from '@utils/url-builder';
10
+
11
+ /** A catalog resource that can be dropped onto the canvas (latest version only) */
12
+ export type CatalogResource = {
13
+ /** `${collection}:${id}`, stored on the canvas node so relationships can be found */
14
+ key: string;
15
+ collection: string;
16
+ id: string;
17
+ version: string;
18
+ name: string;
19
+ summary?: string;
20
+ url: string;
21
+ node: { type: string; data: Record<string, unknown> };
22
+ };
23
+
24
+ /** A relationship between two catalog resources, by key */
25
+ export type CatalogRelation = { source: string; target: string; label?: string };
26
+
27
+ const keyOf = (collection: string, id: string) => `${collection}:${id}`;
28
+
29
+ /** The collection helpers hydrate references (e.g. a domain's services) into entries; their types describe references */
30
+ type HydratedEntry = { data: { id: string } };
31
+ const isHydratedEntry = (value: unknown): value is HydratedEntry => {
32
+ if (typeof value !== 'object' || value === null || !('data' in value)) return false;
33
+ const { data } = value;
34
+ return typeof data === 'object' && data !== null && 'id' in data && typeof data.id === 'string';
35
+ };
36
+ const entries = (value: unknown): HydratedEntry[] => (Array.isArray(value) ? value.filter(isHydratedEntry) : []);
37
+ const idsOf = (value: unknown) => entries(value).map((entry) => entry.data.id);
38
+
39
+ const toResource = (
40
+ collection: string,
41
+ entry: { data: { id: string; version: string; name?: string; summary?: string } },
42
+ node: CatalogResource['node']
43
+ ): CatalogResource => ({
44
+ key: keyOf(collection, entry.data.id),
45
+ collection,
46
+ id: entry.data.id,
47
+ version: entry.data.version,
48
+ name: entry.data.name ?? entry.data.id,
49
+ summary: entry.data.summary,
50
+ url: buildUrl(`/docs/${collection}/${entry.data.id}/${entry.data.version}`),
51
+ node,
52
+ });
53
+
54
+ // Visualiser node data needs plain fields (not collection entries)
55
+ const basics = ({ data }: { data: { id: string; version: string; name?: string; summary?: string } }) => ({
56
+ id: data.id,
57
+ version: data.version,
58
+ name: data.name ?? data.id,
59
+ summary: data.summary ?? '',
60
+ });
61
+
62
+ export const getCatalogResources = async (): Promise<{ resources: CatalogResource[]; relations: CatalogRelation[] }> => {
63
+ const [domains, systems, services, events, commands, queries, channels, containers] = await Promise.all([
64
+ getDomains({ getAllVersions: false }),
65
+ getSystems({ getAllVersions: false }),
66
+ getServices({ getAllVersions: false }),
67
+ getEvents({ getAllVersions: false, hydrateServices: false }),
68
+ getCommands({ getAllVersions: false, hydrateServices: false }),
69
+ getQueries({ getAllVersions: false, hydrateServices: false }),
70
+ getChannels({ getAllVersions: false }),
71
+ getContainers({ getAllVersions: false }),
72
+ ]);
73
+
74
+ const messages = [
75
+ ...events.map((entry) => ({ entry, collection: 'events', type: 'event' })),
76
+ ...commands.map((entry) => ({ entry, collection: 'commands', type: 'command' })),
77
+ ...queries.map((entry) => ({ entry, collection: 'queries', type: 'query' })),
78
+ ];
79
+ const messageCollection = new Map(messages.map(({ entry, collection }) => [entry.data.id, collection]));
80
+
81
+ const resources: CatalogResource[] = [
82
+ ...domains.map((domain) =>
83
+ toResource('domains', domain, {
84
+ type: 'context-domain',
85
+ data: {
86
+ mode: 'full',
87
+ navigable: false,
88
+ domain: basics(domain),
89
+ systemsCount: idsOf(domain.data.systems).length,
90
+ servicesCount: idsOf(domain.data.services).length,
91
+ entitiesCount: idsOf(domain.data.entities).length,
92
+ },
93
+ })
94
+ ),
95
+ ...systems.map((system) =>
96
+ toResource('systems', system, {
97
+ type: 'system',
98
+ data: {
99
+ mode: 'full',
100
+ navigable: false,
101
+ system: { ...basics(system), scope: system.data.scope },
102
+ servicesCount: idsOf(system.data.services).length,
103
+ containersCount: idsOf(system.data.containers).length,
104
+ },
105
+ })
106
+ ),
107
+ ...services.map((service) =>
108
+ toResource('services', service, { type: 'service', data: { mode: 'full', service: basics(service) } })
109
+ ),
110
+ ...messages.map(({ entry, collection, type }) =>
111
+ toResource(collection, entry, { type, data: { mode: 'full', message: basics(entry) } })
112
+ ),
113
+ ...channels.map((channel) =>
114
+ toResource('channels', channel, {
115
+ type: 'channel',
116
+ data: { mode: 'full', channel: { ...basics(channel), protocols: channel.data.protocols ?? [] } },
117
+ })
118
+ ),
119
+ ...containers.map((container) =>
120
+ toResource('containers', container, {
121
+ type: 'data',
122
+ data: { mode: 'full', data: { ...basics(container), container_type: container.data.container_type } },
123
+ })
124
+ ),
125
+ ];
126
+
127
+ const relations: CatalogRelation[] = [];
128
+ const relate = (source: string, target: string, label?: string) => relations.push({ source, target, label });
129
+
130
+ for (const domain of domains) {
131
+ for (const id of idsOf(domain.data.systems)) relate(keyOf('domains', domain.data.id), keyOf('systems', id), 'contains');
132
+ for (const id of idsOf(domain.data.services)) relate(keyOf('domains', domain.data.id), keyOf('services', id), 'contains');
133
+ }
134
+ for (const system of systems) {
135
+ for (const id of idsOf(system.data.services)) relate(keyOf('systems', system.data.id), keyOf('services', id), 'contains');
136
+ }
137
+ for (const service of services) {
138
+ const serviceKey = keyOf('services', service.data.id);
139
+ for (const id of idsOf(service.data.sends)) {
140
+ const collection = messageCollection.get(id);
141
+ if (collection) relate(serviceKey, keyOf(collection, id));
142
+ }
143
+ for (const id of idsOf(service.data.receives)) {
144
+ const collection = messageCollection.get(id);
145
+ if (collection) relate(keyOf(collection, id), serviceKey);
146
+ }
147
+ // A data store it both writes to and reads from is one connection (like the visualiser), not two on top of each other
148
+ const reads = new Set(idsOf(service.data.readsFrom));
149
+ const writes = new Set(idsOf(service.data.writesTo));
150
+ for (const id of writes) relate(serviceKey, keyOf('containers', id), reads.has(id) ? 'reads and writes' : undefined);
151
+ for (const id of reads) if (!writes.has(id)) relate(keyOf('containers', id), serviceKey);
152
+ }
153
+
154
+ return { resources, relations };
155
+ };