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

Replace `Machine.transition(...)` with fluent transition selectors supplied directly to inline handlers. Select a target and optionally attach its resolver, reentry, or named branches without an intermediate wrapper:

```ts
Start: ;
;((to) => to.full.Running().resolve(({ event, target }) => target.from({ count: event.count })))

Route: ;
;((to) =>
to.branches({
running: { target: to.full.Running() },
done: { target: to.full.Done() },
unchanged: { target: to.none() }
}).resolve(({ event, select }) => event.cached ? select.done.from() : select.running.from()))
```

Use `.reenter()` for resolver-free reentry, or pass literal `declinable: true` to `.resolve(...)` when the resolver must receive `decline()`. Bare targets are accepted only when their schemas support default construction.

Remove the machine-definition `.invoke(...)` method. Use `Machine.invoke(...)` in every state; it now retains the owning state, event, parent-event, output, error, element, snapshot, and service inference directly inside `handle(...)`.
116 changes: 45 additions & 71 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,22 +75,13 @@ const CounterDefinition = Machine.make({
const Counter = CounterDefinition.handle({
Idle: {
on: {
Start: Machine.transition({
target: (to) => to.full.Running(),
resolve: ({ target }) => target.from({ count: 0 })
})
Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 }))
}
},
Running: {
on: {
Increment: Machine.transition({
target: (to) => to.full.Running(),
resolve: ({ state, target }) => target.from({ count: state.count + 1 })
}),
Stop: Machine.transition({
target: (to) => to.full.Idle(),
resolve: ({ target }) => target.from()
})
Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })),
Stop: (to) => to.full.Idle().resolve(({ target }) => target.from())
}
}
})
Expand Down Expand Up @@ -153,13 +144,13 @@ When sibling states share fields, remove the source discriminator and pass the
remaining fields through the target schema:

```ts
Submit: Machine.transition({
target: (to) => to.local.Saving(),
resolve: ({ state, target }) => {
const { _tag: _, ...fields } = state
return target.from({ ...fields, attempt: 1 })
}
})
const handlers = {
Submit: (to) =>
to.local.Saving().resolve(({ state, target }) => {
const { _tag: _, ...fields } = state
return target.from({ ...fields, attempt: 1 })
})
}
```

Omit `schema` when a state represents control flow but owns no data:
Expand Down Expand Up @@ -339,15 +330,13 @@ const child = Machine.make({
}).handle({
Working: {
on: {
Finish: Machine.transition({
target: (to) => to.full.Done(),
resolve: ({ parent, target }, enqueue) => {
Finish: (to) =>
to.full.Done().resolve(({ parent, target }, enqueue) => {
if (parent !== undefined) {
enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" }))
}
return target.from()
}
})
})
}
},
Done: {}
Expand Down Expand Up @@ -383,25 +372,31 @@ paths. `parent` always means the owning machine target.
| `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root |
| `target.history` | Restoring a declared history node | The remembered configuration or its typed default |

Every required transition handler returns either a concrete target or
`target.none()`. An absent handler ignores the trigger; `target.none()` handles
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
it and retains queued commands, raised events, and emitted events without
selecting a destination. Declared `targets` constrain only concrete
destinations, so `target.none()` is always permitted. Builders describe the
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; use `{ reenter: true, transition }` when the source must restart. With
`target.none()`, reentry restarts the source while retaining its configuration.
change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }`
to `.resolve(...)` when the source must restart. With `to.none()`, reentry
restarts the source while retaining its configuration.

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:

```ts
Submit: Machine.transition({
declinable: true,
target: (to) => to.local.Saving(),
resolve: ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline()
})
const handlers = {
Submit: (to) =>
to.local.Saving().resolve(
({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline(),
{ declinable: true }
)
}
```

Declining discards work enqueued by that resolver. Event and eventless dispatch
Expand Down Expand Up @@ -444,25 +439,16 @@ Loading: {
invoke: Machine.invoke({
id: "save-document",
effect: () => saveDocument,
onDone: Machine.transition({
target: (to) => to.full.Saved(),
resolve: ({ output, target }) => target.from({ id: output.id })
}),
onFailure: Machine.transition({
target: (to) => to.full.Failed(),
resolve: ({ error, target }) => target.from({ message: String(error) })
})
onDone: (to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id })),
onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))
})
}

Waiting: {
invoke: Machine.invoke({
id: "save-timeout",
after: "3 seconds",
onDone: Machine.transition({
target: (to) => to.full.Failed(),
resolve: ({ target }) => target.from({ message: "Timed out" })
})
onDone: (to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))
})
}
```
Expand All @@ -478,14 +464,8 @@ for state-dependent Effects:
invoke: Machine.invoke({
id: "load-document",
effect: ({ state }) => loadDocument(state.documentId),
onDone: Machine.transition({
target: (to) => to.full.Ready(),
resolve: ({ output, target }) => target.from({ document: output })
}),
onFailure: Machine.transition({
target: (to) => to.full.Failed(),
resolve: ({ error, target }) => target.from({ message: error.message })
})
onDone: (to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output })),
onFailure: (to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message }))
})
```

Expand All @@ -504,17 +484,15 @@ invoke: Machine.invoke({
}
},
onDone: { target: Machine.targetless },
onFailure: Machine.transition({
target: (to) => to.full.Failed(),
resolve: ({ error, target }) => target.from({ error })
})
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
`Machine.transition(...)` for transitions that select state or reenter.
the same fluent `to` builder to select a target for transitions that change
state or reenter.

Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's
public input and `parentEvents` protocols contextually. Its source and lifecycle
Expand All @@ -532,27 +510,23 @@ const machine = Machine.make({
invoke: Machine.invoke({
id: "notify-parent",
effect: () => saveDocument,
onDone: Machine.transition({
target: (to) => to.none(),
resolve: ({ parent, self }, enqueue) => {
onDone: (to) =>
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: Machine.transition({
target: (to) => to.none(),
resolve: () => undefined
})
}),
onFailure: (to) => to.none()
})
}
})
```

The machine-bound `definition.invoke(...)` form remains equivalent when a
definition is already named; it is not required for `self` or `parent` typing.
The standard `Machine.invoke(...)` form retains exact `self` and `parent`
typing even when the definition is named separately; no intermediate
definition method is required.

A direct `invoke: { ... }` object is also supported when its lifecycle handlers
do not need source-derived context. Reuse one exported
Expand Down
3 changes: 1 addition & 2 deletions api-reference.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@
"resume",
"state",
"start",
"states",
"transition"
"states"
]
},
{
Expand Down
Loading