Skip to content

Commit 4dd8d24

Browse files
Fix invoke owner protocol inference (#134)
1 parent c528d3d commit 4dd8d24

13 files changed

Lines changed: 330 additions & 266 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Fix `Machine.invoke(...)` inside `.handle(...)` so invocation sources and lifecycle handlers receive the owning machine's typed `self` and `parent` protocols.
6+
7+
Event protocol examples now pass tagged unions directly to `Machine.events`, `Machine.internalEvents`, and `Machine.emittedEvents`, avoiding throwaway schema bindings.

README.md

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ const State = Schema.TaggedUnion({
5353
Running: { count: Schema.Number }
5454
})
5555

56-
const Event = Schema.TaggedUnion({
57-
Start: {},
58-
Increment: {},
59-
Stop: {}
60-
})
61-
6256
const States = Machine.defineStates(State.cases)
63-
const CounterEvent = Machine.events(Event)
57+
const CounterEvent = Machine.events(
58+
Schema.TaggedUnion({
59+
Start: {},
60+
Increment: {},
61+
Stop: {}
62+
})
63+
)
6464

6565
const CounterDefinition = Machine.make({
6666
id: "Counter",
@@ -110,13 +110,15 @@ const program = Effect.gen(function*() {
110110

111111
Use this order to preserve inference and keep boundaries explicit:
112112

113-
1. Define domain, state, public-event, internal-event, and emitted-event schemas.
114-
2. Declare topology with `Machine.defineStates`.
115-
3. Create public and internal event descriptors with `Machine.events` and
116-
`Machine.internalEvents`.
117-
4. Create the machine protocol and initializer with `Machine.make`.
118-
5. Implement every active state with `.handle(...)`.
119-
6. Add runtime, Atom, testing, or cluster adapters at the application boundary.
113+
1. Define domain schemas used by state and by shared event fields.
114+
2. Declare topology with `Machine.defineStates`, naming a tagged state union
115+
when its `.cases` are reused.
116+
3. Create event descriptors with `Machine.events`, `Machine.internalEvents`,
117+
and `Machine.emittedEvents`, passing tagged unions or tagged classes directly.
118+
4. Create the machine and implement every active state with
119+
`Machine.make({...}).handle({...})`.
120+
5. Add child descriptors, then runtime, Atom, testing, or cluster adapters at
121+
the application boundary.
120122

121123
### Construct state through builders
122124

@@ -178,19 +180,21 @@ belong in `internalEvents`. Ephemeral outward notifications have their own
178180
`emittedEvents` protocol:
179181

180182
```ts
181-
const Command = Schema.TaggedUnion({ Save: {} })
182-
const Internal = Schema.TaggedUnion({
183-
Saved: { id: Schema.String },
184-
SaveFailed: { message: Schema.String }
185-
})
186-
const Emitted = Schema.TaggedUnion({
187-
SaveObserved: { id: Schema.String }
188-
})
189-
190-
export const CommandEvent = Machine.events(Command)
183+
export const CommandEvent = Machine.events(
184+
Schema.TaggedUnion({ Save: {} })
185+
)
191186
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
192-
const InternalEvent = Machine.internalEvents(Internal)
193-
const Emissions = Machine.emittedEvents(Emitted)
187+
const InternalEvent = Machine.internalEvents(
188+
Schema.TaggedUnion({
189+
Saved: { id: Schema.String },
190+
SaveFailed: { message: Schema.String }
191+
})
192+
)
193+
const Emissions = Machine.emittedEvents(
194+
Schema.TaggedUnion({
195+
SaveObserved: { id: Schema.String }
196+
})
197+
)
194198

195199
const definition = Machine.make({
196200
states: States.states,
@@ -446,31 +450,31 @@ invoke: Machine.invoke({
446450
})
447451
```
448452

449-
The standalone `Machine.invoke(...)` constructor does not know the owning
450-
definition, so its `self` and `parent` references are non-sendable. When an
451-
invocation callback sends through either reference, construct it through the
452-
owning definition so those references use its exact public input and
453-
`parentEvents` protocols:
453+
Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's
454+
public input and `parentEvents` protocols contextually. Its source and lifecycle
455+
callbacks can send through `self` and `parent` while retaining the invoked
456+
Effect's output and error inference:
454457

455458
```ts
456-
const definition = Machine.make({
459+
const machine = Machine.make({
457460
events: Commands,
458461
internalEvents: InternalEvents,
459462
parentEvents: ParentEvents
460463
// ...
461-
})
462-
463-
const machine = definition.handle({
464+
}).handle({
464465
Saving: {
465-
invoke: definition.invoke({
466+
invoke: Machine.invoke({
466467
id: "notify-parent",
467-
effect: ({ parent }) =>
468-
parent === undefined
469-
? Effect.void
470-
: parent.send(ParentEvents.SaveStarted()),
468+
effect: () => saveDocument,
471469
onDone: Machine.transition({
472470
target: (to) => to.none(),
473-
resolve: () => undefined
471+
resolve: ({ parent, self }, enqueue) => {
472+
enqueue.sendTo(self, Commands.Save())
473+
if (parent !== undefined) {
474+
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
475+
}
476+
return undefined
477+
}
474478
}),
475479
onFailure: Machine.transition({
476480
target: (to) => to.none(),
@@ -481,6 +485,9 @@ const machine = definition.handle({
481485
})
482486
```
483487

488+
The machine-bound `definition.invoke(...)` form remains equivalent when a
489+
definition is already named; it is not required for `self` or `parent` typing.
490+
484491
A direct `invoke: { ... }` object is also supported when its lifecycle handlers
485492
do not need source-derived context. Reuse one exported
486493
`Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child

docs/agent-guide.md

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,13 @@ currently coupled to the exact Effect peer version listed in its `package.json`.
3535
Use this order so inference has all schemas available when handlers are
3636
declared:
3737

38-
1. Domain schemas used by state and event fields.
39-
2. Tagged schemas for states that own data.
40-
3. Tagged public-event, internal-event, parent-event, and emitted-event schemas.
41-
4. `Machine.defineStates`.
42-
5. `Machine.make`, including input, `events`, `internalEvents`, `parentEvents`,
43-
`emittedEvents`, and the initial function.
44-
6. One or more `.handle(...)` calls.
45-
7. Child descriptors.
46-
8. Runtime, Atom, or Cluster adapters.
38+
1. Domain schemas used by state, and by event fields when they are shared.
39+
2. `Machine.defineStates`, using a tagged state union and `.cases` when state
40+
schemas need to be reused.
41+
3. `Machine.events`, `Machine.internalEvents`, `Machine.emittedEvents`, and
42+
`parentEvents`; pass `Schema.TaggedUnion({...})` or tagged classes directly.
43+
4. `Machine.make({...}).handle({...})`.
44+
5. Child descriptors, then runtime, Atom, or Cluster adapters.
4745

4846
`Schema.TaggedUnion` avoids one class declaration per case:
4947

@@ -54,22 +52,23 @@ const State = Schema.TaggedUnion({
5452
Failed: { message: Schema.String }
5553
})
5654

57-
const Event = Schema.TaggedUnion({
58-
Save: {}
59-
})
60-
61-
const InternalEvent = Schema.TaggedUnion({
62-
Saved: { id: Schema.String },
63-
SaveFailed: { message: Schema.String }
64-
})
65-
6655
const States = Machine.defineStates(State.cases)
67-
const Events = Machine.events(Event)
68-
const InternalEvents = Machine.internalEvents(InternalEvent)
56+
export const Event = Machine.events(
57+
Schema.TaggedUnion({
58+
Save: {}
59+
})
60+
)
61+
export const Internal = Machine.internalEvents(
62+
Schema.TaggedUnion({
63+
Saved: { id: Schema.String },
64+
SaveFailed: { message: Schema.String }
65+
})
66+
)
6967
```
7068

71-
Pass these descriptors to `Machine.make` and export `Events` instead of the raw
72-
event schema. Construct new state values through the target or initial
69+
Pass these descriptors to `Machine.make`; the event descriptor is the public
70+
handle, so do not introduce a tagged-union binding used only by an event helper.
71+
Construct new state values through the target or initial
7372
builder's `.from(...)` method. Both event constructors and state `.from(...)`
7473
defer schema construction until planning, so validation failures remain typed
7574
machine errors. Use
@@ -121,9 +120,9 @@ its extra control is required:
121120
- Use one invocation object: `effect` for one-shot work, `after` for a timer,
122121
`logic` for reusable process logic, and `child` for a complete child
123122
statechart. `Machine.invoke({...})` preserves owner state and source channels
124-
across sibling lifecycle handlers. Use `definition.invoke({...})` when a
125-
callback uses `self` or `parent`; the bound constructor preserves the
126-
definition's exact public input and `parentEvents` protocols.
123+
across sibling lifecycle handlers. Inside `.handle(...)`, `self` and `parent`
124+
use the owning definition's exact public input and `parentEvents` protocols.
125+
The bound `definition.invoke({...})` form is equivalent, not required.
127126
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
128127
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
129128
invocation is addressable only when `Machine.invoke` receives that
@@ -764,8 +763,17 @@ an event for the parent. Both operations validate their schemas.
764763
union handled inside the statechart:
765764

766765
```ts
767-
const Events = Machine.events(Event)
768-
const InternalEvents = Machine.internalEvents(InternalEvent)
766+
const Events = Machine.events(
767+
Schema.TaggedUnion({
768+
Save: {}
769+
})
770+
)
771+
const InternalEvents = Machine.internalEvents(
772+
Schema.TaggedUnion({
773+
Saved: { id: Schema.String },
774+
SaveFailed: { message: Schema.String }
775+
})
776+
)
769777

770778
const definition = Machine.make({
771779
states: States.states,
@@ -871,29 +879,30 @@ invoke: Machine.invoke({
871879
})
872880
```
873881
874-
The standalone constructor cannot know the owning machine's input protocols,
875-
so its `self` and `parent` references are non-sendable. When a source sends
876-
through either reference, use the owning definition's bound constructor:
882+
Inside `.handle(...)`, the constructor receives the owning machine's public
883+
input and `parentEvents` protocols contextually. Sources and lifecycle handlers
884+
can send through `self` and `parent` without naming the definition:
877885
878886
```ts
879-
const definition = Machine.make({
887+
const machine = Machine.make({
880888
events: Commands,
881889
internalEvents: InternalEvents,
882890
parentEvents: ParentEvents,
883891
// ...
884-
})
885-
886-
const machine = definition.handle({
892+
}).handle({
887893
Saving: {
888-
invoke: definition.invoke({
894+
invoke: Machine.invoke({
889895
id: "notify-parent",
890-
effect: ({ parent }) =>
891-
parent === undefined
892-
? Effect.void
893-
: parent.send(ParentEvents.SaveStarted()),
896+
effect: () => saveDocument,
894897
onDone: Machine.transition({
895898
target: (to) => to.none(),
896-
resolve: () => undefined
899+
resolve: ({ parent, self }, enqueue) => {
900+
enqueue.sendTo(self, Commands.Save())
901+
if (parent !== undefined) {
902+
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
903+
}
904+
return undefined
905+
}
897906
}),
898907
onFailure: Machine.transition({
899908
target: (to) => to.none(),
@@ -904,8 +913,9 @@ const machine = definition.handle({
904913
})
905914
```
906915

907-
A direct `invoke: { ... }` object remains available when lifecycle handlers do
908-
not need source-derived context.
916+
The machine-bound `definition.invoke(...)` form remains equivalent when the
917+
definition is already named. A direct `invoke: { ... }` object remains available
918+
when lifecycle handlers do not need source-derived context.
909919

910920
A cancellable timer uses the same object:
911921

examples/platformer/src/machine.ts

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,30 @@ const State = Schema.TaggedUnion({
1818
})
1919

2020
// Inputs and physics facts are one runtime-decoded, statically typed protocol.
21-
const Event = Schema.TaggedUnion({
22-
Move: { axis: Axis, at: Schema.Number },
23-
JumpPressed: { at: Schema.Number, y: Schema.Number, wall: Axis },
24-
WallContact: { wall: Axis },
25-
DownPressed: { at: Schema.Number },
26-
DownReleased: { axis: Axis, at: Schema.Number },
27-
ApexReached: { y: Schema.Number },
28-
Landed: { impact: Schema.Number, axis: Axis, at: Schema.Number },
29-
Pause: { at: Schema.Number },
30-
Resume: {},
31-
Reset: {}
32-
})
33-
34-
const InternalEvent = Schema.TaggedUnion({
35-
LandingSettled: {},
36-
AirJumpUnlocked: {},
37-
TryAirJump: { at: Schema.Number },
38-
DoubleJump: { at: Schema.Number },
39-
WallJump: { at: Schema.Number, push: Axis }
40-
})
21+
export const CharacterEvents = Machine.events(
22+
Schema.TaggedUnion({
23+
Move: { axis: Axis, at: Schema.Number },
24+
JumpPressed: { at: Schema.Number, y: Schema.Number, wall: Axis },
25+
WallContact: { wall: Axis },
26+
DownPressed: { at: Schema.Number },
27+
DownReleased: { axis: Axis, at: Schema.Number },
28+
ApexReached: { y: Schema.Number },
29+
Landed: { impact: Schema.Number, axis: Axis, at: Schema.Number },
30+
Pause: { at: Schema.Number },
31+
Resume: {},
32+
Reset: {}
33+
})
34+
)
4135

42-
export const CharacterEvents = Machine.events(Event)
43-
const InternalEvents = Machine.internalEvents(InternalEvent)
36+
const InternalEvents = Machine.internalEvents(
37+
Schema.TaggedUnion({
38+
LandingSettled: {},
39+
AirJumpUnlocked: {},
40+
TryAirJump: { at: Schema.Number },
41+
DoubleJump: { at: Schema.Number },
42+
WallJump: { at: Schema.Number, push: Axis }
43+
})
44+
)
4445

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

@@ -114,7 +115,7 @@ export const CharacterStates = Machine.defineStates({
114115
}
115116
})
116117

117-
const definition = Machine.make({
118+
export const CharacterMachine = Machine.make({
118119
id: "PlatformerCharacter",
119120
states: CharacterStates.states,
120121
events: CharacterEvents,
@@ -131,9 +132,7 @@ const definition = Machine.make({
131132
.contact.from((contact) => contact.NoWall.from())
132133
)
133134
}
134-
})
135-
136-
export const CharacterMachine = definition.handle({
135+
}).handle({
137136
Character: {
138137
on: {
139138
Reset: Machine.transition({
@@ -261,7 +260,7 @@ export const CharacterMachine = definition.handle({
261260
}
262261
},
263262
Landing: {
264-
invoke: definition.invoke({
263+
invoke: Machine.invoke({
265264
id: "landing-settle",
266265
after: "140 millis",
267266
onDone: Machine.transition({

0 commit comments

Comments
 (0)