Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/bright-event-protocols.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@typeonce/effect-machine": minor
---

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.

```ts
const Events = Machine.events(PublicEvent)
const InternalEvents = Machine.internalEvents(InternalEvent)

const machine = Machine.make({
states: States.states,
events: Events,
internalEvents: InternalEvents,
initial: () => States.initial.Idle.from()
})
```

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.
42 changes: 24 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ const Event = Schema.TaggedUnion({
})

const States = Machine.defineStates(State.cases)
const CounterEvent = Machine.events(Event)

const CounterDefinition = Machine.make({
id: "Counter",
states: States.states,
events: [Event],
events: CounterEvent,
initial: () => States.initial.Idle.from()
})

Expand All @@ -61,8 +62,6 @@ const Counter = CounterDefinition.handle({
}
})

const CounterEvent = Machine.events(Counter)

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

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

### Construct state through builders

Expand Down Expand Up @@ -134,22 +135,24 @@ const Internal = Schema.TaggedUnion({
SaveFailed: { message: Schema.String }
})

export const CommandEvent = Machine.events(Command)
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
const InternalEvent = Machine.internalEvents(Internal)

const definition = Machine.make({
states: States.states,
events: [Command],
internalEvents: [Internal],
events: CommandEvent,
internalEvents: InternalEvent,
initial: () => States.initial.Idle.from()
})

const CommandEvent = Machine.events(definition)
const InternalEvent = Machine.internalEvents(definition)
```

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

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

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

### Choose the target by scope

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

const trace = yield* MachineTest.run(Counter, {
events: [
Machine.event(Counter, Event.cases.Start),
Machine.event(Counter, Event.cases.Increment)
{ _tag: "Start" },
{ _tag: "Increment" }
]
})

yield* MachineTest.verify(Counter, trace)
```

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

## Entrypoints

Expand Down
3 changes: 2 additions & 1 deletion api-reference.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"decodeSnapshot",
"defineStates",
"encodeSnapshot",
"event",
"events",
"internalEvents",
"invoke",
"make",
"plan",
Expand Down
40 changes: 23 additions & 17 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ const InternalEvent = Schema.TaggedUnion({
})

const States = Machine.defineStates(State.cases)
const Events = Machine.events(Event)
const InternalEvents = Machine.internalEvents(InternalEvent)
```

After `Machine.make`, derive public constructors with `Machine.events(machine)`
and internal constructors with `Machine.internalEvents(machine)`. Construct new
state values through the target or initial builder's `.from(...)` method. Both
event constructors and state `.from(...)` defer schema construction until
planning, so validation failures remain typed machine errors. Use
Pass these descriptors to `Machine.make` and export `Events` instead of the raw
event schema. Construct new state values through the target or initial
builder's `.from(...)` method. Both event constructors and state `.from(...)`
defer schema construction until planning, so validation failures remain typed
machine errors. Use
`Schema.TaggedClass` when a case needs class methods or nominal class identity;
the deferred constructors preserve that identity after decoding.

Expand Down Expand Up @@ -223,7 +225,7 @@ const States = Machine.defineStates({

const machine = Machine.make({
states: States.states,
events: [],
events: Machine.events(),
initial: () => States.initial.Done.from()
}).handle({
Done: {
Expand Down Expand Up @@ -505,15 +507,15 @@ an event for the parent. Both operations validate their schemas.
union handled inside the statechart:

```ts
const Events = Machine.events(Event)
const InternalEvents = Machine.internalEvents(InternalEvent)

const definition = Machine.make({
states: States.states,
events: [Event],
internalEvents: [InternalEvent],
events: Events,
internalEvents: InternalEvents,
initial: () => States.initial.Idle.from()
})

const Events = Machine.events(definition)
const InternalEvents = Machine.internalEvents(definition)
```

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

Invalid constructor input fails `Machine.plan` or the running machine with
`MachineSchemaDecodeError`; creating the instruction itself never performs
schema validation. `Machine.event(machine, schema, fields?)` remains available
as an eager low-level constructor for callers that explicitly want an already
decoded value and accept synchronous failure.
schema validation. APIs that explicitly retain decoded events, such as manual
model-testing scenarios or transport messages, can receive complete event
objects directly.

An open discriminator such as `_tag: Schema.String` cannot produce named
constructors because its tag set is not finite. The schema still participates
in the protocol; pass a complete event object at the delivery boundary.

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

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

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

Add the child's emitted schemas to the parent machine's `internalEvents` array:
Create an internal descriptor from the child's emitted schemas:

```ts
events: [Submit],
internalEvents: [...ChildMachine.emits]
events: Machine.events(Submit),
internalEvents: Machine.internalEvents(...ChildMachine.emits)
```

### An internal event is rejected by `send`
Expand Down
22 changes: 12 additions & 10 deletions examples/platformer/src/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { describe, expect, it } from "vitest"
import { makeTextRenderer } from "../../../test/machine/visualization/text.ts"
import {
airJumpMode,
type CharacterEvent,
CharacterEvents,
CharacterMachine,
type CharacterSnapshot,
Event,
facingDirection,
locomotionMode,
wallContact
Expand Down Expand Up @@ -103,15 +103,17 @@ const laws = [

// Exploration scenarios retain decoded events for trace inspection.
const EventValue = {
Resume: () => Machine.event(CharacterMachine, Event.cases.Resume),
Pause: (fields: { readonly at: number }) => Machine.event(CharacterMachine, Event.cases.Pause, fields),
Reset: () => Machine.event(CharacterMachine, Event.cases.Reset),
JumpPressed: (fields: { readonly at: number; readonly y: number; readonly wall: -1 | 0 | 1 }) =>
Machine.event(CharacterMachine, Event.cases.JumpPressed, fields),
Landed: (fields: { readonly impact: number; readonly axis: -1 | 0 | 1; readonly at: number }) =>
Machine.event(CharacterMachine, Event.cases.Landed, fields),
ApexReached: (fields: { readonly y: number }) => Machine.event(CharacterMachine, Event.cases.ApexReached, fields),
DownPressed: (fields: { readonly at: number }) => Machine.event(CharacterMachine, Event.cases.DownPressed, fields)
Resume: (): CharacterEvent => ({ _tag: "Resume" }),
Pause: (fields: { readonly at: number }): CharacterEvent => ({ _tag: "Pause", ...fields }),
Reset: (): CharacterEvent => ({ _tag: "Reset" }),
JumpPressed: (
fields: { readonly at: number; readonly y: number; readonly wall: -1 | 0 | 1 }
) => ({ _tag: "JumpPressed", ...fields } as const),
Landed: (
fields: { readonly impact: number; readonly axis: -1 | 0 | 1; readonly at: number }
) => ({ _tag: "Landed", ...fields } as const),
ApexReached: (fields: { readonly y: number }) => ({ _tag: "ApexReached", ...fields } as const),
DownPressed: (fields: { readonly at: number }) => ({ _tag: "DownPressed", ...fields } as const)
}

const explorationEvents = ({ snapshot }: MachineTest.ExplorationStateContext<typeof CharacterMachine>) => {
Expand Down
12 changes: 6 additions & 6 deletions examples/platformer/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const State = Schema.TaggedUnion({
})

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

export const CharacterEvents = Machine.events(Event)
const InternalEvents = Machine.internalEvents(InternalEvent)

const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0)

export const CharacterStates = Machine.defineStates({
Expand Down Expand Up @@ -124,14 +127,11 @@ const initialCharacter = () =>
const definition = Machine.make({
id: "PlatformerCharacter",
states: CharacterStates.states,
events: [Event],
internalEvents: [InternalEvent],
events: CharacterEvents,
internalEvents: InternalEvents,
initial: initialCharacter
})

export const CharacterEvents = Machine.events(definition)
const InternalEvents = Machine.internalEvents(definition)

export const CharacterMachine = definition.handle({
Character: {
on: {
Expand Down
22 changes: 11 additions & 11 deletions examples/playground/src/examples/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@ import { assert, describe, it } from "@effect/vitest"
import { Machine } from "@typeonce/effect-machine"
import { MachineTest } from "@typeonce/effect-machine/testing"
import { Effect } from "effect"
import { MicrowaveEvent, MicrowaveMachine } from "./microwave/machine.ts"
import { MicrowaveMachine } from "./microwave/machine.ts"
import { TrafficLightMachine } from "./traffic-light/machine.ts"
import { TurnstileEvent, TurnstileMachine } from "./turnstile/machine.ts"
import { TurnstileMachine } from "./turnstile/machine.ts"
import { SharedMachine, SharedTransportEvents } from "./worker-tabs/machine.ts"

describe("playground machines", () => {
it.effect("accepts only the command enabled by the current turnstile state", () =>
Effect.gen(function*() {
const trace = yield* MachineTest.run(TurnstileMachine, {
events: [
Machine.event(TurnstileMachine, TurnstileEvent.cases.GatePushed),
Machine.event(TurnstileMachine, TurnstileEvent.cases.CoinInserted),
Machine.event(TurnstileMachine, TurnstileEvent.cases.CoinInserted),
Machine.event(TurnstileMachine, TurnstileEvent.cases.GatePushed)
{ _tag: "GatePushed" },
{ _tag: "CoinInserted" },
{ _tag: "CoinInserted" },
{ _tag: "GatePushed" }
]
})

Expand Down Expand Up @@ -46,11 +46,11 @@ describe("playground machines", () => {
Effect.gen(function*() {
const trace = yield* MachineTest.run(MicrowaveMachine, {
events: [
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed),
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.DoorOpened),
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed),
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.DoorClosed),
Machine.event(MicrowaveMachine, MicrowaveEvent.cases.PowerPressed)
{ _tag: "PowerPressed" },
{ _tag: "DoorOpened" },
{ _tag: "PowerPressed" },
{ _tag: "DoorClosed" },
{ _tag: "PowerPressed" }
]
})

Expand Down
11 changes: 5 additions & 6 deletions examples/playground/src/examples/media-player/definition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Machine } from "@typeonce/effect-machine"
import { initialAudioSettings, MediaPlayerEvent, MediaPlayerInternalEvent, MediaPlayerStates } from "./schemas.ts"
import { initialAudioSettings, MediaPlayerEvents, MediaPlayerInternalEvents, MediaPlayerStates } from "./schemas.ts"

export { MediaPlayerEvents, MediaPlayerInternalEvents } from "./schemas.ts"

const initialPlayer = () =>
MediaPlayerStates.initial.Player.from((player) =>
Expand All @@ -16,10 +18,7 @@ const initialPlayer = () =>
export const MediaPlayerDefinition = Machine.make({
id: "MediaPlayer",
states: MediaPlayerStates.states,
events: [MediaPlayerEvent],
internalEvents: [MediaPlayerInternalEvent],
events: MediaPlayerEvents,
internalEvents: MediaPlayerInternalEvents,
initial: initialPlayer
})

export const MediaPlayerEvents = Machine.events(MediaPlayerDefinition)
export const MediaPlayerInternalEvents = Machine.internalEvents(MediaPlayerDefinition)
Loading