@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/app.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The @jarenjs/app adapter: turn a jaren-fsm document into the
|
|
4
|
+
* standard app documents that host it (docs/APP-INTEGRATION.md). This
|
|
5
|
+
* is a GENERATOR, not a runtime — it emits pure JSON action documents
|
|
6
|
+
* the app compiles like any hand-written action, and nothing from this
|
|
7
|
+
* package runs afterwards. Selection semantics are the headless
|
|
8
|
+
* engine's, reproduced structurally: one conditional chain per named
|
|
9
|
+
* event, transitions in document order, `$and`'s first-false stop
|
|
10
|
+
* playing the role of the step function's guard gate.
|
|
11
|
+
*
|
|
12
|
+
* The scope mapping (APP-INTEGRATION.md §scope): a guard or `with`
|
|
13
|
+
* query authored against FLOW-FORMAT §3's `{ state, event, payload,
|
|
14
|
+
* context }` is rewritten to read the reserved variable `__fsm`, bound
|
|
15
|
+
* once per action to `{ state: <pointer>.current, event: <literal>,
|
|
16
|
+
* payload: $payload, context: $ }` — all pre-transition, exactly like
|
|
17
|
+
* the headless step scope.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isJsonObject } from '@jarenjs/core/object';
|
|
21
|
+
import { compileFsm } from './fsm.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Bake a string as a LITERAL expression leaf: a `$`-leading name must
|
|
25
|
+
* be `$$`-escaped or the query engine reads it as a path (QUERY-FORMAT
|
|
26
|
+
* §3.2).
|
|
27
|
+
* @param {string} s
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function lit(s) {
|
|
31
|
+
return s.startsWith('$') ? `$$${s.slice(1)}` : s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Operator members whose object value binds variable names. */
|
|
35
|
+
const BINDING_KEYS = ['$let', '$for', '$fold', '$every', '$some'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite a guard / `with` query document from the FLOW-FORMAT §3
|
|
39
|
+
* scope onto the action-local `__fsm` variable: every absolute path
|
|
40
|
+
* (`$`, `$.…`, `$[…`, `$..…`) re-roots on `$__fsm`; variable paths,
|
|
41
|
+
* `$$`-escaped literals and plain strings pass through. The walk
|
|
42
|
+
* always returns fresh nodes (the emitted document never aliases the
|
|
43
|
+
* machine document) and throws when the document binds the reserved
|
|
44
|
+
* name itself.
|
|
45
|
+
* @param {any} node
|
|
46
|
+
* @returns {any}
|
|
47
|
+
*/
|
|
48
|
+
function rewriteScope(node) {
|
|
49
|
+
if (typeof node === 'string') {
|
|
50
|
+
if (node === '$') return '$__fsm';
|
|
51
|
+
if (node.startsWith('$.') || node.startsWith('$[')) return `$__fsm${node.slice(1)}`;
|
|
52
|
+
return node;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(node)) return node.map(rewriteScope);
|
|
55
|
+
if (isJsonObject(node)) {
|
|
56
|
+
/** @type {Record<string, any>} */
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const key of Object.keys(node)) {
|
|
59
|
+
if (BINDING_KEYS.includes(key) && isJsonObject(node[key])
|
|
60
|
+
&& Object.hasOwn(node[key], '__fsm')) {
|
|
61
|
+
throw new TypeError(
|
|
62
|
+
'fsmToApp reserves the variable name "__fsm" inside generated actions; '
|
|
63
|
+
+ 'the machine document must not bind it');
|
|
64
|
+
}
|
|
65
|
+
out[key] = rewriteScope(node[key]);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize the (already compileFsm-validated) `states` member to
|
|
74
|
+
* id → { entry, exit } raw descriptor lists.
|
|
75
|
+
* @param {any} doc
|
|
76
|
+
* @returns {Map<string, { entry: any[], exit: any[] }>}
|
|
77
|
+
*/
|
|
78
|
+
function rawStates(doc) {
|
|
79
|
+
const map = new Map();
|
|
80
|
+
for (const entry of doc.states) {
|
|
81
|
+
if (typeof entry === 'string') map.set(entry, { entry: [], exit: [] });
|
|
82
|
+
else map.set(entry.id, { entry: entry.entry ?? [], exit: entry.exit ?? [] });
|
|
83
|
+
}
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bake one effect descriptor: literal `run`, scope-rewritten `with`.
|
|
89
|
+
* @param {any} effect
|
|
90
|
+
* @returns {any}
|
|
91
|
+
*/
|
|
92
|
+
function bakeEffect(effect) {
|
|
93
|
+
const out = { run: lit(effect.run) };
|
|
94
|
+
if (effect.with !== undefined) out.with = rewriteScope(effect.with);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate the standard app documents that host a jaren-fsm machine:
|
|
100
|
+
* a state slice, one action document per distinct named event, and the
|
|
101
|
+
* event vocabulary. The output is pure JSON with the machine's target
|
|
102
|
+
* states, event names and effect origins baked as literals; guards and
|
|
103
|
+
* effect props run through the app's own query engine at dispatch
|
|
104
|
+
* time. See docs/APP-INTEGRATION.md for the convention this implements.
|
|
105
|
+
*
|
|
106
|
+
* @param {any} fsmDoc - a jaren-fsm document (FLOW-FORMAT §2)
|
|
107
|
+
* @param {{ pointer?: string, namespace?: string }} [options] -
|
|
108
|
+
* `pointer` (default `/fsm`) is where the slice lives in app state,
|
|
109
|
+
* as a chain of identifier-safe segments; `namespace` (default
|
|
110
|
+
* `fsm/`) prefixes the generated action names.
|
|
111
|
+
* @returns {{ slice: { current: string|null }, actions: Record<string, any>, events: string[] }}
|
|
112
|
+
* @throws {import('./errors.js').FlowCompileError} on a bad machine
|
|
113
|
+
* document (the same JF0xxx codes as `compileFsm`)
|
|
114
|
+
* @throws {TypeError} on malformed options or a document binding the
|
|
115
|
+
* reserved `__fsm` variable name
|
|
116
|
+
*/
|
|
117
|
+
export function fsmToApp(fsmDoc, options) {
|
|
118
|
+
compileFsm(fsmDoc);
|
|
119
|
+
|
|
120
|
+
const pointer = options?.pointer ?? '/fsm';
|
|
121
|
+
const namespace = options?.namespace ?? 'fsm/';
|
|
122
|
+
if (typeof namespace !== 'string') {
|
|
123
|
+
throw new TypeError('fsmToApp: "namespace" must be a string');
|
|
124
|
+
}
|
|
125
|
+
if (typeof pointer !== 'string' || !/^(\/[A-Za-z_][A-Za-z0-9_]*)+$/.test(pointer)) {
|
|
126
|
+
throw new TypeError(
|
|
127
|
+
'fsmToApp: "pointer" must be a chain of identifier-safe segments, like /fsm or /ui/wizard');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const currentPath = `$${pointer.replaceAll('/', '.')}.current`;
|
|
131
|
+
const patchPath = `${pointer}/current`;
|
|
132
|
+
const states = rawStates(fsmDoc);
|
|
133
|
+
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
const events = [];
|
|
136
|
+
for (const t of fsmDoc.transitions) {
|
|
137
|
+
if (typeof t.event === 'string' && !events.includes(t.event)) events.push(t.event);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @type {Record<string, any>} */
|
|
141
|
+
const actions = {};
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
const branches = fsmDoc.transitions.filter(
|
|
144
|
+
(t) => t.event === event || t.event === null || t.event === undefined);
|
|
145
|
+
let chain;
|
|
146
|
+
for (let i = branches.length - 1; i >= 0; i--) {
|
|
147
|
+
const t = branches[i];
|
|
148
|
+
const eq = { $eq: [currentPath, lit(t.from)] };
|
|
149
|
+
const cond = t.guard === undefined || t.guard === null
|
|
150
|
+
? eq
|
|
151
|
+
: { $and: [eq, rewriteScope(t.guard)] };
|
|
152
|
+
const moved = t.from !== t.to;
|
|
153
|
+
const meta = /** @type {{ entry: any[], exit: any[] }} */ (states.get(t.from));
|
|
154
|
+
const target = /** @type {{ entry: any[], exit: any[] }} */ (states.get(t.to));
|
|
155
|
+
const effects = [
|
|
156
|
+
...(moved ? meta.exit : []),
|
|
157
|
+
...(t.effects ?? []),
|
|
158
|
+
...(moved ? target.entry : []),
|
|
159
|
+
].map(bakeEffect);
|
|
160
|
+
/** @type {Record<string, any>} */
|
|
161
|
+
const result = {
|
|
162
|
+
patch: [{ op: 'replace', path: patchPath, value: lit(t.to) }],
|
|
163
|
+
};
|
|
164
|
+
if (effects.length > 0) result.effects = effects;
|
|
165
|
+
chain = chain === undefined
|
|
166
|
+
? { $if: [cond, result] }
|
|
167
|
+
: { $if: [cond, result, chain] };
|
|
168
|
+
}
|
|
169
|
+
actions[namespace + event] = {
|
|
170
|
+
$let: {
|
|
171
|
+
__fsm: {
|
|
172
|
+
state: currentPath,
|
|
173
|
+
event: lit(event),
|
|
174
|
+
payload: '$payload',
|
|
175
|
+
context: '$',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
$return: chain,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { slice: { current: fsmDoc.initial }, actions, events };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The state slice's JSON Schema: `current` as an enum of the machine's
|
|
187
|
+
* declared state ids — compose it into a `validateState` schema at the
|
|
188
|
+
* slice pointer so no hand-written action can corrupt the control
|
|
189
|
+
* state (APP-INTEGRATION.md §fail-closed).
|
|
190
|
+
* @param {any} fsmDoc - a jaren-fsm document
|
|
191
|
+
* @returns {{ type: 'object', required: string[], properties: { current: { description: string, enum: string[] } } }}
|
|
192
|
+
* @throws {import('./errors.js').FlowCompileError} on a bad machine document
|
|
193
|
+
*/
|
|
194
|
+
export function fsmStateSchema(fsmDoc) {
|
|
195
|
+
const fsm = compileFsm(fsmDoc);
|
|
196
|
+
return {
|
|
197
|
+
type: 'object',
|
|
198
|
+
required: ['current'],
|
|
199
|
+
properties: {
|
|
200
|
+
current: {
|
|
201
|
+
description: 'The machine\'s control state: always one of the declared state ids.',
|
|
202
|
+
enum: [...fsm.states],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|