@tuongaz/seeflow 0.1.26 → 0.1.28
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.
- package/bin/seeflow +0 -0
- package/bin/seeflow-mcp +0 -0
- package/dist/web/assets/{index-BMaMEi2a.js → index--9KvdiJU.js} +1584 -1584
- package/dist/web/assets/index-XIY68z9O.css +1 -0
- package/dist/web/assets/{index.es-M1iBDKG6.js → index.es-CehYgUiq.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-xZpq8bcn.js → jspdf.es.min-CaFlbpn0.js} +3 -3
- package/dist/web/index.html +2 -2
- package/examples/ecommerce-platform/.seeflow/{seeflow.json → architecture.json} +22 -77
- package/examples/ecommerce-platform/.seeflow/details/api-gateway.md +14 -0
- package/examples/ecommerce-platform/.seeflow/details/auth-service.md +9 -0
- package/examples/ecommerce-platform/.seeflow/details/cart-service.md +10 -0
- package/examples/ecommerce-platform/.seeflow/details/notification-service.md +13 -0
- package/examples/ecommerce-platform/.seeflow/details/order-service.md +16 -0
- package/examples/ecommerce-platform/.seeflow/details/payment-service.md +16 -0
- package/examples/ecommerce-platform/.seeflow/details/product-service.md +10 -0
- package/examples/ecommerce-platform/.seeflow/scripts/platform-health.html +42 -0
- package/examples/ecommerce-platform/.seeflow/style.json +98 -0
- package/examples/order-pipeline/.seeflow/architecture.json +93 -0
- package/examples/order-pipeline/.seeflow/details/fulfillment-service.md +21 -0
- package/examples/order-pipeline/.seeflow/details/inventory-service.md +23 -0
- package/examples/order-pipeline/.seeflow/details/payment-service.md +23 -0
- package/examples/order-pipeline/.seeflow/details/post-orders.md +19 -0
- package/examples/order-pipeline/.seeflow/scripts/play.ts +2 -2
- package/examples/order-pipeline/.seeflow/style.json +42 -0
- package/package.json +4 -2
- package/public/runtime/tailwind.js +931 -24378
- package/src/api.ts +118 -118
- package/src/cli.ts +13 -13
- package/src/demo.ts +6 -6
- package/src/diagram.ts +4 -4
- package/src/events.ts +14 -14
- package/src/file-ref.ts +79 -0
- package/src/mcp.ts +117 -89
- package/src/merge.ts +190 -0
- package/src/operations.ts +415 -416
- package/src/proxy.ts +31 -31
- package/src/registry.ts +32 -20
- package/src/schema.ts +252 -8
- package/src/sdk-template.ts +2 -2
- package/src/sdk-writer.ts +2 -2
- package/src/server.ts +14 -7
- package/src/status-runner.ts +34 -38
- package/src/watcher.ts +165 -114
- package/dist/web/assets/index-BotEftAD.css +0 -1
- package/examples/order-pipeline/.seeflow/seeflow.json +0 -123
package/src/operations.ts
CHANGED
|
@@ -7,28 +7,38 @@
|
|
|
7
7
|
// Helpers extracted in US-003: node lifecycle (add/delete/move/reorder).
|
|
8
8
|
// Future stories add patch_node + connector helpers alongside these.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
statSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
11
19
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
12
20
|
import { type ZodIssue, z } from 'zod';
|
|
21
|
+
import { mergeArchitectureAndStyle, splitFlow } from './merge.ts';
|
|
13
22
|
import { seeflowHome } from './paths.ts';
|
|
14
23
|
import { type Registry, slugify } from './registry.ts';
|
|
15
24
|
import {
|
|
16
25
|
ColorTokenSchema,
|
|
17
|
-
type Demo,
|
|
18
|
-
DemoSchema,
|
|
19
26
|
EdgePinSchema,
|
|
27
|
+
type Flow,
|
|
28
|
+
FlowSchema,
|
|
20
29
|
SourceHandleIdSchema,
|
|
21
30
|
TargetHandleIdSchema,
|
|
22
31
|
} from './schema.ts';
|
|
32
|
+
import { ArchitectureSchema, StyleSchema } from './schema.ts';
|
|
23
33
|
import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
|
|
24
|
-
import type
|
|
34
|
+
import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
|
|
25
35
|
|
|
26
|
-
const
|
|
36
|
+
const DEFAULT_ARCHITECTURE_RELATIVE_PATH = '.seeflow/architecture.json';
|
|
27
37
|
|
|
28
38
|
export const RegisterBodySchema = z.object({
|
|
29
39
|
name: z.string().min(1).optional(),
|
|
30
40
|
repoPath: z.string().min(1),
|
|
31
|
-
|
|
41
|
+
architecturePath: z.string().min(1),
|
|
32
42
|
});
|
|
33
43
|
export type RegisterBody = z.infer<typeof RegisterBodySchema>;
|
|
34
44
|
|
|
@@ -58,7 +68,7 @@ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
|
|
|
58
68
|
|
|
59
69
|
// Partial node update body. Top-level `position` lands on node.position; every
|
|
60
70
|
// other key lands inside node.data. Final validity is enforced by re-parsing
|
|
61
|
-
// the whole demo through
|
|
71
|
+
// the whole demo through FlowSchema after the merge — this body schema just
|
|
62
72
|
// rejects unknown top-level keys to catch typos.
|
|
63
73
|
export const NodePatchBodySchema = z
|
|
64
74
|
.object({
|
|
@@ -79,7 +89,7 @@ export const NodePatchBodySchema = z
|
|
|
79
89
|
// that autoSize:true never coexists with persisted width/height.
|
|
80
90
|
autoSize: z.boolean().optional(),
|
|
81
91
|
shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
|
|
82
|
-
// iconNode-only: stroke color token. Lands at data.color;
|
|
92
|
+
// iconNode-only: stroke color token. Lands at data.color; FlowSchema's
|
|
83
93
|
// post-merge reparse gates that this is only valid on an iconNode.
|
|
84
94
|
color: ColorTokenSchema.optional(),
|
|
85
95
|
// iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
|
|
@@ -204,7 +214,7 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
|
|
|
204
214
|
|
|
205
215
|
export interface OperationsDeps {
|
|
206
216
|
registry: Registry;
|
|
207
|
-
watcher?:
|
|
217
|
+
watcher?: FlowWatcher;
|
|
208
218
|
/**
|
|
209
219
|
* Override the base directory for new projects. Defaults to seeflowHome()
|
|
210
220
|
* — `${SEEFLOW_WORKSPACE}/.seeflow` inside Docker, `~/.seeflow` locally.
|
|
@@ -213,7 +223,7 @@ export interface OperationsDeps {
|
|
|
213
223
|
projectBaseDir?: string;
|
|
214
224
|
}
|
|
215
225
|
|
|
216
|
-
export interface
|
|
226
|
+
export interface FlowListItem {
|
|
217
227
|
id: string;
|
|
218
228
|
slug: string;
|
|
219
229
|
name: string;
|
|
@@ -222,17 +232,17 @@ export interface DemoListItem {
|
|
|
222
232
|
valid: boolean;
|
|
223
233
|
}
|
|
224
234
|
|
|
225
|
-
export interface
|
|
235
|
+
export interface FlowGetResponse {
|
|
226
236
|
id: string;
|
|
227
237
|
slug: string;
|
|
228
238
|
name: string;
|
|
229
239
|
filePath: string;
|
|
230
|
-
|
|
240
|
+
flow: Flow | null;
|
|
231
241
|
valid: boolean;
|
|
232
242
|
error: string | null;
|
|
233
243
|
}
|
|
234
244
|
|
|
235
|
-
export interface
|
|
245
|
+
export interface RegisterFlowSuccess {
|
|
236
246
|
id: string;
|
|
237
247
|
slug: string;
|
|
238
248
|
sdk: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
|
|
@@ -244,21 +254,21 @@ export interface CreateProjectSuccess {
|
|
|
244
254
|
scaffolded: boolean;
|
|
245
255
|
}
|
|
246
256
|
|
|
247
|
-
export type
|
|
257
|
+
export type ListFlowsOutcome = { kind: 'ok'; data: FlowListItem[] };
|
|
248
258
|
|
|
249
|
-
export type
|
|
250
|
-
| { kind: 'ok'; data:
|
|
259
|
+
export type GetFlowOutcome =
|
|
260
|
+
| { kind: 'ok'; data: FlowGetResponse }
|
|
251
261
|
| { kind: 'notFound' }
|
|
252
262
|
| { kind: 'fileNotFound'; path: string };
|
|
253
263
|
|
|
254
|
-
export type
|
|
255
|
-
| { kind: 'ok'; data:
|
|
264
|
+
export type RegisterFlowOutcome =
|
|
265
|
+
| { kind: 'ok'; data: RegisterFlowSuccess }
|
|
256
266
|
| { kind: 'fileNotFound'; path: string }
|
|
257
267
|
| { kind: 'badJson'; detail: string }
|
|
258
268
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
259
269
|
| { kind: 'sdkWriteFailed'; id: string; slug: string; message: string };
|
|
260
270
|
|
|
261
|
-
export type
|
|
271
|
+
export type DeleteFlowOutcome = { kind: 'ok' } | { kind: 'notFound' };
|
|
262
272
|
|
|
263
273
|
export type CreateProjectOutcome =
|
|
264
274
|
| { kind: 'ok'; data: CreateProjectSuccess }
|
|
@@ -272,7 +282,7 @@ export type CreateProjectOutcome =
|
|
|
272
282
|
// same status code + JSON body it used to emit directly.
|
|
273
283
|
export type AddNodeOutcome =
|
|
274
284
|
| { kind: 'ok'; data: { id: string; node: Record<string, unknown> } }
|
|
275
|
-
| { kind: '
|
|
285
|
+
| { kind: 'flowNotFound' }
|
|
276
286
|
| { kind: 'fileNotFound'; path: string }
|
|
277
287
|
| { kind: 'badJson'; message: string }
|
|
278
288
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -280,7 +290,7 @@ export type AddNodeOutcome =
|
|
|
280
290
|
|
|
281
291
|
export type DeleteNodeOutcome =
|
|
282
292
|
| { kind: 'ok' }
|
|
283
|
-
| { kind: '
|
|
293
|
+
| { kind: 'flowNotFound' }
|
|
284
294
|
| { kind: 'fileNotFound'; path: string }
|
|
285
295
|
| { kind: 'badJson'; message: string }
|
|
286
296
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -289,7 +299,7 @@ export type DeleteNodeOutcome =
|
|
|
289
299
|
|
|
290
300
|
export type MoveNodeOutcome =
|
|
291
301
|
| { kind: 'ok'; data: { position: PositionBody } }
|
|
292
|
-
| { kind: '
|
|
302
|
+
| { kind: 'flowNotFound' }
|
|
293
303
|
| { kind: 'fileNotFound'; path: string }
|
|
294
304
|
| { kind: 'badJson'; message: string }
|
|
295
305
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -298,7 +308,7 @@ export type MoveNodeOutcome =
|
|
|
298
308
|
|
|
299
309
|
export type ReorderNodeOutcome =
|
|
300
310
|
| { kind: 'ok' }
|
|
301
|
-
| { kind: '
|
|
311
|
+
| { kind: 'flowNotFound' }
|
|
302
312
|
| { kind: 'fileNotFound'; path: string }
|
|
303
313
|
| { kind: 'badJson'; message: string }
|
|
304
314
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -307,7 +317,7 @@ export type ReorderNodeOutcome =
|
|
|
307
317
|
|
|
308
318
|
export type PatchNodeOutcome =
|
|
309
319
|
| { kind: 'ok' }
|
|
310
|
-
| { kind: '
|
|
320
|
+
| { kind: 'flowNotFound' }
|
|
311
321
|
| { kind: 'fileNotFound'; path: string }
|
|
312
322
|
| { kind: 'badJson'; message: string }
|
|
313
323
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -316,7 +326,7 @@ export type PatchNodeOutcome =
|
|
|
316
326
|
|
|
317
327
|
// Partial connector update body. Strict at the top level so client typos
|
|
318
328
|
// surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
|
|
319
|
-
// are enforced post-merge by re-parsing the whole demo through
|
|
329
|
+
// are enforced post-merge by re-parsing the whole demo through FlowSchema.
|
|
320
330
|
const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
|
|
321
331
|
export const ConnectorPatchBodySchema = z
|
|
322
332
|
.object({
|
|
@@ -334,7 +344,7 @@ export const ConnectorPatchBodySchema = z
|
|
|
334
344
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
|
|
335
345
|
url: z.string().optional(),
|
|
336
346
|
// Reconnect: drag an edge endpoint onto another node's handle. The
|
|
337
|
-
// post-merge
|
|
347
|
+
// post-merge FlowSchema parse rejects dangling references, so we don't
|
|
338
348
|
// need a referential check here.
|
|
339
349
|
source: z.string().min(1).optional(),
|
|
340
350
|
target: z.string().min(1).optional(),
|
|
@@ -368,7 +378,7 @@ export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
|
|
|
368
378
|
// Kind-specific connector fields. When `kind` changes via PATCH, these are
|
|
369
379
|
// dropped first so the resulting connector doesn't carry phantom payloads
|
|
370
380
|
// from the previous kind (e.g. an event→default change leaving eventName
|
|
371
|
-
// behind, which
|
|
381
|
+
// behind, which FlowSchema would silently strip on parse but leave on disk).
|
|
372
382
|
const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
|
|
373
383
|
|
|
374
384
|
export const mergeConnectorUpdates = (
|
|
@@ -395,7 +405,7 @@ export const mergeConnectorUpdates = (
|
|
|
395
405
|
|
|
396
406
|
export type AddConnectorOutcome =
|
|
397
407
|
| { kind: 'ok'; data: { id: string } }
|
|
398
|
-
| { kind: '
|
|
408
|
+
| { kind: 'flowNotFound' }
|
|
399
409
|
| { kind: 'fileNotFound'; path: string }
|
|
400
410
|
| { kind: 'badJson'; message: string }
|
|
401
411
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -403,7 +413,7 @@ export type AddConnectorOutcome =
|
|
|
403
413
|
|
|
404
414
|
export type PatchConnectorOutcome =
|
|
405
415
|
| { kind: 'ok' }
|
|
406
|
-
| { kind: '
|
|
416
|
+
| { kind: 'flowNotFound' }
|
|
407
417
|
| { kind: 'fileNotFound'; path: string }
|
|
408
418
|
| { kind: 'badJson'; message: string }
|
|
409
419
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
@@ -412,28 +422,28 @@ export type PatchConnectorOutcome =
|
|
|
412
422
|
|
|
413
423
|
export type DeleteConnectorOutcome =
|
|
414
424
|
| { kind: 'ok' }
|
|
415
|
-
| { kind: '
|
|
425
|
+
| { kind: 'flowNotFound' }
|
|
416
426
|
| { kind: 'fileNotFound'; path: string }
|
|
417
427
|
| { kind: 'badJson'; message: string }
|
|
418
428
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
419
429
|
| { kind: 'unknownConnector' }
|
|
420
430
|
| { kind: 'writeFailed'; message: string };
|
|
421
431
|
|
|
422
|
-
export const
|
|
423
|
-
isAbsolute(
|
|
432
|
+
export const resolveFilePath = (repoPath: string, architecturePath: string): string =>
|
|
433
|
+
isAbsolute(architecturePath) ? architecturePath : join(repoPath, architecturePath);
|
|
424
434
|
|
|
425
435
|
// Per-demo serialization: read-modify-write of the demo file isn't atomic
|
|
426
436
|
// across multiple PATCHes, so two concurrent drags would race (later writer's
|
|
427
437
|
// older read clobbers the earlier writer's update). We chain writes per
|
|
428
|
-
//
|
|
429
|
-
const
|
|
430
|
-
export const
|
|
431
|
-
const prev =
|
|
438
|
+
// flowId so the read+write sequence is effectively serialized.
|
|
439
|
+
const flowWriteChains = new Map<string, Promise<unknown>>();
|
|
440
|
+
export const withFlowWriteLock = <T>(flowId: string, fn: () => Promise<T>): Promise<T> => {
|
|
441
|
+
const prev = flowWriteChains.get(flowId) ?? Promise.resolve();
|
|
432
442
|
const next = prev.then(fn, fn);
|
|
433
443
|
// Replace with a tail that swallows errors so the chain keeps moving even
|
|
434
444
|
// if one write fails — but the original promise still rejects to its caller.
|
|
435
|
-
|
|
436
|
-
|
|
445
|
+
flowWriteChains.set(
|
|
446
|
+
flowId,
|
|
437
447
|
next.catch(() => undefined),
|
|
438
448
|
);
|
|
439
449
|
return next as Promise<T>;
|
|
@@ -461,6 +471,127 @@ export const writeFileAtomic = (filePath: string, content: string): void => {
|
|
|
461
471
|
}
|
|
462
472
|
};
|
|
463
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Read architecture.json + optional style.json, return the raw parsed JSON
|
|
476
|
+
* so operations can mutate the merged-flow shape without losing forward-compat
|
|
477
|
+
* fields. Returns null if the architecture file is missing or invalid JSON.
|
|
478
|
+
*/
|
|
479
|
+
type ReadRawResult =
|
|
480
|
+
| { kind: 'ok'; rawArch: Record<string, unknown>; rawStyle: Record<string, unknown> }
|
|
481
|
+
| { kind: 'badJson'; message: string };
|
|
482
|
+
|
|
483
|
+
function readRawArchAndStyle(archPath: string): ReadRawResult {
|
|
484
|
+
let rawArch: unknown;
|
|
485
|
+
try {
|
|
486
|
+
rawArch = JSON.parse(readFileSync(archPath, 'utf8'));
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
489
|
+
}
|
|
490
|
+
if (!rawArch || typeof rawArch !== 'object' || Array.isArray(rawArch)) {
|
|
491
|
+
return { kind: 'badJson', message: 'architecture.json is not an object' };
|
|
492
|
+
}
|
|
493
|
+
const stylePath = join(dirname(archPath), 'style.json');
|
|
494
|
+
let rawStyle: Record<string, unknown> = {};
|
|
495
|
+
if (existsSync(stylePath)) {
|
|
496
|
+
try {
|
|
497
|
+
const parsed = JSON.parse(readFileSync(stylePath, 'utf8'));
|
|
498
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
499
|
+
return { kind: 'badJson', message: 'style.json is not an object' };
|
|
500
|
+
}
|
|
501
|
+
rawStyle = parsed as Record<string, unknown>;
|
|
502
|
+
} catch (err) {
|
|
503
|
+
return {
|
|
504
|
+
kind: 'badJson',
|
|
505
|
+
message: `style.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { kind: 'ok', rawArch: rawArch as Record<string, unknown>, rawStyle };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Mutate-in-place helper: read both files into a merged Flow shape, hand it
|
|
514
|
+
* to the mutator, then split back into architecture + style and atomically
|
|
515
|
+
* write both. The mutator returns either { kind: 'ok' } or a discriminated
|
|
516
|
+
* outcome for early-exits (e.g. unknownNode). On schema-validation failure,
|
|
517
|
+
* neither file is written.
|
|
518
|
+
*/
|
|
519
|
+
type MutateMergedFlowMutator<E> = (flow: {
|
|
520
|
+
version: number;
|
|
521
|
+
name: string;
|
|
522
|
+
resetAction?: unknown;
|
|
523
|
+
nodes: Array<Record<string, unknown>>;
|
|
524
|
+
connectors: Array<Record<string, unknown>>;
|
|
525
|
+
}) => { kind: 'ok' } | E;
|
|
526
|
+
|
|
527
|
+
type MutateMergedFlowResult<E> =
|
|
528
|
+
| { kind: 'ok' }
|
|
529
|
+
| { kind: 'badJson'; message: string }
|
|
530
|
+
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
531
|
+
| { kind: 'writeFailed'; message: string }
|
|
532
|
+
| E;
|
|
533
|
+
|
|
534
|
+
export async function mutateMergedFlow<E extends { kind: string }>(
|
|
535
|
+
archPath: string,
|
|
536
|
+
mutator: MutateMergedFlowMutator<E>,
|
|
537
|
+
): Promise<MutateMergedFlowResult<E>> {
|
|
538
|
+
const read = readRawArchAndStyle(archPath);
|
|
539
|
+
if (read.kind === 'badJson') return { kind: 'badJson', message: read.message };
|
|
540
|
+
|
|
541
|
+
const archParse = ArchitectureSchema.safeParse(read.rawArch);
|
|
542
|
+
if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
|
|
543
|
+
const styleParse = StyleSchema.safeParse(read.rawStyle);
|
|
544
|
+
if (!styleParse.success) return { kind: 'badSchema', issues: styleParse.error.issues };
|
|
545
|
+
|
|
546
|
+
const merged = mergeArchitectureAndStyle(archParse.data, styleParse.data) as unknown as {
|
|
547
|
+
version: number;
|
|
548
|
+
name: string;
|
|
549
|
+
resetAction?: unknown;
|
|
550
|
+
nodes: Array<Record<string, unknown>>;
|
|
551
|
+
connectors: Array<Record<string, unknown>>;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const outcome = mutator(merged);
|
|
555
|
+
if (outcome.kind !== 'ok') return outcome;
|
|
556
|
+
|
|
557
|
+
// Final FlowSchema parse so per-kind invariants (event needs eventName, etc.)
|
|
558
|
+
// surface honestly instead of being silently papered over.
|
|
559
|
+
const finalParse = FlowSchema.safeParse(merged);
|
|
560
|
+
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
561
|
+
|
|
562
|
+
const { architecture, style } = splitFlow(merged);
|
|
563
|
+
// Re-validate the post-split files to catch the rare case where a forward-
|
|
564
|
+
// compat field landed in the wrong bucket. Style validation is a no-op for
|
|
565
|
+
// empty maps; architecture revalidation rejects unknown keys via strict().
|
|
566
|
+
const archCheck = ArchitectureSchema.safeParse(architecture);
|
|
567
|
+
if (!archCheck.success) return { kind: 'badSchema', issues: archCheck.error.issues };
|
|
568
|
+
const styleCheck = StyleSchema.safeParse(style);
|
|
569
|
+
if (!styleCheck.success) return { kind: 'badSchema', issues: styleCheck.error.issues };
|
|
570
|
+
|
|
571
|
+
const stylePath = join(dirname(archPath), 'style.json');
|
|
572
|
+
const styleIsEmpty =
|
|
573
|
+
(!style.nodes || Object.keys(style.nodes as Record<string, unknown>).length === 0) &&
|
|
574
|
+
(!style.connectors || Object.keys(style.connectors as Record<string, unknown>).length === 0);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
writeFileAtomic(archPath, `${JSON.stringify(architecture, null, 2)}\n`);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
if (styleIsEmpty) {
|
|
584
|
+
if (existsSync(stylePath)) unlinkSync(stylePath);
|
|
585
|
+
} else {
|
|
586
|
+
writeFileAtomic(stylePath, `${JSON.stringify(style, null, 2)}\n`);
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { kind: 'ok' };
|
|
593
|
+
}
|
|
594
|
+
|
|
464
595
|
export const reorderNodes = (
|
|
465
596
|
nodes: Array<Record<string, unknown>>,
|
|
466
597
|
fromIdx: number,
|
|
@@ -511,9 +642,9 @@ export const reorderNodes = (
|
|
|
511
642
|
}
|
|
512
643
|
};
|
|
513
644
|
|
|
514
|
-
export function listDemosImpl(deps: OperationsDeps):
|
|
645
|
+
export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
|
|
515
646
|
const data = deps.registry.list().map((e) => {
|
|
516
|
-
const fullPath =
|
|
647
|
+
const fullPath = resolveFilePath(e.repoPath, e.architecturePath);
|
|
517
648
|
const fileExists = existsSync(fullPath);
|
|
518
649
|
return {
|
|
519
650
|
id: e.id,
|
|
@@ -527,20 +658,20 @@ export function listDemosImpl(deps: OperationsDeps): ListDemosOutcome {
|
|
|
527
658
|
return { kind: 'ok', data };
|
|
528
659
|
}
|
|
529
660
|
|
|
530
|
-
export async function
|
|
661
|
+
export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise<GetFlowOutcome> {
|
|
531
662
|
const { registry, watcher } = deps;
|
|
532
|
-
const entry = registry.getById(
|
|
663
|
+
const entry = registry.getById(flowId);
|
|
533
664
|
if (!entry) return { kind: 'notFound' };
|
|
534
665
|
|
|
535
|
-
const fullPath =
|
|
536
|
-
const snap = watcher?.snapshot(
|
|
666
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
667
|
+
const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
|
|
537
668
|
|
|
538
|
-
const buildResponse = (s:
|
|
669
|
+
const buildResponse = (s: FlowSnapshot): FlowGetResponse => ({
|
|
539
670
|
id: entry.id,
|
|
540
671
|
slug: entry.slug,
|
|
541
672
|
name: entry.name,
|
|
542
673
|
filePath: fullPath,
|
|
543
|
-
|
|
674
|
+
flow: s.flow,
|
|
544
675
|
valid: s.valid,
|
|
545
676
|
error: s.valid ? null : s.error,
|
|
546
677
|
});
|
|
@@ -551,75 +682,53 @@ export async function getDemoImpl(deps: OperationsDeps, demoId: string): Promise
|
|
|
551
682
|
// callers without a long-lived watcher still get a current snapshot.
|
|
552
683
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
553
684
|
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
raw = await Bun.file(fullPath).json();
|
|
557
|
-
} catch (err) {
|
|
558
|
-
return {
|
|
559
|
-
kind: 'ok',
|
|
560
|
-
data: buildResponse({
|
|
561
|
-
demo: null,
|
|
562
|
-
valid: false,
|
|
563
|
-
error: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
564
|
-
filePath: fullPath,
|
|
565
|
-
parsedAt: Date.now(),
|
|
566
|
-
}),
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
const parsed = DemoSchema.safeParse(raw);
|
|
570
|
-
if (!parsed.success) {
|
|
571
|
-
return {
|
|
572
|
-
kind: 'ok',
|
|
573
|
-
data: buildResponse({
|
|
574
|
-
demo: null,
|
|
575
|
-
valid: false,
|
|
576
|
-
error: parsed.error.issues
|
|
577
|
-
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
578
|
-
.join('; '),
|
|
579
|
-
filePath: fullPath,
|
|
580
|
-
parsedAt: Date.now(),
|
|
581
|
-
}),
|
|
582
|
-
};
|
|
583
|
-
}
|
|
685
|
+
const result = readMergedFlow(fullPath);
|
|
584
686
|
return {
|
|
585
687
|
kind: 'ok',
|
|
586
688
|
data: buildResponse({
|
|
587
|
-
|
|
588
|
-
valid:
|
|
589
|
-
error:
|
|
689
|
+
flow: result.flow,
|
|
690
|
+
valid: result.valid,
|
|
691
|
+
error: result.error,
|
|
590
692
|
filePath: fullPath,
|
|
591
693
|
parsedAt: Date.now(),
|
|
592
694
|
}),
|
|
593
695
|
};
|
|
594
696
|
}
|
|
595
697
|
|
|
596
|
-
export async function
|
|
698
|
+
export async function registerFlowImpl(
|
|
597
699
|
deps: OperationsDeps,
|
|
598
700
|
body: RegisterBody,
|
|
599
|
-
): Promise<
|
|
701
|
+
): Promise<RegisterFlowOutcome> {
|
|
600
702
|
const { registry, watcher } = deps;
|
|
601
|
-
const { repoPath,
|
|
602
|
-
const fullPath =
|
|
703
|
+
const { repoPath, architecturePath } = body;
|
|
704
|
+
const fullPath = resolveFilePath(repoPath, architecturePath);
|
|
603
705
|
|
|
604
706
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
605
707
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
//
|
|
612
|
-
|
|
708
|
+
const merged = readMergedFlow(fullPath);
|
|
709
|
+
if (merged.error && merged.flow === null) {
|
|
710
|
+
if (merged.error.startsWith('Invalid JSON')) {
|
|
711
|
+
return { kind: 'badJson', detail: merged.error };
|
|
712
|
+
}
|
|
713
|
+
// Schema validation failed — surface the issues as a bad-schema outcome
|
|
714
|
+
// by re-running parse to get ZodIssue[].
|
|
715
|
+
let rawArch: unknown;
|
|
716
|
+
try {
|
|
717
|
+
rawArch = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
718
|
+
} catch (err) {
|
|
719
|
+
return { kind: 'badJson', detail: String(err) };
|
|
720
|
+
}
|
|
721
|
+
const archParse = ArchitectureSchema.safeParse(rawArch);
|
|
722
|
+
if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
|
|
723
|
+
return { kind: 'badJson', detail: merged.error };
|
|
613
724
|
}
|
|
614
|
-
|
|
615
|
-
const demoParse = DemoSchema.safeParse(demo);
|
|
616
|
-
if (!demoParse.success) return { kind: 'badSchema', issues: demoParse.error.issues };
|
|
725
|
+
if (!merged.flow) return { kind: 'badJson', detail: merged.error ?? 'unknown error' };
|
|
617
726
|
|
|
618
727
|
const lastModified = statSync(fullPath).mtimeMs;
|
|
619
728
|
const entry = registry.upsert({
|
|
620
|
-
name: body.name ??
|
|
729
|
+
name: body.name ?? merged.flow.name,
|
|
621
730
|
repoPath,
|
|
622
|
-
|
|
731
|
+
architecturePath,
|
|
623
732
|
valid: true,
|
|
624
733
|
lastModified,
|
|
625
734
|
});
|
|
@@ -628,7 +737,7 @@ export async function registerDemoImpl(
|
|
|
628
737
|
|
|
629
738
|
let sdkResult: { outcome: 'written' | 'present' | 'skipped'; filePath: string | null };
|
|
630
739
|
try {
|
|
631
|
-
sdkResult = writeSdkEmitIfNeeded(repoPath,
|
|
740
|
+
sdkResult = writeSdkEmitIfNeeded(repoPath, merged.flow);
|
|
632
741
|
} catch (err) {
|
|
633
742
|
const message = err instanceof Error ? err.message : String(err);
|
|
634
743
|
return { kind: 'sdkWriteFailed', id: entry.id, slug: entry.slug, message };
|
|
@@ -644,7 +753,7 @@ export async function registerDemoImpl(
|
|
|
644
753
|
};
|
|
645
754
|
}
|
|
646
755
|
|
|
647
|
-
export function
|
|
756
|
+
export function deleteFlowImpl(deps: OperationsDeps, idOrSlug: string): DeleteFlowOutcome {
|
|
648
757
|
const { registry, watcher } = deps;
|
|
649
758
|
const entry = registry.getById(idOrSlug) ?? registry.getBySlug(idOrSlug);
|
|
650
759
|
if (!entry) return { kind: 'notFound' };
|
|
@@ -662,7 +771,7 @@ export async function createProjectImpl(
|
|
|
662
771
|
const baseDir = deps.projectBaseDir ?? seeflowHome();
|
|
663
772
|
const folderPath = join(baseDir, slugify(name));
|
|
664
773
|
|
|
665
|
-
const demoFullPath = join(folderPath,
|
|
774
|
+
const demoFullPath = join(folderPath, DEFAULT_ARCHITECTURE_RELATIVE_PATH);
|
|
666
775
|
|
|
667
776
|
if (existsSync(demoFullPath)) {
|
|
668
777
|
let raw: unknown;
|
|
@@ -671,14 +780,14 @@ export async function createProjectImpl(
|
|
|
671
780
|
} catch (err) {
|
|
672
781
|
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
673
782
|
}
|
|
674
|
-
const
|
|
675
|
-
if (!
|
|
783
|
+
const archParse = ArchitectureSchema.safeParse(raw);
|
|
784
|
+
if (!archParse.success) return { kind: 'badSchema', issues: archParse.error.issues };
|
|
676
785
|
|
|
677
786
|
const lastModified = statSync(demoFullPath).mtimeMs;
|
|
678
787
|
const entry = registry.upsert({
|
|
679
788
|
name,
|
|
680
789
|
repoPath: folderPath,
|
|
681
|
-
|
|
790
|
+
architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
|
|
682
791
|
valid: true,
|
|
683
792
|
lastModified,
|
|
684
793
|
});
|
|
@@ -686,7 +795,8 @@ export async function createProjectImpl(
|
|
|
686
795
|
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
|
|
687
796
|
}
|
|
688
797
|
|
|
689
|
-
|
|
798
|
+
// Architecture-only scaffold: empty nodes/connectors, no style.json needed.
|
|
799
|
+
const scaffold: Flow = { version: 2, name, nodes: [], connectors: [] };
|
|
690
800
|
|
|
691
801
|
try {
|
|
692
802
|
mkdirSync(join(folderPath, '.seeflow'), { recursive: true });
|
|
@@ -708,7 +818,7 @@ export async function createProjectImpl(
|
|
|
708
818
|
const entry = registry.upsert({
|
|
709
819
|
name,
|
|
710
820
|
repoPath: folderPath,
|
|
711
|
-
|
|
821
|
+
architecturePath: DEFAULT_ARCHITECTURE_RELATIVE_PATH,
|
|
712
822
|
valid: true,
|
|
713
823
|
lastModified,
|
|
714
824
|
});
|
|
@@ -716,22 +826,27 @@ export async function createProjectImpl(
|
|
|
716
826
|
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
|
|
717
827
|
}
|
|
718
828
|
|
|
719
|
-
// Append a new node to the demo. Auto-generates an id when absent;
|
|
829
|
+
// Append a new node to the demo. Auto-generates an id when absent; FlowSchema
|
|
720
830
|
// is re-run on the post-mutation raw object before commit so a malformed
|
|
721
831
|
// payload never produces a half-written file.
|
|
722
832
|
export async function addNodeImpl(
|
|
723
833
|
deps: OperationsDeps,
|
|
724
|
-
|
|
834
|
+
flowId: string,
|
|
725
835
|
nodeBody: Record<string, unknown>,
|
|
726
836
|
): Promise<AddNodeOutcome> {
|
|
727
|
-
const entry = deps.registry.getById(
|
|
728
|
-
if (!entry) return { kind: '
|
|
837
|
+
const entry = deps.registry.getById(flowId);
|
|
838
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
729
839
|
|
|
730
840
|
const newNode = { ...nodeBody };
|
|
731
841
|
if (typeof newNode.id !== 'string' || newNode.id.length === 0) {
|
|
732
842
|
newNode.id = `node-${crypto.randomUUID()}`;
|
|
733
843
|
}
|
|
734
844
|
const newId = newNode.id as string;
|
|
845
|
+
// Default position so the post-merge FlowSchema parse passes. Position lives
|
|
846
|
+
// on style.json after the split — callers who care set it explicitly.
|
|
847
|
+
if (!newNode.position || typeof newNode.position !== 'object') {
|
|
848
|
+
newNode.position = { x: 0, y: 0 };
|
|
849
|
+
}
|
|
735
850
|
|
|
736
851
|
// US-015: for htmlNode without a client-supplied htmlPath, allocate the
|
|
737
852
|
// studio-managed `blocks/<id>.html` path and queue a starter-file write.
|
|
@@ -761,47 +876,23 @@ export async function addNodeImpl(
|
|
|
761
876
|
}
|
|
762
877
|
}
|
|
763
878
|
|
|
764
|
-
const fullPath =
|
|
879
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
765
880
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
766
881
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
} catch (err) {
|
|
778
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
779
|
-
}
|
|
780
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
781
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
782
|
-
|
|
783
|
-
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
784
|
-
obj.nodes.push(newNode);
|
|
785
|
-
|
|
786
|
-
const finalParse = DemoSchema.safeParse(raw);
|
|
787
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
788
|
-
|
|
789
|
-
if (starterFile) {
|
|
790
|
-
try {
|
|
791
|
-
mkdirSync(dirname(starterFile.absPath), { recursive: true });
|
|
792
|
-
writeFileAtomic(starterFile.absPath, starterFile.content);
|
|
793
|
-
} catch (err) {
|
|
794
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
882
|
+
const result = await withFlowWriteLock(flowId, () =>
|
|
883
|
+
mutateMergedFlow<{ kind: 'writeFailed'; message: string }>(fullPath, (flow) => {
|
|
884
|
+
flow.nodes.push(newNode);
|
|
885
|
+
if (starterFile) {
|
|
886
|
+
try {
|
|
887
|
+
mkdirSync(dirname(starterFile.absPath), { recursive: true });
|
|
888
|
+
writeFileAtomic(starterFile.absPath, starterFile.content);
|
|
889
|
+
} catch (err) {
|
|
890
|
+
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
891
|
+
}
|
|
795
892
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
800
|
-
} catch (err) {
|
|
801
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
802
|
-
}
|
|
803
|
-
return { kind: 'ok' };
|
|
804
|
-
});
|
|
893
|
+
return { kind: 'ok' };
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
805
896
|
|
|
806
897
|
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
|
|
807
898
|
return result;
|
|
@@ -821,7 +912,7 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
|
|
|
821
912
|
`;
|
|
822
913
|
|
|
823
914
|
// Remove a node and cascade-delete every connector touching it in a single
|
|
824
|
-
// atomic write. Final
|
|
915
|
+
// atomic write. Final FlowSchema parse stays in place so a pre-existing
|
|
825
916
|
// schema violation surfaces honestly instead of being silently papered over.
|
|
826
917
|
// US-016: when the removed node is an htmlNode whose data.htmlPath matches the
|
|
827
918
|
// studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
|
|
@@ -829,68 +920,44 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
|
|
|
829
920
|
// with US-015's "client-supplied htmlPath wins, no starter file written").
|
|
830
921
|
export async function deleteNodeImpl(
|
|
831
922
|
deps: OperationsDeps,
|
|
832
|
-
|
|
923
|
+
flowId: string,
|
|
833
924
|
nodeId: string,
|
|
834
925
|
): Promise<DeleteNodeOutcome> {
|
|
835
|
-
const entry = deps.registry.getById(
|
|
836
|
-
if (!entry) return { kind: '
|
|
926
|
+
const entry = deps.registry.getById(flowId);
|
|
927
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
837
928
|
|
|
838
|
-
const fullPath =
|
|
929
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
839
930
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
840
931
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
return { kind: '
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
857
|
-
|
|
858
|
-
const obj = raw as {
|
|
859
|
-
nodes: Array<Record<string, unknown>>;
|
|
860
|
-
connectors: Array<{ source: string; target: string }>;
|
|
861
|
-
};
|
|
862
|
-
const idx = obj.nodes.findIndex((n) => n.id === nodeId);
|
|
863
|
-
if (idx < 0) return { kind: 'unknownNode' };
|
|
864
|
-
const removed = obj.nodes[idx];
|
|
865
|
-
const managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
|
|
866
|
-
obj.nodes.splice(idx, 1);
|
|
867
|
-
obj.connectors = obj.connectors.filter((cn) => cn.source !== nodeId && cn.target !== nodeId);
|
|
868
|
-
|
|
869
|
-
const finalParse = DemoSchema.safeParse(raw);
|
|
870
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
871
|
-
|
|
872
|
-
try {
|
|
873
|
-
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
874
|
-
} catch (err) {
|
|
875
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
876
|
-
}
|
|
877
|
-
return managedHtmlAbsPath ? { kind: 'ok', managedHtmlAbsPath } : { kind: 'ok' };
|
|
878
|
-
});
|
|
932
|
+
let managedHtmlAbsPath: string | undefined;
|
|
933
|
+
|
|
934
|
+
const result = await withFlowWriteLock(flowId, () =>
|
|
935
|
+
mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
|
|
936
|
+
const idx = flow.nodes.findIndex((n) => n.id === nodeId);
|
|
937
|
+
if (idx < 0) return { kind: 'unknownNode' };
|
|
938
|
+
const removed = flow.nodes[idx];
|
|
939
|
+
managedHtmlAbsPath = managedHtmlNodePath(entry.repoPath, nodeId, removed);
|
|
940
|
+
flow.nodes.splice(idx, 1);
|
|
941
|
+
flow.connectors = flow.connectors.filter(
|
|
942
|
+
(cn) => cn.source !== nodeId && cn.target !== nodeId,
|
|
943
|
+
);
|
|
944
|
+
return { kind: 'ok' };
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
879
947
|
|
|
880
|
-
if (result.kind === 'ok' &&
|
|
948
|
+
if (result.kind === 'ok' && managedHtmlAbsPath) {
|
|
881
949
|
try {
|
|
882
|
-
unlinkSync(
|
|
950
|
+
unlinkSync(managedHtmlAbsPath);
|
|
883
951
|
} catch (err) {
|
|
884
952
|
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
885
953
|
if (code !== 'ENOENT') {
|
|
886
954
|
console.warn(
|
|
887
|
-
`[operations] failed to remove managed htmlNode file ${
|
|
955
|
+
`[operations] failed to remove managed htmlNode file ${managedHtmlAbsPath}: ${
|
|
888
956
|
err instanceof Error ? err.message : String(err)
|
|
889
957
|
}`,
|
|
890
958
|
);
|
|
891
959
|
}
|
|
892
960
|
}
|
|
893
|
-
return { kind: 'ok' };
|
|
894
961
|
}
|
|
895
962
|
|
|
896
963
|
return result;
|
|
@@ -918,52 +985,29 @@ const managedHtmlNodePath = (
|
|
|
918
985
|
// schema doesn't yet recognize survive the round-trip untouched.
|
|
919
986
|
export async function moveNodeImpl(
|
|
920
987
|
deps: OperationsDeps,
|
|
921
|
-
|
|
988
|
+
flowId: string,
|
|
922
989
|
nodeId: string,
|
|
923
990
|
position: PositionBody,
|
|
924
991
|
): Promise<MoveNodeOutcome> {
|
|
925
|
-
const entry = deps.registry.getById(
|
|
926
|
-
if (!entry) return { kind: '
|
|
992
|
+
const entry = deps.registry.getById(flowId);
|
|
993
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
927
994
|
|
|
928
|
-
const fullPath =
|
|
995
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
929
996
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
930
997
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
raw = await Bun.file(fullPath).json();
|
|
942
|
-
} catch (err) {
|
|
943
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
944
|
-
}
|
|
945
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
946
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
947
|
-
|
|
948
|
-
const obj = raw as {
|
|
949
|
-
nodes: Array<{ id: string; position: { x: number; y: number } }>;
|
|
950
|
-
};
|
|
951
|
-
const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
|
|
952
|
-
if (!onDiskNode) return { kind: 'unknownNode' };
|
|
953
|
-
onDiskNode.position = { x: position.x, y: position.y };
|
|
954
|
-
|
|
955
|
-
try {
|
|
956
|
-
writeFileAtomic(fullPath, `${JSON.stringify(obj, null, 2)}\n`);
|
|
957
|
-
} catch (err) {
|
|
958
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
959
|
-
}
|
|
960
|
-
return { kind: 'ok' };
|
|
961
|
-
});
|
|
998
|
+
const result = await withFlowWriteLock(flowId, () =>
|
|
999
|
+
mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
|
|
1000
|
+
const node = flow.nodes.find((n) => n.id === nodeId) as
|
|
1001
|
+
| { id: string; position?: { x: number; y: number } }
|
|
1002
|
+
| undefined;
|
|
1003
|
+
if (!node) return { kind: 'unknownNode' };
|
|
1004
|
+
node.position = { x: position.x, y: position.y };
|
|
1005
|
+
return { kind: 'ok' };
|
|
1006
|
+
}),
|
|
1007
|
+
);
|
|
962
1008
|
|
|
963
1009
|
if (result.kind === 'ok') {
|
|
964
|
-
|
|
965
|
-
// returns the updated position without waiting for the 100ms FSWatcher debounce.
|
|
966
|
-
deps.watcher?.reparse(demoId);
|
|
1010
|
+
deps.watcher?.reparse(flowId);
|
|
967
1011
|
return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
|
|
968
1012
|
}
|
|
969
1013
|
return result;
|
|
@@ -971,55 +1015,28 @@ export async function moveNodeImpl(
|
|
|
971
1015
|
|
|
972
1016
|
// Apply a partial PATCH body to a single node. Mutation runs against the
|
|
973
1017
|
// raw parsed JSON (so unknown forward-compat fields survive a round-trip),
|
|
974
|
-
// and the whole demo is re-validated through
|
|
1018
|
+
// and the whole demo is re-validated through FlowSchema before commit so
|
|
975
1019
|
// partial writes can't break invariants like the connector→node superRefine.
|
|
976
1020
|
export async function patchNodeImpl(
|
|
977
1021
|
deps: OperationsDeps,
|
|
978
|
-
|
|
1022
|
+
flowId: string,
|
|
979
1023
|
nodeId: string,
|
|
980
1024
|
updates: NodePatchBody,
|
|
981
1025
|
): Promise<PatchNodeOutcome> {
|
|
982
|
-
const entry = deps.registry.getById(
|
|
983
|
-
if (!entry) return { kind: '
|
|
1026
|
+
const entry = deps.registry.getById(flowId);
|
|
1027
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
984
1028
|
|
|
985
|
-
const fullPath =
|
|
1029
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
986
1030
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
987
1031
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
let raw: unknown;
|
|
997
|
-
try {
|
|
998
|
-
raw = await Bun.file(fullPath).json();
|
|
999
|
-
} catch (err) {
|
|
1000
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1001
|
-
}
|
|
1002
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
1003
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1004
|
-
|
|
1005
|
-
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
1006
|
-
const onDiskNode = obj.nodes.find((n) => n.id === nodeId);
|
|
1007
|
-
if (!onDiskNode) return { kind: 'unknownNode' };
|
|
1008
|
-
|
|
1009
|
-
mergeNodeUpdates(onDiskNode, updates);
|
|
1010
|
-
|
|
1011
|
-
const finalParse = DemoSchema.safeParse(raw);
|
|
1012
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1013
|
-
|
|
1014
|
-
try {
|
|
1015
|
-
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1016
|
-
} catch (err) {
|
|
1017
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1018
|
-
}
|
|
1019
|
-
return { kind: 'ok' };
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
return result;
|
|
1032
|
+
return withFlowWriteLock(flowId, () =>
|
|
1033
|
+
mutateMergedFlow<{ kind: 'unknownNode' }>(fullPath, (flow) => {
|
|
1034
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
1035
|
+
if (!node) return { kind: 'unknownNode' };
|
|
1036
|
+
mergeNodeUpdates(node, updates);
|
|
1037
|
+
return { kind: 'ok' };
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1023
1040
|
}
|
|
1024
1041
|
|
|
1025
1042
|
// Reorder a node within demo.nodes[] (changes paint order in the canvas).
|
|
@@ -1027,65 +1044,41 @@ export async function patchNodeImpl(
|
|
|
1027
1044
|
// writing so we don't trigger a watcher echo for nothing.
|
|
1028
1045
|
export async function reorderNodeImpl(
|
|
1029
1046
|
deps: OperationsDeps,
|
|
1030
|
-
|
|
1047
|
+
flowId: string,
|
|
1031
1048
|
nodeId: string,
|
|
1032
1049
|
body: ReorderBody,
|
|
1033
1050
|
): Promise<ReorderNodeOutcome> {
|
|
1034
|
-
const entry = deps.registry.getById(
|
|
1035
|
-
if (!entry) return { kind: '
|
|
1051
|
+
const entry = deps.registry.getById(flowId);
|
|
1052
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
1036
1053
|
|
|
1037
|
-
const fullPath =
|
|
1054
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
1038
1055
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1039
1056
|
|
|
1040
|
-
|
|
1041
|
-
| { kind: '
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
try {
|
|
1050
|
-
raw = await Bun.file(fullPath).json();
|
|
1051
|
-
} catch (err) {
|
|
1052
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1053
|
-
}
|
|
1054
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
1055
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1056
|
-
|
|
1057
|
-
const obj = raw as { nodes: Array<Record<string, unknown>> };
|
|
1058
|
-
const fromIdx = obj.nodes.findIndex((n) => n.id === nodeId);
|
|
1059
|
-
if (fromIdx < 0) return { kind: 'unknownNode' };
|
|
1060
|
-
|
|
1061
|
-
const moved = reorderNodes(obj.nodes, fromIdx, body);
|
|
1062
|
-
if (!moved) return { kind: 'ok' };
|
|
1063
|
-
|
|
1064
|
-
const finalParse = DemoSchema.safeParse(raw);
|
|
1065
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1066
|
-
|
|
1067
|
-
try {
|
|
1068
|
-
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1069
|
-
} catch (err) {
|
|
1070
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1071
|
-
}
|
|
1072
|
-
return { kind: 'ok' };
|
|
1073
|
-
});
|
|
1057
|
+
const result = await withFlowWriteLock(flowId, () =>
|
|
1058
|
+
mutateMergedFlow<{ kind: 'unknownNode' } | { kind: 'noop' }>(fullPath, (flow) => {
|
|
1059
|
+
const fromIdx = flow.nodes.findIndex((n) => n.id === nodeId);
|
|
1060
|
+
if (fromIdx < 0) return { kind: 'unknownNode' };
|
|
1061
|
+
const moved = reorderNodes(flow.nodes, fromIdx, body);
|
|
1062
|
+
if (!moved) return { kind: 'noop' };
|
|
1063
|
+
return { kind: 'ok' };
|
|
1064
|
+
}),
|
|
1065
|
+
);
|
|
1074
1066
|
|
|
1075
|
-
return
|
|
1067
|
+
if (result.kind === 'noop') return { kind: 'ok' };
|
|
1068
|
+
return result as ReorderNodeOutcome;
|
|
1076
1069
|
}
|
|
1077
1070
|
|
|
1078
1071
|
// Append a new connector to demo.connectors. `id` is auto-generated when
|
|
1079
1072
|
// absent and `kind` defaults to 'default' (the no-semantics user-drawn
|
|
1080
|
-
// variant). Source/target referential integrity is enforced by
|
|
1073
|
+
// variant). Source/target referential integrity is enforced by FlowSchema's
|
|
1081
1074
|
// superRefine on the post-mutation parse.
|
|
1082
1075
|
export async function addConnectorImpl(
|
|
1083
1076
|
deps: OperationsDeps,
|
|
1084
|
-
|
|
1077
|
+
flowId: string,
|
|
1085
1078
|
connBody: Record<string, unknown>,
|
|
1086
1079
|
): Promise<AddConnectorOutcome> {
|
|
1087
|
-
const entry = deps.registry.getById(
|
|
1088
|
-
if (!entry) return { kind: '
|
|
1080
|
+
const entry = deps.registry.getById(flowId);
|
|
1081
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
1089
1082
|
|
|
1090
1083
|
const newConn = { ...connBody };
|
|
1091
1084
|
if (typeof newConn.id !== 'string' || newConn.id.length === 0) {
|
|
@@ -1096,38 +1089,15 @@ export async function addConnectorImpl(
|
|
|
1096
1089
|
}
|
|
1097
1090
|
const newId = newConn.id as string;
|
|
1098
1091
|
|
|
1099
|
-
const fullPath =
|
|
1092
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
1100
1093
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1101
1094
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
const result = await withDemoWriteLock<Inner>(demoId, async () => {
|
|
1109
|
-
let raw: unknown;
|
|
1110
|
-
try {
|
|
1111
|
-
raw = await Bun.file(fullPath).json();
|
|
1112
|
-
} catch (err) {
|
|
1113
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1114
|
-
}
|
|
1115
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
1116
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1117
|
-
|
|
1118
|
-
const obj = raw as { connectors: Array<Record<string, unknown>> };
|
|
1119
|
-
obj.connectors.push(newConn);
|
|
1120
|
-
|
|
1121
|
-
const finalParse = DemoSchema.safeParse(raw);
|
|
1122
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1123
|
-
|
|
1124
|
-
try {
|
|
1125
|
-
writeFileAtomic(fullPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
1126
|
-
} catch (err) {
|
|
1127
|
-
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
1128
|
-
}
|
|
1129
|
-
return { kind: 'ok' };
|
|
1130
|
-
});
|
|
1095
|
+
const result = await withFlowWriteLock(flowId, () =>
|
|
1096
|
+
mutateMergedFlow<never>(fullPath, (flow) => {
|
|
1097
|
+
flow.connectors.push(newConn);
|
|
1098
|
+
return { kind: 'ok' };
|
|
1099
|
+
}),
|
|
1100
|
+
);
|
|
1131
1101
|
|
|
1132
1102
|
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
|
|
1133
1103
|
return result;
|
|
@@ -1138,105 +1108,134 @@ export async function addConnectorImpl(
|
|
|
1138
1108
|
// When `kind` changes, the previous kind's payload fields are dropped first
|
|
1139
1109
|
// so the connector doesn't carry phantom data; explicit `null` in the patch
|
|
1140
1110
|
// clears the field on disk (used by reconnect-to-body to drop a pinned
|
|
1141
|
-
// handle id). The whole demo is re-validated through
|
|
1111
|
+
// handle id). The whole demo is re-validated through FlowSchema before
|
|
1142
1112
|
// commit so the discriminated union catches missing-required-fields
|
|
1143
1113
|
// (e.g. kind='event' without eventName) and the superRefine gates
|
|
1144
1114
|
// source/target referential integrity + handle role invariants.
|
|
1145
1115
|
export async function patchConnectorImpl(
|
|
1146
1116
|
deps: OperationsDeps,
|
|
1147
|
-
|
|
1117
|
+
flowId: string,
|
|
1148
1118
|
connectorId: string,
|
|
1149
1119
|
updates: ConnectorPatchBody,
|
|
1150
1120
|
): Promise<PatchConnectorOutcome> {
|
|
1151
|
-
const entry = deps.registry.getById(
|
|
1152
|
-
if (!entry) return { kind: '
|
|
1121
|
+
const entry = deps.registry.getById(flowId);
|
|
1122
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
1153
1123
|
|
|
1154
|
-
const fullPath =
|
|
1124
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
1155
1125
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1156
1126
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
let raw: unknown;
|
|
1166
|
-
try {
|
|
1167
|
-
raw = await Bun.file(fullPath).json();
|
|
1168
|
-
} catch (err) {
|
|
1169
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1170
|
-
}
|
|
1171
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
1172
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1173
|
-
|
|
1174
|
-
const obj = raw as { connectors: Array<Record<string, unknown>> };
|
|
1175
|
-
const onDiskConn = obj.connectors.find((cn) => cn.id === connectorId);
|
|
1176
|
-
if (!onDiskConn) return { kind: 'unknownConnector' };
|
|
1177
|
-
|
|
1178
|
-
mergeConnectorUpdates(onDiskConn, updates);
|
|
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;
|
|
1127
|
+
return withFlowWriteLock(flowId, () =>
|
|
1128
|
+
mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
|
|
1129
|
+
const conn = flow.connectors.find((cn) => cn.id === connectorId);
|
|
1130
|
+
if (!conn) return { kind: 'unknownConnector' };
|
|
1131
|
+
mergeConnectorUpdates(conn, updates);
|
|
1132
|
+
return { kind: 'ok' };
|
|
1133
|
+
}),
|
|
1134
|
+
);
|
|
1192
1135
|
}
|
|
1193
1136
|
|
|
1194
1137
|
// Remove a connector by id. No cascade — node deletion is what cascades,
|
|
1195
|
-
// not connector deletion. Final
|
|
1138
|
+
// not connector deletion. Final FlowSchema parse still runs so a pre-existing
|
|
1196
1139
|
// schema violation surfaces honestly instead of being silently papered over.
|
|
1197
1140
|
export async function deleteConnectorImpl(
|
|
1198
1141
|
deps: OperationsDeps,
|
|
1199
|
-
|
|
1142
|
+
flowId: string,
|
|
1200
1143
|
connectorId: string,
|
|
1201
1144
|
): Promise<DeleteConnectorOutcome> {
|
|
1202
|
-
const entry = deps.registry.getById(
|
|
1203
|
-
if (!entry) return { kind: '
|
|
1145
|
+
const entry = deps.registry.getById(flowId);
|
|
1146
|
+
if (!entry) return { kind: 'flowNotFound' };
|
|
1204
1147
|
|
|
1205
|
-
const fullPath =
|
|
1148
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.architecturePath);
|
|
1206
1149
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1207
1150
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1151
|
+
return withFlowWriteLock(flowId, () =>
|
|
1152
|
+
mutateMergedFlow<{ kind: 'unknownConnector' }>(fullPath, (flow) => {
|
|
1153
|
+
const idx = flow.connectors.findIndex((cn) => cn.id === connectorId);
|
|
1154
|
+
if (idx < 0) return { kind: 'unknownConnector' };
|
|
1155
|
+
flow.connectors.splice(idx, 1);
|
|
1156
|
+
return { kind: 'ok' };
|
|
1157
|
+
}),
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// =============================================================================
|
|
1162
|
+
// validateImpl — stateless schema validator. Powers POST /api/validate +
|
|
1163
|
+
// the validate_seeflow MCP tool. Schema-only: no file:// resolution, no
|
|
1164
|
+
// registry side-effects.
|
|
1165
|
+
// =============================================================================
|
|
1214
1166
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
} catch (err) {
|
|
1220
|
-
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
1221
|
-
}
|
|
1222
|
-
const demoParsed = DemoSchema.safeParse(raw);
|
|
1223
|
-
if (!demoParsed.success) return { kind: 'badSchema', issues: demoParsed.error.issues };
|
|
1167
|
+
export interface ValidateBody {
|
|
1168
|
+
architecture: unknown;
|
|
1169
|
+
style?: unknown;
|
|
1170
|
+
}
|
|
1224
1171
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1172
|
+
export interface ValidationIssue {
|
|
1173
|
+
scope: 'architecture' | 'style' | 'cross';
|
|
1174
|
+
path: (string | number)[];
|
|
1175
|
+
message: string;
|
|
1176
|
+
code: string;
|
|
1177
|
+
}
|
|
1229
1178
|
|
|
1230
|
-
|
|
1231
|
-
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
1179
|
+
export type ValidateOutcome = { ok: true } | { ok: false; issues: ValidationIssue[] };
|
|
1232
1180
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1181
|
+
export function validateImpl(body: ValidateBody): ValidateOutcome {
|
|
1182
|
+
const issues: ValidationIssue[] = [];
|
|
1183
|
+
|
|
1184
|
+
const archParse = ArchitectureSchema.safeParse(body.architecture);
|
|
1185
|
+
if (!archParse.success) {
|
|
1186
|
+
for (const i of archParse.error.issues) {
|
|
1187
|
+
issues.push({
|
|
1188
|
+
scope: 'architecture',
|
|
1189
|
+
path: [...i.path],
|
|
1190
|
+
message: i.message,
|
|
1191
|
+
code: i.code,
|
|
1192
|
+
});
|
|
1237
1193
|
}
|
|
1238
|
-
|
|
1239
|
-
});
|
|
1194
|
+
}
|
|
1240
1195
|
|
|
1241
|
-
|
|
1196
|
+
let styleData:
|
|
1197
|
+
| { nodes?: Record<string, unknown>; connectors?: Record<string, unknown> }
|
|
1198
|
+
| undefined;
|
|
1199
|
+
if (body.style !== undefined) {
|
|
1200
|
+
const styleParse = StyleSchema.safeParse(body.style);
|
|
1201
|
+
if (!styleParse.success) {
|
|
1202
|
+
for (const i of styleParse.error.issues) {
|
|
1203
|
+
issues.push({
|
|
1204
|
+
scope: 'style',
|
|
1205
|
+
path: [...i.path],
|
|
1206
|
+
message: i.message,
|
|
1207
|
+
code: i.code,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
} else {
|
|
1211
|
+
styleData = styleParse.data as never;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (archParse.success && styleData) {
|
|
1216
|
+
const archNodeIds = new Set(archParse.data.nodes.map((n) => n.id));
|
|
1217
|
+
const archConnIds = new Set(archParse.data.connectors.map((c) => c.id));
|
|
1218
|
+
for (const id of Object.keys(styleData.nodes ?? {})) {
|
|
1219
|
+
if (!archNodeIds.has(id)) {
|
|
1220
|
+
issues.push({
|
|
1221
|
+
scope: 'cross',
|
|
1222
|
+
path: ['nodes', id],
|
|
1223
|
+
message: `Style entry references unknown node id: ${id}`,
|
|
1224
|
+
code: 'orphan_style_node',
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
for (const id of Object.keys(styleData.connectors ?? {})) {
|
|
1229
|
+
if (!archConnIds.has(id)) {
|
|
1230
|
+
issues.push({
|
|
1231
|
+
scope: 'cross',
|
|
1232
|
+
path: ['connectors', id],
|
|
1233
|
+
message: `Style entry references unknown connector id: ${id}`,
|
|
1234
|
+
code: 'orphan_style_connector',
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return issues.length === 0 ? { ok: true } : { ok: false, issues };
|
|
1242
1241
|
}
|