@tuongaz/seeflow 0.1.3

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.
@@ -0,0 +1,1192 @@
1
+ // Shared inner helpers that REST handlers in api.ts and MCP tool handlers in
2
+ // mcp.ts both call. Each helper returns an Outcome discriminated union so the
3
+ // caller layer can translate it into its native response shape (HTTP status
4
+ // vs. MCP CallToolResult) without duplicating any of the business logic.
5
+ //
6
+ // Helpers extracted in US-002: discovery + project setup (5 tools).
7
+ // Helpers extracted in US-003: node lifecycle (add/delete/move/reorder).
8
+ // Future stories add patch_node + connector helpers alongside these.
9
+
10
+ import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, isAbsolute, join } from 'node:path';
13
+ import { type ZodIssue, z } from 'zod';
14
+ import { type Registry, slugify } from './registry.ts';
15
+ import {
16
+ ColorTokenSchema,
17
+ type Demo,
18
+ DemoSchema,
19
+ EdgePinSchema,
20
+ SourceHandleIdSchema,
21
+ TargetHandleIdSchema,
22
+ } from './schema.ts';
23
+ import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
24
+ import type { DemoSnapshot, DemoWatcher } from './watcher.ts';
25
+
26
+ const DEFAULT_DEMO_RELATIVE_PATH = '.seeflow/seeflow.json';
27
+
28
+ export const RegisterBodySchema = z.object({
29
+ name: z.string().min(1).optional(),
30
+ repoPath: z.string().min(1),
31
+ demoPath: z.string().min(1),
32
+ });
33
+ export type RegisterBody = z.infer<typeof RegisterBodySchema>;
34
+
35
+ export const CreateProjectBodySchema = z.object({
36
+ name: z.string().min(1),
37
+ });
38
+ export type CreateProjectBody = z.infer<typeof CreateProjectBodySchema>;
39
+
40
+ export const PositionBodySchema = z.object({
41
+ x: z.number().finite(),
42
+ y: z.number().finite(),
43
+ });
44
+ export type PositionBody = z.infer<typeof PositionBodySchema>;
45
+
46
+ // Reorder a node within `demo.nodes[]`. The four ops mirror the typical
47
+ // "send backward / bring forward / to back / to front" actions; `toIndex`
48
+ // pins the node back to a captured absolute index so undo for `forward` /
49
+ // `backward` from the middle is faithful even under concurrent edits.
50
+ export const ReorderBodySchema = z.discriminatedUnion('op', [
51
+ z.object({ op: z.literal('forward') }),
52
+ z.object({ op: z.literal('backward') }),
53
+ z.object({ op: z.literal('toFront') }),
54
+ z.object({ op: z.literal('toBack') }),
55
+ z.object({ op: z.literal('toIndex'), index: z.number().int().nonnegative() }),
56
+ ]);
57
+ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
58
+
59
+ // Partial node update body. Top-level `position` lands on node.position; every
60
+ // other key lands inside node.data. Final validity is enforced by re-parsing
61
+ // the whole demo through DemoSchema after the merge — this body schema just
62
+ // rejects unknown top-level keys to catch typos.
63
+ export const NodePatchBodySchema = z
64
+ .object({
65
+ position: PositionBodySchema.optional(),
66
+ name: z.string().optional(),
67
+ borderColor: ColorTokenSchema.optional(),
68
+ backgroundColor: ColorTokenSchema.optional(),
69
+ borderSize: z.number().positive().optional(),
70
+ borderWidth: z.number().min(1).max(8).optional(),
71
+ borderStyle: z.enum(['solid', 'dashed', 'dotted']).optional(),
72
+ fontSize: z.number().positive().optional(),
73
+ textColor: ColorTokenSchema.optional(),
74
+ cornerRadius: z.number().min(0).optional(),
75
+ width: z.number().positive().optional(),
76
+ height: z.number().positive().optional(),
77
+ shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
78
+ // iconNode-only: stroke color token. Lands at data.color; DemoSchema's
79
+ // post-merge reparse gates that this is only valid on an iconNode.
80
+ color: ColorTokenSchema.optional(),
81
+ // iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
82
+ // post-merge reparse gates the [0.5, 4] bound and arm validity.
83
+ strokeWidth: z.number().min(0.5).max(4).optional(),
84
+ // iconNode-only: accessible alt text for the icon. Lands at data.alt.
85
+ alt: z.string().optional(),
86
+ // iconNode-only: kebab-case Lucide icon name. Lands at data.icon. The
87
+ // post-merge reparse enforces the schema's `.min(1)` non-empty rule and
88
+ // gates that this lands only on an iconNode.
89
+ icon: z.string().min(1).optional(),
90
+ // US-019: lock state. Lands at data.locked; persists across save/reload.
91
+ // Absent → unlocked default (no badge, all gestures work).
92
+ locked: z.boolean().optional(),
93
+ // Three-field consolidation: free-text metadata on every node variant.
94
+ // Empty string on `description` or `detail` is the documented clear-on-
95
+ // serialize signal — `mergeNodeUpdates` strips the key from disk.
96
+ description: z.string().optional(),
97
+ detail: z.string().optional(),
98
+ })
99
+ .strict();
100
+ export type NodePatchBody = z.infer<typeof NodePatchBodySchema>;
101
+
102
+ // Apply a partial PATCH body to a raw on-disk node. `position` lives at the
103
+ // node root; every other key lives inside `data`. We mutate the raw parsed
104
+ // JSON directly so unknown forward-compat fields the schema doesn't yet
105
+ // recognize survive the round-trip untouched.
106
+ const NODE_DATA_PATCH_KEYS = [
107
+ 'name',
108
+ 'borderColor',
109
+ 'backgroundColor',
110
+ 'borderSize',
111
+ 'borderWidth',
112
+ 'borderStyle',
113
+ 'fontSize',
114
+ 'textColor',
115
+ 'cornerRadius',
116
+ 'width',
117
+ 'height',
118
+ 'shape',
119
+ 'color',
120
+ 'strokeWidth',
121
+ 'alt',
122
+ 'icon',
123
+ 'locked',
124
+ 'description',
125
+ 'detail',
126
+ ] as const satisfies ReadonlyArray<keyof NodePatchBody>;
127
+
128
+ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePatchBody): void => {
129
+ if (updates.position !== undefined) {
130
+ node.position = updates.position;
131
+ }
132
+ const dataAny = node.data;
133
+ const data: Record<string, unknown> =
134
+ dataAny && typeof dataAny === 'object' && !Array.isArray(dataAny)
135
+ ? (dataAny as Record<string, unknown>)
136
+ : {};
137
+ let touchedData = false;
138
+ for (const key of NODE_DATA_PATCH_KEYS) {
139
+ if (updates[key] === undefined) continue;
140
+ // Empty string on the two free-text metadata fields is the documented
141
+ // clear-on-serialize signal — strip the key instead of writing "" to disk
142
+ // so seeflow.json stays compact and round-tripping a cleared node doesn't
143
+ // reintroduce the field.
144
+ if ((key === 'description' || key === 'detail') && updates[key] === '') {
145
+ if (key in data) {
146
+ delete data[key];
147
+ touchedData = true;
148
+ }
149
+ continue;
150
+ }
151
+ data[key] = updates[key];
152
+ touchedData = true;
153
+ }
154
+ if (touchedData) {
155
+ node.data = data;
156
+ }
157
+ };
158
+
159
+ export interface OperationsDeps {
160
+ registry: Registry;
161
+ watcher?: DemoWatcher;
162
+ /** Override the base directory for new projects. Defaults to ~/.seeflow. Tests inject a tmp dir. */
163
+ projectBaseDir?: string;
164
+ }
165
+
166
+ export interface DemoListItem {
167
+ id: string;
168
+ slug: string;
169
+ name: string;
170
+ repoPath: string;
171
+ lastModified: number;
172
+ valid: boolean;
173
+ }
174
+
175
+ export interface DemoGetResponse {
176
+ id: string;
177
+ slug: string;
178
+ name: string;
179
+ filePath: string;
180
+ demo: Demo | null;
181
+ valid: boolean;
182
+ error: string | null;
183
+ }
184
+
185
+ export interface RegisterDemoSuccess {
186
+ id: string;
187
+ slug: string;
188
+ sdk: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
189
+ }
190
+
191
+ export interface CreateProjectSuccess {
192
+ id: string;
193
+ slug: string;
194
+ scaffolded: boolean;
195
+ }
196
+
197
+ export type ListDemosOutcome = { kind: 'ok'; data: DemoListItem[] };
198
+
199
+ export type GetDemoOutcome =
200
+ | { kind: 'ok'; data: DemoGetResponse }
201
+ | { kind: 'notFound' }
202
+ | { kind: 'fileNotFound'; path: string };
203
+
204
+ export type RegisterDemoOutcome =
205
+ | { kind: 'ok'; data: RegisterDemoSuccess }
206
+ | { kind: 'fileNotFound'; path: string }
207
+ | { kind: 'badJson'; detail: string }
208
+ | { kind: 'badSchema'; issues: ZodIssue[] }
209
+ | { kind: 'sdkWriteFailed'; id: string; slug: string; message: string };
210
+
211
+ export type DeleteDemoOutcome = { kind: 'ok' } | { kind: 'notFound' };
212
+
213
+ export type CreateProjectOutcome =
214
+ | { kind: 'ok'; data: CreateProjectSuccess }
215
+ | { kind: 'badJson'; detail: string }
216
+ | { kind: 'badSchema'; issues: ZodIssue[] }
217
+ | { kind: 'scaffoldFailed'; message: string }
218
+ | { kind: 'sdkWriteFailed'; message: string };
219
+
220
+ // Outcomes for the four node-lifecycle helpers. Every variant lines up with
221
+ // an existing REST error response so api.ts can translate them back to the
222
+ // same status code + JSON body it used to emit directly.
223
+ export type AddNodeOutcome =
224
+ | { kind: 'ok'; data: { id: string; node: Record<string, unknown> } }
225
+ | { kind: 'demoNotFound' }
226
+ | { kind: 'fileNotFound'; path: string }
227
+ | { kind: 'badJson'; message: string }
228
+ | { kind: 'badSchema'; issues: ZodIssue[] }
229
+ | { kind: 'writeFailed'; message: string };
230
+
231
+ export type DeleteNodeOutcome =
232
+ | { kind: 'ok' }
233
+ | { kind: 'demoNotFound' }
234
+ | { kind: 'fileNotFound'; path: string }
235
+ | { kind: 'badJson'; message: string }
236
+ | { kind: 'badSchema'; issues: ZodIssue[] }
237
+ | { kind: 'unknownNode' }
238
+ | { kind: 'writeFailed'; message: string };
239
+
240
+ export type MoveNodeOutcome =
241
+ | { kind: 'ok'; data: { position: PositionBody } }
242
+ | { kind: 'demoNotFound' }
243
+ | { kind: 'fileNotFound'; path: string }
244
+ | { kind: 'badJson'; message: string }
245
+ | { kind: 'badSchema'; issues: ZodIssue[] }
246
+ | { kind: 'unknownNode' }
247
+ | { kind: 'writeFailed'; message: string };
248
+
249
+ export type ReorderNodeOutcome =
250
+ | { kind: 'ok' }
251
+ | { kind: 'demoNotFound' }
252
+ | { kind: 'fileNotFound'; path: string }
253
+ | { kind: 'badJson'; message: string }
254
+ | { kind: 'badSchema'; issues: ZodIssue[] }
255
+ | { kind: 'unknownNode' }
256
+ | { kind: 'writeFailed'; message: string };
257
+
258
+ export type PatchNodeOutcome =
259
+ | { kind: 'ok' }
260
+ | { kind: 'demoNotFound' }
261
+ | { kind: 'fileNotFound'; path: string }
262
+ | { kind: 'badJson'; message: string }
263
+ | { kind: 'badSchema'; issues: ZodIssue[] }
264
+ | { kind: 'unknownNode' }
265
+ | { kind: 'writeFailed'; message: string };
266
+
267
+ // Partial connector update body. Strict at the top level so client typos
268
+ // surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
269
+ // are enforced post-merge by re-parsing the whole demo through DemoSchema.
270
+ const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
271
+ export const ConnectorPatchBodySchema = z
272
+ .object({
273
+ label: z.string().optional(),
274
+ style: z.enum(['solid', 'dashed', 'dotted']).optional(),
275
+ color: ColorTokenSchema.optional(),
276
+ direction: z.enum(['forward', 'backward', 'both', 'none']).optional(),
277
+ borderSize: z.number().positive().optional(),
278
+ path: z.enum(['curve', 'step']).optional(),
279
+ // US-018: per-connector label font size (mirrors NodeVisualBaseShape.fontSize).
280
+ fontSize: z.number().positive().optional(),
281
+ kind: ConnectorKindSchema.optional(),
282
+ eventName: z.string().optional(),
283
+ queueName: z.string().optional(),
284
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
285
+ url: z.string().optional(),
286
+ // Reconnect: drag an edge endpoint onto another node's handle. The
287
+ // post-merge DemoSchema parse rejects dangling references, so we don't
288
+ // need a referential check here.
289
+ source: z.string().min(1).optional(),
290
+ target: z.string().min(1).optional(),
291
+ // Reconnect to a different handle on the same (or a new) node. Handle ids
292
+ // identify which side (top/right/bottom/left) of the node the connector
293
+ // attaches to (US-013); the role is locked — `sourceHandle` must be a
294
+ // source-side id, `targetHandle` must be a target-side id (US-022).
295
+ // Nullable so a body-drop reconnect (US-025) can clear a previously-pinned
296
+ // handle id by sending `null`; mergeConnectorUpdates deletes the field
297
+ // when the value is null.
298
+ sourceHandle: SourceHandleIdSchema.nullable().optional(),
299
+ targetHandle: TargetHandleIdSchema.nullable().optional(),
300
+ // US-021: auto-pick flags. Originally written by the picker on body-drop
301
+ // create / reconnect. US-025 keeps the schema shape but redefines the
302
+ // semantics: `true`/absent means "render floating" against the line
303
+ // through the two node centers; `false` means "render pinned to the
304
+ // stored handle id".
305
+ sourceHandleAutoPicked: z.boolean().optional(),
306
+ targetHandleAutoPicked: z.boolean().optional(),
307
+ // US-007: explicit perimeter pin for each endpoint. Sending an EdgePin
308
+ // pins the endpoint to `(side, t)` against the connected node's live
309
+ // bbox so it survives moves and resizes. Nullable so the right-click
310
+ // Unpin flow can clear a stored pin by sending `null`;
311
+ // mergeConnectorUpdates deletes the field when the value is null.
312
+ sourcePin: EdgePinSchema.nullable().optional(),
313
+ targetPin: EdgePinSchema.nullable().optional(),
314
+ })
315
+ .strict();
316
+ export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
317
+
318
+ // Kind-specific connector fields. When `kind` changes via PATCH, these are
319
+ // dropped first so the resulting connector doesn't carry phantom payloads
320
+ // from the previous kind (e.g. an event→default change leaving eventName
321
+ // behind, which DemoSchema would silently strip on parse but leave on disk).
322
+ const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
323
+
324
+ export const mergeConnectorUpdates = (
325
+ conn: Record<string, unknown>,
326
+ updates: ConnectorPatchBody,
327
+ ): void => {
328
+ if (updates.kind !== undefined && updates.kind !== conn.kind) {
329
+ for (const key of CONNECTOR_KIND_FIELDS) {
330
+ delete conn[key];
331
+ }
332
+ }
333
+ for (const [key, value] of Object.entries(updates)) {
334
+ if (value === undefined) continue;
335
+ // US-025: explicit null in the patch body means "clear this field on
336
+ // disk". Used by reconnect-to-body to drop a previously-pinned handle
337
+ // id when the endpoint flips back to floating.
338
+ if (value === null) {
339
+ delete conn[key];
340
+ continue;
341
+ }
342
+ conn[key] = value;
343
+ }
344
+ };
345
+
346
+ export type AddConnectorOutcome =
347
+ | { kind: 'ok'; data: { id: string } }
348
+ | { kind: 'demoNotFound' }
349
+ | { kind: 'fileNotFound'; path: string }
350
+ | { kind: 'badJson'; message: string }
351
+ | { kind: 'badSchema'; issues: ZodIssue[] }
352
+ | { kind: 'writeFailed'; message: string };
353
+
354
+ export type PatchConnectorOutcome =
355
+ | { kind: 'ok' }
356
+ | { kind: 'demoNotFound' }
357
+ | { kind: 'fileNotFound'; path: string }
358
+ | { kind: 'badJson'; message: string }
359
+ | { kind: 'badSchema'; issues: ZodIssue[] }
360
+ | { kind: 'unknownConnector' }
361
+ | { kind: 'writeFailed'; message: string };
362
+
363
+ export type DeleteConnectorOutcome =
364
+ | { kind: 'ok' }
365
+ | { kind: 'demoNotFound' }
366
+ | { kind: 'fileNotFound'; path: string }
367
+ | { kind: 'badJson'; message: string }
368
+ | { kind: 'badSchema'; issues: ZodIssue[] }
369
+ | { kind: 'unknownConnector' }
370
+ | { kind: 'writeFailed'; message: string };
371
+
372
+ export const resolveDemoPath = (repoPath: string, demoPath: string): string =>
373
+ isAbsolute(demoPath) ? demoPath : join(repoPath, demoPath);
374
+
375
+ // Per-demo serialization: read-modify-write of the demo file isn't atomic
376
+ // across multiple PATCHes, so two concurrent drags would race (later writer's
377
+ // older read clobbers the earlier writer's update). We chain writes per
378
+ // demoId so the read+write sequence is effectively serialized.
379
+ const demoWriteChains = new Map<string, Promise<unknown>>();
380
+ export const withDemoWriteLock = <T>(demoId: string, fn: () => Promise<T>): Promise<T> => {
381
+ const prev = demoWriteChains.get(demoId) ?? Promise.resolve();
382
+ const next = prev.then(fn, fn);
383
+ // Replace with a tail that swallows errors so the chain keeps moving even
384
+ // if one write fails — but the original promise still rejects to its caller.
385
+ demoWriteChains.set(
386
+ demoId,
387
+ next.catch(() => undefined),
388
+ );
389
+ return next as Promise<T>;
390
+ };
391
+
392
+ /**
393
+ * Atomic write: writes to a sibling tempfile then renames over the target.
394
+ * `rename(2)` is atomic on POSIX, so a process reading mid-write either sees
395
+ * the old file or the new one — never a half-written one. This keeps user
396
+ * editor diffs clean (single fs.watch event for the rename) and means a crash
397
+ * during write can never corrupt the original.
398
+ */
399
+ export const writeFileAtomic = (filePath: string, content: string): void => {
400
+ const tempPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
401
+ try {
402
+ writeFileSync(tempPath, content);
403
+ renameSync(tempPath, filePath);
404
+ } catch (err) {
405
+ try {
406
+ if (existsSync(tempPath)) unlinkSync(tempPath);
407
+ } catch {
408
+ // best-effort cleanup
409
+ }
410
+ throw err;
411
+ }
412
+ };
413
+
414
+ export const reorderNodes = (
415
+ nodes: Array<Record<string, unknown>>,
416
+ fromIdx: number,
417
+ body: ReorderBody,
418
+ ): boolean => {
419
+ const len = nodes.length;
420
+ switch (body.op) {
421
+ case 'forward': {
422
+ if (fromIdx >= len - 1) return false;
423
+ const tmp = nodes[fromIdx];
424
+ const next = nodes[fromIdx + 1];
425
+ if (tmp === undefined || next === undefined) return false;
426
+ nodes[fromIdx] = next;
427
+ nodes[fromIdx + 1] = tmp;
428
+ return true;
429
+ }
430
+ case 'backward': {
431
+ if (fromIdx <= 0) return false;
432
+ const tmp = nodes[fromIdx];
433
+ const prev = nodes[fromIdx - 1];
434
+ if (tmp === undefined || prev === undefined) return false;
435
+ nodes[fromIdx] = prev;
436
+ nodes[fromIdx - 1] = tmp;
437
+ return true;
438
+ }
439
+ case 'toFront': {
440
+ if (fromIdx === len - 1) return false;
441
+ const [removed] = nodes.splice(fromIdx, 1);
442
+ if (removed === undefined) return false;
443
+ nodes.push(removed);
444
+ return true;
445
+ }
446
+ case 'toBack': {
447
+ if (fromIdx === 0) return false;
448
+ const [removed] = nodes.splice(fromIdx, 1);
449
+ if (removed === undefined) return false;
450
+ nodes.unshift(removed);
451
+ return true;
452
+ }
453
+ case 'toIndex': {
454
+ const target = Math.min(Math.max(body.index, 0), len - 1);
455
+ if (target === fromIdx) return false;
456
+ const [removed] = nodes.splice(fromIdx, 1);
457
+ if (removed === undefined) return false;
458
+ nodes.splice(target, 0, removed);
459
+ return true;
460
+ }
461
+ }
462
+ };
463
+
464
+ export function listDemosImpl(deps: OperationsDeps): ListDemosOutcome {
465
+ const data = deps.registry.list().map((e) => {
466
+ const fullPath = resolveDemoPath(e.repoPath, e.demoPath);
467
+ const fileExists = existsSync(fullPath);
468
+ return {
469
+ id: e.id,
470
+ slug: e.slug,
471
+ name: e.name,
472
+ repoPath: e.repoPath,
473
+ lastModified: e.lastModified,
474
+ valid: e.valid && fileExists,
475
+ };
476
+ });
477
+ return { kind: 'ok', data };
478
+ }
479
+
480
+ export async function getDemoImpl(deps: OperationsDeps, demoId: string): Promise<GetDemoOutcome> {
481
+ const { registry, watcher } = deps;
482
+ const entry = registry.getById(demoId);
483
+ if (!entry) return { kind: 'notFound' };
484
+
485
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
486
+ const snap = watcher?.snapshot(demoId) ?? watcher?.reparse(demoId) ?? null;
487
+
488
+ const buildResponse = (s: DemoSnapshot): DemoGetResponse => ({
489
+ id: entry.id,
490
+ slug: entry.slug,
491
+ name: entry.name,
492
+ filePath: fullPath,
493
+ demo: s.demo,
494
+ valid: s.valid,
495
+ error: s.valid ? null : s.error,
496
+ });
497
+
498
+ if (snap) return { kind: 'ok', data: buildResponse(snap) };
499
+
500
+ // No watcher available — fall back to a synchronous read so MCP / CLI
501
+ // callers without a long-lived watcher still get a current snapshot.
502
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
503
+
504
+ let raw: unknown;
505
+ try {
506
+ raw = await Bun.file(fullPath).json();
507
+ } catch (err) {
508
+ return {
509
+ kind: 'ok',
510
+ data: buildResponse({
511
+ demo: null,
512
+ valid: false,
513
+ error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
514
+ filePath: fullPath,
515
+ parsedAt: Date.now(),
516
+ }),
517
+ };
518
+ }
519
+ const parsed = DemoSchema.safeParse(raw);
520
+ if (!parsed.success) {
521
+ return {
522
+ kind: 'ok',
523
+ data: buildResponse({
524
+ demo: null,
525
+ valid: false,
526
+ error: parsed.error.issues
527
+ .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
528
+ .join('; '),
529
+ filePath: fullPath,
530
+ parsedAt: Date.now(),
531
+ }),
532
+ };
533
+ }
534
+ return {
535
+ kind: 'ok',
536
+ data: buildResponse({
537
+ demo: parsed.data,
538
+ valid: true,
539
+ error: null,
540
+ filePath: fullPath,
541
+ parsedAt: Date.now(),
542
+ }),
543
+ };
544
+ }
545
+
546
+ export async function registerDemoImpl(
547
+ deps: OperationsDeps,
548
+ body: RegisterBody,
549
+ ): Promise<RegisterDemoOutcome> {
550
+ const { registry, watcher } = deps;
551
+ const { repoPath, demoPath } = body;
552
+ const fullPath = resolveDemoPath(repoPath, demoPath);
553
+
554
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
555
+
556
+ let demo: unknown;
557
+ try {
558
+ demo = await Bun.file(fullPath).json();
559
+ } catch (err) {
560
+ // REST uses String(err) here (preserves "SyntaxError: ..." prefix) —
561
+ // keep byte-identical so api.test.ts assertions stay green.
562
+ return { kind: 'badJson', detail: String(err) };
563
+ }
564
+
565
+ const demoParse = DemoSchema.safeParse(demo);
566
+ if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
567
+
568
+ const lastModified = statSync(fullPath).mtimeMs;
569
+ const entry = registry.upsert({
570
+ name: body.name ?? demoParse.data.name,
571
+ repoPath,
572
+ demoPath,
573
+ valid: true,
574
+ lastModified,
575
+ });
576
+
577
+ watcher?.watch(entry.id);
578
+
579
+ let sdkResult: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
580
+ try {
581
+ sdkResult = writeSdkEmitIfNeeded(repoPath, demoParse.data);
582
+ } catch (err) {
583
+ const message = err instanceof Error ? err.message : String(err);
584
+ return { kind: 'sdkWriteFailed', id: entry.id, slug: entry.slug, message };
585
+ }
586
+
587
+ return {
588
+ kind: 'ok',
589
+ data: {
590
+ id: entry.id,
591
+ slug: entry.slug,
592
+ sdk: { outcome: sdkResult.outcome, filePath: sdkResult.filePath },
593
+ },
594
+ };
595
+ }
596
+
597
+ export function deleteDemoImpl(deps: OperationsDeps, idOrSlug: string): DeleteDemoOutcome {
598
+ const { registry, watcher } = deps;
599
+ const entry = registry.getById(idOrSlug) ?? registry.getBySlug(idOrSlug);
600
+ if (!entry) return { kind: 'notFound' };
601
+ watcher?.unwatch(entry.id);
602
+ registry.remove(entry.id);
603
+ return { kind: 'ok' };
604
+ }
605
+
606
+ export async function createProjectImpl(
607
+ deps: OperationsDeps,
608
+ body: CreateProjectBody,
609
+ ): Promise<CreateProjectOutcome> {
610
+ const { registry, watcher } = deps;
611
+ const { name } = body;
612
+ const baseDir = deps.projectBaseDir ?? join(homedir(), '.seeflow');
613
+ const folderPath = join(baseDir, slugify(name));
614
+
615
+ const demoFullPath = join(folderPath, DEFAULT_DEMO_RELATIVE_PATH);
616
+
617
+ if (existsSync(demoFullPath)) {
618
+ let raw: unknown;
619
+ try {
620
+ raw = await Bun.file(demoFullPath).json();
621
+ } catch (err) {
622
+ return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
623
+ }
624
+ const demoParse = DemoSchema.safeParse(raw);
625
+ if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
626
+
627
+ const lastModified = statSync(demoFullPath).mtimeMs;
628
+ const entry = registry.upsert({
629
+ name,
630
+ repoPath: folderPath,
631
+ demoPath: DEFAULT_DEMO_RELATIVE_PATH,
632
+ valid: true,
633
+ lastModified,
634
+ });
635
+ watcher?.watch(entry.id);
636
+ return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
637
+ }
638
+
639
+ const scaffold: Demo = { version: 1, name, nodes: [], connectors: [] };
640
+
641
+ try {
642
+ mkdirSync(join(folderPath, '.seeflow'), { recursive: true });
643
+ writeFileSync(demoFullPath, `${JSON.stringify(scaffold, null, 2)}\n`);
644
+ } catch (err) {
645
+ return { kind: 'scaffoldFailed', message: err instanceof Error ? err.message : String(err) };
646
+ }
647
+
648
+ // Same SDK-emit path as the CLI register flow. For a fresh scaffold with no
649
+ // event-bound state nodes this returns 'skipped' and writes nothing —
650
+ // retained for parity with `seeflow register`.
651
+ try {
652
+ writeSdkEmitIfNeeded(folderPath, scaffold);
653
+ } catch (err) {
654
+ return { kind: 'sdkWriteFailed', message: err instanceof Error ? err.message : String(err) };
655
+ }
656
+
657
+ const lastModified = statSync(demoFullPath).mtimeMs;
658
+ const entry = registry.upsert({
659
+ name,
660
+ repoPath: folderPath,
661
+ demoPath: DEFAULT_DEMO_RELATIVE_PATH,
662
+ valid: true,
663
+ lastModified,
664
+ });
665
+ watcher?.watch(entry.id);
666
+ return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
667
+ }
668
+
669
+ // Append a new node to the demo. Auto-generates an id when absent; DemoSchema
670
+ // is re-run on the post-mutation raw object before commit so a malformed
671
+ // payload never produces a half-written file.
672
+ export async function addNodeImpl(
673
+ deps: OperationsDeps,
674
+ demoId: string,
675
+ nodeBody: Record<string, unknown>,
676
+ ): Promise<AddNodeOutcome> {
677
+ const entry = deps.registry.getById(demoId);
678
+ if (!entry) return { kind: 'demoNotFound' };
679
+
680
+ const newNode = { ...nodeBody };
681
+ if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
682
+ newNode.id = `node-${crypto.randomUUID()}`;
683
+ }
684
+ const newId = newNode.id as string;
685
+
686
+ // US-015: for htmlNode without a client-supplied htmlPath, allocate the
687
+ // studio-managed `blocks/<id>.html` path and queue a starter-file write.
688
+ // Client-supplied htmlPath wins and we skip the starter file (symmetric
689
+ // with US-016's hand-edited-path leave-alone rule).
690
+ let starterFile: { absPath: string; content: string } | undefined;
691
+ if (newNode.type === 'htmlNode') {
692
+ const dataIsRecord =
693
+ newNode.data !== null && typeof newNode.data === 'object' && !Array.isArray(newNode.data);
694
+ const existingData: Record<string, unknown> = dataIsRecord
695
+ ? { ...(newNode.data as Record<string, unknown>) }
696
+ : {};
697
+ const clientProvidedHtmlPath =
698
+ typeof existingData.htmlPath === 'string' && existingData.htmlPath.length > 0;
699
+ if (!clientProvidedHtmlPath) {
700
+ const htmlPath = `blocks/${newId}.html`;
701
+ existingData.htmlPath = htmlPath;
702
+ newNode.data = existingData;
703
+ starterFile = {
704
+ absPath: join(entry.repoPath, '.seeflow', htmlPath),
705
+ content: buildHtmlNodeStarter(newId),
706
+ };
707
+ } else if (!dataIsRecord) {
708
+ // Coerce non-object data into the spread'd record so the schema parse
709
+ // sees the right shape — shouldn't happen in practice but keeps types honest.
710
+ newNode.data = existingData;
711
+ }
712
+ }
713
+
714
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
715
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
716
+
717
+ type Inner =
718
+ | { kind: 'ok' }
719
+ | { kind: 'badJson'; message: string }
720
+ | { kind: 'badSchema'; issues: ZodIssue[] }
721
+ | { kind: 'writeFailed'; message: string };
722
+
723
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
724
+ let raw: unknown;
725
+ try {
726
+ raw = await Bun.file(fullPath).json();
727
+ } catch (err) {
728
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
729
+ }
730
+ const demoParsed = DemoSchema.safeParse(raw);
731
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
732
+
733
+ const obj = raw as { nodes: Array<Record<string, unknown>> };
734
+ obj.nodes.push(newNode);
735
+
736
+ const finalParse = DemoSchema.safeParse(raw);
737
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
738
+
739
+ if (starterFile) {
740
+ try {
741
+ mkdirSync(dirname(starterFile.absPath), { recursive: true });
742
+ writeFileAtomic(starterFile.absPath, starterFile.content);
743
+ } catch (err) {
744
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
745
+ }
746
+ }
747
+
748
+ try {
749
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
750
+ } catch (err) {
751
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
752
+ }
753
+ return { kind: 'ok' };
754
+ });
755
+
756
+ if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
757
+ return result;
758
+ }
759
+
760
+ // US-015: starter HTML content for studio-created htmlNodes. Centered 'Edit me'
761
+ // card with a `blocks/<id>.html` subtitle — matches the design's Section 6
762
+ // markup exactly so the renderer paints a useful first impression while the
763
+ // author hasn't yet edited the file.
764
+ const buildHtmlNodeStarter = (nodeId: string): string =>
765
+ `<div class="flex h-full w-full items-center justify-center rounded-lg border border-slate-300 bg-white p-4 text-slate-900">
766
+ <div class="text-center">
767
+ <div class="font-semibold">Edit me</div>
768
+ <div class="text-xs text-slate-500">blocks/${nodeId}.html</div>
769
+ </div>
770
+ </div>
771
+ `;
772
+
773
+ // Remove a node and cascade-delete every connector touching it in a single
774
+ // atomic write. Final DemoSchema parse stays in place so a pre-existing
775
+ // schema violation surfaces honestly instead of being silently papered over.
776
+ // US-016: when the removed node is an htmlNode whose data.htmlPath matches the
777
+ // studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
778
+ // the seeflow.json write succeeds. Hand-edited paths are left alone (symmetric
779
+ // with US-015's "client-supplied htmlPath wins, no starter file written").
780
+ export async function deleteNodeImpl(
781
+ deps: OperationsDeps,
782
+ demoId: string,
783
+ nodeId: string,
784
+ ): Promise<DeleteNodeOutcome> {
785
+ const entry = deps.registry.getById(demoId);
786
+ if (!entry) return { kind: 'demoNotFound' };
787
+
788
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
789
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
790
+
791
+ type Inner =
792
+ | { kind: 'ok'; managedHtmlAbsPath?: string }
793
+ | { kind: 'badJson'; message: string }
794
+ | { kind: 'badSchema'; issues: ZodIssue[] }
795
+ | { kind: 'unknownNode' }
796
+ | { kind: 'writeFailed'; message: string };
797
+
798
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
799
+ let raw: unknown;
800
+ try {
801
+ raw = await Bun.file(fullPath).json();
802
+ } catch (err) {
803
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
804
+ }
805
+ const demoParsed = DemoSchema.safeParse(raw);
806
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
807
+
808
+ const obj = raw as {
809
+ nodes: Array<Record<string, unknown>>;
810
+ connectors: Array<{ source: string; target: string }>;
811
+ };
812
+ const idx = obj.nodes.findIndex((n) => n.id === nodeId);
813
+ if (idx < 0) return { kind: 'unknownNode' };
814
+ const removed = obj.nodes[idx];
815
+ const managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
816
+ obj.nodes.splice(idx, 1);
817
+ obj.connectors = obj.connectors.filter((cn) => cn.source !== nodeId && cn.target !== nodeId);
818
+
819
+ const finalParse = DemoSchema.safeParse(raw);
820
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
821
+
822
+ try {
823
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
824
+ } catch (err) {
825
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
826
+ }
827
+ return managedHtmlAbsPath ? { kind: 'ok', managedHtmlAbsPath } : { kind: 'ok' };
828
+ });
829
+
830
+ if (result.kind === 'ok' && result.managedHtmlAbsPath) {
831
+ try {
832
+ unlinkSync(result.managedHtmlAbsPath);
833
+ } catch (err) {
834
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
835
+ if (code !== 'ENOENT') {
836
+ console.warn(
837
+ `[operations] failed to remove managed htmlNode file ${result.managedHtmlAbsPath}: ${
838
+ err instanceof Error ? err.message : String(err)
839
+ }`,
840
+ );
841
+ }
842
+ }
843
+ return { kind: 'ok' };
844
+ }
845
+
846
+ return result;
847
+ }
848
+
849
+ // US-016: only delete the companion file when the htmlPath matches the
850
+ // studio-managed shape `blocks/<id>.html` exactly. Hand-edited paths
851
+ // (`custom/hero.html`, an absolute path, anything else) are left alone so
852
+ // authors don't lose work they pointed the node at.
853
+ const managedHtmlNodePath = (
854
+ repoPath: string,
855
+ nodeId: string,
856
+ removed: Record<string, unknown> | undefined,
857
+ ): string | undefined => {
858
+ if (!removed || removed.type !== 'htmlNode') return undefined;
859
+ const data = removed.data;
860
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined;
861
+ const htmlPath = (data as Record<string, unknown>).htmlPath;
862
+ if (htmlPath !== `blocks/${nodeId}.html`) return undefined;
863
+ return join(repoPath, '.seeflow', htmlPath);
864
+ };
865
+
866
+ // Move a single node by writing { x, y } back to its `position` on disk.
867
+ // Mutates the *raw* parsed JSON so any unknown forward-compat fields the
868
+ // schema doesn't yet recognize survive the round-trip untouched.
869
+ export async function moveNodeImpl(
870
+ deps: OperationsDeps,
871
+ demoId: string,
872
+ nodeId: string,
873
+ position: PositionBody,
874
+ ): Promise<MoveNodeOutcome> {
875
+ const entry = deps.registry.getById(demoId);
876
+ if (!entry) return { kind: 'demoNotFound' };
877
+
878
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
879
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
880
+
881
+ type Inner =
882
+ | { kind: 'ok' }
883
+ | { kind: 'badJson'; message: string }
884
+ | { kind: 'badSchema'; issues: ZodIssue[] }
885
+ | { kind: 'unknownNode' }
886
+ | { kind: 'writeFailed'; message: string };
887
+
888
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
889
+ let raw: unknown;
890
+ try {
891
+ raw = await Bun.file(fullPath).json();
892
+ } catch (err) {
893
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
894
+ }
895
+ const demoParsed = DemoSchema.safeParse(raw);
896
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
897
+
898
+ const obj = raw as {
899
+ nodes: Array<{ id: string; position: { x: number; y: number } }>;
900
+ };
901
+ const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
902
+ if (!onDiskNode) return { kind: 'unknownNode' };
903
+ onDiskNode.position = { x: position.x, y: position.y };
904
+
905
+ try {
906
+ writeFileAtomic(fullPath, `${JSON.stringify(obj, null, 2)}\n`);
907
+ } catch (err) {
908
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
909
+ }
910
+ return { kind: 'ok' };
911
+ });
912
+
913
+ if (result.kind === 'ok') {
914
+ // Eagerly refresh snapshot so a subsequent GET /api/demos/:id (e.g. export)
915
+ // returns the updated position without waiting for the 100ms FSWatcher debounce.
916
+ deps.watcher?.reparse(demoId);
917
+ return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
918
+ }
919
+ return result;
920
+ }
921
+
922
+ // Apply a partial PATCH body to a single node. Mutation runs against the
923
+ // raw parsed JSON (so unknown forward-compat fields survive a round-trip),
924
+ // and the whole demo is re-validated through DemoSchema before commit so
925
+ // partial writes can't break invariants like the connector→node superRefine.
926
+ export async function patchNodeImpl(
927
+ deps: OperationsDeps,
928
+ demoId: string,
929
+ nodeId: string,
930
+ updates: NodePatchBody,
931
+ ): Promise<PatchNodeOutcome> {
932
+ const entry = deps.registry.getById(demoId);
933
+ if (!entry) return { kind: 'demoNotFound' };
934
+
935
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
936
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
937
+
938
+ type Inner =
939
+ | { kind: 'ok' }
940
+ | { kind: 'badJson'; message: string }
941
+ | { kind: 'badSchema'; issues: ZodIssue[] }
942
+ | { kind: 'unknownNode' }
943
+ | { kind: 'writeFailed'; message: string };
944
+
945
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
946
+ let raw: unknown;
947
+ try {
948
+ raw = await Bun.file(fullPath).json();
949
+ } catch (err) {
950
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
951
+ }
952
+ const demoParsed = DemoSchema.safeParse(raw);
953
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
954
+
955
+ const obj = raw as { nodes: Array<Record<string, unknown>> };
956
+ const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
957
+ if (!onDiskNode) return { kind: 'unknownNode' };
958
+
959
+ mergeNodeUpdates(onDiskNode, updates);
960
+
961
+ const finalParse = DemoSchema.safeParse(raw);
962
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
963
+
964
+ try {
965
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
966
+ } catch (err) {
967
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
968
+ }
969
+ return { kind: 'ok' };
970
+ });
971
+
972
+ return result;
973
+ }
974
+
975
+ // Reorder a node within demo.nodes[] (changes paint order in the canvas).
976
+ // A no-op reorder (e.g. forward on the topmost node) returns ok without
977
+ // writing so we don't trigger a watcher echo for nothing.
978
+ export async function reorderNodeImpl(
979
+ deps: OperationsDeps,
980
+ demoId: string,
981
+ nodeId: string,
982
+ body: ReorderBody,
983
+ ): Promise<ReorderNodeOutcome> {
984
+ const entry = deps.registry.getById(demoId);
985
+ if (!entry) return { kind: 'demoNotFound' };
986
+
987
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
988
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
989
+
990
+ type Inner =
991
+ | { kind: 'ok' }
992
+ | { kind: 'badJson'; message: string }
993
+ | { kind: 'badSchema'; issues: ZodIssue[] }
994
+ | { kind: 'unknownNode' }
995
+ | { kind: 'writeFailed'; message: string };
996
+
997
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
998
+ let raw: unknown;
999
+ try {
1000
+ raw = await Bun.file(fullPath).json();
1001
+ } catch (err) {
1002
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1003
+ }
1004
+ const demoParsed = DemoSchema.safeParse(raw);
1005
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1006
+
1007
+ const obj = raw as { nodes: Array<Record<string, unknown>> };
1008
+ const fromIdx = obj.nodes.findIndex((n) => n.id === nodeId);
1009
+ if (fromIdx < 0) return { kind: 'unknownNode' };
1010
+
1011
+ const moved = reorderNodes(obj.nodes, fromIdx, body);
1012
+ if (!moved) return { kind: 'ok' };
1013
+
1014
+ const finalParse = DemoSchema.safeParse(raw);
1015
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1016
+
1017
+ try {
1018
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1019
+ } catch (err) {
1020
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1021
+ }
1022
+ return { kind: 'ok' };
1023
+ });
1024
+
1025
+ return result;
1026
+ }
1027
+
1028
+ // Append a new connector to demo.connectors. `id` is auto-generated when
1029
+ // absent and `kind` defaults to 'default' (the no-semantics user-drawn
1030
+ // variant). Source/target referential integrity is enforced by DemoSchema's
1031
+ // superRefine on the post-mutation parse.
1032
+ export async function addConnectorImpl(
1033
+ deps: OperationsDeps,
1034
+ demoId: string,
1035
+ connBody: Record<string, unknown>,
1036
+ ): Promise<AddConnectorOutcome> {
1037
+ const entry = deps.registry.getById(demoId);
1038
+ if (!entry) return { kind: 'demoNotFound' };
1039
+
1040
+ const newConn = { ...connBody };
1041
+ if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
1042
+ newConn.id = `conn-${crypto.randomUUID()}`;
1043
+ }
1044
+ if (typeof newConn.kind !== 'string' || newConn.kind.length === 0) {
1045
+ newConn.kind = 'default';
1046
+ }
1047
+ const newId = newConn.id as string;
1048
+
1049
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1050
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1051
+
1052
+ type Inner =
1053
+ | { kind: 'ok' }
1054
+ | { kind: 'badJson'; message: string }
1055
+ | { kind: 'badSchema'; issues: ZodIssue[] }
1056
+ | { kind: 'writeFailed'; message: string };
1057
+
1058
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
1059
+ let raw: unknown;
1060
+ try {
1061
+ raw = await Bun.file(fullPath).json();
1062
+ } catch (err) {
1063
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1064
+ }
1065
+ const demoParsed = DemoSchema.safeParse(raw);
1066
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1067
+
1068
+ const obj = raw as { connectors: Array<Record<string, unknown>> };
1069
+ obj.connectors.push(newConn);
1070
+
1071
+ const finalParse = DemoSchema.safeParse(raw);
1072
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1073
+
1074
+ try {
1075
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1076
+ } catch (err) {
1077
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1078
+ }
1079
+ return { kind: 'ok' };
1080
+ });
1081
+
1082
+ if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
1083
+ return result;
1084
+ }
1085
+
1086
+ // Apply a partial PATCH body to a single connector. Mutation runs against
1087
+ // the raw parsed JSON (so unknown forward-compat fields survive a round-trip).
1088
+ // When `kind` changes, the previous kind's payload fields are dropped first
1089
+ // so the connector doesn't carry phantom data; explicit `null` in the patch
1090
+ // clears the field on disk (used by reconnect-to-body to drop a pinned
1091
+ // handle id). The whole demo is re-validated through DemoSchema before
1092
+ // commit so the discriminated union catches missing-required-fields
1093
+ // (e.g. kind='event' without eventName) and the superRefine gates
1094
+ // source/target referential integrity + handle role invariants.
1095
+ export async function patchConnectorImpl(
1096
+ deps: OperationsDeps,
1097
+ demoId: string,
1098
+ connectorId: string,
1099
+ updates: ConnectorPatchBody,
1100
+ ): Promise<PatchConnectorOutcome> {
1101
+ const entry = deps.registry.getById(demoId);
1102
+ if (!entry) return { kind: 'demoNotFound' };
1103
+
1104
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1105
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1106
+
1107
+ type Inner =
1108
+ | { kind: 'ok' }
1109
+ | { kind: 'badJson'; message: string }
1110
+ | { kind: 'badSchema'; issues: ZodIssue[] }
1111
+ | { kind: 'unknownConnector' }
1112
+ | { kind: 'writeFailed'; message: string };
1113
+
1114
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
1115
+ let raw: unknown;
1116
+ try {
1117
+ raw = await Bun.file(fullPath).json();
1118
+ } catch (err) {
1119
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1120
+ }
1121
+ const demoParsed = DemoSchema.safeParse(raw);
1122
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1123
+
1124
+ const obj = raw as { connectors: Array<Record<string, unknown>> };
1125
+ const onDiskConn = obj.connectors.find((cn) => cn.id === connectorId);
1126
+ if (!onDiskConn) return { kind: 'unknownConnector' };
1127
+
1128
+ mergeConnectorUpdates(onDiskConn, updates);
1129
+
1130
+ const finalParse = DemoSchema.safeParse(raw);
1131
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1132
+
1133
+ try {
1134
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1135
+ } catch (err) {
1136
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1137
+ }
1138
+ return { kind: 'ok' };
1139
+ });
1140
+
1141
+ return result;
1142
+ }
1143
+
1144
+ // Remove a connector by id. No cascade — node deletion is what cascades,
1145
+ // not connector deletion. Final DemoSchema parse still runs so a pre-existing
1146
+ // schema violation surfaces honestly instead of being silently papered over.
1147
+ export async function deleteConnectorImpl(
1148
+ deps: OperationsDeps,
1149
+ demoId: string,
1150
+ connectorId: string,
1151
+ ): Promise<DeleteConnectorOutcome> {
1152
+ const entry = deps.registry.getById(demoId);
1153
+ if (!entry) return { kind: 'demoNotFound' };
1154
+
1155
+ const fullPath = resolveDemoPath(entry.repoPath, entry.demoPath);
1156
+ if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
1157
+
1158
+ type Inner =
1159
+ | { kind: 'ok' }
1160
+ | { kind: 'badJson'; message: string }
1161
+ | { kind: 'badSchema'; issues: ZodIssue[] }
1162
+ | { kind: 'unknownConnector' }
1163
+ | { kind: 'writeFailed'; message: string };
1164
+
1165
+ const result = await withDemoWriteLock<Inner>(demoId, async () => {
1166
+ let raw: unknown;
1167
+ try {
1168
+ raw = await Bun.file(fullPath).json();
1169
+ } catch (err) {
1170
+ return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
1171
+ }
1172
+ const demoParsed = DemoSchema.safeParse(raw);
1173
+ if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
1174
+
1175
+ const obj = raw as { connectors: Array<{ id: string }> };
1176
+ const idx = obj.connectors.findIndex((cn) => cn.id === connectorId);
1177
+ if (idx < 0) return { kind: 'unknownConnector' };
1178
+ obj.connectors.splice(idx, 1);
1179
+
1180
+ const finalParse = DemoSchema.safeParse(raw);
1181
+ if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
1182
+
1183
+ try {
1184
+ writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
1185
+ } catch (err) {
1186
+ return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
1187
+ }
1188
+ return { kind: 'ok' };
1189
+ });
1190
+
1191
+ return result;
1192
+ }