Skip to content

Commit 62e2281

Browse files
Redesign actor event semantics (#114)
1 parent b7004c2 commit 62e2281

40 files changed

Lines changed: 1676 additions & 460 deletions

.changeset/fresh-actors-emit.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Separate actor inputs from outward notifications. Declare emissions with `Machine.emittedEvents`, publish them with `emit`, and observe the hot, non-replaying `MachineRef.emissions` stream. Children declare the public inputs they expect from their owner through `parentEvents`, then communicate explicitly with the typed, optional `parent` actor reference:
6+
7+
```ts
8+
const Emissions = Machine.emittedEvents(Progress)
9+
const ParentEvents = Machine.events(Completed)
10+
11+
const worker = Machine.make({
12+
// ...
13+
emittedEvents: Emissions,
14+
parentEvents: ParentEvents
15+
}).handle({
16+
Working: {
17+
entry: ({ parent }, enqueue) => {
18+
enqueue.emit(Emissions.Progress({ value: 0.5 }))
19+
if (parent !== undefined) {
20+
enqueue.sendTo(parent, ParentEvents.Completed({ value: 42 }))
21+
}
22+
}
23+
}
24+
})
25+
```
26+
27+
Handler contexts also expose typed `self`; invoked-child composition checks that every `parentEvents` case is accepted by the parent. This release renames structural handler ancestry to `containingState` and `ancestors`, supports zero-payload event and emission constructors with `()`, and exposes root and child emission streams through AtomMachine.

README.md

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Define schemas first, derive the state topology, then add behavior:
2525

2626
```ts
2727
import { Machine } from "@typeonce/effect-machine"
28-
import { Effect, Schema } from "effect"
28+
import { Effect, Schema, Stream } from "effect"
2929

3030
const State = Schema.TaggedUnion({
3131
Idle: {},
@@ -70,8 +70,8 @@ const program = Effect.gen(function*() {
7070
```
7171

7272
`Machine.start` returns a `MachineRef` with `send`, `state`, `snapshot`,
73-
`changes`, `join`, and `stop`. Sending enqueues an event; observe `changes` or
74-
use the testing probe when work must be causally acknowledged.
73+
`changes`, `emissions`, `join`, and `stop`. Sending enqueues an event; observe
74+
`changes` or use the testing probe when work must be causally acknowledged.
7575

7676
## Modeling workflow
7777

@@ -123,26 +123,32 @@ schema-backed paths. Add a schema later if the state starts owning data.
123123
Put data on the narrowest state where it is valid. If sibling phases share
124124
data, put it on their compound parent.
125125

126-
### Separate public and internal events
126+
### Separate inputs, raised events, and emissions
127127

128-
`events` is the public command protocol. Invoke results, timer deliveries,
129-
raised events, and child emissions belong in `internalEvents`:
128+
`events` is the public actor-input protocol. Events raised to the same machine
129+
belong in `internalEvents`. Ephemeral outward notifications have their own
130+
`emittedEvents` protocol:
130131

131132
```ts
132133
const Command = Schema.TaggedUnion({ Save: {} })
133134
const Internal = Schema.TaggedUnion({
134135
Saved: { id: Schema.String },
135136
SaveFailed: { message: Schema.String }
136137
})
138+
const Emitted = Schema.TaggedUnion({
139+
SaveObserved: { id: Schema.String }
140+
})
137141

138142
export const CommandEvent = Machine.events(Command)
139143
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
140144
const InternalEvent = Machine.internalEvents(Internal)
145+
const Emissions = Machine.emittedEvents(Emitted)
141146

142147
const definition = Machine.make({
143148
states: States.states,
144149
events: CommandEvent,
145150
internalEvents: InternalEvent,
151+
emittedEvents: Emissions,
146152
initial: () => States.initial.Idle.from()
147153
})
148154
```
@@ -157,6 +163,7 @@ events without exposing schema `.make` methods:
157163
```ts
158164
ref.send(CommandEvent.Save())
159165
enqueue.raise(InternalEvent.Saved({ id: "entry-1" }))
166+
enqueue.emit(Emissions.SaveObserved({ id: "entry-1" }))
160167
```
161168

162169
The returned constructors preserve each schema's make input, including required
@@ -167,6 +174,65 @@ Schemas with an open discriminator such as `_tag: Schema.String` remain valid
167174
protocols but cannot expose a finite constructor set; pass a complete event
168175
object to `send` or `Machine.plan` for those events.
169176

177+
`ref.emissions` is a hot `Stream`: it publishes only notifications produced
178+
after subscription, replays nothing, and completes when the actor terminates.
179+
Snapshots remain separate and stateful: `ref.changes` begins with the current
180+
lifecycle snapshot and then follows later changes. Because `Machine.start`
181+
returns only after initialization, startup emissions are not observable from
182+
the returned ref; represent startup facts in state when they must be retained.
183+
184+
```ts
185+
const next = ref.emissions.pipe(Stream.take(1), Stream.runHead)
186+
```
187+
188+
Invalid event and emission constructions fail the machine with a typed
189+
`MachineSchemaDecodeError`; they do not throw from the constructor call.
190+
191+
### Send explicitly between actors
192+
193+
`raise` targets the current machine in the same macrostep. `sendTo` targets an
194+
actor mailbox and is processed later. A child declares the subset of parent
195+
inputs it may send with `parentEvents`:
196+
197+
```ts
198+
const ParentEvents = Machine.events(ChildFinished)
199+
200+
const child = Machine.make({
201+
states: ChildStates.states,
202+
events: ChildEvents,
203+
parentEvents: ParentEvents,
204+
initial: () => ChildStates.initial.Working.from()
205+
}).handle({
206+
Working: {
207+
on: {
208+
Finish: ({ parent, target }, enqueue) => {
209+
if (parent !== undefined) {
210+
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
211+
}
212+
return target.full.Done.from()
213+
}
214+
}
215+
},
216+
Done: {}
217+
})
218+
219+
const Child = Machine.child("worker", child)
220+
const ParentInputs = Machine.events(Start, ParentEvents)
221+
```
222+
223+
The same child remains isolated and may be started as a root, where `parent` is
224+
`undefined`. When `Child` is invoked, the parent definition must accept every
225+
event in `parentEvents`; otherwise `.handle(...)` is a compile-time error.
226+
Inside the child, the parent reference accepts only those declared events.
227+
`emit` never sends to the parent: it only publishes on the emitting actor's
228+
`emissions` stream.
229+
230+
Every handler also receives `self`, which can be targeted with `sendTo` when a
231+
later mailbox turn is required. Use `raise` instead for same-macrostep work.
232+
Structural state values use distinct names: `containingState` is the immediate
233+
valued state in the same statechart, while `ancestors` maps valued ancestor
234+
paths. `parent` always means the owning actor reference.
235+
170236
### Choose the target by scope
171237

172238
| Builder | Use when | Preserves |
@@ -271,6 +337,17 @@ The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
271337
equality-aware derivations. React applications using `@effect/atom-react` need
272338
a `RegistryProvider`.
273339

340+
Emissions stay streams rather than becoming retained atom state:
341+
342+
```ts
343+
const rootEmissions = AtomMachine.emissions(counterAtom)
344+
const childEmissions = AtomMachine.childEmissions(counterAtom.child(Worker))
345+
```
346+
347+
These streams require the same `AtomRegistry`, follow the currently mounted
348+
actor instance, and do not replay notifications from an earlier subscription
349+
or child instance.
350+
274351
## Persistence
275352

276353
Logical snapshots can be validated for storage or transport:

docs/agent-guide.md

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ declared:
2222

2323
1. Domain schemas used by state and event fields.
2424
2. Tagged schemas for states that own data.
25-
3. Tagged public-event, internal-event, and emitted-event schemas.
25+
3. Tagged public-event, internal-event, parent-event, and emitted-event schemas.
2626
4. `Machine.defineStates`.
27-
5. `Machine.make`, including input, events, internal events, emits, and the
28-
initial function.
27+
5. `Machine.make`, including input, `events`, `internalEvents`, `parentEvents`,
28+
`emittedEvents`, and the initial function.
2929
6. One or more `.handle(...)` calls.
3030
7. Child descriptors.
3131
8. Runtime, Atom, or Cluster adapters.
@@ -80,17 +80,18 @@ the deferred constructors preserve that identity after decoding.
8080
a handler.
8181
- Every declared output schema needs a matching handler implementation before
8282
planning or execution.
83-
- `parents` keys are full dotted paths.
83+
- Handler `ancestors` keys are full dotted paths.
8484
- Invoke lifetimes follow state entry and exit, not the spelling of the target
8585
builder.
8686
- Handle every typed invoked Effect failure with `onFailure`. Defects and
8787
interruption terminate the owning machine.
8888
- Reuse an exported child descriptor for inline invocation, `sendTo`, and child
8989
lookup. Independently constructed descriptors are equivalent only when both
9090
their id and machine identity match.
91-
- `events` is the public input protocol. `internalEvents` contains machine-local
92-
deliveries such as raised events and invoked-child emissions. Handlers see
93-
both; typed public `send` and `Machine.plan` accept only `events`.
91+
- `events` is the public actor-input protocol. `internalEvents` contains
92+
machine-local raised events. `parentEvents` describes the public events a
93+
child may send to its owner. `emittedEvents` describes outward ephemeral
94+
notifications and is never delivered implicitly to a parent.
9495
- Event tags in `events` and `internalEvents` must be disjoint.
9596
- Event tags must also be unique within each protocol list.
9697

@@ -156,8 +157,8 @@ States.get(snapshot, "Form") // type error: no value schema
156157

157158
For a schema-less path, builders expose only `.from(...)`; the direct callable
158159
form is reserved for already-decoded schema values. Structural ancestors are
159-
also omitted from `parents`; an immediate structural parent is typed as
160-
`undefined`. Add `schema` when a state begins to own data or needs runtime
160+
also omitted from `ancestors`; an immediate structural containing state is
161+
typed as `undefined`. Add `schema` when a state begins to own data or needs runtime
161162
validation and persistence for that data.
162163

163164
Use an atomic state when no child phase can be active beneath it.
@@ -380,7 +381,7 @@ parallel builders still require a callback selecting their active child or
380381
every active region. Omitted input is normalized to `{}` and still passes
381382
through `schema.makeEffect`, including refinements.
382383

383-
## Reading state and parents
384+
## Reading state and structural ancestors
384385

385386
`Machine.defineStates` returns typed helpers:
386387

@@ -402,16 +403,18 @@ States.matches(ready, "Route.Ready.Saving")
402403

403404
All paths are checked against the definition. `get` and `getWithParents` accept
404405
only schema-backed paths; use `matches` or `getSnapshot` for any active path.
405-
`context.parent` is the immediate typed parent value (`undefined` at a root or
406-
when that parent is schema-less). `parents` contains only valued ancestors. Use
407-
its full paths when another ancestor value is needed:
406+
`context.containingState` is the immediate typed state value (`undefined` at a
407+
root or when that state is schema-less). `context.ancestors` contains only
408+
valued structural ancestors. This is separate from `context.parent`, which is
409+
the owning actor reference or `undefined` for a root actor. Use full state paths
410+
when another ancestor value is needed:
408411

409412
```ts
410-
parents["Route.Ready"]
411-
parents["Route.Ready.Editing"]
413+
ancestors["Route.Ready"]
414+
ancestors["Route.Ready.Editing"]
412415
```
413416

414-
Do not guess short properties such as `parents.Ready`.
417+
Do not guess short properties such as `ancestors.Ready`.
415418

416419
### Inspecting the full transition configuration
417420

@@ -473,11 +476,72 @@ Closed statechart and actor operations use `enqueue`:
473476

474477
```ts
475478
Submit: ({ target }, enqueue) => {
476-
enqueue.emit(new SaveRequested({}))
479+
enqueue.emit(Emissions.SaveRequested())
477480
return target.local.Saving.from()
478481
}
479482
```
480483

484+
Declare emission constructors separately from actor inputs:
485+
486+
```ts
487+
const Emissions = Machine.emittedEvents(SaveRequested, AuditRecorded)
488+
489+
const definition = Machine.make({
490+
events: Commands,
491+
internalEvents: InternalEvents,
492+
emittedEvents: Emissions,
493+
// ...
494+
})
495+
```
496+
497+
`enqueue.raise(...)` is a same-macrostep input to self. `enqueue.sendTo(...)`
498+
targets an actor mailbox and is processed later. `enqueue.emit(...)` is neither:
499+
it publishes a one-off outward notification. Observe it with
500+
`ref.emissions`, a hot non-replayed `Stream` that completes with the actor.
501+
`ref.changes` is stateful and begins with the current lifecycle snapshot.
502+
Startup emissions occur before `Machine.start` returns and therefore are not
503+
visible through the returned ref; use state for facts that must be retained.
504+
505+
For child-to-parent input, export a public builder protocol and reuse it at both
506+
composition boundaries:
507+
508+
```ts
509+
export const ParentEvents = Machine.events(ChildFinished)
510+
511+
const child = Machine.make({
512+
events: ChildEvents,
513+
parentEvents: ParentEvents,
514+
// ...
515+
}).handle({
516+
Working: {
517+
on: {
518+
Finish: ({ parent }, enqueue) => {
519+
if (parent !== undefined) {
520+
enqueue.sendTo(parent, ParentEvents.ChildFinished())
521+
}
522+
}
523+
}
524+
}
525+
})
526+
527+
const parent = Machine.make({
528+
events: Machine.events(ParentCommands, ParentEvents),
529+
// ...
530+
})
531+
```
532+
533+
Invoking the child under a parent that lacks any required `parentEvents` case
534+
is a type error. Within child handlers, `parent` accepts only that protocol.
535+
The same child may run as a root, where `parent` is `undefined`. `self` accepts
536+
the machine's public inputs. Neither actor reference is a structural state
537+
value; use `containingState` and `ancestors` for statechart ancestry.
538+
539+
Atom-backed actors retain the same transient semantics. Use
540+
`AtomMachine.emissions(machineAtom)` for a root and
541+
`AtomMachine.childEmissions(childAtom)` for the currently active child. Both
542+
return streams requiring the corresponding `AtomRegistry`; emissions are not
543+
stored as atom state.
544+
481545
For asynchronous validation or persistence, invoke an Effect or child machine
482546
from the state and handle its typed success or failure event in a later
483547
transition. This keeps `(state, event) => [nextState, commands]` synchronous.
@@ -551,8 +615,9 @@ type AnyHandledEvent = Machine.Machine.Event<typeof definition>
551615
552616
`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
553617
events or constructions returned by `Machine.events`. Transition handlers
554-
receive only decoded events. Raised events and child emissions additionally
555-
accept constructions from `Machine.internalEvents`. The
618+
receive only decoded events. Raised events additionally accept constructions
619+
from `Machine.internalEvents`; outward notifications accept constructions from
620+
`Machine.emittedEvents`. The
556621
local planner and runtime intentionally share the complete decoder to support
557622
those internal deliveries, so JavaScript or `any` can bypass the local public
558623
distinction.
@@ -582,11 +647,11 @@ self-interrupts fails the parent. `onDone` is required when the output is not
582647
are forbidden when their channel is `never`.
583648
584649
The source may also be a function of the owning state's entry context when it
585-
needs `state`, `parent`, `parents`, or the entry `event`. Source construction
650+
needs `state`, `containingState`, `ancestors`, or the entry `event`. Source construction
586651
errors, defects, and interruption are machine failures rather than a second
587652
phase in `onFailure`.
588653
589-
When a source function reads `state`, `parent`, `parents`, or the entry `event`,
654+
When a source function reads `state`, `containingState`, `ancestors`, or the entry `event`,
590655
`Machine.invoke` infers that owner context and the returned Effect's output,
591656
error, and service channels together. No return annotation is needed:
592657
@@ -955,13 +1020,19 @@ Wrap the initial builder result:
9551020
initial: () => States.initial.Idle.from()
9561021
```
9571022

958-
### Invoked child emits events not accepted by the parent
1023+
### Invoked child expects events not accepted by the parent
9591024

960-
Create an internal descriptor from the child's emitted schemas:
1025+
Export one parent-event protocol from the child boundary and compose it into
1026+
the parent's public events:
9611027

9621028
```ts
963-
events: Machine.events(Submit),
964-
internalEvents: Machine.internalEvents(...ChildMachine.emits)
1029+
export const ChildParentEvents = Machine.events(ChildFinished)
1030+
1031+
// child
1032+
parentEvents: ChildParentEvents
1033+
1034+
// parent
1035+
events: Machine.events(Submit, ChildParentEvents)
9651036
```
9661037

9671038
### An internal event is rejected by `send`
@@ -996,10 +1067,10 @@ handlers own behavior.
9961067

9971068
### Parent property does not exist
9981069

999-
Use its full path:
1070+
Use the structural ancestor's full path:
10001071

10011072
```ts
1002-
parents["Route.Ready"]
1073+
ancestors["Route.Ready"]
10031074
```
10041075

10051076
### Child descriptor types are unrelated

0 commit comments

Comments
 (0)