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

Make state construction modes explicit and allow one topology target to replace a retained valued owner atomically.

Valued builders are no longer callable. Replace `target(value)` and nested `builder(value, ...)` calls with `.decoded(value, ...)`; keep `.from(input, ...)` for schema make input. Plain state-update resolvers now expose the decoded owner as `current` and its construction builder as `owner`, replacing the previous `ancestors` plus `target` pattern.

Declare a combined transition with `.updating(ownerSelector)`. The resolver must finish destination construction with `.update(...)`, so the owner replacement cannot be omitted:

```ts
to.local.SavingPlan()
.updating(to.branch.Ready)
.resolve(({ current, owner, target }) =>
target.from({ request }).update(
owner.decoded(new Ready({ ...current, notice: null }))
)
)
```

Transition inspection and retained microsteps now include an `updates` array naming replaced owners.
97 changes: 83 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,26 @@ parallel root.

### Construct state through builders

Use `.from(...)` when constructing a new state from fields:
Use `.from(...)` when constructing a new state from schema make input:

```ts
target.from({ draft: event.draft })
```

The machine runs these inputs through the state schema while planning. Schema
defaults, refinements, and tagged-class identity are therefore preserved, and
decode failures remain typed machine failures. Pass a value directly only when
it is already decoded.
defaults, transformations, refinements, and tagged-class identity are
therefore preserved, and decode failures remain typed machine failures. This
is the default construction path.

Use `.decoded(...)` when the value is already a `Schema.Type`:

```ts
target.decoded(new Ready({ notice: null }))
```

The machine still validates the value against the schema's type side. It does
not run encoded-input transformations again. State builders are not callable;
the method name always makes the construction mode visible.

When sibling states share fields, remove the source discriminator and pass the
remaining fields through the target schema:
Expand Down Expand Up @@ -190,7 +200,7 @@ const States = Machine.states({
const definition = Machine.make({
states: States.states,
events: Machine.events(),
initial: (to) => to.Form.initial.resolve(({ target }) => target((form) => form.Editing.from()))
initial: (to) => to.Form.initial.resolve(({ target }) => target.from((form) => form.Editing.from()))
})
```

Expand Down Expand Up @@ -428,18 +438,18 @@ compound scope without rebuilding its active child. Use
handler source:

```ts
Increment: ;
;((to) =>
to.branch.root.session.update(({ ancestors, target }) => target.from({ count: ancestors["root.session"].count + 1 })))
const handlers = {
Increment: (to) => to.branch.root.session.update(({ current, owner }) => owner.from({ count: current.count + 1 }))
}
```

The update keeps the exact active descendants, their values, history records,
completion outputs, and unrelated parallel regions. It runs no exit or entry
actions and does not restart state-owned work. Eventless stabilization still
runs, so an `always` transition can react to the new value.

`update` is callable when used directly and is also a static selection for a
named branch:
The plain update method remains useful when topology does not change. It is
also a static selection for a named branch:

```ts
to.branches({
Expand All @@ -452,10 +462,69 @@ to.branches({
)
```

The resolver must return `target(value)` or `target.from(input)`. It may return
`decline()` only with `{ declinable: true }`. Pass `{ reenter: true }` on event
or invocation transitions when the handler source should exit and enter again.
Reentry applies to that source, not to the ancestor whose value changed.
### Change topology and a retained owner together

When a transition enters another child and also replaces a valued ancestor
that stays active, declare both operations on the same target:

```ts
const handlers = {
CreatePlan: (to) =>
to.local.SavingPlan()
.updating(to.branch.Ready)
.resolve(({ current, event, owner, target }) =>
target.from({
request: { _tag: "Create", input: event.input }
}).update(
owner.decoded(new Ready({ ...current, notice: null }))
)
)
}
```

`to.local.SavingPlan()` selects topology. `.updating(to.branch.Ready)` names
the retained valued owner and makes its replacement mandatory: the resolver
does not type-check unless destination construction finishes with
`.update(...)`. `current` is that owner's decoded value from the
pre-transition snapshot. `target` constructs the destination; `owner`
constructs the complete replacement owner value.

The topology change and owner replacement apply atomically in one microstep.
The owner does not exit or reenter, its work is not restarted, and destination
entry actions observe the new owner value. Eventless stabilization follows.
Only one retained owner may be replaced by a combined target. A `full` target,
or any target that exits the selected owner, does not expose `.updating`.
Combined updates use a direct resolver in this release; named branches continue
to support value-only updates.

For a schema-less destination, construction remains explicit:

```ts
to.local.Idle()
.updating(to.branch.Ready)
.resolve(({ current, output, owner, target }) =>
target.from().update(
owner.decoded(
new Ready({
...current,
day: output,
notice: "Plan changed."
})
)
)
)
```

Both values derive from the same pre-transition snapshot and are validated
before lifecycle actions run. Competing transitions that write the same owner
conflict; document order and hierarchy select one writer rather than applying
last-write-wins behavior.

The resolver must return `target.decoded(value)` or `target.from(input)`. It
may return `decline()` only with `{ declinable: true }`. Pass `{ reenter: true }`
on event or invocation transitions when the handler source should exit and
enter again. Reentry applies to that source, not to the ancestor whose value
changed.

The selector omits `update` for schema-less scopes, atomic and final states,
inactive branches, parallel sibling regions, and choice resolvers. Updating a
Expand Down
41 changes: 36 additions & 5 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ Each step has one job:
Chain `.handle` from `Machine.make`. Do not store the intermediate definition
when the module exports one machine implementation.

State builders construct the next snapshot. Use `.from(...)` when a state owns
data. The machine validates that input through the state schema while it plans
the transition.
State builders construct the next snapshot. Use `.from(...)` for schema make
input; defaults, transformations, and refinements run while the machine plans
the transition. Use `.decoded(...)` only for an existing `Schema.Type`. It is
validated against the type side without rerunning encoded transformations.
Valued state builders are not callable, so the construction mode is always
visible. Schema-less state construction uses `.from()`.

The examples below show one modeling decision at a time. They omit unchanged
state and event declarations already shown above.
Expand Down Expand Up @@ -393,8 +396,8 @@ update selection instead of reconstructing its active descendants:

```ts
Changed: (to) =>
to.local.update(({ ancestors, target }) =>
target.from({ revision: ancestors.document.revision + 1 })
to.local.update(({ current, owner }) =>
owner.from({ revision: current.revision + 1 })
)
```

Expand All @@ -405,6 +408,34 @@ configuration. Neither runs lifecycle actions or restarts state-owned work by
default. Use an event handled inside a parallel sibling when that sibling owns
the value that must change.

When topology changes and one valued ancestor remains active but needs a new
value, declare the owner on the destination:

```ts
CreatePlan: (to) =>
to.local.SavingPlan()
.updating(to.branch.Ready)
.resolve(({ current, event, owner, target }) =>
target.from({ request: event.input }).update(
owner.decoded(new Ready({ ...current, notice: null }))
)
)
```

`.updating(...)` accepts the retained state selector itself. It makes the
owner replacement mandatory at compile time: the resolver must finish a
destination construction with `.update(...)`. `current` is the owner's
decoded pre-transition value. `target` constructs the destination and `owner`
constructs the complete replacement value.

Both instructions are validated and applied atomically. The retained owner is
not reentered, destination entry sees its new value, and eventless
stabilization runs afterward. Only local and branch targets that retain the
owner expose `.updating`; full targets do not. Combined targets support one
owner. Keep separate domain changes explicit by constructing the complete
owner value instead of relying on a partial merge helper. Combined updates use
a direct resolver; named branches support value-only updates.

## Test paths and invariants

Test the statechart as a graph. Send domain events, inspect reached states, and
Expand Down
2 changes: 1 addition & 1 deletion perf/runtime/counter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const counterMachine = Machine.make({
events: benchmarkApi.events(CounterEvent.cases.Increment, CounterEvent.cases.Finish),
initial: benchmarkApi.initial({
target: (to) => to.Count(),
resolve: ({ target }) => target(CounterState.cases.Count.make({ value: 0 }))
resolve: ({ target }) => target.from(CounterState.cases.Count.make({ value: 0 }))
}, () => CounterStates.initial.Count.from({ value: 0 }))
}).handle({
Count: {
Expand Down
6 changes: 3 additions & 3 deletions perf/types/adapter-readiness-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ export const machine = Machine.make({
id: "perf-readiness",
states: States.states,
events: Machine.events(),
initial: (to) => to.Ready().resolve(({ target }) => target(Ready.make({})))
initial: (to) => to.Ready().resolve(({ target }) => target.from(Ready.make({})))
}).handle({
Flow: {
history: {
recent: {
default: ({ target }) => target.Flow(Flow.make({}), (flow) => flow.Idle(Idle.make({})))
default: ({ target }) => target.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))
}
},
states: {
Idle: {},
Route: {
choice: (to) => to.full.Ready().resolve(({ target }) => target(Ready.make({})))
choice: (to) => to.full.Ready().resolve(({ target }) => target.from(Ready.make({})))
}
}
},
Expand Down
8 changes: 4 additions & 4 deletions perf/types/composition-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ export const machine = Machine.make({
events: Machine.events(),
initial: (to) =>
to.App.initial.resolve(({ target }) =>
target(App.make({}), (app) =>
app.Workspace(
target.from(App.make({}), (app) =>
app.Workspace.from(
Workspace.make({}),
(workspace) =>
workspace
.Editor(Editor.make({}), (editor) => editor.Editing(Editing.make({})))
.Sync(Sync.make({}), (sync) => sync.Idle(SyncIdle.make({})))
.Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({})))
.Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({})))
))
)
})
16 changes: 8 additions & 8 deletions perf/types/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ const handled = machine.handle({
history: {
recent: {
default: ({ target }) =>
target.App(App.make({}), (app) =>
app.Workspace(
target.App.from(App.make({}), (app) =>
app.Workspace.from(
Workspace.make({}),
(workspace) =>
workspace
.Editor(Editor.make({}), (editor) => editor.Editing(Editing.make({})))
.Sync(Sync.make({}), (sync) => sync.Idle(SyncIdle.make({})))
.Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({})))
.Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({})))
))
}
},
Expand Down Expand Up @@ -69,13 +69,13 @@ const handled = machine.handle({
Route: {
choice: (to) =>
to.full.App().resolve(({ target }) =>
target(App.make({}), (app) =>
app.Workspace(
target.from(App.make({}), (app) =>
app.Workspace.from(
Workspace.make({}),
(workspace) =>
workspace
.Editor(Editor.make({}), (editor) => editor.Editing(Editing.make({})))
.Sync(Sync.make({}), (sync) => sync.Idle(SyncIdle.make({})))
.Editor.from(Editor.make({}), (editor) => editor.Editing.from(Editing.make({})))
.Sync.from(Sync.make({}), (sync) => sync.Idle.from(SyncIdle.make({})))
))
)
}
Expand Down
3 changes: 2 additions & 1 deletion perf/types/definition-variants-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ export const States = Machine.states({
export const machine = Machine.make({
states: States.states,
events: Machine.events(Start, Finish),
initial: (to) => to.Flow.initial.resolve(({ target }) => target(Flow.make({}), (flow) => flow.Idle(Idle.make({}))))
initial: (to) =>
to.Flow.initial.resolve(({ target }) => target.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({}))))
})
12 changes: 6 additions & 6 deletions perf/types/definition-variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ const complete = machine.handle({
Flow: {
history: {
recent: {
default: ({ target }) => target.Flow(Flow.make({}), (flow) => flow.Idle(Idle.make({})))
default: ({ target }) => target.Flow.from(Flow.make({}), (flow) => flow.Idle.from(Idle.make({})))
}
},
states: {
Route: {
choice: (to) => to.local.Idle().resolve(({ target }) => target(Idle.make({})))
choice: (to) => to.local.Idle().resolve(({ target }) => target.from(Idle.make({})))
},
Idle: {
on: {
Start: (to) => to.local.Running().resolve(({ target }) => target(Running.make({})))
Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({})))
}
},
Running: {
on: {
Finish: (to) => to.local.Done().resolve(({ event, target }) => target(Done.make({ value: event.value })))
Finish: (to) => to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value })))
}
},
Done: {
Expand All @@ -40,7 +40,7 @@ const idleOnly = machine.handle({
states: {
Idle: {
on: {
Start: (to) => to.local.Running().resolve(({ target }) => target(Running.make({})))
Start: (to) => to.local.Running().resolve(({ target }) => target.from(Running.make({})))
}
}
}
Expand All @@ -52,7 +52,7 @@ const runningOnly = machine.handle({
states: {
Running: {
on: {
Finish: (to) => to.local.Done().resolve(({ event, target }) => target(Done.make({ value: event.value })))
Finish: (to) => to.local.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value })))
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion perf/types/dynamic-invoke-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ export const loadUser = (userId: string) => Effect.fail(new LoadError()).pipe(Ef
export const machine = Machine.make({
states: States.states,
events: Machine.events(),
initial: (to) => to.Loading().resolve(({ target }) => target(Loading.make({ userId: "user-1" })))
initial: (to) => to.Loading().resolve(({ target }) => target.from(Loading.make({ userId: "user-1" })))
})
2 changes: 1 addition & 1 deletion perf/types/exact-channels-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@ export const machine = Machine.make({
internalEvents: Machine.internalEvents(Loaded),
emittedEvents: Machine.emittedEvents(Notice),
input: Input,
initial: (to) => to.Idle().resolve(({ input, target }) => target(Idle.make({ value: input.seed })))
initial: (to) => to.Idle().resolve(({ input, target }) => target.from(Idle.make({ value: input.seed })))
})
4 changes: 2 additions & 2 deletions perf/types/exact-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ const complete = machine.handle({
Start: (to) =>
to.full.Done().resolve(({ event, target }, enqueue) => {
enqueue.emit(Notice.make({ value: event.value }))
return target(Done.make({ value: event.value }))
return target.from(Done.make({ value: event.value }))
}),
Loaded: (to) => to.full.Done().resolve(({ event, target }) => target(Done.make({ value: event.value })))
Loaded: (to) => to.full.Done().resolve(({ event, target }) => target.from(Done.make({ value: event.value })))
}
},
Done: {
Expand Down
Loading