From 4d1e1ab8155049243e1f4e8ef2e1d240d6ae1167 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Sat, 7 Nov 2026 11:42:37 +0900 Subject: [PATCH] Run doc examples and ship agent docs in the package Run and compile JSDoc examples with @effect/doctest, generate LLMS.md from ai-docs, and ship both as AGENTS.md, CLAUDE.md, and ai-docs. Fix the examples that no longer compiled, and update AGENTS.md for an agent-first API. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-docs-in-the-package.md | 7 + .gitignore | 3 + AGENTS.md | 15 +- LLMS.md | 1638 +++++++++++++++++ ai-docs/README.md | 18 + .../src/10_modeling/01_create-a-machine.ts | 51 + .../src/10_modeling/index.md | 292 +-- .../src/20_root-api/index.md | 29 +- .../src/30_react/index.md | 64 +- .../src/40_review/index.md | 62 +- ai-docs/src/index.md | 7 + ai-docs/tsconfig.json | 22 + package.json | 11 +- packages/effect-machine-react/README.md | 2 +- packages/effect-machine/README.md | 13 +- packages/effect-machine/package.json | 4 +- packages/effect-machine/src/Machine.ts | 26 +- .../effect-machine/src/testing/MachineTest.ts | 51 +- packages/effect-machine/test/examples/can.ts | 18 - .../effect-machine/test/examples/guard.ts | 17 - packages/oxlint-plugin/README.md | 2 +- pnpm-lock.yaml | 18 + pnpm-workspace.yaml | 2 + scripts/ai-docs/copy.mjs | 11 + scripts/ai-docs/generate.mjs | 66 + scripts/ai-docs/generate.test.mjs | 36 + .../api-reference-site.test.mjs | 4 +- scripts/api-reference-site/generate.mjs | 13 +- scripts/api-reference/examples.test.mjs | 20 - scripts/ci-changes.test.mjs | 2 +- scripts/doc-examples/check.mjs | 79 + scripts/pack-check.mjs | 4 +- vitest.docs.ts | 23 + 33 files changed, 2294 insertions(+), 336 deletions(-) create mode 100644 .changeset/agent-docs-in-the-package.md create mode 100644 LLMS.md create mode 100644 ai-docs/README.md create mode 100644 ai-docs/src/10_modeling/01_create-a-machine.ts rename packages/effect-machine/docs/agent-guide.md => ai-docs/src/10_modeling/index.md (78%) rename packages/effect-machine/docs/root-api.md => ai-docs/src/20_root-api/index.md (86%) rename packages/effect-machine/docs/effect-atom-react.md => ai-docs/src/30_react/index.md (90%) rename packages/effect-machine/docs/machine-review.md => ai-docs/src/40_review/index.md (82%) create mode 100644 ai-docs/src/index.md create mode 100644 ai-docs/tsconfig.json delete mode 100644 packages/effect-machine/test/examples/can.ts delete mode 100644 packages/effect-machine/test/examples/guard.ts create mode 100644 scripts/ai-docs/copy.mjs create mode 100644 scripts/ai-docs/generate.mjs create mode 100644 scripts/ai-docs/generate.test.mjs delete mode 100644 scripts/api-reference/examples.test.mjs create mode 100644 scripts/doc-examples/check.mjs create mode 100644 vitest.docs.ts diff --git a/.changeset/agent-docs-in-the-package.md b/.changeset/agent-docs-in-the-package.md new file mode 100644 index 00000000..59ed8abb --- /dev/null +++ b/.changeset/agent-docs-in-the-package.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Ship agent documentation as `AGENTS.md`, `CLAUDE.md`, and `ai-docs/` in the package, replacing `docs/`. + +An agent working in a project can now read documentation matching the installed version from `node_modules/@typeonce/effect-machine/AGENTS.md`. Replace links to `docs/agent-guide.md`, `docs/root-api.md`, `docs/effect-atom-react.md`, and `docs/machine-review.md` with that file, or with the matching section under `ai-docs/src`. diff --git a/.gitignore b/.gitignore index f00949af..80e1f783 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ references/ .DS_Store .pnpm-store *.tsbuildinfo +packages/effect-machine/AGENTS.md +packages/effect-machine/CLAUDE.md +packages/effect-machine/ai-docs/ diff --git a/AGENTS.md b/AGENTS.md index 06678330..1b110730 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,10 @@ Apply these priorities in order: 1. **Type safety.** Make invalid states, events, compositions, references, and capabilities unrepresentable at compile time whenever possible. Preserve typed Effect failures at runtime; validation must not escape as an accidental throw. 2. **Explicit, opinionated semantics.** Similar concepts need distinct names and contracts. Do not hide a material semantic choice behind an omission, permissive overload, or ambiguous default. Convenience should come from builders and inference, not weaker boundaries. -3. **Readable, concise models.** A human should be able to read a machine definition from top to bottom and understand its protocols, topology, behavior, and effects. Prefer declarative builders and eliminate raw schema plumbing, duplicated declarations, and incidental ceremony. Concision must not obscure intent. +3. **Local, unambiguous models.** Agents write and verify most models, so an agent must be able to edit one transition correctly without reading another part of the machine. Keep each decision next to the state that owns it, give every key one meaning regardless of its siblings, and let the compiler or `MachineTest` reject mistakes. Prefer a longer explicit declaration over a shorter ambiguous one, and remove raw schema plumbing and duplicated declarations. 4. **Effect-core alignment.** Follow Effect naming, failure, `Scope`, `Stream`, service, module, and ownership conventions. Inspect analogous implementations under `references/effect` before designing a new abstraction. -When compatibility, convenience, concision, and semantic clarity conflict, prefer type safety and semantic clarity. Then optimize the resulting API for readable models. Make architectural decisions for the long term; do not accept a stopgap intended to be replaced later. +When compatibility, convenience, concision, and semantic clarity conflict, prefer type safety and semantic clarity. Do not shape the public API around visualization: tooling reads what the model must declare for verification. Make architectural decisions for the long term; do not accept a stopgap intended to be replaced later. ## Experimental versioning @@ -38,7 +38,16 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe - Keep casts at genuine erased boundaries only. Prefer narrower internal representations, explicit invariants, and exhaustive capability checks over broad `any`-based protocols. - For every optimized semantic change, add forced generic-versus-optimized differential coverage and a focused regression test. Include relevant edge cases such as targetless and reentering transitions, simultaneous transitions, raised events, completion, invocation, and retained snapshots. - Put public behavioral tests under `packages/effect-machine/test/` and implementation-strategy tests under `packages/effect-machine/test/internal/`. Tests should establish observable semantics, not mirror implementation details. -- Treat correctness, type safety, and benchmark regressions as blockers. Do not recover performance by weakening semantics or public inference. +- Treat correctness, type safety, and runtime or memory regressions as blockers. Do not recover performance by weakening semantics or public inference. +- A type-performance regression is acceptable when it deliberately buys type safety or a clearer API. State the tradeoff in the pull request; investigate any other regression. + +## Documentation + +Follow Effect's documentation conventions so the docs can move into `effect` with the code: + +- Write public JSDoc in the shape described by `references/effect/.patterns/jsdoc.md`: a one-paragraph summary, then optional `**When to use**`, `**Details**`, and `**Gotchas**` sections, then `@category` and `@since`. +- Title every example, as in `**Example** (Short title)`, and fence it with `` ```ts import.meta.vitest `` so `pnpm doctest` runs it and `pnpm doctest:types` compiles it. Assert results with a trailing `// =>` comment. +- Keep agent documentation in `ai-docs/src`, as described in `ai-docs/README.md`. `LLMS.md` is generated with `pnpm ai-docgen`; do not edit it directly. ## Verification diff --git a/LLMS.md b/LLMS.md new file mode 100644 index 00000000..dfc87972 --- /dev/null +++ b/LLMS.md @@ -0,0 +1,1638 @@ +# Effect Machine documentation + +This documentation covers `@typeonce/effect-machine` and its related packages. +It is generated for the installed version, so prefer it over any other copy. + +**Note**: The examples contain comments for illustration. In practice, you would +not include them in your code. + +## Effect Machine agent guide + +Use this guide to model a statechart with `@typeonce/effect-machine`. It covers +the decisions that shape the machine. Use the API reference for method +signatures, history states, and choice states. + +Read [Effect Atom and React](#effect-atom-and-react) when React needs to consume +a machine. Keep React ownership and atom lookup out of the machine model. + +### Create a machine + +Define schemas first, then states, events, the machine definition, and its +handlers. Export the state descriptor, public event descriptor, and implemented +machine. Tests, runtimes, and adapters can then use the same model. + +```ts +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const CounterState = Schema.TaggedUnion({ + Running: { count: Schema.Number } +}) + +export const CounterStates = Machine.state({ + states: { + Idle: {}, + Running: CounterState.cases.Running + } +}) + +export const CounterEvents = Machine.eventsFromSchemas( + Schema.TaggedUnion({ + Start: {}, + Increment: {}, + Stop: {} + }) +) + +export const CounterMachine = Machine.make({ + id: "Counter", + root: CounterStates, + events: CounterEvents +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Start: { target: "Running", data: () => ({ count: 0 }) } + } + }, + Running: { + on: { + Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Stop: { target: "Idle" } + } + } + } +}) +``` + +Each step has one job: + +- `Machine.state` declares the root, its child topology, and state-owned data. +- `Machine.events` declares the public messages the machine accepts and returns + typed event constructors. +- `Machine.make` joins the state tree, event protocol, input, and reusable sources. +- `.handle` declares initial children and implements the behavior of every active state and returns the + machine to export. + +Chain `.handle` from `Machine.make`. Do not store the intermediate definition +when the module exports one machine implementation. + +Use `data` for schema make input. Constructor defaults and validation run while +planning. Use `{ decoded: true, data }` for an existing schema Type; validation +still applies. Named selectors take the same object format, with nested children +under `states`. Structural states omit data. See [Root machine API](#root-machine-api) for +startup input, root initialization, subtree construction, and history fallbacks. + +The examples below show one modeling decision at a time. They omit unchanged +state and event declarations already shown above. + +Start the implemented machine at the application boundary and send events +through the exported descriptor: + +```ts +import { Effect } from "effect" + +const program = Effect.scoped(Effect.gen(function*() { + const counter = yield* Machine.start(CounterMachine) + + yield* counter.send(CounterEvents.Start()) + yield* counter.send(CounterEvents.Increment()) +})) +``` + +`Machine.start` requires a `Scope`. Closing it stops the machine and everything +it owns; use `ref.stop` to stop earlier. + +### Make impossible states unrepresentable + +A finite state describes how the machine behaves now. State data holds values +needed while that mode is active. + +Do not model mutually exclusive modes with separate flags such as `loading`, +`data`, and `error`. Those fields permit combinations such as loading with both +data and an error. Put the modes in the state tree instead: + +```ts +const RequestState = Schema.TaggedUnion({ + Ready: { value: Schema.String }, + Failed: { message: Schema.String } +}) + +const RequestStates = Machine.state({ + states: { + Idle: {}, + Loading: {}, + Ready: RequestState.cases.Ready, + Failed: RequestState.cases.Failed + } +}) +``` + +The machine can now be `Loading`, `Ready`, or `Failed`. It cannot construct a +snapshot that represents two of those modes at once. + +Use this test when deciding between a state and a field: if the value changes +which events the machine should handle, which work runs, or how the machine +behaves, model it as a state. Otherwise, keep it as data on the state that owns +it. + +### Put data on the lowest state that owns it + +State data should exist only while it is valid. Put it on the lowest node whose +active subtree needs it. If several sibling states need the same data, their +compound parent owns it. + +```ts +const DocumentState = Schema.TaggedUnion({ + Open: { + documentId: Schema.String, + draft: Schema.String + }, + SaveFailed: { + message: Schema.String + } +}) + +const DocumentStates = Machine.state({ + states: { + Closed: {}, + Open: { + // Editing, Saving, and SaveFailed all need the document and draft. + schema: DocumentState.cases.Open, + states: { + Editing: {}, + Saving: {}, + // Only this state owns an error message. + SaveFailed: DocumentState.cases.SaveFailed + } + } + } +}) +``` + +Do not copy `documentId` and `draft` into every child. Copies can disagree after +a transition. Do not move `message` to `Open` either. That would allow an error +message while `Editing` or `Saving` is active. + +### Put shared behavior on the lowest common ancestor + +Hierarchy owns behavior as well as data. Define a transition on the lowest +compound state whose children share it. + +```ts +const DocumentEvents = Machine.eventsFromSchemas( + Schema.TaggedUnion({ + Close: {} + }) +) + +const DocumentMachine = Machine.make({ + root: DocumentStates, + events: DocumentEvents +}).handle({ + initial: { target: "Closed" }, + states: { + Closed: {}, + Open: { + initial: { target: "Open.Editing" }, + on: { + // All Open children close the document in the same way. + Close: { target: "Closed" } + }, + states: { + Editing: {}, + Saving: {}, + SaveFailed: {} + } + } + } +}) +``` + +The machine checks the deepest active state first, then its ancestors. Put a +handler on a child when that state needs different behavior. Keep the shared +case on the parent instead of repeating it in every child. + +### Treat events as the domain protocol + +An event tells the machine what was requested or what happened. Name events +after domain actions and outcomes. Do not expose state setters such as +`SetLoading` or `SetError`. + +```ts +export const CheckoutEvents = Machine.eventsFromSchemas( + Schema.TaggedUnion({ + Submit: {}, + Cancel: {} + }) +) + +const CheckoutMachine = Machine.make({ + root: CheckoutStates, + events: CheckoutEvents +}).handle({ + states: { + Editing: { + on: { + Submit: { target: "Submitting" } + } + }, + Submitting: { + on: { + // Cancel has meaning while work is in progress. + Cancel: { target: "Editing" } + } + }, + Complete: {} + } +}) +``` + +The sender requests `Submit`. The machine decides whether `Submit` has a +transition in the current state. The sender does not choose `Submitting`. + +Carry facts that the machine cannot read from its current snapshot in the event +payload. Do not copy current state into an event to help a handler reconstruct +what the machine already knows. + +### Use parallel states only for independent modes + +A compound state activates one direct child. A parallel state activates one +child in every region. A parallel model therefore accepts the full product of +those regions. + +```ts +const ScreenStates = Machine.state({ + states: { + Screen: { + type: "parallel", + states: { + connection: { + states: { + Online: {}, + Offline: {} + } + }, + panel: { + states: { + Closed: {}, + Open: {} + } + } + } + } + } +}) +const ScreenMachine = Machine.make({ root: ScreenStates, events: Machine.events({}) }).handle({ + initial: { target: "Screen" }, + states: { + Screen: { + states: { + connection: { initial: { target: "Screen.connection.Online" } }, + panel: { initial: { target: "Screen.panel.Closed" } } + } + } + } +}) +``` + +This model permits all four combinations: online with a closed panel, online +with an open panel, offline with a closed panel, and offline with an open panel. + +If one combination would break a domain rule, do not repair it with a UI check +or repeated cross-region conditions. Change the topology. A compound hierarchy +can place a mode only under the parent where it is valid. + +### Let states own running work + +Put asynchronous work on the state whose meaning requires that work. The +machine starts the work when it enters the state and interrupts it when it exits. +Handle expected success and failure as transitions. + +```ts +const LoadState = Schema.TaggedUnion({ + Loading: { documentId: Schema.String }, + Ready: { content: Schema.String }, + Failed: { message: Schema.String } +}) + +const LoadStates = Machine.state({ + states: { + Idle: {}, + Loading: LoadState.cases.Loading, + Ready: LoadState.cases.Ready, + Failed: LoadState.cases.Failed + } +}) + +const LoadMachine = Machine.make({ + root: LoadStates, + effects: { loadDocument }, + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: {}, + Loading: { + invoke: { + src: "loadDocument", + input: ({ state }) => state.documentId, + onDone: { target: "Ready", data: ({ output }) => ({ content: output }) }, + onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } + } + }, + Ready: {}, + Failed: {} + } +}) +``` + +`loadDocument` may require Effect services. Those requirements remain on the +implemented machine type, so the runtime must provide them when it starts the +machine. + +Model a per-instance capability, such as a router or a callback owned by a UI +component, as such a service. Do not put functions in state data or startup +input: snapshots must remain encodable. A React owner provides the capability +through its own runtime, as described in +[Per-owner capabilities](#per-owner-capabilities). + +Do not start a promise inside a transition callback. A transition has no +lifetime in which to own that work. A state does. + +Invoked work starts while the event that entered its state is processed. It +runs in the sender's turn until it first suspends, and its outcome is handled +in a later turn, even when the work completes without suspending. A state that +invokes work is therefore always observed before its outcome leaves it. Put a +browser call that requires user activation, such as `window.open` or +`navigator.clipboard.writeText`, in the first step of the invoked Effect: + +```ts +effects: { + authenticate: Effect.gen(function*() { + // Runs inside the click that sent the event. + const popup = yield* Effect.sync(() => window.open("about:blank", "auth", "popup")) + return yield* signInWithPopup(popup) + }) +} +``` + +The sender also runs any synchronous work before that first suspension. Keep +expensive synchronous computation behind a suspension or in a separate state. + +Give every invocation declared by one state a unique lifecycle ID. Give every +logic or child process that can be active at the same time a unique runtime +address as well: + +```ts +// Register timers: { loadTimeout: "10 seconds" } in make. +Loading: { + invoke: ; + ;[ + { + src: "loadDocument", + input: ({ state }) => state.documentId, + onDone: { target: "Ready", data: ({ output }) => ({ content: output }) }, + onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } + }, + { src: "loadTimeout", onDone: { target: "Idle" } } + ] +} +``` + +Invocation outcomes are routed by state path and lifecycle ID, and overlapping +children cannot own the same runtime address. If work is sequential, represent +the sequence with separate states and transition from the first outcome. Do +not depend on one child completing quickly enough for another declaration to +reuse its identity. + +#### Choose state-owned or process-owned children + +Register a descriptor in `make({ children: { worker: Worker } })` and use +`invoke: { src: "worker", ...outcomes }` when a child belongs to one state and +must stop when that state exits. Use a child family and `children.spawn(...)` +inside a registered Effect when runtime events determine ids or cardinality +and the children must survive owner state changes. See the +dynamic children example in the package README for the complete +input mapping and service requirements. + +The Effect owns the startup attempt. The machine process owns every child that +starts successfully. Leaving `Commissioning` does not stop those children. +Stop one with `children.stop(Worker(id))` inside an Effect or +`enqueue.stop(Worker(id))` inside a transition. A duplicate active id fails +instead of replacing the existing child, and a partially successful group is +not rolled back automatically. + +### Keep transition decisions synchronous + +A transition should choose the next state from the current snapshot and event. +Use ordinary TypeScript conditions when one event has several valid outcomes. + +```ts +const ReviewEvents = Machine.eventsFromSchemas( + Schema.TaggedUnion({ + Evaluate: { score: Schema.Number } + }) +) + +const ReviewMachine = Machine.make({ + root: ReviewStates, + events: ReviewEvents, + branches: { + evaluate: { + accepted: { target: "Accepted" }, + rejected: { target: "Rejected" } + } + } +}).handle({ + initial: { target: "Pending" }, + states: { + Pending: { + on: { + Evaluate: { + branches: "evaluate", + resolve: ({ event, select }) => + event.score >= 80 + ? select.accepted() + : select.rejected() + } + } + }, + Accepted: {}, + Rejected: {} + } +}) +``` + +Given the same snapshot and event, the handler should choose the same result. +Do not read the clock, generate randomness, call a service, or await work while +choosing a transition. Receive such values in an event or produce them through +state-owned work first. + +A `{ none: true }` branch accepts its event without changing state, so +`Machine.can` reports `true` when a resolver selects it. When the event must be +refused, for example to disable a control, use `guard`, or declare +`declinable: true` and return `decline()` from the resolver. + +When only an active state's value changes, use `update` to preserve its active +descendants and running work: + +```ts +Changed: { + update: "Document", + data: ({ ancestors }) => ({ + ...ancestors.Document, + revision: ancestors.Document.revision + 1 + }) +} +``` + +Choose a valued source or retained ancestor explicitly. To change a parallel +sibling, send an event handled by that sibling. Combine a destination and one +retained owner update with `{ target, update, data }`; the callback returns +`{ target: destinationInput, update: completeOwnerInput }`. Both values are +validated atomically, and destination entry sees the new owner value. A named +branch can declare the same pair when a resolver needs commands or nested +construction. Its selector requires `update: { data: ownerValue }` in the construction object. + +The destination of `{ target, update, data }` can be a choice. The owner is +updated before the choice resolves, so its resolver reads the new value through +`containingState`, `ancestors`, or `root`, and `data` returns only the complete +owner input. Every branch the choice can reach, including through nested +choices, must stay below the owner; otherwise the definition does not compile +and `handle` throws while naming the choice, branch, and escaping target. + +### Test paths and invariants + +Test the statechart as a graph. Send domain events, inspect reached states, and +state the rules that every trace must preserve. Do not duplicate the handler's +branches inside the test. + +```ts +import { MachineTest } from "@typeonce/effect-machine/testing" +import { Effect, Option } from "effect" + +const testProgram = Effect.gen(function*() { + const define = MachineTest.invariants(CounterMachine) + + const countNeverBecomesNegative = define.state( + "count never becomes negative", + ({ snapshot }) => + !CounterStates.matches(snapshot, "Running") || + CounterStates.get(snapshot, "Running").pipe( + Option.exists(({ count }) => count >= 0) + ) || + "count became negative" + ) + + const trace = yield* MachineTest.run(CounterMachine, { + events: [ + CounterEvents.Start(), + CounterEvents.Increment(), + CounterEvents.Increment() + ] + }) + + yield* MachineTest.verify(CounterMachine, trace) + yield* MachineTest.checkInvariants(CounterMachine, trace, [ + countNeverBecomesNegative + ]) +}) +``` + +Use pure planner traces for state and transition rules. Start a live machine +and use `MachineTest.probe` when a test depends on timers, invoked work, raised +events, or runtime scheduling. + +### Choose observation deliberately + +Use atom selectors to render state, invocation outcomes to express workflow +behavior, `ref.join` to await final output, and `ref.changes` for ongoing +observation. Emissions are non-replayed notifications; do not use them for work +the workflow depends on, such as navigation. Reach for +`Machine.waitFor(ref, predicate)` only when an external Effect or test must wait +for a specific current or subsequent snapshot, such as a long-lived connection +becoming ready. Compose `Effect.timeout` when needed. + +Do not use `send` followed by `waitFor` as an acknowledgement protocol. The current +snapshot might already match, and a match does not identify which event caused +it. Keep workflow steps in the machine. A matching terminal snapshot succeeds; +an unmatched error preserves its Cause, stopping fails with `StoppedError`, and +completion without a match fails with `Cause.NoSuchElementError`. + +Invoked Effects, Streams, and timers already receive a `Machine.invoke` Effect +span with machine, state, source, and invocation identity. Use ordinary Effect +tracing configuration and application spans such as `Effect.fn("Payments.charge")`. +Do not add manual spans around every transition or introduce a second exporter. +Sender trace context is not automatically carried through machine mailboxes. + +#### Creating and running a machine + +Declare the topology, the protocol, and the behavior, then start the result +inside a Scope. + +```ts +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema } from "effect" + +// The root declares the topology and the data each state owns. +export const CounterStates = Machine.state({ + states: { + Idle: {}, + Running: { fields: { count: Schema.Number } } + } +}) + +// Events are the public protocol. Each one also becomes a typed constructor. +export const CounterEvents = Machine.events({ Start: {}, Increment: {}, Stop: {} }) + +// `make` joins the topology and protocol. `handle` implements every state and +// returns the machine to export. +export const CounterMachine = Machine.make({ + id: "Counter", + root: CounterStates, + events: CounterEvents +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Start: { target: "Running", data: () => ({ count: 0 }) } + } + }, + Running: { + on: { + Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Stop: { target: "Idle" } + } + } + } +}) + +// `Machine.start` requires a Scope. Closing it stops the machine and everything +// the machine owns. +export const program = Effect.scoped(Effect.gen(function*() { + const counter = yield* Machine.start(CounterMachine) + + yield* counter.send(CounterEvents.Start()) + yield* counter.send(CounterEvents.Increment()) +})) +``` + +## Root machine API + +A machine has one root state. It can own data, child topology, both, or neither. +`Machine.state` declares that structure; `Machine.make` declares protocols and +reusable sources; `.handle` supplies initialization and behavior. + +### Startup input and state data + +```ts +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +const Root = Machine.state({ + fields: { locale: Schema.String }, + states: { + Loading: { fields: { documentId: Schema.String } }, + Ready: {} + } +}) +const Events = Machine.events({ + Loaded: {}, + Reload: { locale: Schema.String, documentId: Schema.String } +}) +const Document = Machine.make({ + root: Root, + events: Events, + input: Schema.Struct({ locale: Schema.String, documentId: Schema.String }) +}).handle({ + root: ({ input }) => ({ locale: input.locale }), + initial: { + target: "Loading", + data: ({ input }) => ({ documentId: input.documentId }) + }, + on: { + Reload: { + initialize: ({ event }) => ({ locale: event.locale, documentId: event.documentId }), + reenter: true + } + }, + states: { + Loading: { on: { Loaded: { target: "Ready" } } } + } +}) +``` + +Start with `Machine.start(Document, { locale: "en", documentId: "intro" })`. +Root construction runs before its initial child construction. Both receive the +same validated input. `documentId` belongs only to Loading; it need not be stored +in root data. Input is not retained as a reset value on the machine or snapshot. + +Only the root's initial callbacks receive machine input. Nested initial callbacks +receive their owning state, ancestors, root, and lifecycle event. Pass any needed +values through the nested state's data. Input is not an entry/exit capability. + +For a parallel root, every required region constructor receives input: + +```ts +initial: { + Documents: ({ input }) => ({ folder: input.folder }), + Connection: ({ input }) => ({ endpoint: input.endpoint }) +} +``` + +Structural regions require no data. Schema constructor defaults can supply +optional region data. Compound handlers declare one direct initial child with +`initial: { target, data }`; each compound has its own initial declaration. +There is no `initialConfiguration` or initializer in `make`. + +### Root initialization and updates + +The root is spelled `"root"` in declarations, so a top-level state cannot use +that name. It is not a transition destination. + +`{ initialize: ... }` reconstructs root data and follows its initial +declarations using fresh machine input: a value or a callback of the transition +context. A machine without input declares `{ initialize: true }`. `initialize` +rejects `target`, `update`, `history`, `none`, `data`, and `decoded`. A named +branch declares `{ initialize: true }`, and its resolver passes the input with +`select.branch({ input })`. + +Initializing root retains its lifecycle unless it is reentered. Put a machine-wide +reset handler at root and use `reenter: true` to exit and enter root as well as +its descendants. Reentry restarts work owned by the exited states. Without +reentry, retained root work continues; it does not automatically acquire new +construction values. Ordinary transition conflict and lifecycle rules still apply. +This does not create a new machine reference or discard history records. + +`{ update: "root", data: ... }` instead replaces root's complete value, +retaining its active children and running work. It takes no input and does not +run initial constructors. Other source/ancestor updates follow the same rules. + +### Inline and named construction + +Ordinary transitions stay inline: + +```ts +Load: { + target: "Loading", + data: ({ event }) => ({ documentId: event.documentId }) +} +``` + +Targeting a compound or parallel state follows its initial declarations. Event +transitions use `target`, never `initial`. `initial` names only handler startup +edges. `history` restores a declared history state; `none: true` accepts an +event without changing its target configuration. + +Declare branch topology in `make` when a resolver chooses an outcome, builds an +explicit subtree, or enqueues commands: + +```ts +branches: { + open: { + checkout: { target: "Checkout" }, + unchanged: { none: true } + } +} +``` + +The resolver calls a constructor bound to that declaration: + +```ts +Open: { + branches: "open", + resolve: ({ event, select }) => event.open + ? select.checkout({ + data: { cartId: event.cartId }, + states: { Review: { data: { total: event.total } } } + }) + : select.unchanged() +} +``` + +Objects contain computed values, not nested constructor callbacks. `data` is +schema make input; `{ decoded: true, data: value }` supplies the decoded type. +Both paths validate and report typed schema failures before committing changes. +The decoded flag applies only to its own node. Structural nodes reject data. + +Omitting `states` follows declared initial children. Explicit compound +construction selects exactly one child. Entering an inactive parallel subtree +requires every region. Source-local parallel construction can select a single +region while retaining its active siblings, preserving the existing scope rules. +Names under `states` cannot collide with construction metadata. + +Declared branch targets remain inspectable without executing a resolver. +Dynamically selected descendants are visible after resolution; this API does not +add static analysis of resolver bodies. + +### Retained-owner updates + +An inline combined transition keeps `target` and `update` paths separate: + +```ts +Save: { + target: "Checkout.Saving", + update: "Checkout", + data: ({ event }) => ({ + target: { requestId: event.requestId }, + update: { cartId: event.cartId } + }) +} +``` + +For the same pair declared as a named branch, construct both in one call: + +```ts +select.saved({ + data: { requestId: event.requestId }, + update: { data: { cartId: event.cartId } } +}) +``` + +`update` is required only for a branch declaring an owner update. It is not an +arbitrary nested-state operation. The owner must be active and retained across +the transition. Both complete values validate before either is committed. + +### History and lifecycle + +History fallback uses a bound function with the same node object format: + +```ts +history: { + recent: { + default: ({ target }) => target({ + states: { + Checkout: { + data: { cartId: "new" }, + states: { Review: { data: { total: 0 } } } + } + } + }) + } +} +``` + +A fallback must explicitly construct a complete tree containing its owner, +including required ancestors and parallel siblings. Its root data is state data, +not startup input. `Machine.resume` restores a validated snapshot without rerunning +startup constructors. + +`guard` declines before construction. A resolver may return `decline()` only with +`declinable: true`. Effect, Stream, timer, logic, child invocation, completion, +entry/exit, and required outcome channels retain their existing contracts. + +## Effect Atom and React + +React code should own one stable machine atom, pass it through props or +Context, and subscribe in the descendants that render machine state. Keep the +machine definition free of React dependencies. + +Read [Effect Machine agent guide](#effect-machine-agent-guide) for statechart +modeling, transitions, services, and testing. + +### Recommended folder structure + +```text +src/ +├── context/ +│ └── auth-machine-context.tsx # React ownership and distribution +├── lib/ +│ ├── atom-runtime.ts # Shared bound AtomMachine runtime +│ └── services/ +└── machines/ + └── auth-machine.ts # States, events, and behavior +``` + +`machine.ts` owns the workflow. A Context module only creates and distributes +the machine atom. State-slot components decide which state paths they render. + +Bind service-backed machines once at the application runtime: + +```ts +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Atom } from "effect/reactivity" +import { AppLayer } from "./app-layer" + +const atomRuntime = Atom.runtime(AppLayer) + +export const MachineAtoms = AtomMachine.bind(atomRuntime) +``` + +Service-free machines can use `AtomMachine.make` directly. + +### Own a machine in one React subtree + +Create a context from a factory. Each Provider owns a fresh bridge in its current +Atom registry; it does not subscribe to state changes. + +```tsx +import { createMachineContext } from "@typeonce/effect-machine-react" +import { MachineAtoms } from "../lib/atom-runtime" +import { AuthMachine } from "../machines/auth-machine" + +export const Auth = createMachineContext(({ input }: { input: AuthInput }) => MachineAtoms.make(AuthMachine, input)) +export const useAuthMachine = Auth.useMachine + +function AuthRoute({ input, attemptId }: Props) { + return ( + + + + ) +} +``` + +The factory takes one options object, and each option it declares is a +Provider prop of the same name and type. A machine without input can pass +`MachineAtoms.factory(Machine)` directly, and its Provider takes no `input`. +Input is startup-only. Send events to update the existing workflow; change the +Provider's React key to create a new one. `MachineState` does not suspend, so +the first paint needs no Suspense boundary. Custom readers that use +`useAtomSuspense` still suspend during startup; keep the Provider above their +Suspense boundary so suspension does not discard the owner. Supply an Atom +RegistryProvider at the application boundary. The same factory used by two Providers creates isolated +machines, even when their inputs are equal. + +`useMachineAtom(() => makeMachine(input))` remains available for custom owners. +It mounts the reference after commit without reading `result` or `snapshot`. + +### Per-owner capabilities + +A router, a callback owned by a component, or another per-instance capability +is a service, never a schema field or startup input. Declare the service, let a +state invoke the work that needs it, and provide the implementation from the +React owner: + +```tsx +import { Machine } from "@typeonce/effect-machine" +import { useMachineAtom } from "@typeonce/effect-machine-react" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Context, Effect, Layer } from "effect" +import { Atom } from "effect/reactivity" +import { AppLayer } from "../lib/app-layer" + +export class Navigation extends Context.Service Effect.Effect +}>()("Navigation") {} + +export const OnboardingMachine = Machine.make({ + root: Machine.state({ + states: { Editing: {}, Leaving: {}, Done: { type: "final" } } + }), + events: Machine.events({ Submit: {} }), + effects: { + navigateNext: Effect.flatMap(Navigation, (navigation) => navigation.push("/onboarding/next")) + } +}).handle({ + initial: { target: "Editing" }, + states: { + Editing: { on: { Submit: { target: "Leaving" } } }, + Leaving: { invoke: { src: "navigateNext", onDone: { target: "Done" } } }, + Done: {} + } +}) + +function OnboardingRoute() { + const router = useRouter() + const machine = useMachineAtom(() => + AtomMachine + .bind(Atom.runtime(Layer.merge( + AppLayer, + Layer.succeed(Navigation, { + push: (href) => Effect.sync(() => router.push(href)) + }) + ))) + .make(OnboardingMachine) + ) + + return +} +``` + +The requirement stays on the machine type. Binding the machine to a runtime +without `Navigation`, such as the application-wide `MachineAtoms`, is a compile +error. Snapshots contain only schema data, so `Machine.encodeSnapshot` keeps +working, and tests substitute the capability with `Layer.mock(Navigation, ...)`. + +Create the runtime inside the `useMachineAtom` factory so each owner builds it +once. `Atom.runtime` builds layers through the registry's memo map: the shared +`AppLayer` is built once per registry and reused, and each owner builds only its +own capability layer. Use `Layer.provideMerge(ownerLayer, AppLayer)` when the +owner layer itself needs application services. + +To share the owner-bound machine with a subtree, declare the capability as a +`layer` option of a `createMachineContext` factory. `OnboardingMachine` has no +input, so the factory declares only `layer`: + +```tsx +import { createMachineContext } from "@typeonce/effect-machine-react" + +const navigationLayer = (router: Router) => + Layer.succeed(Navigation, { + push: (href) => Effect.sync(() => router.push(href)) + }) + +export const Onboarding = createMachineContext( + ({ layer }: { layer: Layer.Layer }) => + AtomMachine + .bind(Atom.runtime(Layer.provideMerge(layer, AppLayer))) + .make(OnboardingMachine) +) + +function OnboardingRoute() { + return ( + + + + ) +} +``` + +The Provider requires a `layer` prop of exactly the declared `Layer` type, and +an `input` prop when the factory also declares input, as in +`({ input, layer }: { input: SignInInput; layer: Layer.Layer })`. +Options other than `input` and `layer` are rejected, so a capability cannot +travel under another name. A factory without a `layer` option has a Provider +without a `layer` prop. Like `input`, the layer is read once, when the Provider +mounts: a new router instance does not rebuild the running machine. Change the +Provider's React `key` to create a new owner with a new layer. Binding still +checks the machine's requirements, so a layer type that omits `Navigation` is a +compile error. + +### Render state-owned data + +Render a typed path in the smallest component that needs it: + +```tsx +import { MachineState } from "@typeonce/effect-machine-react" + +function EditingFields() { + const machine = useAuthMachine() + return ( + + {({ value }) => } + + ) +} +``` + +`MachineState` subscribes only its renderer. The path determines the callback's +snapshot type. It does not suspend: until the machine has a snapshot it renders +`pending`, and an inactive path renders `inactive` (both null by default). +Startup and runtime failures reach the nearest error boundary. Once a snapshot +has rendered, the renderer keeps it while the machine is still starting, for +example while a bound runtime layer builds after hydration. + +`MachineState` also renders a child machine. Pass the child bridge; paths and +the snapshot are then the child's, and `inactive` also renders while the state +that owns the child is not active: + +```tsx + + {(snapshot) => } + +``` + +For custom hooks, `useMachineResult(atom)` reads any result atom, such as +`AtomMachine.selectSnapshot(machine, path)`, `matches`, or `can`, with the same +rendering semantics as `MachineState`: it does not suspend, serves the planned +value during server rendering and hydration, and keeps the last `Success` while +the machine is still starting. Prefer it to `useAtomValue`, which returns +`Initial` again after hydration and makes the renderer replace hydrated markup. +`useAtomSuspense(AtomMachine.selectSnapshot(machine, path)).value` returns an +Option of the selected snapshot for readers inside a Suspense boundary. + +`AtomMachine.select` returns the selected state value. +`AtomMachine.selectSnapshot` also retains the selected state's child topology. +Both return `Option.none()` while the path is inactive. Do not replace that +absence with an empty string, `null`, or a global boolean. + +Repeated calls with the same machine and path return the same atom, so path +selection is safe during render without `useMemo`. Equal selected values do not +notify the component. + +Nested paths keep the same ownership: + +```tsx +function PasswordField() { + const machine = useAuthMachine() + const password = useAtomSuspense( + AtomMachine.select(machine, "Editing.Password") + ).value + + return Option.match(password, { + onNone: () => null, + onSome: ({ password }) => + }) +} +``` + +Place independent subscriptions in independent descendants: + +```tsx +function AuthCard() { + return ( + <> + + + + + + ) +} +``` + +Atom granularity cannot isolate hooks that all live in `AuthCard`. Any selected +change rerenders the component that called the hook. + +### Send without subscribing + +Use the writable atom directly: + +```tsx +import { useAtomSet } from "@effect/atom-react" + +function SubmitButton() { + const machine = useAuthMachine() + const send = useAtomSet(machine.send) + + return ( + + ) +} +``` + +`useAtomSet` mounts the writable atom and does not subscribe the component to +its value. + +A write to a ready machine is processed inside the event handler, and work +invoked by the entered state starts there too. A browser call that requires +user activation, such as `window.open`, `navigator.clipboard.writeText`, or +`HTMLMediaElement.play`, can therefore run in the first step of the invoked +Effect. A write held while the machine is still starting is delivered later, +outside the user's gesture. + +Writes made while the machine is still starting, for example while a bound +`Atom.runtime` layer is building after hydration, are held and delivered in +write order once the machine is ready. The write result stays waiting until +the event is delivered and fails with the startup error if startup fails. +Writes after the machine stopped or completed fail with `StoppedError`. The +same applies to `stop` and to child `send` and `stop`; a child command fails +with `ChildNotActiveError` when the state that owns the child is not active. + +### Query concrete event acceptance + +`AtomMachine.can` turns one concrete event input into a reusable machine +projection. Declare the projection once, then apply it to the React-owned +machine. Repeated applications to the same machine return the same atom: + +```tsx +import { useAtomSet, useAtomSuspense } from "@effect/atom-react" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { AuthEvents } from "../machines/auth-machine" + +const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) + +function SubmitButton() { + const machine = useAuthMachine() + const canSubmit = useAtomSuspense(submitAllowed(machine)).value + const send = useAtomSet(machine.send) + + return ( + + ) +} +``` + +Startup still suspends, startup and runtime failures reach the error boundary, +and invalid event input for an active machine remains a +`MachineSchemaDecodeError`. Done and stopped machines return `false`. + +When acceptance depends on a changing payload, project an event atom instead: + +```ts +import { Atom } from "effect/reactivity" + +const submitEvent = Atom.map(draftAtom, (draft) => AuthEvents.Submitted({ draft })) + +const submitAllowed = AtomMachine.can(submitEvent) +``` + +Changes to `draftAtom` recompute acceptance. The event atom contains the event +input itself rather than an `AsyncResult`. + +`can` is `true` whenever the active configuration selects a transition. A +`{ none: true }` branch is a selected transition: it accepts the event without +changing state. A resolver that falls back to a `none` branch therefore keeps +the button enabled: + +```ts +// Before: an incomplete form still accepts Submitted, so `can` is true. +// In make: branches: { submit: { review: { target: "Reviewing" }, incomplete: { none: true } } } +Submitted: { + branches: "submit", + resolve: ({ state, select }) => + state.email.length > 0 ? select.review() : select.incomplete() +} +``` + +When the event must be refused, decline it. Use `guard` for an ordinary +transition, or `declinable: true` with `decline()` in a resolver: + +```ts +// After: an incomplete form declines Submitted, so `can` is false. +Submitted: { target: "Reviewing", guard: ({ state }) => state.email.length > 0 } + +// A resolver that needs commands or construction declines explicitly. +// In make: branches: { submit: { review: { target: "Reviewing" } } } +Submitted: { + branches: "submit", + declinable: true, + resolve: ({ state, select, decline }) => + state.email.length > 0 ? select.review() : decline() +} +``` + +Keep `none` for events that are deliberately accepted and ignored. The +`effect-machine/no-conditional-none-branch` lint rule reports the conditional +fallback. + +Query acceptance during render through `AtomMachine.can(event)(machine)`. Do +not call `Effect.runSync(Machine.can(...))` in a component: the atom shares the +running machine's snapshot, suspends during startup, and recomputes when the +snapshot changes. + +### Whole-result and custom selections + +Reading the full result is correct when a component renders the complete +machine state: + +```tsx +function AuthScreen() { + const machine = useAuthMachine() + const state = useAtomSuspense(machine.result).value + + return +} +``` + +That component rerenders for every result change. Prefer typed path selectors +for state-owned UI. + +Selection and equality belong to atoms, not to React hooks. To subscribe to +part of a selected value, derive an atom from the path selector and give it the +equality that defines a meaningful change. Declare the derivation once with +`Atom.family`, keyed by the machine atom, so every render reuses the same atom: + +```tsx +import { useAtomSuspense } from "@effect/atom-react" +import { Equal, Option } from "effect" +import { Atom } from "effect/reactivity" + +type AuthMachineAtom = ReturnType + +const editingEmail = Atom.family((machine: AuthMachineAtom) => + Atom.mapResult( + AtomMachine.select(machine, "Editing"), + Option.map(({ email }) => email) + ).pipe(Atom.withEquality(Equal.equals)) +) + +function EmailField() { + const machine = useAuthMachine() + const email = useAtomSuspense(editingEmail(machine)).value + + return Option.match(email, { + onNone: () => null, + onSome: (email) => + }) +} +``` + +The component rerenders only when the projected email changes, not when other +`Editing` fields change. Do not create a fresh derived atom on every render. + +### Share a keyed machine outside one React owner + +`AtomMachine.family` is for registry-owned machines that unrelated consumers +find by startup input. It is not the default for one React-owned workflow. + +```ts +export const processAtoms = MachineAtoms.family(ProcessMachine, { + atoms: { + details: AtomMachine.select("Processing"), + ready: AtomMachine.matches("Ready"), + send: (machine) => machine.send + } +}) +``` + +Consumers use the input as the shared identity key: + +```tsx +const details = useAtomSuspense(processAtoms.details(input)).value +const send = useAtomSet(processAtoms.send(input)) +``` + +Each public projection retains its private machine owner. Keeping only +`details(input)` or `send(input)` is safe. Do not return a weakly held composite +scope and retain only one field from it. + +Family keys use Effect `Equal` and `Hash` semantics. Keep them immutable. If a +changing value should update one running workflow, model it as an event instead +of changing the family key. + +### Module-owned machines + +A no-input machine may intentionally have one module-owned identity: + +```ts +export const CounterMachineAtom = MachineAtoms.make(CounterMachine) +export const CounterStateAtom = AtomMachine.select(CounterMachineAtom, "Count") +``` + +Every consumer using the same `AtomRegistry` reaches the same running machine. +Different registries still run independent instances. + +### Child machines + +Direct child selectors follow the active child and preserve inactivity: + +```tsx +const editor = machine.child(Editor) +const editing = useAtomSuspense( + AtomMachine.selectSnapshotChild(editor, "Editing") +).value +``` + +An inactive child or path returns `Option.none()`. Re-entry follows the +replacement child instance. `MachineState` accepts the child bridge directly +and renders `inactive` in both cases. + +Use `AtomMachine.familyChild` when a parent owns a runtime-sized set of keyed +children: + +```ts +const Plant = Machine.childFamily(PlantMachine) + +export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, { + child: (plantId: string) => Plant(plantId), + atoms: { + broken: AtomMachine.matchesChild("Broken"), + state: (plant) => plant.result, + send: (plant) => plant.send + } +}) +``` + +### Generic helpers + +Use `AtomMachine.MachineAtom.Any` and `AtomMachine.ChildMachineAtom.Any` to +accept any bridge, including bridges for machines without events. Recover the +exact types with the namespace extractors: + +```ts +function StateView(props: { + readonly machine: A + readonly render: (state: AtomMachine.MachineAtom.State) => React.ReactNode +}) { + const state = useAtomSuspense(props.machine.result).value + return props.render(state) +} + +type AuthEvent = AtomMachine.MachineAtom.Event +type EditorState = AtomMachine.ChildMachineAtom.State< + AtomMachine.ChildOf +> +``` + +### Keep workflow effects out of emissions + +Emissions are ephemeral notifications for observers. They are not replayed, so +a subscriber that mounts late never sees an earlier emission. Do not drive work +the workflow depends on, such as navigation, from emissions. Invoke that work +from the state that requires it through a capability service, as in +[Per-owner capabilities](#per-owner-capabilities); the machine then decides when +it runs, what cancels it, and how failure changes the workflow. + +An emission that only feeds UI feedback has two stream-atom details: + +- `Atom.make(AtomMachine.emissions(machine))` publishes each emission as a + waiting `Success`, then publishes the last emission again, no longer waiting, + when the machine completes. Handlers must tolerate that repeated value. +- `useAtomSubscribe(atom, f)` subscribes after commit without reading the atom. + Pass `{ immediate: true }` so a lazy emissions atom starts; without it the + callback never runs. The callback then also receives the initial result. + +### Registry and rendering semantics + +A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same +machine atom through two registry providers creates two independent runtimes. +Unmounting a React owner releases its mount. The registry stops the machine +after its final subscription and configured idle retention expire. +`registry.dispose()` stops it immediately. + +`useMachineAtom` does not start a machine during server rendering because +React effects do not run on the server. Server reads never start a machine +either. Every bridge atom carries an Effect Atom server value, which +`@effect/atom-react` hooks read during server rendering and hydration, and +`Atom.getServerValue` reads directly: + +| Atom | Server value | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `ref`, `send`, `stop` | `Initial` | +| `snapshot` | `Success({ status: "active", state })`, waiting | +| `result` | `Success(state)`, waiting | +| `select`, `selectSnapshot`, `matches`, `can` | the projection of `state`, waiting | +| `child(Child).result` and child projections | `Success(Option.some(childState))` when the planned state invokes `Child`, otherwise `Success(Option.none())` | +| `family` and `familyChild` projections | the server value of the projected bridge atom | + +`state` is the machine's planned startup state: the state `Machine.planInitial` +settles into with the bridge's input, without running startup commands, +invocations, or services. A resumed bridge uses its resumed snapshot. Children +spawned at runtime or through child families are `Option.none()`. When the +startup state cannot be planned synchronously, for example because the input +is invalid, every server value is `Initial`. Each bridge plans at most once, +on its first server read. + +### Server rendering and hydration + +Render state slots with `MachineState` and no Suspense boundary: + +```tsx + + + {(editing) => } + + +``` + +The server renders the planned `Editing` snapshot. Hydration renders the same +markup, then the Provider mounts the machine and the same component tree +receives live snapshots. The input keeps its DOM node, typed text, and focus. + +Do not wrap hydrated forms in a Suspense boundary that a startup read can +trigger. A suspended boundary replaces its server-rendered content with the +fallback and later mounts new DOM nodes, discarding typed text and focus. + +## Effect Machine implementation review + +Use this review after a machine and its UI adapter have been implemented. Look +for modeling mistakes, redundant API usage, and logic that escaped the machine. +Do not force a change when the current ownership is intentional. + +Read [Effect Machine agent guide](#effect-machine-agent-guide) for statechart +modeling and [Effect Atom and React](#effect-atom-and-react) for integration +patterns. + +Run the recommended rules from +`@typeonce/oxlint-plugin-effect-machine` before +the manual review. They catch provable duplicate invocation identities, +asynchronous work, browser access, nondeterminism, redundant resolvers, and +conditional `none` fallbacks in direct same-module machine definitions. +Continue with this review for cross-module ownership and architectural +questions that syntax alone cannot answer. + +### Review the responsibility boundaries + +Use this split when deciding where code belongs: + +| Layer | Owns | +| ------------------ | ------------------------------------------------------------------------------- | +| Machine | State, transitions, workflow decisions, effect lifetimes, and service execution | +| Atoms | Reactive selection, projections, and keyed machine lookup | +| Component | Rendering and translating user interaction into domain events | +| `RegistryProvider` | An optional Atom registry ownership boundary | + +If a component coordinates a workflow, an atom performs business work, or a +provider has no ownership purpose, inspect that code more closely. + +### Keep ordinary transitions inline + +Use `{ target: "Running" }` for a state with default construction. +Use `{ target: "Running", data: ({ event }) => ({ count: event.count }) }` +when the destination needs data. The checker rejects missing required fields. +Use `decoded` for values already decoded by their schema. + +Reserve named branch groups and `resolve` for conditional outcomes, commands, +or nested construction. The group lives in `make`; its resolver selects only +constructors derived from those declarations. Avoid an extra branch group for +an ordinary transition. Use `reenter: true` when the source must restart. +An empty targetless resolver simplifies to `{ none: true }`. + +A `{ none: true }` branch accepts its event, so `Machine.can` and +`AtomMachine.can` return `true` when a resolver selects it. Review check: for +every event that enables or disables a control, refuse it with `guard` or with +`declinable: true` and `decline()`. Keep `none` only for events that are +deliberately accepted and ignored. + +### Choose React ownership or keyed family lookup + +Use `useMachineAtom` when one React subtree owns the workflow, including a +machine with startup input: + +```tsx +const makeProcessMachine = machineAtoms.factory(processMachine) +const machine = useMachineAtom(() => makeProcessMachine(input)) +``` + +Pass the stable machine through props or Context. Startup input is captured +once. Send an event to change the running workflow, or change the owner's React +key to replace it. + +Use `AtomMachine.family` when unrelated consumers must find one shared machine +by its startup input. Effect Atom keeps a family value for an equal key while +that returned value is reachable. Current runtimes may hold family values +through `WeakRef`. Retaining one field from a composite family value does not +retain the composite itself: + +```ts +// Unsafe when consumers retain only stateAtom or sendAtom +const processScope = Atom.family((input: ProcessInput) => { + const machine = machineAtoms.make(processMachine, input) + return { + stateAtom: AtomMachine.select(machine, "process"), + sendAtom: machine.send + } +}) +``` + +`AtomMachine.family` returns direct atom families whose atoms retain the +private machine bridge: + +```ts +export const processAtoms = machineAtoms.family(processMachine, { + atoms: { + state: AtomMachine.select("process"), + send: (machine) => machine.send + } +}) + +const stateAtom = processAtoms.state(input) +const sendAtom = processAtoms.send(input) +``` + +No component `useMemo` is needed. The registry retains the public atom while a +hook subscribes to it, and that atom retains the machine owner. Equal inputs +use Effect `Equal` and `Hash` semantics and select the same family value. + +For a no-input machine, use one module-level bridge or `useMachineAtom` in the +owning React subtree. Do not add an unused family key. + +Review check: search for composite `Atom.family` values that own a machine, +`useMemo` around family lookup, repeated input propagation through one React +subtree, and component-local calls to `machineAtoms.make` without a stable +owner. Choose `AtomMachine.family` only when consumers need shared keyed +lookup. + +### Justify each `RegistryProvider` + +Effect Atom hooks use a shared default registry when no provider is present. +Do not add `RegistryProvider` automatically. + +Keep a provider when the subtree intentionally needs its own registry. Common +reasons include: + +- the same atom or machine descriptors must represent independent instances in + separate subtrees; +- the subtree owns registry disposal; +- server rendering or hydration requires a request-local registry; +- the registry needs initial values, custom scheduling, or custom idle + settings. + +Without one of those requirements, the default registry is enough: + +```tsx +// A shared application instance can use the default registry. +export function App() { + return +} +``` + +Remember that adding or nesting a provider changes identity. The same atom +descriptor stores separate state in each registry, so an unnecessary provider +can split a machine that consumers expected to share. + +Review check: for every `RegistryProvider`, state which registry boundary it +creates and why. Remove it when there is no deliberate boundary. + +### Keep workflow logic in the machine + +The machine owns business flow. Components render selected state and send +domain events. Atoms adapt machine state for reactive consumers. Neither layer +should coordinate the workflow. + +The following component owns too much: + +```tsx +const submit = async () => { + send(OrderEvents.SubmitStarted()) + const order = await api.submitOrder(form) + analytics.track("order submitted", { orderId: order.id }) + send(OrderEvents.SubmitSucceeded({ order })) +} +``` + +Model `Submit` as the component-facing event. Let a machine state own the work +and its lifetime: + +```ts +// In make: effects: { submitOrder } +machine.handle({ + states: { + Editing: { + on: { + Submit: { target: "Submitting", data: ({ event }) => ({ order: event.order }) } + } + }, + Submitting: { + invoke: { + src: "submitOrder", + input: ({ state }) => state.order, + onDone: { target: "Complete", data: ({ output }) => ({ order: output }) }, + onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } + } + } + } +}) +``` + +`submitOrder` can use Effect services for the API request and analytics. The +service implementation may live in its own module, but the machine decides +when it runs, which state owns it, what cancels it, and how success or failure +changes the workflow. + +Apply the same test to browser APIs, storage, timers, analytics, navigation, +and other effects. If the result or lifetime affects machine behavior, execute +it through the machine. UI-only work such as focusing an element or measuring +layout can remain in the component when it does not participate in the domain +flow. + +Review check: search components, hooks, and atom modules for service calls, +`Effect.run*`, promises, browser APIs, analytics, timers, and chains of `send` +calls. Move workflow coordination into states, transitions, and invoked work. + +Per-instance capabilities, such as a router or a component callback, are +services provided by the React owner, not schema fields or startup input. A +function in state data or input cannot be encoded by `Machine.encodeSnapshot`. +Review check: search schemas for `Schema.declare` guards that accept functions +and move each one to a service, as in +[Per-owner capabilities](#per-owner-capabilities). Do not +drive navigation or other workflow effects from emissions. + +### Report findings + +For each issue, cite the file and line, name the violated boundary, and show the +smallest correction. Distinguish a confirmed problem from a provider or +ownership choice that needs clarification. Do not rewrite correct code merely +to match an example in this guide. diff --git a/ai-docs/README.md b/ai-docs/README.md new file mode 100644 index 00000000..668d7265 --- /dev/null +++ b/ai-docs/README.md @@ -0,0 +1,18 @@ +# AI docs + +`LLMS.md` is generated from `ai-docs/src`. Each build copies it into the core package as `AGENTS.md` and `CLAUDE.md`, next to a copy of `ai-docs`, so an installed package carries documentation matching its version. + +## Add content + +1. Add or update markdown in `ai-docs/src/**/index.md` for section text. Top-level sections start at `##`. +2. Add complete examples as `.ts` files in the same folder. `pnpm typecheck` compiles them. +3. Run `pnpm ai-docgen` to regenerate `LLMS.md`. `pnpm ai-docgen:check` fails when it is stale. + +## Source file conventions + +- Numeric filename prefixes control ordering. A `.ts` file starting with `0` is inlined into `LLMS.md`; any other is linked. +- A top JSDoc block with `@title` and an optional description sets the rendered title and description. +- `fixtures` directories are ignored. +- Link between sections with anchors, such as `[Root machine API](#root-machine-api)`, because every section ends up in one document. + +Examples must represent real usage and explain the how and why in comments. diff --git a/ai-docs/src/10_modeling/01_create-a-machine.ts b/ai-docs/src/10_modeling/01_create-a-machine.ts new file mode 100644 index 00000000..7edc3460 --- /dev/null +++ b/ai-docs/src/10_modeling/01_create-a-machine.ts @@ -0,0 +1,51 @@ +/** + * @title Creating and running a machine + * + * Declare the topology, the protocol, and the behavior, then start the result + * inside a Scope. + */ +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema } from "effect" + +// The root declares the topology and the data each state owns. +export const CounterStates = Machine.state({ + states: { + Idle: {}, + Running: { fields: { count: Schema.Number } } + } +}) + +// Events are the public protocol. Each one also becomes a typed constructor. +export const CounterEvents = Machine.events({ Start: {}, Increment: {}, Stop: {} }) + +// `make` joins the topology and protocol. `handle` implements every state and +// returns the machine to export. +export const CounterMachine = Machine.make({ + id: "Counter", + root: CounterStates, + events: CounterEvents +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Start: { target: "Running", data: () => ({ count: 0 }) } + } + }, + Running: { + on: { + Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Stop: { target: "Idle" } + } + } + } +}) + +// `Machine.start` requires a Scope. Closing it stops the machine and everything +// the machine owns. +export const program = Effect.scoped(Effect.gen(function*() { + const counter = yield* Machine.start(CounterMachine) + + yield* counter.send(CounterEvents.Start()) + yield* counter.send(CounterEvents.Increment()) +})) diff --git a/packages/effect-machine/docs/agent-guide.md b/ai-docs/src/10_modeling/index.md similarity index 78% rename from packages/effect-machine/docs/agent-guide.md rename to ai-docs/src/10_modeling/index.md index 2702f556..87f09846 100644 --- a/packages/effect-machine/docs/agent-guide.md +++ b/ai-docs/src/10_modeling/index.md @@ -1,14 +1,13 @@ -# Effect Machine agent guide +## Effect Machine agent guide Use this guide to model a statechart with `@typeonce/effect-machine`. It covers the decisions that shape the machine. Use the API reference for method signatures, history states, and choice states. -Read [Effect Atom and React patterns](./effect-atom-react.md) when React needs -to consume a machine. Keep React ownership and atom lookup out of the machine -model. +Read [Effect Atom and React](#effect-atom-and-react) when React needs to consume +a machine. Keep React ownership and atom lookup out of the machine model. -## Create a machine +### Create a machine Define schemas first, then states, events, the machine definition, and its handlers. Export the state descriptor, public event descriptor, and implemented @@ -22,10 +21,12 @@ const CounterState = Schema.TaggedUnion({ Running: { count: Schema.Number } }) -export const CounterStates = Machine.state({ states: { - Idle: {}, - Running: CounterState.cases.Running -} }) +export const CounterStates = Machine.state({ + states: { + Idle: {}, + Running: CounterState.cases.Running + } +}) export const CounterEvents = Machine.eventsFromSchemas( Schema.TaggedUnion({ @@ -38,20 +39,23 @@ export const CounterEvents = Machine.eventsFromSchemas( export const CounterMachine = Machine.make({ id: "Counter", root: CounterStates, - events: CounterEvents, -}).handle({ initial: { target: "Idle" }, states: { - Idle: { - on: { - Start: { target: "Running", data: () => ({ count: 0 }) } - } - }, - Running: { - on: { - Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, - Stop: { target: "Idle" } + events: CounterEvents +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: { + on: { + Start: { target: "Running", data: () => ({ count: 0 }) } + } + }, + Running: { + on: { + Increment: { update: "Running", data: ({ state }) => ({ count: state.count + 1 }) }, + Stop: { target: "Idle" } + } } } -} }) +}) ``` Each step has one job: @@ -69,7 +73,7 @@ when the module exports one machine implementation. Use `data` for schema make input. Constructor defaults and validation run while planning. Use `{ decoded: true, data }` for an existing schema Type; validation still applies. Named selectors take the same object format, with nested children -under `states`. Structural states omit data. See [Root API](./root-api.md) for +under `states`. Structural states omit data. See [Root machine API](#root-machine-api) for startup input, root initialization, subtree construction, and history fallbacks. The examples below show one modeling decision at a time. They omit unchanged @@ -92,7 +96,7 @@ const program = Effect.scoped(Effect.gen(function*() { `Machine.start` requires a `Scope`. Closing it stops the machine and everything it owns; use `ref.stop` to stop earlier. -## Make impossible states unrepresentable +### Make impossible states unrepresentable A finite state describes how the machine behaves now. State data holds values needed while that mode is active. @@ -107,12 +111,14 @@ const RequestState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const RequestStates = Machine.state({ states: { - Idle: {}, - Loading: {}, - Ready: RequestState.cases.Ready, - Failed: RequestState.cases.Failed -} }) +const RequestStates = Machine.state({ + states: { + Idle: {}, + Loading: {}, + Ready: RequestState.cases.Ready, + Failed: RequestState.cases.Failed + } +}) ``` The machine can now be `Loading`, `Ready`, or `Failed`. It cannot construct a @@ -123,7 +129,7 @@ which events the machine should handle, which work runs, or how the machine behaves, model it as a state. Otherwise, keep it as data on the state that owns it. -## Put data on the lowest state that owns it +### Put data on the lowest state that owns it State data should exist only while it is valid. Put it on the lowest node whose active subtree needs it. If several sibling states need the same data, their @@ -140,26 +146,28 @@ const DocumentState = Schema.TaggedUnion({ } }) -const DocumentStates = Machine.state({ states: { - Closed: {}, - Open: { - // Editing, Saving, and SaveFailed all need the document and draft. - schema: DocumentState.cases.Open, - states: { - Editing: {}, - Saving: {}, - // Only this state owns an error message. - SaveFailed: DocumentState.cases.SaveFailed +const DocumentStates = Machine.state({ + states: { + Closed: {}, + Open: { + // Editing, Saving, and SaveFailed all need the document and draft. + schema: DocumentState.cases.Open, + states: { + Editing: {}, + Saving: {}, + // Only this state owns an error message. + SaveFailed: DocumentState.cases.SaveFailed + } } } -} }) +}) ``` Do not copy `documentId` and `draft` into every child. Copies can disagree after a transition. Do not move `message` to `Open` either. That would allow an error message while `Editing` or `Saving` is active. -## Put shared behavior on the lowest common ancestor +### Put shared behavior on the lowest common ancestor Hierarchy owns behavior as well as data. Define a transition on the lowest compound state whose children share it. @@ -174,28 +182,31 @@ const DocumentEvents = Machine.eventsFromSchemas( const DocumentMachine = Machine.make({ root: DocumentStates, events: DocumentEvents -}).handle({ initial: { target: "Closed" }, states: { - Closed: {}, - Open: { - initial: { target: "Open.Editing" }, - on: { - // All Open children close the document in the same way. - Close: { target: "Closed" } - }, - states: { - Editing: {}, - Saving: {}, - SaveFailed: {} +}).handle({ + initial: { target: "Closed" }, + states: { + Closed: {}, + Open: { + initial: { target: "Open.Editing" }, + on: { + // All Open children close the document in the same way. + Close: { target: "Closed" } + }, + states: { + Editing: {}, + Saving: {}, + SaveFailed: {} + } } } -} }) +}) ``` The machine checks the deepest active state first, then its ancestors. Put a handler on a child when that state needs different behavior. Keep the shared case on the parent instead of repeating it in every child. -## Treat events as the domain protocol +### Treat events as the domain protocol An event tells the machine what was requested or what happened. Name events after domain actions and outcomes. Do not expose state setters such as @@ -212,20 +223,22 @@ export const CheckoutEvents = Machine.eventsFromSchemas( const CheckoutMachine = Machine.make({ root: CheckoutStates, events: CheckoutEvents -}).handle({ states: { - Editing: { - on: { - Submit: { target: "Submitting" } - } - }, - Submitting: { - on: { - // Cancel has meaning while work is in progress. - Cancel: { target: "Editing" } - } - }, - Complete: {} -} }) +}).handle({ + states: { + Editing: { + on: { + Submit: { target: "Submitting" } + } + }, + Submitting: { + on: { + // Cancel has meaning while work is in progress. + Cancel: { target: "Editing" } + } + }, + Complete: {} + } +}) ``` The sender requests `Submit`. The machine decides whether `Submit` has a @@ -235,40 +248,45 @@ Carry facts that the machine cannot read from its current snapshot in the event payload. Do not copy current state into an event to help a handler reconstruct what the machine already knows. -## Use parallel states only for independent modes +### Use parallel states only for independent modes A compound state activates one direct child. A parallel state activates one child in every region. A parallel model therefore accepts the full product of those regions. ```ts -const ScreenStates = Machine.state({ states: { - Screen: { - type: "parallel", - states: { - connection: { - states: { - Online: {}, - Offline: {} - } - }, - panel: { - states: { - Closed: {}, - Open: {} +const ScreenStates = Machine.state({ + states: { + Screen: { + type: "parallel", + states: { + connection: { + states: { + Online: {}, + Offline: {} + } + }, + panel: { + states: { + Closed: {}, + Open: {} + } } } } } -} }) +}) const ScreenMachine = Machine.make({ root: ScreenStates, events: Machine.events({}) }).handle({ initial: { target: "Screen" }, - states: { Screen: { states: { - connection: { initial: { target: "Screen.connection.Online" } }, - panel: { initial: { target: "Screen.panel.Closed" } } - } } } + states: { + Screen: { + states: { + connection: { initial: { target: "Screen.connection.Online" } }, + panel: { initial: { target: "Screen.panel.Closed" } } + } + } + } }) - ``` This model permits all four combinations: online with a closed panel, online @@ -278,7 +296,7 @@ If one combination would break a domain rule, do not repair it with a UI check or repeated cross-region conditions. Change the topology. A compound hierarchy can place a mode only under the parent where it is valid. -## Let states own running work +### Let states own running work Put asynchronous work on the state whose meaning requires that work. The machine starts the work when it enters the state and interrupts it when it exits. @@ -291,30 +309,35 @@ const LoadState = Schema.TaggedUnion({ Failed: { message: Schema.String } }) -const LoadStates = Machine.state({ states: { - Idle: {}, - Loading: LoadState.cases.Loading, - Ready: LoadState.cases.Ready, - Failed: LoadState.cases.Failed -} }) +const LoadStates = Machine.state({ + states: { + Idle: {}, + Loading: LoadState.cases.Loading, + Ready: LoadState.cases.Ready, + Failed: LoadState.cases.Failed + } +}) const LoadMachine = Machine.make({ root: LoadStates, effects: { loadDocument }, - events: Machine.eventsFromSchemas(), -}).handle({ initial: { target: "Idle" }, states: { - Idle: {}, - Loading: { - invoke: { - src: "loadDocument", - input: ({ state }) => state.documentId, - onDone: { target: "Ready", data: ({ output }) => ({ content: output }) }, - onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } - } - }, - Ready: {}, - Failed: {} -} }) + events: Machine.eventsFromSchemas() +}).handle({ + initial: { target: "Idle" }, + states: { + Idle: {}, + Loading: { + invoke: { + src: "loadDocument", + input: ({ state }) => state.documentId, + onDone: { target: "Ready", data: ({ output }) => ({ content: output }) }, + onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } + } + }, + Ready: {}, + Failed: {} + } +}) ``` `loadDocument` may require Effect services. Those requirements remain on the @@ -325,7 +348,7 @@ Model a per-instance capability, such as a router or a callback owned by a UI component, as such a service. Do not put functions in state data or startup input: snapshots must remain encodable. A React owner provides the capability through its own runtime, as described in -[Per-owner capabilities](./effect-atom-react.md#per-owner-capabilities). +[Per-owner capabilities](#per-owner-capabilities). Do not start a promise inside a transition callback. A transition has no lifetime in which to own that work. A state does. @@ -357,7 +380,8 @@ address as well: ```ts // Register timers: { loadTimeout: "10 seconds" } in make. Loading: { - invoke: [ + invoke: ; + ;[ { src: "loadDocument", input: ({ state }) => state.documentId, @@ -375,14 +399,14 @@ the sequence with separate states and transition from the first outcome. Do not depend on one child completing quickly enough for another declaration to reuse its identity. -### Choose state-owned or process-owned children +#### Choose state-owned or process-owned children Register a descriptor in `make({ children: { worker: Worker } })` and use `invoke: { src: "worker", ...outcomes }` when a child belongs to one state and must stop when that state exits. Use a child family and `children.spawn(...)` inside a registered Effect when runtime events determine ids or cardinality and the children must survive owner state changes. See the -[dynamic children example](../README.md#dynamic-children) for the complete +dynamic children example in the package README for the complete input mapping and service requirements. The Effect owns the startup attempt. The machine process owns every child that @@ -392,7 +416,7 @@ Stop one with `children.stop(Worker(id))` inside an Effect or instead of replacing the existing child, and a partially successful group is not rolled back automatically. -## Keep transition decisions synchronous +### Keep transition decisions synchronous A transition should choose the next state from the current snapshot and event. Use ordinary TypeScript conditions when one event has several valid outcomes. @@ -413,20 +437,24 @@ const ReviewMachine = Machine.make({ rejected: { target: "Rejected" } } } -}).handle({ initial: { target: "Pending" }, states: { - Pending: { - on: { - Evaluate: { - branches: "evaluate", - resolve: ({ event, select }) => event.score >= 80 - ? select.accepted() - : select.rejected() +}).handle({ + initial: { target: "Pending" }, + states: { + Pending: { + on: { + Evaluate: { + branches: "evaluate", + resolve: ({ event, select }) => + event.score >= 80 + ? select.accepted() + : select.rejected() + } } - } - }, - Accepted: {}, - Rejected: {} -} }) + }, + Accepted: {}, + Rejected: {} + } +}) ``` Given the same snapshot and event, the handler should choose the same result. @@ -467,7 +495,7 @@ owner input. Every branch the choice can reach, including through nested choices, must stay below the owner; otherwise the definition does not compile and `handle` throws while naming the choice, branch, and escaping target. -## Test paths and invariants +### Test paths and invariants Test the statechart as a graph. Send domain events, inspect reached states, and state the rules that every trace must preserve. Do not duplicate the handler's @@ -509,7 +537,7 @@ Use pure planner traces for state and transition rules. Start a live machine and use `MachineTest.probe` when a test depends on timers, invoked work, raised events, or runtime scheduling. -## Choose observation deliberately +### Choose observation deliberately Use atom selectors to render state, invocation outcomes to express workflow behavior, `ref.join` to await final output, and `ref.changes` for ongoing diff --git a/packages/effect-machine/docs/root-api.md b/ai-docs/src/20_root-api/index.md similarity index 86% rename from packages/effect-machine/docs/root-api.md rename to ai-docs/src/20_root-api/index.md index f3805450..1b398620 100644 --- a/packages/effect-machine/docs/root-api.md +++ b/ai-docs/src/20_root-api/index.md @@ -1,10 +1,10 @@ -# Root machine API +## Root machine API A machine has one root state. It can own data, child topology, both, or neither. `Machine.state` declares that structure; `Machine.make` declares protocols and reusable sources; `.handle` supplies initialization and behavior. -## Startup input and state data +### Startup input and state data ```ts import { Machine } from "@typeonce/effect-machine" @@ -66,7 +66,7 @@ optional region data. Compound handlers declare one direct initial child with `initial: { target, data }`; each compound has its own initial declaration. There is no `initialConfiguration` or initializer in `make`. -## Root initialization and updates +### Root initialization and updates The root is spelled `"root"` in declarations, so a top-level state cannot use that name. It is not a transition destination. @@ -89,7 +89,7 @@ This does not create a new machine reference or discard history records. retaining its active children and running work. It takes no input and does not run initial constructors. Other source/ancestor updates follow the same rules. -## Inline and named construction +### Inline and named construction Ordinary transitions stay inline: @@ -146,7 +146,7 @@ Declared branch targets remain inspectable without executing a resolver. Dynamically selected descendants are visible after resolution; this API does not add static analysis of resolver bodies. -## Retained-owner updates +### Retained-owner updates An inline combined transition keeps `target` and `update` paths separate: @@ -174,7 +174,7 @@ select.saved({ arbitrary nested-state operation. The owner must be active and retained across the transition. Both complete values validate before either is committed. -## History and lifecycle +### History and lifecycle History fallback uses a bound function with the same node object format: @@ -201,20 +201,3 @@ startup constructors. `guard` declines before construction. A resolver may return `decline()` only with `declinable: true`. Effect, Stream, timer, logic, child invocation, completion, entry/exit, and required outcome channels retain their existing contracts. - -## Migration - -- Root initial constructors can read `input`; remove root fields used only to - forward that input to the initial child. -- Remove `Machine.targets(Root)`. Replace `targets.root.A.B` with `"A.B"`, - `update: targets.root` with `update: "root"`, and - `{ target: targets.root, input }` with `{ initialize: input }` (or - `{ initialize: true }` without machine input). Rename a top-level state named - `root`. -- Replace event `{ initial: path, data }` with `{ target: path, data }`. -- Replace `select.branch.from(value)` with `select.branch({ data: value })`. -- Replace `.decoded(value)` with `({ decoded: true, data: value })`. -- Replace child callbacks with `states: { Child: { data, states } }`. -- Replace `.update.from(value)` chaining with `update: { data: value }` inside - the same selector call. -- Replace history fallback chains with `target({ states: ... })`. diff --git a/packages/effect-machine/docs/effect-atom-react.md b/ai-docs/src/30_react/index.md similarity index 90% rename from packages/effect-machine/docs/effect-atom-react.md rename to ai-docs/src/30_react/index.md index 7b1e8260..5732d91d 100644 --- a/packages/effect-machine/docs/effect-atom-react.md +++ b/ai-docs/src/30_react/index.md @@ -1,13 +1,13 @@ -# Effect Atom and React +## Effect Atom and React React code should own one stable machine atom, pass it through props or Context, and subscribe in the descendants that render machine state. Keep the machine definition free of React dependencies. -Read the [Effect Machine agent guide](./agent-guide.md) for statechart +Read [Effect Machine agent guide](#effect-machine-agent-guide) for statechart modeling, transitions, services, and testing. -## Recommended folder structure +### Recommended folder structure ```text src/ @@ -37,19 +37,17 @@ export const MachineAtoms = AtomMachine.bind(atomRuntime) Service-free machines can use `AtomMachine.make` directly. -## Own a machine in one React subtree +### Own a machine in one React subtree Create a context from a factory. Each Provider owns a fresh bridge in its current Atom registry; it does not subscribe to state changes. ```tsx import { createMachineContext } from "@typeonce/effect-machine-react" -import { AuthMachine } from "../machines/auth-machine" import { MachineAtoms } from "../lib/atom-runtime" +import { AuthMachine } from "../machines/auth-machine" -export const Auth = createMachineContext(({ input }: { input: AuthInput }) => - MachineAtoms.make(AuthMachine, input) -) +export const Auth = createMachineContext(({ input }: { input: AuthInput }) => MachineAtoms.make(AuthMachine, input)) export const useAuthMachine = Auth.useMachine function AuthRoute({ input, attemptId }: Props) { @@ -75,7 +73,7 @@ machines, even when their inputs are equal. `useMachineAtom(() => makeMachine(input))` remains available for custom owners. It mounts the reference after commit without reading `result` or `snapshot`. -## Per-owner capabilities +### Per-owner capabilities A router, a callback owned by a component, or another per-instance capability is a service, never a schema field or startup input. Declare the service, let a @@ -84,8 +82,8 @@ React owner: ```tsx import { Machine } from "@typeonce/effect-machine" -import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { useMachineAtom } from "@typeonce/effect-machine-react" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { Context, Effect, Layer } from "effect" import { Atom } from "effect/reactivity" import { AppLayer } from "../lib/app-layer" @@ -100,8 +98,7 @@ export const OnboardingMachine = Machine.make({ }), events: Machine.events({ Submit: {} }), effects: { - navigateNext: Effect.flatMap(Navigation, (navigation) => - navigation.push("/onboarding/next")) + navigateNext: Effect.flatMap(Navigation, (navigation) => navigation.push("/onboarding/next")) } }).handle({ initial: { target: "Editing" }, @@ -179,7 +176,7 @@ Provider's React `key` to create a new owner with a new layer. Binding still checks the machine's requirements, so a layer type that omits `Navigation` is a compile error. -## Render state-owned data +### Render state-owned data Render a typed path in the smallest component that needs it: @@ -265,7 +262,7 @@ function AuthCard() { Atom granularity cannot isolate hooks that all live in `AuthCard`. Any selected change rerenders the component that called the hook. -## Send without subscribing +### Send without subscribing Use the writable atom directly: @@ -302,15 +299,15 @@ Writes after the machine stopped or completed fail with `StoppedError`. The same applies to `stop` and to child `send` and `stop`; a child command fails with `ChildNotActiveError` when the state that owns the child is not active. -## Query concrete event acceptance +### Query concrete event acceptance `AtomMachine.can` turns one concrete event input into a reusable machine projection. Declare the projection once, then apply it to the React-owned machine. Repeated applications to the same machine return the same atom: ```tsx -import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { useAtomSet, useAtomSuspense } from "@effect/atom-react" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { AuthEvents } from "../machines/auth-machine" const submitAllowed = AtomMachine.can(AuthEvents.Submitted()) @@ -340,8 +337,7 @@ When acceptance depends on a changing payload, project an event atom instead: ```ts import { Atom } from "effect/reactivity" -const submitEvent = Atom.map(draftAtom, (draft) => - AuthEvents.Submitted({ draft })) +const submitEvent = Atom.map(draftAtom, (draft) => AuthEvents.Submitted({ draft })) const submitAllowed = AtomMachine.can(submitEvent) ``` @@ -390,7 +386,7 @@ not call `Effect.runSync(Machine.can(...))` in a component: the atom shares the running machine's snapshot, suspends during startup, and recomputes when the snapshot changes. -## Whole-result and custom selections +### Whole-result and custom selections Reading the full result is correct when a component renders the complete machine state: @@ -440,7 +436,7 @@ function EmailField() { The component rerenders only when the projected email changes, not when other `Editing` fields change. Do not create a fresh derived atom on every render. -## Share a keyed machine outside one React owner +### Share a keyed machine outside one React owner `AtomMachine.family` is for registry-owned machines that unrelated consumers find by startup input. It is not the default for one React-owned workflow. @@ -470,7 +466,7 @@ Family keys use Effect `Equal` and `Hash` semantics. Keep them immutable. If a changing value should update one running workflow, model it as an event instead of changing the family key. -## Module-owned machines +### Module-owned machines A no-input machine may intentionally have one module-owned identity: @@ -482,7 +478,7 @@ export const CounterStateAtom = AtomMachine.select(CounterMachineAtom, "Count") Every consumer using the same `AtomRegistry` reaches the same running machine. Different registries still run independent instances. -## Child machines +### Child machines Direct child selectors follow the active child and preserve inactivity: @@ -513,7 +509,7 @@ export const plantAtoms = AtomMachine.familyChild(CentralMachineAtom, { }) ``` -## Generic helpers +### Generic helpers Use `AtomMachine.MachineAtom.Any` and `AtomMachine.ChildMachineAtom.Any` to accept any bridge, including bridges for machines without events. Recover the @@ -534,7 +530,7 @@ type EditorState = AtomMachine.ChildMachineAtom.State< > ``` -## Keep workflow effects out of emissions +### Keep workflow effects out of emissions Emissions are ephemeral notifications for observers. They are not replayed, so a subscriber that mounts late never sees an earlier emission. Do not drive work @@ -552,7 +548,7 @@ An emission that only feeds UI feedback has two stream-atom details: Pass `{ immediate: true }` so a lazy emissions atom starts; without it the callback never runs. The callback then also receives the initial result. -## Registry and rendering semantics +### Registry and rendering semantics A `MachineAtom` identifies one machine per `AtomRegistry`. Passing the same machine atom through two registry providers creates two independent runtimes. @@ -566,14 +562,14 @@ either. Every bridge atom carries an Effect Atom server value, which `@effect/atom-react` hooks read during server rendering and hydration, and `Atom.getServerValue` reads directly: -| Atom | Server value | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `ref`, `send`, `stop` | `Initial` | -| `snapshot` | `Success({ status: "active", state })`, waiting | -| `result` | `Success(state)`, waiting | -| `select`, `selectSnapshot`, `matches`, `can` | the projection of `state`, waiting | -| `child(Child).result` and child projections | `Success(Option.some(childState))` when the planned state invokes `Child`, otherwise `Success(Option.none())` | -| `family` and `familyChild` projections | the server value of the projected bridge atom | +| Atom | Server value | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `ref`, `send`, `stop` | `Initial` | +| `snapshot` | `Success({ status: "active", state })`, waiting | +| `result` | `Success(state)`, waiting | +| `select`, `selectSnapshot`, `matches`, `can` | the projection of `state`, waiting | +| `child(Child).result` and child projections | `Success(Option.some(childState))` when the planned state invokes `Child`, otherwise `Success(Option.none())` | +| `family` and `familyChild` projections | the server value of the projected bridge atom | `state` is the machine's planned startup state: the state `Machine.planInitial` settles into with the bridge's input, without running startup commands, @@ -583,7 +579,7 @@ startup state cannot be planned synchronously, for example because the input is invalid, every server value is `Initial`. Each bridge plans at most once, on its first server read. -## Server rendering and hydration +### Server rendering and hydration Render state slots with `MachineState` and no Suspense boundary: diff --git a/packages/effect-machine/docs/machine-review.md b/ai-docs/src/40_review/index.md similarity index 82% rename from packages/effect-machine/docs/machine-review.md rename to ai-docs/src/40_review/index.md index 566c5d70..103b1482 100644 --- a/packages/effect-machine/docs/machine-review.md +++ b/ai-docs/src/40_review/index.md @@ -1,36 +1,36 @@ -# Effect Machine implementation review +## Effect Machine implementation review Use this review after a machine and its UI adapter have been implemented. Look for modeling mistakes, redundant API usage, and logic that escaped the machine. Do not force a change when the current ownership is intentional. -Read the [Effect Machine agent guide](./agent-guide.md) for statechart modeling -and [Effect Atom and React patterns](./effect-atom-react.md) for integration +Read [Effect Machine agent guide](#effect-machine-agent-guide) for statechart +modeling and [Effect Atom and React](#effect-atom-and-react) for integration patterns. Run the recommended rules from -[`@typeonce/oxlint-plugin-effect-machine`](../../oxlint-plugin/README.md) before +`@typeonce/oxlint-plugin-effect-machine` before the manual review. They catch provable duplicate invocation identities, asynchronous work, browser access, nondeterminism, redundant resolvers, and conditional `none` fallbacks in direct same-module machine definitions. Continue with this review for cross-module ownership and architectural questions that syntax alone cannot answer. -## Review the responsibility boundaries +### Review the responsibility boundaries Use this split when deciding where code belongs: -| Layer | Owns | -| --- | --- | -| Machine | State, transitions, workflow decisions, effect lifetimes, and service execution | -| Atoms | Reactive selection, projections, and keyed machine lookup | -| Component | Rendering and translating user interaction into domain events | -| `RegistryProvider` | An optional Atom registry ownership boundary | +| Layer | Owns | +| ------------------ | ------------------------------------------------------------------------------- | +| Machine | State, transitions, workflow decisions, effect lifetimes, and service execution | +| Atoms | Reactive selection, projections, and keyed machine lookup | +| Component | Rendering and translating user interaction into domain events | +| `RegistryProvider` | An optional Atom registry ownership boundary | If a component coordinates a workflow, an atom performs business work, or a provider has no ownership purpose, inspect that code more closely. -## Keep ordinary transitions inline +### Keep ordinary transitions inline Use `{ target: "Running" }` for a state with default construction. Use `{ target: "Running", data: ({ event }) => ({ count: event.count }) }` @@ -49,7 +49,7 @@ every event that enables or disables a control, refuse it with `guard` or with `declinable: true` and `decline()`. Keep `none` only for events that are deliberately accepted and ignored. -## Choose React ownership or keyed family lookup +### Choose React ownership or keyed family lookup Use `useMachineAtom` when one React subtree owns the workflow, including a machine with startup input: @@ -108,7 +108,7 @@ subtree, and component-local calls to `machineAtoms.make` without a stable owner. Choose `AtomMachine.family` only when consumers need shared keyed lookup. -## Justify each `RegistryProvider` +### Justify each `RegistryProvider` Effect Atom hooks use a shared default registry when no provider is present. Do not add `RegistryProvider` automatically. @@ -139,7 +139,7 @@ can split a machine that consumers expected to share. Review check: for every `RegistryProvider`, state which registry boundary it creates and why. Remove it when there is no deliberate boundary. -## Keep workflow logic in the machine +### Keep workflow logic in the machine The machine owns business flow. Components render selected state and send domain events. Atoms adapt machine state for reactive consumers. Neither layer @@ -161,21 +161,23 @@ and its lifetime: ```ts // In make: effects: { submitOrder } -machine.handle({ states: { - Editing: { - on: { - Submit: { target: "Submitting", data: ({ event }) => ({ order: event.order }) } - } - }, - Submitting: { - invoke: { - src: "submitOrder", - input: ({ state }) => state.order, - onDone: { target: "Complete", data: ({ output }) => ({ order: output }) }, - onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } +machine.handle({ + states: { + Editing: { + on: { + Submit: { target: "Submitting", data: ({ event }) => ({ order: event.order }) } + } + }, + Submitting: { + invoke: { + src: "submitOrder", + input: ({ state }) => state.order, + onDone: { target: "Complete", data: ({ output }) => ({ order: output }) }, + onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) } + } } } -} }) +}) ``` `submitOrder` can use Effect services for the API request and analytics. The @@ -198,10 +200,10 @@ services provided by the React owner, not schema fields or startup input. A function in state data or input cannot be encoded by `Machine.encodeSnapshot`. Review check: search schemas for `Schema.declare` guards that accept functions and move each one to a service, as in -[Per-owner capabilities](./effect-atom-react.md#per-owner-capabilities). Do not +[Per-owner capabilities](#per-owner-capabilities). Do not drive navigation or other workflow effects from emissions. -## Report findings +### Report findings For each issue, cite the file and line, name the violated boundary, and show the smallest correction. Distinguish a confirmed problem from a provider or diff --git a/ai-docs/src/index.md b/ai-docs/src/index.md new file mode 100644 index 00000000..5e9f6bdb --- /dev/null +++ b/ai-docs/src/index.md @@ -0,0 +1,7 @@ +# Effect Machine documentation + +This documentation covers `@typeonce/effect-machine` and its related packages. +It is generated for the installed version, so prefer it over any other copy. + +**Note**: The examples contain comments for illustration. In practice, you would +not include them in your code. diff --git a/ai-docs/tsconfig.json b/ai-docs/tsconfig.json new file mode 100644 index 00000000..4a93d645 --- /dev/null +++ b/ai-docs/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.base.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "composite": false, + "incremental": false, + "rootDir": "..", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "paths": { + "@typeonce/effect-machine": ["../packages/effect-machine/src/index.ts"], + "@typeonce/effect-machine/testing": ["../packages/effect-machine/src/testing/index.ts"], + "@typeonce/effect-machine/reactivity": ["../packages/effect-machine/src/unstable/reactivity/index.ts"], + "@typeonce/effect-machine/cluster": ["../packages/effect-machine/src/unstable/cluster/index.ts"], + "effect": ["../packages/effect-machine/node_modules/effect/dist/index.d.ts"], + "effect/*": ["../packages/effect-machine/node_modules/effect/dist/*.d.ts"] + } + } +} diff --git a/package.json b/package.json index 4be3df7e..8bf388b2 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,19 @@ "type": "module", "packageManager": "pnpm@10.17.1", "scripts": { - "build": "pnpm --recursive --filter \"./packages/**\" run build", + "build": "pnpm --recursive --filter \"./packages/**\" run build && node scripts/ai-docs/copy.mjs", "test": "node --expose-gc ./node_modules/vitest/vitest.mjs run", + "doctest": "vitest run --config vitest.docs.ts", + "doctest:types": "node scripts/doc-examples/check.mjs", + "ai-docgen": "node scripts/ai-docs/generate.mjs", + "ai-docgen:check": "node --test scripts/ai-docs/*.test.mjs && node scripts/ai-docs/generate.mjs --check", "test:types": "pnpm --recursive --filter \"./packages/effect-machine*\" run test:types", "devtools": "pnpm --dir packages/devtools dev", "visualizer": "pnpm devtools", "visualizer:build": "pnpm --dir packages/devtools build", "check:architecture": "node --test scripts/check-architecture.test.mjs && node scripts/check-architecture.mjs packages/effect-machine && node scripts/check-devtools-architecture.mjs packages/devtools", "check:ci": "node --test scripts/ci-changes.test.mjs scripts/effect-compatibility.test.mjs scripts/invoke-autocomplete.test.mjs scripts/release-contract.test.mjs scripts/runtime-performance-compatibility.test.mjs scripts/runtime-performance-regression.test.mjs", - "typecheck": "pnpm --recursive --filter \"./packages/**\" run check && tsc -p tsconfig.tests.json", + "typecheck": "pnpm --recursive --filter \"./packages/**\" run check && tsc -p tsconfig.tests.json && tsc -p ai-docs/tsconfig.json", "typecheck:ts7": "pnpm --dir tooling/typescript-7 run typecheck", "perf:types": "pnpm build && node scripts/type-performance.mjs", "perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs", @@ -30,13 +34,14 @@ "react:pack-check": "node scripts/react-pack-check.mjs", "devtools:pack-check": "node scripts/devtools-pack-check.mjs", "oxlint-plugin:pack-check": "node scripts/oxlint-plugin-pack-check.mjs", - "check": "pnpm format:check && pnpm lint && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm typecheck:ts7 && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm react:pack-check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check", + "check": "pnpm format:check && pnpm lint && pnpm check:architecture && pnpm check:ci && pnpm ai-docgen:check && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm typecheck:ts7 && pnpm build && pnpm test && pnpm doctest && pnpm doctest:types && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm react:pack-check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check", "changeset": "changeset", "version-packages": "changeset version && dprint fmt", "release": "pnpm build && changeset publish" }, "devDependencies": { "@changesets/cli": "3.0.3", + "@effect/doctest": "4.1.1", "@effect/vitest": "4.1.1", "@typeonce/oxlint-plugin-effect-machine": "workspace:*", "@types/node": "26.6.6", diff --git a/packages/effect-machine-react/README.md b/packages/effect-machine-react/README.md index 519e5155..20222345 100644 --- a/packages/effect-machine-react/README.md +++ b/packages/effect-machine-react/README.md @@ -83,5 +83,5 @@ the machine starts, where `useAtomValue` would return `Initial` again. Derive na and `Atom.withEquality` rather than hook options. Read `.result` for the whole logical snapshot or `.snapshot` for runtime status; the bridge has no separate `.state` projection. -See the [React guide](../effect-machine/docs/effect-atom-react.md) for events, +See the [React guide](../../ai-docs/src/30_react/index.md) for events, services, child machines, and registry-owned families. diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md index 5fc5bc20..851f43df 100644 --- a/packages/effect-machine/README.md +++ b/packages/effect-machine/README.md @@ -7,8 +7,7 @@ Effect Schema. The same definition can be planned synchronously, run as a managed machine, mounted as an Atom, tested as a model, or hosted by the cluster adapter. -> This is early-release software. Its API may change, and each release targets -> one exact Effect beta. +> This is early-release software. Its API may change. ## Design principles @@ -18,8 +17,10 @@ cluster adapter. - **Explicit and opinionated:** give different semantics different names and contracts. Builders and inference remove ceremony without making behavior depend on ambiguous omissions. -- **Readable models:** keep schemas, topology, behavior, and effects concise - enough that a human can understand the complete model from its definition. +- **Local, unambiguous models:** keep each decision next to the state that owns + it, and give every key one meaning, so a model can be edited and verified one + transition at a time. Prefer an explicit declaration over a shorter ambiguous + one. - **Effect-native:** design toward eventual inclusion in Effect core and follow its API shape, module boundaries, ownership, and failure conventions. @@ -34,7 +35,7 @@ Cluster and are exposed only through explicit integration boundaries. ## Install ```sh -pnpm add @typeonce/effect-machine effect@4.0.0 +pnpm add @typeonce/effect-machine effect ``` `effect` is a peer dependency requiring stable `^4.0.0`. Keep any installed @@ -668,7 +669,7 @@ Each ESM entrypoint is independent and tree-shakeable. ## Reference and development - [API reference](https://effect-machine.typeonce.dev) -- [Agent and implementation guide](./docs/agent-guide.md) +- [Agent documentation](../../ai-docs/src/index.md), shipped in this package as `AGENTS.md` and `ai-docs/` - [Contributing guide](./CONTRIBUTING.md) Use pnpm 10 and Node.js 20 or newer: diff --git a/packages/effect-machine/package.json b/packages/effect-machine/package.json index e0a61599..4f14ea2c 100644 --- a/packages/effect-machine/package.json +++ b/packages/effect-machine/package.json @@ -17,7 +17,9 @@ "files": [ "src/**/*.ts", "dist", - "docs", + "AGENTS.md", + "CLAUDE.md", + "ai-docs/**/*", "README.md", "LICENSE", "NOTICE" diff --git a/packages/effect-machine/src/Machine.ts b/packages/effect-machine/src/Machine.ts index 6e21e63e..38213b67 100644 --- a/packages/effect-machine/src/Machine.ts +++ b/packages/effect-machine/src/Machine.ts @@ -115,14 +115,15 @@ type IsAny = 0 extends (1 & A) ? true : false * Use `declinable: true` when a named branch resolver may return `decline()`. * Register cancellable state-scoped delays in `make({ timers })`. * - * **Example** + * **Example** (Declining an event with a guard) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" - * import { Schema } from "effect" + * import { Effect, Schema } from "effect" + * * const events = Machine.events({ Add: { by: Schema.Number } }) * const Root = Machine.state({ fields: { count: Schema.Number } }) - * export const counter = Machine.make({ + * const counter = Machine.make({ * root: Root, * events * }).handle({ @@ -135,6 +136,15 @@ type IsAny = 0 extends (1 & A) ? true : false * } * } * }) + * + * const accepts = (by: number) => + * Effect.gen(function*() { + * const initial = yield* Machine.planInitial(counter) + * return yield* Machine.can(counter, initial.state, events.Add({ by })) + * }) + * + * await Effect.runPromise(accepts(1)) // => true + * await Effect.runPromise(accepts(0)) // => false * ``` * * @category models @@ -8930,9 +8940,9 @@ export const enabled: < * Synchronous transition resolvers cannot execute it. Querying an internal event * does not make that event available to `MachineRef.send`. * - * **Example** + * **Example** (Checking an event against the current state) * - * ```ts + * ```ts import.meta.vitest * import { Machine } from "@typeonce/effect-machine" * import { Effect, Schema } from "effect" * @@ -8947,10 +8957,12 @@ export const enabled: < * states: { Idle: { on: { Loaded: { none: true } } } } * }) * - * export const canLoad = Effect.gen(function*() { + * const canLoad = Effect.gen(function*() { * const initial = yield* Machine.planInitial(machine) * return yield* Machine.can(machine, initial.state, internalEvents.Loaded()) * }) + * + * await Effect.runPromise(canLoad) // => true * ``` * * @category getters diff --git a/packages/effect-machine/src/testing/MachineTest.ts b/packages/effect-machine/src/testing/MachineTest.ts index f04a05eb..ad4f6815 100644 --- a/packages/effect-machine/src/testing/MachineTest.ts +++ b/packages/effect-machine/src/testing/MachineTest.ts @@ -250,7 +250,7 @@ export interface Scenarios { * states: { * Idle: { * on: { - * Reset: (to) => to.branch.Idle().resolve(({ target }) => target.from()) + * Reset: { target: "Idle" } * } * } * } @@ -1110,7 +1110,7 @@ export const Invariant: { * ```ts * import { Machine } from "@typeonce/effect-machine" * import { MachineTest } from "@typeonce/effect-machine/testing" - * import { Schema } from "effect" + * import { Option, Schema } from "effect" * class Count extends Schema.TaggedClass("Count")("Count", { * value: Schema.Number * }) { @@ -1131,7 +1131,7 @@ export const Invariant: { * }) * const nonNegative = MachineTest.invariants(machine).state( * "count is non-negative", - * ({ snapshot }) => snapshot.value.value >= 0 + * ({ snapshot }) => States.get(snapshot, "Count").pipe(Option.exists(({ value }) => value >= 0)) * ) * ``` * @@ -1443,41 +1443,36 @@ export type ExploreOptions("Count")("Count", { - * value: Schema.Number - * }) { - * } - * class Increment extends Schema.TaggedClass("Increment")("Increment", {}) { - * } - * const States = Machine.state({ states: { Count } }) - * const machine = Machine.make({ - * root: States, - * events: Machine.eventsFromSchemas(Increment) - * }).handle({ - * initial: { - * target: "Count", - * decoded: true, - * data: new Count({ value: 0 }) - * }, + * import { Effect, Option, Schema } from "effect" + * + * const States = Machine.state({ + * states: { Count: { fields: { value: Schema.Number } } } + * }) + * const Events = Machine.events({ Increment: {} }) + * const machine = Machine.make({ root: States, events: Events }).handle({ + * initial: { target: "Count", data: () => ({ value: 0 }) }, * states: { * Count: { * on: { - * Increment: (to) => - * to.branch.Count().resolve(({ state, target }) => target.decoded(new Count({ value: state.value + 1 }))) + * Increment: { update: "Count", data: ({ state }) => ({ value: state.value + 1 }) } * } * } * } * }) - * const explored = MachineTest.explore(machine, { - * events: ({ snapshot }) => snapshot.value.value < 2 ? [new Increment({})] : [], - * stateKey: ({ snapshot }) => snapshot.value.value - * }) + * const count = (snapshot: Machine.Snapshot) => + * States.get(snapshot, "Count").pipe(Option.map(({ value }) => value), Option.getOrElse(() => 0)) + * + * const explored = await Effect.runPromise(MachineTest.explore(machine, { + * events: ({ snapshot }) => count(snapshot) < 2 ? [Events.Increment()] : [], + * stateKey: ({ snapshot }) => count(snapshot) + * })) + * + * explored.nodes.length // => 3 * ``` * * @category exploration diff --git a/packages/effect-machine/test/examples/can.ts b/packages/effect-machine/test/examples/can.ts deleted file mode 100644 index ea2df664..00000000 --- a/packages/effect-machine/test/examples/can.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Machine } from "@typeonce/effect-machine" -import { Effect, Schema } from "effect" - -const internalEvents = Machine.internalEventsFromSchemas(Schema.TaggedStruct("Loaded", {})) -const Root = Machine.state({ states: { Idle: {} } }) -const machine = Machine.make({ - root: Root, - events: Machine.eventsFromSchemas(), - internalEvents -}).handle({ - initial: { target: "Idle" }, - states: { Idle: { on: { Loaded: { none: true } } } } -}) - -export const canLoad = Effect.gen(function*() { - const initial = yield* Machine.planInitial(machine) - return yield* Machine.can(machine, initial.state, internalEvents.Loaded()) -}) diff --git a/packages/effect-machine/test/examples/guard.ts b/packages/effect-machine/test/examples/guard.ts deleted file mode 100644 index 7328821e..00000000 --- a/packages/effect-machine/test/examples/guard.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Machine } from "@typeonce/effect-machine" -import { Schema } from "effect" -const events = Machine.events({ Add: { by: Schema.Number } }) -const Root = Machine.state({ fields: { count: Schema.Number } }) -export const counter = Machine.make({ - root: Root, - events -}).handle({ - root: () => ({ count: 0 }), - on: { - Add: { - update: "root", - guard: ({ event }) => event.by > 0, - data: ({ root, event }) => ({ count: root.count + event.by }) - } - } -}) diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index 9d5ef1f8..89a54dc7 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -195,7 +195,7 @@ Check: { branches: "expiry", declinable: true, resolve: ({ event, select, decline }) => event.now >= event.deadline - ? select.expired.from() + ? select.expired() : decline() } ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89a48efc..990dd607 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: effect: 4.1.1 '@effect/vitest': 4.1.1 '@effect/atom-react': 4.1.1 + '@effect/doctest': 4.1.1 '@effect/platform-browser': 4.1.1 '@effect/platform-node': 4.1.1 '@effect/platform-node-shared': 4.1.1 @@ -19,6 +20,9 @@ importers: '@changesets/cli': specifier: 3.0.3 version: 3.0.3 + '@effect/doctest': + specifier: 4.1.1 + version: 4.1.1(effect@4.1.1)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))(vitest@5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))) '@effect/vitest': specifier: 4.1.1 version: 4.1.1(effect@4.1.1)(vitest@5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))) @@ -429,6 +433,13 @@ packages: react: '>=19.0.0 <20.0.0' scheduler: '>=0.25.0 <0.29.0' + '@effect/doctest@4.1.1': + resolution: {integrity: sha512-otuiqbUsZzAcvyAT6dCmruQGBtTkOduVMOy/wIMnhhkLYcg/ZsGYZ2ByvurWFohDYbMlCc04ABTThXHHwSZxwA==} + peerDependencies: + effect: 4.1.1 + vite: '>=8.1.5 <9.0.0' + vitest: '>=5.0.0 <6.0.0' + '@effect/platform-browser@4.1.1': resolution: {integrity: sha512-bfJuCUX0qPv0fqVWq65cLNWoSFHt2vPDMn0m2cpaxz51/gaCclT3yBAk3JxN/7aEBvcKVXMui9G8uz4a9uoDgA==} peerDependencies: @@ -1998,6 +2009,13 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + '@effect/doctest@4.1.1(effect@4.1.1)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))(vitest@5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)))': + dependencies: + effect: 4.1.1 + rolldown: 1.2.16 + vite: 8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0) + vitest: 5.0.5(@types/node@26.6.6)(jsdom@30.1.3)(vite@8.3.5(@types/node@26.6.6)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)) + '@effect/platform-browser@4.1.1(effect@4.1.1)': dependencies: effect: 4.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a8514b0b..00fdb78a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ overrides: effect: 4.1.1 "@effect/vitest": 4.1.1 "@effect/atom-react": 4.1.1 + "@effect/doctest": 4.1.1 "@effect/platform-browser": 4.1.1 "@effect/platform-node": 4.1.1 "@effect/platform-node-shared": 4.1.1 @@ -15,6 +16,7 @@ minimumReleaseAgeExclude: - effect - "@effect/vitest" - "@effect/atom-react" + - "@effect/doctest" - "@effect/platform-browser" - "@effect/platform-node" - "@effect/platform-node-shared" diff --git a/scripts/ai-docs/copy.mjs b/scripts/ai-docs/copy.mjs new file mode 100644 index 00000000..1b6f4ee8 --- /dev/null +++ b/scripts/ai-docs/copy.mjs @@ -0,0 +1,11 @@ +import { copyFileSync, cpSync, rmSync } from "node:fs" +import { join, resolve } from "node:path" + +// The copies are build outputs: the source of truth stays in `ai-docs` and `LLMS.md`. +const root = resolve(import.meta.dirname, "../..") +const packageDirectory = join(root, "packages/effect-machine") + +copyFileSync(join(root, "LLMS.md"), join(packageDirectory, "AGENTS.md")) +copyFileSync(join(root, "LLMS.md"), join(packageDirectory, "CLAUDE.md")) +rmSync(join(packageDirectory, "ai-docs"), { recursive: true, force: true }) +cpSync(join(root, "ai-docs"), join(packageDirectory, "ai-docs"), { recursive: true }) diff --git a/scripts/ai-docs/generate.mjs b/scripts/ai-docs/generate.mjs new file mode 100644 index 00000000..2a2134b1 --- /dev/null +++ b/scripts/ai-docs/generate.mjs @@ -0,0 +1,66 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" +import { basename, extname, join, relative, resolve } from "node:path" +import { pathToFileURL } from "node:url" + +const root = resolve(import.meta.dirname, "../..") + +const metadata = (path) => { + const name = basename(path, extname(path)) + const fallback = name.replace(/^\d+/, "").replaceAll(/[-_]/g, " ").trim() + const contents = readFileSync(path, "utf8") + const start = contents.indexOf("/**") + const end = contents.indexOf("*/", start) + if (start === -1 || end === -1) { + return { name, title: fallback.charAt(0).toUpperCase() + fallback.slice(1), content: contents.trim() } + } + let title = fallback.charAt(0).toUpperCase() + fallback.slice(1) + const description = [] + for (const line of contents.slice(start, end).split("\n").slice(1)) { + const text = line.replace(/^ \*\s?/, "").trimEnd() + if (text.startsWith("@title")) title = text.replace("@title", "").trim() + else description.push(text) + } + return { + name, + title, + description: description.join("\n").trim() || undefined, + content: contents.slice(end + 2).trim() + } +} + +const renderExample = (path, baseDirectory) => { + const { content, description, name, title } = metadata(path) + if (name.startsWith("0")) { + return `#### ${title}\n\n${description === undefined ? "" : `${description}\n\n`}\`\`\`ts\n${content}\n\`\`\`\n` + } + const link = `[${title}](./${relative(baseDirectory, path).replaceAll("\\", "/")})` + return `- **${link}**${description === undefined ? "" : `: ${description.replaceAll("\n", " ")}`}\n` +} + +/** Renders a documentation directory depth first, in file name order. */ +export const directoryToMarkdown = (directory, baseDirectory = directory) => { + const entries = readdirSync(directory).sort() + const parts = [] + if (entries.includes("index.md")) parts.push(readFileSync(join(directory, "index.md"), "utf8").trim()) + for (const entry of entries) { + const path = join(directory, entry) + if (statSync(path).isDirectory()) { + if (entry !== "fixtures") parts.push(directoryToMarkdown(path, baseDirectory)) + } else if (/\.tsx?$/.test(entry)) { + parts.push(renderExample(path, baseDirectory).trim()) + } + } + return parts.filter((part) => part.length > 0).join("\n\n") +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const output = join(root, "LLMS.md") + const markdown = `${directoryToMarkdown(join(root, "ai-docs/src"), root)}\n` + if (process.argv.includes("--check")) { + if (readFileSync(output, "utf8") !== markdown) { + throw new Error("LLMS.md is stale. Run `pnpm ai-docgen` and commit the result.") + } + } else { + writeFileSync(output, markdown) + } +} diff --git a/scripts/ai-docs/generate.test.mjs b/scripts/ai-docs/generate.test.mjs new file mode 100644 index 00000000..a65ae0d5 --- /dev/null +++ b/scripts/ai-docs/generate.test.mjs @@ -0,0 +1,36 @@ +import { strict as assert } from "node:assert" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import { directoryToMarkdown } from "./generate.mjs" + +test("renders sections in order, inlining 0-prefixed examples and linking the rest", () => { + const directory = mkdtempSync(join(tmpdir(), "ai-docs-")) + try { + writeFileSync(join(directory, "index.md"), "# Docs\n") + mkdirSync(join(directory, "20_second")) + writeFileSync(join(directory, "20_second", "index.md"), "## Second\n") + mkdirSync(join(directory, "10_first", "fixtures"), { recursive: true }) + writeFileSync(join(directory, "10_first", "index.md"), "## First\n") + writeFileSync(join(directory, "10_first", "fixtures", "ignored.ts"), "export const ignored = 1\n") + writeFileSync( + join(directory, "10_first", "01_inline.ts"), + "/**\n * @title Inlined\n *\n * Explains it.\n */\nexport const inlined = 1\n" + ) + writeFileSync(join(directory, "10_first", "10_linked-example.ts"), "export const linked = 1\n") + + assert.equal( + directoryToMarkdown(directory), + [ + "# Docs", + "## First", + "#### Inlined\n\nExplains it.\n\n```ts\nexport const inlined = 1\n```", + "- **[Linked example](./10_first/10_linked-example.ts)**", + "## Second" + ].join("\n\n") + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/scripts/api-reference-site/api-reference-site.test.mjs b/scripts/api-reference-site/api-reference-site.test.mjs index 3e4857d8..4fd8d603 100644 --- a/scripts/api-reference-site/api-reference-site.test.mjs +++ b/scripts/api-reference-site/api-reference-site.test.mjs @@ -58,7 +58,7 @@ test("renders the agent guide Markdown below the changelog navigation", () => { ...site, agentGuide: `# Effect Machine agent guide -Read [Effect Atom patterns](./effect-atom-react.md). +Read [the first example](./ai-docs/src/10_modeling/01_create-a-machine.ts). ## Create a machine @@ -81,7 +81,7 @@ const machine = Machine.make({}).handle({}) assert.match(html, /href="#create-a-machine">Create a machine<\/a>/) assert.match( html, - /href="https:\/\/github\.com\/typeonce-dev\/effect-machine\/blob\/main\/packages\/effect-machine\/docs\/effect-atom-react\.md"/ + /href="https:\/\/github\.com\/typeonce-dev\/effect-machine\/blob\/main\/ai-docs\/src\/10_modeling\/01_create-a-machine\.ts"/ ) assert.match(html, /class="code-block code-block--markdown"/) }) diff --git a/scripts/api-reference-site/generate.mjs b/scripts/api-reference-site/generate.mjs index 04768d8c..fb5f2739 100644 --- a/scripts/api-reference-site/generate.mjs +++ b/scripts/api-reference-site/generate.mjs @@ -73,10 +73,7 @@ const readSiteModel = (inputDirectory, config) => { } }) const changelog = readChangelog(packageManifest.name) - const agentGuide = readFileSync( - join(repositoryDirectory, "packages", "effect-machine", "docs", "agent-guide.md"), - "utf8" - ) + const agentGuide = readFileSync(join(repositoryDirectory, "LLMS.md"), "utf8") return { ...config, @@ -194,8 +191,8 @@ export const renderChangelogPage = (site) => { } export const renderAgentGuidePage = (site) => { - const resolveLink = (href) => href.startsWith("./") && href.endsWith(".md") - ? `${site.package.repositoryUrl}/blob/main/packages/effect-machine/docs/${href.slice(2)}` + const resolveLink = (href) => href.startsWith("./") + ? `${site.package.repositoryUrl}/blob/main/${href.slice(2)}` : href const content = `
@@ -680,7 +677,7 @@ export const renderMarkdown = (value) => { for (index += 1; index < lines.length && !/^\s*```\s*$/u.test(lines[index]); index++) { source.push(lines[index]) } - output.push(renderMarkdownCodeBlock(source.join("\n"), fence[1].trim())) + output.push(renderMarkdownCodeBlock(source.join("\n"), fence[1].trim().split(/\s+/u)[0])) } flushProse() return output.join("") @@ -707,7 +704,7 @@ export const renderMarkdownDocument = (value, { resolveLink = (href) => href } = for (index += 1; index < lines.length && !/^\s*```\s*$/u.test(lines[index]); index++) { source.push(lines[index]) } - output.push(renderMarkdownCodeBlock(source.join("\n"), fence[1].trim())) + output.push(renderMarkdownCodeBlock(source.join("\n"), fence[1].trim().split(/\s+/u)[0])) } flushProse() return output.join("") diff --git a/scripts/api-reference/examples.test.mjs b/scripts/api-reference/examples.test.mjs deleted file mode 100644 index 477277d0..00000000 --- a/scripts/api-reference/examples.test.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { strict as assert } from "node:assert" -import { readFileSync } from "node:fs" -import { test } from "node:test" - -// These fixtures also compile under tsconfig.tests.json. Keep documentation -// examples identical to the checked consumer code rather than copying by hand. -for (const [declarationText, fixture] of [ - ["export const can:", "can"], - ["export interface Machine<", "guard"] -]) test(`Machine documents the compile-checked ${fixture} example`, () => { - const source = readFileSync(new URL("../../packages/effect-machine/src/Machine.ts", import.meta.url), "utf8") - const declaration = source.indexOf(declarationText) - assert.ok(declaration >= 0) - const comment = source.slice(source.lastIndexOf("/**", declaration), declaration) - const example = comment.match(/```ts\n([\s\S]*?)\n \* ```/) - assert.ok(example) - const actual = example[1].split("\n").map((line) => line.replace(/^ \* ?/, "")).join("\n") - const expected = readFileSync(new URL(`../../packages/effect-machine/test/examples/${fixture}.ts`, import.meta.url), "utf8") - assert.equal(actual.trim(), expected.trim()) -}) diff --git a/scripts/ci-changes.test.mjs b/scripts/ci-changes.test.mjs index 31c5bb59..a8ed2046 100644 --- a/scripts/ci-changes.test.mjs +++ b/scripts/ci-changes.test.mjs @@ -19,7 +19,7 @@ const classify = (changedFiles, afterPackageJson = packageJson) => }) test("skips performance for documentation changes", () => { - assert.deepEqual(classify(["README.md", "docs/agent-guide.md"]), { + assert.deepEqual(classify(["README.md", "ai-docs/src/index.md"]), { runtimePerformance: false, typePerformance: false }) diff --git a/scripts/doc-examples/check.mjs b/scripts/doc-examples/check.mjs new file mode 100644 index 00000000..b365d4a2 --- /dev/null +++ b/scripts/doc-examples/check.mjs @@ -0,0 +1,79 @@ +import * as Source from "@effect/doctest/Source" +import { execFileSync } from "node:child_process" +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +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. +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) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return entry.name === "internal" ? [] : sourceFiles(path) + 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 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)) { + snippets.push({ file: relative(root, file), snippet }) + } +} + +rmSync(output, { recursive: true, force: true }) +mkdirSync(output, { recursive: true }) +const names = snippets.map(({ file, snippet }) => { + const name = `${file.replaceAll(/[/.]/g, "_")}_${snippet.line}.ts` + writeFileSync(join(output, name), `${snippet.source}\nexport {}\n`) + return name +}) +writeFileSync( + join(output, "tsconfig.json"), + JSON.stringify( + { + extends: join(root, "tsconfig.base.json"), + include: ["./*.ts"], + compilerOptions: { + composite: false, + incremental: false, + noEmit: true, + declaration: false, + declarationMap: false, + sourceMap: false, + noUnusedLocals: false, + rootDir: root, + types: ["node"], + paths: { + "@typeonce/effect-machine": [join(root, "packages/effect-machine/src/index.ts")], + "@typeonce/effect-machine/testing": [join(root, "packages/effect-machine/src/testing/index.ts")], + "@typeonce/effect-machine/reactivity": [join(root, "packages/effect-machine/src/unstable/reactivity/index.ts")], + "@typeonce/effect-machine/cluster": [join(root, "packages/effect-machine/src/unstable/cluster/index.ts")], + "effect": [join(root, "packages/effect-machine/node_modules/effect/dist/index.d.ts")], + "effect/*": [join(root, "packages/effect-machine/node_modules/effect/dist/*.d.ts")] + } + } + }, + null, + 2 + ) +) + +try { + execFileSync(join(root, "node_modules/.bin/tsc"), ["-p", join(output, "tsconfig.json"), "--pretty", "false"], { + cwd: root, + stdio: "pipe", + encoding: "utf8" + }) + console.log(`doc example types passed (${names.length} examples)`) +} catch (error) { + process.stderr.write(error.stdout ?? String(error)) + process.exitCode = 1 +} diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index b2d91f57..f5543b49 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -33,7 +33,9 @@ try { "package/dist/testing/index.d.ts", "package/package.json", "package/README.md", - "package/docs/agent-guide.md", + "package/AGENTS.md", + "package/CLAUDE.md", + "package/ai-docs/src/index.md", "package/LICENSE", "package/NOTICE" ] diff --git a/vitest.docs.ts b/vitest.docs.ts new file mode 100644 index 00000000..216beed7 --- /dev/null +++ b/vitest.docs.ts @@ -0,0 +1,23 @@ +import * as Doctest from "@effect/doctest/Plugin" +import { resolve } from "node:path" +import { defineConfig } from "vitest/config" + +const source = (path: string) => resolve(import.meta.dirname, "packages/effect-machine/src", path) + +export default defineConfig({ + plugins: [Doctest.plugin()], + resolve: { + alias: [ + { find: "@typeonce/effect-machine/testing", replacement: source("testing/index.ts") }, + { find: "@typeonce/effect-machine/reactivity", replacement: source("unstable/reactivity/index.ts") }, + { find: "@typeonce/effect-machine/cluster", replacement: source("unstable/cluster/index.ts") }, + { find: "@typeonce/effect-machine", replacement: source("index.ts") } + ] + }, + test: { + passWithNoTests: true, + testTimeout: 10_000, + include: [], + includeSource: ["packages/effect-machine/src/**/*.ts"] + } +})