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
8 changes: 8 additions & 0 deletions .changeset/calm-transitions-compose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@typeonce/effect-machine": minor
"@typeonce/oxlint-plugin-effect-machine": patch
---

Construct atomic destination and retained-owner updates with `.updating(owner).from(({ current, event }) => ({ target, update }))`, or use `.decoded(...)` for decoded values. Both values are complete replacements; `.resolve(...)` remains available for explicit configuration builders and commands. Value updates and atomic transitions now support `.guard(...)`, declining before construction and commands while preserving ancestor fallback.

Use chainable `.reenter()` before `.from(...)`, `.decoded(...)`, or `.resolve(...)` to force source exit and entry. Replace `.resolve(callback, { reenter: true })` with `.reenter().resolve(callback)` and omit reentry entirely when it is false. Named branching transitions use `.branches(...).reenter().resolve(...)`. The redundant-resolver lint rule preserves these modifiers when simplifying default construction.
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export const transitionSemanticsMachine = Machine.make({
? select.publish.decoded(new WorkspaceFinished({ result: "published directly" }))
: select.review.decoded(new Review({ requestedBy: event.requestedBy }))
),
Refresh: (to) => to.none.resolve(() => undefined, { reenter: true }),
Refresh: (to) => to.none.reenter(),
Ignore: (to) => to.none,
MaybeHandle: (to) =>
to.none.resolve(({ decline, event }) => event.accept ? undefined : decline(), {
Expand Down
45 changes: 23 additions & 22 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,9 @@ 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
change; call `.reenter()` before `.from(...)`, `.decoded(...)`, or `.resolve(...)`
when the source must restart. Default-constructible targets can finish at
`.reenter()`. With `to.none`, reentry
restarts the source while retaining its configuration.

Topology-only definition instructions are values: `to.none`, declared
Expand All @@ -430,7 +431,7 @@ handler source:

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

Expand Down Expand Up @@ -463,30 +464,28 @@ 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 }))
)
)
.from(({ current, event }) => ({
target: { request: { _tag: "Create", input: event.input } },
update: { ...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 retained valued owner and makes its replacement mandatory. `.from(...)`
returns `{ target, update }` with constructor inputs for both values;
`.decoded(...)` returns already decoded values for both. `current` is that
owner's decoded value from the pre-transition snapshot. Use `.resolve(...)`
when constructing explicit children, mixing construction methods, or queuing
commands; its `target` and `owner` builders construct the two values.

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.
Named branches support value-only updates; a combined update declares its
destination directly.

For a schema-less destination, construction remains explicit:

Expand All @@ -511,11 +510,13 @@ 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.
Use `.guard(predicate)` before construction to decline an update without
constructing values or queuing commands. It is available on standalone and
combined updates. A false guard allows ancestor fallback. A resolver may also
return `decline()` with `{ declinable: true }` for decisions during resolution.
Call `.reenter()` before construction on event or invocation transitions when
the handler source should exit and enter again. Reentry applies to that source,
not to the retained 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
45 changes: 45 additions & 0 deletions packages/effect-machine/docs/root-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,51 @@ an event without changing topology; declining and accepting have different
semantics. Named branches keep `{ target, title? }`: `target` identifies the
checked destination and `title` supplies optional presentation metadata.

Guards also apply to standalone owner updates and combined transitions:

```ts
Increment: (to) => to.self.update
.guard(({ current }) => current.count < 10)
.from(({ current }) => ({ ...current, count: current.count + 1 }))

Save: (to) => to.local.Saving().updating(to.root)
.guard(({ current }) => current.draft.length > 0)
.from(({ current }) => ({
target: { requestId: current.draft },
update: { ...current, attempts: current.attempts + 1 }
}))
```

Combined `.from` returns constructor inputs for both the destination and the
complete owner replacement. `.decoded` returns their decoded values instead.
For a destination with no construction arguments, use `target: undefined`.
Both values are validated before applying either change; destination entry
observes the updated owner. Use `.resolve` for mixed construction methods,
explicit child configurations, or commands.

Reentry is a modifier before construction:

```ts
Retry: (to) => to.local.Saving().reenter()
.from(({ event }) => ({ requestId: event.requestId }))

Refresh: (to) => to.none.reenter()

Choose: (to) => to.branches({
saving: { target: to.local.Saving() },
idle: { target: to.local.Idle() }
}).reenter().resolve(({ state, select }) =>
state.retry ? select.saving.from({ requestId: state.requestId }) : select.idle.from()
)
```

`.reenter()` restarts the handler source. It composes with `.updating`, `.guard`,
`.from`, `.decoded`, and `.resolve` wherever reentry is supported. Apply it to
the whole named-branches builder, whose individual targets describe topology.
Migrate `.resolve(callback, { reenter: true })` to `.reenter().resolve(callback)`;
remove `{ reenter: false }`. `to.self.update` retains the source lifecycle and
does not expose `.reenter()`.

## Completion and history

A compound root returns its completed direct workflow's output when that child
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ export const makeEffectMachineBenchmarkApi = (Machine) => {

const fluentTransition = (definition) => (to) => {
const selection = selectInstruction(definition.target(hasRoot ? { ...to, full: to.branch } : to))
const reentered = definition.reenter === true && typeof selection.reenter === "function" ? selection.reenter() : undefined
if (definition.resolve !== undefined) {
return selection.resolve(definition.resolve, {
...(definition.reenter === true ? { reenter: true } : {}),
const chainable = reentered !== undefined && typeof reentered.resolve === "function"
return (chainable ? reentered : selection).resolve(definition.resolve, {
...(definition.reenter === true && !chainable ? { reenter: true } : {}),
...(definition.declinable === true ? { declinable: true } : {})
})
}
return definition.reenter === true ? selection.reenter() : selection
return reentered ?? selection
}

const fluentInitial = (definition) => (to) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Schema } from "effect"
import { Machine } from "../../dist/index.js"

export class Root extends Schema.TaggedClass<Root>("Root")("Root", { count: Schema.Number }) {}
export class Saved extends Schema.TaggedClass<Saved>("Saved")("Saved", { text: Schema.String }) {}

export const machine = Machine.make({
root: Machine.state({
schema: Root,
initial: "Idle",
states: { Idle: {}, Saved: { schema: Saved } }
}),
events: Machine.events({ Save: { text: Schema.String }, Retry: {}, Reset: {} }),
initial: (root) => root.from(() => ({ count: 0 }))
})
32 changes: 32 additions & 0 deletions packages/effect-machine/perf/types/transition-construction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Machine } from "../../dist/index.js"
import { machine, Root, Saved } from "./transition-construction-control.js"

const handled = machine.handle({
on: {
Reset: (to) => to.self.update.guard(({ current }) => current.count > 0).from(() => ({ count: 0 }))
},
states: {
Idle: {
on: {
Save: (to) =>
to.local.Saved().updating(to.root).guard(({ event }) => event.text.length > 0)
.from(({ current, event }) => ({ target: { text: event.text }, update: { count: current.count + 1 } }))
}
},
Saved: {
on: {
Save: (to) =>
to.local.Saved().updating(to.root).reenter().decoded(({ current, event }) => ({
target: new Saved({ text: event.text }),
update: new Root({ count: current.count + 1 })
})),
Retry: (to) => to.local.Saved().reenter().from(({ state }) => ({ text: state.text })),
Reset: (to) =>
to.branches({ idle: { target: to.local.Idle() }, same: { target: to.none } }).reenter()
.resolve(({ state, select }) => state.text.length === 0 ? select.idle.from() : select.same())
}
}
}
})

void Machine.planInitial(handled)
Loading