@typeonce/effect-machine 0.5.0 → 0.5.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 +164 -792
- package/docs/agent-guide.md +13 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,52 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
Schema-first state machines and statecharts for Effect.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
State, event, input, output, and persistence boundaries are described with
|
|
6
|
+
Effect Schema. The same definition can be planned synchronously, run as a
|
|
7
|
+
managed machine, mounted as an Atom, tested as a model, or hosted by the
|
|
8
|
+
cluster adapter.
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
> This is early-release software. Its API may change, and each release targets
|
|
11
|
+
> one exact Effect beta.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
9
14
|
|
|
10
15
|
```sh
|
|
11
16
|
pnpm add @typeonce/effect-machine effect@4.0.0-beta.107
|
|
12
17
|
```
|
|
13
18
|
|
|
14
|
-
`effect` is an exact peer dependency
|
|
15
|
-
|
|
16
|
-
require upgrading Effect in lockstep; do not override the peer to another beta.
|
|
19
|
+
`effect` is an exact peer dependency. Install the version above and upgrade it
|
|
20
|
+
in lockstep with this package.
|
|
17
21
|
|
|
18
|
-
##
|
|
22
|
+
## Quick start
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
import { Machine } from "@typeonce/effect-machine"
|
|
22
|
-
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
|
|
23
|
-
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
24
|
-
import { MachineTest } from "@typeonce/effect-machine/testing"
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Each ESM entrypoint is independent and tree-shakeable. Importing the root does
|
|
28
|
-
not load the reactivity, cluster, or testing modules.
|
|
29
|
-
|
|
30
|
-
## First machine
|
|
31
|
-
|
|
32
|
-
Schemas provide runtime decoders and the types used by handlers, targets,
|
|
33
|
-
inputs, outputs, and running references. The public/internal event distinction
|
|
34
|
-
has an additional boundary described below.
|
|
35
|
-
|
|
36
|
-
Effect's `Schema.TaggedUnion` is a compact way to declare cases. Its `cases`
|
|
37
|
-
property contains the individual tagged schemas, and each case has a typed
|
|
38
|
-
`make` constructor.
|
|
24
|
+
Define schemas first, derive the state topology, then add behavior:
|
|
39
25
|
|
|
40
26
|
```ts
|
|
41
27
|
import { Machine } from "@typeonce/effect-machine"
|
|
42
|
-
import { Schema } from "effect"
|
|
28
|
+
import { Effect, Schema } from "effect"
|
|
43
29
|
|
|
44
30
|
const State = Schema.TaggedUnion({
|
|
45
31
|
Idle: {},
|
|
46
|
-
Running: {}
|
|
32
|
+
Running: { count: Schema.Number }
|
|
47
33
|
})
|
|
48
34
|
|
|
49
35
|
const Event = Schema.TaggedUnion({
|
|
50
|
-
Start: {}
|
|
36
|
+
Start: {},
|
|
37
|
+
Increment: {},
|
|
38
|
+
Stop: {}
|
|
51
39
|
})
|
|
52
40
|
|
|
53
41
|
const States = Machine.defineStates(State.cases)
|
|
@@ -55,575 +43,174 @@ const States = Machine.defineStates(State.cases)
|
|
|
55
43
|
const Counter = Machine.make({
|
|
56
44
|
id: "Counter",
|
|
57
45
|
states: States.states,
|
|
58
|
-
events: [Event
|
|
46
|
+
events: [Event],
|
|
59
47
|
initial: () => States.initial.Idle.from()
|
|
60
48
|
}).handle({
|
|
61
49
|
Idle: {
|
|
62
50
|
on: {
|
|
63
|
-
Start: ({ target }) => target.full.Running.from()
|
|
51
|
+
Start: ({ target }) => target.full.Running.from({ count: 0 })
|
|
64
52
|
}
|
|
65
53
|
},
|
|
66
|
-
Running: {
|
|
54
|
+
Running: {
|
|
55
|
+
on: {
|
|
56
|
+
Increment: ({ state, target }) => target.full.Running.from({ count: state.count + 1 }),
|
|
57
|
+
Stop: ({ target }) => target.full.Idle.from()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const program = Effect.gen(function*() {
|
|
63
|
+
const ref = yield* Machine.start(Counter)
|
|
64
|
+
yield* ref.send(Machine.event(Counter, Event.cases.Start))
|
|
65
|
+
yield* ref.send(Machine.event(Counter, Event.cases.Increment))
|
|
67
66
|
})
|
|
68
67
|
```
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
`Machine.start` returns a `MachineRef` with `send`, `state`, `snapshot`,
|
|
70
|
+
`changes`, `join`, and `stop`. Sending enqueues an event; observe `changes` or
|
|
71
|
+
use the testing probe when work must be causally acknowledged.
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
initializer receives the decoded input.
|
|
73
|
+
## Modeling workflow
|
|
76
74
|
|
|
77
|
-
|
|
78
|
-
`.from` for constructing one safely from the state schema's make input:
|
|
75
|
+
Use this order to preserve inference and keep boundaries explicit:
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
1. Define domain, state, public-event, internal-event, and emitted-event schemas.
|
|
78
|
+
2. Declare topology with `Machine.defineStates`.
|
|
79
|
+
3. Create the protocol and initializer with `Machine.make`.
|
|
80
|
+
4. Implement every active state with `.handle(...)`.
|
|
81
|
+
5. Add runtime, Atom, testing, or cluster adapters at the application boundary.
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
entering a state from fields. Construction runs through the schema's
|
|
87
|
-
`makeEffect` while the machine plans the configuration, so constructor
|
|
88
|
-
defaults and tagged-class identity are preserved and failed refinements become
|
|
89
|
-
`MachineSchemaDecodeError` failures instead of synchronous throws. The same
|
|
90
|
-
form is available on initial, local, branch, full, compound, parallel, and
|
|
91
|
-
final builders. A `.from` builder result is therefore a machine construction
|
|
92
|
-
instruction; it becomes a validated public snapshot when planning succeeds.
|
|
83
|
+
### Construct state through builders
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
required, while compound and parallel states still require their active-child
|
|
96
|
-
callback:
|
|
85
|
+
Use `.from(...)` when constructing a new state from fields:
|
|
97
86
|
|
|
98
87
|
```ts
|
|
99
|
-
|
|
88
|
+
target.local.Saving.from({ draft: event.draft })
|
|
100
89
|
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
|
|
101
|
-
States.initial.Flow.from((flow) => flow.Idle.from())
|
|
102
90
|
```
|
|
103
91
|
|
|
104
|
-
|
|
105
|
-
identity
|
|
92
|
+
The machine runs these inputs through the state schema while planning. Schema
|
|
93
|
+
defaults, refinements, and tagged-class identity are therefore preserved, and
|
|
94
|
+
decode failures remain typed machine failures. Pass a value directly only when
|
|
95
|
+
it is already decoded, such as a value returned by `Machine.retag`.
|
|
106
96
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
```
|
|
97
|
+
Put data on the narrowest state where it is valid. If sibling phases share
|
|
98
|
+
data, put it on their compound parent.
|
|
110
99
|
|
|
111
|
-
|
|
100
|
+
### Separate public and internal events
|
|
112
101
|
|
|
113
|
-
|
|
114
|
-
|
|
102
|
+
`events` is the public command protocol. Invoke results, timer deliveries,
|
|
103
|
+
raised events, and child emissions belong in `internalEvents`:
|
|
115
104
|
|
|
116
105
|
```ts
|
|
117
|
-
const Command = Schema.TaggedUnion({
|
|
118
|
-
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
const InternalEvent = Schema.TaggedUnion({
|
|
106
|
+
const Command = Schema.TaggedUnion({ Save: {} })
|
|
107
|
+
const Internal = Schema.TaggedUnion({
|
|
122
108
|
Saved: { id: Schema.String },
|
|
123
109
|
SaveFailed: { message: Schema.String }
|
|
124
110
|
})
|
|
125
111
|
|
|
126
112
|
const machine = Machine.make({
|
|
127
113
|
states: States.states,
|
|
128
|
-
events: [Command
|
|
129
|
-
internalEvents: [
|
|
114
|
+
events: [Command],
|
|
115
|
+
internalEvents: [Internal],
|
|
130
116
|
initial: () => States.initial.Idle.from()
|
|
131
117
|
})
|
|
132
118
|
```
|
|
133
119
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
const save = Machine.event(machine, Command.cases.Save)
|
|
139
|
-
yield * ref.send(save)
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
The schema constructor runs once and the decoded value is trusted by that
|
|
143
|
-
machine and definitions derived from it with `handle`. This avoids decoding a
|
|
144
|
-
known event again on every delivery. A configured `Schema.TaggedUnion` can use
|
|
145
|
-
either the union schema itself or one of its `cases`. Treat the returned event
|
|
146
|
-
as immutable; sending it to an unrelated machine goes through that machine's
|
|
147
|
-
normal decoder.
|
|
148
|
-
|
|
149
|
-
Ordinary values remain valid and are decoded at every boundary:
|
|
150
|
-
|
|
151
|
-
```ts
|
|
152
|
-
yield * ref.send({ _tag: "Save" })
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Handlers and machine logic see the complete union. Local public APIs such as
|
|
156
|
-
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` expose only `events`
|
|
157
|
-
in TypeScript. The local planner and runtime still share the complete event
|
|
158
|
-
decoder so machine-local deliveries can flow through the same execution
|
|
159
|
-
protocol; bypassing the types with JavaScript or `any` is therefore not a
|
|
160
|
-
runtime authorization boundary. Cluster RPC delivery additionally validates
|
|
161
|
-
incoming payloads against the public `events` schemas.
|
|
162
|
-
|
|
163
|
-
The utility types make the distinction available to application code:
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
type PublicCommand = Machine.Machine.InputEvent<typeof machine>
|
|
167
|
-
type HandledEvent = Machine.Machine.Event<typeof machine>
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
Tags must be unique within each list, and public and internal tags must be
|
|
171
|
-
disjoint. Reusing a tag is a type error, so a command cannot accidentally
|
|
172
|
-
masquerade as an internal result.
|
|
173
|
-
|
|
174
|
-
## Statechart structure
|
|
175
|
-
|
|
176
|
-
`Machine.defineStates` accepts atomic, compound, parallel, final, choice, and history
|
|
177
|
-
nodes:
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
const State = Schema.TaggedUnion({
|
|
181
|
-
Form: { draft: Schema.String },
|
|
182
|
-
Editing: {},
|
|
183
|
-
Saving: {},
|
|
184
|
-
Done: {}
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
const States = Machine.defineStates({
|
|
188
|
-
Form: {
|
|
189
|
-
schema: State.cases.Form,
|
|
190
|
-
initial: "Editing",
|
|
191
|
-
states: {
|
|
192
|
-
Editing: State.cases.Editing,
|
|
193
|
-
Saving: State.cases.Saving,
|
|
194
|
-
Done: {
|
|
195
|
-
schema: State.cases.Done,
|
|
196
|
-
type: "final",
|
|
197
|
-
output: Schema.String
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
})
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Compound states have one active child and declare its initial key. Parallel
|
|
205
|
-
states use `type: "parallel"` and have one active state in every direct region.
|
|
206
|
-
Finality is topology, so declare `type: "final"` only in the state definition.
|
|
207
|
-
Handlers implement behavior and output computation without repeating it:
|
|
208
|
-
|
|
209
|
-
```ts
|
|
210
|
-
const machine = Machine.make({
|
|
211
|
-
states: States.states,
|
|
212
|
-
events: [],
|
|
213
|
-
initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
|
|
214
|
-
}).handle({
|
|
215
|
-
Form: {
|
|
216
|
-
states: {
|
|
217
|
-
Done: {
|
|
218
|
-
output: () => "saved"
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
})
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Every declared output schema must have a matching handler implementation before
|
|
226
|
-
the machine can be planned, started, invoked, or adapted to Atom/Cluster.
|
|
227
|
-
Final children complete their parent; put `onDone` on that compound or parallel
|
|
228
|
-
parent, not on the final leaf.
|
|
120
|
+
Handlers see both protocols. Typed `send` and `Machine.plan` accept only public
|
|
121
|
+
events. Event tags must be unique and public/internal tags must be disjoint.
|
|
229
122
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
123
|
+
Use `Machine.event(machine, schema, fields?)` for reusable machine-owned event
|
|
124
|
+
values. Ordinary objects and schema-constructed values are also accepted and
|
|
125
|
+
decoded at the machine boundary.
|
|
233
126
|
|
|
234
|
-
###
|
|
127
|
+
### Choose the target by scope
|
|
235
128
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
129
|
+
| Builder | Use when | Preserves |
|
|
130
|
+
| ---------------- | ---------------------------------------- | ------------------------------------------------- |
|
|
131
|
+
| `target.local` | Moving inside the nearest compound scope | Ancestors and unrelated parallel regions |
|
|
132
|
+
| `target.branch` | Moving elsewhere under the active root | Omitted active ancestors and parallel regions |
|
|
133
|
+
| `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root |
|
|
134
|
+
| `target.history` | Restoring a declared history node | The remembered configuration or its typed default |
|
|
240
135
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
schema: State.cases.Flow,
|
|
245
|
-
initial: "Routing",
|
|
246
|
-
states: {
|
|
247
|
-
Routing: { type: "choice" },
|
|
248
|
-
Approved: State.cases.Approved,
|
|
249
|
-
Rejected: State.cases.Rejected
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
})
|
|
136
|
+
Builders describe the next logical configuration. Shared states exit and enter
|
|
137
|
+
only when paths change; use `{ reenter: true, transition }` when the source must
|
|
138
|
+
restart even if its path is unchanged.
|
|
253
139
|
|
|
254
|
-
|
|
255
|
-
states: States.states,
|
|
256
|
-
events: [Event],
|
|
257
|
-
initial: () =>
|
|
258
|
-
States.initial.Flow(
|
|
259
|
-
State.cases.Flow.make({ score: 80 }),
|
|
260
|
-
(flow) => flow.Routing()
|
|
261
|
-
)
|
|
262
|
-
}).handle({
|
|
263
|
-
Flow: {
|
|
264
|
-
states: {
|
|
265
|
-
Routing: {
|
|
266
|
-
choice: {
|
|
267
|
-
targets: ["Flow.Approved", "Flow.Rejected"],
|
|
268
|
-
transition: ({ parent, target }) =>
|
|
269
|
-
parent.score >= 70
|
|
270
|
-
? target.local.Approved(State.cases.Approved.make({}))
|
|
271
|
-
: target.local.Rejected(State.cases.Rejected.make({}))
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
})
|
|
277
|
-
```
|
|
140
|
+
## Statechart capabilities
|
|
278
141
|
|
|
279
|
-
|
|
280
|
-
lifecycle event, typed parent values, target builders, and the normal planning
|
|
281
|
-
capabilities, but no `state` because the choice itself has no state value. Its
|
|
282
|
-
declared `targets` are both a compile-time bound and inspectable graph edges.
|
|
283
|
-
The resolver must return one of them; missing, malformed, or undeclared targets
|
|
284
|
-
fail planning. Choice implementations are required before execution APIs are
|
|
285
|
-
available.
|
|
142
|
+
`Machine.defineStates` supports:
|
|
286
143
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
144
|
+
- atomic states;
|
|
145
|
+
- compound states with one active child;
|
|
146
|
+
- parallel states with one active state in every region;
|
|
147
|
+
- final states and typed outputs;
|
|
148
|
+
- transient choice states;
|
|
149
|
+
- shallow and deep history states.
|
|
292
150
|
|
|
293
|
-
|
|
151
|
+
Declare topology—including finality, output schemas, choices, and history—only
|
|
152
|
+
in `defineStates`. Handlers implement behavior and output computation without
|
|
153
|
+
repeating structural metadata. Final children complete their parent, so
|
|
154
|
+
`onDone` belongs on that compound or parallel parent.
|
|
294
155
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
156
|
+
Transition, entry, exit, choice, initial, and history callbacks are
|
|
157
|
+
synchronous. Conditions use ordinary TypeScript control flow. Callbacks may
|
|
158
|
+
select state and enqueue explicit `raise`, `emit`, `sendTo`, or `stop` commands;
|
|
159
|
+
arbitrary asynchronous Effects do not run inside planning.
|
|
299
160
|
|
|
300
|
-
|
|
301
|
-
const States = Machine.defineStates({
|
|
302
|
-
checkout: {
|
|
303
|
-
schema: Checkout,
|
|
304
|
-
initial: "shipping",
|
|
305
|
-
states: {
|
|
306
|
-
shipping: Shipping,
|
|
307
|
-
payment: {
|
|
308
|
-
schema: Payment,
|
|
309
|
-
initial: "cardEntry",
|
|
310
|
-
states: {
|
|
311
|
-
cardEntry: CardEntry,
|
|
312
|
-
verifying: Verifying
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
resume: { type: "history", history: "deep" }
|
|
316
|
-
}
|
|
317
|
-
},
|
|
318
|
-
support: Support
|
|
319
|
-
})
|
|
320
|
-
```
|
|
161
|
+
## Effects, timers, and child machines
|
|
321
162
|
|
|
322
|
-
|
|
323
|
-
has been remembered, then target history without supplying a state value:
|
|
163
|
+
State-scoped work starts on entry and is interrupted on exit:
|
|
324
164
|
|
|
325
165
|
```ts
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
},
|
|
334
|
-
support: {
|
|
335
|
-
on: {
|
|
336
|
-
Resume: ({ target }) => target.history.checkout.resume()
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
})
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
A history default is source-independent. It must construct a complete root
|
|
343
|
-
configuration containing the history owner, including every inactive ancestor
|
|
344
|
-
above a nested owner and every required region of a parallel ancestor. For a
|
|
345
|
-
top-level owner, its owner snapshot is already a complete root snapshot.
|
|
346
|
-
|
|
347
|
-
For example, a history node owned by `App.Workspace` can be targeted from an
|
|
348
|
-
unrelated `Closed` root and supplies the complete `App` configuration on first
|
|
349
|
-
use:
|
|
350
|
-
|
|
351
|
-
```ts
|
|
352
|
-
Workspace: {
|
|
353
|
-
history: {
|
|
354
|
-
resume: {
|
|
355
|
-
default: ({ target }) =>
|
|
356
|
-
target.App(
|
|
357
|
-
State.cases.App.make({ workspaceId: "default" }),
|
|
358
|
-
(app) =>
|
|
359
|
-
app.Workspace(
|
|
360
|
-
State.cases.Workspace.make({}),
|
|
361
|
-
(workspace) =>
|
|
362
|
-
workspace.Editing(State.cases.Editing.make({}))
|
|
363
|
-
)
|
|
364
|
-
)
|
|
365
|
-
}
|
|
366
|
-
}
|
|
166
|
+
Loading: {
|
|
167
|
+
invoke: Machine.invokeEffect({
|
|
168
|
+
id: "save-document",
|
|
169
|
+
effect: saveDocument,
|
|
170
|
+
onSuccess: (entry) => Internal.cases.Saved.make({ id: entry.id }),
|
|
171
|
+
onFailure: (error) => Internal.cases.SaveFailed.make({ message: String(error) })
|
|
172
|
+
})
|
|
367
173
|
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
The containing branch is enforced statically: unrelated roots, sibling
|
|
371
|
-
compound branches that omit the owner, owner-only nested snapshots, and
|
|
372
|
-
incomplete parallel configurations are rejected.
|
|
373
|
-
|
|
374
|
-
Deep history restores every remembered descendant value. Shallow history
|
|
375
|
-
restores the parent and direct-child values, then follows normal initial paths.
|
|
376
|
-
Only compound or parallel states that shallow restoration can enter implicitly
|
|
377
|
-
need an `initial` handler to construct those new child values:
|
|
378
|
-
|
|
379
|
-
```ts
|
|
380
|
-
payment: {
|
|
381
|
-
initial: ;
|
|
382
|
-
;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }))
|
|
383
|
-
}
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
Execution APIs remain unavailable until required history defaults and shallow
|
|
387
|
-
initializers have been implemented. History records are part of logical
|
|
388
|
-
snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`.
|
|
389
|
-
|
|
390
|
-
Transition between structurally related tagged states with `Machine.retag`.
|
|
391
|
-
The source `_tag` is discarded, compatible fields are reused, and missing or
|
|
392
|
-
incompatible required fields must be supplied:
|
|
393
|
-
|
|
394
|
-
```ts
|
|
395
|
-
const saving = Machine.retag(State.cases.Saving, editing)
|
|
396
|
-
```
|
|
397
174
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
| ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
404
|
-
| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
|
|
405
|
-
| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
|
|
406
|
-
| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
|
|
407
|
-
| `target.history` | A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default |
|
|
408
|
-
|
|
409
|
-
When `target.local` or `target.branch` enters an inactive nested parallel
|
|
410
|
-
state, its callback must select every region, just like `initial` and
|
|
411
|
-
`target.full`. When that parallel state is already active, `target.branch`
|
|
412
|
-
can still update one region directly and preserves the other active regions.
|
|
413
|
-
|
|
414
|
-
The builder controls how the next configuration is assembled; it does not by
|
|
415
|
-
itself decide which invokes restart. The runtime derives exit and entry paths
|
|
416
|
-
from the previous and next active paths. Shared active ancestors remain entered,
|
|
417
|
-
even when `target.full` supplies their values again. Use an event transition
|
|
418
|
-
with `reenter: true` when the source state should explicitly exit and enter
|
|
419
|
-
again:
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
Refresh: {
|
|
423
|
-
reenter: true,
|
|
424
|
-
transition: ({ state, target }) =>
|
|
425
|
-
target.full.Ready(new Ready({ value: state.value }))
|
|
426
|
-
}
|
|
427
|
-
```
|
|
428
|
-
|
|
429
|
-
`States.get`, `States.getWithParents`, `States.getSnapshot`, and
|
|
430
|
-
`States.matches` accept typed dotted paths. Handler `parents` values are also
|
|
431
|
-
keyed by full dotted paths, such as `parents["Form.Editing"]`; `context.parent`
|
|
432
|
-
provides the immediate parent directly and is `undefined` at a root state.
|
|
433
|
-
|
|
434
|
-
Event, eventless, and completion transition contexts also expose `snapshot`, a
|
|
435
|
-
read-only view of the complete logical configuration captured at the beginning
|
|
436
|
-
of that transition microstep. This lets one parallel region inspect a sibling
|
|
437
|
-
without copying active-state facts into parent values:
|
|
438
|
-
|
|
439
|
-
```ts
|
|
440
|
-
BufferReady: ;
|
|
441
|
-
;(({ snapshot, target }) =>
|
|
442
|
-
States.matches(snapshot, "Player.Network.Online")
|
|
443
|
-
? target.local.Playing(State.cases.Playing.make({}))
|
|
444
|
-
: undefined)
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
All non-conflicting handlers selected together observe the same captured
|
|
448
|
-
snapshot. Handlers are synchronous and cannot read mutable live runtime state
|
|
449
|
-
later. `snapshot` is intentionally absent from entry, exit,
|
|
450
|
-
invoke, and choice contexts. In particular, startup and chained choices may run
|
|
451
|
-
before a complete stable snapshot containing their pseudo-source exists.
|
|
452
|
-
|
|
453
|
-
Effect Schema annotations are the metadata source for active states. Annotate
|
|
454
|
-
the schema itself; `Machine.stateNodes` exposes the resolved annotation map:
|
|
455
|
-
|
|
456
|
-
```ts
|
|
457
|
-
const Saving = State.cases.Saving.annotate({
|
|
458
|
-
title: "Saving document",
|
|
459
|
-
description: "Persisting local changes to the server"
|
|
460
|
-
})
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
Schema-less choice and history nodes accept only descriptive `title`,
|
|
464
|
-
`description`, and `documentation` annotations. Titles may be used as display
|
|
465
|
-
labels, but structural paths remain the only identity and targeting mechanism.
|
|
466
|
-
|
|
467
|
-
## Synchronous transitions and actor commands
|
|
468
|
-
|
|
469
|
-
Transition, entry, exit, choice, initial, and history callbacks are synchronous.
|
|
470
|
-
They select state and may enqueue only explicit statechart or actor operations:
|
|
471
|
-
raise an internal event, emit to the parent, send to an invoked child, or stop a
|
|
472
|
-
child. Arbitrary Effects are not accepted at this boundary.
|
|
473
|
-
|
|
474
|
-
```ts
|
|
475
|
-
const handlers = {
|
|
476
|
-
Save: ({ target }, enqueue) => {
|
|
477
|
-
enqueue.emit(new SaveRequested({}))
|
|
478
|
-
return target.local.Saving.from()
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
Use `Machine.invokeEffect`, `Machine.invoke`, or an invoked child machine for
|
|
484
|
-
asynchronous work. Their results return to the parent as typed events, keeping
|
|
485
|
-
the transition core deterministic and synchronous.
|
|
486
|
-
|
|
487
|
-
`Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
|
|
488
|
-
`done` is `true`, `output` is the schema-derived structural terminal union;
|
|
489
|
-
while the machine remains active, it is `undefined`. A started machine's
|
|
490
|
-
`join` uses the same terminal union. Output-less structural terminal paths
|
|
491
|
-
contribute `undefined`, while active atomic roots do not.
|
|
492
|
-
|
|
493
|
-
This union is intentionally conservative with respect to handler behavior. For
|
|
494
|
-
example, a root `onDone` transition may make one structurally terminal result
|
|
495
|
-
unreachable even though its schema remains in `Machine.TerminalOutput`.
|
|
496
|
-
|
|
497
|
-
## State-scoped invokes
|
|
498
|
-
|
|
499
|
-
`Machine.invoke` runs child logic while its owning state is active. Leaving the
|
|
500
|
-
state interrupts the child. For a one-shot Effect, `Machine.invokeEffect` maps
|
|
501
|
-
typed success and failure values directly to internal events:
|
|
502
|
-
|
|
503
|
-
```ts
|
|
504
|
-
const loading = {
|
|
505
|
-
invoke: ({ state }) =>
|
|
506
|
-
Machine.invokeEffect({
|
|
507
|
-
id: "save",
|
|
508
|
-
effect: save(state),
|
|
509
|
-
onSuccess: (entry) => InternalEvent.cases.Saved.make({ id: entry.id }),
|
|
510
|
-
onFailure: (error) =>
|
|
511
|
-
InternalEvent.cases.SaveFailed.make({
|
|
512
|
-
message: String(error)
|
|
513
|
-
})
|
|
514
|
-
})
|
|
175
|
+
Waiting: {
|
|
176
|
+
invoke: Machine.after(
|
|
177
|
+
"3 seconds",
|
|
178
|
+
Internal.cases.SaveFailed.make({ message: "Timed out" })
|
|
179
|
+
)
|
|
515
180
|
}
|
|
516
181
|
```
|
|
517
182
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
`
|
|
522
|
-
lifetime:
|
|
183
|
+
Use `Machine.invokeEffect` for one Effect, `Machine.after` for a cancellable
|
|
184
|
+
delay, and lower-level `Machine.invoke` only for custom process behavior or
|
|
185
|
+
snapshot mapping. Use one exported `Machine.child(id, machine)` descriptor for
|
|
186
|
+
`invokeMachine`, `sendTo`, and child lookup.
|
|
523
187
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
id: "save-timeout"
|
|
527
|
-
})
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
Provide an explicit id when more than one active timer could deliver the same
|
|
531
|
-
event tag.
|
|
532
|
-
|
|
533
|
-
Use lower-level `Machine.invoke` with `Machine.effect` for custom child logic or
|
|
534
|
-
snapshot mapping. Its `id` is only the state-local lifecycle key. If the parent
|
|
535
|
-
must send events to that invocation, create a typed low-level address with
|
|
536
|
-
`Machine.childAddress<Event>("worker")` and pass it through the explicit
|
|
537
|
-
`address` option; the address protocol is checked against the child logic.
|
|
538
|
-
Lifecycle ids must be unique among simultaneously active invokes owned by the
|
|
539
|
-
same state.
|
|
540
|
-
|
|
541
|
-
Invoke outputs, invoke snapshot events, and invoked-child emissions belong in
|
|
542
|
-
`internalEvents`. They are available to typed handlers but are not accepted by
|
|
543
|
-
the typed public input APIs. Include a child machine's emitted protocol with
|
|
544
|
-
`internalEvents: [...ChildMachine.emits]` when those emissions should be handled
|
|
545
|
-
by the parent.
|
|
546
|
-
|
|
547
|
-
For a child statechart, create one descriptor for `invokeMachine`, `sendTo`,
|
|
548
|
-
and child lookup:
|
|
549
|
-
|
|
550
|
-
```ts
|
|
551
|
-
const Editor = Machine.child("editor", EditorMachine)
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
`Machine.child(id, machine)` is the complete statechart descriptor;
|
|
555
|
-
`Machine.childAddress<Event>(id)` is the lower-level event-only address.
|
|
556
|
-
Descriptors are matched by id and machine identity, so independently created
|
|
557
|
-
descriptors for the same pair address the same child without a global cache.
|
|
558
|
-
Exporting one descriptor remains the clearest module boundary.
|
|
559
|
-
|
|
560
|
-
`Machine.activityDefinitions(machine)` inspects state-owned work without
|
|
561
|
-
executing it. Static descriptors report their source path, lifecycle id, and
|
|
562
|
-
kind. Timers also report normalized duration and emitted event tag;
|
|
563
|
-
`invokeEffect` mappings are described as dynamic; invoked machines expose only
|
|
564
|
-
safe child identity. A function-valued `invoke` factory is reported as dynamic
|
|
565
|
-
and is never evaluated during inspection. The result is serializable and does
|
|
566
|
-
not contain Effects, closures, services, or child runtimes.
|
|
188
|
+
Expected failures should become internal events. An unrecovered invoke or child
|
|
189
|
+
failure terminates the owning runtime.
|
|
567
190
|
|
|
568
191
|
## Reactivity
|
|
569
192
|
|
|
570
|
-
`AtomMachine
|
|
571
|
-
`AtomRegistry`. Mounting or reading one of its atoms starts the machine;
|
|
572
|
-
disposing the registry-owned reference stops it.
|
|
193
|
+
`AtomMachine` runs one lazy machine instance per `AtomRegistry`:
|
|
573
194
|
|
|
574
195
|
```ts
|
|
575
196
|
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
576
197
|
import { Atom } from "effect/unstable/reactivity"
|
|
577
198
|
|
|
578
199
|
const runtime = Atom.runtime(AppLayer)
|
|
579
|
-
const
|
|
580
|
-
const machineAtom = machines.make(Counter)
|
|
200
|
+
const counterAtom = AtomMachine.bind(runtime).make(Counter)
|
|
581
201
|
```
|
|
582
202
|
|
|
583
|
-
|
|
584
|
-
`AtomMachine.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
- `snapshot`: authoritative runtime lifecycle, including `active`, `done`,
|
|
595
|
-
`error`, and `stopped`
|
|
596
|
-
- `state`: the last logical state, including the retained state after a runtime
|
|
597
|
-
failure
|
|
598
|
-
- `send` and `stop`: writable command atoms
|
|
599
|
-
- `child(descriptor)`: a reactive bridge for a directly owned child
|
|
600
|
-
|
|
601
|
-
Use `AtomMachine.select` and `AtomMachine.matches` for equality-aware root
|
|
602
|
-
derivations. Use `selectChild` and `matchesChild` for child bridges. Selector
|
|
603
|
-
paths and selected value types are inferred directly from the bridge snapshot,
|
|
604
|
-
so these combinators do not need the `DefinedStates` object. They follow normal
|
|
605
|
-
Atom identity semantics and return a new atom on each call, so retain or memoize
|
|
606
|
-
them when constructing them in a component. The `child` method uses Effect's
|
|
607
|
-
`Atom.family` to reuse a live bridge for the same descriptor without maintaining
|
|
608
|
-
a package-level cache.
|
|
609
|
-
`AtomMachine.ChildMachineAtom<typeof Child>` uses `unknown` as its startup-error
|
|
610
|
-
default for general component props.
|
|
611
|
-
`AtomMachine.ChildOf<typeof parentAtom, typeof Child>` preserves the exact
|
|
612
|
-
parent startup-error channel.
|
|
613
|
-
|
|
614
|
-
Child state and snapshot atoms contain `Option.none()` while that child is
|
|
615
|
-
inactive. React applications using `@effect/atom-react` need a
|
|
616
|
-
`RegistryProvider`; see the [Pokémon example](./examples/pokemon).
|
|
617
|
-
|
|
618
|
-
## Snapshots and persistence
|
|
619
|
-
|
|
620
|
-
`Machine.encodeSnapshot` and `Machine.decodeSnapshot` validate logical
|
|
621
|
-
statechart data for storage or transport. The encoded representation does not
|
|
622
|
-
contain the machine definition, machine version, services, subscriptions, or
|
|
623
|
-
running child processes. Store machine identity and migration/version metadata
|
|
624
|
-
alongside it.
|
|
625
|
-
|
|
626
|
-
Resume a decoded logical snapshot explicitly:
|
|
203
|
+
Binding a shared runtime once is the canonical form for service-backed
|
|
204
|
+
applications. Service-free machines can use `AtomMachine.make(Counter)`.
|
|
205
|
+
|
|
206
|
+
The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
|
|
207
|
+
`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select` and
|
|
208
|
+
`AtomMachine.matches` for typed, equality-aware derivations. React applications
|
|
209
|
+
using `@effect/atom-react` need a `RegistryProvider`.
|
|
210
|
+
|
|
211
|
+
## Persistence
|
|
212
|
+
|
|
213
|
+
Logical snapshots can be validated for storage or transport:
|
|
627
214
|
|
|
628
215
|
```ts
|
|
629
216
|
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
|
|
@@ -631,249 +218,65 @@ const decoded = yield * Machine.decodeSnapshot(machine, encoded)
|
|
|
631
218
|
const ref = yield * Machine.resume(machine, decoded)
|
|
632
219
|
```
|
|
633
220
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
Resumption creates a fresh runtime. Invokes owned by active states start once in
|
|
640
|
-
normal ancestor/document order and receive `Machine.InitialEvent` as their
|
|
641
|
-
lifecycle event. `invokeEffect` runs again, invoked machines start from their
|
|
642
|
-
own initial state, and `Machine.after` timers restart from their full declared
|
|
643
|
-
duration. Spawned children, queued events, subscriptions, fibers, scopes,
|
|
644
|
-
elapsed timer time, child snapshots, and prior `RuntimeSnapshot` status/errors
|
|
645
|
-
are not restored. Completion and history metadata remain logical state and are
|
|
646
|
-
not replayed. A changed machine definition does not cause `resume` itself to
|
|
647
|
-
evaluate newly enabled `always` or `onDone` transitions.
|
|
648
|
-
|
|
649
|
-
Reactive applications use `AtomMachine.resume(machine, decoded)` for a
|
|
650
|
-
service-free machine or `AtomMachine.bind(runtime).resume(machine, decoded)`
|
|
651
|
-
for a service-backed machine. These bridges have the same lazy one-runtime-per-
|
|
652
|
-
registry ownership and disposal behavior as `AtomMachine.make`.
|
|
653
|
-
|
|
654
|
-
`ClusterMachine` provides a separate persisted entity adapter. Its process-local
|
|
655
|
-
restrictions, checkpoint planning, and delivery guarantees are documented on
|
|
656
|
-
that API. `Machine.resume` is logical resumption, not durable process or cluster
|
|
657
|
-
restoration.
|
|
658
|
-
|
|
659
|
-
## Property-based semantic invariants
|
|
660
|
-
|
|
661
|
-
`MachineTest.verify` checks statechart structure and planner lifecycle laws.
|
|
662
|
-
Application semantics belong in invariants that can be reused across generated
|
|
663
|
-
scenarios and, in future, bounded exploration:
|
|
221
|
+
Resumption restores logical state, values, completion, and history metadata.
|
|
222
|
+
It creates a fresh runtime: active invokes restart, timers restart at their
|
|
223
|
+
full duration, and prior fibers, subscriptions, queues, and child runtimes are
|
|
224
|
+
not restored. Store machine identity and migration/version metadata beside the
|
|
225
|
+
encoded snapshot.
|
|
664
226
|
|
|
665
|
-
|
|
666
|
-
import { MachineTest } from "@typeonce/effect-machine/testing"
|
|
667
|
-
import { Effect } from "effect"
|
|
668
|
-
|
|
669
|
-
const invariant = MachineTest.invariants(accountMachine)
|
|
670
|
-
const laws = [
|
|
671
|
-
invariant.state(
|
|
672
|
-
"balance is never negative",
|
|
673
|
-
({ snapshot }) =>
|
|
674
|
-
snapshot.value.balance >= 0 ||
|
|
675
|
-
`negative balance: ${snapshot.value.balance}`
|
|
676
|
-
),
|
|
677
|
-
invariant.step(
|
|
678
|
-
"withdrawal removes exactly its amount",
|
|
679
|
-
({ before, event, after }) =>
|
|
680
|
-
event._tag !== "Withdraw" ||
|
|
681
|
-
after.value.balance === before.value.balance - event.amount
|
|
682
|
-
)
|
|
683
|
-
]
|
|
684
|
-
|
|
685
|
-
const generated = MachineTest.scenarios(accountMachine, {
|
|
686
|
-
minEvents: 0,
|
|
687
|
-
maxEvents: 30
|
|
688
|
-
})
|
|
689
|
-
|
|
690
|
-
it.effect.prop(
|
|
691
|
-
"preserves account laws",
|
|
692
|
-
{ scenario: generated.arbitrary },
|
|
693
|
-
({ scenario }) =>
|
|
694
|
-
MachineTest.run(accountMachine, scenario).pipe(
|
|
695
|
-
Effect.tap((trace) => MachineTest.verify(accountMachine, trace)),
|
|
696
|
-
Effect.flatMap((trace) => MachineTest.assertInvariants(accountMachine, trace, laws))
|
|
697
|
-
)
|
|
698
|
-
)
|
|
699
|
-
```
|
|
227
|
+
## Testing
|
|
700
228
|
|
|
701
|
-
|
|
702
|
-
Set `observe` to `"microsteps"`, `"all"`, or `"final"` for a different scope.
|
|
703
|
-
Use `when` for conditional laws. A condition with no matches is reported as
|
|
704
|
-
`untested`; add `require: { minObservations: 1 }` when a particular trace must
|
|
705
|
-
exercise it. `checkInvariants` returns this report, while `assertInvariants`
|
|
706
|
-
returns `void` for direct use in property tests. Failures retain the complete
|
|
707
|
-
shrunk trace and precise event, microstep, configuration, and observation
|
|
708
|
-
location.
|
|
229
|
+
The testing entrypoint provides complementary layers:
|
|
709
230
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
231
|
+
- `MachineTest.run` and `verify` inspect pure planner traces;
|
|
232
|
+
- invariants and generated scenarios check application laws;
|
|
233
|
+
- `explore` performs bounded breadth-first state-space exploration;
|
|
234
|
+
- `probe` causally acknowledges live runtime commands;
|
|
235
|
+
- runtime command models cover timers, invokes, bursts, and scheduling.
|
|
715
236
|
|
|
716
237
|
```ts
|
|
717
|
-
|
|
718
|
-
events: ({ snapshot }) => [
|
|
719
|
-
new Deposit({ amount: 1 }),
|
|
720
|
-
new Withdraw({ amount: snapshot.value.balance }),
|
|
721
|
-
new Withdraw({ amount: snapshot.value.balance + 1 })
|
|
722
|
-
],
|
|
723
|
-
stateKey: ({ snapshot }) => `${snapshot.value._tag}:${snapshot.value.balance}`,
|
|
724
|
-
limits: {
|
|
725
|
-
maxDepth: 20,
|
|
726
|
-
maxStates: 1_000,
|
|
727
|
-
maxTransitions: 10_000
|
|
728
|
-
},
|
|
729
|
-
invariants: laws
|
|
730
|
-
})
|
|
731
|
-
|
|
732
|
-
const rejected = yield * MachineTest.assertReachable(
|
|
733
|
-
explored,
|
|
734
|
-
"insufficient funds rejection",
|
|
735
|
-
({ configuration }) => configuration.includes("Rejected")
|
|
736
|
-
)
|
|
737
|
-
|
|
738
|
-
console.log(rejected.trace.scenario.events) // shortest witness
|
|
739
|
-
```
|
|
740
|
-
|
|
741
|
-
Exploration is breadth-first, so each retained node owns its shortest trace.
|
|
742
|
-
It is exhaustive only for the concrete events returned by `events` and the
|
|
743
|
-
equivalence relation defined by `stateKey`. Equal keys intentionally collapse
|
|
744
|
-
snapshots and only the first representative is expanded. Results distinguish
|
|
745
|
-
`Complete` from `Truncated` and retain the depth, state, or transition frontier
|
|
746
|
-
that hit a limit. An unreachability assertion succeeds only for a complete
|
|
747
|
-
result; otherwise it fails as inconclusive. Cycles are retained as graph edges,
|
|
748
|
-
but exploration does not enumerate every cyclic path. Invariants are checked
|
|
749
|
-
on startup and on each planned edge extending a node's shortest trace.
|
|
750
|
-
|
|
751
|
-
## Causal runtime probes
|
|
752
|
-
|
|
753
|
-
Pure traces do not execute invokes or the managed runtime. When a test needs to
|
|
754
|
-
prove that one live event has actually left the mailbox, attach a testing-only
|
|
755
|
-
probe to a statechart reference:
|
|
756
|
-
|
|
757
|
-
```ts
|
|
758
|
-
const ref = yield * Machine.start(machine)
|
|
759
|
-
const probe = yield * MachineTest.probe(machine, ref)
|
|
760
|
-
|
|
761
|
-
const step = yield * probe.sendAndAwait(new CancelRequested({}))
|
|
762
|
-
|
|
763
|
-
assert.strictEqual(step.handled, false)
|
|
764
|
-
assert.deepStrictEqual(step.before, step.after)
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
`sendAndAwait` completes after that event's synchronous macrostep and managed
|
|
768
|
-
commit work. It also completes for ignored events, which publish no snapshot
|
|
769
|
-
and therefore cannot be synchronized by waiting for `ref.changes`.
|
|
770
|
-
|
|
771
|
-
The step retains the exact runtime plan, before/after logical snapshots, and
|
|
772
|
-
whether the event was handled or changed/reentered the active configuration.
|
|
773
|
-
It does not wait for timers or invoked processes to finish. Production code
|
|
774
|
-
continues to use enqueue-only `ref.send`; probes are exported only from the
|
|
775
|
-
separate testing entry point.
|
|
238
|
+
import { MachineTest } from "@typeonce/effect-machine/testing"
|
|
776
239
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
240
|
+
const trace = yield* MachineTest.run(Counter, {
|
|
241
|
+
events: [Event.cases.Start.make({}), Event.cases.Increment.make({})]
|
|
242
|
+
})
|
|
780
243
|
|
|
781
|
-
|
|
782
|
-
const transcript = yield * MachineTest.runCausalCommands(
|
|
783
|
-
probe,
|
|
784
|
-
commands,
|
|
785
|
-
{
|
|
786
|
-
initialModel,
|
|
787
|
-
transition: (model, command) =>
|
|
788
|
-
Effect.succeed({
|
|
789
|
-
model: updateModel(model, command),
|
|
790
|
-
expected: expectedResult(model, command)
|
|
791
|
-
}),
|
|
792
|
-
assert: ({ actual, expected }) =>
|
|
793
|
-
Effect.sync(() => {
|
|
794
|
-
if (actual.result._tag === "SendProcessed") {
|
|
795
|
-
assert.deepStrictEqual(actual.result.step.after, expected.snapshot)
|
|
796
|
-
assert.strictEqual(actual.result.step.handled, expected.handled)
|
|
797
|
-
}
|
|
798
|
-
})
|
|
799
|
-
}
|
|
800
|
-
)
|
|
244
|
+
yield* MachineTest.verify(Counter, trace)
|
|
801
245
|
```
|
|
802
246
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
invoke result, or child delivery. The predicate sees the exact runtime snapshot
|
|
806
|
-
type. `actual.awaited` retains every snapshot tested by that explicit wait.
|
|
807
|
-
|
|
808
|
-
Use `runEnqueuedCommands(ref, ...)` when the property intentionally submits
|
|
809
|
-
bursts or retains outstanding mailbox work. Its model steps continue to use
|
|
810
|
-
`RuntimeSynchronization`. The old `runRuntimeCommands` and
|
|
811
|
-
`formatRuntimeTranscript` names are deprecated aliases for the enqueue-oriented
|
|
812
|
-
runner and formatter because their delivery semantics were not visible.
|
|
813
|
-
|
|
814
|
-
### Runtime invariants and planner agreement
|
|
247
|
+
Pure planner tests do not execute invokes or time. Use a started machine and a
|
|
248
|
+
probe when those semantics matter.
|
|
815
249
|
|
|
816
|
-
|
|
817
|
-
laws inspect causal command evidence, explicit asynchronous observations, and
|
|
818
|
-
runtime status without requiring a duplicate reference model:
|
|
819
|
-
|
|
820
|
-
```ts
|
|
821
|
-
const invariant = MachineTest.runtimeInvariants(machine)
|
|
822
|
-
const laws = [
|
|
823
|
-
invariant.snapshot("count never becomes negative", ({ snapshot }) => snapshot.state.value.count >= 0),
|
|
824
|
-
invariant.command(
|
|
825
|
-
"every accepted add is processed",
|
|
826
|
-
({ command, result }) =>
|
|
827
|
-
command._tag !== "Send" || command.event._tag !== "Add" ||
|
|
828
|
-
result._tag === "SendProcessed"
|
|
829
|
-
)
|
|
830
|
-
]
|
|
831
|
-
|
|
832
|
-
const transcript = yield * MachineTest.verifyCausalCommands(
|
|
833
|
-
probe,
|
|
834
|
-
commands,
|
|
835
|
-
{ invariants: laws }
|
|
836
|
-
)
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
Use the existing `runCausalCommands` when a simplified application model
|
|
840
|
-
provides exact expected results. Its returned transcript implements the same
|
|
841
|
-
model-independent evidence interface, so reusable runtime laws compose with
|
|
842
|
-
it directly:
|
|
250
|
+
## Entrypoints
|
|
843
251
|
|
|
844
252
|
```ts
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
253
|
+
import { Machine } from "@typeonce/effect-machine"
|
|
254
|
+
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
|
|
255
|
+
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
256
|
+
import { MachineTest } from "@typeonce/effect-machine/testing"
|
|
849
257
|
```
|
|
850
258
|
|
|
851
|
-
|
|
852
|
-
fails with every predicate and non-vacuity violation. Snapshot laws observe the
|
|
853
|
-
initial and post-command snapshots by default. Select `"awaited"`, `"all"`, or
|
|
854
|
-
`"final"` explicitly when a law targets observations retained by
|
|
855
|
-
`probe.await.until` or only the final runtime snapshot.
|
|
259
|
+
Each ESM entrypoint is independent and tree-shakeable.
|
|
856
260
|
|
|
857
|
-
|
|
858
|
-
application oracle. For each processed send it freshly plans from the receipt's
|
|
859
|
-
`before` snapshot and compares handled/change flags, the public next snapshots,
|
|
860
|
-
completion, command counts, emitted events, and public microstep evidence. It
|
|
861
|
-
does not prove that the planner implements the intended business rules; use a
|
|
862
|
-
reference model and runtime invariants for that.
|
|
261
|
+
## Examples
|
|
863
262
|
|
|
864
|
-
|
|
263
|
+
Every package directly under [`examples/`](./examples) has its own lockfile and
|
|
264
|
+
`check` script.
|
|
865
265
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
state-scoped
|
|
266
|
+
| Example | What it demonstrates |
|
|
267
|
+
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
268
|
+
| [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, microwave safety across parallel regions, a service-backed media player, and a worker-hosted machine synchronized across tabs |
|
|
269
|
+
| [Pokémon](./examples/pokemon) | Compound and parallel states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
|
|
270
|
+
| [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |
|
|
869
271
|
|
|
870
|
-
|
|
272
|
+
The playground is the shortest path from one concept to working code. The
|
|
273
|
+
standalone examples show larger composition and ownership boundaries.
|
|
871
274
|
|
|
872
|
-
|
|
873
|
-
definition order, modeling rules, lifecycle invariants, React recipe, common
|
|
874
|
-
compiler errors, and unsupported features.
|
|
275
|
+
## Reference and development
|
|
875
276
|
|
|
876
|
-
|
|
277
|
+
- [API reference](https://effect-machine.typeonce.dev)
|
|
278
|
+
- [Agent and implementation guide](./docs/agent-guide.md)
|
|
279
|
+
- [Contributing guide](./CONTRIBUTING.md)
|
|
877
280
|
|
|
878
281
|
Use pnpm 10 and Node.js 20 or newer:
|
|
879
282
|
|
|
@@ -882,40 +285,9 @@ pnpm install --frozen-lockfile
|
|
|
882
285
|
pnpm check
|
|
883
286
|
```
|
|
884
287
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
packs the package, imports all public entrypoints, and compiles a strict
|
|
889
|
-
TypeScript consumer with `skipLibCheck: false`.
|
|
890
|
-
|
|
891
|
-
Read [CONTRIBUTING.md](./CONTRIBUTING.md) before proposing a change. Pull
|
|
892
|
-
requests receive an automated base-versus-head type-instantiation report.
|
|
893
|
-
|
|
894
|
-
## Examples
|
|
895
|
-
|
|
896
|
-
The [platformer statechart example](./examples/platformer) is a playable SVG
|
|
897
|
-
demo centered on a schema-first character machine. It demonstrates nested
|
|
898
|
-
compound locomotion, parallel airborne motion and air-jump regions, independent
|
|
899
|
-
facing and wall-contact regions, a pause/resume flow backed by typed deep
|
|
900
|
-
history, typed protocol events, state-scoped timers, and state-driven SVG
|
|
901
|
-
transforms.
|
|
902
|
-
|
|
903
|
-
The [Pokémon statechart example](./examples/pokemon) is a standalone React and
|
|
904
|
-
Vite project demonstrating compound and parallel states, state-scoped invokes,
|
|
905
|
-
invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
|
|
906
|
-
`file:` dependency on this package while retaining an isolated dependency graph,
|
|
907
|
-
lockfile, build, and CI job.
|
|
908
|
-
|
|
909
|
-
The [playground](./examples/playground) collects focused interactive examples
|
|
910
|
-
for traffic lights, turnstiles, media players, microwaves, and worker-backed
|
|
911
|
-
machines. CI discovers every direct package under `examples/` and runs its
|
|
912
|
-
`check` script automatically.
|
|
913
|
-
|
|
914
|
-
## Releases
|
|
915
|
-
|
|
916
|
-
Add a changeset with `pnpm changeset`. CI validates frozen installation and the
|
|
917
|
-
complete check suite. The release workflow opens version PRs and publishes with
|
|
918
|
-
npm provenance through GitHub Actions.
|
|
288
|
+
Declarative first-class guards are not currently part of the API; use ordinary
|
|
289
|
+
TypeScript conditions. Pull requests that change `src/` or `package.json` need
|
|
290
|
+
a changeset and the performance checks described in `AGENTS.md`.
|
|
919
291
|
|
|
920
292
|
When equivalent Machine modules ship in Effect, this package is intended to
|
|
921
|
-
become a
|
|
293
|
+
become a compatibility re-export before eventual retirement.
|
package/docs/agent-guide.md
CHANGED
|
@@ -51,9 +51,11 @@ const InternalEvent = Schema.TaggedUnion({
|
|
|
51
51
|
const States = Machine.defineStates(State.cases)
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Construct values with `
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
Construct event values with `Event.cases.Save.make({})`. Construct new state
|
|
55
|
+
values through the target or initial builder's `.from(...)` method so schema
|
|
56
|
+
construction runs inside planning. Pass a state directly only when it is
|
|
57
|
+
already decoded. Use `Schema.TaggedClass` when a case needs class methods or
|
|
58
|
+
nominal class identity; `.from(...)` preserves that identity.
|
|
57
59
|
|
|
58
60
|
## Hard invariants
|
|
59
61
|
|
|
@@ -190,7 +192,7 @@ const States = Machine.defineStates({
|
|
|
190
192
|
const machine = Machine.make({
|
|
191
193
|
states: States.states,
|
|
192
194
|
events: [],
|
|
193
|
-
initial: () => States.initial.Done
|
|
195
|
+
initial: () => States.initial.Done.from()
|
|
194
196
|
}).handle({
|
|
195
197
|
Done: {
|
|
196
198
|
output: () => "done"
|
|
@@ -268,15 +270,8 @@ Workspace: {
|
|
|
268
270
|
history: {
|
|
269
271
|
resume: {
|
|
270
272
|
default: ({ target }) =>
|
|
271
|
-
target.App(
|
|
272
|
-
|
|
273
|
-
(app) =>
|
|
274
|
-
app.Workspace(
|
|
275
|
-
State.cases.Workspace.make({}),
|
|
276
|
-
(workspace) =>
|
|
277
|
-
workspace.Editing(State.cases.Editing.make({}))
|
|
278
|
-
)
|
|
279
|
-
)
|
|
273
|
+
target.App.from({ workspaceId: "default" }, (app) =>
|
|
274
|
+
app.Workspace.from((workspace) => workspace.Editing.from()))
|
|
280
275
|
}
|
|
281
276
|
}
|
|
282
277
|
}
|
|
@@ -315,7 +310,7 @@ exiting shared states. To force the source to exit and enter again:
|
|
|
315
310
|
Refresh: {
|
|
316
311
|
reenter: true,
|
|
317
312
|
transition: ({ state, target }) =>
|
|
318
|
-
target.full.Ready(
|
|
313
|
+
target.full.Ready.from({ value: state.value })
|
|
319
314
|
}
|
|
320
315
|
```
|
|
321
316
|
|
|
@@ -382,7 +377,7 @@ microstep, before any selected transition is applied:
|
|
|
382
377
|
```ts
|
|
383
378
|
BufferReady: ({ snapshot, target }) =>
|
|
384
379
|
States.matches(snapshot, "Player.Network.Online")
|
|
385
|
-
? target.local.Playing(
|
|
380
|
+
? target.local.Playing.from()
|
|
386
381
|
: undefined
|
|
387
382
|
```
|
|
388
383
|
|
|
@@ -426,7 +421,7 @@ A transition returns a target synchronously:
|
|
|
426
421
|
|
|
427
422
|
```ts
|
|
428
423
|
Submit: ({ state, target }) =>
|
|
429
|
-
state.valid ? target.local.Saving(
|
|
424
|
+
state.valid ? target.local.Saving.from({ draft: state.draft }) : undefined
|
|
430
425
|
```
|
|
431
426
|
|
|
432
427
|
Closed statechart and actor operations use `enqueue`:
|
|
@@ -434,7 +429,7 @@ Closed statechart and actor operations use `enqueue`:
|
|
|
434
429
|
```ts
|
|
435
430
|
Submit: ({ target }, enqueue) => {
|
|
436
431
|
enqueue.emit(new SaveRequested({}))
|
|
437
|
-
return target.local.Saving(
|
|
432
|
+
return target.local.Saving.from()
|
|
438
433
|
}
|
|
439
434
|
```
|
|
440
435
|
|
|
@@ -878,7 +873,7 @@ reference model when correctness of the expected behavior matters.
|
|
|
878
873
|
Wrap the initial builder result:
|
|
879
874
|
|
|
880
875
|
```ts
|
|
881
|
-
initial: () => States.initial.Idle(
|
|
876
|
+
initial: () => States.initial.Idle.from()
|
|
882
877
|
```
|
|
883
878
|
|
|
884
879
|
### Invoked child output must be a machine event
|