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

Add `Machine.prepare` for composing snapshot and emission streams before a machine initializes, while keeping `Machine.start` as the one-step convenience.

```ts
const prepared = yield * Machine.prepare(machine)
yield * prepared.emissions.pipe(
Stream.runForEach(handleEmission),
Effect.forkScoped({ startImmediately: true })
)
const ref = yield * prepared.start
```

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.
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,24 @@ object to `send` or `Machine.plan` for those events.
`ref.emissions` is a hot `Stream`: it publishes only notifications produced
after subscription, replays nothing, and completes when the actor terminates.
Snapshots remain separate and stateful: `ref.changes` begins with the current
lifecycle snapshot and then follows later changes. Because `Machine.start`
returns only after initialization, startup emissions are not observable from
the returned ref; represent startup facts in state when they must be retained.
lifecycle snapshot and then follows later changes. Use `Machine.prepare` when
an observer must be installed before initial-entry actions run:

```ts
const next = ref.emissions.pipe(Stream.take(1), Stream.runHead)
const prepared = yield * Machine.prepare(machine)

yield * prepared.emissions.pipe(
Stream.runForEach(handleEmission),
Effect.forkScoped({ startImmediately: true })
)

const ref = yield * prepared.start
```

`Machine.start(machine)` remains the one-step convenience for callers that do
not observe startup emissions. Preparation does not retain or replay an
emission: the observer is simply subscribed before initialization begins.

Invalid event and emission constructions fail the machine with a typed
`MachineSchemaDecodeError`; they do not throw from the constructor call.

Expand Down Expand Up @@ -322,6 +332,35 @@ invoke: Machine.invoke({
})
```

The standalone `Machine.invoke(...)` constructor does not know the owning
definition, so its `self` and `parent` references are non-sendable. When an
invocation callback sends through either reference, construct it through the
owning definition so those references use its exact public input and
`parentEvents` protocols:

```ts
const definition = Machine.make({
events: Commands,
internalEvents: InternalEvents,
parentEvents: ParentEvents
// ...
})

const machine = definition.handle({
Saving: {
invoke: definition.invoke({
id: "notify-parent",
effect: ({ parent }) =>
parent === undefined
? Effect.void
: parent.send(ParentEvents.SaveStarted()),
onDone: ({ target }) => target.none(),
onFailure: ({ target }) => target.none()
})
}
})
```

A direct `invoke: { ... }` object is also supported when its lifecycle handlers
do not need source-derived context. Reuse one exported
`Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child
Expand Down
58 changes: 50 additions & 8 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ its extra control is required:
`AtomMachine.resume(machine, snapshot)` for service-free machines.
- Use one invocation object: `effect` for one-shot work, `after` for a timer,
`logic` for reusable process logic, and `child` for a complete child
statechart. `Machine.invoke({...})` preserves owner context and source
channels across sibling lifecycle handlers. Use a direct object only when its
lifecycle handlers do not need source-derived context.
statechart. `Machine.invoke({...})` preserves owner state and source channels
across sibling lifecycle handlers. Use `definition.invoke({...})` when a
callback uses `self` or `parent`; the bound constructor preserves the
definition's exact public input and `parentEvents` protocols.
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
invocation is addressable only when `Machine.invoke` receives that
Expand Down Expand Up @@ -517,8 +518,20 @@ targets an actor mailbox and is processed later. `enqueue.emit(...)` is neither:
it publishes a one-off outward notification. Observe it with
`ref.emissions`, a hot non-replayed `Stream` that completes with the actor.
`ref.changes` is stateful and begins with the current lifecycle snapshot.
Startup emissions occur before `Machine.start` returns and therefore are not
visible through the returned ref; use state for facts that must be retained.
Use `Machine.prepare(machine)` to obtain `changes` and `emissions` before
initialization. Subscribe to the desired stream and then evaluate
`prepared.start`. `Machine.start(machine)` remains the one-step convenience
when startup observation is unnecessary. Emissions are still never retained or
replayed; state remains the representation for facts that must be retained.

```ts
const prepared = yield* Machine.prepare(machine)
yield* prepared.emissions.pipe(
Stream.runForEach(handleEmission),
Effect.forkScoped({ startImmediately: true })
)
const ref = yield* prepared.start
```

For child-to-parent input, export a public builder protocol and reuse it at both
composition boundaries:
Expand Down Expand Up @@ -682,6 +695,33 @@ invoke: Machine.invoke({
})
```

The standalone constructor cannot know the owning machine's input protocols,
so its `self` and `parent` references are non-sendable. When a source sends
through either reference, use the owning definition's bound constructor:

```ts
const definition = Machine.make({
events: Commands,
internalEvents: InternalEvents,
parentEvents: ParentEvents,
// ...
})

const machine = definition.handle({
Saving: {
invoke: definition.invoke({
id: "notify-parent",
effect: ({ parent }) =>
parent === undefined
? Effect.void
: parent.send(ParentEvents.SaveStarted()),
onDone: ({ target }) => target.none(),
onFailure: ({ target }) => target.none()
})
}
})
```

A direct `invoke: { ... }` object remains available when lifecycle handlers do
not need source-derived context.

Expand Down Expand Up @@ -729,9 +769,11 @@ parentRef.child(Editor)
parentAtom.child(Editor)
```

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

Descriptors with the same id and machine identity address the same child, even
when independently constructed. The descriptor objects themselves are not
Expand Down
9 changes: 5 additions & 4 deletions scripts/fixtures/consumer/deep-bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,17 @@ const States = Machine.defineStates({
})

const Emissions = Machine.emittedEvents(Emitted.cases.Notice)
const machine = Machine.make({
const definition = Machine.make({
states: States.states,
events: Machine.events(Event.cases.Begin, Event.cases.Save, ChildParentEvents),
internalEvents: Machine.internalEvents(Internal.cases.Loaded, Internal.cases.ChildCompleted),
emittedEvents: Emissions,
input: Schema.Struct({ seed: Schema.String }),
initial: ({ seed: _seed }) => States.initial.Idle(State.cases.Idle.make({}))
}).handle({
})
const machine = definition.handle({
Idle: {
invoke: Machine.invoke({
invoke: definition.invoke({
id: "deep-inline-invoke",
effect: Effect.asVoid(ExternalService),
onDone: ({ target }) => target.none()
Expand All @@ -121,7 +122,7 @@ const machine = Machine.make({
}
},
Saving: {
invoke: Machine.invoke({
invoke: definition.invoke({
child: Child,
input: ({ state }) => ({ value: state.value }),
onDone: ({ target }) => target.none()
Expand Down
Loading