@jarenjs/flow 0.56.0 → 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 +16 -0
- 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 +53 -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
|
@@ -261,6 +261,22 @@ gate — `compileFsm`/`compileDag`. The mapping table, and where the pen's
|
|
|
261
261
|
refusals end and the compiler's begin, are
|
|
262
262
|
[FLOW-PEN.md](../linq/docs/FLOW-PEN.md).
|
|
263
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
|
+
|
|
264
280
|
## Development
|
|
265
281
|
|
|
266
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
|
@@ -210,6 +210,7 @@ tables there and here MUST stay in sync.
|
|
|
210
210
|
| JF0016 | the graph has a cycle (member ids in the message, `docPath` at the first edge inside it) |
|
|
211
211
|
| JF0017 | not exactly one `output` node |
|
|
212
212
|
| JF0018 | a `task` node names no registered handler |
|
|
213
|
+
| JF0019 | a `task` node and its registered handler disagree about the handler version |
|
|
213
214
|
|
|
214
215
|
### §5.2 Runtime: thrown vs recorded
|
|
215
216
|
|
|
@@ -305,7 +306,8 @@ A node's `$` is decided by its inbound edges:
|
|
|
305
306
|
inbound edge MUST name one, ports MUST be unique, and `$` is the
|
|
306
307
|
object of port-named values, members in **edge document order**. A
|
|
307
308
|
single ported edge therefore yields `{ port: value }` — the way to
|
|
308
|
-
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`.
|
|
309
311
|
- Two or more unported inbound edges are a compile error (JF0015).
|
|
310
312
|
|
|
311
313
|
A delivery whose `select` yields the empty sequence delivers `null`; a
|
|
@@ -429,6 +431,51 @@ await dag.run(input, { runId: 'run-42' });
|
|
|
429
431
|
reason). Values recorded for node ids the current document does not
|
|
430
432
|
declare (or no longer declares `checkpoint`) are ignored.
|
|
431
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
|
+
|
|
432
479
|
### §7.7 FSM persistence
|
|
433
480
|
|
|
434
481
|
Needs nothing new: `step` is pure and a session's whole durable state
|
|
@@ -436,9 +483,11 @@ IS its current state string. `@jarenjs/flow` ships three thin helpers
|
|
|
436
483
|
— `snapshotFsm(session)` → `{ state }`, `resumeFsmSession(fsm,
|
|
437
484
|
snapshot)` (an undeclared state refuses with the session's own
|
|
438
485
|
JF2001), and `createDurableFsmSession(fsm, { load, save })`, which
|
|
439
|
-
persists through a SYNCHRONOUS store on every
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
442
491
|
|
|
443
492
|
```js
|
|
444
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
|
});
|