@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9

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 (95) hide show
  1. package/LICENSE +227 -128
  2. package/dist/analytics/analytics.cjs +18 -3
  3. package/dist/analytics/analytics.d.cts +1 -1
  4. package/dist/analytics/analytics.d.ts +1 -1
  5. package/dist/analytics/analytics.js +3 -2
  6. package/dist/analytics/log-build.cjs +70 -34
  7. package/dist/analytics/log-build.js +5 -4
  8. package/dist/analytics/telemetry.cjs +42 -0
  9. package/dist/analytics/telemetry.d.cts +20 -0
  10. package/dist/analytics/telemetry.d.ts +20 -0
  11. package/dist/analytics/telemetry.js +8 -0
  12. package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
  13. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  14. package/dist/chunk-CPW6G62V.js +17 -0
  15. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  16. package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
  17. package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
  18. package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
  19. package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +207 -123
  23. package/dist/eventcatalog.config.d.cts +55 -0
  24. package/dist/eventcatalog.config.d.ts +55 -0
  25. package/dist/eventcatalog.js +39 -19
  26. package/dist/features.cjs +8 -2
  27. package/dist/features.d.cts +3 -1
  28. package/dist/features.d.ts +3 -1
  29. package/dist/features.js +5 -3
  30. package/dist/federation/federate.js +4 -4
  31. package/dist/generate.cjs +1 -1
  32. package/dist/generate.js +3 -3
  33. package/dist/utils/cli-logger.cjs +1 -1
  34. package/dist/utils/cli-logger.js +2 -2
  35. package/dist/utils/license-status.cjs +70 -44
  36. package/dist/utils/license-status.d.cts +14 -5
  37. package/dist/utils/license-status.d.ts +14 -5
  38. package/dist/utils/license-status.js +1 -1
  39. package/eventcatalog/integrations/studio-server.mjs +15 -3
  40. package/eventcatalog/src/components/Header.astro +3 -3
  41. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  42. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  43. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  44. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  45. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  46. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  47. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  48. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  49. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  50. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  51. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  52. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  53. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  54. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  55. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  56. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  57. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  58. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  59. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  60. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  61. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  62. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  63. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  64. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  65. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  66. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  67. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  68. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  69. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  70. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  71. package/eventcatalog/src/features/studio/edges.ts +2 -0
  72. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  73. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  74. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  75. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  76. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  77. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  78. package/eventcatalog/src/features/studio/identity.ts +35 -0
  79. package/eventcatalog/src/features/studio/layout.ts +7 -4
  80. package/eventcatalog/src/features/studio/levels.ts +138 -72
  81. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  82. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  83. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  84. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  85. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  86. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  87. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  88. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  89. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  90. package/eventcatalog/src/features/studio/studio.css +184 -0
  91. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  92. package/eventcatalog/src/utils/feature.ts +7 -3
  93. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  94. package/package.json +3 -3
  95. package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
@@ -11,7 +11,17 @@ import {
11
11
  CANVAS_VIEW_TOOL,
12
12
  type CanvasPayload,
13
13
  } from '@features/mcp/apps/canvas/shared';
14
- import { getCanvasMaps, readCanvas, readMeta, replyToThread, setMeta, setThreadResolved, type Author } from '../canvas-doc';
14
+ import {
15
+ CANVAS_STATUSES,
16
+ getCanvasMaps,
17
+ readCanvas,
18
+ readMeta,
19
+ reopeningIfEdited,
20
+ replyToThread,
21
+ setCanvasStatus,
22
+ setThreadResolved,
23
+ type Author,
24
+ } from '../canvas-doc';
15
25
  import { ADD_TO_CANVAS_DESCRIPTION, describeCanvas, edgeSpecsSchema, indexCatalog, nodeSpecsSchema } from '../canvas-actions';
16
26
  import { getCatalogResources } from '../catalog-resources';
17
27
  import { getLayoutPositions } from '../layout';
@@ -76,6 +86,7 @@ export const CANVAS_TOOL_NAMES = [
76
86
  'connectCanvasNodes',
77
87
  'removeFromCanvas',
78
88
  'layoutCanvas',
89
+ 'setCanvasStatus',
79
90
  'commentOnCanvas',
80
91
  'replyToCanvasComment',
81
92
  'resolveCanvasComment',
@@ -92,12 +103,8 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
92
103
  const missing = (id: string) =>
93
104
  text({ error: `No canvas "${id}". Use listCanvases to find one, or openCanvas to start one.` }, true);
94
105
 
95
- const createCanvas = async (title: string, name?: string) => {
96
- const id = crypto.randomUUID();
97
- const agent = getAgent(name, userAgent);
98
- await runtime.withCanvas(id, (doc) => setMeta(doc, { title, createdAt: Date.now(), createdBy: agent.name }));
99
- return id;
100
- };
106
+ const createCanvas = (title: string, name?: string) =>
107
+ runtime.createCanvas({ title, createdBy: getAgent(name, userAgent).name });
101
108
 
102
109
  /**
103
110
  * Work on a canvas as an agent: changes are played out like a person would make them (see agent-choreography),
@@ -105,13 +112,15 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
105
112
  */
106
113
  const asAgent = (id: string, name: string | undefined) => {
107
114
  const agent = getAgent(name, userAgent);
115
+ const author = { ...agent, agent: true } as Author;
108
116
  const stage: AgentStage = {
109
- change: (fn) => runtime.withCanvas(id, fn),
117
+ // An agent changing an accepted (or rejected) canvas makes it a draft again, like a person doing it
118
+ change: (fn) => runtime.withCanvas(id, (doc) => reopeningIfEdited(doc, author, () => fn(doc))),
110
119
  present: (presence) => runtime.setAgentPresence(id, agent, presence),
111
120
  lastPointer: runtime.getAgentPointer(id, agent),
112
121
  sharesMoves: true,
113
122
  };
114
- return { stage, author: { ...agent, agent: true } as Author };
123
+ return { stage, author };
115
124
  };
116
125
 
117
126
  // ---- The canvas in the chat (MCP App) ----
@@ -168,9 +177,10 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
168
177
  const canvases = runtime
169
178
  .listCanvases()
170
179
  .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
171
- .map(({ canvasId: id, title, people, nodeCount, openComments }) => ({
180
+ .map(({ canvasId: id, title, status, people, nodeCount, openComments }) => ({
172
181
  canvasId: id,
173
182
  title,
183
+ status,
174
184
  people,
175
185
  nodeCount,
176
186
  openComments,
@@ -205,7 +215,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
205
215
  title,
206
216
  url: canvasUrl(id),
207
217
  people: runtime.peopleOn(id),
208
- note: 'Clients that support MCP Apps are showing the user this canvas, live. Work on it with them using the canvas tools (getCanvas, addToCanvas, commentOnCanvas...): they see your changes as you make them. Otherwise, share the url so they can open it.',
218
+ note: 'Clients that support MCP Apps are showing the user this canvas, live. Work on it with them using the canvas tools (getCanvas, addToCanvas...): they see your changes as you make them. Talk with them in the conversation, not in canvas comments (only comment when they ask you to). Otherwise, share the url so they can open it.',
209
219
  },
210
220
  label: `the canvas ${title ?? id}`,
211
221
  getPayload: async (): Promise<CanvasPayload> => payload,
@@ -301,6 +311,30 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
301
311
  }
302
312
  );
303
313
 
314
+ server.registerTool(
315
+ 'setCanvasStatus',
316
+ {
317
+ title: 'Set a canvas status',
318
+ description: [
319
+ "Changes a canvas's status: draft (being worked on, where every canvas starts), proposed (ready for review), accepted (the agreed design) or rejected (decided against).",
320
+ 'Only change it when the user asks you to. Changing the nodes or connections on an accepted or rejected canvas makes it a draft again.',
321
+ ].join(' '),
322
+ inputSchema: z.object({
323
+ canvasId,
324
+ status: z.enum(CANVAS_STATUSES),
325
+ note: z.string().optional().describe('Why, e.g. "Agreed in the architecture review"'),
326
+ agentName,
327
+ }),
328
+ annotations: writes,
329
+ },
330
+ async ({ canvasId: id, status, note, agentName: name }) => {
331
+ if (!runtime.exists(id)) return missing(id);
332
+ const { author } = asAgent(id, name);
333
+ const changed = await runtime.withCanvas(id, (doc) => setCanvasStatus(doc, status, author, note));
334
+ return text({ canvasId: id, status, ...(!changed && { note: `The canvas was already ${status}` }) });
335
+ }
336
+ );
337
+
304
338
  server.registerTool(
305
339
  'addToCanvas',
306
340
  {
@@ -322,12 +356,13 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
322
356
  {
323
357
  title: 'Update a node on a canvas',
324
358
  description:
325
- 'Renames, re-describes or moves a node on a canvas (x/y: its new centre). Catalog resources keep their catalog name and summary; change those in the catalog. For notes, summary is the note text.',
359
+ 'Renames, re-describes, re-versions or moves a node on a canvas (x/y: its new centre). Catalog resources keep their catalog name, summary and version; change those in the catalog. For notes, summary is the note text.',
326
360
  inputSchema: z.object({
327
361
  canvasId,
328
362
  nodeId: z.string(),
329
363
  name: z.string().optional(),
330
364
  summary: z.string().optional(),
365
+ version: z.string().optional().describe('e.g. 1.0.0'),
331
366
  x: z.number().optional(),
332
367
  y: z.number().optional(),
333
368
  agentName,
@@ -401,7 +436,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
401
436
  {
402
437
  title: 'Comment on a canvas',
403
438
  description:
404
- 'Starts a comment thread on a canvas, pinned to a node (nodeId) or a spot (x, y). Use it to ask questions, flag concerns or explain your suggestions where people can see them.',
439
+ "Starts a comment thread on a canvas, pinned to a node (nodeId) or a spot (x, y). Only use it when the user asks you to comment (e.g. to review the canvas or leave notes for others on it). Don't use comments to greet people, introduce yourself or ask the user questions: talk to them in the conversation.",
405
440
  inputSchema: z.object({
406
441
  canvasId,
407
442
  text: z.string(),
@@ -4,8 +4,20 @@ import * as Y from 'yjs';
4
4
  import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
5
5
  import type { XYPosition } from '@xyflow/react';
6
6
  import type { AgentPresence } from '../agent-choreography';
7
- import { canvasDocumentName, canvasIdFromDocumentName, getCanvasMaps, readMeta } from '../canvas-doc';
7
+ import {
8
+ canvasDocumentName,
9
+ canvasIdFromDocumentName,
10
+ getCanvasMaps,
11
+ readMeta,
12
+ setMeta,
13
+ getCanvasStatus,
14
+ type CanvasStatus,
15
+ } from '../canvas-doc';
16
+ import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
8
17
  import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
18
+ import config from '../../../utils/eventcatalog-config/source';
19
+ import { isAuthEnabled } from '../../../utils/feature';
20
+ import { createStorage, memoryStorage, type CanvasStorage, type StudioStorageConfig } from './storage';
9
21
 
10
22
  export const STUDIO_SOCKET_PATH = '/_eventcatalog/studio';
11
23
  const SOCKET_PATH_PATTERN = /\/_eventcatalog\/studio\/?$/;
@@ -17,47 +29,92 @@ export type AgentIdentity = { name: string; color: string };
17
29
  export type CanvasSummary = {
18
30
  canvasId: string;
19
31
  title?: string;
32
+ status: CanvasStatus;
20
33
  createdAt?: number;
34
+ /** Who started it (a person, or an agent) */
35
+ createdBy?: string;
36
+ /** When anyone (a person, or an agent) last changed it */
37
+ updatedAt?: number;
21
38
  nodeCount: number;
22
39
  edgeCount: number;
23
40
  openComments: number;
24
41
  people: string[];
42
+ /** The canvas drawn small (asked for with `withPreview`, e.g. for the canvases page) */
43
+ preview?: CanvasPreview | null;
25
44
  };
26
45
 
27
46
  /**
28
- * What the collaboration server keeps for the life of the process: one Hocuspocus instance, and the canvases
29
- * in memory (a snapshot each time one is stored), so they last until the process restarts.
47
+ * What the collaboration server keeps for the life of the process: one Hocuspocus instance, and every canvas's
48
+ * latest snapshot in memory (read from storage when the server starts, and written through to it each time a
49
+ * canvas is stored), so listing canvases and opening one never wait on storage.
30
50
  */
31
51
  type RuntimeState = {
32
52
  server: Server;
33
53
  snapshots: Map<string, Uint8Array>;
54
+ /** When each canvas last changed (kept with it in storage) */
55
+ updatedAt: Map<string, number>;
34
56
  agents: Map<string, { awareness: Awareness; expires: ReturnType<typeof setTimeout> }>;
35
57
  attached: WeakSet<HttpServer>;
58
+ storage: CanvasStorage;
59
+ /** The storage config in use (to tell when it changes) */
60
+ storageKey?: string;
61
+ /** Settles once the stored canvases are in `snapshots` */
62
+ ready: Promise<void>;
63
+ /** Studio started (by startStudio): its storage loaded */
64
+ started?: Promise<void>;
65
+ savesOnExit: boolean;
66
+ /**
67
+ * Whether a collaboration connection comes from someone signed in, when sign-in is on. Given by the pages
68
+ * (see startStudio): Auth.js's config only loads in the app, not in the dev server's integration.
69
+ */
70
+ isSignedIn?: (request: Request) => Promise<boolean>;
36
71
  };
37
72
 
38
73
  const createState = (): RuntimeState => {
39
- const snapshots = new Map<string, Uint8Array>();
40
- return {
41
- snapshots,
74
+ const state: RuntimeState = {
75
+ snapshots: new Map(),
76
+ updatedAt: new Map(),
42
77
  agents: new Map(),
43
78
  attached: new WeakSet(),
79
+ storage: memoryStorage(),
80
+ ready: Promise.resolve(),
81
+ savesOnExit: false,
44
82
  server: new Server({
45
83
  quiet: true,
84
+ // With sign-in on, only signed-in people can open a canvas (refused until the pages have said how to check)
85
+ onConnect: async ({ request }) => {
86
+ if (!isAuthEnabled()) return;
87
+ if (!(await state.isSignedIn?.(request).catch(() => false))) throw new Error('Sign in to open this canvas');
88
+ },
46
89
  onLoadDocument: async ({ documentName, document }) => {
47
- const snapshot = snapshots.get(documentName);
48
- if (!snapshot) return;
49
90
  // A hook that throws leaves the document half loaded in Hocuspocus (ueberdosis/hocuspocus#1156)
50
91
  try {
51
- Y.applyUpdate(document, snapshot);
92
+ await state.ready;
93
+ const snapshot = state.snapshots.get(documentName);
94
+ if (snapshot) Y.applyUpdate(document, snapshot);
52
95
  } catch (error) {
53
96
  console.error(`[studio] Could not load canvas ${documentName}, starting it empty`, error);
54
97
  }
55
98
  },
56
- onStoreDocument: async ({ documentName, document }) => {
57
- snapshots.set(documentName, Y.encodeStateAsUpdate(document));
99
+ // Changes only: Hocuspocus starts listening once a canvas has loaded
100
+ onChange: async ({ documentName }) => {
101
+ state.updatedAt.set(documentName, Date.now());
58
102
  },
103
+ onStoreDocument: async ({ documentName, document }) => saveSnapshot(state, documentName, document),
59
104
  }),
60
105
  };
106
+ return state;
107
+ };
108
+
109
+ /** Keeps a canvas's snapshot, and writes it to storage (failing to store it doesn't lose it from memory) */
110
+ const saveSnapshot = (state: RuntimeState, documentName: string, document: Y.Doc) => {
111
+ const snapshot = Y.encodeStateAsUpdate(document);
112
+ state.snapshots.set(documentName, snapshot);
113
+ try {
114
+ state.storage.save(documentName, snapshot, state.updatedAt.get(documentName));
115
+ } catch (error) {
116
+ console.error(`[studio] Could not store canvas ${documentName} (${state.storage.type} storage)`, error);
117
+ }
61
118
  };
62
119
 
63
120
  /**
@@ -80,6 +137,89 @@ class StudioRuntime {
80
137
  return this.state.attached;
81
138
  }
82
139
 
140
+ /** Where canvases are kept (memory: until the server restarts) */
141
+ get storage(): Pick<CanvasStorage, 'type' | 'location'> {
142
+ const { type, location } = this.state.storage;
143
+ return { type, location };
144
+ }
145
+
146
+ /**
147
+ * Keeps canvases in the storage configured (`studio.storage`, memory by default), and loads the ones
148
+ * stored there. Set once when the server starts; calling it again with the same config does nothing.
149
+ */
150
+ async useStorage(config: StudioStorageConfig | undefined, projectDirectory: string) {
151
+ const key = JSON.stringify({ config: config ?? null, projectDirectory });
152
+ if (this.state.storageKey === key) return this.state.ready;
153
+ const storage = createStorage(config, projectDirectory);
154
+ this.state.storageKey = key;
155
+ this.state.storage = storage;
156
+ this.state.ready = storage.loadAll().then((stored) => {
157
+ // Canvases already open here (e.g. the dev server reloading) are kept as they are
158
+ stored.forEach(({ state, updatedAt }, name) => {
159
+ if (this.snapshots.has(name)) return;
160
+ this.snapshots.set(name, state);
161
+ if (updatedAt) this.state.updatedAt.set(name, updatedAt);
162
+ });
163
+ });
164
+ this.saveOnExit();
165
+ return this.state.ready;
166
+ }
167
+
168
+ /**
169
+ * Starts Studio once: canvases kept in the storage configured (in memory if it can't be used), and in production the
170
+ * collaboration connection on the server `eventcatalog start` runs (the dev server's integration attaches it there).
171
+ */
172
+ start() {
173
+ this.state.started ??= (async () => {
174
+ const projectDirectory = process.env.PROJECT_DIR || process.cwd();
175
+ try {
176
+ await this.useStorage(config.studio?.storage, projectDirectory);
177
+ } catch (error) {
178
+ console.error(
179
+ `[studio] Could not use the Studio storage configured, keeping canvases in memory: ${(error as Error).message}`
180
+ );
181
+ await this.useStorage({ type: 'memory' }, projectDirectory);
182
+ }
183
+ const httpServer = (globalThis as Record<symbol, unknown>)[HTTP_SERVER_KEY] as HttpServer | undefined;
184
+ if (httpServer && !this.attached.has(httpServer)) {
185
+ this.attach(httpServer);
186
+ const { type, location } = this.storage;
187
+ console.log(
188
+ type === 'memory'
189
+ ? `[studio] Collaboration server ready at ${STUDIO_SOCKET_PATH}. Canvases are kept in memory and lost when the server restarts or redeploys: set studio.storage in eventcatalog.config.js to keep them.`
190
+ : `[studio] Collaboration server ready at ${STUDIO_SOCKET_PATH} (canvases stored in ${location})`
191
+ );
192
+ }
193
+ })();
194
+ return this.state.started;
195
+ }
196
+
197
+ /**
198
+ * Canvases change a moment before they're stored (Hocuspocus waits for edits to settle): as the process exits,
199
+ * the open ones are stored straight away so the last edits aren't lost. Storage writes are synchronous for this.
200
+ */
201
+ private saveOnExit() {
202
+ if (this.state.savesOnExit) return;
203
+ this.state.savesOnExit = true;
204
+ const saveOpenCanvases = () =>
205
+ this.server.hocuspocus.documents.forEach((document, name) => {
206
+ saveSnapshot(this.state, name, document);
207
+ });
208
+ process.once('exit', saveOpenCanvases);
209
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
210
+ process.once(signal, () => {
211
+ saveOpenCanvases();
212
+ // Stopping as it would have without this (unless something else handles the signal)
213
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
214
+ });
215
+ }
216
+ }
217
+
218
+ /** How to tell whether a collaboration connection comes from someone signed in (checked when sign-in is on) */
219
+ useSignIn(isSignedIn: (request: Request) => Promise<boolean>) {
220
+ this.state.isSignedIn = isSignedIn;
221
+ }
222
+
83
223
  /** Handle collaboration WebSocket upgrades on an HTTP server (the dev server, or the production server) */
84
224
  attach(httpServer: HttpServer) {
85
225
  if (this.attached.has(httpServer)) return;
@@ -95,6 +235,15 @@ class StudioRuntime {
95
235
  return this.server.hocuspocus.documents.has(name) || this.snapshots.has(name);
96
236
  }
97
237
 
238
+ /** Starts a canvas with a title, recording when and by whom (a person, or an agent). Returns its id. */
239
+ async createCanvas({ title, createdBy }: { title?: string; createdBy?: string }) {
240
+ const canvasId = crypto.randomUUID();
241
+ await this.withCanvas(canvasId, (doc) =>
242
+ setMeta(doc, { ...(title && { title }), createdAt: Date.now(), ...(createdBy && { createdBy }) })
243
+ );
244
+ return canvasId;
245
+ }
246
+
98
247
  /** Read or change a canvas as the server. Changes sync to everyone on it, like anyone else's. */
99
248
  async withCanvas<T>(canvasId: string, fn: (doc: Y.Doc) => T): Promise<T> {
100
249
  const connection = await this.server.hocuspocus.openDirectConnection(canvasDocumentName(canvasId));
@@ -137,7 +286,7 @@ class StudioRuntime {
137
286
  return response;
138
287
  }
139
288
 
140
- listCanvases(): CanvasSummary[] {
289
+ listCanvases({ withPreview = false }: { withPreview?: boolean } = {}): CanvasSummary[] {
141
290
  const names = new Set([...this.server.hocuspocus.documents.keys(), ...this.snapshots.keys()]);
142
291
  return [...names].flatMap((name) => {
143
292
  const canvasId = canvasIdFromDocumentName(name);
@@ -151,11 +300,15 @@ class StudioRuntime {
151
300
  {
152
301
  canvasId,
153
302
  title: meta.title,
303
+ status: getCanvasStatus(meta),
154
304
  createdAt: meta.createdAt,
305
+ ...(meta.createdBy && { createdBy: meta.createdBy }),
306
+ updatedAt: this.state.updatedAt.get(name) ?? meta.createdAt,
155
307
  nodeCount: nodes.size,
156
308
  edgeCount: edges.size,
157
309
  openComments: Array.from(threads.values()).filter((thread) => !thread.get('resolved')).length,
158
310
  people: live ? this.peopleOn(canvasId) : [],
311
+ ...(withPreview && { preview: getCanvasPreview(Array.from(nodes.values()), Array.from(edges.values())) }),
159
312
  },
160
313
  ];
161
314
  });
@@ -205,11 +358,37 @@ class StudioRuntime {
205
358
  const RUNTIME_KEY = Symbol.for('eventcatalog.studio.runtime');
206
359
  const store = globalThis as typeof globalThis & { [RUNTIME_KEY]?: RuntimeState };
207
360
 
361
+ /**
362
+ * State kept from before a code change (the dev server reloads code but keeps globalThis) gets what's been added to it
363
+ * since. Hooks given to Hocuspocus when it was created (e.g. onChange, onConnect) only change on a restart.
364
+ */
365
+ const upgrade = (state: RuntimeState) => {
366
+ state.updatedAt ??= new Map();
367
+ return state;
368
+ };
369
+
208
370
  /** The process wide collaboration runtime (created on first use) */
209
- export const getStudioRuntime = (): StudioRuntime => new StudioRuntime((store[RUNTIME_KEY] ??= createState()));
371
+ export const getStudioRuntime = (): StudioRuntime => new StudioRuntime(upgrade((store[RUNTIME_KEY] ??= createState())));
372
+
373
+ /**
374
+ * In production, `eventcatalog start` leaves the HTTP server it runs here, so Studio's collaboration connection can be
375
+ * served on it (Astro's Node adapter doesn't expose it otherwise)
376
+ */
377
+ const HTTP_SERVER_KEY = Symbol.for('eventcatalog.http-server');
378
+
379
+ /**
380
+ * Studio, started: what pages and tools use before working with canvases. Pages say how to tell who's signed in,
381
+ * for the collaboration connection.
382
+ */
383
+ export const startStudio = async ({ isSignedIn }: { isSignedIn?: (request: Request) => Promise<boolean> } = {}) => {
384
+ const runtime = getStudioRuntime();
385
+ if (isSignedIn) runtime.useSignIn(isSignedIn);
386
+ await runtime.start();
387
+ return runtime;
388
+ };
210
389
 
211
390
  /** The runtime if the collaboration server is running in this process */
212
391
  export const findStudioRuntime = (): StudioRuntime | undefined => {
213
392
  const state = store[RUNTIME_KEY];
214
- return state ? new StudioRuntime(state) : undefined;
393
+ return state ? new StudioRuntime(upgrade(state)) : undefined;
215
394
  };
@@ -0,0 +1,18 @@
1
+ import { getSession } from '@utils/auth-astro/server';
2
+ import type { NormalizedUser } from '@features/auth/middleware/middleware-auth';
3
+
4
+ /** Someone signed in with the catalog's sign-in (SSO): Studio shows them by these, and they can't change them */
5
+ export type SignedInUser = { name: string; picture?: string };
6
+
7
+ /** For the collaboration connection (see startStudio): it has the same cookies as the pages */
8
+ export const isSignedIn = async (request: Request) => Boolean(await getSession(request));
9
+
10
+ /** Who's signed in, from what the sign-in middleware found (nobody when sign-in is off) */
11
+ export const getSignedInUser = (locals: object): SignedInUser | undefined => {
12
+ const user = (locals as { user?: NormalizedUser }).user;
13
+ const name = user?.name || user?.email;
14
+ if (!user || !name) return undefined;
15
+ // Pictures are loaded from the provider (Auth.js calls it `image`), over HTTPS only
16
+ const picture = user.picture ?? user.raw?.user?.image;
17
+ return { name, ...(typeof picture === 'string' && picture.startsWith('https://') && { picture }) };
18
+ };
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Where Studio keeps canvases (`studio.storage` in eventcatalog.config.js). People and agents always work on the copy
6
+ * the collaboration server holds in memory, so storage is only read when the server starts, and written a moment
7
+ * after a canvas changes: it doesn't slow down editing. Canvases are stored as their whole Yjs document
8
+ * (Y.encodeStateAsUpdate), so nothing is lost between restarts.
9
+ */
10
+ export type StudioStorageConfig =
11
+ /** Kept only while the server runs: canvases are lost when it restarts or redeploys (the default) */
12
+ | { type: 'memory' }
13
+ /**
14
+ * One SQLite database file (default `.eventcatalog/studio.db` in the catalog). Needs Node.js 22.13 or later. In a
15
+ * container, put it on a volume so it survives redeploys.
16
+ */
17
+ | { type: 'sqlite'; path?: string };
18
+
19
+ export type StorageType = StudioStorageConfig['type'];
20
+
21
+ export const DEFAULT_STORAGE: StudioStorageConfig = { type: 'memory' };
22
+ const DEFAULT_SQLITE_PATH = '.eventcatalog/studio.db';
23
+
24
+ /** A canvas as stored: its whole document, and when it last changed */
25
+ export type StoredCanvas = { state: Uint8Array; updatedAt?: number };
26
+
27
+ export type CanvasStorage = {
28
+ type: StorageType;
29
+ /** Where it is, for logs (e.g. the database file) */
30
+ location?: string;
31
+ /** Every stored canvas, by document name (read once, when the server starts) */
32
+ loadAll: () => Promise<Map<string, StoredCanvas>>;
33
+ /**
34
+ * Stores a canvas's document, with when it last changed (now when not known). Synchronous, so canvases can still
35
+ * be saved as the process exits.
36
+ */
37
+ save: (documentName: string, state: Uint8Array, updatedAt?: number) => void;
38
+ };
39
+
40
+ export const memoryStorage = (): CanvasStorage => ({
41
+ type: 'memory',
42
+ loadAll: async () => new Map(),
43
+ save: () => {},
44
+ });
45
+
46
+ type SqliteDatabase = {
47
+ exec: (sql: string) => void;
48
+ prepare: (sql: string) => { run: (...values: unknown[]) => unknown; all: () => unknown[] };
49
+ };
50
+
51
+ export const sqliteStorage = (file: string): CanvasStorage => {
52
+ let database: SqliteDatabase | undefined;
53
+ let upsert: ReturnType<SqliteDatabase['prepare']> | undefined;
54
+ return {
55
+ type: 'sqlite',
56
+ location: file,
57
+ loadAll: async () => {
58
+ // Node's built-in SQLite (no native package to install), asked of Node itself: the dev server loads this
59
+ // through Vite, which doesn't know node:sqlite and fails to import it
60
+ type Sqlite = { DatabaseSync: new (file: string) => SqliteDatabase };
61
+ const getBuiltinModule = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
62
+ let sqlite: Sqlite | undefined;
63
+ try {
64
+ sqlite = getBuiltinModule
65
+ ? (getBuiltinModule('node:sqlite') as Sqlite | undefined)
66
+ : await import(/* @vite-ignore */ ['node', 'sqlite'].join(':'));
67
+ } catch (error) {
68
+ throw new Error(`Could not load node:sqlite (${(error as Error).message})`);
69
+ }
70
+ if (!sqlite?.DatabaseSync) {
71
+ throw new Error(
72
+ `SQLite storage needs Node.js 22.13 or later (it uses node:sqlite); this is Node.js ${process.versions.node}`
73
+ );
74
+ }
75
+ fs.mkdirSync(path.dirname(file), { recursive: true });
76
+ database = new sqlite.DatabaseSync(file);
77
+ database.exec('PRAGMA journal_mode = WAL');
78
+ database.exec(
79
+ 'CREATE TABLE IF NOT EXISTS canvases (name TEXT PRIMARY KEY, state BLOB NOT NULL, updated_at INTEGER NOT NULL)'
80
+ );
81
+ upsert = database.prepare(
82
+ 'INSERT INTO canvases (name, state, updated_at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at'
83
+ );
84
+ const rows = database.prepare('SELECT name, state, updated_at FROM canvases').all() as {
85
+ name: string;
86
+ state: Uint8Array;
87
+ updated_at: number;
88
+ }[];
89
+ return new Map(rows.map((row) => [row.name, { state: new Uint8Array(row.state), updatedAt: Number(row.updated_at) }]));
90
+ },
91
+ save: (documentName, state, updatedAt = Date.now()) => {
92
+ if (!upsert) throw new Error('SQLite storage was used before it was opened');
93
+ upsert.run(documentName, state, updatedAt);
94
+ },
95
+ };
96
+ };
97
+
98
+ /** The storage a config asks for, with paths relative to the catalog. Unknown types are an error. */
99
+ export const createStorage = (config: StudioStorageConfig | undefined, projectDirectory: string): CanvasStorage => {
100
+ const chosen = config ?? DEFAULT_STORAGE;
101
+ const resolve = (location: string | undefined, fallback: string) => path.resolve(projectDirectory, location ?? fallback);
102
+ switch (chosen.type) {
103
+ case 'memory':
104
+ return memoryStorage();
105
+ case 'sqlite':
106
+ return sqliteStorage(resolve(chosen.path, DEFAULT_SQLITE_PATH));
107
+ default:
108
+ throw new Error(`Unknown studio.storage type "${(chosen as { type?: string }).type}": use "memory" or "sqlite"`);
109
+ }
110
+ };