Skip to content

Commit b7004c2

Browse files
Add definition-time event protocol descriptors (#111)
1 parent ccc398a commit b7004c2

97 files changed

Lines changed: 974 additions & 848 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Make `Machine.events` and `Machine.internalEvents` definition-time protocol descriptors that are passed directly to `Machine.make`. The descriptors expose type-safe deferred constructors while retaining their schemas privately, so applications can export the event API without exporting schemas or reaching for throwing schema `.make` methods.
6+
7+
```ts
8+
const Events = Machine.events(PublicEvent)
9+
const InternalEvents = Machine.internalEvents(InternalEvent)
10+
11+
const machine = Machine.make({
12+
states: States.states,
13+
events: Events,
14+
internalEvents: InternalEvents,
15+
initial: () => States.initial.Idle.from()
16+
})
17+
```
18+
19+
Remove the eager schema-based `Machine.event` constructor. Pass complete decoded event objects directly to APIs that intentionally retain values, such as manual model-testing scenarios or transport messages.

README.md

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ const Event = Schema.TaggedUnion({
3939
})
4040

4141
const States = Machine.defineStates(State.cases)
42+
const CounterEvent = Machine.events(Event)
4243

4344
const CounterDefinition = Machine.make({
4445
id: "Counter",
4546
states: States.states,
46-
events: [Event],
47+
events: CounterEvent,
4748
initial: () => States.initial.Idle.from()
4849
})
4950

@@ -61,8 +62,6 @@ const Counter = CounterDefinition.handle({
6162
}
6263
})
6364

64-
const CounterEvent = Machine.events(Counter)
65-
6665
const program = Effect.gen(function*() {
6766
const ref = yield* Machine.start(Counter)
6867
yield* ref.send(CounterEvent.Start())
@@ -80,9 +79,11 @@ Use this order to preserve inference and keep boundaries explicit:
8079

8180
1. Define domain, state, public-event, internal-event, and emitted-event schemas.
8281
2. Declare topology with `Machine.defineStates`.
83-
3. Create the protocol and initializer with `Machine.make`.
84-
4. Implement every active state with `.handle(...)`.
85-
5. Add runtime, Atom, testing, or cluster adapters at the application boundary.
82+
3. Create public and internal event descriptors with `Machine.events` and
83+
`Machine.internalEvents`.
84+
4. Create the machine protocol and initializer with `Machine.make`.
85+
5. Implement every active state with `.handle(...)`.
86+
6. Add runtime, Atom, testing, or cluster adapters at the application boundary.
8687

8788
### Construct state through builders
8889

@@ -134,22 +135,24 @@ const Internal = Schema.TaggedUnion({
134135
SaveFailed: { message: Schema.String }
135136
})
136137

138+
export const CommandEvent = Machine.events(Command)
139+
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
140+
const InternalEvent = Machine.internalEvents(Internal)
141+
137142
const definition = Machine.make({
138143
states: States.states,
139-
events: [Command],
140-
internalEvents: [Internal],
144+
events: CommandEvent,
145+
internalEvents: InternalEvent,
141146
initial: () => States.initial.Idle.from()
142147
})
143-
144-
const CommandEvent = Machine.events(definition)
145-
const InternalEvent = Machine.internalEvents(definition)
146148
```
147149

148150
Handlers see both protocols. Typed `send` and `Machine.plan` accept only public
149151
events. Event tags must be unique and public/internal tags must be disjoint.
150152

151-
Use `Machine.events(machine)` and `Machine.internalEvents(machine)` as the
152-
standard constructors for their respective protocols:
153+
Export the descriptor returned by `Machine.events` instead of exporting its
154+
schemas. This keeps the deferred constructors as the standard way to create
155+
events without exposing schema `.make` methods:
153156

154157
```ts
155158
ref.send(CommandEvent.Save())
@@ -160,6 +163,9 @@ The returned constructors preserve each schema's make input, including required
160163
fields and constructor defaults. They defer schema construction until delivery,
161164
so invalid values fail planning or the running machine with
162165
`MachineSchemaDecodeError` instead of throwing at the call site.
166+
Schemas with an open discriminator such as `_tag: Schema.String` remain valid
167+
protocols but cannot expose a finite constructor set; pass a complete event
168+
object to `send` or `Machine.plan` for those events.
163169

164170
### Choose the target by scope
165171

@@ -296,18 +302,18 @@ import { MachineTest } from "@typeonce/effect-machine/testing"
296302

297303
const trace = yield* MachineTest.run(Counter, {
298304
events: [
299-
Machine.event(Counter, Event.cases.Start),
300-
Machine.event(Counter, Event.cases.Increment)
305+
{ _tag: "Start" },
306+
{ _tag: "Increment" }
301307
]
302308
})
303309

304310
yield* MachineTest.verify(Counter, trace)
305311
```
306312

307313
`MachineTest` scenarios retain decoded event values for model inspection, so
308-
this is the main case for the eager `Machine.event` API. Pure planner tests do
309-
not execute invokes or time. Use a started machine and a probe when those
310-
semantics matter.
314+
pass complete decoded objects when defining scenarios manually. Pure planner
315+
tests do not execute invokes or time. Use a started machine and a probe when
316+
those semantics matter.
311317

312318
## Entrypoints
313319

api-reference.config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"decodeSnapshot",
3030
"defineStates",
3131
"encodeSnapshot",
32-
"event",
32+
"events",
33+
"internalEvents",
3334
"invoke",
3435
"make",
3536
"plan",

docs/agent-guide.md

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@ const InternalEvent = Schema.TaggedUnion({
4949
})
5050

5151
const States = Machine.defineStates(State.cases)
52+
const Events = Machine.events(Event)
53+
const InternalEvents = Machine.internalEvents(InternalEvent)
5254
```
5355

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
56+
Pass these descriptors to `Machine.make` and export `Events` instead of the raw
57+
event schema. Construct new state values through the target or initial
58+
builder's `.from(...)` method. Both event constructors and state `.from(...)`
59+
defer schema construction until planning, so validation failures remain typed
60+
machine errors. Use
5961
`Schema.TaggedClass` when a case needs class methods or nominal class identity;
6062
the deferred constructors preserve that identity after decoding.
6163

@@ -223,7 +225,7 @@ const States = Machine.defineStates({
223225

224226
const machine = Machine.make({
225227
states: States.states,
226-
events: [],
228+
events: Machine.events(),
227229
initial: () => States.initial.Done.from()
228230
}).handle({
229231
Done: {
@@ -505,15 +507,15 @@ an event for the parent. Both operations validate their schemas.
505507
union handled inside the statechart:
506508

507509
```ts
510+
const Events = Machine.events(Event)
511+
const InternalEvents = Machine.internalEvents(InternalEvent)
512+
508513
const definition = Machine.make({
509514
states: States.states,
510-
events: [Event],
511-
internalEvents: [InternalEvent],
515+
events: Events,
516+
internalEvents: InternalEvents,
512517
initial: () => States.initial.Idle.from()
513518
})
514-
515-
const Events = Machine.events(definition)
516-
const InternalEvents = Machine.internalEvents(definition)
517519
```
518520

519521
Use the protocol-bound constructors at every machine delivery boundary:
@@ -532,9 +534,13 @@ fields are intentionally unavailable until the owning machine processes it.
532534

533535
Invalid constructor input fails `Machine.plan` or the running machine with
534536
`MachineSchemaDecodeError`; creating the instruction itself never performs
535-
schema validation. `Machine.event(machine, schema, fields?)` remains available
536-
as an eager low-level constructor for callers that explicitly want an already
537-
decoded value and accept synchronous failure.
537+
schema validation. APIs that explicitly retain decoded events, such as manual
538+
model-testing scenarios or transport messages, can receive complete event
539+
objects directly.
540+
541+
An open discriminator such as `_tag: Schema.String` cannot produce named
542+
constructors because its tag set is not finite. The schema still participates
543+
in the protocol; pass a complete event object at the delivery boundary.
538544

539545
Use the exported utility types when another API must preserve the boundary:
540546

@@ -951,11 +957,11 @@ initial: () => States.initial.Idle.from()
951957

952958
### Invoked child emits events not accepted by the parent
953959

954-
Add the child's emitted schemas to the parent machine's `internalEvents` array:
960+
Create an internal descriptor from the child's emitted schemas:
955961

956962
```ts
957-
events: [Submit],
958-
internalEvents: [...ChildMachine.emits]
963+
events: Machine.events(Submit),
964+
internalEvents: Machine.internalEvents(...ChildMachine.emits)
959965
```
960966

961967
### An internal event is rejected by `send`

examples/platformer/src/machine.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import { describe, expect, it } from "vitest"
55
import { makeTextRenderer } from "../../../test/machine/visualization/text.ts"
66
import {
77
airJumpMode,
8+
type CharacterEvent,
89
CharacterEvents,
910
CharacterMachine,
1011
type CharacterSnapshot,
11-
Event,
1212
facingDirection,
1313
locomotionMode,
1414
wallContact
@@ -103,15 +103,17 @@ const laws = [
103103

104104
// Exploration scenarios retain decoded events for trace inspection.
105105
const EventValue = {
106-
Resume: () => Machine.event(CharacterMachine, Event.cases.Resume),
107-
Pause: (fields: { readonly at: number }) => Machine.event(CharacterMachine, Event.cases.Pause, fields),
108-
Reset: () => Machine.event(CharacterMachine, Event.cases.Reset),
109-
JumpPressed: (fields: { readonly at: number; readonly y: number; readonly wall: -1 | 0 | 1 }) =>
110-
Machine.event(CharacterMachine, Event.cases.JumpPressed, fields),
111-
Landed: (fields: { readonly impact: number; readonly axis: -1 | 0 | 1; readonly at: number }) =>
112-
Machine.event(CharacterMachine, Event.cases.Landed, fields),
113-
ApexReached: (fields: { readonly y: number }) => Machine.event(CharacterMachine, Event.cases.ApexReached, fields),
114-
DownPressed: (fields: { readonly at: number }) => Machine.event(CharacterMachine, Event.cases.DownPressed, fields)
106+
Resume: (): CharacterEvent => ({ _tag: "Resume" }),
107+
Pause: (fields: { readonly at: number }): CharacterEvent => ({ _tag: "Pause", ...fields }),
108+
Reset: (): CharacterEvent => ({ _tag: "Reset" }),
109+
JumpPressed: (
110+
fields: { readonly at: number; readonly y: number; readonly wall: -1 | 0 | 1 }
111+
) => ({ _tag: "JumpPressed", ...fields } as const),
112+
Landed: (
113+
fields: { readonly impact: number; readonly axis: -1 | 0 | 1; readonly at: number }
114+
) => ({ _tag: "Landed", ...fields } as const),
115+
ApexReached: (fields: { readonly y: number }) => ({ _tag: "ApexReached", ...fields } as const),
116+
DownPressed: (fields: { readonly at: number }) => ({ _tag: "DownPressed", ...fields } as const)
115117
}
116118

117119
const explorationEvents = ({ snapshot }: MachineTest.ExplorationStateContext<typeof CharacterMachine>) => {

examples/platformer/src/machine.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const State = Schema.TaggedUnion({
1818
})
1919

2020
// Inputs and physics facts are one runtime-decoded, statically typed protocol.
21-
export const Event = Schema.TaggedUnion({
21+
const Event = Schema.TaggedUnion({
2222
Move: { axis: Axis, at: Schema.Number },
2323
JumpPressed: { at: Schema.Number, y: Schema.Number, wall: Axis },
2424
WallContact: { wall: Axis },
@@ -39,6 +39,9 @@ const InternalEvent = Schema.TaggedUnion({
3939
WallJump: { at: Schema.Number, push: Axis }
4040
})
4141

42+
export const CharacterEvents = Machine.events(Event)
43+
const InternalEvents = Machine.internalEvents(InternalEvent)
44+
4245
const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0)
4346

4447
export const CharacterStates = Machine.defineStates({
@@ -124,14 +127,11 @@ const initialCharacter = () =>
124127
const definition = Machine.make({
125128
id: "PlatformerCharacter",
126129
states: CharacterStates.states,
127-
events: [Event],
128-
internalEvents: [InternalEvent],
130+
events: CharacterEvents,
131+
internalEvents: InternalEvents,
129132
initial: initialCharacter
130133
})
131134

132-
export const CharacterEvents = Machine.events(definition)
133-
const InternalEvents = Machine.internalEvents(definition)
134-
135135
export const CharacterMachine = definition.handle({
136136
Character: {
137137
on: {

examples/playground/src/examples/examples.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@ import { assert, describe, it } from "@effect/vitest"
22
import { Machine } from "@typeonce/effect-machine"
33
import { MachineTest } from "@typeonce/effect-machine/testing"
44
import { Effect } from "effect"
5-
import { MicrowaveEvent, MicrowaveMachine } from "./microwave/machine.ts"
5+
import { MicrowaveMachine } from "./microwave/machine.ts"
66
import { TrafficLightMachine } from "./traffic-light/machine.ts"
7-
import { TurnstileEvent, TurnstileMachine } from "./turnstile/machine.ts"
7+
import { TurnstileMachine } from "./turnstile/machine.ts"
88
import { SharedMachine, SharedTransportEvents } from "./worker-tabs/machine.ts"
99

1010
describe("playground machines", () => {
1111
it.effect("accepts only the command enabled by the current turnstile state", () =>
1212
Effect.gen(function*() {
1313
const trace = yield* MachineTest.run(TurnstileMachine, {
1414
events: [
15-
Machine.event(TurnstileMachine, TurnstileEvent.cases.GatePushed),
16-
Machine.event(TurnstileMachine, TurnstileEvent.cases.CoinInserted),
17-
Machine.event(TurnstileMachine, TurnstileEvent.cases.CoinInserted),
18-
Machine.event(TurnstileMachine, TurnstileEvent.cases.GatePushed)
15+
{ _tag: "GatePushed" },
16+
{ _tag: "CoinInserted" },
17+
{ _tag: "CoinInserted" },
18+
{ _tag: "GatePushed" }
1919
]
2020
})
2121

@@ -46,11 +46,11 @@ describe("playground machines", () => {
4646
Effect.gen(function*() {
4747
const trace = yield* MachineTest.run(MicrowaveMachine, {
4848
events: [
49-
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed),
50-
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.DoorOpened),
51-
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed),
52-
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.DoorClosed),
53-
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed)
49+
{ _tag: "PowerPressed" },
50+
{ _tag: "DoorOpened" },
51+
{ _tag: "PowerPressed" },
52+
{ _tag: "DoorClosed" },
53+
{ _tag: "PowerPressed" }
5454
]
5555
})
5656

examples/playground/src/examples/media-player/definition.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Machine } from "@typeonce/effect-machine"
2-
import { initialAudioSettings, MediaPlayerEvent, MediaPlayerInternalEvent, MediaPlayerStates } from "./schemas.ts"
2+
import { initialAudioSettings, MediaPlayerEvents, MediaPlayerInternalEvents, MediaPlayerStates } from "./schemas.ts"
3+
4+
export { MediaPlayerEvents, MediaPlayerInternalEvents } from "./schemas.ts"
35

46
const initialPlayer = () =>
57
MediaPlayerStates.initial.Player.from((player) =>
@@ -16,10 +18,7 @@ const initialPlayer = () =>
1618
export const MediaPlayerDefinition = Machine.make({
1719
id: "MediaPlayer",
1820
states: MediaPlayerStates.states,
19-
events: [MediaPlayerEvent],
20-
internalEvents: [MediaPlayerInternalEvent],
21+
events: MediaPlayerEvents,
22+
internalEvents: MediaPlayerInternalEvents,
2123
initial: initialPlayer
2224
})
23-
24-
export const MediaPlayerEvents = Machine.events(MediaPlayerDefinition)
25-
export const MediaPlayerInternalEvents = Machine.internalEvents(MediaPlayerDefinition)

0 commit comments

Comments
 (0)