@jarenjs/flow 0.49.2 → 0.66.1
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 +51 -1
- package/dist/types/dag.d.ts +19 -27
- package/dist/types/errors.d.ts +1 -0
- package/dist/types/persist.d.ts +9 -6
- package/docs/FLOW-FORMAT.md +62 -4
- package/package.json +3 -3
- package/schemas/jaren-dag.draft-07.schema.json +5 -0
- package/schemas/jaren-dag.schema.json +5 -0
- package/src/dag.js +96 -7
- package/src/errors.js +1 -0
- package/src/persist.js +11 -7
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ built with `@jarenjs/flow` and XState v5 and asserted to agree before
|
|
|
190
190
|
timing:
|
|
191
191
|
|
|
192
192
|
- **Transitions** — the pure `step` runs several times faster than an
|
|
193
|
-
XState actor's `send` (≈<!--
|
|
193
|
+
XState actor's `send` (≈<!--fact:flow.fsmBand-->5.6–8.1<!--/fact-->× across 5/50/500-state machines); the
|
|
194
194
|
`createFsmSession` wrapper is on the page too.
|
|
195
195
|
- **Compile** — `compileFsm` beats `createMachine` + `createActor`
|
|
196
196
|
≈1.5–2.6×. Not like-for-like: XState builds a scheduling actor, so the
|
|
@@ -227,6 +227,56 @@ section shows the `composeChecks(schema, compileGate)` recipe, and
|
|
|
227
227
|
runs a model as an ordinary dag `task`. Neither package imports the
|
|
228
228
|
other — the composition is data.
|
|
229
229
|
|
|
230
|
+
## Authoring by code
|
|
231
|
+
|
|
232
|
+
The same documents have a by-code twin. `@jarenjs/linq/flow` is the
|
|
233
|
+
suite's pen for both formats: state ids, event names and node ids are
|
|
234
|
+
literal types, so a transition into an undeclared state or an edge from
|
|
235
|
+
an undeclared node is a compile error, and guards, effect props, node
|
|
236
|
+
queries and edge selectors are callbacks captured over the scope the
|
|
237
|
+
engine evaluates them in — never a path typed as a string.
|
|
238
|
+
|
|
239
|
+
```javascript
|
|
240
|
+
import { defineFsm, on, state, effect } from '@jarenjs/linq/flow';
|
|
241
|
+
|
|
242
|
+
const doc = defineFsm({
|
|
243
|
+
initial: 'idle',
|
|
244
|
+
states: [
|
|
245
|
+
'idle',
|
|
246
|
+
state('loading', { entry: [effect('fetch', (s) => ({ url: s.context.url }))] }),
|
|
247
|
+
state('done', { final: true }),
|
|
248
|
+
],
|
|
249
|
+
transitions: [
|
|
250
|
+
on('idle', 'start').to('loading'),
|
|
251
|
+
on('loading', 'ok').when((s) => s.payload.fresh).to('done'),
|
|
252
|
+
on('loading', 'fail').to('idle'),
|
|
253
|
+
],
|
|
254
|
+
}); // the document at the top of this README, byte for byte
|
|
255
|
+
|
|
256
|
+
compileFsm(doc).step('idle', 'start', { context: { url: '/rows' } });
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
A model-authored document and a pen-written one go through the SAME
|
|
260
|
+
gate — `compileFsm`/`compileDag`. The mapping table, and where the pen's
|
|
261
|
+
refusals end and the compiler's begin, are
|
|
262
|
+
[FLOW-PEN.md](../linq/docs/FLOW-PEN.md).
|
|
263
|
+
|
|
264
|
+
## Exports
|
|
265
|
+
|
|
266
|
+
Every subpath a consumer can import, derived from the manifest by
|
|
267
|
+
`npm run docs:derive` (`npm run docs:check` fails when the two drift):
|
|
268
|
+
|
|
269
|
+
<!--fact:exports.flow-->
|
|
270
|
+
| Import | Kind | Declarations |
|
|
271
|
+
|---|---|---|
|
|
272
|
+
| `@jarenjs/flow` | JavaScript | declared |
|
|
273
|
+
| `@jarenjs/flow/schemas/jaren-dag.draft-07.schema.json` | schema | — |
|
|
274
|
+
| `@jarenjs/flow/schemas/jaren-dag.schema.json` | schema | — |
|
|
275
|
+
| `@jarenjs/flow/schemas/jaren-fsm.draft-07.schema.json` | schema | — |
|
|
276
|
+
| `@jarenjs/flow/schemas/jaren-fsm.schema.json` | schema | — |
|
|
277
|
+
| `@jarenjs/flow/package.json` | metadata | — |
|
|
278
|
+
<!--/fact-->
|
|
279
|
+
|
|
230
280
|
## Development
|
|
231
281
|
|
|
232
282
|
Unit tests live in `test/flow/` at the repository root
|
package/dist/types/dag.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export type CompiledDag = {
|
|
|
31
31
|
* - The output node's id.
|
|
32
32
|
*/
|
|
33
33
|
output: string;
|
|
34
|
+
/**
|
|
35
|
+
* - Every
|
|
36
|
+
* declared task identity this workflow depends on, keyed by node id and
|
|
37
|
+
* SORTED (§7.8); a nested workflow's map composes under its node's
|
|
38
|
+
* path. Empty when no node declares a version.
|
|
39
|
+
*/
|
|
40
|
+
taskVersions: Readonly<Record<string, string>>;
|
|
34
41
|
/**
|
|
35
42
|
* -
|
|
36
43
|
* Execute the graph for one input (`undefined` reads as `null`).
|
|
@@ -41,30 +48,6 @@ export type CompiledDag = {
|
|
|
41
48
|
runId?: string;
|
|
42
49
|
}) => Promise<any>;
|
|
43
50
|
};
|
|
44
|
-
/**
|
|
45
|
-
* A settlement record handed to `onNode` (§7.4). `restored` fires at
|
|
46
|
-
* the start of a RESUMED run for every node whose checkpointed value
|
|
47
|
-
* was seeded instead of evaluated (§7.6).
|
|
48
|
-
* @typedef {{ id: string, status: 'ok'|'error'|'aborted'|'restored', ms: number }} DagNodeRecord
|
|
49
|
-
*/
|
|
50
|
-
/**
|
|
51
|
-
* The opt-in checkpoint store (§7.6): `load` answers a prior run's
|
|
52
|
-
* recorded values (or null), `save` records one declared node's
|
|
53
|
-
* value, `complete` records the run's result. Any member may return a
|
|
54
|
-
* promise; a throwing store fails the run (JF2009), never silently.
|
|
55
|
-
* @typedef {Object} DagCheckpointStore
|
|
56
|
-
* @property {(runId: string) => any} load
|
|
57
|
-
* @property {(runId: string, nodeId: string, value: any) => any} save
|
|
58
|
-
* @property {(runId: string, result: any) => any} complete
|
|
59
|
-
*/
|
|
60
|
-
/**
|
|
61
|
-
* A compiled jaren-dag graph.
|
|
62
|
-
* @typedef {Object} CompiledDag
|
|
63
|
-
* @property {readonly string[]} nodes - Declared node ids, document order.
|
|
64
|
-
* @property {string} output - The output node's id.
|
|
65
|
-
* @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
|
|
66
|
-
* Execute the graph for one input (`undefined` reads as `null`).
|
|
67
|
-
*/
|
|
68
51
|
/**
|
|
69
52
|
* Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
|
|
70
53
|
* task registry. Everything is decided here: structural validation,
|
|
@@ -72,7 +55,9 @@ export type CompiledDag = {
|
|
|
72
55
|
* registry resolution — `run` only executes closures.
|
|
73
56
|
*
|
|
74
57
|
* @param {any} doc - the jaren-dag document
|
|
75
|
-
* @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any
|
|
58
|
+
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
|
|
59
|
+
* | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
|
|
60
|
+
* taskVersions?: Record<string, string> }>,
|
|
76
61
|
* checkpoint?: DagCheckpointStore }} [options]
|
|
77
62
|
* @returns {CompiledDag}
|
|
78
63
|
* @throws {FlowCompileError} when the document violates the format (JF0xxx)
|
|
@@ -81,9 +66,16 @@ export type CompiledDag = {
|
|
|
81
66
|
* checkpoint store missing one of load/save/complete)
|
|
82
67
|
*/
|
|
83
68
|
export declare function compileDag(doc: any, options?: {
|
|
84
|
-
tasks?: Record<string, (props: {
|
|
69
|
+
tasks?: Record<string, ((props: {
|
|
85
70
|
with: any;
|
|
86
71
|
input: any;
|
|
87
|
-
}, signal: AbortSignal) => any
|
|
72
|
+
}, signal: AbortSignal) => any) | {
|
|
73
|
+
run: (props: {
|
|
74
|
+
with: any;
|
|
75
|
+
input: any;
|
|
76
|
+
}, signal: AbortSignal) => any;
|
|
77
|
+
version?: string;
|
|
78
|
+
taskVersions?: Record<string, string>;
|
|
79
|
+
}>;
|
|
88
80
|
checkpoint?: DagCheckpointStore;
|
|
89
81
|
}): CompiledDag;
|
package/dist/types/errors.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export declare const FLOW_CODES: Readonly<{
|
|
|
33
33
|
JF0016: "the graph has a cycle";
|
|
34
34
|
JF0017: "the document does not declare exactly one output node";
|
|
35
35
|
JF0018: "a task node names a handler the registry does not provide";
|
|
36
|
+
JF0019: "a task node and its registered handler disagree about the handler version";
|
|
36
37
|
JF2001: "a state id the machine does not declare";
|
|
37
38
|
JF2002: "step was called with a non-string event";
|
|
38
39
|
JF2003: "a guard threw while evaluating";
|
package/dist/types/persist.d.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* captures a session as JSON, `resumeFsmSession` rebuilds one (the
|
|
6
6
|
* existing JF2001 refusal covers a snapshot naming an undeclared
|
|
7
7
|
* state), and `createDurableFsmSession` persists through a
|
|
8
|
-
* SYNCHRONOUS `{ load, save }` store on every
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* SYNCHRONOUS `{ load, save }` store on every fired transition,
|
|
9
|
+
* including self-transitions — a store that throws leaves the session
|
|
10
|
+
* at its previous state, so the event can be retried without losing a
|
|
11
|
+
* transition. An asynchronous store composes its own wrapper; the session contract
|
|
11
12
|
* stays synchronous.
|
|
12
13
|
*/
|
|
13
14
|
/**
|
|
@@ -44,8 +45,10 @@ export declare function resumeFsmSession(fsm: any, snapshot: {
|
|
|
44
45
|
/**
|
|
45
46
|
* A session that persists its state through a synchronous store:
|
|
46
47
|
* `load()` answers the stored state (or null/undefined for a fresh
|
|
47
|
-
* start), `save(state)` records
|
|
48
|
-
* result is returned
|
|
48
|
+
* start), `save(state)` records every fired transition's state,
|
|
49
|
+
* including self-transitions, before the step result is returned and
|
|
50
|
+
* the session advances. A failed save leaves
|
|
51
|
+
* the session unchanged.
|
|
49
52
|
* @param {any} fsm - a machine from `compileFsm`
|
|
50
53
|
* @param {{ load: () => string | null | undefined,
|
|
51
54
|
* save: (state: string) => void }} store
|
|
@@ -57,5 +60,5 @@ export declare function createDurableFsmSession(fsm: any, store: {
|
|
|
57
60
|
readonly state: string;
|
|
58
61
|
readonly done: boolean;
|
|
59
62
|
can: (event: any, opts: any) => boolean;
|
|
60
|
-
send(event: any, opts: any):
|
|
63
|
+
send(event: any, opts: any): any;
|
|
61
64
|
}>;
|
package/docs/FLOW-FORMAT.md
CHANGED
|
@@ -67,6 +67,15 @@ honestly-stated divergences, is specified in
|
|
|
67
67
|
[APP-INTEGRATION.md](APP-INTEGRATION.md); nothing there changes the
|
|
68
68
|
format defined here.
|
|
69
69
|
|
|
70
|
+
Documents in both formats are authored by hand, projected from a
|
|
71
|
+
diagram, decoded by a model under the published grammar, or written by
|
|
72
|
+
code: `@jarenjs/linq/flow` is the suite's by-code producer
|
|
73
|
+
(`defineFsm`, `defineDag`), and it emits exactly the documents this
|
|
74
|
+
section defines — its guards, effect props, node queries and edge
|
|
75
|
+
selectors are callbacks captured over the scopes §3 and §6.1 fix. It
|
|
76
|
+
imports nothing of this package; the compilers here remain the only
|
|
77
|
+
judge of what a document means.
|
|
78
|
+
|
|
70
79
|
## §2 The jaren-fsm document
|
|
71
80
|
|
|
72
81
|
```json
|
|
@@ -201,6 +210,7 @@ tables there and here MUST stay in sync.
|
|
|
201
210
|
| JF0016 | the graph has a cycle (member ids in the message, `docPath` at the first edge inside it) |
|
|
202
211
|
| JF0017 | not exactly one `output` node |
|
|
203
212
|
| JF0018 | a `task` node names no registered handler |
|
|
213
|
+
| JF0019 | a `task` node and its registered handler disagree about the handler version |
|
|
204
214
|
|
|
205
215
|
### §5.2 Runtime: thrown vs recorded
|
|
206
216
|
|
|
@@ -296,7 +306,8 @@ A node's `$` is decided by its inbound edges:
|
|
|
296
306
|
inbound edge MUST name one, ports MUST be unique, and `$` is the
|
|
297
307
|
object of port-named values, members in **edge document order**. A
|
|
298
308
|
single ported edge therefore yields `{ port: value }` — the way to
|
|
299
|
-
force the object shape.
|
|
309
|
+
force the object shape. Each port is an own object member, including
|
|
310
|
+
names that also occur on `Object.prototype`.
|
|
300
311
|
- Two or more unported inbound edges are a compile error (JF0015).
|
|
301
312
|
|
|
302
313
|
A delivery whose `select` yields the empty sequence delivers `null`; a
|
|
@@ -420,6 +431,51 @@ await dag.run(input, { runId: 'run-42' });
|
|
|
420
431
|
reason). Values recorded for node ids the current document does not
|
|
421
432
|
declare (or no longer declares `checkpoint`) are ignored.
|
|
422
433
|
|
|
434
|
+
### §7.8 Declared task versions
|
|
435
|
+
|
|
436
|
+
A checkpointed node's value is REPLAYED on a later run instead of being
|
|
437
|
+
recomputed. That is sound only while the handler that produced it is the
|
|
438
|
+
same handler. So a `task` node that declares `checkpoint: true` must also
|
|
439
|
+
declare `version`: a non-empty string naming the identity of the
|
|
440
|
+
implementation it depends on.
|
|
441
|
+
|
|
442
|
+
```jsonc
|
|
443
|
+
{ "kind": "task", "run": "summarise", "version": "2026-09-05", "checkpoint": true }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**The identity is declared, never derived.** Hashing the handler's source
|
|
447
|
+
would call a reformat a new task and a changed dependency the same one;
|
|
448
|
+
neither is what a caller means. The host states it, and the host is the
|
|
449
|
+
only party that knows when its implementation actually changed.
|
|
450
|
+
|
|
451
|
+
**The registry must agree.** A registered handler is either a bare
|
|
452
|
+
function — the shorthand, which carries no version — or
|
|
453
|
+
`{ run, version }`. A node that declares a version against a bare handler,
|
|
454
|
+
or against a different version, is `JF0019` at COMPILE time, before any
|
|
455
|
+
node runs. The shorthand therefore serves exactly the workflows that
|
|
456
|
+
checkpoint nothing, and a checkpointed node cannot use it.
|
|
457
|
+
|
|
458
|
+
`version` is allowed on a node that does not checkpoint, so a workflow can
|
|
459
|
+
carry one identity vocabulary throughout.
|
|
460
|
+
|
|
461
|
+
**The canonical map.** A compiled workflow answers `taskVersions`: every
|
|
462
|
+
declared identity it depends on, keyed by node id and SORTED, so two
|
|
463
|
+
compiles of the same document produce the same map — byte for byte —
|
|
464
|
+
whatever order the declarations were written in. A handler that is itself
|
|
465
|
+
a compiled workflow may expose its own `taskVersions`; those compose under
|
|
466
|
+
the node's path (`outer`, `outer/inner`), so a composed run has one
|
|
467
|
+
identity rather than two. Every task id is retained as an own member,
|
|
468
|
+
including names inherited by ordinary JavaScript objects.
|
|
469
|
+
|
|
470
|
+
```js
|
|
471
|
+
compileDag(doc, { tasks: { summarise: { version: '2026-09-05', run: handler } } })
|
|
472
|
+
.taskVersions; // → { "draft": "2026-09-05", "draft/tidy": "1" }
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
That map is what a durable queue fingerprints alongside the workflow
|
|
476
|
+
revision and the input, so a run cannot resume onto checkpoints written by
|
|
477
|
+
an implementation nobody is running any more.
|
|
478
|
+
|
|
423
479
|
### §7.7 FSM persistence
|
|
424
480
|
|
|
425
481
|
Needs nothing new: `step` is pure and a session's whole durable state
|
|
@@ -427,9 +483,11 @@ IS its current state string. `@jarenjs/flow` ships three thin helpers
|
|
|
427
483
|
— `snapshotFsm(session)` → `{ state }`, `resumeFsmSession(fsm,
|
|
428
484
|
snapshot)` (an undeclared state refuses with the session's own
|
|
429
485
|
JF2001), and `createDurableFsmSession(fsm, { load, save })`, which
|
|
430
|
-
persists through a SYNCHRONOUS store on every
|
|
431
|
-
|
|
432
|
-
|
|
486
|
+
persists through a SYNCHRONOUS store on every fired transition,
|
|
487
|
+
including self-transitions (`changed: true`), before
|
|
488
|
+
the step result returns and the session advances; a throwing `save`
|
|
489
|
+
leaves the session unchanged so the event can be retried. A worked
|
|
490
|
+
example over a `@jarenjs/db` collection:
|
|
433
491
|
|
|
434
492
|
```js
|
|
435
493
|
const machine = compileFsm(doc);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/flow",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.66.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./dist/types/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"prepack": "npm run build:types"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@jarenjs/core": "^0.
|
|
53
|
-
"@jarenjs/json": "^0.
|
|
52
|
+
"@jarenjs/core": "^0.66.1",
|
|
53
|
+
"@jarenjs/json": "^0.66.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -146,6 +146,11 @@
|
|
|
146
146
|
"type": "string",
|
|
147
147
|
"minLength": 1
|
|
148
148
|
},
|
|
149
|
+
"version": {
|
|
150
|
+
"description": "The declared identity of the handler implementation this node depends on (FLOW-FORMAT section 7.8). REQUIRED when checkpoint is true: a recorded value is replayed only while the handler that produced it is the same one, and that identity is declared, never derived from source. The registry must supply the same token.",
|
|
151
|
+
"type": "string",
|
|
152
|
+
"minLength": 1
|
|
153
|
+
},
|
|
149
154
|
"with": {
|
|
150
155
|
"description": "Handler props: a query resolved against the node's input scope (null when absent or empty).",
|
|
151
156
|
"allOf": [
|
|
@@ -146,6 +146,11 @@
|
|
|
146
146
|
"type": "string",
|
|
147
147
|
"minLength": 1
|
|
148
148
|
},
|
|
149
|
+
"version": {
|
|
150
|
+
"description": "The declared identity of the handler implementation this node depends on (FLOW-FORMAT section 7.8). REQUIRED when checkpoint is true: a recorded value is replayed only while the handler that produced it is the same one, and that identity is declared, never derived from source. The registry must supply the same token.",
|
|
151
|
+
"type": "string",
|
|
152
|
+
"minLength": 1
|
|
153
|
+
},
|
|
149
154
|
"with": {
|
|
150
155
|
"description": "Handler props: a query resolved against the node's input scope (null when absent or empty).",
|
|
151
156
|
"allOf": [
|
package/src/dag.js
CHANGED
|
@@ -18,7 +18,7 @@ import { compileJsonQuery } from '@jarenjs/json/query';
|
|
|
18
18
|
import { compileJsltStylesheet } from '@jarenjs/json/jslt';
|
|
19
19
|
import { canonicalizeJson } from '@jarenjs/json/canonical';
|
|
20
20
|
import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
21
|
-
import { isJsonObject } from '@jarenjs/core/object';
|
|
21
|
+
import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
|
|
22
22
|
import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
|
|
23
23
|
|
|
24
24
|
const KINDS = ['input', 'output', 'const', 'query', 'jslt', 'task'];
|
|
@@ -67,10 +67,49 @@ function compileEmbedded(compile, embedded, docPath) {
|
|
|
67
67
|
* @typedef {Object} CompiledDag
|
|
68
68
|
* @property {readonly string[]} nodes - Declared node ids, document order.
|
|
69
69
|
* @property {string} output - The output node's id.
|
|
70
|
+
* @property {Readonly<Record<string, string>>} taskVersions - Every
|
|
71
|
+
* declared task identity this workflow depends on, keyed by node id and
|
|
72
|
+
* SORTED (§7.8); a nested workflow's map composes under its node's
|
|
73
|
+
* path. Empty when no node declares a version.
|
|
70
74
|
* @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
|
|
71
75
|
* Execute the graph for one input (`undefined` reads as `null`).
|
|
72
76
|
*/
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* One registry entry, in either accepted spelling.
|
|
80
|
+
*
|
|
81
|
+
* `{ run, version }` is the full one. A bare function is the shorthand,
|
|
82
|
+
* and it carries no version — which is why a checkpointed node, whose
|
|
83
|
+
* declared version has nothing to be compared against, cannot use it.
|
|
84
|
+
* An entry may also expose a `taskVersions` map of its own: a handler
|
|
85
|
+
* that is itself a compiled workflow contributes its versions under this
|
|
86
|
+
* node's path, so a composed run has one identity, not two.
|
|
87
|
+
* @param {any} entry
|
|
88
|
+
* @param {string} name
|
|
89
|
+
* @returns {{ run: Function, version: string | null, taskVersions: Record<string, string> | null }}
|
|
90
|
+
*/
|
|
91
|
+
function normalizeTaskEntry(entry, name) {
|
|
92
|
+
if (typeof entry === 'function') return { run: entry, version: null, taskVersions: null };
|
|
93
|
+
if (!isJsonObject(entry) || typeof entry.run !== 'function') {
|
|
94
|
+
throw new TypeError(
|
|
95
|
+
`compileDag: the registered handler '${name}' is not a function, nor { run, version }`);
|
|
96
|
+
}
|
|
97
|
+
if (entry.version !== undefined
|
|
98
|
+
&& (typeof entry.version !== 'string' || entry.version === '')) {
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`compileDag: the registered handler '${name}' has a version that is not a non-empty string`);
|
|
101
|
+
}
|
|
102
|
+
if (entry.taskVersions !== undefined && !isJsonObject(entry.taskVersions)) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
`compileDag: the registered handler '${name}' has a taskVersions that is not an object`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
run: entry.run,
|
|
108
|
+
version: entry.version ?? null,
|
|
109
|
+
taskVersions: entry.taskVersions ?? null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
/**
|
|
75
114
|
* Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
|
|
76
115
|
* task registry. Everything is decided here: structural validation,
|
|
@@ -78,7 +117,9 @@ function compileEmbedded(compile, embedded, docPath) {
|
|
|
78
117
|
* registry resolution — `run` only executes closures.
|
|
79
118
|
*
|
|
80
119
|
* @param {any} doc - the jaren-dag document
|
|
81
|
-
* @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any
|
|
120
|
+
* @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
|
|
121
|
+
* | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
|
|
122
|
+
* taskVersions?: Record<string, string> }>,
|
|
82
123
|
* checkpoint?: DagCheckpointStore }} [options]
|
|
83
124
|
* @returns {CompiledDag}
|
|
84
125
|
* @throws {FlowCompileError} when the document violates the format (JF0xxx)
|
|
@@ -160,16 +201,45 @@ export function compileDag(doc, options) {
|
|
|
160
201
|
throw new FlowCompileError('JF0011',
|
|
161
202
|
`task node '${id}' must carry a non-empty string "run"`, `${base}/run`);
|
|
162
203
|
}
|
|
204
|
+
if (decl.version !== undefined
|
|
205
|
+
&& (typeof decl.version !== 'string' || decl.version === '')) {
|
|
206
|
+
throw new FlowCompileError('JF0011',
|
|
207
|
+
`task node '${id}' has a "version" member that is not a non-empty string`,
|
|
208
|
+
`${base}/version`);
|
|
209
|
+
}
|
|
210
|
+
// a checkpointed node's output is REPLAYED on a later run, which
|
|
211
|
+
// is only sound while the implementation that produced it is the
|
|
212
|
+
// same implementation. That identity is declared, never derived:
|
|
213
|
+
// hashing a closure's source would call a reformat a new task and
|
|
214
|
+
// a changed dependency the same one
|
|
215
|
+
if (node.checkpoint && decl.version === undefined) {
|
|
216
|
+
throw new FlowCompileError('JF0011',
|
|
217
|
+
`task node '${id}' declares checkpoint, so it must also declare a "version" — `
|
|
218
|
+
+ 'a checkpointed result is replayed only while the handler that produced it is '
|
|
219
|
+
+ 'the same one, and that identity has to be stated', `${base}/version`);
|
|
220
|
+
}
|
|
163
221
|
if (!Object.hasOwn(tasks, decl.run)) {
|
|
164
222
|
throw new FlowCompileError('JF0018',
|
|
165
223
|
`task node '${id}' names the handler '${decl.run}', which the registry does not provide`,
|
|
166
224
|
`${base}/run`);
|
|
167
225
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
226
|
+
const entry = normalizeTaskEntry(tasks[decl.run], decl.run);
|
|
227
|
+
if (decl.version !== undefined) {
|
|
228
|
+
if (entry.version === null) {
|
|
229
|
+
throw new FlowCompileError('JF0019',
|
|
230
|
+
`task node '${id}' declares version '${decl.version}', but the registry provides `
|
|
231
|
+
+ `'${decl.run}' as a bare handler with no version — register it as `
|
|
232
|
+
+ '{ run, version } so the two can be compared', `${base}/version`);
|
|
233
|
+
}
|
|
234
|
+
if (entry.version !== decl.version) {
|
|
235
|
+
throw new FlowCompileError('JF0019',
|
|
236
|
+
`task node '${id}' declares version '${decl.version}', but the registry provides `
|
|
237
|
+
+ `'${decl.run}' at version '${entry.version}'`, `${base}/version`);
|
|
238
|
+
}
|
|
171
239
|
}
|
|
172
|
-
node.handler =
|
|
240
|
+
node.handler = entry.run;
|
|
241
|
+
node.version = decl.version ?? null;
|
|
242
|
+
node.nestedVersions = entry.taskVersions;
|
|
173
243
|
node.with = decl.with === undefined
|
|
174
244
|
? null
|
|
175
245
|
: compileEmbedded(compileJsonQuery, decl.with, `${base}/with`);
|
|
@@ -449,7 +519,7 @@ export function compileDag(doc, options) {
|
|
|
449
519
|
if (node.ports) {
|
|
450
520
|
scope = {};
|
|
451
521
|
for (let i = 0; i < node.inbound.length; i++) {
|
|
452
|
-
scope
|
|
522
|
+
setObjectMember(scope, node.inbound[i].port, delivered[i]);
|
|
453
523
|
}
|
|
454
524
|
}
|
|
455
525
|
else if (node.inbound.length === 1) {
|
|
@@ -552,9 +622,28 @@ export function compileDag(doc, options) {
|
|
|
552
622
|
return result;
|
|
553
623
|
}
|
|
554
624
|
|
|
625
|
+
// The canonical version map: every declared task identity this
|
|
626
|
+
// workflow depends on, keyed by node path and SORTED, so two compiles
|
|
627
|
+
// of the same document answer the same map whatever order the
|
|
628
|
+
// declarations were written in. A handler that is itself a workflow
|
|
629
|
+
// contributes its own map under this node's path.
|
|
630
|
+
/** @type {Record<string, string>} */
|
|
631
|
+
const versions = {};
|
|
632
|
+
for (const node of nodes.values()) {
|
|
633
|
+
if (node.kind !== 'task' || node.version === null) continue;
|
|
634
|
+
setObjectMember(versions, node.id, node.version);
|
|
635
|
+
if (node.nestedVersions === null) continue;
|
|
636
|
+
for (const [path, version] of Object.entries(node.nestedVersions)) {
|
|
637
|
+
setObjectMember(versions, `${node.id}/${path}`, version);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const taskVersions = Object.freeze(Object.fromEntries(
|
|
641
|
+
Object.keys(versions).sort().map((key) => [key, versions[key]])));
|
|
642
|
+
|
|
555
643
|
return Object.freeze({
|
|
556
644
|
nodes: Object.freeze(order.slice()),
|
|
557
645
|
output: outputId,
|
|
646
|
+
taskVersions,
|
|
558
647
|
run,
|
|
559
648
|
});
|
|
560
649
|
}
|
package/src/errors.js
CHANGED
|
@@ -36,6 +36,7 @@ export const FLOW_CODES = Object.freeze({
|
|
|
36
36
|
JF0016: 'the graph has a cycle',
|
|
37
37
|
JF0017: 'the document does not declare exactly one output node',
|
|
38
38
|
JF0018: 'a task node names a handler the registry does not provide',
|
|
39
|
+
JF0019: 'a task node and its registered handler disagree about the handler version',
|
|
39
40
|
JF2001: 'a state id the machine does not declare',
|
|
40
41
|
JF2002: 'step was called with a non-string event',
|
|
41
42
|
JF2003: 'a guard threw while evaluating',
|
package/src/persist.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* captures a session as JSON, `resumeFsmSession` rebuilds one (the
|
|
7
7
|
* existing JF2001 refusal covers a snapshot naming an undeclared
|
|
8
8
|
* state), and `createDurableFsmSession` persists through a
|
|
9
|
-
* SYNCHRONOUS `{ load, save }` store on every
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* SYNCHRONOUS `{ load, save }` store on every fired transition,
|
|
10
|
+
* including self-transitions — a store that throws leaves the session
|
|
11
|
+
* at its previous state, so the event can be retried without losing a
|
|
12
|
+
* transition. An asynchronous store composes its own wrapper; the session contract
|
|
12
13
|
* stays synchronous.
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -40,8 +41,10 @@ export function resumeFsmSession(fsm, snapshot) {
|
|
|
40
41
|
/**
|
|
41
42
|
* A session that persists its state through a synchronous store:
|
|
42
43
|
* `load()` answers the stored state (or null/undefined for a fresh
|
|
43
|
-
* start), `save(state)` records
|
|
44
|
-
* result is returned
|
|
44
|
+
* start), `save(state)` records every fired transition's state,
|
|
45
|
+
* including self-transitions, before the step result is returned and
|
|
46
|
+
* the session advances. A failed save leaves
|
|
47
|
+
* the session unchanged.
|
|
45
48
|
* @param {any} fsm - a machine from `compileFsm`
|
|
46
49
|
* @param {{ load: () => string | null | undefined,
|
|
47
50
|
* save: (state: string) => void }} store
|
|
@@ -51,14 +54,15 @@ export function createDurableFsmSession(fsm, store) {
|
|
|
51
54
|
throw new TypeError('createDurableFsmSession: the store must provide load and save');
|
|
52
55
|
}
|
|
53
56
|
const stored = store.load();
|
|
54
|
-
|
|
57
|
+
let session = createFsmSession(fsm, stored ?? undefined);
|
|
55
58
|
return Object.freeze({
|
|
56
59
|
get state() { return session.state; },
|
|
57
60
|
get done() { return session.done; },
|
|
58
61
|
can: (event, opts) => session.can(event, opts),
|
|
59
62
|
send(event, opts) {
|
|
60
|
-
const result = session.
|
|
63
|
+
const result = fsm.step(session.state, event, opts);
|
|
61
64
|
if (result.changed) store.save(result.state);
|
|
65
|
+
session = createFsmSession(fsm, result.state);
|
|
62
66
|
return result;
|
|
63
67
|
},
|
|
64
68
|
});
|