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
12 changes: 12 additions & 0 deletions .changeset/bright-roots-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-react": minor
"@typeonce/effect-machine-devtools": minor
"@typeonce/oxlint-plugin-effect-machine": minor
---

Model each machine with one `Machine.state` root, passed to `Machine.make({ root })`. Root `fields` support data-only machines and shared data that survives child transitions. Replace the former top-level state map with a root descriptor, put child handlers under `handle({ states })`, and use `initial` for root values or `initialConfiguration` for a complete startup override. Add direct target construction, non-reentering `self.update`, and guards with ancestor fallback. Construct local event protocols from field records or import existing schemas with the explicit `FromSchemas` constructors.

Render typed state paths with React `MachineState` and own isolated instances with `createMachineContext(AtomMachine.factory(machine))`. Providers capture startup input without subscribing to state. Replace Atom bridge `.state` reads with `.result` or path selectors; `.snapshot` retains runtime status. Core `MachineRef.state` remains available. Testing runs and probes accept public deferred event inputs and record decoded receipts.

Encoded snapshots use version 2 and include the root at path `""`; explicitly migrate older persisted snapshots before decoding. Replace removed `Machine.Emit`, `Machine.Emits`, `Machine.EmitOf`, and `Machine.PseudoStateAnnotations` aliases with `EmittedEvent`, `EmittedEvents`, `EmittedEventOf`, and `SchemaLessStateAnnotations`.
34 changes: 10 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,18 @@ Effect-native, schema-first, completely type-safe state machines and statecharts

```ts
import { Machine } from "@typeonce/effect-machine"
import { Effect, Schema } from "effect"
import { Effect } from "effect"

const States = Machine.states({
Locked: {},
Unlocked: {}
const Root = Machine.state({
initial: "Locked",
states: { Locked: {}, Unlocked: {} }
})
const Events = Machine.events({ Coin: {}, Push: {} })

const Events = Machine.events(
Schema.TaggedUnion({
Coin: {},
Push: {}
})
)

const Turnstile = Machine.make({
id: "Turnstile",
states: States.states,
events: Events,
initial: (to) => to.Locked()
}).handle({
Locked: {
on: { Coin: (to) => to.full.Unlocked() }
},
Unlocked: {
on: { Push: (to) => to.full.Locked() }
const Turnstile = Machine.make({ root: Root, events: Events }).handle({
states: {
Locked: { on: { Coin: (to) => to.local.Unlocked() } },
Unlocked: { on: { Push: (to) => to.local.Locked() } }
}
})

Expand All @@ -44,8 +31,7 @@ const program = Effect.gen(function*() {
})
```

State and event schemas define the protocol. The handler tree defines the
statechart, and the result runs as an Effect-managed machine.
State and event schemas define the protocol. The root defines the topology and the handler tree adds behavior, and the result runs as an Effect-managed machine.

## Packages

Expand Down
8 changes: 3 additions & 5 deletions api-reference.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"title": "Define state topology",
"description": "Declare the complete state tree and its schema-backed values before constructing the machine.",
"entries": [
{ "declaration": "states" },
{ "declaration": "state" }
]
},
Expand Down Expand Up @@ -84,9 +83,9 @@
],
"usageSections": [
{
"owner": "states",
"owner": "state",
"title": "State node configuration",
"description": "Properties accepted while declaring the state tree passed to `Machine.states`.",
"description": "Properties accepted by root and nested `Machine.state` descriptors.",
"roots": [
{ "reflection": "Machine.AtomicStateNodeConfig", "label": "Atomic and final states" },
{ "reflection": "Machine.CompoundStateNodeConfig", "label": "Compound states" },
Expand Down Expand Up @@ -176,8 +175,7 @@
"planInitial",
"resume",
"state",
"start",
"states"
"start"
]
},
{
Expand Down
25 changes: 21 additions & 4 deletions packages/devtools/src/internal/browser/chart-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,18 +1036,28 @@ const routeLabelCandidates = (
const required = (horizontal ? width : height) + 24
return length < required ? [] : [{ start, end, horizontal, length }]
})
const adjacent: Array<ChartPoint> = []
const ordered = [
...candidates.filter(({ horizontal }) => !horizontal).sort((left, right) => right.length - left.length),
...candidates.filter(({ horizontal }) => horizontal).sort((left, right) => right.length - left.length)
].flatMap(({ start, end, horizontal, length }) => {
const clearance = (horizontal ? width : height) / 2 + 12
return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => {
return [0.5, 2 / 3, 1 / 3, 0.2, 0.8, 0.1, 0.9].flatMap((ratio) => {
const distance = length * ratio
if (distance < clearance || length - distance < clearance) return []
const onRoute = {
x: start.x + (end.x - start.x) * ratio,
y: start.y + (end.y - start.y) * ratio
}
// A root container can place two routes in the same corridor. Keep the
// label attached beside its own segment when no position on it is clear.
for (const direction of [-1, 1]) {
adjacent.push(
horizontal
? { x: onRoute.x, y: onRoute.y + direction * (height / 2 + chartEdgeLabelSpacing) }
: { x: onRoute.x + direction * (width / 2 + chartEdgeLabelSpacing), y: onRoute.y }
)
}
if (horizontal || localDescendantTarget === undefined) return [onRoute]
const targetCenter = localDescendantTarget.x + localDescendantTarget.width / 2
const direction = targetCenter < onRoute.x ? -1 : 1
Expand All @@ -1057,7 +1067,7 @@ const routeLabelCandidates = (
}, onRoute]
})
})
return [...ordered, fallback].filter((candidate, index, all) =>
return [...ordered, ...adjacent, fallback].filter((candidate, index, all) =>
all.findIndex((other) => other.x === candidate.x && other.y === candidate.y) === index
)
}
Expand Down Expand Up @@ -1120,7 +1130,8 @@ const placeTransitionLabels = (
const collectLayout = (
model: ChartModel,
graph: ElkNode,
unconnected: ReadonlyArray<UnconnectedRegion>
unconnected: ReadonlyArray<UnconnectedRegion>,
shortenRoutes = true
): LaidOutChart => {
const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
Expand Down Expand Up @@ -1202,7 +1213,7 @@ const collectLayout = (
chartEdge,
avoidCompoundHeaders(
chartEdge,
shortenTransitionRoute(
(shortenRoutes ? shortenTransitionRoute : (_edge: ChartEdge, route: ReadonlyArray<ChartPoint>) => route)(
chartEdge,
normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath, nodes, hierarchyLanes),
nodesByPath,
Expand Down Expand Up @@ -1708,6 +1719,12 @@ export const layoutChartWith = (
const validation = validate(model, candidate)
if (validation.valid) return Effect.succeed(candidate)
invalid.push({ profile: profile.id, layout: candidate, validation })
// Route shortening can crowd labels beside unrelated transitions.
// Keep the original ELK corridor as a checked alternative.
const originalRoutes = collectLayout(model, graph, regions, false)
const originalValidation = validate(model, originalRoutes)
if (originalValidation.valid) return Effect.succeed(originalRoutes)
invalid.push({ profile: profile.id, layout: originalRoutes, validation: originalValidation })
return attempt(index + 1)
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools/src/internal/browser/chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export interface ChartModel {
readonly initials: ReadonlyArray<ChartInitial>
}

export const isChartStateDescendant = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`)
export const isChartStateDescendant = (path: string, ancestor: string): boolean =>
ancestor === "" ? path !== "" : path.startsWith(`${ancestor}.`)

const activityLabel = (activity: VisualizationActivity): string => {
switch (activity.type) {
Expand Down
179 changes: 94 additions & 85 deletions packages/devtools/src/internal/browser/example-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,116 +24,125 @@ class Finish extends Schema.TaggedClass<Finish>("Finish")("Finish", {}) {}
class Disconnect extends Schema.TaggedClass<Disconnect>("Disconnect")("Disconnect", {}) {}
class Refresh extends Schema.TaggedClass<Refresh>("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"
const States = Machine.state({
initial: "application",
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"
}
},
recent: {
type: "history"
}
}
},
connection: {
schema: Connection,
initial: "online",
states: {
online: Online,
offline: Offline
},
connection: {
schema: Connection,
initial: "online",
states: {
online: Online,
offline: Offline
}
}
}
}
},
disabled: Disabled
},
disabled: Disabled
}
})

export const snapshot = {
path: "application" as const,
value: new Application({ workspace: "effect-machine", revision: 7 }),
states: {
workflow: {
path: "application.workflow" as const,
value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }),
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({}) }
path: "" as const,
value: undefined,
state: {
path: "application" as const,
value: new Application({ workspace: "effect-machine", revision: 7 }),
states: {
workflow: {
path: "application.workflow" as const,
value: new Workflow({ document: "Machine.ts", unsavedChanges: 2 }),
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,
{ readonly "": typeof States.node },
"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)
root: States,
events: Machine.eventsFromSchemas(Start, Finish, Disconnect, Refresh),
initialConfiguration: (to) => to.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({ document: "Machine.ts", unsavedChanges: 3 })))
),
Refresh: (to) =>
to.local.update(({ owner }) =>
owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 }))
)
states: {
application: {
states: {
workflow: {
history: {
recent: {
default: initialWorkflow
}
},
running: {
initialize: ({ builder }) => builder.decoded(new Editing({})),
states: {
editing: {
on: {
Finish: (to) => to.local.complete().resolve(({ target }) => target.decoded(new Complete({})))
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({ document: "Machine.ts", unsavedChanges: 3 })))
),
Refresh: (to) =>
to.local.update.resolve(({ owner }) =>
owner.decoded(new Workflow({ document: "Machine.ts", unsavedChanges: 0 }))
)
}
},
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({})))
},
connection: {
states: {
online: {
on: {
Disconnect: (to) => to.local.offline().resolve(({ target }) => target.decoded(new Offline({})))
}
}
}
}
Expand Down
Loading