@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,150 @@
1
+ /**
2
+ * @file The jaren-fsm engine: compile a finite-state-machine document
3
+ * (docs/FLOW-FORMAT.md §2) into a PURE step function. Everything is
4
+ * decided once at compile time — guards and effect `with` members
5
+ * become compiled query closures — and the running machine holds no
6
+ * mutable state at all: `step(state, event, opts)` maps its arguments
7
+ * to a transition result, and `createFsmSession` is the thin mutable
8
+ * convenience over it.
9
+ *
10
+ * The engine never executes an effect. A fired transition RETURNS
11
+ * resolved effect descriptors as data (exit → transition → entry); how
12
+ * they run — as @jarenjs/app effects, or in any host loop — is the
13
+ * caller's registry, the same boundary discipline the rest of the
14
+ * suite keeps.
15
+ */
16
+ export type CompiledEffect = {
17
+ run: string;
18
+ with: any;
19
+ docPath: string;
20
+ };
21
+ export type FsmStepResult = {
22
+ /**
23
+ * - A transition fired. A self-transition
24
+ * reports true with an unchanged `state`; inspect `state` to tell the
25
+ * two apart.
26
+ */
27
+ changed: boolean;
28
+ /**
29
+ * - The resulting state id.
30
+ */
31
+ state: string;
32
+ /**
33
+ * - Resolved `{ run, with? }` descriptors in
34
+ * firing order: exit → transition → entry (exit/entry only when the
35
+ * state actually changed).
36
+ */
37
+ effects: any[];
38
+ /**
39
+ * - Whether the resulting state is final.
40
+ */
41
+ final: boolean;
42
+ /**
43
+ * -
44
+ * Fail-closed evaluation records (JF2003/JF2004); empty on a clean step.
45
+ */
46
+ errors: {
47
+ code: string;
48
+ docPath: string;
49
+ message: string;
50
+ }[];
51
+ };
52
+ export type CompiledFsm = {
53
+ /**
54
+ * - The document's initial state id.
55
+ */
56
+ initial: string | null;
57
+ /**
58
+ * - Declared state ids, document order.
59
+ */
60
+ states: readonly string[];
61
+ /**
62
+ * - The unique
63
+ * named events leaving a state (wildcards excluded), document order.
64
+ */
65
+ events: (state: string) => readonly string[];
66
+ /**
67
+ * - Whether a state is final.
68
+ */
69
+ final: (state: string) => boolean;
70
+ /**
71
+ * -
72
+ * The pure step function.
73
+ */
74
+ step: (state: string, event: string, opts?: {
75
+ payload?: any;
76
+ context?: any;
77
+ }) => FsmStepResult;
78
+ };
79
+ /**
80
+ * The result of one pure step.
81
+ * @typedef {Object} FsmStepResult
82
+ * @property {boolean} changed - A transition fired. A self-transition
83
+ * reports true with an unchanged `state`; inspect `state` to tell the
84
+ * two apart.
85
+ * @property {string} state - The resulting state id.
86
+ * @property {any[]} effects - Resolved `{ run, with? }` descriptors in
87
+ * firing order: exit → transition → entry (exit/entry only when the
88
+ * state actually changed).
89
+ * @property {boolean} final - Whether the resulting state is final.
90
+ * @property {{ code: string, docPath: string, message: string }[]} errors -
91
+ * Fail-closed evaluation records (JF2003/JF2004); empty on a clean step.
92
+ */
93
+ /**
94
+ * A compiled jaren-fsm machine.
95
+ * @typedef {Object} CompiledFsm
96
+ * @property {string|null} initial - The document's initial state id.
97
+ * @property {readonly string[]} states - Declared state ids, document order.
98
+ * @property {(state: string) => readonly string[]} events - The unique
99
+ * named events leaving a state (wildcards excluded), document order.
100
+ * @property {(state: string) => boolean} final - Whether a state is final.
101
+ * @property {(state: string, event: string, opts?: { payload?: any, context?: any }) => FsmStepResult} step -
102
+ * The pure step function.
103
+ */
104
+ /**
105
+ * Compile a jaren-fsm document (docs/FLOW-FORMAT.md) into a pure
106
+ * machine. Every guard and effect `with` member compiles once, here;
107
+ * `step` only runs specialized closures.
108
+ *
109
+ * The document contract is a strict superset of the mermaid
110
+ * `jaren-workflow` projection shape: `$fsm` is optional, a plain-string
111
+ * guard is a query (a `$`-path expression — any other non-empty string
112
+ * is a literal and therefore always true), and `guard: null` /
113
+ * `event: null` mean no guard / any event.
114
+ *
115
+ * @param {any} doc - the jaren-fsm document
116
+ * @returns {CompiledFsm}
117
+ * @throws {FlowCompileError} when the document violates the format (JF0xxx)
118
+ * @example
119
+ * const fsm = compileFsm({
120
+ * initial: 'idle',
121
+ * states: ['idle', 'busy'],
122
+ * transitions: [{ from: 'idle', event: 'start', to: 'busy' }],
123
+ * });
124
+ * fsm.step('idle', 'start').state; // 'busy'
125
+ */
126
+ export declare function compileFsm(doc: any): CompiledFsm;
127
+ /**
128
+ * The thin mutable convenience over a compiled machine: it holds the
129
+ * current state and forwards to the pure `step`. Because `step` is
130
+ * pure, `can` IS a dry run — it evaluates guards but changes nothing.
131
+ * @param {CompiledFsm} fsm - a machine from {@link compileFsm}
132
+ * @param {string} [startState] - defaults to the document's `initial`
133
+ * @returns {{ readonly state: string, readonly done: boolean,
134
+ * send: (event: string, opts?: { payload?: any, context?: any }) => FsmStepResult,
135
+ * can: (event: string, opts?: { payload?: any, context?: any }) => boolean }}
136
+ * @throws {FlowRuntimeError} JF2005 when neither a start state nor a
137
+ * document `initial` exists; JF2001 when the start state is undeclared.
138
+ */
139
+ export declare function createFsmSession(fsm: CompiledFsm, startState?: string): {
140
+ readonly state: string;
141
+ readonly done: boolean;
142
+ send: (event: string, opts?: {
143
+ payload?: any;
144
+ context?: any;
145
+ }) => FsmStepResult;
146
+ can: (event: string, opts?: {
147
+ payload?: any;
148
+ context?: any;
149
+ }) => boolean;
150
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @file @jarenjs/flow — executable workflow documents over the
3
+ * @jarenjs/json engines: the jaren-fsm finite-state-machine format
4
+ * compiled to a pure step function. See README.md and
5
+ * docs/FLOW-FORMAT.md.
6
+ */
7
+ export { compileFsm, createFsmSession } from './fsm.js';
8
+ export { fsmToApp, fsmStateSchema } from './app.js';
9
+ export { compileDag } from './dag.js';
10
+ export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
11
+ export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @file FSM persistence (FLOW-FORMAT §7.7): nothing new was needed —
3
+ * `step` is pure and a session's whole run state IS its current state
4
+ * string — so these helpers are deliberately thin. `snapshotFsm`
5
+ * captures a session as JSON, `resumeFsmSession` rebuilds one (the
6
+ * existing JF2001 refusal covers a snapshot naming an undeclared
7
+ * state), and `createDurableFsmSession` persists through a
8
+ * SYNCHRONOUS `{ load, save }` store on every state CHANGE — a store
9
+ * that throws fails the send, never loses a transition silently. An
10
+ * asynchronous store composes its own wrapper; the session contract
11
+ * stays synchronous.
12
+ */
13
+ /**
14
+ * Capture a session's durable state — the whole of it.
15
+ * @param {{ state: string, done: boolean }} session
16
+ * @returns {{ state: string }}
17
+ */
18
+ export declare function snapshotFsm(session: {
19
+ state: string;
20
+ done: boolean;
21
+ }): {
22
+ state: string;
23
+ };
24
+ /**
25
+ * Rebuild a session from a snapshot. An undeclared state refuses with
26
+ * the session's own JF2001.
27
+ * @param {any} fsm - a machine from `compileFsm`
28
+ * @param {{ state: string }} snapshot
29
+ */
30
+ export declare function resumeFsmSession(fsm: any, snapshot: {
31
+ state: string;
32
+ }): {
33
+ readonly state: string;
34
+ readonly done: boolean;
35
+ send: (event: string, opts?: {
36
+ payload?: any;
37
+ context?: any;
38
+ }) => import("./fsm.js").FsmStepResult;
39
+ can: (event: string, opts?: {
40
+ payload?: any;
41
+ context?: any;
42
+ }) => boolean;
43
+ };
44
+ /**
45
+ * A session that persists its state through a synchronous store:
46
+ * `load()` answers the stored state (or null/undefined for a fresh
47
+ * start), `save(state)` records each CHANGED state before the step
48
+ * result is returned.
49
+ * @param {any} fsm - a machine from `compileFsm`
50
+ * @param {{ load: () => string | null | undefined,
51
+ * save: (state: string) => void }} store
52
+ */
53
+ export declare function createDurableFsmSession(fsm: any, store: {
54
+ load: () => string | null | undefined;
55
+ save: (state: string) => void;
56
+ }): Readonly<{
57
+ readonly state: string;
58
+ readonly done: boolean;
59
+ can: (event: any, opts: any) => boolean;
60
+ send(event: any, opts: any): import("./fsm.js").FsmStepResult;
61
+ }>;
@@ -0,0 +1,241 @@
1
+ # Hosting a machine in @jarenjs/app
2
+
3
+ How a jaren-fsm document drives a live `@jarenjs/app` application. The
4
+ shape of the convention is one sentence: **control state is a state
5
+ slice, and the machine's transition table becomes generated standard
6
+ action documents** — plain JSON the app compiles like any hand-written
7
+ action, with no `@jarenjs/flow` code anywhere at runtime and neither
8
+ package importing the other. The test suite executes this document's
9
+ worked example verbatim.
10
+
11
+ The philosophy is the same split FLOW-FORMAT §3 states for the headless
12
+ engine — control state lives in the machine, data state lives in the
13
+ host — mapped onto the app: the control state is one slice member
14
+ (`<pointer>/current`), and the host's app state *is* the machine's
15
+ `context`.
16
+
17
+ ## The API
18
+
19
+ ```js
20
+ import { fsmToApp, fsmStateSchema } from '@jarenjs/flow';
21
+
22
+ const { slice, actions, events } = fsmToApp(fsmDoc, {
23
+ pointer: '/fsm', // where the slice lives in app state (default)
24
+ namespace: 'fsm/', // action-name prefix (default)
25
+ });
26
+ // slice → { current: <initial> } mount it at `pointer`
27
+ // actions → { 'fsm/submit': <query doc> } spread into the app's actions
28
+ // events → ['submit', 'approve', ...] the machine's event vocabulary
29
+ ```
30
+
31
+ `fsmToApp` validates the document exactly like `compileFsm` (same
32
+ JF0xxx errors) and then emits **pure JSON**: one action document per
33
+ distinct named event, each a conditional chain over the machine's
34
+ transitions in document order. `pointer` must be a chain of
35
+ identifier-safe segments (`/ui/wizard` is fine); anything else throws a
36
+ `TypeError` — that is an option mistake, not a document defect.
37
+
38
+ `fsmStateSchema(fsmDoc)` returns the slice's JSON Schema — `current`
39
+ as an enum of the declared state ids — for composing into a
40
+ `validateState` schema (below).
41
+
42
+ ## The scope mapping
43
+
44
+ A guard or effect `with` query is authored against FLOW-FORMAT §3's
45
+ scope. Hosted, each member maps to app vocabulary — the generator
46
+ performs this mapping mechanically, so machine documents run unchanged:
47
+
48
+ | FLOW-FORMAT §3 | hosted meaning |
49
+ |---|---|
50
+ | `$.state` | the slice's `current`, read pre-transition |
51
+ | `$.event` | the event name, baked as a literal per action |
52
+ | `$.payload` | the app's `$payload` (the binding's `with`; `null` when absent) |
53
+ | `$.context` | the **whole app state** `$`, pre-transition |
54
+
55
+ Inside a generated action the scope is bound once to the reserved
56
+ variable `__fsm`; machine guards and effect props MUST NOT bind a
57
+ variable of that name (`fsmToApp` throws a `TypeError` when one does).
58
+
59
+ **Everything evaluates pre-transition**, guards and effect props alike —
60
+ the app computes the entire transition object, effects included, in one
61
+ action evaluation against the current state, and this is exactly the
62
+ headless engine's rule (one step scope, built before the transition
63
+ applies). An effect that wants "the state we just entered" needs no
64
+ state read at all: the generator bakes the target state, the patch
65
+ value and the event name as literals. Effect props that read *data*
66
+ state see pre-transition values; when an async effect's completion must
67
+ be checked against later state, use the id-guard convention of
68
+ [`@jarenjs/app`'s TASKS.md](../../app/docs/TASKS.md).
69
+
70
+ ## The rules that make it exact
71
+
72
+ - **Selection is document order, identically.** Each action's
73
+ conditional chain lists that event's transitions (named matches and
74
+ wildcard fallbacks together) in the transition table's order; `$and`
75
+ stops at the first false and `$if` evaluates only the taken branch,
76
+ so a guard runs exactly when the headless engine would run it.
77
+ - **The vocabulary is closed.** An app can only dispatch registered
78
+ action names, so the hosted machine answers exactly its *named*
79
+ events; wildcard transitions participate as fallbacks inside each
80
+ named event's action. Two consequences, stated honestly: a machine
81
+ with only unlabeled transitions has an empty vocabulary and cannot be
82
+ driven in an app, and dispatching an out-of-vocabulary name is the
83
+ app's ordinary unknown-action error (`JA2001`) — where the headless
84
+ engine would have answered any string event.
85
+ - **An unmatched event is a no-op, not an error.** The chain's missing
86
+ final `$else` yields the empty sequence; the app records a `noop`
87
+ transaction, matching FLOW-FORMAT §4's ignored-event rule.
88
+
89
+ ## Honest divergences
90
+
91
+ The headless engine records evaluation failures and fails closed
92
+ (FLOW-FORMAT §5.2). An app has one failure channel for a whole action,
93
+ so two behaviors differ, deliberately:
94
+
95
+ - **A guard or `with` that throws** (an unbound external, a hostile
96
+ value) fails the entire hosted transaction as `JA2002` — no fallback
97
+ transition fires and no JF2003/JF2004 record exists. Headless, the
98
+ same guard reads false and selection continues. Write total guards;
99
+ the divergence only appears in documents that are already broken.
100
+ - **`errors` records don't exist hosted.** The app's `onError` and
101
+ transaction log are the error channel.
102
+
103
+ One behavior that does NOT diverge: an effect `with` whose query yields
104
+ the empty sequence omits the member in both worlds (the query engine
105
+ drops empty members from object constructors), and the app hands the
106
+ handler `null` for an absent `with` — normalize with `?? null` when
107
+ comparing.
108
+
109
+ ## Fail closed with `validateState`
110
+
111
+ The machine's own guarantee — `current` is always a declared state —
112
+ should be enforced by the host too, so a rogue hand-written action
113
+ cannot corrupt the slice:
114
+
115
+ ```js
116
+ import { JarenValidator } from '@jarenjs/validate';
117
+
118
+ const stateSchema = {
119
+ type: 'object',
120
+ required: ['fsm'],
121
+ properties: { fsm: fsmStateSchema(fsmDoc), reviewer: { type: 'string' } },
122
+ };
123
+ const validate = new JarenValidator().compile(stateSchema);
124
+ createApp(appDoc, { validateState: (s) => validate(s) });
125
+ ```
126
+
127
+ A transition into an out-of-vocabulary `current` is rejected with the
128
+ app's own `JA2005` and the state stays untouched.
129
+
130
+ ## Multiple machines
131
+
132
+ `pointer` and `namespace` make machines composable: a wizard at
133
+ `/wizard` with `wizard/` actions and an upload machine at `/upload`
134
+ with `upload/` actions share one app without touching each other —
135
+ each machine's actions read and write only its own slice, and the
136
+ action-name spaces are disjoint by construction.
137
+
138
+ ## The worked example
139
+
140
+ A document-review machine — one guard reading data state, one entry
141
+ effect, two terminal states. This is the document the test suite hosts
142
+ and drives:
143
+
144
+ ```json
145
+ {
146
+ "$fsm": "0.1",
147
+ "initial": "draft",
148
+ "states": [
149
+ "draft",
150
+ { "id": "in-review",
151
+ "entry": [{ "run": "notify", "with": { "reviewer": "$.context.reviewer", "from": "$.state" } }] },
152
+ { "id": "approved", "final": true },
153
+ { "id": "rejected", "final": true }
154
+ ],
155
+ "transitions": [
156
+ { "from": "draft", "event": "submit", "to": "in-review" },
157
+ { "from": "in-review", "event": "approve", "guard": "$.context.reviewer", "to": "approved" },
158
+ { "from": "in-review", "event": "reject", "to": "rejected",
159
+ "effects": [{ "run": "notify", "with": { "reason": "$.payload.reason" } }] }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ Hosted:
165
+
166
+ ```js
167
+ const { slice, actions } = fsmToApp(reviewDoc);
168
+ const app = createApp({
169
+ state: { fsm: slice }, // no reviewer member yet
170
+ view: [{ match: '$', body: ['main', {}] }],
171
+ actions: {
172
+ ...actions, // fsm/submit, fsm/approve, fsm/reject
173
+ assign: { patch: [{ op: 'add', path: '/reviewer', value: '$payload' }] },
174
+ },
175
+ }, {
176
+ effects: { notify: (props) => log.push(props) },
177
+ validateState: (s) => validate(s),
178
+ });
179
+ ```
180
+
181
+ And driven, each step observable through `app.observe`:
182
+
183
+ 1. `dispatch('fsm/submit')` — `applied`, `current` = `in-review`,
184
+ `changedPaths` = `['/fsm/current']`; `notify` receives
185
+ `{ from: 'draft' }` — `reviewer` is **omitted** because the member
186
+ does not exist yet (empty sequence), and `from` is the
187
+ pre-transition state.
188
+ 2. `dispatch('fsm/approve')` — `noop`: the guard reads no reviewer.
189
+ Nothing moved, nothing ran, no error.
190
+ 3. `dispatch('assign', 'sam')` — the host's own action; data state and
191
+ control state live side by side.
192
+ 4. `dispatch('fsm/approve')` — `applied`, `current` = `approved`, a
193
+ final state.
194
+
195
+ Control state moved only through the machine's generated actions, data
196
+ state only through the host's, and the one guard read across the
197
+ boundary the only way the convention allows: through `context`.
198
+
199
+ ## A dag as one effect
200
+
201
+ A compiled dataflow graph (FLOW-FORMAT §6–§7) needs **no adapter at
202
+ all**: `run(input, { signal })` is already the exact shape
203
+ `createTaskEffect` wants, so a whole graph becomes one ordinary app
204
+ effect — with the task convention's per-slot cancellation and the
205
+ id-guard staleness rule for free (the app's TASKS.md is the normative
206
+ home for that pattern). The test suite runs this recipe:
207
+
208
+ ```js
209
+ import { compileDag } from '@jarenjs/flow';
210
+ import { createApp, createTaskEffect } from '@jarenjs/app';
211
+
212
+ const dag = compileDag(enrichDoc, { tasks: { lookup } });
213
+
214
+ const app = createApp({
215
+ state: { rows: [...], tasks: { enrich: { id: 0, status: 'idle' } }, result: null },
216
+ view,
217
+ actions: {
218
+ start: {
219
+ patch: [
220
+ { op: 'replace', path: '/tasks/enrich/id', value: { $add: ['$.tasks.enrich.id', 1] } },
221
+ { op: 'replace', path: '/tasks/enrich/status', value: 'busy' },
222
+ ],
223
+ effects: [{ run: 'enrich',
224
+ with: { input: '$.rows', id: { $add: ['$.tasks.enrich.id', 1] }, done: 'done' } }],
225
+ },
226
+ done: {
227
+ $if: [{ $eq: ['$payload.id', '$.tasks.enrich.id'] },
228
+ { patch: [
229
+ { op: 'replace', path: '/result', value: '$payload.result' },
230
+ { op: 'replace', path: '/tasks/enrich/status', value: 'idle' } ] }],
231
+ },
232
+ },
233
+ }, {
234
+ effects: { enrich: createTaskEffect((props, signal) => dag.run(props.input, { signal })) },
235
+ });
236
+ ```
237
+
238
+ The division of labor is exact: the dag owns the computation and its
239
+ internal abort fan-out (§7.3), `createTaskEffect` owns slot concurrency
240
+ and cancellation, and the state-side id guard owns staleness —
241
+ out-of-order completions are rejected by construction, never by luck.