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
7 changes: 7 additions & 0 deletions .changeset/calm-events-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine": minor
---

Add `Machine.events(machine)` and `Machine.internalEvents(machine)` as the standard way to construct protocol events.

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.
49 changes: 35 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ const Event = Schema.TaggedUnion({

const States = Machine.defineStates(State.cases)

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

const Counter = CounterDefinition.handle({
Idle: {
on: {
Start: ({ target }) => target.full.Running.from({ count: 0 })
Expand All @@ -59,10 +61,12 @@ const Counter = Machine.make({
}
})

const CounterEvent = Machine.events(Counter)

const program = Effect.gen(function*() {
const ref = yield* Machine.start(Counter)
yield* ref.send(Machine.event(Counter, Event.cases.Start))
yield* ref.send(Machine.event(Counter, Event.cases.Increment))
yield* ref.send(CounterEvent.Start())
yield* ref.send(CounterEvent.Increment())
})
```

Expand Down Expand Up @@ -130,20 +134,32 @@ const Internal = Schema.TaggedUnion({
SaveFailed: { message: Schema.String }
})

const machine = Machine.make({
const definition = Machine.make({
states: States.states,
events: [Command],
internalEvents: [Internal],
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.event(machine, schema, fields?)` for reusable machine-owned event
values. Ordinary objects and schema-constructed values are also accepted and
decoded at the machine boundary.
Use `Machine.events(machine)` and `Machine.internalEvents(machine)` as the
standard constructors for their respective protocols:

```ts
ref.send(CommandEvent.Save())
enqueue.raise(InternalEvent.Saved({ id: "entry-1" }))
```

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.

### Choose the target by scope

Expand Down Expand Up @@ -188,15 +204,15 @@ Loading: {
invoke: Machine.invokeEffect({
id: "save-document",
effect: saveDocument,
onSuccess: (entry) => Internal.cases.Saved.make({ id: entry.id }),
onFailure: (error) => Internal.cases.SaveFailed.make({ message: String(error) })
onSuccess: (entry) => InternalEvent.Saved({ id: entry.id }),
onFailure: (error) => InternalEvent.SaveFailed({ message: String(error) })
})
}

Waiting: {
invoke: Machine.after(
"3 seconds",
Internal.cases.SaveFailed.make({ message: "Timed out" })
InternalEvent.SaveFailed({ message: "Timed out" })
)
}
```
Expand Down Expand Up @@ -260,14 +276,19 @@ The testing entrypoint provides complementary layers:
import { MachineTest } from "@typeonce/effect-machine/testing"

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

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

Pure planner tests do not execute invokes or time. Use a started machine and a
probe when those semantics matter.
`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.

## Entrypoints

Expand Down
70 changes: 41 additions & 29 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ const InternalEvent = Schema.TaggedUnion({
const States = Machine.defineStates(State.cases)
```

Construct event values with `Event.cases.Save.make({})`. Construct new state
values through the target or initial builder's `.from(...)` method so schema
construction runs inside planning. Pass a state directly only when it is
already decoded. Use `Schema.TaggedClass` when a case needs class methods or
nominal class identity; `.from(...)` preserves that identity.
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
`Schema.TaggedClass` when a case needs class methods or nominal class identity;
the deferred constructors preserve that identity after decoding.

## Hard invariants

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

```ts
const machine = Machine.make({
const definition = Machine.make({
states: States.states,
events: [Event.cases.Save],
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
events: [Event],
internalEvents: [InternalEvent],
initial: () => States.initial.Idle.from()
})

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

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

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

`Machine.event` runs the configured schema constructor once. That machine and
definitions derived from it with `handle` then recognize the decoded event as
trusted and do not decode it again. Tagged-union case schemas are recognized
when their union is configured. Treat the returned event as immutable. Raw
objects and values constructed for another machine continue through normal
runtime validation on every delivery.
`Machine.events` exposes only public constructors;
`Machine.internalEvents` exposes only machine-local constructors. Both flatten
configured tagged unions and preserve tagged classes, finite discriminator
unions, required inputs, and constructor defaults. A constructor returns an
opaque instruction whose `_tag` is available for activity metadata. Its decoded
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.

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

```ts
type PublicEvent = Machine.Machine.InputEvent<typeof machine>
type AnyHandledEvent = Machine.Machine.Event<typeof machine>
type PublicEvent = Machine.Machine.InputEvent<typeof definition>
type AnyHandledEvent = Machine.Machine.Event<typeof definition>
```

`MachineRef.send`, `machineAtom.send`, and `Machine.plan` use `InputEvent` at
their TypeScript boundary. Transition handlers, raised events, invoke results,
and mapped child events use the complete `Event` union. The local planner and
runtime intentionally share the complete decoder to support those internal
deliveries, so JavaScript or `any` can bypass the local public distinction.
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
events or constructions returned by `Machine.events`. Transition handlers
receive only decoded events. Raised events, invoke results, and mapped child
events additionally accept constructions from `Machine.internalEvents`. The
local planner and runtime intentionally share the complete decoder to support
those internal deliveries, so JavaScript or `any` can bypass the local public
distinction.
Cluster RPC payloads are additionally decoded against the public `events`
schemas at the transport boundary. Never repeat an `_tag` within a list or
across both configuration lists.
Expand All @@ -548,8 +560,8 @@ invoke: ({ state }) =>
Machine.invokeEffect({
id: "save",
effect: SaveService.save(state.draft),
onSuccess: (entry) => new Saved({ entry }),
onFailure: (error) => new SaveFailed({ message: error.message })
onSuccess: (entry) => InternalEvents.Saved({ entry }),
onFailure: (error) => InternalEvents.SaveFailed({ message: error.message })
})
```

Expand All @@ -566,7 +578,7 @@ recover only expected typed failures.
A cancellable timer uses `Machine.after`:

```ts
invoke: Machine.after("3 seconds", new ClearStatus({}), {
invoke: Machine.after("3 seconds", InternalEvents.ClearStatus(), {
id: "clear-status"
})
```
Expand Down Expand Up @@ -601,7 +613,7 @@ invoke: Machine.invokeMachine({
Use `Editor` for:

```ts
Machine.sendTo(Editor, new Reset({}))
Machine.sendTo(Editor, EditorEvent.Reset())
parentRef.child(Editor)
parentAtom.child(Editor)
```
Expand Down
2 changes: 1 addition & 1 deletion scripts/fixtures/consumer/deep-bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ type Output = typeof machineAtom extends AtomMachine.MachineAtom<any, any, any,
type Failure = Atom.Failure<typeof machineAtom.result>

type StateIsExact = Expect<Equal<StateSuccess, Snapshot>>
type EventsArePublicOnly = Expect<Equal<SendEvent, typeof Event.Type>>
type EventsArePublicOnly = Expect<Equal<SendEvent, Machine.Machine.EventInput<typeof Event.Type>>>
type OutputIsExact = Expect<Equal<Output, string>>
type RuntimeErrorIsPreserved = Expect<Equal<Extract<Failure, RuntimeFailure>, RuntimeFailure>>
type FailureIsNotUnknown = Expect<Equal<unknown extends Failure ? true : false, false>>
Expand Down
Loading