Skip to content

Commit 2a84cd2

Browse files
Add prepared machine lifecycle and bound invocation typing (#115)
1 parent 19c4b2a commit 2a84cd2

15 files changed

Lines changed: 1349 additions & 167 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add `Machine.prepare` for composing snapshot and emission streams before a machine initializes, while keeping `Machine.start` as the one-step convenience.
6+
7+
```ts
8+
const prepared = yield * Machine.prepare(machine)
9+
yield * prepared.emissions.pipe(
10+
Stream.runForEach(handleEmission),
11+
Effect.forkScoped({ startImmediately: true })
12+
)
13+
const ref = yield * prepared.start
14+
```
15+
16+
AtomMachine emission streams use the same preparation boundary, and machine definitions now expose `definition.invoke(...)` so invocation `self` and `parent` references use the exact public input and `parentEvents` protocols.

README.md

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,24 @@ object to `send` or `Machine.plan` for those events.
188188
`ref.emissions` is a hot `Stream`: it publishes only notifications produced
189189
after subscription, replays nothing, and completes when the actor terminates.
190190
Snapshots remain separate and stateful: `ref.changes` begins with the current
191-
lifecycle snapshot and then follows later changes. Because `Machine.start`
192-
returns only after initialization, startup emissions are not observable from
193-
the returned ref; represent startup facts in state when they must be retained.
191+
lifecycle snapshot and then follows later changes. Use `Machine.prepare` when
192+
an observer must be installed before initial-entry actions run:
194193

195194
```ts
196-
const next = ref.emissions.pipe(Stream.take(1), Stream.runHead)
195+
const prepared = yield * Machine.prepare(machine)
196+
197+
yield * prepared.emissions.pipe(
198+
Stream.runForEach(handleEmission),
199+
Effect.forkScoped({ startImmediately: true })
200+
)
201+
202+
const ref = yield * prepared.start
197203
```
198204

205+
`Machine.start(machine)` remains the one-step convenience for callers that do
206+
not observe startup emissions. Preparation does not retain or replay an
207+
emission: the observer is simply subscribed before initialization begins.
208+
199209
Invalid event and emission constructions fail the machine with a typed
200210
`MachineSchemaDecodeError`; they do not throw from the constructor call.
201211

@@ -322,6 +332,35 @@ invoke: Machine.invoke({
322332
})
323333
```
324334

335+
The standalone `Machine.invoke(...)` constructor does not know the owning
336+
definition, so its `self` and `parent` references are non-sendable. When an
337+
invocation callback sends through either reference, construct it through the
338+
owning definition so those references use its exact public input and
339+
`parentEvents` protocols:
340+
341+
```ts
342+
const definition = Machine.make({
343+
events: Commands,
344+
internalEvents: InternalEvents,
345+
parentEvents: ParentEvents
346+
// ...
347+
})
348+
349+
const machine = definition.handle({
350+
Saving: {
351+
invoke: definition.invoke({
352+
id: "notify-parent",
353+
effect: ({ parent }) =>
354+
parent === undefined
355+
? Effect.void
356+
: parent.send(ParentEvents.SaveStarted()),
357+
onDone: ({ target }) => target.none(),
358+
onFailure: ({ target }) => target.none()
359+
})
360+
}
361+
})
362+
```
363+
325364
A direct `invoke: { ... }` object is also supported when its lifecycle handlers
326365
do not need source-derived context. Reuse one exported
327366
`Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child

docs/agent-guide.md

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,10 @@ its extra control is required:
105105
`AtomMachine.resume(machine, snapshot)` for service-free machines.
106106
- Use one invocation object: `effect` for one-shot work, `after` for a timer,
107107
`logic` for reusable process logic, and `child` for a complete child
108-
statechart. `Machine.invoke({...})` preserves owner context and source
109-
channels across sibling lifecycle handlers. Use a direct object only when its
110-
lifecycle handlers do not need source-derived context.
108+
statechart. `Machine.invoke({...})` preserves owner state and source channels
109+
across sibling lifecycle handlers. Use `definition.invoke({...})` when a
110+
callback uses `self` or `parent`; the bound constructor preserves the
111+
definition's exact public input and `parentEvents` protocols.
111112
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
112113
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
113114
invocation is addressable only when `Machine.invoke` receives that
@@ -517,8 +518,20 @@ targets an actor mailbox and is processed later. `enqueue.emit(...)` is neither:
517518
it publishes a one-off outward notification. Observe it with
518519
`ref.emissions`, a hot non-replayed `Stream` that completes with the actor.
519520
`ref.changes` is stateful and begins with the current lifecycle snapshot.
520-
Startup emissions occur before `Machine.start` returns and therefore are not
521-
visible through the returned ref; use state for facts that must be retained.
521+
Use `Machine.prepare(machine)` to obtain `changes` and `emissions` before
522+
initialization. Subscribe to the desired stream and then evaluate
523+
`prepared.start`. `Machine.start(machine)` remains the one-step convenience
524+
when startup observation is unnecessary. Emissions are still never retained or
525+
replayed; state remains the representation for facts that must be retained.
526+
527+
```ts
528+
const prepared = yield* Machine.prepare(machine)
529+
yield* prepared.emissions.pipe(
530+
Stream.runForEach(handleEmission),
531+
Effect.forkScoped({ startImmediately: true })
532+
)
533+
const ref = yield* prepared.start
534+
```
522535

523536
For child-to-parent input, export a public builder protocol and reuse it at both
524537
composition boundaries:
@@ -682,6 +695,33 @@ invoke: Machine.invoke({
682695
})
683696
```
684697
698+
The standalone constructor cannot know the owning machine's input protocols,
699+
so its `self` and `parent` references are non-sendable. When a source sends
700+
through either reference, use the owning definition's bound constructor:
701+
702+
```ts
703+
const definition = Machine.make({
704+
events: Commands,
705+
internalEvents: InternalEvents,
706+
parentEvents: ParentEvents,
707+
// ...
708+
})
709+
710+
const machine = definition.handle({
711+
Saving: {
712+
invoke: definition.invoke({
713+
id: "notify-parent",
714+
effect: ({ parent }) =>
715+
parent === undefined
716+
? Effect.void
717+
: parent.send(ParentEvents.SaveStarted()),
718+
onDone: ({ target }) => target.none(),
719+
onFailure: ({ target }) => target.none()
720+
})
721+
}
722+
})
723+
```
724+
685725
A direct `invoke: { ... }` object remains available when lifecycle handlers do
686726
not need source-derived context.
687727

@@ -729,9 +769,11 @@ parentRef.child(Editor)
729769
parentAtom.child(Editor)
730770
```
731771

732-
Child emissions are delivered through the parent's internal protocol.
733-
`onSnapshot`, `onDone`, and `onFailure` are direct parent transitions. Invoked
734-
child IDs must be unique while simultaneously active.
772+
Child emissions remain on the child's hot `emissions` stream; they are never
773+
delivered implicitly to the parent. A child sends an input explicitly with
774+
`enqueue.sendTo(parent, ParentEvents.Example())`. `onSnapshot`, `onDone`, and
775+
`onFailure` are direct parent transitions. Invoked child IDs must be unique
776+
while simultaneously active.
735777

736778
Descriptors with the same id and machine identity address the same child, even
737779
when independently constructed. The descriptor objects themselves are not

scripts/fixtures/consumer/deep-bound.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,17 @@ const States = Machine.defineStates({
8686
})
8787

8888
const Emissions = Machine.emittedEvents(Emitted.cases.Notice)
89-
const machine = Machine.make({
89+
const definition = Machine.make({
9090
states: States.states,
9191
events: Machine.events(Event.cases.Begin, Event.cases.Save, ChildParentEvents),
9292
internalEvents: Machine.internalEvents(Internal.cases.Loaded, Internal.cases.ChildCompleted),
9393
emittedEvents: Emissions,
9494
input: Schema.Struct({ seed: Schema.String }),
9595
initial: ({ seed: _seed }) => States.initial.Idle(State.cases.Idle.make({}))
96-
}).handle({
96+
})
97+
const machine = definition.handle({
9798
Idle: {
98-
invoke: Machine.invoke({
99+
invoke: definition.invoke({
99100
id: "deep-inline-invoke",
100101
effect: Effect.asVoid(ExternalService),
101102
onDone: ({ target }) => target.none()
@@ -121,7 +122,7 @@ const machine = Machine.make({
121122
}
122123
},
123124
Saving: {
124-
invoke: Machine.invoke({
125+
invoke: definition.invoke({
125126
child: Child,
126127
input: ({ state }) => ({ value: state.value }),
127128
onDone: ({ target }) => target.none()

0 commit comments

Comments
 (0)