@jarenjs/flow 0.72.3 → 0.75.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,420 @@
1
+ //@ts-check
2
+ /** Pure, explicit-time statecharts. See docs/STATECHART-FORMAT.md. */
3
+ import { deepFreeze, isJsonObject } from '@jarenjs/core/object';
4
+ import { compileJsonQuery } from '@jarenjs/json/query';
5
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
6
+ import { compileEffectList, resolveEffect } from './fsm.js';
7
+ import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
8
+
9
+ /** @typedef {Readonly<{ active: readonly string[], history: Readonly<Record<string, readonly string[]>>,
10
+ * timers: readonly Readonly<{ transition: number, at: number, token: number }>[], serial: number, time: number }>} StatechartState */
11
+ /** @typedef {{ now?: number, payload?: any, context?: any, one?: boolean }} StatechartOptions */
12
+ /** @typedef {{ changed: boolean, state: StatechartState, final: boolean,
13
+ * effects: any[], errors: {code: string, docPath: string, message: string}[],
14
+ * entered: string[], exited: string[], transitions: number[] }} StatechartResult */
15
+ /** @typedef {Object} CompiledStatechart
16
+ * @property {readonly string[]} states
17
+ * @property {(opts?: StatechartOptions) => StatechartResult} start
18
+ * @property {(state: StatechartState, event: string, opts?: StatechartOptions) => StatechartResult} step
19
+ * @property {(state: StatechartState, now: number, opts?: StatechartOptions) => StatechartResult} advance
20
+ * @property {(state: StatechartState) => boolean} final
21
+ * @property {(state: StatechartState) => readonly string[]} events
22
+ * @property {(state: StatechartState) => StatechartState} restore
23
+ */
24
+
25
+ const defect = (reason, path) => { throw new FlowCompileError('JF0020', reason, path); };
26
+ const invalid = (reason) => { throw new FlowRuntimeError('JF2010', reason); };
27
+ const finiteTime = (value) => typeof value === 'number' && Number.isFinite(value);
28
+
29
+ /** Compile jaren-fsm 0.2 without creating a clock, timer or effect handler.
30
+ * @param {any} doc
31
+ * @param {{ maxMicrosteps?: number }} [options]
32
+ * @returns {CompiledStatechart}
33
+ */
34
+ export function compileStatechart(doc, options = {}) {
35
+ const maxMicrosteps = options.maxMicrosteps ?? 1000;
36
+ if (!Number.isSafeInteger(maxMicrosteps) || maxMicrosteps < 1)
37
+ throw new TypeError('maxMicrosteps must be a positive safe integer');
38
+ if (!isJsonObject(doc) || doc.$fsm !== '0.2')
39
+ defect('a statechart requires "$fsm": "0.2"', '/$fsm');
40
+ for (const key of Object.keys(doc)) if (!['$fsm', 'initial', 'states', 'transitions'].includes(key))
41
+ defect('unknown statechart member', `/${key}`);
42
+ if (!Array.isArray(doc.states) || doc.states.length === 0)
43
+ defect('states must be a non-empty array', '/states');
44
+ const nodes = new Map();
45
+ const roots = [];
46
+ for (const [index, decl] of doc.states.entries()) {
47
+ const s = typeof decl === 'string' ? { id: decl } : decl;
48
+ const path = `/states/${index}`;
49
+ if (!isJsonObject(s) || typeof s.id !== 'string' || !s.id)
50
+ defect('a state needs a non-empty string id', path);
51
+ for (const key of Object.keys(s)) if (!['id', 'parent', 'initial', 'type', 'history', 'final', 'entry', 'exit'].includes(key))
52
+ defect('unknown state member', `${path}/${key}`);
53
+ if (nodes.has(s.id)) defect(`duplicate state '${s.id}'`, path);
54
+ if (s.parent !== undefined && (typeof s.parent !== 'string' || !s.parent))
55
+ defect('parent must be a state id', `${path}/parent`);
56
+ if (s.initial !== undefined && typeof s.initial !== 'string')
57
+ defect('initial must be a state id', `${path}/initial`);
58
+ if (s.type !== undefined && !['atomic', 'compound', 'parallel', 'final', 'history'].includes(s.type))
59
+ defect('unknown state type', `${path}/type`);
60
+ if (s.history !== undefined && !['shallow', 'deep'].includes(s.history))
61
+ defect('history must be shallow or deep', `${path}/history`);
62
+ if (s.final !== undefined && typeof s.final !== 'boolean')
63
+ defect('final must be boolean', `${path}/final`);
64
+ const type = s.type ?? (s.history ? 'history' : s.final ? 'final' : null);
65
+ if ((s.final === true && type !== 'final') || (s.history && type !== 'history'))
66
+ defect('conflicting state types', path);
67
+ nodes.set(s.id, { ...s, type, parent: s.parent ?? null, path, index, children: [],
68
+ entry: compileEffectList(s.entry, `${path}/entry`),
69
+ exit: compileEffectList(s.exit, `${path}/exit`) });
70
+ }
71
+ for (const s of nodes.values()) {
72
+ if (s.parent === null) roots.push(s.id);
73
+ else {
74
+ if (!nodes.has(s.parent)) defect('parent is undeclared', `${s.path}/parent`);
75
+ nodes.get(s.parent).children.push(s.id);
76
+ }
77
+ const seen = new Set([s.id]);
78
+ let parent = s.parent;
79
+ while (parent !== null && nodes.has(parent)) {
80
+ if (seen.has(parent)) defect('state hierarchy has a cycle', `${s.path}/parent`);
81
+ seen.add(parent); parent = nodes.get(parent).parent;
82
+ }
83
+ }
84
+ const children = (id) => nodes.get(id).children.filter((child) => nodes.get(child).type !== 'history');
85
+ for (const s of nodes.values()) {
86
+ const ordinary = children(s.id);
87
+ s.type ??= ordinary.length ? 'compound' : 'atomic';
88
+ if (['atomic', 'final', 'history'].includes(s.type) && s.children.length)
89
+ defect(`${s.type} states cannot have children`, s.path);
90
+ if (s.type === 'compound' && (!ordinary.length || !ordinary.includes(s.initial)))
91
+ defect('a compound state needs an initial naming an immediate ordinary child', `${s.path}/initial`);
92
+ if (s.type === 'parallel' && !ordinary.length) defect('a parallel state needs regions', s.path);
93
+ if (s.type !== 'compound' && s.initial !== undefined)
94
+ defect('only compound states have initial', `${s.path}/initial`);
95
+ if (s.type === 'history') {
96
+ if (s.parent === null || !s.history || s.entry.length || s.exit.length)
97
+ defect('history needs a parent and a mode, and cannot carry effects', s.path);
98
+ }
99
+ }
100
+ if (typeof doc.initial !== 'string' || !roots.includes(doc.initial)
101
+ || nodes.get(doc.initial).type === 'history')
102
+ defect('initial must name an ordinary root state', '/initial');
103
+ const initial = doc.initial;
104
+ // Tree order is independent of where a flattened child was declared.
105
+ const order = [];
106
+ const visit = (id) => { order.push(id); for (const c of nodes.get(id).children) visit(c); };
107
+ roots.forEach(visit);
108
+ const rank = new Map(order.map((id, i) => [id, i]));
109
+ const ordered = (ids) => [...ids].sort((a, b) => rank.get(a) - rank.get(b));
110
+ const ancestors = (id) => {
111
+ const out = [];
112
+ for (let p = nodes.get(id).parent; p !== null; p = nodes.get(p).parent) out.push(p);
113
+ return out;
114
+ };
115
+ const below = (id, parent) => parent === null || ancestors(id).includes(parent);
116
+ const within = (id, parent) => id === parent || below(id, parent);
117
+ const closure = (leaves) => new Set(leaves.flatMap((id) => [id, ...ancestors(id)]));
118
+
119
+ if (!Array.isArray(doc.transitions)) defect('transitions must be an array', '/transitions');
120
+ const byFrom = new Map(order.map((id) => [id, []]));
121
+ const transitions = doc.transitions.map((t, index) => {
122
+ const path = `/transitions/${index}`;
123
+ if (!isJsonObject(t) || !nodes.has(t.from) || !nodes.has(t.to))
124
+ defect('transition from/to must name declared states', path);
125
+ for (const key of Object.keys(t)) if (!['from', 'to', 'event', 'after', 'done', 'always', 'type', 'guard', 'effects'].includes(key))
126
+ defect('unknown transition member', `${path}/${key}`);
127
+ if (['history', 'final'].includes(nodes.get(t.from).type))
128
+ defect('history and final states cannot have outgoing transitions', `${path}/from`);
129
+ if (t.event !== undefined && t.event !== null && typeof t.event !== 'string')
130
+ defect('event must be a string or null', `${path}/event`);
131
+ if (t.after !== undefined && (!finiteTime(t.after) || t.after < 0))
132
+ defect('after must be a finite nonnegative millisecond duration', `${path}/after`);
133
+ if (t.done !== undefined && t.done !== true) defect('done must be true when present', `${path}/done`);
134
+ if (t.always !== undefined && t.always !== true) defect('always must be true when present', `${path}/always`);
135
+ const triggers = ['event', 'after', 'done', 'always'].filter((key) => t[key] !== undefined);
136
+ if (triggers.length > 1) defect('a transition has exactly one trigger', path);
137
+ if (t.done && !['compound', 'parallel'].includes(nodes.get(t.from).type))
138
+ defect('done requires a compound or parallel source', `${path}/done`);
139
+ const type = t.type ?? 'external';
140
+ if (!['internal', 'external'].includes(type)
141
+ || (type === 'internal' && !within(t.to, t.from)))
142
+ defect('internal transitions must target their source or a descendant', `${path}/type`);
143
+ let guard = null;
144
+ if (t.guard !== undefined && t.guard !== null) {
145
+ try { guard = compileJsonQuery(t.guard); }
146
+ catch (err) { throw new FlowCompileError('JF0007', 'statechart guard failed to compile', `${path}/guard`, asError(err)); }
147
+ }
148
+ // A history pseudo-state is a child of its owner. Using the owner as
149
+ // the target here would wrongly exit it on a sibling-to-history jump,
150
+ // overwriting the remembered configuration before it could be restored.
151
+ const target = t.to;
152
+ const domain = type === 'internal' ? t.from
153
+ : ancestors(t.from).find((id) => nodes.get(id).type !== 'parallel' && below(target, id)) ?? null;
154
+ const compiled = { ...t, type, domain, index, path, guard,
155
+ effects: compileEffectList(t.effects, `${path}/effects`) };
156
+ byFrom.get(t.from).push(compiled);
157
+ return compiled;
158
+ });
159
+
160
+ // Add an explicit target, then fill missing initial children/regions. A
161
+ // history target expands to ordinary targets before completing the tree.
162
+ function targetInto(id, active, history) {
163
+ const s = nodes.get(id);
164
+ if (s.type === 'history') {
165
+ const remembered = Object.hasOwn(history, id) ? history[id] : null;
166
+ if (remembered) for (const r of remembered) targetInto(r, active, history);
167
+ else targetInto(s.parent, active, history);
168
+ return;
169
+ }
170
+ active.add(id);
171
+ ancestors(id).forEach((p) => active.add(p));
172
+ }
173
+ function fill(active) {
174
+ for (const id of order) {
175
+ if (!active.has(id)) continue;
176
+ const s = nodes.get(id);
177
+ if (s.type === 'parallel') children(id).forEach((c) => active.add(c));
178
+ else if (s.type === 'compound' && !children(id).some((c) => active.has(c))) active.add(s.initial);
179
+ }
180
+ return ordered([...active].filter((id) => ['atomic', 'final'].includes(nodes.get(id).type)));
181
+ }
182
+ function legal(leaves, root = null) {
183
+ if (!Array.isArray(leaves) || !leaves.length || new Set(leaves).size !== leaves.length
184
+ || leaves.some((id) => !nodes.has(id) || !['atomic', 'final'].includes(nodes.get(id).type)
185
+ || (root !== null && !below(id, root)))) return false;
186
+ const active = closure(leaves);
187
+ if (root === null && roots.filter((id) => active.has(id)).length !== 1) return false;
188
+ for (const id of active) {
189
+ if (root !== null && !within(id, root)) continue;
190
+ const s = nodes.get(id);
191
+ const count = children(id).filter((c) => active.has(c)).length;
192
+ if ((s.type === 'compound' && count !== 1)
193
+ || (s.type === 'parallel' && count !== children(id).length)) return false;
194
+ }
195
+ return true;
196
+ }
197
+ /** @type {CompiledStatechart['restore']} */
198
+ function restore(state) {
199
+ if (!isJsonObject(state) || !legal(state.active) || !isJsonObject(state.history)
200
+ || !Array.isArray(state.timers) || !Number.isSafeInteger(state.serial) || state.serial < 0
201
+ || !finiteTime(state.time)) invalid('invalid statechart snapshot');
202
+ const active = closure(state.active);
203
+ for (const [id, remembered] of Object.entries(state.history)) {
204
+ const s = nodes.get(id);
205
+ if (!s || s.type !== 'history' || !Array.isArray(remembered) || !remembered.length
206
+ || new Set(remembered).size !== remembered.length) invalid('invalid history record');
207
+ if (s.history === 'deep') {
208
+ if (!legal(remembered, s.parent)) invalid('illegal deep history configuration');
209
+ }
210
+ else if (remembered.some((c) => !children(s.parent).includes(c))
211
+ || remembered.length !== (nodes.get(s.parent).type === 'parallel' ? children(s.parent).length : 1))
212
+ invalid('illegal shallow history configuration');
213
+ }
214
+ const seen = new Set();
215
+ const tokens = new Set();
216
+ for (const timer of state.timers) {
217
+ const t = transitions[timer?.transition];
218
+ if (!t || t.after === undefined || !Number.isSafeInteger(timer.transition)
219
+ || !active.has(t.from) || !finiteTime(timer.at)
220
+ || !Number.isSafeInteger(timer.token) || timer.token <= 0 || timer.token > state.serial
221
+ || seen.has(timer.transition) || tokens.has(timer.token)) invalid('invalid timer record');
222
+ seen.add(timer.transition); tokens.add(timer.token);
223
+ }
224
+ const normalized = JSON.parse(canonicalizeJson(state));
225
+ normalized.active = ordered(normalized.active);
226
+ for (const id of Object.keys(normalized.history)) normalized.history[id] = ordered(normalized.history[id]);
227
+ normalized.timers.sort((a, b) => a.at - b.at || a.transition - b.transition);
228
+ return deepFreeze(normalized);
229
+ }
230
+ function complete(id, active) {
231
+ if (!active.has(id)) return false;
232
+ const s = nodes.get(id);
233
+ if (s.type === 'final') return true;
234
+ if (s.type === 'compound') return children(id).some((c) => active.has(c) && nodes.get(c).type === 'final');
235
+ if (s.type === 'parallel') return children(id).every((c) => complete(c, active));
236
+ return false;
237
+ }
238
+ const final = (state) => roots.some((id) => complete(id, closure(state.active)));
239
+ function timeAt(state, now) {
240
+ const at = now ?? state?.time ?? 0;
241
+ if (!finiteTime(at) || (state && at < state.time))
242
+ throw new FlowRuntimeError('JF2011', 'time must be finite and cannot move backwards');
243
+ return at;
244
+ }
245
+ function result(state) {
246
+ return { changed: false, state, final: final(state), effects: [], errors: [], entered: [], exited: [], transitions: [] };
247
+ }
248
+ function effects(list, scope, out) {
249
+ for (const e of list) resolveEffect(e, scope, out.effects, out.errors);
250
+ }
251
+ function schedule(state, entered, now) {
252
+ for (const id of entered) for (const t of byFrom.get(id)) {
253
+ if (t.after === undefined) continue;
254
+ const at = now + t.after;
255
+ if (!finiteTime(at) || !Number.isSafeInteger(state.serial + 1))
256
+ throw new FlowRuntimeError('JF2011', 'timer deadline or token exceeds its numeric range');
257
+ state.timers.push({ transition: t.index, at, token: ++state.serial });
258
+ }
259
+ state.timers.sort((a, b) => a.at - b.at || a.transition - b.transition);
260
+ }
261
+ function scopeFor(state, event, opts) {
262
+ return { state: state.active, event, payload: opts?.payload ?? null, context: opts?.context ?? null };
263
+ }
264
+ function select(state, trigger, scope, out) {
265
+ const active = closure(state.active);
266
+ const evaluated = new Map();
267
+ const enabled = new Set();
268
+ const matches = (t) => {
269
+ if (trigger.kind === 'timer') return t.index === trigger.index;
270
+ if (trigger.kind === 'auto') return t.always === true || (t.done === true && complete(t.from, active));
271
+ return t.after === undefined && !t.always && !t.done && (t.event == null || t.event === trigger.event);
272
+ };
273
+ for (const leaf of ordered(state.active)) {
274
+ outer: for (const id of [leaf, ...ancestors(leaf)]) {
275
+ for (const t of byFrom.get(id)) {
276
+ if (!matches(t)) continue;
277
+ if (!evaluated.has(t)) {
278
+ let pass = true;
279
+ try { if (t.guard !== null) pass = t.guard.ebv(scope); }
280
+ catch (err) {
281
+ pass = false;
282
+ out.errors.push({ code: 'JF2003', docPath: `${t.path}/guard`, message: asError(err).message });
283
+ }
284
+ evaluated.set(t, pass);
285
+ }
286
+ if (evaluated.get(t)) { enabled.add(t); break outer; }
287
+ }
288
+ }
289
+ }
290
+ const exits = (t) => new Set([...active].filter((id) => below(id, t.domain)));
291
+ const selected = [];
292
+ for (const t of enabled) {
293
+ const exit = exits(t);
294
+ const conflicts = selected.filter((other) => [...exit].some((id) => other.exit.has(id))
295
+ || within(t.from, other.t.from) || within(other.t.from, t.from));
296
+ if (conflicts.some((other) => !below(t.from, other.t.from))) continue;
297
+ for (const other of conflicts) selected.splice(selected.indexOf(other), 1);
298
+ selected.push({ t, exit });
299
+ }
300
+ return selected.sort((a, b) => a.t.index - b.t.index);
301
+ }
302
+ function microstep(state, selected, scope, out) {
303
+ if (!selected.length) return false;
304
+ const active = closure(state.active);
305
+ const exiting = new Set(selected.flatMap((s) => [...s.exit]));
306
+ for (const id of exiting) for (const h of nodes.get(id).children) {
307
+ const history = nodes.get(h);
308
+ if (history.type !== 'history') continue;
309
+ Object.defineProperty(state.history, h, { value: history.history === 'deep'
310
+ ? state.active.filter((leaf) => below(leaf, id))
311
+ : children(id).filter((c) => active.has(c)), enumerable: true, writable: true, configurable: true });
312
+ }
313
+ const exited = ordered(exiting).reverse();
314
+ for (const id of exited) { effects(nodes.get(id).exit, scope, out); active.delete(id); }
315
+ state.timers = state.timers.filter((timer) => !exiting.has(transitions[timer.transition].from));
316
+ for (const { t } of selected) effects(t.effects, scope, out);
317
+ const kept = new Set(active);
318
+ for (const { t } of selected) targetInto(t.to, active, state.history);
319
+ state.active = fill(active);
320
+ const entered = ordered([...active].filter((id) => !kept.has(id)));
321
+ for (const id of entered) effects(nodes.get(id).entry, scope, out);
322
+ schedule(state, entered, state.time);
323
+ out.changed = true;
324
+ out.entered.push(...entered); out.exited.push(...exited);
325
+ out.transitions.push(...selected.map(({ t }) => t.index));
326
+ return true;
327
+ }
328
+ function settle(state, scope, out, budget) {
329
+ while (true) {
330
+ const selected = select(state, { kind: 'auto' }, { ...scope, state: state.active }, out);
331
+ if (!selected.length) break;
332
+ if (++budget.count > maxMicrosteps) throw new FlowRuntimeError('JF2012', 'statechart microstep limit exceeded');
333
+ microstep(state, selected, { ...scope, state: state.active }, out);
334
+ }
335
+ }
336
+ function finish(out) { out.final = final(out.state); deepFreeze(out.state); return out; }
337
+ return Object.freeze({
338
+ states: Object.freeze(order), restore,
339
+ final(state) { return final(restore(state)); },
340
+ events(state) {
341
+ const active = closure(restore(state).active);
342
+ return Object.freeze([...new Set(transitions.filter((t) => active.has(t.from) && typeof t.event === 'string').map((t) => t.event))]);
343
+ },
344
+ start(opts) {
345
+ const state = { active: [], history: {}, timers: [], serial: 0, time: timeAt(null, opts?.now) };
346
+ const active = new Set();
347
+ targetInto(initial, active, state.history);
348
+ state.active = fill(active);
349
+ const out = result(state);
350
+ out.entered = ordered(active);
351
+ const scope = scopeFor(state, null, opts);
352
+ for (const id of out.entered) effects(nodes.get(id).entry, scope, out);
353
+ schedule(state, out.entered, state.time);
354
+ settle(state, scope, out, { count: 0 });
355
+ return finish(out);
356
+ },
357
+ step(snapshot, event, opts) {
358
+ if (typeof event !== 'string') throw new FlowRuntimeError('JF2002', 'event must be a string');
359
+ const state = JSON.parse(canonicalizeJson(restore(snapshot)));
360
+ state.time = timeAt(snapshot, opts?.now);
361
+ const out = result(state);
362
+ const scope = scopeFor(state, event, opts);
363
+ microstep(state, select(state, { kind: 'event', event }, scope, out), scope, out);
364
+ settle(state, scope, out, { count: 1 });
365
+ return finish(out);
366
+ },
367
+ advance(snapshot, now, opts) {
368
+ const until = timeAt(snapshot, now);
369
+ const state = JSON.parse(canonicalizeJson(restore(snapshot)));
370
+ const out = result(state);
371
+ const budget = { count: 0 };
372
+ while (state.timers.length && state.timers[0].at <= until) {
373
+ if (++budget.count > maxMicrosteps) throw new FlowRuntimeError('JF2012', 'statechart microstep limit exceeded');
374
+ const timer = state.timers.shift();
375
+ state.time = Math.max(state.time, timer.at);
376
+ const scope = scopeFor(state, null, opts);
377
+ microstep(state, select(state, { kind: 'timer', index: timer.transition }, scope, out), scope, out);
378
+ settle(state, scope, out, budget);
379
+ if (opts?.one) break;
380
+ }
381
+ if (!opts?.one || budget.count === 0) state.time = until;
382
+ return finish(out);
383
+ },
384
+ });
385
+ }
386
+
387
+ /** A synchronous session; callers execute returned effects and supply time.
388
+ * @param {CompiledStatechart} chart
389
+ * @param {{ state?: StatechartState, now?: number, context?: any,
390
+ * store?: { load: () => StatechartState | null, save: (state: StatechartState) => void } }} [options]
391
+ */
392
+ export function createStatechartSession(chart, options = {}) {
393
+ const store = options.store;
394
+ if (store && (typeof store.load !== 'function' || typeof store.save !== 'function'))
395
+ throw new TypeError('statechart store needs synchronous load and save');
396
+ const loaded = options.state ?? store?.load();
397
+ const initial = loaded ? null : chart.start(options);
398
+ let state = loaded ? chart.restore(loaded) : initial.state;
399
+ const save = (next) => {
400
+ const saved = store?.save(next);
401
+ if (saved && typeof saved.then === 'function') {
402
+ Promise.resolve(saved).catch(() => {});
403
+ throw new TypeError('statechart session stores must be synchronous');
404
+ }
405
+ };
406
+ if (initial) save(state);
407
+ /** @param {StatechartResult} r @returns {StatechartResult} */
408
+ const commit = (r) => { save(r.state); state = r.state; return r; };
409
+ return Object.freeze({
410
+ get state() { return state; },
411
+ get done() { return chart.final(state); },
412
+ initial,
413
+ /** @param {string} event @param {StatechartOptions} [opts] */
414
+ send: (event, opts) => commit(chart.step(state, event, opts)),
415
+ /** @param {number} now @param {StatechartOptions} [opts] */
416
+ advance: (now, opts) => commit(chart.advance(state, now, opts)),
417
+ /** @param {string} event @param {StatechartOptions} [opts] */
418
+ can: (event, opts) => chart.step(state, event, opts).changed,
419
+ });
420
+ }