@typeonce/effect-machine 0.17.0 → 0.19.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.
Files changed (45) hide show
  1. package/README.md +133 -80
  2. package/dist/Machine.d.ts +434 -297
  3. package/dist/Machine.d.ts.map +1 -1
  4. package/dist/Machine.js +13 -55
  5. package/dist/Machine.js.map +1 -1
  6. package/dist/internal/machine/cluster.d.ts +2 -2
  7. package/dist/internal/machine/cluster.d.ts.map +1 -1
  8. package/dist/internal/machine/cluster.js +2 -1
  9. package/dist/internal/machine/cluster.js.map +1 -1
  10. package/dist/internal/machine/invocation.d.ts.map +1 -1
  11. package/dist/internal/machine/invocation.js +1 -1
  12. package/dist/internal/machine/invocation.js.map +1 -1
  13. package/dist/internal/machine/machine.d.ts.map +1 -1
  14. package/dist/internal/machine/machine.js +48 -10
  15. package/dist/internal/machine/machine.js.map +1 -1
  16. package/dist/internal/machine/serialization.d.ts.map +1 -1
  17. package/dist/internal/machine/serialization.js +75 -18
  18. package/dist/internal/machine/serialization.js.map +1 -1
  19. package/dist/internal/testing/machine/verification.d.ts +1 -1
  20. package/dist/internal/testing/machine/verification.d.ts.map +1 -1
  21. package/dist/internal/testing/machine/verification.js +9 -6
  22. package/dist/internal/testing/machine/verification.js.map +1 -1
  23. package/dist/testing/MachineTest.d.ts +13 -10
  24. package/dist/testing/MachineTest.d.ts.map +1 -1
  25. package/dist/testing/MachineTest.js +5 -3
  26. package/dist/testing/MachineTest.js.map +1 -1
  27. package/dist/unstable/cluster/ClusterMachine.d.ts +22 -2
  28. package/dist/unstable/cluster/ClusterMachine.d.ts.map +1 -1
  29. package/dist/unstable/cluster/ClusterMachine.js +4 -0
  30. package/dist/unstable/cluster/ClusterMachine.js.map +1 -1
  31. package/dist/unstable/reactivity/AtomMachine.d.ts +1 -1
  32. package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
  33. package/docs/agent-guide.md +189 -83
  34. package/package.json +4 -4
  35. package/src/Machine.ts +845 -1051
  36. package/src/internal/machine/cluster.ts +4 -1
  37. package/src/internal/machine/invocation.ts +1 -1
  38. package/src/internal/machine/machine.ts +64 -8
  39. package/src/internal/machine/serialization.ts +100 -25
  40. package/src/internal/testing/machine/exploration.ts +1 -1
  41. package/src/internal/testing/machine/trace.ts +1 -1
  42. package/src/internal/testing/machine/verification.ts +16 -11
  43. package/src/testing/MachineTest.ts +13 -10
  44. package/src/unstable/cluster/ClusterMachine.ts +51 -1
  45. package/src/unstable/reactivity/AtomMachine.ts +1 -1
package/README.md CHANGED
@@ -34,7 +34,7 @@ Cluster and are exposed only through explicit integration boundaries.
34
34
  ## Install
35
35
 
36
36
  ```sh
37
- pnpm add @typeonce/effect-machine effect@4.0.0-rc.109
37
+ pnpm add @typeonce/effect-machine effect@4.0.0-rc.110
38
38
  ```
39
39
 
40
40
  `effect` is an exact peer dependency. Install the version above and upgrade it
@@ -49,11 +49,14 @@ import { Machine } from "@typeonce/effect-machine"
49
49
  import { Effect, Schema, Stream } from "effect"
50
50
 
51
51
  const State = Schema.TaggedUnion({
52
- Idle: {},
53
52
  Running: { count: Schema.Number }
54
53
  })
55
54
 
56
- const States = Machine.states(State.cases)
55
+ const States = Machine.states({
56
+ Idle: {},
57
+ Running: State.cases.Running
58
+ })
59
+
57
60
  const CounterEvent = Machine.events(
58
61
  Schema.TaggedUnion({
59
62
  Start: {},
@@ -66,7 +69,7 @@ const CounterDefinition = Machine.make({
66
69
  id: "Counter",
67
70
  states: States.states,
68
71
  events: CounterEvent,
69
- initial: (to) => to.Idle().resolve(({ target }) => target.from())
72
+ initial: (to) => to.Idle()
70
73
  })
71
74
 
72
75
  const Counter = CounterDefinition.handle({
@@ -78,7 +81,7 @@ const Counter = CounterDefinition.handle({
78
81
  Running: {
79
82
  on: {
80
83
  Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })),
81
- Stop: (to) => to.full.Idle().resolve(({ target }) => target.from())
84
+ Stop: (to) => to.full.Idle()
82
85
  }
83
86
  }
84
87
  })
@@ -122,7 +125,27 @@ Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when
122
125
  the same active state definition is mounted more than once; tagged schemas are
123
126
  already reusable without it. For repeated finite regions, derive names with
124
127
  `States.path(...)` so every literal in the path family is checked against the
125
- complete tree. Type full-snapshot helpers as `Machine.Snapshot<typeof States>`.
128
+ complete tree. Type full-snapshot helpers as `Machine.Snapshot<typeof States>`
129
+ or `Machine.Snapshot<typeof machine>`, schema-backed state payloads as
130
+ `Machine.Value<typeof States, Path>`, and path-rooted snapshots as
131
+ `Machine.SnapshotAt<typeof States, Path>`.
132
+
133
+ ### Make invalid states unrepresentable
134
+
135
+ Treat topology as a domain contract, not as file organization. A parallel state
136
+ declares the full Cartesian product of its regions, so use it only when every
137
+ combination has a coherent meaning. If one region must inspect another before
138
+ entering a state safely, prefer a compound hierarchy that makes the forbidden
139
+ combination impossible. `matches` remains useful for views, tests, and genuine
140
+ coordination between independent regions; it should not repair an invalid
141
+ state product.
142
+
143
+ Keep state-scoped Effects beneath the state that guarantees their resources,
144
+ and enforce command availability in the machine rather than only by disabling
145
+ UI controls. When entering an inactive compound or parallel state's declared
146
+ default, select `.initial`; explicitly construct descendants only for a
147
+ non-default configuration or a complete replacement of an already-active
148
+ parallel root.
126
149
 
127
150
  ### Construct state through builders
128
151
 
@@ -150,7 +173,8 @@ const handlers = {
150
173
  }
151
174
  ```
152
175
 
153
- Omit `schema` when a state represents control flow but owns no data:
176
+ Omit `schema` when a state represents control flow but owns no data. Use `{}`
177
+ instead of defining an empty tagged schema:
154
178
 
155
179
  ```ts
156
180
  const States = Machine.states({
@@ -175,6 +199,11 @@ Schema-less states remain active, targetable, matchable, and visible through
175
199
  their handler `state` is `undefined`, and `get` / `getWithParents` accept only
176
200
  schema-backed paths. Add a schema later if the state starts owning data.
177
201
 
202
+ Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and
203
+ reference its cases from the topology. For a standalone state schema whose
204
+ class identity is useful, declare a named `Schema.TaggedClass`. Do not bury
205
+ one-off tagged schema declarations inside `Machine.states`.
206
+
178
207
  Put data on the narrowest state where it is valid. If sibling phases share
179
208
  data, put it on their compound parent.
180
209
 
@@ -206,7 +235,7 @@ const definition = Machine.make({
206
235
  events: CommandEvent,
207
236
  internalEvents: InternalEvent,
208
237
  emittedEvents: Emissions,
209
- initial: (to) => to.Idle().resolve(({ target }) => target.from())
238
+ initial: (to) => to.Idle()
210
239
  })
211
240
  ```
212
241
 
@@ -318,7 +347,7 @@ const child = Machine.make({
318
347
  states: ChildStates.states,
319
348
  events: ChildEvents,
320
349
  parent: Machine.parent(ParentEvents),
321
- initial: (to) => to.Working().resolve(({ target }) => target.from())
350
+ initial: (to) => to.Working()
322
351
  }).handle({
323
352
  Working: {
324
353
  on: {
@@ -369,9 +398,13 @@ paths. `parent` always means the owning machine target.
369
398
  | `target.history` | Restoring a declared history node | The remembered configuration or its typed default |
370
399
 
371
400
  Every required transition handler selects a target from its inline `to`
372
- builder. A bare selection uses the target schema's default construction; call
373
- `.resolve(...)` when construction depends on handler context. An absent handler
374
- ignores the trigger; `to.none` handles
401
+ builder. Return a bare selection such as `to.full.Idle()` when the selected
402
+ builder supports zero-argument construction; the machine applies the same
403
+ default construction as `target.from()`. This includes empty schemas and
404
+ schemas whose constructor fields are all optional or defaulted. TypeScript
405
+ rejects the bare form when state data or nested configuration is required.
406
+ Call `.resolve(...)` when construction depends on handler context or the
407
+ transition needs to enqueue commands. An absent handler ignores the trigger; `to.none` handles
375
408
  it and retains queued commands, raised events, and emitted events without
376
409
  selecting a destination. Concrete destinations stay narrowed inside their
377
410
  resolver, and `to.branches({...})` gives the resolver only the declared named
@@ -437,37 +470,35 @@ arbitrary asynchronous Effects do not run inside planning.
437
470
  State-scoped work starts on entry and is interrupted on exit:
438
471
 
439
472
  ```ts
440
- Loading: {
441
- invoke: Machine.invoke({
442
- id: "save-document",
443
- effect: () => saveDocument,
444
- onDone: (to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id })),
445
- onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))
446
- })
447
- }
448
-
449
- Waiting: {
450
- invoke: Machine.invoke({
451
- id: "save-timeout",
452
- after: "3 seconds",
453
- onDone: (to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))
454
- })
455
- }
473
+ machine.handle({
474
+ Loading: {
475
+ invoke: (from) =>
476
+ from.effect("save-document", () => saveDocument)
477
+ .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id })))
478
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) })))
479
+ },
480
+ Waiting: {
481
+ invoke: (from) =>
482
+ from.timer("save-timeout", "3 seconds")
483
+ .onDone((to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" })))
484
+ }
485
+ })
456
486
  ```
457
487
 
458
- Use `effect` for one Effect, `stream` for a sequence of externally produced
459
- values, `after` for a cancellable delay, `logic` for a reusable process, and
460
- `child` for a complete child statechart—all through
461
- `Machine.invoke({...})`. The helper is an identity at runtime and preserves
462
- owner-context and source-channel inference across lifecycle handlers, including
463
- for state-dependent Effects:
488
+ The state-local `from` selector starts an `effect`, `stream`, `timer`, reusable
489
+ `logic`, or complete `child` statechart. The selected source determines which
490
+ lifecycle methods the chain requires and which methods are available. For
491
+ example, an Effect with non-`never` output and error channels must handle both;
492
+ the completed chain is the value returned by the callback:
464
493
 
465
494
  ```ts
466
- invoke: Machine.invoke({
467
- id: "load-document",
468
- effect: ({ state }) => loadDocument(state.documentId),
469
- onDone: (to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output })),
470
- onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message }))
495
+ machine.handle({
496
+ Loading: {
497
+ invoke: (from) =>
498
+ from.effect("load-document", ({ state }) => loadDocument(state.documentId))
499
+ .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output })))
500
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message })))
501
+ }
471
502
  })
472
503
  ```
473
504
 
@@ -476,15 +507,18 @@ is mapped by `onElement`, and the next element is not pulled until that parent
476
507
  macrostep commits:
477
508
 
478
509
  ```ts
479
- invoke: Machine.invoke({
480
- id: "channel",
481
- stream: () => channelMessages,
482
- onElement: (to) =>
483
- to.none.resolve(({ element }, enqueue) => {
484
- enqueue.raise(Events.MessageReceived({ message: element }))
485
- }),
486
- onDone: (to) => to.none,
487
- onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ error }))
510
+ machine.handle({
511
+ Listening: {
512
+ invoke: (from) =>
513
+ from.stream("channel", () => channelMessages)
514
+ .onElement((to) =>
515
+ to.none.resolve(({ element }, enqueue) => {
516
+ enqueue.raise(Events.MessageReceived({ message: element }))
517
+ })
518
+ )
519
+ .onDone((to) => to.none)
520
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ error })))
521
+ }
488
522
  })
489
523
  ```
490
524
 
@@ -493,10 +527,10 @@ current configuration, or call `.resolve(...)` when the transition only needs
493
527
  to enqueue commands. A block resolver may omit its return because it is
494
528
  contextually typed to return `undefined`.
495
529
 
496
- Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's
497
- public input and declared parent protocol contextually. Its source and lifecycle
498
- callbacks can send through `self` and `parent` while retaining the invoked
499
- Effect's output and error inference:
530
+ Inside `.handle(...)`, `from` receives the owning machine's public input and
531
+ declared parent protocol contextually. Source and lifecycle callbacks can send
532
+ through `self` and `parent` while retaining the invoked Effect's output and
533
+ error inference:
500
534
 
501
535
  ```ts
502
536
  const machine = Machine.make({
@@ -506,36 +540,47 @@ const machine = Machine.make({
506
540
  // ...
507
541
  }).handle({
508
542
  Saving: {
509
- invoke: Machine.invoke({
510
- id: "notify-parent",
511
- effect: () => saveDocument,
512
- onDone: (to) =>
513
- to.none.resolve(({ parent, self }, enqueue) => {
514
- enqueue.sendTo(self, Commands.Save())
515
- enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
516
- }),
517
- onFailure: (to) => to.none
518
- })
543
+ invoke: (from) =>
544
+ from.effect("notify-parent", () => saveDocument)
545
+ .onDone((to) =>
546
+ to.none.resolve(({ parent, self }, enqueue) => {
547
+ enqueue.sendTo(self, Commands.Save())
548
+ enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
549
+ })
550
+ )
551
+ .onFailure((to) => to.none)
519
552
  }
520
553
  })
521
554
  ```
522
555
 
523
- The standard `Machine.invoke(...)` form retains exact `self` and `parent`
524
- typing even when the definition is named separately; no intermediate
525
- definition method is required.
556
+ Return an array of completed chains to compose multiple state-owned activities.
557
+ The source computation itself, process logic, or `Machine.child(id, machine)`
558
+ descriptor can be named and reused; the invocation chain stays local so its
559
+ transitions retain the exact owning state and machine protocols.
526
560
 
527
- A direct `invoke: { ... }` object is also supported when its lifecycle handlers
528
- do not need source-derived context. Reuse one exported
529
- `Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child
530
- lookup.
561
+ ```ts
562
+ const refreshCache = Cache.refresh
563
+
564
+ machine.handle({
565
+ Active: {
566
+ invoke: (from) => [
567
+ from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none),
568
+ from.timer("expire-session", "5 minutes").onDone((to) => to.full.Expired())
569
+ ]
570
+ }
571
+ })
572
+ ```
531
573
 
532
574
  `onDone` is required for a non-`never` output, and `onFailure` is required for a
533
- non-`never` typed error; each handler is omitted when its channel is `never`.
534
- Defects, interruption, and source-construction failures terminate the owning
535
- runtime. Effect sources are always factories evaluated when their state is
536
- entered. Use `effect: () => Effect.sleep(...)` for a generic Effect, while
537
- `after` keeps timers explicit and makes static durations visible through
538
- activity inspection.
575
+ non-`never` typed error. Streams additionally require `onElement` when their
576
+ element channel is non-`never` and always require `onDone`; logic and child
577
+ chains optionally expose `onSnapshot`. A handled method disappears from the
578
+ next builder step, so every reachable lifecycle channel is handled exactly
579
+ once. Defects, interruption, and source-construction failures terminate the
580
+ owning runtime. Effect sources are factories evaluated when their state is
581
+ entered. Use an Effect containing `Effect.sleep(...)` for generic work, while
582
+ `from.timer(...)` keeps timer intent explicit and makes static durations visible
583
+ through activity inspection.
539
584
 
540
585
  ## Reactivity
541
586
 
@@ -579,6 +624,14 @@ const decoded = yield * Machine.decodeSnapshot(machine, encoded)
579
624
  const ref = yield * Machine.resume(machine, decoded)
580
625
  ```
581
626
 
627
+ Decoded snapshots are local runtime values and may contain class instances or
628
+ other process-local data. `encodeSnapshot` is the persistence boundary: it uses
629
+ each declared schema's canonical JSON codec and succeeds only when every active
630
+ state value, completion output, and history value is JSON. Rich values such as
631
+ dates and bigints use their schema-defined JSON representation; cyclic or
632
+ non-JSON values fail with `MachineSchemaEncodeError` instead of escaping to a
633
+ later `JSON.stringify` crash.
634
+
582
635
  Resumption restores logical state, values, completion, and history metadata.
583
636
  It creates a fresh runtime: active invokes restart, timers restart at their
584
637
  full duration, and prior fibers, subscriptions, queues, and child runtimes are
@@ -631,11 +684,11 @@ Each ESM entrypoint is independent and tree-shakeable.
631
684
  Every package directly under [`examples/`](./examples) has its own lockfile and
632
685
  `check` script.
633
686
 
634
- | Example | What it demonstrates |
635
- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
636
- | [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 |
637
- | [Pokémon](./examples/pokemon) | Compound and parallel states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
638
- | [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |
687
+ | Example | What it demonstrates |
688
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
689
+ | [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, hierarchical microwave safety, a resource-owned media player, and a worker-hosted machine synchronized across tabs |
690
+ | [Pokémon](./examples/pokemon) | Compound workflow states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
691
+ | [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |
639
692
 
640
693
  The playground is the shortest path from one concept to working code. The
641
694
  standalone examples show larger composition and ownership boundaries.