@typeonce/effect-machine 0.4.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.
Files changed (90) hide show
  1. package/README.md +166 -588
  2. package/dist/Machine.d.ts +426 -240
  3. package/dist/Machine.d.ts.map +1 -1
  4. package/dist/Machine.js +219 -43
  5. package/dist/Machine.js.map +1 -1
  6. package/dist/internal/machine/activities.d.ts +1 -1
  7. package/dist/internal/machine/activities.js +1 -1
  8. package/dist/internal/machine/atom.d.ts +1 -1
  9. package/dist/internal/machine/atom.js +1 -1
  10. package/dist/internal/machine/cluster.d.ts +2 -2
  11. package/dist/internal/machine/cluster.js +1 -1
  12. package/dist/internal/machine/command.d.ts +1 -1
  13. package/dist/internal/machine/command.js +1 -1
  14. package/dist/internal/machine/commandRuntime.d.ts +1 -1
  15. package/dist/internal/machine/commandRuntime.js +1 -1
  16. package/dist/internal/machine/configuration.d.ts +1 -1
  17. package/dist/internal/machine/configuration.js +1 -1
  18. package/dist/internal/machine/errors.d.ts +7 -7
  19. package/dist/internal/machine/errors.js +7 -7
  20. package/dist/internal/machine/executionPlan.d.ts +2 -2
  21. package/dist/internal/machine/executionPlan.d.ts.map +1 -1
  22. package/dist/internal/machine/executionPlan.js +17 -8
  23. package/dist/internal/machine/executionPlan.js.map +1 -1
  24. package/dist/internal/machine/invocation.d.ts +1 -1
  25. package/dist/internal/machine/invocation.js +1 -1
  26. package/dist/internal/machine/planner.d.ts +1 -1
  27. package/dist/internal/machine/planner.js +1 -1
  28. package/dist/internal/machine/process.d.ts +1 -1
  29. package/dist/internal/machine/process.d.ts.map +1 -1
  30. package/dist/internal/machine/process.js +76 -24
  31. package/dist/internal/machine/process.js.map +1 -1
  32. package/dist/internal/machine/protocol.d.ts +1 -1
  33. package/dist/internal/machine/protocol.js +1 -1
  34. package/dist/internal/machine/runtime.d.ts +32 -4
  35. package/dist/internal/machine/runtime.d.ts.map +1 -1
  36. package/dist/internal/machine/runtime.js +120 -12
  37. package/dist/internal/machine/runtime.js.map +1 -1
  38. package/dist/internal/machine/serialization.d.ts +1 -1
  39. package/dist/internal/machine/serialization.js +1 -1
  40. package/dist/internal/machine/topology.d.ts +1 -1
  41. package/dist/internal/machine/topology.js +1 -1
  42. package/dist/internal/testing/machine/arbitrary.d.ts +3 -3
  43. package/dist/internal/testing/machine/exploration.d.ts +30 -0
  44. package/dist/internal/testing/machine/exploration.d.ts.map +1 -0
  45. package/dist/internal/testing/machine/exploration.js +237 -0
  46. package/dist/internal/testing/machine/exploration.js.map +1 -0
  47. package/dist/internal/testing/machine/finiteModel.d.ts +20 -20
  48. package/dist/internal/testing/machine/finiteModel.js +2 -2
  49. package/dist/internal/testing/machine/invariant.d.ts +31 -0
  50. package/dist/internal/testing/machine/invariant.d.ts.map +1 -0
  51. package/dist/internal/testing/machine/invariant.js +214 -0
  52. package/dist/internal/testing/machine/invariant.js.map +1 -0
  53. package/dist/internal/testing/machine/probe.d.ts +26 -0
  54. package/dist/internal/testing/machine/probe.d.ts.map +1 -0
  55. package/dist/internal/testing/machine/probe.js +45 -0
  56. package/dist/internal/testing/machine/probe.js.map +1 -0
  57. package/dist/internal/testing/machine/referenceModel.d.ts +14 -14
  58. package/dist/internal/testing/machine/referenceModel.js +2 -2
  59. package/dist/internal/testing/machine/runtime.d.ts +329 -26
  60. package/dist/internal/testing/machine/runtime.d.ts.map +1 -1
  61. package/dist/internal/testing/machine/runtime.js +303 -14
  62. package/dist/internal/testing/machine/runtime.js.map +1 -1
  63. package/dist/internal/testing/machine/runtimeInvariant.d.ts +32 -0
  64. package/dist/internal/testing/machine/runtimeInvariant.d.ts.map +1 -0
  65. package/dist/internal/testing/machine/runtimeInvariant.js +334 -0
  66. package/dist/internal/testing/machine/runtimeInvariant.js.map +1 -0
  67. package/dist/internal/testing/machine/trace.d.ts +17 -0
  68. package/dist/internal/testing/machine/trace.d.ts.map +1 -0
  69. package/dist/internal/testing/machine/trace.js +94 -0
  70. package/dist/internal/testing/machine/trace.js.map +1 -0
  71. package/dist/internal/testing/machine/verification.d.ts +9 -6
  72. package/dist/internal/testing/machine/verification.d.ts.map +1 -1
  73. package/dist/internal/testing/machine/verification.js +8 -79
  74. package/dist/internal/testing/machine/verification.js.map +1 -1
  75. package/dist/testing/MachineTest.d.ts +1029 -47
  76. package/dist/testing/MachineTest.d.ts.map +1 -1
  77. package/dist/testing/MachineTest.js +315 -11
  78. package/dist/testing/MachineTest.js.map +1 -1
  79. package/dist/testing/index.d.ts +1 -1
  80. package/dist/testing/index.js +1 -1
  81. package/dist/unstable/cluster/ClusterMachine.d.ts +38 -19
  82. package/dist/unstable/cluster/ClusterMachine.d.ts.map +1 -1
  83. package/dist/unstable/cluster/ClusterMachine.js +27 -9
  84. package/dist/unstable/cluster/ClusterMachine.js.map +1 -1
  85. package/dist/unstable/reactivity/AtomMachine.d.ts +82 -31
  86. package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
  87. package/dist/unstable/reactivity/AtomMachine.js +63 -12
  88. package/dist/unstable/reactivity/AtomMachine.js.map +1 -1
  89. package/docs/agent-guide.md +160 -18
  90. package/package.json +11 -2
package/README.md CHANGED
@@ -2,51 +2,40 @@
2
2
 
3
3
  Schema-first state machines and statecharts for Effect.
4
4
 
5
- > Early-release software: APIs may change, and releases are coupled to an exact
6
- > Effect beta.
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
- ## Installation
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, not a bundled runtime dependency.
15
- Consumers must install `effect@4.0.0-beta.107`. Upgrading this package may
16
- require upgrading Effect in lockstep; do not override the peer to another beta.
17
-
18
- ## Entrypoints
19
-
20
- ```ts
21
- import { Machine } from "@typeonce/effect-machine"
22
- import { ClusterMachine } from "@typeonce/effect-machine/cluster"
23
- import { AtomMachine } from "@typeonce/effect-machine/reactivity"
24
- ```
25
-
26
- Each ESM entrypoint is independent and tree-shakeable. Importing the root does
27
- not load the reactivity or cluster adapters.
19
+ `effect` is an exact peer dependency. Install the version above and upgrade it
20
+ in lockstep with this package.
28
21
 
29
- ## First machine
22
+ ## Quick start
30
23
 
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.
24
+ Define schemas first, derive the state topology, then add behavior:
38
25
 
39
26
  ```ts
40
27
  import { Machine } from "@typeonce/effect-machine"
41
- import { Schema } from "effect"
28
+ import { Effect, Schema } from "effect"
42
29
 
43
30
  const State = Schema.TaggedUnion({
44
31
  Idle: {},
45
- Running: {}
32
+ Running: { count: Schema.Number }
46
33
  })
47
34
 
48
35
  const Event = Schema.TaggedUnion({
49
- Start: {}
36
+ Start: {},
37
+ Increment: {},
38
+ Stop: {}
50
39
  })
51
40
 
52
41
  const States = Machine.defineStates(State.cases)
@@ -54,620 +43,240 @@ const States = Machine.defineStates(State.cases)
54
43
  const Counter = Machine.make({
55
44
  id: "Counter",
56
45
  states: States.states,
57
- events: [Event.cases.Start],
46
+ events: [Event],
58
47
  initial: () => States.initial.Idle.from()
59
48
  }).handle({
60
49
  Idle: {
61
50
  on: {
62
- Start: ({ target }) => target.full.Running.from()
51
+ Start: ({ target }) => target.full.Running.from({ count: 0 })
63
52
  }
64
53
  },
65
- 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))
66
66
  })
67
67
  ```
68
68
 
69
- Handler objects mirror the state definition recursively. `effect-machine` does
70
- not impose a fixed handler-tree depth; inference continues until TypeScript's
71
- normal, shape-dependent compiler limits.
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.
72
72
 
73
- `initial` is always a function. For a machine with an input schema, the
74
- initializer receives the decoded input.
73
+ ## Modeling workflow
75
74
 
76
- Builder methods accept an already constructed state value directly, or expose
77
- `.from` for constructing one safely from the state schema's make input:
75
+ Use this order to preserve inference and keep boundaries explicit:
78
76
 
79
- ```ts
80
- target.local.Running(decodedRunning)
81
- target.local.Running.from({ startedAt: event.at })
82
- ```
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.
83
82
 
84
- Use the direct call when a decoded value already exists. Use `.from` when
85
- entering a state from fields. Construction runs through the schema's
86
- `makeEffect` while the machine plans the configuration, so constructor
87
- defaults and tagged-class identity are preserved and failed refinements become
88
- `MachineSchemaDecodeError` failures instead of synchronous throws. The same
89
- form is available on initial, local, branch, full, compound, parallel, and
90
- final builders. A `.from` builder result is therefore a machine construction
91
- instruction; it becomes a validated public snapshot when planning succeeds.
83
+ ### Construct state through builders
92
84
 
93
- When `{}` is valid constructor input, omit it. Required state fields remain
94
- required, while compound and parallel states still require their active-child
95
- callback:
85
+ Use `.from(...)` when constructing a new state from fields:
96
86
 
97
87
  ```ts
98
- States.initial.Idle.from()
88
+ target.local.Saving.from({ draft: event.draft })
99
89
  States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
100
- States.initial.Flow.from((flow) => flow.Idle.from())
101
90
  ```
102
91
 
103
- Tagged classes are equally valid when cases need class methods or nominal
104
- 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`.
105
96
 
106
- ```ts
107
- class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}
108
- ```
97
+ Put data on the narrowest state where it is valid. If sibling phases share
98
+ data, put it on their compound parent.
109
99
 
110
- ## Public and internal events
100
+ ### Separate public and internal events
111
101
 
112
- Declare commands that callers may send in `events`. Declare machine-local
113
- deliveries, such as invoke results and child emissions, in `internalEvents`:
102
+ `events` is the public command protocol. Invoke results, timer deliveries,
103
+ raised events, and child emissions belong in `internalEvents`:
114
104
 
115
105
  ```ts
116
- const Command = Schema.TaggedUnion({
117
- Save: {}
118
- })
119
-
120
- const InternalEvent = Schema.TaggedUnion({
106
+ const Command = Schema.TaggedUnion({ Save: {} })
107
+ const Internal = Schema.TaggedUnion({
121
108
  Saved: { id: Schema.String },
122
109
  SaveFailed: { message: Schema.String }
123
110
  })
124
111
 
125
112
  const machine = Machine.make({
126
113
  states: States.states,
127
- events: [Command.cases.Save],
128
- internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
114
+ events: [Command],
115
+ internalEvents: [Internal],
129
116
  initial: () => States.initial.Idle.from()
130
117
  })
131
118
  ```
132
119
 
133
- Construct reusable events with `Machine.event` when the schema is owned by the
134
- machine protocol:
135
-
136
- ```ts
137
- const save = Machine.event(machine, Command.cases.Save)
138
- yield * ref.send(save)
139
- ```
140
-
141
- The schema constructor runs once and the decoded value is trusted by that
142
- machine and definitions derived from it with `handle`. This avoids decoding a
143
- known event again on every delivery. A configured `Schema.TaggedUnion` can use
144
- either the union schema itself or one of its `cases`. Treat the returned event
145
- as immutable; sending it to an unrelated machine goes through that machine's
146
- normal decoder.
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.
147
122
 
148
- Ordinary values remain valid and are decoded at every boundary:
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.
149
126
 
150
- ```ts
151
- yield * ref.send({ _tag: "Save" })
152
- ```
127
+ ### Choose the target by scope
153
128
 
154
- Handlers and machine logic see the complete union. Local public APIs such as
155
- `MachineRef.send`, `machineAtom.send`, and `Machine.plan` expose only `events`
156
- in TypeScript. The local planner and runtime still share the complete event
157
- decoder so machine-local deliveries can flow through the same execution
158
- protocol; bypassing the types with JavaScript or `any` is therefore not a
159
- runtime authorization boundary. Cluster RPC delivery additionally validates
160
- incoming payloads against the public `events` schemas.
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 |
161
135
 
162
- The utility types make the distinction available to application code:
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.
163
139
 
164
- ```ts
165
- type PublicCommand = Machine.Machine.InputEvent<typeof machine>
166
- type HandledEvent = Machine.Machine.Event<typeof machine>
167
- ```
140
+ ## Statechart capabilities
168
141
 
169
- Tags must be unique within each list, and public and internal tags must be
170
- disjoint. Reusing a tag is a type error, so a command cannot accidentally
171
- masquerade as an internal result.
142
+ `Machine.defineStates` supports:
172
143
 
173
- ## Statechart structure
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.
174
150
 
175
- `Machine.defineStates` accepts atomic, compound, parallel, final, choice, and history
176
- nodes:
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.
177
155
 
178
- ```ts
179
- const State = Schema.TaggedUnion({
180
- Form: { draft: Schema.String },
181
- Editing: {},
182
- Saving: {},
183
- Done: {}
184
- })
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.
185
160
 
186
- const States = Machine.defineStates({
187
- Form: {
188
- schema: State.cases.Form,
189
- initial: "Editing",
190
- states: {
191
- Editing: State.cases.Editing,
192
- Saving: State.cases.Saving,
193
- Done: {
194
- schema: State.cases.Done,
195
- type: "final",
196
- output: Schema.String
197
- }
198
- }
199
- }
200
- })
201
- ```
161
+ ## Effects, timers, and child machines
202
162
 
203
- Compound states have one active child and declare its initial key. Parallel
204
- states use `type: "parallel"` and have one active state in every direct region.
205
- Finality is topology, so declare `type: "final"` only in the state definition.
206
- Handlers implement behavior and output computation without repeating it:
163
+ State-scoped work starts on entry and is interrupted on exit:
207
164
 
208
165
  ```ts
209
- const machine = Machine.make({
210
- states: States.states,
211
- events: [],
212
- initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
213
- }).handle({
214
- Form: {
215
- states: {
216
- Done: {
217
- output: () => "saved"
218
- }
219
- }
220
- }
221
- })
222
- ```
223
-
224
- Every declared output schema must have a matching handler implementation before
225
- the machine can be planned, started, invoked, or adapted to Atom/Cluster.
226
- Final children complete their parent; put `onDone` on that compound or parallel
227
- parent, not on the final leaf.
228
-
229
- Put data on the narrowest state where it is valid. If several sibling phases
230
- share data, prefer storing it on their compound parent instead of copying it
231
- into every child state.
232
-
233
- ### Choice states
234
-
235
- A choice is a transient, targetable decision point. Declare it with only
236
- `type: "choice"`; it has no schema, value, children, lifecycle actions, invoke,
237
- or event handlers. A choice is therefore absent from `StateIdentifier`, stable
238
- snapshots, configurations, and encoded snapshots.
239
-
240
- ```ts
241
- const States = Machine.defineStates({
242
- Flow: {
243
- schema: State.cases.Flow,
244
- initial: "Routing",
245
- states: {
246
- Routing: { type: "choice" },
247
- Approved: State.cases.Approved,
248
- Rejected: State.cases.Rejected
249
- }
250
- }
251
- })
252
-
253
- const machine = Machine.make({
254
- states: States.states,
255
- events: [Event],
256
- initial: () =>
257
- States.initial.Flow(
258
- State.cases.Flow.make({ score: 80 }),
259
- (flow) => flow.Routing()
260
- )
261
- }).handle({
262
- Flow: {
263
- states: {
264
- Routing: {
265
- choice: {
266
- targets: ["Flow.Approved", "Flow.Rejected"],
267
- transition: ({ parent, target }) =>
268
- parent.score >= 70
269
- ? target.local.Approved(State.cases.Approved.make({}))
270
- : target.local.Rejected(State.cases.Rejected.make({}))
271
- }
272
- }
273
- }
274
- }
275
- })
276
- ```
277
-
278
- The resolver is ordinary TypeScript or an `Effect`. It receives the triggering
279
- lifecycle event, typed parent values, target builders, and the normal planning
280
- capabilities, but no `state` because the choice itself has no state value. Its
281
- declared `targets` are both a compile-time bound and inspectable graph edges.
282
- The resolver must return one of them; missing, malformed, or undeclared targets
283
- fail planning. Choice implementations are required before execution APIs are
284
- available.
285
-
286
- Initial, event, completion, always, history-default, and other choice
287
- transitions settle in the same macrostep. Chained choices use the normal
288
- infinite-transition limit. Choice nodes never run entry or exit actions and
289
- never become active while their resolution remains visible in traces and
290
- coverage as a `choice` transition trigger.
291
-
292
- ### History states
293
-
294
- A history pseudo-state remembers the last active configuration of its parent.
295
- It has no value schema and never appears in an active snapshot. History is
296
- shallow by default; use `history: "deep"` to retain the complete descendant
297
- configuration and its validated values:
298
-
299
- ```ts
300
- const States = Machine.defineStates({
301
- checkout: {
302
- schema: Checkout,
303
- initial: "shipping",
304
- states: {
305
- shipping: Shipping,
306
- payment: {
307
- schema: Payment,
308
- initial: "cardEntry",
309
- states: {
310
- cardEntry: CardEntry,
311
- verifying: Verifying
312
- }
313
- },
314
- resume: { type: "history", history: "deep" }
315
- }
316
- },
317
- support: Support
318
- })
319
- ```
320
-
321
- Implement a typed default for the first transition before any configuration
322
- has been remembered, then target history without supplying a state value:
323
-
324
- ```ts
325
- machine.handle({
326
- checkout: {
327
- history: {
328
- resume: {
329
- default: () => initialCheckoutSnapshot
330
- }
331
- }
332
- },
333
- support: {
334
- on: {
335
- Resume: ({ target }) => target.history.checkout.resume()
336
- }
337
- }
338
- })
339
- ```
340
-
341
- A history default is source-independent. It must construct a complete root
342
- configuration containing the history owner, including every inactive ancestor
343
- above a nested owner and every required region of a parallel ancestor. For a
344
- top-level owner, its owner snapshot is already a complete root snapshot.
345
-
346
- For example, a history node owned by `App.Workspace` can be targeted from an
347
- unrelated `Closed` root and supplies the complete `App` configuration on first
348
- use:
349
-
350
- ```ts
351
- Workspace: {
352
- history: {
353
- resume: {
354
- default: ({ target }) =>
355
- target.App(
356
- State.cases.App.make({ workspaceId: "default" }),
357
- (app) =>
358
- app.Workspace(
359
- State.cases.Workspace.make({}),
360
- (workspace) =>
361
- workspace.Editing(State.cases.Editing.make({}))
362
- )
363
- )
364
- }
365
- }
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
+ })
366
173
  }
367
- ```
368
-
369
- The containing branch is enforced statically: unrelated roots, sibling
370
- compound branches that omit the owner, owner-only nested snapshots, and
371
- incomplete parallel configurations are rejected.
372
-
373
- Deep history restores every remembered descendant value. Shallow history
374
- restores the parent and direct-child values, then follows normal initial paths.
375
- Only compound or parallel states that shallow restoration can enter implicitly
376
- need an `initial` handler to construct those new child values:
377
174
 
378
- ```ts
379
- payment: {
380
- initial: ;
381
- ;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }))
175
+ Waiting: {
176
+ invoke: Machine.after(
177
+ "3 seconds",
178
+ Internal.cases.SaveFailed.make({ message: "Timed out" })
179
+ )
382
180
  }
383
181
  ```
384
182
 
385
- Execution APIs remain unavailable until required history defaults and shallow
386
- initializers have been implemented. History records are part of logical
387
- snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`.
388
-
389
- Transition between structurally related tagged states with `Machine.retag`.
390
- The source `_tag` is discarded, compatible fields are reused, and missing or
391
- incompatible required fields must be supplied:
392
-
393
- ```ts
394
- const saving = Machine.retag(State.cases.Saving, editing)
395
- ```
396
-
397
- ## Choosing a target builder
398
-
399
- Transition contexts expose four typed target builders:
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.
400
187
 
401
- | Builder | Destination | Configuration behavior |
402
- | ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
403
- | `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
404
- | `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
405
- | `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
406
- | `target.history` | A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default |
188
+ Expected failures should become internal events. An unrecovered invoke or child
189
+ failure terminates the owning runtime.
407
190
 
408
- When `target.local` or `target.branch` enters an inactive nested parallel
409
- state, its callback must select every region, just like `initial` and
410
- `target.full`. When that parallel state is already active, `target.branch`
411
- can still update one region directly and preserves the other active regions.
191
+ ## Reactivity
412
192
 
413
- The builder controls how the next configuration is assembled; it does not by
414
- itself decide which invokes restart. The runtime derives exit and entry paths
415
- from the previous and next active paths. Shared active ancestors remain entered,
416
- even when `target.full` supplies their values again. Use an event transition
417
- with `reenter: true` when the source state should explicitly exit and enter
418
- again:
193
+ `AtomMachine` runs one lazy machine instance per `AtomRegistry`:
419
194
 
420
195
  ```ts
421
- Refresh: {
422
- reenter: true,
423
- transition: ({ state, target }) =>
424
- target.full.Ready(new Ready({ value: state.value }))
425
- }
426
- ```
427
-
428
- `States.get`, `States.getWithParents`, `States.getSnapshot`, and
429
- `States.matches` accept typed dotted paths. Handler `parents` values are also
430
- keyed by full dotted paths, such as `parents["Form.Editing"]`; `context.parent`
431
- provides the immediate parent directly and is `undefined` at a root state.
432
-
433
- Event, eventless, and completion transition contexts also expose `snapshot`, a
434
- read-only view of the complete logical configuration captured at the beginning
435
- of that transition microstep. This lets one parallel region inspect a sibling
436
- without copying active-state facts into parent values:
196
+ import { AtomMachine } from "@typeonce/effect-machine/reactivity"
197
+ import { Atom } from "effect/unstable/reactivity"
437
198
 
438
- ```ts
439
- BufferReady: ;
440
- ;(({ snapshot, target }) =>
441
- States.matches(snapshot, "Player.Network.Online")
442
- ? target.local.Playing(State.cases.Playing.make({}))
443
- : undefined)
199
+ const runtime = Atom.runtime(AppLayer)
200
+ const counterAtom = AtomMachine.bind(runtime).make(Counter)
444
201
  ```
445
202
 
446
- All non-conflicting handlers selected together observe the same captured
447
- snapshot. Handlers are synchronous and cannot read mutable live runtime state
448
- later. `snapshot` is intentionally absent from entry, exit,
449
- invoke, and choice contexts. In particular, startup and chained choices may run
450
- before a complete stable snapshot containing their pseudo-source exists.
203
+ Binding a shared runtime once is the canonical form for service-backed
204
+ applications. Service-free machines can use `AtomMachine.make(Counter)`.
451
205
 
452
- Effect Schema annotations are the metadata source for active states. Annotate
453
- the schema itself; `Machine.stateNodes` exposes the resolved annotation map:
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`.
454
210
 
455
- ```ts
456
- const Saving = State.cases.Saving.annotate({
457
- title: "Saving document",
458
- description: "Persisting local changes to the server"
459
- })
460
- ```
461
-
462
- Schema-less choice and history nodes accept only descriptive `title`,
463
- `description`, and `documentation` annotations. Titles may be used as display
464
- labels, but structural paths remain the only identity and targeting mechanism.
211
+ ## Persistence
465
212
 
466
- ## Synchronous transitions and actor commands
467
-
468
- Transition, entry, exit, choice, initial, and history callbacks are synchronous.
469
- They select state and may enqueue only explicit statechart or actor operations:
470
- raise an internal event, emit to the parent, send to an invoked child, or stop a
471
- child. Arbitrary Effects are not accepted at this boundary.
213
+ Logical snapshots can be validated for storage or transport:
472
214
 
473
215
  ```ts
474
- const handlers = {
475
- Save: ({ target }, enqueue) => {
476
- enqueue.emit(new SaveRequested({}))
477
- return target.local.Saving.from()
478
- }
479
- }
216
+ const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
217
+ const decoded = yield * Machine.decodeSnapshot(machine, encoded)
218
+ const ref = yield * Machine.resume(machine, decoded)
480
219
  ```
481
220
 
482
- Use `Machine.invokeEffect`, `Machine.invoke`, or an invoked child machine for
483
- asynchronous work. Their results return to the parent as typed events, keeping
484
- the transition core deterministic and synchronous.
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.
485
226
 
486
- `Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
487
- `done` is `true`, `output` is the schema-derived structural terminal union;
488
- while the machine remains active, it is `undefined`. A started machine's
489
- `join` uses the same terminal union. Output-less structural terminal paths
490
- contribute `undefined`, while active atomic roots do not.
227
+ ## Testing
491
228
 
492
- This union is intentionally conservative with respect to handler behavior. For
493
- example, a root `onDone` transition may make one structurally terminal result
494
- unreachable even though its schema remains in `Machine.TerminalOutput`.
229
+ The testing entrypoint provides complementary layers:
495
230
 
496
- ## State-scoped invokes
497
-
498
- `Machine.invoke` runs child logic while its owning state is active. Leaving the
499
- state interrupts the child. For a one-shot Effect, `Machine.invokeEffect` maps
500
- typed success and failure values directly to internal events:
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.
501
236
 
502
237
  ```ts
503
- const loading = {
504
- invoke: ({ state }) =>
505
- Machine.invokeEffect({
506
- id: "save",
507
- effect: save(state),
508
- onSuccess: (entry) => InternalEvent.cases.Saved.make({ id: entry.id }),
509
- onFailure: (error) =>
510
- InternalEvent.cases.SaveFailed.make({
511
- message: String(error)
512
- })
513
- })
514
- }
515
- ```
516
-
517
- Omit `onFailure` when the Effect cannot fail. Defects and interruption remain
518
- failures rather than being mapped.
519
-
520
- `Machine.after` creates a cancellable, state-scoped delayed event with the same
521
- lifetime:
238
+ import { MachineTest } from "@typeonce/effect-machine/testing"
522
239
 
523
- ```ts
524
- invoke: Machine.after("3 seconds", InternalEvent.cases.SaveFailed.make({ message: "Timed out" }), {
525
- id: "save-timeout"
240
+ const trace = yield* MachineTest.run(Counter, {
241
+ events: [Event.cases.Start.make({}), Event.cases.Increment.make({})]
526
242
  })
527
- ```
528
-
529
- Provide an explicit id when more than one active timer could deliver the same
530
- event tag.
531
-
532
- Use lower-level `Machine.invoke` with `Machine.effect` for custom child logic or
533
- snapshot mapping. Its `id` is only the state-local lifecycle key. If the parent
534
- must send events to that invocation, create a typed low-level address with
535
- `Machine.childAddress<Event>("worker")` and pass it through the explicit
536
- `address` option; the address protocol is checked against the child logic.
537
- Lifecycle ids must be unique among simultaneously active invokes owned by the
538
- same state.
539
-
540
- Invoke outputs, invoke snapshot events, and invoked-child emissions belong in
541
- `internalEvents`. They are available to typed handlers but are not accepted by
542
- the typed public input APIs. Include a child machine's emitted protocol with
543
- `internalEvents: [...ChildMachine.emits]` when those emissions should be handled
544
- by the parent.
545
243
 
546
- For a child statechart, create one descriptor for `invokeMachine`, `sendTo`,
547
- and child lookup:
548
-
549
- ```ts
550
- const Editor = Machine.child("editor", EditorMachine)
244
+ yield* MachineTest.verify(Counter, trace)
551
245
  ```
552
246
 
553
- `Machine.child(id, machine)` is the complete statechart descriptor;
554
- `Machine.childAddress<Event>(id)` is the lower-level event-only address.
555
- Descriptors are matched by id and machine identity, so independently created
556
- descriptors for the same pair address the same child without a global cache.
557
- Exporting one descriptor remains the clearest module boundary.
558
-
559
- `Machine.activityDefinitions(machine)` inspects state-owned work without
560
- executing it. Static descriptors report their source path, lifecycle id, and
561
- kind. Timers also report normalized duration and emitted event tag;
562
- `invokeEffect` mappings are described as dynamic; invoked machines expose only
563
- safe child identity. A function-valued `invoke` factory is reported as dynamic
564
- and is never evaluated during inspection. The result is serializable and does
565
- not contain Effects, closures, services, or child runtimes.
566
-
567
- ## Reactivity
247
+ Pure planner tests do not execute invokes or time. Use a started machine and a
248
+ probe when those semantics matter.
568
249
 
569
- `AtomMachine.make` creates a lazy bridge backed by one running machine per
570
- `AtomRegistry`. Mounting or reading one of its atoms starts the machine;
571
- disposing the registry-owned reference stops it.
250
+ ## Entrypoints
572
251
 
573
252
  ```ts
253
+ import { Machine } from "@typeonce/effect-machine"
254
+ import { ClusterMachine } from "@typeonce/effect-machine/cluster"
574
255
  import { AtomMachine } from "@typeonce/effect-machine/reactivity"
575
- import { Atom } from "effect/unstable/reactivity"
576
-
577
- const runtime = Atom.runtime(AppLayer)
578
- const machines = AtomMachine.bind(runtime)
579
- const machineAtom = machines.make(Counter)
580
- ```
581
-
582
- For applications with a shared runtime, treat
583
- `AtomMachine.bind(runtime).make(...)` as the canonical form. It keeps runtime
584
- ownership at the composition boundary so it does not need to be passed through
585
- every feature. Service-free machines may use `AtomMachine.make(machine)`
586
- directly.
587
-
588
- The bridge exposes:
589
-
590
- - `ref`: the running `MachineRef`
591
- - `result`: fail-aware logical state, combining startup and post-start runtime
592
- failures
593
- - `snapshot`: authoritative runtime lifecycle, including `active`, `done`,
594
- `error`, and `stopped`
595
- - `state`: the last logical state, including the retained state after a runtime
596
- failure
597
- - `send` and `stop`: writable command atoms
598
- - `child(descriptor)`: a reactive bridge for a directly owned child
599
-
600
- Use `AtomMachine.select` and `AtomMachine.matches` for equality-aware root
601
- derivations. Use `selectChild` and `matchesChild` for child bridges. Selector
602
- paths and selected value types are inferred directly from the bridge snapshot,
603
- so these combinators do not need the `DefinedStates` object. They follow normal
604
- Atom identity semantics and return a new atom on each call, so retain or memoize
605
- them when constructing them in a component. The `child` method uses Effect's
606
- `Atom.family` to reuse a live bridge for the same descriptor without maintaining
607
- a package-level cache.
608
- `AtomMachine.ChildMachineAtom<typeof Child>` uses `unknown` as its startup-error
609
- default for general component props.
610
- `AtomMachine.ChildOf<typeof parentAtom, typeof Child>` preserves the exact
611
- parent startup-error channel.
612
-
613
- Child state and snapshot atoms contain `Option.none()` while that child is
614
- inactive. React applications using `@effect/atom-react` need a
615
- `RegistryProvider`; see the [Pokémon example](./examples/pokemon).
616
-
617
- ## Snapshots and persistence
618
-
619
- `Machine.encodeSnapshot` and `Machine.decodeSnapshot` validate logical
620
- statechart data for storage or transport. The encoded representation does not
621
- contain the machine definition, machine version, services, subscriptions, or
622
- running child processes. Store machine identity and migration/version metadata
623
- alongside it.
624
-
625
- Resume a decoded logical snapshot explicitly:
626
-
627
- ```ts
628
- const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
629
- const decoded = yield * Machine.decodeSnapshot(machine, encoded)
630
- const ref = yield * Machine.resume(machine, decoded)
256
+ import { MachineTest } from "@typeonce/effect-machine/testing"
631
257
  ```
632
258
 
633
- `resume` does not call `initial`, require machine input, or replay entry,
634
- transition, completion, eventless, raised-event, or emitted-event work that
635
- produced the snapshot. The decoded snapshot is the first published logical
636
- state. A final snapshot immediately yields a completed ref with its output.
637
-
638
- Resumption creates a fresh runtime. Invokes owned by active states start once in
639
- normal ancestor/document order and receive `Machine.InitialEvent` as their
640
- lifecycle event. `invokeEffect` runs again, invoked machines start from their
641
- own initial state, and `Machine.after` timers restart from their full declared
642
- duration. Spawned children, queued events, subscriptions, fibers, scopes,
643
- elapsed timer time, child snapshots, and prior `RuntimeSnapshot` status/errors
644
- are not restored. Completion and history metadata remain logical state and are
645
- not replayed. A changed machine definition does not cause `resume` itself to
646
- evaluate newly enabled `always` or `onDone` transitions.
647
-
648
- Reactive applications use `AtomMachine.resume(machine, decoded)` for a
649
- service-free machine or `AtomMachine.bind(runtime).resume(machine, decoded)`
650
- for a service-backed machine. These bridges have the same lazy one-runtime-per-
651
- registry ownership and disposal behavior as `AtomMachine.make`.
259
+ Each ESM entrypoint is independent and tree-shakeable.
652
260
 
653
- `ClusterMachine` provides a separate persisted entity adapter. Its process-local
654
- restrictions, checkpoint planning, and delivery guarantees are documented on
655
- that API. `Machine.resume` is logical resumption, not durable process or cluster
656
- restoration.
261
+ ## Examples
657
262
 
658
- ## Current limits
263
+ Every package directly under [`examples/`](./examples) has its own lockfile and
264
+ `check` script.
659
265
 
660
- Declarative first-class guards are not part of the current API. Ordinary
661
- TypeScript conditions implement guards. Use `Machine.after` for a cancellable
662
- state-scoped delayed event.
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 |
663
271
 
664
- ## Guidance for agents and contributors
272
+ The playground is the shortest path from one concept to working code. The
273
+ standalone examples show larger composition and ownership boundaries.
665
274
 
666
- The shipped [agent guide](./docs/agent-guide.md) contains the recommended
667
- definition order, modeling rules, lifecycle invariants, React recipe, common
668
- compiler errors, and unsupported features.
275
+ ## Reference and development
669
276
 
670
- ## Development and validation
277
+ - [API reference](https://effect-machine.typeonce.dev)
278
+ - [Agent and implementation guide](./docs/agent-guide.md)
279
+ - [Contributing guide](./CONTRIBUTING.md)
671
280
 
672
281
  Use pnpm 10 and Node.js 20 or newer:
673
282
 
@@ -676,40 +285,9 @@ pnpm install --frozen-lockfile
676
285
  pnpm check
677
286
  ```
678
287
 
679
- Individual commands are available for `build`, `test`, `test:types`,
680
- `typecheck`, `format:check`, `test:consumer`, and `pack:check`. Runtime tests use
681
- `@effect/vitest`; type tests use TSTyche and TypeScript 6.0.3. The consumer check
682
- packs the package, imports all public entrypoints, and compiles a strict
683
- TypeScript consumer with `skipLibCheck: false`.
684
-
685
- Read [CONTRIBUTING.md](./CONTRIBUTING.md) before proposing a change. Pull
686
- requests receive an automated base-versus-head type-instantiation report.
687
-
688
- ## Examples
689
-
690
- The [platformer statechart example](./examples/platformer) is a playable SVG
691
- demo centered on a schema-first character machine. It demonstrates nested
692
- compound locomotion, parallel airborne motion and air-jump regions, independent
693
- facing and wall-contact regions, a pause/resume flow backed by typed deep
694
- history, typed protocol events, state-scoped timers, and state-driven SVG
695
- transforms.
696
-
697
- The [Pokémon statechart example](./examples/pokemon) is a standalone React and
698
- Vite project demonstrating compound and parallel states, state-scoped invokes,
699
- invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
700
- `file:` dependency on this package while retaining an isolated dependency graph,
701
- lockfile, build, and CI job.
702
-
703
- The [playground](./examples/playground) collects focused interactive examples
704
- for traffic lights, turnstiles, media players, microwaves, and worker-backed
705
- machines. CI discovers every direct package under `examples/` and runs its
706
- `check` script automatically.
707
-
708
- ## Releases
709
-
710
- Add a changeset with `pnpm changeset`. CI validates frozen installation and the
711
- complete check suite. The release workflow opens version PRs and publishes with
712
- 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`.
713
291
 
714
292
  When equivalent Machine modules ship in Effect, this package is intended to
715
- become a thin compatibility re-export package before eventual retirement.
293
+ become a compatibility re-export before eventual retirement.