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/calm-selectors-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typeonce/effect-machine": minor
---

Make definition-time topology instructions immutable values. Use `to.none`, declared `.initial` and history properties, and `to.local.with` without an empty call; state and choice destinations such as `to.full.Running()` remain callable.

Author machine startup through the same target-first grammar: `initial: (to) => to.Flow.initial.resolve(...)`. The selector is captured once and its resolver remains lazy until initial planning.

Remove `Machine.targetless` and the `{ target: Machine.targetless, resolve }` shorthand. Use `(to) => to.none` or `(to) => to.none.resolve(...)`; block-bodied targetless resolvers may omit an explicit `return undefined`.
2 changes: 1 addition & 1 deletion .changeset/fluent-transition-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const handlers = {
to.branches({
running: { target: to.full.Running() },
done: { target: to.full.Done() },
unchanged: { target: to.none() }
unchanged: { target: to.none }
}).resolve(({ event, select }) => event.cached ? select.done.from() : select.running.from())
}
```
Expand Down
58 changes: 26 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ const CounterDefinition = Machine.make({
id: "Counter",
states: States.states,
events: CounterEvent,
initial: {
target: (to) => to.Idle(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Idle().resolve(({ target }) => target.from())
})

const Counter = CounterDefinition.handle({
Expand Down Expand Up @@ -166,10 +163,11 @@ const States = Machine.states({
}
})

initial: {
target: (to) => to.Form.initial(),
resolve: ({ target }) => target((form) => form.Editing.from())
}
const definition = Machine.make({
states: States.states,
events: Machine.events(),
initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
})
```

Schema-less states remain active, targetable, matchable, and visible through
Expand Down Expand Up @@ -208,10 +206,7 @@ const definition = Machine.make({
events: CommandEvent,
internalEvents: InternalEvent,
emittedEvents: Emissions,
initial: {
target: (to) => to.Idle(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Idle().resolve(({ target }) => target.from())
})
```

Expand Down Expand Up @@ -323,10 +318,7 @@ const child = Machine.make({
states: ChildStates.states,
events: ChildEvents,
parentEvents: ParentEvents,
initial: {
target: (to) => to.Working(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Working().resolve(({ target }) => target.from())
}).handle({
Working: {
on: {
Expand Down Expand Up @@ -375,16 +367,22 @@ paths. `parent` always means the owning machine target.
Every required transition handler selects a target from its inline `to`
builder. A bare selection uses the target schema's default construction; call
`.resolve(...)` when construction depends on handler context. An absent handler
ignores the trigger; `to.none()` handles
ignores the trigger; `to.none` handles
it and retains queued commands, raised events, and emitted events without
selecting a destination. Concrete destinations stay narrowed inside their
resolver, and `to.branches({...})` gives the resolver only the declared named
`select` builders. Builders describe the
next logical configuration. Shared states exit and enter only when paths
change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }`
to `.resolve(...)` when the source must restart. With `to.none()`, reentry
to `.resolve(...)` when the source must restart. With `to.none`, reentry
restarts the source while retaining its configuration.

Topology-only definition instructions are values: `to.none`, declared
`.initial` and history selections, and `to.local.with`. Concrete state and
choice destinations remain calls such as `to.full.Running()`. Runtime named
branch builders remain callable, including `select.unchanged()`, because their
result carries the selected branch evidence.

Use `declinable: true` when a resolver may decide that its transition is not
enabled. Only that resolver receives `decline()`, and its return type expands to
accept the opaque declined result:
Expand Down Expand Up @@ -477,22 +475,19 @@ macrostep commits:
invoke: Machine.invoke({
id: "channel",
stream: () => channelMessages,
onElement: {
target: Machine.targetless,
resolve: ({ element }, enqueue) => {
onElement: (to) =>
to.none.resolve(({ element }, enqueue) => {
enqueue.raise(Events.MessageReceived({ message: element }))
}
},
onDone: { target: Machine.targetless },
}),
onDone: (to) => to.none,
onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ error }))
})
```

`target: Machine.targetless` is the direct shorthand for a non-reentering
transition that keeps the current configuration. Its optional `resolve`
callback may enqueue commands and must return `undefined`. Use
the same fluent `to` builder to select a target for transitions that change
state or reenter.
`to.none` is the targetless transition value. Return it directly to keep the
current configuration, or call `.resolve(...)` when the transition only needs
to enqueue commands. A block resolver may omit its return because it is
contextually typed to return `undefined`.

Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's
public input and `parentEvents` protocols contextually. Its source and lifecycle
Expand All @@ -511,14 +506,13 @@ const machine = Machine.make({
id: "notify-parent",
effect: () => saveDocument,
onDone: (to) =>
to.none().resolve(({ parent, self }, enqueue) => {
to.none.resolve(({ parent, self }, enqueue) => {
enqueue.sendTo(self, Commands.Save())
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
}
return undefined
}),
onFailure: (to) => to.none()
onFailure: (to) => to.none
})
}
})
Expand Down
87 changes: 46 additions & 41 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,7 @@ const States = Machine.states({
}
})

initial: {
target: (to) => to.Form.initial(),
resolve: ({ target }) => target((form) => form.Editing.from())
}
initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
```

Schema-less states have the same control semantics as schema-backed states:
Expand All @@ -245,7 +242,7 @@ in snapshots. They do not have a state value:
Idle: {
on: {
Start: (to) =>
to.full.Form.initial().resolve(({ state, target }) => {
to.full.Form.initial.resolve(({ state, target }) => {
// state: undefined
return target.from((form) => form.Editing.from())
})
Expand Down Expand Up @@ -329,10 +326,7 @@ const States = Machine.states({
const machine = Machine.make({
states: States.states,
events: Machine.events(),
initial: {
target: (to) => to.Done(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Done().resolve(({ target }) => target.from())
}).handle({
Done: {
output: () => "done"
Expand All @@ -349,9 +343,12 @@ with `.initial`. This is available on top-level state methods under
`target.branch`; atomic and final state methods do not expose it:

```ts
Open: (to) => to.full.opened.initial().resolve(({ target }) => target.from({ teamId: "team-1" }))
Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ teamId: "team-1" }))
```

The definition-time `.initial` property is a topology value. The exact
resolver `target` is still a callable runtime builder.

The selected state's own value is passed directly to `initial(value)` or
constructed inside planning with `initial.from(input)`. A structural selected
state uses `initial()`.
Expand Down Expand Up @@ -426,9 +423,12 @@ checkout: {
Target it without a value:

```ts
Resume: (to) => to.history.checkout.exact().resolve(({ target }) => target())
Resume: (to) => to.history.checkout.exact.resolve(({ target }) => target())
```

Each declared history leaf is a topology value; the resolver's selected
history builder remains callable to construct restoration evidence.

Deep history restores the complete remembered subtree and its decoded values.
Shallow history restores only parent and direct-child values. If the remembered
child is compound, its configured initial child needs a freshly constructed
Expand Down Expand Up @@ -478,6 +478,22 @@ prior effects, machine instances, and timers are not rewound.
| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot |
| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or a source-independent complete default containing that owner before the first capture |

Definition-time instructions that only identify topology are values:
`to.none`, `to.full.Flow.initial`, `to.history.Flow.recent`, and
`to.local.with`. State and choice destinations remain calls, such as
`to.full.Running()` and `to.local.Routing()`, because those calls select the
node. Resolver-time builders also remain callable because they construct and,
for named branches, brand runtime evidence such as `select.unchanged()`.

Use `to.local.with` when a descendant transition updates the nearest
schema-backed compound value while retaining that same compound scope:

```ts
Play: (to) =>
to.local.with.resolve(({ containingState, target }) =>
target.from({ ...containingState, playing: true }, (flow) => flow.Playing.from()))
```

Entering an inactive parallel state through `target.local` or `target.branch`
requires a complete callback with one selection per region. A parallel state
that is already active remains partially addressable through `target.branch`;
Expand All @@ -498,7 +514,7 @@ When no resolver is needed, use the selected target directly and append

```ts
Finish: (to) => to.full.Done()
Restart: (to) => to.none().reenter()
Restart: (to) => to.none.reenter()
```

Do not use `target.full` merely because it is easiest to discover. Prefer the
Expand Down Expand Up @@ -578,7 +594,7 @@ microstep, before any selected transition is applied:
BufferReady: (to) =>
to.branches({
online: { target: to.local.Playing() },
unchanged: { target: to.none() }
unchanged: { target: to.none }
}).resolve(({ snapshot, select }) =>
States.matches(snapshot, "Player.Network.Online")
? select.online.from()
Expand Down Expand Up @@ -637,7 +653,7 @@ synchronously:
Submit: (to) =>
to.branches({
valid: { target: to.local.Saving() },
invalid: { target: to.none() }
invalid: { target: to.none }
}).resolve(({ state, select }) => state.valid
? select.valid.from({ draft: state.draft })
: select.invalid()
Expand All @@ -649,7 +665,7 @@ its `resolve` method. A branching transition calls `to.branches` with every
possible target, then uses ordinary TypeScript control flow in `resolve` to return one
typed `select` builder. Branch keys are stable testing and inspection identities;
an optional `title` controls presentation and otherwise defaults to the key.
Selecting a branch whose target is `to.none()` handles the transition without a
Selecting a branch whose target is `to.none` handles the transition without a
destination while retaining queued commands, raised events, and emitted events.

Set `declinable: true` only when the resolver may decide that its transition is
Expand All @@ -660,7 +676,7 @@ permits its opaque result:
Submit: (to) =>
to.branches({
accepted: { target: to.local.Saving() },
consumed: { target: to.none() }
consumed: { target: to.none }
}).resolve(({ event, select, decline }) => {
if (!belongsToThisState(event)) return decline()
return event.consume ? select.consumed() : select.accepted.from()
Expand All @@ -670,7 +686,7 @@ Submit: (to) =>
Declining discards that resolver's enqueue buffer and resumes hierarchical
event or eventless selection at the next eligible ancestor. If no candidate
accepts, the trigger is unhandled. This is deliberately different from
`to.none()`, which consumes the trigger. `decline()` is absent and its result is
`to.none`, which consumes the trigger. `decline()` is absent and its result is
rejected unless the literal flag is present. Choice and initial routing remain
total and cannot decline. Static inspection exposes the distinction through
`TransitionDefinition.acceptance` without executing resolver code. Completion
Expand All @@ -683,7 +699,7 @@ array-index and symbol keys are rejected. Treat the string key as semantic:
reordering named properties may change their display index, but visualizers,
coverage, and trace verification identify each branch by its key.

`reenter: true` remains meaningful with `to.none()`: the source exits and
`reenter: true` remains meaningful with `to.none`: the source exits and
enters again while its logical configuration is retained.

Closed statechart and machine operations use `enqueue`:
Expand Down Expand Up @@ -788,11 +804,10 @@ const child = Machine.make({
Working: {
on: {
Finish: (to) =>
to.none().resolve(({ parent }, enqueue) => {
to.none.resolve(({ parent }, enqueue) => {
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished())
}
return undefined
})
}
}
Expand Down Expand Up @@ -868,10 +883,7 @@ const definition = Machine.make({
states: States.states,
events: Events,
internalEvents: InternalEvents,
initial: {
target: (to) => to.Idle(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Idle().resolve(({ target }) => target.from())
})
```

Expand Down Expand Up @@ -952,13 +964,11 @@ handles normal Stream completion and `onFailure` handles the typed Stream error:
invoke: Machine.invoke({
id: "broadcast-channel",
stream: () => messages,
onElement: {
target: Machine.targetless,
resolve: ({ element }, enqueue) => {
onElement: (to) =>
to.none.resolve(({ element }, enqueue) => {
enqueue.raise(Events.MessageReceived({ message: element }))
}
},
onDone: { target: Machine.targetless },
}),
onDone: (to) => to.none,
onFailure: (to) => to.full.Disconnected().resolve(({ error, target }) => target.from({ error }))
})
```
Expand All @@ -968,10 +978,9 @@ after the selected parent macrostep commits. Exiting or reentering the owner
interrupts the Stream and runs its finalizers. A later entry starts a fresh
Stream. Stream defects and self-interruption fail the owning machine.

The direct `{ target: Machine.targetless, resolve }` shorthand is available
when a transition only enqueues commands. It is non-reentering and the resolver
must return `undefined`. Use the same fluent `to` selector for full state
selection, named branches, and reentry.
Use `to.none` when a transition keeps the current configuration. Call
`to.none.resolve(...)` when it also enqueues commands; a block resolver may
omit its return because it is contextually typed to return `undefined`.

When a source function reads `state`, `containingState`, `ancestors`, or the entry `event`,
`Machine.invoke` infers that owner context and the returned Effect's output,
Expand Down Expand Up @@ -1002,14 +1011,13 @@ const machine = Machine.make({
id: "notify-parent",
effect: () => saveDocument,
onDone: (to) =>
to.none().resolve(({ parent, self }, enqueue) => {
to.none.resolve(({ parent, self }, enqueue) => {
enqueue.sendTo(self, Commands.Save())
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
}
return undefined
}),
onFailure: (to) => to.none()
onFailure: (to) => to.none
})
}
})
Expand Down Expand Up @@ -1373,10 +1381,7 @@ reference model when correctness of the expected behavior matters.
Select the initial root separately from constructing its value:

```ts
initial: {
target: (to) => to.Idle(),
resolve: ({ target }) => target.from()
}
initial: (to) => to.Idle().resolve(({ target }) => target.from())
```

### Invoked child expects events not accepted by the parent
Expand Down
Loading