Skip to content

Commit d471d42

Browse files
Add machine event constructor collections (#107)
1 parent 03be11c commit d471d42

15 files changed

Lines changed: 741 additions & 82 deletions

File tree

.changeset/calm-events-build.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add `Machine.events(machine)` and `Machine.internalEvents(machine)` as the standard way to construct protocol events.
6+
7+
The returned tag-keyed constructors preserve schema make inputs and defer decoding until machine delivery, so invalid values fail with `MachineSchemaDecodeError` through planning or the running machine instead of throwing at the construction call site.

README.md

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ const Event = Schema.TaggedUnion({
4040

4141
const States = Machine.defineStates(State.cases)
4242

43-
const Counter = Machine.make({
43+
const CounterDefinition = Machine.make({
4444
id: "Counter",
4545
states: States.states,
4646
events: [Event],
4747
initial: () => States.initial.Idle.from()
48-
}).handle({
48+
})
49+
50+
const Counter = CounterDefinition.handle({
4951
Idle: {
5052
on: {
5153
Start: ({ target }) => target.full.Running.from({ count: 0 })
@@ -59,10 +61,12 @@ const Counter = Machine.make({
5961
}
6062
})
6163

64+
const CounterEvent = Machine.events(Counter)
65+
6266
const program = Effect.gen(function*() {
6367
const ref = yield* Machine.start(Counter)
64-
yield* ref.send(Machine.event(Counter, Event.cases.Start))
65-
yield* ref.send(Machine.event(Counter, Event.cases.Increment))
68+
yield* ref.send(CounterEvent.Start())
69+
yield* ref.send(CounterEvent.Increment())
6670
})
6771
```
6872

@@ -130,20 +134,32 @@ const Internal = Schema.TaggedUnion({
130134
SaveFailed: { message: Schema.String }
131135
})
132136

133-
const machine = Machine.make({
137+
const definition = Machine.make({
134138
states: States.states,
135139
events: [Command],
136140
internalEvents: [Internal],
137141
initial: () => States.initial.Idle.from()
138142
})
143+
144+
const CommandEvent = Machine.events(definition)
145+
const InternalEvent = Machine.internalEvents(definition)
139146
```
140147

141148
Handlers see both protocols. Typed `send` and `Machine.plan` accept only public
142149
events. Event tags must be unique and public/internal tags must be disjoint.
143150

144-
Use `Machine.event(machine, schema, fields?)` for reusable machine-owned event
145-
values. Ordinary objects and schema-constructed values are also accepted and
146-
decoded at the machine boundary.
151+
Use `Machine.events(machine)` and `Machine.internalEvents(machine)` as the
152+
standard constructors for their respective protocols:
153+
154+
```ts
155+
ref.send(CommandEvent.Save())
156+
enqueue.raise(InternalEvent.Saved({ id: "entry-1" }))
157+
```
158+
159+
The returned constructors preserve each schema's make input, including required
160+
fields and constructor defaults. They defer schema construction until delivery,
161+
so invalid values fail planning or the running machine with
162+
`MachineSchemaDecodeError` instead of throwing at the call site.
147163

148164
### Choose the target by scope
149165

@@ -188,15 +204,15 @@ Loading: {
188204
invoke: Machine.invokeEffect({
189205
id: "save-document",
190206
effect: saveDocument,
191-
onSuccess: (entry) => Internal.cases.Saved.make({ id: entry.id }),
192-
onFailure: (error) => Internal.cases.SaveFailed.make({ message: String(error) })
207+
onSuccess: (entry) => InternalEvent.Saved({ id: entry.id }),
208+
onFailure: (error) => InternalEvent.SaveFailed({ message: String(error) })
193209
})
194210
}
195211

196212
Waiting: {
197213
invoke: Machine.after(
198214
"3 seconds",
199-
Internal.cases.SaveFailed.make({ message: "Timed out" })
215+
InternalEvent.SaveFailed({ message: "Timed out" })
200216
)
201217
}
202218
```
@@ -260,14 +276,19 @@ The testing entrypoint provides complementary layers:
260276
import { MachineTest } from "@typeonce/effect-machine/testing"
261277

262278
const trace = yield* MachineTest.run(Counter, {
263-
events: [Event.cases.Start.make({}), Event.cases.Increment.make({})]
279+
events: [
280+
Machine.event(Counter, Event.cases.Start),
281+
Machine.event(Counter, Event.cases.Increment)
282+
]
264283
})
265284

266285
yield* MachineTest.verify(Counter, trace)
267286
```
268287

269-
Pure planner tests do not execute invokes or time. Use a started machine and a
270-
probe when those semantics matter.
288+
`MachineTest` scenarios retain decoded event values for model inspection, so
289+
this is the main case for the eager `Machine.event` API. Pure planner tests do
290+
not execute invokes or time. Use a started machine and a probe when those
291+
semantics matter.
271292

272293
## Entrypoints
273294

docs/agent-guide.md

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@ const InternalEvent = Schema.TaggedUnion({
5151
const States = Machine.defineStates(State.cases)
5252
```
5353

54-
Construct event values with `Event.cases.Save.make({})`. Construct new state
55-
values through the target or initial builder's `.from(...)` method so schema
56-
construction runs inside planning. Pass a state directly only when it is
57-
already decoded. Use `Schema.TaggedClass` when a case needs class methods or
58-
nominal class identity; `.from(...)` preserves that identity.
54+
After `Machine.make`, derive public constructors with `Machine.events(machine)`
55+
and internal constructors with `Machine.internalEvents(machine)`. Construct new
56+
state values through the target or initial builder's `.from(...)` method. Both
57+
event constructors and state `.from(...)` defer schema construction until
58+
planning, so validation failures remain typed machine errors. Use
59+
`Schema.TaggedClass` when a case needs class methods or nominal class identity;
60+
the deferred constructors preserve that identity after decoding.
5961

6062
## Hard invariants
6163

@@ -499,41 +501,51 @@ an event for the parent. Both operations validate their schemas.
499501
union handled inside the statechart:
500502

501503
```ts
502-
const machine = Machine.make({
504+
const definition = Machine.make({
503505
states: States.states,
504-
events: [Event.cases.Save],
505-
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
506+
events: [Event],
507+
internalEvents: [InternalEvent],
506508
initial: () => States.initial.Idle.from()
507509
})
510+
511+
const Events = Machine.events(definition)
512+
const InternalEvents = Machine.internalEvents(definition)
508513
```
509514

510-
When the same already-constructed event may be delivered repeatedly, construct
511-
it once through its owning machine protocol:
515+
Use the protocol-bound constructors at every machine delivery boundary:
512516

513517
```ts
514-
const save = Machine.event(machine, Event.cases.Save)
515-
yield* ref.send(save)
518+
yield* ref.send(Events.Save())
519+
enqueue.raise(InternalEvents.Saved({ id: "entry-1" }))
516520
```
517521

518-
`Machine.event` runs the configured schema constructor once. That machine and
519-
definitions derived from it with `handle` then recognize the decoded event as
520-
trusted and do not decode it again. Tagged-union case schemas are recognized
521-
when their union is configured. Treat the returned event as immutable. Raw
522-
objects and values constructed for another machine continue through normal
523-
runtime validation on every delivery.
522+
`Machine.events` exposes only public constructors;
523+
`Machine.internalEvents` exposes only machine-local constructors. Both flatten
524+
configured tagged unions and preserve tagged classes, finite discriminator
525+
unions, required inputs, and constructor defaults. A constructor returns an
526+
opaque instruction whose `_tag` is available for activity metadata. Its decoded
527+
fields are intentionally unavailable until the owning machine processes it.
528+
529+
Invalid constructor input fails `Machine.plan` or the running machine with
530+
`MachineSchemaDecodeError`; creating the instruction itself never performs
531+
schema validation. `Machine.event(machine, schema, fields?)` remains available
532+
as an eager low-level constructor for callers that explicitly want an already
533+
decoded value and accept synchronous failure.
524534

525535
Use the exported utility types when another API must preserve the boundary:
526536

527537
```ts
528-
type PublicEvent = Machine.Machine.InputEvent<typeof machine>
529-
type AnyHandledEvent = Machine.Machine.Event<typeof machine>
538+
type PublicEvent = Machine.Machine.InputEvent<typeof definition>
539+
type AnyHandledEvent = Machine.Machine.Event<typeof definition>
530540
```
531541
532-
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` use `InputEvent` at
533-
their TypeScript boundary. Transition handlers, raised events, invoke results,
534-
and mapped child events use the complete `Event` union. The local planner and
535-
runtime intentionally share the complete decoder to support those internal
536-
deliveries, so JavaScript or `any` can bypass the local public distinction.
542+
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
543+
events or constructions returned by `Machine.events`. Transition handlers
544+
receive only decoded events. Raised events, invoke results, and mapped child
545+
events additionally accept constructions from `Machine.internalEvents`. The
546+
local planner and runtime intentionally share the complete decoder to support
547+
those internal deliveries, so JavaScript or `any` can bypass the local public
548+
distinction.
537549
Cluster RPC payloads are additionally decoded against the public `events`
538550
schemas at the transport boundary. Never repeat an `_tag` within a list or
539551
across both configuration lists.
@@ -548,8 +560,8 @@ invoke: ({ state }) =>
548560
Machine.invokeEffect({
549561
id: "save",
550562
effect: SaveService.save(state.draft),
551-
onSuccess: (entry) => new Saved({ entry }),
552-
onFailure: (error) => new SaveFailed({ message: error.message })
563+
onSuccess: (entry) => InternalEvents.Saved({ entry }),
564+
onFailure: (error) => InternalEvents.SaveFailed({ message: error.message })
553565
})
554566
```
555567
@@ -566,7 +578,7 @@ recover only expected typed failures.
566578
A cancellable timer uses `Machine.after`:
567579
568580
```ts
569-
invoke: Machine.after("3 seconds", new ClearStatus({}), {
581+
invoke: Machine.after("3 seconds", InternalEvents.ClearStatus(), {
570582
id: "clear-status"
571583
})
572584
```
@@ -601,7 +613,7 @@ invoke: Machine.invokeMachine({
601613
Use `Editor` for:
602614

603615
```ts
604-
Machine.sendTo(Editor, new Reset({}))
616+
Machine.sendTo(Editor, EditorEvent.Reset())
605617
parentRef.child(Editor)
606618
parentAtom.child(Editor)
607619
```

scripts/fixtures/consumer/deep-bound.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ type Output = typeof machineAtom extends AtomMachine.MachineAtom<any, any, any,
302302
type Failure = Atom.Failure<typeof machineAtom.result>
303303

304304
type StateIsExact = Expect<Equal<StateSuccess, Snapshot>>
305-
type EventsArePublicOnly = Expect<Equal<SendEvent, typeof Event.Type>>
305+
type EventsArePublicOnly = Expect<Equal<SendEvent, Machine.Machine.EventInput<typeof Event.Type>>>
306306
type OutputIsExact = Expect<Equal<Output, string>>
307307
type RuntimeErrorIsPreserved = Expect<Equal<Extract<Failure, RuntimeFailure>, RuntimeFailure>>
308308
type FailureIsNotUnknown = Expect<Equal<unknown extends Failure ? true : false, false>>

0 commit comments

Comments
 (0)