@jarenjs/flow 0.34.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.
package/src/dag.js ADDED
@@ -0,0 +1,560 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The jaren-dag engine: compile an acyclic dataflow document
4
+ * (docs/FLOW-FORMAT.md §6) once — every embedded query, stylesheet,
5
+ * `with` and `select` becomes a closure, the task registry is resolved,
6
+ * the wiring rules are proven — and run it many times. A run resolves
7
+ * nodes as their inputs arrive (independent branches concurrently),
8
+ * delivers values by reference, and fails closed: the first failing
9
+ * node aborts the shared signal and rejects the whole run (§7.3) — no
10
+ * retries, no partial results.
11
+ *
12
+ * Determinism is same input → same output VALUES, never same timing:
13
+ * results are keyed per node and port objects assemble in edge
14
+ * document order, so completion order cannot change a value (§7.2).
15
+ */
16
+
17
+ import { compileJsonQuery } from '@jarenjs/json/query';
18
+ import { compileJsltStylesheet } from '@jarenjs/json/jslt';
19
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
20
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
21
+ import { isJsonObject } from '@jarenjs/core/object';
22
+ import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
23
+
24
+ const KINDS = ['input', 'output', 'const', 'query', 'jslt', 'task'];
25
+
26
+ /** Kinds that must not receive an inbound edge / must have one. */
27
+ const NO_INBOUND = new Set(['input', 'const']);
28
+ const NEEDS_INBOUND = new Set(['query', 'jslt', 'task', 'output']);
29
+
30
+ /**
31
+ * Compile one embedded document, wrapping the engine error as JF0014.
32
+ * @param {(d: any) => any} compile
33
+ * @param {any} embedded
34
+ * @param {string} docPath
35
+ */
36
+ function compileEmbedded(compile, embedded, docPath) {
37
+ try {
38
+ return compile(embedded);
39
+ }
40
+ catch (err) {
41
+ const cause = asError(err);
42
+ throw new FlowCompileError('JF0014',
43
+ `the embedded document failed to compile: ${cause.message}`, docPath, cause);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A settlement record handed to `onNode` (§7.4). `restored` fires at
49
+ * the start of a RESUMED run for every node whose checkpointed value
50
+ * was seeded instead of evaluated (§7.6).
51
+ * @typedef {{ id: string, status: 'ok'|'error'|'aborted'|'restored', ms: number }} DagNodeRecord
52
+ */
53
+
54
+ /**
55
+ * The opt-in checkpoint store (§7.6): `load` answers a prior run's
56
+ * recorded values (or null), `save` records one declared node's
57
+ * value, `complete` records the run's result. Any member may return a
58
+ * promise; a throwing store fails the run (JF2009), never silently.
59
+ * @typedef {Object} DagCheckpointStore
60
+ * @property {(runId: string) => any} load
61
+ * @property {(runId: string, nodeId: string, value: any) => any} save
62
+ * @property {(runId: string, result: any) => any} complete
63
+ */
64
+
65
+ /**
66
+ * A compiled jaren-dag graph.
67
+ * @typedef {Object} CompiledDag
68
+ * @property {readonly string[]} nodes - Declared node ids, document order.
69
+ * @property {string} output - The output node's id.
70
+ * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
71
+ * Execute the graph for one input (`undefined` reads as `null`).
72
+ */
73
+
74
+ /**
75
+ * Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
76
+ * task registry. Everything is decided here: structural validation,
77
+ * the wiring rules, acyclicity, embedded-document compilation and
78
+ * registry resolution — `run` only executes closures.
79
+ *
80
+ * @param {any} doc - the jaren-dag document
81
+ * @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any>,
82
+ * checkpoint?: DagCheckpointStore }} [options]
83
+ * @returns {CompiledDag}
84
+ * @throws {FlowCompileError} when the document violates the format (JF0xxx)
85
+ * @throws {TypeError} when the options are malformed (a registry that is
86
+ * not an object, a registered handler that is not a function, or a
87
+ * checkpoint store missing one of load/save/complete)
88
+ */
89
+ export function compileDag(doc, options) {
90
+ const tasks = options?.tasks ?? {};
91
+ if (!isJsonObject(tasks)) {
92
+ throw new TypeError('compileDag: "tasks" must be an object of handler functions');
93
+ }
94
+ const checkpoint = options?.checkpoint;
95
+ if (checkpoint !== undefined && (typeof checkpoint?.load !== 'function'
96
+ || typeof checkpoint.save !== 'function'
97
+ || typeof checkpoint.complete !== 'function')) {
98
+ throw new TypeError(
99
+ 'compileDag: "checkpoint" must provide load, save and complete functions');
100
+ }
101
+
102
+ if (!isJsonObject(doc)) {
103
+ throw new FlowCompileError('JF0010', 'the dag document must be an object', '');
104
+ }
105
+ if (doc.$dag !== '0.1') {
106
+ throw new FlowCompileError('JF0010',
107
+ `the "$dag" member is required and must be '0.1' (got ${JSON.stringify(doc.$dag)})`,
108
+ '/$dag');
109
+ }
110
+ if (!isJsonObject(doc.nodes)) {
111
+ throw new FlowCompileError('JF0011',
112
+ 'the "nodes" member must be an object of node declarations', '/nodes');
113
+ }
114
+
115
+ /** @type {Map<string, any>} */
116
+ const nodes = new Map();
117
+ const order = Object.keys(doc.nodes);
118
+ for (const id of order) {
119
+ const decl = doc.nodes[id];
120
+ const base = `/nodes/${encodeJSONPointerSegment(id)}`;
121
+ if (!isJsonObject(decl) || !KINDS.includes(decl.kind)) {
122
+ throw new FlowCompileError('JF0011',
123
+ `node '${id}' must be an object with a kind from ${KINDS.join('|')}`,
124
+ isJsonObject(decl) ? `${base}/kind` : base);
125
+ }
126
+ if (decl.checkpoint !== undefined && typeof decl.checkpoint !== 'boolean') {
127
+ throw new FlowCompileError('JF0011',
128
+ `node '${id}' has a "checkpoint" member that is not a boolean`,
129
+ `${base}/checkpoint`);
130
+ }
131
+ /** @type {any} */
132
+ const node = {
133
+ id, kind: decl.kind, docPath: base, inbound: [],
134
+ checkpoint: decl.checkpoint === true,
135
+ };
136
+ switch (decl.kind) {
137
+ case 'const':
138
+ if (!Object.hasOwn(decl, 'value')) {
139
+ throw new FlowCompileError('JF0011',
140
+ `const node '${id}' must carry a "value" member`, base);
141
+ }
142
+ node.value = decl.value;
143
+ break;
144
+ case 'query':
145
+ if (decl.query === undefined) {
146
+ throw new FlowCompileError('JF0011',
147
+ `query node '${id}' must carry a "query" member`, base);
148
+ }
149
+ node.query = compileEmbedded(compileJsonQuery, decl.query, `${base}/query`);
150
+ break;
151
+ case 'jslt':
152
+ if (decl.stylesheet === undefined) {
153
+ throw new FlowCompileError('JF0011',
154
+ `jslt node '${id}' must carry a "stylesheet" member`, base);
155
+ }
156
+ node.transform = compileEmbedded(compileJsltStylesheet, decl.stylesheet, `${base}/stylesheet`);
157
+ break;
158
+ case 'task': {
159
+ if (typeof decl.run !== 'string' || decl.run === '') {
160
+ throw new FlowCompileError('JF0011',
161
+ `task node '${id}' must carry a non-empty string "run"`, `${base}/run`);
162
+ }
163
+ if (!Object.hasOwn(tasks, decl.run)) {
164
+ throw new FlowCompileError('JF0018',
165
+ `task node '${id}' names the handler '${decl.run}', which the registry does not provide`,
166
+ `${base}/run`);
167
+ }
168
+ if (typeof tasks[decl.run] !== 'function') {
169
+ throw new TypeError(
170
+ `compileDag: the registered handler '${decl.run}' is not a function`);
171
+ }
172
+ node.handler = tasks[decl.run];
173
+ node.with = decl.with === undefined
174
+ ? null
175
+ : compileEmbedded(compileJsonQuery, decl.with, `${base}/with`);
176
+ break;
177
+ }
178
+ default:
179
+ break;
180
+ }
181
+ nodes.set(id, node);
182
+ }
183
+
184
+ const outputs = order.filter((id) => nodes.get(id).kind === 'output');
185
+ if (outputs.length !== 1) {
186
+ throw new FlowCompileError('JF0017',
187
+ `a dag declares exactly one output node (found ${outputs.length})`, '/nodes');
188
+ }
189
+ const outputId = outputs[0];
190
+
191
+ if (!Array.isArray(doc.edges)) {
192
+ throw new FlowCompileError('JF0012',
193
+ 'the "edges" member must be an array of edge entries', '/edges');
194
+ }
195
+ for (let i = 0; i < doc.edges.length; i++) {
196
+ const e = doc.edges[i];
197
+ const base = `/edges/${i}`;
198
+ if (!isJsonObject(e)) {
199
+ throw new FlowCompileError('JF0012', `edge ${i} must be an object`, base);
200
+ }
201
+ if (typeof e.from !== 'string') {
202
+ throw new FlowCompileError('JF0012', `edge ${i} must carry a string "from"`, `${base}/from`);
203
+ }
204
+ if (typeof e.to !== 'string') {
205
+ throw new FlowCompileError('JF0012', `edge ${i} must carry a string "to"`, `${base}/to`);
206
+ }
207
+ if (e.port !== undefined && (typeof e.port !== 'string' || e.port === '')) {
208
+ throw new FlowCompileError('JF0012',
209
+ `edge ${i} has a "port" that is not a non-empty string`, `${base}/port`);
210
+ }
211
+ if (!nodes.has(e.from)) {
212
+ throw new FlowCompileError('JF0013',
213
+ `edge ${i} leaves the undeclared node '${e.from}'`, `${base}/from`);
214
+ }
215
+ if (!nodes.has(e.to)) {
216
+ throw new FlowCompileError('JF0013',
217
+ `edge ${i} enters the undeclared node '${e.to}'`, `${base}/to`);
218
+ }
219
+ if (NO_INBOUND.has(nodes.get(e.to).kind)) {
220
+ throw new FlowCompileError('JF0015',
221
+ `edge ${i} enters '${e.to}', but ${nodes.get(e.to).kind} nodes accept no inbound edge`,
222
+ `${base}/to`);
223
+ }
224
+ if (nodes.get(e.from).kind === 'output') {
225
+ throw new FlowCompileError('JF0015',
226
+ `edge ${i} leaves the output node '${e.from}'`, `${base}/from`);
227
+ }
228
+ nodes.get(e.to).inbound.push({
229
+ from: e.from,
230
+ port: e.port ?? null,
231
+ select: e.select === undefined
232
+ ? null
233
+ : compileEmbedded(compileJsonQuery, e.select, `${base}/select`),
234
+ edgeIndex: i,
235
+ });
236
+ }
237
+
238
+ // port completeness/uniqueness and inbound-required rules (§6.1)
239
+ for (const id of order) {
240
+ const node = nodes.get(id);
241
+ if (NEEDS_INBOUND.has(node.kind) && node.inbound.length === 0) {
242
+ throw new FlowCompileError('JF0015',
243
+ `${node.kind} node '${id}' has no inbound edge`, node.docPath);
244
+ }
245
+ const ported = node.inbound.some((e) => e.port !== null);
246
+ if (node.inbound.length > 1 || ported) {
247
+ const seen = new Set();
248
+ for (const e of node.inbound) {
249
+ if (e.port === null) {
250
+ throw new FlowCompileError('JF0015',
251
+ `edge ${e.edgeIndex} into '${id}' needs a "port": ported fan-in must be all-ported`,
252
+ `/edges/${e.edgeIndex}`);
253
+ }
254
+ if (seen.has(e.port)) {
255
+ throw new FlowCompileError('JF0015',
256
+ `edge ${e.edgeIndex} duplicates port '${e.port}' into '${id}'`,
257
+ `/edges/${e.edgeIndex}/port`);
258
+ }
259
+ seen.add(e.port);
260
+ }
261
+ node.ports = true;
262
+ }
263
+ else {
264
+ node.ports = false;
265
+ }
266
+ }
267
+
268
+ // acyclicity via Kahn (insertion-order tie-break). A forward pass's
269
+ // leftover holds cycles PLUS their downstream; a backward pass's
270
+ // leftover holds cycles PLUS their upstream — the intersection names
271
+ // exactly the cyclic core, so the message never accuses an innocent
272
+ // downstream node.
273
+ {
274
+ /** @param {(id: string) => string[]} depsOf */
275
+ const kahnLeftover = (depsOf) => {
276
+ const degree = new Map(order.map((id) => [id, depsOf(id).length]));
277
+ const consumers = new Map(order.map((id) => [id, /** @type {string[]} */ ([])]));
278
+ for (const id of order) {
279
+ for (const dep of depsOf(id)) /** @type {string[]} */ (consumers.get(dep)).push(id);
280
+ }
281
+ const ready = order.filter((id) => degree.get(id) === 0);
282
+ while (ready.length > 0) {
283
+ const id = /** @type {string} */ (ready.shift());
284
+ degree.set(id, -1);
285
+ for (const next of /** @type {string[]} */ (consumers.get(id))) {
286
+ const left = /** @type {number} */ (degree.get(next)) - 1;
287
+ degree.set(next, left);
288
+ if (left === 0) ready.push(next);
289
+ }
290
+ }
291
+ return new Set(order.filter((id) => /** @type {number} */ (degree.get(id)) > 0));
292
+ };
293
+ const forward = kahnLeftover((id) => nodes.get(id).inbound.map((e) => e.from));
294
+ if (forward.size > 0) {
295
+ const backward = kahnLeftover((id) => {
296
+ const out = [];
297
+ for (const other of order) {
298
+ for (const e of nodes.get(other).inbound) {
299
+ if (e.from === id) out.push(other);
300
+ }
301
+ }
302
+ return out;
303
+ });
304
+ const cyclic = new Set([...forward].filter((id) => backward.has(id)));
305
+ const offender = doc.edges.findIndex(
306
+ (e) => cyclic.has(e.from) && cyclic.has(e.to));
307
+ throw new FlowCompileError('JF0016',
308
+ `the graph has a cycle among: ${[...cyclic].join(', ')}`, `/edges/${offender}`);
309
+ }
310
+ }
311
+
312
+ /** @type {CompiledDag['run']} */
313
+ function run(input, opts) {
314
+ // option misuse throws synchronously, like every compile surface;
315
+ // only document-level outcomes travel through the promise
316
+ const signal = opts?.signal;
317
+ if (signal !== undefined && typeof signal?.addEventListener !== 'function') {
318
+ throw new TypeError('run: "signal" must be an AbortSignal');
319
+ }
320
+ const onNode = opts?.onNode;
321
+ if (onNode !== undefined && typeof onNode !== 'function') {
322
+ throw new TypeError('run: "onNode" must be a function');
323
+ }
324
+ const runId = opts?.runId;
325
+ if (runId !== undefined && checkpoint === undefined) {
326
+ throw new TypeError('run: "runId" needs a checkpoint store on compileDag');
327
+ }
328
+ if (checkpoint !== undefined
329
+ && (typeof runId !== 'string' || runId === '')) {
330
+ throw new TypeError(
331
+ 'run: a checkpointed dag needs a non-empty string "runId" to persist under');
332
+ }
333
+ return execute(input === undefined ? null : input, signal, onNode, runId);
334
+ }
335
+
336
+ /**
337
+ * @param {any} runInput
338
+ * @param {AbortSignal|undefined} signal
339
+ * @param {((record: DagNodeRecord) => void)|undefined} onNode
340
+ * @param {string|undefined} runId
341
+ */
342
+ async function execute(runInput, signal, onNode, runId) {
343
+ const controller = new AbortController();
344
+ /** @type {FlowRuntimeError|null} */
345
+ let failure = null;
346
+
347
+ /** @param {DagNodeRecord} rec */
348
+ const record = (rec) => {
349
+ if (onNode === undefined) return;
350
+ try {
351
+ onNode(rec);
352
+ }
353
+ catch { /* observation must not change a run (§7.4) */ }
354
+ };
355
+
356
+ /**
357
+ * Register the canonical run failure exactly once and abort the
358
+ * shared signal (§7.3).
359
+ * @param {FlowRuntimeError} err
360
+ */
361
+ const fail = (err) => {
362
+ if (failure === null) {
363
+ failure = err;
364
+ controller.abort();
365
+ }
366
+ };
367
+
368
+ /** @type {(() => void) | null} */
369
+ let onAbort = null;
370
+ if (signal !== undefined) {
371
+ if (signal.aborted) {
372
+ throw new FlowRuntimeError('JF2007', 'the run was aborted before it started', '',
373
+ signal.reason instanceof Error ? signal.reason : undefined);
374
+ }
375
+ onAbort = () => {
376
+ fail(new FlowRuntimeError('JF2007', 'the run was aborted', '',
377
+ signal.reason instanceof Error ? signal.reason : undefined));
378
+ };
379
+ signal.addEventListener('abort', onAbort, { once: true });
380
+ }
381
+
382
+ /** @type {Map<string, Promise<any>>} */
383
+ const promises = new Map();
384
+
385
+ // a resumed run SEEDS the memo from the store (§7.6): recorded
386
+ // values for declared-checkpoint nodes skip evaluation entirely —
387
+ // the execution model is untouched, only where the memo comes from
388
+ if (checkpoint !== undefined && runId !== undefined) {
389
+ let loaded;
390
+ try {
391
+ loaded = await checkpoint.load(runId);
392
+ }
393
+ catch (err) {
394
+ const cause = asError(err);
395
+ throw new FlowRuntimeError('JF2009',
396
+ `the checkpoint store failed to load run '${runId}': ${cause.message}`,
397
+ '', cause);
398
+ }
399
+ if (loaded !== null && loaded !== undefined && isJsonObject(loaded.values)) {
400
+ for (const id of Object.keys(loaded.values)) {
401
+ const node = nodes.get(id);
402
+ if (node === undefined || node.checkpoint !== true) continue;
403
+ promises.set(id, Promise.resolve(loaded.values[id]));
404
+ record({ id, status: 'restored', ms: 0 });
405
+ }
406
+ }
407
+ }
408
+
409
+ /** @param {string} id @returns {Promise<any>} */
410
+ const valueOf = (id) => {
411
+ let p = promises.get(id);
412
+ if (p === undefined) {
413
+ p = evaluate(/** @type {any} */ (nodes.get(id)));
414
+ promises.set(id, p);
415
+ }
416
+ return p;
417
+ };
418
+
419
+ /** @param {any} node @returns {Promise<any>} */
420
+ async function evaluate(node) {
421
+ // upstream failures propagate without a record: this node never
422
+ // started (§7.4)
423
+ const raw = await Promise.all(node.inbound.map((e) => valueOf(e.from)));
424
+ // a failed run launches no new work — inputs may have arrived,
425
+ // but the canonical failure propagates instead (§7.3)
426
+ if (failure !== null) throw failure;
427
+
428
+ const started = globalThis.performance.now();
429
+ /** @param {'ok'|'error'|'aborted'} status */
430
+ const settle = (status) =>
431
+ record({ id: node.id, status, ms: globalThis.performance.now() - started });
432
+
433
+ try {
434
+ // deliveries: per-edge select, empty → null (§6.1)
435
+ const delivered = node.inbound.map((e, i) => {
436
+ if (e.select === null) return raw[i];
437
+ try {
438
+ const v = e.select(raw[i]);
439
+ return v === undefined ? null : v;
440
+ }
441
+ catch (err) {
442
+ const cause = asError(err);
443
+ throw new FlowRuntimeError('JF2006',
444
+ `the select on edge ${e.edgeIndex} into '${node.id}' failed: ${cause.message}`,
445
+ `/edges/${e.edgeIndex}/select`, cause);
446
+ }
447
+ });
448
+ let scope = null;
449
+ if (node.ports) {
450
+ scope = {};
451
+ for (let i = 0; i < node.inbound.length; i++) {
452
+ scope[node.inbound[i].port] = delivered[i];
453
+ }
454
+ }
455
+ else if (node.inbound.length === 1) {
456
+ scope = delivered[0];
457
+ }
458
+
459
+ let value;
460
+ switch (node.kind) {
461
+ case 'input': value = runInput; break;
462
+ case 'const': value = node.value; break;
463
+ case 'output': value = scope; break;
464
+ case 'query': value = node.query(scope) ?? null; break;
465
+ case 'jslt': value = node.transform(scope) ?? null; break;
466
+ case 'task': {
467
+ const props = {
468
+ with: node.with === null ? null : node.with(scope) ?? null,
469
+ input: scope,
470
+ };
471
+ value = (await node.handler(props, controller.signal)) ?? null;
472
+ break;
473
+ }
474
+ default: value = null; break;
475
+ }
476
+ if (node.checkpoint && checkpoint !== undefined && runId !== undefined) {
477
+ // the explicit serialization contract (§7.6): the node
478
+ // DECLARED its output JSON; a value that is not fails the
479
+ // run at save time, never a silent skip
480
+ try {
481
+ canonicalizeJson(value);
482
+ }
483
+ catch (err) {
484
+ const cause = asError(err);
485
+ throw new FlowRuntimeError('JF2008',
486
+ `node '${node.id}' declared checkpoint but produced a value that is `
487
+ + `not JSON-serializable: ${cause.message}`, node.docPath, cause);
488
+ }
489
+ try {
490
+ await checkpoint.save(runId, node.id, value);
491
+ }
492
+ catch (err) {
493
+ const cause = asError(err);
494
+ throw new FlowRuntimeError('JF2009',
495
+ `the checkpoint store failed to save node '${node.id}': ${cause.message}`,
496
+ node.docPath, cause);
497
+ }
498
+ }
499
+ settle('ok');
500
+ return value;
501
+ }
502
+ catch (err) {
503
+ const mapped = err instanceof FlowRuntimeError && /** @type {any} */ (err).nodeId !== undefined
504
+ ? /** @type {FlowRuntimeError} */ (err)
505
+ : (() => {
506
+ const cause = err instanceof FlowRuntimeError ? err.cause : asError(err);
507
+ const wrapped = err instanceof FlowRuntimeError
508
+ ? err
509
+ : new FlowRuntimeError('JF2006',
510
+ `node '${node.id}' failed: ${asError(err).message}`,
511
+ node.docPath, /** @type {Error|undefined} */ (cause instanceof Error ? cause : undefined));
512
+ return wrapped;
513
+ })();
514
+ /** @type {any} */ (mapped).nodeId ??= node.id;
515
+ const status = failure !== null || controller.signal.aborted ? 'aborted' : 'error';
516
+ if (status === 'error') fail(mapped);
517
+ settle(status);
518
+ throw mapped;
519
+ }
520
+ }
521
+
522
+ const all = order.map((id) => {
523
+ const p = valueOf(id);
524
+ p.catch(() => { /* guarded: the run rethrows the canonical failure */ });
525
+ return p;
526
+ });
527
+
528
+ try {
529
+ await Promise.all(all);
530
+ }
531
+ catch (err) {
532
+ throw failure ?? err;
533
+ }
534
+ finally {
535
+ if (signal !== undefined && onAbort !== null) {
536
+ signal.removeEventListener('abort', onAbort);
537
+ }
538
+ }
539
+ if (failure !== null) throw failure;
540
+ const result = await promises.get(outputId);
541
+ if (checkpoint !== undefined && runId !== undefined) {
542
+ try {
543
+ await checkpoint.complete(runId, result);
544
+ }
545
+ catch (err) {
546
+ const cause = asError(err);
547
+ throw new FlowRuntimeError('JF2009',
548
+ `the checkpoint store failed to complete run '${runId}': ${cause.message}`,
549
+ '', cause);
550
+ }
551
+ }
552
+ return result;
553
+ }
554
+
555
+ return Object.freeze({
556
+ nodes: Object.freeze(order.slice()),
557
+ output: outputId,
558
+ run,
559
+ });
560
+ }