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
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema, Stream } from "effect"

const State = Schema.TaggedUnion({
Idle: {},
Running: { count: Schema.Number }
})

const States = Machine.states(State.cases)
const States = Machine.states({
Idle: {},
Running: State.cases.Running
})

const CounterEvent = Machine.events(
Schema.TaggedUnion({
Start: {},
Expand Down Expand Up @@ -127,6 +130,23 @@ or `Machine.Snapshot<typeof machine>`, schema-backed state payloads as
`Machine.Value<typeof States, Path>`, and path-rooted snapshots as
`Machine.SnapshotAt<typeof States, Path>`.

### Make invalid states unrepresentable

Treat topology as a domain contract, not as file organization. A parallel state
declares the full Cartesian product of its regions, so use it only when every
combination has a coherent meaning. If one region must inspect another before
entering a state safely, prefer a compound hierarchy that makes the forbidden
combination impossible. `matches` remains useful for views, tests, and genuine
coordination between independent regions; it should not repair an invalid
state product.

Keep state-scoped Effects beneath the state that guarantees their resources,
and enforce command availability in the machine rather than only by disabling
UI controls. When entering an inactive compound or parallel state's declared
default, select `.initial`; explicitly construct descendants only for a
non-default configuration or a complete replacement of an already-active
parallel root.

### Construct state through builders

Use `.from(...)` when constructing a new state from fields:
Expand All @@ -153,7 +173,8 @@ const handlers = {
}
```

Omit `schema` when a state represents control flow but owns no data:
Omit `schema` when a state represents control flow but owns no data. Use `{}`
instead of defining an empty tagged schema:

```ts
const States = Machine.states({
Expand All @@ -178,6 +199,11 @@ Schema-less states remain active, targetable, matchable, and visible through
their handler `state` is `undefined`, and `get` / `getWithParents` accept only
schema-backed paths. Add a schema later if the state starts owning data.

Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and
reference its cases from the topology. For a standalone state schema whose
class identity is useful, declare a named `Schema.TaggedClass`. Do not bury
one-off tagged schema declarations inside `Machine.states`.

Put data on the narrowest state where it is valid. If sibling phases share
data, put it on their compound parent.

Expand Down Expand Up @@ -646,11 +672,11 @@ Each ESM entrypoint is independent and tree-shakeable.
Every package directly under [`examples/`](./examples) has its own lockfile and
`check` script.

| Example | What it demonstrates |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, microwave safety across parallel regions, a service-backed media player, and a worker-hosted machine synchronized across tabs |
| [Pokémon](./examples/pokemon) | Compound and parallel states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
| [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |
| Example | What it demonstrates |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Playground](./examples/playground) | Five focused React examples: atomic turnstile commands, state-scoped traffic-light timers, hierarchical microwave safety, a resource-owned media player, and a worker-hosted machine synchronized across tabs |
| [Pokémon](./examples/pokemon) | Compound workflow states, invoked child machines, typed emissions, Atom reactivity, and a live Effect service |
| [Platformer](./examples/platformer) | Nested parallel statecharts, typed deep history, raised events, state-scoped timers, deterministic model tests, and a playable SVG adapter |

The playground is the shortest path from one concept to working code. The
standalone examples show larger composition and ownership boundaries.
Expand Down
64 changes: 56 additions & 8 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ testing, or simulation variant.

```ts
const State = Schema.TaggedUnion({
Idle: {},
Saving: { draft: Draft },
Failed: { message: Schema.String }
})

const States = Machine.states(State.cases)
const States = Machine.states({
Idle: {},
Saving: State.cases.Saving,
Failed: State.cases.Failed
})
export const Event = Machine.events(
Schema.TaggedUnion({
Save: {}
Expand Down Expand Up @@ -140,6 +143,23 @@ its extra control is required:

## Atomic, compound, parallel, and history states

### Topology is a validity boundary

Design the state tree so invalid domain situations cannot be constructed. A
parallel node is not merely a convenient grouping of related concepts: it
declares the Cartesian product of its regions. Every combination must be
meaningful in snapshots, explicit targets, decoding, and resume.

If a handler reads a sibling region to decide whether entering its target is
legal, treat that as a topology smell and try a compound hierarchy first. Do
not move the same invariant into a disabled UI control, redundant event field,
or invoked-service failure. Cross-region reads remain useful for coordinating
genuinely independent regions and for projecting snapshots into views.

Place an invoked Effect or resource-dependent state beneath the state that
guarantees the resource exists. Exiting the owner should structurally exit and
interrupt all dependent work.

### Inline topology by default; extract only repeated states

Prefer writing the complete topology inline in `Machine.states`. A one-off
Expand Down Expand Up @@ -234,7 +254,8 @@ machine-bound forms over `.cases.Case.Type`, `typeof States.states`, or
composing `Machine.Machine.States` with raw-tree path extractors.

An active state does not need a schema unless it owns data. Omit `schema` for
control-only atomic, compound, parallel, and final states:
control-only atomic, compound, parallel, and final states. In particular, use
`{}` instead of an empty tagged-union case or tagged class:

```ts
const States = Machine.states({
Expand All @@ -258,11 +279,7 @@ in snapshots. They do not have a state value:
```ts
Idle: {
on: {
Start: (to) =>
to.full.Form.initial.resolve(({ state, target }) => {
// state: undefined
return target.from((form) => form.Editing.from())
})
Start: (to) => to.full.Form.initial
}
}

Expand All @@ -277,6 +294,13 @@ also omitted from `ancestors`; an immediate structural containing state is
typed as `undefined`. Add `schema` when a state begins to own data or needs runtime
validation and persistence for that data.

Declare data-bearing states together with the named
`const State = Schema.TaggedUnion(...)` pattern, then reference `State.cases`
from the topology. This keeps the state value protocol visible and reusable.
Use a named `Schema.TaggedClass` instead when a standalone state benefits from
class identity. Do not bury one-off tagged schema declarations inside
`Machine.states`.

Use an atomic state when no child phase can be active beneath it.

Use a compound state when exactly one child phase is active. It must declare an
Expand Down Expand Up @@ -326,6 +350,11 @@ Every parallel region needs an active state in initial and full snapshot
builders. The same rule applies when a local or branch target enters an
inactive nested parallel state.

This is also a semantic product: `Online + Closed`, `Online + Open`,
`Offline + Closed`, and `Offline + Open` are all valid configurations in the
example above. If even one combination must be prevented for correctness, use
a compound hierarchy or redesign the regions.

Use `type: "final"` for a terminal leaf in `Machine.states`. A final
child completes its compound parent. Put `onDone` on that completed parent,
never on the final leaf. The definition owns the output schema and the handler
Expand Down Expand Up @@ -363,6 +392,20 @@ with `.initial`. This is available on top-level state methods under
Open: (to) => to.full.opened.initial.resolve(({ target }) => target.from({ teamId: "team-1" }))
```

Return the `.initial` transition directly when the selected state owns no data
and the transition has no commands to enqueue:

```ts
Close: (to) => to.full.closed.initial
```

Do not manually reconstruct the declared initial descendants at ordinary entry
transitions. Reserve explicit descendant builders for deliberately non-default
configurations and for replacing an already-active parallel root with one
complete canonical configuration. `Machine.make({ initial })` still constructs
the first complete snapshot through its initial selector; that selector
statically restricts a compound node to its declared initial child.

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

Expand Down Expand Up @@ -624,6 +667,11 @@ Use the existing `States.matches`, `States.get`, `States.getWithParents`, and
selected in one microstep receive the same capture. Synchronous handlers use
that captured value and cannot consult live runtime state later.

Before using a cross-region read to permit or reject a target, verify that all
combinations of the parallel regions are valid. If the check excludes an
invalid combination, move the invariant into a compound hierarchy. Observer,
view, diagnostic, and test queries do not have this concern.

Do not expect `snapshot` in entry, exit, invoke, initializer, history-default,
or choice contexts. Choice is an important soundness boundary: a startup or
chained choice can run without a complete stable configuration containing the
Expand Down
22 changes: 12 additions & 10 deletions examples/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ pnpm check

## Examples

| Route | Machine concept | Integration concept |
| ---------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `/turnstile` | Atomic states, typed commands, ignored events | Service-free `AtomMachine` |
| `/traffic-light` | Internal events, cancellable state-scoped timers, re-entry | Reactive timer-driven rendering |
| `/microwave` | Parallel regions, simultaneous transitions, conditional behavior | Safety-oriented controls |
| `/media-player` | Nested compound/parallel states and state-scoped Effects | Shared Atom runtime, DOM audio, Web Audio service |
| `/worker-tabs` | A machine hosted outside the UI thread | Schema-validated worker messages and `BroadcastChannel` synchronization |
| Route | Machine concept | Integration concept |
| ---------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `/turnstile` | Atomic states, typed commands, ignored events | Service-free `AtomMachine` |
| `/traffic-light` | Internal events, cancellable state-scoped timers, re-entry | Reactive timer-driven rendering |
| `/microwave` | Compound hierarchy and unrepresentable invalid states | Safety-oriented controls |
| `/media-player` | Resource-owned compound states and an independent settings region | Shared Atom runtime, DOM audio, Web Audio service |
| `/worker-tabs` | A machine hosted outside the UI thread | Schema-validated worker messages and `BroadcastChannel` synchronization |

Each route keeps its machine, adapter, and supporting protocol beside the page.
The machines own legal behavior; components project snapshots and send typed
Expand All @@ -35,10 +35,12 @@ public commands.
- The traffic light exposes `Reset` publicly while timer deliveries stay in
`internalEvents`.
- The microwave stores elapsed time only on `Cooking`, where it is valid.
`DoorOpened` is handled by both active parallel regions, opening the door and
stopping the engine in the same macrostep.
`Cooking` is nested below `Closed`, so opening the door exits and interrupts
cooking and `Cooking + Open` cannot be represented.
- The media player keeps browser APIs behind an Effect service. Its invoked
work returns typed internal events to the deterministic transition core.
transport is nested below the registered audio session that it requires,
while sound settings remain an independent parallel region. Invoked work
returns typed internal events to the deterministic transition core.
- The worker validates unknown incoming messages with Effect Schema before
forwarding public events. Tabs replicate commands and exchange a typed
synchronization state when a tab joins.
Expand Down
11 changes: 6 additions & 5 deletions examples/playground/src/examples/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("playground machines", () => {
yield* ref.stop
}))

it.effect("interrupts cooking and opens the door in one parallel macrostep", () =>
it.effect("makes cooking with an open door unreachable", () =>
Effect.gen(function*() {
const trace = yield* MachineTest.run(MicrowaveMachine, {
events: [
Expand All @@ -56,11 +56,12 @@ describe("playground machines", () => {

yield* MachineTest.verify(MicrowaveMachine, trace)
const opened = trace.steps[1]?.after
assert.strictEqual(opened?.states.engine.state.path, "Oven.engine.Idle")
assert.strictEqual(opened?.states.door.state.path, "Oven.door.Open")
assert.strictEqual(opened?.state.path, "Oven.Open")
assert.deepStrictEqual(trace.steps[2]?.before, trace.steps[2]?.after)
assert.strictEqual(trace.final.states.engine.state.path, "Oven.engine.Cooking")
assert.strictEqual(trace.final.states.door.state.path, "Oven.door.Closed")
assert.strictEqual(trace.final.state.path, "Oven.Closed")
if (trace.final.state.path === "Oven.Closed") {
assert.strictEqual(trace.final.state.state.path, "Oven.Closed.Cooking")
}
}))

it.effect("restores worker state from a tab synchronization command", () =>
Expand Down
Loading