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

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:

```ts
const Emissions = Machine.emittedEvents(Progress)
const ParentEvents = Machine.events(Completed)

const worker = Machine.make({
// ...
emittedEvents: Emissions,
parentEvents: ParentEvents
}).handle({
Working: {
entry: ({ parent }, enqueue) => {
enqueue.emit(Emissions.Progress({ value: 0.5 }))
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.Completed({ value: 42 }))
}
}
}
})
```

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.
89 changes: 83 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Define schemas first, derive the state topology, then add behavior:

```ts
import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"

const State = Schema.TaggedUnion({
Idle: {},
Expand Down Expand Up @@ -70,8 +70,8 @@ const program = Effect.gen(function*() {
```

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

## Modeling workflow

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

### Separate public and internal events
### Separate inputs, raised events, and emissions

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

```ts
const Command = Schema.TaggedUnion({ Save: {} })
const Internal = Schema.TaggedUnion({
Saved: { id: Schema.String },
SaveFailed: { message: Schema.String }
})
const Emitted = Schema.TaggedUnion({
SaveObserved: { id: Schema.String }
})

export const CommandEvent = Machine.events(Command)
export type PublicCommandEvent = Machine.EventOf<typeof CommandEvent>
const InternalEvent = Machine.internalEvents(Internal)
const Emissions = Machine.emittedEvents(Emitted)

const definition = Machine.make({
states: States.states,
events: CommandEvent,
internalEvents: InternalEvent,
emittedEvents: Emissions,
initial: () => States.initial.Idle.from()
})
```
Expand All @@ -157,6 +163,7 @@ events without exposing schema `.make` methods:
```ts
ref.send(CommandEvent.Save())
enqueue.raise(InternalEvent.Saved({ id: "entry-1" }))
enqueue.emit(Emissions.SaveObserved({ id: "entry-1" }))
```

The returned constructors preserve each schema's make input, including required
Expand All @@ -167,6 +174,65 @@ Schemas with an open discriminator such as `_tag: Schema.String` remain valid
protocols but cannot expose a finite constructor set; pass a complete event
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.

```ts
const next = ref.emissions.pipe(Stream.take(1), Stream.runHead)
```

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

### Send explicitly between actors

`raise` targets the current machine in the same macrostep. `sendTo` targets an
actor mailbox and is processed later. A child declares the subset of parent
inputs it may send with `parentEvents`:

```ts
const ParentEvents = Machine.events(ChildFinished)

const child = Machine.make({
states: ChildStates.states,
events: ChildEvents,
parentEvents: ParentEvents,
initial: () => ChildStates.initial.Working.from()
}).handle({
Working: {
on: {
Finish: ({ parent, target }, enqueue) => {
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
}
return target.full.Done.from()
}
}
},
Done: {}
})

const Child = Machine.child("worker", child)
const ParentInputs = Machine.events(Start, ParentEvents)
```

The same child remains isolated and may be started as a root, where `parent` is
`undefined`. When `Child` is invoked, the parent definition must accept every
event in `parentEvents`; otherwise `.handle(...)` is a compile-time error.
Inside the child, the parent reference accepts only those declared events.
`emit` never sends to the parent: it only publishes on the emitting actor's
`emissions` stream.

Every handler also receives `self`, which can be targeted with `sendTo` when a
later mailbox turn is required. Use `raise` instead for same-macrostep work.
Structural state values use distinct names: `containingState` is the immediate
valued state in the same statechart, while `ancestors` maps valued ancestor
paths. `parent` always means the owning actor reference.

### Choose the target by scope

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

Emissions stay streams rather than becoming retained atom state:

```ts
const rootEmissions = AtomMachine.emissions(counterAtom)
const childEmissions = AtomMachine.childEmissions(counterAtom.child(Worker))
```

These streams require the same `AtomRegistry`, follow the currently mounted
actor instance, and do not replay notifications from an earlier subscription
or child instance.

## Persistence

Logical snapshots can be validated for storage or transport:
Expand Down
125 changes: 98 additions & 27 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ declared:

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

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

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

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

## Reading state and parents
## Reading state and structural ancestors

`Machine.defineStates` returns typed helpers:

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

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

```ts
parents["Route.Ready"]
parents["Route.Ready.Editing"]
ancestors["Route.Ready"]
ancestors["Route.Ready.Editing"]
```

Do not guess short properties such as `parents.Ready`.
Do not guess short properties such as `ancestors.Ready`.

### Inspecting the full transition configuration

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

```ts
Submit: ({ target }, enqueue) => {
enqueue.emit(new SaveRequested({}))
enqueue.emit(Emissions.SaveRequested())
return target.local.Saving.from()
}
```

Declare emission constructors separately from actor inputs:

```ts
const Emissions = Machine.emittedEvents(SaveRequested, AuditRecorded)

const definition = Machine.make({
events: Commands,
internalEvents: InternalEvents,
emittedEvents: Emissions,
// ...
})
```

`enqueue.raise(...)` is a same-macrostep input to self. `enqueue.sendTo(...)`
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.

For child-to-parent input, export a public builder protocol and reuse it at both
composition boundaries:

```ts
export const ParentEvents = Machine.events(ChildFinished)

const child = Machine.make({
events: ChildEvents,
parentEvents: ParentEvents,
// ...
}).handle({
Working: {
on: {
Finish: ({ parent }, enqueue) => {
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished())
}
}
}
}
})

const parent = Machine.make({
events: Machine.events(ParentCommands, ParentEvents),
// ...
})
```

Invoking the child under a parent that lacks any required `parentEvents` case
is a type error. Within child handlers, `parent` accepts only that protocol.
The same child may run as a root, where `parent` is `undefined`. `self` accepts
the machine's public inputs. Neither actor reference is a structural state
value; use `containingState` and `ancestors` for statechart ancestry.

Atom-backed actors retain the same transient semantics. Use
`AtomMachine.emissions(machineAtom)` for a root and
`AtomMachine.childEmissions(childAtom)` for the currently active child. Both
return streams requiring the corresponding `AtomRegistry`; emissions are not
stored as atom state.

For asynchronous validation or persistence, invoke an Effect or child machine
from the state and handle its typed success or failure event in a later
transition. This keeps `(state, event) => [nextState, commands]` synchronous.
Expand Down Expand Up @@ -551,8 +615,9 @@ type AnyHandledEvent = Machine.Machine.Event<typeof definition>

`MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public
events or constructions returned by `Machine.events`. Transition handlers
receive only decoded events. Raised events and child emissions additionally
accept constructions from `Machine.internalEvents`. The
receive only decoded events. Raised events additionally accept constructions
from `Machine.internalEvents`; outward notifications accept constructions from
`Machine.emittedEvents`. The
local planner and runtime intentionally share the complete decoder to support
those internal deliveries, so JavaScript or `any` can bypass the local public
distinction.
Expand Down Expand Up @@ -582,11 +647,11 @@ self-interrupts fails the parent. `onDone` is required when the output is not
are forbidden when their channel is `never`.

The source may also be a function of the owning state's entry context when it
needs `state`, `parent`, `parents`, or the entry `event`. Source construction
needs `state`, `containingState`, `ancestors`, or the entry `event`. Source construction
errors, defects, and interruption are machine failures rather than a second
phase in `onFailure`.

When a source function reads `state`, `parent`, `parents`, or the entry `event`,
When a source function reads `state`, `containingState`, `ancestors`, or the entry `event`,
`Machine.invoke` infers that owner context and the returned Effect's output,
error, and service channels together. No return annotation is needed:

Expand Down Expand Up @@ -955,13 +1020,19 @@ Wrap the initial builder result:
initial: () => States.initial.Idle.from()
```

### Invoked child emits events not accepted by the parent
### Invoked child expects events not accepted by the parent

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

```ts
events: Machine.events(Submit),
internalEvents: Machine.internalEvents(...ChildMachine.emits)
export const ChildParentEvents = Machine.events(ChildFinished)

// child
parentEvents: ChildParentEvents

// parent
events: Machine.events(Submit, ChildParentEvents)
```

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

### Parent property does not exist

Use its full path:
Use the structural ancestor's full path:

```ts
parents["Route.Ready"]
ancestors["Route.Ready"]
```

### Child descriptor types are unrelated
Expand Down
Loading