@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.
@@ -0,0 +1,441 @@
1
+ # The Jaren flow formats
2
+
3
+ This document is the normative contract for the `@jarenjs/flow` document
4
+ formats. The key words MUST, MUST NOT, SHOULD and MAY are to be
5
+ interpreted as described in RFC 2119. The structural grammar is
6
+ published as JSON Schema in [`schemas/`](../schemas/) (draft 2020-12
7
+ plus a mechanically derived draft-07 twin per artifact); this prose is
8
+ authoritative for everything a structural schema cannot express, and
9
+ the compiler enforces it.
10
+
11
+ Section map: §1 scope, §2 the jaren-fsm document, §3 the evaluation
12
+ scope, §4 the selection rule, §5 errors. §6 (the jaren-dag document)
13
+ and §7 (dag execution) are reserved for the dataflow format.
14
+
15
+ ## §1 Scope
16
+
17
+ A **jaren-fsm** document is an executable finite state machine: named
18
+ control states, one current state at a time, and a transition table
19
+ answering named events. It is the executable superset of the shape the
20
+ `@jarenjs/mermaid` state-diagram projection produces (`jaren-workflow`):
21
+ every document valid under that projection contract MUST compile here
22
+ unchanged. This projection-shaped document — no `$fsm` key, string
23
+ states, null events and guards — is already a complete machine:
24
+
25
+ ```json
26
+ {
27
+ "initial": "draft",
28
+ "states": ["draft", "review", "published"],
29
+ "transitions": [
30
+ { "from": "draft", "event": "submit", "guard": null, "to": "review" },
31
+ { "from": "review", "event": "approve", "guard": null, "to": "published" },
32
+ { "from": "review", "event": null, "guard": null, "to": "draft" }
33
+ ]
34
+ }
35
+ ```
36
+
37
+ The engine is **headless and pure**. Compiling yields a step function
38
+ from `(state, event, options)` to a transition result; the machine holds
39
+ no mutable state, executes no side effects and touches no host API.
40
+ Effects are **descriptors returned as data** — the host's registry runs
41
+ them, the same boundary discipline `@jarenjs/app` keeps.
42
+
43
+ ### §1.1 Non-goals of format 0.1
44
+
45
+ Named so nobody reads absence as oversight:
46
+
47
+ - **Hierarchy.** No compound or nested states; a mermaid composite state
48
+ arrives flattened.
49
+ - **Eventless chains.** A transition only ever fires in answer to a
50
+ `step`/`send` call; there are no spontaneous microsteps and no
51
+ always-transitions that cascade.
52
+ - **History, parallel regions, delayed/timed transitions.** Statechart
53
+ vocabulary deferred until a use case demands it.
54
+ - **Effect execution.** The engine resolves descriptors and returns
55
+ them; it MUST NOT invoke handlers.
56
+ - **Persistence.** A machine's current state is a string; storing it is
57
+ the host's business.
58
+
59
+ ### §1.2 Hosting
60
+
61
+ The engine is deliberately host-agnostic. For `@jarenjs/app` the
62
+ package ships a generator, `fsmToApp`, that projects a machine into
63
+ standard app documents — a state slice and one action document per
64
+ named event, with this section's scope mapped onto app vocabulary
65
+ (the host's state is the `context`). That convention, including its
66
+ honestly-stated divergences, is specified in
67
+ [APP-INTEGRATION.md](APP-INTEGRATION.md); nothing there changes the
68
+ format defined here.
69
+
70
+ ## §2 The jaren-fsm document
71
+
72
+ ```json
73
+ {
74
+ "$fsm": "0.1",
75
+ "initial": "idle",
76
+ "states": [
77
+ "idle",
78
+ { "id": "loading", "entry": [{ "run": "fetch", "with": { "url": "$.context.url" } }] },
79
+ { "id": "done", "final": true }
80
+ ],
81
+ "transitions": [
82
+ { "from": "idle", "event": "start", "to": "loading" },
83
+ { "from": "loading", "event": "ok", "guard": "$.payload.fresh", "to": "done" },
84
+ { "from": "loading", "event": "fail", "to": "idle",
85
+ "effects": [{ "run": "toast", "with": { "text": "retrying" } }] }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ - **`$fsm`** MAY be present; when present it MUST be `"0.1"`. Absent
91
+ implies 0.1 — the projection contract predates the key.
92
+ - **`initial`** MUST be the id of a declared state, or `null` when the
93
+ document does not choose one (a session then requires an explicit
94
+ start state).
95
+ - **`states`** MUST be an array of declarations, each a string id or an
96
+ object `{ id, entry?, exit?, final? }`; a string is shorthand for
97
+ `{ id }`. Ids MUST be unique. `final: true` marks a terminal state —
98
+ advisory to hosts (a session reports `done`); the engine itself
99
+ happily steps out of a final state if a transition says so.
100
+ - **`transitions`** MUST be an array of
101
+ `{ from, event?, guard?, to, effects? }` entries. `from` and `to`
102
+ MUST name declared states. `event` is a string, or null/absent — a
103
+ **wildcard** matching any event name. `guard` is a Jaren JSON Query
104
+ document (§3), or null/absent for none.
105
+ - **Effect descriptors** (`entry`, `exit`, transition `effects`) MUST be
106
+ `{ "run": name, "with"?: query }` with a non-empty string `run`.
107
+ - Unknown members anywhere are ignored for forward compatibility, as in
108
+ the app format.
109
+
110
+ ## §3 The evaluation scope
111
+
112
+ Guards and effect `with` members are Jaren JSON Query documents
113
+ (QUERY-FORMAT.md), compiled once at `compileFsm` time. At step time each
114
+ evaluates with `$` bound to one scope object:
115
+
116
+ | member | value |
117
+ |---|---|
118
+ | `$.state` | the current state id (the transition's `from`) |
119
+ | `$.event` | the event name being stepped |
120
+ | `$.payload` | the caller's payload, or `null` when absent |
121
+ | `$.context` | caller-supplied extended data, or `null` when absent |
122
+
123
+ **Control state lives in the machine; data state lives in the host.**
124
+ The engine carries no context of its own — the caller passes `context`
125
+ per call, which is what keeps the machine a pure function and the host
126
+ (an `@jarenjs/app` state slice, a server session, a test) the single
127
+ owner of its data.
128
+
129
+ A guard is asserted by **effective boolean value** (QUERY-FORMAT's EBV
130
+ rules). Two consequences worth stating plainly:
131
+
132
+ - A guard that is a plain string **not** starting with `$` is a literal
133
+ string, and a non-empty literal is EBV-true: the guard is **vacuously
134
+ true**. This is deliberate — the mermaid projection carries opaque
135
+ display guards (`[count > 3]` from a diagram label), and a picture's
136
+ annotation MUST NOT change execution. Write `$`-paths or operator
137
+ documents for real conditions.
138
+ - A guard whose result has no effective boolean value (a multi-item
139
+ sequence) does not fail the step: it reads **false** and the failure
140
+ is recorded (§5.2).
141
+
142
+ An effect's `with` evaluates against the same scope; an empty query
143
+ result omits the `with` member from the resolved descriptor.
144
+
145
+ ## §4 The selection rule
146
+
147
+ **Document order is the whole priority scheme.** For a step from state
148
+ S on event E, the first entry of `transitions` — in document order —
149
+ whose `from` equals S, whose `event` equals E or is a wildcard, and
150
+ whose guard is absent or EBV-true, fires. There is no specificity
151
+ ranking (a wildcard listed first beats a named match listed later), no
152
+ backtracking, and order among guards is the author's to arrange —
153
+ fallbacks go last.
154
+
155
+ When a transition fires, the step result reports `changed: true`,
156
+ `state` = the transition's `to`, and the resolved effect descriptors in
157
+ this order:
158
+
159
+ 1. the `exit` effects of the from-state — only when the state actually
160
+ changed (`from ≠ to`);
161
+ 2. the transition's own `effects` — always;
162
+ 3. the `entry` effects of the to-state — only when the state actually
163
+ changed.
164
+
165
+ A **self-transition** (`from = to`) therefore runs its transition
166
+ effects but neither exit nor entry: format 0.1 has no internal/external
167
+ transition distinction, and re-running entry work on a self-loop is the
168
+ surprising default. `changed` means **a transition fired** — a
169
+ self-transition reports `changed: true` with an unchanged `state`.
170
+
171
+ A step whose event no transition answers is **ignored**: the result is
172
+ `changed: false`, the same state, no effects — the conventional FSM
173
+ reading of an unhandled event, and the shape hosts can treat as a
174
+ no-op. It MUST NOT be an error.
175
+
176
+ ## §5 Errors
177
+
178
+ Every failure carries a stable `code` and the `docPath` of the
179
+ offending document member. The classes live in `src/errors.js`; the
180
+ tables there and here MUST stay in sync.
181
+
182
+ ### §5.1 Compile errors (`FlowCompileError`, thrown)
183
+
184
+ | code | condition |
185
+ |---|---|
186
+ | JF0001 | the document is not an object, or `$fsm` is present and not `"0.1"` |
187
+ | JF0002 | `states` is not an array, or a state entry is malformed |
188
+ | JF0003 | two state entries share one id |
189
+ | JF0004 | `initial` is neither null nor a declared state id |
190
+ | JF0005 | `transitions` is not an array, or an entry is malformed |
191
+ | JF0006 | a transition's `from` or `to` names no declared state |
192
+ | JF0007 | a guard failed to compile (`cause` carries the query error) |
193
+ | JF0008 | an effects list or effect descriptor is malformed |
194
+ | JF0009 | an effect's `with` failed to compile (`cause`) |
195
+ | JF0010 | the dag document is not an object, or `$dag` is not `"0.1"` |
196
+ | JF0011 | `nodes` is not an object, or a node declaration is malformed |
197
+ | JF0012 | `edges` is not an array, or an edge entry is malformed |
198
+ | JF0013 | an edge's `from` or `to` names no declared node |
199
+ | JF0014 | an embedded query/stylesheet/`with`/`select` failed to compile (`cause`) |
200
+ | JF0015 | wiring rules violated: inbound into `input`/`const`, outbound from `output`, incomplete/duplicate ports, or a consumer with no inbound edge |
201
+ | JF0016 | the graph has a cycle (member ids in the message, `docPath` at the first edge inside it) |
202
+ | JF0017 | not exactly one `output` node |
203
+ | JF0018 | a `task` node names no registered handler |
204
+
205
+ ### §5.2 Runtime: thrown vs recorded
206
+
207
+ Only **caller mistakes** throw (`FlowRuntimeError`):
208
+
209
+ | code | condition |
210
+ |---|---|
211
+ | JF2001 | an undeclared state id passed to `step`/`events`/`final`, or as a session start |
212
+ | JF2002 | a non-string event passed to `step` |
213
+ | JF2005 | a session created with no start state anywhere |
214
+
215
+ **Document-level evaluation failures never throw.** They fail closed
216
+ and are recorded as plain data in the step result's `errors` array,
217
+ each record `{ code, docPath, message }`:
218
+
219
+ | code | condition | fail-closed reading |
220
+ |---|---|---|
221
+ | JF2003 | a guard threw while evaluating | the guard is false; selection continues |
222
+ | JF2004 | an effect's `with` threw while evaluating | the effect is omitted; the step completes |
223
+
224
+ A step with a non-empty `errors` array still returns a valid result —
225
+ hosts SHOULD surface the records through their own error channel, and a
226
+ repair loop gets a `docPath` pointing at exactly the query that failed.
227
+
228
+ **Dag runs are different, deliberately** (§7.3): a dag has no
229
+ recorded-error channel. A failure rejects the whole run promise —
230
+ because a dataflow result assembled from partially failed nodes is
231
+ exactly the kind of partial result D7 forbids:
232
+
233
+ | code | condition |
234
+ |---|---|
235
+ | JF2006 | a node failed while evaluating (own `nodeId` property beside `docPath` and `cause`) |
236
+ | JF2007 | the caller's signal aborted the run |
237
+ | JF2008 | a node declared `checkpoint: true` but produced a value that is not JSON-serializable; the run rejects at save time |
238
+ | JF2009 | the checkpoint store threw while loading, saving or completing; the run rejects |
239
+
240
+ ## §6 The jaren-dag document
241
+
242
+ A **jaren-dag** document is an executable, acyclic dataflow: named
243
+ nodes wired by edges that carry data, run to completion for one input.
244
+ The nodes are the suite's own engines — a closed vocabulary, which is
245
+ what keeps the schema a real contract for constrained decoding;
246
+ extending it is a format revision, not an option.
247
+
248
+ ```json
249
+ {
250
+ "$dag": "0.1",
251
+ "nodes": {
252
+ "rows": { "kind": "input" },
253
+ "adults": { "kind": "query",
254
+ "query": { "$for": { "r": "$[*]" }, "$where": { "$ge": ["$r.age", 18] }, "$return": "$r" } },
255
+ "names": { "kind": "jslt",
256
+ "stylesheet": [{ "match": "$", "body": ["ul", {},
257
+ [{ "$for": { "p": "$[*]" }, "$return": ["li", {}, "$p.name"] }]] }] },
258
+ "out": { "kind": "output" }
259
+ },
260
+ "edges": [
261
+ { "from": "rows", "to": "adults" },
262
+ { "from": "adults", "to": "names" },
263
+ { "from": "names", "to": "out" }
264
+ ]
265
+ }
266
+ ```
267
+
268
+ - **`$dag`** MUST be `"0.1"` and MUST be present — unlike `$fsm` there
269
+ is no earlier contract to stay compatible with, so the document says
270
+ what it is.
271
+ - **`nodes`** MUST be an object of id → declaration. The kinds:
272
+
273
+ | kind | members | meaning |
274
+ |---|---|---|
275
+ | `input` | — | yields the `run(input)` value (`null` when absent) |
276
+ | `const` | `value` (required, any JSON) | yields its literal value |
277
+ | `query` | `query` | a Jaren JSON Query over the node's input scope |
278
+ | `jslt` | `stylesheet` | a JSLT stylesheet over the node's input scope |
279
+ | `task` | `run`, `with?` | a registered async handler (§7.2) |
280
+ | `output` | — | its input scope value is the run's result |
281
+
282
+ - **`edges`** MUST be an array of `{ from, to, port?, select? }`.
283
+ `select` is a query applied to the source value before delivery.
284
+ Wiring rules (all compile-time): `input` and `const` nodes accept no
285
+ inbound edge; the `output` node has no outbound edge; `query`,
286
+ `jslt`, `task` and `output` nodes MUST have at least one inbound
287
+ edge; the graph MUST be acyclic; exactly one `output` node MUST be
288
+ declared. Unknown members are ignored for forward compatibility.
289
+
290
+ ### §6.1 The input scope
291
+
292
+ A node's `$` is decided by its inbound edges:
293
+
294
+ - **One unported edge** — `$` is the delivered value, verbatim.
295
+ - **Ported edges** — when any inbound edge names a `port`, every
296
+ inbound edge MUST name one, ports MUST be unique, and `$` is the
297
+ object of port-named values, members in **edge document order**. A
298
+ single ported edge therefore yields `{ port: value }` — the way to
299
+ force the object shape.
300
+ - Two or more unported inbound edges are a compile error (JF0015).
301
+
302
+ A delivery whose `select` yields the empty sequence delivers `null`; a
303
+ `query`/`jslt` node whose own result is empty likewise yields `null` —
304
+ `undefined` is not a JSON value and never flows through a graph.
305
+ Values pass **by reference**: nodes and hosts MUST NOT mutate what
306
+ they receive.
307
+
308
+ ## §7 Dag execution
309
+
310
+ ### §7.1 Compile once, run many
311
+
312
+ `compileDag(doc, { tasks })` validates the document (§5.1 codes),
313
+ compiles every embedded query, stylesheet, `with` and `select` exactly
314
+ once, resolves every `task` node against the registry (a missing
315
+ handler is compile-time JF0018 — fail early, not mid-run), and proves
316
+ acyclicity. `run(input, { signal?, onNode? })` may then be called any
317
+ number of times, concurrently; runs share nothing but the compiled
318
+ closures.
319
+
320
+ ### §7.2 The run
321
+
322
+ Nodes evaluate when their inputs are ready — topological order with
323
+ insertion-order tie-break; independent branches run **concurrently**.
324
+ Determinism is *same input → same output values*, never same timing:
325
+ results are keyed and port objects are assembled in edge document
326
+ order, so completion order cannot change a value. Each node evaluates
327
+ at most once per run; every declared node evaluates, reachable from
328
+ the output or not.
329
+
330
+ A `task` node's handler is called as
331
+ `handler({ with, input }, signal)` — `with` is its query resolved
332
+ against the node's input scope (`null` when absent or empty), `input`
333
+ is the scope value, and `signal` is the run's shared `AbortSignal`.
334
+ The handler MAY return a plain value or a promise; a resolved
335
+ `undefined` reads as `null`. **Handlers MUST honor the signal**: an
336
+ ignoring handler can never block a run's rejection, but it blocks a
337
+ run's successful resolution (the run resolves only when every node
338
+ settled).
339
+
340
+ ### §7.3 Failure and abort
341
+
342
+ The first failing node wins: the run rejects with `JF2006` (own
343
+ `nodeId`, `docPath` to the failing member, `cause`), the shared signal
344
+ aborts, and in-flight tasks are expected to reject promptly. The
345
+ caller's `signal` aborting rejects the run with `JF2007`. There are no
346
+ retries and no partial results — rerunning is the caller's decision,
347
+ and the id-guard convention of `@jarenjs/app`'s TASKS.md is the
348
+ staleness answer when a dag runs as an app effect
349
+ (APP-INTEGRATION.md).
350
+
351
+ ### §7.4 Observability
352
+
353
+ `onNode` receives one bounded JSON record per node **that started
354
+ evaluating**, at settlement: `{ id, status, ms }` with `status` one of
355
+ `ok` | `error` | `aborted` | `restored` and `ms` the node's own
356
+ evaluation time (waiting for inputs excluded). `restored` is the one
357
+ exception to "started evaluating": a RESUMED run (§7.6) fires it
358
+ first, `ms: 0`, for every node whose checkpointed value was seeded
359
+ instead of evaluated. The first failure records `error`;
360
+ concurrent losers and abort victims record `aborted`; nodes whose
361
+ inputs never arrived produce no record. Records for stragglers MAY
362
+ arrive after the run promise already rejected. A throwing observer is
363
+ isolated and ignored — observation MUST NOT change a run.
364
+
365
+ ### §7.5 Non-goals of format 0.1
366
+
367
+ - **Streaming.** A run is one value in, one value out; feeding a dag
368
+ from the `@jarenjs/josl` incremental readers chunk by chunk is the
369
+ natural 0.2 composition, and doing it well changes the node contract
370
+ — so it is not bolted on here.
371
+ - **Retries.** A failed run is rerun by its caller; retry policy
372
+ belongs to the host (or the app's task convention), not the graph.
373
+ - **Persistence / resume — amended.** A run STILL holds no durable
374
+ state by default, because checkpoints would make every node contract
375
+ a serialization contract. §7.6 makes that contract EXPLICIT and
376
+ opt-in instead of universal: nothing changes for any document or
377
+ caller that does not ask.
378
+ - **Cross-run caching.** Same-input memoization is a host concern;
379
+ the engine promising it would outlaw impure task handlers the
380
+ format explicitly allows.
381
+
382
+ ### §7.6 Checkpointing — the explicit serialization contract
383
+
384
+ Durable runs are OPT-IN twice: the caller provides a store, and each
385
+ node that wants its value persisted declares it.
386
+
387
+ ```js
388
+ const dag = compileDag(doc, { tasks, checkpoint: {
389
+ load(runId) {}, // → { values: { [nodeId]: value } } | null
390
+ save(runId, nodeId, value) {}, // record one declared node's value
391
+ complete(runId, result) {}, // record the run's result
392
+ } });
393
+ await dag.run(input, { runId: 'run-42' });
394
+ ```
395
+
396
+ - A node declares `"checkpoint": true` to assert its output is JSON.
397
+ A declared node whose value fails RFC 8785 canonicalization is
398
+ **`JF2008` at save time, never a silent skip** — the node's author
399
+ claimed a serialization contract and broke it. Undeclared nodes are
400
+ simply RECOMPUTED on resume, which preserves the exact 0.1 contract
401
+ for every document written before this section existed.
402
+ - A resumed run (`run(input, { runId })` with recorded values) SEEDS
403
+ the per-run memo from `load` and restarts the rest — the execution
404
+ model is untouched; only where the memo comes from changed. Seeded
405
+ nodes fire `restored` records (§7.4).
406
+ - `save` runs after the node's value exists and before its `ok`
407
+ record: a crash between the two re-runs the node on resume —
408
+ at-least-once, stated plainly. A store member MAY return a promise;
409
+ a THROWING store fails the run with `JF2009`.
410
+ - `complete(runId, result)` runs after the output settles; a queue
411
+ backing the store can mark its job done in the same transaction —
412
+ which is the entire point of the shape.
413
+ - **Idempotency is the caller's.** A task node with side effects that
414
+ runs twice after a crash is the caller's bug, bluntly. The
415
+ mitigation is an idempotency key threaded through the node's
416
+ `with` props and honoured by the effectful system itself.
417
+ - Resuming under a DIFFERENT document than the one that saved is
418
+ undefined behaviour — keep the document stable with the run (the
419
+ `@jarenjs/db` queue stores it on the job row for exactly this
420
+ reason). Values recorded for node ids the current document does not
421
+ declare (or no longer declares `checkpoint`) are ignored.
422
+
423
+ ### §7.7 FSM persistence
424
+
425
+ Needs nothing new: `step` is pure and a session's whole durable state
426
+ IS its current state string. `@jarenjs/flow` ships three thin helpers
427
+ — `snapshotFsm(session)` → `{ state }`, `resumeFsmSession(fsm,
428
+ snapshot)` (an undeclared state refuses with the session's own
429
+ JF2001), and `createDurableFsmSession(fsm, { load, save })`, which
430
+ persists through a SYNCHRONOUS store on every state CHANGE, before
431
+ the step result returns; a throwing `save` fails the send rather than
432
+ lose a transition. A worked example over a `@jarenjs/db` collection:
433
+
434
+ ```js
435
+ const machine = compileFsm(doc);
436
+ const orders = store.sync.collection('fsm');
437
+ const session = createDurableFsmSession(machine, {
438
+ load: () => orders.get('order-7')?.state ?? null,
439
+ save: (state) => orders.put({ id: 'order-7', state }, 'order-7'),
440
+ });
441
+ ```
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@jarenjs/flow",
3
+ "private": false,
4
+ "version": "0.34.0",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./dist/types/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./schemas/*": "./schemas/*",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist/types/",
19
+ "src/",
20
+ "docs/",
21
+ "schemas/"
22
+ ],
23
+ "description": "Executable workflow documents for the Jaren suite: the jaren-fsm finite-state-machine format compiled to a pure step function, with query-document guards and effect descriptors as data",
24
+ "author": "joham",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/jklarenbeek/jarenjs.git",
28
+ "directory": "packages/flow"
29
+ },
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=24"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "keywords": [
39
+ "jaren",
40
+ "json",
41
+ "fsm",
42
+ "finite-state-machine",
43
+ "workflow",
44
+ "state-machine"
45
+ ],
46
+ "scripts": {
47
+ "build": "npm run build:types",
48
+ "build:types": "tsc -p tsconfig.json",
49
+ "prepack": "npm run build:types"
50
+ },
51
+ "dependencies": {
52
+ "@jarenjs/core": "^0.34.0",
53
+ "@jarenjs/json": "^0.34.0"
54
+ }
55
+ }