Skip to content

Commit 4d6e7b0

Browse files
Adopt synchronous machine transitions (#41)
* Adopt synchronous machine transitions * Add runtime memory attribution benchmarks
1 parent 69f1c20 commit 4d6e7b0

56 files changed

Lines changed: 2429 additions & 8149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Replace Effectful state transition, lifecycle, choice, history, and initial
6+
callbacks with a synchronous `(state, event) => [nextState, commands]` core.
7+
Callbacks may enqueue only typed `raise`, `emit`, `sendTo`, and `stop`
8+
operations; asynchronous work remains available through invoked Effects,
9+
actors, and child machines.
10+
11+
Remove `Machine.action`, `Machine.runtime`, and `Machine.runActions`. Planning
12+
now returns closed actor `commands`, while managed runtimes execute those
13+
commands around state publication and typed emission delivery.

README.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,8 @@ BufferReady: ;
423423
```
424424

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

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

445-
## Planning Effects and staged actions
445+
## Synchronous transitions and actor commands
446446

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

452452
```ts
453453
const handlers = {
454-
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
454+
Save: ({ target }, enqueue) => {
455+
enqueue.emit(new SaveRequested({}))
456+
return target.local.Saving.from()
457+
}
455458
}
456459
```
457460

458-
The one-argument form returns `void` after staging. The two-argument form
459-
returns its second argument, which avoids a generator when an action and the
460-
next target are the whole transition.
461-
462-
If an action fails, the runtime keeps the previously published state and
463-
suppresses emissions from that plan.
461+
Use `Machine.invokeEffect`, `Machine.invoke`, or an invoked child machine for
462+
asynchronous work. Their results return to the parent as typed events, keeping
463+
the transition core deterministic and synchronous.
464464

465465
`Machine.plan` and `Machine.planInitial` return a `done` discriminator. When
466466
`done` is `true`, `output` is the schema-derived structural terminal union;

docs/agent-guide.md

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ class methods or nominal class identity.
6565
transport boundary.
6666
- Return snapshots or typed target-builder results from transitions. Do not
6767
return raw decoded state values.
68-
- Effects returned by handlers are planning Effects. Wrap external effects in
69-
`Machine.action`.
68+
- Transition and lifecycle callbacks are synchronous. Put asynchronous work in
69+
an invoked Effect, actor, or child machine and map its result to an event.
7070
- Put data on the narrowest state where it is valid. Put data shared by sibling
7171
phases on their compound parent.
7272
- Declare finality only in the state definition. Do not put `type: "final"` in
@@ -101,9 +101,8 @@ its extra control is required:
101101
`Machine.childAddress<Event>(id)` for a low-level process address. An
102102
invocation is addressable only when `Machine.invoke` receives that address
103103
explicitly.
104-
- Stage external effects with `Machine.action`; its optional second argument is
105-
the same operation with a returned transition value, not a separate action
106-
API.
104+
- Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
105+
`stop`. These operations record closed actor commands and do not run Effects.
107106

108107
## Atomic, compound, parallel, and history states
109108

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

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

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

424423
## Planning, actions, raised events, and emissions
425424

426-
A transition may return a target directly or compute it in an Effect:
425+
A transition returns a target synchronously:
427426

428427
```ts
429-
Submit: Effect.fn(function* ({ state, target }) {
430-
const service = yield* SaveService
431-
const canSave = yield* service.validate(state.draft)
432-
433-
return canSave ? target.local.Saving(new Saving({ draft: state.draft })) : undefined
434-
})
428+
Submit: ({ state, target }) =>
429+
state.valid ? target.local.Saving(new Saving({ draft: state.draft })) : undefined
435430
```
436431

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

439434
```ts
440-
Submit: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(new Saving({})))
435+
Submit: ({ target }, enqueue) => {
436+
enqueue.emit(new SaveRequested({}))
437+
return target.local.Saving(new Saving({}))
438+
}
441439
```
442440

443-
`Machine.action(effect)` stages the action and returns `void`.
444-
`Machine.action(effect, next)` stages the same action and returns `next`, which
445-
is convenient when the transition does not otherwise need an Effect generator.
446-
447-
The managed runtime executes staged actions before publishing the planned
448-
state. If an action fails, it retains the previous state and suppresses planned
449-
emissions.
441+
For asynchronous validation or persistence, invoke an Effect or child machine
442+
from the state and handle its typed success or failure event in a later
443+
transition. This keeps `(state, event) => [nextState, commands]` synchronous.
450444

451445
Plans have a discriminated completion result:
452446

examples/platformer/src/machine.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Machine } from "@typeonce/effect-machine"
2-
import { Effect, Schema } from "effect"
2+
import { Schema } from "effect"
33

44
// Domain schemas are shared by state payloads and the public physics protocol.
55
export const Axis = Schema.Literals([-1, 0, 1])
@@ -280,15 +280,14 @@ export const CharacterMachine = Machine.make({
280280
on: {
281281
JumpPressed: {
282282
targets: [],
283-
transition: Effect.fn(function*({ event, runtime }) {
284-
const machine = yield* runtime
283+
transition: ({ event }, enqueue) => {
285284
const push = awayFrom(event.wall)
286-
yield* machine.raise(
285+
enqueue.raise(
287286
push === 0
288287
? InternalEvent.cases.TryAirJump.make({ at: event.at })
289288
: InternalEvent.cases.WallJump.make({ at: event.at, push })
290289
)
291-
})
290+
}
292291
},
293292
Landed: {
294293
targets: ["Character.locomotion.Playing.Grounded.Landing"],
@@ -386,11 +385,10 @@ export const CharacterMachine = Machine.make({
386385
on: {
387386
TryAirJump: {
388387
targets: ["Character.locomotion.Playing.Airborne.airJump.AirJumpSpent"],
389-
transition: Effect.fn(function*({ event, runtime, target }) {
390-
const machine = yield* runtime
391-
yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at }))
388+
transition: ({ event, target }, enqueue) => {
389+
enqueue.raise(InternalEvent.cases.DoubleJump.make({ at: event.at }))
392390
return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({}))
393-
})
391+
}
394392
}
395393
}
396394
},

examples/pokemon/src/machine.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,44 @@ class ActiveTeam extends Schema.TaggedClass<ActiveTeam>("ActiveTeam")("ActiveTea
1010
team: Schema.Array(Pokemon)
1111
}) {}
1212

13-
export const States = Machine.defineStates({ ActiveTeam })
13+
class Loading extends Schema.TaggedClass<Loading>("Loading")("Loading", {}) {}
14+
15+
class TeamLoaded extends Schema.TaggedClass<TeamLoaded>("TeamLoaded")("TeamLoaded", {
16+
team: Schema.Array(Pokemon)
17+
}) {}
18+
19+
class TeamLoadFailed extends Schema.TaggedClass<TeamLoadFailed>("TeamLoadFailed")("TeamLoadFailed", {}) {}
20+
21+
class Failed extends Schema.TaggedClass<Failed>("Failed")("Failed", {}) {}
22+
23+
export const States = Machine.defineStates({ Loading, ActiveTeam, Failed })
1424

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

28+
const LoadTeam = Machine.invokeEffect({
29+
id: "load-team",
30+
effect: Effect.gen(function*() {
31+
const service = yield* PokemonService
32+
return yield* service.getRandomTeam()
33+
}),
34+
onSuccess: (team) => new TeamLoaded({ team }),
35+
onFailure: () => new TeamLoadFailed({})
36+
})
37+
1838
const machine = Machine.make({
1939
states: States.states,
2040
events: [ReplaceInTeam],
21-
initial: Effect.fn(function*() {
22-
const pk = yield* PokemonService
23-
const team = yield* pk.getRandomTeam()
24-
return States.initial.ActiveTeam(new ActiveTeam({ team }))
25-
})
41+
internalEvents: [TeamLoaded, TeamLoadFailed],
42+
initial: () => States.initial.Loading(new Loading({}))
2643
}).handle({
44+
Loading: {
45+
invoke: LoadTeam,
46+
on: {
47+
TeamLoaded: ({ event, target }) => target.full.ActiveTeam(new ActiveTeam({ team: event.team })),
48+
TeamLoadFailed: ({ target }) => target.full.Failed(new Failed({}))
49+
}
50+
},
2751
ActiveTeam: {
2852
invoke: [Machine.invokeMachine({ child: SelectionChild }), Machine.invokeMachine({ child: ReplaceChild })],
2953
on: {
@@ -32,7 +56,8 @@ const machine = Machine.make({
3256
new ActiveTeam({ team: state.team.map((pokemon) => (pokemon.id === event.id ? event.pokemon : pokemon)) })
3357
)
3458
}
35-
}
59+
},
60+
Failed: {}
3661
})
3762

3863
const atomRuntime = Atom.runtime(PokemonService.layer)

examples/pokemon/src/machines/replace.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ export const ReplaceMachine = Machine.make({
5252
Replacing: {
5353
invoke: () => ReplaceWithRandomMachine,
5454
on: {
55-
Replaced: ({ event, target, emit, state }) =>
56-
emit(new ReplaceInTeam({ id: state.id, pokemon: event.pokemon })).pipe(Effect.as(target.full.Idle(new Idle())))
55+
Replaced: ({ event, target, state }, enqueue) => {
56+
enqueue.emit(new ReplaceInTeam({ id: state.id, pokemon: event.pokemon }))
57+
return target.full.Idle(new Idle())
58+
}
5759
}
5860
}
5961
})

examples/pokemon/src/machines/selection.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,13 @@ export const SelectionMachine = Machine.make({
110110
states: {
111111
WithPokemon: {
112112
on: {
113-
ReplacePokemon: ({ event, emit, state, target }) =>
114-
emit(new ReplaceInTeam({ id: event.id, pokemon: state.pokemon })).pipe(
115-
Effect.as(
116-
target.full.form(new Form(), (form) =>
117-
form
118-
.search(new Search({ searchText: "" }), (search) => search.NoPokemon(new NoPokemon()))
119-
.selection(new Selection(), (selection) => selection.Unselected(new Unselected())))
120-
)
121-
)
113+
ReplacePokemon: ({ event, state, target }, enqueue) => {
114+
enqueue.emit(new ReplaceInTeam({ id: event.id, pokemon: state.pokemon }))
115+
return target.full.form(new Form(), (form) =>
116+
form
117+
.search(new Search({ searchText: "" }), (search) => search.NoPokemon(new NoPokemon()))
118+
.selection(new Selection(), (selection) => selection.Unselected(new Unselected())))
119+
}
122120
}
123121
},
124122
Searching: {

perf/runtime/README.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ The command reports:
1515
- repeated child lookup and delivery to one running child;
1616
- machine start-and-stop throughput;
1717
- parent-with-child start-and-stop throughput;
18-
- heap and resident-memory growth for both idle machines and idle parents with
19-
one child, at 100, 500, and 1,000 live units.
18+
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
19+
a raw managed process, an idle statechart, two independent statecharts, a
20+
parent with one child, and that relationship with child observation active;
21+
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
22+
with a waiting fiber, and a minimal mailbox/state/completion actor shell.
2023

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

4548
These scenarios compare observable work, not identical internals. Effect
46-
Machine plans through an `Effect`, validates schema-backed state and events,
47-
and its running machine provisions Effect queues, fibers, synchronization,
48-
change publication, and child/invoke lifecycle machinery. XState's counter is
49-
a smaller synchronous actor. Treat the comparison as an application-level
50-
cost baseline, not a claim that the libraries provide the same runtime
51-
guarantees.
52-
53-
The fitted heap slope is the primary idle-capacity metric. Resident memory is
54-
reported as a raw diagnostic because V8 and the operating-system allocator can
55-
reuse already committed pages. The capacity-per-GiB value is a linear estimate
56-
that excludes shared process overhead; it is not a run-until-OOM limit.
49+
Machine plans transitions synchronously and validates schema-backed state and
50+
events, while its running machine provisions Effect queues, fibers,
51+
synchronization, change publication, and child/invoke lifecycle machinery.
52+
XState's counter is a smaller synchronous actor. Treat the comparison as an
53+
application-level cost baseline, not a claim that the libraries provide the
54+
same runtime guarantees.
55+
56+
The fitted heap slope is the primary idle-capacity metric. Compare adjacent
57+
profiles to attribute retained memory: raw process to idle statechart isolates
58+
statechart machinery, two independent machines to parent-with-child isolates
59+
relationship bookkeeping, and unobserved to observed parent-child isolates
60+
observation. The Effect profiles are primitive lower bounds, not feature-equivalent
61+
competitors. Resident memory is reported as a raw diagnostic because V8 and the
62+
operating-system allocator can reuse already committed pages. The
63+
capacity-per-GiB value is a linear estimate that excludes shared process
64+
overhead; it is not a run-until-OOM limit.
5765

5866
Use a shorter smoke run while changing the harness:
5967

0 commit comments

Comments
 (0)