diff --git a/.changeset/README.md b/.changeset/README.md index 78ad896..17b7ed3 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -11,6 +11,10 @@ Effect Machine is experimental and pre-1.0. Use only: Do not create a major changeset before 1.0. Backward compatibility is not a design goal during this phase: prefer the clearest long-term API and provide a direct migration instead of adding deprecated aliases or compatibility wrappers. +## Synchronized package versions + +`@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting either package publishes both at the same version, so users can select compatible packages by matching their versions. + ## Writing changelog entries Changeset descriptions are published directly on the documentation website. Write them for library users rather than repository maintainers. diff --git a/.changeset/bright-trees-listen.md b/.changeset/bright-trees-listen.md new file mode 100644 index 0000000..2e40e35 --- /dev/null +++ b/.changeset/bright-trees-listen.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine-devtools": minor +--- + +Add a local interactive text visualizer prototype that renders the public machine inspection data as a collapsible tree. + +Use the text tree to navigate topology, expand nested states, select subtrees, and inspect structured machine details without converting the model into a chart. diff --git a/.changeset/calm-events-simulate.md b/.changeset/calm-events-simulate.md new file mode 100644 index 0000000..6e2415a --- /dev/null +++ b/.changeset/calm-events-simulate.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine-devtools": minor +--- + +Add `MachineSimulator` and browser controls for side-effect-free, best-effort topology simulation. Direct required transitions advance the active tree; runtime-dependent transitions remain visibly indeterminate instead of executing user code or guessing. diff --git a/.changeset/calm-tools-organize.md b/.changeset/calm-tools-organize.md new file mode 100644 index 0000000..274bd7c --- /dev/null +++ b/.changeset/calm-tools-organize.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine": patch +--- + +Move the published package into an Effect-style workspace without changing its public exports. diff --git a/.changeset/config.json b/.changeset/config.json index b821332..bb45105 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,12 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [ + [ + "@typeonce/effect-machine", + "@typeonce/effect-machine-devtools" + ] + ], "linked": [], "access": "public", "baseBranch": "main", diff --git a/.changeset/fair-tools-align.md b/.changeset/fair-tools-align.md new file mode 100644 index 0000000..7865bc3 --- /dev/null +++ b/.changeset/fair-tools-align.md @@ -0,0 +1,6 @@ +--- +"@typeonce/effect-machine": patch +"@typeonce/effect-machine-devtools": patch +--- + +Release `@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` at the same version. Install matching versions so the devtools inspection protocol and machine model remain compatible. diff --git a/.changeset/young-machines-watch.md b/.changeset/young-machines-watch.md new file mode 100644 index 0000000..363ec09 --- /dev/null +++ b/.changeset/young-machines-watch.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine-devtools": minor +--- + +Add a local `effect-machine` command that discovers exported `.handle(...)` machines, keeps their last valid inspection document across incomplete reloads, and serves the live interactive text visualizer. + +Native file-system events are used by default. Pass `--watch-polling` on platforms where native events are unavailable. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ee4ce50..5150f78 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,7 @@ -/src/ @SandroMaglione +/packages/ @SandroMaglione /scripts/ @SandroMaglione /.github/ @SandroMaglione /package.json @SandroMaglione /pnpm-lock.yaml @SandroMaglione -/tsconfig.build.json @SandroMaglione +/tsconfig*.json @SandroMaglione /.changeset/config.json @SandroMaglione diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ea6e6e9..528bf30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,7 +5,7 @@ ## Changeset - [ ] Added or updated for a library or package-metadata change -- [ ] Not required because this PR does not change `src/` or `package.json` +- [ ] Not required because this PR does not change a publishable package ## Validation diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dac155..fcf9533 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,9 @@ on: paths: - ".changeset/**" - "package.json" + - "packages/*/package.json" - "pnpm-lock.yaml" - - "src/**" + - "packages/*/src/**" concurrency: group: release-${{ github.ref }} diff --git a/.gitignore b/.gitignore index 86cb90b..91d443e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ references/ *.tgz .DS_Store .pnpm-store +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 4dbef30..fac5cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe ## Experimental versioning - The library is experimental and pre-1.0. Public additions and breaking API changes use a minor changeset; compatible fixes and implementation improvements use a patch changeset. Do not create major changesets before 1.0. +- `@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility. - Backward compatibility is not currently a design goal. Change or remove an existing API whenever a clearer, safer, smaller long-term design replaces it. - Do not add deprecated aliases, compatibility wrappers, or parallel APIs solely to preserve an inferior existing design unless the user explicitly requests them. - Explain the resulting API and direct migration in changesets. Do not use changelog entries to credit an external library or narrate implementation history. @@ -31,12 +32,12 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe ## Effect internal standards - Before introducing an internal abstraction or protocol, inspect the analogous implementation under `references/effect` and follow its naming, module-boundary, and ownership conventions where they apply. -- Keep public modules declarative and route implementation through `src/internal`. Preserve directed dependencies and avoid internal barrel modules. +- Keep public modules declarative and route implementation through `packages/effect-machine/src/internal`. Preserve directed dependencies and avoid internal barrel modules. - Give optimized planners and runtimes explicit contracts and ownership boundaries. Do not hide mutable state behind readonly types or retain caller-owned mutable containers in cached structures. - Treat the generic planner/runtime as the semantic reference. Optimized strategies must fail closed: a new capability uses the generic path until its optimized semantics are implemented deliberately. - 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 `test/` and implementation-strategy tests under `test/internal/`. Tests should establish observable semantics, not mirror implementation details. +- 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. ## Verification @@ -63,5 +64,5 @@ Use the pull request performance workflows to compare against the base branch; d ## Pull request conventions -- Add or update a changeset for changes under `src/` or changes to `package.json`, following the changelog-writing guide in `.changeset/README.md`. +- Add or update a changeset for changes under `packages/effect-machine/src/` or changes to a publishable package manifest, following the changelog-writing guide in `.changeset/README.md`. - Fill in the pull request template, including the validation performed and the changeset decision. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df4447f..4990b28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Wait for the proposal to be discussed and accepted before starting an implementa The source tree follows Effect's public-module/internal-implementation split: ```text -src/ +packages/effect-machine/src/ ├── Machine.ts ├── index.ts ├── testing/ @@ -23,7 +23,7 @@ Public entrypoints and public modules use Effect-style names. Private files sit under the domain they implement and use responsibility names such as `planner.ts`, `process.ts`, and `runtime.ts`; they do not repeat `machine` in every filename. Runtime tests mirror the same domains. Tests below -`test/internal/` are the only white-box suites allowed to import `src/internal`. +`packages/effect-machine/test/internal/` are the only white-box suites allowed to import `packages/effect-machine/src/internal`. The core dependency direction is: @@ -39,7 +39,7 @@ public entrypoint -> public module -> process -> planner Internal machine modules may refer back to the public `Machine` types through type-only imports. The runtime is intentionally unaware of the model, planner, and process layers. Testing implementations are isolated under -`src/internal/testing` and may only be consumed by the public testing module or +`packages/effect-machine/src/internal/testing` and may only be consumed by the public testing module or other testing internals. `pnpm check:architecture` builds a TypeScript dependency graph using the diff --git a/README.md b/README.md index 63d8b47..9e5de86 100644 --- a/README.md +++ b/README.md @@ -1,859 +1,10 @@ -# @typeonce/effect-machine +# Effect Machine -Schema-first state machines and statecharts for Effect. +This repository is the pnpm workspace for Effect Machine and its development tools. -State, event, input, output, and persistence boundaries are described with -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. +- [`@typeonce/effect-machine`](./packages/effect-machine/README.md) contains the machine runtime, testing modules, and documentation. +- [`@typeonce/effect-machine-devtools`](./packages/devtools/README.md) contains the publishable local machine visualizer and CLI. -> This is early-release software. Its API may change, and each release targets -> one exact Effect beta. +Both packages use the same version. Install matching versions of core and devtools so their inspection protocol and machine model stay compatible. -## Design principles - -- **Type-safe by construction:** reject invalid protocols, compositions, and - capabilities at compile time where possible, and preserve typed Effect - failures at runtime. -- **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. -- **Effect-native:** design toward eventual inclusion in Effect core and follow - its API shape, module boundaries, ownership, and failure conventions. - -The package is pre-1.0: a clearer or safer long-term API takes priority over -backward compatibility. Breaking changes use minor releases, compatible fixes -use patch releases, and compatibility aliases are not added by default. - -The core machine model remains local. Distributed identity, placement, -transport, routing, delivery, and remote lifecycle semantics belong to Effect -Cluster and are exposed only through explicit integration boundaries. - -## Install - -```sh -pnpm add @typeonce/effect-machine effect@4.0.0-rc.111 -``` - -`effect` is an exact peer dependency. Install the version above and upgrade it -in lockstep with this package. - -## Quick start - -Define schemas first, derive the state topology, then add behavior: - -```ts -import { Machine } from "@typeonce/effect-machine" -import { Effect, Schema, Stream } from "effect" - -const State = Schema.TaggedUnion({ - Running: { count: Schema.Number } -}) - -const States = Machine.states({ - Idle: {}, - Running: State.cases.Running -}) - -const CounterEvent = Machine.events( - Schema.TaggedUnion({ - Start: {}, - Increment: {}, - Stop: {} - }) -) - -const CounterDefinition = Machine.make({ - id: "Counter", - states: States.states, - events: CounterEvent, - initial: (to) => to.Idle() -}) - -const Counter = CounterDefinition.handle({ - Idle: { - on: { - Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 })) - } - }, - Running: { - on: { - Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })), - Stop: (to) => to.full.Idle() - } - } -}) - -const program = Effect.gen(function*() { - const ref = yield* Machine.start(Counter) - yield* ref.send(CounterEvent.Start()) - yield* ref.send(CounterEvent.Increment()) -}) -``` - -`handle` creates a complete implementation boundary. Its result does not -expose `handle`, so all behavior for one machine belongs in the same handler -tree. Reuse the definition when multiple independent implementations are -useful, such as production and testing variants: - -```ts -const ProductionCounter = CounterDefinition.handle(productionHandlers) -const TestingCounter = CounterDefinition.handle(testingHandlers) -``` - -`Machine.start` returns a `MachineRef` with `send`, `state`, `snapshot`, -`changes`, `emissions`, `join`, and `stop`. Sending enqueues an event; observe -`changes` or use the testing probe when work must be causally acknowledged. - -## Modeling workflow - -Use this order to preserve inference and keep boundaries explicit: - -1. Define domain schemas used by state and by shared event fields. -2. Declare topology with `Machine.states`, naming a tagged state union - when its `.cases` are reused. -3. Create event descriptors with `Machine.events`, `Machine.internalEvents`, - and `Machine.emittedEvents`, passing tagged unions or tagged classes directly. -4. Create the machine and implement every active state with - `Machine.make({...}).handle({...})`. -5. Add child descriptors, then runtime, Atom, testing, or cluster adapters at - the application boundary. - -Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when -the same active state definition is mounted more than once; tagged schemas are -already reusable without it. For repeated finite regions, derive names with -`States.path(...)` so every literal in the path family is checked against the -complete tree. Type full-snapshot helpers as `Machine.Snapshot` -or `Machine.Snapshot`, schema-backed state payloads as -`Machine.Value`, and path-rooted snapshots as -`Machine.SnapshotAt`. - -### Make invalid states unrepresentable - -Treat topology as a domain contract, not as file organization. A parallel state -declares the full Cartesian product of its regions, so use it only when every -combination has a coherent meaning. If one region must inspect another before -entering a state safely, prefer a compound hierarchy that makes the forbidden -combination impossible. `matches` remains useful for views, tests, and genuine -coordination between independent regions; it should not repair an invalid -state product. - -Keep state-scoped Effects beneath the state that guarantees their resources, -and enforce command availability in the machine rather than only by disabling -UI controls. When entering an inactive compound or parallel state's declared -default, select `.initial`; explicitly construct descendants only for a -non-default configuration or a complete replacement of an already-active -parallel root. - -### Construct state through builders - -Use `.from(...)` when constructing a new state from schema make input: - -```ts -target.from({ draft: event.draft }) -``` - -The machine runs these inputs through the state schema while planning. Schema -defaults, transformations, refinements, and tagged-class identity are -therefore preserved, and decode failures remain typed machine failures. This -is the default construction path. - -Use `.decoded(...)` when the value is already a `Schema.Type`: - -```ts -target.decoded(new Ready({ notice: null })) -``` - -The machine still validates the value against the schema's type side. It does -not run encoded-input transformations again. State builders are not callable; -the method name always makes the construction mode visible. - -When sibling states share fields, remove the source discriminator and pass the -remaining fields through the target schema: - -```ts -const handlers = { - Submit: (to) => - to.local.Saving().resolve(({ state, target }) => { - const { _tag: _, ...fields } = state - return target.from({ ...fields, attempt: 1 }) - }) -} -``` - -Omit `schema` when a state represents control flow but owns no data. Use `{}` -instead of defining an empty tagged schema: - -```ts -const States = Machine.states({ - Form: { - initial: "Editing", - states: { - Editing: {}, - Saving - } - } -}) - -const definition = Machine.make({ - states: States.states, - events: Machine.events(), - initial: (to) => to.Form.initial.resolve(({ target }) => target.from((form) => form.Editing.from())) -}) -``` - -Schema-less states remain active, targetable, matchable, and visible through -`getSnapshot`, but have no value to read. Their builders expose only `.from`, -their handler `state` is `undefined`, and `get` / `getWithParents` accept only -schema-backed paths. Add a schema later if the state starts owning data. - -Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and -reference its cases from the topology. For a standalone state schema whose -class identity is useful, declare a named `Schema.TaggedClass`. Do not bury -one-off tagged schema declarations inside `Machine.states`. - -Put data on the narrowest state where it is valid. If sibling phases share -data, put it on their compound parent. - -### Separate inputs, raised events, and emissions - -`events` is the public machine-input protocol. Events raised to the same machine -belong in `internalEvents`. Ephemeral outward notifications have their own -`emittedEvents` protocol: - -```ts -export const CommandEvent = Machine.events( - Schema.TaggedUnion({ Save: {} }) -) -export type PublicCommandEvent = Machine.EventOf -const InternalEvent = Machine.internalEvents( - Schema.TaggedUnion({ - Saved: { id: Schema.String }, - SaveFailed: { message: Schema.String } - }) -) -const Emissions = Machine.emittedEvents( - Schema.TaggedUnion({ - SaveObserved: { id: Schema.String } - }) -) - -const definition = Machine.make({ - states: States.states, - events: CommandEvent, - internalEvents: InternalEvent, - emittedEvents: Emissions, - initial: (to) => to.Idle() -}) -``` - -Handlers see both protocols. Typed `send` and `Machine.plan` accept only public -events. Event tags must be unique and public/internal tags must be disjoint. - -Export the descriptor returned by `Machine.events` instead of exporting its -schemas. This keeps the deferred constructors as the standard way to create -events without exposing schema `.make` methods: - -```ts -ref.send(CommandEvent.Save()) -enqueue.raise(InternalEvent.Saved({ id: "entry-1" })) -enqueue.emit(Emissions.SaveObserved({ id: "entry-1" })) -``` - -The returned constructors preserve each schema's make input, including required -fields and constructor defaults. They defer schema construction until delivery, -so invalid values fail planning or the running machine with -`MachineSchemaDecodeError` instead of throwing at the call site. -Schemas with an open discriminator such as `_tag: Schema.String` remain valid -protocols but cannot expose a finite constructor set; pass a complete event -object to `send` or `Machine.plan` for those events. - -`ref.emissions` is a hot `Stream`: it publishes only notifications produced -after subscription, replays nothing, and completes when the machine terminates. -Snapshots remain separate and stateful: `ref.changes` begins with the current -lifecycle snapshot and then follows later changes. Use `Machine.prepare` when -an observer must be installed before initial-entry actions run: - -```ts -const prepared = yield * Machine.prepare(machine) - -yield * prepared.emissions.pipe( - Stream.runForEach(handleEmission), - Effect.forkScoped({ startImmediately: true }) -) - -const ref = yield * prepared.start -``` - -`Machine.start(machine)` remains the one-step convenience for callers that do -not observe startup emissions. Preparation does not retain or replay an -emission: the observer is simply subscribed before initialization begins. - -### Inspect a live machine tree - -`Machine.prepare(machine).inspection` is the operational counterpart to the -domain-facing `changes` and `emissions` streams. It observes the prepared root -and every locally owned child, `Logic` process, Effect, and timer in one total -publication order: - -```ts -const prepared = yield * Machine.prepare(checkout) - -yield * prepared.inspection.pipe( - Stream.runForEach((record) => Console.log(record.sequence, record.subject.id, record._tag)), - Effect.forkScoped({ startImmediately: true }) -) - -const checkoutRef = yield * prepared.start -``` - -For a handled input, the stream may expose values such as: - -```ts -{ _tag: "EventSent", sequence: 2, deliveryId: 0, - subject: { id: "checkout", sessionId: "machine:0", kind: "Machine" }, - source: undefined, target: { id: "checkout", sessionId: "machine:0" }, - event: CheckoutEvents.Submit(), causedBy: undefined } - -{ _tag: "EventProcessed", sequence: 4, macrostepId: 0, - deliveryId: 0, handled: true, configurationChanged: true, - before: { status: "active", state: /* ... */ }, - after: { status: "active", state: /* ... */ }, microsteps: [/* ... */] } -``` - -The closed `Machine.Inspection.Event` union also reports creation, -initialization and startup failure, direct `Logic` state updates, outward -emissions, Effect/timer activity lifecycles, and termination. Records erase -unrelated child protocols to `unknown`; application-level observation remains -typed through each reference's `changes` and `emissions`. - -The stream is hot, non-replayed, never fails, and completes after the root -terminates. Subscribe before `prepared.start` to capture initialization. Local -session ids are unique only inside that prepared ownership tree: `machine:0` -is the root and later ids identify its descendants. They are intentionally not -distributed identities. Cluster placement, routing, and correlation continue -to use Cluster entity, runner, and request identities at the integration -boundary. - -`AtomMachine.inspection(machineAtom)` provides the same root-scoped stream and -starts a fresh atom-backed machine only after its inspection subscription is -installed. - -Invalid event and emission constructions fail the machine with a typed -`MachineSchemaDecodeError`; they do not throw from the constructor call. - -### Send explicitly between machines - -`raise` targets the current machine in the same macrostep. `sendTo` targets a -machine mailbox and is processed later. A machine that requires an owner -declares the subset of parent inputs it may send with `Machine.parent`: - -```ts -const ParentEvents = Machine.events(ChildFinished) - -const child = Machine.make({ - states: ChildStates.states, - events: ChildEvents, - parent: Machine.parent(ParentEvents), - initial: (to) => to.Working() -}).handle({ - Working: { - on: { - Finish: (to) => - to.full.Done().resolve(({ parent, target }, enqueue) => { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - return target.from() - }) - } - }, - Done: {} -}) - -const Child = Machine.child("worker", child) -const ParentInputs = Machine.events(Start, ParentEvents) -``` - -`parent` is statically present in every child callback, and root APIs such as -`Machine.start`, `Machine.planInitial`, Atom machines, and Cluster machines -reject this machine. When `Child` is invoked, the parent definition must accept -every declared parent event; otherwise `.handle(...)` is a compile-time error. -Inside the child, the parent target accepts only those declared events. - -Use `parent: Machine.optionalParent(ParentEvents)` when the same machine is -intentionally valid both as a root and as a child. In that case `parent` is -`MachineTarget<...> | undefined` and must be narrowed before sending. When no -parent declaration is present, callbacks do not expose a `parent` property. -`emit` never sends to the parent: it only publishes on the emitting machine's -`emissions` stream. - -Every handler also receives `self`, which can be targeted with `sendTo` when a -later mailbox turn is required. Use `raise` instead for same-macrostep work. -Both `self` and `parent` are minimal `Machine.MachineTarget` values. The -shared `Machine.MachineReferences` context keeps -their input protocols separate without exposing snapshot or lifecycle APIs. -Structural state values use distinct names: `containingState` is the immediate -valued state in the same statechart, while `ancestors` maps valued ancestor -paths. `parent` always means the owning machine target. - -### Choose the target by scope - -| Builder | Use when | Preserves | -| ---------------- | ---------------------------------------- | ------------------------------------------------- | -| `target.none()` | Handling without selecting a destination | The complete current configuration | -| `target.local` | Moving inside the nearest compound scope | Ancestors and unrelated parallel regions | -| `target.branch` | Moving elsewhere under the active root | Omitted active ancestors and parallel regions | -| `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root | -| `target.history` | Restoring a declared history node | The remembered configuration or its typed default | - -Every required transition handler selects a target from its inline `to` -builder. Return a bare selection such as `to.full.Idle()` when the selected -builder supports zero-argument construction; the machine applies the same -default construction as `target.from()`. This includes empty schemas and -schemas whose constructor fields are all optional or defaulted. TypeScript -rejects the bare form when state data or nested configuration is required. -Call `.resolve(...)` when construction depends on handler context or the -transition needs to enqueue commands. An absent handler ignores the trigger; `to.none` handles -it and retains queued commands, raised events, and emitted events without -selecting a destination. Concrete destinations stay narrowed inside their -resolver, and `to.branches({...})` gives the resolver only the declared named -`select` builders. Builders describe the -next logical configuration. Shared states exit and enter only when paths -change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }` -to `.resolve(...)` when the source must restart. With `to.none`, reentry -restarts the source while retaining its configuration. - -Topology-only definition instructions are values: `to.none`, declared -`.initial` and history selections, and `to.local.with`. Concrete state and -choice destinations remain calls such as `to.full.Running()`. Runtime named -branch builders remain callable, including `select.unchanged()`, because their -result carries the selected branch evidence. - -### Update an active scope value - -Use `to.local.update(...)` to replace the value owned by the nearest active -compound scope without rebuilding its active child. Use -`to.branch..update(...)` for a valued compound or parallel ancestor of the -handler source: - -```ts -const handlers = { - Increment: (to) => to.branch.root.session.update(({ current, owner }) => owner.from({ count: current.count + 1 })) -} -``` - -The update keeps the exact active descendants, their values, history records, -completion outputs, and unrelated parallel regions. It runs no exit or entry -actions and does not restart state-owned work. Eventless stabilization still -runs, so an `always` transition can react to the new value. - -The plain update method remains useful when topology does not change. It is -also a static selection for a named branch: - -```ts -to.branches({ - changed: { target: to.local.update }, - unchanged: { target: to.none } -}).resolve(({ select, event }) => - event.changed - ? select.changed.from({ count: event.count }) - : select.unchanged() -) -``` - -### Change topology and a retained owner together - -When a transition enters another child and also replaces a valued ancestor -that stays active, declare both operations on the same target: - -```ts -const handlers = { - CreatePlan: (to) => - to.local.SavingPlan() - .updating(to.branch.Ready) - .resolve(({ current, event, owner, target }) => - target.from({ - request: { _tag: "Create", input: event.input } - }).update( - owner.decoded(new Ready({ ...current, notice: null })) - ) - ) -} -``` - -`to.local.SavingPlan()` selects topology. `.updating(to.branch.Ready)` names -the retained valued owner and makes its replacement mandatory: the resolver -does not type-check unless destination construction finishes with -`.update(...)`. `current` is that owner's decoded value from the -pre-transition snapshot. `target` constructs the destination; `owner` -constructs the complete replacement owner value. - -The topology change and owner replacement apply atomically in one microstep. -The owner does not exit or reenter, its work is not restarted, and destination -entry actions observe the new owner value. Eventless stabilization follows. -Only one retained owner may be replaced by a combined target. A `full` target, -or any target that exits the selected owner, does not expose `.updating`. -Combined updates use a direct resolver in this release; named branches continue -to support value-only updates. - -For a schema-less destination, construction remains explicit: - -```ts -to.local.Idle() - .updating(to.branch.Ready) - .resolve(({ current, output, owner, target }) => - target.from().update( - owner.decoded( - new Ready({ - ...current, - day: output, - notice: "Plan changed." - }) - ) - ) - ) -``` - -Both values derive from the same pre-transition snapshot and are validated -before lifecycle actions run. Competing transitions that write the same owner -conflict; document order and hierarchy select one writer rather than applying -last-write-wins behavior. - -The resolver must return `target.decoded(value)` or `target.from(input)`. It -may return `decline()` only with `{ declinable: true }`. Pass `{ reenter: true }` -on event or invocation transitions when the handler source should exit and -enter again. Reentry applies to that source, not to the ancestor whose value -changed. - -The selector omits `update` for schema-less scopes, atomic and final states, -inactive branches, parallel sibling regions, and choice resolvers. Updating a -parallel sibling requires an event handled by that region. - -Use `declinable: true` when a resolver may decide that its transition is not -enabled. Only that resolver receives `decline()`, and its return type expands to -accept the opaque declined result: - -```ts -const handlers = { - Submit: (to) => - to.local.Saving().resolve( - ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline(), - { declinable: true } - ) -} -``` - -Declining discards work enqueued by that resolver. Event and eventless dispatch -continues with the next eligible ancestor; if no candidate accepts, no -transition is selected. This differs from `target.none()`, which consumes the -trigger and prevents an ancestor from handling it. `transitionDefinitions` -reports each handler's `acceptance` as `"required"` or `"declinable"` while -preserving the exact declared target branches. Choices and initial routing must -remain total and cannot use declinable transitions. Completion and invocation -outcomes have no ancestor candidate: declining one ignores that lifecycle -occurrence and leaves the current configuration active. - -## Statechart capabilities - -`Machine.states` supports: - -- atomic states; -- compound states with one active child; -- parallel states with one active state in every region; -- final states and typed outputs; -- transient choice states; -- shallow and deep history states. - -Declare topology—including finality, output schemas, choices, and history—only -in `states`. Handlers implement behavior and output computation without -repeating structural metadata. Final children complete their parent, so -`onDone` belongs on that compound or parallel parent. - -Transition, entry, exit, choice, initial, and history callbacks are -synchronous. Conditions use ordinary TypeScript control flow. Callbacks may -select state and enqueue explicit `raise`, `emit`, `sendTo`, or `stop` commands; -arbitrary asynchronous Effects do not run inside planning. - -## Effects, Streams, timers, and child machines - -State-scoped work starts on entry and is interrupted on exit: - -```ts -machine.handle({ - Loading: { - invoke: (from) => - from.effect("save-document", () => saveDocument) - .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id }))) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) - }, - Waiting: { - invoke: (from) => - from.timer("save-timeout", "3 seconds") - .onDone((to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))) - } -}) -``` - -The state-local `from` selector starts an `effect`, `stream`, `timer`, reusable -`logic`, or complete `child` statechart. The selected source determines which -lifecycle methods the chain requires and which methods are available. For -example, an Effect with non-`never` output and error channels must handle both; -the completed chain is the value returned by the callback: - -```ts -machine.handle({ - Loading: { - invoke: (from) => - from.effect("load-document", ({ state }) => loadDocument(state.documentId)) - .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output }))) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message }))) - } -}) -``` - -A Stream source remains independent of the parent event protocol. Each element -is mapped by `onElement`, and the next element is not pulled until that parent -macrostep commits: - -```ts -machine.handle({ - Listening: { - invoke: (from) => - from.stream("channel", () => channelMessages) - .onElement((to) => - to.none.resolve(({ element }, enqueue) => { - enqueue.raise(Events.MessageReceived({ message: element })) - }) - ) - .onDone((to) => to.none) - .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ error }))) - } -}) -``` - -`to.none` is the targetless transition value. Return it directly to keep the -current configuration, or call `.resolve(...)` when the transition only needs -to enqueue commands. A block resolver may omit its return because it is -contextually typed to return `undefined`. - -Inside `.handle(...)`, `from` receives the owning machine's public input and -declared parent protocol contextually. Source and lifecycle callbacks can send -through `self` and `parent` while retaining the invoked Effect's output and -error inference: - -```ts -const machine = Machine.make({ - events: Commands, - internalEvents: InternalEvents, - parent: Machine.parent(ParentEvents) - // ... -}).handle({ - Saving: { - invoke: (from) => - from.effect("notify-parent", () => saveDocument) - .onDone((to) => - to.none.resolve(({ parent, self }, enqueue) => { - enqueue.sendTo(self, Commands.Save()) - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - }) - ) - .onFailure((to) => to.none) - } -}) -``` - -Return an array of completed chains to compose multiple state-owned activities. -The source computation itself, process logic, or `Machine.child(id, machine)` -descriptor can be named and reused; the invocation chain stays local so its -transitions retain the exact owning state and machine protocols. - -```ts -const refreshCache = Cache.refresh - -machine.handle({ - Active: { - invoke: (from) => [ - from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none), - from.timer("expire-session", "5 minutes").onDone((to) => to.full.Expired()) - ] - } -}) -``` - -`onDone` is required for a non-`never` output, and `onFailure` is required for a -non-`never` typed error. Streams additionally require `onElement` when their -element channel is non-`never` and always require `onDone`; logic and child -chains optionally expose `onSnapshot`. A handled method disappears from the -next builder step, so every reachable lifecycle channel is handled exactly -once. Defects, interruption, and source-construction failures terminate the -owning runtime. Effect sources are factories evaluated when their state is -entered. Use an Effect containing `Effect.sleep(...)` for generic work, while -`from.timer(...)` keeps timer intent explicit and makes static durations visible -through activity inspection. - -### Spawn dynamic child machines - -Use `from.child(...)` when a state owns a fixed child lifecycle. Use the -`children` context inside an invoked Effect when the machine process owns an -open set of children that must survive state changes: - -```ts -const Plant = Machine.childFamily(plantMachine) - -const central = Machine.make({ - events: Machine.events(ResourcesOffered, PlantBroken) - // ... -}).handle({ - Commissioning: { - invoke: (from) => - from.effect("commission-wave", ({ children, state }) => - Effect.forEach( - state.plants, - (input) => children.spawn(Plant(input.id), { input }), - { discard: true } - )) - .onDone((to) => to.full.Operating()) - .onFailure((to) => to.full.CommissioningFailed()) - } -}) -``` - -`children.spawn` completes after initialization. The new child remains owned -by the machine process after the commissioning Effect completes or its state -exits. `children.sendTo` and `children.stop` address one active child from an -Effect; transition resolvers use `enqueue.sendTo` and `enqueue.stop` with the -same descriptor. Duplicate active ids fail with `ChildAlreadyExistsError` and -do not replace the existing child. Earlier successful spawns remain active if -a later spawn in the same wave fails. - -The child machine's declared `Machine.parent(...)` events must be accepted by -the owner. This is checked at each spawn call even though ids and cardinality -remain dynamic. `scope.spawn(child, { input })` provides the same descriptor -form for lower-level process logic, where the process event protocol is known. - -## Reactivity - -`AtomMachine` runs one lazy machine instance per `AtomRegistry`: - -```ts -import { AtomMachine } from "@typeonce/effect-machine/reactivity" -import { Atom } from "effect/unstable/reactivity" - -const runtime = Atom.runtime(AppLayer) -const counterAtom = AtomMachine.bind(runtime).make(Counter) -``` - -Binding a shared runtime once is the canonical form for service-backed -applications. Service-free machines can use `AtomMachine.make(Counter)`. - -The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable -`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`, -`AtomMachine.selectSnapshot`, and `AtomMachine.matches` for typed, -equality-aware derivations. React applications using `@effect/atom-react` need -a `RegistryProvider`. - -Descriptors reconstructed from a `Machine.childFamily` resolve the same child -bridge by machine identity and id: - -```ts -const Plant = Machine.childFamily(plantMachine) -const plantAtom = centralAtom.child(Plant(selectedPlantId)) -const brokenAtom = AtomMachine.matchesChild(plantAtom, "Broken") -``` - -Emissions stay streams rather than becoming retained atom state: - -```ts -const rootEmissions = AtomMachine.emissions(counterAtom) -const childEmissions = AtomMachine.childEmissions(counterAtom.child(Worker)) -``` - -These streams require the same `AtomRegistry`, follow the currently mounted -machine instance, and do not replay notifications from an earlier subscription -or child instance. - -## Persistence - -Logical snapshots can be validated for storage or transport: - -```ts -const encoded = yield * Machine.encodeSnapshot(machine, snapshot) -const decoded = yield * Machine.decodeSnapshot(machine, encoded) -const ref = yield * Machine.resume(machine, decoded) -``` - -Decoded snapshots are local runtime values and may contain class instances or -other process-local data. `encodeSnapshot` is the persistence boundary: it uses -each declared schema's canonical JSON codec and succeeds only when every active -state value, completion output, and history value is JSON. Rich values such as -dates and bigints use their schema-defined JSON representation; cyclic or -non-JSON values fail with `MachineSchemaEncodeError` instead of escaping to a -later `JSON.stringify` crash. - -Resumption restores logical state, values, completion, and history metadata. -It creates a fresh runtime: active invokes restart, timers restart at their -full duration, and prior fibers, subscriptions, queues, and child runtimes are -not restored. Store machine identity and migration/version metadata beside the -encoded snapshot. - -## Testing - -The testing entrypoint provides complementary layers: - -- `MachineTest.run` and `verify` inspect pure planner traces; -- `coverage` reports exact transition-definition and conditional-branch hits; -- invariants and generated scenarios check application laws; -- `explore` performs bounded breadth-first state-space exploration and retains - exact transition-definition and branch coverage for every plan it computes; -- `probe` causally acknowledges live runtime commands; -- runtime command models cover timers, invokes, bursts, and scheduling. - -```ts -import { MachineTest } from "@typeonce/effect-machine/testing" - -const trace = yield* MachineTest.run(Counter, { - events: [ - { _tag: "Start" }, - { _tag: "Increment" } - ] -}) - -yield* MachineTest.verify(Counter, trace) -``` - -`MachineTest` scenarios retain decoded event values for model inspection, so -pass complete decoded objects when defining scenarios manually. Pure planner -tests do not execute invokes or time. Use a started machine and a probe when -those semantics matter. - -## Entrypoints - -```ts -import { Machine } from "@typeonce/effect-machine" -import { ClusterMachine } from "@typeonce/effect-machine/cluster" -import { AtomMachine } from "@typeonce/effect-machine/reactivity" -import { MachineTest } from "@typeonce/effect-machine/testing" -``` - -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) -- [Contributing guide](./CONTRIBUTING.md) - -Use pnpm 10 and Node.js 20 or newer: - -```sh -pnpm install --frozen-lockfile -pnpm check -``` - -Declarative first-class guards are not currently part of the API; use ordinary -TypeScript conditions. Pull requests that change `src/` or `package.json` need -a changeset and the performance checks described in `AGENTS.md`. - -When equivalent Machine modules ship in Effect, this package is intended to -become a compatibility re-export before eventual retirement. +See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for repository development and validation commands. diff --git a/api-reference.config.json b/api-reference.config.json index 12ca97d..e3b8274 100644 --- a/api-reference.config.json +++ b/api-reference.config.json @@ -1,7 +1,8 @@ { "channel": "v4", "output": ".data/api-reference/v4", - "tsconfig": "tsconfig.json", + "packageDirectory": "packages/effect-machine", + "tsconfig": "packages/effect-machine/tsconfig.json", "barrels": [ { "export": ".", diff --git a/package.json b/package.json index c909bb2..1dbe404 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,17 @@ { - "name": "@typeonce/effect-machine", - "version": "0.22.0", - "description": "Schema-first state machines and statecharts for Effect", - "author": "Sandro Maglione", - "repository": { - "type": "git", - "url": "git+https://github.com/typeonce-dev/effect-machine.git" - }, - "bugs": { - "url": "https://github.com/typeonce-dev/effect-machine/issues" - }, - "homepage": "https://github.com/typeonce-dev/effect-machine#readme", + "private": true, "type": "module", - "license": "MIT", - "sideEffects": false, - "files": [ - "src/**/*.ts", - "dist", - "docs", - "README.md", - "LICENSE", - "NOTICE" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./reactivity": { - "types": "./dist/unstable/reactivity/index.d.ts", - "import": "./dist/unstable/reactivity/index.js" - }, - "./cluster": { - "types": "./dist/unstable/cluster/index.d.ts", - "import": "./dist/unstable/cluster/index.js" - }, - "./testing": { - "types": "./dist/testing/index.d.ts", - "import": "./dist/testing/index.js" - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public", - "provenance": true - }, + "packageManager": "pnpm@10.17.1", "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "pnpm --recursive --filter \"./packages/**\" run build", "test": "vitest run", - "test:types": "tstyche", - "check:architecture": "node --test scripts/check-architecture.test.mjs && node scripts/check-architecture.mjs", - "check:ci": "node --test scripts/ci-changes.test.mjs scripts/invoke-autocomplete.test.mjs scripts/runtime-performance-compatibility.test.mjs scripts/runtime-performance-regression.test.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit", + "test:types": "pnpm --dir packages/effect-machine 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/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", "perf:types": "pnpm build && node scripts/type-performance.mjs", "perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs", "format": "dprint fmt", @@ -65,14 +25,12 @@ "docs:site:serve": "node scripts/api-reference-site/serve.mjs", "test:consumer": "node scripts/test-consumer.mjs", "pack:check": "node scripts/pack-check.mjs", - "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check", + "devtools:pack-check": "node scripts/devtools-pack-check.mjs", + "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check", "changeset": "changeset", "version-packages": "changeset version && dprint fmt", "release": "pnpm build && changeset publish" }, - "peerDependencies": { - "effect": "4.0.0-rc.111" - }, "devDependencies": { "@changesets/cli": "2.31.0", "@effect/vitest": "4.0.0-rc.111", @@ -84,10 +42,7 @@ "tstyche": "7.2.1", "typedoc": "0.28.20", "typescript": "6.0.3", + "vite": "8.1.5", "vitest": "4.1.10" - }, - "packageManager": "pnpm@10.17.1", - "engines": { - "node": ">=20" } } diff --git a/packages/devtools/LICENSE b/packages/devtools/LICENSE new file mode 100644 index 0000000..afebcef --- /dev/null +++ b/packages/devtools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sandro Maglione + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/devtools/NOTICE b/packages/devtools/NOTICE new file mode 100644 index 0000000..b49e70e --- /dev/null +++ b/packages/devtools/NOTICE @@ -0,0 +1,3 @@ +Portions are adapted from the Effect project, which is distributed under the +MIT License. See https://github.com/Effect-TS/effect and the source history for +authorship and provenance. diff --git a/packages/devtools/README.md b/packages/devtools/README.md new file mode 100644 index 0000000..52ad096 --- /dev/null +++ b/packages/devtools/README.md @@ -0,0 +1,81 @@ +# Effect Machine devtools + +`@typeonce/effect-machine-devtools` scans a local project for exported Effect Machine `.handle(...)` results and serves a live text-tree visualizer. + +The package is experimental and pre-1.0. Minor releases may change its command options, document schemas, and programmatic modules. + +## Compatibility + +Core and devtools always release at the same version. Install matching versions: + +```sh +pnpm add @typeonce/effect-machine@latest effect@4.0.0-rc.111 +pnpm add --save-dev @typeonce/effect-machine-devtools@latest +``` + +When pinning a release instead of using `latest`, use the same explicit version for both packages. + +The current release requires Node.js 22.19 or newer and Effect `4.0.0-rc.111`. + +## Run the visualizer + +Start it from the project that contains the machines: + +```sh +pnpm exec effect-machine +``` + +The command scans `**/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and serves the visualizer at `http://127.0.0.1:5173`. + +```sh +pnpm exec effect-machine \ + --root ./packages/app \ + --include "src/**/*.ts" \ + --port 4173 \ + --open +``` + +| Option | Default | Purpose | +| ----------------- | --------------------------------------------- | ---------------------------------------------------------- | +| `--root` | current directory | Project directory to inspect | +| `--include` | `**/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` | Machine source glob relative to the project root | +| `--host` | `127.0.0.1` | Local server host | +| `--port` | `5173` | Local server port | +| `--open` | `false` | Open the visualizer in the default browser | +| `--watch-polling` | `false` | Use polling when native file-system events are unavailable | + +Native file-system events are the default. Polling scans more frequently and may use more CPU in large repositories, so enable it only when the platform watcher misses changes. + +## Live results + +The browser reports one of these statuses for every candidate: + +- `Ready` contains the latest complete machine document. +- `Partial` keeps the last valid document visible and adds diagnostics from the incomplete source. +- `Failed` contains diagnostics when no valid document is available. + +An incomplete edit can remove `.handle(...)` or change an export temporarily without discarding the last valid topology. A syntactically valid removal removes the machine from the index on the next scan. + +## Trusted projects only + +Discovery parses source files without executing them. Evaluation then loads candidate modules in a fresh worker. Module initialization code executes during that load, although transition resolvers and activity sources do not. + +Run the devtools only against code you trust. The server has no authentication and binds to the loopback interface by default. Do not expose it on a public or untrusted network. + +## Inspection and simulation + +The visualizer shows topology, active initial paths, state annotations, events, transitions, branches, state updates, activities, source metadata, and diagnostics. The tree supports pointer and keyboard navigation, subtree expansion, related-state highlighting, and structured detail inspection. + +Simulation works from the serialized machine document and never runs project code. It advances only when an event has one required direct transition whose target is statically known. + +Declinable transitions, conditional branches, parallel transitions, history, and choices return an indeterminate result. Deterministic steps report skipped state updates, runtime effects, raised events, reentry lifecycles, and automatic stabilization instead of pretending to execute them. + +## Programmatic modules + +The first release publishes three programmatic modules: + +- `DevToolsProtocol` defines the versioned worker and browser messages. +- `MachineDocument` defines and constructs the serializable inspection document. +- `MachineSimulator` provides the side-effect-free document simulator. + +The project inspector, registry, worker, and local server remain implementation modules. Their interfaces can change without becoming package-level compatibility commitments. diff --git a/packages/devtools/index.html b/packages/devtools/index.html new file mode 100644 index 0000000..9c802a9 --- /dev/null +++ b/packages/devtools/index.html @@ -0,0 +1,13 @@ + + + + + + + Effect Machine · Text visualizer + + +
+ + + diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000..7e66803 --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,77 @@ +{ + "name": "@typeonce/effect-machine-devtools", + "version": "0.22.0", + "description": "Local development tools for Effect Machine", + "author": "Sandro Maglione", + "repository": { + "type": "git", + "url": "git+https://github.com/typeonce-dev/effect-machine.git", + "directory": "packages/devtools" + }, + "bugs": { + "url": "https://github.com/typeonce-dev/effect-machine/issues" + }, + "homepage": "https://github.com/typeonce-dev/effect-machine/tree/main/packages/devtools#readme", + "license": "MIT", + "type": "module", + "sideEffects": false, + "bin": { + "effect-machine": "./dist/bin.js" + }, + "exports": { + ".": "./src/index.ts", + "./DevToolsProtocol": "./src/DevToolsProtocol.ts", + "./MachineDocument": "./src/MachineDocument.ts", + "./MachineSimulator": "./src/MachineSimulator.ts", + "./package.json": "./package.json", + "./internal/*": null + }, + "scripts": { + "build": "node scripts/clean.mjs && vite build --config vite.config.ts && tsc -p tsconfig.build.json", + "check": "tsc -p tsconfig.json", + "dev": "tsx src/bin.ts" + }, + "dependencies": { + "@effect/platform-browser": "4.0.0-rc.111", + "@effect/platform-node": "4.0.0-rc.111", + "@typeonce/effect-machine": "workspace:^", + "chokidar": "4.0.3", + "typescript": "6.0.3", + "vite": "8.1.5" + }, + "peerDependencies": { + "effect": "4.0.0-rc.111" + }, + "devDependencies": { + "@types/node": "25.7.0", + "effect": "4.0.0-rc.111", + "tsx": "4.21.0" + }, + "files": [ + "src/**/*", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "dist/client/**/*", + "index.html", + "README.md", + "LICENSE", + "NOTICE" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./DevToolsProtocol": "./dist/DevToolsProtocol.js", + "./MachineDocument": "./dist/MachineDocument.js", + "./MachineSimulator": "./dist/MachineSimulator.js", + "./internal/*": null + } + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/packages/devtools/scripts/clean.mjs b/packages/devtools/scripts/clean.mjs new file mode 100644 index 0000000..28f0bf9 --- /dev/null +++ b/packages/devtools/scripts/clean.mjs @@ -0,0 +1,3 @@ +import { rm } from "node:fs/promises" + +await rm(new URL("../dist", import.meta.url), { recursive: true, force: true }) diff --git a/packages/devtools/src/DevServer.ts b/packages/devtools/src/DevServer.ts new file mode 100644 index 0000000..f22d274 --- /dev/null +++ b/packages/devtools/src/DevServer.ts @@ -0,0 +1,45 @@ +/** + * Local HTTP server for the Effect Machine visualizer. + * + * @since 0.23.0 + */ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as internal from "./internal/devServer.js" +import type * as MachineRegistry from "./MachineRegistry.js" + +/** + * @category models + * @since 0.23.0 + */ +export interface Options { + readonly root: string + readonly host: string + readonly port: number + readonly open?: boolean | undefined + readonly debounce?: number | undefined + readonly watchPolling?: boolean | undefined +} + +/** + * Failure while starting or serving the local visualizer. + * + * @category errors + * @since 0.23.0 + */ +export class DevServerError extends Schema.Error( + "@typeonce/effect-machine-devtools/DevServer/DevServerError" +)({ + _tag: Schema.tag("DevServerError"), + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} + +/** + * Starts the local visualizer and runs until interrupted. + * + * @category constructors + * @since 0.23.0 + */ +export const run = (options: Options): Effect.Effect => + internal.run(DevServerError, options) diff --git a/packages/devtools/src/DevToolsProtocol.ts b/packages/devtools/src/DevToolsProtocol.ts new file mode 100644 index 0000000..c8a32af --- /dev/null +++ b/packages/devtools/src/DevToolsProtocol.ts @@ -0,0 +1,146 @@ +/** + * Versioned messages exchanged by Effect Machine devtools processes. + * + * @since 0.23.0 + */ +import * as Schema from "effect/Schema" +import * as MachineDocument from "./MachineDocument.js" + +/** + * Current devtools protocol version. + * + * @category models + * @since 0.23.0 + */ +export const protocolVersion = 1 as const + +/** + * @category schemas + * @since 0.23.0 + */ +export const Location = Schema.Struct({ + file: Schema.String, + line: Schema.NullOr(Schema.Natural), + column: Schema.NullOr(Schema.Natural) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Location = Schema.Schema.Type + +/** + * A recoverable or terminal problem associated with one machine candidate. + * + * @category schemas + * @since 0.23.0 + */ +export const Diagnostic = Schema.Struct({ + severity: Schema.Literals(["warning", "error"]), + code: Schema.String, + message: Schema.String, + location: Schema.NullOr(Location), + statePath: Schema.NullOr(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Diagnostic = Schema.Schema.Type + +const ResultFields = { + protocolVersion: Schema.Literal(protocolVersion), + key: Schema.String +} + +/** + * A complete machine inspection result. + * + * @category schemas + * @since 0.23.0 + */ +export const Ready = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Ready"), + document: MachineDocument.MachineDocument, + diagnostics: Schema.Array(Diagnostic) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Ready = Schema.Schema.Type + +/** + * A usable document accompanied by diagnostics from an incomplete reload. + * + * @category schemas + * @since 0.23.0 + */ +export const Partial = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Partial"), + document: MachineDocument.MachineDocument, + diagnostics: Schema.Array(Diagnostic) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Partial = Schema.Schema.Type + +/** + * A machine candidate that could not produce a document. + * + * @category schemas + * @since 0.23.0 + */ +export const Failed = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Failed"), + source: MachineDocument.Source, + machineId: Schema.NullOr(Schema.String), + diagnostics: Schema.Array(Diagnostic) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Failed = Schema.Schema.Type + +/** + * Schema for every result returned by discovery and evaluation. + * + * @category schemas + * @since 0.23.0 + */ +export const MachineResult = Schema.Union([Ready, Partial, Failed]) + +/** + * @category models + * @since 0.23.0 + */ +export type MachineResult = Schema.Schema.Type + +/** + * A complete live-registry update sent to browser clients. + * + * @category schemas + * @since 0.23.0 + */ +export const RegistrySnapshot = Schema.Struct({ + protocolVersion: Schema.Literal(protocolVersion), + revision: Schema.Natural, + results: Schema.Array(MachineResult) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type RegistrySnapshot = Schema.Schema.Type diff --git a/packages/devtools/src/MachineDocument.ts b/packages/devtools/src/MachineDocument.ts new file mode 100644 index 0000000..036b705 --- /dev/null +++ b/packages/devtools/src/MachineDocument.ts @@ -0,0 +1,264 @@ +/** + * Serializable inspection documents for Effect Machine devtools. + * + * @since 0.23.0 + */ +import type { Machine } from "@typeonce/effect-machine" +import * as Schema from "effect/Schema" +import * as internal from "./internal/machineDocument.js" + +/** + * Current machine document schema version. + * + * @category models + * @since 0.23.0 + */ +export const schemaVersion = 1 as const + +/** + * Source module and export that produced a machine. + * + * @category schemas + * @since 0.23.0 + */ +export const Source = Schema.Struct({ + file: Schema.String, + exportName: Schema.NullOr(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Source = Schema.Schema.Type + +/** + * Static target selection retained from a transition definition. + * + * @category schemas + * @since 0.23.0 + */ +export const Selection = Schema.Struct({ + path: Schema.NullOr(Schema.String), + kind: Schema.Literals(["state", "initial", "history", "choice", "update", "none"]), + scope: Schema.NullOr(Schema.Literals(["local", "branch", "full", "initial"])) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Selection = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const Initial = Schema.Struct({ + target: Schema.String, + selection: Selection +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Initial = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const State = Schema.Struct({ + path: Schema.String, + key: Schema.String, + order: Schema.Natural, + title: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + documentation: Schema.NullOr(Schema.String), + type: Schema.Literals(["atomic", "compound", "parallel", "final", "history", "choice"]), + history: Schema.NullOr(Schema.Literals(["shallow", "deep"])), + parent: Schema.NullOr(Schema.String), + children: Schema.Array(Schema.String), + initial: Schema.NullOr(Schema.String), + transitionIds: Schema.Array(Schema.String), + activityIds: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type State = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const Trigger = Schema.Union([ + Schema.Struct({ type: Schema.tag("event"), event: Schema.String }), + Schema.Struct({ type: Schema.tag("always") }), + Schema.Struct({ type: Schema.tag("done") }), + Schema.Struct({ type: Schema.tag("choice") }), + Schema.Struct({ + type: Schema.tag("invoke"), + id: Schema.String, + outcome: Schema.Literals(["element", "done", "failure", "snapshot"]) + }) +]) + +/** + * @category models + * @since 0.23.0 + */ +export type Trigger = Schema.Schema.Type + +const BranchFields = { + id: Schema.String, + target: Schema.NullOr(Schema.String), + selection: Selection, + updates: Schema.Array(Schema.String) +} + +/** + * @category schemas + * @since 0.23.0 + */ +export const Branch = Schema.Union([ + Schema.Struct({ ...BranchFields, type: Schema.tag("direct") }), + Schema.Struct({ + ...BranchFields, + type: Schema.tag("branch"), + key: Schema.String, + title: Schema.String + }) +]) + +/** + * @category models + * @since 0.23.0 + */ +export type Branch = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const Transition = Schema.Struct({ + id: Schema.String, + source: Schema.String, + trigger: Trigger, + reenter: Schema.Boolean, + acceptance: Schema.Literals(["required", "declinable"]), + branches: Schema.Array(Branch) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Transition = Schema.Schema.Type + +const ActivityFields = { + id: Schema.String, + source: Schema.String, + lifecycleId: Schema.String +} + +/** + * @category schemas + * @since 0.23.0 + */ +export const Activity = Schema.Union([ + Schema.Struct({ ...ActivityFields, type: Schema.tag("process") }), + Schema.Struct({ + ...ActivityFields, + type: Schema.tag("effect"), + outcomes: Schema.Struct({ + success: Schema.Literal("dynamic"), + failure: Schema.Literals(["dynamic", "none"]) + }) + }), + Schema.Struct({ + ...ActivityFields, + type: Schema.tag("timer"), + duration: Schema.String + }), + Schema.Struct({ ...ActivityFields, type: Schema.tag("stream") }), + Schema.Struct({ + ...ActivityFields, + type: Schema.tag("machine"), + child: Schema.Struct({ + id: Schema.String, + machineId: Schema.NullOr(Schema.String) + }) + }) +]) + +/** + * @category models + * @since 0.23.0 + */ +export type Activity = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const Snapshot = Schema.Struct({ + activePaths: Schema.Array(Schema.String), + candidateEvents: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Snapshot = Schema.Schema.Type + +/** + * Complete, versioned, JSON-safe machine inspection document. + * + * @category schemas + * @since 0.23.0 + */ +export const MachineDocument = Schema.Struct({ + schemaVersion: Schema.Literal(schemaVersion), + revision: Schema.Natural, + source: Schema.NullOr(Source), + machineId: Schema.String, + initial: Initial, + roots: Schema.Array(Schema.String), + states: Schema.Array(State), + transitions: Schema.Array(Transition), + activities: Schema.Array(Activity), + snapshot: Schema.NullOr(Snapshot) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type MachineDocument = Schema.Schema.Type + +/** + * Options for capturing a machine document. + * + * @category models + * @since 0.23.0 + */ +export interface MakeOptions { + readonly revision?: number | undefined + readonly source?: Source | undefined + readonly snapshot?: Machine.Machine.Snapshot> | undefined +} + +/** + * Captures every serializable definition exposed by Effect Machine inspection. + * It never evaluates transition resolvers or activity sources. + * + * @category constructors + * @since 0.23.0 + */ +export const make: (machine: M, options?: MakeOptions) => MachineDocument = + internal.make diff --git a/packages/devtools/src/MachineRegistry.ts b/packages/devtools/src/MachineRegistry.ts new file mode 100644 index 0000000..be1e989 --- /dev/null +++ b/packages/devtools/src/MachineRegistry.ts @@ -0,0 +1,69 @@ +/** + * Live registry of machine inspection results for one project. + * + * @since 0.23.0 + */ +import * as Context from "effect/Context" +import type * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import type * as Stream from "effect/Stream" +import * as DevToolsProtocol from "./DevToolsProtocol.js" +import * as internal from "./internal/machineRegistry.js" +import type * as ProjectInspector from "./ProjectInspector.js" + +/** + * Current registry state sent to browser clients. + * + * @category schemas + * @since 0.23.0 + */ +export const Snapshot = DevToolsProtocol.RegistrySnapshot + +/** + * @category models + * @since 0.23.0 + */ +export type Snapshot = Schema.Schema.Type + +/** + * @category models + * @since 0.23.0 + */ +export interface Options extends ProjectInspector.InspectOptions { +} + +/** + * Failure while starting the initial project inspection. + * + * @category errors + * @since 0.23.0 + */ +export class RegistryError extends Schema.Error( + "@typeonce/effect-machine-devtools/MachineRegistry/RegistryError" +)({ + _tag: Schema.tag("RegistryError"), + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} + +/** + * A last-known-good machine registry. Reload failures preserve the previous + * document as a partial result while exposing the new diagnostics. + * + * @category services + * @since 0.23.0 + */ +export class MachineRegistry extends Context.Service + readonly changes: Stream.Stream + readonly refresh: Effect.Effect +}>()("@typeonce/effect-machine-devtools/MachineRegistry") {} + +/** + * Builds a scoped registry that scans immediately and refreshes after relevant + * source file changes. + * + * @category layers + * @since 0.23.0 + */ +export const layer = (options: Options) => internal.layer({ MachineRegistry, RegistryError }, options) diff --git a/packages/devtools/src/MachineSimulator.ts b/packages/devtools/src/MachineSimulator.ts new file mode 100644 index 0000000..a4a569f --- /dev/null +++ b/packages/devtools/src/MachineSimulator.ts @@ -0,0 +1,154 @@ +/** + * Side-effect-free, best-effort simulation over a machine document. + * + * @since 0.23.0 + */ +import * as Schema from "effect/Schema" +import * as internal from "./internal/machineSimulator.js" +import type * as MachineDocument from "./MachineDocument.js" + +/** + * Serializable state of a simulation session. + * + * @category schemas + * @since 0.23.0 + */ +export const Snapshot = Schema.Struct({ + step: Schema.Natural, + activePaths: Schema.Array(Schema.String), + candidateEvents: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Snapshot = Schema.Schema.Type + +/** + * Opaque simulation state paired with its source document. + * + * @category models + * @since 0.23.0 + */ +export interface Session { + readonly document: MachineDocument.MachineDocument + readonly snapshot: Snapshot +} + +/** + * Runtime behavior deliberately skipped by a best-effort step. + * + * @category schemas + * @since 0.23.0 + */ +export const Note = Schema.Literals([ + "runtime-effects-skipped", + "state-updates-skipped", + "reentry-lifecycles-skipped", + "automatic-transitions-skipped" +]) + +/** + * @category models + * @since 0.23.0 + */ +export type Note = Schema.Schema.Type + +const ResultFields = { + event: Schema.String, + transitionIds: Schema.Array(Schema.String), + session: Snapshot +} + +/** + * A topologically deterministic step. + * + * @category schemas + * @since 0.23.0 + */ +export const Applied = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Applied"), + notes: Schema.Array(Note) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Applied = Schema.Schema.Type + +/** + * An event with no registration in the current active configuration. + * + * @category schemas + * @since 0.23.0 + */ +export const Blocked = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Blocked"), + reason: Schema.Literal("event-not-enabled") +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Blocked = Schema.Schema.Type + +/** + * A step whose topology depends on runtime behavior the document cannot safely + * evaluate. + * + * @category schemas + * @since 0.23.0 + */ +export const Indeterminate = Schema.Struct({ + ...ResultFields, + _tag: Schema.tag("Indeterminate"), + reason: Schema.Literals([ + "multiple-transitions", + "declinable-transition", + "conditional-branches", + "history-target", + "choice-target", + "missing-target" + ]) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Indeterminate = Schema.Schema.Type + +/** + * @category schemas + * @since 0.23.0 + */ +export const StepResult = Schema.Union([Applied, Blocked, Indeterminate]) + +/** + * @category models + * @since 0.23.0 + */ +export type StepResult = Schema.Schema.Type + +/** + * Starts from the captured snapshot when present, otherwise from the static + * initial topology. + * + * @category constructors + * @since 0.23.0 + */ +export const start: (document: MachineDocument.MachineDocument) => Session = internal.start + +/** + * Sends an event without running user code. `Applied` means the active topology + * is statically known; its notes list runtime behavior that was skipped. + * + * @category combinators + * @since 0.23.0 + */ +export const send: (session: Session, event: string) => StepResult = internal.send diff --git a/packages/devtools/src/ProjectInspector.ts b/packages/devtools/src/ProjectInspector.ts new file mode 100644 index 0000000..933b2ee --- /dev/null +++ b/packages/devtools/src/ProjectInspector.ts @@ -0,0 +1,140 @@ +/** + * Discovery and isolated evaluation of Effect Machine definitions in a project. + * + * @since 0.23.0 + */ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import type * as DevToolsProtocol from "./DevToolsProtocol.js" +import * as internal from "./internal/projectInspector.js" + +/** + * A source module containing at least one `.handle(...)` machine definition. + * + * @category schemas + * @since 0.23.0 + */ +export const Candidate = Schema.Struct({ + file: Schema.String, + exportNames: Schema.Array(Schema.String) +}) + +/** + * @category models + * @since 0.23.0 + */ +export type Candidate = Schema.Schema.Type + +/** + * Options shared by discovery and inspection. + * + * Include and exclude entries are glob patterns relative to `root`. + * + * @category models + * @since 0.23.0 + */ +export interface InspectOptions { + readonly root: string + readonly include?: string | undefined + readonly exclude?: ReadonlyArray | undefined + readonly revision?: number | undefined + /** + * Last-known machine candidates. A syntactically incomplete source file is + * evaluated with this identity so the registry can retain its document. + * + * @internal + */ + readonly retainedCandidates?: ReadonlyArray | undefined +} + +/** + * A typed failure while reading or parsing the project source tree. + * + * @category errors + * @since 0.23.0 + */ +export class DiscoveryError extends Schema.Error( + "@typeonce/effect-machine-devtools/ProjectInspector/DiscoveryError" +)({ + _tag: Schema.tag("DiscoveryError"), + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} + +/** + * A typed failure in the isolated evaluator itself. Failures in individual + * project modules are returned as `DevToolsProtocol.Failed` values instead. + * + * @category errors + * @since 0.23.0 + */ +export class EvaluationError extends Schema.Error( + "@typeonce/effect-machine-devtools/ProjectInspector/EvaluationError" +)({ + _tag: Schema.tag("EvaluationError"), + message: Schema.String, + cause: Schema.optional(Schema.Defect()) +}) {} + +/** + * Service for locating and evaluating machine modules. + * + * @category services + * @since 0.23.0 + */ +export class ProjectInspector extends Context.Service Effect.Effect, DiscoveryError> + readonly evaluate: ( + candidates: ReadonlyArray, + options: InspectOptions + ) => Effect.Effect, EvaluationError> + readonly inspect: ( + options: InspectOptions + ) => Effect.Effect, DiscoveryError | EvaluationError> +}>()("@typeonce/effect-machine-devtools/ProjectInspector") {} + +/** + * Discovers machine candidates with the configured inspector. + * + * @category combinators + * @since 0.23.0 + */ +export const discover = (options: InspectOptions): Effect.Effect< + ReadonlyArray, + DiscoveryError, + ProjectInspector +> => Effect.flatMap(ProjectInspector, (inspector) => inspector.discover(options)) + +/** + * Evaluates machine candidates with the configured inspector. + * + * @category combinators + * @since 0.23.0 + */ +export const evaluate = ( + candidates: ReadonlyArray, + options: InspectOptions +): Effect.Effect, EvaluationError, ProjectInspector> => + Effect.flatMap(ProjectInspector, (inspector) => inspector.evaluate(candidates, options)) + +/** + * Discovers and evaluates every machine candidate in a project. + * + * @category combinators + * @since 0.23.0 + */ +export const inspect = (options: InspectOptions): Effect.Effect< + ReadonlyArray, + DiscoveryError | EvaluationError, + ProjectInspector +> => Effect.flatMap(ProjectInspector, (inspector) => inspector.inspect(options)) + +/** + * Node-backed inspector layer. Each inspection evaluates candidates inside a + * fresh worker so a broken project module cannot corrupt the long-lived host. + * + * @category layers + * @since 0.23.0 + */ +export const layer = internal.layer({ ProjectInspector, DiscoveryError, EvaluationError }) diff --git a/packages/devtools/src/bin.ts b/packages/devtools/src/bin.ts new file mode 100644 index 0000000..193af46 --- /dev/null +++ b/packages/devtools/src/bin.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Command from "effect/unstable/cli/Command" +import * as Flag from "effect/unstable/cli/Flag" +import PackageJson from "../package.json" with { type: "json" } +import * as DevServer from "./DevServer.js" +import * as MachineRegistry from "./MachineRegistry.js" +import * as ProjectInspector from "./ProjectInspector.js" + +const root = Flag.directory("root", { mustExist: true }).pipe( + Flag.withDescription("Project root to inspect"), + Flag.withDefault(process.cwd()) +) + +const include = Flag.string("include").pipe( + Flag.withDescription("Machine source glob relative to the project root"), + Flag.withDefault("**/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}") +) + +const host = Flag.string("host").pipe( + Flag.withDescription("Host for the local visualizer"), + Flag.withDefault("127.0.0.1") +) + +const port = Flag.integer("port").pipe( + Flag.withDescription("Port for the local visualizer"), + Flag.withDefault(5173) +) + +const open = Flag.boolean("open").pipe( + Flag.withDescription("Open the visualizer in the default browser"), + Flag.withDefault(false) +) + +const watchPolling = Flag.boolean("watch-polling").pipe( + Flag.withDescription("Use polling instead of native file-system events"), + Flag.withDefault(false) +) + +const cli = Command.make("effect-machine", { root, include, host, port, open, watchPolling }).pipe( + Command.withDescription("Inspect Effect Machine definitions in a live local visualizer"), + Command.withHandler(({ host, include, open, port, root, watchPolling }) => { + const RegistryLayer = MachineRegistry.layer({ root, include }).pipe( + Layer.provideMerge(ProjectInspector.layer) + ) + return DevServer.run({ root, host, port, open, watchPolling }).pipe(Effect.provide(RegistryLayer)) + }) +) + +Command.run(cli, { version: PackageJson.version }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain +) diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000..91df872 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,20 @@ +/** + * Local development tools for Effect Machine. + * + * @since 0.23.0 + */ + +/** + * @since 0.23.0 + */ +export * as DevToolsProtocol from "./DevToolsProtocol.js" + +/** + * @since 0.23.0 + */ +export * as MachineDocument from "./MachineDocument.js" + +/** + * @since 0.23.0 + */ +export * as MachineSimulator from "./MachineSimulator.js" diff --git a/packages/devtools/src/internal/browser/example-machine.ts b/packages/devtools/src/internal/browser/example-machine.ts new file mode 100644 index 0000000..4508740 --- /dev/null +++ b/packages/devtools/src/internal/browser/example-machine.ts @@ -0,0 +1,134 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +// Shared by the live browser UI and its project-inspection fixture. + +class Application extends Schema.TaggedClass("Application")("Application", {}) {} +class Workflow extends Schema.TaggedClass("Workflow")("Workflow", {}) {} +class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} +class Running extends Schema.TaggedClass("Running")("Running", {}) {} +class Editing extends Schema.TaggedClass("Editing")("Editing", {}) {} +class Complete extends Schema.TaggedClass("Complete")("Complete", {}) {} +class Connection extends Schema.TaggedClass("Connection")("Connection", {}) {} +class Online extends Schema.TaggedClass("Online")("Online", {}) {} +class Offline extends Schema.TaggedClass("Offline")("Offline", {}) {} +class Disabled extends Schema.TaggedClass("Disabled")("Disabled", {}) {} +class Start extends Schema.TaggedClass("Start")("Start", {}) {} +class Finish extends Schema.TaggedClass("Finish")("Finish", {}) {} +class Disconnect extends Schema.TaggedClass("Disconnect")("Disconnect", {}) {} +class Refresh extends Schema.TaggedClass("Refresh")("Refresh", {}) {} + +const States = Machine.states({ + application: { + schema: Application, + type: "parallel", + states: { + workflow: { + schema: Workflow, + initial: "idle", + states: { + idle: Idle, + running: { + schema: Running, + initial: "editing", + states: { + editing: Editing, + complete: { + schema: Complete, + type: "final" + } + } + }, + recent: { + type: "history" + } + } + }, + connection: { + schema: Connection, + initial: "online", + states: { + online: Online, + offline: Offline + } + } + } + }, + disabled: Disabled +}) + +export const snapshot = { + path: "application" as const, + value: new Application({}), + states: { + workflow: { + path: "application.workflow" as const, + value: new Workflow({}), + state: { path: "application.workflow.idle" as const, value: new Idle({}) } + }, + connection: { + path: "application.connection" as const, + value: new Connection({}), + state: { path: "application.connection.online" as const, value: new Online({}) } + } + } +} + +const initialWorkflow = (): Machine.Machine.CompleteSnapshotContaining< + typeof States.states, + "application.workflow" +> => snapshot + +export const machine = Machine.make({ + id: "inspection-example", + states: States.states, + events: Machine.events(Start, Finish, Disconnect, Refresh), + initial: (to) => to.application.initial.resolve(() => snapshot) +}).handle({ + application: { + states: { + workflow: { + history: { + recent: { + default: initialWorkflow + } + }, + states: { + idle: { + on: { + Start: (to) => + to.local.running() + .updating(to.branch.application.workflow) + .resolve(({ owner, target }) => + target.decoded( + new Running({}), + (running) => running.editing.decoded(new Editing({})) + ).update(owner.decoded(new Workflow({}))) + ), + Refresh: (to) => to.local.update(({ owner }) => owner.decoded(new Workflow({}))) + } + }, + running: { + initialize: ({ builder }) => builder.decoded(new Editing({})), + states: { + editing: { + on: { + Finish: (to) => to.local.complete().resolve(({ target }) => target.decoded(new Complete({}))) + } + } + } + } + } + }, + connection: { + states: { + online: { + on: { + Disconnect: (to) => to.local.offline().resolve(({ target }) => target.decoded(new Offline({}))) + } + } + } + } + } + } +}) diff --git a/packages/devtools/src/internal/browser/machine-index.ts b/packages/devtools/src/internal/browser/machine-index.ts new file mode 100644 index 0000000..edbf609 --- /dev/null +++ b/packages/devtools/src/internal/browser/machine-index.ts @@ -0,0 +1,81 @@ +import type { MachineResult, RegistrySnapshot } from "../../DevToolsProtocol.js" +import { mountVisualizer } from "./visualizer.js" + +let selectedKey: string | undefined + +const createElement = ( + tag: Tag, + className?: string, + text?: string +): HTMLElementTagNameMap[Tag] => { + const element = document.createElement(tag) + if (className !== undefined) element.className = className + if (text !== undefined) element.textContent = text + return element +} + +const resultLabel = (result: MachineResult): string => { + if (result._tag === "Ready" || result._tag === "Partial") return result.document.machineId + return result.machineId ?? result.source.exportName ?? result.source.file.split(/[\\/]/).at(-1) ?? result.key +} + +const resultFile = (result: MachineResult): string => + result._tag === "Ready" || result._tag === "Partial" + ? result.document.source?.file ?? "unknown source" + : result.source.file + +const statusLabel = (result: MachineResult): string => { + switch (result._tag) { + case "Ready": + return "ready" + case "Partial": + return "partial" + case "Failed": + return "error" + } +} + +export const mountMachineIndex = (root: HTMLElement, snapshot: RegistrySnapshot): void => { + const results = [...snapshot.results].sort((left, right) => resultLabel(left).localeCompare(resultLabel(right))) + if (selectedKey === undefined || !results.some((result) => result.key === selectedKey)) { + selectedKey = results[0]?.key + } + + const shell = createElement("main", "devtools-shell") + const index = createElement("nav", "machine-index") + index.setAttribute("aria-label", "Machines") + const view = createElement("div", "machine-view") + + if (results.length === 0) { + index.append(createElement("div", "machine-index-empty", "No machines")) + const empty = createElement("div", "registry-empty") + empty.append( + createElement("strong", undefined, "No .handle machines found"), + createElement("span", undefined, "The list updates when a matching source file changes.") + ) + view.append(empty) + } else { + for (const result of results) { + const button = createElement("button", `machine-row${result.key === selectedKey ? " is-selected" : ""}`) + button.type = "button" + button.dataset.machineKey = result.key + button.setAttribute("aria-current", result.key === selectedKey ? "true" : "false") + const label = createElement("span", "machine-row-label", resultLabel(result)) + const file = createElement("span", "machine-row-file", resultFile(result)) + const status = createElement("span", `machine-row-status status-${statusLabel(result)}`) + status.setAttribute("aria-label", statusLabel(result)) + button.append(status, label, file) + button.addEventListener("click", () => { + selectedKey = result.key + mountMachineIndex(root, snapshot) + }) + index.append(button) + } + + const selected = results.find((result) => result.key === selectedKey) ?? results[0] + if (selected !== undefined) mountVisualizer(view, selected) + } + + shell.append(index, view) + root.replaceChildren(shell) +} diff --git a/packages/devtools/src/internal/browser/main.ts b/packages/devtools/src/internal/browser/main.ts new file mode 100644 index 0000000..ee52cbe --- /dev/null +++ b/packages/devtools/src/internal/browser/main.ts @@ -0,0 +1,42 @@ +import * as BrowserRuntime from "@effect/platform-browser/BrowserRuntime" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import "./styles.css" +import * as DevToolsProtocol from "../../DevToolsProtocol.js" +import { mountMachineIndex } from "./machine-index.js" + +const root = document.querySelector("#app") +if (root === null) throw new Error("Visualizer root element was not found") + +const showConnectionFailure = (message: string): void => { + const failure = document.createElement("div") + failure.className = "connection-failure" + failure.textContent = message + root.replaceChildren(failure) +} + +const connect = Effect.acquireRelease( + Effect.sync(() => new EventSource("/api/events")), + (events) => Effect.sync(() => events.close()) +).pipe( + Effect.flatMap((events) => + Effect.callback((_resume, signal) => { + events.onmessage = (event) => { + try { + const snapshot = Schema.decodeUnknownSync(DevToolsProtocol.RegistrySnapshot)(JSON.parse(event.data)) + mountMachineIndex(root, snapshot) + } catch (cause) { + showConnectionFailure(cause instanceof Error ? cause.message : String(cause)) + } + } + events.onerror = () => { + if (events.readyState === EventSource.CLOSED) { + showConnectionFailure("The visualizer server disconnected. Restart the command to reconnect.") + } + } + signal.addEventListener("abort", () => events.close(), { once: true }) + }) + ) +) + +Effect.scoped(connect).pipe(BrowserRuntime.runMain) diff --git a/packages/devtools/src/internal/browser/styles.css b/packages/devtools/src/internal/browser/styles.css new file mode 100644 index 0000000..bf33fca --- /dev/null +++ b/packages/devtools/src/internal/browser/styles.css @@ -0,0 +1,817 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e8eaed; + background: #0b0c0e; + font-synthesis: none; + text-rendering: optimizeLegibility; + --surface: #0f1114; + --surface-raised: #14171b; + --line: #292d34; + --line-soft: #20242a; + --muted: #8e949e; + --accent: #75a7ff; + --accent-bg: rgb(75 125 255 / 18%); + --orange: #f0a35b; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +body { + background: #0b0c0e; +} + +.devtools-shell { + display: grid; + min-height: 100vh; + grid-template-columns: 220px minmax(0, 1fr); +} + +.machine-index { + min-width: 0; + height: 100vh; + padding: 8px 0; + border-right: 1px solid var(--line); + background: #0d0f12; + overflow: auto; +} + +.machine-row { + display: grid; + width: 100%; + grid-template-columns: 8px minmax(0, 1fr); + gap: 3px 8px; + padding: 10px 12px; + border: 0; + color: #c9cdd2; + background: transparent; + text-align: left; + cursor: pointer; + user-select: none; +} + +.machine-row:hover, +.machine-row:focus-visible { + color: #fff; + background: #171a1f; + outline: none; +} + +.machine-row.is-selected { + color: #fff; + background: rgb(75 125 255 / 18%); +} + +.machine-row-status { + width: 6px; + height: 6px; + align-self: center; + grid-row: 1; + border-radius: 50%; + background: #646b75; +} + +.machine-row-status.status-ready { + background: #67c894; +} + +.machine-row-status.status-partial { + background: var(--orange); +} + +.machine-row-status.status-error { + background: #df6964; +} + +.machine-row-label, +.machine-row-file { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.machine-row-label { + grid-column: 2; + font: 600 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.machine-row-file { + grid-column: 2; + color: #727983; + font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.machine-index-empty { + padding: 12px; + color: #646b75; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.machine-view { + min-width: 0; +} + +.registry-empty, +.connection-failure { + display: grid; + min-height: 100vh; + align-content: start; + gap: 7px; + padding: 28px; + color: #7f8690; + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.registry-empty strong { + color: #d4d7dc; + font-size: 13px; +} + +.connection-failure { + color: #e4b4b1; +} + +button { + font: inherit; +} + +.app-shell, +.workspace { + min-height: 100vh; +} + +.workspace { + display: grid; + height: 100vh; + grid-template-columns: minmax(420px, 1fr) minmax(380px, 46%); +} + +.tree-panel { + display: flex; + min-width: 0; + height: 100vh; + flex-direction: column; + border-right: 1px solid var(--line); + overflow: hidden; +} + +.diagnostics { + border-bottom: 1px solid var(--line); + background: #121418; +} + +.diagnostic { + display: flex; + min-height: 36px; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 16px; + color: #aeb4bd; + font-size: 11px; +} + +.diagnostic + .diagnostic { + border-top: 1px solid var(--line-soft); +} + +.badge-warning { + color: #e9c89f; + background: rgb(240 163 91 / 13%); +} + +.badge-error { + color: #f2aaa7; + background: rgb(224 89 84 / 14%); +} + +.toolbar { + display: flex; + min-height: 46px; + align-items: center; + justify-content: space-between; + gap: 2px; + padding: 7px 16px; + border-bottom: 1px solid var(--line); +} + +.toolbar-actions, +.runtime-summary { + display: flex; + align-items: center; +} + +.toolbar-actions { + gap: 2px; +} + +.runtime-summary { + gap: 7px; + color: #7f8690; + font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.runtime-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #555c65; +} + +.runtime-dot.has-snapshot { + background: var(--accent); +} + +.toolbar-button { + padding: 6px 9px; + border: 0; + border-radius: 4px; + color: var(--muted); + background: transparent; + font-size: 12px; + cursor: pointer; +} + +.toolbar-button:not(:disabled):hover, +.toolbar-button:not(:disabled):focus-visible { + color: #fff; + background: #1b1e23; + outline: none; +} + +.toolbar-button:disabled { + color: #50545c; + cursor: default; +} + +.topology-tree { + flex: 1; + min-height: 0; + padding: 23px 18px 34px; + overflow: auto; + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.machine-id { + margin: 0 8px 16px; + color: #fff; + font-weight: 700; +} + +.enabled-events { + display: flex; + min-height: 32px; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 8px 14px; + padding-bottom: 13px; + border-bottom: 1px solid var(--line-soft); +} + +.enabled-events-label, +.enabled-events-empty { + color: #6f7680; + font-size: 10px; +} + +.enabled-events-label { + margin-right: 3px; + text-transform: uppercase; +} + +.simulation-feedback { + margin: -5px 8px 14px; + color: #7f99c4; + font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.simulation-feedback[data-status="indeterminate"] { + color: #d9ae7e; +} + +.simulation-feedback[data-status="blocked"] { + color: #d58d89; +} + +.enabled-events[hidden], +.simulation-feedback[hidden] { + display: none; +} + +.event-button { + padding: 4px 7px; + border: 0; + border-radius: 3px; + color: #aeb5bf; + background: #1b1f25; + font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + cursor: pointer; +} + +.event-button:hover, +.event-button:focus-visible, +.event-button.is-selected { + color: #d9e6ff; + background: rgb(75 125 255 / 22%); + outline: none; +} + +.topology-node { + background: transparent; +} + +.topology-empty { + display: grid; + max-width: 420px; + gap: 7px; + margin: 26px 8px; + color: #7f8690; + font: 12px/1.6 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.topology-empty strong { + color: #d4d7dc; + font: 600 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.topology-node.is-selected { + background: var(--accent-bg); +} + +.state-row { + display: grid; + width: 100%; + min-height: 38px; + grid-template-columns: 14px 10px minmax(90px, auto) 1fr; + align-items: center; + gap: 8px; + padding: 6px 8px 6px calc(8px + var(--depth) * 22px); + border: 0; + color: #d7d9dc; + background: transparent; + text-align: left; + cursor: pointer; + user-select: none; +} + +.state-row:hover, +.state-row:focus-visible { + color: #fff; + background: #1b1e23; + outline: none; +} + +.state-row[aria-selected="true"] { + color: #fff; +} + +.topology-node.is-selected > .state-row { + color: #fff; +} + +.topology-node.is-related-target > .state-row { + background: rgb(75 125 255 / 9%); +} + +.topology-node.is-related-source > .state-row { + background: rgb(172 104 224 / 8%); +} + +.topology-node.is-related-update > .state-row { + background: rgb(240 163 91 / 8%); +} + +.state-disclosure { + color: #737a84; + font-size: 11px; +} + +.state-status { + width: 7px; + height: 7px; + border: 1px solid #686f78; + border-radius: 50%; +} + +.state-status.is-active { + border-color: var(--accent); + background: var(--accent); + box-shadow: 0 0 0 3px rgb(117 167 255 / 10%); +} + +.state-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.state-markers, +.inspector-eyebrow, +.card-title, +.card-flags, +.branch-main { + display: flex; + align-items: center; + gap: 6px; +} + +.state-markers { + justify-self: end; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 2px 6px; + border-radius: 3px; + color: #a8afb8; + background: #20242a; + font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.badge-active { + color: #b9d2ff; + background: rgb(75 125 255 / 22%); +} + +.badge-initial { + color: #e9c89f; + background: rgb(240 163 91 / 13%); +} + +.badge-trigger { + color: #b9d2ff; + background: rgb(75 125 255 / 16%); +} + +.badge-condition { + color: #d9b8ef; + background: rgb(172 104 224 / 15%); +} + +.badge-activity { + color: #a8ddc4; + background: rgb(78 181 131 / 14%); +} + +.badge-count { + color: #858c96; + background: transparent; +} + +.inspector { + min-width: 0; + height: 100vh; + padding: 28px; + overflow: auto; + background: var(--surface); +} + +.failure-shell { + display: grid; + min-height: 100vh; + align-content: start; + gap: 12px; + padding: 32px; + color: #d6d8dc; + background: #0b0c0e; +} + +.failure-kind { + color: #f2aaa7; + font: 600 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.failure-shell h1 { + margin: 0; + font: 600 18px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.failure-shell pre { + max-width: 900px; + margin: 4px 0 0; + padding: 14px 16px; + border-left: 2px solid #a54743; + color: #e4b4b1; + background: #151112; + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow: auto; + white-space: pre-wrap; +} + +.failure-shell p { + margin: 0; + color: #7f8690; + font-size: 12px; +} + +.inspector-empty { + max-width: 420px; + color: var(--muted); +} + +.inspector-empty-kind { + color: var(--orange); + font: 600 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inspector-empty h2, +.inspector-header h2 { + margin: 9px 0 0; + color: #f1f2f4; + font: 600 18px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} + +.inspector-empty p, +.section-empty, +.empty-inline { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.inspector-header { + padding-bottom: 24px; + border-bottom: 1px solid var(--line); +} + +.state-annotations { + margin-top: 18px; +} + +.state-annotations p { + margin: 7px 0 0; + color: #aeb4bd; + font-size: 12px; + line-height: 1.6; +} + +.state-annotations .state-documentation { + color: #7f8690; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; +} + +.breadcrumbs { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 5px; + margin-bottom: 16px; +} + +.breadcrumb-separator { + color: #555c65; +} + +.state-link { + max-width: 100%; + padding: 0; + border: 0; + color: #9bbfff; + background: transparent; + font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; + text-align: left; + cursor: pointer; +} + +.state-link:hover, +.state-link:focus-visible { + color: #d5e4ff; + text-decoration: underline; + outline: none; +} + +.metadata { + display: grid; + grid-template-columns: minmax(74px, auto) minmax(0, 1fr); + gap: 8px 16px; + margin: 20px 0 0; + font-size: 11px; +} + +.metadata dt { + color: #727983; +} + +.metadata dd { + min-width: 0; + margin: 0; + color: #d2d5da; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} + +.inspector-section { + margin-top: 28px; +} + +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 11px; +} + +.section-heading h3 { + margin: 0; + color: #c9cdd2; + font-size: 12px; + font-weight: 650; +} + +.section-count { + color: #737a84; + font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.inspection-card { + margin-top: 8px; + border: 1px solid var(--line-soft); + background: var(--surface-raised); +} + +.card-header { + display: flex; + min-height: 42px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; +} + +.transition-source { + display: flex; + align-items: baseline; + gap: 12px; + padding: 0 12px 10px; + color: #727983; + font-size: 11px; +} + +.card-title { + min-width: 0; +} + +.card-title strong { + color: #e2e4e7; + font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} + +.branch-list { + border-top: 1px solid var(--line-soft); +} + +.branch-row { + padding: 11px 12px 13px; +} + +.branch-row + .branch-row { + border-top: 1px solid var(--line-soft); +} + +.branch-main { + min-width: 0; +} + +.branch-arrow { + color: var(--accent); +} + +.branch-target { + color: #d7d9dc; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + overflow-wrap: anywhere; +} + +.branch-row .metadata, +.activity-card .metadata, +.incoming-card .metadata { + margin-top: 10px; +} + +.incoming-card { + padding-bottom: 12px; +} + +.incoming-card .metadata { + padding: 0 12px; +} + +.branch-updates { + display: grid; + grid-template-columns: minmax(74px, auto) minmax(0, 1fr); + gap: 8px 16px; + margin-top: 9px; + font-size: 11px; +} + +.branch-updates-label { + color: #727983; +} + +.branch-updates .state-link + .state-link { + grid-column: 2; +} + +.activity-card { + padding-bottom: 12px; +} + +.activity-card .metadata { + padding: 0 12px; +} + +@media (max-width: 900px) { + .devtools-shell { + grid-template-columns: 1fr; + } + + .machine-index { + display: flex; + height: auto; + min-height: 54px; + padding: 0; + border-right: 0; + border-bottom: 1px solid var(--line); + overflow-x: auto; + } + + .machine-row { + width: min(220px, 70vw); + flex: 0 0 auto; + } + + .workspace { + height: auto; + grid-template-columns: 1fr; + } + + .tree-panel { + height: 58vh; + min-height: 58vh; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .inspector { + height: auto; + min-height: 42vh; + } +} + +@media (max-width: 600px) { + .toolbar { + align-items: flex-start; + padding-inline: 10px; + } + + .toolbar-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .runtime-summary { + padding-top: 8px; + } + + .topology-tree, + .inspector { + padding-inline: 14px; + } + + .state-markers .badge-count { + display: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + .state-row, + .toolbar-button { + transition: color 120ms ease, background-color 120ms ease; + } +} diff --git a/packages/devtools/src/internal/browser/text-tree.ts b/packages/devtools/src/internal/browser/text-tree.ts new file mode 100644 index 0000000..6e96844 --- /dev/null +++ b/packages/devtools/src/internal/browser/text-tree.ts @@ -0,0 +1,214 @@ +import type { + Activity as VisualizationActivity, + Branch as VisualizationBranch, + MachineDocument as VisualizationDocument, + State as VisualizationState, + Transition as VisualizationTransition +} from "../../MachineDocument.js" + +export type TreeItemKind = "state" | "transition" | "branch" | "activity" + +export interface TreeItemDetail { + readonly label: string + readonly value: string +} + +export interface TreeItem { + readonly id: string + readonly kind: TreeItemKind + readonly label: string + readonly details: ReadonlyArray + readonly children: ReadonlyArray +} + +export interface TextTree { + readonly machineId: string + readonly legend: string + readonly roots: ReadonlyArray + readonly candidateEvents: ReadonlyArray | undefined +} + +const nodeLabel = (node: VisualizationState, active: ReadonlySet): string => { + const status = active.has(node.path) ? "●" : "○" + const label = node.title === null ? node.key : `${node.title} (${node.key})` + const details: Array = node.type === "atomic" ? [] : [node.type] + if (node.initial !== null) { + details.push(`initial: ${node.initial.slice(node.path.length + 1)}`) + } + if (node.history !== null) { + details.push(node.history) + } + return details.length === 0 ? `${status} ${label}` : `${status} ${label} [${details.join(", ")}]` +} + +const triggerLabel = (definition: VisualizationTransition): string => { + const reenter = definition.reenter ? " [reenter]" : "" + const acceptance = definition.acceptance === "declinable" ? " [declinable]" : "" + switch (definition.trigger.type) { + case "event": + return `◇ on: ${definition.trigger.event}${reenter}${acceptance}` + case "always": + return `◇ always${reenter}${acceptance}` + case "done": + return `◇ done${reenter}${acceptance}` + case "choice": + return "◇ choice" + case "invoke": + return `◇ invoke ${definition.trigger.id} ${definition.trigger.outcome}${reenter}${acceptance}` + } +} + +const branchLabel = (branch: VisualizationBranch): string | undefined => { + if (branch.target === null && branch.updates.length > 0) { + return branch.type === "direct" + ? `update ${branch.updates.join(", ")}` + : `[${branch.title}] update ${branch.updates.join(", ")}` + } + if (branch.target === null) return undefined + + const target = branch.target.slice(branch.target.lastIndexOf(".") + 1) + const updates = branch.updates.length === 0 ? "" : ` / update ${branch.updates.join(", ")}` + return branch.type === "direct" + ? `→ ${target}${updates}` + : `[${branch.title}] → ${target}${updates}` +} + +const activityLabel = (definition: VisualizationActivity): string => { + switch (definition.type) { + case "process": + return `◆ process: ${definition.lifecycleId}` + case "effect": + return `◆ effect: ${definition.lifecycleId} [success: ${definition.outcomes.success}, failure: ${definition.outcomes.failure}]` + case "timer": + return `◆ timer: ${definition.lifecycleId} [${definition.duration}]` + case "stream": + return `◆ stream: ${definition.lifecycleId}` + case "machine": { + const identity = definition.child.machineId ?? definition.child.id + return `◆ machine: ${definition.lifecycleId} → ${identity}` + } + } +} + +const activityDetails = (definition: VisualizationActivity): ReadonlyArray => { + const common: Array = [ + { label: "Owner", value: definition.source }, + { label: "Lifecycle id", value: definition.lifecycleId }, + { label: "Kind", value: definition.type } + ] + if (definition.type === "timer") common.push({ label: "Duration", value: definition.duration }) + if (definition.type === "machine") { + common.push({ label: "Child address", value: definition.child.id }) + common.push({ label: "Machine", value: definition.child.machineId ?? "dynamic" }) + } + return common +} + +export const visualizationDocumentToTextTree = (document: VisualizationDocument): TextTree => { + const active = new Set(document.snapshot?.activePaths ?? []) + const states = new Map(document.states.map((state) => [state.path, state])) + const transitions = new Map(document.transitions.map((transition) => [transition.id, transition])) + const activities = new Map(document.activities.map((activity) => [activity.id, activity])) + const children = new Map>() + + for (const state of document.states) { + const siblings = children.get(state.parent) ?? [] + siblings.push(state) + children.set(state.parent, siblings) + } + + const visit = (state: VisualizationState): TreeItem => { + const transitionItems = state.transitionIds.flatMap((transitionId): ReadonlyArray => { + const definition = transitions.get(transitionId) + if (definition === undefined) return [] + const branchItems = definition.branches.flatMap((branch): ReadonlyArray => { + const label = branchLabel(branch) + if (label === undefined) return [] + return [{ + id: branch.id, + kind: "branch", + label, + details: [ + { label: "Source", value: definition.source }, + { label: "Target", value: branch.target ?? "none" }, + { label: "Selection", value: branch.selection.kind }, + { label: "Scope", value: branch.selection.scope ?? "none" }, + { label: "Updates", value: branch.updates.length === 0 ? "none" : branch.updates.join(", ") } + ], + children: [] + }] + }) + return [{ + id: definition.id, + kind: "transition", + label: triggerLabel(definition), + details: [ + { label: "Source", value: definition.source }, + { label: "Acceptance", value: definition.acceptance }, + { label: "Reenter", value: definition.reenter ? "yes" : "no" }, + { label: "Branches", value: String(branchItems.length) } + ], + children: branchItems + }] + }) + const activityItems = state.activityIds.flatMap((activityId): ReadonlyArray => { + const definition = activities.get(activityId) + return definition === undefined ? [] : [{ + id: definition.id, + kind: "activity", + label: activityLabel(definition), + details: activityDetails(definition), + children: [] + }] + }) + const descendantItems = (children.get(state.path) ?? []).map(visit) + + return { + id: state.path, + kind: "state", + label: nodeLabel(state, active), + details: [ + { label: "Path", value: state.path }, + { label: "Type", value: state.type }, + { label: "Status", value: active.has(state.path) ? "active" : "inactive" }, + { label: "Parent", value: state.parent ?? "root" }, + { label: "Initial", value: state.initial ?? "none" }, + { label: "Children", value: String(state.children.length) } + ], + children: [...transitionItems, ...activityItems, ...descendantItems] + } + } + + return { + machineId: document.machineId, + legend: document.activities.length === 0 + ? "● active ○ inactive ◇ transition ┄ branch → target" + : "● active ○ inactive ◇ transition ┄ branch → target ◆ activity", + roots: document.roots.flatMap((path): ReadonlyArray => { + const state = states.get(path) + return state === undefined ? [] : [visit(state)] + }), + candidateEvents: document.snapshot?.candidateEvents + } +} + +const textConnector = (kind: TreeItemKind, isLast: boolean): string => { + if (kind === "branch") return isLast ? "└┄" : "├┄" + return isLast ? "└─" : "├─" +} + +export const textTreeToString = (document: VisualizationDocument): string => { + const tree = visualizationDocumentToTextTree(document) + const lines = [tree.machineId, tree.legend, ""] + const visit = (item: TreeItem, prefix: string, isLast: boolean): void => { + lines.push(`${prefix}${textConnector(item.kind, isLast)} ${item.label}`) + const childPrefix = `${prefix}${isLast ? " " : "│ "}` + item.children.forEach((child, index) => visit(child, childPrefix, index === item.children.length - 1)) + } + tree.roots.forEach((item, index) => visit(item, "", index === tree.roots.length - 1)) + if (tree.candidateEvents !== undefined) { + const candidates = tree.candidateEvents.length === 0 ? "none" : tree.candidateEvents.join(", ") + lines.push("", `Candidate events: ${candidates}`) + } + return lines.join("\n") +} diff --git a/packages/devtools/src/internal/browser/visualizer-app.ts b/packages/devtools/src/internal/browser/visualizer-app.ts new file mode 100644 index 0000000..09a500f --- /dev/null +++ b/packages/devtools/src/internal/browser/visualizer-app.ts @@ -0,0 +1,659 @@ +import type { Diagnostic } from "../../DevToolsProtocol.js" +import type { + Activity as VisualizationActivity, + Branch as VisualizationBranch, + MachineDocument as VisualizationDocument, + Transition as VisualizationTransition +} from "../../MachineDocument.js" +import * as MachineSimulator from "../../MachineSimulator.js" +import { + type EventInspection, + type IncomingTransition, + makeVisualizerModel, + type StateInspection, + type TopologyNode, + triggerLabel +} from "./visualizer-model.js" + +const createElement = ( + tag: Tag, + className?: string, + text?: string +): HTMLElementTagNameMap[Tag] => { + const element = document.createElement(tag) + if (className !== undefined) element.className = className + if (text !== undefined) element.textContent = text + return element +} + +const metadata = (items: ReadonlyArray): HTMLDListElement => { + const list = createElement("dl", "metadata") + for (const [label, value] of items) { + list.append(createElement("dt", undefined, label), createElement("dd", undefined, value)) + } + return list +} + +const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text) + +type StateNavigator = (path: string) => void + +const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => { + const link = createElement("button", "state-link", label) + link.type = "button" + link.addEventListener("click", () => navigate(path)) + return link +} + +const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => { + const row = createElement("div", "branch-row") + const main = createElement("div", "branch-main") + if (branch.type === "branch") main.append(badge(branch.title, "condition")) + main.append(createElement("span", "branch-arrow", "→")) + if (branch.target !== null) { + main.append(stateLink(branch.target, branch.target, navigate)) + } else { + main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target")) + } + row.append(main) + + const details: Array = [ + ["Selection", branch.selection.kind], + ["Scope", branch.selection.scope ?? "none"] + ] + row.append(metadata(details)) + if (branch.updates.length > 0) { + const updates = createElement("div", "branch-updates") + updates.append(createElement("span", "branch-updates-label", "Updates")) + branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate))) + row.append(updates) + } + return row +} + +const renderTransition = ( + transition: VisualizationTransition, + navigate: StateNavigator, + showSource = false +): HTMLElement => { + const card = createElement("article", "inspection-card transition-card") + const header = createElement("div", "card-header") + const title = createElement("div", "card-title") + title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition))) + const flags = createElement("div", "card-flags") + if (transition.reenter) flags.append(badge("reenter")) + if (transition.acceptance === "declinable") flags.append(badge("declinable")) + header.append(title, flags) + card.append(header) + if (showSource) { + const source = createElement("div", "transition-source") + source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate)) + card.append(source) + } + + const branches = createElement("div", "branch-list") + if (transition.branches.length === 0) { + branches.append(createElement("div", "empty-inline", "No transition branches")) + } else { + transition.branches.forEach((branch) => branches.append(renderBranch(branch, navigate))) + } + card.append(branches) + return card +} + +const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => { + const card = createElement("article", "inspection-card incoming-card") + const header = createElement("div", "card-header") + const title = createElement("div", "card-title") + title.append( + badge(incoming.transition.trigger.type, "trigger"), + createElement("strong", undefined, triggerLabel(incoming.transition)) + ) + header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate)) + card.append(header) + + const details: Array = [ + ["Selection", incoming.branch.selection.kind], + ["Scope", incoming.branch.selection.scope ?? "none"] + ] + if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title]) + card.append(metadata(details)) + return card +} + +const activityTitle = (activity: VisualizationActivity): string => { + switch (activity.type) { + case "process": + case "effect": + case "timer": + case "stream": + return activity.lifecycleId + case "machine": + return `${activity.lifecycleId} → ${activity.child.machineId ?? activity.child.id}` + } +} + +const renderActivity = (activity: VisualizationActivity): HTMLElement => { + const card = createElement("article", "inspection-card activity-card") + const header = createElement("div", "card-header") + const title = createElement("div", "card-title") + title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity))) + header.append(title) + card.append(header) + + const details: Array = [["Owner", activity.source]] + if (activity.type === "timer") details.push(["Duration", activity.duration]) + if (activity.type === "effect") { + details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure]) + } + if (activity.type === "machine") { + details.push(["Child address", activity.child.id], ["Machine", activity.child.machineId ?? "dynamic"]) + } + card.append(metadata(details)) + return card +} + +const inspectionSection = (title: string, count: number): HTMLElement => { + const header = createElement("div", "section-heading") + header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count))) + return header +} + +const simulationResultMessage = (result: MachineSimulator.StepResult): string => { + if (result._tag === "Applied") return `${result.event} applied · runtime code was skipped` + if (result._tag === "Blocked") return `${result.event} is not enabled in the current topology` + const reasons: Record = { + "multiple-transitions": "multiple active transitions", + "declinable-transition": "acceptance depends on runtime code", + "conditional-branches": "the selected branch depends on runtime code", + "history-target": "history resolution needs runtime state", + "choice-target": "choice resolution needs runtime code", + "missing-target": "the target is not present in the document" + } + return `${result.event} was not applied · ${reasons[result.reason]}` +} + +export const renderVisualizer = ( + root: HTMLElement, + visualization: VisualizationDocument, + diagnostics: ReadonlyArray = [] +): void => { + const model = makeVisualizerModel(visualization) + const rows = new Map() + const nodes = new Map() + const statuses = new Map() + const eventButtons = new Map() + const relatedPaths = new Set() + let selectedPath: string | undefined + let selectedEvent: string | undefined + let simulation: MachineSimulator.Session | undefined + + const activePaths = (): ReadonlyArray => simulation?.snapshot.activePaths ?? model.activePaths + const candidateEvents = (): ReadonlyArray => simulation?.snapshot.candidateEvents ?? model.candidateEvents + + const shell = createElement("main", "app-shell") + const workspace = createElement("section", "workspace") + const treePanel = createElement("section", "tree-panel") + treePanel.setAttribute("aria-label", `${model.machineId} topology`) + const inspector = createElement("aside", "inspector") + inspector.setAttribute("aria-live", "polite") + + const clearButton = createElement("button", "toolbar-button", "Clear selection") + clearButton.type = "button" + clearButton.disabled = true + const expandButton = createElement("button", "toolbar-button", "Expand all") + expandButton.type = "button" + const collapseButton = createElement("button", "toolbar-button", "Collapse all") + collapseButton.type = "button" + const revealActiveButton = createElement("button", "toolbar-button", "Reveal active") + revealActiveButton.type = "button" + revealActiveButton.disabled = model.activePaths.length === 0 + const simulationButton = createElement("button", "toolbar-button", "Start simulation") + simulationButton.type = "button" + simulationButton.disabled = model.roots.length === 0 + + const renderEmptyInspector = (): void => { + inspector.replaceChildren() + const summary = createElement("div", "inspector-empty") + summary.append(createElement("span", "inspector-empty-kind", "Machine")) + summary.append(createElement("h2", undefined, visualization.machineId)) + summary.append(metadata([ + ["Source", visualization.source?.file ?? "in memory"], + ["Export", visualization.source?.exportName ?? "none"], + ["Initial", visualization.initial.target], + ["Selection", visualization.initial.selection.kind], + ["Revision", String(visualization.revision)] + ])) + summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities.")) + inspector.append(summary) + } + + const renderInspection = (inspection: StateInspection): void => { + inspector.replaceChildren() + const header = createElement("header", "inspector-header") + const breadcrumbs = createElement("nav", "breadcrumbs") + breadcrumbs.setAttribute("aria-label", "State path") + inspection.breadcrumbs.forEach((item, index) => { + if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/")) + breadcrumbs.append(stateLink(item.path, item.label, navigateToState)) + }) + const eyebrow = createElement("div", "inspector-eyebrow") + eyebrow.append(badge(inspection.state.type, "state")) + if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active")) + if (inspection.initial) eyebrow.append(badge("initial", "initial")) + header.append(breadcrumbs, eyebrow, createElement("h2", undefined, inspection.label)) + header.append(metadata([ + ["Path", inspection.state.path], + ["Parent", inspection.state.parent ?? "root"], + ["Children", String(inspection.state.children.length)], + ["Initial child", inspection.state.initial ?? "none"], + ["History", inspection.state.history ?? "none"] + ])) + if (inspection.state.description !== null || inspection.state.documentation !== null) { + const annotations = createElement("div", "state-annotations") + if (inspection.state.description !== null) { + annotations.append(createElement("p", "state-description", inspection.state.description)) + } + if (inspection.state.documentation !== null) { + annotations.append(createElement("p", "state-documentation", inspection.state.documentation)) + } + header.append(annotations) + } + inspector.append(header) + + const transitions = createElement("section", "inspector-section") + transitions.append(inspectionSection("Transitions", inspection.outgoing.length)) + if (inspection.outgoing.length === 0) { + transitions.append(createElement("p", "section-empty", "No transitions leave this state.")) + } else { + inspection.outgoing.forEach((transition) => transitions.append(renderTransition(transition, navigateToState))) + } + inspector.append(transitions) + + const incoming = createElement("section", "inspector-section") + incoming.append(inspectionSection("Entered by", inspection.incoming.length)) + if (inspection.incoming.length === 0) { + incoming.append(createElement("p", "section-empty", "No transitions target this state.")) + } else { + inspection.incoming.forEach((transition) => + incoming.append(renderIncomingTransition(transition, navigateToState)) + ) + } + inspector.append(incoming) + + if (inspection.activities.length > 0) { + const activities = createElement("section", "inspector-section") + activities.append(inspectionSection("Activities", inspection.activities.length)) + inspection.activities.forEach((activity) => activities.append(renderActivity(activity))) + inspector.append(activities) + } + } + + const renderEventInspection = (inspection: EventInspection): void => { + inspector.replaceChildren() + const header = createElement("header", "inspector-header") + const eyebrow = createElement("div", "inspector-eyebrow") + eyebrow.append(badge("event", "trigger")) + const candidate = candidateEvents().includes(inspection.event) + if (candidate) eyebrow.append(badge("enabled", "active")) + header.append(eyebrow, createElement("h2", undefined, inspection.event)) + header.append(metadata([ + ["Status", candidate ? "enabled" : "not enabled"], + ["Registrations", String(inspection.transitions.length)] + ])) + inspector.append(header) + + const transitions = createElement("section", "inspector-section") + transitions.append(inspectionSection("Transitions", inspection.transitions.length)) + inspection.transitions.forEach((transition) => + transitions.append(renderTransition(transition, navigateToState, true)) + ) + inspector.append(transitions) + } + + const clearRelations = (): void => { + for (const path of relatedPaths) { + nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update") + } + relatedPaths.clear() + } + + const clearSelection = (): void => { + if (selectedPath !== undefined) { + nodes.get(selectedPath)?.classList.remove("is-selected") + rows.get(selectedPath)?.setAttribute("aria-selected", "false") + } + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + clearRelations() + selectedPath = undefined + selectedEvent = undefined + clearButton.disabled = true + renderEmptyInspector() + } + + const markTransitions = (transitions: ReadonlyArray): void => { + for (const transition of transitions) { + relatedPaths.add(transition.source) + nodes.get(transition.source)?.classList.add("is-related-source") + for (const branch of transition.branches) { + if (branch.target !== null) { + relatedPaths.add(branch.target) + nodes.get(branch.target)?.classList.add("is-related-target") + } + for (const update of branch.updates) { + relatedPaths.add(update) + nodes.get(update)?.classList.add("is-related-update") + } + } + } + } + + const markRelatedStates = (inspection: StateInspection): void => { + clearRelations() + for (const incoming of inspection.incoming) { + relatedPaths.add(incoming.transition.source) + nodes.get(incoming.transition.source)?.classList.add("is-related-source") + } + markTransitions(inspection.outgoing) + } + + const expandAncestors = (inspection: StateInspection): void => { + for (const ancestor of inspection.breadcrumbs.slice(0, -1)) { + const row = rows.get(ancestor.path) + const children = nodes.get(ancestor.path)?.querySelector(":scope > .topology-children") + if (row === undefined || children === null || children === undefined) continue + row.setAttribute("aria-expanded", "true") + children.hidden = false + const disclosure = row.querySelector(".state-disclosure") + if (disclosure !== null) disclosure.textContent = "▾" + } + } + + const selectState = (path: string, focus: boolean): void => { + const inspection = model.inspectState(path) + if (inspection === undefined) return + expandAncestors(inspection) + if (selectedPath !== undefined) { + nodes.get(selectedPath)?.classList.remove("is-selected") + rows.get(selectedPath)?.setAttribute("aria-selected", "false") + } + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + selectedPath = path + selectedEvent = undefined + nodes.get(path)?.classList.add("is-selected") + rows.get(path)?.setAttribute("aria-selected", "true") + markRelatedStates(inspection) + clearButton.disabled = false + renderInspection(inspection) + if (focus) { + rows.get(path)?.focus({ preventScroll: true }) + rows.get(path)?.scrollIntoView({ block: "nearest" }) + } + } + + function navigateToState(path: string): void { + selectState(path, true) + } + + const selectEvent = (event: string): void => { + const inspection = model.inspectEvent(event) + if (selectedPath !== undefined) { + nodes.get(selectedPath)?.classList.remove("is-selected") + rows.get(selectedPath)?.setAttribute("aria-selected", "false") + } + if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected") + selectedPath = undefined + selectedEvent = event + eventButtons.get(event)?.classList.add("is-selected") + clearRelations() + markTransitions(inspection.transitions) + clearButton.disabled = false + renderEventInspection(inspection) + } + + const setExpanded = (row: HTMLElement, expanded: boolean): void => { + const node = row.closest(".topology-node") + const children = node?.querySelector(":scope > .topology-children") + if (children === null || children === undefined) return + row.setAttribute("aria-expanded", String(expanded)) + children.hidden = !expanded + const disclosure = row.querySelector(".state-disclosure") + if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸" + } + + const renderNode = (node: TopologyNode, depth: number): HTMLElement => { + const container = createElement("div", "topology-node") + container.dataset.statePath = node.path + nodes.set(node.path, container) + + const row = createElement("button", "state-row") + row.type = "button" + row.tabIndex = -1 + row.setAttribute("role", "treeitem") + row.setAttribute("aria-level", String(depth + 1)) + row.setAttribute("aria-selected", "false") + row.style.setProperty("--depth", String(depth)) + row.dataset.statePath = node.path + rows.set(node.path, row) + + const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾") + const status = createElement("span", `state-status${node.active ? " is-active" : ""}`) + status.setAttribute("aria-label", node.active ? "active" : "inactive") + statuses.set(node.path, status) + const label = createElement("span", "state-label", node.label) + const markers = createElement("span", "state-markers") + if (node.initial) markers.append(badge("initial", "initial")) + if (node.type !== "atomic") markers.append(badge(node.type, "state")) + if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count")) + if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count")) + row.append(disclosure, status, label, markers) + container.append(row) + row.addEventListener("focus", () => { + rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1) + }) + + if (node.children.length > 0) { + const children = createElement("div", "topology-children") + children.setAttribute("role", "group") + node.children.forEach((child) => children.append(renderNode(child, depth + 1))) + container.append(children) + row.setAttribute("aria-expanded", "true") + row.addEventListener("click", () => { + const expanded = row.getAttribute("aria-expanded") === "true" + setExpanded(row, !expanded) + selectState(node.path, false) + }) + } else { + row.addEventListener("click", () => selectState(node.path, false)) + } + return container + } + + const setAllExpanded = (expanded: boolean): void => { + treePanel.querySelectorAll(".state-row[aria-expanded]").forEach((row) => { + setExpanded(row, expanded) + }) + } + + clearButton.addEventListener("click", clearSelection) + expandButton.addEventListener("click", () => setAllExpanded(true)) + collapseButton.addEventListener("click", () => setAllExpanded(false)) + revealActiveButton.addEventListener("click", () => { + const deepest = [...activePaths()].sort((left, right) => right.split(".").length - left.split(".").length)[0] + if (deepest !== undefined) navigateToState(deepest) + }) + + const toolbar = createElement("div", "toolbar") + const runtime = createElement("div", "runtime-summary") + const runtimeDot = createElement("span", "runtime-dot") + const runtimeText = createElement("span") + runtime.append(runtimeDot, runtimeText) + const toolbarActions = createElement("div", "toolbar-actions") + toolbarActions.append(clearButton, simulationButton, revealActiveButton, expandButton, collapseButton) + toolbar.append(runtime, toolbarActions) + const tree = createElement("div", "topology-tree") + tree.setAttribute("role", "tree") + tree.setAttribute("aria-label", `${model.machineId} states`) + tree.append(createElement("div", "machine-id", model.machineId)) + const events = createElement("div", "enabled-events") + const simulationFeedback = createElement("div", "simulation-feedback") + simulationFeedback.setAttribute("role", "status") + tree.append(events, simulationFeedback) + + const renderEventButtons = (): void => { + events.replaceChildren(createElement("span", "enabled-events-label", "Enabled")) + eventButtons.clear() + const candidates = candidateEvents() + if (candidates.length === 0) { + events.append(createElement("span", "enabled-events-empty", "none")) + return + } + candidates.forEach((event) => { + const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event) + button.type = "button" + button.addEventListener("click", () => { + if (simulation !== undefined) { + const result = MachineSimulator.send(simulation, event) + if (result._tag === "Applied") simulation = { document: visualization, snapshot: result.session } + simulationFeedback.textContent = simulationResultMessage(result) + simulationFeedback.dataset.status = result._tag.toLowerCase() + updateSimulationUi() + } + selectEvent(event) + }) + eventButtons.set(event, button) + events.append(button) + }) + } + + const updateSimulationUi = (): void => { + const active = new Set(activePaths()) + statuses.forEach((status, path) => { + const isActive = active.has(path) + status.classList.toggle("is-active", isActive) + status.setAttribute("aria-label", isActive ? "active" : "inactive") + }) + const hasRuntimeState = simulation !== undefined || model.hasSnapshot + runtimeDot.classList.toggle("has-snapshot", hasRuntimeState) + runtimeText.textContent = simulation !== undefined + ? `${active.size} active · step ${simulation.snapshot.step}` + : diagnostics.length > 0 + ? "Partial" + : model.hasSnapshot + ? `${active.size} active` + : "Structure only" + simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation" + revealActiveButton.disabled = active.size === 0 + events.hidden = !hasRuntimeState + simulationFeedback.hidden = simulation === undefined + renderEventButtons() + if (selectedPath !== undefined) { + const inspection = model.inspectState(selectedPath) + if (inspection !== undefined) renderInspection(inspection) + } else if (selectedEvent !== undefined) { + renderEventInspection(model.inspectEvent(selectedEvent)) + } + } + + simulationButton.addEventListener("click", () => { + if (simulation === undefined) { + simulation = MachineSimulator.start(visualization) + simulationFeedback.textContent = "Best-effort simulation started · user code will not run" + simulationFeedback.dataset.status = "applied" + } else { + simulation = undefined + simulationFeedback.textContent = "" + delete simulationFeedback.dataset.status + } + updateSimulationUi() + }) + if (model.roots.length === 0) { + const empty = createElement("div", "topology-empty") + empty.append( + createElement("strong", undefined, "No states yet"), + createElement("span", undefined, "The topology will appear as the machine definition becomes available.") + ) + tree.append(empty) + } else { + model.roots.forEach((node) => tree.append(renderNode(node, 0))) + } + updateSimulationUi() + rows.values().next().value?.setAttribute("tabindex", "0") + tree.addEventListener("keydown", (event) => { + const current = event.target instanceof HTMLElement ? event.target.closest(".state-row") : null + if (current === null) return + const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0) + const index = visible.indexOf(current) + const focus = (row: HTMLElement | undefined): void => { + if (row === undefined) return + event.preventDefault() + row.focus() + } + switch (event.key) { + case "ArrowDown": + focus(visible[index + 1]) + break + case "ArrowUp": + focus(visible[index - 1]) + break + case "Home": + focus(visible[0]) + break + case "End": + focus(visible.at(-1)) + break + case "ArrowRight": { + if (current.getAttribute("aria-expanded") === "false") { + event.preventDefault() + setExpanded(current, true) + } else { + const child = current.closest(".topology-node") + ?.querySelector(":scope > .topology-children > .topology-node > .state-row") + focus(child ?? undefined) + } + break + } + case "ArrowLeft": { + if (current.getAttribute("aria-expanded") === "true") { + event.preventDefault() + setExpanded(current, false) + } else { + const parent = current.closest(".topology-children") + ?.closest(".topology-node") + ?.querySelector(":scope > .state-row") + focus(parent ?? undefined) + } + break + } + case "Enter": + case " ": + event.preventDefault() + current.click() + break + case "Escape": + event.preventDefault() + clearSelection() + break + } + }) + treePanel.append(toolbar) + if (diagnostics.length > 0) { + const diagnosticList = createElement("div", "diagnostics") + diagnosticList.setAttribute("role", "status") + diagnostics.forEach((diagnostic) => { + const item = createElement("div", `diagnostic diagnostic-${diagnostic.severity}`) + item.append(badge(diagnostic.severity, diagnostic.severity), createElement("span", undefined, diagnostic.message)) + if (diagnostic.statePath !== null && model.inspectState(diagnostic.statePath) !== undefined) { + item.append(stateLink(diagnostic.statePath, diagnostic.statePath, navigateToState)) + } + diagnosticList.append(item) + }) + treePanel.append(diagnosticList) + } + treePanel.append(tree) + + renderEmptyInspector() + workspace.append(treePanel, inspector) + shell.append(workspace) + root.replaceChildren(shell) +} diff --git a/packages/devtools/src/internal/browser/visualizer-model.ts b/packages/devtools/src/internal/browser/visualizer-model.ts new file mode 100644 index 0000000..088154b --- /dev/null +++ b/packages/devtools/src/internal/browser/visualizer-model.ts @@ -0,0 +1,173 @@ +import type { + Activity as VisualizationActivity, + Branch as VisualizationBranch, + MachineDocument as VisualizationDocument, + State as VisualizationState, + Transition as VisualizationTransition +} from "../../MachineDocument.js" + +export interface TopologyNode { + readonly path: string + readonly label: string + readonly type: VisualizationState["type"] + readonly active: boolean + readonly initial: boolean + readonly transitionCount: number + readonly activityCount: number + readonly children: ReadonlyArray +} + +export interface StateBreadcrumb { + readonly path: string + readonly label: string +} + +export interface IncomingTransition { + readonly transition: VisualizationTransition + readonly branch: VisualizationBranch +} + +export interface StateInspection { + readonly state: VisualizationState + readonly label: string + readonly active: boolean + readonly initial: boolean + readonly breadcrumbs: ReadonlyArray + readonly outgoing: ReadonlyArray + readonly incoming: ReadonlyArray + readonly activities: ReadonlyArray +} + +export interface EventInspection { + readonly event: string + readonly candidate: boolean + readonly transitions: ReadonlyArray +} + +export interface VisualizerModel { + readonly machineId: string + readonly roots: ReadonlyArray + readonly hasSnapshot: boolean + readonly activePaths: ReadonlyArray + readonly candidateEvents: ReadonlyArray + readonly inspectState: (path: string) => StateInspection | undefined + readonly inspectEvent: (event: string) => EventInspection +} + +export const stateLabel = (state: VisualizationState): string => + state.title === null ? state.key : `${state.title} (${state.key})` + +export const triggerLabel = (transition: VisualizationTransition): string => { + switch (transition.trigger.type) { + case "event": + return transition.trigger.event + case "always": + return "Always" + case "done": + return "On completion" + case "choice": + return "Choice" + case "invoke": + return `${transition.trigger.id} · ${transition.trigger.outcome}` + } +} + +const buildInitialPaths = (document: VisualizationDocument): ReadonlySet => { + const initial = new Set([document.initial.target]) + for (const state of document.states) { + if (state.initial !== null) initial.add(state.initial) + } + return initial +} + +export const makeVisualizerModel = (document: VisualizationDocument): VisualizerModel => { + const states = new Map(document.states.map((state) => [state.path, state])) + const transitions = new Map(document.transitions.map((transition) => [transition.id, transition])) + const activities = new Map(document.activities.map((activity) => [activity.id, activity])) + const active = new Set(document.snapshot?.activePaths ?? []) + const initial = buildInitialPaths(document) + const candidateEvents = document.snapshot?.candidateEvents ?? [] + + const outgoing = (state: VisualizationState): ReadonlyArray => + state.transitionIds.flatMap((id): ReadonlyArray => { + const transition = transitions.get(id) + return transition === undefined ? [] : [transition] + }) + + const ownedActivities = (state: VisualizationState): ReadonlyArray => + state.activityIds.flatMap((id): ReadonlyArray => { + const definition = activities.get(id) + return definition === undefined ? [] : [definition] + }) + + const incoming = new Map>() + for (const transition of document.transitions) { + for (const branch of transition.branches) { + if (branch.target === null) continue + const registrations = incoming.get(branch.target) ?? [] + registrations.push({ transition, branch }) + incoming.set(branch.target, registrations) + } + } + + const visit = (path: string): TopologyNode | undefined => { + const state = states.get(path) + if (state === undefined) return undefined + return { + path: state.path, + label: stateLabel(state), + type: state.type, + active: active.has(state.path), + initial: initial.has(state.path), + transitionCount: state.transitionIds.length, + activityCount: state.activityIds.length, + children: state.children.flatMap((childPath): ReadonlyArray => { + const child = visit(childPath) + return child === undefined ? [] : [child] + }) + } + } + + const breadcrumbs = (state: VisualizationState): ReadonlyArray => { + const result: Array = [] + let current: VisualizationState | undefined = state + while (current !== undefined) { + result.unshift({ path: current.path, label: stateLabel(current) }) + current = current.parent === null ? undefined : states.get(current.parent) + } + return result + } + + return { + machineId: document.machineId, + roots: document.roots.flatMap((path): ReadonlyArray => { + const root = visit(path) + return root === undefined ? [] : [root] + }), + hasSnapshot: document.snapshot !== null, + activePaths: [...active], + candidateEvents: [...candidateEvents], + inspectState: (path) => { + const state = states.get(path) + return state === undefined + ? undefined + : { + state, + label: stateLabel(state), + active: active.has(path), + initial: initial.has(path), + breadcrumbs: breadcrumbs(state), + outgoing: outgoing(state), + incoming: [...incoming.get(path) ?? []], + activities: ownedActivities(state) + } + }, + inspectEvent: (event) => ({ + event, + candidate: candidateEvents.includes(event), + transitions: document.transitions.filter((transition) => + transition.trigger.type === "event" && transition.trigger.event === event + ) + }) + } +} diff --git a/packages/devtools/src/internal/browser/visualizer.ts b/packages/devtools/src/internal/browser/visualizer.ts new file mode 100644 index 0000000..6dc1642 --- /dev/null +++ b/packages/devtools/src/internal/browser/visualizer.ts @@ -0,0 +1,31 @@ +import type { MachineResult } from "../../DevToolsProtocol.js" +import { renderVisualizer } from "./visualizer-app.js" + +const renderError = (root: HTMLElement, source: Extract): void => { + const shell = document.createElement("main") + shell.className = "failure-shell" + shell.setAttribute("role", "alert") + const kind = document.createElement("span") + kind.className = "failure-kind" + kind.textContent = "Visualizer error" + const title = document.createElement("h1") + title.textContent = "Machine could not be inspected" + const message = document.createElement("pre") + message.textContent = source.diagnostics.map((diagnostic) => diagnostic.message).join("\n") + const hint = document.createElement("p") + hint.textContent = "Fix the machine definition and the page will reload." + shell.append(kind, title, message, hint) + root.replaceChildren(shell) +} + +export const mountVisualizer = (root: HTMLElement, source: MachineResult): void => { + switch (source._tag) { + case "Ready": + case "Partial": + renderVisualizer(root, source.document, source.diagnostics) + break + case "Failed": + renderError(root, source) + break + } +} diff --git a/packages/devtools/src/internal/devServer.ts b/packages/devtools/src/internal/devServer.ts new file mode 100644 index 0000000..113dbd8 --- /dev/null +++ b/packages/devtools/src/internal/devServer.ts @@ -0,0 +1,171 @@ +import { type ChokidarOptions, type FSWatcher, watch } from "chokidar" +import * as Effect from "effect/Effect" +import * as Stream from "effect/Stream" +import type { IncomingMessage, ServerResponse } from "node:http" +import { isAbsolute, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { createServer, type Plugin, type ViteDevServer } from "vite" +import type * as DevServer from "../DevServer.js" +import * as MachineRegistry from "../MachineRegistry.js" + +type DevServerErrorConstructor = typeof DevServer.DevServerError + +const packageRoot = fileURLToPath(new URL("../..", import.meta.url)) + +const sourceExtension = /\.(?:[cm]?[jt]sx?)$/ + +const isProjectSource = (root: string, file: string): boolean => { + const projectPath = relative(root, isAbsolute(file) ? file : resolve(root, file)) + return projectPath !== "" && + !projectPath.startsWith("..") && + sourceExtension.test(projectPath) && + !projectPath.split(/[\\/]/).some((part) => + part === "node_modules" || + part === ".git" || + part === "dist" || + part === "build" || + part === "coverage" || + part === ".data" || + part === "references" + ) +} + +const isIgnored = (file: string): boolean => + file.split(/[\\/]/).some((part) => + part === "node_modules" || + part === ".git" || + part === "dist" || + part === "build" || + part === "coverage" || + part === ".data" || + part === "references" + ) + +const writeJson = (response: ServerResponse, value: unknown): void => { + response.statusCode = 200 + response.setHeader("content-type", "application/json; charset=utf-8") + response.setHeader("cache-control", "no-store") + response.end(JSON.stringify(value)) +} + +const apiPlugin = ( + registry: MachineRegistry.MachineRegistry["Service"] +): Plugin => ({ + name: "effect-machine-devtools-api", + configureServer(server) { + server.middlewares.use((request: IncomingMessage, response: ServerResponse, next: () => void) => { + if (request.url === "/api/machines") { + void Effect.runPromise(registry.get).then( + (snapshot) => writeJson(response, snapshot), + (cause) => { + response.statusCode = 500 + response.end(String(cause)) + } + ) + return + } + if (request.url !== "/api/events") { + next() + return + } + + response.statusCode = 200 + response.setHeader("content-type", "text/event-stream") + response.setHeader("cache-control", "no-cache, no-transform") + response.setHeader("connection", "keep-alive") + response.flushHeaders() + + const controller = new AbortController() + request.on("close", () => controller.abort()) + void Effect.runPromise( + registry.changes.pipe( + Stream.runForEach((snapshot) => Effect.sync(() => response.write(`data: ${JSON.stringify(snapshot)}\n\n`))) + ), + { signal: controller.signal } + ).catch(() => undefined) + }) + } +}) + +const acquire = ( + ErrorType: DevServerErrorConstructor, + options: DevServer.Options, + registry: MachineRegistry.MachineRegistry["Service"] +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const server = await createServer({ + root: packageRoot, + appType: "spa", + logLevel: "error", + plugins: [apiPlugin(registry)], + server: { + host: options.host, + port: options.port, + strictPort: true, + open: options.open ?? false + } + }) + await server.listen() + return server + }, + catch: (cause) => + new ErrorType({ + message: `Could not start the Effect Machine visualizer on ${options.host}:${options.port}`, + cause + }) + }) + +const acquireWatcher = ( + options: DevServer.Options, + registry: MachineRegistry.MachineRegistry["Service"] +): Effect.Effect => + Effect.sync(() => { + let refreshTimer: ReturnType | undefined + const watcher = watch(options.root, watcherOptions(options)) + watcher.on("all", (_event, file) => { + if (!isProjectSource(options.root, file)) return + if (refreshTimer !== undefined) clearTimeout(refreshTimer) + refreshTimer = setTimeout(() => { + Effect.runFork( + registry.refresh.pipe( + Effect.catch((cause) => Effect.logWarning("Machine reload failed", cause)) + ) + ) + }, options.debounce ?? 150) + }) + watcher.on("close", () => { + if (refreshTimer !== undefined) clearTimeout(refreshTimer) + }) + watcher.on("error", (cause) => { + Effect.runFork(Effect.logError("Machine file watcher failed", cause)) + }) + return watcher + }) + +export const watcherOptions = (options: DevServer.Options): ChokidarOptions => + ({ + ignoreInitial: true, + interval: 200, + usePolling: options.watchPolling ?? false, + ignored: (file, stats) => isIgnored(file) || stats?.isFile() === true && !sourceExtension.test(file) + }) satisfies ChokidarOptions + +export const run = ( + ErrorType: DevServerErrorConstructor, + options: DevServer.Options +): Effect.Effect => + Effect.gen(function*() { + const registry = yield* MachineRegistry.MachineRegistry + const server = yield* Effect.acquireRelease( + acquire(ErrorType, options, registry), + (server) => Effect.promise(() => server.close()) + ) + yield* Effect.acquireRelease( + acquireWatcher(options, registry), + (watcher) => Effect.promise(() => watcher.close()) + ) + const address = server.resolvedUrls?.local[0] ?? `http://${options.host}:${options.port}/` + yield* Effect.logInfo(`Effect Machine visualizer: ${address}`) + return yield* Effect.never + }).pipe(Effect.scoped) diff --git a/packages/devtools/src/internal/evaluationWorker.ts b/packages/devtools/src/internal/evaluationWorker.ts new file mode 100644 index 0000000..74a27d3 --- /dev/null +++ b/packages/devtools/src/internal/evaluationWorker.ts @@ -0,0 +1,138 @@ +import * as NodeWorkerRunner from "@effect/platform-node/NodeWorkerRunner" +import type { Machine } from "@typeonce/effect-machine" +import * as Effect from "effect/Effect" +import * as WorkerRunner from "effect/unstable/workers/WorkerRunner" +import { resolve } from "node:path" +import { pathToFileURL } from "node:url" +import type { ViteDevServer } from "vite" +import * as DevToolsProtocol from "../DevToolsProtocol.js" +import * as MachineDocument from "../MachineDocument.js" +import type * as ProjectInspector from "../ProjectInspector.js" + +interface EvaluationRequest { + readonly root: string + readonly revision: number + readonly candidates: ReadonlyArray +} + +interface EvaluationResponse { + readonly results: ReadonlyArray +} + +const isMachine = (value: unknown): value is Machine.Machine.Any => + typeof value === "object" && + value !== null && + "stateNodes" in value && + "handlers" in value && + "initialDefinition" in value && + "initial" in value + +const sourceOf = (file: string, exportName: string | null): MachineDocument.Source => ({ file, exportName }) + +const diagnostic = ( + file: string, + code: string, + message: string +): DevToolsProtocol.Diagnostic => ({ + severity: "error", + code, + message, + location: { file, line: null, column: null }, + statePath: null +}) + +const messageOf = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause) + +const failed = ( + candidate: ProjectInspector.Candidate, + code: string, + message: string, + exportName: string | null = candidate.exportNames[0] ?? null +): DevToolsProtocol.Failed => ({ + _tag: "Failed", + protocolVersion: DevToolsProtocol.protocolVersion, + key: exportName === null ? candidate.file : `${candidate.file}#${exportName}`, + source: sourceOf(candidate.file, exportName), + machineId: null, + diagnostics: [diagnostic(candidate.file, code, message)] +}) + +const evaluateCandidate = ( + server: ViteDevServer, + request: EvaluationRequest, + candidate: ProjectInspector.Candidate +): Effect.Effect> => + Effect.tryPromise({ + try: () => server.ssrLoadModule(pathToFileURL(resolve(request.root, candidate.file)).href), + catch: (cause) => cause + }).pipe( + Effect.flatMap((module) => + Effect.try(() => + Object.entries(module).filter((entry): entry is [string, Machine.Machine.Any] => isMachine(entry[1])) + ) + ), + Effect.flatMap((machines) => { + if (machines.length === 0) { + return Effect.succeed([failed( + candidate, + "machine-export-not-found", + "The module contains a .handle(...) call but does not export the resulting machine" + )]) + } + + const seen = new Set() + return Effect.forEach(machines, ([exportName, machine]) => { + if (seen.has(machine)) return Effect.succeed(undefined) + seen.add(machine) + const source = sourceOf(candidate.file, exportName) + return Effect.try({ + try: () => + ({ + _tag: "Ready", + protocolVersion: DevToolsProtocol.protocolVersion, + key: `${candidate.file}#${exportName}`, + document: MachineDocument.make(machine, { + revision: request.revision, + source + }), + diagnostics: [] + }) satisfies DevToolsProtocol.Ready, + catch: (cause) => cause + }).pipe( + Effect.catch((cause) => + Effect.succeed(failed( + candidate, + "machine-inspection-failed", + messageOf(cause), + exportName + )) + ) + ) + }).pipe( + Effect.map((results) => + results.filter((result): result is DevToolsProtocol.MachineResult => result !== undefined) + ) + ) + }), + Effect.catch((cause) => Effect.succeed([failed(candidate, "module-load-failed", messageOf(cause))])) + ) + +const handle = (server: ViteDevServer, request: EvaluationRequest): Effect.Effect => + Effect.forEach(request.candidates, (candidate) => evaluateCandidate(server, request, candidate), { + concurrency: 1 + }).pipe( + Effect.map((results) => ({ results: results.flat() })) + ) + +export const run = (server: ViteDevServer): Promise => { + return Effect.gen(function*() { + const platform = yield* WorkerRunner.WorkerRunnerPlatform + const runner = yield* platform.start() + yield* runner.run((_portId, request) => + Effect.flatMap(handle(server, request), (response) => runner.send(0, response)) + ) + }).pipe( + Effect.provide(NodeWorkerRunner.layer), + Effect.runPromise + ) +} diff --git a/packages/devtools/src/internal/evaluationWorkerBootstrap.js b/packages/devtools/src/internal/evaluationWorkerBootstrap.js new file mode 100644 index 0000000..bd6f675 --- /dev/null +++ b/packages/devtools/src/internal/evaluationWorkerBootstrap.js @@ -0,0 +1,21 @@ +import { workerData } from "node:worker_threads" +import { createServer } from "vite" + +const server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { + hmr: false, + middlewareMode: true + }, + optimizeDeps: { + noDiscovery: true + } +}) + +try { + const runtime = await server.ssrLoadModule(workerData.runtimeModuleUrl) + await runtime.run(server) +} finally { + await server.close() +} diff --git a/packages/devtools/src/internal/machineDocument.ts b/packages/devtools/src/internal/machineDocument.ts new file mode 100644 index 0000000..2f8c974 --- /dev/null +++ b/packages/devtools/src/internal/machineDocument.ts @@ -0,0 +1,138 @@ +import { Machine } from "@typeonce/effect-machine" +import type * as Public from "../MachineDocument.js" + +const enabled = Machine.enabled as ( + machine: Machine.Machine.Any, + snapshot: unknown +) => ReadonlyArray + +const selection = (value: Machine.Machine.TransitionTargetSelection): Public.Selection => ({ + path: value.path ?? null, + kind: value.kind, + scope: value.scope ?? null +}) + +const trigger = (value: Machine.Machine.TransitionTrigger): Public.Trigger => { + switch (value.type) { + case "event": + return { type: "event", event: String(value.event) } + case "always": + return { type: "always" } + case "done": + return { type: "done" } + case "choice": + return { type: "choice" } + case "invoke": + return { type: "invoke", id: value.id, outcome: value.outcome } + } +} + +const activity = (value: Machine.Machine.ActivityDefinition, id: string): Public.Activity => { + const common = { id, source: value.source, lifecycleId: value.id } + switch (value.type) { + case "process": + return { ...common, type: "process" } + case "effect": + return { ...common, type: "effect", outcomes: { ...value.outcomes } } + case "timer": + return { ...common, type: "timer", duration: value.duration } + case "stream": + return { ...common, type: "stream" } + case "machine": + return { ...common, type: "machine", child: { ...value.child } } + } +} + +const appendReference = (index: Map>, owner: string, id: string): void => { + const references = index.get(owner) ?? [] + references.push(id) + index.set(owner, references) +} + +export const make = ( + machine: M, + options: Public.MakeOptions = {} +): Public.MachineDocument => { + const nodes = Machine.stateNodes(machine) + const transitionIds = new Map>() + const activityIds = new Map>() + const transitionOffsets = new Map() + const activityOffsets = new Map() + const childPaths = new Map>() + + for (const node of nodes) { + const parent = node.parent ?? null + const siblings = childPaths.get(parent) ?? [] + siblings.push(node.path) + childPaths.set(parent, siblings) + } + + const transitions = Machine.transitionDefinitions(machine).map((definition): Public.Transition => { + const offset = transitionOffsets.get(definition.source) ?? 0 + transitionOffsets.set(definition.source, offset + 1) + const id = `${definition.source}:transition:${offset}` + appendReference(transitionIds, definition.source, id) + return { + id, + source: definition.source, + trigger: trigger(definition.trigger), + reenter: definition.reenter, + acceptance: definition.acceptance, + branches: definition.branches.map((branch, branchIndex): Public.Branch => { + const common = { + id: `${id}:branch:${branchIndex}`, + target: branch.target ?? null, + selection: selection(branch.selection), + updates: [...branch.updates] + } + return branch.type === "direct" + ? { ...common, type: "direct" } + : { ...common, type: "branch", key: branch.key, title: branch.title } + }) + } + }) + + const activities = Machine.activityDefinitions(machine).map((definition): Public.Activity => { + const offset = activityOffsets.get(definition.source) ?? 0 + activityOffsets.set(definition.source, offset + 1) + const id = `${definition.source}:activity:${offset}` + appendReference(activityIds, definition.source, id) + return activity(definition, id) + }) + + const initial = Machine.initialDefinition(machine) + return { + schemaVersion: 1, + revision: options.revision ?? 0, + source: options.source ?? null, + machineId: machine.id ?? "Machine", + initial: { + target: initial.target, + selection: selection(initial.selection) + }, + roots: [...childPaths.get(null) ?? []], + states: nodes.map((node): Public.State => ({ + path: node.path, + key: node.key, + order: node.order, + title: node.annotations?.title ?? null, + description: node.annotations?.description ?? null, + documentation: node.annotations?.documentation ?? null, + type: node.type, + history: node.history ?? null, + parent: node.parent ?? null, + children: [...childPaths.get(node.path) ?? []], + initial: node.initial ?? null, + transitionIds: [...transitionIds.get(node.path) ?? []], + activityIds: [...activityIds.get(node.path) ?? []] + })), + transitions, + activities, + snapshot: options.snapshot === undefined + ? null + : { + activePaths: Machine.configuration(machine, options.snapshot).map((node) => node.path), + candidateEvents: enabled(machine, options.snapshot).map(String) + } + } +} diff --git a/packages/devtools/src/internal/machineRegistry.ts b/packages/devtools/src/internal/machineRegistry.ts new file mode 100644 index 0000000..d4b53e6 --- /dev/null +++ b/packages/devtools/src/internal/machineRegistry.ts @@ -0,0 +1,128 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as SubscriptionRef from "effect/SubscriptionRef" +import * as DevToolsProtocol from "../DevToolsProtocol.js" +import type * as MachineRegistry from "../MachineRegistry.js" +import * as ProjectInspector from "../ProjectInspector.js" + +interface PublicApi { + readonly MachineRegistry: typeof MachineRegistry.MachineRegistry + readonly RegistryError: typeof MachineRegistry.RegistryError +} + +const staleDiagnostic = (failed: DevToolsProtocol.Failed): DevToolsProtocol.Diagnostic => ({ + severity: "warning", + code: "stale-document", + message: "Showing the last valid machine document while this module is incomplete.", + location: failed.source === null + ? null + : { file: failed.source.file, line: null, column: null }, + statePath: null +}) + +export const reconcile = ( + previous: ReadonlyArray, + next: ReadonlyArray +): ReadonlyArray => { + const validPrevious = previous.filter((result): result is DevToolsProtocol.Ready | DevToolsProtocol.Partial => + result._tag === "Ready" || result._tag === "Partial" + ) + const previousByKey = new Map(validPrevious.map((result) => [result.key, result])) + const previousByFile = new Map>() + for (const result of validPrevious) { + const file = result.document.source?.file + if (file === undefined) continue + const matches = previousByFile.get(file) + if (matches === undefined) previousByFile.set(file, [result]) + else matches.push(result) + } + + const emitted = new Set(next.filter((result) => result._tag !== "Failed").map((result) => result.key)) + return next.flatMap((result): ReadonlyArray => { + if (result._tag !== "Failed") return [result] + + const moduleFailure = result.diagnostics.some((diagnostic) => + diagnostic.code === "module-load-failed" || diagnostic.code === "machine-export-not-found" + ) + const exact = previousByKey.get(result.key) + const candidates = moduleFailure || exact === undefined + ? previousByFile.get(result.source.file) ?? [] + : [exact] + const retained = candidates.filter((candidate) => !emitted.has(candidate.key)) + if (retained.length === 0) return [result] + + for (const candidate of retained) emitted.add(candidate.key) + return retained.map((candidate): DevToolsProtocol.Partial => ({ + _tag: "Partial", + protocolVersion: DevToolsProtocol.protocolVersion, + key: candidate.key, + document: candidate.document, + diagnostics: [staleDiagnostic(result), ...result.diagnostics] + })) + }) +} + +const retainedCandidates = ( + results: ReadonlyArray +): ReadonlyArray => { + const exportsByFile = new Map>() + for (const result of results) { + if (result._tag !== "Ready" && result._tag !== "Partial") continue + const source = result.document.source + if (source === null) continue + const exportNames = exportsByFile.get(source.file) ?? new Set() + if (source.exportName !== null) exportNames.add(source.exportName) + exportsByFile.set(source.file, exportNames) + } + return [...exportsByFile].map(([file, exportNames]) => ({ + file, + exportNames: [...exportNames].sort() + })) +} + +const make = (api: PublicApi, options: MachineRegistry.Options) => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const state = yield* SubscriptionRef.make({ + protocolVersion: DevToolsProtocol.protocolVersion, + revision: 0, + results: [] + }) + + const refresh = SubscriptionRef.get(state).pipe( + Effect.flatMap((current) => + inspector.inspect({ + ...options, + revision: current.revision + 1, + retainedCandidates: retainedCandidates(current.results) + }).pipe( + Effect.map((results): MachineRegistry.Snapshot => ({ + protocolVersion: DevToolsProtocol.protocolVersion, + revision: current.revision + 1, + results: reconcile(current.results, results) + })) + ) + ), + Effect.tap((next) => SubscriptionRef.set(state, next)), + Effect.mapError((cause) => + new api.RegistryError({ + message: `Could not inspect Effect Machine definitions under ${options.root}`, + cause + }) + ) + ) + + yield* refresh + + return api.MachineRegistry.of({ + get: SubscriptionRef.get(state), + changes: SubscriptionRef.changes(state), + refresh + }) + }) + +export const layer = ( + api: PublicApi, + options: MachineRegistry.Options +): Layer.Layer => + Layer.effect(api.MachineRegistry, make(api, options)) diff --git a/packages/devtools/src/internal/machineSimulator.ts b/packages/devtools/src/internal/machineSimulator.ts new file mode 100644 index 0000000..c9c9499 --- /dev/null +++ b/packages/devtools/src/internal/machineSimulator.ts @@ -0,0 +1,199 @@ +import type * as MachineDocument from "../MachineDocument.js" +import type * as MachineSimulator from "../MachineSimulator.js" + +type State = MachineDocument.State +type Transition = MachineDocument.Transition + +interface Model { + readonly states: ReadonlyMap + readonly order: ReadonlyMap +} + +const makeModel = (document: MachineDocument.MachineDocument): Model => ({ + states: new Map(document.states.map((state) => [state.path, state])), + order: new Map(document.states.map((state) => [state.path, state.order])) +}) + +const ordered = (model: Model, paths: Iterable): ReadonlyArray => + [...new Set(paths)].sort((left, right) => (model.order.get(left) ?? 0) - (model.order.get(right) ?? 0)) + +const enter = (model: Model, path: string, active: Set): void => { + const state = model.states.get(path) + if (state === undefined) return + active.add(path) + if (state.type === "parallel") { + state.children.forEach((child) => enter(model, child, active)) + } else if (state.type === "compound" && state.initial !== null) { + enter(model, state.initial, active) + } +} + +const ancestors = (model: Model, path: string): ReadonlyArray => { + const result: Array = [] + let current = model.states.get(path) + while (current !== undefined) { + result.unshift(current.path) + current = current.parent === null ? undefined : model.states.get(current.parent) + } + return result +} + +const leastCommonAncestor = (model: Model, left: string, right: string): string | null => { + const leftAncestors = ancestors(model, left) + const rightAncestors = ancestors(model, right) + let result: string | null = null + const length = Math.min(leftAncestors.length, rightAncestors.length) + for (let index = 0; index < length; index++) { + if (leftAncestors[index] !== rightAncestors[index]) break + result = leftAncestors[index] ?? null + } + return result +} + +const isDescendant = (model: Model, path: string, ancestor: string): boolean => + path !== ancestor && ancestors(model, path).includes(ancestor) + +const candidateEvents = ( + document: MachineDocument.MachineDocument, + activePaths: ReadonlyArray +): ReadonlyArray => { + const active = new Set(activePaths) + return [ + ...new Set( + document.transitions.flatMap((transition): ReadonlyArray => + transition.trigger.type === "event" && active.has(transition.source) ? [transition.trigger.event] : [] + ) + ) + ].sort() +} + +const snapshot = ( + document: MachineDocument.MachineDocument, + model: Model, + step: number, + activePaths: Iterable +): MachineSimulator.Snapshot => { + const active = ordered(model, activePaths) + return { + step, + activePaths: active, + candidateEvents: candidateEvents(document, active) + } +} + +export const start = (document: MachineDocument.MachineDocument): MachineSimulator.Session => { + const model = makeModel(document) + const active = new Set() + if (document.snapshot === null) { + const targetAncestors = ancestors(model, document.initial.target) + targetAncestors.forEach((path) => active.add(path)) + enter(model, document.initial.target, active) + } else { + document.snapshot.activePaths.forEach((path) => active.add(path)) + } + return { document, snapshot: snapshot(document, model, 0, active) } +} + +const indeterminate = ( + session: MachineSimulator.Session, + event: string, + transitions: ReadonlyArray, + reason: MachineSimulator.Indeterminate["reason"] +): MachineSimulator.Indeterminate => ({ + _tag: "Indeterminate", + event, + transitionIds: transitions.map((transition) => transition.id), + session: session.snapshot, + reason +}) + +const notesFor = ( + document: MachineDocument.MachineDocument, + transition: Transition, + nextActive: ReadonlyArray +): ReadonlyArray => { + const notes = new Set(["runtime-effects-skipped"]) + if (transition.reenter) notes.add("reentry-lifecycles-skipped") + if (transition.branches.some((branch) => branch.updates.length > 0)) notes.add("state-updates-skipped") + const active = new Set(nextActive) + if ( + document.transitions.some((candidate) => + active.has(candidate.source) && + (candidate.trigger.type === "always" || candidate.trigger.type === "choice" || candidate.trigger.type === "done") + ) + ) { + notes.add("automatic-transitions-skipped") + } + return [...notes] +} + +const nextConfiguration = ( + session: MachineSimulator.Session, + transition: Transition, + target: string | null +): MachineSimulator.Session | undefined => { + const document = session.document + const model = makeModel(document) + if (target === null) { + return { + document, + snapshot: snapshot(document, model, session.snapshot.step + 1, session.snapshot.activePaths) + } + } + if (!model.states.has(target)) return undefined + + const naturalBoundary = leastCommonAncestor(model, transition.source, target) + const sourceParent = model.states.get(transition.source)?.parent ?? null + const boundary = transition.reenter + ? sourceParent === null || naturalBoundary === null + ? null + : ancestors(model, naturalBoundary).length <= ancestors(model, sourceParent).length + ? naturalBoundary + : sourceParent + : naturalBoundary + const active = new Set(session.snapshot.activePaths) + for (const path of active) { + if (boundary === null || isDescendant(model, path, boundary)) active.delete(path) + } + ancestors(model, target).forEach((path) => { + if (boundary === null || path === boundary || isDescendant(model, path, boundary)) active.add(path) + }) + enter(model, target, active) + return { document, snapshot: snapshot(document, model, session.snapshot.step + 1, active) } +} + +export const send = (session: MachineSimulator.Session, event: string): MachineSimulator.StepResult => { + const active = new Set(session.snapshot.activePaths) + const transitions = session.document.transitions.filter((transition) => + transition.trigger.type === "event" && transition.trigger.event === event && active.has(transition.source) + ) + if (transitions.length === 0) { + return { + _tag: "Blocked", + event, + transitionIds: [], + session: session.snapshot, + reason: "event-not-enabled" + } + } + if (transitions.length > 1) return indeterminate(session, event, transitions, "multiple-transitions") + const transition = transitions[0]! + if (transition.acceptance === "declinable") { + return indeterminate(session, event, transitions, "declinable-transition") + } + if (transition.branches.length !== 1 || transition.branches[0]?.type === "branch") { + return indeterminate(session, event, transitions, "conditional-branches") + } + const branch = transition.branches[0]! + if (branch.selection.kind === "history") return indeterminate(session, event, transitions, "history-target") + if (branch.selection.kind === "choice") return indeterminate(session, event, transitions, "choice-target") + const next = nextConfiguration(session, transition, branch.target) + if (next === undefined) return indeterminate(session, event, transitions, "missing-target") + return { + _tag: "Applied", + event, + transitionIds: [transition.id], + session: next.snapshot, + notes: notesFor(session.document, transition, next.snapshot.activePaths) + } +} diff --git a/packages/devtools/src/internal/projectInspector.ts b/packages/devtools/src/internal/projectInspector.ts new file mode 100644 index 0000000..c2bbfd3 --- /dev/null +++ b/packages/devtools/src/internal/projectInspector.ts @@ -0,0 +1,226 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" +import * as NodePath from "@effect/platform-node/NodePath" +import * as NodeWorker from "@effect/platform-node/NodeWorker" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as Schema from "effect/Schema" +import * as Worker from "effect/unstable/workers/Worker" +import { Worker as NodeWorkerThread } from "node:worker_threads" +import ts from "typescript" +import * as DevToolsProtocol from "../DevToolsProtocol.js" +import type * as ProjectInspector from "../ProjectInspector.js" + +const defaultInclude = "**/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" + +const defaultExclude = [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/.data/**", + "**/references/**", + "**/*.d.ts" +] as const + +interface EvaluationRequest { + readonly root: string + readonly revision: number + readonly candidates: ReadonlyArray +} + +interface EvaluationResponse { + readonly results: ReadonlyArray +} + +const scriptKind = (file: string): ts.ScriptKind => { + if (file.endsWith(".tsx")) return ts.ScriptKind.TSX + if (file.endsWith(".jsx")) return ts.ScriptKind.JSX + if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs")) return ts.ScriptKind.JS + return ts.ScriptKind.TS +} + +const hasExportModifier = (node: ts.Node): boolean => + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true + +const containsHandleCall = (node: ts.Node): boolean => { + let found = false + const visit = (current: ts.Node): void => { + if (found) return + if ( + ts.isCallExpression(current) && + ((ts.isPropertyAccessExpression(current.expression) && current.expression.name.text === "handle") || + (ts.isElementAccessExpression(current.expression) && + ts.isStringLiteral(current.expression.argumentExpression) && + current.expression.argumentExpression.text === "handle")) + ) { + found = true + return + } + ts.forEachChild(current, visit) + } + visit(node) + return found +} + +export const parseCandidate = (file: string, source: string): ProjectInspector.Candidate | undefined => { + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file)) + if (!containsHandleCall(sourceFile)) return undefined + + const exportNames = new Set() + for (const statement of sourceFile.statements) { + if (ts.isExportAssignment(statement) && containsHandleCall(statement.expression)) { + exportNames.add("default") + continue + } + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && declaration.initializer && containsHandleCall(declaration.initializer) + ) { + exportNames.add(declaration.name.text) + } + } + } + } + + return { + file, + exportNames: [...exportNames].sort() + } +} + +const hasSyntacticErrors = (file: string, source: string): boolean => + ts.transpileModule(source, { + fileName: file, + reportDiagnostics: true, + compilerOptions: { + allowJs: true, + jsx: ts.JsxEmit.Preserve, + target: ts.ScriptTarget.Latest + } + }).diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) === true + +interface PublicApi { + readonly ProjectInspector: typeof ProjectInspector.ProjectInspector + readonly DiscoveryError: typeof ProjectInspector.DiscoveryError + readonly EvaluationError: typeof ProjectInspector.EvaluationError +} + +const makeDiscovery = (api: PublicApi) => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + return (options: ProjectInspector.InspectOptions): Effect.Effect< + ReadonlyArray, + ProjectInspector.DiscoveryError + > => + Effect.gen(function*() { + const root = path.resolve(options.root) + const retainedByFile = new Map( + (options.retainedCandidates ?? []).map((candidate) => [candidate.file, candidate]) + ) + const files = yield* fs.glob(options.include ?? defaultInclude, { + root, + exclude: [...defaultExclude, ...(options.exclude ?? [])] + }) + const candidates = yield* Effect.forEach( + files.sort(), + (file) => + fs.readFileString(path.join(root, file)).pipe( + Effect.map((source) => { + const candidate = parseCandidate(file, source) + if (candidate !== undefined) return candidate + const retained = retainedByFile.get(file) + return retained !== undefined && hasSyntacticErrors(file, source) ? retained : undefined + }) + ), + { concurrency: "unbounded" } + ) + return candidates.filter((candidate): candidate is ProjectInspector.Candidate => candidate !== undefined) + }).pipe( + Effect.mapError((cause) => + new api.DiscoveryError({ + message: `Could not discover Effect Machine definitions under ${options.root}`, + cause + }) + ) + ) + }) + +const workerBootstrap = new URL("./evaluationWorkerBootstrap.js", import.meta.url) +const workerRuntime = new URL( + import.meta.url.endsWith(".ts") ? "./evaluationWorker.ts" : "./evaluationWorker.js", + import.meta.url +) + +const WorkerLayer = NodeWorker.layer(() => + new NodeWorkerThread(workerBootstrap, { + execArgv: [], + workerData: { + runtimeModuleUrl: workerRuntime.href + } + }) +) + +const evaluate = ( + api: PublicApi, + candidates: ReadonlyArray, + options: ProjectInspector.InspectOptions +): Effect.Effect, ProjectInspector.EvaluationError> => { + if (candidates.length === 0) return Effect.succeed([]) + + const request: EvaluationRequest = { + root: options.root, + revision: options.revision ?? 0, + candidates + } + + return Effect.scoped( + Effect.gen(function*() { + const platform = yield* Worker.WorkerPlatform + const worker = yield* platform.spawn(0) + const response = yield* Deferred.make() + const runner = yield* Effect.forkScoped( + worker.run((message) => Deferred.succeed(response, message)) + ) + yield* worker.send(request) + const message = yield* Effect.raceFirst(Deferred.await(response), Fiber.join(runner)) + return yield* Effect.forEach( + message.results, + (result) => Schema.decodeUnknownEffect(DevToolsProtocol.MachineResult)(result) + ) + }) + ).pipe( + Effect.provide(WorkerLayer), + Effect.mapError((cause) => + new api.EvaluationError({ + message: "The isolated machine evaluator failed", + cause + }) + ) + ) +} + +const make = (api: PublicApi) => + Effect.gen(function*() { + const discover = yield* makeDiscovery(api) + const inspect = (options: ProjectInspector.InspectOptions) => + Effect.flatMap(discover(options), (candidates) => evaluate(api, candidates, options)) + return api.ProjectInspector.of({ + discover, + evaluate: (candidates, options) => evaluate(api, candidates, options), + inspect + }) + }) + +const PlatformLayer = Layer.merge(NodeFileSystem.layer, NodePath.layer) + +export const layer = (api: PublicApi): Layer.Layer => + Layer.effect(api.ProjectInspector, make(api)).pipe(Layer.provide(PlatformLayer)) diff --git a/packages/devtools/test/DevServer.test.ts b/packages/devtools/test/DevServer.test.ts new file mode 100644 index 0000000..5348564 --- /dev/null +++ b/packages/devtools/test/DevServer.test.ts @@ -0,0 +1,10 @@ +import { assert, describe, it } from "@effect/vitest" +import { watcherOptions } from "../src/internal/devServer.js" + +describe("DevServer", () => { + it("uses native file-system events unless polling is requested", () => { + const options = { root: "/project", host: "127.0.0.1", port: 5173 } + assert.strictEqual(watcherOptions(options).usePolling, false) + assert.strictEqual(watcherOptions({ ...options, watchPolling: true }).usePolling, true) + }) +}) diff --git a/packages/devtools/test/MachineDocument.test.ts b/packages/devtools/test/MachineDocument.test.ts new file mode 100644 index 0000000..40c195c --- /dev/null +++ b/packages/devtools/test/MachineDocument.test.ts @@ -0,0 +1,62 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Schema from "effect/Schema" +import * as DevToolsProtocol from "../src/DevToolsProtocol.js" +import { machine, snapshot } from "../src/internal/browser/example-machine.js" +import * as MachineDocument from "../src/MachineDocument.js" + +describe("MachineDocument", () => { + it("captures and validates a versioned machine document", () => { + const document = MachineDocument.make(machine, { + revision: 3, + source: { file: "/project/src/workflow.ts", exportName: "workflow" }, + snapshot + }) + + assert.strictEqual(document.schemaVersion, 1) + assert.strictEqual(document.revision, 3) + assert.deepStrictEqual(document.source, { + file: "/project/src/workflow.ts", + exportName: "workflow" + }) + assert.deepStrictEqual(Schema.decodeUnknownSync(MachineDocument.MachineDocument)(document), document) + }) + + it("validates ready, partial, and failed evaluation results", () => { + const document = MachineDocument.make(machine) + const diagnostic: DevToolsProtocol.Diagnostic = { + severity: "warning", + code: "stale-document", + message: "The last valid document is shown.", + location: null, + statePath: null + } + const results: ReadonlyArray = [ + { + _tag: "Ready", + protocolVersion: 1, + key: "workflow.ts#workflow", + document, + diagnostics: [] + }, + { + _tag: "Partial", + protocolVersion: 1, + key: "workflow.ts#workflow", + document, + diagnostics: [diagnostic] + }, + { + _tag: "Failed", + protocolVersion: 1, + key: "broken.ts#machine", + source: { file: "broken.ts", exportName: "machine" }, + machineId: null, + diagnostics: [{ ...diagnostic, severity: "error" }] + } + ] + + results.forEach((result) => { + assert.deepStrictEqual(Schema.decodeUnknownSync(DevToolsProtocol.MachineResult)(result), result) + }) + }) +}) diff --git a/packages/devtools/test/MachineRegistry.test.ts b/packages/devtools/test/MachineRegistry.test.ts new file mode 100644 index 0000000..3a0b8f0 --- /dev/null +++ b/packages/devtools/test/MachineRegistry.test.ts @@ -0,0 +1,75 @@ +import { assert, describe, it } from "@effect/vitest" +import * as DevToolsProtocol from "../src/DevToolsProtocol.js" +import { machine } from "../src/internal/browser/example-machine.js" +import { reconcile } from "../src/internal/machineRegistry.js" +import * as MachineDocument from "../src/MachineDocument.js" + +describe("MachineRegistry", () => { + it("keeps the last valid document when a reload fails", () => { + const document = MachineDocument.make(machine, { + source: { file: "src/workflow.ts", exportName: "workflow" } + }) + const ready: DevToolsProtocol.Ready = { + _tag: "Ready", + protocolVersion: 1, + key: "src/workflow.ts#workflow", + document, + diagnostics: [] + } + const failed: DevToolsProtocol.Failed = { + _tag: "Failed", + protocolVersion: 1, + key: ready.key, + source: { file: "src/workflow.ts", exportName: "workflow" }, + machineId: null, + diagnostics: [{ + severity: "error", + code: "module-load-failed", + message: "Unexpected end of input", + location: { file: "src/workflow.ts", line: null, column: null }, + statePath: null + }] + } + + const result = reconcile([ready], [failed])[0] + assert.strictEqual(result?._tag, "Partial") + if (result?._tag === "Partial") { + assert.strictEqual(result.document, document) + assert.deepStrictEqual(result.diagnostics.map((diagnostic) => diagnostic.code), [ + "stale-document", + "module-load-failed" + ]) + } + }) + + it("matches a failed reload by source file when its export identity changes", () => { + const document = MachineDocument.make(machine, { + source: { file: "src/workflow.ts", exportName: "workflow" } + }) + const ready: DevToolsProtocol.Ready = { + _tag: "Ready", + protocolVersion: 1, + key: "src/workflow.ts#workflow", + document, + diagnostics: [] + } + const failed: DevToolsProtocol.Failed = { + _tag: "Failed", + protocolVersion: 1, + key: "src/workflow.ts#renamed", + source: { file: "src/workflow.ts", exportName: "renamed" }, + machineId: null, + diagnostics: [{ + severity: "error", + code: "module-load-failed", + message: "Unexpected end of input", + location: { file: "src/workflow.ts", line: null, column: null }, + statePath: null + }] + } + + const result = reconcile([ready], [failed])[0] + assert.strictEqual(result?._tag, "Partial") + assert.strictEqual(result?.key, ready.key) + }) +}) diff --git a/packages/devtools/test/MachineSimulator.test.ts b/packages/devtools/test/MachineSimulator.test.ts new file mode 100644 index 0000000..b2f153e --- /dev/null +++ b/packages/devtools/test/MachineSimulator.test.ts @@ -0,0 +1,57 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Schema from "effect/Schema" +import { machine } from "../src/internal/browser/example-machine.js" +import * as MachineDocument from "../src/MachineDocument.js" +import * as MachineSimulator from "../src/MachineSimulator.js" + +describe("MachineSimulator", () => { + const document = MachineDocument.make(machine) + + it("enters compound and parallel initial states without running the machine", () => { + const session = MachineSimulator.start(document) + assert.deepStrictEqual(session.snapshot.activePaths, [ + "application", + "application.workflow", + "application.workflow.idle", + "application.connection", + "application.connection.online" + ]) + assert.deepStrictEqual(session.snapshot.candidateEvents, ["Disconnect", "Refresh", "Start"]) + }) + + it("applies a statically direct transition and preserves the parallel region", () => { + const started = MachineSimulator.start(document) + const result = MachineSimulator.send(started, "Start") + assert.strictEqual(result._tag, "Applied") + if (result._tag === "Applied") { + assert.deepStrictEqual(result.session.activePaths, [ + "application", + "application.workflow", + "application.workflow.running", + "application.workflow.running.editing", + "application.connection", + "application.connection.online" + ]) + assert.deepStrictEqual(result.session.candidateEvents, ["Disconnect", "Finish"]) + assert.deepStrictEqual(Schema.decodeUnknownSync(MachineSimulator.StepResult)(result), result) + } + }) + + it("does not guess whether a declinable transition accepts an event", () => { + const transition = document.transitions.find((transition) => transition.trigger.type === "event")! + const guarded = { + ...document, + transitions: document.transitions.map((candidate) => + candidate.id === transition.id ? { ...candidate, acceptance: "declinable" as const } : candidate + ) + } + const result = MachineSimulator.send(MachineSimulator.start(guarded), "Start") + assert.strictEqual(result._tag, "Indeterminate") + if (result._tag === "Indeterminate") assert.strictEqual(result.reason, "declinable-transition") + }) + + it("blocks events that are not registered in the active configuration", () => { + const result = MachineSimulator.send(MachineSimulator.start(document), "Finish") + assert.strictEqual(result._tag, "Blocked") + }) +}) diff --git a/packages/devtools/test/ProjectInspector.test.ts b/packages/devtools/test/ProjectInspector.test.ts new file mode 100644 index 0000000..c6d8fba --- /dev/null +++ b/packages/devtools/test/ProjectInspector.test.ts @@ -0,0 +1,87 @@ +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { parseCandidate } from "../src/internal/projectInspector.js" +import * as ProjectInspector from "../src/ProjectInspector.js" + +describe("ProjectInspector", () => { + it("finds handle calls and records direct machine exports", () => { + assert.deepStrictEqual( + parseCandidate( + "src/workflow.ts", + ` + const builder = Machine.make({}) + export const workflow = builder.handle({}) + const internal = builder["handle"]({}) + ` + ), + { file: "src/workflow.ts", exportNames: ["workflow"] } + ) + assert.isUndefined(parseCandidate("src/value.ts", "export const value = 1")) + }) + + it.effect("discovers and evaluates an exported machine in an isolated worker", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const results = yield* inspector.inspect({ + root: process.cwd(), + include: "packages/devtools/src/internal/browser/example-machine.ts", + revision: 7 + }) + + assert.strictEqual(results.length, 1) + const result = results[0] + assert.strictEqual(result?._tag, "Ready") + if (result?._tag === "Ready") { + assert.strictEqual(result.document.machineId, "inspection-example") + assert.strictEqual(result.document.revision, 7) + assert.deepStrictEqual(result.document.source, { + file: "packages/devtools/src/internal/browser/example-machine.ts", + exportName: "machine" + }) + } + }).pipe(Effect.provide(ProjectInspector.layer))) + + it.effect("returns a failed result when one candidate cannot be loaded", () => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const results = yield* inspector.evaluate( + [{ + file: "packages/devtools/test/fixtures/broken-machine.mjs", + exportNames: ["machine"] + }], + { root: process.cwd() } + ) + + assert.strictEqual(results.length, 1) + assert.strictEqual(results[0]?._tag, "Failed") + if (results[0]?._tag === "Failed") { + assert.strictEqual(results[0].diagnostics[0]?.code, "module-load-failed") + } + }).pipe(Effect.provide(ProjectInspector.layer))) + + it.effect("retains a known candidate only while its source is syntactically incomplete", () => + Effect.acquireUseRelease( + Effect.promise(() => mkdtemp(join(tmpdir(), "effect-machine-inspector-"))), + (root) => + Effect.gen(function*() { + const inspector = yield* ProjectInspector.ProjectInspector + const sourceDirectory = join(root, "src") + const sourceFile = join(sourceDirectory, "workflow.ts") + yield* Effect.promise(() => mkdir(sourceDirectory)) + yield* Effect.promise(() => writeFile(sourceFile, "export const building = {", "utf8")) + + const options = { + root, + retainedCandidates: [{ file: "src/workflow.ts", exportNames: ["workflow"] }] + } + assert.deepStrictEqual(yield* inspector.discover(options), options.retainedCandidates) + + yield* Effect.promise(() => writeFile(sourceFile, "export const value = 1\n", "utf8")) + assert.deepStrictEqual(yield* inspector.discover(options), []) + }).pipe(Effect.provide(ProjectInspector.layer)), + (root) => Effect.promise(() => rm(root, { recursive: true, force: true })) + )) +}) diff --git a/packages/devtools/test/fixtures/broken-machine.mjs b/packages/devtools/test/fixtures/broken-machine.mjs new file mode 100644 index 0000000..9062d85 --- /dev/null +++ b/packages/devtools/test/fixtures/broken-machine.mjs @@ -0,0 +1 @@ +export const machine = Machine.make({}).handle( diff --git a/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts new file mode 100644 index 0000000..f3939fa --- /dev/null +++ b/packages/devtools/test/internal/browser/InteractiveTextVisualization.test.ts @@ -0,0 +1,114 @@ +import { assert, describe, it } from "@effect/vitest" +import { Machine } from "@typeonce/effect-machine" +import * as Schema from "effect/Schema" +import { makeTextRenderer } from "../../../../effect-machine/test/machine/visualization/text.js" +import { machine, snapshot } from "../../../src/internal/browser/example-machine.js" +import { textTreeToString } from "../../../src/internal/browser/text-tree.js" +import { makeVisualizerModel } from "../../../src/internal/browser/visualizer-model.js" +import * as MachineDocument from "../../../src/MachineDocument.js" + +const renderText = makeTextRenderer(Machine) +const buildDocument = () => MachineDocument.make(machine, { snapshot }) + +describe("Interactive text visualization", () => { + it("captures ordered serializable machine information", () => { + const document = buildDocument() + const idle = document.states.find((state) => state.path === "application.workflow.idle") + + assert.deepStrictEqual(document.initial, { + target: "application", + selection: { path: "application", kind: "initial", scope: "initial" } + }) + assert.deepStrictEqual(document.roots, ["application", "disabled"]) + assert.deepStrictEqual(idle?.transitionIds, [ + "application.workflow.idle:transition:0", + "application.workflow.idle:transition:1" + ]) + assert.deepStrictEqual(document.transitions[0], { + id: "application.workflow.idle:transition:0", + source: "application.workflow.idle", + trigger: { type: "event", event: "Start" }, + reenter: false, + acceptance: "required", + branches: [{ + id: "application.workflow.idle:transition:0:branch:0", + type: "direct", + target: "application.workflow.running", + selection: { path: "application.workflow.running", kind: "state", scope: "local" }, + updates: ["application.workflow"] + }] + }) + assert.deepStrictEqual( + document.states.find((state) => state.path === "application.workflow")?.children, + [ + "application.workflow.idle", + "application.workflow.running", + "application.workflow.recent" + ] + ) + assert.deepStrictEqual(document.snapshot, { + activePaths: [ + "application", + "application.workflow", + "application.workflow.idle", + "application.connection", + "application.connection.online" + ], + candidateEvents: ["Start", "Refresh", "Disconnect"] + }) + assert.deepStrictEqual(JSON.parse(JSON.stringify(document)), document) + assert.deepStrictEqual(Schema.decodeUnknownSync(MachineDocument.MachineDocument)(document), document) + assert.strictEqual(MachineDocument.make(machine).snapshot, null) + }) + + it("preserves the static text renderer output", () => { + assert.strictEqual(textTreeToString(buildDocument()), renderText(machine, snapshot)) + }) + + it("projects a state-only topology and structured state inspection", () => { + const model = makeVisualizerModel(buildDocument()) + const application = model.roots[0] + const workflow = application?.children[0] + const idle = workflow?.children[0] + const inspection = idle === undefined ? undefined : model.inspectState(idle.path) + + assert.deepStrictEqual( + model.roots.map((root) => root.path), + ["application", "disabled"] + ) + assert.strictEqual(application?.active, true) + assert.strictEqual(application?.initial, true) + assert.deepStrictEqual( + workflow?.children.map((child) => child.path), + ["application.workflow.idle", "application.workflow.running", "application.workflow.recent"] + ) + assert.strictEqual(idle?.transitionCount, 2) + assert.strictEqual(inspection?.outgoing.length, 2) + assert.strictEqual(model.inspectState("application.workflow.running")?.incoming[0]?.transition.source, idle?.path) + assert.deepStrictEqual( + model.inspectEvent("Start").transitions.map((transition) => transition.source), + ["application.workflow.idle"] + ) + assert.strictEqual(model.inspectEvent("Start").candidate, true) + assert.deepStrictEqual( + inspection?.breadcrumbs.map((item) => item.path), + ["application", "application.workflow", "application.workflow.idle"] + ) + }) + + it("accepts an empty partial topology", () => { + const document = buildDocument() + const model = makeVisualizerModel({ + ...document, + roots: [], + states: [], + transitions: [], + activities: [], + snapshot: null + }) + + assert.deepStrictEqual(model.roots, []) + assert.strictEqual(model.hasSnapshot, false) + assert.strictEqual(model.inspectState("application"), undefined) + }) +}) diff --git a/packages/devtools/tsconfig.build.json b/packages/devtools/tsconfig.build.json new file mode 100644 index 0000000..b12fd27 --- /dev/null +++ b/packages/devtools/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true, + "declaration": true, + "declarationMap": true, + "incremental": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": false, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": "src", + "sourceMap": true + }, + "include": ["src/*.ts", "src/internal/*.ts", "src/internal/*.js"], + "exclude": ["src/internal/browser/**/*"] +} diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000..a62908f --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/packages/devtools/vite.config.ts b/packages/devtools/vite.config.ts new file mode 100644 index 0000000..a6e64c7 --- /dev/null +++ b/packages/devtools/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite" + +export default defineConfig({ + root: new URL(".", import.meta.url).pathname, + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true + }, + build: { + outDir: "dist/client", + emptyOutDir: true + } +}) diff --git a/CHANGELOG.md b/packages/effect-machine/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/effect-machine/CHANGELOG.md diff --git a/packages/effect-machine/LICENSE b/packages/effect-machine/LICENSE new file mode 100644 index 0000000..afebcef --- /dev/null +++ b/packages/effect-machine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sandro Maglione + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/effect-machine/NOTICE b/packages/effect-machine/NOTICE new file mode 100644 index 0000000..b49e70e --- /dev/null +++ b/packages/effect-machine/NOTICE @@ -0,0 +1,3 @@ +Portions are adapted from the Effect project, which is distributed under the +MIT License. See https://github.com/Effect-TS/effect and the source history for +authorship and provenance. diff --git a/packages/effect-machine/README.md b/packages/effect-machine/README.md new file mode 100644 index 0000000..63d8b47 --- /dev/null +++ b/packages/effect-machine/README.md @@ -0,0 +1,859 @@ +# @typeonce/effect-machine + +Schema-first state machines and statecharts for Effect. + +State, event, input, output, and persistence boundaries are described with +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. + +## Design principles + +- **Type-safe by construction:** reject invalid protocols, compositions, and + capabilities at compile time where possible, and preserve typed Effect + failures at runtime. +- **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. +- **Effect-native:** design toward eventual inclusion in Effect core and follow + its API shape, module boundaries, ownership, and failure conventions. + +The package is pre-1.0: a clearer or safer long-term API takes priority over +backward compatibility. Breaking changes use minor releases, compatible fixes +use patch releases, and compatibility aliases are not added by default. + +The core machine model remains local. Distributed identity, placement, +transport, routing, delivery, and remote lifecycle semantics belong to Effect +Cluster and are exposed only through explicit integration boundaries. + +## Install + +```sh +pnpm add @typeonce/effect-machine effect@4.0.0-rc.111 +``` + +`effect` is an exact peer dependency. Install the version above and upgrade it +in lockstep with this package. + +## Quick start + +Define schemas first, derive the state topology, then add behavior: + +```ts +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema, Stream } from "effect" + +const State = Schema.TaggedUnion({ + Running: { count: Schema.Number } +}) + +const States = Machine.states({ + Idle: {}, + Running: State.cases.Running +}) + +const CounterEvent = Machine.events( + Schema.TaggedUnion({ + Start: {}, + Increment: {}, + Stop: {} + }) +) + +const CounterDefinition = Machine.make({ + id: "Counter", + states: States.states, + events: CounterEvent, + initial: (to) => to.Idle() +}) + +const Counter = CounterDefinition.handle({ + Idle: { + on: { + Start: (to) => to.full.Running().resolve(({ target }) => target.from({ count: 0 })) + } + }, + Running: { + on: { + Increment: (to) => to.full.Running().resolve(({ state, target }) => target.from({ count: state.count + 1 })), + Stop: (to) => to.full.Idle() + } + } +}) + +const program = Effect.gen(function*() { + const ref = yield* Machine.start(Counter) + yield* ref.send(CounterEvent.Start()) + yield* ref.send(CounterEvent.Increment()) +}) +``` + +`handle` creates a complete implementation boundary. Its result does not +expose `handle`, so all behavior for one machine belongs in the same handler +tree. Reuse the definition when multiple independent implementations are +useful, such as production and testing variants: + +```ts +const ProductionCounter = CounterDefinition.handle(productionHandlers) +const TestingCounter = CounterDefinition.handle(testingHandlers) +``` + +`Machine.start` returns a `MachineRef` with `send`, `state`, `snapshot`, +`changes`, `emissions`, `join`, and `stop`. Sending enqueues an event; observe +`changes` or use the testing probe when work must be causally acknowledged. + +## Modeling workflow + +Use this order to preserve inference and keep boundaries explicit: + +1. Define domain schemas used by state and by shared event fields. +2. Declare topology with `Machine.states`, naming a tagged state union + when its `.cases` are reused. +3. Create event descriptors with `Machine.events`, `Machine.internalEvents`, + and `Machine.emittedEvents`, passing tagged unions or tagged classes directly. +4. Create the machine and implement every active state with + `Machine.make({...}).handle({...})`. +5. Add child descriptors, then runtime, Atom, testing, or cluster adapters at + the application boundary. + +Keep one-off topology inline in `Machine.states`. Use `Machine.state` only when +the same active state definition is mounted more than once; tagged schemas are +already reusable without it. For repeated finite regions, derive names with +`States.path(...)` so every literal in the path family is checked against the +complete tree. Type full-snapshot helpers as `Machine.Snapshot` +or `Machine.Snapshot`, schema-backed state payloads as +`Machine.Value`, and path-rooted snapshots as +`Machine.SnapshotAt`. + +### Make invalid states unrepresentable + +Treat topology as a domain contract, not as file organization. A parallel state +declares the full Cartesian product of its regions, so use it only when every +combination has a coherent meaning. If one region must inspect another before +entering a state safely, prefer a compound hierarchy that makes the forbidden +combination impossible. `matches` remains useful for views, tests, and genuine +coordination between independent regions; it should not repair an invalid +state product. + +Keep state-scoped Effects beneath the state that guarantees their resources, +and enforce command availability in the machine rather than only by disabling +UI controls. When entering an inactive compound or parallel state's declared +default, select `.initial`; explicitly construct descendants only for a +non-default configuration or a complete replacement of an already-active +parallel root. + +### Construct state through builders + +Use `.from(...)` when constructing a new state from schema make input: + +```ts +target.from({ draft: event.draft }) +``` + +The machine runs these inputs through the state schema while planning. Schema +defaults, transformations, refinements, and tagged-class identity are +therefore preserved, and decode failures remain typed machine failures. This +is the default construction path. + +Use `.decoded(...)` when the value is already a `Schema.Type`: + +```ts +target.decoded(new Ready({ notice: null })) +``` + +The machine still validates the value against the schema's type side. It does +not run encoded-input transformations again. State builders are not callable; +the method name always makes the construction mode visible. + +When sibling states share fields, remove the source discriminator and pass the +remaining fields through the target schema: + +```ts +const handlers = { + Submit: (to) => + to.local.Saving().resolve(({ state, target }) => { + const { _tag: _, ...fields } = state + return target.from({ ...fields, attempt: 1 }) + }) +} +``` + +Omit `schema` when a state represents control flow but owns no data. Use `{}` +instead of defining an empty tagged schema: + +```ts +const States = Machine.states({ + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving + } + } +}) + +const definition = Machine.make({ + states: States.states, + events: Machine.events(), + initial: (to) => to.Form.initial.resolve(({ target }) => target.from((form) => form.Editing.from())) +}) +``` + +Schema-less states remain active, targetable, matchable, and visible through +`getSnapshot`, but have no value to read. Their builders expose only `.from`, +their handler `state` is `undefined`, and `get` / `getWithParents` accept only +schema-backed paths. Add a schema later if the state starts owning data. + +Keep data-bearing state schemas together in a named `Schema.TaggedUnion` and +reference its cases from the topology. For a standalone state schema whose +class identity is useful, declare a named `Schema.TaggedClass`. Do not bury +one-off tagged schema declarations inside `Machine.states`. + +Put data on the narrowest state where it is valid. If sibling phases share +data, put it on their compound parent. + +### Separate inputs, raised events, and emissions + +`events` is the public machine-input protocol. Events raised to the same machine +belong in `internalEvents`. Ephemeral outward notifications have their own +`emittedEvents` protocol: + +```ts +export const CommandEvent = Machine.events( + Schema.TaggedUnion({ Save: {} }) +) +export type PublicCommandEvent = Machine.EventOf +const InternalEvent = Machine.internalEvents( + Schema.TaggedUnion({ + Saved: { id: Schema.String }, + SaveFailed: { message: Schema.String } + }) +) +const Emissions = Machine.emittedEvents( + Schema.TaggedUnion({ + SaveObserved: { id: Schema.String } + }) +) + +const definition = Machine.make({ + states: States.states, + events: CommandEvent, + internalEvents: InternalEvent, + emittedEvents: Emissions, + initial: (to) => to.Idle() +}) +``` + +Handlers see both protocols. Typed `send` and `Machine.plan` accept only public +events. Event tags must be unique and public/internal tags must be disjoint. + +Export the descriptor returned by `Machine.events` instead of exporting its +schemas. This keeps the deferred constructors as the standard way to create +events without exposing schema `.make` methods: + +```ts +ref.send(CommandEvent.Save()) +enqueue.raise(InternalEvent.Saved({ id: "entry-1" })) +enqueue.emit(Emissions.SaveObserved({ id: "entry-1" })) +``` + +The returned constructors preserve each schema's make input, including required +fields and constructor defaults. They defer schema construction until delivery, +so invalid values fail planning or the running machine with +`MachineSchemaDecodeError` instead of throwing at the call site. +Schemas with an open discriminator such as `_tag: Schema.String` remain valid +protocols but cannot expose a finite constructor set; pass a complete event +object to `send` or `Machine.plan` for those events. + +`ref.emissions` is a hot `Stream`: it publishes only notifications produced +after subscription, replays nothing, and completes when the machine terminates. +Snapshots remain separate and stateful: `ref.changes` begins with the current +lifecycle snapshot and then follows later changes. Use `Machine.prepare` when +an observer must be installed before initial-entry actions run: + +```ts +const prepared = yield * Machine.prepare(machine) + +yield * prepared.emissions.pipe( + Stream.runForEach(handleEmission), + Effect.forkScoped({ startImmediately: true }) +) + +const ref = yield * prepared.start +``` + +`Machine.start(machine)` remains the one-step convenience for callers that do +not observe startup emissions. Preparation does not retain or replay an +emission: the observer is simply subscribed before initialization begins. + +### Inspect a live machine tree + +`Machine.prepare(machine).inspection` is the operational counterpart to the +domain-facing `changes` and `emissions` streams. It observes the prepared root +and every locally owned child, `Logic` process, Effect, and timer in one total +publication order: + +```ts +const prepared = yield * Machine.prepare(checkout) + +yield * prepared.inspection.pipe( + Stream.runForEach((record) => Console.log(record.sequence, record.subject.id, record._tag)), + Effect.forkScoped({ startImmediately: true }) +) + +const checkoutRef = yield * prepared.start +``` + +For a handled input, the stream may expose values such as: + +```ts +{ _tag: "EventSent", sequence: 2, deliveryId: 0, + subject: { id: "checkout", sessionId: "machine:0", kind: "Machine" }, + source: undefined, target: { id: "checkout", sessionId: "machine:0" }, + event: CheckoutEvents.Submit(), causedBy: undefined } + +{ _tag: "EventProcessed", sequence: 4, macrostepId: 0, + deliveryId: 0, handled: true, configurationChanged: true, + before: { status: "active", state: /* ... */ }, + after: { status: "active", state: /* ... */ }, microsteps: [/* ... */] } +``` + +The closed `Machine.Inspection.Event` union also reports creation, +initialization and startup failure, direct `Logic` state updates, outward +emissions, Effect/timer activity lifecycles, and termination. Records erase +unrelated child protocols to `unknown`; application-level observation remains +typed through each reference's `changes` and `emissions`. + +The stream is hot, non-replayed, never fails, and completes after the root +terminates. Subscribe before `prepared.start` to capture initialization. Local +session ids are unique only inside that prepared ownership tree: `machine:0` +is the root and later ids identify its descendants. They are intentionally not +distributed identities. Cluster placement, routing, and correlation continue +to use Cluster entity, runner, and request identities at the integration +boundary. + +`AtomMachine.inspection(machineAtom)` provides the same root-scoped stream and +starts a fresh atom-backed machine only after its inspection subscription is +installed. + +Invalid event and emission constructions fail the machine with a typed +`MachineSchemaDecodeError`; they do not throw from the constructor call. + +### Send explicitly between machines + +`raise` targets the current machine in the same macrostep. `sendTo` targets a +machine mailbox and is processed later. A machine that requires an owner +declares the subset of parent inputs it may send with `Machine.parent`: + +```ts +const ParentEvents = Machine.events(ChildFinished) + +const child = Machine.make({ + states: ChildStates.states, + events: ChildEvents, + parent: Machine.parent(ParentEvents), + initial: (to) => to.Working() +}).handle({ + Working: { + on: { + Finish: (to) => + to.full.Done().resolve(({ parent, target }, enqueue) => { + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) + return target.from() + }) + } + }, + Done: {} +}) + +const Child = Machine.child("worker", child) +const ParentInputs = Machine.events(Start, ParentEvents) +``` + +`parent` is statically present in every child callback, and root APIs such as +`Machine.start`, `Machine.planInitial`, Atom machines, and Cluster machines +reject this machine. When `Child` is invoked, the parent definition must accept +every declared parent event; otherwise `.handle(...)` is a compile-time error. +Inside the child, the parent target accepts only those declared events. + +Use `parent: Machine.optionalParent(ParentEvents)` when the same machine is +intentionally valid both as a root and as a child. In that case `parent` is +`MachineTarget<...> | undefined` and must be narrowed before sending. When no +parent declaration is present, callbacks do not expose a `parent` property. +`emit` never sends to the parent: it only publishes on the emitting machine's +`emissions` stream. + +Every handler also receives `self`, which can be targeted with `sendTo` when a +later mailbox turn is required. Use `raise` instead for same-macrostep work. +Both `self` and `parent` are minimal `Machine.MachineTarget` values. The +shared `Machine.MachineReferences` context keeps +their input protocols separate without exposing snapshot or lifecycle APIs. +Structural state values use distinct names: `containingState` is the immediate +valued state in the same statechart, while `ancestors` maps valued ancestor +paths. `parent` always means the owning machine target. + +### Choose the target by scope + +| Builder | Use when | Preserves | +| ---------------- | ---------------------------------------- | ------------------------------------------------- | +| `target.none()` | Handling without selecting a destination | The complete current configuration | +| `target.local` | Moving inside the nearest compound scope | Ancestors and unrelated parallel regions | +| `target.branch` | Moving elsewhere under the active root | Omitted active ancestors and parallel regions | +| `target.full` | Replacing or selecting a complete root | Nothing implicit for a newly selected root | +| `target.history` | Restoring a declared history node | The remembered configuration or its typed default | + +Every required transition handler selects a target from its inline `to` +builder. Return a bare selection such as `to.full.Idle()` when the selected +builder supports zero-argument construction; the machine applies the same +default construction as `target.from()`. This includes empty schemas and +schemas whose constructor fields are all optional or defaulted. TypeScript +rejects the bare form when state data or nested configuration is required. +Call `.resolve(...)` when construction depends on handler context or the +transition needs to enqueue commands. An absent handler ignores the trigger; `to.none` handles +it and retains queued commands, raised events, and emitted events without +selecting a destination. Concrete destinations stay narrowed inside their +resolver, and `to.branches({...})` gives the resolver only the declared named +`select` builders. Builders describe the +next logical configuration. Shared states exit and enter only when paths +change; call `.reenter()` for resolver-free reentry or pass `{ reenter: true }` +to `.resolve(...)` when the source must restart. With `to.none`, reentry +restarts the source while retaining its configuration. + +Topology-only definition instructions are values: `to.none`, declared +`.initial` and history selections, and `to.local.with`. Concrete state and +choice destinations remain calls such as `to.full.Running()`. Runtime named +branch builders remain callable, including `select.unchanged()`, because their +result carries the selected branch evidence. + +### Update an active scope value + +Use `to.local.update(...)` to replace the value owned by the nearest active +compound scope without rebuilding its active child. Use +`to.branch..update(...)` for a valued compound or parallel ancestor of the +handler source: + +```ts +const handlers = { + Increment: (to) => to.branch.root.session.update(({ current, owner }) => owner.from({ count: current.count + 1 })) +} +``` + +The update keeps the exact active descendants, their values, history records, +completion outputs, and unrelated parallel regions. It runs no exit or entry +actions and does not restart state-owned work. Eventless stabilization still +runs, so an `always` transition can react to the new value. + +The plain update method remains useful when topology does not change. It is +also a static selection for a named branch: + +```ts +to.branches({ + changed: { target: to.local.update }, + unchanged: { target: to.none } +}).resolve(({ select, event }) => + event.changed + ? select.changed.from({ count: event.count }) + : select.unchanged() +) +``` + +### Change topology and a retained owner together + +When a transition enters another child and also replaces a valued ancestor +that stays active, declare both operations on the same target: + +```ts +const handlers = { + CreatePlan: (to) => + to.local.SavingPlan() + .updating(to.branch.Ready) + .resolve(({ current, event, owner, target }) => + target.from({ + request: { _tag: "Create", input: event.input } + }).update( + owner.decoded(new Ready({ ...current, notice: null })) + ) + ) +} +``` + +`to.local.SavingPlan()` selects topology. `.updating(to.branch.Ready)` names +the retained valued owner and makes its replacement mandatory: the resolver +does not type-check unless destination construction finishes with +`.update(...)`. `current` is that owner's decoded value from the +pre-transition snapshot. `target` constructs the destination; `owner` +constructs the complete replacement owner value. + +The topology change and owner replacement apply atomically in one microstep. +The owner does not exit or reenter, its work is not restarted, and destination +entry actions observe the new owner value. Eventless stabilization follows. +Only one retained owner may be replaced by a combined target. A `full` target, +or any target that exits the selected owner, does not expose `.updating`. +Combined updates use a direct resolver in this release; named branches continue +to support value-only updates. + +For a schema-less destination, construction remains explicit: + +```ts +to.local.Idle() + .updating(to.branch.Ready) + .resolve(({ current, output, owner, target }) => + target.from().update( + owner.decoded( + new Ready({ + ...current, + day: output, + notice: "Plan changed." + }) + ) + ) + ) +``` + +Both values derive from the same pre-transition snapshot and are validated +before lifecycle actions run. Competing transitions that write the same owner +conflict; document order and hierarchy select one writer rather than applying +last-write-wins behavior. + +The resolver must return `target.decoded(value)` or `target.from(input)`. It +may return `decline()` only with `{ declinable: true }`. Pass `{ reenter: true }` +on event or invocation transitions when the handler source should exit and +enter again. Reentry applies to that source, not to the ancestor whose value +changed. + +The selector omits `update` for schema-less scopes, atomic and final states, +inactive branches, parallel sibling regions, and choice resolvers. Updating a +parallel sibling requires an event handled by that region. + +Use `declinable: true` when a resolver may decide that its transition is not +enabled. Only that resolver receives `decline()`, and its return type expands to +accept the opaque declined result: + +```ts +const handlers = { + Submit: (to) => + to.local.Saving().resolve( + ({ event, target, decline }) => accepts(event) ? target.from({ draft: event.draft }) : decline(), + { declinable: true } + ) +} +``` + +Declining discards work enqueued by that resolver. Event and eventless dispatch +continues with the next eligible ancestor; if no candidate accepts, no +transition is selected. This differs from `target.none()`, which consumes the +trigger and prevents an ancestor from handling it. `transitionDefinitions` +reports each handler's `acceptance` as `"required"` or `"declinable"` while +preserving the exact declared target branches. Choices and initial routing must +remain total and cannot use declinable transitions. Completion and invocation +outcomes have no ancestor candidate: declining one ignores that lifecycle +occurrence and leaves the current configuration active. + +## Statechart capabilities + +`Machine.states` supports: + +- atomic states; +- compound states with one active child; +- parallel states with one active state in every region; +- final states and typed outputs; +- transient choice states; +- shallow and deep history states. + +Declare topology—including finality, output schemas, choices, and history—only +in `states`. Handlers implement behavior and output computation without +repeating structural metadata. Final children complete their parent, so +`onDone` belongs on that compound or parallel parent. + +Transition, entry, exit, choice, initial, and history callbacks are +synchronous. Conditions use ordinary TypeScript control flow. Callbacks may +select state and enqueue explicit `raise`, `emit`, `sendTo`, or `stop` commands; +arbitrary asynchronous Effects do not run inside planning. + +## Effects, Streams, timers, and child machines + +State-scoped work starts on entry and is interrupted on exit: + +```ts +machine.handle({ + Loading: { + invoke: (from) => + from.effect("save-document", () => saveDocument) + .onDone((to) => to.full.Saved().resolve(({ output, target }) => target.from({ id: output.id }))) + .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: String(error) }))) + }, + Waiting: { + invoke: (from) => + from.timer("save-timeout", "3 seconds") + .onDone((to) => to.full.Failed().resolve(({ target }) => target.from({ message: "Timed out" }))) + } +}) +``` + +The state-local `from` selector starts an `effect`, `stream`, `timer`, reusable +`logic`, or complete `child` statechart. The selected source determines which +lifecycle methods the chain requires and which methods are available. For +example, an Effect with non-`never` output and error channels must handle both; +the completed chain is the value returned by the callback: + +```ts +machine.handle({ + Loading: { + invoke: (from) => + from.effect("load-document", ({ state }) => loadDocument(state.documentId)) + .onDone((to) => to.full.Ready().resolve(({ output, target }) => target.from({ document: output }))) + .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ message: error.message }))) + } +}) +``` + +A Stream source remains independent of the parent event protocol. Each element +is mapped by `onElement`, and the next element is not pulled until that parent +macrostep commits: + +```ts +machine.handle({ + Listening: { + invoke: (from) => + from.stream("channel", () => channelMessages) + .onElement((to) => + to.none.resolve(({ element }, enqueue) => { + enqueue.raise(Events.MessageReceived({ message: element })) + }) + ) + .onDone((to) => to.none) + .onFailure((to) => to.full.Failed().resolve(({ error, target }) => target.from({ error }))) + } +}) +``` + +`to.none` is the targetless transition value. Return it directly to keep the +current configuration, or call `.resolve(...)` when the transition only needs +to enqueue commands. A block resolver may omit its return because it is +contextually typed to return `undefined`. + +Inside `.handle(...)`, `from` receives the owning machine's public input and +declared parent protocol contextually. Source and lifecycle callbacks can send +through `self` and `parent` while retaining the invoked Effect's output and +error inference: + +```ts +const machine = Machine.make({ + events: Commands, + internalEvents: InternalEvents, + parent: Machine.parent(ParentEvents) + // ... +}).handle({ + Saving: { + invoke: (from) => + from.effect("notify-parent", () => saveDocument) + .onDone((to) => + to.none.resolve(({ parent, self }, enqueue) => { + enqueue.sendTo(self, Commands.Save()) + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) + }) + ) + .onFailure((to) => to.none) + } +}) +``` + +Return an array of completed chains to compose multiple state-owned activities. +The source computation itself, process logic, or `Machine.child(id, machine)` +descriptor can be named and reused; the invocation chain stays local so its +transitions retain the exact owning state and machine protocols. + +```ts +const refreshCache = Cache.refresh + +machine.handle({ + Active: { + invoke: (from) => [ + from.effect("refresh-cache", () => refreshCache).onDone((to) => to.none).onFailure((to) => to.none), + from.timer("expire-session", "5 minutes").onDone((to) => to.full.Expired()) + ] + } +}) +``` + +`onDone` is required for a non-`never` output, and `onFailure` is required for a +non-`never` typed error. Streams additionally require `onElement` when their +element channel is non-`never` and always require `onDone`; logic and child +chains optionally expose `onSnapshot`. A handled method disappears from the +next builder step, so every reachable lifecycle channel is handled exactly +once. Defects, interruption, and source-construction failures terminate the +owning runtime. Effect sources are factories evaluated when their state is +entered. Use an Effect containing `Effect.sleep(...)` for generic work, while +`from.timer(...)` keeps timer intent explicit and makes static durations visible +through activity inspection. + +### Spawn dynamic child machines + +Use `from.child(...)` when a state owns a fixed child lifecycle. Use the +`children` context inside an invoked Effect when the machine process owns an +open set of children that must survive state changes: + +```ts +const Plant = Machine.childFamily(plantMachine) + +const central = Machine.make({ + events: Machine.events(ResourcesOffered, PlantBroken) + // ... +}).handle({ + Commissioning: { + invoke: (from) => + from.effect("commission-wave", ({ children, state }) => + Effect.forEach( + state.plants, + (input) => children.spawn(Plant(input.id), { input }), + { discard: true } + )) + .onDone((to) => to.full.Operating()) + .onFailure((to) => to.full.CommissioningFailed()) + } +}) +``` + +`children.spawn` completes after initialization. The new child remains owned +by the machine process after the commissioning Effect completes or its state +exits. `children.sendTo` and `children.stop` address one active child from an +Effect; transition resolvers use `enqueue.sendTo` and `enqueue.stop` with the +same descriptor. Duplicate active ids fail with `ChildAlreadyExistsError` and +do not replace the existing child. Earlier successful spawns remain active if +a later spawn in the same wave fails. + +The child machine's declared `Machine.parent(...)` events must be accepted by +the owner. This is checked at each spawn call even though ids and cardinality +remain dynamic. `scope.spawn(child, { input })` provides the same descriptor +form for lower-level process logic, where the process event protocol is known. + +## Reactivity + +`AtomMachine` runs one lazy machine instance per `AtomRegistry`: + +```ts +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { Atom } from "effect/unstable/reactivity" + +const runtime = Atom.runtime(AppLayer) +const counterAtom = AtomMachine.bind(runtime).make(Counter) +``` + +Binding a shared runtime once is the canonical form for service-backed +applications. Service-free machines can use `AtomMachine.make(Counter)`. + +The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable +`send` and `stop` atoms, and `child(descriptor)`. Use `AtomMachine.select`, +`AtomMachine.selectSnapshot`, and `AtomMachine.matches` for typed, +equality-aware derivations. React applications using `@effect/atom-react` need +a `RegistryProvider`. + +Descriptors reconstructed from a `Machine.childFamily` resolve the same child +bridge by machine identity and id: + +```ts +const Plant = Machine.childFamily(plantMachine) +const plantAtom = centralAtom.child(Plant(selectedPlantId)) +const brokenAtom = AtomMachine.matchesChild(plantAtom, "Broken") +``` + +Emissions stay streams rather than becoming retained atom state: + +```ts +const rootEmissions = AtomMachine.emissions(counterAtom) +const childEmissions = AtomMachine.childEmissions(counterAtom.child(Worker)) +``` + +These streams require the same `AtomRegistry`, follow the currently mounted +machine instance, and do not replay notifications from an earlier subscription +or child instance. + +## Persistence + +Logical snapshots can be validated for storage or transport: + +```ts +const encoded = yield * Machine.encodeSnapshot(machine, snapshot) +const decoded = yield * Machine.decodeSnapshot(machine, encoded) +const ref = yield * Machine.resume(machine, decoded) +``` + +Decoded snapshots are local runtime values and may contain class instances or +other process-local data. `encodeSnapshot` is the persistence boundary: it uses +each declared schema's canonical JSON codec and succeeds only when every active +state value, completion output, and history value is JSON. Rich values such as +dates and bigints use their schema-defined JSON representation; cyclic or +non-JSON values fail with `MachineSchemaEncodeError` instead of escaping to a +later `JSON.stringify` crash. + +Resumption restores logical state, values, completion, and history metadata. +It creates a fresh runtime: active invokes restart, timers restart at their +full duration, and prior fibers, subscriptions, queues, and child runtimes are +not restored. Store machine identity and migration/version metadata beside the +encoded snapshot. + +## Testing + +The testing entrypoint provides complementary layers: + +- `MachineTest.run` and `verify` inspect pure planner traces; +- `coverage` reports exact transition-definition and conditional-branch hits; +- invariants and generated scenarios check application laws; +- `explore` performs bounded breadth-first state-space exploration and retains + exact transition-definition and branch coverage for every plan it computes; +- `probe` causally acknowledges live runtime commands; +- runtime command models cover timers, invokes, bursts, and scheduling. + +```ts +import { MachineTest } from "@typeonce/effect-machine/testing" + +const trace = yield* MachineTest.run(Counter, { + events: [ + { _tag: "Start" }, + { _tag: "Increment" } + ] +}) + +yield* MachineTest.verify(Counter, trace) +``` + +`MachineTest` scenarios retain decoded event values for model inspection, so +pass complete decoded objects when defining scenarios manually. Pure planner +tests do not execute invokes or time. Use a started machine and a probe when +those semantics matter. + +## Entrypoints + +```ts +import { Machine } from "@typeonce/effect-machine" +import { ClusterMachine } from "@typeonce/effect-machine/cluster" +import { AtomMachine } from "@typeonce/effect-machine/reactivity" +import { MachineTest } from "@typeonce/effect-machine/testing" +``` + +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) +- [Contributing guide](./CONTRIBUTING.md) + +Use pnpm 10 and Node.js 20 or newer: + +```sh +pnpm install --frozen-lockfile +pnpm check +``` + +Declarative first-class guards are not currently part of the API; use ordinary +TypeScript conditions. Pull requests that change `src/` or `package.json` need +a changeset and the performance checks described in `AGENTS.md`. + +When equivalent Machine modules ship in Effect, this package is intended to +become a compatibility re-export before eventual retirement. diff --git a/docs/agent-guide.md b/packages/effect-machine/docs/agent-guide.md similarity index 100% rename from docs/agent-guide.md rename to packages/effect-machine/docs/agent-guide.md diff --git a/docs/effect-atom-react.md b/packages/effect-machine/docs/effect-atom-react.md similarity index 100% rename from docs/effect-atom-react.md rename to packages/effect-machine/docs/effect-atom-react.md diff --git a/packages/effect-machine/package.json b/packages/effect-machine/package.json new file mode 100644 index 0000000..1485df6 --- /dev/null +++ b/packages/effect-machine/package.json @@ -0,0 +1,87 @@ +{ + "name": "@typeonce/effect-machine", + "version": "0.22.0", + "description": "Schema-first state machines and statecharts for Effect", + "author": "Sandro Maglione", + "repository": { + "type": "git", + "url": "git+https://github.com/typeonce-dev/effect-machine.git" + }, + "bugs": { + "url": "https://github.com/typeonce-dev/effect-machine/issues" + }, + "homepage": "https://github.com/typeonce-dev/effect-machine#readme", + "type": "module", + "license": "MIT", + "sideEffects": false, + "files": [ + "src/**/*.ts", + "dist", + "docs", + "README.md", + "LICENSE", + "NOTICE" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./reactivity": { + "types": "./src/unstable/reactivity/index.ts", + "import": "./src/unstable/reactivity/index.ts" + }, + "./cluster": { + "types": "./src/unstable/cluster/index.ts", + "import": "./src/unstable/cluster/index.ts" + }, + "./testing": { + "types": "./src/testing/index.ts", + "import": "./src/testing/index.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./reactivity": { + "types": "./dist/unstable/reactivity/index.d.ts", + "import": "./dist/unstable/reactivity/index.js" + }, + "./cluster": { + "types": "./dist/unstable/cluster/index.d.ts", + "import": "./dist/unstable/cluster/index.js" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js" + }, + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "tsc -b tsconfig.build.json", + "check": "tsc -b tsconfig.json", + "test:types": "tstyche" + }, + "peerDependencies": { + "effect": "4.0.0-rc.111" + }, + "devDependencies": { + "@effect/vitest": "4.0.0-rc.111", + "@types/node": "25.7.0", + "effect": "4.0.0-rc.111", + "tinybench": "2.9.0", + "tstyche": "7.2.1", + "typescript": "6.0.3", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=20" + } +} diff --git a/perf/runtime/README.md b/packages/effect-machine/perf/runtime/README.md similarity index 100% rename from perf/runtime/README.md rename to packages/effect-machine/perf/runtime/README.md diff --git a/perf/runtime/counter.mjs b/packages/effect-machine/perf/runtime/counter.mjs similarity index 99% rename from perf/runtime/counter.mjs rename to packages/effect-machine/perf/runtime/counter.mjs index f9f39d2..4c6c26b 100644 --- a/perf/runtime/counter.mjs +++ b/packages/effect-machine/perf/runtime/counter.mjs @@ -4,9 +4,12 @@ import { dirname, join, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import { makeEffectMachineBenchmarkApi } from "./effect-machine-compatibility.mjs" -const implementationRoot = resolve( +const configuredRoot = resolve( process.env.EFFECT_MACHINE_BENCHMARK_ROOT ?? fileURLToPath(new URL("../..", import.meta.url)) ) +const implementationRoot = existsSync(join(configuredRoot, "packages", "effect-machine", "package.json")) + ? join(configuredRoot, "packages", "effect-machine") + : configuredRoot const implementationRequire = createRequire(pathToFileURL(join(implementationRoot, "package.json"))) const effectPackagePath = implementationRequire.resolve("effect/package.json") const effectPackage = JSON.parse(readFileSync(effectPackagePath, "utf8")) diff --git a/perf/runtime/effect-machine-compatibility.mjs b/packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs similarity index 100% rename from perf/runtime/effect-machine-compatibility.mjs rename to packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs diff --git a/perf/runtime/implementations.mjs b/packages/effect-machine/perf/runtime/implementations.mjs similarity index 74% rename from perf/runtime/implementations.mjs rename to packages/effect-machine/perf/runtime/implementations.mjs index 8b52080..de26a23 100644 --- a/perf/runtime/implementations.mjs +++ b/packages/effect-machine/perf/runtime/implementations.mjs @@ -1,12 +1,15 @@ -import { readFileSync } from "node:fs" -import { resolve } from "node:path" +import { existsSync, readFileSync } from "node:fs" +import { join, resolve } from "node:path" import { fileURLToPath } from "node:url" import { effectMachineAdapter } from "./counter.mjs" const readPackageVersion = (path) => JSON.parse(readFileSync(path, "utf8")).version -const implementationRoot = resolve( +const configuredRoot = resolve( process.env.EFFECT_MACHINE_BENCHMARK_ROOT ?? fileURLToPath(new URL("../..", import.meta.url)) ) +const implementationRoot = existsSync(join(configuredRoot, "packages", "effect-machine", "package.json")) + ? join(configuredRoot, "packages", "effect-machine") + : configuredRoot export const packageVersions = { effectMachine: readPackageVersion(resolve(implementationRoot, "package.json")), diff --git a/perf/runtime/memory-worker.mjs b/packages/effect-machine/perf/runtime/memory-worker.mjs similarity index 100% rename from perf/runtime/memory-worker.mjs rename to packages/effect-machine/perf/runtime/memory-worker.mjs diff --git a/perf/types/adapter-readiness-control.ts b/packages/effect-machine/perf/types/adapter-readiness-control.ts similarity index 95% rename from perf/types/adapter-readiness-control.ts rename to packages/effect-machine/perf/types/adapter-readiness-control.ts index bca06f2..d882dcd 100644 --- a/perf/types/adapter-readiness-control.ts +++ b/packages/effect-machine/perf/types/adapter-readiness-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const Flow = Schema.TaggedStruct("Flow", {}) export const Idle = Schema.TaggedStruct("Idle", {}) diff --git a/perf/types/adapter-readiness.ts b/packages/effect-machine/perf/types/adapter-readiness.ts similarity index 88% rename from perf/types/adapter-readiness.ts rename to packages/effect-machine/perf/types/adapter-readiness.ts index f1bf983..078419b 100644 --- a/perf/types/adapter-readiness.ts +++ b/packages/effect-machine/perf/types/adapter-readiness.ts @@ -1,7 +1,7 @@ -import { Machine } from "@typeonce/effect-machine" -import { ClusterMachine } from "@typeonce/effect-machine/cluster" -import { AtomMachine } from "@typeonce/effect-machine/reactivity" import { Atom } from "effect/unstable/reactivity" +import { Machine } from "../../dist/index.js" +import { ClusterMachine } from "../../dist/unstable/cluster/index.js" +import { AtomMachine } from "../../dist/unstable/reactivity/index.js" import { machine, snapshot } from "./adapter-readiness-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? diff --git a/perf/types/composition-control.ts b/packages/effect-machine/perf/types/composition-control.ts similarity index 97% rename from perf/types/composition-control.ts rename to packages/effect-machine/perf/types/composition-control.ts index 60fd7aa..f56a84b 100644 --- a/perf/types/composition-control.ts +++ b/packages/effect-machine/perf/types/composition-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const App = Schema.TaggedStruct("App", {}) export const Workspace = Schema.TaggedStruct("Workspace", {}) diff --git a/perf/types/composition.ts b/packages/effect-machine/perf/types/composition.ts similarity index 98% rename from perf/types/composition.ts rename to packages/effect-machine/perf/types/composition.ts index a4e0e84..a2a6928 100644 --- a/perf/types/composition.ts +++ b/packages/effect-machine/perf/types/composition.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Context, Data, Effect } from "effect" +import { Machine } from "../../dist/index.js" import { App, Editing, diff --git a/perf/types/define-states.ts b/packages/effect-machine/perf/types/define-states.ts similarity index 78% rename from perf/types/define-states.ts rename to packages/effect-machine/perf/types/define-states.ts index aab2c6e..6059783 100644 --- a/perf/types/define-states.ts +++ b/packages/effect-machine/perf/types/define-states.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/definition-variants-control.ts b/packages/effect-machine/perf/types/definition-variants-control.ts similarity index 95% rename from perf/types/definition-variants-control.ts rename to packages/effect-machine/perf/types/definition-variants-control.ts index b4f4095..f2ed2ce 100644 --- a/perf/types/definition-variants-control.ts +++ b/packages/effect-machine/perf/types/definition-variants-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const Flow = Schema.TaggedStruct("Flow", {}) export const Idle = Schema.TaggedStruct("Idle", {}) diff --git a/perf/types/definition-variants.ts b/packages/effect-machine/perf/types/definition-variants.ts similarity index 97% rename from perf/types/definition-variants.ts rename to packages/effect-machine/perf/types/definition-variants.ts index a1b7ac5..8f8f53c 100644 --- a/perf/types/definition-variants.ts +++ b/packages/effect-machine/perf/types/definition-variants.ts @@ -1,4 +1,4 @@ -import { Machine } from "@typeonce/effect-machine" +import { Machine } from "../../dist/index.js" import { Done, Flow, Idle, machine, Running } from "./definition-variants-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? diff --git a/perf/types/dynamic-invoke-control.ts b/packages/effect-machine/perf/types/dynamic-invoke-control.ts similarity index 91% rename from perf/types/dynamic-invoke-control.ts rename to packages/effect-machine/perf/types/dynamic-invoke-control.ts index 4f2ebf5..3553b34 100644 --- a/perf/types/dynamic-invoke-control.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Effect, Schema } from "effect" +import { Machine } from "../../dist/index.js" export class LoadError { readonly _tag = "LoadError" diff --git a/perf/types/dynamic-invoke.ts b/packages/effect-machine/perf/types/dynamic-invoke.ts similarity index 92% rename from perf/types/dynamic-invoke.ts rename to packages/effect-machine/perf/types/dynamic-invoke.ts index 5dc793f..fd1d74d 100644 --- a/perf/types/dynamic-invoke.ts +++ b/packages/effect-machine/perf/types/dynamic-invoke.ts @@ -1,4 +1,4 @@ -import { Machine } from "@typeonce/effect-machine" +import { Machine } from "../../dist/index.js" import { LoadError, loadUser, machine } from "./dynamic-invoke-control.js" interface User { diff --git a/perf/types/effect-only.ts b/packages/effect-machine/perf/types/effect-only.ts similarity index 100% rename from perf/types/effect-only.ts rename to packages/effect-machine/perf/types/effect-only.ts diff --git a/perf/types/exact-channels-control.ts b/packages/effect-machine/perf/types/exact-channels-control.ts similarity index 94% rename from perf/types/exact-channels-control.ts rename to packages/effect-machine/perf/types/exact-channels-control.ts index deff5fb..d6af7d7 100644 --- a/perf/types/exact-channels-control.ts +++ b/packages/effect-machine/perf/types/exact-channels-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const Idle = Schema.TaggedStruct("Idle", { value: Schema.Number }) export const Done = Schema.TaggedStruct("Done", { value: Schema.String }) diff --git a/perf/types/exact-channels.ts b/packages/effect-machine/perf/types/exact-channels.ts similarity index 98% rename from perf/types/exact-channels.ts rename to packages/effect-machine/perf/types/exact-channels.ts index ecd59a7..28d2209 100644 --- a/perf/types/exact-channels.ts +++ b/packages/effect-machine/perf/types/exact-channels.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Effect } from "effect" +import { Machine } from "../../dist/index.js" import { Done, Loaded, machine, Notice, Start, States } from "./exact-channels-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? diff --git a/perf/types/handle-depth-24-control.ts b/packages/effect-machine/perf/types/handle-depth-24-control.ts similarity index 99% rename from perf/types/handle-depth-24-control.ts rename to packages/effect-machine/perf/types/handle-depth-24-control.ts index ae08dec..3f5bb11 100644 --- a/perf/types/handle-depth-24-control.ts +++ b/packages/effect-machine/perf/types/handle-depth-24-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) diff --git a/perf/types/handle-depth-24.ts b/packages/effect-machine/perf/types/handle-depth-24.ts similarity index 99% rename from perf/types/handle-depth-24.ts rename to packages/effect-machine/perf/types/handle-depth-24.ts index cb8cc7d..570dec3 100644 --- a/perf/types/handle-depth-24.ts +++ b/packages/effect-machine/perf/types/handle-depth-24.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Context, Data, Effect } from "effect" +import { Machine } from "../../dist/index.js" import { machine } from "./handle-depth-24-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? diff --git a/perf/types/handle-depth-wide-16-control.ts b/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts similarity index 99% rename from perf/types/handle-depth-wide-16-control.ts rename to packages/effect-machine/perf/types/handle-depth-wide-16-control.ts index 95f85f8..d9e9d7d 100644 --- a/perf/types/handle-depth-wide-16-control.ts +++ b/packages/effect-machine/perf/types/handle-depth-wide-16-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const NodeState = Schema.TaggedStruct("Node", {}) diff --git a/perf/types/handle-depth-wide-16.ts b/packages/effect-machine/perf/types/handle-depth-wide-16.ts similarity index 99% rename from perf/types/handle-depth-wide-16.ts rename to packages/effect-machine/perf/types/handle-depth-wide-16.ts index 5f7fe0d..7f68264 100644 --- a/perf/types/handle-depth-wide-16.ts +++ b/packages/effect-machine/perf/types/handle-depth-wide-16.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Context, Data, Effect } from "effect" +import { Machine } from "../../dist/index.js" import { machine } from "./handle-depth-wide-16-control.js" type Equal = (() => Type extends Left ? 1 : 2) extends (() => Type extends Right ? 1 : 2) ? diff --git a/perf/types/handle.ts b/packages/effect-machine/perf/types/handle.ts similarity index 94% rename from perf/types/handle.ts rename to packages/effect-machine/perf/types/handle.ts index 2ceb7b3..09d4ffb 100644 --- a/perf/types/handle.ts +++ b/packages/effect-machine/perf/types/handle.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/import-only.ts b/packages/effect-machine/perf/types/import-only.ts similarity index 75% rename from perf/types/import-only.ts rename to packages/effect-machine/perf/types/import-only.ts index ed4b547..84b6ac2 100644 --- a/perf/types/import-only.ts +++ b/packages/effect-machine/perf/types/import-only.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/make-control.ts b/packages/effect-machine/perf/types/make-control.ts similarity index 84% rename from perf/types/make-control.ts rename to packages/effect-machine/perf/types/make-control.ts index 6664e2f..b575b11 100644 --- a/perf/types/make-control.ts +++ b/packages/effect-machine/perf/types/make-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/make.ts b/packages/effect-machine/perf/types/make.ts similarity index 90% rename from perf/types/make.ts rename to packages/effect-machine/perf/types/make.ts index 7e7a3b4..8014de5 100644 --- a/perf/types/make.ts +++ b/packages/effect-machine/perf/types/make.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/named-branches-control.ts b/packages/effect-machine/perf/types/named-branches-control.ts similarity index 90% rename from perf/types/named-branches-control.ts rename to packages/effect-machine/perf/types/named-branches-control.ts index 3904d64..7ce4ea5 100644 --- a/perf/types/named-branches-control.ts +++ b/packages/effect-machine/perf/types/named-branches-control.ts @@ -1,5 +1,5 @@ -import { Machine } from "@typeonce/effect-machine" import { Schema } from "effect" +import { Machine } from "../../dist/index.js" export const State = Schema.TaggedUnion({ Idle: {}, diff --git a/perf/types/named-branches.ts b/packages/effect-machine/perf/types/named-branches.ts similarity index 97% rename from perf/types/named-branches.ts rename to packages/effect-machine/perf/types/named-branches.ts index 3ff2db1..565459f 100644 --- a/perf/types/named-branches.ts +++ b/packages/effect-machine/perf/types/named-branches.ts @@ -1,4 +1,4 @@ -import { Machine } from "@typeonce/effect-machine" +import { Machine } from "../../dist/index.js" import { machine, State } from "./named-branches-control.js" const handled = machine.handle({ diff --git a/src/Machine.ts b/packages/effect-machine/src/Machine.ts similarity index 100% rename from src/Machine.ts rename to packages/effect-machine/src/Machine.ts diff --git a/src/index.ts b/packages/effect-machine/src/index.ts similarity index 100% rename from src/index.ts rename to packages/effect-machine/src/index.ts diff --git a/src/internal/machine/activities.ts b/packages/effect-machine/src/internal/machine/activities.ts similarity index 100% rename from src/internal/machine/activities.ts rename to packages/effect-machine/src/internal/machine/activities.ts diff --git a/src/internal/machine/atom.ts b/packages/effect-machine/src/internal/machine/atom.ts similarity index 100% rename from src/internal/machine/atom.ts rename to packages/effect-machine/src/internal/machine/atom.ts diff --git a/src/internal/machine/cluster.ts b/packages/effect-machine/src/internal/machine/cluster.ts similarity index 100% rename from src/internal/machine/cluster.ts rename to packages/effect-machine/src/internal/machine/cluster.ts diff --git a/src/internal/machine/command.ts b/packages/effect-machine/src/internal/machine/command.ts similarity index 100% rename from src/internal/machine/command.ts rename to packages/effect-machine/src/internal/machine/command.ts diff --git a/src/internal/machine/commandRuntime.ts b/packages/effect-machine/src/internal/machine/commandRuntime.ts similarity index 100% rename from src/internal/machine/commandRuntime.ts rename to packages/effect-machine/src/internal/machine/commandRuntime.ts diff --git a/src/internal/machine/configuration.ts b/packages/effect-machine/src/internal/machine/configuration.ts similarity index 100% rename from src/internal/machine/configuration.ts rename to packages/effect-machine/src/internal/machine/configuration.ts diff --git a/src/internal/machine/errors.ts b/packages/effect-machine/src/internal/machine/errors.ts similarity index 100% rename from src/internal/machine/errors.ts rename to packages/effect-machine/src/internal/machine/errors.ts diff --git a/src/internal/machine/executionPlan.ts b/packages/effect-machine/src/internal/machine/executionPlan.ts similarity index 100% rename from src/internal/machine/executionPlan.ts rename to packages/effect-machine/src/internal/machine/executionPlan.ts diff --git a/src/internal/machine/initialization.ts b/packages/effect-machine/src/internal/machine/initialization.ts similarity index 100% rename from src/internal/machine/initialization.ts rename to packages/effect-machine/src/internal/machine/initialization.ts diff --git a/src/internal/machine/inspectionRuntime.ts b/packages/effect-machine/src/internal/machine/inspectionRuntime.ts similarity index 100% rename from src/internal/machine/inspectionRuntime.ts rename to packages/effect-machine/src/internal/machine/inspectionRuntime.ts diff --git a/src/internal/machine/invocation.ts b/packages/effect-machine/src/internal/machine/invocation.ts similarity index 100% rename from src/internal/machine/invocation.ts rename to packages/effect-machine/src/internal/machine/invocation.ts diff --git a/src/internal/machine/invocationEvent.ts b/packages/effect-machine/src/internal/machine/invocationEvent.ts similarity index 100% rename from src/internal/machine/invocationEvent.ts rename to packages/effect-machine/src/internal/machine/invocationEvent.ts diff --git a/src/internal/machine/machine.ts b/packages/effect-machine/src/internal/machine/machine.ts similarity index 100% rename from src/internal/machine/machine.ts rename to packages/effect-machine/src/internal/machine/machine.ts diff --git a/src/internal/machine/planner.ts b/packages/effect-machine/src/internal/machine/planner.ts similarity index 100% rename from src/internal/machine/planner.ts rename to packages/effect-machine/src/internal/machine/planner.ts diff --git a/src/internal/machine/process.ts b/packages/effect-machine/src/internal/machine/process.ts similarity index 100% rename from src/internal/machine/process.ts rename to packages/effect-machine/src/internal/machine/process.ts diff --git a/src/internal/machine/protocol.ts b/packages/effect-machine/src/internal/machine/protocol.ts similarity index 100% rename from src/internal/machine/protocol.ts rename to packages/effect-machine/src/internal/machine/protocol.ts diff --git a/src/internal/machine/readiness.ts b/packages/effect-machine/src/internal/machine/readiness.ts similarity index 100% rename from src/internal/machine/readiness.ts rename to packages/effect-machine/src/internal/machine/readiness.ts diff --git a/src/internal/machine/runtime.ts b/packages/effect-machine/src/internal/machine/runtime.ts similarity index 100% rename from src/internal/machine/runtime.ts rename to packages/effect-machine/src/internal/machine/runtime.ts diff --git a/src/internal/machine/serialization.ts b/packages/effect-machine/src/internal/machine/serialization.ts similarity index 100% rename from src/internal/machine/serialization.ts rename to packages/effect-machine/src/internal/machine/serialization.ts diff --git a/src/internal/machine/stateDefinition.ts b/packages/effect-machine/src/internal/machine/stateDefinition.ts similarity index 100% rename from src/internal/machine/stateDefinition.ts rename to packages/effect-machine/src/internal/machine/stateDefinition.ts diff --git a/src/internal/machine/symbols.ts b/packages/effect-machine/src/internal/machine/symbols.ts similarity index 100% rename from src/internal/machine/symbols.ts rename to packages/effect-machine/src/internal/machine/symbols.ts diff --git a/src/internal/machine/topology.ts b/packages/effect-machine/src/internal/machine/topology.ts similarity index 100% rename from src/internal/machine/topology.ts rename to packages/effect-machine/src/internal/machine/topology.ts diff --git a/src/internal/testing/machine/arbitrary.ts b/packages/effect-machine/src/internal/testing/machine/arbitrary.ts similarity index 100% rename from src/internal/testing/machine/arbitrary.ts rename to packages/effect-machine/src/internal/testing/machine/arbitrary.ts diff --git a/src/internal/testing/machine/exploration.ts b/packages/effect-machine/src/internal/testing/machine/exploration.ts similarity index 100% rename from src/internal/testing/machine/exploration.ts rename to packages/effect-machine/src/internal/testing/machine/exploration.ts diff --git a/src/internal/testing/machine/finiteModel.ts b/packages/effect-machine/src/internal/testing/machine/finiteModel.ts similarity index 100% rename from src/internal/testing/machine/finiteModel.ts rename to packages/effect-machine/src/internal/testing/machine/finiteModel.ts diff --git a/src/internal/testing/machine/invariant.ts b/packages/effect-machine/src/internal/testing/machine/invariant.ts similarity index 100% rename from src/internal/testing/machine/invariant.ts rename to packages/effect-machine/src/internal/testing/machine/invariant.ts diff --git a/src/internal/testing/machine/probe.ts b/packages/effect-machine/src/internal/testing/machine/probe.ts similarity index 100% rename from src/internal/testing/machine/probe.ts rename to packages/effect-machine/src/internal/testing/machine/probe.ts diff --git a/src/internal/testing/machine/referenceModel.ts b/packages/effect-machine/src/internal/testing/machine/referenceModel.ts similarity index 100% rename from src/internal/testing/machine/referenceModel.ts rename to packages/effect-machine/src/internal/testing/machine/referenceModel.ts diff --git a/src/internal/testing/machine/runtime.ts b/packages/effect-machine/src/internal/testing/machine/runtime.ts similarity index 100% rename from src/internal/testing/machine/runtime.ts rename to packages/effect-machine/src/internal/testing/machine/runtime.ts diff --git a/src/internal/testing/machine/runtimeInvariant.ts b/packages/effect-machine/src/internal/testing/machine/runtimeInvariant.ts similarity index 100% rename from src/internal/testing/machine/runtimeInvariant.ts rename to packages/effect-machine/src/internal/testing/machine/runtimeInvariant.ts diff --git a/src/internal/testing/machine/trace.ts b/packages/effect-machine/src/internal/testing/machine/trace.ts similarity index 100% rename from src/internal/testing/machine/trace.ts rename to packages/effect-machine/src/internal/testing/machine/trace.ts diff --git a/src/internal/testing/machine/transitionCoverage.ts b/packages/effect-machine/src/internal/testing/machine/transitionCoverage.ts similarity index 100% rename from src/internal/testing/machine/transitionCoverage.ts rename to packages/effect-machine/src/internal/testing/machine/transitionCoverage.ts diff --git a/src/internal/testing/machine/verification.ts b/packages/effect-machine/src/internal/testing/machine/verification.ts similarity index 100% rename from src/internal/testing/machine/verification.ts rename to packages/effect-machine/src/internal/testing/machine/verification.ts diff --git a/src/testing/MachineTest.ts b/packages/effect-machine/src/testing/MachineTest.ts similarity index 100% rename from src/testing/MachineTest.ts rename to packages/effect-machine/src/testing/MachineTest.ts diff --git a/src/testing/index.ts b/packages/effect-machine/src/testing/index.ts similarity index 100% rename from src/testing/index.ts rename to packages/effect-machine/src/testing/index.ts diff --git a/src/unstable/cluster/ClusterMachine.ts b/packages/effect-machine/src/unstable/cluster/ClusterMachine.ts similarity index 100% rename from src/unstable/cluster/ClusterMachine.ts rename to packages/effect-machine/src/unstable/cluster/ClusterMachine.ts diff --git a/src/unstable/cluster/index.ts b/packages/effect-machine/src/unstable/cluster/index.ts similarity index 100% rename from src/unstable/cluster/index.ts rename to packages/effect-machine/src/unstable/cluster/index.ts diff --git a/src/unstable/reactivity/AtomMachine.ts b/packages/effect-machine/src/unstable/reactivity/AtomMachine.ts similarity index 100% rename from src/unstable/reactivity/AtomMachine.ts rename to packages/effect-machine/src/unstable/reactivity/AtomMachine.ts diff --git a/src/unstable/reactivity/index.ts b/packages/effect-machine/src/unstable/reactivity/index.ts similarity index 100% rename from src/unstable/reactivity/index.ts rename to packages/effect-machine/src/unstable/reactivity/index.ts diff --git a/test/internal/machine/activities.test.ts b/packages/effect-machine/test/internal/machine/activities.test.ts similarity index 100% rename from test/internal/machine/activities.test.ts rename to packages/effect-machine/test/internal/machine/activities.test.ts diff --git a/test/internal/machine/invocation.test.ts b/packages/effect-machine/test/internal/machine/invocation.test.ts similarity index 100% rename from test/internal/machine/invocation.test.ts rename to packages/effect-machine/test/internal/machine/invocation.test.ts diff --git a/test/internal/machine/processLifecycle.test.ts b/packages/effect-machine/test/internal/machine/processLifecycle.test.ts similarity index 100% rename from test/internal/machine/processLifecycle.test.ts rename to packages/effect-machine/test/internal/machine/processLifecycle.test.ts diff --git a/test/internal/machine/protocol.test.ts b/packages/effect-machine/test/internal/machine/protocol.test.ts similarity index 100% rename from test/internal/machine/protocol.test.ts rename to packages/effect-machine/test/internal/machine/protocol.test.ts diff --git a/test/internal/machine/strategyDifferential.test.ts b/packages/effect-machine/test/internal/machine/strategyDifferential.test.ts similarity index 100% rename from test/internal/machine/strategyDifferential.test.ts rename to packages/effect-machine/test/internal/machine/strategyDifferential.test.ts diff --git a/test/internal/machine/support/strategyDifferential.ts b/packages/effect-machine/test/internal/machine/support/strategyDifferential.ts similarity index 100% rename from test/internal/machine/support/strategyDifferential.ts rename to packages/effect-machine/test/internal/machine/support/strategyDifferential.ts diff --git a/test/machine/ActivityLifecycleModel.test.ts b/packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts similarity index 100% rename from test/machine/ActivityLifecycleModel.test.ts rename to packages/effect-machine/test/machine/ActivityLifecycleModel.test.ts diff --git a/test/machine/Annotations.test.ts b/packages/effect-machine/test/machine/Annotations.test.ts similarity index 100% rename from test/machine/Annotations.test.ts rename to packages/effect-machine/test/machine/Annotations.test.ts diff --git a/test/machine/AnnotationsVisualization.test.ts b/packages/effect-machine/test/machine/AnnotationsVisualization.test.ts similarity index 100% rename from test/machine/AnnotationsVisualization.test.ts rename to packages/effect-machine/test/machine/AnnotationsVisualization.test.ts diff --git a/test/machine/Choice.test.ts b/packages/effect-machine/test/machine/Choice.test.ts similarity index 100% rename from test/machine/Choice.test.ts rename to packages/effect-machine/test/machine/Choice.test.ts diff --git a/test/machine/DeepHandlers.test.ts b/packages/effect-machine/test/machine/DeepHandlers.test.ts similarity index 100% rename from test/machine/DeepHandlers.test.ts rename to packages/effect-machine/test/machine/DeepHandlers.test.ts diff --git a/test/machine/DynamicChildren.test.ts b/packages/effect-machine/test/machine/DynamicChildren.test.ts similarity index 100% rename from test/machine/DynamicChildren.test.ts rename to packages/effect-machine/test/machine/DynamicChildren.test.ts diff --git a/test/machine/History.test.ts b/packages/effect-machine/test/machine/History.test.ts similarity index 100% rename from test/machine/History.test.ts rename to packages/effect-machine/test/machine/History.test.ts diff --git a/test/machine/InitialEntry.test.ts b/packages/effect-machine/test/machine/InitialEntry.test.ts similarity index 100% rename from test/machine/InitialEntry.test.ts rename to packages/effect-machine/test/machine/InitialEntry.test.ts diff --git a/test/machine/Inspection.test.ts b/packages/effect-machine/test/machine/Inspection.test.ts similarity index 100% rename from test/machine/Inspection.test.ts rename to packages/effect-machine/test/machine/Inspection.test.ts diff --git a/test/machine/Invoke.test.ts b/packages/effect-machine/test/machine/Invoke.test.ts similarity index 100% rename from test/machine/Invoke.test.ts rename to packages/effect-machine/test/machine/Invoke.test.ts diff --git a/test/machine/LiveInspection.test.ts b/packages/effect-machine/test/machine/LiveInspection.test.ts similarity index 100% rename from test/machine/LiveInspection.test.ts rename to packages/effect-machine/test/machine/LiveInspection.test.ts diff --git a/test/machine/LocalTargetWith.test.ts b/packages/effect-machine/test/machine/LocalTargetWith.test.ts similarity index 100% rename from test/machine/LocalTargetWith.test.ts rename to packages/effect-machine/test/machine/LocalTargetWith.test.ts diff --git a/test/machine/Machine.test.ts b/packages/effect-machine/test/machine/Machine.test.ts similarity index 100% rename from test/machine/Machine.test.ts rename to packages/effect-machine/test/machine/Machine.test.ts diff --git a/test/machine/MachineReferences.test.ts b/packages/effect-machine/test/machine/MachineReferences.test.ts similarity index 100% rename from test/machine/MachineReferences.test.ts rename to packages/effect-machine/test/machine/MachineReferences.test.ts diff --git a/test/machine/MermaidVisualization.test.ts b/packages/effect-machine/test/machine/MermaidVisualization.test.ts similarity index 100% rename from test/machine/MermaidVisualization.test.ts rename to packages/effect-machine/test/machine/MermaidVisualization.test.ts diff --git a/test/machine/PublicPrototype.test.ts b/packages/effect-machine/test/machine/PublicPrototype.test.ts similarity index 100% rename from test/machine/PublicPrototype.test.ts rename to packages/effect-machine/test/machine/PublicPrototype.test.ts diff --git a/test/machine/Resume.test.ts b/packages/effect-machine/test/machine/Resume.test.ts similarity index 100% rename from test/machine/Resume.test.ts rename to packages/effect-machine/test/machine/Resume.test.ts diff --git a/test/machine/RuntimeDifferential.test.ts b/packages/effect-machine/test/machine/RuntimeDifferential.test.ts similarity index 100% rename from test/machine/RuntimeDifferential.test.ts rename to packages/effect-machine/test/machine/RuntimeDifferential.test.ts diff --git a/test/machine/Scheduling.test.ts b/packages/effect-machine/test/machine/Scheduling.test.ts similarity index 100% rename from test/machine/Scheduling.test.ts rename to packages/effect-machine/test/machine/Scheduling.test.ts diff --git a/test/machine/SnapshotCodecAdversarial.test.ts b/packages/effect-machine/test/machine/SnapshotCodecAdversarial.test.ts similarity index 100% rename from test/machine/SnapshotCodecAdversarial.test.ts rename to packages/effect-machine/test/machine/SnapshotCodecAdversarial.test.ts diff --git a/test/machine/SnapshotContext.test.ts b/packages/effect-machine/test/machine/SnapshotContext.test.ts similarity index 100% rename from test/machine/SnapshotContext.test.ts rename to packages/effect-machine/test/machine/SnapshotContext.test.ts diff --git a/test/machine/StateDefinition.test.ts b/packages/effect-machine/test/machine/StateDefinition.test.ts similarity index 100% rename from test/machine/StateDefinition.test.ts rename to packages/effect-machine/test/machine/StateDefinition.test.ts diff --git a/test/machine/StateUpdate.test.ts b/packages/effect-machine/test/machine/StateUpdate.test.ts similarity index 100% rename from test/machine/StateUpdate.test.ts rename to packages/effect-machine/test/machine/StateUpdate.test.ts diff --git a/test/machine/StructuralStates.test.ts b/packages/effect-machine/test/machine/StructuralStates.test.ts similarity index 100% rename from test/machine/StructuralStates.test.ts rename to packages/effect-machine/test/machine/StructuralStates.test.ts diff --git a/test/machine/Totality.test.ts b/packages/effect-machine/test/machine/Totality.test.ts similarity index 100% rename from test/machine/Totality.test.ts rename to packages/effect-machine/test/machine/Totality.test.ts diff --git a/test/machine/Visualization.test.ts b/packages/effect-machine/test/machine/Visualization.test.ts similarity index 100% rename from test/machine/Visualization.test.ts rename to packages/effect-machine/test/machine/Visualization.test.ts diff --git a/test/machine/support/activityLifecycleModel.ts b/packages/effect-machine/test/machine/support/activityLifecycleModel.ts similarity index 100% rename from test/machine/support/activityLifecycleModel.ts rename to packages/effect-machine/test/machine/support/activityLifecycleModel.ts diff --git a/test/machine/support/runtimeDifferential.ts b/packages/effect-machine/test/machine/support/runtimeDifferential.ts similarity index 100% rename from test/machine/support/runtimeDifferential.ts rename to packages/effect-machine/test/machine/support/runtimeDifferential.ts diff --git a/test/machine/visualization/mermaid.ts b/packages/effect-machine/test/machine/visualization/mermaid.ts similarity index 100% rename from test/machine/visualization/mermaid.ts rename to packages/effect-machine/test/machine/visualization/mermaid.ts diff --git a/test/machine/visualization/model.ts b/packages/effect-machine/test/machine/visualization/model.ts similarity index 100% rename from test/machine/visualization/model.ts rename to packages/effect-machine/test/machine/visualization/model.ts diff --git a/test/machine/visualization/text.ts b/packages/effect-machine/test/machine/visualization/text.ts similarity index 100% rename from test/machine/visualization/text.ts rename to packages/effect-machine/test/machine/visualization/text.ts diff --git a/test/testing/Coverage.test.ts b/packages/effect-machine/test/testing/Coverage.test.ts similarity index 100% rename from test/testing/Coverage.test.ts rename to packages/effect-machine/test/testing/Coverage.test.ts diff --git a/test/testing/Exploration.test.ts b/packages/effect-machine/test/testing/Exploration.test.ts similarity index 100% rename from test/testing/Exploration.test.ts rename to packages/effect-machine/test/testing/Exploration.test.ts diff --git a/test/testing/FiniteModel.test.ts b/packages/effect-machine/test/testing/FiniteModel.test.ts similarity index 100% rename from test/testing/FiniteModel.test.ts rename to packages/effect-machine/test/testing/FiniteModel.test.ts diff --git a/test/testing/Invariant.test.ts b/packages/effect-machine/test/testing/Invariant.test.ts similarity index 100% rename from test/testing/Invariant.test.ts rename to packages/effect-machine/test/testing/Invariant.test.ts diff --git a/test/testing/MachineTest.test.ts b/packages/effect-machine/test/testing/MachineTest.test.ts similarity index 100% rename from test/testing/MachineTest.test.ts rename to packages/effect-machine/test/testing/MachineTest.test.ts diff --git a/test/testing/Probe.test.ts b/packages/effect-machine/test/testing/Probe.test.ts similarity index 100% rename from test/testing/Probe.test.ts rename to packages/effect-machine/test/testing/Probe.test.ts diff --git a/test/testing/ReferenceModel.test.ts b/packages/effect-machine/test/testing/ReferenceModel.test.ts similarity index 100% rename from test/testing/ReferenceModel.test.ts rename to packages/effect-machine/test/testing/ReferenceModel.test.ts diff --git a/test/testing/Runtime.test.ts b/packages/effect-machine/test/testing/Runtime.test.ts similarity index 100% rename from test/testing/Runtime.test.ts rename to packages/effect-machine/test/testing/Runtime.test.ts diff --git a/test/testing/Verification.test.ts b/packages/effect-machine/test/testing/Verification.test.ts similarity index 100% rename from test/testing/Verification.test.ts rename to packages/effect-machine/test/testing/Verification.test.ts diff --git a/test/unstable/cluster/ClusterMachine.test.ts b/packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts similarity index 100% rename from test/unstable/cluster/ClusterMachine.test.ts rename to packages/effect-machine/test/unstable/cluster/ClusterMachine.test.ts diff --git a/test/unstable/reactivity/AtomMachine.test.ts b/packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts similarity index 100% rename from test/unstable/reactivity/AtomMachine.test.ts rename to packages/effect-machine/test/unstable/reactivity/AtomMachine.test.ts diff --git a/packages/effect-machine/tsconfig.build.json b/packages/effect-machine/tsconfig.build.json new file mode 100644 index 0000000..0178f60 --- /dev/null +++ b/packages/effect-machine/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/effect-machine/tsconfig.json b/packages/effect-machine/tsconfig.json new file mode 100644 index 0000000..552a74f --- /dev/null +++ b/packages/effect-machine/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/tstyche.json b/packages/effect-machine/tstyche.json similarity index 100% rename from tstyche.json rename to packages/effect-machine/tstyche.json diff --git a/typetest/machine/Activities.tst.ts b/packages/effect-machine/typetest/machine/Activities.tst.ts similarity index 100% rename from typetest/machine/Activities.tst.ts rename to packages/effect-machine/typetest/machine/Activities.tst.ts diff --git a/typetest/machine/Annotations.tst.ts b/packages/effect-machine/typetest/machine/Annotations.tst.ts similarity index 100% rename from typetest/machine/Annotations.tst.ts rename to packages/effect-machine/typetest/machine/Annotations.tst.ts diff --git a/typetest/machine/Choice.tst.ts b/packages/effect-machine/typetest/machine/Choice.tst.ts similarity index 100% rename from typetest/machine/Choice.tst.ts rename to packages/effect-machine/typetest/machine/Choice.tst.ts diff --git a/typetest/machine/ConsumerTypes.tst.ts b/packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts similarity index 100% rename from typetest/machine/ConsumerTypes.tst.ts rename to packages/effect-machine/typetest/machine/ConsumerTypes.tst.ts diff --git a/typetest/machine/DeepHandlers.tst.ts b/packages/effect-machine/typetest/machine/DeepHandlers.tst.ts similarity index 100% rename from typetest/machine/DeepHandlers.tst.ts rename to packages/effect-machine/typetest/machine/DeepHandlers.tst.ts diff --git a/typetest/machine/DynamicChildren.tst.ts b/packages/effect-machine/typetest/machine/DynamicChildren.tst.ts similarity index 100% rename from typetest/machine/DynamicChildren.tst.ts rename to packages/effect-machine/typetest/machine/DynamicChildren.tst.ts diff --git a/typetest/machine/EventByTag.tst.ts b/packages/effect-machine/typetest/machine/EventByTag.tst.ts similarity index 100% rename from typetest/machine/EventByTag.tst.ts rename to packages/effect-machine/typetest/machine/EventByTag.tst.ts diff --git a/typetest/machine/EventConstructors.tst.ts b/packages/effect-machine/typetest/machine/EventConstructors.tst.ts similarity index 100% rename from typetest/machine/EventConstructors.tst.ts rename to packages/effect-machine/typetest/machine/EventConstructors.tst.ts diff --git a/typetest/machine/History.tst.ts b/packages/effect-machine/typetest/machine/History.tst.ts similarity index 100% rename from typetest/machine/History.tst.ts rename to packages/effect-machine/typetest/machine/History.tst.ts diff --git a/typetest/machine/InitialEntry.tst.ts b/packages/effect-machine/typetest/machine/InitialEntry.tst.ts similarity index 100% rename from typetest/machine/InitialEntry.tst.ts rename to packages/effect-machine/typetest/machine/InitialEntry.tst.ts diff --git a/typetest/machine/Inspection.tst.ts b/packages/effect-machine/typetest/machine/Inspection.tst.ts similarity index 100% rename from typetest/machine/Inspection.tst.ts rename to packages/effect-machine/typetest/machine/Inspection.tst.ts diff --git a/typetest/machine/Machine.tst.ts b/packages/effect-machine/typetest/machine/Machine.tst.ts similarity index 100% rename from typetest/machine/Machine.tst.ts rename to packages/effect-machine/typetest/machine/Machine.tst.ts diff --git a/typetest/machine/MachineReferences.tst.ts b/packages/effect-machine/typetest/machine/MachineReferences.tst.ts similarity index 100% rename from typetest/machine/MachineReferences.tst.ts rename to packages/effect-machine/typetest/machine/MachineReferences.tst.ts diff --git a/typetest/machine/Readiness.tst.ts b/packages/effect-machine/typetest/machine/Readiness.tst.ts similarity index 100% rename from typetest/machine/Readiness.tst.ts rename to packages/effect-machine/typetest/machine/Readiness.tst.ts diff --git a/typetest/machine/Resume.tst.ts b/packages/effect-machine/typetest/machine/Resume.tst.ts similarity index 100% rename from typetest/machine/Resume.tst.ts rename to packages/effect-machine/typetest/machine/Resume.tst.ts diff --git a/typetest/machine/SnapshotContext.tst.ts b/packages/effect-machine/typetest/machine/SnapshotContext.tst.ts similarity index 100% rename from typetest/machine/SnapshotContext.tst.ts rename to packages/effect-machine/typetest/machine/SnapshotContext.tst.ts diff --git a/typetest/machine/StateDefinition.tst.ts b/packages/effect-machine/typetest/machine/StateDefinition.tst.ts similarity index 100% rename from typetest/machine/StateDefinition.tst.ts rename to packages/effect-machine/typetest/machine/StateDefinition.tst.ts diff --git a/typetest/machine/StateUpdate.tst.ts b/packages/effect-machine/typetest/machine/StateUpdate.tst.ts similarity index 100% rename from typetest/machine/StateUpdate.tst.ts rename to packages/effect-machine/typetest/machine/StateUpdate.tst.ts diff --git a/typetest/machine/StructuralStates.tst.ts b/packages/effect-machine/typetest/machine/StructuralStates.tst.ts similarity index 100% rename from typetest/machine/StructuralStates.tst.ts rename to packages/effect-machine/typetest/machine/StructuralStates.tst.ts diff --git a/typetest/testing/Coverage.tst.ts b/packages/effect-machine/typetest/testing/Coverage.tst.ts similarity index 100% rename from typetest/testing/Coverage.tst.ts rename to packages/effect-machine/typetest/testing/Coverage.tst.ts diff --git a/typetest/testing/Exploration.tst.ts b/packages/effect-machine/typetest/testing/Exploration.tst.ts similarity index 100% rename from typetest/testing/Exploration.tst.ts rename to packages/effect-machine/typetest/testing/Exploration.tst.ts diff --git a/typetest/testing/FiniteModel.tst.ts b/packages/effect-machine/typetest/testing/FiniteModel.tst.ts similarity index 100% rename from typetest/testing/FiniteModel.tst.ts rename to packages/effect-machine/typetest/testing/FiniteModel.tst.ts diff --git a/typetest/testing/Invariant.tst.ts b/packages/effect-machine/typetest/testing/Invariant.tst.ts similarity index 100% rename from typetest/testing/Invariant.tst.ts rename to packages/effect-machine/typetest/testing/Invariant.tst.ts diff --git a/typetest/testing/MachineTest.tst.ts b/packages/effect-machine/typetest/testing/MachineTest.tst.ts similarity index 100% rename from typetest/testing/MachineTest.tst.ts rename to packages/effect-machine/typetest/testing/MachineTest.tst.ts diff --git a/typetest/testing/Probe.tst.ts b/packages/effect-machine/typetest/testing/Probe.tst.ts similarity index 100% rename from typetest/testing/Probe.tst.ts rename to packages/effect-machine/typetest/testing/Probe.tst.ts diff --git a/typetest/testing/ReferenceModel.tst.ts b/packages/effect-machine/typetest/testing/ReferenceModel.tst.ts similarity index 100% rename from typetest/testing/ReferenceModel.tst.ts rename to packages/effect-machine/typetest/testing/ReferenceModel.tst.ts diff --git a/typetest/testing/Verification.tst.ts b/packages/effect-machine/typetest/testing/Verification.tst.ts similarity index 100% rename from typetest/testing/Verification.tst.ts rename to packages/effect-machine/typetest/testing/Verification.tst.ts diff --git a/typetest/unstable/cluster/ClusterMachine.tst.ts b/packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts similarity index 100% rename from typetest/unstable/cluster/ClusterMachine.tst.ts rename to packages/effect-machine/typetest/unstable/cluster/ClusterMachine.tst.ts diff --git a/typetest/unstable/reactivity/AtomMachine.tst.ts b/packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts similarity index 100% rename from typetest/unstable/reactivity/AtomMachine.tst.ts rename to packages/effect-machine/typetest/unstable/reactivity/AtomMachine.tst.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f4be17..92175ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: version: 2.31.0(@types/node@25.7.0) '@effect/vitest': specifier: 4.0.0-rc.111 - version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0))) + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))) '@types/node': specifier: 25.7.0 version: 25.7.0 @@ -42,9 +42,67 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)) + + packages/devtools: + dependencies: + '@effect/platform-browser': + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111(effect@4.0.0-rc.111) + '@effect/platform-node': + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) + '@typeonce/effect-machine': + specifier: workspace:^ + version: link:../effect-machine + chokidar: + specifier: 4.0.3 + version: 4.0.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0) + devDependencies: + '@types/node': + specifier: 25.7.0 + version: 25.7.0 + effect: + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 + tsx: + specifier: 4.21.0 + version: 4.21.0 + + packages/effect-machine: + devDependencies: + '@effect/vitest': + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))) + '@types/node': + specifier: 25.7.0 + version: 25.7.0 + effect: + specifier: 4.0.0-rc.111 + version: 4.0.0-rc.111 + tinybench: + specifier: 2.9.0 + version: 2.9.0 + tstyche: + specifier: 7.2.1 + version: 7.2.1(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -191,6 +249,24 @@ packages: cpu: [x64] os: [win32] + '@effect/platform-browser@4.0.0-rc.111': + resolution: {integrity: sha512-4SmaYB4zWsmuM82V8Dgida2yupzEWlv6/QoDL2Ogj1jOPq65KUx3VjQKcNn2NenOR6SFRWaWsUWrnxwA2IPFzg==} + peerDependencies: + effect: 4.0.0-rc.111 + + '@effect/platform-node-shared@4.0.0-rc.111': + resolution: {integrity: sha512-iES0Q9vmjhaUKqeW9ceonuD45MUg/Ouk08LzRSptZ+B5qB0w9WlRjDmUz5TJmY2betNop5FRI5k4AD4mtQt3Bw==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: 4.0.0-rc.111 + + '@effect/platform-node@4.0.0-rc.111': + resolution: {integrity: sha512-oy1i7HsOGg/5r+DuBe5+ddmnUhnXmyZFPFPXZCBdO/RQpHK3PIFe1/2UMGxZE+ngDymrSksuQTSgQLF6P+MLqw==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: 4.0.0-rc.111 + redis: '>=5.0.0 <7.0.0' + '@effect/vitest@4.0.0-rc.111': resolution: {integrity: sha512-YDaEVT+grREBVMzykRNFtJwGxy02achzT0WXZYwMJA4ukzuB9krkgQ5roc3N6zhC3NQq/j4IyM32/Pcov7AhHw==} peerDependencies: @@ -206,6 +282,162 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} @@ -313,6 +545,42 @@ packages: cpu: [x64] os: [win32] + '@redis/bloom@6.2.1': + resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/client@6.2.1': + resolution: {integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.2.1': + resolution: {integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/search@6.2.1': + resolution: {integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/time-series@6.2.1': + resolution: {integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -453,6 +721,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -527,6 +798,14 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -564,6 +843,11 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -620,6 +904,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -782,6 +1069,11 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -896,10 +1188,21 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redis@6.2.1: + resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} + engines: {node: '>= 20.0.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -999,6 +1302,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + typedoc@0.28.20: resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} engines: {node: '>= 18', pnpm: '>= 10'} @@ -1017,6 +1325,10 @@ packages: undici-types@7.21.0: resolution: {integrity: sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1115,6 +1427,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -1312,10 +1636,34 @@ snapshots: '@dprint/win32-x64@0.55.2': optional: true - '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0)))': + '@effect/platform-browser@4.0.0-rc.111(effect@4.0.0-rc.111)': + dependencies: + effect: 4.0.0-rc.111 + + '@effect/platform-node-shared@4.0.0-rc.111(effect@4.0.0-rc.111)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-rc.111 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.111(effect@4.0.0-rc.111) + effect: 4.0.0-rc.111 + mime: 4.1.0 + redis: 6.2.1 + undici: 8.10.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)))': dependencies: effect: 4.0.0-rc.111 - vitest: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)) '@emnapi/core@1.11.1': dependencies: @@ -1333,6 +1681,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + '@gerrit0/mini-shiki@3.23.0': dependencies: '@shikijs/engine-oniguruma': 3.23.0 @@ -1426,6 +1852,26 @@ snapshots: '@pagefind/windows-x64@1.5.2': optional: true + '@redis/bloom@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/client@6.2.1': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/search@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/time-series@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -1525,6 +1971,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.7.0 + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -1534,13 +1984,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@25.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1598,6 +2048,12 @@ snapshots: chardet@2.2.0: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cluster-key-slot@1.1.2: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1647,6 +2103,35 @@ snapshots: es-module-lexer@2.3.1: {} + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esprima@4.0.1: {} estree-walker@3.0.3: @@ -1701,6 +2186,10 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1836,6 +2325,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime@4.1.0: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -1938,8 +2429,23 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readdirp@4.1.2: {} + + redis@6.2.1: + dependencies: + '@redis/bloom': 6.2.1(@redis/client@6.2.1) + '@redis/client': 6.2.1 + '@redis/json': 6.2.1(@redis/client@6.2.1) + '@redis/search': 6.2.1(@redis/client@6.2.1) + '@redis/time-series': 6.2.1(@redis/client@6.2.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rolldown@1.1.5: @@ -2026,6 +2532,13 @@ snapshots: optionalDependencies: typescript: 6.0.3 + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.3 + optionalDependencies: + fsevents: 2.3.3 + typedoc@0.28.20(typescript@6.0.3): dependencies: '@gerrit0/mini-shiki': 3.23.0 @@ -2041,9 +2554,11 @@ snapshots: undici-types@7.21.0: {} + undici@8.10.0: {} + universalify@0.1.2: {} - vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0): + vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -2052,13 +2567,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.7.0 + esbuild: 0.27.7 fsevents: 2.3.3 + tsx: 4.21.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2075,7 +2592,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@25.7.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.7.0 @@ -2091,4 +2608,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.3: {} + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 343d508..c991a6a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ packages: - - "." + - "packages/*" overrides: effect: 4.0.0-rc.111 diff --git a/scripts/api-reference-site/api-reference-site.test.mjs b/scripts/api-reference-site/api-reference-site.test.mjs index 55bbffa..5a929ee 100644 --- a/scripts/api-reference-site/api-reference-site.test.mjs +++ b/scripts/api-reference-site/api-reference-site.test.mjs @@ -80,7 +80,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\/docs\/effect-atom-react\.md"/ + /href="https:\/\/github\.com\/typeonce-dev\/effect-machine\/blob\/main\/packages\/effect-machine\/docs\/effect-atom-react\.md"/ ) 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 e5f3f07..a3247ab 100644 --- a/scripts/api-reference-site/generate.mjs +++ b/scripts/api-reference-site/generate.mjs @@ -73,7 +73,10 @@ const readSiteModel = (inputDirectory, config) => { } }) const changelog = readChangelog(packageManifest.name) - const agentGuide = readFileSync(join(repositoryDirectory, "docs", "agent-guide.md"), "utf8") + const agentGuide = readFileSync( + join(repositoryDirectory, "packages", "effect-machine", "docs", "agent-guide.md"), + "utf8" + ) return { ...config, @@ -192,7 +195,7 @@ export const renderChangelogPage = (site) => { export const renderAgentGuidePage = (site) => { const resolveLink = (href) => href.startsWith("./") && href.endsWith(".md") - ? `${site.package.repositoryUrl}/blob/main/docs/${href.slice(2)}` + ? `${site.package.repositoryUrl}/blob/main/packages/effect-machine/docs/${href.slice(2)}` : href const content = `
@@ -840,7 +843,7 @@ const parseChangeGroups = (markdown) => { const readChangelog = (packageName) => { const releases = parseChangelog( - readFileSync(join(repositoryDirectory, "CHANGELOG.md"), "utf8"), + readFileSync(join(repositoryDirectory, "packages", "effect-machine", "CHANGELOG.md"), "utf8"), readReleaseDates(packageName) ) const pending = readdirSync(join(repositoryDirectory, ".changeset"), { withFileTypes: true }) diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 661bae8..a746b01 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -491,7 +491,7 @@ export const formatArchitectureDiagnostics = (diagnostics) => const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url) if (isMain) { - const diagnostics = checkArchitecture() + const diagnostics = checkArchitecture({ rootDirectory: process.argv[2] ?? process.cwd() }) if (diagnostics.length > 0) { console.error(formatArchitectureDiagnostics(diagnostics)) process.exitCode = 1 diff --git a/scripts/check-changeset.mjs b/scripts/check-changeset.mjs index 4076fcc..c1a84ff 100644 --- a/scripts/check-changeset.mjs +++ b/scripts/check-changeset.mjs @@ -33,7 +33,13 @@ const diff = (filters) => { const changedFiles = diff("ACDMRTUXB") const releaseFiles = changedFiles.filter( - (path) => path.startsWith("src/") || path === "package.json" + (path) => + path.startsWith("src/") || + path.startsWith("packages/effect-machine/src/") || + path.startsWith("packages/devtools/src/") || + path === "package.json" || + path === "packages/effect-machine/package.json" || + path === "packages/devtools/package.json" ) if (releaseFiles.length === 0) { diff --git a/scripts/check-devtools-architecture.mjs b/scripts/check-devtools-architecture.mjs new file mode 100644 index 0000000..f975d3c --- /dev/null +++ b/scripts/check-devtools-architecture.mjs @@ -0,0 +1,57 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs" +import { resolve } from "node:path" + +const packageRoot = resolve(process.argv[2] ?? "packages/devtools") +const sourceRoot = resolve(packageRoot, "src") +const errors = [] + +const visit = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + if (entry.isDirectory()) { + visit(path) + } else if (path.includes("/internal/") && entry.name === "index.ts") { + errors.push(`Internal barrel modules are not allowed: ${path.slice(packageRoot.length + 1)}`) + } + } +} + +visit(sourceRoot) + +const publicIndex = resolve(sourceRoot, "index.ts") +if (!existsSync(publicIndex)) { + errors.push("Missing src/index.ts") +} else if (readFileSync(publicIndex, "utf8").includes("/internal/")) { + errors.push("src/index.ts must not expose internal modules") +} + +const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) +if (manifest.exports?.["./internal/*"] !== null) { + errors.push('package.json must block the "./internal/*" export') +} + +const publicExports = [ + ".", + "./DevToolsProtocol", + "./MachineDocument", + "./MachineSimulator", + "./package.json", + "./internal/*" +] +const unexpectedExports = Object.keys(manifest.exports ?? {}).filter((path) => !publicExports.includes(path)) +if (unexpectedExports.length > 0) { + errors.push(`Unexpected public devtools exports: ${unexpectedExports.join(", ")}`) +} + +const unexpectedPublishedExports = Object.keys(manifest.publishConfig?.exports ?? {}) + .filter((path) => !publicExports.includes(path)) +if (unexpectedPublishedExports.length > 0) { + errors.push(`Unexpected published devtools exports: ${unexpectedPublishedExports.join(", ")}`) +} + +if (errors.length > 0) { + console.error(errors.join("\n")) + process.exitCode = 1 +} else { + console.log("Devtools architecture checks passed") +} diff --git a/scripts/ci-changes.mjs b/scripts/ci-changes.mjs index 33d0b01..212b3ea 100644 --- a/scripts/ci-changes.mjs +++ b/scripts/ci-changes.mjs @@ -23,7 +23,9 @@ export const classifyChanges = ({ beforePackageJson, changedFiles }) => { - const sourceChanged = changedFiles.some((path) => path.startsWith("src/")) + const sourceChanged = changedFiles.some((path) => + path.startsWith("src/") || path.startsWith("packages/effect-machine/src/") + ) const dependencyChanged = relevantDependenciesChanged(beforePackageJson, afterPackageJson) const classifierChanged = changedFiles.includes("scripts/ci-changes.mjs") const typePerformance = sourceChanged || @@ -31,21 +33,27 @@ export const classifyChanges = ({ classifierChanged || changedFiles.some((path) => path.startsWith("perf/types/") || + path.startsWith("packages/effect-machine/perf/types/") || path === "scripts/type-performance.mjs" || path === "scripts/compare-type-performance.mjs" || path === "tsconfig.json" || path === "tsconfig.build.json" || + path === "packages/effect-machine/tsconfig.build.json" || path === ".github/workflows/type-performance.yml" ) const runtimePerformance = dependencyChanged || classifierChanged || changedFiles.some((path) => path.startsWith("src/internal/machine/") || + path.startsWith("packages/effect-machine/src/internal/machine/") || path === "src/Machine.ts" || + path === "packages/effect-machine/src/Machine.ts" || path.startsWith("perf/runtime/") || + path.startsWith("packages/effect-machine/perf/runtime/") || path === "scripts/runtime-performance.mjs" || path === "scripts/compare-runtime-performance.mjs" || path === "tsconfig.build.json" || + path === "packages/effect-machine/tsconfig.build.json" || path === ".github/workflows/runtime-performance.yml" ) return { @@ -56,7 +64,17 @@ export const classifyChanges = ({ const git = (...args) => execFileSync("git", args, { encoding: "utf8" }) -const readPackageJsonAt = (revision) => JSON.parse(git("show", `${revision}:package.json`)) +const readPackageJsonAt = (revision) => { + for (const path of ["packages/effect-machine/package.json", "package.json"]) { + try { + const manifest = JSON.parse(git("show", `${revision}:${path}`)) + if (manifest.name === "@typeonce/effect-machine") return manifest + } catch { + // Try the layout used by the other revision. + } + } + throw new Error(`Unable to locate @typeonce/effect-machine package.json at ${revision}`) +} const main = () => { const options = { diff --git a/scripts/ci-changes.test.mjs b/scripts/ci-changes.test.mjs index 9c3b8e3..3b481b8 100644 --- a/scripts/ci-changes.test.mjs +++ b/scripts/ci-changes.test.mjs @@ -30,6 +30,10 @@ test("runs type performance for public source changes", () => { runtimePerformance: false, typePerformance: true }) + assert.deepEqual(classify(["packages/effect-machine/src/index.ts"]), { + runtimePerformance: false, + typePerformance: true + }) }) test("adds runtime performance for machine implementation changes", () => { @@ -37,6 +41,10 @@ test("adds runtime performance for machine implementation changes", () => { runtimePerformance: true, typePerformance: true }) + assert.deepEqual(classify(["packages/effect-machine/src/internal/machine/runtime.ts"]), { + runtimePerformance: true, + typePerformance: true + }) }) test("distinguishes performance dependencies from unrelated tooling", () => { diff --git a/scripts/devtools-pack-check.mjs b/scripts/devtools-pack-check.mjs new file mode 100644 index 0000000..a78dbcf --- /dev/null +++ b/scripts/devtools-pack-check.mjs @@ -0,0 +1,229 @@ +import { spawn, spawnSync } from "node:child_process" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { createServer } from "node:net" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +const repositoryRoot = resolve(import.meta.dirname, "..") +const destination = await mkdtemp(join(tmpdir(), "effect-machine-devtools-pack-")) +const consumer = join(destination, "consumer") +const machineFile = join(consumer, "src", "machine.ts") +let child +const childOutput = [] + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + encoding: "utf8", + ...options + }) + if (result.status !== 0) { + throw new Error( + [`${command} ${args.join(" ")} failed`, result.stdout?.trim(), result.stderr?.trim()] + .filter(Boolean) + .join("\n") + ) + } + return result +} + +const pack = (directory) => { + const result = run("pnpm", ["pack", "--pack-destination", destination], { cwd: directory }) + const archive = result.stdout.trim().split("\n").at(-1) + if (archive === undefined) throw new Error(`pnpm pack did not return an archive for ${directory}`) + return archive +} + +const availablePort = () => + new Promise((resolvePort, reject) => { + const server = createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (address === null || typeof address === "string") { + server.close(() => reject(new Error("Could not allocate a local test port"))) + return + } + server.close((cause) => cause === undefined ? resolvePort(address.port) : reject(cause)) + }) + }) + +const waitFor = async (description, check, timeout = 20_000) => { + const deadline = Date.now() + timeout + let lastError + while (Date.now() < deadline) { + try { + const value = await check() + if (value !== undefined) return value + } catch (cause) { + lastError = cause + } + await new Promise((resolveWait) => setTimeout(resolveWait, 100)) + } + throw new Error(`Timed out waiting for ${description}${lastError === undefined ? "" : `: ${String(lastError)}`}`) +} + +const waitForExit = (process, timeout = 10_000) => + new Promise((resolveExit, reject) => { + let timer + const done = (code, signal) => { + clearTimeout(timer) + resolveExit({ code, signal }) + } + process.once("exit", done) + timer = setTimeout(() => { + process.off("exit", done) + reject(new Error("The installed devtools process did not stop after SIGINT")) + }, timeout) + }) + +const readyMachine = `import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class Idle extends Schema.TaggedClass("Idle")("Idle", {}) {} + +const states = Machine.states({ Idle }) + +export const machine = Machine.make({ + id: "packed-fixture", + states: states.states, + events: Machine.events(), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new Idle())) +}).handle({}) +` + +try { + const coreArchive = pack(join(repositoryRoot, "packages", "effect-machine")) + const devtoolsArchive = pack(join(repositoryRoot, "packages", "devtools")) + const corePackage = JSON.parse(await readFile(join(repositoryRoot, "packages", "effect-machine", "package.json"))) + const devtoolsPackage = JSON.parse(await readFile(join(repositoryRoot, "packages", "devtools", "package.json"))) + + await mkdir(join(consumer, "src"), { recursive: true }) + await writeFile(join(consumer, "package.json"), JSON.stringify({ + private: true, + type: "module", + dependencies: { + "@typeonce/effect-machine": `file:${coreArchive}`, + "@typeonce/effect-machine-devtools": `file:${devtoolsArchive}`, + effect: devtoolsPackage.peerDependencies.effect + } + }, null, 2)) + await writeFile( + join(consumer, "pnpm-workspace.yaml"), + `packages:\n - .\noverrides:\n "@typeonce/effect-machine": "file:${coreArchive}"\n` + ) + await writeFile(machineFile, readyMachine) + + run("pnpm", ["install", "--prefer-offline", "--ignore-scripts"], { cwd: consumer }) + + const binary = join(consumer, "node_modules", ".bin", "effect-machine") + const version = run(binary, ["--version"], { cwd: consumer }) + if (!version.stdout.includes(devtoolsPackage.version)) { + throw new Error(`installed CLI reported the wrong version: ${version.stdout.trim()}`) + } + if (corePackage.version !== devtoolsPackage.version) { + throw new Error(`packed package versions differ: core ${corePackage.version}, devtools ${devtoolsPackage.version}`) + } + const help = run(binary, ["--help"], { cwd: consumer }) + if (!help.stdout.includes("--watch-polling")) { + throw new Error("installed CLI help does not document the polling fallback") + } + + run(process.execPath, [ + "--input-type=module", + "--eval", + `await import("@typeonce/effect-machine-devtools"); + await import("@typeonce/effect-machine-devtools/DevToolsProtocol"); + await import("@typeonce/effect-machine-devtools/MachineDocument"); + await import("@typeonce/effect-machine-devtools/MachineSimulator");` + ], { cwd: consumer }) + const privateImport = spawnSync(process.execPath, [ + "--input-type=module", + "--eval", + `await import("@typeonce/effect-machine-devtools/ProjectInspector")` + ], { cwd: consumer, encoding: "utf8" }) + if (privateImport.status === 0 || !privateImport.stderr.includes("ERR_PACKAGE_PATH_NOT_EXPORTED")) { + throw new Error("ProjectInspector is unexpectedly importable from the packed package") + } + + const port = await availablePort() + child = spawn(binary, ["--root", consumer, "--host", "127.0.0.1", "--port", String(port)], { + cwd: consumer, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"] + }) + child.stdout.on("data", (chunk) => childOutput.push(String(chunk))) + child.stderr.on("data", (chunk) => childOutput.push(String(chunk))) + + const endpoint = `http://127.0.0.1:${port}` + const initial = await waitFor("the installed machine registry", async () => { + const response = await fetch(`${endpoint}/api/machines`) + if (!response.ok) return undefined + const snapshot = await response.json() + const result = snapshot.results?.[0] + return result?._tag === "Ready" && result.document?.machineId === "packed-fixture" ? snapshot : undefined + }) + const page = await fetch(endpoint) + if (!page.ok || !(await page.text()).includes("Effect Machine · Text visualizer")) { + throw new Error("The installed package did not serve the browser application") + } + + await writeFile(machineFile, "export const building = {") + const partial = await waitFor("a partial result after an incomplete edit", async () => { + const snapshot = await fetch(`${endpoint}/api/machines`).then((response) => response.json()) + const result = snapshot.results?.[0] + return snapshot.revision > initial.revision && + result?._tag === "Partial" && + result.document?.machineId === "packed-fixture" + ? snapshot + : undefined + }) + + await writeFile(machineFile, readyMachine) + await waitFor("recovery after a valid edit", async () => { + const snapshot = await fetch(`${endpoint}/api/machines`).then((response) => response.json()) + const result = snapshot.results?.[0] + return snapshot.revision > partial.revision && + result?._tag === "Ready" && + result.document?.machineId === "packed-fixture" + ? snapshot + : undefined + }) + + child.kill("SIGINT") + await waitForExit(child) + child = undefined + + const occupiedPort = await availablePort() + const occupied = createServer() + await new Promise((resolveListen, reject) => { + occupied.once("error", reject) + occupied.listen(occupiedPort, "127.0.0.1", resolveListen) + }) + try { + const collision = spawnSync( + binary, + ["--root", consumer, "--host", "127.0.0.1", "--port", String(occupiedPort)], + { cwd: consumer, encoding: "utf8", timeout: 15_000 } + ) + const collisionOutput = `${collision.stdout ?? ""}\n${collision.stderr ?? ""}` + if (collision.status === 0 || !collisionOutput.includes("Could not start the Effect Machine visualizer")) { + throw new Error(`installed CLI did not report an occupied port\n${collisionOutput.trim()}`) + } + } finally { + await new Promise((resolveClose, reject) => + occupied.close((cause) => cause === undefined ? resolveClose() : reject(cause)) + ) + } + + console.log("installed devtools CLI, worker, browser, live reload, shutdown, and failure handling passed") +} catch (cause) { + if (child !== undefined) { + child.kill("SIGKILL") + } + const logs = childOutput.join("").trim() + throw new Error(`${cause instanceof Error ? cause.message : String(cause)}${logs === "" ? "" : `\n${logs}`}`, { + cause + }) +} finally { + await rm(destination, { recursive: true, force: true }) +} diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 5c39d42..8f6e1b2 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -4,7 +4,7 @@ import path from "node:path" import { test } from "node:test" import ts from "typescript" -const projectRoot = path.resolve(import.meta.dirname, "..") +const projectRoot = path.resolve(import.meta.dirname, "../packages/effect-machine") const virtualFile = path.join(projectRoot, "invoke-autocomplete.fixture.ts") const source = ` import { Effect, Stream } from "effect" diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 18a9965..b2d91f5 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -3,7 +3,7 @@ import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { spawnSync } from "node:child_process" -const root = resolve(import.meta.dirname, "..") +const root = resolve(import.meta.dirname, "../packages/effect-machine") const destination = await mkdtemp(join(tmpdir(), "effect-machine-pack-")) try { diff --git a/scripts/release-contract.test.mjs b/scripts/release-contract.test.mjs new file mode 100644 index 0000000..1d0ae7e --- /dev/null +++ b/scripts/release-contract.test.mjs @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert" +import { readFile } from "node:fs/promises" +import { test } from "node:test" +import { resolve } from "node:path" + +const repositoryRoot = resolve(import.meta.dirname, "..") + +const readJson = async (path) => JSON.parse(await readFile(resolve(repositoryRoot, path), "utf8")) + +test("core and devtools release with the same version", async () => { + const [changesets, core, devtools] = await Promise.all([ + readJson(".changeset/config.json"), + readJson("packages/effect-machine/package.json"), + readJson("packages/devtools/package.json") + ]) + + assert.equal(devtools.version, core.version) + assert.equal(devtools.dependencies[core.name], "workspace:^") + assert.ok( + changesets.fixed.some((group) => + group.length === 2 && + group.includes(core.name) && + group.includes(devtools.name) + ), + "core and devtools must remain in the same Changesets fixed group" + ) +}) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index be210db..ff39a69 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert" import { test } from "node:test" -import { makeEffectMachineBenchmarkApi } from "../perf/runtime/effect-machine-compatibility.mjs" +import { makeEffectMachineBenchmarkApi } from "../packages/effect-machine/perf/runtime/effect-machine-compatibility.mjs" test("adapts the state-definition constructor across the public rename", () => { const definitions = { Idle: "schema" } diff --git a/scripts/runtime-performance.mjs b/scripts/runtime-performance.mjs index 3e3bd86..664ab41 100644 --- a/scripts/runtime-performance.mjs +++ b/scripts/runtime-performance.mjs @@ -10,7 +10,7 @@ import { memoryImplementations, packageVersions, runtimeReferenceImplementations -} from "../perf/runtime/implementations.mjs" +} from "../packages/effect-machine/perf/runtime/implementations.mjs" const sourceRevision = (() => { try { return { @@ -391,7 +391,9 @@ const benchmarks = bench.tasks.map((task) => { } }) -const memoryWorker = fileURLToPath(new URL("../perf/runtime/memory-worker.mjs", import.meta.url)) +const memoryWorker = fileURLToPath( + new URL("../packages/effect-machine/perf/runtime/memory-worker.mjs", import.meta.url) +) const memory = memoryImplementations.map((implementation) => { const profiles = Object.keys(implementation.memoryProfiles).map((profileId) => JSON.parse( diff --git a/scripts/test-consumer.mjs b/scripts/test-consumer.mjs index e99840e..9e70a50 100644 --- a/scripts/test-consumer.mjs +++ b/scripts/test-consumer.mjs @@ -3,7 +3,8 @@ import { cp, mkdir, mkdtemp, readdir, rm, symlink } from "node:fs/promises" import { tmpdir } from "node:os" import { join, resolve } from "node:path" -const root = resolve(import.meta.dirname, "..") +const repositoryRoot = resolve(import.meta.dirname, "..") +const root = join(repositoryRoot, "packages", "effect-machine") const destination = await mkdtemp(join(tmpdir(), "effect-machine-consumer-")) const run = (command, args, options = {}) => { @@ -30,20 +31,24 @@ try { const consumer = join(destination, "consumer") const packageDirectory = join(consumer, "node_modules", "@typeonce", "effect-machine") - await cp(join(root, "scripts", "fixtures", "consumer"), consumer, { + await cp(join(repositoryRoot, "scripts", "fixtures", "consumer"), consumer, { recursive: true }) await mkdir(packageDirectory, { recursive: true }) run("tar", ["-xzf", join(destination, archives[0]), "-C", packageDirectory, "--strip-components=1"], { cwd: root }) - await symlink(join(root, "node_modules", "effect"), join(consumer, "node_modules", "effect"), "dir") + await symlink(join(repositoryRoot, "node_modules", "effect"), join(consumer, "node_modules", "effect"), "dir") await mkdir(join(consumer, "node_modules", "@types"), { recursive: true }) - await symlink(join(root, "node_modules", "@types", "node"), join(consumer, "node_modules", "@types", "node"), "dir") + await symlink( + join(repositoryRoot, "node_modules", "@types", "node"), + join(consumer, "node_modules", "@types", "node"), + "dir" + ) run( process.execPath, - [join(root, "node_modules", "typescript", "bin", "tsc"), "-p", join(consumer, "tsconfig.json")], + [join(repositoryRoot, "node_modules", "typescript", "bin", "tsc"), "-p", join(consumer, "tsconfig.json")], { cwd: consumer } ) if (process.env.TSGO_BIN !== undefined) { diff --git a/scripts/type-performance.mjs b/scripts/type-performance.mjs index 4532936..dde2723 100644 --- a/scripts/type-performance.mjs +++ b/scripts/type-performance.mjs @@ -29,6 +29,9 @@ for (let index = 2; index < process.argv.length; index += 1) { const root = options.root const tsc = resolve(root, "node_modules", "typescript", "bin", "tsc") +const performanceDirectory = existsSync(resolve(root, "packages", "effect-machine", "perf", "types")) + ? resolve(root, "packages", "effect-machine", "perf", "types") + : resolve(root, "perf", "types") // Instantiation counts are deterministic for the pinned compiler. Budgets keep // roughly thirty percent headroom; wall-clock check time is never gated. @@ -242,7 +245,7 @@ const version = run(["--version"]) const results = new Map() for (const scenario of scenarios) { - const file = resolve(root, "perf", "types", scenario.file) + const file = resolve(performanceDirectory, scenario.file) if (!existsSync(file)) { if (options.allowMissing) { continue diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..3309688 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": [], + "compilerOptions": { + "composite": true, + "incremental": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": [] + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index 4bec050..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "rootDir": "src", - "outDir": "dist", - "types": [] - }, - "include": ["src/**/*.ts"], - "exclude": ["test", "typetest"] -} diff --git a/tsconfig.json b/tsconfig.json index 68e0bac..91962d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,7 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": true, - "skipLibCheck": true, - "noEmit": true, - "types": ["node", "vitest/globals"], - "verbatimModuleSyntax": true, - "exactOptionalPropertyTypes": true - }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "$schema": "https://json.schemastore.org/tsconfig", + "include": [], + "references": [ + { "path": "tsconfig.packages.json" } + ] } diff --git a/tsconfig.packages.json b/tsconfig.packages.json new file mode 100644 index 0000000..269da2c --- /dev/null +++ b/tsconfig.packages.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "include": [], + "references": [ + { "path": "packages/effect-machine" } + ] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..db2e20f --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.base.json", + "include": ["packages/*/test/**/*.ts"], + "exclude": ["**/dist/**", "**/node_modules/**", "references/**"], + "references": [ + { "path": "packages/effect-machine" } + ], + "compilerOptions": { + "composite": false, + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "types": ["node", "vitest/globals"], + "paths": { + "@typeonce/effect-machine": ["./packages/effect-machine/src/index.ts"], + "@typeonce/effect-machine/*": ["./packages/effect-machine/src/*.ts"] + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts index dcaac60..d85488e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { - include: ["test/**/*.test.ts"] + include: ["packages/*/test/**/*.test.ts"] } })