@typeonce/effect-machine 0.16.0 → 0.18.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 (68) hide show
  1. package/README.md +157 -150
  2. package/dist/Machine.d.ts +416 -592
  3. package/dist/Machine.d.ts.map +1 -1
  4. package/dist/Machine.js +55 -136
  5. package/dist/Machine.js.map +1 -1
  6. package/dist/internal/machine/atom.d.ts +7 -7
  7. package/dist/internal/machine/atom.d.ts.map +1 -1
  8. package/dist/internal/machine/atom.js.map +1 -1
  9. package/dist/internal/machine/cluster.d.ts +2 -2
  10. package/dist/internal/machine/cluster.d.ts.map +1 -1
  11. package/dist/internal/machine/cluster.js +6 -5
  12. package/dist/internal/machine/cluster.js.map +1 -1
  13. package/dist/internal/machine/executionPlan.d.ts.map +1 -1
  14. package/dist/internal/machine/executionPlan.js +10 -3
  15. package/dist/internal/machine/executionPlan.js.map +1 -1
  16. package/dist/internal/machine/invocation.d.ts.map +1 -1
  17. package/dist/internal/machine/invocation.js +1 -1
  18. package/dist/internal/machine/invocation.js.map +1 -1
  19. package/dist/internal/machine/machine.d.ts +8 -6
  20. package/dist/internal/machine/machine.d.ts.map +1 -1
  21. package/dist/internal/machine/machine.js +239 -35
  22. package/dist/internal/machine/machine.js.map +1 -1
  23. package/dist/internal/machine/planner.d.ts +19 -4
  24. package/dist/internal/machine/planner.d.ts.map +1 -1
  25. package/dist/internal/machine/planner.js +56 -20
  26. package/dist/internal/machine/planner.js.map +1 -1
  27. package/dist/internal/machine/stateDefinition.d.ts.map +1 -1
  28. package/dist/internal/machine/stateDefinition.js +14 -0
  29. package/dist/internal/machine/stateDefinition.js.map +1 -1
  30. package/dist/internal/machine/topology.d.ts +9 -0
  31. package/dist/internal/machine/topology.d.ts.map +1 -1
  32. package/dist/internal/machine/topology.js +16 -0
  33. package/dist/internal/machine/topology.js.map +1 -1
  34. package/dist/internal/testing/machine/finiteModel.d.ts.map +1 -1
  35. package/dist/internal/testing/machine/finiteModel.js +15 -20
  36. package/dist/internal/testing/machine/finiteModel.js.map +1 -1
  37. package/dist/internal/testing/machine/transitionCoverage.d.ts.map +1 -1
  38. package/dist/internal/testing/machine/transitionCoverage.js +2 -0
  39. package/dist/internal/testing/machine/transitionCoverage.js.map +1 -1
  40. package/dist/testing/MachineTest.d.ts +15 -15
  41. package/dist/testing/MachineTest.d.ts.map +1 -1
  42. package/dist/testing/MachineTest.js +5 -8
  43. package/dist/testing/MachineTest.js.map +1 -1
  44. package/dist/unstable/cluster/ClusterMachine.d.ts +2 -2
  45. package/dist/unstable/cluster/ClusterMachine.d.ts.map +1 -1
  46. package/dist/unstable/cluster/ClusterMachine.js.map +1 -1
  47. package/dist/unstable/reactivity/AtomMachine.d.ts +13 -13
  48. package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
  49. package/dist/unstable/reactivity/AtomMachine.js.map +1 -1
  50. package/docs/agent-guide.md +229 -193
  51. package/package.json +1 -1
  52. package/src/Machine.ts +1686 -2234
  53. package/src/internal/machine/atom.ts +20 -15
  54. package/src/internal/machine/cluster.ts +14 -9
  55. package/src/internal/machine/executionPlan.ts +8 -3
  56. package/src/internal/machine/invocation.ts +1 -1
  57. package/src/internal/machine/machine.ts +365 -48
  58. package/src/internal/machine/planner.ts +89 -27
  59. package/src/internal/machine/stateDefinition.ts +39 -0
  60. package/src/internal/machine/topology.ts +28 -0
  61. package/src/internal/testing/machine/exploration.ts +1 -1
  62. package/src/internal/testing/machine/finiteModel.ts +18 -21
  63. package/src/internal/testing/machine/trace.ts +1 -1
  64. package/src/internal/testing/machine/transitionCoverage.ts +2 -0
  65. package/src/internal/testing/machine/verification.ts +1 -1
  66. package/src/testing/MachineTest.ts +18 -15
  67. package/src/unstable/cluster/ClusterMachine.ts +8 -4
  68. package/src/unstable/reactivity/AtomMachine.ts +20 -13
package/README.md CHANGED
@@ -66,31 +66,19 @@ const CounterDefinition = Machine.make({
66
66
  id: "Counter",
67
67
  states: States.states,
68
68
  events: CounterEvent,
69
- initial: {
70
- target: (to) => to.Idle(),
71
- resolve: ({ target }) => target.from()
72
- }
69
+ initial: (to) => to.Idle().resolve(({ target }) => target.from())
73
70
  })
74
71
 
75
72
  const Counter = CounterDefinition.handle({
76
73
  Idle: {
77
74
  on: {
78
- Start: Machine.transition({
79
- target: (to) => to.full.Running(),
80
- resolve: ({ target }) => target.from({ count: 0 })
81
- })
75
+ Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 }))
82
76
  }
83
77
  },
84
78
  Running: {
85
79
  on: {
86
- Increment: Machine.transition({
87
- target: (to) => to.full.Running(),
88
- resolve: ({ state, target }) => target.from({ count: state.count + 1 })
89
- }),
90
- Stop: Machine.transition({
91
- target: (to) => to.full.Idle(),
92
- resolve: ({ target }) => target.from()
93
- })
80
+ Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })),
81
+ Stop: (to) => to.full.Idle().resolve(({ target }) => target.from())
94
82
  }
95
83
  }
96
84
  })
@@ -134,7 +122,10 @@ Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when
134
122
  the same active state definition is mounted more than once; tagged schemas are
135
123
  already reusable without it. For repeated finite regions, derive names with
136
124
  `States.path(...)` so every literal in the path family is checked against the
137
- complete tree. Type full-snapshot helpers as `Machine.Snapshot<typeof States>`.
125
+ complete tree. Type full-snapshot helpers as `Machine.Snapshot<typeof States>`
126
+ or `Machine.Snapshot<typeof machine>`, schema-backed state payloads as
127
+ `Machine.Value<typeof States, Path>`, and path-rooted snapshots as
128
+ `Machine.SnapshotAt<typeof States, Path>`.
138
129
 
139
130
  ### Construct state through builders
140
131
 
@@ -153,13 +144,13 @@ When sibling states share fields, remove the source discriminator and pass the
153
144
  remaining fields through the target schema:
154
145
 
155
146
  ```ts
156
- Submit: Machine.transition({
157
- target: (to) => to.local.Saving(),
158
- resolve: ({ state, target }) => {
159
- const { _tag: _, ...fields } = state
160
- return target.from({ ...fields, attempt: 1 })
161
- }
162
- })
147
+ const handlers = {
148
+ Submit: (to) =>
149
+ to.local.Saving().resolve(({ state, target }) => {
150
+ const { _tag: _, ...fields } = state
151
+ return target.from({ ...fields, attempt: 1 })
152
+ })
153
+ }
163
154
  ```
164
155
 
165
156
  Omit `schema` when a state represents control flow but owns no data:
@@ -175,10 +166,11 @@ const States = Machine.states({
175
166
  }
176
167
  })
177
168
 
178
- initial: {
179
- target: (to) => to.Form.initial(),
180
- resolve: ({ target }) => target((form) => form.Editing.from())
181
- }
169
+ const definition = Machine.make({
170
+ states: States.states,
171
+ events: Machine.events(),
172
+ initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
173
+ })
182
174
  ```
183
175
 
184
176
  Schema-less states remain active, targetable, matchable, and visible through
@@ -217,10 +209,7 @@ const definition = Machine.make({
217
209
  events: CommandEvent,
218
210
  internalEvents: InternalEvent,
219
211
  emittedEvents: Emissions,
220
- initial: {
221
- target: (to) => to.Idle(),
222
- resolve: ({ target }) => target.from()
223
- }
212
+ initial: (to) => to.Idle().resolve(({ target }) => target.from())
224
213
  })
225
214
  ```
226
215
 
@@ -322,8 +311,8 @@ Invalid event and emission constructions fail the machine with a typed
322
311
  ### Send explicitly between machines
323
312
 
324
313
  `raise` targets the current machine in the same macrostep. `sendTo` targets a
325
- machine mailbox and is processed later. A child declares the subset of parent
326
- inputs it may send with `parentEvents`:
314
+ machine mailbox and is processed later. A machine that requires an owner
315
+ declares the subset of parent inputs it may send with `Machine.parent`:
327
316
 
328
317
  ```ts
329
318
  const ParentEvents = Machine.events(ChildFinished)
@@ -331,23 +320,16 @@ const ParentEvents = Machine.events(ChildFinished)
331
320
  const child = Machine.make({
332
321
  states: ChildStates.states,
333
322
  events: ChildEvents,
334
- parentEvents: ParentEvents,
335
- initial: {
336
- target: (to) => to.Working(),
337
- resolve: ({ target }) => target.from()
338
- }
323
+ parent: Machine.parent(ParentEvents),
324
+ initial: (to) => to.Working().resolve(({ target }) => target.from())
339
325
  }).handle({
340
326
  Working: {
341
327
  on: {
342
- Finish: Machine.transition({
343
- target: (to) => to.full.Done(),
344
- resolve: ({ parent, target }, enqueue) => {
345
- if (parent !== undefined) {
346
- enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
347
- }
328
+ Finish: (to) =>
329
+ to.full.Done().resolve(({ parent, target }, enqueue) => {
330
+ enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
348
331
  return target.from()
349
- }
350
- })
332
+ })
351
333
  }
352
334
  },
353
335
  Done: {}
@@ -357,10 +339,16 @@ const Child = Machine.child("worker", child)
357
339
  const ParentInputs = Machine.events(Start, ParentEvents)
358
340
  ```
359
341
 
360
- The same child remains isolated and may be started as a root, where `parent` is
361
- `undefined`. When `Child` is invoked, the parent definition must accept every
362
- event in `parentEvents`; otherwise `.handle(...)` is a compile-time error.
342
+ `parent` is statically present in every child callback, and root APIs such as
343
+ `Machine.start`, `Machine.planInitial`, Atom machines, and Cluster machines
344
+ reject this machine. When `Child` is invoked, the parent definition must accept
345
+ every declared parent event; otherwise `.handle(...)` is a compile-time error.
363
346
  Inside the child, the parent target accepts only those declared events.
347
+
348
+ Use `parent: Machine.optionalParent(ParentEvents)` when the same machine is
349
+ intentionally valid both as a root and as a child. In that case `parent` is
350
+ `MachineTarget<...> | undefined` and must be narrowed before sending. When no
351
+ parent declaration is present, callbacks do not expose a `parent` property.
364
352
  `emit` never sends to the parent: it only publishes on the emitting machine's
365
353
  `emissions` stream.
366
354
 
@@ -383,14 +371,48 @@ paths. `parent` always means the owning machine target.
383
371
  | `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root |
384
372
  | `target.history` | Restoring a declared history node | The remembered configuration or its typed default |
385
373
 
386
- Every installed transition handler returns either a concrete target or
387
- `target.none()`. An absent handler ignores the trigger; `target.none()` handles
374
+ Every required transition handler selects a target from its inline `to`
375
+ builder. A bare selection uses the target schema's default construction; call
376
+ `.resolve(...)` when construction depends on handler context. An absent handler
377
+ ignores the trigger; `to.none` handles
388
378
  it and retains queued commands, raised events, and emitted events without
389
- selecting a destination. Declared `targets` constrain only concrete
390
- destinations, so `target.none()` is always permitted. Builders describe the
379
+ selecting a destination. Concrete destinations stay narrowed inside their
380
+ resolver, and `to.branches({...})` gives the resolver only the declared named
381
+ `select` builders. Builders describe the
391
382
  next logical configuration. Shared states exit and enter only when paths
392
- change; use `{ reenter: true, transition }` when the source must restart. With
393
- `target.none()`, reentry restarts the source while retaining its configuration.
383
+ change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }`
384
+ to `.resolve(...)` when the source must restart. With `to.none`, reentry
385
+ restarts the source while retaining its configuration.
386
+
387
+ Topology-only definition instructions are values: `to.none`, declared
388
+ `.initial` and history selections, and `to.local.with`. Concrete state and
389
+ choice destinations remain calls such as `to.full.Running()`. Runtime named
390
+ branch builders remain callable, including `select.unchanged()`, because their
391
+ result carries the selected branch evidence.
392
+
393
+ Use `declinable: true` when a resolver may decide that its transition is not
394
+ enabled. Only that resolver receives `decline()`, and its return type expands to
395
+ accept the opaque declined result:
396
+
397
+ ```ts
398
+ const handlers = {
399
+ Submit: (to) =>
400
+ to.local.Saving().resolve(
401
+ ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline(),
402
+ { declinable: true }
403
+ )
404
+ }
405
+ ```
406
+
407
+ Declining discards work enqueued by that resolver. Event and eventless dispatch
408
+ continues with the next eligible ancestor; if no candidate accepts, no
409
+ transition is selected. This differs from `target.none()`, which consumes the
410
+ trigger and prevents an ancestor from handling it. `transitionDefinitions`
411
+ reports each handler's `acceptance` as `"required"` or `"declinable"` while
412
+ preserving the exact declared target branches. Choices and initial routing must
413
+ remain total and cannot use declinable transitions. Completion and invocation
414
+ outcomes have no ancestor candidate: declining one ignores that lifecycle
415
+ occurrence and leaves the current configuration active.
394
416
 
395
417
  ## Statechart capabilities
396
418
 
@@ -418,52 +440,35 @@ arbitrary asynchronous Effects do not run inside planning.
418
440
  State-scoped work starts on entry and is interrupted on exit:
419
441
 
420
442
  ```ts
421
- Loading: {
422
- invoke: Machine.invoke({
423
- id: "save-document",
424
- effect: () => saveDocument,
425
- onDone: Machine.transition({
426
- target: (to) => to.full.Saved(),
427
- resolve: ({ output, target }) => target.from({ id: output.id })
428
- }),
429
- onFailure: Machine.transition({
430
- target: (to) => to.full.Failed(),
431
- resolve: ({ error, target }) => target.from({ message: String(error) })
432
- })
433
- })
434
- }
435
-
436
- Waiting: {
437
- invoke: Machine.invoke({
438
- id: "save-timeout",
439
- after: "3 seconds",
440
- onDone: Machine.transition({
441
- target: (to) => to.full.Failed(),
442
- resolve: ({ target }) => target.from({ message: "Timed out" })
443
- })
444
- })
445
- }
443
+ machine.handle({
444
+ Loading: {
445
+ invoke: (from) =>
446
+ from.effect("save-document", () => saveDocument)
447
+ .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id })))
448
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) })))
449
+ },
450
+ Waiting: {
451
+ invoke: (from) =>
452
+ from.timer("save-timeout", "3 seconds")
453
+ .onDone((to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" })))
454
+ }
455
+ })
446
456
  ```
447
457
 
448
- Use `effect` for one Effect, `stream` for a sequence of externally produced
449
- values, `after` for a cancellable delay, `logic` for a reusable process, and
450
- `child` for a complete child statechart—all through
451
- `Machine.invoke({...})`. The helper is an identity at runtime and preserves
452
- owner-context and source-channel inference across lifecycle handlers, including
453
- for state-dependent Effects:
458
+ The state-local `from` selector starts an `effect`, `stream`, `timer`, reusable
459
+ `logic`, or complete `child` statechart. The selected source determines which
460
+ lifecycle methods the chain requires and which methods are available. For
461
+ example, an Effect with non-`never` output and error channels must handle both;
462
+ the completed chain is the value returned by the callback:
454
463
 
455
464
  ```ts
456
- invoke: Machine.invoke({
457
- id: "load-document",
458
- effect: ({ state }) => loadDocument(state.documentId),
459
- onDone: Machine.transition({
460
- target: (to) => to.full.Ready(),
461
- resolve: ({ output, target }) => target.from({ document: output })
462
- }),
463
- onFailure: Machine.transition({
464
- target: (to) => to.full.Failed(),
465
- resolve: ({ error, target }) => target.from({ message: error.message })
466
- })
465
+ machine.handle({
466
+ Loading: {
467
+ invoke: (from) =>
468
+ from.effect("load-document", ({ 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 })))
471
+ }
467
472
  })
468
473
  ```
469
474
 
@@ -472,78 +477,80 @@ is mapped by `onElement`, and the next element is not pulled until that parent
472
477
  macrostep commits:
473
478
 
474
479
  ```ts
475
- invoke: Machine.invoke({
476
- id: "channel",
477
- stream: () => channelMessages,
478
- onElement: {
479
- target: Machine.targetless,
480
- resolve: ({ element }, enqueue) => {
481
- enqueue.raise(Events.MessageReceived({ message: element }))
482
- }
483
- },
484
- onDone: { target: Machine.targetless },
485
- onFailure: Machine.transition({
486
- target: (to) => to.full.Failed(),
487
- resolve: ({ error, target }) => target.from({ error })
488
- })
480
+ machine.handle({
481
+ Listening: {
482
+ invoke: (from) =>
483
+ from.stream("channel", () => channelMessages)
484
+ .onElement((to) =>
485
+ to.none.resolve(({ element }, enqueue) => {
486
+ enqueue.raise(Events.MessageReceived({ message: element }))
487
+ })
488
+ )
489
+ .onDone((to) => to.none)
490
+ .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ error })))
491
+ }
489
492
  })
490
493
  ```
491
494
 
492
- `target: Machine.targetless` is the direct shorthand for a non-reentering
493
- transition that keeps the current configuration. Its optional `resolve`
494
- callback may enqueue commands and must return `undefined`. Use
495
- `Machine.transition(...)` for transitions that select state or reenter.
495
+ `to.none` is the targetless transition value. Return it directly to keep the
496
+ current configuration, or call `.resolve(...)` when the transition only needs
497
+ to enqueue commands. A block resolver may omit its return because it is
498
+ contextually typed to return `undefined`.
496
499
 
497
- Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's
498
- public input and `parentEvents` protocols contextually. Its source and lifecycle
499
- callbacks can send through `self` and `parent` while retaining the invoked
500
- Effect's output and error inference:
500
+ Inside `.handle(...)`, `from` receives the owning machine's public input and
501
+ declared parent protocol contextually. Source and lifecycle callbacks can send
502
+ through `self` and `parent` while retaining the invoked Effect's output and
503
+ error inference:
501
504
 
502
505
  ```ts
503
506
  const machine = Machine.make({
504
507
  events: Commands,
505
508
  internalEvents: InternalEvents,
506
- parentEvents: ParentEvents
509
+ parent: Machine.parent(ParentEvents)
507
510
  // ...
508
511
  }).handle({
509
512
  Saving: {
510
- invoke: Machine.invoke({
511
- id: "notify-parent",
512
- effect: () => saveDocument,
513
- onDone: Machine.transition({
514
- target: (to) => to.none(),
515
- resolve: ({ parent, self }, enqueue) => {
516
- enqueue.sendTo(self, Commands.Save())
517
- if (parent !== undefined) {
513
+ invoke: (from) =>
514
+ from.effect("notify-parent", () => saveDocument)
515
+ .onDone((to) =>
516
+ to.none.resolve(({ parent, self }, enqueue) => {
517
+ enqueue.sendTo(self, Commands.Save())
518
518
  enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
519
- }
520
- return undefined
521
- }
522
- }),
523
- onFailure: Machine.transition({
524
- target: (to) => to.none(),
525
- resolve: () => undefined
526
- })
527
- })
519
+ })
520
+ )
521
+ .onFailure((to) => to.none)
528
522
  }
529
523
  })
530
524
  ```
531
525
 
532
- The machine-bound `definition.invoke(...)` form remains equivalent when a
533
- definition is already named; it is not required for `self` or `parent` typing.
526
+ Return an array of completed chains to compose multiple state-owned activities.
527
+ The source computation itself, process logic, or `Machine.child(id, machine)`
528
+ descriptor can be named and reused; the invocation chain stays local so its
529
+ transitions retain the exact owning state and machine protocols.
534
530
 
535
- A direct `invoke: { ... }` object is also supported when its lifecycle handlers
536
- do not need source-derived context. Reuse one exported
537
- `Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child
538
- lookup.
531
+ ```ts
532
+ const refreshCache = Cache.refresh
533
+
534
+ machine.handle({
535
+ Active: {
536
+ invoke: (from) => [
537
+ from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none),
538
+ from.timer("expire-session", "5 minutes").onDone((to) => to.full.Expired())
539
+ ]
540
+ }
541
+ })
542
+ ```
539
543
 
540
544
  `onDone` is required for a non-`never` output, and `onFailure` is required for a
541
- non-`never` typed error; each handler is omitted when its channel is `never`.
542
- Defects, interruption, and source-construction failures terminate the owning
543
- runtime. Effect sources are always factories evaluated when their state is
544
- entered. Use `effect: () => Effect.sleep(...)` for a generic Effect, while
545
- `after` keeps timers explicit and makes static durations visible through
546
- activity inspection.
545
+ non-`never` typed error. Streams additionally require `onElement` when their
546
+ element channel is non-`never` and always require `onDone`; logic and child
547
+ chains optionally expose `onSnapshot`. A handled method disappears from the
548
+ next builder step, so every reachable lifecycle channel is handled exactly
549
+ once. Defects, interruption, and source-construction failures terminate the
550
+ owning runtime. Effect sources are factories evaluated when their state is
551
+ entered. Use an Effect containing `Effect.sleep(...)` for generic work, while
552
+ `from.timer(...)` keeps timer intent explicit and makes static durations visible
553
+ through activity inspection.
547
554
 
548
555
  ## Reactivity
549
556