@typeonce/effect-machine 0.19.0 → 0.19.1
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 +1 -1
- package/docs/agent-guide.md +277 -1455
- package/docs/effect-atom-react.md +202 -0
- package/package.json +4 -4
package/docs/agent-guide.md
CHANGED
|
@@ -1,1578 +1,400 @@
|
|
|
1
1
|
# Effect Machine agent guide
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Use this guide to model a statechart with `@typeonce/effect-machine`. It covers
|
|
4
|
+
the decisions that shape the machine. Use the API reference for method
|
|
5
|
+
signatures, history states, and choice states.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Read [Effect Atom and React patterns](./effect-atom-react.md) when React needs
|
|
8
|
+
to consume a machine. Keep React ownership and atom lookup out of the machine
|
|
9
|
+
model.
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
readable and concise machine models, and alignment with Effect core. Convenience
|
|
11
|
-
must come from builders and inference rather than ambiguous omissions or weaker
|
|
12
|
-
contracts. The package is pre-1.0, so improve or remove an existing API when a
|
|
13
|
-
clearer long-term design replaces it; do not preserve an inferior design with
|
|
14
|
-
aliases by default.
|
|
11
|
+
## Create a machine
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
lifecycle belong to Cluster; expose integration through an explicit adapter
|
|
20
|
-
instead of creating a similar local abstraction with different semantics.
|
|
21
|
-
|
|
22
|
-
## Public imports
|
|
13
|
+
Define schemas first, then states, events, the machine definition, and its
|
|
14
|
+
handlers. Export the state descriptor, public event descriptor, and implemented
|
|
15
|
+
machine. Tests, runtimes, and adapters can then use the same model.
|
|
23
16
|
|
|
24
17
|
```ts
|
|
25
18
|
import { Machine } from "@typeonce/effect-machine"
|
|
26
|
-
import {
|
|
27
|
-
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Do not import the published package as `effect/unstable/machine`. The package is
|
|
31
|
-
currently coupled to the exact Effect peer version listed in its `package.json`.
|
|
32
|
-
|
|
33
|
-
## Definition order
|
|
34
|
-
|
|
35
|
-
Use this order so inference has all schemas available when handlers are
|
|
36
|
-
declared:
|
|
37
|
-
|
|
38
|
-
1. Domain schemas used by state, and by event fields when they are shared.
|
|
39
|
-
2. `Machine.states`, using a tagged state union and `.cases` when state
|
|
40
|
-
schemas need to be reused.
|
|
41
|
-
3. `Machine.events`, `Machine.internalEvents`, `Machine.emittedEvents`, and any
|
|
42
|
-
protocol passed to `Machine.parent` or `Machine.optionalParent`; pass
|
|
43
|
-
`Schema.TaggedUnion({...})` or tagged classes directly.
|
|
44
|
-
4. `Machine.make({...}).handle({...})`.
|
|
45
|
-
5. Child descriptors, then runtime, Atom, or Cluster adapters.
|
|
46
|
-
|
|
47
|
-
`Machine.make` returns a reusable definition. Each `handle` call creates one
|
|
48
|
-
independent machine implementation and the result does not expose `handle`
|
|
49
|
-
again. Put one implementation's complete behavior in a single handler tree;
|
|
50
|
-
call `handle` again on the original definition for a separate production,
|
|
51
|
-
testing, or simulation variant.
|
|
19
|
+
import { Schema } from "effect"
|
|
52
20
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
const State = Schema.TaggedUnion({
|
|
57
|
-
Saving: { draft: Draft },
|
|
58
|
-
Failed: { message: Schema.String }
|
|
21
|
+
const CounterState = Schema.TaggedUnion({
|
|
22
|
+
Running: { count: Schema.Number }
|
|
59
23
|
})
|
|
60
24
|
|
|
61
|
-
const
|
|
25
|
+
export const CounterStates = Machine.states({
|
|
62
26
|
Idle: {},
|
|
63
|
-
|
|
64
|
-
Failed: State.cases.Failed
|
|
27
|
+
Running: CounterState.cases.Running
|
|
65
28
|
})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Save: {}
|
|
69
|
-
})
|
|
70
|
-
)
|
|
71
|
-
export const Internal = Machine.internalEvents(
|
|
29
|
+
|
|
30
|
+
export const CounterEvents = Machine.events(
|
|
72
31
|
Schema.TaggedUnion({
|
|
73
|
-
|
|
74
|
-
|
|
32
|
+
Start: {},
|
|
33
|
+
Increment: {},
|
|
34
|
+
Stop: {}
|
|
75
35
|
})
|
|
76
36
|
)
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Pass these descriptors to `Machine.make`; the event descriptor is the public
|
|
80
|
-
handle, so do not introduce a tagged-union binding used only by an event helper.
|
|
81
|
-
Construct new state values through the target or initial
|
|
82
|
-
builder's `.from(...)` method. Both event constructors and state `.from(...)`
|
|
83
|
-
defer schema construction until planning, so validation failures remain typed
|
|
84
|
-
machine errors. Use
|
|
85
|
-
`Schema.TaggedClass` when a case needs class methods or nominal class identity;
|
|
86
|
-
the deferred constructors preserve that identity after decoding.
|
|
87
|
-
|
|
88
|
-
## Hard invariants
|
|
89
|
-
|
|
90
|
-
- `Machine.make({ initial })` expects a function, including for `Schema.Void`
|
|
91
|
-
input.
|
|
92
|
-
- State, emit, input, and output schemas validate their runtime boundaries.
|
|
93
|
-
Event schemas provide decoders, but the local public/internal distinction is
|
|
94
|
-
a TypeScript boundary; Cluster additionally validates public commands at its
|
|
95
|
-
transport boundary.
|
|
96
|
-
- Return snapshots or typed target-builder results from transitions. Do not
|
|
97
|
-
return raw decoded state values.
|
|
98
|
-
- Transition and lifecycle callbacks are synchronous. Put asynchronous work in
|
|
99
|
-
an invoked Effect, logic process, or child machine and handle its lifecycle
|
|
100
|
-
with `onDone`, `onFailure`, and `onSnapshot`.
|
|
101
|
-
- Put data on the narrowest state where it is valid. Put data shared by sibling
|
|
102
|
-
phases on their compound parent.
|
|
103
|
-
- Declare finality only in the state definition. Do not put `type: "final"` in
|
|
104
|
-
a handler.
|
|
105
|
-
- Every declared output schema needs a matching handler implementation before
|
|
106
|
-
planning or execution.
|
|
107
|
-
- Handler `ancestors` keys are full dotted paths.
|
|
108
|
-
- Invoke lifetimes follow state entry and exit, not the spelling of the target
|
|
109
|
-
builder.
|
|
110
|
-
- Handle every typed invoked Effect failure with `onFailure`. Defects and
|
|
111
|
-
interruption terminate the owning machine.
|
|
112
|
-
- Reuse an exported child descriptor for inline invocation, `sendTo`, and child
|
|
113
|
-
lookup. Independently constructed descriptors are equivalent only when both
|
|
114
|
-
their id and machine identity match.
|
|
115
|
-
- `events` is the public machine-input protocol. `internalEvents` contains
|
|
116
|
-
machine-local raised events. `parent: Machine.parent(events)` requires an
|
|
117
|
-
owner, while `parent: Machine.optionalParent(events)` permits a root and
|
|
118
|
-
exposes an optional owner. `emittedEvents` describes outward ephemeral
|
|
119
|
-
notifications and is never delivered implicitly to a parent.
|
|
120
|
-
- Event tags in `events` and `internalEvents` must be disjoint.
|
|
121
|
-
- Event tags must also be unique within each protocol list.
|
|
122
|
-
|
|
123
|
-
## Canonical API choices
|
|
124
|
-
|
|
125
|
-
Choose one helper from the intent, and reach for the lower-level form only when
|
|
126
|
-
its extra control is required:
|
|
127
|
-
|
|
128
|
-
- Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the
|
|
129
|
-
returned `make` or `resume`. Use `AtomMachine.make(machine)` and
|
|
130
|
-
`AtomMachine.resume(machine, snapshot)` for service-free machines.
|
|
131
|
-
- Use the state-local `invoke: (from) => ...` selector: `from.effect` for
|
|
132
|
-
one-shot work, `from.stream` for repeated externally produced values,
|
|
133
|
-
`from.timer` for a timer, `from.logic` for reusable process logic, and
|
|
134
|
-
`from.child` for a complete child statechart. Its chain preserves owner state
|
|
135
|
-
and source channels across lifecycle handlers. Inside `.handle(...)`, `self`
|
|
136
|
-
and any declared `parent` use the owning definition's exact protocols.
|
|
137
|
-
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
|
|
138
|
-
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
|
|
139
|
-
invocation is addressable only when `from.logic` receives that address
|
|
140
|
-
explicitly.
|
|
141
|
-
- Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
|
|
142
|
-
`stop`. These operations record closed machine commands and do not run Effects.
|
|
143
|
-
|
|
144
|
-
## Atomic, compound, parallel, and history states
|
|
145
|
-
|
|
146
|
-
### Topology is a validity boundary
|
|
147
|
-
|
|
148
|
-
Design the state tree so invalid domain situations cannot be constructed. A
|
|
149
|
-
parallel node is not merely a convenient grouping of related concepts: it
|
|
150
|
-
declares the Cartesian product of its regions. Every combination must be
|
|
151
|
-
meaningful in snapshots, explicit targets, decoding, and resume.
|
|
152
|
-
|
|
153
|
-
If a handler reads a sibling region to decide whether entering its target is
|
|
154
|
-
legal, treat that as a topology smell and try a compound hierarchy first. Do
|
|
155
|
-
not move the same invariant into a disabled UI control, redundant event field,
|
|
156
|
-
or invoked-service failure. Cross-region reads remain useful for coordinating
|
|
157
|
-
genuinely independent regions and for projecting snapshots into views.
|
|
158
|
-
|
|
159
|
-
Place an invoked Effect or resource-dependent state beneath the state that
|
|
160
|
-
guarantees the resource exists. Exiting the owner should structurally exit and
|
|
161
|
-
interrupt all dependent work.
|
|
162
|
-
|
|
163
|
-
### Inline topology by default; extract only repeated states
|
|
164
|
-
|
|
165
|
-
Prefer writing the complete topology inline in `Machine.states`. A one-off
|
|
166
|
-
compound or parallel area is easier to understand in place, and extracting it
|
|
167
|
-
does not improve its types. Use `Machine.state` only when the same active state
|
|
168
|
-
definition is mounted more than once. Tagged schemas are already reusable and
|
|
169
|
-
do not need `Machine.state`.
|
|
170
37
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
initial:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
const States = Machine.states({
|
|
184
|
-
root: {
|
|
185
|
-
type: "parallel",
|
|
186
|
-
states: {
|
|
187
|
-
trading: {
|
|
188
|
-
type: "parallel",
|
|
189
|
-
states: {
|
|
190
|
-
slot1: TradingSlot,
|
|
191
|
-
slot2: TradingSlot,
|
|
192
|
-
slot3: TradingSlot,
|
|
193
|
-
slot4: TradingSlot,
|
|
194
|
-
slot5: TradingSlot,
|
|
195
|
-
slot6: TradingSlot
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
// Other explicit regions stay visible here.
|
|
38
|
+
export const CounterMachine = Machine.make({
|
|
39
|
+
id: "Counter",
|
|
40
|
+
states: CounterStates.states,
|
|
41
|
+
events: CounterEvents,
|
|
42
|
+
initial: (to) => to.Idle()
|
|
43
|
+
}).handle({
|
|
44
|
+
Idle: {
|
|
45
|
+
on: {
|
|
46
|
+
Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 }))
|
|
199
47
|
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
checks child keys and the compound `initial` at the reusable definition. It is
|
|
206
|
-
not a second model builder, does not define handlers, and does not accept
|
|
207
|
-
history or choice nodes as roots. `Machine.states` remains the complete model
|
|
208
|
-
boundary and captures every mount independently.
|
|
209
|
-
|
|
210
|
-
For a finite family of paths, bind the template to that definition instead of
|
|
211
|
-
maintaining a parallel string table:
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
const inSessionPath = <const Slot extends TeamSlot>(slot: Slot) =>
|
|
215
|
-
States.path(`root.trading.slot${slot}.InSession`)
|
|
216
|
-
|
|
217
|
-
States.matches(snapshot, inSessionPath(slot))
|
|
218
|
-
AtomMachine.matches(machineAtom, inSessionPath(slot))
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
`States.path` is a compile-time identity helper. It accepts a literal or a
|
|
222
|
-
finite template-literal union only when every member is an active path in this
|
|
223
|
-
tree. Renaming a slot or child therefore breaks the path helper at its
|
|
224
|
-
definition rather than leaving a stale catalog.
|
|
225
|
-
|
|
226
|
-
Use the definition-bound snapshot type when a query genuinely needs the full
|
|
227
|
-
machine snapshot:
|
|
228
|
-
|
|
229
|
-
```ts
|
|
230
|
-
const offeredIfSlot = (
|
|
231
|
-
snapshot: Machine.Snapshot<typeof States>,
|
|
232
|
-
slot: TeamSlot
|
|
233
|
-
) =>
|
|
234
|
-
!States.matches(snapshot, inSessionPath(slot))
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Do not derive this type with `Parameters<typeof States.get>[0]`; that depends
|
|
238
|
-
on overload order and does not express ownership by the state definition.
|
|
239
|
-
|
|
240
|
-
The same extractor accepts a machine when that is the object exported at the
|
|
241
|
-
consumer boundary. Use `Value` for a decoded schema-backed state payload and
|
|
242
|
-
`SnapshotAt` for the snapshot rooted at one active path:
|
|
243
|
-
|
|
244
|
-
```ts
|
|
245
|
-
type Complete = Machine.Snapshot<typeof machine>
|
|
246
|
-
type Session = Machine.Value<typeof States, "root.trading.InSession">
|
|
247
|
-
type Trading = Machine.SnapshotAt<typeof machine, "root.trading">
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
`Value` accepts only paths that own a schema, matching `States.get`.
|
|
251
|
-
`SnapshotAt` also accepts structural paths, matching `States.getSnapshot`.
|
|
252
|
-
Both reject stale or misspelled paths. Prefer these definition- or
|
|
253
|
-
machine-bound forms over `.cases.Case.Type`, `typeof States.states`, or
|
|
254
|
-
composing `Machine.Machine.States` with raw-tree path extractors.
|
|
255
|
-
|
|
256
|
-
An active state does not need a schema unless it owns data. Omit `schema` for
|
|
257
|
-
control-only atomic, compound, parallel, and final states. In particular, use
|
|
258
|
-
`{}` instead of an empty tagged-union case or tagged class:
|
|
259
|
-
|
|
260
|
-
```ts
|
|
261
|
-
const States = Machine.states({
|
|
262
|
-
Idle: {},
|
|
263
|
-
Form: {
|
|
264
|
-
initial: "Editing",
|
|
265
|
-
states: {
|
|
266
|
-
Editing: {},
|
|
267
|
-
Saving: State.cases.Saving
|
|
48
|
+
},
|
|
49
|
+
Running: {
|
|
50
|
+
on: {
|
|
51
|
+
Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })),
|
|
52
|
+
Stop: (to) => to.full.Idle()
|
|
268
53
|
}
|
|
269
54
|
}
|
|
270
55
|
})
|
|
271
|
-
|
|
272
|
-
initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
|
|
273
56
|
```
|
|
274
57
|
|
|
275
|
-
|
|
276
|
-
they are active, targetable, matchable, receive lifecycle handlers, and appear
|
|
277
|
-
in snapshots. They do not have a state value:
|
|
58
|
+
Each step has one job:
|
|
278
59
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
States.matches(snapshot, "Form") // allowed
|
|
287
|
-
States.getSnapshot(snapshot, "Form") // allowed
|
|
288
|
-
States.get(snapshot, "Form") // type error: no value schema
|
|
289
|
-
```
|
|
60
|
+
- `Machine.states` declares the state tree and the data owned by each state.
|
|
61
|
+
- `Machine.events` declares the public messages the machine accepts and returns
|
|
62
|
+
typed event constructors.
|
|
63
|
+
- `Machine.make` joins the state tree, event protocol, input, and initial state.
|
|
64
|
+
- `.handle` implements the behavior of every active state and returns the
|
|
65
|
+
machine to export.
|
|
290
66
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
also omitted from `ancestors`; an immediate structural containing state is
|
|
294
|
-
typed as `undefined`. Add `schema` when a state begins to own data or needs runtime
|
|
295
|
-
validation and persistence for that data.
|
|
67
|
+
Chain `.handle` from `Machine.make`. Do not store the intermediate definition
|
|
68
|
+
when the module exports one machine implementation.
|
|
296
69
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
Use a named `Schema.TaggedClass` instead when a standalone state benefits from
|
|
301
|
-
class identity. Do not bury one-off tagged schema declarations inside
|
|
302
|
-
`Machine.states`.
|
|
70
|
+
State builders construct the next snapshot. Use `.from(...)` when a state owns
|
|
71
|
+
data. The machine validates that input through the state schema while it plans
|
|
72
|
+
the transition.
|
|
303
73
|
|
|
304
|
-
|
|
74
|
+
The examples below show one modeling decision at a time. They omit unchanged
|
|
75
|
+
state and event declarations already shown above.
|
|
305
76
|
|
|
306
|
-
|
|
307
|
-
|
|
77
|
+
Start the implemented machine at the application boundary and send events
|
|
78
|
+
through the exported descriptor:
|
|
308
79
|
|
|
309
80
|
```ts
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const FormStates = Machine.states({
|
|
313
|
-
Form: {
|
|
314
|
-
initial: "Editing",
|
|
315
|
-
states: {
|
|
316
|
-
Editing: {},
|
|
317
|
-
Saving: FormState.cases.Saving
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
})
|
|
321
|
-
```
|
|
81
|
+
import { Effect } from "effect"
|
|
322
82
|
|
|
323
|
-
|
|
83
|
+
const program = Effect.gen(function*() {
|
|
84
|
+
const counter = yield* Machine.start(CounterMachine)
|
|
324
85
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
Screen: {
|
|
328
|
-
type: "parallel",
|
|
329
|
-
states: {
|
|
330
|
-
network: {
|
|
331
|
-
initial: "Online",
|
|
332
|
-
states: {
|
|
333
|
-
Online: {},
|
|
334
|
-
Offline: {}
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
panel: {
|
|
338
|
-
initial: "Closed",
|
|
339
|
-
states: {
|
|
340
|
-
Closed: {},
|
|
341
|
-
Open: {}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
86
|
+
yield* counter.send(CounterEvents.Start())
|
|
87
|
+
yield* counter.send(CounterEvents.Increment())
|
|
346
88
|
})
|
|
347
89
|
```
|
|
348
90
|
|
|
349
|
-
|
|
350
|
-
builders. The same rule applies when a local or branch target enters an
|
|
351
|
-
inactive nested parallel state.
|
|
91
|
+
## Make impossible states unrepresentable
|
|
352
92
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
example above. If even one combination must be prevented for correctness, use
|
|
356
|
-
a compound hierarchy or redesign the regions.
|
|
93
|
+
A finite state describes how the machine behaves now. State data holds values
|
|
94
|
+
needed while that mode is active.
|
|
357
95
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
computes its value:
|
|
96
|
+
Do not model mutually exclusive modes with separate flags such as `loading`,
|
|
97
|
+
`data`, and `error`. Those fields permit combinations such as loading with both
|
|
98
|
+
data and an error. Put the modes in the state tree instead:
|
|
362
99
|
|
|
363
100
|
```ts
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
type: "final",
|
|
368
|
-
output: Schema.String
|
|
369
|
-
}
|
|
101
|
+
const RequestState = Schema.TaggedUnion({
|
|
102
|
+
Ready: { value: Schema.String },
|
|
103
|
+
Failed: { message: Schema.String }
|
|
370
104
|
})
|
|
371
105
|
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
Done: {
|
|
378
|
-
output: () => "done"
|
|
379
|
-
}
|
|
106
|
+
const RequestStates = Machine.states({
|
|
107
|
+
Idle: {},
|
|
108
|
+
Loading: {},
|
|
109
|
+
Ready: RequestState.cases.Ready,
|
|
110
|
+
Failed: RequestState.cases.Failed
|
|
380
111
|
})
|
|
381
112
|
```
|
|
382
113
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
Enter a compound or parallel state through its declared initial configuration
|
|
387
|
-
with `.initial`. This is available on top-level state methods under
|
|
388
|
-
`target.full` and compatible nested state methods under `target.local` and
|
|
389
|
-
`target.branch`; atomic and final state methods do not expose it:
|
|
390
|
-
|
|
391
|
-
```ts
|
|
392
|
-
Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ teamId: "team-1" }))
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
Return the `.initial` transition directly when the selected state owns no data
|
|
396
|
-
and the transition has no commands to enqueue:
|
|
397
|
-
|
|
398
|
-
```ts
|
|
399
|
-
Close: (to) => to.full.closed.initial
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
Do not manually reconstruct the declared initial descendants at ordinary entry
|
|
403
|
-
transitions. Reserve explicit descendant builders for deliberately non-default
|
|
404
|
-
configurations and for replacing an already-active parallel root with one
|
|
405
|
-
complete canonical configuration. `Machine.make({ initial })` still constructs
|
|
406
|
-
the first complete snapshot through its initial selector; that selector
|
|
407
|
-
statically restricts a compound node to its declared initial child.
|
|
408
|
-
|
|
409
|
-
The definition-time `.initial` property is a topology value. The exact
|
|
410
|
-
resolver `target` is still a callable runtime builder.
|
|
411
|
-
|
|
412
|
-
The selected state's own value is passed directly to `initial(value)` or
|
|
413
|
-
constructed inside planning with `initial.from(input)`. A structural selected
|
|
414
|
-
state uses `initial()`.
|
|
415
|
-
|
|
416
|
-
When a declared initial child owns a schema, its parent implements
|
|
417
|
-
`initialize`. The context's `builder` is already bound to that child, so it
|
|
418
|
-
cannot accidentally select a state that differs from the definition:
|
|
419
|
-
|
|
420
|
-
```ts
|
|
421
|
-
opened: {
|
|
422
|
-
initialize: ({ state, builder }) =>
|
|
423
|
-
builder.from({ requestId: state.requestId })
|
|
424
|
-
}
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
A parallel initializer supplies every schema-valued direct region with a
|
|
428
|
-
fluent completion builder. Structural regions are omitted:
|
|
114
|
+
The machine can now be `Loading`, `Ready`, or `Failed`. It cannot construct a
|
|
115
|
+
snapshot that represents two of those modes at once.
|
|
429
116
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
```
|
|
117
|
+
Use this test when deciding between a state and a field: if the value changes
|
|
118
|
+
which events the machine should handle, which work runs, or how the machine
|
|
119
|
+
behaves, model it as a state. Otherwise, keep it as data on the state that owns
|
|
120
|
+
it.
|
|
436
121
|
|
|
437
|
-
|
|
438
|
-
provide their own `initialize` implementations. Missing implementations and
|
|
439
|
-
incomplete parallel builders are reported at `handle(...)`. Builder `.from`
|
|
440
|
-
inputs are decoded by the machine, so schema failures remain typed machine
|
|
441
|
-
failures. An explicit snapshot target that manually selects all children does
|
|
442
|
-
not use `initialize`.
|
|
122
|
+
## Put data on the lowest state that owns it
|
|
443
123
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
124
|
+
State data should exist only while it is valid. Put it on the lowest node whose
|
|
125
|
+
active subtree needs it. If several sibling states need the same data, their
|
|
126
|
+
compound parent owns it.
|
|
447
127
|
|
|
448
128
|
```ts
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
states: {
|
|
454
|
-
shipping: Shipping,
|
|
455
|
-
payment: {
|
|
456
|
-
schema: Payment,
|
|
457
|
-
initial: "cardEntry",
|
|
458
|
-
states: {
|
|
459
|
-
cardEntry: CardEntry,
|
|
460
|
-
verifying: Verifying
|
|
461
|
-
}
|
|
462
|
-
},
|
|
463
|
-
recent: { type: "history" },
|
|
464
|
-
exact: { type: "history", history: "deep" }
|
|
465
|
-
}
|
|
129
|
+
const DocumentState = Schema.TaggedUnion({
|
|
130
|
+
Open: {
|
|
131
|
+
documentId: Schema.String,
|
|
132
|
+
draft: Schema.String
|
|
466
133
|
},
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
Every history node needs a source-independent default for the first use. The
|
|
472
|
-
default is a complete root snapshot containing the history owner:
|
|
473
|
-
|
|
474
|
-
```ts
|
|
475
|
-
checkout: {
|
|
476
|
-
history: {
|
|
477
|
-
recent: { default: () => initialCheckoutSnapshot },
|
|
478
|
-
exact: { default: () => initialCheckoutSnapshot }
|
|
134
|
+
SaveFailed: {
|
|
135
|
+
message: Schema.String
|
|
479
136
|
}
|
|
480
|
-
}
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
Target it without a value:
|
|
484
|
-
|
|
485
|
-
```ts
|
|
486
|
-
Resume: (to) => to.history.checkout.exact
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
Each declared history leaf is a topology value and can be returned directly
|
|
490
|
-
when no resolver work is needed.
|
|
491
|
-
|
|
492
|
-
Deep history restores the complete remembered subtree and its decoded values.
|
|
493
|
-
Shallow history restores only parent and direct-child values. If the remembered
|
|
494
|
-
child is compound, its configured initial child needs a freshly constructed
|
|
495
|
-
value, so implement `initialize` only on paths required by shallow history:
|
|
496
|
-
|
|
497
|
-
```ts
|
|
498
|
-
payment: {
|
|
499
|
-
initialize: ({ state, builder }) =>
|
|
500
|
-
builder.from({ cardNumber: `attempt-${state.attempt}` })
|
|
501
|
-
}
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
A nested default must include every ancestor above its owner and every region
|
|
505
|
-
of any parallel ancestor. The containing branch is checked statically, so an
|
|
506
|
-
unrelated root, a sibling compound branch, a direct-owner-only nested snapshot,
|
|
507
|
-
or an incomplete parallel configuration is rejected. A canonical nested
|
|
508
|
-
default looks like:
|
|
137
|
+
})
|
|
509
138
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
139
|
+
const DocumentStates = Machine.states({
|
|
140
|
+
Closed: {},
|
|
141
|
+
Open: {
|
|
142
|
+
// Editing, Saving, and SaveFailed all need the document and draft.
|
|
143
|
+
schema: DocumentState.cases.Open,
|
|
144
|
+
initial: "Editing",
|
|
145
|
+
states: {
|
|
146
|
+
Editing: {},
|
|
147
|
+
Saving: {},
|
|
148
|
+
// Only this state owns an error message.
|
|
149
|
+
SaveFailed: DocumentState.cases.SaveFailed
|
|
517
150
|
}
|
|
518
151
|
}
|
|
519
|
-
}
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
On first use from an inactive root, this complete configuration is entered. If
|
|
523
|
-
a parallel ancestor is already active, unaffected active regions are retained.
|
|
524
|
-
Once a history record exists, shallow or deep recorded restoration wins over
|
|
525
|
-
the default.
|
|
526
|
-
|
|
527
|
-
The machine's readiness type tracks missing defaults and shallow initializers.
|
|
528
|
-
History is an overwriteable register, not a stack: restoration does not consume
|
|
529
|
-
it, and the next parent exit replaces it. Entry actions and invokes run again;
|
|
530
|
-
prior effects, machine instances, and timers are not rewound.
|
|
531
|
-
|
|
532
|
-
## Choosing a target
|
|
533
|
-
|
|
534
|
-
| Builder | Use it when | What it preserves |
|
|
535
|
-
| ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
536
|
-
| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
|
|
537
|
-
| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
|
|
538
|
-
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
|
|
539
|
-
| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or a source-independent complete default containing that owner before the first capture |
|
|
540
|
-
|
|
541
|
-
Definition-time instructions that only identify topology are values:
|
|
542
|
-
`to.none`, `to.full.Flow.initial`, `to.history.Flow.recent`, and
|
|
543
|
-
`to.local.with`. State and choice destinations remain calls, such as
|
|
544
|
-
`to.full.Running()` and `to.local.Routing()`, because those calls select the
|
|
545
|
-
node. Resolver-time builders also remain callable because they construct and,
|
|
546
|
-
for named branches, brand runtime evidence such as `select.unchanged()`.
|
|
547
|
-
|
|
548
|
-
Use `to.local.with` when a descendant transition updates the nearest
|
|
549
|
-
schema-backed compound value while retaining that same compound scope:
|
|
550
|
-
|
|
551
|
-
```ts
|
|
552
|
-
Play: (to) =>
|
|
553
|
-
to.local.with.resolve(({ containingState, target }) =>
|
|
554
|
-
target.from({ ...containingState, playing: true }, (flow) => flow.Playing.from()))
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
Entering an inactive parallel state through `target.local` or `target.branch`
|
|
558
|
-
requires a complete callback with one selection per region. A parallel state
|
|
559
|
-
that is already active remains partially addressable through `target.branch`;
|
|
560
|
-
unmentioned active regions are preserved.
|
|
561
|
-
|
|
562
|
-
These describe configuration construction, not automatic process restart.
|
|
563
|
-
Machine planning compares active paths and derives the actual exit and entry
|
|
564
|
-
sets. A `target.full` result with the same active paths can update values without
|
|
565
|
-
exiting shared states. To force the source to exit and enter again:
|
|
566
|
-
|
|
567
|
-
```ts
|
|
568
|
-
Refresh: (to) =>
|
|
569
|
-
to.full.Ready().resolve(({ state, target }) => target.from({ value: state.value }), { reenter: true })
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
When no resolver is needed and the selected builder supports zero-argument
|
|
573
|
-
construction, return the selected target directly. This applies the same
|
|
574
|
-
default construction as `target.from()`; the compiler rejects the shorthand
|
|
575
|
-
when state data or nested configuration is required. Append `.reenter()` only
|
|
576
|
-
when restart semantics are intentional:
|
|
577
|
-
|
|
578
|
-
```ts
|
|
579
|
-
Finish: (to) => to.full.Done()
|
|
580
|
-
Restart: (to) => to.none.reenter()
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
Do not use `target.full` merely because it is easiest to discover. Prefer the
|
|
584
|
-
narrowest builder that expresses the intended configuration change.
|
|
585
|
-
|
|
586
|
-
Every state builder method has two construction forms:
|
|
587
|
-
|
|
588
|
-
```ts
|
|
589
|
-
target(decodedReady)
|
|
590
|
-
target.from({ value: event.value })
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
The direct call accepts the schema's decoded `Type`. `.from` accepts its
|
|
594
|
-
`~type.make.in`, so callers do not need to invoke a TaggedUnion case's `make`
|
|
595
|
-
or instantiate a TaggedClass. The machine resolves `.from` with
|
|
596
|
-
`schema.makeEffect` during planning. Constructor defaults and class identity
|
|
597
|
-
are retained; refinement failures use `MachineSchemaDecodeError` at the state
|
|
598
|
-
boundary rather than throwing synchronously. This applies recursively to
|
|
599
|
-
initial, full, local, branch, compound, parallel, final, and `local.with`
|
|
600
|
-
builders.
|
|
601
|
-
|
|
602
|
-
If `{}` satisfies the schema's constructor input, omit it:
|
|
603
|
-
|
|
604
|
-
```ts
|
|
605
|
-
target.from()
|
|
606
|
-
target.from((flow) => flow.Idle.from())
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
This shorthand also applies to schemas whose constructor fields are all
|
|
610
|
-
optional or defaulted. It does not make required fields optional. Compound and
|
|
611
|
-
parallel builders still require a callback selecting their active child or
|
|
612
|
-
every active region. Omitted input is normalized to `{}` and still passes
|
|
613
|
-
through `schema.makeEffect`, including refinements.
|
|
614
|
-
|
|
615
|
-
## Reading state and structural ancestors
|
|
616
|
-
|
|
617
|
-
`Machine.states` returns typed helpers:
|
|
618
|
-
|
|
619
|
-
```ts
|
|
620
|
-
States.get(snapshot, "Route.Ready")
|
|
621
|
-
States.getWithParents(snapshot, "Route.Ready.Editing")
|
|
622
|
-
States.getSnapshot(snapshot, "Route.Ready")
|
|
623
|
-
States.matches(snapshot, "Route.Ready.Saving")
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
Snapshots returned by `getSnapshot` can be queried again with `get`,
|
|
627
|
-
`getSnapshot`, or `matches`. Paths remain absolute and are restricted to the
|
|
628
|
-
extracted snapshot and its descendants:
|
|
629
|
-
|
|
630
|
-
```ts
|
|
631
|
-
const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready"))
|
|
632
|
-
States.matches(ready, "Route.Ready.Saving")
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
All paths are checked against the definition. `get` and `getWithParents` accept
|
|
636
|
-
only schema-backed paths; use `matches` or `getSnapshot` for any active path.
|
|
637
|
-
`context.containingState` is the immediate typed state value (`undefined` at a
|
|
638
|
-
root or when that state is schema-less). `context.ancestors` contains only
|
|
639
|
-
valued structural ancestors. This is separate from `context.parent`, which is
|
|
640
|
-
present only when declared by the machine. `Machine.parent` makes it a required
|
|
641
|
-
owning-machine target; `Machine.optionalParent` makes it a target or
|
|
642
|
-
`undefined`. Use full state paths when another ancestor value is needed:
|
|
643
|
-
|
|
644
|
-
```ts
|
|
645
|
-
ancestors["Route.Ready"]
|
|
646
|
-
ancestors["Route.Ready.Editing"]
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
Do not guess short properties such as `ancestors.Ready`.
|
|
650
|
-
|
|
651
|
-
### Inspecting the full transition configuration
|
|
652
|
-
|
|
653
|
-
Event, `always`, and `onDone` transition contexts include a fully typed
|
|
654
|
-
`snapshot`. It is the complete logical snapshot at the beginning of that
|
|
655
|
-
microstep, before any selected transition is applied:
|
|
656
|
-
|
|
657
|
-
```ts
|
|
658
|
-
BufferReady: (to) =>
|
|
659
|
-
to.branches({
|
|
660
|
-
online: { target: to.local.Playing() },
|
|
661
|
-
unchanged: { target: to.none }
|
|
662
|
-
}).resolve(({ snapshot, select }) =>
|
|
663
|
-
States.matches(snapshot, "Player.Network.Online")
|
|
664
|
-
? select.online.from()
|
|
665
|
-
: select.unchanged()
|
|
666
|
-
```
|
|
667
|
-
|
|
668
|
-
Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
|
|
669
|
-
`States.getSnapshot` helpers for cross-region reads. Parallel transitions
|
|
670
|
-
selected in one microstep receive the same capture. Synchronous handlers use
|
|
671
|
-
that captured value and cannot consult live runtime state later.
|
|
672
|
-
|
|
673
|
-
Before using a cross-region read to permit or reject a target, verify that all
|
|
674
|
-
combinations of the parallel regions are valid. If the check excludes an
|
|
675
|
-
invalid combination, move the invariant into a compound hierarchy. Observer,
|
|
676
|
-
view, diagnostic, and test queries do not have this concern.
|
|
677
|
-
|
|
678
|
-
Do not expect `snapshot` in entry, exit, invoke, initializer, history-default,
|
|
679
|
-
or choice contexts. Choice is an important soundness boundary: a startup or
|
|
680
|
-
chained choice can run without a complete stable configuration containing the
|
|
681
|
-
pseudo-source, so the API does not fabricate a partial `Machine.Snapshot`.
|
|
682
|
-
|
|
683
|
-
### State annotations
|
|
684
|
-
|
|
685
|
-
Attach active-state metadata through Effect Schema:
|
|
686
|
-
|
|
687
|
-
```ts
|
|
688
|
-
const Saving = State.cases.Saving.annotate({
|
|
689
|
-
title: "Saving document",
|
|
690
|
-
description: "Persisting local changes to the server",
|
|
691
|
-
documentation: "https://docs.example.test/saving"
|
|
692
152
|
})
|
|
693
153
|
```
|
|
694
154
|
|
|
695
|
-
`
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
they cannot change behavior, identity, or targeting. Visualization may show a
|
|
699
|
-
title, while the structural path remains authoritative.
|
|
700
|
-
|
|
701
|
-
When sibling state payloads share fields, destructure away the source
|
|
702
|
-
discriminator and construct the destination through its target builder:
|
|
703
|
-
|
|
704
|
-
```ts
|
|
705
|
-
Submit: (to) =>
|
|
706
|
-
to.local.Saving().resolve(({ state, target }) => {
|
|
707
|
-
const { _tag: _, ...fields } = state
|
|
708
|
-
return target.from({ ...fields, attempt: 1 })
|
|
709
|
-
})
|
|
710
|
-
```
|
|
711
|
-
|
|
712
|
-
The target schema remains responsible for defaults, transforms, refinements,
|
|
713
|
-
and class identity. Prefer moving broadly shared data to the compound parent
|
|
714
|
-
rather than copying it through every phase.
|
|
155
|
+
Do not copy `documentId` and `draft` into every child. Copies can disagree after
|
|
156
|
+
a transition. Do not move `message` to `Open` either. That would allow an error
|
|
157
|
+
message while `Editing` or `Saving` is active.
|
|
715
158
|
|
|
716
|
-
##
|
|
159
|
+
## Put shared behavior on the lowest common ancestor
|
|
717
160
|
|
|
718
|
-
|
|
719
|
-
|
|
161
|
+
Hierarchy owns behavior as well as data. Define a transition on the lowest
|
|
162
|
+
compound state whose children share it.
|
|
720
163
|
|
|
721
164
|
```ts
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
invalid: { target: to.none }
|
|
726
|
-
}).resolve(({ state, select }) => state.valid
|
|
727
|
-
? select.valid.from({ draft: state.draft })
|
|
728
|
-
: select.invalid()
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
Every installed event, `always`, `onDone`, choice, and invoke lifecycle handler
|
|
732
|
-
receives a bound `to` selector. A direct transition selects one target and calls
|
|
733
|
-
its `resolve` method. A branching transition calls `to.branches` with every
|
|
734
|
-
possible target, then uses ordinary TypeScript control flow in `resolve` to return one
|
|
735
|
-
typed `select` builder. Branch keys are stable testing and inspection identities;
|
|
736
|
-
an optional `title` controls presentation and otherwise defaults to the key.
|
|
737
|
-
Selecting a branch whose target is `to.none` handles the transition without a
|
|
738
|
-
destination while retaining queued commands, raised events, and emitted events.
|
|
739
|
-
|
|
740
|
-
Set `declinable: true` only when the resolver may decide that its transition is
|
|
741
|
-
not enabled. The flag adds a typed `decline()` capability to that resolver and
|
|
742
|
-
permits its opaque result:
|
|
743
|
-
|
|
744
|
-
```ts
|
|
745
|
-
Submit: (to) =>
|
|
746
|
-
to.branches({
|
|
747
|
-
accepted: { target: to.local.Saving() },
|
|
748
|
-
consumed: { target: to.none }
|
|
749
|
-
}).resolve(({ event, select, decline }) => {
|
|
750
|
-
if (!belongsToThisState(event)) return decline()
|
|
751
|
-
return event.consume ? select.consumed() : select.accepted.from()
|
|
752
|
-
}, { declinable: true })
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
Declining discards that resolver's enqueue buffer and resumes hierarchical
|
|
756
|
-
event or eventless selection at the next eligible ancestor. If no candidate
|
|
757
|
-
accepts, the trigger is unhandled. This is deliberately different from
|
|
758
|
-
`to.none`, which consumes the trigger. `decline()` is absent and its result is
|
|
759
|
-
rejected unless the literal flag is present. Choice and initial routing remain
|
|
760
|
-
total and cannot decline. Static inspection exposes the distinction through
|
|
761
|
-
`TransitionDefinition.acceptance` without executing resolver code. Completion
|
|
762
|
-
and invocation outcomes have no ancestor candidate; declining one ignores that
|
|
763
|
-
lifecycle occurrence and leaves the current configuration active.
|
|
764
|
-
|
|
765
|
-
The `branches` callback runs once when handlers are installed. Its record uses
|
|
766
|
-
the deterministic ECMAScript property order for presentation and `branchIndex`;
|
|
767
|
-
array-index and symbol keys are rejected. Treat the string key as semantic:
|
|
768
|
-
reordering named properties may change their display index, but visualizers,
|
|
769
|
-
coverage, and trace verification identify each branch by its key.
|
|
770
|
-
|
|
771
|
-
`reenter: true` remains meaningful with `to.none`: the source exits and
|
|
772
|
-
enters again while its logical configuration is retained.
|
|
773
|
-
|
|
774
|
-
Closed statechart and machine operations use `enqueue`:
|
|
775
|
-
|
|
776
|
-
```ts
|
|
777
|
-
Submit: (to) =>
|
|
778
|
-
to.local.Saving().resolve(({ target }, enqueue) => {
|
|
779
|
-
enqueue.emit(Emissions.SaveRequested())
|
|
780
|
-
return target.from()
|
|
165
|
+
const DocumentEvents = Machine.events(
|
|
166
|
+
Schema.TaggedUnion({
|
|
167
|
+
Close: {}
|
|
781
168
|
})
|
|
782
|
-
```
|
|
783
|
-
|
|
784
|
-
Declare emission constructors separately from machine inputs:
|
|
785
|
-
|
|
786
|
-
```ts
|
|
787
|
-
const Emissions = Machine.emittedEvents(SaveRequested, AuditRecorded)
|
|
788
|
-
|
|
789
|
-
const definition = Machine.make({
|
|
790
|
-
events: Commands,
|
|
791
|
-
internalEvents: InternalEvents,
|
|
792
|
-
emittedEvents: Emissions,
|
|
793
|
-
// ...
|
|
794
|
-
})
|
|
795
|
-
```
|
|
796
|
-
|
|
797
|
-
`enqueue.raise(...)` is a same-macrostep input to self. `enqueue.sendTo(...)`
|
|
798
|
-
targets a machine mailbox and is processed later. `enqueue.emit(...)` is neither:
|
|
799
|
-
it publishes a one-off outward notification. Observe it with
|
|
800
|
-
`ref.emissions`, a hot non-replayed `Stream` that completes with the machine.
|
|
801
|
-
`ref.changes` is stateful and begins with the current lifecycle snapshot.
|
|
802
|
-
Use `Machine.prepare(machine)` to obtain `changes` and `emissions` before
|
|
803
|
-
initialization. Subscribe to the desired stream and then evaluate
|
|
804
|
-
`prepared.start`. `Machine.start(machine)` remains the one-step convenience
|
|
805
|
-
when startup observation is unnecessary. Emissions are still never retained or
|
|
806
|
-
replayed; state remains the representation for facts that must be retained.
|
|
807
|
-
|
|
808
|
-
```ts
|
|
809
|
-
const prepared = yield* Machine.prepare(machine)
|
|
810
|
-
yield* prepared.emissions.pipe(
|
|
811
|
-
Stream.runForEach(handleEmission),
|
|
812
|
-
Effect.forkScoped({ startImmediately: true })
|
|
813
|
-
)
|
|
814
|
-
const ref = yield* prepared.start
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
`prepared.inspection` is a third, operational stream. It covers the root and
|
|
818
|
-
its complete local ownership tree rather than one machine protocol. Subscribe
|
|
819
|
-
before `prepared.start` when creation and initialization records matter:
|
|
820
|
-
|
|
821
|
-
```ts
|
|
822
|
-
const prepared = yield* Machine.prepare(machine)
|
|
823
|
-
yield* prepared.inspection.pipe(
|
|
824
|
-
Stream.runForEach((event) => Console.log(event.sequence, event.subject.id, event._tag)),
|
|
825
|
-
Effect.forkScoped({ startImmediately: true })
|
|
826
169
|
)
|
|
827
|
-
const ref = yield* prepared.start
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
`Machine.Inspection.Event` is a closed union:
|
|
831
|
-
|
|
832
|
-
- `Created`, `Initialized`, and `StartFailed` describe process startup;
|
|
833
|
-
- `EventSent` records accepted mailbox delivery and `EventProcessed` records
|
|
834
|
-
the committed macrostep, including retained transitions, raised events,
|
|
835
|
-
emissions, commands, and entry/exit paths for each microstep;
|
|
836
|
-
- `StateChanged` describes direct updates made by generic `Logic`;
|
|
837
|
-
- `Emitted` records actual outward notification publication;
|
|
838
|
-
- `ActivityStarted` and `ActivityStopped` describe Effect and timer invokes;
|
|
839
|
-
- `Terminated` carries the final `done`, `error`, or `stopped` snapshot.
|
|
840
|
-
|
|
841
|
-
Every record has a root-local `sequence`, `rootSessionId`, and `subject`.
|
|
842
|
-
`deliveryId` correlates acceptance with processing; `macrostepId` correlates
|
|
843
|
-
work caused by one statechart input. `source` is present for sends originating
|
|
844
|
-
inside the inspected tree. `origin` distinguishes a root, state-owned invoke,
|
|
845
|
-
and explicit spawn. Child machine and generic process protocols are erased to
|
|
846
|
-
`unknown` because one stream can contain unrelated types.
|
|
847
|
-
|
|
848
|
-
Inspection is hot, non-replayed, never fails, and completes with the prepared
|
|
849
|
-
root. It is not a replacement for `changes`, which retains the latest lifecycle
|
|
850
|
-
snapshot, or `emissions`, which remains the typed domain-notification channel.
|
|
851
|
-
Invalid decoded inputs or emissions still fail the owning machine through its
|
|
852
|
-
typed `MachineSchemaDecodeError`; inspection never turns validation into a
|
|
853
|
-
throw or a stream failure.
|
|
854
|
-
|
|
855
|
-
Session ids are deterministic and unique only inside one prepared local tree
|
|
856
|
-
(`machine:0`, `machine:1`, ...). Do not persist them as globally unique actor
|
|
857
|
-
ids. Distributed identity, placement, delivery, and request correlation belong
|
|
858
|
-
to Effect Cluster and its entity, runner, shard, and request identifiers. A
|
|
859
|
-
Cluster adapter may translate local inspection records into telemetry, but the
|
|
860
|
-
core machine stream does not claim cross-node identity or ordering.
|
|
861
|
-
|
|
862
|
-
For child-to-parent input, export a public builder protocol and reuse it at both
|
|
863
|
-
composition boundaries:
|
|
864
170
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
events: ChildEvents,
|
|
870
|
-
parent: Machine.parent(ParentEvents),
|
|
871
|
-
// ...
|
|
171
|
+
const DocumentMachine = Machine.make({
|
|
172
|
+
states: DocumentStates.states,
|
|
173
|
+
events: DocumentEvents,
|
|
174
|
+
initial: (to) => to.Closed()
|
|
872
175
|
}).handle({
|
|
873
|
-
|
|
176
|
+
Closed: {},
|
|
177
|
+
Open: {
|
|
874
178
|
on: {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
179
|
+
// All Open children close the document in the same way.
|
|
180
|
+
Close: (to) => to.full.Closed()
|
|
181
|
+
},
|
|
182
|
+
states: {
|
|
183
|
+
Editing: {},
|
|
184
|
+
Saving: {},
|
|
185
|
+
SaveFailed: {}
|
|
879
186
|
}
|
|
880
187
|
}
|
|
881
188
|
})
|
|
882
|
-
|
|
883
|
-
const parent = Machine.make({
|
|
884
|
-
events: Machine.events(ParentCommands, ParentEvents),
|
|
885
|
-
// ...
|
|
886
|
-
})
|
|
887
|
-
```
|
|
888
|
-
|
|
889
|
-
Invoking the child under a parent that lacks any required parent event is a
|
|
890
|
-
type error. Within child handlers, `parent` accepts only that protocol and is
|
|
891
|
-
not optional. Root APIs reject the machine. Use
|
|
892
|
-
`Machine.optionalParent(ParentEvents)` instead when the same definition must
|
|
893
|
-
also run as a root; then `parent` is optional. With no declaration, callbacks
|
|
894
|
-
have no `parent` property. `self` accepts the machine's public inputs. Both
|
|
895
|
-
targets are minimal `MachineTarget<Event>` values. Neither machine target is a
|
|
896
|
-
structural state value; use
|
|
897
|
-
`containingState` and `ancestors` for statechart ancestry.
|
|
898
|
-
|
|
899
|
-
Atom-backed machines retain the same transient semantics. Use
|
|
900
|
-
`AtomMachine.emissions(machineAtom)` for a root and
|
|
901
|
-
`AtomMachine.childEmissions(childAtom)` for the currently active child. Both
|
|
902
|
-
return streams requiring the corresponding `AtomRegistry`; emissions are not
|
|
903
|
-
stored as atom state.
|
|
904
|
-
|
|
905
|
-
Use `AtomMachine.inspection(machineAtom)` for root-scoped operational records.
|
|
906
|
-
It installs the subscription before a fresh bridge starts, so initialization,
|
|
907
|
-
owned children, and activities are visible without storing inspection records
|
|
908
|
-
in atom state.
|
|
909
|
-
|
|
910
|
-
For asynchronous validation or persistence, invoke an Effect or child machine
|
|
911
|
-
from the state and handle its typed success or failure event in a later
|
|
912
|
-
transition. This keeps `(state, event) => [nextState, commands]` synchronous.
|
|
913
|
-
|
|
914
|
-
Plans have a discriminated completion result:
|
|
915
|
-
|
|
916
|
-
```ts
|
|
917
|
-
const planned = yield * Machine.plan(machine, state, event)
|
|
918
|
-
if (planned.done) {
|
|
919
|
-
planned.output // schema-derived structural terminal union
|
|
920
|
-
}
|
|
921
189
|
```
|
|
922
190
|
|
|
923
|
-
|
|
924
|
-
child
|
|
925
|
-
|
|
926
|
-
contribute `undefined`; active atomic roots do not. Handler behavior can make
|
|
927
|
-
the type conservative—for example, a root `onDone` transition can move away
|
|
928
|
-
before that root becomes the machine's terminal result.
|
|
191
|
+
The machine checks the deepest active state first, then its ancestors. Put a
|
|
192
|
+
handler on a child when that state needs different behavior. Keep the shared
|
|
193
|
+
case on the parent instead of repeating it in every child.
|
|
929
194
|
|
|
930
|
-
|
|
931
|
-
an event for the parent. Both operations validate their schemas.
|
|
195
|
+
## Treat events as the domain protocol
|
|
932
196
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
`
|
|
936
|
-
union handled inside the statechart:
|
|
197
|
+
An event tells the machine what was requested or what happened. Name events
|
|
198
|
+
after domain actions and outcomes. Do not expose state setters such as
|
|
199
|
+
`SetLoading` or `SetError`.
|
|
937
200
|
|
|
938
201
|
```ts
|
|
939
|
-
const
|
|
940
|
-
Schema.TaggedUnion({
|
|
941
|
-
Save: {}
|
|
942
|
-
})
|
|
943
|
-
)
|
|
944
|
-
const InternalEvents = Machine.internalEvents(
|
|
202
|
+
export const CheckoutEvents = Machine.events(
|
|
945
203
|
Schema.TaggedUnion({
|
|
946
|
-
|
|
947
|
-
|
|
204
|
+
Submit: {},
|
|
205
|
+
Cancel: {}
|
|
948
206
|
})
|
|
949
207
|
)
|
|
950
208
|
|
|
951
|
-
const
|
|
952
|
-
states:
|
|
953
|
-
events:
|
|
954
|
-
|
|
955
|
-
|
|
209
|
+
const CheckoutMachine = Machine.make({
|
|
210
|
+
states: CheckoutStates.states,
|
|
211
|
+
events: CheckoutEvents,
|
|
212
|
+
initial: (to) => to.Editing()
|
|
213
|
+
}).handle({
|
|
214
|
+
Editing: {
|
|
215
|
+
on: {
|
|
216
|
+
Submit: (to) => to.full.Submitting()
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
Submitting: {
|
|
220
|
+
on: {
|
|
221
|
+
// Cancel has meaning while work is in progress.
|
|
222
|
+
Cancel: (to) => to.full.Editing()
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
Complete: {}
|
|
956
226
|
})
|
|
957
227
|
```
|
|
958
228
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
```ts
|
|
962
|
-
yield* ref.send(Events.Save())
|
|
963
|
-
enqueue.raise(InternalEvents.Saved({ id: "entry-1" }))
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
`Machine.events` exposes only public constructors;
|
|
967
|
-
`Machine.internalEvents` exposes only machine-local constructors. Both flatten
|
|
968
|
-
configured tagged unions and preserve tagged classes, finite discriminator
|
|
969
|
-
unions, required inputs, and constructor defaults. A constructor returns an
|
|
970
|
-
opaque instruction whose `_tag` is available for activity metadata. Its decoded
|
|
971
|
-
fields are intentionally unavailable until the owning machine processes it.
|
|
972
|
-
|
|
973
|
-
Invalid constructor input fails `Machine.plan` or the running machine with
|
|
974
|
-
`MachineSchemaDecodeError`; creating the instruction itself never performs
|
|
975
|
-
schema validation. APIs that explicitly retain decoded events, such as manual
|
|
976
|
-
model-testing scenarios or transport messages, can receive complete event
|
|
977
|
-
objects directly.
|
|
229
|
+
The sender requests `Submit`. The machine decides whether `Submit` has a
|
|
230
|
+
transition in the current state. The sender does not choose `Submitting`.
|
|
978
231
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
232
|
+
Carry facts that the machine cannot read from its current snapshot in the event
|
|
233
|
+
payload. Do not copy current state into an event to help a handler reconstruct
|
|
234
|
+
what the machine already knows.
|
|
982
235
|
|
|
983
|
-
Use
|
|
984
|
-
|
|
985
|
-
```ts
|
|
986
|
-
type PublicEvent = Machine.Machine.InputEvent<typeof definition>
|
|
987
|
-
type AnyHandledEvent = Machine.Machine.Event<typeof definition>
|
|
988
|
-
type StartupInput = Machine.Machine.Input<typeof definition>
|
|
989
|
-
type StartupInputSchema = Machine.Machine.InputSchema<typeof definition>
|
|
990
|
-
```
|
|
236
|
+
## Use parallel states only for independent modes
|
|
991
237
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
|
|
997
|
-
events or constructions returned by `Machine.events`. Transition handlers
|
|
998
|
-
receive only decoded events. Raised events additionally accept constructions
|
|
999
|
-
from `Machine.internalEvents`; outward notifications accept constructions from
|
|
1000
|
-
`Machine.emittedEvents`. The
|
|
1001
|
-
local planner and runtime intentionally share the complete decoder to support
|
|
1002
|
-
those internal deliveries, so JavaScript or `any` can bypass the local public
|
|
1003
|
-
distinction.
|
|
1004
|
-
Cluster RPC payloads are additionally decoded against the public `events`
|
|
1005
|
-
schemas at the transport boundary. Never repeat an `_tag` within a list or
|
|
1006
|
-
across both configuration lists.
|
|
1007
|
-
|
|
1008
|
-
Do not extract `enqueue`, target builders, transition contexts, command or
|
|
1009
|
-
inspection unions, or event-construction `ReturnType`s into application helper
|
|
1010
|
-
APIs. Keep commands inside transition resolvers, where the owning state,
|
|
1011
|
-
protocols, references, and capabilities are inferred. Likewise, do not add
|
|
1012
|
-
Atom `State` or `Event` aliases: selectors infer from their bridge, while
|
|
1013
|
-
consumer props use `Snapshot`, `Value`, or `InputEvent` from the exported state
|
|
1014
|
-
definition or machine.
|
|
1015
|
-
|
|
1016
|
-
## Recoverable state-scoped work
|
|
1017
|
-
|
|
1018
|
-
Use `from.effect` for one-shot work. Lifecycle callbacks receive the typed
|
|
1019
|
-
Effect channels and can transition directly:
|
|
238
|
+
A compound state activates one direct child. A parallel state activates one
|
|
239
|
+
child in every region. A parallel model therefore accepts the full product of
|
|
240
|
+
those regions.
|
|
1020
241
|
|
|
1021
242
|
```ts
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
243
|
+
const ScreenStates = Machine.states({
|
|
244
|
+
Screen: {
|
|
245
|
+
type: "parallel",
|
|
246
|
+
states: {
|
|
247
|
+
connection: {
|
|
248
|
+
initial: "Online",
|
|
249
|
+
states: {
|
|
250
|
+
Online: {},
|
|
251
|
+
Offline: {}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
panel: {
|
|
255
|
+
initial: "Closed",
|
|
256
|
+
states: {
|
|
257
|
+
Closed: {},
|
|
258
|
+
Open: {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
1029
262
|
}
|
|
1030
263
|
})
|
|
1031
264
|
```
|
|
1032
265
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
self-interrupts fails the parent. `onDone` is required when the output is not
|
|
1036
|
-
`never`; `onFailure` is required when the typed error is not `never`. Handlers
|
|
1037
|
-
are forbidden when their channel is `never`.
|
|
1038
|
-
|
|
1039
|
-
The source may also be a function of the owning state's entry context when it
|
|
1040
|
-
needs `state`, `containingState`, `ancestors`, or the entry `event`. Source construction
|
|
1041
|
-
errors, defects, and interruption are machine failures rather than a second
|
|
1042
|
-
phase in `onFailure`.
|
|
1043
|
-
|
|
1044
|
-
Use a Stream invocation for repeated values that are not themselves machine
|
|
1045
|
-
events. `onElement` maps each value into an owner transition, while `onDone`
|
|
1046
|
-
handles normal Stream completion and `onFailure` handles the typed Stream error:
|
|
266
|
+
This model permits all four combinations: online with a closed panel, online
|
|
267
|
+
with an open panel, offline with a closed panel, and offline with an open panel.
|
|
1047
268
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
invoke: (from) =>
|
|
1052
|
-
from.stream("broadcast-channel", () => messages)
|
|
1053
|
-
.onElement((to) =>
|
|
1054
|
-
to.none.resolve(({ element }, enqueue) => {
|
|
1055
|
-
enqueue.raise(Events.MessageReceived({ message: element }))
|
|
1056
|
-
}))
|
|
1057
|
-
.onDone((to) => to.none)
|
|
1058
|
-
.onFailure((to) => to.full.Disconnected().resolve(({ error, target }) => target.from({ error })))
|
|
1059
|
-
}
|
|
1060
|
-
})
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
Element delivery is owner-scoped and backpressured: the Stream pulls again only
|
|
1064
|
-
after the selected parent macrostep commits. Exiting or reentering the owner
|
|
1065
|
-
interrupts the Stream and runs its finalizers. A later entry starts a fresh
|
|
1066
|
-
Stream. Stream defects and self-interruption fail the owning machine.
|
|
269
|
+
If one combination would break a domain rule, do not repair it with a UI check
|
|
270
|
+
or repeated cross-region conditions. Change the topology. A compound hierarchy
|
|
271
|
+
can place a mode only under the parent where it is valid.
|
|
1067
272
|
|
|
1068
|
-
|
|
1069
|
-
`to.none.resolve(...)` when it also enqueues commands; a block resolver may
|
|
1070
|
-
omit its return because it is contextually typed to return `undefined`.
|
|
273
|
+
## Let states own running work
|
|
1071
274
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
275
|
+
Put asynchronous work on the state whose meaning requires that work. The
|
|
276
|
+
machine starts the work when it enters the state and interrupts it when it exits.
|
|
277
|
+
Handle expected success and failure as transitions.
|
|
1075
278
|
|
|
1076
279
|
```ts
|
|
1077
|
-
|
|
1078
|
-
Loading: {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
.onDone((to) => to.full.Loaded().resolve(({ output, target }) => target.from({ user: output })))
|
|
1082
|
-
.onFailure((to) => to.full.LoadFailed().resolve(({ error, target }) => target.from({ error })))
|
|
1083
|
-
}
|
|
280
|
+
const LoadState = Schema.TaggedUnion({
|
|
281
|
+
Loading: { documentId: Schema.String },
|
|
282
|
+
Ready: { content: Schema.String },
|
|
283
|
+
Failed: { message: Schema.String }
|
|
1084
284
|
})
|
|
1085
|
-
```
|
|
1086
285
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
286
|
+
const LoadStates = Machine.states({
|
|
287
|
+
Idle: {},
|
|
288
|
+
Loading: LoadState.cases.Loading,
|
|
289
|
+
Ready: LoadState.cases.Ready,
|
|
290
|
+
Failed: LoadState.cases.Failed
|
|
291
|
+
})
|
|
1090
292
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
events:
|
|
1094
|
-
|
|
1095
|
-
parent: Machine.parent(ParentEvents),
|
|
1096
|
-
// ...
|
|
293
|
+
const LoadMachine = Machine.make({
|
|
294
|
+
states: LoadStates.states,
|
|
295
|
+
events: Machine.events(),
|
|
296
|
+
initial: (to) => to.Idle()
|
|
1097
297
|
}).handle({
|
|
1098
|
-
|
|
298
|
+
Idle: {},
|
|
299
|
+
Loading: {
|
|
1099
300
|
invoke: (from) =>
|
|
1100
|
-
from
|
|
1101
|
-
.
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
}
|
|
301
|
+
from
|
|
302
|
+
.effect("load-document", ({ state }) => loadDocument(state.documentId))
|
|
303
|
+
.onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ content: output })))
|
|
304
|
+
.onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) })))
|
|
305
|
+
},
|
|
306
|
+
Ready: {},
|
|
307
|
+
Failed: {}
|
|
1108
308
|
})
|
|
1109
309
|
```
|
|
1110
310
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
machine
|
|
1114
|
-
than one activity.
|
|
311
|
+
`loadDocument` may require Effect services. Those requirements remain on the
|
|
312
|
+
implemented machine type, so the runtime must provide them when it starts the
|
|
313
|
+
machine.
|
|
1115
314
|
|
|
1116
|
-
|
|
315
|
+
Do not start a promise inside a transition callback. A transition has no
|
|
316
|
+
lifetime in which to own that work. A state does.
|
|
1117
317
|
|
|
1118
|
-
|
|
1119
|
-
machine.handle({
|
|
1120
|
-
Waiting: {
|
|
1121
|
-
invoke: (from) =>
|
|
1122
|
-
from.timer("clear-status", "3 seconds")
|
|
1123
|
-
.onDone((to) => to.full.Clear())
|
|
1124
|
-
}
|
|
1125
|
-
})
|
|
1126
|
-
```
|
|
318
|
+
## Keep transition decisions synchronous
|
|
1127
319
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
cancellation behavior, but `from.timer` records timer intent and exposes a
|
|
1131
|
-
static duration through `Machine.activityDefinitions`. Effect sources are
|
|
1132
|
-
always factories evaluated when their state is entered. For reusable process
|
|
1133
|
-
logic, pass a state-local lifecycle id plus `{ logic, address }` to
|
|
1134
|
-
`from.logic`. TypeScript checks the address protocol against the logic event
|
|
1135
|
-
protocol. Lifecycle ids and addresses serve different purposes and must both
|
|
1136
|
-
be explicit.
|
|
1137
|
-
|
|
1138
|
-
## Invoked child statecharts
|
|
1139
|
-
|
|
1140
|
-
Create a complete child-statechart descriptor:
|
|
320
|
+
A transition should choose the next state from the current snapshot and event.
|
|
321
|
+
Use ordinary TypeScript conditions when one event has several valid outcomes.
|
|
1141
322
|
|
|
1142
323
|
```ts
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
324
|
+
const ReviewEvents = Machine.events(
|
|
325
|
+
Schema.TaggedUnion({
|
|
326
|
+
Evaluate: { score: Schema.Number }
|
|
327
|
+
})
|
|
328
|
+
)
|
|
1147
329
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
330
|
+
const ReviewMachine = Machine.make({
|
|
331
|
+
states: ReviewStates.states,
|
|
332
|
+
events: ReviewEvents,
|
|
333
|
+
initial: (to) => to.Pending()
|
|
334
|
+
}).handle({
|
|
335
|
+
Pending: {
|
|
336
|
+
on: {
|
|
337
|
+
Evaluate: (to) =>
|
|
338
|
+
to
|
|
339
|
+
.branches({
|
|
340
|
+
accepted: { target: to.full.Accepted() },
|
|
341
|
+
rejected: { target: to.full.Rejected() }
|
|
342
|
+
})
|
|
343
|
+
.resolve(({ event, select }) =>
|
|
344
|
+
event.score >= 80
|
|
345
|
+
? select.accepted.from()
|
|
346
|
+
: select.rejected.from()
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
Accepted: {},
|
|
351
|
+
Rejected: {}
|
|
1155
352
|
})
|
|
1156
353
|
```
|
|
1157
354
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
parentRef.child(Editor)
|
|
1163
|
-
parentAtom.child(Editor)
|
|
1164
|
-
```
|
|
1165
|
-
|
|
1166
|
-
Child emissions remain on the child's hot `emissions` stream; they are never
|
|
1167
|
-
delivered implicitly to the parent. A child sends an input explicitly with
|
|
1168
|
-
`enqueue.sendTo(parent, ParentEvents.Example())`. `onSnapshot`, `onDone`, and
|
|
1169
|
-
`onFailure` are direct parent transitions. Invoked child IDs must be unique
|
|
1170
|
-
while simultaneously active.
|
|
1171
|
-
|
|
1172
|
-
Descriptors with the same id and machine identity address the same child, even
|
|
1173
|
-
when independently constructed. The descriptor objects themselves are not
|
|
1174
|
-
canonicalized. Prefer exporting one descriptor as the application boundary.
|
|
1175
|
-
Use the separate
|
|
1176
|
-
`Machine.childAddress<Event>(id)` constructor only for lower-level process
|
|
1177
|
-
logic that does not have a complete machine descriptor.
|
|
1178
|
-
|
|
1179
|
-
### Inspecting state-owned activities
|
|
1180
|
-
|
|
1181
|
-
Use `Machine.activityDefinitions(machine)` to inspect invokes without running
|
|
1182
|
-
them. Static inline fluent invocation definitions expose serializable ownership
|
|
1183
|
-
metadata:
|
|
1184
|
-
|
|
1185
|
-
```ts
|
|
1186
|
-
Machine.activityDefinitions(machine)
|
|
1187
|
-
// [{ source: "Loading", id: "load-timeout", type: "timer",
|
|
1188
|
-
// duration: "10s" }]
|
|
1189
|
-
```
|
|
1190
|
-
|
|
1191
|
-
Child machines expose descriptor identity, never their runtime or
|
|
1192
|
-
implementation. Function-valued sources and durations are represented as
|
|
1193
|
-
dynamic because inspection must not evaluate user code.
|
|
1194
|
-
|
|
1195
|
-
## AtomMachine and React
|
|
1196
|
-
|
|
1197
|
-
`AtomMachine.make(machine, ...input)` works when the machine has no external
|
|
1198
|
-
service requirements. For an application runtime, the canonical form is to
|
|
1199
|
-
bind it once at the composition boundary:
|
|
1200
|
-
|
|
1201
|
-
```ts
|
|
1202
|
-
const runtime = Atom.runtime(AppLayer)
|
|
1203
|
-
const machines = AtomMachine.bind(runtime)
|
|
1204
|
-
const machineAtom = machines.make(machine, input)
|
|
1205
|
-
```
|
|
1206
|
-
|
|
1207
|
-
One bridge owns one machine instance per `AtomRegistry`. In React:
|
|
1208
|
-
|
|
1209
|
-
1. Render a `RegistryProvider` from `@effect/atom-react`.
|
|
1210
|
-
2. Keep a component-owned bridge referentially stable, normally with
|
|
1211
|
-
`useMemo`.
|
|
1212
|
-
3. Use scalar dependencies that define when the machine should restart.
|
|
1213
|
-
4. Expect a new bridge identity to create a new instance once mounted.
|
|
1214
|
-
|
|
1215
|
-
The root bridge shapes are:
|
|
1216
|
-
|
|
1217
|
-
```ts
|
|
1218
|
-
machineAtom.state
|
|
1219
|
-
// Atom<AsyncResult<State, StartError>>
|
|
1220
|
-
|
|
1221
|
-
machineAtom.result
|
|
1222
|
-
// Atom<AsyncResult<State, StartError | RuntimeError>>
|
|
1223
|
-
|
|
1224
|
-
machineAtom.snapshot
|
|
1225
|
-
// Atom<AsyncResult<RuntimeSnapshot<State, RuntimeError, Output>, StartError>>
|
|
1226
|
-
```
|
|
1227
|
-
|
|
1228
|
-
`state` remains a successful last-state value after a post-start runtime
|
|
1229
|
-
failure. Prefer `result` for ordinary fail-aware UI state. Use `snapshot` when
|
|
1230
|
-
the full lifecycle, completion output, cause, or stopped status matters.
|
|
1231
|
-
|
|
1232
|
-
Use equality-aware selectors instead of repeating AsyncResult/Option unwrapping.
|
|
1233
|
-
Paths and selected values are inferred from the bridge snapshot, so do not pass
|
|
1234
|
-
the `DefinedStates` object:
|
|
1235
|
-
|
|
1236
|
-
```ts
|
|
1237
|
-
AtomMachine.select(machineAtom, "Ready")
|
|
1238
|
-
AtomMachine.selectSnapshot(machineAtom, "Ready")
|
|
1239
|
-
AtomMachine.matches(machineAtom, "Ready.Saving")
|
|
1240
|
-
AtomMachine.selectChild(childAtom, "Editing")
|
|
1241
|
-
AtomMachine.selectSnapshotChild(childAtom, "Editing")
|
|
1242
|
-
AtomMachine.matchesChild(childAtom, "Editing")
|
|
1243
|
-
```
|
|
1244
|
-
|
|
1245
|
-
`select` returns only the decoded state value. Use `selectSnapshot` when a
|
|
1246
|
-
component needs the selected node's compound or parallel child topology.
|
|
355
|
+
Given the same snapshot and event, the handler should choose the same result.
|
|
356
|
+
Do not read the clock, generate randomness, call a service, or await work while
|
|
357
|
+
choosing a transition. Receive such values in an event or produce them through
|
|
358
|
+
state-owned work first.
|
|
1247
359
|
|
|
1248
|
-
|
|
1249
|
-
atom. Define it at a stable composition boundary or memoize it when constructing
|
|
1250
|
-
it inside a component.
|
|
360
|
+
## Test paths and invariants
|
|
1251
361
|
|
|
1252
|
-
|
|
1253
|
-
the
|
|
1254
|
-
|
|
1255
|
-
```ts
|
|
1256
|
-
const editorAtom = parentAtom.child(Editor)
|
|
1257
|
-
|
|
1258
|
-
editorAtom.state
|
|
1259
|
-
// Atom<AsyncResult<Option<State>, StartError>>
|
|
1260
|
-
```
|
|
1261
|
-
|
|
1262
|
-
`Option.none()` means the child is not currently active or has not become
|
|
1263
|
-
active yet. A child command while inactive fails with `ChildNotActiveError`.
|
|
1264
|
-
Use `AtomMachine.ChildMachineAtom<typeof Editor>` for a descriptor-based child prop,
|
|
1265
|
-
or `AtomMachine.ChildOf<typeof parentAtom, typeof Editor>` to infer the exact
|
|
1266
|
-
bridge from a parent.
|
|
1267
|
-
|
|
1268
|
-
## Persistence
|
|
1269
|
-
|
|
1270
|
-
Use `Machine.encodeSnapshot` and `Machine.decodeSnapshot` for validated logical
|
|
1271
|
-
statechart data. Persist machine identity and an application migration/version
|
|
1272
|
-
next to the encoded snapshot.
|
|
1273
|
-
|
|
1274
|
-
The canonical resumption boundary is explicit:
|
|
1275
|
-
|
|
1276
|
-
```ts
|
|
1277
|
-
const encoded = yield* Machine.encodeSnapshot(machine, snapshot)
|
|
1278
|
-
const decoded = yield* Machine.decodeSnapshot(machine, encoded)
|
|
1279
|
-
const ref = yield* Machine.resume(machine, decoded)
|
|
1280
|
-
```
|
|
1281
|
-
|
|
1282
|
-
`Machine.Snapshot` is the decoded, process-local representation. It may retain
|
|
1283
|
-
`Schema.Class` instances and capabilities that cannot cross a JSON boundary.
|
|
1284
|
-
`Machine.EncodedSnapshot` is different: successful `encodeSnapshot` calls
|
|
1285
|
-
guarantee canonical `Schema.Json` for every active value, completion output,
|
|
1286
|
-
and history value. Schema codecs convert rich values such as `Date`, `bigint`,
|
|
1287
|
-
and `undefined` to their declared JSON forms. Cycles, functions, symbols, and
|
|
1288
|
-
opaque values without a JSON representation fail with the typed
|
|
1289
|
-
`MachineSchemaEncodeError`; they never become a later `JSON.stringify` defect.
|
|
1290
|
-
|
|
1291
|
-
Keep DOM nodes, open handles, services, and similar capabilities in an Effect
|
|
1292
|
-
service or UI adapter. Local events may carry those values when they stay inside
|
|
1293
|
-
one process. Cluster public input events, persisted state, and completion
|
|
1294
|
-
outputs are transport protocols and must instead declare JSON-compatible
|
|
1295
|
-
encoded forms; use an explicit transform or `Schema.toCodecJson` where the
|
|
1296
|
-
canonical codec is the intended wire contract.
|
|
1297
|
-
|
|
1298
|
-
Pass only a decoded `Machine.Snapshot` to `resume`; encoded or arbitrary
|
|
1299
|
-
transport data belongs at `decodeSnapshot`. Resumption validates and normalizes
|
|
1300
|
-
the logical snapshot again, then publishes it as the fresh runtime's first
|
|
1301
|
-
state. It does not call the initial function, require machine input, or include
|
|
1302
|
-
initial-only failures and services in its Effect type.
|
|
1303
|
-
|
|
1304
|
-
Encoding does not preserve:
|
|
1305
|
-
|
|
1306
|
-
- running invokes or spawned children;
|
|
1307
|
-
- subscriptions, queued events, fibers, scopes, timers, or services;
|
|
1308
|
-
- the machine definition;
|
|
1309
|
-
- application migration metadata.
|
|
1310
|
-
|
|
1311
|
-
`resume` reconstructs runtime ownership from logical state only:
|
|
1312
|
-
|
|
1313
|
-
- no historical entry, transition, completion, eventless, raise, or emit work
|
|
1314
|
-
is replayed;
|
|
1315
|
-
- completion and history records survive but do not retrigger `onDone`;
|
|
1316
|
-
- active-state invokes start once in ordinary ancestor/document order with
|
|
1317
|
-
`Machine.InitialEvent`;
|
|
1318
|
-
- inline Effects restart, child machines start fresh from their normal initial
|
|
1319
|
-
state, and timers restart their complete duration;
|
|
1320
|
-
- inactive invokes, spawned children, child snapshots, elapsed timer time, and
|
|
1321
|
-
prior `RuntimeSnapshot` status/errors are not restored;
|
|
1322
|
-
- a final logical snapshot creates an immediately completed ref;
|
|
1323
|
-
- `resume` itself does not evaluate `always` or `onDone`, including transitions
|
|
1324
|
-
newly enabled by a changed machine definition. Later events use ordinary
|
|
1325
|
-
planning semantics.
|
|
1326
|
-
|
|
1327
|
-
Use `AtomMachine.resume(machine, decoded)` or
|
|
1328
|
-
`AtomMachine.bind(runtime).resume(machine, decoded)` for the same contract in a
|
|
1329
|
-
lazy atom bridge. Registry disposal stops the fresh invokes and timers exactly
|
|
1330
|
-
as it does for `AtomMachine.make`.
|
|
1331
|
-
|
|
1332
|
-
This is not durable runtime restoration. `ClusterMachine` has a separate
|
|
1333
|
-
checkpoint/planning contract and process-local restrictions; do not substitute
|
|
1334
|
-
`Machine.resume` for cluster recovery.
|
|
1335
|
-
|
|
1336
|
-
## Testing machine semantics
|
|
1337
|
-
|
|
1338
|
-
Import planner testing tools from the dedicated entrypoint:
|
|
362
|
+
Test the statechart as a graph. Send domain events, inspect reached states, and
|
|
363
|
+
state the rules that every trace must preserve. Do not duplicate the handler's
|
|
364
|
+
branches inside the test.
|
|
1339
365
|
|
|
1340
366
|
```ts
|
|
1341
367
|
import { MachineTest } from "@typeonce/effect-machine/testing"
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
```ts
|
|
1357
|
-
const invariant = MachineTest.invariants(machine)
|
|
1358
|
-
|
|
1359
|
-
const laws = [
|
|
1360
|
-
invariant.state("balance is never negative", ({ snapshot }) =>
|
|
1361
|
-
snapshot.value.balance >= 0 || "negative balance"),
|
|
1362
|
-
invariant.step("withdrawal is exact", ({ before, event, after }) =>
|
|
1363
|
-
event._tag !== "Withdraw" ||
|
|
1364
|
-
after.value.balance === before.value.balance - event.amount),
|
|
1365
|
-
invariant.trace("all inputs were planned", ({ trace }) =>
|
|
1366
|
-
trace.steps.length === trace.scenario.events.length)
|
|
1367
|
-
]
|
|
1368
|
-
```
|
|
1369
|
-
|
|
1370
|
-
State laws observe settled states by default. Select `"microsteps"`, `"all"`,
|
|
1371
|
-
or `"final"` only when the law requires that evidence. A `when` condition with
|
|
1372
|
-
no matches is explicitly `untested`; use
|
|
1373
|
-
`require: { minObservations: 1 }` when the current trace must exercise it.
|
|
1374
|
-
Prefer `assertInvariants` inside FastCheck properties because it succeeds with
|
|
1375
|
-
`void`. Use `checkInvariants` when the test needs the per-law report.
|
|
1376
|
-
|
|
1377
|
-
For systematic planner exploration, provide a finite abstraction explicitly:
|
|
1378
|
-
|
|
1379
|
-
```ts
|
|
1380
|
-
const explored = yield * MachineTest.explore(machine, {
|
|
1381
|
-
events: ({ snapshot }) => eventRepresentatives(snapshot),
|
|
1382
|
-
stateKey: ({ snapshot }) => logicalStateKey(snapshot),
|
|
1383
|
-
limits: { maxDepth: 20, maxStates: 1_000 },
|
|
1384
|
-
invariants: laws
|
|
1385
|
-
})
|
|
1386
|
-
```
|
|
1387
|
-
|
|
1388
|
-
The event callback returns concrete representatives, not schemas or
|
|
1389
|
-
arbitraries. Include meaningful boundary values based on the current snapshot.
|
|
1390
|
-
The key defines which snapshots are treated as equivalent; it must retain every
|
|
1391
|
-
piece of data that can change the future behavior being tested. A coarse key
|
|
1392
|
-
can make exploration finite but under-approximate behavior.
|
|
1393
|
-
|
|
1394
|
-
`assertReachable` returns the shortest witness. `assertUnreachable` succeeds
|
|
1395
|
-
only when `explored.completeness` is `Complete`. Never interpret a truncated
|
|
1396
|
-
depth, state, or transition frontier as an unreachability proof. The explorer
|
|
1397
|
-
retains cycles as graph edges but does not enumerate every path around them;
|
|
1398
|
-
use a separate temporal/path model when a law depends on repeated traversal
|
|
1399
|
-
rather than logical-state reachability.
|
|
1400
|
-
|
|
1401
|
-
Do not encode application invariants as guards merely to make them testable.
|
|
1402
|
-
Keep ordinary TypeScript branching in transition handlers unless a choice is
|
|
1403
|
-
part of the statechart topology. Invariants independently verify the resulting
|
|
1404
|
-
trace without changing production transition selection.
|
|
1405
|
-
|
|
1406
|
-
### Live event causality
|
|
1407
|
-
|
|
1408
|
-
Use a probe when a test must establish that one event was processed by a
|
|
1409
|
-
running statechart rather than merely accepted by its mailbox:
|
|
1410
|
-
|
|
1411
|
-
```ts
|
|
1412
|
-
const ref = yield * Machine.start(machine)
|
|
1413
|
-
const probe = yield * MachineTest.probe(machine, ref)
|
|
1414
|
-
const step = yield * probe.sendAndAwait(event)
|
|
1415
|
-
```
|
|
1416
|
-
|
|
1417
|
-
Inspect `step.before`, `step.after`, `step.plan`, `step.handled`, and
|
|
1418
|
-
`step.configurationChanged`. An ignored event, including one for which every
|
|
1419
|
-
eligible candidate declines, has `handled: false` and an empty microstep list,
|
|
1420
|
-
but still completes its acknowledgement. A targetless handler has
|
|
1421
|
-
`handled: true` even if its before and after snapshots are equal.
|
|
1422
|
-
|
|
1423
|
-
Do not use a probe as a substitute for a domain completion event. The
|
|
1424
|
-
acknowledgement covers the submitted event's synchronous macrostep, state
|
|
1425
|
-
commit, emissions, and invoke startup; it does not wait for an invoke or timer
|
|
1426
|
-
to complete. Application code should continue to use `MachineRef.send`.
|
|
1427
|
-
|
|
1428
|
-
For generated runtime command sequences, select delivery behavior by name:
|
|
1429
|
-
|
|
1430
|
-
```ts
|
|
1431
|
-
yield* MachineTest.runCausalCommands(probe, commands, causalModel)
|
|
1432
|
-
yield* MachineTest.runEnqueuedCommands(ref, commands, enqueueModel)
|
|
1433
|
-
```
|
|
1434
|
-
|
|
1435
|
-
Prefer `runCausalCommands` for semantic and reference-model properties. Every
|
|
1436
|
-
accepted send produces a `SendProcessed` result containing its exact
|
|
1437
|
-
`ProbeStep`, including ignored and targetless events. A machine processing
|
|
1438
|
-
error fails that exact command and retains its checked prefix for shrinking.
|
|
1439
|
-
The next command does not begin until the submitted send's managed macrostep
|
|
1440
|
-
has completed.
|
|
1441
|
-
|
|
1442
|
-
Use `probe.await.until(predicate)` in a causal model step only when the
|
|
1443
|
-
assertion also requires later timer, invoke, or child activity. It observes the
|
|
1444
|
-
current runtime snapshot before waiting for subsequent publications, so it
|
|
1445
|
-
does not miss work that completed immediately after the causal boundary.
|
|
1446
|
-
|
|
1447
|
-
Use `runEnqueuedCommands` only when outstanding mailbox work is intentional,
|
|
1448
|
-
such as burst ordering and queue behavior. Its `RuntimeSynchronization`
|
|
1449
|
-
policies observe public snapshots but do not turn send acceptance into causal
|
|
1450
|
-
completion. Do not use the deprecated `runRuntimeCommands` name in new code;
|
|
1451
|
-
it is an alias for enqueue behavior and hides that important distinction.
|
|
1452
|
-
|
|
1453
|
-
For semantic laws over live execution, bind runtime invariant constructors to
|
|
1454
|
-
the machine and use the law-oriented causal verifier:
|
|
1455
|
-
|
|
1456
|
-
```ts
|
|
1457
|
-
const invariant = MachineTest.runtimeInvariants(machine)
|
|
1458
|
-
|
|
1459
|
-
const laws = [
|
|
1460
|
-
invariant.snapshot("balance never becomes negative", ({ snapshot }) =>
|
|
1461
|
-
snapshot.state.value.balance >= 0
|
|
1462
|
-
),
|
|
1463
|
-
invariant.command("stopped sends are rejected", ({ previous, result }) =>
|
|
1464
|
-
previous?.result._tag !== "Stopped" || result._tag === "SendRejected"
|
|
368
|
+
import { Effect, Option } from "effect"
|
|
369
|
+
|
|
370
|
+
const testProgram = Effect.gen(function*() {
|
|
371
|
+
const define = MachineTest.invariants(CounterMachine)
|
|
372
|
+
|
|
373
|
+
const countNeverBecomesNegative = define.state(
|
|
374
|
+
"count never becomes negative",
|
|
375
|
+
({ snapshot }) =>
|
|
376
|
+
!CounterStates.matches(snapshot, "Running") ||
|
|
377
|
+
CounterStates.get(snapshot, "Running").pipe(
|
|
378
|
+
Option.exists(({ count }) => count >= 0)
|
|
379
|
+
) ||
|
|
380
|
+
"count became negative"
|
|
1465
381
|
)
|
|
1466
|
-
]
|
|
1467
|
-
|
|
1468
|
-
yield* MachineTest.verifyCausalCommands(probe, commands, { invariants: laws })
|
|
1469
|
-
```
|
|
1470
382
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
Use `assertPlannerRuntimeAgreement(machine, transcript)` only to check the
|
|
1479
|
-
managed runtime boundary against a fresh pure plan. It is not an independent
|
|
1480
|
-
business oracle and is intentionally an explicit operation rather than a
|
|
1481
|
-
generic conformance mode. Combine it with application runtime laws or a
|
|
1482
|
-
reference model when correctness of the expected behavior matters.
|
|
1483
|
-
|
|
1484
|
-
## Common compiler errors
|
|
1485
|
-
|
|
1486
|
-
### `initial` requires a static target
|
|
1487
|
-
|
|
1488
|
-
Select the initial root separately from constructing its value:
|
|
1489
|
-
|
|
1490
|
-
```ts
|
|
1491
|
-
initial: (to) => to.Idle()
|
|
1492
|
-
```
|
|
1493
|
-
|
|
1494
|
-
### Invoked child expects events not accepted by the parent
|
|
1495
|
-
|
|
1496
|
-
Export one parent-event protocol from the child boundary and compose it into
|
|
1497
|
-
the parent's public events:
|
|
1498
|
-
|
|
1499
|
-
```ts
|
|
1500
|
-
export const ChildParentEvents = Machine.events(ChildFinished)
|
|
1501
|
-
|
|
1502
|
-
// child-only machine
|
|
1503
|
-
parent: Machine.parent(ChildParentEvents)
|
|
1504
|
-
|
|
1505
|
-
// parent
|
|
1506
|
-
events: Machine.events(Submit, ChildParentEvents)
|
|
1507
|
-
```
|
|
1508
|
-
|
|
1509
|
-
Use `Machine.optionalParent(ChildParentEvents)` only when the child is also a
|
|
1510
|
-
valid independent root and narrow `parent` before sending.
|
|
1511
|
-
|
|
1512
|
-
### An internal event is rejected by `send`
|
|
1513
|
-
|
|
1514
|
-
This is intentional. Public input boundaries accept only schemas declared in
|
|
1515
|
-
`events`. Handle the event as a child delivery or raised event; move it to
|
|
1516
|
-
`events` only if external callers should genuinely be allowed to send it.
|
|
1517
|
-
|
|
1518
|
-
### Public and internal event tags overlap
|
|
1519
|
-
|
|
1520
|
-
Give the cases distinct `_tag` values. The split is a protocol boundary, so one
|
|
1521
|
-
tag cannot be both externally sendable and machine-local.
|
|
1522
|
-
|
|
1523
|
-
### Missing output implementation
|
|
1524
|
-
|
|
1525
|
-
An output schema is a runtime contract, not an optional annotation. Add the
|
|
1526
|
-
corresponding nested handler:
|
|
1527
|
-
|
|
1528
|
-
```ts
|
|
1529
|
-
Done: {
|
|
1530
|
-
output: ({ state }) => state.value
|
|
1531
|
-
}
|
|
1532
|
-
```
|
|
1533
|
-
|
|
1534
|
-
Keep `type: "final"` and `output: Schema...` in the state definition; do not
|
|
1535
|
-
repeat the final marker in this handler.
|
|
1536
|
-
|
|
1537
|
-
### `type: "final"` is rejected by `handle`
|
|
1538
|
-
|
|
1539
|
-
Move it to `Machine.states`. Definitions own statechart topology;
|
|
1540
|
-
handlers own behavior.
|
|
1541
|
-
|
|
1542
|
-
### Parent property does not exist
|
|
1543
|
-
|
|
1544
|
-
Use the structural ancestor's full path:
|
|
383
|
+
const trace = yield* MachineTest.run(CounterMachine, {
|
|
384
|
+
events: [
|
|
385
|
+
CounterEvents.Start(),
|
|
386
|
+
CounterEvents.Increment(),
|
|
387
|
+
CounterEvents.Increment()
|
|
388
|
+
]
|
|
389
|
+
})
|
|
1545
390
|
|
|
1546
|
-
|
|
1547
|
-
|
|
391
|
+
yield* MachineTest.verify(CounterMachine, trace)
|
|
392
|
+
yield* MachineTest.checkInvariants(CounterMachine, trace, [
|
|
393
|
+
countNeverBecomesNegative
|
|
394
|
+
])
|
|
395
|
+
})
|
|
1548
396
|
```
|
|
1549
397
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
An independently created descriptor with the same id and machine identity also
|
|
1554
|
-
matches; the same id paired with a different machine remains a distinct child.
|
|
1555
|
-
|
|
1556
|
-
### Child atom start error defaults to `unknown`
|
|
1557
|
-
|
|
1558
|
-
`ChildMachineAtom<Child>` is suitable for a general boundary because its startup
|
|
1559
|
-
error defaults to `unknown`. Atoms created with an `AtomRuntime<R, E>` include
|
|
1560
|
-
`E` in their startup error type. Use `ChildOf<ParentAtom, Child>` to infer that
|
|
1561
|
-
exact channel from a parent instead of restating it manually.
|
|
1562
|
-
|
|
1563
|
-
### Handler tree reaches a compiler instantiation limit
|
|
1564
|
-
|
|
1565
|
-
`effect-machine` does not impose a fixed handler-tree depth. Inference follows
|
|
1566
|
-
the nested handler object until TypeScript reaches its normal, shape-dependent
|
|
1567
|
-
compiler resource or instantiation limits.
|
|
1568
|
-
|
|
1569
|
-
## Unsupported and intentionally imperative features
|
|
1570
|
-
|
|
1571
|
-
The current API does not include:
|
|
1572
|
-
|
|
1573
|
-
- declarative first-class guards;
|
|
1574
|
-
- a complete inspectable graph for arbitrary transition Effects.
|
|
1575
|
-
|
|
1576
|
-
Use ordinary TypeScript conditions for guards and inline
|
|
1577
|
-
`invoke: (from) => from.timer(...)` chains for state-scoped timers. Do not
|
|
1578
|
-
invent undocumented state-node properties such as `guard`.
|
|
398
|
+
Use pure planner traces for state and transition rules. Start a live machine
|
|
399
|
+
and use `MachineTest.probe` when a test depends on timers, invoked work, raised
|
|
400
|
+
events, or runtime scheduling.
|