@jarenjs/flow 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.js ADDED
@@ -0,0 +1,169 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Error types for @jarenjs/flow, built on `@jarenjs/core`'s coded
4
+ * contract: every failure carries a stable `code` (JF0xxx compile,
5
+ * JF2xxx runtime), a bare `reason`, a composed `message`, and — where
6
+ * one exists — the `docPath` of the offending member of the flow
7
+ * document. The feedback shape a repair loop needs. The normative
8
+ * table lives in docs/FLOW-FORMAT.md §5, proven in sync with
9
+ * `FLOW_CODES` below by a test.
10
+ */
11
+
12
+ import { CodedError } from '@jarenjs/core/errors';
13
+
14
+ /**
15
+ * The runtime code table (the `CSV_CODES` shape): one entry per code
16
+ * this package can raise, proven in sync with FLOW-FORMAT.md §5's
17
+ * normative table by a test — the "must stay in sync by hand" note this
18
+ * file used to carry is now a checked fact.
19
+ */
20
+ export const FLOW_CODES = Object.freeze({
21
+ JF0001: 'the document is not an object, or $fsm is not 0.1',
22
+ JF0002: 'states is not an array, or a state entry is malformed',
23
+ JF0003: 'two state entries share one id',
24
+ JF0004: 'initial is neither null nor a declared state id',
25
+ JF0005: 'transitions is not an array, or an entry is malformed',
26
+ JF0006: 'a transition from/to names no declared state',
27
+ JF0007: 'a guard failed to compile as a query document',
28
+ JF0008: 'an effects list or effect descriptor is malformed',
29
+ JF0009: 'an effect with failed to compile as a query document',
30
+ JF0010: 'the dag document is not an object, or $dag is not 0.1',
31
+ JF0011: 'nodes is not an object, or a node declaration is malformed',
32
+ JF0012: 'edges is not an array, or an edge entry is malformed',
33
+ JF0013: 'an edge from/to names no declared node',
34
+ JF0014: 'an embedded document failed to compile',
35
+ JF0015: 'the wiring rules are violated',
36
+ JF0016: 'the graph has a cycle',
37
+ JF0017: 'the document does not declare exactly one output node',
38
+ JF0018: 'a task node names a handler the registry does not provide',
39
+ JF2001: 'a state id the machine does not declare',
40
+ JF2002: 'step was called with a non-string event',
41
+ JF2003: 'a guard threw while evaluating',
42
+ JF2004: 'an effect with threw while evaluating',
43
+ JF2005: 'a session was created with no start state',
44
+ JF2006: 'a dag node failed while evaluating; the run rejects',
45
+ JF2007: 'the caller signal aborted the run',
46
+ JF2008: 'a declared checkpoint value is not JSON-serializable',
47
+ JF2009: 'the checkpoint store failed',
48
+ });
49
+
50
+ /**
51
+ * A defect in the flow document itself, raised while `compileFsm`
52
+ * compiles it. Codes:
53
+ *
54
+ * - `JF0001` — the document is not an object, or `$fsm` is present and
55
+ * not `'0.1'`
56
+ * - `JF0002` — `states` is not an array, or a state entry is neither a
57
+ * string nor an object with a string `id` (or carries a non-boolean
58
+ * `final`)
59
+ * - `JF0003` — two state entries share one id
60
+ * - `JF0004` — `initial` is neither null nor the id of a declared state
61
+ * - `JF0005` — `transitions` is not an array, or a transition entry is
62
+ * malformed (not an object; `from`/`to` not strings; `event` neither
63
+ * a string nor null)
64
+ * - `JF0006` — a transition's `from` or `to` names no declared state
65
+ * - `JF0007` — a guard failed to compile as a query document (see
66
+ * `cause`)
67
+ * - `JF0008` — an effects list is not an array, or an effect
68
+ * descriptor is not an object with a non-empty string `run`
69
+ * - `JF0009` — an effect's `with` failed to compile as a query
70
+ * document (see `cause`)
71
+ *
72
+ * Dag documents (`compileDag`):
73
+ *
74
+ * - `JF0010` — the dag document is not an object, or `$dag` is not
75
+ * `'0.1'` (the key is required — the dag format has no legacy
76
+ * contract to stay compatible with)
77
+ * - `JF0011` — `nodes` is not an object, or a node declaration is
78
+ * malformed (not an object; unknown `kind`; a kind-specific member
79
+ * missing or mistyped: `const` needs `value`, `query` needs
80
+ * `query`, `jslt` needs `stylesheet`, `task` needs a non-empty
81
+ * string `run`)
82
+ * - `JF0012` — `edges` is not an array, or an edge entry is malformed
83
+ * (not an object; `from`/`to` not strings; `port` present but not a
84
+ * non-empty string)
85
+ * - `JF0013` — an edge's `from` or `to` names no declared node
86
+ * - `JF0014` — an embedded document failed to compile (a node's
87
+ * `query`/`stylesheet`/`with` or an edge's `select`; see `cause`)
88
+ * - `JF0015` — the wiring rules are violated: an edge enters an
89
+ * `input`/`const` node or leaves the `output` node; fan-in without
90
+ * complete unique ports (a ported inbound set must be all-ported
91
+ * and duplicate-free); or a consuming node (`query`/`jslt`/`task`/
92
+ * `output`) has no inbound edge
93
+ * - `JF0016` — the graph has a cycle (the message lists the member
94
+ * ids; `docPath` points at the first edge inside it)
95
+ * - `JF0017` — the document does not declare exactly one `output`
96
+ * node
97
+ * - `JF0018` — a `task` node names a handler the compile-time
98
+ * registry does not provide
99
+ */
100
+ export class FlowCompileError extends CodedError {
101
+ /**
102
+ * @param {string} code
103
+ * @param {string} reason - The bare reason; `message` is composed per
104
+ * the coded contract.
105
+ * @param {string} [docPath] - JSON Pointer into the flow document;
106
+ * `''` is the document root, `undefined` means no location.
107
+ * @param {Error} [cause]
108
+ */
109
+ constructor(code, reason, docPath, cause) {
110
+ super('FlowCompileError', code, reason, docPath,
111
+ cause !== undefined ? { cause } : undefined);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * A failure while a compiled machine is being driven. Only caller
117
+ * mistakes throw; document-level evaluation failures never do — they
118
+ * fail closed and are recorded as plain data on the step result
119
+ * (FLOW-FORMAT §5.2). Codes:
120
+ *
121
+ * - `JF2001` — `step`, `events` or `final` was called with a state id
122
+ * the machine does not declare (thrown; a caller bug, not machine
123
+ * input)
124
+ * - `JF2002` — `step` was called with a non-string event (thrown)
125
+ * - `JF2003` — a guard threw while evaluating (recorded on the step
126
+ * result; the guard reads false and selection continues)
127
+ * - `JF2004` — an effect's `with` threw while evaluating (recorded on
128
+ * the step result; the effect is omitted)
129
+ * - `JF2005` — a session was created with no start state (`initial`
130
+ * is null and none was given) (thrown)
131
+ *
132
+ * Dag runs (`compileDag(...).run`) have no recorded-error channel —
133
+ * a failure rejects the run promise (fail closed, no partial results):
134
+ *
135
+ * - `JF2006` — a node failed while evaluating; the run rejects, the
136
+ * shared signal aborts in-flight siblings, and the error carries
137
+ * the failing node's id as an own `nodeId` property beside
138
+ * `docPath` and `cause`
139
+ * - `JF2007` — the caller's `signal` aborted the run (`cause` is the
140
+ * abort reason when one was given)
141
+ */
142
+ export class FlowRuntimeError extends CodedError {
143
+ /**
144
+ * @param {string} code
145
+ * @param {string} reason - The bare reason; `message` is composed per
146
+ * the coded contract.
147
+ * @param {string} [docPath] - JSON Pointer into the flow document;
148
+ * `''` is the document root, `undefined` means no location.
149
+ * @param {Error} [cause]
150
+ */
151
+ constructor(code, reason, docPath, cause) {
152
+ super('FlowRuntimeError', code, reason, docPath,
153
+ cause !== undefined ? { cause } : undefined);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Normalize a thrown value to an `Error`. A dag run rejects with whatever
159
+ * a registered task threw, and a task is host code free to throw a
160
+ * non-Error, so the rejection contract needs one: a bare value becomes an
161
+ * `Error` naming the type it arrived as. In the fsm engine the only throw
162
+ * sources are the query engine's own error classes, so there it is
163
+ * belt-and-braces rather than hostile-input hardening.
164
+ * @param {unknown} v
165
+ * @returns {Error}
166
+ */
167
+ export function asError(v) {
168
+ return v instanceof Error ? v : new Error(`non-Error thrown (${typeof v})`);
169
+ }
package/src/fsm.js ADDED
@@ -0,0 +1,396 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The jaren-fsm engine: compile a finite-state-machine document
4
+ * (docs/FLOW-FORMAT.md §2) into a PURE step function. Everything is
5
+ * decided once at compile time — guards and effect `with` members
6
+ * become compiled query closures — and the running machine holds no
7
+ * mutable state at all: `step(state, event, opts)` maps its arguments
8
+ * to a transition result, and `createFsmSession` is the thin mutable
9
+ * convenience over it.
10
+ *
11
+ * The engine never executes an effect. A fired transition RETURNS
12
+ * resolved effect descriptors as data (exit → transition → entry); how
13
+ * they run — as @jarenjs/app effects, or in any host loop — is the
14
+ * caller's registry, the same boundary discipline the rest of the
15
+ * suite keeps.
16
+ */
17
+
18
+ import { isJsonObject } from '@jarenjs/core/object';
19
+ import { compileJsonQuery } from '@jarenjs/json/query';
20
+ import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
21
+
22
+ /**
23
+ * A compiled effect: the registered handler name, the compiled `with`
24
+ * query (or null) and the descriptor's docPath for runtime records.
25
+ * @typedef {{ run: string, with: any, docPath: string }} CompiledEffect
26
+ */
27
+
28
+ /**
29
+ * Compile one effects list (`entry`, `exit` or a transition's
30
+ * `effects`). Absent means none; anything else must be an array of
31
+ * `{ run, with? }` descriptors.
32
+ * @param {any} list
33
+ * @param {string} docPath - JSON Pointer of the list member.
34
+ * @returns {CompiledEffect[]}
35
+ */
36
+ function compileEffectList(list, docPath) {
37
+ if (list === undefined) return [];
38
+ if (!Array.isArray(list)) {
39
+ throw new FlowCompileError('JF0008',
40
+ 'an effects list must be an array of { run, with? } descriptors', docPath);
41
+ }
42
+ return list.map((effect, i) => {
43
+ const path = `${docPath}/${i}`;
44
+ if (!isJsonObject(effect) || typeof effect.run !== 'string' || effect.run === '') {
45
+ throw new FlowCompileError('JF0008',
46
+ 'an effect descriptor must be an object with a non-empty string "run"', path);
47
+ }
48
+ let withQuery = null;
49
+ if (effect.with !== undefined) {
50
+ try {
51
+ withQuery = compileJsonQuery(effect.with);
52
+ }
53
+ catch (err) {
54
+ const cause = asError(err);
55
+ throw new FlowCompileError('JF0009',
56
+ `effect '${effect.run}' has a "with" that failed to compile: ${cause.message}`,
57
+ `${path}/with`, cause);
58
+ }
59
+ }
60
+ return { run: effect.run, with: withQuery, docPath: path };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Resolve one compiled effect against the evaluation scope. An empty
66
+ * query result omits the `with` member; a throwing `with` fails closed —
67
+ * the effect is omitted and the failure recorded (JF2004).
68
+ * @param {CompiledEffect} effect
69
+ * @param {any} scope
70
+ * @param {any[]} out - resolved descriptors, appended to
71
+ * @param {any[]} errors - step-result error records, appended to
72
+ */
73
+ function resolveEffect(effect, scope, out, errors) {
74
+ if (effect.with === null) {
75
+ out.push({ run: effect.run });
76
+ return;
77
+ }
78
+ let value;
79
+ try {
80
+ value = effect.with(scope);
81
+ }
82
+ catch (err) {
83
+ errors.push({
84
+ code: 'JF2004',
85
+ docPath: `${effect.docPath}/with`,
86
+ message: `effect '${effect.run}' was omitted: its "with" threw while evaluating: ${asError(err).message}`,
87
+ });
88
+ return;
89
+ }
90
+ out.push(value === undefined ? { run: effect.run } : { run: effect.run, with: value });
91
+ }
92
+
93
+ /**
94
+ * The result of one pure step.
95
+ * @typedef {Object} FsmStepResult
96
+ * @property {boolean} changed - A transition fired. A self-transition
97
+ * reports true with an unchanged `state`; inspect `state` to tell the
98
+ * two apart.
99
+ * @property {string} state - The resulting state id.
100
+ * @property {any[]} effects - Resolved `{ run, with? }` descriptors in
101
+ * firing order: exit → transition → entry (exit/entry only when the
102
+ * state actually changed).
103
+ * @property {boolean} final - Whether the resulting state is final.
104
+ * @property {{ code: string, docPath: string, message: string }[]} errors -
105
+ * Fail-closed evaluation records (JF2003/JF2004); empty on a clean step.
106
+ */
107
+
108
+ /**
109
+ * A compiled jaren-fsm machine.
110
+ * @typedef {Object} CompiledFsm
111
+ * @property {string|null} initial - The document's initial state id.
112
+ * @property {readonly string[]} states - Declared state ids, document order.
113
+ * @property {(state: string) => readonly string[]} events - The unique
114
+ * named events leaving a state (wildcards excluded), document order.
115
+ * @property {(state: string) => boolean} final - Whether a state is final.
116
+ * @property {(state: string, event: string, opts?: { payload?: any, context?: any }) => FsmStepResult} step -
117
+ * The pure step function.
118
+ */
119
+
120
+ /**
121
+ * Compile a jaren-fsm document (docs/FLOW-FORMAT.md) into a pure
122
+ * machine. Every guard and effect `with` member compiles once, here;
123
+ * `step` only runs specialized closures.
124
+ *
125
+ * The document contract is a strict superset of the mermaid
126
+ * `jaren-workflow` projection shape: `$fsm` is optional, a plain-string
127
+ * guard is a query (a `$`-path expression — any other non-empty string
128
+ * is a literal and therefore always true), and `guard: null` /
129
+ * `event: null` mean no guard / any event.
130
+ *
131
+ * @param {any} doc - the jaren-fsm document
132
+ * @returns {CompiledFsm}
133
+ * @throws {FlowCompileError} when the document violates the format (JF0xxx)
134
+ * @example
135
+ * const fsm = compileFsm({
136
+ * initial: 'idle',
137
+ * states: ['idle', 'busy'],
138
+ * transitions: [{ from: 'idle', event: 'start', to: 'busy' }],
139
+ * });
140
+ * fsm.step('idle', 'start').state; // 'busy'
141
+ */
142
+ export function compileFsm(doc) {
143
+ if (!isJsonObject(doc)) {
144
+ throw new FlowCompileError('JF0001', 'the fsm document must be an object', '');
145
+ }
146
+ if (doc.$fsm !== undefined && doc.$fsm !== '0.1') {
147
+ throw new FlowCompileError('JF0001',
148
+ `unknown fsm format version ${JSON.stringify(doc.$fsm)} (this engine speaks '0.1')`,
149
+ '/$fsm');
150
+ }
151
+
152
+ // states: string shorthand or { id, entry?, exit?, final? }
153
+ if (!Array.isArray(doc.states)) {
154
+ throw new FlowCompileError('JF0002',
155
+ 'the "states" member must be an array of state declarations', '/states');
156
+ }
157
+ /** @type {Map<string, { entry: CompiledEffect[], exit: CompiledEffect[], final: boolean }>} */
158
+ const stateMap = new Map();
159
+ /** @type {string[]} */
160
+ const stateOrder = [];
161
+ for (let i = 0; i < doc.states.length; i++) {
162
+ const entry = doc.states[i];
163
+ const base = `/states/${i}`;
164
+ let id;
165
+ let meta = { entry: /** @type {CompiledEffect[]} */ ([]), exit: /** @type {CompiledEffect[]} */ ([]), final: false };
166
+ if (typeof entry === 'string') {
167
+ id = entry;
168
+ }
169
+ else if (isJsonObject(entry) && typeof entry.id === 'string') {
170
+ id = entry.id;
171
+ if (entry.final !== undefined && typeof entry.final !== 'boolean') {
172
+ throw new FlowCompileError('JF0002',
173
+ `state '${id}' has a non-boolean "final"`, `${base}/final`);
174
+ }
175
+ meta = {
176
+ entry: compileEffectList(entry.entry, `${base}/entry`),
177
+ exit: compileEffectList(entry.exit, `${base}/exit`),
178
+ final: entry.final === true,
179
+ };
180
+ }
181
+ else {
182
+ throw new FlowCompileError('JF0002',
183
+ `state ${i} must be a string id or an object with a string "id"`, base);
184
+ }
185
+ if (stateMap.has(id)) {
186
+ throw new FlowCompileError('JF0003',
187
+ `duplicate state id '${id}'`, base);
188
+ }
189
+ stateMap.set(id, meta);
190
+ stateOrder.push(id);
191
+ }
192
+
193
+ // initial: null, or a declared state id
194
+ const initial = doc.initial;
195
+ if (initial !== null && typeof initial !== 'string') {
196
+ throw new FlowCompileError('JF0004',
197
+ 'the "initial" member must be a state id or null', '/initial');
198
+ }
199
+ if (initial !== null && !stateMap.has(initial)) {
200
+ throw new FlowCompileError('JF0004',
201
+ `the initial state '${initial}' is not declared in "states"`, '/initial');
202
+ }
203
+
204
+ // transitions: document order is the selection order (§4)
205
+ if (!Array.isArray(doc.transitions)) {
206
+ throw new FlowCompileError('JF0005',
207
+ 'the "transitions" member must be an array of transition entries', '/transitions');
208
+ }
209
+ /** @type {Map<string, any[]>} */
210
+ const byFrom = new Map();
211
+ for (let i = 0; i < doc.transitions.length; i++) {
212
+ const t = doc.transitions[i];
213
+ const base = `/transitions/${i}`;
214
+ if (!isJsonObject(t)) {
215
+ throw new FlowCompileError('JF0005',
216
+ `transition ${i} must be an object`, base);
217
+ }
218
+ if (typeof t.from !== 'string') {
219
+ throw new FlowCompileError('JF0005',
220
+ `transition ${i} must carry a string "from"`, `${base}/from`);
221
+ }
222
+ if (typeof t.to !== 'string') {
223
+ throw new FlowCompileError('JF0005',
224
+ `transition ${i} must carry a string "to"`, `${base}/to`);
225
+ }
226
+ if (t.event !== undefined && t.event !== null && typeof t.event !== 'string') {
227
+ throw new FlowCompileError('JF0005',
228
+ `transition ${i} has an "event" that is neither a string nor null`, `${base}/event`);
229
+ }
230
+ if (!stateMap.has(t.from)) {
231
+ throw new FlowCompileError('JF0006',
232
+ `transition ${i} leaves the undeclared state '${t.from}'`, `${base}/from`);
233
+ }
234
+ if (!stateMap.has(t.to)) {
235
+ throw new FlowCompileError('JF0006',
236
+ `transition ${i} enters the undeclared state '${t.to}'`, `${base}/to`);
237
+ }
238
+ let guard = null;
239
+ if (t.guard !== undefined && t.guard !== null) {
240
+ try {
241
+ guard = compileJsonQuery(t.guard);
242
+ }
243
+ catch (err) {
244
+ const cause = asError(err);
245
+ throw new FlowCompileError('JF0007',
246
+ `transition ${i} has a guard that failed to compile: ${cause.message}`,
247
+ `${base}/guard`, cause);
248
+ }
249
+ }
250
+ const compiled = {
251
+ event: t.event === undefined || t.event === null ? null : t.event,
252
+ to: t.to,
253
+ guard,
254
+ guardPath: `${base}/guard`,
255
+ effects: compileEffectList(t.effects, `${base}/effects`),
256
+ };
257
+ const list = byFrom.get(t.from);
258
+ if (list === undefined) byFrom.set(t.from, [compiled]);
259
+ else list.push(compiled);
260
+ }
261
+
262
+ // per-state introspection: unique named events, document order
263
+ /** @type {Map<string, readonly string[]>} */
264
+ const eventsByState = new Map();
265
+ for (const id of stateOrder) {
266
+ const names = [];
267
+ for (const t of byFrom.get(id) ?? []) {
268
+ if (t.event !== null && !names.includes(t.event)) names.push(t.event);
269
+ }
270
+ eventsByState.set(id, Object.freeze(names));
271
+ }
272
+
273
+ /**
274
+ * @param {string} state
275
+ * @param {string} member
276
+ */
277
+ function assertKnown(state, member) {
278
+ if (!stateMap.has(state)) {
279
+ throw new FlowRuntimeError('JF2001',
280
+ `${member} was called with the undeclared state ${JSON.stringify(state)}`);
281
+ }
282
+ }
283
+
284
+ /** @type {CompiledFsm['step']} */
285
+ function step(state, event, opts) {
286
+ assertKnown(state, 'step');
287
+ if (typeof event !== 'string') {
288
+ throw new FlowRuntimeError('JF2002',
289
+ `step was called with a non-string event (${typeof event})`);
290
+ }
291
+ const scope = {
292
+ state,
293
+ event,
294
+ payload: opts?.payload ?? null,
295
+ context: opts?.context ?? null,
296
+ };
297
+ /** @type {FsmStepResult['errors']} */
298
+ const errors = [];
299
+ for (const t of byFrom.get(state) ?? []) {
300
+ if (t.event !== null && t.event !== event) continue;
301
+ if (t.guard !== null) {
302
+ let pass = false;
303
+ try {
304
+ pass = t.guard.ebv(scope);
305
+ }
306
+ catch (err) {
307
+ errors.push({
308
+ code: 'JF2003',
309
+ docPath: t.guardPath,
310
+ message: `the guard threw while evaluating and reads false: ${asError(err).message}`,
311
+ });
312
+ }
313
+ if (!pass) continue;
314
+ }
315
+ const effects = [];
316
+ const moved = state !== t.to;
317
+ if (moved) {
318
+ for (const e of /** @type {any} */ (stateMap.get(state)).exit) {
319
+ resolveEffect(e, scope, effects, errors);
320
+ }
321
+ }
322
+ for (const e of t.effects) resolveEffect(e, scope, effects, errors);
323
+ if (moved) {
324
+ for (const e of /** @type {any} */ (stateMap.get(t.to)).entry) {
325
+ resolveEffect(e, scope, effects, errors);
326
+ }
327
+ }
328
+ return {
329
+ changed: true,
330
+ state: t.to,
331
+ effects,
332
+ final: /** @type {any} */ (stateMap.get(t.to)).final,
333
+ errors,
334
+ };
335
+ }
336
+ return {
337
+ changed: false,
338
+ state,
339
+ effects: [],
340
+ final: /** @type {any} */ (stateMap.get(state)).final,
341
+ errors,
342
+ };
343
+ }
344
+
345
+ return Object.freeze({
346
+ initial: initial ?? null,
347
+ states: Object.freeze(stateOrder.slice()),
348
+ /** @type {CompiledFsm['events']} */
349
+ events(state) {
350
+ assertKnown(state, 'events');
351
+ return /** @type {readonly string[]} */ (eventsByState.get(state));
352
+ },
353
+ /** @type {CompiledFsm['final']} */
354
+ final(state) {
355
+ assertKnown(state, 'final');
356
+ return /** @type {any} */ (stateMap.get(state)).final;
357
+ },
358
+ step,
359
+ });
360
+ }
361
+
362
+ /**
363
+ * The thin mutable convenience over a compiled machine: it holds the
364
+ * current state and forwards to the pure `step`. Because `step` is
365
+ * pure, `can` IS a dry run — it evaluates guards but changes nothing.
366
+ * @param {CompiledFsm} fsm - a machine from {@link compileFsm}
367
+ * @param {string} [startState] - defaults to the document's `initial`
368
+ * @returns {{ readonly state: string, readonly done: boolean,
369
+ * send: (event: string, opts?: { payload?: any, context?: any }) => FsmStepResult,
370
+ * can: (event: string, opts?: { payload?: any, context?: any }) => boolean }}
371
+ * @throws {FlowRuntimeError} JF2005 when neither a start state nor a
372
+ * document `initial` exists; JF2001 when the start state is undeclared.
373
+ */
374
+ export function createFsmSession(fsm, startState) {
375
+ let current = startState ?? fsm.initial;
376
+ if (current === null || current === undefined) {
377
+ throw new FlowRuntimeError('JF2005',
378
+ 'the session has no start state: the document\'s "initial" is null and none was given');
379
+ }
380
+ if (!fsm.states.includes(current)) {
381
+ throw new FlowRuntimeError('JF2001',
382
+ `the session start state ${JSON.stringify(current)} is not declared`);
383
+ }
384
+ return {
385
+ get state() { return /** @type {string} */ (current); },
386
+ get done() { return fsm.final(/** @type {string} */ (current)); },
387
+ send(event, opts) {
388
+ const result = fsm.step(/** @type {string} */ (current), event, opts);
389
+ current = result.state;
390
+ return result;
391
+ },
392
+ can(event, opts) {
393
+ return fsm.step(/** @type {string} */ (current), event, opts).changed;
394
+ },
395
+ };
396
+ }
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ //@ts-check
2
+ /**
3
+ * @file @jarenjs/flow — executable workflow documents over the
4
+ * @jarenjs/json engines: the jaren-fsm finite-state-machine format
5
+ * compiled to a pure step function. See README.md and
6
+ * docs/FLOW-FORMAT.md.
7
+ */
8
+
9
+ export { compileFsm, createFsmSession } from './fsm.js';
10
+ export { fsmToApp, fsmStateSchema } from './app.js';
11
+ export { compileDag } from './dag.js';
12
+ export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
13
+ export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
package/src/persist.js ADDED
@@ -0,0 +1,65 @@
1
+ //@ts-check
2
+ /**
3
+ * @file FSM persistence (FLOW-FORMAT §7.7): nothing new was needed —
4
+ * `step` is pure and a session's whole run state IS its current state
5
+ * string — so these helpers are deliberately thin. `snapshotFsm`
6
+ * captures a session as JSON, `resumeFsmSession` rebuilds one (the
7
+ * existing JF2001 refusal covers a snapshot naming an undeclared
8
+ * state), and `createDurableFsmSession` persists through a
9
+ * SYNCHRONOUS `{ load, save }` store on every state CHANGE — a store
10
+ * that throws fails the send, never loses a transition silently. An
11
+ * asynchronous store composes its own wrapper; the session contract
12
+ * stays synchronous.
13
+ */
14
+
15
+ import { createFsmSession } from './fsm.js';
16
+
17
+ /**
18
+ * Capture a session's durable state — the whole of it.
19
+ * @param {{ state: string, done: boolean }} session
20
+ * @returns {{ state: string }}
21
+ */
22
+ export function snapshotFsm(session) {
23
+ return { state: session.state };
24
+ }
25
+
26
+ /**
27
+ * Rebuild a session from a snapshot. An undeclared state refuses with
28
+ * the session's own JF2001.
29
+ * @param {any} fsm - a machine from `compileFsm`
30
+ * @param {{ state: string }} snapshot
31
+ */
32
+ export function resumeFsmSession(fsm, snapshot) {
33
+ if (snapshot === null || typeof snapshot !== 'object'
34
+ || typeof snapshot.state !== 'string') {
35
+ throw new TypeError('resumeFsmSession: the snapshot must carry a string "state"');
36
+ }
37
+ return createFsmSession(fsm, snapshot.state);
38
+ }
39
+
40
+ /**
41
+ * A session that persists its state through a synchronous store:
42
+ * `load()` answers the stored state (or null/undefined for a fresh
43
+ * start), `save(state)` records each CHANGED state before the step
44
+ * result is returned.
45
+ * @param {any} fsm - a machine from `compileFsm`
46
+ * @param {{ load: () => string | null | undefined,
47
+ * save: (state: string) => void }} store
48
+ */
49
+ export function createDurableFsmSession(fsm, store) {
50
+ if (typeof store?.load !== 'function' || typeof store.save !== 'function') {
51
+ throw new TypeError('createDurableFsmSession: the store must provide load and save');
52
+ }
53
+ const stored = store.load();
54
+ const session = createFsmSession(fsm, stored ?? undefined);
55
+ return Object.freeze({
56
+ get state() { return session.state; },
57
+ get done() { return session.done; },
58
+ can: (event, opts) => session.can(event, opts),
59
+ send(event, opts) {
60
+ const result = session.send(event, opts);
61
+ if (result.changed) store.save(result.state);
62
+ return result;
63
+ },
64
+ });
65
+ }