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
6 changes: 6 additions & 0 deletions .changeset/runnable-doc-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@typeonce/effect-machine": patch
"@typeonce/effect-machine-react": patch
---

Every API example in the published source now has a title, and the core package's examples compile and run against the current API.
148 changes: 148 additions & 0 deletions LLMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,154 @@ export const program = Effect.scoped(Effect.gen(function*() {
}))
```

#### Choosing a destination from an event

List every destination in `branches`, next to the `resolve` that selects one.
The compiler checks one against the other, so this transition can be edited
without reading anything else.

```ts
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

export const CheckoutStates = Machine.state({
fields: { attempts: Schema.Number },
states: {
Editing: {},
Charging: { fields: { amount: Schema.Number } },
Paid: {}
}
})

export const CheckoutEvents = Machine.events({
Pay: { amount: Schema.Number },
Paid: {}
})

export const CheckoutMachine = Machine.make({
root: CheckoutStates,
events: CheckoutEvents
}).handle({
root: { attempts: 0 },
initial: { target: "Editing" },
states: {
Editing: {
on: {
Pay: {
// `stay` accepts the event, so `Machine.can` is true. `decline` refuses it, so `Machine.can` is false and a
// parent handler for the same event runs.
branches: [{ target: "Charging" }, { target: "Paid" }, { update: "root" }, { stay: true }, { decline: true }],
resolve: ({ event, root, select }) => {
if (event.amount < 0) return select.decline()
if (event.amount === 0) return select.target("Paid")
if (root.attempts >= 3) return select.stay()
if (event.amount > 1_000) return select.update({ root: { attempts: root.attempts + 1 } })
// Each call takes the values of the path it is given.
return select.target("Charging", { data: { amount: event.amount } })
}
}
}
},
Charging: {
on: {
Paid: { target: "Paid" }
}
}
}
})
```

#### Updating retained state and enqueueing commands

`data` only ever means the target's value. `update` is a record naming the one
retained state it replaces. `commands` enqueues work on any plain transition.

```ts
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

export const DocumentEmitted = Machine.emittedEvents({ Saved: { revision: Schema.Number } })

export const DocumentMachine = Machine.make({
root: Machine.state({
states: {
Document: {
fields: { revision: Schema.Number },
states: {
Editing: {},
Saving: { fields: { draft: Schema.String } }
}
}
}
}),
events: Machine.events({ Change: {}, Save: { draft: Schema.String }, Saved: {} }),
emittedEvents: DocumentEmitted
}).handle({
initial: { target: "Document", data: { revision: 0 } },
states: {
Document: {
initial: { target: "Document.Editing" },
on: {
// Replaces the value of Document and keeps its active child and running work.
Change: { update: { Document: ({ state }) => ({ revision: state.revision + 1 }) } }
},
states: {
Editing: {
on: {
// Enters a state and replaces a retained ancestor in one validated step.
Save: {
target: "Document.Saving",
data: ({ event }) => ({ draft: event.draft }),
update: { Document: ({ ancestors }) => ({ revision: ancestors.Document.revision + 1 }) }
}
}
},
Saving: {
on: {
Saved: {
target: "Document.Editing",
commands: ({ ancestors }, enqueue) =>
enqueue.emit(DocumentEmitted.Saved({ revision: ancestors.Document.revision }))
}
}
}
}
}
}
})
```

#### Verifying a machine after changing it

Run `MachineTest.check` after every change. It needs no test setup, and a
finding names the law it broke and the events that reproduce it.

```ts
import { Machine } from "@typeonce/effect-machine"
import { MachineTest } from "@typeonce/effect-machine/testing"
import { Effect } from "effect"

export const SwitchMachine = Machine.make({
root: Machine.state({ states: { Off: {}, On: {} } }),
events: Machine.events({ Toggle: {} })
}).handle({
initial: { target: "Off" },
states: {
Off: { on: { Toggle: { target: "On" } } },
On: { on: { Toggle: { target: "Off" } } }
}
})

export const verified = Effect.gen(function*() {
const report = yield* MachineTest.check(SwitchMachine)
if (!report.ok) {
return yield* Effect.die(MachineTest.formatCheckReport(report))
}
// "exhaustive" means nothing limited exploration. "bounded" lists why in `limits`, and is not a proof.
return report.exploration
})
```

## Root machine API

A machine has one root state. It can own data, child topology, both, or neither.
Expand Down
55 changes: 55 additions & 0 deletions ai-docs/src/10_modeling/02_branch-on-an-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @title Choosing a destination from an event
*
* List every destination in `branches`, next to the `resolve` that selects one.
* The compiler checks one against the other, so this transition can be edited
* without reading anything else.
*/
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

export const CheckoutStates = Machine.state({
fields: { attempts: Schema.Number },
states: {
Editing: {},
Charging: { fields: { amount: Schema.Number } },
Paid: {}
}
})

export const CheckoutEvents = Machine.events({
Pay: { amount: Schema.Number },
Paid: {}
})

export const CheckoutMachine = Machine.make({
root: CheckoutStates,
events: CheckoutEvents
}).handle({
root: { attempts: 0 },
initial: { target: "Editing" },
states: {
Editing: {
on: {
Pay: {
// `stay` accepts the event, so `Machine.can` is true. `decline` refuses it, so `Machine.can` is false and a
// parent handler for the same event runs.
branches: [{ target: "Charging" }, { target: "Paid" }, { update: "root" }, { stay: true }, { decline: true }],
resolve: ({ event, root, select }) => {
if (event.amount < 0) return select.decline()
if (event.amount === 0) return select.target("Paid")
if (root.attempts >= 3) return select.stay()
if (event.amount > 1_000) return select.update({ root: { attempts: root.attempts + 1 } })
// Each call takes the values of the path it is given.
return select.target("Charging", { data: { amount: event.amount } })
}
}
}
},
Charging: {
on: {
Paid: { target: "Paid" }
}
}
}
})
58 changes: 58 additions & 0 deletions ai-docs/src/10_modeling/03_update-retained-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @title Updating retained state and enqueueing commands
*
* `data` only ever means the target's value. `update` is a record naming the one
* retained state it replaces. `commands` enqueues work on any plain transition.
*/
import { Machine } from "@typeonce/effect-machine"
import { Schema } from "effect"

export const DocumentEmitted = Machine.emittedEvents({ Saved: { revision: Schema.Number } })

export const DocumentMachine = Machine.make({
root: Machine.state({
states: {
Document: {
fields: { revision: Schema.Number },
states: {
Editing: {},
Saving: { fields: { draft: Schema.String } }
}
}
}
}),
events: Machine.events({ Change: {}, Save: { draft: Schema.String }, Saved: {} }),
emittedEvents: DocumentEmitted
}).handle({
initial: { target: "Document", data: { revision: 0 } },
states: {
Document: {
initial: { target: "Document.Editing" },
on: {
// Replaces the value of Document and keeps its active child and running work.
Change: { update: { Document: ({ state }) => ({ revision: state.revision + 1 }) } }
},
states: {
Editing: {
on: {
// Enters a state and replaces a retained ancestor in one validated step.
Save: {
target: "Document.Saving",
data: ({ event }) => ({ draft: event.draft }),
update: { Document: ({ ancestors }) => ({ revision: ancestors.Document.revision + 1 }) }
}
}
},
Saving: {
on: {
Saved: {
target: "Document.Editing",
commands: ({ ancestors }, enqueue) =>
enqueue.emit(DocumentEmitted.Saved({ revision: ancestors.Document.revision }))
}
}
}
}
}
}
})
29 changes: 29 additions & 0 deletions ai-docs/src/10_modeling/04_verify-after-changing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @title Verifying a machine after changing it
*
* Run `MachineTest.check` after every change. It needs no test setup, and a
* finding names the law it broke and the events that reproduce it.
*/
import { Machine } from "@typeonce/effect-machine"
import { MachineTest } from "@typeonce/effect-machine/testing"
import { Effect } from "effect"

export const SwitchMachine = Machine.make({
root: Machine.state({ states: { Off: {}, On: {} } }),
events: Machine.events({ Toggle: {} })
}).handle({
initial: { target: "Off" },
states: {
Off: { on: { Toggle: { target: "On" } } },
On: { on: { Toggle: { target: "Off" } } }
}
})

export const verified = Effect.gen(function*() {
const report = yield* MachineTest.check(SwitchMachine)
if (!report.ok) {
return yield* Effect.die(MachineTest.formatCheckReport(report))
}
// "exhaustive" means nothing limited exploration. "bounded" lists why in `limits`, and is not a proof.
return report.exploration
})
4 changes: 2 additions & 2 deletions packages/effect-machine-react/src/MachineAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export declare namespace MachineStateProps {
* and the callback snapshot are then those of the child machine, and
* `inactive` also renders while the state that owns the child is not active.
*
* **Example**
* **Example** (Rendering a child's state)
*
* ```tsx
* <MachineState machine={machine.child(QuantityChild)} path="" inactive={null}>
Expand Down Expand Up @@ -202,7 +202,7 @@ export declare namespace MachineContext {
* update a running workflow, or change the Provider's React `key` to create a
* new owner with new input and layer.
*
* **Example**
* **Example** (Creating a machine context for each ownership style)
*
* ```tsx
* const Auth = createMachineContext(AtomMachine.factory(AuthMachine))
Expand Down
10 changes: 6 additions & 4 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ Prefer `ref.join` for final output, invocation `onDone` / `onFailure` for workfl
behavior, atom selectors for UI reads, and `ref.changes` for continuous observation.

```ts
const ready = yield * Machine.waitFor(ref, isReady).pipe(Effect.timeout("5 seconds"))
const ready = Machine.waitFor(ref, isReady).pipe(Effect.timeout("5 seconds"))
```

The predicate runs first, including for terminal snapshots. An unmatched failure
Expand Down Expand Up @@ -588,9 +588,11 @@ encoded snapshots are rejected; migrate persisted data explicitly before decodin
Snapshots can be validated for storage or transport:

```ts
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
const decoded = yield * Machine.decodeSnapshot(machine, encoded)
const ref = yield * Machine.resume(machine, decoded) // owned by the current Scope
const resumed = Effect.gen(function*() {
const encoded = yield* Machine.encodeSnapshot(machine, snapshot)
const decoded = yield* Machine.decodeSnapshot(machine, encoded)
return yield* Machine.resume(machine, decoded) // owned by the current Scope
})
```

Decoded snapshots are local runtime values and may contain class instances or
Expand Down
Loading
Loading