@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/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
+ }