Skip to content

Commit ef4286c

Browse files
Adopt synchronous machine transitions
1 parent 69f1c20 commit ef4286c

48 files changed

Lines changed: 2170 additions & 8093 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/types/composition.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,7 @@ const handled = machine.handle({
4242
Editor: {
4343
states: {
4444
Editing: {
45-
entry: () =>
46-
Effect.flatMap(
47-
CompositionService,
48-
() => Math.random() > 2 ? Effect.fail(new CompositionFailure()) : Effect.void
49-
)
45+
entry: () => {}
5046
},
5147
Done: {
5248
output: ({ state }) => state.value
@@ -81,8 +77,8 @@ const handled = machine.handle({
8177
}
8278
})
8379

84-
type ErrorIsExact = Expect<Equal<Machine.Machine.Error<typeof handled>, CompositionFailure>>
85-
type ServicesAreExact = Expect<Equal<Machine.Machine.Services<typeof handled>, CompositionService>>
80+
type ErrorIsExact = Expect<Equal<Machine.Machine.Error<typeof handled>, never>>
81+
type ServicesAreExact = Expect<Equal<Machine.Machine.Services<typeof handled>, never>>
8682
type OutputIsExact = Expect<
8783
Equal<
8884
Machine.Machine.OutputByIdentifier<typeof States.states, "App.Workspace">,
Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Machine } from "@typeonce/effect-machine"
2-
import { Context, Data, Effect, Schema } from "effect"
2+
import { Schema } from "effect"
33

44
export const Idle = Schema.TaggedStruct("Idle", { value: Schema.Number })
55
export const Done = Schema.TaggedStruct("Done", { value: Schema.String })
@@ -8,9 +8,6 @@ export const Loaded = Schema.TaggedStruct("Loaded", { value: Schema.String })
88
export const Notice = Schema.TaggedStruct("Notice", { value: Schema.String })
99
export const Input = Schema.Struct({ seed: Schema.Number })
1010

11-
export class InitialService extends Context.Service<InitialService, string>()("perf/channels/InitialService") {}
12-
export class InitialFailure extends Data.TaggedError("InitialFailure")<{}> {}
13-
1411
export const States = Machine.defineStates({
1512
Idle,
1613
Done: {
@@ -26,9 +23,5 @@ export const machine = Machine.make({
2623
internalEvents: [Loaded],
2724
emits: [Notice],
2825
input: Input,
29-
initial: (input) =>
30-
Effect.flatMap(InitialService, () =>
31-
input.seed < 0
32-
? Effect.fail(new InitialFailure())
33-
: Effect.succeed(States.initial.Idle(Idle.make({ value: input.seed }))))
26+
initial: (input) => States.initial.Idle(Idle.make({ value: input.seed }))
3427
})

perf/types/exact-channels.ts

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,21 @@
11
import { Machine } from "@typeonce/effect-machine"
2-
import { Context, Data, Effect } from "effect"
3-
import {
4-
Done,
5-
InitialFailure,
6-
InitialService,
7-
Loaded,
8-
machine,
9-
Notice,
10-
Start,
11-
States
12-
} from "./exact-channels-control.js"
2+
import { Effect } from "effect"
3+
import { Done, Loaded, machine, Notice, Start, States } from "./exact-channels-control.js"
134

145
type Equal<Left, Right> = (<Type>() => Type extends Left ? 1 : 2) extends (<Type>() => Type extends Right ? 1 : 2) ?
156
true :
167
false
178
type Expect<Value extends true> = Value
189
type IsAny<Value> = 0 extends 1 & Value ? true : false
1910

20-
export class RuntimeService extends Context.Service<RuntimeService, string>()("perf/channels/RuntimeService") {}
21-
export class ActionService extends Context.Service<ActionService, string>()("perf/channels/ActionService") {}
22-
export class RuntimeFailure extends Data.TaggedError("RuntimeFailure")<{}> {}
23-
export class ActionFailure extends Data.TaggedError("ActionFailure")<{}> {}
24-
2511
const complete = machine.handle({
2612
Idle: {
27-
entry: () =>
28-
Machine.action(
29-
Effect.flatMap(ActionService, () => Math.random() > 2 ? Effect.fail(new ActionFailure()) : Effect.void)
30-
),
13+
entry: () => {},
3114
on: {
32-
Start: ({ event, target }) =>
33-
Effect.flatMap(RuntimeService, () =>
34-
Math.random() > 2
35-
? Effect.fail(new RuntimeFailure())
36-
: Effect.succeed(target.full.Done(Done.make({ value: event.value })))),
15+
Start: ({ event, target }, enqueue) => {
16+
enqueue.emit(Notice.make({ value: event.value }))
17+
return target.full.Done(Done.make({ value: event.value }))
18+
},
3719
Loaded: ({ event, target }) => target.full.Done(Done.make({ value: event.value }))
3820
}
3921
},
@@ -46,15 +28,10 @@ type InputIsExact = Expect<Equal<Machine.Machine.Input<typeof complete>["Type"],
4628
type InputEventIsExact = Expect<Equal<Machine.Machine.InputEvent<typeof complete>, typeof Start.Type>>
4729
type EventIsExact = Expect<Equal<Machine.Machine.Event<typeof complete>, typeof Start.Type | typeof Loaded.Type>>
4830
type EmitIsExact = Expect<Equal<Machine.Machine.Emit<typeof complete>, typeof Notice.Type>>
49-
type InitialErrorIsExact = Expect<Equal<Machine.Machine.InitialError<typeof complete>, InitialFailure>>
50-
type InitialServicesAreExact = Expect<Equal<Machine.Machine.InitialServices<typeof complete>, InitialService>>
51-
type ErrorIsExact = Expect<Equal<Machine.Machine.Error<typeof complete>, RuntimeFailure>>
52-
type ServicesAreExact = Expect<
53-
Equal<
54-
Machine.Machine.Services<typeof complete>,
55-
RuntimeService | Machine.ActionRequirement<ActionFailure, ActionService>
56-
>
57-
>
31+
type InitialErrorIsExact = Expect<Equal<Machine.Machine.InitialError<typeof complete>, never>>
32+
type InitialServicesAreExact = Expect<Equal<Machine.Machine.InitialServices<typeof complete>, never>>
33+
type ErrorIsExact = Expect<Equal<Machine.Machine.Error<typeof complete>, never>>
34+
type ServicesAreExact = Expect<Equal<Machine.Machine.Services<typeof complete>, never>>
5835
type OutputIsExact = Expect<Equal<Machine.Machine.Output<typeof complete>, string>>
5936
type OutputStatesAreExact = Expect<Equal<Machine.Machine.OutputStates<typeof complete>, "Done">>
6037
type ErrorIsNotAny = Expect<Equal<IsAny<Machine.Machine.Error<typeof complete>>, false>>

0 commit comments

Comments
 (0)