@typeonce/effect-machine 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,3 @@
1
- This package incubates the Machine work proposed in Effect PR #6429.
2
-
3
1
  Portions are adapted from the Effect project, which is distributed under the
4
2
  MIT License. See https://github.com/Effect-TS/effect and the source history for
5
3
  authorship and provenance.
package/README.md CHANGED
@@ -1,10 +1,6 @@
1
1
  # @typeonce/effect-machine
2
2
 
3
- External home for the schema-first Machine API proposed in
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,9 +11,9 @@ 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. Consumers
19
- must install `effect@4.0.0-beta.102`. Upgrading this package may require upgrading
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
 
@@ -30,78 +26,381 @@ import { ClusterMachine } from "@typeonce/effect-machine/cluster"
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
- ## Basic machine
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"
40
+ import { Schema } from "effect"
37
41
  import { Machine } from "@typeonce/effect-machine"
38
42
 
39
- class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}
40
- class Running extends Schema.TaggedClass<Running>("Running")("Running", {}) {}
41
- class Start extends Schema.TaggedClass<Start>("Start")("Start", {}) {}
43
+ const State = Schema.TaggedUnion({
44
+ Idle: {},
45
+ Running: {}
46
+ })
42
47
 
43
- const states = Machine.defineStates({ Idle, Running })
48
+ const Event = Schema.TaggedUnion({
49
+ Start: {}
50
+ })
51
+
52
+ const States = Machine.defineStates(State.cases)
44
53
 
45
54
  const Counter = Machine.make({
46
55
  id: "Counter",
47
- states: states.states,
48
- events: [Start],
49
- initial: states.initial.Idle(new Idle())
56
+ states: States.states,
57
+ events: [Event.cases.Start],
58
+ initial: () => States.initial.Idle(State.cases.Idle.make({}))
50
59
  }).handle({
51
60
  Idle: {
52
61
  on: {
53
- Start: ({ target }) => Effect.succeed(target.full.Running(new Running()))
62
+ Start: ({ target }) => target.full.Running(State.cases.Running.make({}))
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
+ Tagged classes are equally valid when cases need class methods or nominal
73
+ identity:
74
+
75
+ ```ts
76
+ class Idle extends Schema.TaggedClass<Idle>("Idle")("Idle", {}) {}
77
+ ```
78
+
79
+ ## Public and internal events
80
+
81
+ Declare commands that callers may send in `events`. Declare machine-local
82
+ deliveries, such as invoke results and child emissions, in `internalEvents`:
83
+
84
+ ```ts
85
+ const Command = Schema.TaggedUnion({
86
+ Save: {}
87
+ })
88
+
89
+ const InternalEvent = Schema.TaggedUnion({
90
+ Saved: { id: Schema.String },
91
+ SaveFailed: { message: Schema.String }
92
+ })
93
+
94
+ const machine = Machine.make({
95
+ states: States.states,
96
+ events: [Command.cases.Save],
97
+ internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
98
+ initial: () => States.initial.Idle(State.cases.Idle.make({}))
99
+ })
100
+ ```
101
+
102
+ Handlers and machine logic see the complete union. Local public APIs such as
103
+ `MachineRef.send`, `machineAtom.send`, and `Machine.plan` expose only `events`
104
+ in TypeScript. The local planner and runtime still share the complete event
105
+ decoder so machine-local deliveries can flow through the same execution
106
+ protocol; bypassing the types with JavaScript or `any` is therefore not a
107
+ runtime authorization boundary. Cluster RPC delivery additionally validates
108
+ incoming payloads against the public `events` schemas.
109
+
110
+ The utility types make the distinction available to application code:
111
+
112
+ ```ts
113
+ type PublicCommand = Machine.Machine.InputEvent<typeof machine>
114
+ type HandledEvent = Machine.Machine.Event<typeof machine>
115
+ ```
116
+
117
+ Tags must be unique within each list, and public and internal tags must be
118
+ disjoint. Reusing a tag is a type error, so a command cannot accidentally
119
+ masquerade as an internal result.
120
+
121
+ ## Statechart structure
122
+
123
+ `Machine.defineStates` accepts atomic, compound, parallel, and final state
124
+ nodes:
125
+
126
+ ```ts
127
+ const State = Schema.TaggedUnion({
128
+ Form: { draft: Schema.String },
129
+ Editing: {},
130
+ Saving: {},
131
+ Done: {}
132
+ })
133
+
134
+ const States = Machine.defineStates({
135
+ Form: {
136
+ schema: State.cases.Form,
137
+ initial: "Editing",
138
+ states: {
139
+ Editing: State.cases.Editing,
140
+ Saving: State.cases.Saving,
141
+ Done: {
142
+ schema: State.cases.Done,
143
+ type: "final",
144
+ output: Schema.String
145
+ }
54
146
  }
55
147
  }
56
148
  })
57
149
  ```
58
150
 
59
- The exported namespaces preserve the API, type identifiers, service keys,
60
- semantics, and documentation of the Effect proposal.
151
+ Compound states have one active child and declare its initial key. Parallel
152
+ states use `type: "parallel"` and have one active state in every direct region.
153
+ Finality is topology, so declare `type: "final"` only in the state definition.
154
+ Handlers implement behavior and output computation without repeating it:
61
155
 
62
- ## Development and validation
156
+ ```ts
157
+ const machine = Machine.make({
158
+ states: States.states,
159
+ events: [],
160
+ initial: () =>
161
+ States.initial.Form(State.cases.Form.make({ draft: "" }), (form) => form.Editing(State.cases.Editing.make({})))
162
+ }).handle({
163
+ Form: {
164
+ states: {
165
+ Done: {
166
+ output: () => "saved"
167
+ }
168
+ }
169
+ }
170
+ })
171
+ ```
63
172
 
64
- Use pnpm 10 and Node.js 20 or newer:
173
+ Every declared output schema must have a matching handler implementation before
174
+ the machine can be planned, started, invoked, or adapted to Atom/Cluster.
175
+ Final children complete their parent; put `onDone` on that compound or parallel
176
+ parent, not on the final leaf.
65
177
 
66
- ```sh
67
- pnpm install --frozen-lockfile
68
- pnpm check
178
+ Put data on the narrowest state where it is valid. If several sibling phases
179
+ share data, prefer storing it on their compound parent instead of copying it
180
+ into every child state.
181
+
182
+ Transition between structurally related tagged states with `Machine.retag`.
183
+ The source `_tag` is discarded, compatible fields are reused, and missing or
184
+ incompatible required fields must be supplied:
185
+
186
+ ```ts
187
+ const saving = Machine.retag(State.cases.Saving, editing)
69
188
  ```
70
189
 
71
- Individual commands are available for `build`, `test`, `test:types`,
72
- `typecheck`, `format:check`, `test:consumer`, `test:sync`, `sync:check`, and
73
- `pack:check`. Runtime tests use `@effect/vitest`; type tests use TSTyche and
74
- TypeScript 6.0.3.
190
+ ## Choosing a target builder
75
191
 
76
- ## Synchronizing Effect PR #6429
192
+ Transition contexts expose three typed target builders:
77
193
 
78
- Make logic changes here first and run `pnpm check`. Then generate the mapped
79
- production files, runtime tests, and type tests into a writable Effect checkout:
194
+ | Builder | Destination | Configuration behavior |
195
+ | --------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
196
+ | `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions |
197
+ | `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions |
198
+ | `target.full` | Any top-level root | Builds a complete active snapshot for the selected root |
80
199
 
81
- ```sh
82
- pnpm sync:effect -- /path/to/effect
200
+ The builder controls how the next configuration is assembled; it does not by
201
+ itself decide which invokes restart. The runtime derives exit and entry paths
202
+ from the previous and next active paths. Shared active ancestors remain entered,
203
+ even when `target.full` supplies their values again. Use an event transition
204
+ with `reenter: true` when the source state should explicitly exit and enter
205
+ again:
206
+
207
+ ```ts
208
+ Refresh: {
209
+ reenter: true,
210
+ transition: ({ state, target }) =>
211
+ target.full.Ready(new Ready({ value: state.value }))
212
+ }
83
213
  ```
84
214
 
85
- The explicit mapping covers only the eight production files and six test files.
86
- It rewrites package imports to Effect repository-relative `.ts` imports and
87
- restores Effect's private `PipeInspectableProto` boundary. It never copies
88
- package metadata, documentation, release files, or changesets.
215
+ `States.get`, `States.getWithParents`, `States.getSnapshot`, and
216
+ `States.matches` accept typed dotted paths. Handler `parents` values are also
217
+ keyed by full dotted paths, such as `parents["Form.Editing"]`; `context.parent`
218
+ provides the immediate parent directly and is `undefined` at a root state.
89
219
 
90
- Check an existing checkout without writing:
220
+ ## Planning Effects and staged actions
221
+
222
+ An Effect returned by a transition handler is part of planning. Use it to read
223
+ services, choose a target, raise an event, or emit an event. Wrap external side
224
+ effects in `Machine.action`; actions are staged during planning and run by the
225
+ managed runtime before it publishes the next state.
226
+
227
+ ```ts
228
+ Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(State.cases.Saving.make({})))
229
+ ```
230
+
231
+ The one-argument form returns `void` after staging. The two-argument form
232
+ returns its second argument, which avoids a generator when an action and the
233
+ next target are the whole transition.
234
+
235
+ If an action fails, the runtime keeps the previously published state and
236
+ suppresses emissions from that plan.
237
+
238
+ `Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
239
+ `done` is `true`, `output` is the schema-derived structural terminal union;
240
+ while the machine remains active, it is `undefined`. A started machine's
241
+ `join` uses the same terminal union. Output-less structural terminal paths
242
+ contribute `undefined`, while active atomic roots do not.
243
+
244
+ This union is intentionally conservative with respect to handler behavior. For
245
+ example, a root `onDone` transition may make one structurally terminal result
246
+ unreachable even though its schema remains in `Machine.TerminalOutput`.
247
+
248
+ ## State-scoped invokes
249
+
250
+ `Machine.invoke` runs child logic while its owning state is active. Leaving the
251
+ state interrupts the child. For a one-shot Effect, `Machine.invokeEffect` maps
252
+ typed success and failure values directly to internal events:
253
+
254
+ ```ts
255
+ invoke: ({ state }) =>
256
+ Machine.invokeEffect({
257
+ id: "save",
258
+ effect: save(state),
259
+ onSuccess: (entry) => InternalEvent.cases.Saved.make({ id: entry.id }),
260
+ onFailure: (error) =>
261
+ InternalEvent.cases.SaveFailed.make({
262
+ message: String(error)
263
+ })
264
+ })
265
+ ```
266
+
267
+ Omit `onFailure` when the Effect cannot fail. Defects and interruption remain
268
+ failures rather than being mapped.
269
+
270
+ `Machine.after` creates a cancellable, state-scoped delayed event with the same
271
+ lifetime:
272
+
273
+ ```ts
274
+ invoke: Machine.after("3 seconds", InternalEvent.cases.SaveFailed.make({ message: "Timed out" }), {
275
+ id: "save-timeout"
276
+ })
277
+ ```
278
+
279
+ Provide an explicit id when more than one active timer could deliver the same
280
+ event tag.
281
+
282
+ Use lower-level `Machine.invoke` with `Machine.effect` for custom child logic or
283
+ snapshot mapping. Its `id` is only the state-local lifecycle key. If the parent
284
+ must send events to that invocation, create a typed low-level address with
285
+ `Machine.childAddress<Event>("worker")` and pass it through the explicit
286
+ `address` option; the address protocol is checked against the child logic.
287
+ Lifecycle ids must be unique among simultaneously active invokes owned by the
288
+ same state.
289
+
290
+ Invoke outputs, invoke snapshot events, and invoked-child emissions belong in
291
+ `internalEvents`. They are available to typed handlers but are not accepted by
292
+ the typed public input APIs. Include a child machine's emitted protocol with
293
+ `internalEvents: [...ChildMachine.emits]` when those emissions should be handled
294
+ by the parent.
295
+
296
+ For a child statechart, create one descriptor for `invokeMachine`, `sendTo`,
297
+ and child lookup:
298
+
299
+ ```ts
300
+ const Editor = Machine.child("editor", EditorMachine)
301
+ ```
302
+
303
+ `Machine.child(id, machine)` is the complete statechart descriptor;
304
+ `Machine.childAddress<Event>(id)` is the lower-level event-only address.
305
+ Descriptors are matched by id and machine identity, so independently created
306
+ descriptors for the same pair address the same child without a global cache.
307
+ Exporting one descriptor remains the clearest module boundary.
308
+
309
+ ## Reactivity
310
+
311
+ `AtomMachine.make` creates a lazy bridge backed by one running machine per
312
+ `AtomRegistry`. Mounting or reading one of its atoms starts the machine;
313
+ disposing the registry-owned reference stops it.
314
+
315
+ ```ts
316
+ import { Atom } from "effect/unstable/reactivity"
317
+ import { AtomMachine } from "@typeonce/effect-machine/reactivity"
318
+
319
+ const runtime = Atom.runtime(AppLayer)
320
+ const machines = AtomMachine.bind(runtime)
321
+ const machineAtom = machines.make(Counter)
322
+ ```
323
+
324
+ For applications with a shared runtime, treat
325
+ `AtomMachine.bind(runtime).make(...)` as the canonical form. It keeps runtime
326
+ ownership at the composition boundary so it does not need to be passed through
327
+ every feature. Service-free machines may use `AtomMachine.make(machine)`
328
+ directly.
329
+
330
+ The bridge exposes:
331
+
332
+ - `ref`: the running `MachineRef`
333
+ - `result`: fail-aware logical state, combining startup and post-start runtime
334
+ failures
335
+ - `snapshot`: authoritative runtime lifecycle, including `active`, `done`,
336
+ `error`, and `stopped`
337
+ - `state`: the last logical state, including the retained state after a runtime
338
+ failure
339
+ - `send` and `stop`: writable command atoms
340
+ - `child(descriptor)`: a reactive bridge for a directly owned child
341
+
342
+ Use `AtomMachine.select` and `AtomMachine.matches` for equality-aware root
343
+ derivations. Use `selectChild` and `matchesChild` for child bridges. Selector
344
+ paths and selected value types are inferred directly from the bridge snapshot,
345
+ so these combinators do not need the `DefinedStates` object. They follow normal
346
+ Atom identity semantics and return a new atom on each call, so retain or memoize
347
+ them when constructing them in a component. The `child` method uses Effect's
348
+ `Atom.family` to reuse a live bridge for the same descriptor without maintaining
349
+ a package-level cache.
350
+ `AtomMachine.ChildMachineAtom<typeof Child>` uses `unknown` as its startup-error
351
+ default for general component props.
352
+ `AtomMachine.ChildOf<typeof parentAtom, typeof Child>` preserves the exact
353
+ parent startup-error channel.
354
+
355
+ Child state and snapshot atoms contain `Option.none()` while that child is
356
+ inactive. React applications using `@effect/atom-react` need a
357
+ `RegistryProvider`; see the [Pokémon example](./examples/pokemon).
358
+
359
+ ## Snapshots and persistence
360
+
361
+ `Machine.encodeSnapshot` and `Machine.decodeSnapshot` validate logical
362
+ statechart data for storage or transport. The encoded representation does not
363
+ contain the machine definition, machine version, services, subscriptions, or
364
+ running child processes. Store machine identity and migration/version metadata
365
+ alongside it.
366
+
367
+ `ClusterMachine` provides a separate persisted entity adapter. Its process-local
368
+ restrictions and delivery guarantees are documented on that API.
369
+
370
+ ## Current limits
371
+
372
+ History states and declarative first-class guards are not part of the current
373
+ API. Ordinary TypeScript conditions implement guards. Use `Machine.after` for a
374
+ cancellable state-scoped delayed event.
375
+
376
+ ## Guidance for agents and contributors
377
+
378
+ The shipped [agent guide](./docs/agent-guide.md) contains the recommended
379
+ definition order, modeling rules, lifecycle invariants, React recipe, common
380
+ compiler errors, and unsupported features.
381
+
382
+ ## Development and validation
383
+
384
+ Use pnpm 10 and Node.js 20 or newer:
91
385
 
92
386
  ```sh
93
- pnpm sync:effect -- --check /path/to/effect
387
+ pnpm install --frozen-lockfile
388
+ pnpm check
94
389
  ```
95
390
 
96
- Check mode exits non-zero on any drift. `pnpm test:sync` exercises generation,
97
- a clean check, and drift detection in a disposable temporary directory. Never
98
- generate into a checkout with work you have not reviewed. After generation,
99
- inspect the Effect diff and run its targeted machine, reactivity, cluster, and
100
- type-test validations followed by `pnpm check`.
391
+ Individual commands are available for `build`, `test`, `test:types`,
392
+ `typecheck`, `format:check`, `test:consumer`, and `pack:check`. Runtime tests use
393
+ `@effect/vitest`; type tests use TSTyche and TypeScript 6.0.3. The consumer check
394
+ packs the package, imports all public entrypoints, and compiles a strict
395
+ TypeScript consumer with `skipLibCheck: false`.
396
+
397
+ ## Examples
101
398
 
102
- The current source reference is branch `sandro/state-charts` in the Effect
103
- repository. Exact dependency pins and the sync check are the proof boundary;
104
- vendoring the rest of Effect is intentionally unnecessary.
399
+ The [Pokémon statechart example](./examples/pokemon) is a standalone React and
400
+ Vite project demonstrating compound and parallel states, state-scoped invokes,
401
+ invoked child statecharts, typed emissions, and Atom reactivity. It uses a local
402
+ `file:` dependency on this package while retaining an isolated dependency graph,
403
+ lockfile, build, and CI job.
105
404
 
106
405
  ## Releases
107
406
 
@@ -109,9 +408,5 @@ Add a changeset with `pnpm changeset`. CI validates frozen installation and the
109
408
  complete check suite. The release workflow opens version PRs and publishes with
110
409
  npm provenance through GitHub Actions.
111
410
 
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
411
  When equivalent Machine modules ship in Effect, this package is intended to
117
412
  become a thin compatibility re-export package before eventual retirement.