@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The jaren-fsm engine: compile a finite-state-machine document
|
|
3
|
+
* (docs/FLOW-FORMAT.md §2) into a PURE step function. Everything is
|
|
4
|
+
* decided once at compile time — guards and effect `with` members
|
|
5
|
+
* become compiled query closures — and the running machine holds no
|
|
6
|
+
* mutable state at all: `step(state, event, opts)` maps its arguments
|
|
7
|
+
* to a transition result, and `createFsmSession` is the thin mutable
|
|
8
|
+
* convenience over it.
|
|
9
|
+
*
|
|
10
|
+
* The engine never executes an effect. A fired transition RETURNS
|
|
11
|
+
* resolved effect descriptors as data (exit → transition → entry); how
|
|
12
|
+
* they run — as @jarenjs/app effects, or in any host loop — is the
|
|
13
|
+
* caller's registry, the same boundary discipline the rest of the
|
|
14
|
+
* suite keeps.
|
|
15
|
+
*/
|
|
16
|
+
export type CompiledEffect = {
|
|
17
|
+
run: string;
|
|
18
|
+
with: any;
|
|
19
|
+
docPath: string;
|
|
20
|
+
};
|
|
21
|
+
export type FsmStepResult = {
|
|
22
|
+
/**
|
|
23
|
+
* - A transition fired. A self-transition
|
|
24
|
+
* reports true with an unchanged `state`; inspect `state` to tell the
|
|
25
|
+
* two apart.
|
|
26
|
+
*/
|
|
27
|
+
changed: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* - The resulting state id.
|
|
30
|
+
*/
|
|
31
|
+
state: string;
|
|
32
|
+
/**
|
|
33
|
+
* - Resolved `{ run, with? }` descriptors in
|
|
34
|
+
* firing order: exit → transition → entry (exit/entry only when the
|
|
35
|
+
* state actually changed).
|
|
36
|
+
*/
|
|
37
|
+
effects: any[];
|
|
38
|
+
/**
|
|
39
|
+
* - Whether the resulting state is final.
|
|
40
|
+
*/
|
|
41
|
+
final: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* -
|
|
44
|
+
* Fail-closed evaluation records (JF2003/JF2004); empty on a clean step.
|
|
45
|
+
*/
|
|
46
|
+
errors: {
|
|
47
|
+
code: string;
|
|
48
|
+
docPath: string;
|
|
49
|
+
message: string;
|
|
50
|
+
}[];
|
|
51
|
+
};
|
|
52
|
+
export type CompiledFsm = {
|
|
53
|
+
/**
|
|
54
|
+
* - The document's initial state id.
|
|
55
|
+
*/
|
|
56
|
+
initial: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* - Declared state ids, document order.
|
|
59
|
+
*/
|
|
60
|
+
states: readonly string[];
|
|
61
|
+
/**
|
|
62
|
+
* - The unique
|
|
63
|
+
* named events leaving a state (wildcards excluded), document order.
|
|
64
|
+
*/
|
|
65
|
+
events: (state: string) => readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* - Whether a state is final.
|
|
68
|
+
*/
|
|
69
|
+
final: (state: string) => boolean;
|
|
70
|
+
/**
|
|
71
|
+
* -
|
|
72
|
+
* The pure step function.
|
|
73
|
+
*/
|
|
74
|
+
step: (state: string, event: string, opts?: {
|
|
75
|
+
payload?: any;
|
|
76
|
+
context?: any;
|
|
77
|
+
}) => FsmStepResult;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The result of one pure step.
|
|
81
|
+
* @typedef {Object} FsmStepResult
|
|
82
|
+
* @property {boolean} changed - A transition fired. A self-transition
|
|
83
|
+
* reports true with an unchanged `state`; inspect `state` to tell the
|
|
84
|
+
* two apart.
|
|
85
|
+
* @property {string} state - The resulting state id.
|
|
86
|
+
* @property {any[]} effects - Resolved `{ run, with? }` descriptors in
|
|
87
|
+
* firing order: exit → transition → entry (exit/entry only when the
|
|
88
|
+
* state actually changed).
|
|
89
|
+
* @property {boolean} final - Whether the resulting state is final.
|
|
90
|
+
* @property {{ code: string, docPath: string, message: string }[]} errors -
|
|
91
|
+
* Fail-closed evaluation records (JF2003/JF2004); empty on a clean step.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* A compiled jaren-fsm machine.
|
|
95
|
+
* @typedef {Object} CompiledFsm
|
|
96
|
+
* @property {string|null} initial - The document's initial state id.
|
|
97
|
+
* @property {readonly string[]} states - Declared state ids, document order.
|
|
98
|
+
* @property {(state: string) => readonly string[]} events - The unique
|
|
99
|
+
* named events leaving a state (wildcards excluded), document order.
|
|
100
|
+
* @property {(state: string) => boolean} final - Whether a state is final.
|
|
101
|
+
* @property {(state: string, event: string, opts?: { payload?: any, context?: any }) => FsmStepResult} step -
|
|
102
|
+
* The pure step function.
|
|
103
|
+
*/
|
|
104
|
+
/**
|
|
105
|
+
* Compile a jaren-fsm document (docs/FLOW-FORMAT.md) into a pure
|
|
106
|
+
* machine. Every guard and effect `with` member compiles once, here;
|
|
107
|
+
* `step` only runs specialized closures.
|
|
108
|
+
*
|
|
109
|
+
* The document contract is a strict superset of the mermaid
|
|
110
|
+
* `jaren-workflow` projection shape: `$fsm` is optional, a plain-string
|
|
111
|
+
* guard is a query (a `$`-path expression — any other non-empty string
|
|
112
|
+
* is a literal and therefore always true), and `guard: null` /
|
|
113
|
+
* `event: null` mean no guard / any event.
|
|
114
|
+
*
|
|
115
|
+
* @param {any} doc - the jaren-fsm document
|
|
116
|
+
* @returns {CompiledFsm}
|
|
117
|
+
* @throws {FlowCompileError} when the document violates the format (JF0xxx)
|
|
118
|
+
* @example
|
|
119
|
+
* const fsm = compileFsm({
|
|
120
|
+
* initial: 'idle',
|
|
121
|
+
* states: ['idle', 'busy'],
|
|
122
|
+
* transitions: [{ from: 'idle', event: 'start', to: 'busy' }],
|
|
123
|
+
* });
|
|
124
|
+
* fsm.step('idle', 'start').state; // 'busy'
|
|
125
|
+
*/
|
|
126
|
+
export declare function compileFsm(doc: any): CompiledFsm;
|
|
127
|
+
/**
|
|
128
|
+
* The thin mutable convenience over a compiled machine: it holds the
|
|
129
|
+
* current state and forwards to the pure `step`. Because `step` is
|
|
130
|
+
* pure, `can` IS a dry run — it evaluates guards but changes nothing.
|
|
131
|
+
* @param {CompiledFsm} fsm - a machine from {@link compileFsm}
|
|
132
|
+
* @param {string} [startState] - defaults to the document's `initial`
|
|
133
|
+
* @returns {{ readonly state: string, readonly done: boolean,
|
|
134
|
+
* send: (event: string, opts?: { payload?: any, context?: any }) => FsmStepResult,
|
|
135
|
+
* can: (event: string, opts?: { payload?: any, context?: any }) => boolean }}
|
|
136
|
+
* @throws {FlowRuntimeError} JF2005 when neither a start state nor a
|
|
137
|
+
* document `initial` exists; JF2001 when the start state is undeclared.
|
|
138
|
+
*/
|
|
139
|
+
export declare function createFsmSession(fsm: CompiledFsm, startState?: string): {
|
|
140
|
+
readonly state: string;
|
|
141
|
+
readonly done: boolean;
|
|
142
|
+
send: (event: string, opts?: {
|
|
143
|
+
payload?: any;
|
|
144
|
+
context?: any;
|
|
145
|
+
}) => FsmStepResult;
|
|
146
|
+
can: (event: string, opts?: {
|
|
147
|
+
payload?: any;
|
|
148
|
+
context?: any;
|
|
149
|
+
}) => boolean;
|
|
150
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file @jarenjs/flow — executable workflow documents over the
|
|
3
|
+
* @jarenjs/json engines: the jaren-fsm finite-state-machine format
|
|
4
|
+
* compiled to a pure step function. See README.md and
|
|
5
|
+
* docs/FLOW-FORMAT.md.
|
|
6
|
+
*/
|
|
7
|
+
export { compileFsm, createFsmSession } from './fsm.js';
|
|
8
|
+
export { fsmToApp, fsmStateSchema } from './app.js';
|
|
9
|
+
export { compileDag } from './dag.js';
|
|
10
|
+
export { snapshotFsm, resumeFsmSession, createDurableFsmSession } from './persist.js';
|
|
11
|
+
export { FlowCompileError, FlowRuntimeError, FLOW_CODES } from './errors.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file FSM persistence (FLOW-FORMAT §7.7): nothing new was needed —
|
|
3
|
+
* `step` is pure and a session's whole run state IS its current state
|
|
4
|
+
* string — so these helpers are deliberately thin. `snapshotFsm`
|
|
5
|
+
* captures a session as JSON, `resumeFsmSession` rebuilds one (the
|
|
6
|
+
* existing JF2001 refusal covers a snapshot naming an undeclared
|
|
7
|
+
* state), and `createDurableFsmSession` persists through a
|
|
8
|
+
* SYNCHRONOUS `{ load, save }` store on every state CHANGE — a store
|
|
9
|
+
* that throws fails the send, never loses a transition silently. An
|
|
10
|
+
* asynchronous store composes its own wrapper; the session contract
|
|
11
|
+
* stays synchronous.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Capture a session's durable state — the whole of it.
|
|
15
|
+
* @param {{ state: string, done: boolean }} session
|
|
16
|
+
* @returns {{ state: string }}
|
|
17
|
+
*/
|
|
18
|
+
export declare function snapshotFsm(session: {
|
|
19
|
+
state: string;
|
|
20
|
+
done: boolean;
|
|
21
|
+
}): {
|
|
22
|
+
state: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Rebuild a session from a snapshot. An undeclared state refuses with
|
|
26
|
+
* the session's own JF2001.
|
|
27
|
+
* @param {any} fsm - a machine from `compileFsm`
|
|
28
|
+
* @param {{ state: string }} snapshot
|
|
29
|
+
*/
|
|
30
|
+
export declare function resumeFsmSession(fsm: any, snapshot: {
|
|
31
|
+
state: string;
|
|
32
|
+
}): {
|
|
33
|
+
readonly state: string;
|
|
34
|
+
readonly done: boolean;
|
|
35
|
+
send: (event: string, opts?: {
|
|
36
|
+
payload?: any;
|
|
37
|
+
context?: any;
|
|
38
|
+
}) => import("./fsm.js").FsmStepResult;
|
|
39
|
+
can: (event: string, opts?: {
|
|
40
|
+
payload?: any;
|
|
41
|
+
context?: any;
|
|
42
|
+
}) => boolean;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A session that persists its state through a synchronous store:
|
|
46
|
+
* `load()` answers the stored state (or null/undefined for a fresh
|
|
47
|
+
* start), `save(state)` records each CHANGED state before the step
|
|
48
|
+
* result is returned.
|
|
49
|
+
* @param {any} fsm - a machine from `compileFsm`
|
|
50
|
+
* @param {{ load: () => string | null | undefined,
|
|
51
|
+
* save: (state: string) => void }} store
|
|
52
|
+
*/
|
|
53
|
+
export declare function createDurableFsmSession(fsm: any, store: {
|
|
54
|
+
load: () => string | null | undefined;
|
|
55
|
+
save: (state: string) => void;
|
|
56
|
+
}): Readonly<{
|
|
57
|
+
readonly state: string;
|
|
58
|
+
readonly done: boolean;
|
|
59
|
+
can: (event: any, opts: any) => boolean;
|
|
60
|
+
send(event: any, opts: any): import("./fsm.js").FsmStepResult;
|
|
61
|
+
}>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Hosting a machine in @jarenjs/app
|
|
2
|
+
|
|
3
|
+
How a jaren-fsm document drives a live `@jarenjs/app` application. The
|
|
4
|
+
shape of the convention is one sentence: **control state is a state
|
|
5
|
+
slice, and the machine's transition table becomes generated standard
|
|
6
|
+
action documents** — plain JSON the app compiles like any hand-written
|
|
7
|
+
action, with no `@jarenjs/flow` code anywhere at runtime and neither
|
|
8
|
+
package importing the other. The test suite executes this document's
|
|
9
|
+
worked example verbatim.
|
|
10
|
+
|
|
11
|
+
The philosophy is the same split FLOW-FORMAT §3 states for the headless
|
|
12
|
+
engine — control state lives in the machine, data state lives in the
|
|
13
|
+
host — mapped onto the app: the control state is one slice member
|
|
14
|
+
(`<pointer>/current`), and the host's app state *is* the machine's
|
|
15
|
+
`context`.
|
|
16
|
+
|
|
17
|
+
## The API
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { fsmToApp, fsmStateSchema } from '@jarenjs/flow';
|
|
21
|
+
|
|
22
|
+
const { slice, actions, events } = fsmToApp(fsmDoc, {
|
|
23
|
+
pointer: '/fsm', // where the slice lives in app state (default)
|
|
24
|
+
namespace: 'fsm/', // action-name prefix (default)
|
|
25
|
+
});
|
|
26
|
+
// slice → { current: <initial> } mount it at `pointer`
|
|
27
|
+
// actions → { 'fsm/submit': <query doc> } spread into the app's actions
|
|
28
|
+
// events → ['submit', 'approve', ...] the machine's event vocabulary
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`fsmToApp` validates the document exactly like `compileFsm` (same
|
|
32
|
+
JF0xxx errors) and then emits **pure JSON**: one action document per
|
|
33
|
+
distinct named event, each a conditional chain over the machine's
|
|
34
|
+
transitions in document order. `pointer` must be a chain of
|
|
35
|
+
identifier-safe segments (`/ui/wizard` is fine); anything else throws a
|
|
36
|
+
`TypeError` — that is an option mistake, not a document defect.
|
|
37
|
+
|
|
38
|
+
`fsmStateSchema(fsmDoc)` returns the slice's JSON Schema — `current`
|
|
39
|
+
as an enum of the declared state ids — for composing into a
|
|
40
|
+
`validateState` schema (below).
|
|
41
|
+
|
|
42
|
+
## The scope mapping
|
|
43
|
+
|
|
44
|
+
A guard or effect `with` query is authored against FLOW-FORMAT §3's
|
|
45
|
+
scope. Hosted, each member maps to app vocabulary — the generator
|
|
46
|
+
performs this mapping mechanically, so machine documents run unchanged:
|
|
47
|
+
|
|
48
|
+
| FLOW-FORMAT §3 | hosted meaning |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `$.state` | the slice's `current`, read pre-transition |
|
|
51
|
+
| `$.event` | the event name, baked as a literal per action |
|
|
52
|
+
| `$.payload` | the app's `$payload` (the binding's `with`; `null` when absent) |
|
|
53
|
+
| `$.context` | the **whole app state** `$`, pre-transition |
|
|
54
|
+
|
|
55
|
+
Inside a generated action the scope is bound once to the reserved
|
|
56
|
+
variable `__fsm`; machine guards and effect props MUST NOT bind a
|
|
57
|
+
variable of that name (`fsmToApp` throws a `TypeError` when one does).
|
|
58
|
+
|
|
59
|
+
**Everything evaluates pre-transition**, guards and effect props alike —
|
|
60
|
+
the app computes the entire transition object, effects included, in one
|
|
61
|
+
action evaluation against the current state, and this is exactly the
|
|
62
|
+
headless engine's rule (one step scope, built before the transition
|
|
63
|
+
applies). An effect that wants "the state we just entered" needs no
|
|
64
|
+
state read at all: the generator bakes the target state, the patch
|
|
65
|
+
value and the event name as literals. Effect props that read *data*
|
|
66
|
+
state see pre-transition values; when an async effect's completion must
|
|
67
|
+
be checked against later state, use the id-guard convention of
|
|
68
|
+
[`@jarenjs/app`'s TASKS.md](../../app/docs/TASKS.md).
|
|
69
|
+
|
|
70
|
+
## The rules that make it exact
|
|
71
|
+
|
|
72
|
+
- **Selection is document order, identically.** Each action's
|
|
73
|
+
conditional chain lists that event's transitions (named matches and
|
|
74
|
+
wildcard fallbacks together) in the transition table's order; `$and`
|
|
75
|
+
stops at the first false and `$if` evaluates only the taken branch,
|
|
76
|
+
so a guard runs exactly when the headless engine would run it.
|
|
77
|
+
- **The vocabulary is closed.** An app can only dispatch registered
|
|
78
|
+
action names, so the hosted machine answers exactly its *named*
|
|
79
|
+
events; wildcard transitions participate as fallbacks inside each
|
|
80
|
+
named event's action. Two consequences, stated honestly: a machine
|
|
81
|
+
with only unlabeled transitions has an empty vocabulary and cannot be
|
|
82
|
+
driven in an app, and dispatching an out-of-vocabulary name is the
|
|
83
|
+
app's ordinary unknown-action error (`JA2001`) — where the headless
|
|
84
|
+
engine would have answered any string event.
|
|
85
|
+
- **An unmatched event is a no-op, not an error.** The chain's missing
|
|
86
|
+
final `$else` yields the empty sequence; the app records a `noop`
|
|
87
|
+
transaction, matching FLOW-FORMAT §4's ignored-event rule.
|
|
88
|
+
|
|
89
|
+
## Honest divergences
|
|
90
|
+
|
|
91
|
+
The headless engine records evaluation failures and fails closed
|
|
92
|
+
(FLOW-FORMAT §5.2). An app has one failure channel for a whole action,
|
|
93
|
+
so two behaviors differ, deliberately:
|
|
94
|
+
|
|
95
|
+
- **A guard or `with` that throws** (an unbound external, a hostile
|
|
96
|
+
value) fails the entire hosted transaction as `JA2002` — no fallback
|
|
97
|
+
transition fires and no JF2003/JF2004 record exists. Headless, the
|
|
98
|
+
same guard reads false and selection continues. Write total guards;
|
|
99
|
+
the divergence only appears in documents that are already broken.
|
|
100
|
+
- **`errors` records don't exist hosted.** The app's `onError` and
|
|
101
|
+
transaction log are the error channel.
|
|
102
|
+
|
|
103
|
+
One behavior that does NOT diverge: an effect `with` whose query yields
|
|
104
|
+
the empty sequence omits the member in both worlds (the query engine
|
|
105
|
+
drops empty members from object constructors), and the app hands the
|
|
106
|
+
handler `null` for an absent `with` — normalize with `?? null` when
|
|
107
|
+
comparing.
|
|
108
|
+
|
|
109
|
+
## Fail closed with `validateState`
|
|
110
|
+
|
|
111
|
+
The machine's own guarantee — `current` is always a declared state —
|
|
112
|
+
should be enforced by the host too, so a rogue hand-written action
|
|
113
|
+
cannot corrupt the slice:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
117
|
+
|
|
118
|
+
const stateSchema = {
|
|
119
|
+
type: 'object',
|
|
120
|
+
required: ['fsm'],
|
|
121
|
+
properties: { fsm: fsmStateSchema(fsmDoc), reviewer: { type: 'string' } },
|
|
122
|
+
};
|
|
123
|
+
const validate = new JarenValidator().compile(stateSchema);
|
|
124
|
+
createApp(appDoc, { validateState: (s) => validate(s) });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A transition into an out-of-vocabulary `current` is rejected with the
|
|
128
|
+
app's own `JA2005` and the state stays untouched.
|
|
129
|
+
|
|
130
|
+
## Multiple machines
|
|
131
|
+
|
|
132
|
+
`pointer` and `namespace` make machines composable: a wizard at
|
|
133
|
+
`/wizard` with `wizard/` actions and an upload machine at `/upload`
|
|
134
|
+
with `upload/` actions share one app without touching each other —
|
|
135
|
+
each machine's actions read and write only its own slice, and the
|
|
136
|
+
action-name spaces are disjoint by construction.
|
|
137
|
+
|
|
138
|
+
## The worked example
|
|
139
|
+
|
|
140
|
+
A document-review machine — one guard reading data state, one entry
|
|
141
|
+
effect, two terminal states. This is the document the test suite hosts
|
|
142
|
+
and drives:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"$fsm": "0.1",
|
|
147
|
+
"initial": "draft",
|
|
148
|
+
"states": [
|
|
149
|
+
"draft",
|
|
150
|
+
{ "id": "in-review",
|
|
151
|
+
"entry": [{ "run": "notify", "with": { "reviewer": "$.context.reviewer", "from": "$.state" } }] },
|
|
152
|
+
{ "id": "approved", "final": true },
|
|
153
|
+
{ "id": "rejected", "final": true }
|
|
154
|
+
],
|
|
155
|
+
"transitions": [
|
|
156
|
+
{ "from": "draft", "event": "submit", "to": "in-review" },
|
|
157
|
+
{ "from": "in-review", "event": "approve", "guard": "$.context.reviewer", "to": "approved" },
|
|
158
|
+
{ "from": "in-review", "event": "reject", "to": "rejected",
|
|
159
|
+
"effects": [{ "run": "notify", "with": { "reason": "$.payload.reason" } }] }
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Hosted:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
const { slice, actions } = fsmToApp(reviewDoc);
|
|
168
|
+
const app = createApp({
|
|
169
|
+
state: { fsm: slice }, // no reviewer member yet
|
|
170
|
+
view: [{ match: '$', body: ['main', {}] }],
|
|
171
|
+
actions: {
|
|
172
|
+
...actions, // fsm/submit, fsm/approve, fsm/reject
|
|
173
|
+
assign: { patch: [{ op: 'add', path: '/reviewer', value: '$payload' }] },
|
|
174
|
+
},
|
|
175
|
+
}, {
|
|
176
|
+
effects: { notify: (props) => log.push(props) },
|
|
177
|
+
validateState: (s) => validate(s),
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
And driven, each step observable through `app.observe`:
|
|
182
|
+
|
|
183
|
+
1. `dispatch('fsm/submit')` — `applied`, `current` = `in-review`,
|
|
184
|
+
`changedPaths` = `['/fsm/current']`; `notify` receives
|
|
185
|
+
`{ from: 'draft' }` — `reviewer` is **omitted** because the member
|
|
186
|
+
does not exist yet (empty sequence), and `from` is the
|
|
187
|
+
pre-transition state.
|
|
188
|
+
2. `dispatch('fsm/approve')` — `noop`: the guard reads no reviewer.
|
|
189
|
+
Nothing moved, nothing ran, no error.
|
|
190
|
+
3. `dispatch('assign', 'sam')` — the host's own action; data state and
|
|
191
|
+
control state live side by side.
|
|
192
|
+
4. `dispatch('fsm/approve')` — `applied`, `current` = `approved`, a
|
|
193
|
+
final state.
|
|
194
|
+
|
|
195
|
+
Control state moved only through the machine's generated actions, data
|
|
196
|
+
state only through the host's, and the one guard read across the
|
|
197
|
+
boundary the only way the convention allows: through `context`.
|
|
198
|
+
|
|
199
|
+
## A dag as one effect
|
|
200
|
+
|
|
201
|
+
A compiled dataflow graph (FLOW-FORMAT §6–§7) needs **no adapter at
|
|
202
|
+
all**: `run(input, { signal })` is already the exact shape
|
|
203
|
+
`createTaskEffect` wants, so a whole graph becomes one ordinary app
|
|
204
|
+
effect — with the task convention's per-slot cancellation and the
|
|
205
|
+
id-guard staleness rule for free (the app's TASKS.md is the normative
|
|
206
|
+
home for that pattern). The test suite runs this recipe:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
import { compileDag } from '@jarenjs/flow';
|
|
210
|
+
import { createApp, createTaskEffect } from '@jarenjs/app';
|
|
211
|
+
|
|
212
|
+
const dag = compileDag(enrichDoc, { tasks: { lookup } });
|
|
213
|
+
|
|
214
|
+
const app = createApp({
|
|
215
|
+
state: { rows: [...], tasks: { enrich: { id: 0, status: 'idle' } }, result: null },
|
|
216
|
+
view,
|
|
217
|
+
actions: {
|
|
218
|
+
start: {
|
|
219
|
+
patch: [
|
|
220
|
+
{ op: 'replace', path: '/tasks/enrich/id', value: { $add: ['$.tasks.enrich.id', 1] } },
|
|
221
|
+
{ op: 'replace', path: '/tasks/enrich/status', value: 'busy' },
|
|
222
|
+
],
|
|
223
|
+
effects: [{ run: 'enrich',
|
|
224
|
+
with: { input: '$.rows', id: { $add: ['$.tasks.enrich.id', 1] }, done: 'done' } }],
|
|
225
|
+
},
|
|
226
|
+
done: {
|
|
227
|
+
$if: [{ $eq: ['$payload.id', '$.tasks.enrich.id'] },
|
|
228
|
+
{ patch: [
|
|
229
|
+
{ op: 'replace', path: '/result', value: '$payload.result' },
|
|
230
|
+
{ op: 'replace', path: '/tasks/enrich/status', value: 'idle' } ] }],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}, {
|
|
234
|
+
effects: { enrich: createTaskEffect((props, signal) => dag.run(props.input, { signal })) },
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The division of labor is exact: the dag owns the computation and its
|
|
239
|
+
internal abort fan-out (§7.3), `createTaskEffect` owns slot concurrency
|
|
240
|
+
and cancellation, and the state-side id guard owns staleness —
|
|
241
|
+
out-of-order completions are rejected by construction, never by luck.
|