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
5 changes: 5 additions & 0 deletions .changeset/calm-machines-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@typeonce/effect-machine": patch
---

Decompose machine topology, schema protocols, configurations, snapshot serialization, command execution, semantic planning, and compiled execution plans into explicit internal modules without changing runtime behavior or public types.
79 changes: 77 additions & 2 deletions scripts/check-architecture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const ruleDescriptions = {
ARCH002: "Public modules may only reach internals through their designated implementation seam",
ARCH003: "Core internals may only refer back to Machine through type-only imports",
ARCH004: "The planner may not depend on process or runtime execution",
ARCH005: "Machine semantic layers may only depend inward",
ARCH006: "The runtime may not depend on machine semantics or process orchestration",
ARCH007: "Production modules may not depend on testing internals",
ARCH008: "Black-box tests may not depend on implementation internals",
Expand Down Expand Up @@ -219,6 +220,65 @@ export const checkArchitecture = ({
["src/unstable/reactivity/AtomMachine.ts", "src/internal/machine/atom.ts"],
["src/unstable/cluster/ClusterMachine.ts", "src/internal/machine/cluster.ts"]
])
const forbiddenSemanticDependencies = new Map([
["src/internal/machine/topology.ts", new Set([
"src/internal/machine/protocol.ts",
"src/internal/machine/configuration.ts",
"src/internal/machine/serialization.ts",
"src/internal/machine/planner.ts",
"src/internal/machine/command.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/protocol.ts", new Set([
"src/internal/machine/configuration.ts",
"src/internal/machine/serialization.ts",
"src/internal/machine/planner.ts",
"src/internal/machine/command.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/configuration.ts", new Set([
"src/internal/machine/serialization.ts",
"src/internal/machine/planner.ts",
"src/internal/machine/command.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/serialization.ts", new Set([
"src/internal/machine/planner.ts",
"src/internal/machine/command.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/command.ts", new Set([
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/planner.ts", new Set([
"src/internal/machine/serialization.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])],
["src/internal/machine/executionPlan.ts", new Set([
"src/internal/machine/serialization.ts",
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
])]
])

for (const edge of edges) {
if (entrypoints.has(edge.source) && edge.target.includes("/internal/")) {
Expand Down Expand Up @@ -261,7 +321,12 @@ export const checkArchitecture = ({
if (
edge.source === "src/internal/machine/planner.ts" &&
!edge.typeOnly &&
(edge.target === "src/internal/machine/process.ts" || edge.target === "src/internal/machine/runtime.ts")
[
"src/internal/machine/commandRuntime.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/process.ts",
"src/internal/machine/runtime.ts"
].includes(edge.target)
) {
diagnostics.push(diagnostic(
"ARCH004",
Expand All @@ -271,10 +336,20 @@ export const checkArchitecture = ({
`Planner has a runtime dependency on ${edge.target}`
))
}
if (forbiddenSemanticDependencies.get(edge.source)?.has(edge.target)) {
diagnostics.push(diagnostic(
"ARCH005",
edge.sourceFile,
edge.node,
edge.source,
`Semantic layer depends outward on ${edge.target}`
))
}
if (
edge.source === "src/internal/machine/runtime.ts" &&
[
"src/internal/machine/model.ts",
"src/internal/machine/configuration.ts",
"src/internal/machine/executionPlan.ts",
"src/internal/machine/planner.ts",
"src/internal/machine/process.ts"
].includes(edge.target)
Expand Down
10 changes: 9 additions & 1 deletion scripts/check-architecture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ test("rejects value back-edges and execution-layer inversions", () => {
"src/internal/testing/machine/arbitrary.ts": "export const arbitrary = 1",
"src/consumer.ts": 'import { arbitrary } from "./internal/testing/machine/arbitrary.js"\nvoid arbitrary'
})
assert.deepEqual(rules(root), ["ARCH007", "ARCH003", "ARCH004", "ARCH006"])
assert.deepEqual(rules(root), ["ARCH007", "ARCH003", "ARCH004", "ARCH005", "ARCH006"])
})

test("rejects outward machine semantic dependencies", () => {
const root = makeProject({
"src/internal/machine/topology.ts": 'import { decode } from "./protocol.js"\nexport const topology = decode',
"src/internal/machine/protocol.ts": "export const decode = 1"
})
assert.deepEqual(rules(root), ["ARCH005"])
})

test("detects runtime cycles while permitting type-only cycles", () => {
Expand Down
12 changes: 6 additions & 6 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ import type {
} from "./internal/machine/machine.js"
import * as internal from "./internal/machine/machine.js"
import { InitialEventTypeId } from "./internal/machine/machine.js"
import type * as Model from "./internal/machine/model.js"
import type { EnsureExecutable } from "./internal/machine/readiness.js"
import type * as internalRuntime from "./internal/machine/runtime.js"
import type * as StateDefinition from "./internal/machine/stateDefinition.js"
import type * as Topology from "./internal/machine/topology.js"

/**
* String literal type used as the runtime type identifier for `Machine`
Expand Down Expand Up @@ -3394,7 +3394,7 @@ export declare namespace Machine {
* @since 4.0.0
*/
export interface StateConstruction<Result> {
readonly [Model.StateConstructionTypeId]: Result
readonly [Topology.StateConstructionTypeId]: Result
}

/**
Expand All @@ -3407,8 +3407,8 @@ export declare namespace Machine {
States extends StateSchemas,
StateId extends StateIdentifier<States>
> {
readonly [Model.TargetTypeId]: typeof Model.TargetTypeId
readonly [Model.TargetSnapshotTypeId]?: SnapshotByIdentifier<States, StateId>
readonly [Topology.TargetTypeId]: typeof Topology.TargetTypeId
readonly [Topology.TargetSnapshotTypeId]?: SnapshotByIdentifier<States, StateId>
readonly path: StateId
readonly value: StateByIdentifier<States, StateId>
readonly values?: Partial<
Expand All @@ -3432,14 +3432,14 @@ export declare namespace Machine {
States extends StateSchemas,
HistoryId extends HistoryIdentifier<States>
> {
readonly [Model.HistoryTargetTypeId]: typeof Model.HistoryTargetTypeId
readonly [Topology.HistoryTargetTypeId]: typeof Topology.HistoryTargetTypeId
readonly path: HistoryId
readonly parent: Extract<ParentPath<HistoryId>, StateIdentifier<States>>
}

/** Branded transient target instruction used while constructing initial states. */
export interface ChoiceTargetInstruction<ChoiceId extends string = string> {
readonly [Model.ChoiceTargetTypeId]: typeof Model.ChoiceTargetTypeId
readonly [Topology.ChoiceTargetTypeId]: typeof Topology.ChoiceTargetTypeId
readonly path: ChoiceId
readonly parent: ParentPath<ChoiceId>
readonly values?: Readonly<Record<string, unknown>>
Expand Down
8 changes: 4 additions & 4 deletions src/internal/machine/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity
import type * as Machine from "../../Machine.js"
import type { Bound, ChildMachineAtom, ChildOf, MachineAtom } from "../../unstable/reactivity/AtomMachine.js"
import * as internalMachine from "./machine.js"
import * as Model from "./model.js"
import type { EnsureExecutable } from "./readiness.js"
import * as Topology from "./topology.js"

export class NotReadyError extends Data.TaggedError("NotReadyError") {}

Expand Down Expand Up @@ -448,7 +448,7 @@ const selectSnapshot = <
snapshot: State,
path: Path
): Option.Option<SnapshotValueByIdentifier<State, Path>> =>
Model.getSnapshotByPath(snapshot, path).pipe(
Topology.getSnapshotByPath(snapshot, path).pipe(
Option.map((snapshot) => snapshot.value)
) as Option.Option<SnapshotValueByIdentifier<State, Path>>

Expand Down Expand Up @@ -498,7 +498,7 @@ export const matches = <
self: MachineAtom<State, Event, Error, Output, StartError>,
path: Path
): Atom.Atom<AsyncResult.AsyncResult<boolean, StartError | Error>> =>
Atom.mapResult(self.result, (snapshot) => Option.isSome(Model.getSnapshotByPath(snapshot, path))).pipe(
Atom.mapResult(self.result, (snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path))).pipe(
Atom.withEquality(Equal.equals)
)

Expand All @@ -514,7 +514,7 @@ export const matchesChild = <
> =>
Atom.mapResult(
self.result,
Option.exists((snapshot) => Option.isSome(Model.getSnapshotByPath(snapshot, path)))
Option.exists((snapshot) => Option.isSome(Topology.getSnapshotByPath(snapshot, path)))
).pipe(Atom.withEquality(Equal.equals))

const BoundRequirementsTypeId = "~effect/reactivity/AtomMachine/BoundRequirements"
Expand Down
58 changes: 58 additions & 0 deletions src/internal/machine/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Internal machine command collection.
*
* @since 4.0.0
*/

import type { Command, Enqueue, Machine } from "../../Machine.js"
import { decodeEmitSync, decodeEventSync } from "./protocol.js"

export type RuntimeCommand = Command

export interface Collected<Event> {
readonly enqueue: Enqueue<Event, unknown>
readonly commands: Array<RuntimeCommand>
readonly raisedEvents: Array<Event>
readonly emittedEvents: Array<unknown>
}

const targetBuilderCache = new WeakMap<object, Map<string, unknown>>()

export const getTargetBuilder = (machine: Machine.Any, path: string): any => {
let byPath = targetBuilderCache.get(machine)
if (byPath === undefined) {
byPath = new Map()
targetBuilderCache.set(machine, byPath)
}
if (byPath.has(path)) {
return byPath.get(path)
}
const builder = machine.makeTargetBuilder(path as any)
byPath.set(path, builder)
return builder
}

export const makeCollector = <Event>(machine: Machine.Any): Collected<Event> => {
const commands: Array<RuntimeCommand> = []
const raisedEvents: Array<Event> = []
const emittedEvents: Array<unknown> = []
return {
commands,
raisedEvents,
emittedEvents,
enqueue: {
raise: (event) => {
raisedEvents.push(decodeEventSync(machine, event) as Event)
},
emit: (event) => {
emittedEvents.push(decodeEmitSync(machine, event))
},
sendTo: (child: unknown, event: unknown) => {
commands.push({ _tag: "SendTo", child: child as any, event })
},
stop: (child: unknown) => {
commands.push({ _tag: "Stop", child: child as any })
}
}
}
}
43 changes: 43 additions & 0 deletions src/internal/machine/commandRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Internal process-side machine command execution.
*
* @since 4.0.0
*/

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"

export const makeLiveRuntime = <Events, Emits>(
machine: Machine.Any,
scope: ProcessScope<Events>
): Runtime<Events, Emits> => ({
raise: (event) =>
decodeEvent(machine, event).pipe(
Effect.flatMap((event) => scope.self.send(event as Events))
),
sendParent: (event) =>
decodeEmit(machine, event).pipe(
Effect.flatMap((event) => scope.sendParent(event))
)
})

export const runCommands = <Event>(
commands: Iterable<RuntimeCommand>,
scope: ProcessScope<Event>
) =>
Effect.forEach(commands, (command) =>
command._tag === "SendTo"
? scope.sendTo(command.child as never, command.event)
: scope.stopChild(command.child as never), { discard: true })

export const runEmittedEvents = <Events, Emits>(
events: Iterable<Emits>,
runtime: Runtime<Events, Emits>
) =>
Effect.all(
Array.from(events, (event) => runtime.sendParent(event)),
{ discard: true }
)
Loading