From 3196be045b7c5e0fab70ad2820e9f73dec9f3bb1 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 7 Nov 2026 15:29:46 +0900 Subject: [PATCH] Make every API example runnable and titled Convert the remaining JSDoc examples to titled doctests with assertions, which also removes examples the API had outgrown, and fail the type check on any example that is unmarked or untitled so this cannot regress. Add compiled guide examples for branching, retained updates, and verification. Co-Authored-By: Claude Opus 5.5 --- .changeset/runnable-doc-examples.md | 6 + LLMS.md | 148 ++++++ .../src/10_modeling/02_branch-on-an-event.ts | 55 +++ .../10_modeling/03_update-retained-state.ts | 58 +++ .../10_modeling/04_verify-after-changing.ts | 29 ++ .../effect-machine-react/src/MachineAtom.ts | 4 +- packages/effect-machine/README.md | 10 +- packages/effect-machine/src/Machine.ts | 446 +++++++++++------- .../effect-machine/src/testing/MachineTest.ts | 183 +++---- .../src/unstable/cluster/ClusterMachine.ts | 23 +- .../src/unstable/reactivity/AtomMachine.ts | 237 +++++++--- scripts/doc-examples/check.mjs | 25 +- 12 files changed, 842 insertions(+), 382 deletions(-) create mode 100644 .changeset/runnable-doc-examples.md create mode 100644 ai-docs/src/10_modeling/02_branch-on-an-event.ts create mode 100644 ai-docs/src/10_modeling/03_update-retained-state.ts create mode 100644 ai-docs/src/10_modeling/04_verify-after-changing.ts diff --git a/.changeset/runnable-doc-examples.md b/.changeset/runnable-doc-examples.md new file mode 100644 index 00000000..75b029b5 --- /dev/null +++ b/.changeset/runnable-doc-examples.md @@ -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. diff --git a/LLMS.md b/LLMS.md index 48493a36..f3032f1c 100644 --- a/LLMS.md +++ b/LLMS.md @@ -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. diff --git a/ai-docs/src/10_modeling/02_branch-on-an-event.ts b/ai-docs/src/10_modeling/02_branch-on-an-event.ts new file mode 100644 index 00000000..9909cf45 --- /dev/null +++ b/ai-docs/src/10_modeling/02_branch-on-an-event.ts @@ -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" } + } + } + } +}) diff --git a/ai-docs/src/10_modeling/03_update-retained-state.ts b/ai-docs/src/10_modeling/03_update-retained-state.ts new file mode 100644 index 00000000..5deef7af --- /dev/null +++ b/ai-docs/src/10_modeling/03_update-retained-state.ts @@ -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 })) + } + } + } + } + } + } +}) diff --git a/ai-docs/src/10_modeling/04_verify-after-changing.ts b/ai-docs/src/10_modeling/04_verify-after-changing.ts new file mode 100644 index 00000000..9a6b4fcf --- /dev/null +++ b/ai-docs/src/10_modeling/04_verify-after-changing.ts @@ -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 +}) diff --git a/packages/effect-machine-react/src/MachineAtom.ts b/packages/effect-machine-react/src/MachineAtom.ts index 75c34d03..ef05e593 100644 --- a/packages/effect-machine-react/src/MachineAtom.ts +++ b/packages/effect-machine-react/src/MachineAtom.ts @@ -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 * @@ -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)) diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 69aaf710..96243d60 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -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 @@ -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 diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index ad212027..97134f78 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -312,18 +312,27 @@ export interface Definition< * captured by value, so later mutation of the supplied objects cannot alter * the resulting machine. * - * **Example** - * - * ```ts - * const counter = definition.handle({ states: { - * Count: { - * on: { - * Increment: { - * update: { Count: {} } - * } - * } - * } - * } }) + * **Example** (Adding handlers to a definition) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Effect, Schema } from "effect" + * + * const Events = Machine.events({ Increment: { by: Schema.Number } }) + * const definition = Machine.make({ root: Machine.state({ fields: { count: Schema.Number } }), events: Events }) + * + * const counter = definition.handle({ + * root: { count: 0 }, + * on: { Increment: { update: { root: ({ root, event }) => ({ count: root.count + event.by }) } } } + * }) + * + * const count = Effect.gen(function*() { + * const initial = yield* Machine.planInitial(counter) + * const planned = yield* Machine.plan(counter, initial.state, Events.Increment({ by: 2 })) + * return planned.next.value.count + * }) + * + * await Effect.runPromise(count) // => 2 * ``` * * @since 0.4.0 @@ -7864,14 +7873,13 @@ export const isFinal: < * * **Example** (Repeated compound state) * - * ```ts + * **Example** (Reusing one subtree in two parallel regions) + * + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Schema } from "effect" + * import { Effect, Schema } from "effect" * - * const TradingState = Schema.TaggedUnion({ - * InSession: {}, - * Applying: {} - * }) + * const TradingState = Schema.TaggedUnion({ InSession: {}, Applying: {} }) * const TradingSlot = Machine.state({ * states: { * Idle: {}, @@ -7879,13 +7887,21 @@ export const isFinal: < * Applying: TradingState.cases.Applying * } * }) + * const States = Machine.state({ type: "parallel", states: { slot1: TradingSlot, slot2: TradingSlot } }) * - * const States = Machine.state({ + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ * states: { - * slot1: TradingSlot, - * slot2: TradingSlot + * slot1: { initial: { target: "slot1.Idle" } }, + * slot2: { initial: { target: "slot2.Idle" } } * } * }) + * + * const paths = Effect.map(Machine.planInitial(machine), ({ state }) => [ + * state.states.slot1.state.path, + * state.states.slot2.state.path + * ]) + * + * await Effect.runPromise(paths) // => ["slot1.Idle", "slot2.Idle"] * ``` * * @category constructors @@ -8072,39 +8088,39 @@ interface Make { * * **Example** (Typed counter machine) * - * ```ts + * **Example** (Counting with schema classes) + * + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Schema } from "effect" - * class Count extends Schema.TaggedClass("Count")("Count", { - * value: Schema.Number - * }) { - * } - * class Increment extends Schema.TaggedClass("Increment")("Increment", { - * by: Schema.Number - * }) { - * } + * import { Effect, Option, Schema } from "effect" + * + * class Count extends Schema.TaggedClass("Count")("Count", { value: Schema.Number }) {} + * class Increment extends Schema.TaggedClass("Increment")("Increment", { by: Schema.Number }) {} + * * const States = Machine.state({ states: { Count } }) - * const Events = Machine.eventsFromSchemas(Increment) * const counter = Machine.make({ * root: States, - * events: Events + * events: Machine.eventsFromSchemas(Increment) * }).handle({ - * initial: { - * target: "Count", - * decoded: true, - * data: new Count({ value: 0 }) - * }, + * initial: { target: "Count", decoded: true, data: new Count({ value: 0 }) }, * states: { * Count: { * on: { * Increment: { - * update: { Count: { decoded: true, data: ({ event, state }) => new Count({ value: state.value + event.by }) } }, - * decoded: true + * update: { Count: { decoded: true, data: ({ event, state }) => new Count({ value: state.value + event.by }) } } * } * } * } * } * }) + * + * const value = Effect.gen(function*() { + * const initial = yield* Machine.planInitial(counter) + * const planned = yield* Machine.plan(counter, initial.state, new Increment({ by: 2 })) + * return States.get(planned.next, "Count").pipe(Option.map((count) => count.value)) + * }) + * + * await Effect.runPromise(value) // => Option.some(2) * ``` * * @see {@link state} for typed state-tree helpers. @@ -8133,17 +8149,33 @@ export type EventOf = Machine.EventO * enter the machine's `MachineSchemaDecodeError` channel rather than throwing * at the call site. * - * **Example** + * **Example** (Reusing a tagged union as the event protocol) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Effect, Schema } from "effect" * - * ```ts - * export const Event = Machine.eventsFromSchemas( + * const Event = Machine.eventsFromSchemas( * Schema.TaggedUnion({ * Increment: { by: Schema.Number }, * Reset: {} * }) * ) - * const machine = Machine.make({ events: Event, ... }) - * yield* ref.send(Event.Increment({ by: 1 })) + * const machine = Machine.make({ root: Machine.state({ fields: { count: Schema.Number } }), events: Event }).handle({ + * root: { count: 0 }, + * on: { + * Increment: { update: { root: ({ root, event }) => ({ count: root.count + event.by }) } }, + * Reset: { update: { root: { count: 0 } } } + * } + * }) + * + * const count = Effect.scoped(Effect.gen(function*() { + * const ref = yield* Machine.start(machine) + * yield* ref.send(Event.Increment({ by: 1 })) + * return (yield* ref.state).value.count + * })) + * + * await Effect.runPromise(count) // => 1 * ``` * * @category constructors @@ -8165,9 +8197,16 @@ type ValidateEventFields = { /** Defines a public event protocol from tagged field records. * - * ```ts + * **Example** (Defining events from fields) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Schema } from "effect" + * * const Events = Machine.events({ Increment: { by: Schema.Number }, Reset: {} }) - * const increment = Events.Increment({ by: 1 }) + * + * Events.Increment({ by: 1 })._tag // => "Increment" + * Events.Reset()._tag // => "Reset" * ``` * * Constructors defer schema validation until delivery. Use eventsFromSchemas @@ -8224,19 +8263,39 @@ export const optionalParent: enqueue.raise(Internal.Loaded({ value: event.value })) }, + * Loaded: { update: { root: ({ event }) => ({ value: event.value }) } }, + * Failed: { stay: true } + * } + * }) * - * // Inside a transition callback: - * enqueue.raise(Internal.Loaded({ value })) + * const value = Effect.gen(function*() { + * const initial = yield* Machine.planInitial(machine) + * const planned = yield* Machine.plan(machine, initial.state, Events.Load({ value: "ready" })) + * return planned.next.value.value + * }) + * + * await Effect.runPromise(value) // => "ready" * ``` * * @category constructors @@ -8265,15 +8324,38 @@ export const internalEventsFromSchemas: { * implicitly to a parent machine. Observe them through `MachineRef.emissions` or * the AtomMachine emission stream adapters. * - * **Example** + * **Example** (Emitting an event to observers) * - * ```ts + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Effect, Schema } from "effect" + * + * const Events = Machine.events({ Save: { id: Schema.String } }) * const Emitted = Machine.emittedEventsFromSchemas( * Schema.TaggedUnion({ * Saved: { id: Schema.String } * }) * ) - * const machine = Machine.make({ emittedEvents: Emitted, ... }) + * const machine = Machine.make({ + * root: Machine.state({ states: { Idle: {} } }), + * events: Events, + * emittedEvents: Emitted + * }).handle({ + * initial: { target: "Idle" }, + * states: { + * Idle: { + * on: { Save: { stay: true, commands: ({ event }, enqueue) => enqueue.emit(Emitted.Saved({ id: event.id })) } } + * } + * } + * }) + * + * const emitted = Effect.gen(function*() { + * const initial = yield* Machine.planInitial(machine) + * const planned = yield* Machine.plan(machine, initial.state, Events.Save({ id: "a" })) + * return planned.emittedEvents.length + * }) + * + * await Effect.runPromise(emitted) // => 1 * ``` * * @category constructors @@ -8322,29 +8404,24 @@ export const emittedEventsFromSchemas: { * Define an explicit JSON codec or keep process-local capabilities outside the * logical snapshot. * - * **Example** + * **Example** (Encoding a snapshot for storage) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) + * * const encoded = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) - * return yield* Machine.encodeSnapshot(machine, initial.state) + * const snapshot = yield* Machine.encodeSnapshot(machine, initial.state) + * return typeof snapshot * }) + * + * await Effect.runPromise(encoded) // => "object" * ``` * * @see {@link decodeSnapshot} for restoring an encoded snapshot. @@ -8409,30 +8486,25 @@ export const encodeSnapshot: < * Decoding restores logical statechart data only. It does not restart invoked * processes, recreate spawned children, or restore a previous `MachineRef`. * - * **Example** + * **Example** (Restoring an encoded snapshot) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const roundTrip = Effect.gen(function*() { + * + * const path = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * const encoded = yield* Machine.encodeSnapshot(machine, initial.state) - * return yield* Machine.decodeSnapshot(machine, encoded) + * const decoded = yield* Machine.decodeSnapshot(machine, encoded) + * return decoded.state.path * }) + * + * await Effect.runPromise(path) // => "Idle" * ``` * * @see {@link encodeSnapshot} for creating the normalized representation. @@ -8496,26 +8568,20 @@ export const decodeSnapshot: < * protocol. Manual planners may inspect commands but need running machine targets * to execute child-addressed operations. * - * **Example** + * **Example** (Planning startup without running the machine) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const initialState = Effect.map(Machine.planInitial(machine), (plan) => plan.state) + * + * const path = Effect.map(Machine.planInitial(machine), (plan) => plan.state.state.path) + * + * await Effect.runPromise(path) // => "Idle" * ``` * * @see {@link plan} for planning a received event. @@ -8911,38 +8977,30 @@ export const can: { * `emittedEvents`. Events with no enabled transition are ignored and produce * an unchanged plan. * - * **Example** + * **Example** (Planning one event without running the machine) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Off extends Schema.TaggedClass("Off")("Off", {}) { - * } - * class On extends Schema.TaggedClass("On")("On", {}) { - * } - * class Toggle extends Schema.TaggedClass("Toggle")("Toggle", {}) { - * } - * const States = Machine.state({ states: { Off, On } }) + * import { Effect } from "effect" + * + * const Events = Machine.events({ Toggle: {} }) * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas(Toggle) + * root: Machine.state({ states: { Off: {}, On: {} } }), + * events: Events * }).handle({ - * initial: { - * target: "Off" - * }, + * initial: { target: "Off" }, * states: { - * Off: { - * on: { - * Toggle: { target: "On" } - * } - * }, - * On: {} + * Off: { on: { Toggle: { target: "On" } } } * } * }) - * const nextState = Effect.gen(function*() { + * + * const path = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) - * return (yield* Machine.plan(machine, initial.state, new Toggle({}))).next + * const planned = yield* Machine.plan(machine, initial.state, Events.Toggle()) + * return planned.next.state.path * }) + * + * await Effect.runPromise(path) // => "On" * ``` * * @see {@link planInitial} for planning machine startup. @@ -9225,8 +9283,31 @@ export const watch: ( * historical snapshots are not observed. * * @example - * ```ts - * const snapshot = yield* Machine.waitFor(ref, isReady).pipe(Effect.timeout("5 seconds")) + * **Example** (Waiting for a state with a timeout) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Effect } from "effect" + * + * const Events = Machine.events({ Finish: {} }) + * const machine = Machine.make({ + * root: Machine.state({ states: { Working: {}, Ready: {} } }), + * events: Events + * }).handle({ + * initial: { target: "Working" }, + * states: { + * Working: { on: { Finish: { target: "Ready" } } } + * } + * }) + * + * const path = Effect.scoped(Effect.gen(function*() { + * const ref = yield* Machine.start(machine) + * yield* ref.send(Events.Finish()) + * const ready = yield* Machine.waitFor(ref, ({ state }) => state.state.path === "Ready").pipe(Effect.timeout("5 seconds")) + * return ready.state.state.path + * })) + * + * await Effect.runPromise(path) // => "Ready" * ``` * @category combinators * @since 0.37.0 @@ -9265,15 +9346,41 @@ export const waitFor: { * `prepared.start` is one-shot and scoped: the `Scope` that first evaluates it * owns the machine, and closing that `Scope` is equivalent to `ref.stop`. * - * ```ts - * const prepared = yield* Machine.prepare(machine) + * **Example** (Observing emissions before startup) * - * yield* prepared.emissions.pipe( - * Stream.runForEach(handleEmission), - * Effect.forkScoped({ startImmediately: true }) - * ) + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { Effect, Stream } from "effect" * - * const ref = yield* prepared.start + * const Emitted = Machine.emittedEvents({ Started: {} }) + * const machine = Machine.make({ + * root: Machine.state({ states: { Idle: {} } }), + * events: Machine.events({}), + * emittedEvents: Emitted + * }).handle({ + * initial: { target: "Idle" }, + * states: { + * Idle: { + * entry: (_, enqueue) => { + * enqueue.emit(Emitted.Started()) + * } + * } + * } + * }) + * + * const emitted = Effect.scoped(Effect.gen(function*() { + * const prepared = yield* Machine.prepare(machine) + * const seen: Array = [] + * yield* prepared.emissions.pipe( + * Stream.runForEach((event) => Effect.sync(() => seen.push(event._tag))), + * Effect.forkScoped({ startImmediately: true }) + * ) + * yield* prepared.start + * yield* Effect.yieldNow + * return seen + * })) + * + * await Effect.runPromise(emitted) // => ["Started"] * ``` * * @category constructors @@ -9371,29 +9478,23 @@ export const prepare: < * rather than being returned by `send`. Sending after the machine reaches any * terminal state fails immediately with `StoppedError`. * - * **Example** + * **Example** (Starting a machine in a scope) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const state = Effect.scoped(Effect.gen(function*() { + * + * const path = Effect.scoped(Effect.gen(function*() { * const ref = yield* Machine.start(machine) - * return yield* ref.state + * return (yield* ref.state).state.path * })) + * + * await Effect.runPromise(path) // => "Idle" * ``` * * @see {@link plan} for inspecting the same transition plan without executing it. @@ -9492,31 +9593,26 @@ export const start: < * evaluated merely because the runtime was resumed; only ordinary subsequent * transition planning can enter and stabilize states. * - * **Example** + * **Example** (Resuming from a decoded snapshot) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const resumed = Effect.scoped(Effect.gen(function*() { + * + * const path = Effect.scoped(Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * const encoded = yield* Machine.encodeSnapshot(machine, initial.state) * const snapshot = yield* Machine.decodeSnapshot(machine, encoded) - * return yield* Machine.resume(machine, snapshot) + * const ref = yield* Machine.resume(machine, snapshot) + * return (yield* ref.state).state.path * })) + * + * await Effect.runPromise(path) // => "Idle" * ``` * * @see {@link decodeSnapshot} for the schema and transport boundary. diff --git a/packages/effect-machine/src/testing/MachineTest.ts b/packages/effect-machine/src/testing/MachineTest.ts index d8f2a6e8..093f9618 100644 --- a/packages/effect-machine/src/testing/MachineTest.ts +++ b/packages/effect-machine/src/testing/MachineTest.ts @@ -230,33 +230,31 @@ export interface Scenarios { * Unsupported schema derivations fail immediately through `Arbitrary.schema`. * Non-fatal derivation warnings are returned instead of being hidden. * - * **Example** + * **Example** (Generating event scenarios) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * class Reset extends Schema.TaggedClass("Reset")("Reset", {}) { - * } - * const States = Machine.state({ states: { Idle } }) + * import { Effect } from "effect" + * import * as Arbitrary from "effect/Arbitrary" + * + * const Events = Machine.events({ Reset: {} }) * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas(Reset) + * root: Machine.state({ states: { Idle: {} } }), + * events: Events * }).handle({ - * initial: { - * target: "Idle" - * }, + * initial: { target: "Idle" }, * states: { - * Idle: { - * on: { - * Reset: { target: "Idle" } - * } - * } + * Idle: { on: { Reset: { target: "Idle" } } } * } * }) + * * const generated = MachineTest.scenarios(machine, { maxEvents: 5 }) + * const samples = Arbitrary.sampleEffect(generated.arbitrary, { count: 1, seed: 1 }) + * + * const count = Effect.map(samples, (scenarios) => scenarios.length) + * + * await Effect.runPromise(count) // => 1 * ``` * * @category constructors @@ -492,30 +490,25 @@ export { ProbeUnavailableError } from "../internal/testing/machine/probe.js" * available exclusively through `MachineRef.send` and retain their * asynchronous enqueue-only semantics. * - * **Example** + * **Example** (Acknowledging commands at runtime) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const program = Effect.scoped(Effect.gen(function*() { + * + * const probed = Effect.scoped(Effect.gen(function*() { * const ref = yield* Machine.start(machine) - * return yield* MachineTest.probe(machine, ref) + * const probe = yield* MachineTest.probe(machine, ref) + * return typeof probe * })) + * + * await Effect.runPromise(probed) // => "object" * ``` * * @category runtime testing @@ -1106,34 +1099,26 @@ export const Invariant: { * types. The machine is used only for inference; invariant evaluation remains * pure and reusable across traces from that machine. * - * **Example** + * **Example** (Declaring a state invariant) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Option, Schema } from "effect" - * class Count extends Schema.TaggedClass("Count")("Count", { - * value: Schema.Number - * }) { - * } - * const States = Machine.state({ states: { Count } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Count", - * decoded: true, - * data: new Count({ value: 0 }) - * }, - * states: { - * Count: {} - * } + * import { Effect, Option, Schema } from "effect" + * + * const States = Machine.state({ states: { Count: { fields: { value: Schema.Number } } } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Count", data: { value: 0 } } * }) + * * const nonNegative = MachineTest.invariants(machine).state( * "count is non-negative", * ({ snapshot }) => States.get(snapshot, "Count").pipe(Option.exists(({ value }) => value >= 0)) * ) + * + * const ok = Effect.map(MachineTest.check(machine, { invariants: [nonNegative] }), (report) => report.ok) + * + * await Effect.runPromise(ok) // => true * ``` * * @category constructors @@ -1636,27 +1621,21 @@ export type RunServices = IsAny< * Typed planning errors retain the scenario and successfully completed prefix * in a `RunFailure`. * - * **Example** + * **Example** (Running a pure trace) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const trace = MachineTest.run(machine, { events: [] }) + * + * const path = Effect.map(MachineTest.run(machine, { events: [] }), (trace) => trace.final.state.path) + * + * await Effect.runPromise(path) // => "Idle" * ``` * * @category constructors @@ -1907,27 +1886,22 @@ export interface Coverage { * scenarios and logical configurations report observations only because their * complete spaces are generally infinite. * - * **Example** + * **Example** (Reporting trace coverage) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const report = Effect.map(MachineTest.run(machine, { events: [] }), (trace) => MachineTest.coverage(machine, trace)) + * + * const missing = Effect.map(MachineTest.run(machine, { events: [] }), (trace) => + * MachineTest.coverage(machine, trace).transitions.definitions.missing) + * + * await Effect.runPromise(missing) // => 0 * ``` * * @category verification @@ -2135,30 +2109,25 @@ export interface VerifyOptions { * Every selected law is evaluated and returned in one structured error so a * shrunk property-test counterexample retains all relevant evidence. * - * **Example** + * **Example** (Verifying planner laws on a trace) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Effect, Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { Effect } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const checked = Effect.gen(function*() { + * + * const verified = Effect.gen(function*() { * const trace = yield* MachineTest.run(machine, { events: [] }) * yield* MachineTest.verify(machine, trace) + * return true * }) + * + * await Effect.runPromise(verified) // => true * ``` * * @category verification diff --git a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts index 31c6077f..0d3cb736 100644 --- a/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts +++ b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts @@ -338,27 +338,22 @@ export const layerMemory: Layer.Layer = internal.layerMemory * JSON-compatible encoded representations. Local and internal event protocols * remain unrestricted because they do not cross the Cluster boundary. * - * **Example** + * **Example** (Adapting a machine to an entity) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { ClusterMachine } from "@typeonce/effect-machine/cluster" - * import { Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) + * * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() + * root: Machine.state({ states: { Idle: {} } }), + * events: Machine.events({}) * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * initial: { target: "Idle" } * }) + * * const adapter = ClusterMachine.make("IdleMachine", machine, { version: "1" }) + * + * adapter.entity.type // => "IdleMachine" * ``` * * @category constructors diff --git a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts index c447a1d4..08de90b4 100644 --- a/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts +++ b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts @@ -465,32 +465,26 @@ type EnsureValuedSelectorPath = [Path] extends [Valu * The derived atom suppresses structurally equal updates. Repeated calls with * the same bridge and path return the same atom. * - * **Example** + * **Example** (Selecting a state value) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * import { Schema } from "effect" - * class Count extends Schema.TaggedClass("Count")("Count", { - * value: Schema.Number - * }) { - * } - * const States = Machine.state({ states: { Count } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Count", - * decoded: true, - * data: new Count({ value: 0 }) - * }, - * states: { - * Count: {} - * } + * import { AsyncResult, AtomRegistry } from "effect/reactivity" + * import { Option, Schema } from "effect" + * + * const States = Machine.state({ states: { Count: { fields: { value: Schema.Number } } } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Count", data: { value: 0 } } * }) - * const machineAtom = AtomMachine.make(machine) - * const countAtom = AtomMachine.select(machineAtom, "Count") + * + * const countAtom = AtomMachine.select(AtomMachine.make(machine), "Count") + * const registry = AtomRegistry.make() + * registry.mount(countAtom) + * + * const count = AsyncResult.value(registry.get(countAtom)).pipe(Option.flatten, Option.map(({ value }) => value)) + * + * count // => Option.some(0) * ``` * * @category combinators @@ -601,11 +595,35 @@ export const selectSnapshot: { * bridge. An inactive child produces `Option.none()`. Repeated calls with the * same child bridge and path return the same atom. * - * **Example** + * **Example** (Selecting a state value of a child) * - * ```ts + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { AtomMachine } from "@typeonce/effect-machine/reactivity" + * import { AtomRegistry } from "effect/reactivity" + * import { Schema } from "effect" + * + * const Editing = Schema.TaggedStruct("Editing", { text: Schema.String }) + * const Editor = Machine.make({ + * root: Machine.state({ states: { Editing } }), + * events: Machine.events({}) + * }).handle({ + * initial: { target: "Editing", data: { text: "" } } + * }) + * const EditorChild = Machine.child("editor", Editor) + * const parent = Machine.make({ + * root: Machine.state({ states: { Open: {} } }), + * events: Machine.events({}), + * children: { EditorChild } + * }).handle({ + * initial: { target: "Open" }, + * states: { Open: { invoke: { src: "EditorChild" } } } + * }) + * + * const editorAtom = AtomMachine.make(parent).child(EditorChild) * const editingAtom = AtomMachine.selectChild(editorAtom, "Editing") - * // Atom, StartError | ChildRuntimeError>> + * + * AtomRegistry.make().mount(editingAtom) * ``` * * @category combinators @@ -712,28 +730,24 @@ export const selectSnapshotChild: { * bridge and path return the same atom. Runtime failures remain in the typed * failure channel. * - * **Example** + * **Example** (Matching the active state) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * import { Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { AsyncResult, AtomRegistry } from "effect/reactivity" + * import { Option } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) - * const machineAtom = AtomMachine.make(machine) - * const isIdleAtom = AtomMachine.matches(machineAtom, "Idle") + * + * const isIdleAtom = AtomMachine.matches(AtomMachine.make(machine), "Idle") + * const registry = AtomRegistry.make() + * registry.mount(isIdleAtom) + * + * AsyncResult.value(registry.get(isIdleAtom)) // => Option.some(true) * ``` * * @category combinators @@ -791,15 +805,36 @@ export const matches: { * {@link Machine.can}; done and stopped snapshots produce `false`, while * runtime and schema failures remain in the typed failure channel. * - * **Example** + * **Example** (Asking whether a machine accepts an event) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { AtomMachine } from "@typeonce/effect-machine/reactivity" + * import { Atom, AtomRegistry } from "effect/reactivity" + * import { Schema } from "effect" + * + * const Events = Machine.events({ Submitted: { draft: Schema.String } }) + * const machine = Machine.make({ + * root: Machine.state({ states: { Editing: {}, Sent: {} } }), + * events: Events + * }).handle({ + * initial: { target: "Editing" }, + * states: { + * Editing: { on: { Submitted: { target: "Sent", guard: ({ event }) => event.draft.length > 0 } } } + * } + * }) * - * ```ts - * const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) - * const canSubmitAtom = submitAllowed(authMachineAtom) + * const machineAtom = AtomMachine.make(machine) + * const canSubmitAtom = AtomMachine.can(Events.Submitted({ draft: "a" }))(machineAtom) * - * const submitEvent = Atom.map(draftAtom, (draft) => - * AuthEvents.Submitted({ draft })) - * const reactiveSubmitAllowed = AtomMachine.can(submitEvent) + * // The event can also come from an atom, so acceptance follows its value. + * const draftAtom = Atom.make("") + * const submitEvent = Atom.map(draftAtom, (draft) => Events.Submitted({ draft })) + * const reactiveCanSubmitAtom = AtomMachine.can(submitEvent)(machineAtom) + * + * const registry = AtomRegistry.make() + * registry.mount(canSubmitAtom) + * registry.mount(reactiveCanSubmitAtom) * ``` * * @category combinators @@ -1109,9 +1144,26 @@ export interface Bound { * registry runtime mounted. Keys follow Effect `Equal` and `Hash` semantics. * The optional `label` function labels each public projected atom. * - * **Example** + * **Example** (Keeping one machine per key) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { AtomMachine } from "@typeonce/effect-machine/reactivity" + * import { AtomRegistry } from "effect/reactivity" + * import { Schema } from "effect" + * + * const Events = Machine.events({ Finish: {} }) + * const processMachine = Machine.make({ + * root: Machine.state({ states: { Running: {}, Ready: {} } }), + * events: Events, + * input: Schema.String + * }).handle({ + * initial: { target: "Running" }, + * states: { + * Running: { on: { Finish: { target: "Ready" } } } + * } + * }) * - * ```ts * const processAtoms = AtomMachine.family(processMachine, { * atoms: { * ready: AtomMachine.matches("Ready"), @@ -1121,7 +1173,8 @@ export interface Bound { * }) * * const readyAtom = processAtoms.ready("effect") - * const sendAtom = processAtoms.send("effect") + * + * AtomRegistry.make().mount(readyAtom) * ``` * * @category constructors @@ -1154,12 +1207,32 @@ export const family: < * Every projected atom retains the resulting child bridge while the projected * atom remains reachable. Keys follow Effect `Equal` and `Hash` semantics. * - * **Example** + * **Example** (Keeping child atoms per key) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { AtomMachine } from "@typeonce/effect-machine/reactivity" + * import { Schema } from "effect" * - * ```ts + * const plantMachine = Machine.make({ + * root: Machine.state({ states: { Growing: {}, Broken: {} } }), + * events: Machine.events({ Break: {} }), + * input: Schema.String + * }).handle({ + * initial: { target: "Growing" }, + * states: { + * Growing: { on: { Break: { target: "Broken" } } } + * } + * }) * const Plant = Machine.childFamily(plantMachine) + * const parent = Machine.make({ + * root: Machine.state({ states: { Open: {} } }), + * events: Machine.events({}) + * }).handle({ + * initial: { target: "Open" } + * }) * - * const plantAtoms = AtomMachine.familyChild(parentMachineAtom, { + * const plantAtoms = AtomMachine.familyChild(AtomMachine.make(parent), { * child: (plantId: string) => Plant(plantId), * atoms: { * broken: AtomMachine.matchesChild("Broken"), @@ -1167,6 +1240,8 @@ export const family: < * send: (child) => child.send * } * }) + * + * plantAtoms.broken("a") * ``` * * @category constructors @@ -1195,27 +1270,26 @@ export const familyChild: < * Use `bind(runtime).make(machine)` when the machine requires external * services. * - * **Example** + * **Example** (Owning a machine with an atom) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { AtomMachine } from "@typeonce/effect-machine/reactivity" - * import { Schema } from "effect" - * class Idle extends Schema.TaggedClass("Idle")("Idle", {}) { - * } - * const States = Machine.state({ states: { Idle } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas() - * }).handle({ - * initial: { - * target: "Idle" - * }, - * states: { - * Idle: {} - * } + * import { AsyncResult, AtomRegistry } from "effect/reactivity" + * import { Option } from "effect" + * + * const States = Machine.state({ states: { Idle: {} } }) + * const machine = Machine.make({ root: States, events: Machine.events({}) }).handle({ + * initial: { target: "Idle" } * }) + * * const machineAtom = AtomMachine.make(machine) + * const registry = AtomRegistry.make() + * registry.mount(machineAtom.result) + * + * const path = AsyncResult.value(registry.get(machineAtom.result)).pipe(Option.map(({ state }) => state.path)) + * + * path // => Option.some("Idle") * ``` * * @category constructors @@ -1283,13 +1357,28 @@ export const make: { * bridge type. Every call creates a fresh `MachineAtom`; it does not cache by * input or start the machine before an `AtomRegistry` reads or mounts it. * - * **Example** + * **Example** (Making machine atoms from input) + * + * ```ts import.meta.vitest + * import { Machine } from "@typeonce/effect-machine" + * import { AtomMachine } from "@typeonce/effect-machine/reactivity" + * import { Schema } from "effect" + * + * const searchMachine = Machine.make({ + * root: Machine.state({ fields: { query: Schema.String } }), + * events: Machine.events({}), + * input: Schema.Struct({ query: Schema.String }) + * }).handle({ + * root: ({ input }) => ({ query: input.query }) + * }) * - * ```ts * const makeSearchMachine = AtomMachine.factory(searchMachine) * const search = makeSearchMachine({ query: "effect" }) * * type SearchMachineAtom = ReturnType + * const typed: SearchMachineAtom = search + * + * typed === search // => true * ``` * * @category constructors diff --git a/scripts/doc-examples/check.mjs b/scripts/doc-examples/check.mjs index b365d4a2..d33cee62 100644 --- a/scripts/doc-examples/check.mjs +++ b/scripts/doc-examples/check.mjs @@ -5,10 +5,10 @@ import { join, relative, resolve } from "node:path" // Doctests execute examples without type checking them. Extract the same // snippets and compile them so that a stale API cannot survive in an example -// whose wrong types happen to work at runtime. +// whose wrong types happen to work at runtime. A plain `ts` fence would escape +// both, so every example in the published source must be runnable and titled. const root = resolve(import.meta.dirname, "../..") const output = join(root, ".data", "doc-examples") -const includeUnmarked = process.argv.includes("--all") const sourceFiles = (directory) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { @@ -17,13 +17,26 @@ const sourceFiles = (directory) => return entry.name.endsWith(".ts") ? [path] : [] }) -// `--all` also compiles plain `ts` fences. Use it to audit examples that are not runnable yet. -const markRunnable = (text) => text.replace(/(\* ```ts)[ \t]*$/gm, "$1 import.meta.vitest") +const documentedFiles = [ + ...sourceFiles(join(root, "packages/effect-machine/src")), + ...sourceFiles(join(root, "packages/effect-machine-react/src")) +] +const problems = documentedFiles.flatMap((file) => + readFileSync(file, "utf8").split("\n").flatMap((line, index) => { + const location = `${relative(root, file)}:${index + 1}` + if (/^\s*\*\s*```ts\s*$/.test(line)) return [`${location} is not runnable. Use \`\`\`ts import.meta.vitest`] + if (/^\s*\*\s*\*\*Example\*\*\s*$/.test(line)) return [`${location} has no title. Use **Example** (Short title)`] + return [] + }) +) +if (problems.length > 0) { + process.stderr.write(`${problems.join("\n")}\n`) + process.exit(1) +} const snippets = [] for (const file of sourceFiles(join(root, "packages/effect-machine/src"))) { - const contents = readFileSync(file, "utf8") - for (const snippet of Source.extract(includeUnmarked ? markRunnable(contents) : contents)) { + for (const snippet of Source.extract(readFileSync(file, "utf8"))) { snippets.push({ file: relative(root, file), snippet }) } }