@typeonce/effect-machine 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +0 -2
- package/README.md +464 -53
- package/dist/AtomMachine.d.ts +189 -10
- package/dist/AtomMachine.d.ts.map +1 -1
- package/dist/AtomMachine.js +145 -18
- package/dist/AtomMachine.js.map +1 -1
- package/dist/ClusterMachine.d.ts +17 -7
- package/dist/ClusterMachine.d.ts.map +1 -1
- package/dist/ClusterMachine.js +2 -2
- package/dist/ClusterMachine.js.map +1 -1
- package/dist/Machine.d.ts +949 -202
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +245 -82
- package/dist/Machine.js.map +1 -1
- package/dist/internal/machineErrors.d.ts +2 -2
- package/dist/internal/machineErrors.d.ts.map +1 -1
- package/dist/internal/machineModel.d.ts +52 -4
- package/dist/internal/machineModel.d.ts.map +1 -1
- package/dist/internal/machineModel.js +576 -29
- package/dist/internal/machineModel.js.map +1 -1
- package/dist/internal/machinePlanner.d.ts +15 -5
- package/dist/internal/machinePlanner.d.ts.map +1 -1
- package/dist/internal/machinePlanner.js +260 -57
- package/dist/internal/machinePlanner.js.map +1 -1
- package/dist/internal/machineProcess.d.ts +2 -2
- package/dist/internal/machineProcess.d.ts.map +1 -1
- package/dist/internal/machineProcess.js +22 -11
- package/dist/internal/machineProcess.js.map +1 -1
- package/dist/internal/machineRuntime.d.ts +11 -5
- package/dist/internal/machineRuntime.d.ts.map +1 -1
- package/dist/internal/machineRuntime.js +24 -13
- package/dist/internal/machineRuntime.js.map +1 -1
- package/docs/agent-guide.md +680 -0
- package/package.json +20 -22
package/NOTICE
CHANGED
package/README.md
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
# @typeonce/effect-machine
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[Effect PR #6429](https://github.com/Effect-TS/effect/pull/6429). During
|
|
5
|
-
incubation this repository is the canonical implementation; the synchronization
|
|
6
|
-
tool keeps the Effect PR mechanically aligned without a second logic
|
|
7
|
-
implementation.
|
|
3
|
+
Schema-first state machines and statecharts for Effect.
|
|
8
4
|
|
|
9
5
|
> Early-release software: APIs may change, and releases are coupled to an exact
|
|
10
6
|
> Effect beta.
|
|
@@ -15,93 +11,512 @@ implementation.
|
|
|
15
11
|
pnpm add @typeonce/effect-machine effect@4.0.0-beta.102
|
|
16
12
|
```
|
|
17
13
|
|
|
18
|
-
`effect` is an exact peer dependency, not a bundled runtime dependency.
|
|
19
|
-
must install `effect@4.0.0-beta.102`. Upgrading this package may
|
|
20
|
-
Effect in lockstep; do not override the peer to another beta.
|
|
14
|
+
`effect` is an exact peer dependency, not a bundled runtime dependency.
|
|
15
|
+
Consumers must install `effect@4.0.0-beta.102`. Upgrading this package may
|
|
16
|
+
require upgrading Effect in lockstep; do not override the peer to another beta.
|
|
21
17
|
|
|
22
18
|
## Entrypoints
|
|
23
19
|
|
|
24
20
|
```ts
|
|
25
21
|
import { Machine } from "@typeonce/effect-machine"
|
|
26
|
-
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
27
22
|
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
|
|
23
|
+
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
28
24
|
```
|
|
29
25
|
|
|
30
26
|
Each ESM entrypoint is independent and tree-shakeable. Importing the root does
|
|
31
27
|
not load the reactivity or cluster adapters.
|
|
32
28
|
|
|
33
|
-
##
|
|
29
|
+
## First machine
|
|
30
|
+
|
|
31
|
+
Schemas provide runtime decoders and the types used by handlers, targets,
|
|
32
|
+
inputs, outputs, and running references. The public/internal event distinction
|
|
33
|
+
has an additional boundary described below.
|
|
34
|
+
|
|
35
|
+
Effect's `Schema.TaggedUnion` is a compact way to declare cases. Its `cases`
|
|
36
|
+
property contains the individual tagged schemas, and each case has a typed
|
|
37
|
+
`make` constructor.
|
|
34
38
|
|
|
35
39
|
```ts
|
|
36
|
-
import { Effect, Schema } from "effect"
|
|
37
40
|
import { Machine } from "@typeonce/effect-machine"
|
|
41
|
+
import { Schema } from "effect"
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const State = Schema.TaggedUnion({
|
|
44
|
+
Idle: {},
|
|
45
|
+
Running: {}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const Event = Schema.TaggedUnion({
|
|
49
|
+
Start: {}
|
|
50
|
+
})
|
|
42
51
|
|
|
43
|
-
const
|
|
52
|
+
const States = Machine.defineStates(State.cases)
|
|
44
53
|
|
|
45
54
|
const Counter = Machine.make({
|
|
46
55
|
id: "Counter",
|
|
47
|
-
states:
|
|
48
|
-
events: [Start],
|
|
49
|
-
initial:
|
|
56
|
+
states: States.states,
|
|
57
|
+
events: [Event.cases.Start],
|
|
58
|
+
initial: () => States.initial.Idle.from()
|
|
50
59
|
}).handle({
|
|
51
60
|
Idle: {
|
|
52
61
|
on: {
|
|
53
|
-
Start: ({ target }) =>
|
|
62
|
+
Start: ({ target }) => target.full.Running.from()
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
Running: {}
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`initial` is always a function. For a machine with an input schema, the
|
|
70
|
+
initializer receives the decoded input.
|
|
71
|
+
|
|
72
|
+
Builder methods accept an already constructed state value directly, or expose
|
|
73
|
+
`.from` for constructing one safely from the state schema's make input:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
target.local.Running(decodedRunning)
|
|
77
|
+
target.local.Running.from({ startedAt: event.at })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Use the direct call when a decoded value already exists. Use `.from` when
|
|
81
|
+
entering a state from fields. Construction runs through the schema's
|
|
82
|
+
`makeEffect` while the machine plans the configuration, so constructor
|
|
83
|
+
defaults and tagged-class identity are preserved and failed refinements become
|
|
84
|
+
`MachineSchemaDecodeError` failures instead of synchronous throws. The same
|
|
85
|
+
form is available on initial, local, branch, full, compound, parallel, and
|
|
86
|
+
final builders. A `.from` builder result is therefore a machine construction
|
|
87
|
+
instruction; it becomes a validated public snapshot when planning succeeds.
|
|
88
|
+
|
|
89
|
+
When `{}` is valid constructor input, omit it. Required state fields remain
|
|
90
|
+
required, while compound and parallel states still require their active-child
|
|
91
|
+
callback:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
States.initial.Idle.from()
|
|
95
|
+
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
|
|
96
|
+
States.initial.Flow.from((flow) => flow.Idle.from())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Tagged classes are equally valid when cases need class methods or nominal
|
|
100
|
+
identity:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Public and internal events
|
|
107
|
+
|
|
108
|
+
Declare commands that callers may send in `events`. Declare machine-local
|
|
109
|
+
deliveries, such as invoke results and child emissions, in `internalEvents`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const Command = Schema.TaggedUnion({
|
|
113
|
+
Save: {}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const InternalEvent = Schema.TaggedUnion({
|
|
117
|
+
Saved: { id: Schema.String },
|
|
118
|
+
SaveFailed: { message: Schema.String }
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const machine = Machine.make({
|
|
122
|
+
states: States.states,
|
|
123
|
+
events: [Command.cases.Save],
|
|
124
|
+
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
|
|
125
|
+
initial: () => States.initial.Idle.from()
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Handlers and machine logic see the complete union. Local public APIs such as
|
|
130
|
+
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` expose only `events`
|
|
131
|
+
in TypeScript. The local planner and runtime still share the complete event
|
|
132
|
+
decoder so machine-local deliveries can flow through the same execution
|
|
133
|
+
protocol; bypassing the types with JavaScript or `any` is therefore not a
|
|
134
|
+
runtime authorization boundary. Cluster RPC delivery additionally validates
|
|
135
|
+
incoming payloads against the public `events` schemas.
|
|
136
|
+
|
|
137
|
+
The utility types make the distinction available to application code:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
type PublicCommand = Machine.Machine.InputEvent<typeof machine>
|
|
141
|
+
type HandledEvent = Machine.Machine.Event<typeof machine>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Tags must be unique within each list, and public and internal tags must be
|
|
145
|
+
disjoint. Reusing a tag is a type error, so a command cannot accidentally
|
|
146
|
+
masquerade as an internal result.
|
|
147
|
+
|
|
148
|
+
## Statechart structure
|
|
149
|
+
|
|
150
|
+
`Machine.defineStates` accepts atomic, compound, parallel, final, and history
|
|
151
|
+
nodes:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const State = Schema.TaggedUnion({
|
|
155
|
+
Form: { draft: Schema.String },
|
|
156
|
+
Editing: {},
|
|
157
|
+
Saving: {},
|
|
158
|
+
Done: {}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const States = Machine.defineStates({
|
|
162
|
+
Form: {
|
|
163
|
+
schema: State.cases.Form,
|
|
164
|
+
initial: "Editing",
|
|
165
|
+
states: {
|
|
166
|
+
Editing: State.cases.Editing,
|
|
167
|
+
Saving: State.cases.Saving,
|
|
168
|
+
Done: {
|
|
169
|
+
schema: State.cases.Done,
|
|
170
|
+
type: "final",
|
|
171
|
+
output: Schema.String
|
|
172
|
+
}
|
|
54
173
|
}
|
|
55
174
|
}
|
|
56
175
|
})
|
|
57
176
|
```
|
|
58
177
|
|
|
59
|
-
|
|
60
|
-
|
|
178
|
+
Compound states have one active child and declare its initial key. Parallel
|
|
179
|
+
states use `type: "parallel"` and have one active state in every direct region.
|
|
180
|
+
Finality is topology, so declare `type: "final"` only in the state definition.
|
|
181
|
+
Handlers implement behavior and output computation without repeating it:
|
|
61
182
|
|
|
62
|
-
|
|
183
|
+
```ts
|
|
184
|
+
const machine = Machine.make({
|
|
185
|
+
states: States.states,
|
|
186
|
+
events: [],
|
|
187
|
+
initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
|
|
188
|
+
}).handle({
|
|
189
|
+
Form: {
|
|
190
|
+
states: {
|
|
191
|
+
Done: {
|
|
192
|
+
output: () => "saved"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
```
|
|
63
198
|
|
|
64
|
-
|
|
199
|
+
Every declared output schema must have a matching handler implementation before
|
|
200
|
+
the machine can be planned, started, invoked, or adapted to Atom/Cluster.
|
|
201
|
+
Final children complete their parent; put `onDone` on that compound or parallel
|
|
202
|
+
parent, not on the final leaf.
|
|
65
203
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
204
|
+
Put data on the narrowest state where it is valid. If several sibling phases
|
|
205
|
+
share data, prefer storing it on their compound parent instead of copying it
|
|
206
|
+
into every child state.
|
|
207
|
+
|
|
208
|
+
### History states
|
|
209
|
+
|
|
210
|
+
A history pseudo-state remembers the last active configuration of its parent.
|
|
211
|
+
It has no value schema and never appears in an active snapshot. History is
|
|
212
|
+
shallow by default; use `history: "deep"` to retain the complete descendant
|
|
213
|
+
configuration and its validated values:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const States = Machine.defineStates({
|
|
217
|
+
checkout: {
|
|
218
|
+
schema: Checkout,
|
|
219
|
+
initial: "shipping",
|
|
220
|
+
states: {
|
|
221
|
+
shipping: Shipping,
|
|
222
|
+
payment: {
|
|
223
|
+
schema: Payment,
|
|
224
|
+
initial: "cardEntry",
|
|
225
|
+
states: {
|
|
226
|
+
cardEntry: CardEntry,
|
|
227
|
+
verifying: Verifying
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
resume: { type: "history", history: "deep" }
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
support: Support
|
|
234
|
+
})
|
|
69
235
|
```
|
|
70
236
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
237
|
+
Implement a typed default for the first transition before any configuration
|
|
238
|
+
has been remembered, then target history without supplying a state value:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
machine.handle({
|
|
242
|
+
checkout: {
|
|
243
|
+
history: {
|
|
244
|
+
resume: {
|
|
245
|
+
default: () => initialCheckoutSnapshot
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
support: {
|
|
250
|
+
on: {
|
|
251
|
+
Resume: ({ target }) => target.history.checkout.resume()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
```
|
|
75
256
|
|
|
76
|
-
|
|
257
|
+
Deep history restores every remembered descendant value. Shallow history
|
|
258
|
+
restores the parent and direct-child values, then follows normal initial paths.
|
|
259
|
+
Only compound or parallel states that shallow restoration can enter implicitly
|
|
260
|
+
need an `initial` handler to construct those new child values:
|
|
77
261
|
|
|
78
|
-
|
|
79
|
-
|
|
262
|
+
```ts
|
|
263
|
+
payment: {
|
|
264
|
+
initial: ;
|
|
265
|
+
;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }))
|
|
266
|
+
}
|
|
267
|
+
```
|
|
80
268
|
|
|
81
|
-
|
|
82
|
-
|
|
269
|
+
Execution APIs remain unavailable until required history defaults and shallow
|
|
270
|
+
initializers have been implemented. History records are part of logical
|
|
271
|
+
snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`.
|
|
272
|
+
|
|
273
|
+
Transition between structurally related tagged states with `Machine.retag`.
|
|
274
|
+
The source `_tag` is discarded, compatible fields are reused, and missing or
|
|
275
|
+
incompatible required fields must be supplied:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const saving = Machine.retag(State.cases.Saving, editing)
|
|
83
279
|
```
|
|
84
280
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
281
|
+
## Choosing a target builder
|
|
282
|
+
|
|
283
|
+
Transition contexts expose four typed target builders:
|
|
284
|
+
|
|
285
|
+
| Builder | Destination | Configuration behavior |
|
|
286
|
+
| ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
287
|
+
| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
|
|
288
|
+
| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
|
|
289
|
+
| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
|
|
290
|
+
| `target.history` | A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default |
|
|
291
|
+
|
|
292
|
+
When `target.local` or `target.branch` enters an inactive nested parallel
|
|
293
|
+
state, its callback must select every region, just like `initial` and
|
|
294
|
+
`target.full`. When that parallel state is already active, `target.branch`
|
|
295
|
+
can still update one region directly and preserves the other active regions.
|
|
296
|
+
|
|
297
|
+
The builder controls how the next configuration is assembled; it does not by
|
|
298
|
+
itself decide which invokes restart. The runtime derives exit and entry paths
|
|
299
|
+
from the previous and next active paths. Shared active ancestors remain entered,
|
|
300
|
+
even when `target.full` supplies their values again. Use an event transition
|
|
301
|
+
with `reenter: true` when the source state should explicitly exit and enter
|
|
302
|
+
again:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
Refresh: {
|
|
306
|
+
reenter: true,
|
|
307
|
+
transition: ({ state, target }) =>
|
|
308
|
+
target.full.Ready(new Ready({ value: state.value }))
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`States.get`, `States.getWithParents`, `States.getSnapshot`, and
|
|
313
|
+
`States.matches` accept typed dotted paths. Handler `parents` values are also
|
|
314
|
+
keyed by full dotted paths, such as `parents["Form.Editing"]`; `context.parent`
|
|
315
|
+
provides the immediate parent directly and is `undefined` at a root state.
|
|
316
|
+
|
|
317
|
+
## Planning Effects and staged actions
|
|
318
|
+
|
|
319
|
+
An Effect returned by a transition handler is part of planning. Use it to read
|
|
320
|
+
services, choose a target, raise an event, or emit an event. Wrap external side
|
|
321
|
+
effects in `Machine.action`; actions are staged during planning and run by the
|
|
322
|
+
managed runtime before it publishes the next state.
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const handlers = {
|
|
326
|
+
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The one-argument form returns `void` after staging. The two-argument form
|
|
331
|
+
returns its second argument, which avoids a generator when an action and the
|
|
332
|
+
next target are the whole transition.
|
|
333
|
+
|
|
334
|
+
If an action fails, the runtime keeps the previously published state and
|
|
335
|
+
suppresses emissions from that plan.
|
|
336
|
+
|
|
337
|
+
`Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
|
|
338
|
+
`done` is `true`, `output` is the schema-derived structural terminal union;
|
|
339
|
+
while the machine remains active, it is `undefined`. A started machine's
|
|
340
|
+
`join` uses the same terminal union. Output-less structural terminal paths
|
|
341
|
+
contribute `undefined`, while active atomic roots do not.
|
|
342
|
+
|
|
343
|
+
This union is intentionally conservative with respect to handler behavior. For
|
|
344
|
+
example, a root `onDone` transition may make one structurally terminal result
|
|
345
|
+
unreachable even though its schema remains in `Machine.TerminalOutput`.
|
|
346
|
+
|
|
347
|
+
## State-scoped invokes
|
|
348
|
+
|
|
349
|
+
`Machine.invoke` runs child logic while its owning state is active. Leaving the
|
|
350
|
+
state interrupts the child. For a one-shot Effect, `Machine.invokeEffect` maps
|
|
351
|
+
typed success and failure values directly to internal events:
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
const loading = {
|
|
355
|
+
invoke: ({ state }) =>
|
|
356
|
+
Machine.invokeEffect({
|
|
357
|
+
id: "save",
|
|
358
|
+
effect: save(state),
|
|
359
|
+
onSuccess: (entry) => InternalEvent.cases.Saved.make({ id: entry.id }),
|
|
360
|
+
onFailure: (error) =>
|
|
361
|
+
InternalEvent.cases.SaveFailed.make({
|
|
362
|
+
message: String(error)
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Omit `onFailure` when the Effect cannot fail. Defects and interruption remain
|
|
369
|
+
failures rather than being mapped.
|
|
370
|
+
|
|
371
|
+
`Machine.after` creates a cancellable, state-scoped delayed event with the same
|
|
372
|
+
lifetime:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
invoke: Machine.after("3 seconds", InternalEvent.cases.SaveFailed.make({ message: "Timed out" }), {
|
|
376
|
+
id: "save-timeout"
|
|
377
|
+
})
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Provide an explicit id when more than one active timer could deliver the same
|
|
381
|
+
event tag.
|
|
382
|
+
|
|
383
|
+
Use lower-level `Machine.invoke` with `Machine.effect` for custom child logic or
|
|
384
|
+
snapshot mapping. Its `id` is only the state-local lifecycle key. If the parent
|
|
385
|
+
must send events to that invocation, create a typed low-level address with
|
|
386
|
+
`Machine.childAddress<Event>("worker")` and pass it through the explicit
|
|
387
|
+
`address` option; the address protocol is checked against the child logic.
|
|
388
|
+
Lifecycle ids must be unique among simultaneously active invokes owned by the
|
|
389
|
+
same state.
|
|
89
390
|
|
|
90
|
-
|
|
391
|
+
Invoke outputs, invoke snapshot events, and invoked-child emissions belong in
|
|
392
|
+
`internalEvents`. They are available to typed handlers but are not accepted by
|
|
393
|
+
the typed public input APIs. Include a child machine's emitted protocol with
|
|
394
|
+
`internalEvents: [...ChildMachine.emits]` when those emissions should be handled
|
|
395
|
+
by the parent.
|
|
396
|
+
|
|
397
|
+
For a child statechart, create one descriptor for `invokeMachine`, `sendTo`,
|
|
398
|
+
and child lookup:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const Editor = Machine.child("editor", EditorMachine)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`Machine.child(id, machine)` is the complete statechart descriptor;
|
|
405
|
+
`Machine.childAddress<Event>(id)` is the lower-level event-only address.
|
|
406
|
+
Descriptors are matched by id and machine identity, so independently created
|
|
407
|
+
descriptors for the same pair address the same child without a global cache.
|
|
408
|
+
Exporting one descriptor remains the clearest module boundary.
|
|
409
|
+
|
|
410
|
+
## Reactivity
|
|
411
|
+
|
|
412
|
+
`AtomMachine.make` creates a lazy bridge backed by one running machine per
|
|
413
|
+
`AtomRegistry`. Mounting or reading one of its atoms starts the machine;
|
|
414
|
+
disposing the registry-owned reference stops it.
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
418
|
+
import { Atom } from "effect/unstable/reactivity"
|
|
419
|
+
|
|
420
|
+
const runtime = Atom.runtime(AppLayer)
|
|
421
|
+
const machines = AtomMachine.bind(runtime)
|
|
422
|
+
const machineAtom = machines.make(Counter)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
For applications with a shared runtime, treat
|
|
426
|
+
`AtomMachine.bind(runtime).make(...)` as the canonical form. It keeps runtime
|
|
427
|
+
ownership at the composition boundary so it does not need to be passed through
|
|
428
|
+
every feature. Service-free machines may use `AtomMachine.make(machine)`
|
|
429
|
+
directly.
|
|
430
|
+
|
|
431
|
+
The bridge exposes:
|
|
432
|
+
|
|
433
|
+
- `ref`: the running `MachineRef`
|
|
434
|
+
- `result`: fail-aware logical state, combining startup and post-start runtime
|
|
435
|
+
failures
|
|
436
|
+
- `snapshot`: authoritative runtime lifecycle, including `active`, `done`,
|
|
437
|
+
`error`, and `stopped`
|
|
438
|
+
- `state`: the last logical state, including the retained state after a runtime
|
|
439
|
+
failure
|
|
440
|
+
- `send` and `stop`: writable command atoms
|
|
441
|
+
- `child(descriptor)`: a reactive bridge for a directly owned child
|
|
442
|
+
|
|
443
|
+
Use `AtomMachine.select` and `AtomMachine.matches` for equality-aware root
|
|
444
|
+
derivations. Use `selectChild` and `matchesChild` for child bridges. Selector
|
|
445
|
+
paths and selected value types are inferred directly from the bridge snapshot,
|
|
446
|
+
so these combinators do not need the `DefinedStates` object. They follow normal
|
|
447
|
+
Atom identity semantics and return a new atom on each call, so retain or memoize
|
|
448
|
+
them when constructing them in a component. The `child` method uses Effect's
|
|
449
|
+
`Atom.family` to reuse a live bridge for the same descriptor without maintaining
|
|
450
|
+
a package-level cache.
|
|
451
|
+
`AtomMachine.ChildMachineAtom<typeof Child>` uses `unknown` as its startup-error
|
|
452
|
+
default for general component props.
|
|
453
|
+
`AtomMachine.ChildOf<typeof parentAtom, typeof Child>` preserves the exact
|
|
454
|
+
parent startup-error channel.
|
|
455
|
+
|
|
456
|
+
Child state and snapshot atoms contain `Option.none()` while that child is
|
|
457
|
+
inactive. React applications using `@effect/atom-react` need a
|
|
458
|
+
`RegistryProvider`; see the [Pokémon example](./examples/pokemon).
|
|
459
|
+
|
|
460
|
+
## Snapshots and persistence
|
|
461
|
+
|
|
462
|
+
`Machine.encodeSnapshot` and `Machine.decodeSnapshot` validate logical
|
|
463
|
+
statechart data for storage or transport. The encoded representation does not
|
|
464
|
+
contain the machine definition, machine version, services, subscriptions, or
|
|
465
|
+
running child processes. Store machine identity and migration/version metadata
|
|
466
|
+
alongside it.
|
|
467
|
+
|
|
468
|
+
`ClusterMachine` provides a separate persisted entity adapter. Its process-local
|
|
469
|
+
restrictions and delivery guarantees are documented on that API.
|
|
470
|
+
|
|
471
|
+
## Current limits
|
|
472
|
+
|
|
473
|
+
Declarative first-class guards are not part of the current API. Ordinary
|
|
474
|
+
TypeScript conditions implement guards. Use `Machine.after` for a cancellable
|
|
475
|
+
state-scoped delayed event.
|
|
476
|
+
|
|
477
|
+
## Guidance for agents and contributors
|
|
478
|
+
|
|
479
|
+
The shipped [agent guide](./docs/agent-guide.md) contains the recommended
|
|
480
|
+
definition order, modeling rules, lifecycle invariants, React recipe, common
|
|
481
|
+
compiler errors, and unsupported features.
|
|
482
|
+
|
|
483
|
+
## Development and validation
|
|
484
|
+
|
|
485
|
+
Use pnpm 10 and Node.js 20 or newer:
|
|
91
486
|
|
|
92
487
|
```sh
|
|
93
|
-
pnpm
|
|
488
|
+
pnpm install --frozen-lockfile
|
|
489
|
+
pnpm check
|
|
94
490
|
```
|
|
95
491
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
492
|
+
Individual commands are available for `build`, `test`, `test:types`,
|
|
493
|
+
`typecheck`, `format:check`, `test:consumer`, and `pack:check`. Runtime tests use
|
|
494
|
+
`@effect/vitest`; type tests use TSTyche and TypeScript 6.0.3. The consumer check
|
|
495
|
+
packs the package, imports all public entrypoints, and compiles a strict
|
|
496
|
+
TypeScript consumer with `skipLibCheck: false`.
|
|
497
|
+
|
|
498
|
+
Read [CONTRIBUTING.md](./CONTRIBUTING.md) before proposing a change. Pull
|
|
499
|
+
requests receive an automated base-versus-head type-instantiation report.
|
|
101
500
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
501
|
+
## Examples
|
|
502
|
+
|
|
503
|
+
The [platformer statechart example](./examples/platformer) is a playable SVG
|
|
504
|
+
demo centered on a schema-first character machine. It demonstrates nested
|
|
505
|
+
compound locomotion, parallel airborne motion and air-jump regions, independent
|
|
506
|
+
facing and wall-contact regions, a pause/resume flow backed by typed deep
|
|
507
|
+
history, typed protocol events, state-scoped timers, and state-driven SVG
|
|
508
|
+
transforms.
|
|
509
|
+
|
|
510
|
+
The [Pokémon statechart example](./examples/pokemon) is a standalone React and
|
|
511
|
+
Vite project demonstrating compound and parallel states, state-scoped invokes,
|
|
512
|
+
invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
|
|
513
|
+
`file:` dependency on this package while retaining an isolated dependency graph,
|
|
514
|
+
lockfile, build, and CI job.
|
|
515
|
+
|
|
516
|
+
The [playground](./examples/playground) collects focused interactive examples
|
|
517
|
+
for traffic lights, turnstiles, media players, microwaves, and worker-backed
|
|
518
|
+
machines. CI discovers every direct package under `examples/` and runs its
|
|
519
|
+
`check` script automatically.
|
|
105
520
|
|
|
106
521
|
## Releases
|
|
107
522
|
|
|
@@ -109,9 +524,5 @@ Add a changeset with `pnpm changeset`. CI validates frozen installation and the
|
|
|
109
524
|
complete check suite. The release workflow opens version PRs and publishes with
|
|
110
525
|
npm provenance through GitHub Actions.
|
|
111
526
|
|
|
112
|
-
Before the first release, create the `typeonce-dev/effect-machine` GitHub
|
|
113
|
-
repository and configure npm trusted publishing for the repository and
|
|
114
|
-
`.github/workflows/release.yml` environment. No npm token is intended.
|
|
115
|
-
|
|
116
527
|
When equivalent Machine modules ship in Effect, this package is intended to
|
|
117
528
|
become a thin compatibility re-export package before eventual retirement.
|