@tuongaz/seeflow 0.1.31 → 0.1.40
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/README.md +8 -8
- package/dist/web/assets/index-BwdVgB2y.css +1 -0
- package/dist/web/assets/index-DTNk6GGk.js +7838 -0
- package/dist/web/assets/{index.es-B9awKpqd.js → index.es-D_iCCj4R.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BPVV_TTL.js → jspdf.es.min-C9FG4HQT.js} +3 -3
- package/dist/web/index.html +2 -2
- package/package.json +3 -4
- package/src/api.ts +212 -20
- package/src/cli.ts +156 -35
- package/src/diagram.ts +29 -69
- package/src/layout.ts +217 -0
- package/src/mcp.ts +10 -10
- package/src/merge.ts +50 -51
- package/src/operations.ts +184 -121
- package/src/registry.ts +10 -16
- package/src/schema.ts +46 -55
- package/src/status-runner.ts +6 -6
- package/src/watcher.ts +124 -31
- package/dist/web/assets/index-CYxryPhh.css +0 -1
- package/dist/web/assets/index-CeQZymwF.js +0 -7838
- /package/examples/ecommerce-platform/.seeflow/{architecture.json → flow.json} +0 -0
- /package/examples/order-pipeline/.seeflow/{architecture.json → flow.json} +0 -0
package/src/operations.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from 'node:fs';
|
|
19
19
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
20
20
|
import { type ZodIssue, z } from 'zod';
|
|
21
|
-
import {
|
|
21
|
+
import { mergeFlowAndStyle, splitFlow } from './merge.ts';
|
|
22
22
|
import { seeflowHome } from './paths.ts';
|
|
23
23
|
import { type Registry, slugify } from './registry.ts';
|
|
24
24
|
import {
|
|
@@ -26,19 +26,21 @@ import {
|
|
|
26
26
|
EdgePinSchema,
|
|
27
27
|
type Flow,
|
|
28
28
|
FlowSchema,
|
|
29
|
+
type ResolvedFlow,
|
|
30
|
+
ResolvedFlowSchema,
|
|
29
31
|
SourceHandleIdSchema,
|
|
32
|
+
StyleSchema,
|
|
30
33
|
TargetHandleIdSchema,
|
|
31
34
|
} from './schema.ts';
|
|
32
|
-
import { ArchitectureSchema, StyleSchema } from './schema.ts';
|
|
33
35
|
import { writeSdkEmitIfNeeded } from './sdk-writer.ts';
|
|
34
36
|
import { type FlowSnapshot, type FlowWatcher, readMergedFlow } from './watcher.ts';
|
|
35
37
|
|
|
36
|
-
const
|
|
38
|
+
const DEFAULT_FLOW_RELATIVE_PATH = '.seeflow/flow.json';
|
|
37
39
|
|
|
38
40
|
export const RegisterBodySchema = z.object({
|
|
39
41
|
name: z.string().min(1).optional(),
|
|
40
42
|
repoPath: z.string().min(1),
|
|
41
|
-
|
|
43
|
+
flowPath: z.string().min(1),
|
|
42
44
|
});
|
|
43
45
|
export type RegisterBody = z.infer<typeof RegisterBodySchema>;
|
|
44
46
|
|
|
@@ -68,7 +70,7 @@ export type ReorderBody = z.infer<typeof ReorderBodySchema>;
|
|
|
68
70
|
|
|
69
71
|
// Partial node update body. Top-level `position` lands on node.position; every
|
|
70
72
|
// other key lands inside node.data. Final validity is enforced by re-parsing
|
|
71
|
-
// the whole demo through
|
|
73
|
+
// the whole demo through ResolvedFlowSchema after the merge — this body schema just
|
|
72
74
|
// rejects unknown top-level keys to catch typos.
|
|
73
75
|
export const NodePatchBodySchema = z
|
|
74
76
|
.object({
|
|
@@ -89,7 +91,7 @@ export const NodePatchBodySchema = z
|
|
|
89
91
|
// that autoSize:true never coexists with persisted width/height.
|
|
90
92
|
autoSize: z.boolean().optional(),
|
|
91
93
|
shape: z.enum(['rectangle', 'ellipse', 'sticky', 'text']).optional(),
|
|
92
|
-
// iconNode-only: stroke color token. Lands at data.color;
|
|
94
|
+
// iconNode-only: stroke color token. Lands at data.color; ResolvedFlowSchema's
|
|
93
95
|
// post-merge reparse gates that this is only valid on an iconNode.
|
|
94
96
|
color: ColorTokenSchema.optional(),
|
|
95
97
|
// iconNode-only: glyph stroke width. Lands at data.strokeWidth; the
|
|
@@ -103,9 +105,6 @@ export const NodePatchBodySchema = z
|
|
|
103
105
|
// clears the field (mergeNodeUpdates strips the key from disk) — mirrors
|
|
104
106
|
// the empty-string clear convention used for description / detail.
|
|
105
107
|
icon: z.string().min(1).nullable().optional(),
|
|
106
|
-
// US-019: lock state. Lands at data.locked; persists across save/reload.
|
|
107
|
-
// Absent → unlocked default (no badge, all gestures work).
|
|
108
|
-
locked: z.boolean().optional(),
|
|
109
108
|
// Three-field consolidation: free-text metadata on every node variant.
|
|
110
109
|
// Empty string on `description` or `detail` is the documented clear-on-
|
|
111
110
|
// serialize signal — `mergeNodeUpdates` strips the key from disk.
|
|
@@ -137,7 +136,6 @@ const NODE_DATA_PATCH_KEYS = [
|
|
|
137
136
|
'strokeWidth',
|
|
138
137
|
'alt',
|
|
139
138
|
'icon',
|
|
140
|
-
'locked',
|
|
141
139
|
'description',
|
|
142
140
|
'detail',
|
|
143
141
|
] as const satisfies ReadonlyArray<keyof NodePatchBody>;
|
|
@@ -156,7 +154,7 @@ export const mergeNodeUpdates = (node: Record<string, unknown>, updates: NodePat
|
|
|
156
154
|
if (updates[key] === undefined) continue;
|
|
157
155
|
// Empty string on the two free-text metadata fields is the documented
|
|
158
156
|
// clear-on-serialize signal — strip the key instead of writing "" to disk
|
|
159
|
-
// so
|
|
157
|
+
// so flow.json stays compact and round-tripping a cleared node doesn't
|
|
160
158
|
// reintroduce the field.
|
|
161
159
|
if ((key === 'description' || key === 'detail') && updates[key] === '') {
|
|
162
160
|
if (key in data) {
|
|
@@ -237,7 +235,7 @@ export interface FlowGetResponse {
|
|
|
237
235
|
slug: string;
|
|
238
236
|
name: string;
|
|
239
237
|
filePath: string;
|
|
240
|
-
flow:
|
|
238
|
+
flow: ResolvedFlow | null;
|
|
241
239
|
valid: boolean;
|
|
242
240
|
error: string | null;
|
|
243
241
|
}
|
|
@@ -326,7 +324,7 @@ export type PatchNodeOutcome =
|
|
|
326
324
|
|
|
327
325
|
// Partial connector update body. Strict at the top level so client typos
|
|
328
326
|
// surface as 400. Per-kind invariants (e.g. kind='event' requires eventName)
|
|
329
|
-
// are enforced post-merge by re-parsing the whole demo through
|
|
327
|
+
// are enforced post-merge by re-parsing the whole demo through ResolvedFlowSchema.
|
|
330
328
|
const ConnectorKindSchema = z.enum(['http', 'event', 'queue', 'default']);
|
|
331
329
|
export const ConnectorPatchBodySchema = z
|
|
332
330
|
.object({
|
|
@@ -344,7 +342,7 @@ export const ConnectorPatchBodySchema = z
|
|
|
344
342
|
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(),
|
|
345
343
|
url: z.string().optional(),
|
|
346
344
|
// Reconnect: drag an edge endpoint onto another node's handle. The
|
|
347
|
-
// post-merge
|
|
345
|
+
// post-merge ResolvedFlowSchema parse rejects dangling references, so we don't
|
|
348
346
|
// need a referential check here.
|
|
349
347
|
source: z.string().min(1).optional(),
|
|
350
348
|
target: z.string().min(1).optional(),
|
|
@@ -378,7 +376,7 @@ export type ConnectorPatchBody = z.infer<typeof ConnectorPatchBodySchema>;
|
|
|
378
376
|
// Kind-specific connector fields. When `kind` changes via PATCH, these are
|
|
379
377
|
// dropped first so the resulting connector doesn't carry phantom payloads
|
|
380
378
|
// from the previous kind (e.g. an event→default change leaving eventName
|
|
381
|
-
// behind, which
|
|
379
|
+
// behind, which ResolvedFlowSchema would silently strip on parse but leave on disk).
|
|
382
380
|
const CONNECTOR_KIND_FIELDS = ['method', 'url', 'eventName', 'queueName'] as const;
|
|
383
381
|
|
|
384
382
|
export const mergeConnectorUpdates = (
|
|
@@ -429,8 +427,8 @@ export type DeleteConnectorOutcome =
|
|
|
429
427
|
| { kind: 'unknownConnector' }
|
|
430
428
|
| { kind: 'writeFailed'; message: string };
|
|
431
429
|
|
|
432
|
-
export const resolveFilePath = (repoPath: string,
|
|
433
|
-
isAbsolute(
|
|
430
|
+
export const resolveFilePath = (repoPath: string, flowPath: string): string =>
|
|
431
|
+
isAbsolute(flowPath) ? flowPath : join(repoPath, flowPath);
|
|
434
432
|
|
|
435
433
|
// Per-demo serialization: read-modify-write of the demo file isn't atomic
|
|
436
434
|
// across multiple PATCHes, so two concurrent drags would race (later writer's
|
|
@@ -472,25 +470,25 @@ export const writeFileAtomic = (filePath: string, content: string): void => {
|
|
|
472
470
|
};
|
|
473
471
|
|
|
474
472
|
/**
|
|
475
|
-
* Read
|
|
476
|
-
*
|
|
477
|
-
* fields. Returns null if the
|
|
473
|
+
* Read flow.json + optional style.json, return the raw parsed JSON so
|
|
474
|
+
* operations can mutate the merged-flow shape without losing forward-compat
|
|
475
|
+
* fields. Returns null if the flow file is missing or invalid JSON.
|
|
478
476
|
*/
|
|
479
477
|
type ReadRawResult =
|
|
480
|
-
| { kind: 'ok';
|
|
478
|
+
| { kind: 'ok'; rawFlow: Record<string, unknown>; rawStyle: Record<string, unknown> }
|
|
481
479
|
| { kind: 'badJson'; message: string };
|
|
482
480
|
|
|
483
|
-
function
|
|
484
|
-
let
|
|
481
|
+
function readRawFlowAndStyle(flowPath: string): ReadRawResult {
|
|
482
|
+
let rawFlow: unknown;
|
|
485
483
|
try {
|
|
486
|
-
|
|
484
|
+
rawFlow = JSON.parse(readFileSync(flowPath, 'utf8'));
|
|
487
485
|
} catch (err) {
|
|
488
486
|
return { kind: 'badJson', message: err instanceof Error ? err.message : String(err) };
|
|
489
487
|
}
|
|
490
|
-
if (!
|
|
491
|
-
return { kind: 'badJson', message: '
|
|
488
|
+
if (!rawFlow || typeof rawFlow !== 'object' || Array.isArray(rawFlow)) {
|
|
489
|
+
return { kind: 'badJson', message: 'flow.json is not an object' };
|
|
492
490
|
}
|
|
493
|
-
const stylePath = join(dirname(
|
|
491
|
+
const stylePath = join(dirname(flowPath), 'style.json');
|
|
494
492
|
let rawStyle: Record<string, unknown> = {};
|
|
495
493
|
if (existsSync(stylePath)) {
|
|
496
494
|
try {
|
|
@@ -506,12 +504,12 @@ function readRawArchAndStyle(archPath: string): ReadRawResult {
|
|
|
506
504
|
};
|
|
507
505
|
}
|
|
508
506
|
}
|
|
509
|
-
return { kind: 'ok',
|
|
507
|
+
return { kind: 'ok', rawFlow: rawFlow as Record<string, unknown>, rawStyle };
|
|
510
508
|
}
|
|
511
509
|
|
|
512
510
|
/**
|
|
513
511
|
* Mutate-in-place helper: read both files into a merged Flow shape, hand it
|
|
514
|
-
* to the mutator, then split back into
|
|
512
|
+
* to the mutator, then split back into flow + style and atomically
|
|
515
513
|
* write both. The mutator returns either { kind: 'ok' } or a discriminated
|
|
516
514
|
* outcome for early-exits (e.g. unknownNode). On schema-validation failure,
|
|
517
515
|
* neither file is written.
|
|
@@ -524,26 +522,36 @@ type MutateMergedFlowMutator<E> = (flow: {
|
|
|
524
522
|
connectors: Array<Record<string, unknown>>;
|
|
525
523
|
}) => { kind: 'ok' } | E;
|
|
526
524
|
|
|
525
|
+
type MutateMergedFlowOk = {
|
|
526
|
+
kind: 'ok';
|
|
527
|
+
/** Validated post-write merged flow — hand straight to watcher.notifyWritten. */
|
|
528
|
+
snap: FlowSnapshot;
|
|
529
|
+
/** Exact bytes written to flow.json — used for own-write hash dedupe. */
|
|
530
|
+
flowContent: string;
|
|
531
|
+
/** Exact bytes written to style.json, or '' when style.json was deleted / never existed. */
|
|
532
|
+
styleContent: string;
|
|
533
|
+
};
|
|
534
|
+
|
|
527
535
|
type MutateMergedFlowResult<E> =
|
|
528
|
-
|
|
|
536
|
+
| MutateMergedFlowOk
|
|
529
537
|
| { kind: 'badJson'; message: string }
|
|
530
538
|
| { kind: 'badSchema'; issues: ZodIssue[] }
|
|
531
539
|
| { kind: 'writeFailed'; message: string }
|
|
532
540
|
| E;
|
|
533
541
|
|
|
534
542
|
export async function mutateMergedFlow<E extends { kind: string }>(
|
|
535
|
-
|
|
543
|
+
flowPath: string,
|
|
536
544
|
mutator: MutateMergedFlowMutator<E>,
|
|
537
545
|
): Promise<MutateMergedFlowResult<E>> {
|
|
538
|
-
const read =
|
|
546
|
+
const read = readRawFlowAndStyle(flowPath);
|
|
539
547
|
if (read.kind === 'badJson') return { kind: 'badJson', message: read.message };
|
|
540
548
|
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
549
|
+
const flowParse = FlowSchema.safeParse(read.rawFlow);
|
|
550
|
+
if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
|
|
543
551
|
const styleParse = StyleSchema.safeParse(read.rawStyle);
|
|
544
552
|
if (!styleParse.success) return { kind: 'badSchema', issues: styleParse.error.issues };
|
|
545
553
|
|
|
546
|
-
const merged =
|
|
554
|
+
const merged = mergeFlowAndStyle(flowParse.data, styleParse.data) as unknown as {
|
|
547
555
|
version: number;
|
|
548
556
|
name: string;
|
|
549
557
|
resetAction?: unknown;
|
|
@@ -552,29 +560,36 @@ export async function mutateMergedFlow<E extends { kind: string }>(
|
|
|
552
560
|
};
|
|
553
561
|
|
|
554
562
|
const outcome = mutator(merged);
|
|
555
|
-
|
|
563
|
+
// E is generic — TS can't prove the narrowed branch isn't `{ kind: 'ok' }`,
|
|
564
|
+
// so we cast. By convention, E never reuses `'ok'` as a discriminant.
|
|
565
|
+
if (outcome.kind !== 'ok') return outcome as E;
|
|
556
566
|
|
|
557
|
-
// Final
|
|
567
|
+
// Final ResolvedFlowSchema parse so per-kind invariants (event needs eventName, etc.)
|
|
558
568
|
// surface honestly instead of being silently papered over.
|
|
559
|
-
const finalParse =
|
|
569
|
+
const finalParse = ResolvedFlowSchema.safeParse(merged);
|
|
560
570
|
if (!finalParse.success) return { kind: 'badSchema', issues: finalParse.error.issues };
|
|
561
571
|
|
|
562
|
-
const {
|
|
572
|
+
const { flow, style } = splitFlow(merged);
|
|
563
573
|
// Re-validate the post-split files to catch the rare case where a forward-
|
|
564
574
|
// compat field landed in the wrong bucket. Style validation is a no-op for
|
|
565
|
-
// empty maps;
|
|
566
|
-
const
|
|
567
|
-
if (!
|
|
575
|
+
// empty maps; flow revalidation rejects unknown keys via strict().
|
|
576
|
+
const flowCheck = FlowSchema.safeParse(flow);
|
|
577
|
+
if (!flowCheck.success) return { kind: 'badSchema', issues: flowCheck.error.issues };
|
|
568
578
|
const styleCheck = StyleSchema.safeParse(style);
|
|
569
579
|
if (!styleCheck.success) return { kind: 'badSchema', issues: styleCheck.error.issues };
|
|
570
580
|
|
|
571
|
-
const stylePath = join(dirname(
|
|
581
|
+
const stylePath = join(dirname(flowPath), 'style.json');
|
|
572
582
|
const styleIsEmpty =
|
|
573
583
|
(!style.nodes || Object.keys(style.nodes as Record<string, unknown>).length === 0) &&
|
|
574
584
|
(!style.connectors || Object.keys(style.connectors as Record<string, unknown>).length === 0);
|
|
575
585
|
|
|
586
|
+
// Pre-compute the bytes we're about to write so the caller can hand them
|
|
587
|
+
// straight to watcher.notifyWritten without re-reading the file.
|
|
588
|
+
const flowContent = `${JSON.stringify(flow, null, 2)}\n`;
|
|
589
|
+
const styleContent = styleIsEmpty ? '' : `${JSON.stringify(style, null, 2)}\n`;
|
|
590
|
+
|
|
576
591
|
try {
|
|
577
|
-
writeFileAtomic(
|
|
592
|
+
writeFileAtomic(flowPath, flowContent);
|
|
578
593
|
} catch (err) {
|
|
579
594
|
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
580
595
|
}
|
|
@@ -583,13 +598,48 @@ export async function mutateMergedFlow<E extends { kind: string }>(
|
|
|
583
598
|
if (styleIsEmpty) {
|
|
584
599
|
if (existsSync(stylePath)) unlinkSync(stylePath);
|
|
585
600
|
} else {
|
|
586
|
-
writeFileAtomic(stylePath,
|
|
601
|
+
writeFileAtomic(stylePath, styleContent);
|
|
587
602
|
}
|
|
588
603
|
} catch (err) {
|
|
589
604
|
return { kind: 'writeFailed', message: err instanceof Error ? err.message : String(err) };
|
|
590
605
|
}
|
|
591
606
|
|
|
592
|
-
|
|
607
|
+
const snap: FlowSnapshot = {
|
|
608
|
+
flow: finalParse.data as ResolvedFlow,
|
|
609
|
+
valid: true,
|
|
610
|
+
error: null,
|
|
611
|
+
filePath: flowPath,
|
|
612
|
+
parsedAt: Date.now(),
|
|
613
|
+
};
|
|
614
|
+
return { kind: 'ok', snap, flowContent, styleContent };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Wrap a mutation in the write lock AND broadcast a flow:reload directly
|
|
619
|
+
* from the post-write snapshot. Every mutation endpoint that updates flow
|
|
620
|
+
* state should go through this so:
|
|
621
|
+
* 1. The watcher's content-hash dedupe suppresses the fs-watcher echo for
|
|
622
|
+
* this same write — no double broadcast, no double reparse.
|
|
623
|
+
* 2. The SSE event reaches the client without waiting for the fs debounce.
|
|
624
|
+
*
|
|
625
|
+
* For mutators whose ok branch carries no payload, `E` should be `never`.
|
|
626
|
+
*/
|
|
627
|
+
export async function mutateMergedFlowAndBroadcast<E extends { kind: string }>(
|
|
628
|
+
deps: OperationsDeps,
|
|
629
|
+
flowId: string,
|
|
630
|
+
flowPath: string,
|
|
631
|
+
mutator: MutateMergedFlowMutator<E>,
|
|
632
|
+
): Promise<MutateMergedFlowResult<E>> {
|
|
633
|
+
return withFlowWriteLock(flowId, async () => {
|
|
634
|
+
const result = await mutateMergedFlow(flowPath, mutator);
|
|
635
|
+
if (result.kind === 'ok') {
|
|
636
|
+
// E is generic, so TS can't prove the ok branch is MutateMergedFlowOk —
|
|
637
|
+
// cast here, parallel to the cast inside mutateMergedFlow itself.
|
|
638
|
+
const ok = result as MutateMergedFlowOk;
|
|
639
|
+
deps.watcher?.notifyWritten(flowId, ok.snap, ok.flowContent, ok.styleContent);
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
});
|
|
593
643
|
}
|
|
594
644
|
|
|
595
645
|
export const reorderNodes = (
|
|
@@ -644,7 +694,7 @@ export const reorderNodes = (
|
|
|
644
694
|
|
|
645
695
|
export function listDemosImpl(deps: OperationsDeps): ListFlowsOutcome {
|
|
646
696
|
const data = deps.registry.list().map((e) => {
|
|
647
|
-
const fullPath = resolveFilePath(e.repoPath, e.
|
|
697
|
+
const fullPath = resolveFilePath(e.repoPath, e.flowPath);
|
|
648
698
|
const fileExists = existsSync(fullPath);
|
|
649
699
|
return {
|
|
650
700
|
id: e.id,
|
|
@@ -663,7 +713,7 @@ export async function getFlowImpl(deps: OperationsDeps, flowId: string): Promise
|
|
|
663
713
|
const entry = registry.getById(flowId);
|
|
664
714
|
if (!entry) return { kind: 'notFound' };
|
|
665
715
|
|
|
666
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
716
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
667
717
|
const snap = watcher?.snapshot(flowId) ?? watcher?.reparse(flowId) ?? null;
|
|
668
718
|
|
|
669
719
|
const buildResponse = (s: FlowSnapshot): FlowGetResponse => ({
|
|
@@ -700,8 +750,8 @@ export async function registerFlowImpl(
|
|
|
700
750
|
body: RegisterBody,
|
|
701
751
|
): Promise<RegisterFlowOutcome> {
|
|
702
752
|
const { registry, watcher } = deps;
|
|
703
|
-
const { repoPath,
|
|
704
|
-
const fullPath = resolveFilePath(repoPath,
|
|
753
|
+
const { repoPath, flowPath } = body;
|
|
754
|
+
const fullPath = resolveFilePath(repoPath, flowPath);
|
|
705
755
|
|
|
706
756
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
707
757
|
|
|
@@ -712,14 +762,14 @@ export async function registerFlowImpl(
|
|
|
712
762
|
}
|
|
713
763
|
// Schema validation failed — surface the issues as a bad-schema outcome
|
|
714
764
|
// by re-running parse to get ZodIssue[].
|
|
715
|
-
let
|
|
765
|
+
let rawFlow: unknown;
|
|
716
766
|
try {
|
|
717
|
-
|
|
767
|
+
rawFlow = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
718
768
|
} catch (err) {
|
|
719
769
|
return { kind: 'badJson', detail: String(err) };
|
|
720
770
|
}
|
|
721
|
-
const
|
|
722
|
-
if (!
|
|
771
|
+
const flowParse = FlowSchema.safeParse(rawFlow);
|
|
772
|
+
if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
|
|
723
773
|
return { kind: 'badJson', detail: merged.error };
|
|
724
774
|
}
|
|
725
775
|
if (!merged.flow) return { kind: 'badJson', detail: merged.error ?? 'unknown error' };
|
|
@@ -728,7 +778,7 @@ export async function registerFlowImpl(
|
|
|
728
778
|
const entry = registry.upsert({
|
|
729
779
|
name: body.name ?? merged.flow.name,
|
|
730
780
|
repoPath,
|
|
731
|
-
|
|
781
|
+
flowPath,
|
|
732
782
|
valid: true,
|
|
733
783
|
lastModified,
|
|
734
784
|
});
|
|
@@ -771,7 +821,7 @@ export async function createProjectImpl(
|
|
|
771
821
|
const baseDir = deps.projectBaseDir ?? seeflowHome();
|
|
772
822
|
const folderPath = join(baseDir, slugify(name));
|
|
773
823
|
|
|
774
|
-
const demoFullPath = join(folderPath,
|
|
824
|
+
const demoFullPath = join(folderPath, DEFAULT_FLOW_RELATIVE_PATH);
|
|
775
825
|
|
|
776
826
|
if (existsSync(demoFullPath)) {
|
|
777
827
|
let raw: unknown;
|
|
@@ -780,14 +830,14 @@ export async function createProjectImpl(
|
|
|
780
830
|
} catch (err) {
|
|
781
831
|
return { kind: 'badJson', detail: err instanceof Error ? err.message : String(err) };
|
|
782
832
|
}
|
|
783
|
-
const
|
|
784
|
-
if (!
|
|
833
|
+
const flowParse = FlowSchema.safeParse(raw);
|
|
834
|
+
if (!flowParse.success) return { kind: 'badSchema', issues: flowParse.error.issues };
|
|
785
835
|
|
|
786
836
|
const lastModified = statSync(demoFullPath).mtimeMs;
|
|
787
837
|
const entry = registry.upsert({
|
|
788
838
|
name,
|
|
789
839
|
repoPath: folderPath,
|
|
790
|
-
|
|
840
|
+
flowPath: DEFAULT_FLOW_RELATIVE_PATH,
|
|
791
841
|
valid: true,
|
|
792
842
|
lastModified,
|
|
793
843
|
});
|
|
@@ -795,7 +845,7 @@ export async function createProjectImpl(
|
|
|
795
845
|
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: false } };
|
|
796
846
|
}
|
|
797
847
|
|
|
798
|
-
//
|
|
848
|
+
// Flow-only scaffold: empty nodes/connectors, no style.json needed.
|
|
799
849
|
const scaffold: Flow = { version: 2, name, nodes: [], connectors: [] };
|
|
800
850
|
|
|
801
851
|
try {
|
|
@@ -818,7 +868,7 @@ export async function createProjectImpl(
|
|
|
818
868
|
const entry = registry.upsert({
|
|
819
869
|
name,
|
|
820
870
|
repoPath: folderPath,
|
|
821
|
-
|
|
871
|
+
flowPath: DEFAULT_FLOW_RELATIVE_PATH,
|
|
822
872
|
valid: true,
|
|
823
873
|
lastModified,
|
|
824
874
|
});
|
|
@@ -826,7 +876,7 @@ export async function createProjectImpl(
|
|
|
826
876
|
return { kind: 'ok', data: { id: entry.id, slug: entry.slug, scaffolded: true } };
|
|
827
877
|
}
|
|
828
878
|
|
|
829
|
-
// Append a new node to the demo. Auto-generates an id when absent;
|
|
879
|
+
// Append a new node to the demo. Auto-generates an id when absent; ResolvedFlowSchema
|
|
830
880
|
// is re-run on the post-mutation raw object before commit so a malformed
|
|
831
881
|
// payload never produces a half-written file.
|
|
832
882
|
export async function addNodeImpl(
|
|
@@ -842,7 +892,7 @@ export async function addNodeImpl(
|
|
|
842
892
|
newNode.id = `node-${crypto.randomUUID()}`;
|
|
843
893
|
}
|
|
844
894
|
const newId = newNode.id as string;
|
|
845
|
-
// Default position so the post-merge
|
|
895
|
+
// Default position so the post-merge ResolvedFlowSchema parse passes. Position lives
|
|
846
896
|
// on style.json after the split — callers who care set it explicitly.
|
|
847
897
|
if (!newNode.position || typeof newNode.position !== 'object') {
|
|
848
898
|
newNode.position = { x: 0, y: 0 };
|
|
@@ -876,11 +926,14 @@ export async function addNodeImpl(
|
|
|
876
926
|
}
|
|
877
927
|
}
|
|
878
928
|
|
|
879
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
929
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
880
930
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
881
931
|
|
|
882
|
-
const result = await
|
|
883
|
-
|
|
932
|
+
const result = await mutateMergedFlowAndBroadcast<{ kind: 'writeFailed'; message: string }>(
|
|
933
|
+
deps,
|
|
934
|
+
flowId,
|
|
935
|
+
fullPath,
|
|
936
|
+
(flow) => {
|
|
884
937
|
flow.nodes.push(newNode);
|
|
885
938
|
if (starterFile) {
|
|
886
939
|
try {
|
|
@@ -891,7 +944,7 @@ export async function addNodeImpl(
|
|
|
891
944
|
}
|
|
892
945
|
}
|
|
893
946
|
return { kind: 'ok' };
|
|
894
|
-
}
|
|
947
|
+
},
|
|
895
948
|
);
|
|
896
949
|
|
|
897
950
|
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId, node: newNode } };
|
|
@@ -912,11 +965,11 @@ const buildHtmlNodeStarter = (nodeId: string): string =>
|
|
|
912
965
|
`;
|
|
913
966
|
|
|
914
967
|
// Remove a node and cascade-delete every connector touching it in a single
|
|
915
|
-
// atomic write. Final
|
|
968
|
+
// atomic write. Final ResolvedFlowSchema parse stays in place so a pre-existing
|
|
916
969
|
// schema violation surfaces honestly instead of being silently papered over.
|
|
917
970
|
// US-016: when the removed node is an htmlNode whose data.htmlPath matches the
|
|
918
971
|
// studio-managed shape `blocks/<id>.html`, the companion file is removed AFTER
|
|
919
|
-
// the
|
|
972
|
+
// the flow.json write succeeds. Hand-edited paths are left alone (symmetric
|
|
920
973
|
// with US-015's "client-supplied htmlPath wins, no starter file written").
|
|
921
974
|
export async function deleteNodeImpl(
|
|
922
975
|
deps: OperationsDeps,
|
|
@@ -926,13 +979,16 @@ export async function deleteNodeImpl(
|
|
|
926
979
|
const entry = deps.registry.getById(flowId);
|
|
927
980
|
if (!entry) return { kind: 'flowNotFound' };
|
|
928
981
|
|
|
929
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
982
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
930
983
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
931
984
|
|
|
932
985
|
let managedHtmlAbsPath: string | undefined;
|
|
933
986
|
|
|
934
|
-
const result = await
|
|
935
|
-
|
|
987
|
+
const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
|
|
988
|
+
deps,
|
|
989
|
+
flowId,
|
|
990
|
+
fullPath,
|
|
991
|
+
(flow) => {
|
|
936
992
|
const idx = flow.nodes.findIndex((n) => n.id === nodeId);
|
|
937
993
|
if (idx < 0) return { kind: 'unknownNode' };
|
|
938
994
|
const removed = flow.nodes[idx];
|
|
@@ -942,7 +998,7 @@ export async function deleteNodeImpl(
|
|
|
942
998
|
(cn) => cn.source !== nodeId && cn.target !== nodeId,
|
|
943
999
|
);
|
|
944
1000
|
return { kind: 'ok' };
|
|
945
|
-
}
|
|
1001
|
+
},
|
|
946
1002
|
);
|
|
947
1003
|
|
|
948
1004
|
if (result.kind === 'ok' && managedHtmlAbsPath) {
|
|
@@ -992,22 +1048,24 @@ export async function moveNodeImpl(
|
|
|
992
1048
|
const entry = deps.registry.getById(flowId);
|
|
993
1049
|
if (!entry) return { kind: 'flowNotFound' };
|
|
994
1050
|
|
|
995
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1051
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
996
1052
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
997
1053
|
|
|
998
|
-
const result = await
|
|
999
|
-
|
|
1054
|
+
const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(
|
|
1055
|
+
deps,
|
|
1056
|
+
flowId,
|
|
1057
|
+
fullPath,
|
|
1058
|
+
(flow) => {
|
|
1000
1059
|
const node = flow.nodes.find((n) => n.id === nodeId) as
|
|
1001
1060
|
| { id: string; position?: { x: number; y: number } }
|
|
1002
1061
|
| undefined;
|
|
1003
1062
|
if (!node) return { kind: 'unknownNode' };
|
|
1004
1063
|
node.position = { x: position.x, y: position.y };
|
|
1005
1064
|
return { kind: 'ok' };
|
|
1006
|
-
}
|
|
1065
|
+
},
|
|
1007
1066
|
);
|
|
1008
1067
|
|
|
1009
1068
|
if (result.kind === 'ok') {
|
|
1010
|
-
deps.watcher?.reparse(flowId);
|
|
1011
1069
|
return { kind: 'ok', data: { position: { x: position.x, y: position.y } } };
|
|
1012
1070
|
}
|
|
1013
1071
|
return result;
|
|
@@ -1015,7 +1073,7 @@ export async function moveNodeImpl(
|
|
|
1015
1073
|
|
|
1016
1074
|
// Apply a partial PATCH body to a single node. Mutation runs against the
|
|
1017
1075
|
// raw parsed JSON (so unknown forward-compat fields survive a round-trip),
|
|
1018
|
-
// and the whole demo is re-validated through
|
|
1076
|
+
// and the whole demo is re-validated through ResolvedFlowSchema before commit so
|
|
1019
1077
|
// partial writes can't break invariants like the connector→node superRefine.
|
|
1020
1078
|
export async function patchNodeImpl(
|
|
1021
1079
|
deps: OperationsDeps,
|
|
@@ -1026,17 +1084,15 @@ export async function patchNodeImpl(
|
|
|
1026
1084
|
const entry = deps.registry.getById(flowId);
|
|
1027
1085
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1028
1086
|
|
|
1029
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1087
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1030
1088
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1031
1089
|
|
|
1032
|
-
return
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
}),
|
|
1039
|
-
);
|
|
1090
|
+
return mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' }>(deps, flowId, fullPath, (flow) => {
|
|
1091
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
1092
|
+
if (!node) return { kind: 'unknownNode' };
|
|
1093
|
+
mergeNodeUpdates(node, updates);
|
|
1094
|
+
return { kind: 'ok' };
|
|
1095
|
+
});
|
|
1040
1096
|
}
|
|
1041
1097
|
|
|
1042
1098
|
// Reorder a node within demo.nodes[] (changes paint order in the canvas).
|
|
@@ -1051,17 +1107,20 @@ export async function reorderNodeImpl(
|
|
|
1051
1107
|
const entry = deps.registry.getById(flowId);
|
|
1052
1108
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1053
1109
|
|
|
1054
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1110
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1055
1111
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1056
1112
|
|
|
1057
|
-
const result = await
|
|
1058
|
-
|
|
1113
|
+
const result = await mutateMergedFlowAndBroadcast<{ kind: 'unknownNode' } | { kind: 'noop' }>(
|
|
1114
|
+
deps,
|
|
1115
|
+
flowId,
|
|
1116
|
+
fullPath,
|
|
1117
|
+
(flow) => {
|
|
1059
1118
|
const fromIdx = flow.nodes.findIndex((n) => n.id === nodeId);
|
|
1060
1119
|
if (fromIdx < 0) return { kind: 'unknownNode' };
|
|
1061
1120
|
const moved = reorderNodes(flow.nodes, fromIdx, body);
|
|
1062
1121
|
if (!moved) return { kind: 'noop' };
|
|
1063
1122
|
return { kind: 'ok' };
|
|
1064
|
-
}
|
|
1123
|
+
},
|
|
1065
1124
|
);
|
|
1066
1125
|
|
|
1067
1126
|
if (result.kind === 'noop') return { kind: 'ok' };
|
|
@@ -1070,7 +1129,7 @@ export async function reorderNodeImpl(
|
|
|
1070
1129
|
|
|
1071
1130
|
// Append a new connector to demo.connectors. `id` is auto-generated when
|
|
1072
1131
|
// absent and `kind` defaults to 'default' (the no-semantics user-drawn
|
|
1073
|
-
// variant). Source/target referential integrity is enforced by
|
|
1132
|
+
// variant). Source/target referential integrity is enforced by ResolvedFlowSchema's
|
|
1074
1133
|
// superRefine on the post-mutation parse.
|
|
1075
1134
|
export async function addConnectorImpl(
|
|
1076
1135
|
deps: OperationsDeps,
|
|
@@ -1089,15 +1148,13 @@ export async function addConnectorImpl(
|
|
|
1089
1148
|
}
|
|
1090
1149
|
const newId = newConn.id as string;
|
|
1091
1150
|
|
|
1092
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1151
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1093
1152
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1094
1153
|
|
|
1095
|
-
const result = await
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}),
|
|
1100
|
-
);
|
|
1154
|
+
const result = await mutateMergedFlowAndBroadcast<never>(deps, flowId, fullPath, (flow) => {
|
|
1155
|
+
flow.connectors.push(newConn);
|
|
1156
|
+
return { kind: 'ok' };
|
|
1157
|
+
});
|
|
1101
1158
|
|
|
1102
1159
|
if (result.kind === 'ok') return { kind: 'ok', data: { id: newId } };
|
|
1103
1160
|
return result;
|
|
@@ -1108,7 +1165,7 @@ export async function addConnectorImpl(
|
|
|
1108
1165
|
// When `kind` changes, the previous kind's payload fields are dropped first
|
|
1109
1166
|
// so the connector doesn't carry phantom data; explicit `null` in the patch
|
|
1110
1167
|
// clears the field on disk (used by reconnect-to-body to drop a pinned
|
|
1111
|
-
// handle id). The whole demo is re-validated through
|
|
1168
|
+
// handle id). The whole demo is re-validated through ResolvedFlowSchema before
|
|
1112
1169
|
// commit so the discriminated union catches missing-required-fields
|
|
1113
1170
|
// (e.g. kind='event' without eventName) and the superRefine gates
|
|
1114
1171
|
// source/target referential integrity + handle role invariants.
|
|
@@ -1121,21 +1178,24 @@ export async function patchConnectorImpl(
|
|
|
1121
1178
|
const entry = deps.registry.getById(flowId);
|
|
1122
1179
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1123
1180
|
|
|
1124
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1181
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1125
1182
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1126
1183
|
|
|
1127
|
-
return
|
|
1128
|
-
|
|
1184
|
+
return mutateMergedFlowAndBroadcast<{ kind: 'unknownConnector' }>(
|
|
1185
|
+
deps,
|
|
1186
|
+
flowId,
|
|
1187
|
+
fullPath,
|
|
1188
|
+
(flow) => {
|
|
1129
1189
|
const conn = flow.connectors.find((cn) => cn.id === connectorId);
|
|
1130
1190
|
if (!conn) return { kind: 'unknownConnector' };
|
|
1131
1191
|
mergeConnectorUpdates(conn, updates);
|
|
1132
1192
|
return { kind: 'ok' };
|
|
1133
|
-
}
|
|
1193
|
+
},
|
|
1134
1194
|
);
|
|
1135
1195
|
}
|
|
1136
1196
|
|
|
1137
1197
|
// Remove a connector by id. No cascade — node deletion is what cascades,
|
|
1138
|
-
// not connector deletion. Final
|
|
1198
|
+
// not connector deletion. Final ResolvedFlowSchema parse still runs so a pre-existing
|
|
1139
1199
|
// schema violation surfaces honestly instead of being silently papered over.
|
|
1140
1200
|
export async function deleteConnectorImpl(
|
|
1141
1201
|
deps: OperationsDeps,
|
|
@@ -1145,16 +1205,19 @@ export async function deleteConnectorImpl(
|
|
|
1145
1205
|
const entry = deps.registry.getById(flowId);
|
|
1146
1206
|
if (!entry) return { kind: 'flowNotFound' };
|
|
1147
1207
|
|
|
1148
|
-
const fullPath = resolveFilePath(entry.repoPath, entry.
|
|
1208
|
+
const fullPath = resolveFilePath(entry.repoPath, entry.flowPath);
|
|
1149
1209
|
if (!existsSync(fullPath)) return { kind: 'fileNotFound', path: fullPath };
|
|
1150
1210
|
|
|
1151
|
-
return
|
|
1152
|
-
|
|
1211
|
+
return mutateMergedFlowAndBroadcast<{ kind: 'unknownConnector' }>(
|
|
1212
|
+
deps,
|
|
1213
|
+
flowId,
|
|
1214
|
+
fullPath,
|
|
1215
|
+
(flow) => {
|
|
1153
1216
|
const idx = flow.connectors.findIndex((cn) => cn.id === connectorId);
|
|
1154
1217
|
if (idx < 0) return { kind: 'unknownConnector' };
|
|
1155
1218
|
flow.connectors.splice(idx, 1);
|
|
1156
1219
|
return { kind: 'ok' };
|
|
1157
|
-
}
|
|
1220
|
+
},
|
|
1158
1221
|
);
|
|
1159
1222
|
}
|
|
1160
1223
|
|
|
@@ -1165,12 +1228,12 @@ export async function deleteConnectorImpl(
|
|
|
1165
1228
|
// =============================================================================
|
|
1166
1229
|
|
|
1167
1230
|
export interface ValidateBody {
|
|
1168
|
-
|
|
1231
|
+
flow: unknown;
|
|
1169
1232
|
style?: unknown;
|
|
1170
1233
|
}
|
|
1171
1234
|
|
|
1172
1235
|
export interface ValidationIssue {
|
|
1173
|
-
scope: '
|
|
1236
|
+
scope: 'flow' | 'style' | 'cross';
|
|
1174
1237
|
path: (string | number)[];
|
|
1175
1238
|
message: string;
|
|
1176
1239
|
code: string;
|
|
@@ -1181,11 +1244,11 @@ export type ValidateOutcome = { ok: true } | { ok: false; issues: ValidationIssu
|
|
|
1181
1244
|
export function validateImpl(body: ValidateBody): ValidateOutcome {
|
|
1182
1245
|
const issues: ValidationIssue[] = [];
|
|
1183
1246
|
|
|
1184
|
-
const
|
|
1185
|
-
if (!
|
|
1186
|
-
for (const i of
|
|
1247
|
+
const flowParse = FlowSchema.safeParse(body.flow);
|
|
1248
|
+
if (!flowParse.success) {
|
|
1249
|
+
for (const i of flowParse.error.issues) {
|
|
1187
1250
|
issues.push({
|
|
1188
|
-
scope: '
|
|
1251
|
+
scope: 'flow',
|
|
1189
1252
|
path: [...i.path],
|
|
1190
1253
|
message: i.message,
|
|
1191
1254
|
code: i.code,
|
|
@@ -1212,11 +1275,11 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
|
|
|
1212
1275
|
}
|
|
1213
1276
|
}
|
|
1214
1277
|
|
|
1215
|
-
if (
|
|
1216
|
-
const
|
|
1217
|
-
const
|
|
1278
|
+
if (flowParse.success && styleData) {
|
|
1279
|
+
const flowNodeIds = new Set(flowParse.data.nodes.map((n) => n.id));
|
|
1280
|
+
const flowConnIds = new Set(flowParse.data.connectors.map((c) => c.id));
|
|
1218
1281
|
for (const id of Object.keys(styleData.nodes ?? {})) {
|
|
1219
|
-
if (!
|
|
1282
|
+
if (!flowNodeIds.has(id)) {
|
|
1220
1283
|
issues.push({
|
|
1221
1284
|
scope: 'cross',
|
|
1222
1285
|
path: ['nodes', id],
|
|
@@ -1226,7 +1289,7 @@ export function validateImpl(body: ValidateBody): ValidateOutcome {
|
|
|
1226
1289
|
}
|
|
1227
1290
|
}
|
|
1228
1291
|
for (const id of Object.keys(styleData.connectors ?? {})) {
|
|
1229
|
-
if (!
|
|
1292
|
+
if (!flowConnIds.has(id)) {
|
|
1230
1293
|
issues.push({
|
|
1231
1294
|
scope: 'cross',
|
|
1232
1295
|
path: ['connectors', id],
|