@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 +0 -2
- package/README.md +347 -52
- package/dist/AtomMachine.d.ts +187 -10
- package/dist/AtomMachine.d.ts.map +1 -1
- package/dist/AtomMachine.js +145 -18
- package/dist/AtomMachine.js.map +1 -1
- package/dist/ClusterMachine.d.ts +15 -5
- package/dist/ClusterMachine.d.ts.map +1 -1
- package/dist/ClusterMachine.js.map +1 -1
- package/dist/Machine.d.ts +410 -94
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +105 -47
- package/dist/Machine.js.map +1 -1
- package/dist/internal/machineModel.d.ts +6 -1
- package/dist/internal/machineModel.d.ts.map +1 -1
- package/dist/internal/machineModel.js +68 -15
- package/dist/internal/machineModel.js.map +1 -1
- package/dist/internal/machinePlanner.d.ts +15 -5
- package/dist/internal/machinePlanner.d.ts.map +1 -1
- package/dist/internal/machinePlanner.js +75 -48
- package/dist/internal/machinePlanner.js.map +1 -1
- package/dist/internal/machineProcess.d.ts +2 -2
- package/dist/internal/machineProcess.d.ts.map +1 -1
- package/dist/internal/machineProcess.js +22 -11
- package/dist/internal/machineProcess.js.map +1 -1
- package/dist/internal/machineRuntime.d.ts +11 -5
- package/dist/internal/machineRuntime.d.ts.map +1 -1
- package/dist/internal/machineRuntime.js +24 -13
- package/dist/internal/machineRuntime.js.map +1 -1
- package/docs/agent-guide.md +585 -0
- package/package.json +18 -21
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# Effect Machine agent guide
|
|
2
|
+
|
|
3
|
+
This is the model-facing reference for the currently published
|
|
4
|
+
`@typeonce/effect-machine` API. Prefer these patterns over reconstructing the API
|
|
5
|
+
from its internal implementation.
|
|
6
|
+
|
|
7
|
+
## Public imports
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Machine } from "@typeonce/effect-machine"
|
|
11
|
+
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
|
|
12
|
+
import { ClusterMachine } from "@typeonce/effect-machine/cluster"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Do not import the published package as `effect/unstable/machine`. The package is
|
|
16
|
+
currently coupled to the exact Effect peer version listed in its `package.json`.
|
|
17
|
+
|
|
18
|
+
## Definition order
|
|
19
|
+
|
|
20
|
+
Use this order so inference has all schemas available when handlers are
|
|
21
|
+
declared:
|
|
22
|
+
|
|
23
|
+
1. Domain schemas used by state and event fields.
|
|
24
|
+
2. Tagged state schemas.
|
|
25
|
+
3. Tagged public-event, internal-event, and emitted-event schemas.
|
|
26
|
+
4. `Machine.defineStates`.
|
|
27
|
+
5. `Machine.make`, including input, events, internal events, emits, and the
|
|
28
|
+
initial function.
|
|
29
|
+
6. One or more `.handle(...)` calls.
|
|
30
|
+
7. Child descriptors.
|
|
31
|
+
8. Runtime, Atom, or Cluster adapters.
|
|
32
|
+
|
|
33
|
+
`Schema.TaggedUnion` avoids one class declaration per case:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const State = Schema.TaggedUnion({
|
|
37
|
+
Idle: {},
|
|
38
|
+
Saving: { draft: Draft },
|
|
39
|
+
Failed: { message: Schema.String }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const Event = Schema.TaggedUnion({
|
|
43
|
+
Save: {}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const InternalEvent = Schema.TaggedUnion({
|
|
47
|
+
Saved: { id: Schema.String },
|
|
48
|
+
SaveFailed: { message: Schema.String }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const States = Machine.defineStates(State.cases)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Construct values with `State.cases.Idle.make({})` or
|
|
55
|
+
`Event.cases.Save.make({})`. Use `Schema.TaggedClass` instead when a case needs
|
|
56
|
+
class methods or nominal class identity.
|
|
57
|
+
|
|
58
|
+
## Hard invariants
|
|
59
|
+
|
|
60
|
+
- `Machine.make({ initial })` expects a function, including for `Schema.Void`
|
|
61
|
+
input.
|
|
62
|
+
- State, emit, input, and output schemas validate their runtime boundaries.
|
|
63
|
+
Event schemas provide decoders, but the local public/internal distinction is
|
|
64
|
+
a TypeScript boundary; Cluster additionally validates public commands at its
|
|
65
|
+
transport boundary.
|
|
66
|
+
- Return snapshots or typed target-builder results from transitions. Do not
|
|
67
|
+
return raw decoded state values.
|
|
68
|
+
- Effects returned by handlers are planning Effects. Wrap external effects in
|
|
69
|
+
`Machine.action`.
|
|
70
|
+
- Put data on the narrowest state where it is valid. Put data shared by sibling
|
|
71
|
+
phases on their compound parent.
|
|
72
|
+
- Declare finality only in the state definition. Do not put `type: "final"` in
|
|
73
|
+
a handler.
|
|
74
|
+
- Every declared output schema needs a matching handler implementation before
|
|
75
|
+
planning or execution.
|
|
76
|
+
- `parents` keys are full dotted paths.
|
|
77
|
+
- Invoke lifetimes follow state entry and exit, not the spelling of the target
|
|
78
|
+
builder.
|
|
79
|
+
- Recover expected invoked Effect failures into machine events. Unrecovered
|
|
80
|
+
child failures terminate the owning machine.
|
|
81
|
+
- Reuse the exact child descriptor value for `invokeMachine`, `sendTo`, and
|
|
82
|
+
child lookup.
|
|
83
|
+
- `events` is the public input protocol. `internalEvents` contains machine-local
|
|
84
|
+
deliveries such as invoke results and invoked-child emissions. Handlers see
|
|
85
|
+
both; typed public `send` and `Machine.plan` accept only `events`.
|
|
86
|
+
- Event tags in `events` and `internalEvents` must be disjoint.
|
|
87
|
+
- Event tags must also be unique within each protocol list.
|
|
88
|
+
|
|
89
|
+
## Canonical API choices
|
|
90
|
+
|
|
91
|
+
Choose one helper from the intent, and reach for the lower-level form only when
|
|
92
|
+
its extra control is required:
|
|
93
|
+
|
|
94
|
+
- Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the
|
|
95
|
+
returned `make`. Use `AtomMachine.make(machine)` for a service-free machine.
|
|
96
|
+
- Use `Machine.invokeEffect` for a typed one-shot Effect and `Machine.after` for
|
|
97
|
+
a timer. Use `Machine.invoke` with `Machine.effect` only for custom child
|
|
98
|
+
process behavior or snapshot mapping.
|
|
99
|
+
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
|
|
100
|
+
`Machine.childAddress<Event>(id)` for a low-level process address. An
|
|
101
|
+
invocation is addressable only when `Machine.invoke` receives that address
|
|
102
|
+
explicitly.
|
|
103
|
+
- Stage external effects with `Machine.action`; its optional second argument is
|
|
104
|
+
the same operation with a returned transition value, not a separate action
|
|
105
|
+
API.
|
|
106
|
+
|
|
107
|
+
## Atomic, compound, and parallel states
|
|
108
|
+
|
|
109
|
+
Use an atomic state when no child phase can be active beneath it.
|
|
110
|
+
|
|
111
|
+
Use a compound state when exactly one child phase is active. It must declare an
|
|
112
|
+
`initial` child:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const FormState = Schema.TaggedUnion({
|
|
116
|
+
Form: { draft: Schema.String },
|
|
117
|
+
Editing: {},
|
|
118
|
+
Saving: {}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const FormStates = Machine.defineStates({
|
|
122
|
+
Form: {
|
|
123
|
+
schema: FormState.cases.Form,
|
|
124
|
+
initial: "Editing",
|
|
125
|
+
states: {
|
|
126
|
+
Editing: FormState.cases.Editing,
|
|
127
|
+
Saving: FormState.cases.Saving
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Use a parallel state when every direct region is active:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const ParallelState = Schema.TaggedUnion({
|
|
137
|
+
Screen: {},
|
|
138
|
+
Network: {},
|
|
139
|
+
Online: {},
|
|
140
|
+
Offline: {},
|
|
141
|
+
Panel: {},
|
|
142
|
+
Closed: {},
|
|
143
|
+
Open: {}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const ParallelStates = Machine.defineStates({
|
|
147
|
+
Screen: {
|
|
148
|
+
schema: ParallelState.cases.Screen,
|
|
149
|
+
type: "parallel",
|
|
150
|
+
states: {
|
|
151
|
+
network: {
|
|
152
|
+
schema: ParallelState.cases.Network,
|
|
153
|
+
initial: "Online",
|
|
154
|
+
states: {
|
|
155
|
+
Online: ParallelState.cases.Online,
|
|
156
|
+
Offline: ParallelState.cases.Offline
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
panel: {
|
|
160
|
+
schema: ParallelState.cases.Panel,
|
|
161
|
+
initial: "Closed",
|
|
162
|
+
states: {
|
|
163
|
+
Closed: ParallelState.cases.Closed,
|
|
164
|
+
Open: ParallelState.cases.Open
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Every parallel region needs an active state in initial and full snapshot
|
|
173
|
+
builders.
|
|
174
|
+
|
|
175
|
+
Use `type: "final"` for a terminal leaf in `Machine.defineStates`. A final
|
|
176
|
+
child completes its compound parent. Put `onDone` on that completed parent,
|
|
177
|
+
never on the final leaf. The definition owns the output schema and the handler
|
|
178
|
+
computes its value:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const States = Machine.defineStates({
|
|
182
|
+
Done: {
|
|
183
|
+
schema: State.cases.Done,
|
|
184
|
+
type: "final",
|
|
185
|
+
output: Schema.String
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const machine = Machine.make({
|
|
190
|
+
states: States.states,
|
|
191
|
+
events: [],
|
|
192
|
+
initial: () => States.initial.Done(State.cases.Done.make({}))
|
|
193
|
+
}).handle({
|
|
194
|
+
Done: {
|
|
195
|
+
output: () => "done"
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Do not repeat `type: "final"` in `handle`. Execution APIs reject a machine
|
|
201
|
+
until every declared output schema has an implementation.
|
|
202
|
+
|
|
203
|
+
## Choosing a target
|
|
204
|
+
|
|
205
|
+
| Builder | Use it when | What it preserves |
|
|
206
|
+
| --------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
207
|
+
| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions |
|
|
208
|
+
| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions |
|
|
209
|
+
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
|
|
210
|
+
|
|
211
|
+
These describe configuration construction, not automatic process restart.
|
|
212
|
+
Machine planning compares active paths and derives the actual exit and entry
|
|
213
|
+
sets. A `target.full` result with the same active paths can update values without
|
|
214
|
+
exiting shared states. To force the source to exit and enter again:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
Refresh: {
|
|
218
|
+
reenter: true,
|
|
219
|
+
transition: ({ state, target }) =>
|
|
220
|
+
target.full.Ready(new Ready({ value: state.value }))
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Do not use `target.full` merely because it is easiest to discover. Prefer the
|
|
225
|
+
narrowest builder that expresses the intended configuration change.
|
|
226
|
+
|
|
227
|
+
## Reading state and parents
|
|
228
|
+
|
|
229
|
+
`Machine.defineStates` returns typed helpers:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
States.get(snapshot, "Route.Ready")
|
|
233
|
+
States.getWithParents(snapshot, "Route.Ready.Editing")
|
|
234
|
+
States.getSnapshot(snapshot, "Route.Ready")
|
|
235
|
+
States.matches(snapshot, "Route.Ready.Saving")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
All paths are checked against the definition. `context.parent` is the immediate
|
|
239
|
+
typed parent (`undefined` at a root). Use `parents` when another ancestor is
|
|
240
|
+
needed:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
parents["Route.Ready"]
|
|
244
|
+
parents["Route.Ready.Editing"]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Do not guess short properties such as `parents.Ready`.
|
|
248
|
+
|
|
249
|
+
Use `Machine.retag(TargetCase, source, patch?)` when sibling state payloads
|
|
250
|
+
share fields. It removes the source discriminator, reuses only compatible
|
|
251
|
+
fields, and requires a patch for every missing or incompatible required field.
|
|
252
|
+
Prefer moving broadly shared data to the compound parent rather than retagging
|
|
253
|
+
it through every phase.
|
|
254
|
+
|
|
255
|
+
## Planning, actions, raised events, and emissions
|
|
256
|
+
|
|
257
|
+
A transition may return a target directly or compute it in an Effect:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
Submit: Effect.fn(function* ({ state, target }) {
|
|
261
|
+
const service = yield* SaveService
|
|
262
|
+
const canSave = yield* service.validate(state.draft)
|
|
263
|
+
|
|
264
|
+
return canSave ? target.local.Saving(new Saving({ draft: state.draft })) : undefined
|
|
265
|
+
})
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
That Effect runs during planning. External side effects must be staged:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
Submit: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(new Saving({})))
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`Machine.action(effect)` stages the action and returns `void`.
|
|
275
|
+
`Machine.action(effect, next)` stages the same action and returns `next`, which
|
|
276
|
+
is convenient when the transition does not otherwise need an Effect generator.
|
|
277
|
+
|
|
278
|
+
The managed runtime executes staged actions before publishing the planned
|
|
279
|
+
state. If an action fails, it retains the previous state and suppresses planned
|
|
280
|
+
emissions.
|
|
281
|
+
|
|
282
|
+
Plans have a discriminated completion result:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const planned = yield * Machine.plan(machine, state, event)
|
|
286
|
+
if (planned.done) {
|
|
287
|
+
planned.output // schema-derived structural terminal union
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
When `done` is false, `output` is `undefined`. `MachineRef.join` and invoked
|
|
292
|
+
child `onDone.output` use the same structural terminal union rather than adding
|
|
293
|
+
an unconditional optional value. Output-less structural terminal paths
|
|
294
|
+
contribute `undefined`; active atomic roots do not. Handler behavior can make
|
|
295
|
+
the type conservative—for example, a root `onDone` transition can move away
|
|
296
|
+
before that root becomes the machine's terminal result.
|
|
297
|
+
|
|
298
|
+
`raise` queues an event for the same machine's current macrostep. `emit` queues
|
|
299
|
+
an event for the parent. Both operations validate their schemas.
|
|
300
|
+
|
|
301
|
+
## Public and internal event protocols
|
|
302
|
+
|
|
303
|
+
`events` defines the protocol callers can send. `internalEvents` augments the
|
|
304
|
+
union handled inside the statechart:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
const machine = Machine.make({
|
|
308
|
+
states: States.states,
|
|
309
|
+
events: [Event.cases.Save],
|
|
310
|
+
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
|
|
311
|
+
initial: () => States.initial.Idle(State.cases.Idle.make({}))
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Use the exported utility types when another API must preserve the boundary:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
type PublicEvent = Machine.Machine.InputEvent<typeof machine>
|
|
319
|
+
type AnyHandledEvent = Machine.Machine.Event<typeof machine>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` use `InputEvent` at
|
|
323
|
+
their TypeScript boundary. Transition handlers, raised events, invoke results,
|
|
324
|
+
and mapped child events use the complete `Event` union. The local planner and
|
|
325
|
+
runtime intentionally share the complete decoder to support those internal
|
|
326
|
+
deliveries, so JavaScript or `any` can bypass the local public distinction.
|
|
327
|
+
Cluster RPC payloads are additionally decoded against the public `events`
|
|
328
|
+
schemas at the transport boundary. Never repeat an `_tag` within a list or
|
|
329
|
+
across both configuration lists.
|
|
330
|
+
|
|
331
|
+
## Recoverable state-scoped work
|
|
332
|
+
|
|
333
|
+
Use `Machine.invokeEffect` for a one-shot Effect. Its callbacks preserve the
|
|
334
|
+
typed success and failure channels while mapping both into machine events:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
invoke: ({ state }) =>
|
|
338
|
+
Machine.invokeEffect({
|
|
339
|
+
id: "save",
|
|
340
|
+
effect: SaveService.save(state.draft),
|
|
341
|
+
onSuccess: (entry) => new Saved({ entry }),
|
|
342
|
+
onFailure: (error) => new SaveFailed({ message: error.message })
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The owning state scopes the child. Owner-driven interruption on state exit is
|
|
347
|
+
normal cancellation and stale output is ignored. A child Effect that defects
|
|
348
|
+
or self-interrupts fails the parent. Omit `onFailure` only when the Effect error
|
|
349
|
+
type is `never`; defects and interruption are not mapped.
|
|
350
|
+
|
|
351
|
+
Successful non-void output is delivered as a parent event. Include every
|
|
352
|
+
possible mapped result schema in the parent machine's `internalEvents` array and
|
|
353
|
+
add handlers for the relevant tags. Leave defects and interruption fatal;
|
|
354
|
+
recover only expected typed failures.
|
|
355
|
+
|
|
356
|
+
A cancellable timer uses `Machine.after`:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
invoke: Machine.after("3 seconds", new ClearStatus({}), {
|
|
360
|
+
id: "clear-status"
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The timer starts on state entry and is interrupted on exit. Supply an explicit
|
|
365
|
+
id when more than one active timer could deliver the same event tag. Use
|
|
366
|
+
lower-level `Machine.invoke` with `Machine.effect` when custom child logic or
|
|
367
|
+
snapshot mapping is required. In that API, `id` is only the invocation's
|
|
368
|
+
state-local lifecycle key. To communicate with the invocation, create a
|
|
369
|
+
`Machine.childAddress<Event>("worker")` and pass it as `address`; TypeScript
|
|
370
|
+
checks the address protocol against the child logic. Lifecycle ids must be
|
|
371
|
+
unique among simultaneously active invokes owned by the same state.
|
|
372
|
+
|
|
373
|
+
## Invoked child statecharts
|
|
374
|
+
|
|
375
|
+
Create a complete child-statechart descriptor:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const Editor = Machine.child("editor", EditorMachine)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Invoke it from its owning state:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
invoke: Machine.invokeMachine({
|
|
385
|
+
child: Editor,
|
|
386
|
+
input: editorInput,
|
|
387
|
+
onDone: ({ output }) => new EditorCompleted({ output })
|
|
388
|
+
})
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Use `Editor` for:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
Machine.sendTo(Editor, new Reset({}))
|
|
395
|
+
parentRef.child(Editor)
|
|
396
|
+
parentAtom.child(Editor)
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Child emissions, mapped snapshots, and mapped completion output are delivered as
|
|
400
|
+
parent events and must be accepted by the parent's `internalEvents` list.
|
|
401
|
+
Invoked child IDs must be unique while simultaneously active.
|
|
402
|
+
|
|
403
|
+
Descriptors with the same id and machine identity address the same child, even
|
|
404
|
+
when independently constructed. The descriptor objects themselves are not
|
|
405
|
+
canonicalized. Prefer exporting one descriptor as the application boundary.
|
|
406
|
+
Use the separate
|
|
407
|
+
`Machine.childAddress<Event>(id)` constructor only for lower-level process
|
|
408
|
+
logic that does not have a complete machine descriptor.
|
|
409
|
+
|
|
410
|
+
## AtomMachine and React
|
|
411
|
+
|
|
412
|
+
`AtomMachine.make(machine, ...input)` works when the machine has no external
|
|
413
|
+
service requirements. For an application runtime, the canonical form is to
|
|
414
|
+
bind it once at the composition boundary:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
const runtime = Atom.runtime(AppLayer)
|
|
418
|
+
const machines = AtomMachine.bind(runtime)
|
|
419
|
+
const machineAtom = machines.make(machine, input)
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
One bridge owns one machine instance per `AtomRegistry`. In React:
|
|
423
|
+
|
|
424
|
+
1. Render a `RegistryProvider` from `@effect/atom-react`.
|
|
425
|
+
2. Keep a component-owned bridge referentially stable, normally with
|
|
426
|
+
`useMemo`.
|
|
427
|
+
3. Use scalar dependencies that define when the machine should restart.
|
|
428
|
+
4. Expect a new bridge identity to create a new instance once mounted.
|
|
429
|
+
|
|
430
|
+
The root bridge shapes are:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
machineAtom.state
|
|
434
|
+
// Atom<AsyncResult<State, StartError>>
|
|
435
|
+
|
|
436
|
+
machineAtom.result
|
|
437
|
+
// Atom<AsyncResult<State, StartError | RuntimeError>>
|
|
438
|
+
|
|
439
|
+
machineAtom.snapshot
|
|
440
|
+
// Atom<AsyncResult<RuntimeSnapshot<State, RuntimeError, Output>, StartError>>
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`state` remains a successful last-state value after a post-start runtime
|
|
444
|
+
failure. Prefer `result` for ordinary fail-aware UI state. Use `snapshot` when
|
|
445
|
+
the full lifecycle, completion output, cause, or stopped status matters.
|
|
446
|
+
|
|
447
|
+
Use equality-aware selectors instead of repeating AsyncResult/Option unwrapping.
|
|
448
|
+
Paths and selected values are inferred from the bridge snapshot, so do not pass
|
|
449
|
+
the `DefinedStates` object:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
AtomMachine.select(machineAtom, "Ready")
|
|
453
|
+
AtomMachine.matches(machineAtom, "Ready.Saving")
|
|
454
|
+
AtomMachine.selectChild(childAtom, "Editing")
|
|
455
|
+
AtomMachine.matchesChild(childAtom, "Editing")
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Like ordinary Effect Atom combinators, each selector call returns a derived
|
|
459
|
+
atom. Define it at a stable composition boundary or memoize it when constructing
|
|
460
|
+
it inside a component.
|
|
461
|
+
|
|
462
|
+
An invoked child bridge adds an inactivity axis. Keep the descriptor stable;
|
|
463
|
+
the bridge uses Effect's `Atom.family` identity semantics:
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
const editorAtom = parentAtom.child(Editor)
|
|
467
|
+
|
|
468
|
+
editorAtom.state
|
|
469
|
+
// Atom<AsyncResult<Option<State>, StartError>>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`Option.none()` means the child is not currently active or has not become
|
|
473
|
+
active yet. A child command while inactive fails with `ChildNotActiveError`.
|
|
474
|
+
Use `AtomMachine.ChildMachineAtom<typeof Editor>` for a descriptor-based child prop,
|
|
475
|
+
or `AtomMachine.ChildOf<typeof parentAtom, typeof Editor>` to infer the exact
|
|
476
|
+
bridge from a parent.
|
|
477
|
+
|
|
478
|
+
## Persistence
|
|
479
|
+
|
|
480
|
+
Use `Machine.encodeSnapshot` and `Machine.decodeSnapshot` for validated logical
|
|
481
|
+
statechart data. Persist machine identity and an application migration/version
|
|
482
|
+
next to the encoded snapshot.
|
|
483
|
+
|
|
484
|
+
Encoding does not preserve:
|
|
485
|
+
|
|
486
|
+
- running invokes or spawned children;
|
|
487
|
+
- subscriptions, timers, or services;
|
|
488
|
+
- the machine definition;
|
|
489
|
+
- application migration metadata.
|
|
490
|
+
|
|
491
|
+
Do not treat decoding as resuming the previous process. It reconstructs logical
|
|
492
|
+
state only.
|
|
493
|
+
|
|
494
|
+
## Common compiler errors
|
|
495
|
+
|
|
496
|
+
### `initial` is not callable
|
|
497
|
+
|
|
498
|
+
Wrap the initial builder result:
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
initial: () => States.initial.Idle(new Idle({}))
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Invoked child output must be a machine event
|
|
505
|
+
|
|
506
|
+
Add the output's tagged schema to the parent machine's `internalEvents` array,
|
|
507
|
+
or map/ignore the output before it reaches the parent.
|
|
508
|
+
|
|
509
|
+
### Invoked child emits events not accepted by the parent
|
|
510
|
+
|
|
511
|
+
Add the child's emitted schemas to the parent machine's `internalEvents` array:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
events: [Submit],
|
|
515
|
+
internalEvents: [...ChildMachine.emits]
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### An internal event is rejected by `send`
|
|
519
|
+
|
|
520
|
+
This is intentional. Public input boundaries accept only schemas declared in
|
|
521
|
+
`events`. Handle the event as an invoke result, child delivery, or raised event;
|
|
522
|
+
move it to `events` only if external callers should genuinely be allowed to
|
|
523
|
+
send it.
|
|
524
|
+
|
|
525
|
+
### Public and internal event tags overlap
|
|
526
|
+
|
|
527
|
+
Give the cases distinct `_tag` values. The split is a protocol boundary, so one
|
|
528
|
+
tag cannot be both externally sendable and machine-local.
|
|
529
|
+
|
|
530
|
+
### Missing output implementation
|
|
531
|
+
|
|
532
|
+
An output schema is a runtime contract, not an optional annotation. Add the
|
|
533
|
+
corresponding nested handler:
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
Done: {
|
|
537
|
+
output: ({ state }) => state.value
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Keep `type: "final"` and `output: Schema...` in the state definition; do not
|
|
542
|
+
repeat the final marker in this handler.
|
|
543
|
+
|
|
544
|
+
### `type: "final"` is rejected by `handle`
|
|
545
|
+
|
|
546
|
+
Move it to `Machine.defineStates`. Definitions own statechart topology;
|
|
547
|
+
handlers own behavior.
|
|
548
|
+
|
|
549
|
+
### Parent property does not exist
|
|
550
|
+
|
|
551
|
+
Use its full path:
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
parents["Route.Ready"]
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Child descriptor types are unrelated
|
|
558
|
+
|
|
559
|
+
Use the descriptor exported by the module that configured `invokeMachine`.
|
|
560
|
+
An independently created descriptor with the same id and machine identity also
|
|
561
|
+
matches; the same id paired with a different machine remains a distinct child.
|
|
562
|
+
|
|
563
|
+
### Child atom start error defaults to `unknown`
|
|
564
|
+
|
|
565
|
+
`ChildMachineAtom<Child>` is suitable for a general boundary because its startup
|
|
566
|
+
error defaults to `unknown`. Atoms created with an `AtomRuntime<R, E>` include
|
|
567
|
+
`E` in their startup error type. Use `ChildOf<ParentAtom, Child>` to infer that
|
|
568
|
+
exact channel from a parent instead of restating it manually.
|
|
569
|
+
|
|
570
|
+
### Handler tree is too deeply nested
|
|
571
|
+
|
|
572
|
+
Type inference traverses at most eight nested handler objects. Split or flatten
|
|
573
|
+
a deeper statechart instead of casting away the diagnostic.
|
|
574
|
+
|
|
575
|
+
## Unsupported and intentionally imperative features
|
|
576
|
+
|
|
577
|
+
The current API does not include:
|
|
578
|
+
|
|
579
|
+
- history states;
|
|
580
|
+
- declarative first-class guards;
|
|
581
|
+
- a complete inspectable graph for arbitrary transition Effects.
|
|
582
|
+
|
|
583
|
+
Use ordinary TypeScript conditions for guards and `Machine.after` for
|
|
584
|
+
state-scoped timers. Do not invent undocumented state-node properties such as
|
|
585
|
+
`guard` or `history`.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeonce/effect-machine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Schema-first state machines and statecharts for Effect",
|
|
5
5
|
"author": "Sandro Maglione",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/typeonce-dev/effect-machine.git"
|
|
8
|
+
"url": "git+https://github.com/typeonce-dev/effect-machine.git"
|
|
9
9
|
},
|
|
10
10
|
"bugs": {
|
|
11
11
|
"url": "https://github.com/typeonce-dev/effect-machine/issues"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
+
"docs",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE",
|
|
21
22
|
"NOTICE"
|
|
@@ -39,23 +40,6 @@
|
|
|
39
40
|
"access": "public",
|
|
40
41
|
"provenance": true
|
|
41
42
|
},
|
|
42
|
-
"scripts": {
|
|
43
|
-
"build": "tsc -p tsconfig.build.json",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:types": "tstyche",
|
|
46
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
-
"format": "prettier --write .",
|
|
48
|
-
"format:check": "prettier --check .",
|
|
49
|
-
"sync:effect": "node scripts/sync-effect.mjs",
|
|
50
|
-
"sync:check": "node scripts/sync-effect.mjs --check",
|
|
51
|
-
"test:sync": "node scripts/test-sync.mjs",
|
|
52
|
-
"test:consumer": "node scripts/test-consumer.mjs",
|
|
53
|
-
"pack:check": "node scripts/pack-check.mjs",
|
|
54
|
-
"check": "pnpm format:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm test:sync && pnpm pack:check",
|
|
55
|
-
"changeset": "changeset",
|
|
56
|
-
"version-packages": "changeset version",
|
|
57
|
-
"release": "pnpm build && pnpm publish --access public --provenance"
|
|
58
|
-
},
|
|
59
43
|
"peerDependencies": {
|
|
60
44
|
"effect": "4.0.0-beta.102"
|
|
61
45
|
},
|
|
@@ -69,8 +53,21 @@
|
|
|
69
53
|
"typescript": "6.0.3",
|
|
70
54
|
"vitest": "4.1.10"
|
|
71
55
|
},
|
|
72
|
-
"packageManager": "pnpm@10.17.1",
|
|
73
56
|
"engines": {
|
|
74
57
|
"node": ">=20"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:types": "tstyche",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
64
|
+
"format": "prettier --write .",
|
|
65
|
+
"format:check": "prettier --check .",
|
|
66
|
+
"test:consumer": "node scripts/test-consumer.mjs",
|
|
67
|
+
"pack:check": "node scripts/pack-check.mjs",
|
|
68
|
+
"check": "pnpm format:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check",
|
|
69
|
+
"changeset": "changeset",
|
|
70
|
+
"version-packages": "changeset version",
|
|
71
|
+
"release": "pnpm build && changeset publish"
|
|
75
72
|
}
|
|
76
|
-
}
|
|
73
|
+
}
|