diff --git a/.changeset/explicit-targetless-transitions.md b/.changeset/explicit-targetless-transitions.md new file mode 100644 index 0000000..1bf0e4c --- /dev/null +++ b/.changeset/explicit-targetless-transitions.md @@ -0,0 +1,12 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add `target.none()` for explicit targetless transitions. Every installed transition handler now returns a concrete target or `target.none()`; declared `targets` remain an upper bound on concrete destinations and never exclude `target.none()`. + +Remove `Machine.retag`. To reuse compatible fields across sibling states, destructure away the source discriminator and construct the destination through its target builder: + +```ts +const { _tag: _, ...fields } = state +return target.local.Saving.from({ ...fields, attempt: 1 }) +``` diff --git a/README.md b/README.md index 4e8ff17..63ef387 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,18 @@ States.initial.Form.from({ draft: "" }, (form) => form.Editing.from()) The machine runs these inputs through the state schema while planning. Schema 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`. +it is already decoded. + +When sibling states share fields, remove the source discriminator and pass the +remaining fields through the target schema: + +```ts +Submit: ; +;(({ state, target }) => { + const { _tag: _, ...fields } = state + return target.local.Saving.from({ ...fields, attempt: 1 }) +}) +``` Omit `schema` when a state represents control flow but owns no data: @@ -237,14 +248,20 @@ paths. `parent` always means the owning actor reference. | 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 | -Builders describe the next logical configuration. Shared states exit and enter -only when paths change; use `{ reenter: true, transition }` when the source must -restart even if its path is unchanged. +Every installed transition handler returns either a concrete target or +`target.none()`. An absent handler ignores the trigger; `target.none()` handles +it and retains queued commands, raised events, and emitted events without +selecting a destination. Declared `targets` constrain only concrete +destinations, so `target.none()` is always permitted. Builders describe the +next logical configuration. Shared states exit and enter only when paths +change; use `{ reenter: true, transition }` when the source must restart. With +`target.none()`, reentry restarts the source while retaining its configuration. ## Statechart capabilities diff --git a/docs/agent-guide.md b/docs/agent-guide.md index a12aa1e..f8f5624 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -426,7 +426,7 @@ microstep, before any selected transition is applied: BufferReady: ({ snapshot, target }) => States.matches(snapshot, "Player.Network.Online") ? target.local.Playing.from() - : undefined + : target.none() ``` Use the existing `States.matches`, `States.get`, `States.getWithParents`, and @@ -457,11 +457,19 @@ history definitions may declare an `annotations` object containing only they cannot change behavior, identity, or targeting. Visualization may show a title, while the structural path remains authoritative. -Use `Machine.retag(TargetCase, source, patch?)` when sibling state payloads -share fields. It removes the source discriminator, reuses only compatible -fields, and requires a patch for every missing or incompatible required field. -Prefer moving broadly shared data to the compound parent rather than retagging -it through every phase. +When sibling state payloads share fields, destructure away the source +discriminator and construct the destination through its target builder: + +```ts +Submit: ({ state, target }) => { + const { _tag: _, ...fields } = state + return target.local.Saving.from({ ...fields, attempt: 1 }) +} +``` + +The target schema remains responsible for defaults, transforms, refinements, +and class identity. Prefer moving broadly shared data to the compound parent +rather than copying it through every phase. ## Planning, actions, raised events, and emissions @@ -469,9 +477,19 @@ A transition returns a target synchronously: ```ts Submit: ({ state, target }) => - state.valid ? target.local.Saving.from({ draft: state.draft }) : undefined + state.valid ? target.local.Saving.from({ draft: state.draft }) : target.none() ``` +Every installed event, `always`, `onDone`, and invoke lifecycle handler must +return a concrete target or `target.none()`. An absent event handler means the +event is ignored. Returning `target.none()` means it was handled without a +destination, so queued commands, raised events, and emitted events are still +retained. It remains valid when a transition declares `targets`: those paths +are an upper bound on concrete destinations, not an exhaustive result set. + +`reenter: true` remains meaningful with `target.none()`: the source exits and +enters again while its logical configuration is retained. + Closed statechart and actor operations use `enqueue`: ```ts diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts index f1926d3..68165c0 100644 --- a/examples/platformer/src/machine.ts +++ b/examples/platformer/src/machine.ts @@ -192,7 +192,7 @@ export const CharacterMachine = definition.handle({ targets: ["Character.locomotion.Playing.Grounded.Running"], transition: ({ event, target }) => event.axis === 0 - ? undefined + ? target.none() : target.local.Running.from({ startedAt: event.at }) }, DownPressed: { @@ -205,7 +205,8 @@ export const CharacterMachine = definition.handle({ on: { Move: { targets: ["Character.locomotion.Playing.Grounded.Standing"], - transition: ({ event, target }) => event.axis === 0 ? target.local.Standing.from() : undefined + transition: ({ event, target }) => + event.axis === 0 ? target.local.Standing.from() : target.none() }, DownPressed: { targets: ["Character.locomotion.Playing.Grounded.Ducking"], @@ -245,8 +246,10 @@ export const CharacterMachine = definition.handle({ on: { Move: { targets: ["Character.locomotion.Playing.Grounded.Landing"], - transition: ({ event, state, target }) => - target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })) + transition: ({ event, state, target }) => { + const { _tag: _, ...fields } = state + return target.local.Landing.from({ ...fields, resumeAxis: event.axis }) + } } } } @@ -256,13 +259,14 @@ export const CharacterMachine = definition.handle({ on: { JumpPressed: { targets: [], - transition: ({ event }, enqueue) => { + transition: ({ event, target }, enqueue) => { const push = awayFrom(event.wall) enqueue.raise( push === 0 ? InternalEvents.TryAirJump({ at: event.at }) : InternalEvents.WallJump({ at: event.at, push }) ) + return target.none() } }, Landed: { @@ -380,11 +384,11 @@ export const CharacterMachine = definition.handle({ on: { Move: { targets: ["Character.facing.Right"], - transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : undefined + transition: ({ event, target }) => event.axis === 1 ? target.local.Right.from() : target.none() }, WallJump: { targets: ["Character.facing.Right"], - transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : undefined + transition: ({ event, target }) => event.push === 1 ? target.local.Right.from() : target.none() } } }, @@ -392,11 +396,11 @@ export const CharacterMachine = definition.handle({ on: { Move: { targets: ["Character.facing.Left"], - transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : undefined + transition: ({ event, target }) => event.axis === -1 ? target.local.Left.from() : target.none() }, WallJump: { targets: ["Character.facing.Left"], - transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : undefined + transition: ({ event, target }) => event.push === -1 ? target.local.Left.from() : target.none() } } } diff --git a/examples/playground/src/examples/media-player/machine.ts b/examples/playground/src/examples/media-player/machine.ts index 4e64ece..5e647dc 100644 --- a/examples/playground/src/examples/media-player/machine.ts +++ b/examples/playground/src/examples/media-player/machine.ts @@ -34,9 +34,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ invoke: Machine.invoke({ id: "load-audio", effect: ({ state }) => loadAudio(state.url), - onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()), - onFailure: ({ error }, enqueue) => + onDone: ({ target }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()) + return target.none() + }, + onFailure: ({ error, target }, enqueue) => { enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return target.none() + } }), on: { LoadSucceeded: ({ target }) => target.local.Ready.from((ready) => ready.Paused.from(initialPlaybackData)) @@ -49,9 +54,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ invoke: Machine.invoke({ id: "pause-audio", effect: pauseAudio, - onDone: () => undefined, - onFailure: ({ error }, enqueue) => + onDone: ({ target }) => target.none(), + onFailure: ({ error, target }, enqueue) => { enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return target.none() + } }), on: { PlayRequested: ({ state, target }) => @@ -69,18 +76,21 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ Machine.invoke({ id: "play-audio", effect: playAudio, - onDone: () => undefined, - onFailure: ({ error }, enqueue) => + onDone: ({ target }) => target.none(), + onFailure: ({ error, target }, enqueue) => { enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return target.none() + } }), Machine.invoke({ id: "analyze-audio", address: Machine.childAddress("analyze-audio"), logic: analyzeAudio, - onDone: () => undefined, - onSnapshot: ({ snapshot }, enqueue) => { + onDone: ({ target }) => target.none(), + onSnapshot: ({ snapshot, target }, enqueue) => { const event = loudnessEvent(snapshot.state) if (event !== undefined) enqueue.raise(event) + return target.none() } }) ], @@ -136,9 +146,14 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ invoke: Machine.invoke({ id: "restart-audio", effect: restartAudio, - onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()), - onFailure: ({ error }, enqueue) => + onDone: ({ target }, enqueue) => { + enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()) + return target.none() + }, + onFailure: ({ error, target }, enqueue) => { enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + return target.none() + } }), on: { RestartSucceeded: ({ target }) => target.local.Playing.from({ currentTime: 0, loudness: null }), @@ -159,15 +174,15 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Failed: { - invoke: { + invoke: Machine.invoke({ id: "report-error", effect: ({ state }) => Effect.gen(function*() { const mediaPlayer = yield* MediaPlayer yield* mediaPlayer.reportError(state.message) }), - onDone: () => undefined - } + onDone: ({ target }) => target.none() + }) } } }, @@ -175,11 +190,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ settings: { states: { Audible: { - invoke: { + invoke: Machine.invoke({ id: "apply-audio-settings", effect: ({ state }) => applyAudioSettings(state, false), - onDone: () => undefined - }, + onDone: ({ target }) => target.none() + }), on: { VolumeChanged: { reenter: true, @@ -208,11 +223,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Muted: { - invoke: { + invoke: Machine.invoke({ id: "apply-audio-settings", effect: ({ state }) => applyAudioSettings(state, true), - onDone: () => undefined - }, + onDone: ({ target }) => target.none() + }), on: { VolumeChanged: { reenter: true, diff --git a/examples/playground/src/examples/microwave/machine.ts b/examples/playground/src/examples/microwave/machine.ts index c93d01c..4a18e7f 100644 --- a/examples/playground/src/examples/microwave/machine.ts +++ b/examples/playground/src/examples/microwave/machine.ts @@ -57,7 +57,7 @@ export const MicrowaveMachine = definition.handle({ PowerPressed: ({ snapshot, target }) => MicrowaveStates.matches(snapshot, "Oven.door.Closed") ? target.local.Cooking.from({ elapsedSeconds: 0 }) - : undefined + : target.none() } }, Cooking: { diff --git a/examples/pokemon/src/machine.ts b/examples/pokemon/src/machine.ts index ed0c9f9..c24c2c9 100644 --- a/examples/pokemon/src/machine.ts +++ b/examples/pokemon/src/machine.ts @@ -35,7 +35,7 @@ const machine = Machine.make({ invoke: [ Machine.invoke({ child: SelectionChild, - onDone: () => undefined, + onDone: ({ target }) => target.none(), onFailure: ({ target }) => target.full.Failed.from() }), Machine.invoke({ child: ReplaceChild, onFailure: ({ target }) => target.full.Failed.from() }) diff --git a/examples/pokemon/src/machines/replace.ts b/examples/pokemon/src/machines/replace.ts index 1a2bcc1..f116b2f 100644 --- a/examples/pokemon/src/machines/replace.ts +++ b/examples/pokemon/src/machines/replace.ts @@ -47,7 +47,10 @@ export const ReplaceMachine = Machine.make({ invoke: Machine.invoke({ id: "replaceWithRandom", effect: replaceWithRandom, - onDone: ({ output }, enqueue) => enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon })), + onDone: ({ output, target }, enqueue) => { + enqueue.raise(ReplaceInternalEvents.Replaced({ pokemon: output.pokemon })) + return target.none() + }, onFailure: ({ target }) => target.full.Idle.from() }), on: { diff --git a/perf/runtime/counter.mjs b/perf/runtime/counter.mjs index 9585619..dcfb406 100644 --- a/perf/runtime/counter.mjs +++ b/perf/runtime/counter.mjs @@ -74,7 +74,7 @@ const counterParentMachine = Machine.make({ initial: () => ParentStates.initial.Active.from() }).handle({ Active: { - invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: () => undefined }) + invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: benchmarkApi.targetless }) } }) @@ -87,8 +87,8 @@ const counterSnapshotParentMachine = Machine.make({ Active: { invoke: benchmarkApi.invokeChild({ child: CounterChild, - onDone: () => undefined, - onSnapshot: () => undefined + onDone: benchmarkApi.targetless, + onSnapshot: benchmarkApi.targetless }) } }) @@ -308,7 +308,7 @@ const waitForCounterChild = (parent) => } yield* Effect.yieldNow } - return yield* Effect.dieMessage("Effect Machine child did not become ready") + return yield* Effect.die(new Error("Effect Machine child did not become ready")) }) export const startChildCounter = () => @@ -328,13 +328,13 @@ export const runChildCounterBurst = (parent, size) => for (let index = 0; index < size; index += 1) { const child = yield* parent.child(CounterChild) if (Option.isNone(child)) { - return yield* Effect.dieMessage("Effect Machine child disappeared during the benchmark") + return yield* Effect.die(new Error("Effect Machine child disappeared during the benchmark")) } yield* child.value.send(incrementEvent) } const child = yield* parent.child(CounterChild) if (Option.isNone(child)) { - return yield* Effect.dieMessage("Effect Machine child disappeared before the terminal fence") + return yield* Effect.die(new Error("Effect Machine child disappeared before the terminal fence")) } yield* child.value.send(finishEvent) return yield* child.value.join diff --git a/perf/runtime/effect-machine-compatibility.mjs b/perf/runtime/effect-machine-compatibility.mjs index e511ae0..9a51c3c 100644 --- a/perf/runtime/effect-machine-compatibility.mjs +++ b/perf/runtime/effect-machine-compatibility.mjs @@ -9,6 +9,7 @@ export const makeEffectMachineBenchmarkApi = (Machine) => ({ events: typeof Machine.event === "function" ? (...schemas) => schemas : (...schemas) => Machine.events(...schemas), + targetless: ({ target }) => typeof target.none === "function" ? target.none() : undefined, invokeChild: typeof Machine.invokeMachine === "function" ? ({ onSnapshot, onFailure, ...config }) => { if (onFailure !== undefined) { diff --git a/perf/types/dynamic-invoke.ts b/perf/types/dynamic-invoke.ts index aaf0e05..e935563 100644 --- a/perf/types/dynamic-invoke.ts +++ b/perf/types/dynamic-invoke.ts @@ -11,13 +11,15 @@ const invoked = machine.handle({ invoke: Machine.invoke({ id: "load-user", effect: ({ state }) => loadUser(state.userId), - onDone: ({ output }) => { + onDone: ({ output, target }) => { const user: User = output void user + return target.none() }, - onFailure: ({ error }) => { + onFailure: ({ error, target }) => { const loadError: LoadError = error void loadError + return target.none() } }) } diff --git a/scripts/fixtures/consumer/consumer.ts b/scripts/fixtures/consumer/consumer.ts index 7dd4614..ec4e84d 100644 --- a/scripts/fixtures/consumer/consumer.ts +++ b/scripts/fixtures/consumer/consumer.ts @@ -53,12 +53,12 @@ const cluster = ClusterMachine.make("ConsumerEntity", machine, { const invoked = Machine.invoke({ id: "fixture-load", effect: Effect.succeed("ready"), - onDone: () => undefined + onDone: ({ target }) => target.none() }) const delayed = Machine.invoke({ id: "fixture-delay", after: "1 second", - onDone: () => undefined + onDone: ({ target }) => target.none() }) const generated = MachineTest.scenarios(machine, { minEvents: 1, maxEvents: 2 }) diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index 6838ca9..b8a17b5 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -98,7 +98,7 @@ const machine = Machine.make({ invoke: Machine.invoke({ id: "deep-inline-invoke", effect: Effect.asVoid(ExternalService), - onDone: () => undefined + onDone: ({ target }) => target.none() }), on: { Begin: ({ target }) => @@ -117,15 +117,15 @@ const machine = Machine.make({ Editing: { on: { Save: ({ event, target }) => target.local.Saving(State.cases.Saving.make({ value: event.value })), - Loaded: () => undefined + Loaded: ({ target }) => target.none() } }, Saving: { - invoke: { + invoke: Machine.invoke({ child: Child, input: ({ state }) => ({ value: state.value }), - onDone: () => undefined - }, + onDone: ({ target }) => target.none() + }), on: { ChildNotice: ({ event, target }, enqueue) => { enqueue.emit(Emissions.Notice({ value: event.value })) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs index ab8e1a7..119b401 100644 --- a/scripts/runtime-performance-compatibility.test.mjs +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -4,6 +4,7 @@ import { makeEffectMachineBenchmarkApi } from "../perf/runtime/effect-machine-co test("uses the current child invocation capability when available", () => { const calls = [] + const noTarget = Symbol("no-target") const Machine = { events: (...schemas) => ({ api: "current-events", schemas }), invoke: (config) => { @@ -23,6 +24,7 @@ test("uses the current child invocation capability when available", () => { config }) assert.deepEqual(calls, [config]) + assert.equal(makeEffectMachineBenchmarkApi(Machine).targetless({ target: { none: () => noTarget } }), noTarget) }) test("adapts lifecycle names for the legacy child invocation capability", () => { @@ -57,6 +59,7 @@ test("adapts lifecycle names for the legacy child invocation capability", () => } ) assert.deepEqual(calls, [{ child: "counter", input: { seed: 1 }, onDone, snapshot: onSnapshot }]) + assert.equal(makeEffectMachineBenchmarkApi(Machine).targetless({ target: {} }), undefined) }) test("fails closed when a legacy capability cannot preserve lifecycle semantics", () => { diff --git a/src/Machine.ts b/src/Machine.ts index 5dd8ff9..18ebe6f 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -3771,6 +3771,21 @@ export declare namespace Machine { > } + /** + * Opaque result returned by an explicitly targetless transition. + * + * The transition remains handled and retains its queued commands, raised + * events, and emitted events, but selects no concrete destination. Declared + * `targets` constrain only concrete destinations, so this result is always + * permitted from ordinary transition handlers. + * + * @category models + * @since 0.10.0 + */ + export interface NoTarget { + readonly [Topology.NoTargetTypeId]: typeof Topology.NoTargetTypeId + } + /** * Transition instruction that restores a history pseudo-state's parent. * @@ -3877,9 +3892,9 @@ export declare namespace Machine { * * **Details** * - * `local` targets the nearest compound scope for the source state, `branch` - * targets descendants of the source root, and `full` builds complete - * snapshots for any root. + * `none()` handles without selecting a destination, `local` targets the + * nearest compound scope for the source state, `branch` targets descendants + * of the source root, and `full` builds complete snapshots for any root. * * These builders control how the next active configuration is assembled; they * do not directly control state re-entry. Exit and entry paths are derived @@ -3895,6 +3910,18 @@ export declare namespace Machine { States extends StateSchemas, Source extends StateNodeIdentifier > { + /** + * Selects an explicitly targetless transition. + * + * The event or lifecycle outcome is handled and queued operations are + * retained, while the current state configuration remains the transition + * result. This remains valid when the handler declares concrete `targets`, + * because those paths are an upper bound rather than an exhaustive result. + * + * @since 0.10.0 + */ + readonly none: () => NoTarget + /** * Moves to another state in the same local group. The value of the state * containing that group, and values in other active branches, are kept. @@ -4239,9 +4266,10 @@ export declare namespace Machine { * * **Details** * - * Handlers return snapshots for complete state replacement or target builder - * results for path-safe partial transitions. Raw decoded state values are not - * accepted at transition boundaries. + * Handlers return snapshots for complete state replacement, target builder + * results for path-safe partial transitions, or `target.none()` for an + * explicitly targetless transition. Raw decoded state values and `void` are + * not accepted at transition boundaries. * * @category utility types * @since 0.4.0 @@ -4257,7 +4285,7 @@ export declare namespace Machine { | HistoryTarget> | ChoiceTarget> > - | void + | NoTarget /** A choice resolver must always select a typed target synchronously. */ export type ChoiceResult = @@ -5058,7 +5086,7 @@ export declare namespace Machine { readonly reenter?: boolean /** * Upper bound of state or history paths this handler may target. - * Returning `void` is always permitted. Declaring a parent state also + * Returning `target.none()` is always permitted. Declaring a parent state also * permits concrete descendant targets below it. */ readonly targets?: ReadonlyArray> @@ -6825,57 +6853,6 @@ export const invoke: { Machine.EventOf> > } = ((config: unknown) => config) as any -type RetagFields = Omit - -type RetagTargetCompatibility = Target extends { - readonly fields: Schema.Struct.Fields -} ? "_tag" extends keyof Target["~type.make.in"] ? {} extends Pick ? unknown : { - readonly "~effect/Machine/RetagTargetError": "Target schema must supply its discriminator when make is called" - } - : unknown - : { - readonly "~effect/Machine/RetagTargetError": "Target schema must be one tagged struct or tagged class" - } - -type RequiredKeys = { - readonly [Key in keyof A]-?: {} extends Pick ? never : Key -}[keyof A] - -type CompatibleSourceKeys = { - readonly [Key in Extract]: Source[Key] extends Fields[Key] ? Key : never -}[Extract] - -type RetagPatch = - & Partial> - & Pick< - RetagFields, - Exclude>, CompatibleSourceKeys, Source>> - > - -type RetagArgs = [ - Exclude>, CompatibleSourceKeys, Source>> -] extends [never] ? [patch?: RetagPatch] - : [patch: RetagPatch] - -/** - * Constructs another tagged case from compatible source fields. - * - * The target must be one tagged struct or tagged class whose discriminator is - * supplied by its constructor. Union wrappers are rejected because their - * `make` operation cannot select a member after the source discriminator has - * been discarded. Missing or incompatible required target fields must be - * supplied by the patch, and the target schema's normal `make` validation - * remains authoritative at runtime. - * - * @category constructors - * @since 0.4.0 - */ -export const retag: ( - target: Target & RetagTargetCompatibility, - source: Source, - ...args: RetagArgs -) => Target["Type"] = internal.retag - /** * Plans the initial state for a machine without executing actor commands. * diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index b8553f5..9a29146 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -46,7 +46,7 @@ import { validateDeclaredTransitionTarget } from "./planner.js" import { decodeEmitSync, decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" -import { isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" +import { isNoTarget, isSnapshot, isTarget, TargetSnapshotTypeId } from "./topology.js" interface IndexedExecutionDescriptor { readonly flat: boolean @@ -427,7 +427,7 @@ const collectIndexedTransition = ( let commands: Array | undefined let raisedEvents: Array | undefined let emittedEvents: Array | undefined - const state = transition(context, { + const result = transition(context, { raise: (event: unknown) => { ;(raisedEvents ??= []).push(decodeEventSync(machine, event)) }, @@ -442,7 +442,7 @@ const collectIndexedTransition = ( } }) return { - state, + state: isNoTarget(result) ? undefined : result, commands: commands ?? emptyExecutionValues, raisedEvents: raisedEvents ?? emptyExecutionValues, emittedEvents: emittedEvents ?? emptyExecutionValues diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 491724a..2cb2d49 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -728,6 +728,7 @@ const makeTargetBuilder = ( const history = makeHistoryTargetBuilder(states, "") as Machine.HistoryTargetBuilder return >(source: Source): Machine.TargetBuilder => ({ + none: Topology.makeNoTarget, local: makeLocalTargetBuilder(states, stateNodes, source), branch: makeBranchTargetBuilder(states, stateNodes, source), full, @@ -990,14 +991,6 @@ export const decodeSnapshot: < Machine.SnapshotDecodingServices > = Serialization.decodeSnapshot as any -export const retag = ( - target: Machine.TaggedSchema, - source: { readonly _tag: PropertyKey }, - patch?: unknown -): any => { - const { _tag: _, ...fields } = source - return target.make({ ...fields, ...((patch ?? {}) as object) } as never) -} export const planInitial: < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index 16a00d0..b1bbf27 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -45,6 +45,7 @@ import { getNode, isChoiceTarget, isHistoryTarget, + isNoTarget, isSnapshot, isTarget, makeChoiceTarget, @@ -187,9 +188,9 @@ const collectTransition = < context: Context ) => { const collected = makeCollector(machine) - const state = transition(context, collected.enqueue) + const result = transition(context, collected.enqueue) return { - state, + state: isNoTarget(result) ? undefined : result, commands: collected.commands, raisedEvents: collected.raisedEvents, emittedEvents: collected.emittedEvents diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index b83fa0a..dc05a2c 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -21,6 +21,8 @@ export const HistoryTargetTypeId: unique symbol = Symbol("effect/Machine/History export const ChoiceTargetTypeId: unique symbol = Symbol("effect/Machine/ChoiceTarget") +export const NoTargetTypeId: unique symbol = Symbol("effect/Machine/NoTarget") + interface StateInput { readonly [StateInputTypeId]: typeof StateInputTypeId readonly input: unknown @@ -42,6 +44,19 @@ export interface ChoiceTarget { readonly values?: Readonly> } +/** Internal marker returned by an explicitly targetless transition. */ +export interface NoTarget { + readonly [NoTargetTypeId]: typeof NoTargetTypeId +} + +const noTarget = Object.freeze({ + [NoTargetTypeId]: NoTargetTypeId +}) as NoTarget + +export const makeNoTarget = (): Machine.NoTarget => noTarget + +export const isNoTarget = (u: unknown): u is Machine.NoTarget => hasProperty(u, NoTargetTypeId) + export const makeHistoryTarget = (path: string, parent: string): HistoryTarget => ({ [HistoryTargetTypeId]: HistoryTargetTypeId, path, diff --git a/test/internal/machine/activities.test.ts b/test/internal/machine/activities.test.ts index 94c6eb4..26cb1d1 100644 --- a/test/internal/machine/activities.test.ts +++ b/test/internal/machine/activities.test.ts @@ -40,10 +40,10 @@ const activityMachine = Machine.make({ Machine.invoke({ id: "load-document", effect: Effect.fail("unavailable").pipe(Effect.as(1)), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), - Machine.invoke({ id: "load-timeout", after: timerDuration, onDone: () => undefined }), + Machine.invoke({ id: "load-timeout", after: timerDuration, onDone: ({ target }) => target.none() }), Machine.invoke({ child }) ] }, @@ -159,7 +159,7 @@ describe("machine activity metadata", () => { initial: () => activityStates.initial.Loading(new Loading({})) }).handle({ Loading: { - invoke: Machine.invoke({ id, after: durationMillis, onDone: () => undefined }) + invoke: Machine.invoke({ id, after: durationMillis, onDone: ({ target }) => target.none() }) } }) const definition = Machine.activityDefinitions(generated)[0] diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index 8e54d58..e353f90 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -32,11 +32,15 @@ const makeFlatMachine = () => { }).handle({ Count: { on: { - Noop: () => undefined, + Noop: { + targets: ["Done"], + transition: ({ target }) => target.none() + }, Increment: ({ state, target }) => target.full.Count(new Count({ value: state.value + 1 })), Reenter: { reenter: true, - transition: ({ state, target }) => target.full.Count(new Count({ value: state.value })) + targets: ["Done"], + transition: ({ target }) => target.none() }, Finish: ({ state, target }) => target.full.Done(new Done({ value: state.value })) } @@ -54,6 +58,17 @@ describe("machine planner and runtime strategies", () => { label: "flat strategy" })) + it.effect("reenters the source when an explicit targetless transition requests reentry", () => + Effect.gen(function*() { + const machine = makeFlatMachine() + const initial = yield* Machine.planInitial(machine) + const planned = yield* Machine.plan(machine, initial.state, new Reenter({})) + + assert.deepStrictEqual(planned.next, initial.state) + assert.deepStrictEqual(planned.microsteps[0]?.exitPaths, ["Count"]) + assert.deepStrictEqual(planned.microsteps[0]?.entryPaths, ["Count"]) + })) + it.effect("keeps indexed execution microsteps narrower than diagnostic planner microsteps", () => Effect.gen(function*() { const machine = makeFlatMachine() @@ -338,10 +353,11 @@ describe("machine planner and runtime strategies", () => { }).handle({ Idle: { on: { - Publish: ({ parent, self }, enqueue) => { + Publish: ({ parent, self, target }, enqueue) => { assert.strictEqual(parent, undefined) assert.ok(self.sessionId.startsWith("machine:")) enqueue.emit(Emissions.Published({ value } as never)) + return target.none() } } } @@ -558,9 +574,9 @@ describe("machine planner and runtime strategies", () => { ) }) }, - onFailure: () => undefined, + onFailure: ({ target }) => target.none(), onSnapshot: ({ snapshot, target }) => - snapshot.state === "stale" ? target.full.Failed(new Failed({})) : undefined + snapshot.state === "stale" ? target.full.Failed(new Failed({})) : target.none() }), on: { Reenter: { diff --git a/test/machine/ActivityLifecycleModel.test.ts b/test/machine/ActivityLifecycleModel.test.ts index 63c777c..4bc6677 100644 --- a/test/machine/ActivityLifecycleModel.test.ts +++ b/test/machine/ActivityLifecycleModel.test.ts @@ -85,8 +85,8 @@ describe("machine activity lifecycle model", () => { id: "activity", address: Machine.childAddress("activity"), logic: probe.logic("active", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), on: { Leave: ({ target }) => target.full.Idle(new Idle({})), @@ -156,7 +156,7 @@ describe("machine activity lifecycle model", () => { address: Machine.childAddress("immediate"), logic: probe.immediate("immediate", (epoch) => new Completed({ epoch })), onDone: ({ output, target }) => target.full.Done(new Done({ epoch: output.epoch })), - onFailure: () => undefined + onFailure: ({ target }) => target.none() }) }, Done: { @@ -194,8 +194,8 @@ describe("machine activity lifecycle model", () => { _tag: "StaleOnCancel", event: (epoch) => new Completed({ epoch }) }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), on: { Restart: { @@ -307,8 +307,8 @@ describe("machine activity lifecycle model", () => { id: "left-activity", address: Machine.childAddress("left-activity"), logic: probe.logic("left", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), on: { LeaveLeft: ({ target }) => target.local.idle(new LeftIdle({})) @@ -323,8 +323,8 @@ describe("machine activity lifecycle model", () => { id: "right-activity", address: Machine.childAddress("right-activity"), logic: probe.logic("right", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }) } } @@ -373,8 +373,8 @@ describe("machine activity lifecycle model", () => { id: "timed-activity", address: Machine.childAddress("timed-activity"), logic: probe.logic("timed", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), Machine.invoke({ id: "deadline", @@ -418,7 +418,7 @@ describe("machine activity lifecycle model", () => { id: "failing", address: Machine.childAddress("failing"), logic: probe.logic("failing", { _tag: "Failure" }), - onDone: () => undefined, + onDone: ({ target }) => target.none(), onFailure: ({ error }) => { throw error } @@ -427,8 +427,8 @@ describe("machine activity lifecycle model", () => { id: "sibling", address: Machine.childAddress("sibling"), logic: probe.logic("sibling", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }) ] } @@ -466,15 +466,15 @@ describe("machine activity lifecycle model", () => { id: "first", address: Machine.childAddress("first"), logic: probe.logic("first", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }), Machine.invoke({ id: "second", address: Machine.childAddress("second"), logic: probe.logic("second", { _tag: "Blocked" }), - onDone: () => undefined, - onFailure: () => undefined + onDone: ({ target }) => target.none(), + onFailure: ({ target }) => target.none() }) ] } diff --git a/test/machine/ActorEvents.test.ts b/test/machine/ActorEvents.test.ts index 4c6fb94..39fd0c2 100644 --- a/test/machine/ActorEvents.test.ts +++ b/test/machine/ActorEvents.test.ts @@ -27,7 +27,10 @@ describe("actor event channels", () => { }).handle({ Idle: { on: { - Publish: ({ event }, enqueue) => enqueue.emit(Emissions.Published({ value: event.value })) + Publish: ({ event, target }, enqueue) => { + enqueue.emit(Emissions.Published({ value: event.value })) + return target.none() + } } } }) @@ -74,7 +77,10 @@ describe("actor event channels", () => { }).handle({ Idle: { on: { - Publish: (_, enqueue) => enqueue.emit(Emissions.Published({ value: "invalid" } as never)) + Publish: ({ target }, enqueue) => { + enqueue.emit(Emissions.Published({ value: "invalid" } as never)) + return target.none() + } } } }) diff --git a/test/machine/Invoke.test.ts b/test/machine/Invoke.test.ts index ca9abda..f6a8473 100644 --- a/test/machine/Invoke.test.ts +++ b/test/machine/Invoke.test.ts @@ -85,7 +85,7 @@ describe("inline invoke", () => { effect: (): Effect.Effect => { throw defect }, - onDone: () => undefined + onDone: ({ target }) => target.none() }) }, Complete: {}, diff --git a/test/machine/Machine.test.ts b/test/machine/Machine.test.ts index 779b776..576bbde 100644 --- a/test/machine/Machine.test.ts +++ b/test/machine/Machine.test.ts @@ -109,7 +109,15 @@ describe("Machine", () => { const transition = { reenter: false, targets: [] as Array<"Stable">, - transition: () => undefined + transition: ({ target }: Machine.Machine.HandlerContext< + typeof states.states, + readonly [typeof Ping], + readonly [], + "Stable", + "Ping", + never, + never + >) => target.none() } const on: { Ping?: typeof transition } = { Ping: transition } const machine = Machine.make({ @@ -312,12 +320,33 @@ describe("Machine", () => { assert.strictEqual(Machine.isMachine({ [Machine.TypeId]: "not-a-machine" }), false) }) - it("retag constructs the target case without copying the source discriminator", () => { - const result = Machine.retag(RequestSucceeded, new Submit({ value: "loaded" })) + it.effect("constructs a sibling target by destructuring the source value", () => + Effect.gen(function*() { + class Convert extends Schema.TaggedClass("Convert")("Convert", {}) {} + const states = Machine.defineStates({ Submit, RequestSucceeded }) + const machine = Machine.make({ + states: states.states, + events: Machine.events(Convert), + initial: () => states.initial.Submit(new Submit({ value: "loaded" })) + }).handle({ + Submit: { + on: { + Convert: ({ state, target }) => { + const { _tag: _, ...fields } = state + return target.full.RequestSucceeded.from(fields) + } + } + } + }) - assert.instanceOf(result, RequestSucceeded) - assert.deepStrictEqual(result, new RequestSucceeded({ value: "loaded" })) - }) + const plan = yield* Machine.plan( + machine, + states.initial.Submit(new Submit({ value: "loaded" })), + new Convert({}) + ) + assert.instanceOf(plan.next.value, RequestSucceeded) + assert.deepStrictEqual(plan.next.value, new RequestSucceeded({ value: "loaded" })) + })) it("make stores the machine id", () => { const states = Machine.defineStates({ Idle, Loading }) @@ -588,6 +617,7 @@ describe("Machine", () => { SetValue: ({ event, target }) => target.full.Active.from({ value: event.value }), Reset: (_, enqueue) => { enqueue.raise(internalEvents.Loaded({ value: "loaded" })) + return _.target.none() }, Defaulted: ({ event, target }) => target.full.Active.from({ value: event.label ?? "default-label" }), Loaded: ({ event, target }) => target.full.Active.from({ value: event.value }), @@ -652,7 +682,7 @@ describe("Machine", () => { initial: () => states.initial.Idle.from() }) const events = definition.events - const machine = definition.handle({ Idle: { on: { Submit: () => undefined } } }) + const machine = definition.handle({ Idle: { on: { Submit: ({ target }) => target.none() } } }) let construction: ReturnType | undefined assert.doesNotThrow(() => { @@ -745,7 +775,7 @@ describe("Machine", () => { states: states.states, events: Machine.events(SecondEvent), initial: () => states.initial.Idle.from() - }).handle({ Idle: { on: { Submit: () => undefined } } }) + }).handle({ Idle: { on: { Submit: ({ target }) => target.none() } } }) const construction = first.events.Submit({ value: "value" }) const initial = yield* Machine.planInitial(second) const error = yield* Machine.plan(second, initial.state, construction).pipe(Effect.flip) @@ -3970,7 +4000,7 @@ describe("Machine", () => { assert.deepStrictEqual(planned.microsteps, []) })) - it.effect("handlers can omit returning a state for self-transitions", () => + it.effect("handlers use target.none for explicit targetless transitions", () => Effect.gen(function*() { const machine = Machine.make({ states: { Idle, Loading }, @@ -3980,7 +4010,7 @@ describe("Machine", () => { }).handle({ Idle: { on: { - Submit: () => {} + Submit: ({ target }) => target.none() } } }) @@ -4535,7 +4565,7 @@ describe("Machine", () => { Effect.andThen(Effect.never) ) }), - onFailure: () => undefined + onFailure: ({ target }) => target.none() }), on: { RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) @@ -4579,7 +4609,7 @@ describe("Machine", () => { Effect.onInterrupt(() => sendTo(parent, new RequestSucceeded({ value: "stale" }))) ) }), - onFailure: () => undefined + onFailure: ({ target }) => target.none() }), on: { Resolve: () => FlatInitial.Idle(new Idle({ userId: "resolved" })) @@ -4772,7 +4802,7 @@ describe("Machine", () => { }) })) - it.effect("start lets invoke snapshot handlers filter with undefined", () => + it.effect("start lets invoke snapshot handlers filter with target.none", () => Effect.gen(function*() { const started = yield* Deferred.make() const release = yield* Deferred.make() @@ -4801,7 +4831,9 @@ describe("Machine", () => { ) }), onSnapshot: ({ snapshot, target }) => - snapshot.state === "ready" ? target.full.Success(new Success({ requestId: snapshot.state })) : undefined + snapshot.state === "ready" + ? target.full.Success(new Success({ requestId: snapshot.state })) + : target.none() }) }, Success: { @@ -4855,7 +4887,7 @@ describe("Machine", () => { initial: "pending", run: () => Effect.void }), - onDone: () => undefined + onDone: ({ target }) => target.none() }) } }) @@ -5406,7 +5438,7 @@ describe("Machine", () => { }).handle({ ConcurrentIdle: { on: { - ConcurrentPing: () => {} + ConcurrentPing: ({ target }) => target.none() } } }) diff --git a/test/machine/RuntimeDifferential.test.ts b/test/machine/RuntimeDifferential.test.ts index b97b34e..86a2e53 100644 --- a/test/machine/RuntimeDifferential.test.ts +++ b/test/machine/RuntimeDifferential.test.ts @@ -60,6 +60,7 @@ describe("pure planning and managed runtime differential", () => { on: { Cascade: (_, enqueue) => { enqueue.raise(new Increment({})) + return _.target.none() }, Increment: ({ state, target }) => target.full.Count(new Count({ value: state.value + 1 })), Finish: ({ state, target }) => target.full.Done(new Done({ value: state.value })) @@ -200,6 +201,7 @@ describe("pure planning and managed runtime differential", () => { left: snapshot.states.Left.value.value, right: snapshot.states.Right.value.value }) + return context.target.none() } } } @@ -463,6 +465,7 @@ describe("pure planning and managed runtime differential", () => { RaisedOne: (_, enqueue) => { record("raised:one") enqueue.emit(new Notice({ label: "raised-one" })) + return _.target.none() }, RaisedTwo: ({ target }, enqueue) => { record("raised:two") diff --git a/test/machine/SnapshotContext.test.ts b/test/machine/SnapshotContext.test.ts index e3ccf8f..7bb726b 100644 --- a/test/machine/SnapshotContext.test.ts +++ b/test/machine/SnapshotContext.test.ts @@ -59,7 +59,7 @@ describe("Machine transition snapshot context", () => { captured = snapshot return States.matches(snapshot, "System.Network.Online") ? target.local.Playing(new Playing({})) - : undefined + : target.none() } } } @@ -146,7 +146,7 @@ describe("Machine transition snapshot context", () => { captured = snapshot return States.matches(snapshot, "System.Network.Online") ? target.local.Playing(new Playing({})) - : undefined + : target.none() } } } diff --git a/test/machine/Visualization.test.ts b/test/machine/Visualization.test.ts index 925880f..088bd81 100644 --- a/test/machine/Visualization.test.ts +++ b/test/machine/Visualization.test.ts @@ -93,7 +93,7 @@ const machine = Machine.make({ }, Refresh: { targets: [], - transition: () => undefined + transition: ({ target }) => target.none() } } }, @@ -218,16 +218,16 @@ describe("Machine structural visualization", () => { on: { Refresh: { reenter: true, - transition: () => undefined + transition: ({ target }) => target.none() } }, always: { targets: ["idle"], - transition: () => undefined + transition: ({ target }) => target.none() }, onDone: { targets: ["idle"], - transition: () => undefined + transition: ({ target }) => target.none() } } }) @@ -419,7 +419,7 @@ describe("Machine structural visualization", () => { on: { Start: { targets: ["missing"] as any, - transition: () => undefined + transition: ({ target }) => target.none() } } } @@ -436,7 +436,7 @@ describe("Machine structural visualization", () => { idle: { always: { targets: ["missing"], - transition: () => undefined + transition: ({ target }: any) => target.none() } } } as any), @@ -448,7 +448,7 @@ describe("Machine structural visualization", () => { workflow: { onDone: { targets: ["missing"], - transition: () => undefined + transition: ({ target }: any) => target.none() } } } as any), diff --git a/test/testing/Coverage.test.ts b/test/testing/Coverage.test.ts index e3fa8b9..7de3001 100644 --- a/test/testing/Coverage.test.ts +++ b/test/testing/Coverage.test.ts @@ -60,7 +60,7 @@ const startupMachine = Machine.make({ always: ({ target, state }) => state.value === 0 ? target.full.count(new Count({ value: 1 })) - : undefined, + : target.none(), on: { Add: ({ event, state, target }) => target.full.count(new Count({ value: state.value + event.amount })) } @@ -81,9 +81,9 @@ const finiteEventMachine = Machine.make({ }).handle({ count: { on: { - [Tick]: () => undefined, - Alpha: () => undefined, - Beta: () => undefined + [Tick]: ({ target }) => target.none(), + Alpha: ({ target }) => target.none(), + Beta: ({ target }) => target.none() } } }) diff --git a/test/testing/MachineTest.test.ts b/test/testing/MachineTest.test.ts index 13b846a..509bdb5 100644 --- a/test/testing/MachineTest.test.ts +++ b/test/testing/MachineTest.test.ts @@ -249,7 +249,10 @@ describe("MachineTest", () => { }).handle({ Idle: { on: { - Start: () => undefined + Start: { + targets: ["Idle"], + transition: ({ target }) => target.none() + } } } }) diff --git a/test/testing/Probe.test.ts b/test/testing/Probe.test.ts index c14f369..d5927c7 100644 --- a/test/testing/Probe.test.ts +++ b/test/testing/Probe.test.ts @@ -28,7 +28,7 @@ const machine = Machine.make({ Counter: { on: { Increment: ({ event, state, target }) => target.full.Counter(new Counter({ count: state.count + event.amount })), - Noop: () => undefined, + Noop: ({ target }) => target.none(), Reenter: { reenter: true, transition: ({ state, target }) => target.full.Counter(new Counter({ count: state.count })) diff --git a/test/testing/Runtime.test.ts b/test/testing/Runtime.test.ts index 5dffc6b..004c4d2 100644 --- a/test/testing/Runtime.test.ts +++ b/test/testing/Runtime.test.ts @@ -47,7 +47,7 @@ const causalMachine = Machine.make({ Counter: { on: { Add: ({ event, state, target }) => target.full.Counter(new Counter({ count: state.count + event.amount })), - Noop: () => undefined, + Noop: ({ target }) => target.none(), Burst: ({ state, target }, enqueue) => { enqueue.raise(new InternalAdd({ amount: 10 })) return target.full.Counter(new Counter({ count: state.count + 1 })) diff --git a/test/testing/Verification.test.ts b/test/testing/Verification.test.ts index e6e17c6..d395627 100644 --- a/test/testing/Verification.test.ts +++ b/test/testing/Verification.test.ts @@ -65,7 +65,7 @@ const counterMachine = Machine.make({ counter: { on: { Increment: ({ state, target }) => target.full.counter(new Counter({ count: state.count + 1 })), - Noop: () => undefined + Noop: ({ target }) => target.none() } } }) diff --git a/test/unstable/cluster/ClusterMachine.test.ts b/test/unstable/cluster/ClusterMachine.test.ts index 5e61d2f..f15e7e1 100644 --- a/test/unstable/cluster/ClusterMachine.test.ts +++ b/test/unstable/cluster/ClusterMachine.test.ts @@ -568,7 +568,7 @@ describe("ClusterMachine", () => { invoke: Machine.invoke({ id: "child", effect: Effect.void, - onDone: () => undefined + onDone: ({ target }) => target.none() }) } }) diff --git a/test/unstable/reactivity/AtomMachine.test.ts b/test/unstable/reactivity/AtomMachine.test.ts index 41dfc28..6a083c0 100644 --- a/test/unstable/reactivity/AtomMachine.test.ts +++ b/test/unstable/reactivity/AtomMachine.test.ts @@ -158,7 +158,7 @@ describe("AtomMachine", () => { } }, ValueRead: { - invoke: Machine.invoke({ child: Child, onDone: () => undefined }), + invoke: Machine.invoke({ child: Child, onDone: ({ target }) => target.none() }), on: { ReadValue: () => MachineInitial.Count(new Count({ value: 0 })) } diff --git a/typetest/machine/Activities.tst.ts b/typetest/machine/Activities.tst.ts index b8c701d..9ec0e45 100644 --- a/typetest/machine/Activities.tst.ts +++ b/typetest/machine/Activities.tst.ts @@ -13,10 +13,10 @@ const machine = Machine.make({ initial: () => States.initial.Loading(new Loading({})) }).handle({ Loading: { - invoke: Machine.invoke({ id: "timeout", after: "1 second", onDone: () => undefined }) + invoke: Machine.invoke({ id: "timeout", after: "1 second", onDone: ({ target }) => target.none() }) }, Dynamic: { - invoke: Machine.invoke({ id: "dynamic", after: () => "2 seconds" as const, onDone: () => undefined }) + invoke: Machine.invoke({ id: "dynamic", after: () => "2 seconds" as const, onDone: ({ target }) => target.none() }) } }) diff --git a/typetest/machine/ActorEvents.tst.ts b/typetest/machine/ActorEvents.tst.ts index d0a4ebf..e8b77fb 100644 --- a/typetest/machine/ActorEvents.tst.ts +++ b/typetest/machine/ActorEvents.tst.ts @@ -36,7 +36,7 @@ describe("machine actor event channels", () => { }).handle({ Idle: { on: { - Ping: () => undefined + Ping: ({ target }) => target.none() } } }) @@ -53,7 +53,7 @@ describe("machine actor event channels", () => { }).handle({ Idle: { on: { - Ping: ({ parent, self }, enqueue) => { + Ping: ({ parent, self, target }, enqueue) => { expect(self.send).type.toBeCallableWith(Events.Ping()) expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) expect(enqueue.sendTo).type.toBeCallableWith(self, Events.Ping()) @@ -68,6 +68,7 @@ describe("machine actor event channels", () => { expect(enqueue.emit).type.toBeCallableWith(Emissions.Published()) expect(enqueue.emit).type.toBeCallableWith(Emissions.ValuedPublished({ value: 1 })) expect(enqueue.emit).type.not.toBeCallableWith(Events.Ping()) + return target.none() } } } @@ -84,9 +85,9 @@ describe("machine actor event channels", () => { Idle: { invoke: { child: Child, - onDone: () => undefined, - onFailure: () => undefined, - onSnapshot: () => undefined + onDone: () => states.initial.Idle(new Idle({})), + onFailure: () => states.initial.Idle(new Idle({})), + onSnapshot: () => states.initial.Idle(new Idle({})) } } }) @@ -100,9 +101,9 @@ describe("machine actor event channels", () => { Idle: { invoke: { child: Child, - onDone: () => undefined, - onFailure: () => undefined, - onSnapshot: () => undefined + onDone: () => states.initial.Idle(new Idle({})), + onFailure: () => states.initial.Idle(new Idle({})), + onSnapshot: () => states.initial.Idle(new Idle({})) } } }) diff --git a/typetest/machine/EventByTag.tst.ts b/typetest/machine/EventByTag.tst.ts index 1ac64dc..25c1aa1 100644 --- a/typetest/machine/EventByTag.tst.ts +++ b/typetest/machine/EventByTag.tst.ts @@ -39,19 +39,21 @@ describe("Machine.EventByTag", () => { }).handle({ Idle: { on: { - Alpha: ({ event }) => { + Alpha: ({ event, target }) => { expect(event).type.toBe<{ readonly _tag: "Alpha" readonly payload: string readonly count: number }>() + return target.none() }, - Beta: ({ event }) => { + Beta: ({ event, target }) => { expect(event).type.toBe<{ readonly _tag: "Beta" readonly payload: string readonly count: number }>() + return target.none() } } } diff --git a/typetest/machine/EventConstructors.tst.ts b/typetest/machine/EventConstructors.tst.ts index 3eaf752..686a196 100644 --- a/typetest/machine/EventConstructors.tst.ts +++ b/typetest/machine/EventConstructors.tst.ts @@ -113,12 +113,18 @@ describe("Machine event constructor collections", () => { Machine.invoke({ id: "load", effect: Effect.succeed("ready"), - onDone: ({ output }, enqueue) => enqueue.raise(internalEvents.Loaded({ value: output })) + onDone: ({ output, target }, enqueue) => { + enqueue.raise(internalEvents.Loaded({ value: output })) + return target.none() + } }), Machine.invoke({ id: "timeout", after: "1 second", - onDone: (_, enqueue) => enqueue.raise(internalEvents.Failed()) + onDone: ({ target }, enqueue) => { + enqueue.raise(internalEvents.Failed()) + return target.none() + } }) ] } diff --git a/typetest/machine/Inspection.tst.ts b/typetest/machine/Inspection.tst.ts index abd51f9..3d0dae6 100644 --- a/typetest/machine/Inspection.tst.ts +++ b/typetest/machine/Inspection.tst.ts @@ -26,7 +26,7 @@ describe("Machine inspection", () => { }).handle({ root: { on: { - Reset: () => undefined + Reset: ({ target }) => target.none() } } }) diff --git a/typetest/machine/Machine.tst.ts b/typetest/machine/Machine.tst.ts index c7c4b24..ce5c654 100644 --- a/typetest/machine/Machine.tst.ts +++ b/typetest/machine/Machine.tst.ts @@ -558,7 +558,7 @@ describe("Machine", () => { expect(state).type.toBe() return Effect.succeed(state._tag) }, - onDone: () => undefined + onDone: () => UpStates.initial.down(new Down({})) } } }) @@ -583,11 +583,13 @@ describe("Machine", () => { expect(state).type.toBe() return load(state._tag) }, - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output).type.toBe<{ userId: string }>() + return target.none() }, - onFailure: ({ error }) => { + onFailure: ({ error, target }) => { expect(error).type.toBe() + return target.none() } }) } @@ -610,15 +612,17 @@ describe("Machine", () => { Machine.invoke({ id: "success", effect: ({ state }) => Effect.succeed(state._tag), - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output).type.toBe<"Down">() + return target.none() } }), Machine.invoke({ id: "failure", effect: ({ state }) => Effect.fail(new LoadFailure()).pipe(Effect.annotateLogs("state", state._tag)), - onFailure: ({ error }) => { + onFailure: ({ error, target }) => { expect(error).type.toBe() + return target.none() } }), Machine.invoke({ @@ -628,8 +632,9 @@ describe("Machine", () => { Machine.invoke({ id: "requirements", effect: ({ state }) => Effect.as(EntryRequirement, state._tag), - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output).type.toBe<"Down">() + return target.none() } }) ] @@ -641,8 +646,9 @@ describe("Machine", () => { invoke: Machine.invoke({ id: "requirements-only", effect: ({ state }) => Effect.as(EntryRequirement, state._tag), - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output).type.toBe<"Down">() + return target.none() } }) } @@ -705,11 +711,13 @@ describe("Machine", () => { }).handle({ down: { on: { - SignIn: ({ event }) => { + SignIn: ({ event, target }) => { expect(event).type.toBe() + return target.none() }, - SignInCompleted: ({ event }) => { + SignInCompleted: ({ event, target }) => { expect(event).type.toBe() + return target.none() } } } @@ -774,38 +782,34 @@ describe("Machine", () => { invoke: Machine.invoke({ id: "erased-failure", effect: erasedFailure, - onFailure: ({ error }) => { + onFailure: ({ error, target }) => { expect(error).type.toBe() + return target.none() } }) } }) }) - it("retag reuses compatible fields and requires missing target fields", () => { - const source = new Up({ id: "up-1" }) - const target = Machine.retag(RetaggedUp, source, { attempt: 1 }) - const RetaggedStruct = Schema.TaggedStruct("RetaggedStruct", { - id: Schema.String, - attempt: Schema.Number - }) - const requiredTag = Schema.Struct({ - _tag: Schema.Literal("RequiredTag"), - id: Schema.String - }) - const unionTarget = Schema.Union([ - Schema.TaggedStruct("FirstTarget", { id: Schema.String }), - Schema.TaggedStruct("SecondTarget", { id: Schema.String }) - ]) - - expect(target).type.toBe() - expect(Machine.retag).type.toBeCallableWith(RetaggedStruct, source, { attempt: 1 }) - expect(Machine.retag).type.not.toBeCallableWith(RetaggedUp, source) - expect(Machine.retag).type.not.toBeCallableWith(RetaggedUp, source, { - attempt: "invalid" + it("constructs sibling targets from destructured source fields", () => { + const states = Machine.defineStates({ source: Up, target: RetaggedUp }) + Machine.make({ + states: states.states, + events: Machine.events(SignIn), + initial: () => states.initial.source(new Up({ id: "up-1" })) + }).handle({ + source: { + on: { + SignIn: ({ state, target }) => { + const { _tag: _, ...fields } = state + expect(target.full.target.from).type.toBeCallableWith({ ...fields, attempt: 1 }) + expect(target.full.target.from).type.not.toBeCallableWith(fields) + expect(target.full.target.from).type.not.toBeCallableWith({ ...fields, attempt: "invalid" }) + return target.full.target.from({ ...fields, attempt: 1 }) + } + } + } }) - expect(Machine.retag).type.not.toBeCallableWith(requiredTag, source) - expect(Machine.retag).type.not.toBeCallableWith(unionTarget, source) }) it("child invocation composes complete machines with type-safe protocols", () => { @@ -842,12 +846,14 @@ describe("Machine", () => { invoke: Machine.invoke({ child: Child, input: { userId: "child" }, - onSnapshot: ({ snapshot }) => { + onSnapshot: ({ snapshot, target }) => { expect(snapshot.state).type.toBe>() + return target.none() }, - onDone: ({ output, state }) => { + onDone: ({ output, state, target }) => { expect(output).type.toBe() expect(state).type.toBe() + return target.none() } }) } @@ -913,9 +919,10 @@ describe("Machine", () => { invoke: Machine.invoke({ id: "nested", effect: Effect.succeed(Option.some(1)), - onDone: ({ output, state }) => { + onDone: ({ output, state, target }) => { expect(output).type.toBe>() expect(state).type.toBe() + return target.none() } }) } @@ -994,7 +1001,7 @@ describe("Machine", () => { | Machine.MachineSchemaDecodeError | Machine.StoppedError >() - const invocation = Machine.invoke({ child: Child, onDone: () => undefined }) + const invocation = Machine.invoke({ child: Child, onDone: ({ target }) => target.none() }) expect>().type.toBe() }) @@ -1099,8 +1106,9 @@ describe("Machine", () => { } void id }, - always: ({ event }) => { + always: ({ event, target }) => { expect(event).type.toBe() + return target.none() }, states: { auth: { @@ -1446,13 +1454,14 @@ describe("Machine", () => { machine.handle({ payment: { - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output.status).type.toBe<"approved" | "declined">() if (output.status === "approved") { expect(output.authId).type.toBe() } else { expect(output.reason).type.toBe() } + return target.none() }, states: { approved: { @@ -1532,9 +1541,10 @@ describe("Machine", () => { requestId: outputs.sync.requestId } }, - onDone: ({ output }) => { + onDone: ({ output, target }) => { expect(output.userId).type.toBe() expect(output.requestId).type.toBe() + return target.none() }, states: { auth: { @@ -2230,6 +2240,31 @@ describe("Machine", () => { const context = null as unknown as SignInContext expect>().type.toBe() + expect(context.target.none()).type.toBe() + }) + + it("requires explicit targetless results and permits them with declared targets", () => { + const definition = Machine.make({ + states: UpStates.states, + events: Machine.events(SignIn), + initial: () => UpStates.initial.down(new Down({})) + }) + + expect(definition.handle).type.not.toBeCallableWith({ + down: { on: { SignIn: () => undefined } } + }) + expect( + definition.handle({ + down: { + on: { + SignIn: { + targets: ["up.auth.signedIn"], + transition: ({ target }) => target.none() + } + } + } + }) + ).type.not.toRaiseError() }) it("rejects invalid compound initial keys", () => { diff --git a/typetest/machine/Readiness.tst.ts b/typetest/machine/Readiness.tst.ts index 74341a9..4f90b81 100644 --- a/typetest/machine/Readiness.tst.ts +++ b/typetest/machine/Readiness.tst.ts @@ -171,7 +171,7 @@ describe("executable machine readiness", () => { const resumed = Machine.resume(complete, completeSnapshot) const invocation = Machine.invoke({ child: Machine.child("complete", complete), - onDone: () => undefined + onDone: ({ target }) => target.none() }) const trace = MachineTest.run(complete, { events: [new Tick({})] }) const atom = AtomMachine.make(complete) diff --git a/typetest/machine/SnapshotContext.tst.ts b/typetest/machine/SnapshotContext.tst.ts index 5306938..eee4572 100644 --- a/typetest/machine/SnapshotContext.tst.ts +++ b/typetest/machine/SnapshotContext.tst.ts @@ -58,9 +58,9 @@ describe("Machine transition snapshot context", () => { }, states: { LeftIdle: { - always: ({ snapshot }) => { + always: ({ snapshot, target }) => { expect(snapshot).type.toBe>() - return undefined + return target.none() }, on: { Advance: ({ snapshot, target }) => { diff --git a/typetest/testing/Exploration.tst.ts b/typetest/testing/Exploration.tst.ts index 1c6e7c1..79b2009 100644 --- a/typetest/testing/Exploration.tst.ts +++ b/typetest/testing/Exploration.tst.ts @@ -19,8 +19,8 @@ describe("MachineTest exploration", () => { }).handle({ counter: { on: { - Increment: () => undefined, - Internal: () => undefined + Increment: ({ target }) => target.none(), + Internal: ({ target }) => target.none() } } }) diff --git a/typetest/testing/Invariant.tst.ts b/typetest/testing/Invariant.tst.ts index a023a21..ae6afa5 100644 --- a/typetest/testing/Invariant.tst.ts +++ b/typetest/testing/Invariant.tst.ts @@ -17,8 +17,8 @@ describe("MachineTest invariants", () => { }).handle({ idle: { on: { - Tick: () => undefined, - Internal: () => undefined + Tick: ({ target }) => target.none(), + Internal: ({ target }) => target.none() } } }) diff --git a/typetest/testing/MachineTest.tst.ts b/typetest/testing/MachineTest.tst.ts index 8d3d37b..4835b84 100644 --- a/typetest/testing/MachineTest.tst.ts +++ b/typetest/testing/MachineTest.tst.ts @@ -22,8 +22,8 @@ describe("MachineTest", () => { }).handle({ idle: { on: { - PublicEvent: () => undefined, - InternalEvent: () => undefined + PublicEvent: ({ target }) => target.none(), + InternalEvent: ({ target }) => target.none() } } }) @@ -105,7 +105,7 @@ describe("MachineTest", () => { effect: Effect.gen(function*() { yield* InvokeRequirement }), - onDone: () => undefined + onDone: ({ target }) => target.none() }) } }) diff --git a/typetest/testing/Probe.tst.ts b/typetest/testing/Probe.tst.ts index 4141867..7382452 100644 --- a/typetest/testing/Probe.tst.ts +++ b/typetest/testing/Probe.tst.ts @@ -19,8 +19,8 @@ describe("MachineTest probe", () => { }).handle({ State: { on: { - PublicEvent: () => undefined, - InternalEvent: () => undefined + PublicEvent: ({ target }) => target.none(), + InternalEvent: ({ target }) => target.none() } } })