@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/README.md +234 -0
- package/dist/types/app.d.ts +65 -0
- package/dist/types/dag.d.ts +89 -0
- package/dist/types/errors.d.ts +155 -0
- package/dist/types/fsm.d.ts +150 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/persist.d.ts +61 -0
- package/docs/APP-INTEGRATION.md +241 -0
- package/docs/FLOW-FORMAT.md +441 -0
- package/package.json +55 -0
- package/schemas/jaren-dag.draft-07.schema.json +200 -0
- package/schemas/jaren-dag.schema.json +200 -0
- package/schemas/jaren-fsm.draft-07.schema.json +127 -0
- package/schemas/jaren-fsm.schema.json +94 -0
- package/src/app.js +206 -0
- package/src/dag.js +560 -0
- package/src/errors.js +169 -0
- package/src/fsm.js +396 -0
- package/src/index.js +13 -0
- package/src/persist.js +65 -0
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
|
+
}
|