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

Replace Effectful state transition, lifecycle, choice, history, and initial
callbacks with a synchronous `(state, event) => [nextState, commands]` core.
Callbacks may enqueue only typed `raise`, `emit`, `sendTo`, and `stop`
operations; asynchronous work remains available through invoked Effects,
actors, and child machines.

Remove `Machine.action`, `Machine.runtime`, and `Machine.runActions`. Planning
now returns closed actor `commands`, while managed runtimes execute those
commands around state publication and typed emission delivery.
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ BufferReady: ;
```

All non-conflicting handlers selected together observe the same captured
snapshot. An Effectful handler keeps that value; it does not read mutable live
runtime state later. `snapshot` is intentionally absent from entry, exit,
snapshot. Handlers are synchronous and cannot read mutable live runtime state
later. `snapshot` is intentionally absent from entry, exit,
invoke, and choice contexts. In particular, startup and chained choices may run
before a complete stable snapshot containing their pseudo-source exists.

Expand All @@ -442,25 +442,25 @@ Schema-less choice and history nodes accept only descriptive `title`,
`description`, and `documentation` annotations. Titles may be used as display
labels, but structural paths remain the only identity and targeting mechanism.

## Planning Effects and staged actions
## Synchronous transitions and actor commands

An Effect returned by a transition handler is part of planning. Use it to read
services, choose a target, raise an event, or emit an event. Wrap external side
effects in `Machine.action`; actions are staged during planning and run by the
managed runtime before it publishes the next state.
Transition, entry, exit, choice, initial, and history callbacks are synchronous.
They select state and may enqueue only explicit statechart or actor operations:
raise an internal event, emit to the parent, send to an invoked child, or stop a
child. Arbitrary Effects are not accepted at this boundary.

```ts
const handlers = {
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
Save: ({ target }, enqueue) => {
enqueue.emit(new SaveRequested({}))
return target.local.Saving.from()
}
}
```

The one-argument form returns `void` after staging. The two-argument form
returns its second argument, which avoids a generator when an action and the
next target are the whole transition.

If an action fails, the runtime keeps the previously published state and
suppresses emissions from that plan.
Use `Machine.invokeEffect`, `Machine.invoke`, or an invoked child machine for
asynchronous work. Their results return to the parent as typed events, keeping
the transition core deterministic and synchronous.

`Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
`done` is `true`, `output` is the schema-derived structural terminal union;
Expand Down
40 changes: 17 additions & 23 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ class methods or nominal class identity.
transport boundary.
- Return snapshots or typed target-builder results from transitions. Do not
return raw decoded state values.
- Effects returned by handlers are planning Effects. Wrap external effects in
`Machine.action`.
- Transition and lifecycle callbacks are synchronous. Put asynchronous work in
an invoked Effect, actor, or child machine and map its result to an event.
- Put data on the narrowest state where it is valid. Put data shared by sibling
phases on their compound parent.
- Declare finality only in the state definition. Do not put `type: "final"` in
Expand Down Expand Up @@ -101,9 +101,8 @@ its extra control is required:
`Machine.childAddress<Event>(id)` for a low-level process address. An
invocation is addressable only when `Machine.invoke` receives that address
explicitly.
- Stage external effects with `Machine.action`; its optional second argument is
the same operation with a returned transition value, not a separate action
API.
- Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
`stop`. These operations record closed actor commands and do not run Effects.

## Atomic, compound, parallel, and history states

Expand Down Expand Up @@ -389,8 +388,8 @@ BufferReady: ({ snapshot, target }) =>

Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
`States.getSnapshot` helpers for cross-region reads. Parallel transitions
selected in one microstep receive the same capture. Effectful handlers retain
that captured value rather than consulting live runtime state.
selected in one microstep receive the same capture. Synchronous handlers use
that captured value and cannot consult live runtime state later.

Do not expect `snapshot` in entry, exit, invoke, initializer, history-default,
or choice contexts. Choice is an important soundness boundary: a startup or
Expand Down Expand Up @@ -423,30 +422,25 @@ it through every phase.

## Planning, actions, raised events, and emissions

A transition may return a target directly or compute it in an Effect:
A transition returns a target synchronously:

```ts
Submit: Effect.fn(function* ({ state, target }) {
const service = yield* SaveService
const canSave = yield* service.validate(state.draft)

return canSave ? target.local.Saving(new Saving({ draft: state.draft })) : undefined
})
Submit: ({ state, target }) =>
state.valid ? target.local.Saving(new Saving({ draft: state.draft })) : undefined
```

That Effect runs during planning. External side effects must be staged:
Closed statechart and actor operations use `enqueue`:

```ts
Submit: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(new Saving({})))
Submit: ({ target }, enqueue) => {
enqueue.emit(new SaveRequested({}))
return target.local.Saving(new Saving({}))
}
```

`Machine.action(effect)` stages the action and returns `void`.
`Machine.action(effect, next)` stages the same action and returns `next`, which
is convenient when the transition does not otherwise need an Effect generator.

The managed runtime executes staged actions before publishing the planned
state. If an action fails, it retains the previous state and suppresses planned
emissions.
For asynchronous validation or persistence, invoke an Effect or child machine
from the state and handle its typed success or failure event in a later
transition. This keeps `(state, event) => [nextState, commands]` synchronous.

Plans have a discriminated completion result:

Expand Down
16 changes: 7 additions & 9 deletions examples/platformer/src/machine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"
import { Schema } from "effect"

// Domain schemas are shared by state payloads and the public physics protocol.
export const Axis = Schema.Literals([-1, 0, 1])
Expand Down Expand Up @@ -280,15 +280,14 @@ export const CharacterMachine = Machine.make({
on: {
JumpPressed: {
targets: [],
transition: Effect.fn(function*({ event, runtime }) {
const machine = yield* runtime
transition: ({ event }, enqueue) => {
const push = awayFrom(event.wall)
yield* machine.raise(
enqueue.raise(
push === 0
? InternalEvent.cases.TryAirJump.make({ at: event.at })
: InternalEvent.cases.WallJump.make({ at: event.at, push })
)
})
}
},
Landed: {
targets: ["Character.locomotion.Playing.Grounded.Landing"],
Expand Down Expand Up @@ -386,11 +385,10 @@ export const CharacterMachine = Machine.make({
on: {
TryAirJump: {
targets: ["Character.locomotion.Playing.Airborne.airJump.AirJumpSpent"],
transition: Effect.fn(function*({ event, runtime, target }) {
const machine = yield* runtime
yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at }))
transition: ({ event, target }, enqueue) => {
enqueue.raise(InternalEvent.cases.DoubleJump.make({ at: event.at }))
return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({}))
})
}
}
}
},
Expand Down
39 changes: 32 additions & 7 deletions examples/pokemon/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,44 @@ class ActiveTeam extends Schema.TaggedClass<ActiveTeam>("ActiveTeam")("ActiveTea
team: Schema.Array(Pokemon)
}) {}

export const States = Machine.defineStates({ ActiveTeam })
class Loading extends Schema.TaggedClass<Loading>("Loading")("Loading", {}) {}

class TeamLoaded extends Schema.TaggedClass<TeamLoaded>("TeamLoaded")("TeamLoaded", {
team: Schema.Array(Pokemon)
}) {}

class TeamLoadFailed extends Schema.TaggedClass<TeamLoadFailed>("TeamLoadFailed")("TeamLoadFailed", {}) {}

class Failed extends Schema.TaggedClass<Failed>("Failed")("Failed", {}) {}

export const States = Machine.defineStates({ Loading, ActiveTeam, Failed })

export const SelectionChild = Machine.child("selection", SelectionMachine)
export const ReplaceChild = Machine.child("replace", ReplaceMachine)

const LoadTeam = Machine.invokeEffect({
id: "load-team",
effect: Effect.gen(function*() {
const service = yield* PokemonService
return yield* service.getRandomTeam()
}),
onSuccess: (team) => new TeamLoaded({ team }),
onFailure: () => new TeamLoadFailed({})
})

const machine = Machine.make({
states: States.states,
events: [ReplaceInTeam],
initial: Effect.fn(function*() {
const pk = yield* PokemonService
const team = yield* pk.getRandomTeam()
return States.initial.ActiveTeam(new ActiveTeam({ team }))
})
internalEvents: [TeamLoaded, TeamLoadFailed],
initial: () => States.initial.Loading(new Loading({}))
}).handle({
Loading: {
invoke: LoadTeam,
on: {
TeamLoaded: ({ event, target }) => target.full.ActiveTeam(new ActiveTeam({ team: event.team })),
TeamLoadFailed: ({ target }) => target.full.Failed(new Failed({}))
}
},
ActiveTeam: {
invoke: [Machine.invokeMachine({ child: SelectionChild }), Machine.invokeMachine({ child: ReplaceChild })],
on: {
Expand All @@ -32,7 +56,8 @@ const machine = Machine.make({
new ActiveTeam({ team: state.team.map((pokemon) => (pokemon.id === event.id ? event.pokemon : pokemon)) })
)
}
}
},
Failed: {}
})

const atomRuntime = Atom.runtime(PokemonService.layer)
Expand Down
6 changes: 4 additions & 2 deletions examples/pokemon/src/machines/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ export const ReplaceMachine = Machine.make({
Replacing: {
invoke: () => ReplaceWithRandomMachine,
on: {
Replaced: ({ event, target, emit, state }) =>
emit(new ReplaceInTeam({ id: state.id, pokemon: event.pokemon })).pipe(Effect.as(target.full.Idle(new Idle())))
Replaced: ({ event, target, state }, enqueue) => {
enqueue.emit(new ReplaceInTeam({ id: state.id, pokemon: event.pokemon }))
return target.full.Idle(new Idle())
}
}
}
})
16 changes: 7 additions & 9 deletions examples/pokemon/src/machines/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,13 @@ export const SelectionMachine = Machine.make({
states: {
WithPokemon: {
on: {
ReplacePokemon: ({ event, emit, state, target }) =>
emit(new ReplaceInTeam({ id: event.id, pokemon: state.pokemon })).pipe(
Effect.as(
target.full.form(new Form(), (form) =>
form
.search(new Search({ searchText: "" }), (search) => search.NoPokemon(new NoPokemon()))
.selection(new Selection(), (selection) => selection.Unselected(new Unselected())))
)
)
ReplacePokemon: ({ event, state, target }, enqueue) => {
enqueue.emit(new ReplaceInTeam({ id: event.id, pokemon: state.pokemon }))
return target.full.form(new Form(), (form) =>
form
.search(new Search({ searchText: "" }), (search) => search.NoPokemon(new NoPokemon()))
.selection(new Selection(), (selection) => selection.Unselected(new Unselected())))
}
}
},
Searching: {
Expand Down
34 changes: 21 additions & 13 deletions perf/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ The command reports:
- repeated child lookup and delivery to one running child;
- machine start-and-stop throughput;
- parent-with-child start-and-stop throughput;
- heap and resident-memory growth for both idle machines and idle parents with
one child, at 100, 500, and 1,000 live units.
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
a raw managed process, an idle statechart, two independent statecharts, a
parent with one child, and that relationship with child observation active;
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
with a waiting fiber, and a minimal mailbox/state/completion actor shell.

The comparison dependencies use package aliases, so XState 5 and 6 can be
loaded by the same process:
Expand All @@ -43,17 +46,22 @@ in a fresh child process so garbage from one library cannot distort another
library's baseline.

These scenarios compare observable work, not identical internals. Effect
Machine plans through an `Effect`, validates schema-backed state and events,
and its running machine provisions Effect queues, fibers, synchronization,
change publication, and child/invoke lifecycle machinery. XState's counter is
a smaller synchronous actor. Treat the comparison as an application-level
cost baseline, not a claim that the libraries provide the same runtime
guarantees.

The fitted heap slope is the primary idle-capacity metric. Resident memory is
reported as a raw diagnostic because V8 and the operating-system allocator can
reuse already committed pages. The capacity-per-GiB value is a linear estimate
that excludes shared process overhead; it is not a run-until-OOM limit.
Machine plans transitions synchronously and validates schema-backed state and
events, while its running machine provisions Effect queues, fibers,
synchronization, change publication, and child/invoke lifecycle machinery.
XState's counter is a smaller synchronous actor. Treat the comparison as an
application-level cost baseline, not a claim that the libraries provide the
same runtime guarantees.

The fitted heap slope is the primary idle-capacity metric. Compare adjacent
profiles to attribute retained memory: raw process to idle statechart isolates
statechart machinery, two independent machines to parent-with-child isolates
relationship bookkeeping, and unobserved to observed parent-child isolates
observation. The Effect profiles are primitive lower bounds, not feature-equivalent
competitors. Resident memory is reported as a raw diagnostic because V8 and the
operating-system allocator can reuse already committed pages. The
capacity-per-GiB value is a linear estimate that excludes shared process
overhead; it is not a run-until-OOM limit.

Use a shorter smoke run while changing the harness:

Expand Down
Loading