Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/clear-machine-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/effect-machine": patch
---

Clarify declarative guards in the machine documentation and add a checked example of guarded state construction.

Improve internal typing and organization for machine definitions, execution strategies, snapshot validation, and Cluster contracts. Existing public APIs and machine behavior remain unchanged.
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ and process layers. Testing implementations are isolated under
`packages/effect-machine/src/internal/testing` and may only be consumed by the public testing module or
other testing internals.

`implementation.ts` owns the captured handler representation. Public handler
callbacks author transitions through selectors; captured handlers contain the
executable transitions. Semantic layers use `toImpl` to access that representation
instead of treating the public erased handler fields as `any`. Existing public
fields and type parameters remain available for compatibility.

Runtime responsibilities are separated as follows:

- `runtime.ts` allocates a root runtime and selects an execution strategy.
- `runtimeProtocol.ts` owns shared process contracts, child ownership, mailboxes,
and observation. It receives startup functions from the coordinator so child
startup does not create a dependency back to either execution strategy.
- `runtimeGeneric.ts` runs the general Effect worker and supervisor.
- `runtimeCompiled.ts` runs compiled statecharts, including synchronous owned
child startup. It preserves the same lifecycle contract as the generic runtime.

Consumers import directly from the module that owns a contract. Runtime modules
must remain independent of planning and statechart semantics. The Cluster adapter
similarly shares its checkpoint service and wire schemas through `clusterProtocol.ts`.

`pnpm check:architecture` builds a TypeScript dependency graph using the
project's NodeNext resolver. It distinguishes type-only and runtime edges,
understands imports, re-exports, and dynamic imports, and enforces:
Expand Down
5 changes: 3 additions & 2 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -939,8 +939,9 @@ 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
Use `.guard(predicate)` to decline a transition before state construction, or
`.resolve(..., { declinable: true })` to use `decline()` during resolution.
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
Expand Down
29 changes: 25 additions & 4 deletions packages/effect-machine/src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import * as internal from "./internal/machine/machine.js"
import { InitialEventTypeId } from "./internal/machine/machine.js"
import type { EnsureExecutable } from "./internal/machine/readiness.js"
import type { ExcludeCompatibleRuntime } from "./internal/machine/requirements.js"
import type * as internalRuntime from "./internal/machine/runtime.js"
import type * as internalRuntime from "./internal/machine/runtimeProtocol.js"
import type * as StateDefinition from "./internal/machine/stateDefinition.js"
import type * as Topology from "./internal/machine/topology.js"

Expand Down Expand Up @@ -111,9 +111,30 @@ type IsAny<A> = 0 extends (1 & A) ? true : false
*
* **Gotchas**
*
* Declarative first-class guards are not part of the current API. Conditional
* behavior can be expressed in typed handlers with ordinary TypeScript control
* flow. Use `after` for cancellable state-scoped delayed events.
* Use `.guard(predicate)` to decline a transition before constructing its next
* state. Use `.resolve(..., { declinable: true })` when conditional resolution
* needs to return `decline()`. Use `after` for cancellable state-scoped delays.
*
* **Example**
*
* ```ts
* import { Machine } from "@typeonce/effect-machine"
* import { Schema } from "effect"
*
* const events = Machine.events({ Add: { by: Schema.Number } })
* export const counter = Machine.make({
* root: Machine.state({ fields: { count: Schema.Number } }),
* events,
* initial: (root) => root.from(() => ({ count: 0 }))
* }).handle({
* on: {
* Add: (to) =>
* to.self.update.guard(({ event }) => event.by > 0).from(({ current, event }) => ({
* count: current.count + event.by
* }))
* }
* })
* ```
*
* @category models
* @since 0.4.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Option from "effect/Option"
import type * as Scope from "effect/Scope"
import type { MachineRef } from "./runtime.js"
import type { MachineRef } from "./runtimeProtocol.js"

export type ChildDescriptor = {
readonly id: string
Expand Down
80 changes: 7 additions & 73 deletions packages/effect-machine/src/internal/machine/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,92 +4,26 @@
* @since 0.4.0
*/
import * as Cause from "effect/Cause"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Schema from "effect/Schema"
import { ClusterError, ClusterSchema, Entity, EntityAddress, MessageStorage, Snowflake } from "effect/unstable/cluster"
import { ClusterSchema, Entity, MessageStorage } from "effect/unstable/cluster"
import type { EntityAddress, Snowflake } from "effect/unstable/cluster"
import { Rpc } from "effect/unstable/rpc"
import type * as Machine from "../../Machine.js"
import type { Checkpoint, ClusterMachine, LoadResult } from "../../unstable/cluster/ClusterMachine.js"
import type { Checkpoint } from "../../unstable/cluster/ClusterMachine.js"
import type { ClusterMachine } from "../../unstable/cluster/ClusterMachine.js"
import { Accepted, CommitResult, Rejected, type RejectionReason, SendResult, Storage } from "./clusterProtocol.js"
import { toImpl } from "./implementation.js"
import * as internalMachine from "./machine.js"
import * as Protocol from "./protocol.js"
import type { EnsureExecutable } from "./readiness.js"
import type { ExcludeCompatibleRuntime } from "./requirements.js"

type EntityAddress = EntityAddress.EntityAddress
type PersistenceError = ClusterError.PersistenceError
type Snowflake = Snowflake.Snowflake

export type CommitResult = CommitResult.Committed | CommitResult.Duplicate

export const CommitResult = {
Committed: (): CommitResult.Committed => ({ _tag: "Committed" }),
Duplicate: (): CommitResult.Duplicate => ({ _tag: "Duplicate" })
}

export declare namespace CommitResult {
/**
* Indicates that the request id and checkpoint were committed atomically.
*
* @category models
* @since 0.4.0
*/
export interface Committed {
readonly _tag: "Committed"
}

/**
* Indicates that the request id was already committed.
*
* @category models
* @since 0.4.0
*/
export interface Duplicate {
readonly _tag: "Duplicate"
}
}

export class Storage extends Context.Service<Storage, {
readonly load: (
address: EntityAddress,
requestId: Snowflake
) => Effect.Effect<LoadResult, PersistenceError>
readonly commit: (
address: EntityAddress,
checkpoint: Checkpoint
) => Effect.Effect<CommitResult, PersistenceError>
}>()("effect/cluster/ClusterMachine/Storage") {}

export class Accepted extends Schema.TaggedClass<Accepted>("effect/cluster/ClusterMachine/Accepted")(
"Accepted",
{}
) {}

export const RejectionReason = Schema.Literals([
"MachineIdMismatch",
"VersionMismatch",
"InvalidCheckpoint",
"UnsupportedProcessLocal",
"TransitionFailure",
"SnapshotEncodeFailure",
"PersistenceFailure",
"EmissionFailure"
])

export type RejectionReason = typeof RejectionReason.Type

export class Rejected extends Schema.TaggedClass<Rejected>("effect/cluster/ClusterMachine/Rejected")(
"Rejected",
{
reason: RejectionReason,
message: Schema.String
}
) {}

export const SendResult = Schema.Union([Accepted, Rejected])

type SendRpc<Events extends ReadonlyArray<Machine.Machine.TaggedSchema>> = Rpc.Rpc<
"send",
Schema.Union<Events>,
Expand All @@ -101,7 +35,7 @@ type MachineEvents<M extends Machine.Machine.Any> = Machine.Machine.InputEvents<
type MachineEmits<M extends Machine.Machine.Any> = Machine.Machine.EmittedEvents<M>

const hasInvokes = (machine: Machine.Machine.Any): boolean =>
Reflect.ownKeys(machine.handlers).some((key) => machine.handlers[key as string]?.invoke !== undefined)
Reflect.ownKeys(toImpl(machine).handlers).some((key) => toImpl(machine).handlers[key as string]?.invoke !== undefined)

const reject = (reason: RejectionReason, message: string): Rejected => new Rejected({ reason, message })

Expand Down
56 changes: 56 additions & 0 deletions packages/effect-machine/src/internal/machine/clusterProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** Canonical checkpoint service and wire schemas for the Cluster adapter. */
import * as Context from "effect/Context"
import type * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import type { ClusterError, EntityAddress, Snowflake } from "effect/unstable/cluster"
import type * as Public from "../../unstable/cluster/ClusterMachine.js"
import type { Checkpoint, LoadResult } from "../../unstable/cluster/ClusterMachine.js"
type EntityAddress = EntityAddress.EntityAddress
type PersistenceError = ClusterError.PersistenceError
type Snowflake = Snowflake.Snowflake

export type CommitResult = Public.CommitResult

export const CommitResult = {
Committed: (): Public.CommitResult.Committed => ({ _tag: "Committed" }),
Duplicate: (): Public.CommitResult.Duplicate => ({ _tag: "Duplicate" })
}

export class Storage extends Context.Service<Storage, {
readonly load: (
address: EntityAddress,
requestId: Snowflake
) => Effect.Effect<LoadResult, PersistenceError>
readonly commit: (
address: EntityAddress,
checkpoint: Checkpoint
) => Effect.Effect<CommitResult, PersistenceError>
}>()("effect/cluster/ClusterMachine/Storage") {}

export class Accepted extends Schema.TaggedClass<Accepted>("effect/cluster/ClusterMachine/Accepted")(
"Accepted",
{}
) {}

export const RejectionReason = Schema.Literals([
"MachineIdMismatch",
"VersionMismatch",
"InvalidCheckpoint",
"UnsupportedProcessLocal",
"TransitionFailure",
"SnapshotEncodeFailure",
"PersistenceFailure",
"EmissionFailure"
])

export type RejectionReason = typeof RejectionReason.Type

export class Rejected extends Schema.TaggedClass<Rejected>("effect/cluster/ClusterMachine/Rejected")(
"Rejected",
{
reason: RejectionReason,
message: Schema.String
}
) {}

export const SendResult = Schema.Union([Accepted, Rejected])
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as Effect from "effect/Effect"
import type { Machine, Runtime } from "../../Machine.js"
import type { RuntimeCommand } from "./command.js"
import { decodeEmit, decodeEvent } from "./protocol.js"
import type { ProcessScope } from "./runtime.js"
import type { ProcessScope } from "./runtimeProtocol.js"

export const makeLiveRuntime = <Events, Emits>(
machine: Machine.Any,
Expand Down
Loading