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 }) } }