@luispm/zflow-graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ # 2 · The Runtime
2
+
3
+ This is the part most graph libraries don't have, and it's what makes zflow useful for real applications.
4
+
5
+ ## The core idea
6
+
7
+ A **graph** is data. A **runtime** turns that data into computation.
8
+
9
+ When you call `flow.run()`, the runtime walks every node in **topological order** and calls its `execute(ctx, inputs)` function. The return value becomes the node's output. Outputs flow through edges to downstream nodes as their inputs.
10
+
11
+ ```
12
+ [Source: random] → [×2] → [if >100] ─ ok ─→ [save]
13
+ └ bad ─→ [alert]
14
+ ```
15
+
16
+ If you give each box an `execute`, that diagram becomes a running program. That's it.
17
+
18
+ ## Minimum viable example
19
+
20
+ ```js
21
+ flow.registerKind({
22
+ name: 'gen',
23
+ nin: 0, nout: 1,
24
+ portOut: ['value'],
25
+ execute: () => ({ value: Math.random() * 100 }),
26
+ });
27
+
28
+ flow.registerKind({
29
+ name: 'log',
30
+ nin: 1, nout: 0,
31
+ portIn: ['value'],
32
+ execute: (ctx, ins) => { console.log('got', ins.value); },
33
+ });
34
+
35
+ const a = flow.addNode({ kind: 'gen' });
36
+ const b = flow.addNode({ kind: 'log' });
37
+ flow.addEdge({ from: a, to: b });
38
+
39
+ await flow.run();
40
+ // console: "got 73.2"
41
+ ```
42
+
43
+ ## How values flow through edges
44
+
45
+ The runtime expects `execute` to return **either** a primitive **or** an object whose keys match the kind's `portOut` labels:
46
+
47
+ ```js
48
+ // Single output, one downstream port: return either form is fine.
49
+ execute: () => 42 // primitive
50
+ execute: () => ({ value: 42 }) // object with 'value'
51
+ execute: () => ({ result: 42 }) // works if portOut: ['result']
52
+
53
+ // Multi-output (conditional routing): emit only the branch you want.
54
+ execute: (ctx, ins) => ins.x > 0
55
+ ? { positive: ins.x } // only this key is set → only 'positive' edge fires
56
+ : { negative: ins.x };
57
+ ```
58
+
59
+ The downstream node receives values keyed by **its** `portIn` labels:
60
+
61
+ ```js
62
+ flow.registerKind({
63
+ name: 'add',
64
+ nin: 2, nout: 1,
65
+ portIn: ['a', 'b'],
66
+ portOut: ['sum'],
67
+ execute: (ctx, ins) => ({ sum: ins.a + ins.b }),
68
+ });
69
+ ```
70
+
71
+ If no port labels are declared, inputs are exposed as `in0`, `in1`, `in2`, etc. (also indexable by number: `ins[0]`).
72
+
73
+ ## The `ctx` object
74
+
75
+ Every `execute` receives a context with:
76
+
77
+ ```js
78
+ execute: async (ctx, ins) => {
79
+ ctx.nodeId // this node's id
80
+ ctx.signal // AbortSignal — abort if flow.stop() is called
81
+ ctx.params // params set via flow.setNodeParams(id, params)
82
+ ctx.emit(value) // intermediate emission (for streaming)
83
+ ctx.setProgress(p) // 0..1 → drawn as a bar inside the node
84
+ ctx.log(...args) // emits a 'node:log' event
85
+ ctx.metric(v) // push a number to the live sparkline
86
+ ctx.get(otherId) // read the latest value of another node
87
+ return { ... }; // final output
88
+ }
89
+ ```
90
+
91
+ ## Async, retry, abort
92
+
93
+ ```js
94
+ flow.registerKind({
95
+ name: 'fetch-user',
96
+ retry: { n: 3, delay: 500 }, // 3 attempts, 500ms between
97
+ execute: async (ctx, ins) => {
98
+ ctx.setProgress(0.1);
99
+ const r = await fetch(`/api/users/${ins.id}`, { signal: ctx.signal });
100
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
101
+ ctx.setProgress(0.9);
102
+ return { user: await r.json() };
103
+ },
104
+ });
105
+
106
+ const p = flow.run();
107
+ setTimeout(() => flow.stop(), 5000); // give up after 5s
108
+ await p;
109
+ ```
110
+
111
+ If a node throws after exhausting retries, its status becomes `'error'` and the runtime emits `node:error`. By default it continues with other nodes; set `flow.options.stopOnError = true` to abort.
112
+
113
+ ## Streaming nodes (`async function*`)
114
+
115
+ A node can emit **multiple values over time** by returning an async generator:
116
+
117
+ ```js
118
+ flow.registerKind({
119
+ name: 'tick',
120
+ nin: 0, nout: 1,
121
+ execute: async function* (ctx) {
122
+ for (let i = 0; i < 10; i++) {
123
+ if (ctx.signal.aborted) return;
124
+ yield { count: i };
125
+ await new Promise((r) => setTimeout(r, 500));
126
+ }
127
+ },
128
+ });
129
+ ```
130
+
131
+ Each `yield` propagates through downstream nodes **before** the next yield runs. So a `tick → log` graph prints 10 times, not 1.
132
+
133
+ ## Conditional routing
134
+
135
+ Declare `portOut` labels and emit only the branches you want:
136
+
137
+ ```js
138
+ flow.registerKind({
139
+ name: 'threshold',
140
+ nin: 1, nout: 2,
141
+ portIn: ['value'],
142
+ portOut: ['high', 'low'],
143
+ execute: (ctx, ins) => ins.value > 100
144
+ ? { high: ins.value } // 'low' edge does NOT fire
145
+ : { low: ins.value }, // 'high' edge does NOT fire
146
+ });
147
+ ```
148
+
149
+ Downstream nodes on the **non-firing** branch don't execute that tick. This is how you build if/else flow.
150
+
151
+ ## Run modes
152
+
153
+ ```js
154
+ await flow.run(); // whole graph
155
+ await flow.runFrom(nodeId); // only nodeId and its descendants
156
+ await flow.runFrame(frameId); // only nodes inside a frame
157
+ await flow.run({ filter: (id) => ... }); // arbitrary predicate
158
+
159
+ flow.startLoop(500); // re-run every 500ms forever
160
+ flow.stopLoop();
161
+ flow.stop(); // abort current run
162
+ ```
163
+
164
+ ## Memoization
165
+
166
+ If your inputs don't change, why re-compute?
167
+
168
+ ```js
169
+ flow.setMemoization(true);
170
+
171
+ // First run executes everything
172
+ await flow.run();
173
+
174
+ // Second run skips nodes whose inputs hash matches the previous run
175
+ await flow.run(); // → most nodes hit cache; only changed ones re-run
176
+ ```
177
+
178
+ Uses FNV-1a 32-bit hash (~80µs for ~1k-entry inputs). The cache invalidates per-node when its hash changes.
179
+
180
+ ## Step-through debugging
181
+
182
+ ```js
183
+ flow.setBreakpoint(suspectNodeId);
184
+
185
+ flow.on('run:paused', ({ nodeId }) => {
186
+ console.log('paused at', nodeId);
187
+ // Inspect:
188
+ console.log('inputs:', flow._values.get(predecessor));
189
+ });
190
+
191
+ await flow.run(); // pauses at breakpoint
192
+ // In response to a UI button:
193
+ flow.stepOver(); // execute the breakpoint node and pause again at next
194
+ flow.resume(); // exit debug mode, continue normally
195
+ ```
196
+
197
+ Or step through everything:
198
+
199
+ ```js
200
+ flow.setStepMode(true);
201
+ await flow.run(); // pauses before every node
202
+ flow.stepOver(); // advance one at a time
203
+ ```
204
+
205
+ ## Driving the runtime from external state
206
+
207
+ Sometimes the graph has source nodes whose values you want to inject from outside:
208
+
209
+ ```js
210
+ const sourceId = flow.addNode({ kind: 'gen' });
211
+
212
+ flow.setNodeInput(sourceId, { value: 42 }); // injects value
213
+ await flow.run();
214
+ // gen's execute didn't run; the injected value flows downstream
215
+ ```
216
+
217
+ This is how UI controls (sliders, text inputs) drive a running graph.
218
+
219
+ ## Reading the result
220
+
221
+ ```js
222
+ await flow.run();
223
+ flow.getNodeValue(sinkId); // → { received: 42 }
224
+ flow._values.get(sinkId); // → same thing
225
+ ```
226
+
227
+ ## Events
228
+
229
+ ```js
230
+ flow.on('run:start', ({ order }) => {});
231
+ flow.on('run:done', ({ executed, errors, values }) => {});
232
+ flow.on('run:paused', ({ nodeId }) => {});
233
+ flow.on('node:exec', ({ id, inputs }) => {});
234
+ flow.on('node:emit', ({ id, outputs }) => {}); // includes streaming emissions
235
+ flow.on('node:done', ({ id, outputs }) => {});
236
+ flow.on('node:error', ({ id, error }) => {});
237
+ flow.on('node:retry', ({ id, attempt, error }) => {});
238
+ flow.on('node:cached', ({ id }) => {}); // memoization skip
239
+ flow.on('node:log', ({ id, args }) => {}); // from ctx.log()
240
+ ```
241
+
242
+ ## Visual cues automatic during a run
243
+
244
+ | What you see | Why |
245
+ | ----------------------------------------- | ------------------------------------------ |
246
+ | Blue pulsing border on a node | status === 'running' |
247
+ | Floating bubble with the emitted value | per emission, auto |
248
+ | Edge thickens + glows blue | data is currently flowing through it |
249
+ | Progress bar inside node | `ctx.setProgress(p)` was called |
250
+ | Sparkline at bottom of node | numeric outputs accumulate over time |
251
+ | Status dot top-right (green/red/blue) | `status` field |
252
+
253
+ You can set `flow.setRunStepDelay(ms)` to pause between nodes so propagation is **visible**. Default 250ms — set to 0 for production speed.
254
+
255
+ ## Next
256
+
257
+ → [Designing Kinds](./03-kinds.md) — schemas, retry, and patterns for executable nodes
@@ -0,0 +1,282 @@
1
+ # 3 · Designing Kinds
2
+
3
+ Kinds are the API surface your end-users see. Treat them like the public API of your app — name them well, give them sensible defaults, validate their inputs.
4
+
5
+ ## Full kind spec
6
+
7
+ ```js
8
+ flow.registerKind({
9
+ // ── Visual ────────────────────────────────────────────────────────────
10
+ name: 'http-get', // required, unique
11
+ color: '#5b8def', // CSS hex, used for header + ports
12
+ badge: 'H', // 1-2 chars or emoji, shown top-left
13
+ w: 200, h: 80, // default size in world units
14
+ shape: 'rect', // 'rect' | 'diamond' | 'ellipse' | 'hexagon' | 'circle' | 'round' | 'subroutine'
15
+
16
+ // ── Ports ─────────────────────────────────────────────────────────────
17
+ nin: 1, nout: 2, // number of input and output ports
18
+ portIn: ['url'], // labels (used as keys in execute's `ins`)
19
+ portOut: ['body', 'error'], // labels for outputs
20
+
21
+ // ── Schema (optional) ─────────────────────────────────────────────────
22
+ inputs: [{ name: 'url', type: 'string', required: true }],
23
+ outputs: [{ name: 'body', type: 'string' },
24
+ { name: 'error', type: 'string' }],
25
+
26
+ // ── Runtime ───────────────────────────────────────────────────────────
27
+ execute: async (ctx, ins) => { /* ... */ },
28
+ retry: { n: 3, delay: 500 }, // automatic retry policy
29
+
30
+ // ── HTML overlay (alternative to canvas) ──────────────────────────────
31
+ html: false,
32
+ template: null, // see below
33
+ });
34
+ ```
35
+
36
+ Every field is optional except `name`. Defaults are sane.
37
+
38
+ ## Naming conventions that won't bite you later
39
+
40
+ - Use **kebab-case** for `name`: `http-get`, `sql-query`, `email-send`
41
+ - Reserve `process` / `input` / `output` for the built-ins
42
+ - Group with prefixes: `db-read`, `db-write`, `db-migrate`
43
+ - The `name` is what shows up in `flow.toJSON()` — once you ship, **don't rename**, or old saved graphs break. Add a new kind and deprecate the old one.
44
+
45
+ ## Ports: labels vs indices
46
+
47
+ Without `portIn`/`portOut`, inputs are addressable only by index:
48
+
49
+ ```js
50
+ flow.registerKind({ name: 'foo', nin: 2 /* no portIn */, execute: (ctx, ins) => {
51
+ ins[0]; // first input
52
+ ins[1]; // second
53
+ ins.in0; // alias
54
+ ins.in1;
55
+ }});
56
+ ```
57
+
58
+ With labels, you also get named access:
59
+
60
+ ```js
61
+ flow.registerKind({ name: 'foo', nin: 2, portIn: ['user', 'token'], execute: (ctx, ins) => {
62
+ ins.user;
63
+ ins.token;
64
+ // ins[0] and ins.in0 still work
65
+ }});
66
+ ```
67
+
68
+ **Always declare labels** for kinds with more than one port. It makes the executor readable.
69
+
70
+ ## Schema (validation at connection time)
71
+
72
+ Schemas don't change runtime behavior — they prevent invalid connections at the editor level:
73
+
74
+ ```js
75
+ flow.registerKind({
76
+ name: 'add',
77
+ inputs: [{ name: 'a', type: 'number' }, { name: 'b', type: 'number' }],
78
+ outputs: [{ name: 'sum', type: 'number' }],
79
+ });
80
+
81
+ flow.registerKind({
82
+ name: 'concat',
83
+ inputs: [{ name: 'text', type: 'string' }],
84
+ });
85
+
86
+ // In the editor, dragging from add's 'sum' port to concat's 'text' port
87
+ // shows a red toast "type mismatch: number → string" and rejects.
88
+ ```
89
+
90
+ Built-in compatibility rules:
91
+ - Same type → ok
92
+ - Either side is `'any'` → ok
93
+ - Numeric types (`number`, `int`, `float`, `integer`) are interchangeable
94
+ - `string` accepts everything (string is the universal type — everything stringifies)
95
+ - Otherwise → mismatch
96
+
97
+ You can override the rules entirely with a custom validator:
98
+
99
+ ```js
100
+ flow.setConnectionValidator((fromN, fp, toN, tp) => {
101
+ // Block self-loops
102
+ if (fromN === toN) return false;
103
+ // Otherwise allow
104
+ return true;
105
+ });
106
+ ```
107
+
108
+ ## Patterns: source nodes
109
+
110
+ Sources have `nin: 0`. They're triggered when `flow.run()` reaches them in topo order:
111
+
112
+ ```js
113
+ flow.registerKind({
114
+ name: 'now',
115
+ nin: 0, nout: 1,
116
+ portOut: ['ms'],
117
+ execute: () => ({ ms: Date.now() }),
118
+ });
119
+ ```
120
+
121
+ Or driven externally:
122
+
123
+ ```js
124
+ flow.registerKind({ name: 'input-slot', nin: 0, nout: 1 }); // no execute
125
+
126
+ const slot = flow.addNode({ kind: 'input-slot' });
127
+ flow.setNodeInput(slot, { value: 42 });
128
+ // → every downstream tick sees 42 from this slot
129
+ ```
130
+
131
+ ## Patterns: sink nodes
132
+
133
+ Sinks have `nout: 0`. They produce side effects (DB write, UI update, log):
134
+
135
+ ```js
136
+ flow.registerKind({
137
+ name: 'console-log',
138
+ nin: 1, nout: 0,
139
+ portIn: ['value'],
140
+ execute: (ctx, ins) => { console.log(ins.value); /* return undefined */ },
141
+ });
142
+ ```
143
+
144
+ A sink's return value (if any) is still stored in `flow._values` so debug UI can show it, but downstream nodes don't get it (there are none).
145
+
146
+ ## Patterns: branching / control flow
147
+
148
+ ```js
149
+ flow.registerKind({
150
+ name: 'switch',
151
+ nin: 1, nout: 3,
152
+ portIn: ['value'],
153
+ portOut: ['gt100', 'gt50', 'else'],
154
+ execute: (ctx, ins) => {
155
+ if (ins.value > 100) return { gt100: ins.value };
156
+ if (ins.value > 50) return { gt50: ins.value };
157
+ return { else: ins.value };
158
+ },
159
+ });
160
+ ```
161
+
162
+ Downstream nodes on **non-firing** branches don't execute that tick. They sit idle.
163
+
164
+ ## Patterns: stateful nodes
165
+
166
+ Need state across runs? Stash it on the node via `setNodeParams`:
167
+
168
+ ```js
169
+ flow.registerKind({
170
+ name: 'counter',
171
+ nin: 0, nout: 1,
172
+ execute: (ctx) => {
173
+ const p = ctx.params;
174
+ p.count = (p.count || 0) + 1;
175
+ flow.setNodeParams(ctx.nodeId, p);
176
+ return { count: p.count };
177
+ },
178
+ });
179
+ ```
180
+
181
+ Or use closure-captured Maps for module-level state:
182
+
183
+ ```js
184
+ const counts = new Map();
185
+ flow.registerKind({
186
+ name: 'counter',
187
+ execute: (ctx) => {
188
+ counts.set(ctx.nodeId, (counts.get(ctx.nodeId) || 0) + 1);
189
+ return { count: counts.get(ctx.nodeId) };
190
+ },
191
+ });
192
+ ```
193
+
194
+ ## Patterns: streaming
195
+
196
+ ```js
197
+ flow.registerKind({
198
+ name: 'every',
199
+ nin: 0, nout: 1,
200
+ execute: async function* (ctx) {
201
+ while (!ctx.signal.aborted) {
202
+ yield { tick: Date.now() };
203
+ await new Promise((r) => setTimeout(r, ctx.params.intervalMs || 1000));
204
+ }
205
+ },
206
+ });
207
+
208
+ const t = flow.addNode({ kind: 'every' });
209
+ flow.setNodeParams(t, { intervalMs: 500 });
210
+ flow.run(); // runs forever (until flow.stop())
211
+ ```
212
+
213
+ ## Patterns: HTML overlay nodes
214
+
215
+ When canvas isn't enough — e.g., you want a form node with input fields:
216
+
217
+ ```js
218
+ flow.registerKind({
219
+ name: 'form',
220
+ html: true,
221
+ template: `
222
+ <div style="padding:12px;">
223
+ <input class="user-input" placeholder="name" style="width:100%;padding:6px;background:#0b0f17;border:1px solid #5b8def;border-radius:4px;color:white;">
224
+ <button class="submit-btn" style="margin-top:8px;width:100%;padding:6px;background:#5b8def;color:white;border:0;border-radius:4px;">Submit</button>
225
+ </div>
226
+ `,
227
+ w: 220, h: 110, nin: 0, nout: 1,
228
+ execute: (ctx) => ({ value: ctx.params.lastSubmit || null }),
229
+ });
230
+
231
+ // Hook up listeners after creating the node:
232
+ const id = flow.addNode({ kind: 'form', x: 0, y: 0 });
233
+ // Wait one frame for the DOM to mount, then:
234
+ requestAnimationFrame(() => {
235
+ const el = flow._htmlOverlays.get(id);
236
+ el.querySelector('.submit-btn').onclick = () => {
237
+ const val = el.querySelector('.user-input').value;
238
+ flow.setNodeParams(id, { lastSubmit: val });
239
+ flow.runFrom(id);
240
+ };
241
+ });
242
+ ```
243
+
244
+ HTML nodes are real DOM. They participate in pan/zoom (their position is synced every frame). Events bubble normally.
245
+
246
+ ## Validation in `execute`
247
+
248
+ Schemas catch wrong types at edit time, but you should still validate at runtime:
249
+
250
+ ```js
251
+ execute: async (ctx, ins) => {
252
+ if (typeof ins.url !== 'string') throw new Error('url required');
253
+ if (!ins.url.startsWith('http')) throw new Error('url must be absolute');
254
+ // ...
255
+ }
256
+ ```
257
+
258
+ Errors surface in `node:error` events and color the node red.
259
+
260
+ ## Documenting kinds for end-users
261
+
262
+ If your app is an editor for end-users, expose a palette / sidebar. Save kind metadata for tooltips:
263
+
264
+ ```js
265
+ flow.registerKind({
266
+ name: 'http-get',
267
+ meta: {
268
+ description: 'Send an HTTP GET request and return the body.',
269
+ docs: 'https://yourapp.com/docs/http-get',
270
+ },
271
+ });
272
+
273
+ // In your sidebar UI:
274
+ const cat = flow.kinds[flow.kindByName.get('http-get')];
275
+ console.log(cat.meta?.description);
276
+ ```
277
+
278
+ (Anything you pass to `registerKind` not in the spec list above is preserved on the kind object.)
279
+
280
+ ## Next
281
+
282
+ → [Performance at Scale](./04-performance.md) — make it fly with 50k+ nodes