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

Replace `Machine.invokeEffect`, `Machine.after`, `Machine.invokeMachine`, and `Machine.effect` with one inline `invoke` lifecycle object API and a zero-runtime `Machine.invoke` inference helper.

Choose an `effect`, `after`, `logic`, or `child` source and handle typed outcomes directly with `onDone`, `onFailure`, and `onSnapshot`. Lifecycle handlers can now transition the owning state without routing results through mapped machine events.

State-dependent Effect sources infer their owner state, output, error, and service requirements together without a manual return annotation.
45 changes: 32 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,29 +201,48 @@ State-scoped work starts on entry and is interrupted on exit:

```ts
Loading: {
invoke: Machine.invokeEffect({
invoke: Machine.invoke({
id: "save-document",
effect: saveDocument,
onSuccess: (entry) => InternalEvent.Saved({ id: entry.id }),
onFailure: (error) => InternalEvent.SaveFailed({ message: String(error) })
onDone: ({ output, target }) => target.full.Saved({ id: output.id }),
onFailure: ({ error, target }) => target.full.Failed({ message: String(error) })
})
}

Waiting: {
invoke: Machine.after(
"3 seconds",
InternalEvent.SaveFailed({ message: "Timed out" })
)
invoke: Machine.invoke({
id: "save-timeout",
after: "3 seconds",
onDone: ({ target }) => target.full.Failed({ message: "Timed out" })
})
}
```

Use `Machine.invokeEffect` for one Effect, `Machine.after` for a cancellable
delay, and lower-level `Machine.invoke` only for custom process behavior or
snapshot mapping. Use one exported `Machine.child(id, machine)` descriptor for
`invokeMachine`, `sendTo`, and child lookup.
Use `effect` for one Effect, `after` for a cancellable delay, `logic` for a
reusable process, and `child` for a complete child statechart—all through
`Machine.invoke({...})`. The helper is an identity at runtime and preserves
owner-context and source-channel inference across lifecycle handlers, including
for state-dependent Effects:

```ts
invoke: Machine.invoke({
id: "load-document",
effect: ({ state }) => loadDocument(state.documentId),
onDone: ({ output, target }) => target.full.Ready({ document: output }),
onFailure: ({ error, target }) => target.full.Failed({ message: error.message })
})
```

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
lookup.

Expected failures should become internal events. An unrecovered invoke or child
failure terminates the owning runtime.
`onDone` is required for a non-`never` output, and `onFailure` is required for a
non-`never` typed error; each handler is omitted when its channel is `never`.
Defects, interruption, and source-construction failures terminate the owning
runtime. `effect: Effect.sleep(...)` is valid, but `after` keeps timers explicit
and makes static durations visible through activity inspection.

## Reactivity

Expand Down
3 changes: 0 additions & 3 deletions api-reference.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,11 @@
"source": "src/Machine.ts",
"barrel": ".",
"examples": [
"after",
"decodeSnapshot",
"defineStates",
"effect",
"encodeSnapshot",
"event",
"invoke",
"invokeEffect",
"make",
"plan",
"planInitial",
Expand Down
151 changes: 82 additions & 69 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ the deferred constructors preserve that identity after decoding.
- Return snapshots or typed target-builder results from transitions. Do not
return raw decoded state values.
- Transition and lifecycle callbacks are synchronous. Put asynchronous work in
an invoked Effect, actor, or child machine and map its result to an event.
an invoked Effect, logic process, or child machine and handle its lifecycle
with `onDone`, `onFailure`, and `onSnapshot`.
- Put data on the narrowest state where it is valid. Put data shared by sibling
phases on their compound parent.
- Declare finality only in the state definition. Do not put `type: "final"` in
Expand All @@ -80,12 +81,13 @@ the deferred constructors preserve that identity after decoding.
- `parents` keys are full dotted paths.
- Invoke lifetimes follow state entry and exit, not the spelling of the target
builder.
- Recover expected invoked Effect failures into machine events. Unrecovered
child failures terminate the owning machine.
- Reuse the exact child descriptor value for `invokeMachine`, `sendTo`, and
child lookup.
- 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 invoke results and invoked-child emissions. Handlers see
deliveries such as raised events and invoked-child emissions. Handlers see
both; typed public `send` and `Machine.plan` accept only `events`.
- Event tags in `events` and `internalEvents` must be disjoint.
- Event tags must also be unique within each protocol list.
Expand All @@ -98,13 +100,15 @@ its extra control is required:
- Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the
returned `make` or `resume`. Use `AtomMachine.make(machine)` and
`AtomMachine.resume(machine, snapshot)` for service-free machines.
- Use `Machine.invokeEffect` for a typed one-shot Effect and `Machine.after` for
a timer. Use `Machine.invoke` with `Machine.effect` only for custom child
process behavior or snapshot mapping.
- 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.
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
`Machine.childAddress<Event>(id)` for a low-level process address. An
invocation is addressable only when `Machine.invoke` receives that address
explicitly.
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
invocation is addressable only when `Machine.invoke` receives that
address explicitly.
- Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
`stop`. These operations record closed actor commands and do not run Effects.

Expand Down Expand Up @@ -541,8 +545,8 @@ 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, invoke results, and mapped child
events additionally accept constructions from `Machine.internalEvents`. The
receive only decoded events. Raised events and child emissions additionally
accept constructions from `Machine.internalEvents`. 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 All @@ -552,45 +556,63 @@ across both configuration lists.

## Recoverable state-scoped work

Use `Machine.invokeEffect` for a one-shot Effect. Its callbacks preserve the
typed success and failure channels while mapping both into machine events:
Use `Machine.invoke` with an `effect` for one-shot work. Lifecycle callbacks
receive the typed Effect channels and can transition directly:

```ts
invoke: ({ state }) =>
Machine.invokeEffect({
id: "save",
effect: SaveService.save(state.draft),
onSuccess: (entry) => InternalEvents.Saved({ entry }),
onFailure: (error) => InternalEvents.SaveFailed({ message: error.message })
})
invoke: Machine.invoke({
id: "save",
effect: SaveService.save(draft),
onDone: ({ output, target }) => target.full.Saved({ entry: output }),
onFailure: ({ error, target }) =>
target.full.SaveFailed({ message: error.message })
})
```

The owning state scopes the child. Owner-driven interruption on state exit is
normal cancellation and stale output is ignored. A child Effect that defects
or self-interrupts fails the parent. Omit `onFailure` only when the Effect error
type is `never`; defects and interruption are not mapped.
normal cancellation and stale output is ignored. An Effect that defects or
self-interrupts fails the parent. `onDone` is required when the output is not
`never`; `onFailure` is required when the typed error is not `never`. Handlers
are forbidden when their channel is `never`.

Successful non-void output is delivered as a parent event. Include every
possible mapped result schema in the parent machine's `internalEvents` array and
add handlers for the relevant tags. Leave defects and interruption fatal;
recover only expected typed failures.
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
errors, defects, and interruption are machine failures rather than a second
phase in `onFailure`.

A cancellable timer uses `Machine.after`:
When a source function reads `state`, `parent`, `parents`, 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:

```ts
invoke: Machine.after("3 seconds", InternalEvents.ClearStatus(), {
id: "clear-status"
invoke: Machine.invoke({
id: "load",
effect: ({ state }) => LoadService.load(state.userId),
onDone: ({ output, target }) => target.full.Loaded({ user: output }),
onFailure: ({ error, target }) => target.full.LoadFailed({ error })
})
```

The timer starts on state entry and is interrupted on exit. Supply an explicit
id when more than one active timer could deliver the same event tag. Use
lower-level `Machine.invoke` with `Machine.effect` when custom child logic or
snapshot mapping is required. In that API, `id` is only the invocation's
state-local lifecycle key. To communicate with the invocation, create a
`Machine.childAddress<Event>("worker")` and pass it as `address`; TypeScript
checks the address protocol against the child logic. Lifecycle ids must be
unique among simultaneously active invokes owned by the same state.
A direct `invoke: { ... }` object remains available when lifecycle handlers do
not need source-derived context.

A cancellable timer uses the same object:

```ts
invoke: Machine.invoke({
id: "clear-status",
after: "3 seconds",
onDone: ({ target }) => target.full.Clear()
})
```

The timer starts on state entry and is interrupted on exit. Its `onDone` is
always required. `effect: Effect.sleep(...)` has the same scoped cancellation
behavior, but `after` records timer intent and exposes a static duration through
`Machine.activityDefinitions`. For reusable process logic, provide `logic`, a
state-local lifecycle `id`, and a typed `address`. TypeScript checks the address
protocol against the logic event protocol. Lifecycle ids and addresses serve
different purposes and must both be explicit.

## Invoked child statecharts

Expand All @@ -603,10 +625,10 @@ const Editor = Machine.child("editor", EditorMachine)
Invoke it from its owning state:

```ts
invoke: Machine.invokeMachine({
invoke: Machine.invoke({
child: Editor,
input: editorInput,
onDone: ({ output }) => new EditorCompleted({ output })
onDone: ({ output, target }) => target.full.EditorDone({ output })
})
```

Expand All @@ -618,9 +640,9 @@ parentRef.child(Editor)
parentAtom.child(Editor)
```

Child emissions, mapped snapshots, and mapped completion output are delivered as
parent events and must be accepted by the parent's `internalEvents` list.
Invoked child IDs must be unique while simultaneously active.
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.

Descriptors with the same id and machine identity address the same child, even
when independently constructed. The descriptor objects themselves are not
Expand All @@ -632,21 +654,18 @@ logic that does not have a complete machine descriptor.
### Inspecting state-owned activities

Use `Machine.activityDefinitions(machine)` to inspect invokes without running
them. Static `Machine.invoke`, `Machine.invokeEffect`, `Machine.after`, and
`Machine.invokeMachine` descriptors expose serializable ownership metadata:
them. Static inline `Machine.invoke` definitions expose serializable ownership
metadata:

```ts
Machine.activityDefinitions(machine)
// [{ source: "Loading", id: "load-timeout", type: "timer",
// duration: "10s", event: "LoadTimedOut" }]
// duration: "10s" }]
```

Effect success/failure mappers are closures and therefore appear as dynamic
outcomes. Child machines expose descriptor identity, never their runtime or
implementation. A function-valued `invoke` factory is represented as a dynamic
activity because inspection must not evaluate user code. The existing invoke
helpers remain the only execution API; this metadata does not add lifecycle
configuration syntax or affect execution.
Child machines expose descriptor identity, never their runtime or
implementation. Function-valued sources and durations are represented as
dynamic because inspection must not evaluate user code.

## AtomMachine and React

Expand Down Expand Up @@ -755,8 +774,8 @@ Encoding does not preserve:
- completion and history records survive but do not retrigger `onDone`;
- active-state invokes start once in ordinary ancestor/document order with
`Machine.InitialEvent`;
- `invokeEffect` restarts, `invokeMachine` creates a fresh child from its normal
initial state, and `Machine.after` restarts its complete duration;
- inline Effects restart, child machines start fresh from their normal initial
state, and timers restart their complete duration;
- inactive invokes, spawned children, child snapshots, elapsed timer time, and
prior `RuntimeSnapshot` status/errors are not restored;
- a final logical snapshot creates an immediately completed ref;
Expand Down Expand Up @@ -930,11 +949,6 @@ Wrap the initial builder result:
initial: () => States.initial.Idle.from()
```

### Invoked child output must be a machine event

Add the output's tagged schema to the parent machine's `internalEvents` array,
or map/ignore the output before it reaches the parent.

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

Add the child's emitted schemas to the parent machine's `internalEvents` array:
Expand All @@ -947,9 +961,8 @@ internalEvents: [...ChildMachine.emits]
### An internal event is rejected by `send`

This is intentional. Public input boundaries accept only schemas declared in
`events`. Handle the event as an invoke result, child delivery, or raised event;
move it to `events` only if external callers should genuinely be allowed to
send it.
`events`. Handle the event as a child delivery or raised event; move it to
`events` only if external callers should genuinely be allowed to send it.

### Public and internal event tags overlap

Expand Down Expand Up @@ -985,7 +998,7 @@ parents["Route.Ready"]

### Child descriptor types are unrelated

Use the descriptor exported by the module that configured `invokeMachine`.
Use the descriptor exported by the module that configured the child invocation.
An independently created descriptor with the same id and machine identity also
matches; the same id paired with a different machine remains a distinct child.

Expand All @@ -1009,6 +1022,6 @@ The current API does not include:
- declarative first-class guards;
- a complete inspectable graph for arbitrary transition Effects.

Use ordinary TypeScript conditions for guards and `Machine.after` for
state-scoped timers. Do not invent undocumented state-node properties such as
`guard`.
Use ordinary TypeScript conditions for guards and an inline `Machine.invoke`
with `after` for state-scoped timers. Do not invent undocumented state-node
properties such as `guard`.
2 changes: 1 addition & 1 deletion examples/platformer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ only `Airborne` interprets the wall sample as a wall jump. It turns and pushes
away, refreshes the air jump through `WallLock`, and the same wall may be used
again after physically returning to it. Movement phases own their timestamps,
and both landing and capability locks demonstrate state-scoped
`Machine.after` timers.
inline `Machine.invoke({ after: ... })` timers.

Keyboard commands and physics facts share a typed `Schema.TaggedUnion`
protocol. The adapter executes velocity and floor collision, then reports
Expand Down
Loading