diff --git a/.changeset/quiet-states.md b/.changeset/quiet-states.md new file mode 100644 index 0000000..12c965d --- /dev/null +++ b/.changeset/quiet-states.md @@ -0,0 +1,16 @@ +--- +"@typeonce/effect-machine": minor +--- + +Allow active states to omit `schema` when they own no data. Schema-less atomic, compound, parallel, and final states keep full control-flow semantics while exposing value-free `.from(...)` builders, `undefined` handler state, and snapshot-only query APIs. + +```ts +const States = Machine.defineStates({ + Form: { + initial: "Editing", + states: { Editing: {}, Saving } + } +}) + +States.initial.Form.from((form) => form.Editing.from()) +``` diff --git a/README.md b/README.md index 1fe22c7..2440fce 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,27 @@ defaults, refinements, and tagged-class identity are therefore preserved, and decode failures remain typed machine failures. Pass a value directly only when it is already decoded, such as a value returned by `Machine.retag`. +Omit `schema` when a state represents control flow but owns no data: + +```ts +const States = Machine.defineStates({ + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving + } + } +}) + +States.initial.Form.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. + Put data on the narrowest state where it is valid. If sibling phases share data, put it on their compound parent. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 85c1279..10b4d09 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -21,7 +21,7 @@ Use this order so inference has all schemas available when handlers are declared: 1. Domain schemas used by state and event fields. -2. Tagged state schemas. +2. Tagged schemas for states that own data. 3. Tagged public-event, internal-event, and emitted-event schemas. 4. `Machine.defineStates`. 5. `Machine.make`, including input, events, internal events, emits, and the @@ -108,24 +108,63 @@ its extra control is required: ## Atomic, compound, parallel, and history states +An active state does not need a schema unless it owns data. Omit `schema` for +control-only atomic, compound, parallel, and final states: + +```ts +const States = Machine.defineStates({ + Idle: {}, + Form: { + initial: "Editing", + states: { + Editing: {}, + Saving: State.cases.Saving + } + } +}) + +States.initial.Idle.from() +States.initial.Form.from((form) => form.Editing.from()) +``` + +Schema-less states have the same control semantics as schema-backed states: +they are active, targetable, matchable, receive lifecycle handlers, and appear +in snapshots. They do not have a state value: + +```ts +Idle: { + on: { + Start: ({ state, target }) => { + // state: undefined + return target.full.Form.from((form) => form.Editing.from()) + } + } +} + +States.matches(snapshot, "Form") // allowed +States.getSnapshot(snapshot, "Form") // allowed +States.get(snapshot, "Form") // type error: no value schema +``` + +For a schema-less path, builders expose only `.from(...)`; the direct callable +form is reserved for already-decoded schema values. Structural ancestors are +also omitted from `parents`; an immediate structural parent is typed as +`undefined`. Add `schema` when a state begins to own data or needs runtime +validation and persistence for that data. + Use an atomic state when no child phase can be active beneath it. Use a compound state when exactly one child phase is active. It must declare an `initial` child: ```ts -const FormState = Schema.TaggedUnion({ - Form: { draft: Schema.String }, - Editing: {}, - Saving: {} -}) +const FormState = Schema.TaggedUnion({ Saving: { draft: Schema.String } }) const FormStates = Machine.defineStates({ Form: { - schema: FormState.cases.Form, initial: "Editing", states: { - Editing: FormState.cases.Editing, + Editing: {}, Saving: FormState.cases.Saving } } @@ -135,35 +174,22 @@ const FormStates = Machine.defineStates({ Use a parallel state when every direct region is active: ```ts -const ParallelState = Schema.TaggedUnion({ - Screen: {}, - Network: {}, - Online: {}, - Offline: {}, - Panel: {}, - Closed: {}, - Open: {} -}) - const ParallelStates = Machine.defineStates({ Screen: { - schema: ParallelState.cases.Screen, type: "parallel", states: { network: { - schema: ParallelState.cases.Network, initial: "Online", states: { - Online: ParallelState.cases.Online, - Offline: ParallelState.cases.Offline + Online: {}, + Offline: {} } }, panel: { - schema: ParallelState.cases.Panel, initial: "Closed", states: { - Closed: ParallelState.cases.Closed, - Open: ParallelState.cases.Open + Closed: {}, + Open: {} } } } @@ -366,9 +392,11 @@ const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready")) States.matches(ready, "Route.Ready.Saving") ``` -All paths are checked against the definition. `context.parent` is the immediate -typed parent (`undefined` at a root). Use `parents` when another ancestor is -needed: +All paths are checked against the definition. `get` and `getWithParents` accept +only schema-backed paths; use `matches` or `getSnapshot` for any active path. +`context.parent` is the immediate typed parent value (`undefined` at a root or +when that parent is schema-less). `parents` contains only valued ancestors. Use +its full paths when another ancestor value is needed: ```ts parents["Route.Ready"] diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts index 3a1a045..851c66b 100644 --- a/examples/platformer/src/machine.ts +++ b/examples/platformer/src/machine.ts @@ -7,32 +7,14 @@ export type Axis = typeof Axis.Type const JumpKind = Schema.Literals(["Ground", "Double", "Wall"]) const State = Schema.TaggedUnion({ - Character: {}, - Locomotion: {}, - Playing: {}, Paused: { pausedAt: Schema.Number }, - Grounded: {}, - Standing: {}, Running: { startedAt: Schema.Number }, Ducking: { startedAt: Schema.Number }, Landing: { impact: Schema.Number, resumeAxis: Axis, landedAt: Schema.Number }, Airborne: { originY: Schema.Number }, - Motion: {}, Jumping: { startedAt: Schema.Number, push: Axis, kind: JumpKind }, Falling: { apexY: Schema.Number }, - Diving: { startedAt: Schema.Number }, - AirJump: {}, - AirJumpGroundLock: {}, - AirJumpWallLock: {}, - AirJumpReady: {}, - AirJumpSpent: {}, - WallContact: {}, - NoWall: {}, - LeftWall: {}, - RightWall: {}, - Facing: {}, - Left: {}, - Right: {} + Diving: { startedAt: Schema.Number } }) // Inputs and physics facts are one runtime-decoded, statically typed protocol. @@ -61,22 +43,18 @@ const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0) export const CharacterStates = Machine.defineStates({ Character: { - schema: State.cases.Character, type: "parallel", states: { locomotion: { - schema: State.cases.Locomotion, initial: "Playing", states: { Playing: { - schema: State.cases.Playing, initial: "Grounded", states: { Grounded: { - schema: State.cases.Grounded, initial: "Standing", states: { - Standing: State.cases.Standing, + Standing: {}, Running: State.cases.Running, Ducking: State.cases.Ducking, Landing: State.cases.Landing @@ -87,7 +65,6 @@ export const CharacterStates = Machine.defineStates({ type: "parallel", states: { motion: { - schema: State.cases.Motion, initial: "Jumping", states: { Jumping: State.cases.Jumping, @@ -96,13 +73,12 @@ export const CharacterStates = Machine.defineStates({ } }, airJump: { - schema: State.cases.AirJump, initial: "AirJumpGroundLock", states: { - AirJumpGroundLock: State.cases.AirJumpGroundLock, - AirJumpWallLock: State.cases.AirJumpWallLock, - AirJumpReady: State.cases.AirJumpReady, - AirJumpSpent: State.cases.AirJumpSpent + AirJumpGroundLock: {}, + AirJumpWallLock: {}, + AirJumpReady: {}, + AirJumpSpent: {} } } } @@ -117,20 +93,18 @@ export const CharacterStates = Machine.defineStates({ } }, facing: { - schema: State.cases.Facing, initial: "Right", states: { - Left: State.cases.Left, - Right: State.cases.Right + Left: {}, + Right: {} } }, contact: { - schema: State.cases.WallContact, initial: "NoWall", states: { - NoWall: State.cases.NoWall, - LeftWall: State.cases.LeftWall, - RightWall: State.cases.RightWall + NoWall: {}, + LeftWall: {}, + RightWall: {} } } } @@ -457,9 +431,12 @@ export const locomotionState = (snapshot: CharacterSnapshot) => { } const playing = locomotion.state - return playing.path === "Character.locomotion.Playing.Grounded" - ? playing.state.value - : playing.states.motion.state.value + if (playing.path === "Character.locomotion.Playing.Airborne") { + return playing.states.motion.state.value + } + return playing.state.path === "Character.locomotion.Playing.Grounded.Standing" + ? { _tag: "Standing" as const } + : playing.state.value } export type LocomotionMode = ReturnType["_tag"] @@ -468,22 +445,32 @@ export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => l export const locomotionBranch = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state - return locomotion.path === "Character.locomotion.Paused" ? locomotion.value._tag : locomotion.state.value._tag + if (locomotion.path === "Character.locomotion.Paused") return "Paused" as const + return locomotion.state.path === "Character.locomotion.Playing.Grounded" ? "Grounded" as const : "Airborne" as const } export const airJumpMode = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state - return locomotion.path === "Character.locomotion.Playing" && - locomotion.state.path === "Character.locomotion.Playing.Airborne" - ? locomotion.state.states.airJump.state.value._tag - : undefined + if ( + locomotion.path !== "Character.locomotion.Playing" || + locomotion.state.path !== "Character.locomotion.Playing.Airborne" + ) return undefined + + const path = locomotion.state.states.airJump.state.path + if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpGroundLock") return "AirJumpGroundLock" as const + if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock") return "AirJumpWallLock" as const + if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpReady") return "AirJumpReady" as const + return "AirJumpSpent" as const } export const wallContact = (snapshot: CharacterSnapshot) => { - return snapshot.states.contact.state.value._tag + const path = snapshot.states.contact.state.path + if (path === "Character.contact.NoWall") return "NoWall" as const + return path === "Character.contact.LeftWall" ? "LeftWall" as const : "RightWall" as const } -export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states.facing.state.value._tag +export const facingDirection = (snapshot: CharacterSnapshot) => + snapshot.states.facing.state.path === "Character.facing.Left" ? "Left" as const : "Right" as const export const activeStateData = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state @@ -493,15 +480,16 @@ export const activeStateData = (snapshot: CharacterSnapshot) => { } const playing = locomotion.state - const { _tag: _branch, ...branchData } = playing.value if (playing.path === "Character.locomotion.Playing.Grounded") { + if (playing.state.path === "Character.locomotion.Playing.Grounded.Standing") return {} const { _tag: _leaf, ...leafData } = playing.state.value - return { ...branchData, ...leafData } + return leafData } + const { _tag: _branch, ...branchData } = playing.value const { _tag: _motion, ...motionData } = playing.states.motion.state.value return { ...branchData, ...motionData, - airJump: playing.states.airJump.state.value._tag + airJump: airJumpMode(snapshot) } } diff --git a/examples/playground/src/examples/media-player/schemas.ts b/examples/playground/src/examples/media-player/schemas.ts index f794c37..f24409c 100644 --- a/examples/playground/src/examples/media-player/schemas.ts +++ b/examples/playground/src/examples/media-player/schemas.ts @@ -40,16 +40,8 @@ const soundSettingsFields = { } export const MediaPlayerState = Schema.TaggedUnion({ - Player: {}, - - Transport: {}, - - Empty: {}, - Loading: { url: Schema.String }, - Ready: {}, - Paused: playbackFields, Playing: { @@ -65,8 +57,6 @@ export const MediaPlayerState = Schema.TaggedUnion({ Failed: { message: Schema.String }, - Settings: {}, - Audible: soundSettingsFields, Muted: soundSettingsFields @@ -101,19 +91,16 @@ export const MediaPlayerInternalEvent = Schema.TaggedUnion({ export const MediaPlayerStates = Machine.defineStates({ Player: { - schema: MediaPlayerState.cases.Player, type: "parallel", states: { transport: { - schema: MediaPlayerState.cases.Transport, initial: "Empty", states: { - Empty: MediaPlayerState.cases.Empty, + Empty: {}, Loading: MediaPlayerState.cases.Loading, Ready: { - schema: MediaPlayerState.cases.Ready, initial: "Paused", states: { Paused: MediaPlayerState.cases.Paused, @@ -133,7 +120,6 @@ export const MediaPlayerStates = Machine.defineStates({ }, settings: { - schema: MediaPlayerState.cases.Settings, initial: "Audible", states: { Audible: MediaPlayerState.cases.Audible, diff --git a/examples/playground/src/examples/microwave/MicrowavePage.tsx b/examples/playground/src/examples/microwave/MicrowavePage.tsx index b352158..4dcf066 100644 --- a/examples/playground/src/examples/microwave/MicrowavePage.tsx +++ b/examples/playground/src/examples/microwave/MicrowavePage.tsx @@ -24,6 +24,8 @@ export function MicrowavePage() { const cooking = engine.path === "Oven.engine.Cooking" const open = door.path === "Oven.door.Open" const elapsedSeconds = cooking ? engine.value.elapsedSeconds : 0 + const engineName = cooking ? "Cooking" : "Idle" + const doorName = open ? "Open" : "Closed" return (
@@ -41,7 +43,7 @@ export function MicrowavePage() {

Parallel configuration

{cooking ? `Cooking · ${elapsedSeconds}s` : open ? "Idle · door open" : "Idle · door closed"}

- Engine: {engine.value._tag} · Door: {door.value._tag} + Engine: {engineName} · Door: {doorName}