diff --git a/.changeset/inline-invocation-lifecycles.md b/.changeset/inline-invocation-lifecycles.md new file mode 100644 index 0000000..4038ea5 --- /dev/null +++ b/.changeset/inline-invocation-lifecycles.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine": minor +--- + +Replace `Machine.invokeEffect`, `Machine.after`, `Machine.invokeMachine`, and `Machine.effect` with one inline `invoke` lifecycle object API and a zero-runtime `Machine.invoke` inference helper. + +Choose an `effect`, `after`, `logic`, or `child` source and handle typed outcomes directly with `onDone`, `onFailure`, and `onSnapshot`. Lifecycle handlers can now transition the owning state without routing results through mapped machine events. + +State-dependent Effect sources infer their owner state, output, error, and service requirements together without a manual return annotation. diff --git a/README.md b/README.md index 63192ad..255fbbd 100644 --- a/README.md +++ b/README.md @@ -201,29 +201,48 @@ State-scoped work starts on entry and is interrupted on exit: ```ts Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "save-document", effect: saveDocument, - onSuccess: (entry) => InternalEvent.Saved({ id: entry.id }), - onFailure: (error) => InternalEvent.SaveFailed({ message: String(error) }) + onDone: ({ output, target }) => target.full.Saved({ id: output.id }), + onFailure: ({ error, target }) => target.full.Failed({ message: String(error) }) }) } Waiting: { - invoke: Machine.after( - "3 seconds", - InternalEvent.SaveFailed({ message: "Timed out" }) - ) + invoke: Machine.invoke({ + id: "save-timeout", + after: "3 seconds", + onDone: ({ target }) => target.full.Failed({ message: "Timed out" }) + }) } ``` -Use `Machine.invokeEffect` for one Effect, `Machine.after` for a cancellable -delay, and lower-level `Machine.invoke` only for custom process behavior or -snapshot mapping. Use one exported `Machine.child(id, machine)` descriptor for -`invokeMachine`, `sendTo`, and child lookup. +Use `effect` for one Effect, `after` for a cancellable delay, `logic` for a +reusable process, and `child` for a complete child statechart—all through +`Machine.invoke({...})`. The helper is an identity at runtime and preserves +owner-context and source-channel inference across lifecycle handlers, including +for state-dependent Effects: + +```ts +invoke: Machine.invoke({ + id: "load-document", + effect: ({ state }) => loadDocument(state.documentId), + onDone: ({ output, target }) => target.full.Ready({ document: output }), + onFailure: ({ error, target }) => target.full.Failed({ message: error.message }) +}) +``` + +A direct `invoke: { ... }` object is also supported when its lifecycle handlers +do not need source-derived context. Reuse one exported +`Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child +lookup. -Expected failures should become internal events. An unrecovered invoke or child -failure terminates the owning runtime. +`onDone` is required for a non-`never` output, and `onFailure` is required for a +non-`never` typed error; each handler is omitted when its channel is `never`. +Defects, interruption, and source-construction failures terminate the owning +runtime. `effect: Effect.sleep(...)` is valid, but `after` keeps timers explicit +and makes static durations visible through activity inspection. ## Reactivity diff --git a/api-reference.config.json b/api-reference.config.json index 2072ac9..f904e0a 100644 --- a/api-reference.config.json +++ b/api-reference.config.json @@ -26,14 +26,11 @@ "source": "src/Machine.ts", "barrel": ".", "examples": [ - "after", "decodeSnapshot", "defineStates", - "effect", "encodeSnapshot", "event", "invoke", - "invokeEffect", "make", "plan", "planInitial", diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 3b13276..55a53ca 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -70,7 +70,8 @@ the deferred constructors preserve that identity after decoding. - Return snapshots or typed target-builder results from transitions. Do not return raw decoded state values. - Transition and lifecycle callbacks are synchronous. Put asynchronous work in - an invoked Effect, actor, or child machine and map its result to an event. + an invoked Effect, logic process, or child machine and handle its lifecycle + with `onDone`, `onFailure`, and `onSnapshot`. - Put data on the narrowest state where it is valid. Put data shared by sibling phases on their compound parent. - Declare finality only in the state definition. Do not put `type: "final"` in @@ -80,12 +81,13 @@ the deferred constructors preserve that identity after decoding. - `parents` keys are full dotted paths. - Invoke lifetimes follow state entry and exit, not the spelling of the target builder. -- Recover expected invoked Effect failures into machine events. Unrecovered - child failures terminate the owning machine. -- Reuse the exact child descriptor value for `invokeMachine`, `sendTo`, and - child lookup. +- Handle every typed invoked Effect failure with `onFailure`. Defects and + interruption terminate the owning machine. +- Reuse an exported child descriptor for inline invocation, `sendTo`, and child + lookup. Independently constructed descriptors are equivalent only when both + their id and machine identity match. - `events` is the public input protocol. `internalEvents` contains machine-local - deliveries such as invoke results and invoked-child emissions. Handlers see + deliveries such as raised events and invoked-child emissions. Handlers see both; typed public `send` and `Machine.plan` accept only `events`. - Event tags in `events` and `internalEvents` must be disjoint. - Event tags must also be unique within each protocol list. @@ -98,13 +100,15 @@ its extra control is required: - Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the returned `make` or `resume`. Use `AtomMachine.make(machine)` and `AtomMachine.resume(machine, snapshot)` for service-free machines. -- Use `Machine.invokeEffect` for a typed one-shot Effect and `Machine.after` for - a timer. Use `Machine.invoke` with `Machine.effect` only for custom child - process behavior or snapshot mapping. +- Use one invocation object: `effect` for one-shot work, `after` for a timer, + `logic` for reusable process logic, and `child` for a complete child + statechart. `Machine.invoke({...})` preserves owner context and source + channels across sibling lifecycle handlers. Use a direct object only when its + lifecycle handlers do not need source-derived context. - Use `Machine.child(id, machine)` for a complete statechart descriptor and - `Machine.childAddress(id)` for a low-level process address. An - invocation is addressable only when `Machine.invoke` receives that address - explicitly. + `Machine.childAddress(id)` for a low-level process address. A logic + invocation is addressable only when `Machine.invoke` receives that + address explicitly. - Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and `stop`. These operations record closed actor commands and do not run Effects. @@ -541,8 +545,8 @@ type AnyHandledEvent = Machine.Machine.Event `MachineRef.send`, `machineAtom.send`, and `Machine.plan` accept decoded public events or constructions returned by `Machine.events`. Transition handlers -receive only decoded events. Raised events, invoke results, and mapped child -events additionally accept constructions from `Machine.internalEvents`. The +receive only decoded events. Raised events and child emissions additionally +accept constructions from `Machine.internalEvents`. The local planner and runtime intentionally share the complete decoder to support those internal deliveries, so JavaScript or `any` can bypass the local public distinction. @@ -552,45 +556,63 @@ across both configuration lists. ## Recoverable state-scoped work -Use `Machine.invokeEffect` for a one-shot Effect. Its callbacks preserve the -typed success and failure channels while mapping both into machine events: +Use `Machine.invoke` with an `effect` for one-shot work. Lifecycle callbacks +receive the typed Effect channels and can transition directly: ```ts -invoke: ({ state }) => - Machine.invokeEffect({ - id: "save", - effect: SaveService.save(state.draft), - onSuccess: (entry) => InternalEvents.Saved({ entry }), - onFailure: (error) => InternalEvents.SaveFailed({ message: error.message }) - }) +invoke: Machine.invoke({ + id: "save", + effect: SaveService.save(draft), + onDone: ({ output, target }) => target.full.Saved({ entry: output }), + onFailure: ({ error, target }) => + target.full.SaveFailed({ message: error.message }) +}) ``` The owning state scopes the child. Owner-driven interruption on state exit is -normal cancellation and stale output is ignored. A child Effect that defects -or self-interrupts fails the parent. Omit `onFailure` only when the Effect error -type is `never`; defects and interruption are not mapped. +normal cancellation and stale output is ignored. An Effect that defects or +self-interrupts fails the parent. `onDone` is required when the output is not +`never`; `onFailure` is required when the typed error is not `never`. Handlers +are forbidden when their channel is `never`. -Successful non-void output is delivered as a parent event. Include every -possible mapped result schema in the parent machine's `internalEvents` array and -add handlers for the relevant tags. Leave defects and interruption fatal; -recover only expected typed failures. +The source may also be a function of the owning state's entry context when it +needs `state`, `parent`, `parents`, or the entry `event`. Source construction +errors, defects, and interruption are machine failures rather than a second +phase in `onFailure`. -A cancellable timer uses `Machine.after`: +When a source function reads `state`, `parent`, `parents`, or the entry `event`, +`Machine.invoke` infers that owner context and the returned Effect's output, +error, and service channels together. No return annotation is needed: ```ts -invoke: Machine.after("3 seconds", InternalEvents.ClearStatus(), { - id: "clear-status" +invoke: Machine.invoke({ + id: "load", + effect: ({ state }) => LoadService.load(state.userId), + onDone: ({ output, target }) => target.full.Loaded({ user: output }), + onFailure: ({ error, target }) => target.full.LoadFailed({ error }) }) ``` -The timer starts on state entry and is interrupted on exit. Supply an explicit -id when more than one active timer could deliver the same event tag. Use -lower-level `Machine.invoke` with `Machine.effect` when custom child logic or -snapshot mapping is required. In that API, `id` is only the invocation's -state-local lifecycle key. To communicate with the invocation, create a -`Machine.childAddress("worker")` and pass it as `address`; TypeScript -checks the address protocol against the child logic. Lifecycle ids must be -unique among simultaneously active invokes owned by the same state. +A direct `invoke: { ... }` object remains available when lifecycle handlers do +not need source-derived context. + +A cancellable timer uses the same object: + +```ts +invoke: Machine.invoke({ + id: "clear-status", + after: "3 seconds", + onDone: ({ target }) => target.full.Clear() +}) +``` + +The timer starts on state entry and is interrupted on exit. Its `onDone` is +always required. `effect: Effect.sleep(...)` has the same scoped cancellation +behavior, but `after` records timer intent and exposes a static duration through +`Machine.activityDefinitions`. For reusable process logic, provide `logic`, a +state-local lifecycle `id`, and a typed `address`. TypeScript checks the address +protocol against the logic event protocol. Lifecycle ids and addresses serve +different purposes and must both be explicit. ## Invoked child statecharts @@ -603,10 +625,10 @@ const Editor = Machine.child("editor", EditorMachine) Invoke it from its owning state: ```ts -invoke: Machine.invokeMachine({ +invoke: Machine.invoke({ child: Editor, input: editorInput, - onDone: ({ output }) => new EditorCompleted({ output }) + onDone: ({ output, target }) => target.full.EditorDone({ output }) }) ``` @@ -618,9 +640,9 @@ parentRef.child(Editor) parentAtom.child(Editor) ``` -Child emissions, mapped snapshots, and mapped completion output are delivered as -parent events and must be accepted by the parent's `internalEvents` list. -Invoked child IDs must be unique while simultaneously active. +Child emissions are delivered through the parent's internal protocol. +`onSnapshot`, `onDone`, and `onFailure` are direct parent transitions. Invoked +child IDs must be unique while simultaneously active. Descriptors with the same id and machine identity address the same child, even when independently constructed. The descriptor objects themselves are not @@ -632,21 +654,18 @@ logic that does not have a complete machine descriptor. ### Inspecting state-owned activities Use `Machine.activityDefinitions(machine)` to inspect invokes without running -them. Static `Machine.invoke`, `Machine.invokeEffect`, `Machine.after`, and -`Machine.invokeMachine` descriptors expose serializable ownership metadata: +them. Static inline `Machine.invoke` definitions expose serializable ownership +metadata: ```ts Machine.activityDefinitions(machine) // [{ source: "Loading", id: "load-timeout", type: "timer", -// duration: "10s", event: "LoadTimedOut" }] +// duration: "10s" }] ``` -Effect success/failure mappers are closures and therefore appear as dynamic -outcomes. Child machines expose descriptor identity, never their runtime or -implementation. A function-valued `invoke` factory is represented as a dynamic -activity because inspection must not evaluate user code. The existing invoke -helpers remain the only execution API; this metadata does not add lifecycle -configuration syntax or affect execution. +Child machines expose descriptor identity, never their runtime or +implementation. Function-valued sources and durations are represented as +dynamic because inspection must not evaluate user code. ## AtomMachine and React @@ -755,8 +774,8 @@ Encoding does not preserve: - completion and history records survive but do not retrigger `onDone`; - active-state invokes start once in ordinary ancestor/document order with `Machine.InitialEvent`; -- `invokeEffect` restarts, `invokeMachine` creates a fresh child from its normal - initial state, and `Machine.after` restarts its complete duration; +- inline Effects restart, child machines start fresh from their normal initial + state, and timers restart their complete duration; - inactive invokes, spawned children, child snapshots, elapsed timer time, and prior `RuntimeSnapshot` status/errors are not restored; - a final logical snapshot creates an immediately completed ref; @@ -930,11 +949,6 @@ Wrap the initial builder result: initial: () => States.initial.Idle.from() ``` -### Invoked child output must be a machine event - -Add the output's tagged schema to the parent machine's `internalEvents` array, -or map/ignore the output before it reaches the parent. - ### Invoked child emits events not accepted by the parent Add the child's emitted schemas to the parent machine's `internalEvents` array: @@ -947,9 +961,8 @@ internalEvents: [...ChildMachine.emits] ### An internal event is rejected by `send` This is intentional. Public input boundaries accept only schemas declared in -`events`. Handle the event as an invoke result, child delivery, or raised event; -move it to `events` only if external callers should genuinely be allowed to -send it. +`events`. Handle the event as a child delivery or raised event; move it to +`events` only if external callers should genuinely be allowed to send it. ### Public and internal event tags overlap @@ -985,7 +998,7 @@ parents["Route.Ready"] ### Child descriptor types are unrelated -Use the descriptor exported by the module that configured `invokeMachine`. +Use the descriptor exported by the module that configured the child invocation. An independently created descriptor with the same id and machine identity also matches; the same id paired with a different machine remains a distinct child. @@ -1009,6 +1022,6 @@ The current API does not include: - declarative first-class guards; - a complete inspectable graph for arbitrary transition Effects. -Use ordinary TypeScript conditions for guards and `Machine.after` for -state-scoped timers. Do not invent undocumented state-node properties such as -`guard`. +Use ordinary TypeScript conditions for guards and an inline `Machine.invoke` +with `after` for state-scoped timers. Do not invent undocumented state-node +properties such as `guard`. diff --git a/examples/platformer/README.md b/examples/platformer/README.md index 0a3331b..2c1cb5d 100644 --- a/examples/platformer/README.md +++ b/examples/platformer/README.md @@ -73,7 +73,7 @@ only `Airborne` interprets the wall sample as a wall jump. It turns and pushes away, refreshes the air jump through `WallLock`, and the same wall may be used again after physically returning to it. Movement phases own their timestamps, and both landing and capability locks demonstrate state-scoped -`Machine.after` timers. +inline `Machine.invoke({ after: ... })` timers. Keyboard commands and physics facts share a typed `Schema.TaggedUnion` protocol. The adapter executes velocity and floor collision, then reports diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts index 12b21c0..69aa962 100644 --- a/examples/platformer/src/machine.ts +++ b/examples/platformer/src/machine.ts @@ -228,16 +228,10 @@ export const CharacterMachine = definition.handle({ } }, Landing: { - invoke: Machine.after("140 millis", InternalEvents.LandingSettled(), { - id: "landing-settle" - }), - on: { - Move: { - targets: ["Character.locomotion.Playing.Grounded.Landing"], - transition: ({ event, state, target }) => - target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })) - }, - LandingSettled: { + invoke: Machine.invoke({ + id: "landing-settle", + after: "140 millis", + onDone: { targets: [ "Character.locomotion.Playing.Grounded.Standing", "Character.locomotion.Playing.Grounded.Running" @@ -247,6 +241,13 @@ export const CharacterMachine = definition.handle({ ? target.local.Standing.from() : target.local.Running.from({ startedAt: state.landedAt + 140 }) } + }), + on: { + Move: { + targets: ["Character.locomotion.Playing.Grounded.Landing"], + transition: ({ event, state, target }) => + target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })) + } } } } @@ -324,26 +325,26 @@ export const CharacterMachine = definition.handle({ }, states: { AirJumpGroundLock: { - invoke: Machine.after("120 millis", InternalEvents.AirJumpUnlocked(), { - id: "ground-air-jump-unlock" - }), - on: { - AirJumpUnlocked: { + invoke: Machine.invoke({ + id: "ground-air-jump-unlock", + after: "120 millis", + onDone: { targets: ["Character.locomotion.Playing.Airborne.airJump.AirJumpReady"], transition: ({ target }) => target.local.AirJumpReady.from() } - } + }), + on: {} }, AirJumpWallLock: { - invoke: Machine.after("240 millis", InternalEvents.AirJumpUnlocked(), { - id: "wall-air-jump-unlock" - }), - on: { - AirJumpUnlocked: { + invoke: Machine.invoke({ + id: "wall-air-jump-unlock", + after: "240 millis", + onDone: { targets: ["Character.locomotion.Playing.Airborne.airJump.AirJumpReady"], transition: ({ target }) => target.local.AirJumpReady.from() } - } + }), + on: {} }, AirJumpReady: { on: { diff --git a/examples/playground/README.md b/examples/playground/README.md index 65a662a..07af69c 100644 --- a/examples/playground/README.md +++ b/examples/playground/README.md @@ -44,5 +44,5 @@ public commands. synchronization state when a tab joins. `src/examples/examples.test.ts` covers the smaller machines, including virtual -clock advancement for `Machine.after`. The media player keeps focused model and +clock advancement for inline invocation timers. The media player keeps focused model and property coverage in its own directory. diff --git a/examples/playground/src/examples/media-player/invocations.ts b/examples/playground/src/examples/media-player/invocations.ts index 93c1454..6b08686 100644 --- a/examples/playground/src/examples/media-player/invocations.ts +++ b/examples/playground/src/examples/media-player/invocations.ts @@ -5,54 +5,30 @@ import { type LoudnessSample, type SoundSettings, toAudioSettings } from "./sche import { MediaPlayer, MediaPlayerError } from "./service.ts" export const loadAudio = (url: string) => - Machine.invokeEffect({ - id: "load-audio", - effect: Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.load(url) - }), - onSuccess: () => MediaPlayerInternalEvents.LoadSucceeded(), - onFailure: (failure) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.load(url) }) -export const pauseAudio = Machine.invokeEffect({ - id: "pause-audio", - effect: Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.pause - }), - onSuccess: () => undefined, - onFailure: (failure) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) +export const pauseAudio = Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.pause }) -export const playAudio = Machine.invokeEffect({ - id: "play-audio", - effect: Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.play - }), - onSuccess: () => undefined, - onFailure: (failure) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) +export const playAudio = Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.play }) -export const restartAudio = Machine.invokeEffect({ - id: "restart-audio", - effect: Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.restart - }), - onSuccess: () => MediaPlayerInternalEvents.RestartSucceeded(), - onFailure: (failure) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) +export const restartAudio = Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.restart }) export const applyAudioSettings = (settings: SoundSettings, muted: boolean) => - Machine.invokeEffect({ - id: "apply-audio-settings", - effect: Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.applySettings(toAudioSettings(settings, muted)) - }), - onSuccess: () => undefined + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.applySettings(toAudioSettings(settings, muted)) }) type LoudnessProcessState = Data.TaggedEnum<{ @@ -63,27 +39,22 @@ type LoudnessProcessState = Data.TaggedEnum<{ const LoudnessProcessState = Data.taggedEnum() -export const analyzeAudio = Machine.invoke({ - id: "analyze-audio", - src: () => - Machine.logic({ - initial: LoudnessProcessState.Waiting(), - run: ({ setState }) => - Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer +export const analyzeAudio = Machine.logic({ + initial: LoudnessProcessState.Waiting(), + run: ({ setState }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.loudness.pipe( - Stream.runForEach((sample) => setState(LoudnessProcessState.Measured({ sample }))), - Effect.catch((failure) => - setState(LoudnessProcessState.Failed({ failure })).pipe(Effect.andThen(Effect.never)) - ) - ) - }) - }), - snapshot: ({ snapshot }) => - LoudnessProcessState.$match(snapshot.state, { - Waiting: () => undefined, - Measured: ({ sample }) => MediaPlayerInternalEvents.LoudnessMeasured(sample), - Failed: ({ failure }) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) + yield* mediaPlayer.loudness.pipe( + Stream.runForEach((sample) => setState(LoudnessProcessState.Measured({ sample }))), + Effect.catch((failure) => setState(LoudnessProcessState.Failed({ failure })).pipe(Effect.andThen(Effect.never))) + ) }) }) + +export const loudnessEvent = (state: LoudnessProcessState) => + LoudnessProcessState.$match(state, { + Waiting: () => undefined, + Measured: ({ sample }) => MediaPlayerInternalEvents.LoudnessMeasured(sample), + Failed: ({ failure }) => MediaPlayerInternalEvents.OperationFailed({ message: failure.message }) + }) diff --git a/examples/playground/src/examples/media-player/machine.ts b/examples/playground/src/examples/media-player/machine.ts index f9729ef..4e64ece 100644 --- a/examples/playground/src/examples/media-player/machine.ts +++ b/examples/playground/src/examples/media-player/machine.ts @@ -1,7 +1,15 @@ import { Machine } from "@typeonce/effect-machine" import { Effect } from "effect" -import { MediaPlayerDefinition } from "./definition.ts" -import { analyzeAudio, applyAudioSettings, loadAudio, pauseAudio, playAudio, restartAudio } from "./invocations.ts" +import { MediaPlayerDefinition, MediaPlayerInternalEvents } from "./definition.ts" +import { + analyzeAudio, + applyAudioSettings, + loadAudio, + loudnessEvent, + pauseAudio, + playAudio, + restartAudio +} from "./invocations.ts" import { initialPlaybackData, updatePlaybackData } from "./schemas.ts" import { MediaPlayer } from "./service.ts" @@ -23,7 +31,13 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ Empty: {}, Loading: { - invoke: ({ state }) => loadAudio(state.url), + invoke: Machine.invoke({ + id: "load-audio", + effect: ({ state }) => loadAudio(state.url), + onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()), + onFailure: ({ error }, enqueue) => + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + }), on: { LoadSucceeded: ({ target }) => target.local.Ready.from((ready) => ready.Paused.from(initialPlaybackData)) } @@ -32,7 +46,13 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ Ready: { states: { Paused: { - invoke: pauseAudio, + invoke: Machine.invoke({ + id: "pause-audio", + effect: pauseAudio, + onDone: () => undefined, + onFailure: ({ error }, enqueue) => + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + }), on: { PlayRequested: ({ state, target }) => target.local.Playing.from({ @@ -45,7 +65,25 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Playing: { - invoke: [playAudio, analyzeAudio], + invoke: [ + Machine.invoke({ + id: "play-audio", + effect: playAudio, + onDone: () => undefined, + onFailure: ({ error }, enqueue) => + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + }), + Machine.invoke({ + id: "analyze-audio", + address: Machine.childAddress("analyze-audio"), + logic: analyzeAudio, + onDone: () => undefined, + onSnapshot: ({ snapshot }, enqueue) => { + const event = loudnessEvent(snapshot.state) + if (event !== undefined) enqueue.raise(event) + } + }) + ], on: { PauseRequested: ({ state, target }) => target.local.Paused.from(updatePlaybackData(state, {})), @@ -95,7 +133,13 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Restarting: { - invoke: restartAudio, + invoke: Machine.invoke({ + id: "restart-audio", + effect: restartAudio, + onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.RestartSucceeded()), + onFailure: ({ error }, enqueue) => + enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message })) + }), on: { RestartSucceeded: ({ target }) => target.local.Playing.from({ currentTime: 0, loudness: null }), @@ -115,17 +159,15 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Failed: { - invoke: ({ state }) => - Machine.invoke({ - id: "report-error", - src: () => - Machine.effect( - Effect.gen(function*() { - const mediaPlayer = yield* MediaPlayer - yield* mediaPlayer.reportError(state.message) - }) - ) - }) + invoke: { + id: "report-error", + effect: ({ state }) => + Effect.gen(function*() { + const mediaPlayer = yield* MediaPlayer + yield* mediaPlayer.reportError(state.message) + }), + onDone: () => undefined + } } } }, @@ -133,7 +175,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ settings: { states: { Audible: { - invoke: ({ state }) => applyAudioSettings(state, false), + invoke: { + id: "apply-audio-settings", + effect: ({ state }) => applyAudioSettings(state, false), + onDone: () => undefined + }, on: { VolumeChanged: { reenter: true, @@ -162,7 +208,11 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({ }, Muted: { - invoke: ({ state }) => applyAudioSettings(state, true), + invoke: { + id: "apply-audio-settings", + effect: ({ state }) => applyAudioSettings(state, true), + onDone: () => undefined + }, on: { VolumeChanged: { reenter: true, diff --git a/examples/playground/src/examples/microwave/machine.ts b/examples/playground/src/examples/microwave/machine.ts index 23327a4..ac48280 100644 --- a/examples/playground/src/examples/microwave/machine.ts +++ b/examples/playground/src/examples/microwave/machine.ts @@ -11,10 +11,6 @@ export const MicrowaveEvent = Schema.TaggedUnion({ DoorClosed: {} }) -export const MicrowaveInternalEvent = Schema.TaggedUnion({ - SecondElapsed: {} -}) - export const MicrowaveStates = Machine.defineStates({ Oven: { type: "parallel", @@ -41,7 +37,6 @@ const definition = Machine.make({ id: "Microwave", states: MicrowaveStates.states, events: [MicrowaveEvent], - internalEvents: [MicrowaveInternalEvent], initial: () => MicrowaveStates.initial.Oven.from((oven) => oven @@ -51,9 +46,6 @@ const definition = Machine.make({ }) export const MicrowaveEvents = Machine.events(definition) -const InternalEvents = Machine.internalEvents(definition) -const secondElapsed = InternalEvents.SecondElapsed() - export const MicrowaveMachine = definition.handle({ Oven: { states: { @@ -68,15 +60,14 @@ export const MicrowaveMachine = definition.handle({ } }, Cooking: { - invoke: Machine.after("1 second", secondElapsed, { id: "cooking-second" }), + invoke: Machine.invoke({ + id: "cooking-second", + after: "1 second", + onDone: ({ state, target }) => target.local.Cooking.from({ elapsedSeconds: state.elapsedSeconds + 1 }) + }), on: { PowerPressed: ({ target }) => target.local.Idle.from(), - DoorOpened: ({ target }) => target.local.Idle.from(), - SecondElapsed: { - reenter: true, - transition: ({ state, target }) => - target.local.Cooking.from({ elapsedSeconds: state.elapsedSeconds + 1 }) - } + DoorOpened: ({ target }) => target.local.Idle.from() } } } diff --git a/examples/playground/src/examples/traffic-light/TrafficLightPage.tsx b/examples/playground/src/examples/traffic-light/TrafficLightPage.tsx index a421981..a70c5d7 100644 --- a/examples/playground/src/examples/traffic-light/TrafficLightPage.tsx +++ b/examples/playground/src/examples/traffic-light/TrafficLightPage.tsx @@ -37,7 +37,7 @@ export function TrafficLightPage() {

{signal === "RedYellow" ? "Red + yellow" : signal}

The next transition is scheduled in {(duration / 1_000).toFixed(1)} seconds by a state-scoped{` `} - Machine.after invocation. + an inline after invocation.

diff --git a/examples/playground/src/examples/traffic-light/machine.ts b/examples/playground/src/examples/traffic-light/machine.ts index 155698c..5cbdb7e 100644 --- a/examples/playground/src/examples/traffic-light/machine.ts +++ b/examples/playground/src/examples/traffic-light/machine.ts @@ -5,10 +5,6 @@ export const TrafficLightEvent = Schema.TaggedUnion({ Reset: {} }) -export const TrafficLightInternalEvent = Schema.TaggedUnion({ - TimerElapsed: {} -}) - export const trafficLightDurations = { Red: 4_000, RedYellow: 1_000, @@ -27,44 +23,52 @@ const definition = Machine.make({ id: "TrafficLight", states: TrafficLightStates.states, events: [TrafficLightEvent], - internalEvents: [TrafficLightInternalEvent], initial: () => TrafficLightStates.initial.Red.from() }) export const TrafficLightEvents = Machine.events(definition) -const InternalEvents = Machine.internalEvents(definition) -const elapsed = InternalEvents.TimerElapsed() - export const TrafficLightMachine = definition.handle({ Red: { - invoke: Machine.after(trafficLightDurations.Red, elapsed), + invoke: Machine.invoke({ + id: "red-timer", + after: trafficLightDurations.Red, + onDone: ({ target }) => target.full.RedYellow.from() + }), on: { Reset: { reenter: true, transition: ({ target }) => target.full.Red.from() - }, - TimerElapsed: ({ target }) => target.full.RedYellow.from() + } } }, RedYellow: { - invoke: Machine.after(trafficLightDurations.RedYellow, elapsed), + invoke: Machine.invoke({ + id: "red-yellow-timer", + after: trafficLightDurations.RedYellow, + onDone: ({ target }) => target.full.Green.from() + }), on: { - Reset: ({ target }) => target.full.Red.from(), - TimerElapsed: ({ target }) => target.full.Green.from() + Reset: ({ target }) => target.full.Red.from() } }, Green: { - invoke: Machine.after(trafficLightDurations.Green, elapsed), + invoke: Machine.invoke({ + id: "green-timer", + after: trafficLightDurations.Green, + onDone: ({ target }) => target.full.Yellow.from() + }), on: { - Reset: ({ target }) => target.full.Red.from(), - TimerElapsed: ({ target }) => target.full.Yellow.from() + Reset: ({ target }) => target.full.Red.from() } }, Yellow: { - invoke: Machine.after(trafficLightDurations.Yellow, elapsed), + invoke: Machine.invoke({ + id: "yellow-timer", + after: trafficLightDurations.Yellow, + onDone: ({ target }) => target.full.Red.from() + }), on: { - Reset: ({ target }) => target.full.Red.from(), - TimerElapsed: ({ target }) => target.full.Red.from() + Reset: ({ target }) => target.full.Red.from() } } }) diff --git a/examples/pokemon/src/machine.ts b/examples/pokemon/src/machine.ts index 5b7ac06..6488bc4 100644 --- a/examples/pokemon/src/machine.ts +++ b/examples/pokemon/src/machine.ts @@ -10,42 +10,36 @@ class ActiveTeam extends Schema.TaggedClass("ActiveTeam")("ActiveTea team: Schema.Array(Pokemon) }) {} -class TeamLoaded extends Schema.TaggedClass("TeamLoaded")("TeamLoaded", { - team: Schema.Array(Pokemon) -}) {} - -class TeamLoadFailed extends Schema.TaggedClass("TeamLoadFailed")("TeamLoadFailed", {}) {} - export const States = Machine.defineStates({ Loading: {}, ActiveTeam, Failed: {} }) export const SelectionChild = Machine.child("selection", SelectionMachine) export const ReplaceChild = Machine.child("replace", ReplaceMachine) -const LoadTeam = Machine.invokeEffect({ - id: "load-team", - effect: Effect.gen(function*() { - const service = yield* PokemonService - return yield* service.getRandomTeam() - }), - onSuccess: (team) => new TeamLoaded({ team }), - onFailure: () => new TeamLoadFailed({}) -}) - const machine = Machine.make({ states: States.states, events: [ReplaceInTeam], - internalEvents: [TeamLoaded, TeamLoadFailed], initial: () => States.initial.Loading.from() }).handle({ Loading: { - invoke: LoadTeam, - on: { - TeamLoaded: ({ event, target }) => target.full.ActiveTeam.from({ team: event.team }), - TeamLoadFailed: ({ target }) => target.full.Failed.from() - } + invoke: Machine.invoke({ + id: "load-team", + effect: Effect.gen(function*() { + const service = yield* PokemonService + return yield* service.getRandomTeam() + }), + onDone: ({ output, target }) => target.full.ActiveTeam.from({ team: output }), + onFailure: ({ target }) => target.full.Failed.from() + }) }, ActiveTeam: { - invoke: [Machine.invokeMachine({ child: SelectionChild }), Machine.invokeMachine({ child: ReplaceChild })], + invoke: [ + Machine.invoke({ + child: SelectionChild, + onDone: () => undefined, + onFailure: ({ target }) => target.full.Failed.from() + }), + Machine.invoke({ child: ReplaceChild, onFailure: ({ target }) => target.full.Failed.from() }) + ], on: { ReplaceInTeam: ({ event, target, state }) => target.full.ActiveTeam.from({ diff --git a/examples/pokemon/src/machines/replace.ts b/examples/pokemon/src/machines/replace.ts index 350ac17..fed23ad 100644 --- a/examples/pokemon/src/machines/replace.ts +++ b/examples/pokemon/src/machines/replace.ts @@ -16,22 +16,16 @@ class Replaced extends Schema.TaggedClass("Replaced")("Replaced", { pokemon: Pokemon }) {} -const ReplaceWithRandomMachine = Machine.invoke({ - id: "replaceWithRandom", - src: () => - Machine.effect( - Effect.sleep("500 millis").pipe( - Effect.andThen( - Effect.gen(function*() { - const pk = yield* PokemonService - const pokemon = yield* pk.getRandomPokemon() - return new Replaced({ pokemon }) - }) - ), - Effect.onInterrupt(() => Effect.log("Replace with random interrupted")) - ) - ) -}) +const replaceWithRandom = Effect.sleep("500 millis").pipe( + Effect.andThen( + Effect.gen(function*() { + const pk = yield* PokemonService + const pokemon = yield* pk.getRandomPokemon() + return new Replaced({ pokemon }) + }) + ), + Effect.onInterrupt(() => Effect.log("Replace with random interrupted")) +) export const ReplaceStates = Machine.defineStates({ Idle: {}, Replacing }) @@ -48,12 +42,14 @@ export const ReplaceMachine = Machine.make({ } }, Replacing: { - invoke: () => ReplaceWithRandomMachine, - on: { - Replaced: ({ event, target, state }, enqueue) => { - enqueue.emit(new ReplaceInTeam({ id: state.id, pokemon: event.pokemon })) + invoke: Machine.invoke({ + id: "replaceWithRandom", + effect: replaceWithRandom, + onDone: ({ output, target, state }, enqueue) => { + enqueue.emit(new ReplaceInTeam({ id: state.id, pokemon: output.pokemon })) return target.full.Idle.from() - } - } + }, + onFailure: ({ target }) => target.full.Idle.from() + }) } }) diff --git a/examples/pokemon/src/machines/selection.ts b/examples/pokemon/src/machines/selection.ts index fd0ef6c..488f7d7 100644 --- a/examples/pokemon/src/machines/selection.ts +++ b/examples/pokemon/src/machines/selection.ts @@ -32,23 +32,17 @@ class ReplacePokemon extends Schema.TaggedClass("ReplacePokemon" id: Pokemon.fields.id }) {} -const SearchMachine = ({ searchText }: { searchText: string }) => - Machine.invoke({ - id: "search", - src: () => - Machine.effect( - Effect.sleep("500 millis").pipe( - Effect.andThen( - Effect.gen(function*() { - const pk = yield* PokemonService - const pokemon = yield* pk.getByName(searchText) - return new SearchResult({ result: pokemon }) - }) - ), - Effect.onInterrupt(() => Effect.log("Search interrupted")) - ) - ) - }) +const searchPokemon = (searchText: string) => + Effect.sleep("500 millis").pipe( + Effect.andThen( + Effect.gen(function*() { + const pk = yield* PokemonService + const pokemon = yield* pk.getByName(searchText) + return new SearchResult({ result: pokemon }) + }) + ), + Effect.onInterrupt(() => Effect.log("Search interrupted")) + ) export const SelectionStates = Machine.defineStates({ form: { @@ -110,16 +104,18 @@ export const SelectionMachine = Machine.make({ } }, Searching: { - invoke: ({ parents }) => SearchMachine({ searchText: parents["form.search"].searchText }), - on: { - SearchResult: ({ event, target }) => - event.result.pipe( + invoke: Machine.invoke({ + id: "search", + effect: ({ parents }) => searchPokemon(parents["form.search"].searchText), + onDone: ({ output, target }) => + output.result.pipe( Option.match({ onNone: () => target.local.NoPokemon.from(), onSome: (pokemon) => target.local.WithPokemon.from({ pokemon }) }) - ) - } + ), + onFailure: ({ target }) => target.local.NoPokemon.from() + }) } } }, diff --git a/package.json b/package.json index a460af3..879d6f9 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test": "vitest run", "test:types": "tstyche", "check:architecture": "node --test scripts/check-architecture.test.mjs && node scripts/check-architecture.mjs", - "check:ci": "node --test scripts/ci-changes.test.mjs scripts/runtime-performance-regression.test.mjs", + "check:ci": "node --test scripts/ci-changes.test.mjs scripts/runtime-performance-compatibility.test.mjs scripts/runtime-performance-regression.test.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "perf:types": "pnpm build && node scripts/type-performance.mjs", "perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs", diff --git a/perf/runtime/README.md b/perf/runtime/README.md index b15c4c0..65e73ff 100644 --- a/perf/runtime/README.md +++ b/perf/runtime/README.md @@ -37,9 +37,10 @@ The fitted heap slope is the primary idle-capacity metric. Compare adjacent profiles to attribute retained memory: raw process to idle statechart isolates statechart machinery, two independent machines to parent-with-child isolates relationship bookkeeping, while the two observed parent-child profiles isolate -registry and invoked-snapshot observation. Invoked snapshot mapping uses a -direct, state-scoped delivery path; its profile measures the retained callback -and mapping state rather than a general `changes` stream subscription. +registry and invoked-snapshot observation. Invoked snapshot handling uses a +direct, state-scoped transition path; its profile measures the retained +callback and lifecycle state rather than a general `changes` stream +subscription. Resident memory is reported as a raw diagnostic because V8 and the operating-system allocator can reuse already committed pages. The @@ -81,3 +82,7 @@ because hosted-runner and allocator behavior makes it substantially noisier. The implementation lives in `scripts/runtime-performance.mjs`, and the Effect Machine fixture is in `perf/runtime/counter.mjs`. Every scenario consumes and checks its result so the JavaScript engine cannot discard the measured work. +Because the pull request fixture runs against multiple library revisions, +public API compatibility is capability-based and centralized in +`perf/runtime/effect-machine-compatibility.mjs`. Keep version adaptation there +rather than branching inside individual benchmark scenarios. diff --git a/perf/runtime/counter.mjs b/perf/runtime/counter.mjs index d124e2b..7133e0f 100644 --- a/perf/runtime/counter.mjs +++ b/perf/runtime/counter.mjs @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs" import { createRequire } from "node:module" import { dirname, join, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" +import { makeEffectMachineBenchmarkApi } from "./effect-machine-compatibility.mjs" const implementationRoot = resolve( process.env.EFFECT_MACHINE_BENCHMARK_ROOT ?? fileURLToPath(new URL("../..", import.meta.url)) @@ -11,6 +12,7 @@ const effectPackagePath = implementationRequire.resolve("effect/package.json") const effectPackage = JSON.parse(readFileSync(effectPackagePath, "utf8")) const effect = await import(pathToFileURL(resolve(dirname(effectPackagePath), effectPackage.exports["."])).href) const { Machine } = await import(pathToFileURL(join(implementationRoot, "dist/index.js")).href) +const benchmarkApi = makeEffectMachineBenchmarkApi(Machine) const machineRuntimePath = [ join(implementationRoot, "dist/internal/machine/runtime.js"), join(implementationRoot, "dist/internal/machineRuntime.js") @@ -72,7 +74,7 @@ const counterParentMachine = Machine.make({ initial: () => ParentStates.initial.Active.from() }).handle({ Active: { - invoke: Machine.invokeMachine({ child: CounterChild }) + invoke: benchmarkApi.invokeChild({ child: CounterChild, onDone: () => undefined }) } }) @@ -83,9 +85,10 @@ const counterSnapshotParentMachine = Machine.make({ initial: () => ParentStates.initial.Active.from() }).handle({ Active: { - invoke: Machine.invokeMachine({ + invoke: benchmarkApi.invokeChild({ child: CounterChild, - snapshot: () => undefined + onDone: () => undefined, + onSnapshot: () => undefined }) } }) diff --git a/perf/runtime/effect-machine-compatibility.mjs b/perf/runtime/effect-machine-compatibility.mjs new file mode 100644 index 0000000..433a08a --- /dev/null +++ b/perf/runtime/effect-machine-compatibility.mjs @@ -0,0 +1,21 @@ +/** + * Adapts the Effect Machine public API used by the runtime benchmark fixture. + * + * Pull request benchmarks execute the head revision's fixture against both the + * base and head packages. Public API migrations therefore belong at this one + * capability boundary instead of leaking version checks into benchmark cases. + */ +export const makeEffectMachineBenchmarkApi = (Machine) => ({ + invokeChild: typeof Machine.invokeMachine === "function" + ? ({ onSnapshot, onFailure, ...config }) => { + if (onFailure !== undefined) { + throw new Error("The legacy child invocation API cannot handle failures as parent transitions") + } + + return Machine.invokeMachine({ + ...config, + ...(onSnapshot === undefined ? {} : { snapshot: onSnapshot }) + }) + } + : (config) => Machine.invoke(config) +}) diff --git a/perf/types/adapter-readiness.ts b/perf/types/adapter-readiness.ts index 4b56904..a1058f5 100644 --- a/perf/types/adapter-readiness.ts +++ b/perf/types/adapter-readiness.ts @@ -31,7 +31,7 @@ type BoundAtomEventIsExact = Expect< void Machine.planInitial(machine) void Machine.start(machine) void Machine.resume(machine, snapshot) -void Machine.invokeMachine({ child }) +void Machine.invoke({ child }) void resumedAtom void cluster void boundResumedAtom diff --git a/perf/types/dynamic-invoke-control.ts b/perf/types/dynamic-invoke-control.ts new file mode 100644 index 0000000..9672c31 --- /dev/null +++ b/perf/types/dynamic-invoke-control.ts @@ -0,0 +1,18 @@ +import { Machine } from "@typeonce/effect-machine" +import { Effect, Schema } from "effect" + +export class LoadError { + readonly _tag = "LoadError" +} + +export const Loading = Schema.TaggedStruct("Loading", { userId: Schema.String }) + +export const States = Machine.defineStates({ Loading }) + +export const loadUser = (userId: string) => Effect.fail(new LoadError()).pipe(Effect.as({ id: userId, name: "Ada" })) + +export const machine = Machine.make({ + states: States.states, + events: [], + initial: () => States.initial.Loading(Loading.make({ userId: "user-1" })) +}) diff --git a/perf/types/dynamic-invoke.ts b/perf/types/dynamic-invoke.ts new file mode 100644 index 0000000..aaf0e05 --- /dev/null +++ b/perf/types/dynamic-invoke.ts @@ -0,0 +1,26 @@ +import { Machine } from "@typeonce/effect-machine" +import { LoadError, loadUser, machine } from "./dynamic-invoke-control.js" + +interface User { + readonly id: string + readonly name: string +} + +const invoked = machine.handle({ + Loading: { + invoke: Machine.invoke({ + id: "load-user", + effect: ({ state }) => loadUser(state.userId), + onDone: ({ output }) => { + const user: User = output + void user + }, + onFailure: ({ error }) => { + const loadError: LoadError = error + void loadError + } + }) + } +}) + +void invoked diff --git a/scripts/fixtures/consumer/consumer.ts b/scripts/fixtures/consumer/consumer.ts index a605e93..ec73140 100644 --- a/scripts/fixtures/consumer/consumer.ts +++ b/scripts/fixtures/consumer/consumer.ts @@ -48,12 +48,16 @@ const invalidSelector = AtomMachine.select(atoms, "Missing") const cluster = ClusterMachine.make("ConsumerEntity", machine, { version: "1" }) -const invoked = Machine.invokeEffect({ +const invoked = Machine.invoke({ id: "fixture-load", effect: Effect.succeed("ready"), - onSuccess: (value) => InternalEvent.cases.Loaded.make({ value }) + onDone: () => undefined +}) +const delayed = Machine.invoke({ + id: "fixture-delay", + after: "1 second", + onDone: () => undefined }) -const delayed = Machine.after("1 second", InternalEvent.cases.Loaded.make({ value: "late" })) const generated = MachineTest.scenarios(machine, { minEvents: 1, maxEvents: 2 }) type InputEvent = Machine.Machine.InputEvent diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index 9632b8a..a1c666e 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -93,7 +93,8 @@ const machine = Machine.make({ Idle: { invoke: Machine.invoke({ id: "deep-inline-invoke", - src: () => Machine.effect(Effect.as(ExternalService, Internal.cases.Loaded.make({ value: "loaded" }))) + effect: Effect.asVoid(ExternalService), + onDone: () => undefined }), on: { Begin: ({ target }) => @@ -116,12 +117,11 @@ const machine = Machine.make({ } }, Saving: { - invoke: ({ state }) => - Machine.invokeMachine({ - child: Child, - input: { value: state.value }, - onDone: ({ output }) => Internal.cases.ChildCompleted.make({ value: output }) - }), + invoke: { + child: Child, + input: ({ state }) => ({ value: state.value }), + onDone: () => undefined + }, on: { ChildNotice: ({ event, target }, enqueue) => { enqueue.emit(Emitted.cases.Notice.make({ value: event.value })) diff --git a/scripts/runtime-performance-compatibility.test.mjs b/scripts/runtime-performance-compatibility.test.mjs new file mode 100644 index 0000000..f705b2b --- /dev/null +++ b/scripts/runtime-performance-compatibility.test.mjs @@ -0,0 +1,64 @@ +import { strict as assert } from "node:assert" +import { test } from "node:test" +import { makeEffectMachineBenchmarkApi } from "../perf/runtime/effect-machine-compatibility.mjs" + +test("uses the current child invocation capability when available", () => { + const calls = [] + const Machine = { + invoke: (config) => { + calls.push(config) + return { api: "current", config } + } + } + const config = { child: "counter", onDone: () => undefined } + + assert.deepEqual(makeEffectMachineBenchmarkApi(Machine).invokeChild(config), { + api: "current", + config + }) + assert.deepEqual(calls, [config]) +}) + +test("adapts lifecycle names for the legacy child invocation capability", () => { + const calls = [] + const onDone = () => undefined + const onSnapshot = () => undefined + const Machine = { + invokeMachine: (config) => { + calls.push(config) + return { api: "legacy", config } + } + } + + assert.deepEqual( + makeEffectMachineBenchmarkApi(Machine).invokeChild({ + child: "counter", + input: { seed: 1 }, + onDone, + onSnapshot + }), + { + api: "legacy", + config: { + child: "counter", + input: { seed: 1 }, + onDone, + snapshot: onSnapshot + } + } + ) + assert.deepEqual(calls, [{ child: "counter", input: { seed: 1 }, onDone, snapshot: onSnapshot }]) +}) + +test("fails closed when a legacy capability cannot preserve lifecycle semantics", () => { + const Machine = { invokeMachine: () => undefined } + + assert.throws( + () => + makeEffectMachineBenchmarkApi(Machine).invokeChild({ + child: "counter", + onFailure: () => undefined + }), + /legacy child invocation API cannot handle failures/ + ) +}) diff --git a/scripts/type-performance.mjs b/scripts/type-performance.mjs index 6a0d549..95df47d 100644 --- a/scripts/type-performance.mjs +++ b/scripts/type-performance.mjs @@ -74,6 +74,20 @@ const scenarios = [ maxInstantiations: 30_000, maxMarginalInstantiations: 19_000 }, + { + id: "dynamic-invoke-control", + label: "dynamic Machine.invoke control", + file: "dynamic-invoke-control.ts", + hidden: true + }, + { + id: "dynamic-invoke", + label: "Machine.invoke (state-dependent Effect)", + file: "dynamic-invoke.ts", + control: "dynamic-invoke-control", + maxInstantiations: 85_000, + maxMarginalInstantiations: 76_000 + }, { id: "handle-depth-24-control", label: "machine.handle depth 24 control", diff --git a/src/Machine.ts b/src/Machine.ts index 4f43b94..37e548d 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -51,6 +51,8 @@ declare const MachineOutputStatesTypeId: unique symbol declare const MachineTypeId: unique symbol declare const EventConstructionTypeId: unique symbol +const ChildMachineLogicTypeId: typeof internal.ChildMachineLogicTypeId = internal.ChildMachineLogicTypeId + /** * Type identifier used for the synthetic event passed to startup lifecycle * actions. @@ -159,7 +161,8 @@ export interface Machine< readonly events: InputEvents /** - * Events reserved for invokes, child emissions, and other machine-local work. + * Events reserved for raised events, child emissions, and other machine-local + * work. * * @since 0.4.0 */ @@ -1768,7 +1771,8 @@ export declare namespace Logic { * * `sendParent` accepts `unknown` because process logic is independent from * the parent that eventually owns it. Prefer typed process output or an - * invoke snapshot mapper when either can represent the communication. + * invocation lifecycle transition when either can represent the + * communication. * * @category models * @since 0.4.0 @@ -1830,8 +1834,9 @@ type InvokeLifecycleId = string & { readonly [ChildAddressTypeId]?: never } * **Details** * * The descriptor carries the child's address and complete machine type. Pass - * the same value to `invokeMachine`, `sendTo`, and child lookup APIs so state, - * event, error, and output types are inferred without separate annotations. + * a descriptor for the same id and machine to inline `invoke`, `sendTo`, and + * child lookup APIs so state, event, error, and output types are inferred + * without separate annotations. * * @category models * @since 0.4.0 @@ -1844,6 +1849,9 @@ export interface ChildMachine { /** Complete machine definition carried by this descriptor. */ readonly machine: M + + /** @internal */ + readonly [ChildMachineLogicTypeId]: (input?: unknown) => Logic } /** @@ -2691,6 +2699,11 @@ export declare namespace Machine { | { readonly type: "choice" } + | { + readonly type: "invoke" + readonly id: string + readonly outcome: "done" | "failure" | "snapshot" + } /** * Statically inspectable destination paths for a transition handler. @@ -2713,10 +2726,10 @@ export declare namespace Machine { * * **Details** * - * Event, eventless, and completion handlers may declare an upper bound of - * possible target paths. A handler without that declaration is explicitly - * reported as dynamic. The source, trigger, and reentry behavior are - * available without executing the handler. + * Event, eventless, completion, and invocation lifecycle handlers may + * declare an upper bound of possible target paths. A handler without that + * declaration is explicitly reported as dynamic. The source, trigger, and + * reentry behavior are available without executing the handler. * * @category models * @since 0.4.0 @@ -2735,9 +2748,9 @@ export declare namespace Machine { /** * Serializable description of state-owned work. * - * Static invoke descriptors expose their lifecycle id and kind without - * retaining Effects, closures, services, or child runtimes. A function-valued - * invoke factory is reported as dynamic and is never evaluated by inspection. + * Static invoke definitions expose their lifecycle id and kind without + * retaining Effects, closures, services, or child runtimes. Function-valued + * sources are reported as dynamic and are never evaluated by inspection. * * @category models * @since 0.4.0 @@ -3793,7 +3806,7 @@ export declare namespace Machine { } /** - * Context passed to an invoked child process source. + * Context passed to a function-valued invocation source. * * @category models * @since 0.4.0 @@ -3811,27 +3824,67 @@ export declare namespace Machine { } /** - * Context passed to an invoked child process active snapshot mapper. + * Context passed to an invocation's active-snapshot transition. * * @category models * @since 0.4.0 */ - export interface InvokeSnapshotContext { + export interface InvokeSnapshotContext< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + State, + Error, + Output + > { readonly id: string + readonly state: StateByIdentifier + readonly parent: ParentStateValue + readonly parents: ParentStateValues + readonly target: TargetBuilder readonly snapshot: Extract, { readonly status: "active" }> } /** - * Context passed to an invoked machine terminal output mapper. + * Context passed to an invocation's successful completion transition. * * @category models * @since 0.4.0 */ - export interface InvokeDoneContext { + export interface InvokeDoneContext< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + Output + > { readonly id: string + readonly state: StateByIdentifier + readonly parent: ParentStateValue + readonly parents: ParentStateValues + readonly snapshot: Snapshot + readonly target: TargetBuilder readonly output: Output } + /** Context passed to an invocation typed-failure transition. */ + export interface InvokeFailureContext< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + Error + > { + readonly id: string + readonly state: StateByIdentifier + readonly parent: ParentStateValue + readonly parents: ParentStateValues + readonly snapshot: Snapshot + readonly target: TargetBuilder + readonly error: Error + } + /** * Context passed to an eventless transition handler. * @@ -4121,7 +4174,41 @@ export declare namespace Machine { * @category utility types * @since 0.4.0 */ - export type InvokeLogic = Invoke extends { readonly src: (...args: any) => infer Logic } ? Logic : never + export type InvokeResolvedSource = Source extends (...args: any) => infer Resolved ? Resolved : Source + + type ChildMachineLogic = Child extends ChildMachine ? Logic< + Snapshot>, + EventInput>, + Error | ActionError> | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, + ExcludeCompatibleRuntime< + Exclude | Services>, internalRuntime.MachineRuntime>, + Event, + Emit + >, + Output, + | InitialError + | Error + | ActionError | Services> + | InfiniteTransitionError + | MachineSchemaDecodeError + | StartupError + | StoppedError + > + : never + + export type InvokeLogic = Invoke extends { readonly effect: infer Source } ? + InvokeResolvedSource extends infer Fx extends Effect.Effect ? Logic< + void, + never, + Effect.Error, + Effect.Services, + Effect.Success + > + : never + : Invoke extends { readonly after: unknown } ? Logic + : Invoke extends { readonly logic: infer Source } ? InvokeResolvedSource + : Invoke extends { readonly child: infer Child } ? ChildMachineLogic + : never /** * Extracts the startup error from an invoke source child process logic. * @@ -4173,19 +4260,16 @@ export declare namespace Machine { * @since 0.4.0 */ export type InvokeEmits = Invoke extends { - readonly [InvokeTypeId]: { readonly emits: Types.Covariant } - } ? Emits : - never - /** - * Extracts events returned by an invoked child snapshot mapper. - * - * @category utility types - * @since 0.4.0 - */ - export type InvokeSnapshotEvent = Invoke extends { - readonly [InvokeTypeId]: { readonly snapshotEvent: Types.Covariant } - } ? Event : - never + readonly [InvokeTypeId]: { readonly emits: Types.Covariant } + } ? Emitted + : Invoke extends { readonly child: ChildMachine } ? Emit + : never + /** Extracts transition results returned by invocation lifecycle handlers. */ + export type InvokeOutcomeReturn = Invoke extends unknown ? + | (Invoke extends { readonly onDone?: infer Handler } ? EventTransitionReturn> : never) + | (Invoke extends { readonly onFailure?: infer Handler } ? EventTransitionReturn> : never) + | (Invoke extends { readonly onSnapshot?: infer Handler } ? EventTransitionReturn> : never) + : never /** * Extracts the parent transition error contribution from invoked children. * @@ -4193,7 +4277,10 @@ export declare namespace Machine { * @since 0.4.0 */ export type InvokeError = [InvokeReturn] extends [never] ? never - : ChildAlreadyExistsError | InvokeInitialError> | InvokeRuntimeError> + : + | ChildAlreadyExistsError + | InvokeInitialError> + | Effect.Error>> /** * Extracts the parent service requirement contribution from invoked children. * @@ -4201,7 +4288,12 @@ export declare namespace Machine { * @since 0.4.0 */ export type InvokeRequirements = [InvokeReturn] extends [never] ? never - : MachineRuntimeRequirement | InvokeServices> + : + | MachineRuntimeRequirement + | InvokeServices> + | Effect.Services< + InvokeOutcomeReturn> + > /** * Extracts the return value from an eventless transition. * @@ -4247,106 +4339,436 @@ export declare namespace Machine { | Effect.Services> | InvokeRequirements - /** - * Configuration for invoking a child process while a state is active. - * - * @category models - * @since 0.4.0 - */ - export interface InvokeConfig< + export type InvokeTransition< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, - StateId extends StateIdentifier, - Event, - ChildState, - ChildEvent, - ChildError, - ChildRequirements, - ChildOutput, - ChildInitialError, - ChildEmits = never, - DeliveredOutput = ChildOutput - > { - readonly [InvokeTypeId]: { - readonly output: Types.Covariant - readonly emits: Types.Covariant - readonly snapshotEvent: Types.Covariant - readonly error: Types.Covariant - readonly requirements: Types.Covariant - readonly initialError: Types.Covariant + Context + > = + | ((context: Context, enqueue: Enqueue, EmitOf>) => HandlerResult) + | { + readonly reenter?: boolean + readonly targets?: ReadonlyArray> + readonly transition: ( + context: Context, + enqueue: Enqueue, EmitOf> + ) => HandlerResult } - /** @internal Serializable descriptor metadata used by inspection. */ - readonly [Activities.ActivityMetadataTypeId]?: Activities.StaticActivityMetadata - readonly id: string - /** - * Optional parent-local address for sending events to this invocation. - * - * The invocation `id` is only its state-local lifecycle key. Use an - * explicit typed address when the parent must communicate with it. - */ - readonly address?: string - /** @internal */ - readonly descriptor?: ChildMachine.Any - src(): Logic< - ChildState, - ChildEvent, - ChildError, - ChildRequirements, - ChildOutput, - ChildInitialError - > - snapshot?( - context: InvokeSnapshotContext - ): Event | undefined - onDone?(context: InvokeDoneContext): DeliveredOutput | undefined + + export type InvokeSource = Value | ((context: Context) => Value) + + interface AnyLogicSource { + initial(...args: ReadonlyArray): Effect.Effect + run(...args: ReadonlyArray): Effect.Effect } - /** @internal */ - export interface AnyInvokeConfig< - Output = unknown, - Error = unknown, - Requirements = unknown, - InitialError = unknown, - Emits = never, - SnapshotEvent = never + /** Inline state-owned work that runs for the lifetime of its active state. */ + export interface InvokeOwned< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier > { + readonly "~effect/Machine/InvokeOwner"?: Types.Covariant + } + + /** Type evidence retained by {@link invoke} without affecting runtime data. */ + export interface InvokeTyped { readonly [InvokeTypeId]: { readonly output: Types.Covariant - readonly emits: Types.Covariant - readonly snapshotEvent: Types.Covariant readonly error: Types.Covariant readonly requirements: Types.Covariant readonly initialError: Types.Covariant + readonly emits: Types.Covariant } } - interface InvokeDefinitionValue { - readonly [InvokeTypeId]: unknown - } + export type InvokeConfig< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier + > = + & InvokeOwned + & ( + | { + readonly id: string + readonly effect: InvokeSource, InvokeContext> + readonly after?: never + readonly logic?: never + readonly child?: never + readonly address?: never + readonly onDone?: unknown + readonly onFailure?: unknown + readonly onSnapshot?: never + } + | { + readonly id: string + readonly after: InvokeSource> + readonly effect?: never + readonly logic?: never + readonly child?: never + readonly address?: never + readonly onDone: unknown + readonly onFailure?: never + readonly onSnapshot?: never + } + | { + readonly id: string + readonly address: string + readonly logic: InvokeSource> + readonly effect?: never + readonly after?: never + readonly child?: never + readonly onDone?: unknown + readonly onFailure?: unknown + readonly onSnapshot?: unknown + } + | { + readonly child: ChildMachine.Any + readonly input?: {} | null | ((context: InvokeContext) => unknown) + readonly id?: never + readonly address?: never + readonly effect?: never + readonly after?: never + readonly logic?: never + readonly onDone?: unknown + readonly onFailure?: unknown + readonly onSnapshot?: unknown + } + ) - /** - * State-bound configuration for invoked child processes. - * - * **Details** - * - * A function form receives the owning state's typed value and lifecycle - * event before constructing one or more invoke configurations. - * - * @category models - * @since 0.4.0 - */ + /** State-bound inline invocation configuration. */ export type InvokeDefinition< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier > = - | InvokeDefinitionValue - | ReadonlyArray - | ((context: InvokeContext) => - | InvokeDefinitionValue - | ReadonlyArray) + | InvokeConfig + | ReadonlyArray> + + type InvokeHandlerRequirement = IsAny extends true ? { readonly handler: Handler } + : [Value] extends [never] ? { readonly handler?: never } + : { readonly handler: Handler } + + export type InvokeDoneRequirement = InvokeHandlerRequirement extends + infer Requirement ? Requirement extends { readonly handler: infer Required } ? { readonly onDone: Required } + : { readonly onDone?: never } + : never + + export type InvokeFailureRequirement = InvokeHandlerRequirement extends + infer Requirement ? Requirement extends { readonly handler: infer Required } ? { readonly onFailure: Required } + : { readonly onFailure?: never } + : never + + export type EffectInvokeArgs< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + Fx extends Effect.Effect, + Source = Fx + > = + & { + readonly id: InvokeLifecycleId + readonly effect: Source + readonly after?: never + readonly logic?: never + readonly child?: never + readonly address?: never + readonly onSnapshot?: never + } + & InvokeDoneRequirement< + Effect.Success>, + InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext>> + > + > + & InvokeFailureRequirement< + Effect.Error>, + InvokeTransition< + States, + Events, + Emits, + InvokeFailureContext>> + > + > + + export type TimerInvokeArgs< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier + > = { + readonly id: InvokeLifecycleId + readonly after: InvokeSource> + readonly effect?: never + readonly logic?: never + readonly child?: never + readonly address?: never + readonly onFailure?: never + readonly onSnapshot?: never + readonly onDone: InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext + > + } + + export type LogicInvokeArgs< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + ChildState, + ChildEvent, + ChildError, + ChildRequirements, + ChildOutput, + ChildInitialError, + Address extends ChildAddress, + Source = Logic + > = + & { + readonly id: InvokeLifecycleId + readonly address: Address & ChildAddress.Compatibility + readonly logic: Source + readonly effect?: never + readonly after?: never + readonly child?: never + readonly onSnapshot?: InvokeTransition< + States, + Events, + Emits, + InvokeSnapshotContext + > + } + & InvokeDoneRequirement< + ChildOutput, + InvokeTransition> + > + & InvokeFailureRequirement< + ChildError, + InvokeTransition> + > + + export type ChildInvokeArgs< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + ChildDefinition extends Machine.Any, + Child extends ChildMachine + > = + & { + readonly child: + & Child + & (ChildDefinition extends EnsureExecutable< + Machine.States, + Machine.UnhandledStates, + Machine.OutputStates + > ? unknown : + never) + readonly id?: never + readonly address?: never + readonly effect?: never + readonly after?: never + readonly logic?: never + readonly onSnapshot?: InvokeTransition< + States, + Events, + Emits, + InvokeSnapshotContext< + States, + Events, + Emits, + StateId, + Snapshot>, + Error, + Output + > + > + } + & (Input extends typeof Schema.Void ? { readonly input?: never } : { + readonly input: InvokeSource["Type"], InvokeContext> + }) + & InvokeDoneRequirement< + Output, + InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext> + > + > + & InvokeFailureRequirement< + Error | ActionError>, + InvokeTransition< + States, + Events, + Emits, + InvokeFailureContext< + States, + Events, + Emits, + StateId, + Error | ActionError> + > + > + > + + export type LogicStateOf = Value extends Logic ? State : never + export type LogicEventOf = Value extends Logic ? Event : never + export type LogicErrorOf = Value extends Logic ? Error : never + export type LogicServicesOf = Value extends Logic ? Services : never + export type LogicOutputOf = Value extends Logic ? Output : never + export type LogicInitialErrorOf = Value extends Logic ? Error : never + + type ContextualInvokeConfig< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + Raw + > = Raw extends InvokeTyped ? unknown + : Raw extends { readonly effect: infer Source } ? + InvokeResolvedSource extends infer Fx extends Effect.Effect ? + & InvokeDoneRequirement< + Effect.Success, + InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext> + > + > + & InvokeFailureRequirement< + Effect.Error, + InvokeTransition< + States, + Events, + Emits, + InvokeFailureContext> + > + > + & { readonly onSnapshot?: never } + : never + : Raw extends { readonly after: unknown } ? { + readonly onDone: InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext + > + readonly onFailure?: never + readonly onSnapshot?: never + } + : Raw extends { readonly logic: infer Source; readonly address: infer Address } ? + InvokeResolvedSource extends infer ChildLogic extends Logic ? + & InvokeDoneRequirement< + InvokeOutput, + InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext> + > + > + & InvokeFailureRequirement< + InvokeRuntimeError, + InvokeTransition< + States, + Events, + Emits, + InvokeFailureContext> + > + > + & { + readonly address: + & ChildAddress + & ChildAddress.Compatibility< + Address, + ChildLogic extends Logic ? ChildEvent : never + > + readonly onSnapshot?: InvokeTransition< + States, + Events, + Emits, + InvokeSnapshotContext< + States, + Events, + Emits, + StateId, + ChildLogic extends Logic ? ChildState : never, + InvokeRuntimeError, + InvokeOutput + > + > + } + : never + : Raw extends { readonly child: infer Child extends ChildMachine } ? + ChildMachineLogic extends infer ChildLogic extends Logic ? + & InvokeDoneRequirement< + Output, + InvokeTransition< + States, + Events, + Emits, + InvokeDoneContext> + > + > + & InvokeFailureRequirement< + Error | ActionError>, + InvokeTransition< + States, + Events, + Emits, + InvokeFailureContext< + States, + Events, + Emits, + StateId, + Error | ActionError> + > + > + > + & (Input extends typeof Schema.Void ? { readonly input?: never } : { + readonly input: InvokeSource["Type"], InvokeContext> + }) + & { + readonly onSnapshot?: InvokeTransition< + States, + Events, + Emits, + InvokeSnapshotContext< + States, + Events, + Emits, + StateId, + Snapshot>, + Error, + Output + > + > + } + : never + : never + + type ContextualInvokeDefinition< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier, + Raw + > = Raw extends ReadonlyArray ? { + readonly [Index in keyof Raw]: ContextualInvokeConfig + } + : ContextualInvokeConfig type OutputHandlerConfig< States extends StateSchemas, @@ -4632,15 +5054,48 @@ export declare namespace Machine { : Path extends keyof Config ? Config[Path] : never - // Rebuild the public nested handler shape so branded validation errors stay - // attached to the exact property that introduced them. - type HandlerValidationAtPath = Path extends `${infer Head}.${infer Rest}` ? { - readonly [Key in Head]?: { - readonly states: HandlerValidationAtPath - } - } - : { readonly [Key in Path]?: Validation } - + type HandlerInvokeContextAtPath< + AllStates extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Config, + StateId extends StateNodeIdentifier, + NodeConfig = HandlerConfigAtPath + > = StateId extends StateIdentifier ? + NodeConfig extends { readonly invoke: infer Invoke } ? HandlerValidationAtPath< + StateId, + { readonly invoke: ContextualInvokeDefinition } + > + : unknown + : unknown + + type HandlerInvokeContexts< + AllStates extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + Config + > = Types.UnionToIntersection< + StateNodeIdentifier extends infer StateId extends StateNodeIdentifier ? + StateId extends StateNodeIdentifier ? HandlerInvokeContextAtPath< + AllStates, + Events, + Emits, + Config, + StateId + > + : never + : never + > + + // Rebuild the public nested handler shape so branded validation errors stay + // attached to the exact property that introduced them. + type HandlerValidationAtPath = Path extends `${infer Head}.${infer Rest}` ? { + readonly [Key in Head]?: { + readonly states: HandlerValidationAtPath + } + } + : { readonly [Key in Path]?: Validation } + type HandlerNode< AllStates extends StateSchemas, Node, @@ -4946,9 +5401,7 @@ export declare namespace Machine { & HandlerOnTargetValidation & HandlerDirectTargetValidation & HandlerDirectTargetValidation - & HandlerInvokeOutputValidation & HandlerInvokeEmitsValidation - & HandlerInvokeSnapshotValidation & HandlerChildrenValidation & HandlerOutputRequirementValidation & HandlerRuntimeValidation @@ -4977,20 +5430,6 @@ export declare namespace Machine { } : unknown - type HandlerInvokeOutputValidation< - Events extends ReadonlyArray, - StateId extends string, - Config - > = [InvokeReturn] extends [never] ? unknown - : [Exclude>, EventInput> | void>] extends [never] ? unknown - : { - readonly invoke: HandlerValidationError< - "Invoked child output must be a machine event or void", - StateId, - Exclude>, EventInput> | void> - > - } - type HandlerInvokeEmitsValidation< Events extends ReadonlyArray, StateId extends string, @@ -5005,22 +5444,6 @@ export declare namespace Machine { > } - type HandlerInvokeSnapshotValidation< - Events extends ReadonlyArray, - StateId extends string, - Config - > = [InvokeReturn] extends [never] ? unknown - : IsAny>> extends true ? unknown - : [Exclude>, EventInput> | undefined>] extends [never] - ? unknown - : { - readonly invoke: HandlerValidationError< - "Invoked child snapshot mapper must return a machine event or undefined", - StateId, - Exclude>, EventInput> | undefined> - > - } - type HandlerRuntimeValidation< Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -5224,6 +5647,7 @@ export declare namespace Machine { >( config: & Config + & HandlerInvokeContexts> & HandlerTreeValidation< States, Events, @@ -5545,8 +5969,8 @@ interface Make { * to implement state behavior with ordinary TypeScript control flow. * * Schemas in `events` define the public input protocol. Schemas in - * `internalEvents` are added to the complete handler protocol for invoke - * results, child emissions, and other machine-local deliveries. Their tags + * `internalEvents` are added to the complete handler protocol for raised + * events, child emissions, and other machine-local deliveries. Their tags * must be disjoint. * * **Example** (Typed counter machine) @@ -5663,22 +6087,10 @@ export const events: ( * Returns deferred constructors for every internal event in a machine * protocol. * - * Use these constructors for invoke results, timers, raised events, and other - * machine-local deliveries. Construction failures are reported through the + * Use these constructors for raised events and other machine-local deliveries. + * Construction failures are reported through the * owning machine's `MachineSchemaDecodeError` channel. * - * **Example** - * - * ```ts - * const InternalEvent = Machine.internalEvents(definition) - * - * const load = Machine.invokeEffect({ - * id: "load", - * effect: Effect.succeed("ready"), - * onSuccess: (value) => InternalEvent.Loaded({ value }) - * }) - * ``` - * * @category constructors * @since 0.9.0 */ @@ -5851,63 +6263,231 @@ export const decodeSnapshot: < Machine.SnapshotDecodingServices > = internal.decodeSnapshot +type DynamicEffectInvokeSource< + States extends Machine.StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + Source extends ( + context: Machine.InvokeContext + ) => Effect.Effect +> = { + readonly id: InvokeLifecycleId + readonly effect: Source + readonly after?: never + readonly logic?: never + readonly child?: never + readonly address?: never + readonly onSnapshot?: never +} + +type DynamicEffectDoneHandler< + States extends Machine.StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + Source extends (...args: ReadonlyArray) => Effect.Effect +> = Machine.InvokeTransition< + States, + Events, + Emits, + Machine.InvokeDoneContext>>> +> + +type DynamicEffectFailureHandler< + States extends Machine.StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + Source extends (...args: ReadonlyArray) => Effect.Effect +> = Machine.InvokeTransition< + States, + Events, + Emits, + Machine.InvokeFailureContext>>> +> + +type DynamicEffectInvokeResult< + States extends Machine.StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + Source extends (...args: ReadonlyArray) => Effect.Effect +> = + & Machine.InvokeConfig + & Machine.InvokeTyped< + Effect.Success>, + Effect.Error>, + Effect.Services>, + never + > + +type InvokeChannelIsNever = IsAny extends true ? false : [Value] extends [never] ? true : false + /** - * Creates an invoked child process configuration for an active state. + * Preserves inference for a state-owned invocation configuration. * - * **When to use** + * Use `effect` for one-shot work, `after` for a cancellable timer, `logic` for + * reusable process logic, or `child` for a complete child machine. `onDone` is + * required whenever the source can complete with an output, while `onFailure` + * is required only when the source has a typed failure channel. * - * Use to run a child process while a machine remains in a state. Successful - * outputs are sent directly to the parent machine as events; `void` sends - * nothing. Unrecovered child failures fail the owning machine. Active - * snapshots can optionally be mapped to progress events. - * - * **Gotchas** - * - * Invoked child processes run while their owning state is active and are - * stopped before the state exits. An unrecovered child failure fails the owning - * machine; recover inside the child Effect when failure should become an event. - * The `id` is a state-local lifecycle key, not a communication address. To - * send events to the invocation, pass a typed `childAddress` as `address`. - * The `src` callback is intentionally independent from its parent state. When - * construction depends on the typed state, lifecycle event, or runtime, use - * the state config factory form `invoke: (context) => Machine.invoke(...)` and - * close over that context from `src`. - * - * **Example** (Effect output as a parent event) + * This constructor is an identity at runtime, but preserves lifecycle callback + * inference through published declarations. Effects and durations may be + * supplied directly or derived from the owning state's entry context. Dynamic + * Effect sources infer the owner context, output, error, and service channels + * together without a return annotation. Logic invocations require both a + * lifecycle `id` and a typed communication `address`. Child descriptors already + * own their identity, so `id` and `address` must not be repeated. * * ```ts - * import { Effect, Schema } from "effect" - * import { Machine } from "@typeonce/effect-machine" - * - * class Loaded extends Schema.TaggedClass("Loaded")("Loaded", { - * value: Schema.String - * }) {} - * - * const load = Machine.invoke({ + * invoke: Machine.invoke({ * id: "load", - * src: () => Machine.effect(Effect.succeed(new Loaded({ value: "ready" }))) + * effect: Effect.tryPromise({ + * try: () => fetch("/api/data").then((response) => response.json()), + * catch: (cause) => new LoadError({ cause }) + * }), + * onDone: ({ output, target }) => target.full.Ready({ data: output }), + * onFailure: ({ error, target }) => target.full.Failed({ error }) * }) * ``` * - * @see {@link effect} for one-shot child effects. - * @see {@link spawn} for children whose lifetime is controlled by actions. * @category constructors - * @since 0.4.0 + * @since 0.9.0 */ -export const invoke: < - ChildState, - ChildEvent, - ChildError = never, - ChildRequirements = never, - ChildOutput = never, - ChildInitialError = never, - Event = never, - Address extends ChildAddress | undefined = undefined ->( - config: - & { - readonly id: InvokeLifecycleId - readonly src: () => Logic< +export const invoke: { + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Source extends ( + context: Machine.InvokeContext + ) => Effect.Effect + >( + config: + & DynamicEffectInvokeSource + & { + readonly onDone: DynamicEffectDoneHandler + readonly onFailure: DynamicEffectFailureHandler + }, + ..._validation: InvokeChannelIsNever>> extends true ? [ + "onDone must be omitted when the Effect output is never" + ] + : InvokeChannelIsNever>> extends true ? [ + "onFailure must be omitted when the Effect error is never" + ] + : [] + ): DynamicEffectInvokeResult + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Source extends ( + context: Machine.InvokeContext + ) => Effect.Effect + >( + config: + & DynamicEffectInvokeSource + & { + readonly onDone: DynamicEffectDoneHandler + readonly onFailure?: never + }, + ..._validation: InvokeChannelIsNever>> extends true ? [ + "onDone must be omitted when the Effect output is never" + ] + : [] + ): DynamicEffectInvokeResult + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Source extends ( + context: Machine.InvokeContext + ) => Effect.Effect + >( + config: + & DynamicEffectInvokeSource + & { + readonly onDone?: never + readonly onFailure: DynamicEffectFailureHandler + }, + ..._validation: InvokeChannelIsNever>> extends true ? [ + "onFailure must be omitted when the Effect error is never" + ] + : [] + ): DynamicEffectInvokeResult + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Source extends ( + context: Machine.InvokeContext + ) => Effect.Effect + >( + config: + & DynamicEffectInvokeSource + & { + readonly onDone?: never + readonly onFailure?: never + } + ): DynamicEffectInvokeResult + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Fx extends Effect.Effect, + const Config extends object + >( + config: Config & Machine.EffectInvokeArgs + ): + & Config + & Machine.InvokeOwned + & Machine.InvokeTyped< + Effect.Success, + Effect.Error, + Effect.Services, + never + > + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Config extends object + >( + config: Config & Machine.TimerInvokeArgs + ): Config & Machine.InvokeOwned & Machine.InvokeTyped + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + ChildState, + ChildEvent, + ChildError, + ChildRequirements, + ChildOutput, + ChildInitialError, + Address extends ChildAddress + >( + config: Machine.LogicInvokeArgs< + States, + Events, + Emits, + StateId, + ChildState, + ChildEvent, + ChildError, + ChildRequirements, + ChildOutput, + ChildInitialError, + Address, + (context: Machine.InvokeContext) => Logic< ChildState, ChildEvent, ChildError, @@ -5915,150 +6495,73 @@ export const invoke: < ChildOutput, ChildInitialError > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext - ) => Event | undefined - } - & ([Address] extends [undefined] ? { - readonly address?: never - } - : { - readonly address: Exclude - } & ChildAddress.Compatibility, NoInfer>) -) => Machine.InvokeConfig< - any, - any, - any, - any, - Event, - ChildState, - ChildEvent, - ChildError, - ChildRequirements, - ChildOutput, - ChildInitialError -> = internal.invoke - -type InvokeEffectResult = Machine.InvokeConfig< - any, - any, - any, - any, - never, - void, - never, - never, - Requirements, - Event | void, - never -> - -type InvokeEffectIsInfallible> = IsAny> extends true ? false - : [Effect.Error] extends [never] ? true - : false - -type InvokeEffectConfig< - Fx extends Effect.Effect, - SuccessEvent, - FailureEvent -> = - & { - readonly id: InvokeLifecycleId - readonly effect: Fx - readonly onSuccess: (value: NoInfer>) => SuccessEvent | void - } - & ( - InvokeEffectIsInfallible extends true ? { - readonly onFailure?: never - } - : { - readonly onFailure: (error: NoInfer>) => FailureEvent | void - } - ) - -/** - * Invokes one Effect and maps its typed outcome into machine-local events. - * - * **Details** - * - * This is the high-level one-shot counterpart to `invoke`. Success and typed - * failure values are mapped independently, so callers do not need to recover - * an Effect into a common event union by hand. Defects and interruption remain - * failures of the owning machine. - * - * Declare mapped outcomes in `internalEvents` unless they are also legitimate - * public commands. - * - * **Example** - * - * ```ts - * import { Effect, Schema } from "effect" - * import { Machine } from "@typeonce/effect-machine" - * - * const Internal = Schema.TaggedUnion({ Loaded: { value: Schema.String } }) - * const States = Machine.defineStates({ Loading: {} }) - * const definition = Machine.make({ - * states: States.states, - * events: [], - * internalEvents: [Internal], - * initial: () => States.initial.Loading.from() - * }) - * const InternalEvent = Machine.internalEvents(definition) - * - * const load = Machine.invokeEffect({ - * id: "load", - * effect: Effect.succeed("ready"), - * onSuccess: (value) => InternalEvent.Loaded({ value }) - * }) - * ``` - * - * @see {@link invoke} for arbitrary child process logic. - * @see {@link after} for a state-scoped delayed event. - * @category constructors - * @since 0.4.0 - */ -export const invokeEffect: , SuccessEvent, FailureEvent = never>( - config: InvokeEffectConfig -) => InvokeEffectResult< - Effect.Services, - SuccessEvent | (InvokeEffectIsInfallible extends true ? never : FailureEvent) -> = internal.invokeEffect - -/** - * Creates a cancellable state-scoped delayed event. - * - * The timer starts when its owning state is entered and is interrupted when - * that state exits. The delayed value should normally be declared in - * `internalEvents`. - * - * **Example** - * - * ```ts - * import { Schema } from "effect" - * import { Machine } from "@typeonce/effect-machine" - * - * const Internal = Schema.TaggedUnion({ TimedOut: {} }) - * const States = Machine.defineStates({ Waiting: {} }) - * const definition = Machine.make({ - * states: States.states, - * events: [], - * internalEvents: [Internal], - * initial: () => States.initial.Waiting.from() - * }) - * const InternalEvent = Machine.internalEvents(definition) - * - * const timeout = Machine.after("5 seconds", InternalEvent.TimedOut()) - * ``` - * - * @category constructors - * @since 0.4.0 - */ -export const after: ( - duration: Duration.Input, - event: Event, - options?: { readonly id?: InvokeLifecycleId } -) => InvokeEffectResult = internal.after - + > + ): + & Machine.InvokeConfig + & Machine.InvokeTyped< + ChildOutput, + ChildError, + ChildRequirements, + ChildInitialError + > + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + ChildState, + ChildEvent, + ChildError, + ChildRequirements, + ChildOutput, + ChildInitialError, + Address extends ChildAddress, + const Config extends object + >( + config: + & Config + & Machine.LogicInvokeArgs< + States, + Events, + Emits, + StateId, + ChildState, + ChildEvent, + ChildError, + ChildRequirements, + ChildOutput, + ChildInitialError, + Address + > + ): + & Config + & Machine.InvokeOwned + & Machine.InvokeTyped< + ChildOutput, + ChildError, + ChildRequirements, + ChildInitialError + > + < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + StateId extends Machine.StateIdentifier, + const Child extends ChildMachine.Any, + const Config extends object + >( + config: Config & Machine.ChildInvokeArgs + ): + & Config + & Machine.InvokeOwned + & Machine.InvokeTyped< + Machine.Output, + Machine.Error | ActionError>, + Machine.Services, + Machine.InitialError, + Machine.Emit + > +} = ((config: unknown) => config) as any type RetagFields = Omit type RetagTargetCompatibility = Target extends { @@ -6110,195 +6613,6 @@ export const retag: ) => Target["Type"] = internal.retag -type InvokeMachineInput = Input extends typeof Schema.Void ? { - readonly input?: never - } - : { - readonly input: Input["Type"] - } - -/** - * Creates an invoked child process from a complete statechart machine. - * - * **When to use** - * - * Use when a state should own another statechart machine and communicate with - * it through typed child events, emissions, snapshots, or terminal output. - * - * **Details** - * - * Child emissions are delivered directly to the parent as events. Active - * snapshots and terminal output can be mapped to parent events. The owning - * state controls the child lifetime. - * - * **Gotchas** - * - * Active invoked machines must have unique child addresses. A machine starts - * after its owning state's entry actions, so those actions cannot send events - * to a newly entered child. Unrecovered child failures fail the parent. - * - * @see {@link invoke} for invoking lower-level process logic. - * @see {@link sendTo} for sending events to the invoked machine. - * @category constructors - * @since 0.4.0 - */ -export const invokeMachine: { - < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - SnapshotEvent = never, - DoneEvent = never, - Id extends string = string, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events - >( - config: - & { - readonly child: ChildMachine< - Id, - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable - > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext< - Machine.Snapshot, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output - > - ) => SnapshotEvent | undefined - readonly onDone: (context: Machine.InvokeDoneContext) => DoneEvent | undefined - } - & InvokeMachineInput - ): Machine.InvokeConfig< - any, - any, - any, - any, - SnapshotEvent, - Machine.Snapshot, - Machine.EventInputOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, - Machine.EventOf, - Machine.EmitOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - Machine.EmitOf, - DoneEvent - > - < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - SnapshotEvent = never, - Id extends string = string, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events - >( - config: - & { - readonly child: ChildMachine< - Id, - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable - > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext< - Machine.Snapshot, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output - > - ) => SnapshotEvent | undefined - readonly onDone?: never - } - & InvokeMachineInput - ): Machine.InvokeConfig< - any, - any, - any, - any, - SnapshotEvent, - Machine.Snapshot, - Machine.EventInputOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, - Machine.EventOf, - Machine.EmitOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - Machine.EmitOf - > -} = internal.invokeMachine - /** * Plans the initial state for a machine without executing actor commands. * @@ -6460,10 +6774,9 @@ export const transitionDefinitions: (machine: M) => Reado * * **Details** * - * Static `invoke`, `invokeEffect`, `after`, and `invokeMachine` descriptors - * expose stable ownership and lifecycle metadata without serializing runtime - * values. Function-valued invoke factories are represented as dynamic and are - * never evaluated during inspection. + * Static inline invocation definitions expose stable ownership and lifecycle + * metadata without serializing runtime values. Function-valued sources are + * represented as dynamic and are never evaluated during inspection. * * @category getters * @since 0.4.0 @@ -6649,52 +6962,6 @@ export const plan: < never > = internal.plan -/** - * Creates a one-shot child process from an Effect. - * - * **When to use** - * - * Use when you need side effects that produce one typed output or error. - * - * **Details** - * - * The Effect may run arbitrary side effects. Its success value is the process - * output, its typed error is preserved, and its services are inferred. When - * invoked, the output is sent to the owning machine as an event unless it is - * `void`. - * - * **Gotchas** - * - * This process has no incoming event protocol. Its Effect runs once. Use - * `transition` for a process that receives events over time and `logic` for - * direct machine-local communication or intermediate snapshots. - * - * **Example** (Recover a child failure as output) - * - * ```ts - * import { Effect, Schema } from "effect" - * import { Machine } from "@typeonce/effect-machine" - * - * class LoadFailed extends Schema.TaggedClass("LoadFailed")("LoadFailed", { - * reason: Schema.String - * }) {} - * - * const load = Machine.effect( - * Effect.fail("unavailable").pipe( - * Effect.catch((reason) => Effect.succeed(new LoadFailed({ reason }))) - * ) - * ) - * ``` - * - * @see {@link transition} for event-driven state. - * @see {@link logic} for direct control over intermediate snapshots. - * @category constructors - * @since 0.4.0 - */ -export const effect: ( - effect: Effect.Effect -) => Logic = internal.effect - /** * Creates advanced stateful process logic from explicit initialization and * execution methods. @@ -6716,9 +6983,9 @@ export const effect: ( * This is the low-level process constructor. Parent messages sent directly * through its scope are intentionally `unknown` because the logic does not know * which machine will eventually own it. Prefer typed output, typed child - * addresses, or invoke snapshot mapping when possible. + * addresses, or invocation lifecycle transitions when possible. * - * @see {@link effect} for one-shot work. + * Use an inline `invoke` with an `effect` source for one-shot work. * @see {@link transition} for event-driven state. * @category constructors * @since 0.4.0 @@ -6767,7 +7034,7 @@ export const logic: < * ) * ``` * - * @see {@link effect} for one-shot work. + * Use an inline `invoke` with an `effect` source for one-shot work. * @see {@link logic} for direct process lifecycle control. * @category constructors * @since 0.4.0 @@ -7012,8 +7279,8 @@ export const start: < * entry or transition actions, re-deliver raised or emitted events, or * re-evaluate historical completion and eventless transitions. Active-state * invokes start once in ancestor and document order with {@link InitialEvent}; - * delayed invokes restart their complete duration and invoked machines start - * from their own initial state. + * timer invocations restart their complete duration and child-machine + * invocations start from their own initial state. * * Only logical state, completion, and history metadata are resumed. Queues, * scopes, subscriptions, fibers, spawned children, invoke progress, and prior diff --git a/src/internal/machine/activities.ts b/src/internal/machine/activities.ts index defe81e..a3293b1 100644 --- a/src/internal/machine/activities.ts +++ b/src/internal/machine/activities.ts @@ -4,8 +4,7 @@ * @since 0.4.0 */ -/** @internal */ -export const ActivityMetadataTypeId: unique symbol = Symbol.for("effect/Machine/ActivityMetadata") +import * as Duration from "effect/Duration" /** @internal */ export type StaticActivityMetadata = @@ -21,8 +20,7 @@ export type StaticActivityMetadata = } | { readonly type: "timer" - readonly duration: string - readonly event: string + readonly duration: string | "dynamic" } | { readonly type: "machine" @@ -33,20 +31,10 @@ export type StaticActivityMetadata = } /** @internal */ -export type ActivityDefinition = - | ({ - readonly source: Source - readonly id: string - } & StaticActivityMetadata) - | { - readonly source: Source - readonly type: "dynamic" - } - -interface ActivityDescriptor { +export type ActivityDefinition = { + readonly source: Source readonly id: string - readonly [ActivityMetadataTypeId]: StaticActivityMetadata -} +} & StaticActivityMetadata interface InspectableMachine { readonly handlers: unknown @@ -62,31 +50,60 @@ const isObject = (value: unknown): value is object => const getProperty = (value: unknown, key: PropertyKey): unknown => isObject(value) ? Reflect.get(value, key) : undefined -const hasActivityMetadata = (value: unknown): value is ActivityDescriptor => - isObject(value) && typeof getProperty(value, "id") === "string" && - isObject(getProperty(value, ActivityMetadataTypeId)) - const appendStaticDefinition = ( definitions: Array, source: string, descriptor: unknown ): void => { - if (!hasActivityMetadata(descriptor)) { + if (!isObject(descriptor)) return + const child = getProperty(descriptor, "child") + if (isObject(child)) { + const id = getProperty(child, "id") + if (typeof id !== "string") return + const machine = getProperty(child, "machine") + const machineId = getProperty(machine, "id") + definitions.push({ + source, + id, + type: "machine", + child: { id, machineId: typeof machineId === "string" ? machineId : null } + }) return } - definitions.push({ - source, - id: descriptor.id, - ...descriptor[ActivityMetadataTypeId] - }) + const id = getProperty(descriptor, "id") + if (typeof id !== "string") return + if (Reflect.has(descriptor, "effect")) { + definitions.push({ + source, + id, + type: "effect", + outcomes: { + success: "dynamic", + failure: Reflect.has(descriptor, "onFailure") ? "dynamic" : "none" + } + }) + return + } + if (Reflect.has(descriptor, "after")) { + const after = getProperty(descriptor, "after") + definitions.push({ + source, + id, + type: "timer", + duration: typeof after === "function" ? "dynamic" : Duration.format(Duration.fromInputUnsafe(after as any)) + }) + return + } + if (Reflect.has(descriptor, "logic")) { + definitions.push({ source, id, type: "process" }) + } } /** * Collects state-owned activity descriptions without executing user code. * - * Function-valued invoke definitions depend on an entry context and are - * therefore represented as dynamic. Static descriptors are returned in state - * definition order and descriptor array order. + * Source factories are inspected without execution. Static descriptors are + * returned in state definition order and descriptor array order. * * @internal */ @@ -94,9 +111,7 @@ export const activityDefinitions = (machine: InspectableMachine): ReadonlyArray< const definitions: Array = [] for (const node of machine.stateNodes.byPath.values()) { const invoke = getProperty(getProperty(machine.handlers, node.path), "invoke") - if (typeof invoke === "function") { - definitions.push({ source: node.path, type: "dynamic" }) - } else if (Array.isArray(invoke)) { + if (Array.isArray(invoke)) { for (const descriptor of invoke) { appendStaticDefinition(definitions, node.path, descriptor) } diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index 88aef1b..84cfd1a 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -21,6 +21,7 @@ import { validateInitialConfiguration } from "./configuration.js" import { InfiniteTransitionError } from "./errors.js" +import * as InvocationEvent from "./invocationEvent.js" import { broadenTransitionBoundary, type EvaluatedTransition, @@ -775,6 +776,34 @@ const planIndexedState = ( input: unknown, retainMicrosteps: boolean ): ExecutionMacrostep => { + if (InvocationEvent.isInvocationEvent(input)) { + // Invocation lifecycle transitions retain the generic planner as their + // semantic reference. Ordinary machine events and initialization stay on + // the indexed representation; only the private lifecycle macrostep crosses + // the representation boundary. + const planned = planConfiguration( + machine as any, + activeConfigurationFromIndexedState(descriptor, configuration), + input + ) + return { + next: ownedIndexedStateFromActive(descriptor, planned.next), + commands: planned.commands, + emittedEvents: planned.emittedEvents, + microsteps: planned.microsteps.map((step) => ({ + next: ownedIndexedStateFromActive(descriptor, step.next), + event: step.event, + commands: step.commands, + raisedEvents: step.raisedEvents, + emittedEvents: step.emittedEvents, + exitPaths: step.exitPaths, + entryPaths: step.entryPaths, + changed: step.changed + })), + done: planned.done, + output: planned.output + } + } const decoded = decodeEventSync(machine, input) if (descriptor.flat) { return planIndexedFlatState(machine, descriptor, configuration, decoded, retainMicrosteps) diff --git a/src/internal/machine/invocation.ts b/src/internal/machine/invocation.ts index 0b44a69..0ef6a48 100644 --- a/src/internal/machine/invocation.ts +++ b/src/internal/machine/invocation.ts @@ -4,28 +4,26 @@ * @since 0.4.0 */ +import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" -import type { Machine } from "../../Machine.js" +import type { ChildMachine, Logic, Machine } from "../../Machine.js" import * as Configuration from "./configuration.js" +import { InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from "./errors.js" +import * as InvocationEvent from "./invocationEvent.js" import * as Planner from "./planner.js" import type * as Runtime from "./runtime.js" +import { ChildMachineLogicTypeId } from "./symbols.js" /** @internal */ -export type AnyConfig = Machine.InvokeConfig< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> +export interface AnyConfig { + readonly id: string + readonly address?: string + readonly descriptor?: ChildMachine.Any + readonly src: () => Runtime.ProcessLogic + readonly onDone?: unknown + readonly onFailure?: unknown + readonly onSnapshot?: unknown +} /** @internal */ export const makeKey = (path: string, id: string): string => `${path.length}:${path}${id}` @@ -33,16 +31,79 @@ export const makeKey = (path: string, id: string): string => `${path.length}:${p /** @internal */ export const makeChildId = (path: string, id: string): string => `Machine.invoke:${makeKey(path, id)}` -const resolve = ( - config: Machine.AnyStateConfig | undefined, +const oneShot = (effect: Effect.Effect): Logic => ({ + initial: () => Effect.void, + run: () => effect +}) + +const resolveValue = (value: unknown, context: Machine.InvokeContext): unknown => + typeof value === "function" ? value(context) : value + +const resolveOne = ( + raw: Record, context: Machine.InvokeContext -): ReadonlyArray => { - const definition = config?.invoke - const invokes = typeof definition === "function" ? definition(context) : definition - if (invokes === undefined) return [] - return Array.isArray(invokes) ? invokes as ReadonlyArray : [invokes as AnyConfig] +): AnyConfig => { + if ("effect" in raw) { + return { + id: String(raw.id), + src: () => + oneShot(resolveValue(raw.effect, context) as Effect.Effect) as unknown as Runtime.ProcessLogic< + any, + any, + any, + any, + any, + any + >, + onDone: raw.onDone, + onFailure: raw.onFailure, + onSnapshot: raw.onSnapshot + } + } + if ("after" in raw) { + return { + id: String(raw.id), + src: () => + oneShot(Effect.sleep(resolveValue(raw.after, context) as any)) as unknown as Runtime.ProcessLogic< + any, + any, + any, + any, + any, + any + >, + onDone: raw.onDone + } + } + if ("logic" in raw) { + return { + id: String(raw.id), + address: String(raw.address), + src: () => resolveValue(raw.logic, context) as Runtime.ProcessLogic, + onDone: raw.onDone, + onFailure: raw.onFailure, + onSnapshot: raw.onSnapshot + } + } + if ("child" in raw) { + const descriptor = raw.child as ChildMachine.Any + return { + id: descriptor.id, + address: descriptor.id, + descriptor, + src: () => + descriptor[ChildMachineLogicTypeId]( + "input" in raw ? resolveValue(raw.input, context) : undefined + ) as Runtime.ProcessLogic, + onDone: raw.onDone, + onFailure: raw.onFailure, + onSnapshot: raw.onSnapshot + } + } + throw new Error("Machine invoke must define exactly one of effect, after, logic, or child") } +/** @internal */ const runSequentialDiscard = ( effects: ReadonlyArray> ): Effect.Effect => @@ -52,45 +113,110 @@ const runSequentialDiscard = ( ? effects[0]! : Effect.all(effects, { discard: true }) -const start = ( +const sendLifecycle = ( + scope: Runtime.ProcessScope, + event: InvocationEvent.InvocationEvent +): Effect.Effect => scope.self.send(event).pipe(Effect.catchTag("StoppedError", () => Effect.void)) + +const isFrameworkFailure = (error: unknown): boolean => + error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError || error instanceof StoppedError + +const startResolved = ( scope: Runtime.ProcessScope, ownedChildren: Runtime.OwnedChildRuntime, path: string, - config: AnyConfig + invokeId: string, + childId: string, + descriptor: ChildMachine.Any | undefined, + src: () => Runtime.ProcessLogic, + onDone: unknown, + onFailure: unknown, + onSnapshot: unknown ): Effect.Effect => Effect.suspend(() => { - const invokeId = String(config.id) const key = makeKey(path, invokeId) - const childId = config.address === undefined ? makeChildId(path, invokeId) : String(config.address) - return ownedChildren.spawn(config.src as () => Runtime.ProcessLogic, { + return ownedChildren.spawn(src, { key, path, id: childId, duplicateId: invokeId, - ...(config.descriptor === undefined ? undefined : { descriptor: config.descriptor }), + ...(descriptor === undefined ? undefined : { descriptor }), sendParent: (isCurrent, event) => isCurrent() ? scope.self.send(event) : Effect.void, onOutcome: (isCurrent, outcome) => { if (outcome._tag === "Stopped" || !isCurrent()) return Effect.void - if (outcome._tag !== "Done") return scope.failCause(outcome.cause) - const mappedEvent = config.onDone === undefined - ? outcome.output - : config.onDone({ id: config.id, output: outcome.output }) - return mappedEvent === undefined - ? Effect.void - : scope.self.send(mappedEvent).pipe(Effect.catchTag("StoppedError", () => Effect.void)) - }, - ...(config.snapshot === undefined ? undefined : { - onSnapshot: (isCurrent: () => boolean, snapshot: any) => { - if (!isCurrent()) return Effect.void - const mappedEvent = config.snapshot!({ id: config.id, snapshot }) - return mappedEvent === undefined - ? Effect.void - : scope.self.send(mappedEvent).pipe(Effect.catchTag("StoppedError", () => Effect.void)) + if (outcome._tag === "Done") { + if (onDone === undefined) { + return scope.failCause(Cause.die( + new Error( + `Invocation "${invokeId}" completed without the required onDone handler` + ) + )) + } + return sendLifecycle(scope, InvocationEvent.done(path, invokeId, outcome.output)) } + if ( + outcome._tag === "Failure" && + onFailure !== undefined && + (descriptor === undefined || !isFrameworkFailure(outcome.error)) + ) { + return sendLifecycle(scope, InvocationEvent.failure(path, invokeId, outcome.error)) + } + return scope.failCause(outcome.cause) + }, + ...(onSnapshot === undefined ? undefined : { + onSnapshot: (isCurrent: () => boolean, snapshot: any) => + isCurrent() + ? sendLifecycle(scope, InvocationEvent.snapshot(path, invokeId, snapshot)) + : Effect.void }) }) }) +const start = ( + scope: Runtime.ProcessScope, + ownedChildren: Runtime.OwnedChildRuntime, + path: string, + config: AnyConfig +): Effect.Effect => { + const invokeId = String(config.id) + return startResolved( + scope, + ownedChildren, + path, + invokeId, + config.address === undefined ? makeChildId(path, invokeId) : String(config.address), + config.descriptor, + config.src, + config.onDone, + config.onFailure, + config.onSnapshot + ) +} + +const startStaticChild = ( + scope: Runtime.ProcessScope, + ownedChildren: Runtime.OwnedChildRuntime, + path: string, + raw: Record +): Effect.Effect => { + // A zero-input child has no entry-context dependency. Reuse the descriptor's + // source function so each running parent does not retain a resolved config + // object and an otherwise redundant source closure. + const descriptor = raw.child as ChildMachine.Any + return startResolved( + scope, + ownedChildren, + path, + descriptor.id, + descriptor.id, + descriptor, + descriptor[ChildMachineLogicTypeId], + raw.onDone, + raw.onFailure, + raw.onSnapshot + ) +} + /** * Starts every invocation owned by active entry paths in deterministic entry * order. `undefined` keeps the compiled drain free of empty Effect nodes. @@ -107,13 +233,18 @@ export const startAll = ( ): Effect.Effect | undefined => { const effects = Planner.sortEntryPaths(machine, paths) .filter((path) => configuration.active.has(path)) - .flatMap((path) => - resolve(Configuration.getStateConfigByPath(machine, path), { + .flatMap((path) => { + const context = { state: configuration.values.get(path), parent: Configuration.getParentValue(machine, configuration, path), parents: Configuration.getParentValues(machine, configuration, path), event - }).map((config) => start(scope, ownedChildren, path, config)) - ) + } + return InvocationEvent.definitions(Configuration.getStateConfigByPath(machine, path)?.invoke).map((definition) => + "child" in definition && !("input" in definition) + ? startStaticChild(scope, ownedChildren, path, definition) + : start(scope, ownedChildren, path, resolveOne(definition, context)) + ) + }) return effects.length === 0 ? undefined : runSequentialDiscard(effects) } diff --git a/src/internal/machine/invocationEvent.ts b/src/internal/machine/invocationEvent.ts new file mode 100644 index 0000000..7fa5db1 --- /dev/null +++ b/src/internal/machine/invocationEvent.ts @@ -0,0 +1,72 @@ +/** + * Private mailbox messages used to route invocation lifecycle changes through + * the owning machine planner. + * + * @since 0.9.0 + */ + +/** @internal */ +export const InvocationEventTypeId: unique symbol = Symbol("effect/Machine/InvocationEvent") + +/** @internal */ +export type InvocationEvent = + | { + readonly [InvocationEventTypeId]: true + readonly path: string + readonly id: string + readonly type: "done" + readonly output: unknown + } + | { + readonly [InvocationEventTypeId]: true + readonly path: string + readonly id: string + readonly type: "failure" + readonly error: unknown + } + | { + readonly [InvocationEventTypeId]: true + readonly path: string + readonly id: string + readonly type: "snapshot" + readonly snapshot: unknown + } + +/** @internal */ +export const done = (path: string, id: string, output: unknown): InvocationEvent => ({ + [InvocationEventTypeId]: true, + path, + id, + type: "done", + output +}) + +/** @internal */ +export const failure = (path: string, id: string, error: unknown): InvocationEvent => ({ + [InvocationEventTypeId]: true, + path, + id, + type: "failure", + error +}) + +/** @internal */ +export const snapshot = (path: string, id: string, value: unknown): InvocationEvent => ({ + [InvocationEventTypeId]: true, + path, + id, + type: "snapshot", + snapshot: value +}) + +/** @internal */ +export const isInvocationEvent = (value: unknown): value is InvocationEvent => + typeof value === "object" && value !== null && InvocationEventTypeId in value + +/** @internal */ +export const definitions = (invoke: unknown): ReadonlyArray> => { + if (invoke === undefined) return [] + return Array.isArray(invoke) + ? invoke as ReadonlyArray> + : [invoke as Record] +} diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index c45282d..4b7b81b 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -1,4 +1,3 @@ -import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Inspectable from "effect/Inspectable" import * as Option from "effect/Option" @@ -34,6 +33,7 @@ import type { EnsureExecutable } from "./readiness.js" import * as internalRuntime from "./runtime.js" import * as Serialization from "./serialization.js" import * as StateDefinition from "./stateDefinition.js" +import { ChildMachineLogicTypeId } from "./symbols.js" import * as Topology from "./topology.js" export { @@ -45,13 +45,12 @@ export { StartupError, StoppedError } from "./errors.js" -export { InitialEventTypeId } from "./symbols.js" +export { ChildMachineLogicTypeId, InitialEventTypeId } from "./symbols.js" const TypeId = "~effect/Machine" export const SnapshotBuilderStateTypeId: unique symbol = Symbol("effect/Machine/SnapshotBuilderState") export const InvokeTypeId: unique symbol = Symbol.for("effect/Machine/Invoke") const ChildMachineTypeId = "~effect/Machine/ChildMachine" -type InvokeLifecycleId = string type IsAny = 0 extends 1 & A ? true : false type MachineRuntimeRequirement = internalRuntime.MachineRuntime type ExcludeCompatibleRuntime = Requirements extends Runtime.Requirement< @@ -967,145 +966,6 @@ export const decodeSnapshot: < Machine.SnapshotDecodingServices > = Serialization.decodeSnapshot as any -export const invoke = < - ChildState, - ChildEvent, - ChildError = never, - ChildRequirements = never, - ChildOutput = never, - ChildInitialError = never, - Event = never, - Address extends ChildAddress | undefined = undefined ->( - config: - & { - readonly id: InvokeLifecycleId - readonly src: () => Logic< - ChildState, - ChildEvent, - ChildError, - ChildRequirements, - ChildOutput, - ChildInitialError - > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext - ) => Event | undefined - } - & ([Address] extends [undefined] ? { - readonly address?: never - } - : { - readonly address: Exclude - } & ChildAddress.Compatibility, NoInfer>) -): Machine.InvokeConfig< - any, - any, - any, - any, - Event, - ChildState, - ChildEvent, - ChildError, - ChildRequirements, - ChildOutput, - ChildInitialError -> => ({ - ...config, - [InvokeTypeId]: undefined as any, - [Activities.ActivityMetadataTypeId]: { type: "process" } -}) - -type InvokeEffectResult = Machine.InvokeConfig< - any, - any, - any, - any, - never, - void, - never, - never, - Requirements, - Event | void, - never -> - -type InvokeEffectIsInfallible> = IsAny> extends true ? false - : [Effect.Error] extends [never] ? true - : false - -type InvokeEffectConfig< - Fx extends Effect.Effect, - SuccessEvent, - FailureEvent -> = - & { - readonly id: InvokeLifecycleId - readonly effect: Fx - readonly onSuccess: (value: NoInfer>) => SuccessEvent | void - } - & ( - InvokeEffectIsInfallible extends true ? { - readonly onFailure?: never - } - : { - readonly onFailure: (error: NoInfer>) => FailureEvent | void - } - ) - -export const invokeEffect = < - const Fx extends Effect.Effect, - SuccessEvent, - FailureEvent = never ->( - config: InvokeEffectConfig -): InvokeEffectResult< - Effect.Services, - SuccessEvent | (InvokeEffectIsInfallible extends true ? never : FailureEvent) -> => - ((config: { - readonly id: string - readonly effect: Effect.Effect - readonly onSuccess: (value: unknown) => unknown - readonly onFailure?: (error: unknown) => unknown - }) => ({ - ...invoke({ - id: config.id, - src: () => - effect( - config.onFailure === undefined - ? Effect.map(config.effect, config.onSuccess) - : Effect.matchEffect(config.effect, { - onFailure: (error) => Effect.succeed(config.onFailure!(error)), - onSuccess: (value) => Effect.succeed(config.onSuccess(value)) - }) - ) - }), - [Activities.ActivityMetadataTypeId]: { - type: "effect", - outcomes: { - success: "dynamic", - failure: config.onFailure === undefined ? "none" : "dynamic" - } - } - }))(config as any) as any - -export const after = ( - duration: Duration.Input, - event: Event, - options?: { readonly id?: InvokeLifecycleId } -): InvokeEffectResult => ({ - ...invoke({ - id: options?.id ?? `Machine.after:${String(event._tag)}`, - src: () => effect(Effect.as(Effect.sleep(duration), event)) - }), - [Activities.ActivityMetadataTypeId]: { - type: "timer", - duration: Duration.format(Duration.fromInputUnsafe(duration)), - event: String(event._tag) - } -}) - export const retag = ( target: Machine.TaggedSchema, source: { readonly _tag: PropertyKey }, @@ -1114,199 +974,6 @@ export const retag = ( const { _tag: _, ...fields } = source return target.make({ ...fields, ...((patch ?? {}) as object) } as never) } - -type InvokeMachineInput = Input extends typeof Schema.Void ? { - readonly input?: never - } - : { - readonly input: Input["Type"] - } - -export const invokeMachine: { - < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - SnapshotEvent = never, - DoneEvent = never, - Id extends string = string, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events - >( - config: - & { - readonly child: ChildMachine< - Id, - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable - > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext< - Machine.Snapshot, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output - > - ) => SnapshotEvent | undefined - readonly onDone: (context: Machine.InvokeDoneContext) => DoneEvent | undefined - } - & InvokeMachineInput - ): Machine.InvokeConfig< - any, - any, - any, - any, - SnapshotEvent, - Machine.Snapshot, - Machine.EventInputOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, - Machine.EventOf, - Machine.EmitOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - Machine.EmitOf, - DoneEvent - > - < - const States extends Machine.StateSchemas, - const Events extends ReadonlyArray, - const Emits extends ReadonlyArray = readonly [], - const Input extends Schema.Top = typeof Schema.Void, - UnhandledStates extends Machine.StateIdentifier = Machine.StateIdentifier, - E = never, - R = never, - InitialE = never, - InitialR = never, - FinalStates extends Machine.StateIdentifier = never, - Output = never, - SnapshotEvent = never, - Id extends string = string, - OutputStates extends Machine.StateIdentifier = never, - InputEvents extends ReadonlyArray = Events - >( - config: - & { - readonly child: ChildMachine< - Id, - & Machine< - States, - Events, - Input, - UnhandledStates, - E, - R, - InitialE, - InitialR, - FinalStates, - Output, - Emits, - OutputStates, - InputEvents - > - & EnsureExecutable - > - readonly snapshot?: ( - context: Machine.InvokeSnapshotContext< - Machine.Snapshot, - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StoppedError, - Output - > - ) => SnapshotEvent | undefined - readonly onDone?: never - } - & InvokeMachineInput - ): Machine.InvokeConfig< - any, - any, - any, - any, - SnapshotEvent, - Machine.Snapshot, - Machine.EventInputOf, - E | ActionError | InfiniteTransitionError | MachineSchemaDecodeError | StoppedError, - ExcludeCompatibleRuntime< - Exclude, internalRuntime.MachineRuntime>, - Machine.EventOf, - Machine.EmitOf - >, - Output, - | InitialE - | E - | ActionError - | InfiniteTransitionError - | MachineSchemaDecodeError - | StartupError - | StoppedError, - Machine.EmitOf - > -} = ((config: { - readonly child: ChildMachine.Any - readonly input?: unknown - readonly snapshot?: (context: Machine.InvokeSnapshotContext) => unknown - readonly onDone?: (context: Machine.InvokeDoneContext) => unknown -}) => { - const machine = config.child.machine - // An invoke descriptor fixes both its machine and input. Compile its process - // logic once; all mutable execution state belongs to the process instance. - const logic = machine.input === undefined - ? (internalProcess.toProcessLogic as any)(machine) - : (internalProcess.toProcessLogic as any)(machine, config.input) - return { - id: config.child.id, - address: config.child.id, - descriptor: config.child, - src: () => logic, - snapshot: config.snapshot, - onDone: config.onDone, - [Activities.ActivityMetadataTypeId]: { - type: "machine", - child: { - id: config.child.id, - machineId: machine.id ?? null - } - }, - [InvokeTypeId]: undefined as any - } -}) as any - export const planInitial: < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, @@ -1542,13 +1209,6 @@ export const plan: < never > = internalPlanner.plan as any -export const effect = ( - effect: Effect.Effect -): Logic => ({ - initial: () => Effect.void, - run: () => effect -}) - export const logic = < State, Event = never, @@ -1597,7 +1257,11 @@ export const child = ( ): ChildMachine => ({ [ChildMachineTypeId]: ChildMachineTypeId, id, - machine + machine, + [ChildMachineLogicTypeId]: (input) => + machine.input === undefined + ? (internalProcess.toProcessLogic as any)(machine) + : (internalProcess.toProcessLogic as any)(machine, input) }) export const childAddress = (id: string): ChildAddress => id as ChildAddress diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index 7750227..49f3d61 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -38,6 +38,7 @@ import { validateInitialConfiguration } from "./configuration.js" import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./errors.js" +import * as InvocationEvent from "./invocationEvent.js" import { decodeEventSync, decodeInputSync, decodeStateValueSync } from "./protocol.js" import { InitialEventTypeId } from "./symbols.js" import { @@ -798,6 +799,54 @@ const selectEventTransitions = < return selected } +const selectInvocationTransition = < + const States extends Machine.StateSchemas, + const Events extends ReadonlyArray, + const Emits extends ReadonlyArray, + E, + R +>( + machine: Machine.Any, + configuration: ActiveConfiguration, + event: InvocationEvent.InvocationEvent +): ReadonlyArray> => { + if (!configuration.active.has(event.path)) return [] + const config = machine.handlers[event.path] as Machine.AnyStateConfig | undefined + const invoke = InvocationEvent.definitions(config?.invoke).find((definition) => { + const id = "child" in definition ? definition.child?.id : definition.id + return String(id) === event.id + }) + if (invoke === undefined) return [] + const handler = event.type === "done" + ? invoke.onDone + : event.type === "failure" + ? invoke.onFailure + : invoke.onSnapshot + const transition = normalizeTransition(handler) + if (transition === undefined) return [] + const snapshot = snapshotFromConfiguration(machine, configuration) + const context = { + state: configuration.values.get(event.path), + parent: getParentValue(machine, configuration, event.path), + parents: getParentValues(machine, configuration, event.path), + snapshot, + target: getTargetBuilder(machine, event.path), + id: event.id, + ...(event.type === "done" + ? { output: event.output } + : event.type === "failure" + ? { error: event.error } + : { snapshot: event.snapshot }) + } + return [{ + sourcePath: event.path, + leafPath: getActiveLeafPathFrom(machine, configuration, event.path), + trigger: { type: "invoke", id: event.id, outcome: event.type }, + transition: transition as unknown as MicrostepTransition, + context + }] +} + export const getTargetNodePath = ( target: | Machine.Snapshot @@ -1812,11 +1861,11 @@ const macrostepConfiguration = < >( machine: Machine, configuration: ActiveConfiguration, - event: Machine.EventOf + event: Machine.EventOf | InvocationEvent.InvocationEvent ) => { - const decodedEvent = decodeEventSync(machine, event) + const decodedEvent = InvocationEvent.isInvocationEvent(event) ? event : decodeEventSync(machine, event) if (isActiveFinalConfiguration(machine, configuration)) { - const completed = completeConfigurationSync(machine, configuration, decodedEvent) + const completed = completeConfigurationSync(machine, configuration, decodedEvent as any) const root = getRootPath(machine, completed.configuration) if (!completed.configuration.outputs.has(root)) { throw new Error("Machine reached a terminal configuration without a completed root output") @@ -1831,11 +1880,13 @@ const macrostepConfiguration = < } } - const selections = selectEventTransitions( - machine, - configuration, - decodedEvent as Machine.EventByTag> - ) + const selections = InvocationEvent.isInvocationEvent(decodedEvent) + ? selectInvocationTransition(machine, configuration, decodedEvent) + : selectEventTransitions( + machine, + configuration, + decodedEvent as Machine.EventByTag> + ) if (selections.length === 0) { return { next: configuration, @@ -1849,14 +1900,14 @@ const macrostepConfiguration = < const step = microstep( machine, configuration, - decodedEvent, + decodedEvent as any, selections ) const commands = [...step.commands] const raisedEvents = [...step.raisedEvents] const emittedEvents = [...step.emittedEvents] const microsteps = [step] - return settle(machine, step.next, decodedEvent, commands, raisedEvents, emittedEvents, microsteps) + return settle(machine, step.next, decodedEvent as any, commands, raisedEvents, emittedEvents, microsteps) } const snapshotMacrostep = < diff --git a/src/internal/machine/symbols.ts b/src/internal/machine/symbols.ts index 31e3b28..d2ca606 100644 --- a/src/internal/machine/symbols.ts +++ b/src/internal/machine/symbols.ts @@ -1,2 +1,5 @@ /** @internal */ export const InitialEventTypeId: unique symbol = Symbol("effect/Machine/InitialEvent") + +/** @internal Returns process logic for a child descriptor and optional input. */ +export const ChildMachineLogicTypeId: unique symbol = Symbol("effect/Machine/ChildMachineLogic") diff --git a/src/internal/machine/topology.ts b/src/internal/machine/topology.ts index 33a351c..b83fa0a 100644 --- a/src/internal/machine/topology.ts +++ b/src/internal/machine/topology.ts @@ -392,6 +392,29 @@ export const transitionDefinitions = ( targets: transitionTargets(config.onDone) }) } + const invokes = config.invoke === undefined + ? [] + : Array.isArray(config.invoke) + ? config.invoke + : [config.invoke] + for (const invoke of invokes) { + const id = "child" in invoke ? String(invoke.child.id) : String(invoke.id) + for (const outcome of ["done", "failure", "snapshot"] as const) { + const handler = outcome === "done" + ? invoke.onDone + : outcome === "failure" + ? invoke.onFailure + : invoke.onSnapshot + if (handler !== undefined) { + definitions.push({ + source: node.path, + trigger: { type: "invoke", id, outcome }, + reenter: typeof handler === "object" && handler !== null && handler.reenter === true, + targets: transitionTargets(handler) + }) + } + } + } } return definitions } diff --git a/test/internal/machine/activities.test.ts b/test/internal/machine/activities.test.ts index 583f9e6..c735f8e 100644 --- a/test/internal/machine/activities.test.ts +++ b/test/internal/machine/activities.test.ts @@ -2,11 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { Duration, Effect, Schema } from "effect" import { FastCheck } from "effect/testing" import { Machine } from "../../../src/index.js" -import { - activityDefinitions, - ActivityMetadataTypeId, - type StaticActivityMetadata -} from "../../../src/internal/machine/activities.js" +import { activityDefinitions } from "../../../src/internal/machine/activities.js" import { makeTextRenderer } from "../../machine/visualization/text.js" class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} @@ -27,7 +23,6 @@ const child = Machine.child("child", childMachine) let dynamicFactoryEvaluations = 0 const timerDuration = "10 seconds" -const timerEvent = new LoadTimedOut({}) const activityStates = Machine.defineStates({ Loading, Dynamic }) const activityMachine = Machine.make({ id: "activity-inspection", @@ -39,26 +34,28 @@ const activityMachine = Machine.make({ invoke: [ Machine.invoke({ id: "poll-server", - src: () => Machine.effect(Effect.void) + address: Machine.childAddress("poll-server"), + logic: Machine.logic({ initial: undefined, run: () => Effect.never }) }), - Machine.invokeEffect({ + Machine.invoke({ id: "load-document", effect: Effect.fail("unavailable").pipe(Effect.as(1)), - onSuccess: () => new WorkSucceeded({}), - onFailure: () => new WorkFailed({}) + onDone: () => undefined, + onFailure: () => undefined }), - Machine.after(timerDuration, timerEvent, { id: "load-timeout" }), - Machine.invokeMachine({ child }) + Machine.invoke({ id: "load-timeout", after: timerDuration, onDone: () => undefined }), + Machine.invoke({ child }) ] }, Dynamic: { - invoke: () => { - dynamicFactoryEvaluations++ - return Machine.invoke({ - id: "context-owned", - src: () => Machine.effect(Effect.void) - }) - } + invoke: Machine.invoke({ + id: "context-owned", + address: Machine.childAddress("context-owned"), + logic: () => { + dynamicFactoryEvaluations++ + return Machine.logic({ initial: undefined, run: () => Effect.never }) + } + }) } }) @@ -67,11 +64,6 @@ const renderActivityMachine = makeTextRenderer< Machine.Machine.Snapshot >(Machine) -const descriptor = (id: string, metadata: StaticActivityMetadata) => ({ - id, - [ActivityMetadataTypeId]: metadata -}) - const machine = { stateNodes: { byPath: new Map([ @@ -85,25 +77,20 @@ const machine = { handlers: { Loading: { invoke: [ - descriptor("load-document", { - type: "effect", - outcomes: { success: "dynamic", failure: "dynamic" } - }), - descriptor("load-timeout", { - type: "timer", - duration: "10s", - event: "LoadTimedOut" - }) + { + id: "load-document", + effect: Effect.void, + onFailure: () => undefined, + type: "effect" + }, + { id: "load-timeout", after: "10 seconds" } ] }, "Parent.Active": { - invoke: descriptor("child", { - type: "machine", - child: { id: "child", machineId: "document-worker" } - }) + invoke: { child } }, Dynamic: { - invoke: () => descriptor("runtime-dependent", { type: "process" }) + invoke: { id: "runtime-dependent", address: "runtime-dependent", logic: () => Effect.never } } } } @@ -128,8 +115,7 @@ describe("machine activity metadata", () => { source: "Loading", id: "load-timeout", type: "timer", - duration: "10s", - event: "LoadTimedOut" + duration: "10s" }, { source: "Loading", @@ -139,7 +125,8 @@ describe("machine activity metadata", () => { }, { source: "Dynamic", - type: "dynamic" + id: "context-owned", + type: "process" } ] @@ -149,7 +136,6 @@ describe("machine activity metadata", () => { const timer = Machine.activityDefinitions(activityMachine).find(({ type }) => type === "timer") assert(timer?.type === "timer") assert.strictEqual(timer.duration, Duration.format(Duration.fromInputUnsafe(timerDuration))) - assert.strictEqual(timer.event, String(timerEvent._tag)) assert.strictEqual(dynamicFactoryEvaluations, 0) }) @@ -173,7 +159,7 @@ describe("machine activity metadata", () => { initial: () => activityStates.initial.Loading(new Loading({})) }).handle({ Loading: { - invoke: Machine.after(durationMillis, timerEvent, { id }) + invoke: Machine.invoke({ id, after: durationMillis, onDone: () => undefined }) } }) const definition = Machine.activityDefinitions(generated)[0] @@ -182,8 +168,7 @@ describe("machine activity metadata", () => { source: "Loading", id, type: "timer", - duration: Duration.format(Duration.fromInputUnsafe(durationMillis)), - event: "LoadTimedOut" + duration: Duration.format(Duration.fromInputUnsafe(durationMillis)) }) assert(Machine.stateNodes(generated).some(({ path }) => path === definition?.source)) }), @@ -202,8 +187,7 @@ describe("machine activity metadata", () => { source: "Loading", id: "load-timeout", type: "timer", - duration: "10s", - event: "LoadTimedOut" + duration: "10s" }, { source: "Parent.Active", @@ -211,29 +195,38 @@ describe("machine activity metadata", () => { type: "machine", child: { id: "child", machineId: "document-worker" } }, - { - source: "Dynamic", - type: "dynamic" - } + { source: "Dynamic", id: "runtime-dependent", type: "process" } ]) }) - it("does not evaluate dynamic factories while inspecting", () => { + it("does not evaluate source factories while inspecting", () => { let evaluations = 0 const dynamic = { stateNodes: { byPath: new Map([["Active", { path: "Active" }]]) }, handlers: { Active: { - invoke: () => { - evaluations++ - return descriptor("runtime-dependent", { type: "process" }) + invoke: { + id: "runtime-dependent", + address: "runtime-dependent", + logic: () => { + evaluations++ + return Effect.never + } } } } } - assert.deepStrictEqual(activityDefinitions(dynamic), [{ source: "Active", type: "dynamic" }]) - assert.deepStrictEqual(activityDefinitions(dynamic), [{ source: "Active", type: "dynamic" }]) + assert.deepStrictEqual(activityDefinitions(dynamic), [{ + source: "Active", + id: "runtime-dependent", + type: "process" + }]) + assert.deepStrictEqual(activityDefinitions(dynamic), [{ + source: "Active", + id: "runtime-dependent", + type: "process" + }]) assert.strictEqual(evaluations, 0) }) @@ -243,7 +236,7 @@ describe("machine activity metadata", () => { handlers: { ...machine.handlers, Missing: { - invoke: descriptor("orphan", { type: "process" }) + invoke: { id: "orphan", address: "orphan", logic: Effect.never } } } } @@ -263,12 +256,15 @@ describe("machine activity metadata", () => { "● active ○ inactive ◇ transition (→ declared, ∅ none, omitted dynamic) ◆ activity", "", "├─ ● Loading", + "│ ├─ ◇ invoke load-document done", + "│ ├─ ◇ invoke load-document failure", + "│ ├─ ◇ invoke load-timeout done", "│ ├─ ◆ process: poll-server", "│ ├─ ◆ effect: load-document [success: dynamic, failure: dynamic]", - "│ ├─ ◆ timer: load-timeout [10s] → LoadTimedOut", + "│ ├─ ◆ timer: load-timeout [10s]", "│ └─ ◆ machine: child → document-worker", "└─ ○ Dynamic", - " └─ ◆ activity: dynamic", + " └─ ◆ process: context-owned", "", "Candidate events: none" ].join("\n") diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index 6681030..2cd81d2 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -406,15 +406,15 @@ describe("machine planner and runtime strategies", () => { Loading: { invoke: Machine.invoke({ id: "load", - src: () => Machine.effect(Effect.succeed(new Loaded({ value: "complete" }))) - }), - on: { - Loaded: ({ event }) => states.initial.Success(new Success({ value: event.value })) - } + effect: Effect.succeed(new Loaded({ value: "complete" })), + onDone: ({ output }) => states.initial.Success(new Success({ value: output.value })) + }) }, Success: { output: ({ state }) => state.value } }) + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "indexed-flat") + const results: Array = [] for (const strategy of ["generic", "compiled"] as const) { const ref = yield* openWithRuntimeStrategy(machine, strategy) @@ -430,6 +430,44 @@ describe("machine planner and runtime strategies", () => { assert.deepStrictEqual(results[1], results[0]) }) as Effect.Effect) + it.effect("matches generic and indexed invoke failure traces", () => + Effect.gen(function*() { + class Loading extends Schema.TaggedClass("StrategyInvokeFailureLoading")("Loading", {}) {} + class Failed extends Schema.TaggedClass("StrategyInvokeFailureFailed")("Failed", { + error: Schema.String + }) {} + const states = Machine.defineStates({ + Loading, + Failed: { schema: Failed, type: "final", output: Schema.String } + }) + const machine = Machine.make({ + states: states.states, + events: [], + initial: () => states.initial.Loading(new Loading({})) + }).handle({ + Loading: { + invoke: Machine.invoke({ + id: "load", + effect: Effect.fail("unavailable"), + onFailure: ({ error, target }) => target.full.Failed(new Failed({ error })) + }) + }, + Failed: { output: ({ state }) => state.error } + }) + + assert.strictEqual(ExecutionPlan.selectExecutionPlanForTesting(machine, "auto").strategy, "indexed-flat") + + const results: Array = [] + for (const strategy of ["generic", "compiled"] as const) { + const ref = yield* openWithRuntimeStrategy(machine, strategy) + results.push({ + output: yield* ref.join, + snapshot: yield* ref.snapshot + }) + } + assert.deepStrictEqual(results[1], results[0]) + }) as Effect.Effect) + it.effect("drops stale invoke messages and snapshots after reentry in both runtime strategies", () => Effect.gen(function*() { class Loading extends Schema.TaggedClass("StrategyStaleInvokeLoading")("Loading", { @@ -451,7 +489,8 @@ describe("machine planner and runtime strategies", () => { Loading: { invoke: Machine.invoke({ id: "worker", - src: () => { + address: Machine.childAddress("worker"), + logic: () => { generation += 1 const current = generation return Machine.logic({ @@ -467,7 +506,9 @@ describe("machine planner and runtime strategies", () => { ) }) }, - snapshot: ({ snapshot }) => snapshot.state === "stale" ? new Stale({}) : undefined + onFailure: () => undefined, + onSnapshot: ({ snapshot, target }) => + snapshot.state === "stale" ? target.full.Failed(new Failed({})) : undefined }), on: { Reenter: { diff --git a/test/machine/ActivityLifecycleModel.test.ts b/test/machine/ActivityLifecycleModel.test.ts index 2fc5510..7f2639f 100644 --- a/test/machine/ActivityLifecycleModel.test.ts +++ b/test/machine/ActivityLifecycleModel.test.ts @@ -83,7 +83,10 @@ describe("machine activity lifecycle model", () => { Active: { invoke: Machine.invoke({ id: "activity", - src: () => probe.logic("active", { _tag: "Blocked" }) + address: Machine.childAddress("activity"), + logic: probe.logic("active", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined }), on: { Leave: ({ target }) => target.full.Idle(new Idle({})), @@ -150,11 +153,11 @@ describe("machine activity lifecycle model", () => { Active: { invoke: Machine.invoke({ id: "immediate", - src: () => probe.immediate("immediate", (epoch) => new Completed({ epoch })) - }), - on: { - Completed: ({ event, target }) => target.full.Done(new Done({ epoch: event.epoch })) - } + 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 + }) }, Done: { output: ({ state }) => state.epoch @@ -186,11 +189,13 @@ describe("machine activity lifecycle model", () => { Active: { invoke: Machine.invoke({ id: "epoch", - src: () => - probe.logic("epoch", { - _tag: "StaleOnCancel", - event: (epoch) => new Completed({ epoch }) - }) + address: Machine.childAddress("epoch"), + logic: probe.logic("epoch", { + _tag: "StaleOnCancel", + event: (epoch) => new Completed({ epoch }) + }), + onDone: () => undefined, + onFailure: () => undefined }), on: { Restart: { @@ -300,7 +305,10 @@ describe("machine activity lifecycle model", () => { active: { invoke: Machine.invoke({ id: "left-activity", - src: () => probe.logic("left", { _tag: "Blocked" }) + address: Machine.childAddress("left-activity"), + logic: probe.logic("left", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined }), on: { LeaveLeft: ({ target }) => target.local.idle(new LeftIdle({})) @@ -313,7 +321,10 @@ describe("machine activity lifecycle model", () => { active: { invoke: Machine.invoke({ id: "right-activity", - src: () => probe.logic("right", { _tag: "Blocked" }) + address: Machine.childAddress("right-activity"), + logic: probe.logic("right", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined }) } } @@ -360,13 +371,19 @@ describe("machine activity lifecycle model", () => { invoke: [ Machine.invoke({ id: "timed-activity", - src: () => probe.logic("timed", { _tag: "Blocked" }) + address: Machine.childAddress("timed-activity"), + logic: probe.logic("timed", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined }), - Machine.after("1 hour", new TimerFired({}), { id: "deadline" }) + Machine.invoke({ + id: "deadline", + after: "1 hour", + onDone: ({ target }) => target.full.Done(new Done({ epoch: -1 })) + }) ], on: { - Leave: ({ target }) => target.full.Idle(new Idle({})), - TimerFired: ({ target }) => target.full.Done(new Done({ epoch: -1 })) + Leave: ({ target }) => target.full.Idle(new Idle({})) } }, Done: {} @@ -399,11 +416,19 @@ describe("machine activity lifecycle model", () => { invoke: [ Machine.invoke({ id: "failing", - src: () => probe.logic("failing", { _tag: "Failure" }) + address: Machine.childAddress("failing"), + logic: probe.logic("failing", { _tag: "Failure" }), + onDone: () => undefined, + onFailure: ({ error }) => { + throw error + } }), Machine.invoke({ id: "sibling", - src: () => probe.logic("sibling", { _tag: "Blocked" }) + address: Machine.childAddress("sibling"), + logic: probe.logic("sibling", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined }) ] } @@ -418,7 +443,7 @@ describe("machine activity lifecycle model", () => { assert(Exit.isFailure(failed)) if (Exit.isFailure(failed)) { - assert.instanceOf(failed.cause.reasons.find((reason) => reason._tag === "Fail")?.error, ActivityFailure) + assert.instanceOf(failed.cause.reasons.find((reason) => reason._tag === "Die")?.defect, ActivityFailure) } const records = yield* probe.records assert.strictEqual(countRecords(records, "failed", "failing"), 1) @@ -437,8 +462,20 @@ describe("machine activity lifecycle model", () => { }).handle({ Active: { invoke: [ - Machine.invoke({ id: "first", src: () => probe.logic("first", { _tag: "Blocked" }) }), - Machine.invoke({ id: "second", src: () => probe.logic("second", { _tag: "Blocked" }) }) + Machine.invoke({ + id: "first", + address: Machine.childAddress("first"), + logic: probe.logic("first", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined + }), + Machine.invoke({ + id: "second", + address: Machine.childAddress("second"), + logic: probe.logic("second", { _tag: "Blocked" }), + onDone: () => undefined, + onFailure: () => undefined + }) ] } }) diff --git a/test/machine/Invoke.test.ts b/test/machine/Invoke.test.ts new file mode 100644 index 0000000..0f13b39 --- /dev/null +++ b/test/machine/Invoke.test.ts @@ -0,0 +1,140 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause, Effect, Schema } from "effect" +import { Machine } from "../../src/index.js" + +class Loading extends Schema.TaggedClass("InvokeLoading")("Loading", {}) {} +class Complete extends Schema.TaggedClass("InvokeComplete")("Complete", { + value: Schema.String +}) {} +class Failed extends Schema.TaggedClass("InvokeFailed")("Failed", { + message: Schema.String +}) {} +class Idle extends Schema.TaggedClass("InvokeIdle")("Idle", {}) {} +class Start extends Schema.TaggedClass("InvokeStart")("Start", {}) {} + +const States = Machine.defineStates({ Idle, Loading, Complete, Failed }) + +describe("inline invoke", () => { + it.effect("plans a successful Effect outcome directly", () => + Effect.gen(function*() { + const machine = Machine.make({ + states: States.states, + events: [], + initial: () => States.initial.Loading.from() + }).handle({ + Loading: { + invoke: Machine.invoke({ + id: "load", + effect: Effect.succeed("ready"), + onDone: ({ output, target }) => target.full.Complete(new Complete({ value: output })) + }) + }, + Complete: {}, + Failed: {} + }) + + assert.deepStrictEqual(Machine.transitionDefinitions(machine), [{ + source: "Loading", + trigger: { type: "invoke", id: "load", outcome: "done" }, + reenter: false, + targets: { type: "dynamic" } + }]) + + const ref = yield* Machine.start(machine) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + assert.deepStrictEqual(yield* ref.state, States.initial.Complete(new Complete({ value: "ready" }))) + })) + + it.effect("plans a typed Effect failure directly", () => + Effect.gen(function*() { + const machine = Machine.make({ + states: States.states, + events: [], + initial: () => States.initial.Loading.from() + }).handle({ + Loading: { + invoke: Machine.invoke({ + id: "load", + effect: Effect.fail("offline"), + onFailure: ({ error, target }) => target.full.Failed(new Failed({ message: error })) + }) + }, + Complete: {}, + Failed: {} + }) + + const ref = yield* Machine.start(machine) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + assert.deepStrictEqual(yield* ref.state, States.initial.Failed(new Failed({ message: "offline" }))) + })) + + it.effect("fails the owning machine when an Effect source factory defects", () => + Effect.gen(function*() { + const defect = new Error("source defect") + const machine = Machine.make({ + states: States.states, + events: [Start], + initial: () => States.initial.Idle.from() + }).handle({ + Idle: { + on: { Start: ({ target }) => target.full.Loading.from() } + }, + Loading: { + invoke: Machine.invoke({ + id: "load", + effect: (): Effect.Effect => { + throw defect + }, + onDone: () => undefined + }) + }, + Complete: {}, + Failed: {} + }) + + const ref = yield* Machine.start(machine) + yield* ref.send(new Start({})) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + const snapshot = yield* ref.snapshot + + assert.strictEqual(snapshot.status, "error") + if (snapshot.status !== "error") return assert.fail("expected an error snapshot") + assert.strictEqual(Cause.squash(snapshot.cause), defect) + })) + + it.effect("fails the owning machine when reusable logic cannot initialize", () => + Effect.gen(function*() { + const failure = new Error("initialization failed") + const logic = Machine.logic({ + initial: () => Effect.fail(failure), + run: () => Effect.never + }) + const machine = Machine.make({ + states: States.states, + events: [Start], + initial: () => States.initial.Idle.from() + }).handle({ + Idle: { + on: { Start: ({ target }) => target.full.Loading.from() } + }, + Loading: { + invoke: Machine.invoke({ + id: "worker", + address: Machine.childAddress("worker"), + logic + }) + }, + Complete: {}, + Failed: {} + }) + + const ref = yield* Machine.start(machine) + yield* ref.send(new Start({})) + for (let index = 0; index < 5; index += 1) yield* Effect.yieldNow + const snapshot = yield* ref.snapshot + + assert.strictEqual(snapshot.status, "error") + if (snapshot.status !== "error") return assert.fail("expected an error snapshot") + assert.strictEqual(Cause.squash(snapshot.cause), failure) + })) +}) diff --git a/test/machine/Machine.test.ts b/test/machine/Machine.test.ts index 4b6acb2..24bbe95 100644 --- a/test/machine/Machine.test.ts +++ b/test/machine/Machine.test.ts @@ -663,7 +663,7 @@ describe("Machine", () => { assert.strictEqual(snapshot.status, "error") })) - it.effect("delivers internal constructions from invokeEffect and after", () => + it.effect("plans inline Effect and timer outcomes", () => Effect.gen(function*() { const release = yield* Deferred.make() const InternalEvent = Schema.TaggedUnion({ Loaded: {}, TimedOut: {} }) @@ -674,23 +674,20 @@ describe("Machine", () => { internalEvents: [InternalEvent], initial: () => states.initial.Loading.from() }) - const internalEvents = Machine.internalEvents(definition) const machine = definition.handle({ Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "load", effect: Deferred.await(release), - onSuccess: () => internalEvents.Loaded() - }), - on: { - Loaded: ({ target }) => target.full.Waiting.from() - } + onDone: ({ target }) => target.full.Waiting.from() + }) }, Waiting: { - invoke: Machine.after("1 second", internalEvents.TimedOut()), - on: { - TimedOut: ({ target }) => target.full.Done.from() - } + invoke: Machine.invoke({ + id: "timeout", + after: "1 second", + onDone: ({ target }) => target.full.Done.from() + }) }, Done: {} }) @@ -4189,17 +4186,11 @@ describe("Machine", () => { } }, Loading: { - invoke: ({ state }) => - Machine.invoke({ - id: "request", - src: () => - Machine.effect( - Effect.succeed(new RequestSucceeded({ value: `done:${state.requestId}` })) - ) - }), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) - } + invoke: Machine.invoke({ + id: "request", + effect: Effect.succeed("done:request-1"), + onDone: ({ output, target }) => target.full.Success(new Success({ requestId: output })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4236,17 +4227,11 @@ describe("Machine", () => { } }, Loading: { - invoke: ({ state }) => - Machine.invoke({ - id: "request", - src: () => - Machine.effect( - Effect.succeed(new RequestSucceeded({ value: `done:${state.requestId}` })) - ) - }), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) - } + invoke: Machine.invoke({ + id: "request", + effect: Effect.succeed("done:request-1"), + onDone: ({ output, target }) => target.full.Success(new Success({ requestId: output })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4285,7 +4270,7 @@ describe("Machine", () => { initial: () => parentStates.initial.Loading(new Loading({ requestId: "parent" })) }).handle({ Loading: { - invoke: Machine.invokeMachine({ child: Child }) + invoke: Machine.invoke({ child: Child }) } }) @@ -4335,7 +4320,7 @@ describe("Machine", () => { events: [], initial: () => parentStates.initial.Loading(new Loading({ requestId: "parent" })) }).handle({ - Loading: { invoke: Machine.invokeMachine({ child: Child }) } + Loading: { invoke: Machine.invoke({ child: Child }) } }) const parent = yield* Machine.start(parentMachine) @@ -4373,7 +4358,7 @@ describe("Machine", () => { initial: () => parentStates.initial.Loading(new Loading({ requestId: "parent" })) }).handle({ Loading: { - invoke: Machine.invokeMachine({ child: Child, input: { userId: "configured" } }) + invoke: Machine.invoke({ child: Child, input: { userId: "configured" } }) } }) @@ -4420,13 +4405,10 @@ describe("Machine", () => { initial: () => parentStates.initial.Loading(new Loading({ requestId: "parent" })) }).handle({ Loading: { - invoke: Machine.invokeMachine({ + invoke: Machine.invoke({ child: Child, - onDone: ({ output }) => new ChildFinished({ output }) - }), - on: { - ChildFinished: ({ event, target }) => target.full.Success(new Success({ requestId: event.output })) - } + onDone: ({ output, target }) => target.full.Success(new Success({ requestId: output })) + }) }, Success: { output: ({ state }) => state.requestId } }) @@ -4466,7 +4448,7 @@ describe("Machine", () => { yield* Effect.all([first.stop, second.stop], { concurrency: "unbounded" }) })) - it.effect("invokeMachine rejects duplicate active child addresses", () => + it.effect("child invocation rejects duplicate active child addresses", () => Effect.gen(function*() { const childStates = Machine.defineStates({ Idle }) const child = Machine.make({ @@ -4483,8 +4465,8 @@ describe("Machine", () => { }).handle({ Loading: { invoke: [ - Machine.invokeMachine({ child: Child }), - Machine.invokeMachine({ child: Child }) + Machine.invoke({ child: Child }), + Machine.invoke({ child: Child }) ] } }) @@ -4502,7 +4484,7 @@ describe("Machine", () => { let sourceEvaluations = 0 const source = () => { sourceEvaluations += 1 - return Machine.effect(Effect.never) + return Machine.logic({ initial: undefined, run: () => Effect.never }) } const parentStates = Machine.defineStates({ Loading }) const parent = Machine.make({ @@ -4515,12 +4497,12 @@ describe("Machine", () => { Machine.invoke({ id: "worker", address: First, - src: source + logic: source }), Machine.invoke({ id: "worker", address: Second, - src: source + logic: source }) ] } @@ -4551,16 +4533,9 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => - Machine.effect( - Effect.fail(error).pipe( - Effect.catch((error) => Effect.succeed(new RequestFailed({ error, cause: Cause.fail(error) }))) - ) - ) - }), - on: { - RequestFailed: ({ event }) => FlatInitial.Failed(new Failed({ message: event.error.message })) - } + effect: Effect.fail(error), + onFailure: ({ error, target }) => target.full.Failed(new Failed({ message: error.message })) + }) }, Failed: { output: ({ state }) => state.message @@ -4599,11 +4574,9 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => Machine.effect(Effect.succeed(new RequestSucceeded({ value: "loaded" }))) - }), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) - } + effect: Effect.succeed("loaded"), + onDone: ({ output, target }) => target.full.Success(new Success({ requestId: output })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4627,15 +4600,16 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => - Machine.logic({ - initial: undefined, - run: ({ sendParent }) => - Deferred.succeed(childStarted, void 0).pipe( - Effect.andThen(sendParent(new RequestSucceeded({ value: "child" }))), - Effect.andThen(Effect.never) - ) - }) + address: Machine.childAddress("request-parent"), + logic: Machine.logic({ + initial: undefined, + run: ({ sendParent }) => + Deferred.succeed(childStarted, void 0).pipe( + Effect.andThen(sendParent(new RequestSucceeded({ value: "child" }))), + Effect.andThen(Effect.never) + ) + }), + onFailure: () => undefined }), on: { RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) @@ -4668,15 +4642,16 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => - Machine.logic({ - initial: undefined, - run: ({ sendParent }) => - Deferred.succeed(childStarted, void 0).pipe( - Effect.andThen(Effect.never), - Effect.onInterrupt(() => sendParent(new RequestSucceeded({ value: "stale" }))) - ) - }) + address: Machine.childAddress("stale-request"), + logic: Machine.logic({ + initial: undefined, + run: ({ sendParent }) => + Deferred.succeed(childStarted, void 0).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => sendParent(new RequestSucceeded({ value: "stale" }))) + ) + }), + onFailure: () => undefined }), on: { Resolve: () => FlatInitial.Idle(new Idle({ userId: "resolved" })) @@ -4703,7 +4678,7 @@ describe("Machine", () => { yield* actor.stop })) - it.effect("invokeEffect maps typed failures without manual Effect recovery", () => + it.effect("inline Effect invocation handles typed failures without manual recovery", () => Effect.gen(function*() { const failure = new InvokeError({ message: "unavailable" }) const machine = Machine.make({ @@ -4713,16 +4688,11 @@ describe("Machine", () => { initial: () => FlatInitial.Loading(new Loading({ requestId: "request-1" })) }).handle({ Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "request", effect: Effect.fail(failure), - onSuccess: (value: string) => new RequestSucceeded({ value }), - onFailure: (error) => new RequestFailed({ error, cause: Cause.fail(error) }) - }), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Failed(new Failed({ message: event.value })), - RequestFailed: ({ event }) => FlatInitial.Failed(new Failed({ message: event.error.message })) - } + onFailure: ({ error, target }) => target.full.Failed(new Failed({ message: error.message })) + }) }, Failed: { output: ({ state }) => state.message @@ -4746,14 +4716,11 @@ describe("Machine", () => { initial: () => FlatInitial.Loading(new Loading({ requestId: "request-1" })) }).handle({ Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "request", effect: requiredMessage, - onSuccess: (value: string) => new RequestSucceeded({ value }) - }), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) - } + onDone: ({ output, target }) => target.full.Success(new Success({ requestId: output })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4779,10 +4746,11 @@ describe("Machine", () => { initial: () => FlatInitial.Loading(new Loading({ requestId: "request-1" })) }).handle({ Loading: { - invoke: Machine.after("1 hour", new RequestSucceeded({ value: "timeout" })), - on: { - RequestSucceeded: ({ event }) => FlatInitial.Success(new Success({ requestId: event.value })) - } + invoke: Machine.invoke({ + id: "timeout", + after: "1 hour", + onDone: ({ target }) => target.full.Success(new Success({ requestId: "timeout" })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4812,13 +4780,11 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => Machine.logic({ initial: "pending", run: () => Effect.never }), - snapshot: ({ id, snapshot }) => new RequestProgress({ id, childState: snapshot.state }) - }), - on: { - RequestProgress: ({ event }) => - FlatInitial.Success(new Success({ requestId: `${event.id}:${event.childState}` })) - } + address: Machine.childAddress("progress-request"), + logic: Machine.logic({ initial: "pending", run: () => Effect.never }), + onSnapshot: ({ id, snapshot, target }) => + target.full.Success(new Success({ requestId: `${id}:${snapshot.state}` })) + }) }, Success: { output: ({ state }) => state.requestId @@ -4841,7 +4807,7 @@ describe("Machine", () => { }) })) - it.effect("start fails the owning machine when an invoked effect failure is not recovered", () => + it.effect("start fails the owning machine when an invoked effect defects", () => Effect.gen(function*() { const error = new InvokeError({ message: "boom" }) const machine = Machine.make({ @@ -4858,24 +4824,27 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => Machine.effect(Effect.fail(error)) + effect: Effect.die(error) }) } }) const ref = yield* Machine.start(machine, { userId: "user-1" }) - yield* ref.send(new Submit({ value: "hello" })) - - assert.strictEqual(yield* Effect.flip(ref.join), error) - const snapshot = yield* ref.snapshot + const snapshot = yield* sendAndWaitForSnapshot( + ref, + new Submit({ value: "hello" }), + (snapshot) => snapshot.status === "error" + ) assert.strictEqual(snapshot.status, "error") + if (snapshot.status !== "error") return assert.fail("expected an error snapshot") + assert.strictEqual(Cause.squash(snapshot.cause), error) assert.deepStrictEqual(snapshot.state, { path: "Loading", value: new Loading({ requestId: "request-1" }) }) })) - it.effect("start lets invoke snapshot mappers filter with undefined", () => + it.effect("start lets invoke snapshot handlers filter with undefined", () => Effect.gen(function*() { const started = yield* Deferred.make() const release = yield* Deferred.make() @@ -4893,22 +4862,19 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => - Machine.logic({ - initial: "pending", - run: ({ setState }) => - Deferred.succeed(started, void 0).pipe( - Effect.andThen(Deferred.await(release)), - Effect.andThen(setState("ready")), - Effect.andThen(Effect.never) - ) - }), - snapshot: ({ id, snapshot }) => - snapshot.state === "ready" ? new RequestProgress({ id, childState: snapshot.state }) : undefined - }), - on: { - RequestProgress: ({ event }) => FlatInitial.Success(new Success({ requestId: event.childState })) - } + address: Machine.childAddress("filtered-progress"), + logic: Machine.logic({ + initial: "pending", + run: ({ setState }) => + Deferred.succeed(started, void 0).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(setState("ready")), + Effect.andThen(Effect.never) + ) + }), + onSnapshot: ({ snapshot, target }) => + snapshot.state === "ready" ? target.full.Success(new Success({ requestId: snapshot.state })) : undefined + }) }, Success: { output: ({ state }) => state.requestId @@ -4940,7 +4906,7 @@ describe("Machine", () => { }) })) - it.effect("start allows invoked children without a snapshot mapper", () => + it.effect("start allows invoked children without a snapshot handler", () => Effect.gen(function*() { const machine = Machine.make({ states: { Idle, Loading }, @@ -4956,11 +4922,12 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => - Machine.logic({ - initial: "pending", - run: () => Effect.void - }) + address: Machine.childAddress("void-request"), + logic: Machine.logic({ + initial: "pending", + run: () => Effect.void + }), + onDone: () => undefined }) } }) @@ -5010,7 +4977,8 @@ describe("Machine", () => { Loading: { invoke: Machine.invoke({ id: "request", - src: () => childLogic + address: Machine.childAddress("stopping-request"), + logic: childLogic }), on: { Resolve: () => FlatInitial.Success(new Success({ requestId: "request-1" })), @@ -5094,18 +5062,20 @@ describe("Machine", () => { payment: { invoke: Machine.invoke({ id: "request", - src: () => makeInvokeLogic("parent", parentStarted) + address: Machine.childAddress("payment-parent"), + logic: makeInvokeLogic("parent", parentStarted) }), states: { entering: { - invoke: ({ parents, state }) => { + entry: ({ parents, state }) => { assert.deepStrictEqual(state, entering) assert.deepStrictEqual(parents, { payment }) - return Machine.invoke({ - id: "request", - src: () => makeInvokeLogic("entering", enteringStarted) - }) }, + invoke: Machine.invoke({ + id: "request", + address: Machine.childAddress("payment-entering"), + logic: makeInvokeLogic("entering", enteringStarted) + }), on: { Authorize: ({ event, target }) => target.local.authorized(new AuthorizedPayment({ code: event.code })) } @@ -5113,7 +5083,8 @@ describe("Machine", () => { authorized: { invoke: Machine.invoke({ id: "request", - src: () => makeInvokeLogic("authorized", authorizedStarted) + address: Machine.childAddress("payment-authorized"), + logic: makeInvokeLogic("authorized", authorizedStarted) }) } } @@ -5223,13 +5194,15 @@ describe("Machine", () => { fulfillment: { invoke: Machine.invoke({ id: "request", - src: () => makeInvokeLogic(parentStarted, parentStopping) + address: Machine.childAddress("fulfillment-parent"), + logic: makeInvokeLogic(parentStarted, parentStopping) }), states: { inventory: { invoke: Machine.invoke({ id: "request", - src: () => makeInvokeLogic(inventoryStarted, inventoryStopping) + address: Machine.childAddress("fulfillment-inventory"), + logic: makeInvokeLogic(inventoryStarted, inventoryStopping) }), states: { checking: { @@ -5242,7 +5215,8 @@ describe("Machine", () => { shipping: { invoke: Machine.invoke({ id: "request", - src: () => makeInvokeLogic(shippingStarted, shippingStopping) + address: Machine.childAddress("fulfillment-shipping"), + logic: makeInvokeLogic(shippingStarted, shippingStopping) }) } } diff --git a/test/machine/Resume.test.ts b/test/machine/Resume.test.ts index 4d718e0..47b86dc 100644 --- a/test/machine/Resume.test.ts +++ b/test/machine/Resume.test.ts @@ -43,20 +43,11 @@ describe("Machine.resume", () => { class RightOn extends Schema.TaggedClass("RightOn")("RightOn", {}) {} class Inactive extends Schema.TaggedClass("Inactive")("Inactive", {}) {} const starts = yield* Ref.make>([]) - const lifecycleEvents: Array = [] - const invoked = (label: string) => - Machine.invoke({ - id: label, - src: () => - Machine.logic({ - initial: () => Ref.update(starts, (labels) => [...labels, label]).pipe(Effect.as(label)), - run: () => Effect.never - }) + const restoredLogic = (label: string) => + Machine.logic({ + initial: () => Ref.update(starts, (labels) => [...labels, label]).pipe(Effect.as(label)), + run: () => Effect.never }) - const restoredInvoke = (label: string) => (context: { readonly event: unknown }) => { - lifecycleEvents.push(Machine.isInitialEvent(context.event)) - return invoked(label) - } const states = Machine.defineStates({ Root: { schema: Root, @@ -82,19 +73,53 @@ describe("Machine.resume", () => { initial: () => states.initial.Inactive(new Inactive({})) }).handle({ Root: { - invoke: restoredInvoke("root"), + invoke: Machine.invoke({ + id: "root", + address: Machine.childAddress("root"), + logic: restoredLogic("root") + }), states: { left: { - invoke: restoredInvoke("left"), - states: { On: { invoke: restoredInvoke("left-leaf") } } + invoke: Machine.invoke({ + id: "left", + address: Machine.childAddress("left"), + logic: restoredLogic("left") + }), + states: { + On: { + invoke: Machine.invoke({ + id: "left-leaf", + address: Machine.childAddress("left-leaf"), + logic: restoredLogic("left-leaf") + }) + } + } }, right: { - invoke: restoredInvoke("right"), - states: { On: { invoke: restoredInvoke("right-leaf") } } + invoke: Machine.invoke({ + id: "right", + address: Machine.childAddress("right"), + logic: restoredLogic("right") + }), + states: { + On: { + invoke: Machine.invoke({ + id: "right-leaf", + address: Machine.childAddress("right-leaf"), + logic: restoredLogic("right-leaf") + }) + } + } } } }, - Inactive: { invoke: restoredInvoke("inactive") } + Inactive: { + invoke: Machine.invoke({ + id: "inactive", + address: Machine.childAddress("inactive"), + logic: restoredLogic("inactive") + }) + } }) const snapshot = states.initial.Root(new Root({}), (root) => root @@ -105,7 +130,6 @@ describe("Machine.resume", () => { yield* Effect.forEach(Array.from({ length: 20 }), () => Effect.yieldNow, { discard: true }) assert.deepStrictEqual(yield* ref.state, snapshot) assert.deepStrictEqual(yield* Ref.get(starts), ["root", "left", "right", "left-leaf", "right-leaf"]) - assert.deepStrictEqual(lifecycleEvents, [true, true, true, true, true]) yield* ref.stop })) @@ -267,10 +291,13 @@ describe("Machine.resume", () => { initial: () => states.initial.Cancelled(new Cancelled({})) }).handle({ Waiting: { - invoke: Machine.after("1 second", new Timeout({})), + invoke: Machine.invoke({ + id: "timeout", + after: "1 second", + onDone: ({ target }) => target.full.TimedOut(new TimedOut({})) + }), on: { - Cancel: ({ target }) => target.full.Cancelled(new Cancelled({})), - Timeout: ({ target }) => target.full.TimedOut(new TimedOut({})) + Cancel: ({ target }) => target.full.Cancelled(new Cancelled({})) } }, Cancelled: {}, @@ -291,7 +318,7 @@ describe("Machine.resume", () => { yield* second.stop })) - it.effect("restarts invokeEffect once and maps its result through the normal runtime", () => + it.effect("restarts an inline Effect once and handles its result through the normal runtime", () => Effect.gen(function*() { class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} class Loaded extends Schema.TaggedClass("Loaded")("Loaded", { value: Schema.String }) {} @@ -307,12 +334,11 @@ describe("Machine.resume", () => { initial: () => states.initial.Loaded(new Loaded({ value: "initial" })) }).handle({ Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "load", effect: Ref.updateAndGet(runs, (n) => n + 1).pipe(Effect.as("fresh")), - onSuccess: (value) => new LoadedEvent({ value }) - }), - on: { LoadedEvent: ({ event, target }) => target.full.Loaded(new Loaded({ value: event.value })) } + onDone: ({ output, target }) => target.full.Loaded(new Loaded({ value: output })) + }) }, Loaded: {} }) @@ -324,7 +350,7 @@ describe("Machine.resume", () => { yield* ref.stop })) - it.effect("maps a restarted invokeEffect typed failure once", () => + it.effect("handles a restarted inline Effect typed failure once", () => Effect.gen(function*() { class Loading extends Schema.TaggedClass("Loading")("Loading", {}) {} class Failed extends Schema.TaggedClass("Failed")("Failed", { message: Schema.String }) {} @@ -343,15 +369,13 @@ describe("Machine.resume", () => { initial: () => states.initial.Failed(new Failed({ message: "initial" })) }).handle({ Loading: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "load", effect: Ref.update(runs, (n) => n + 1).pipe( Effect.andThen(Effect.fail(new LoadFailure({ message: "offline" }))) ), - onSuccess: (_value: never) => undefined, - onFailure: (error) => new FailedEvent({ message: error.message }) - }), - on: { FailedEvent: ({ event, target }) => target.full.Failed(new Failed({ message: event.message })) } + onFailure: ({ error, target }) => target.full.Failed(new Failed({ message: error.message })) + }) }, Failed: {} }) @@ -394,11 +418,10 @@ describe("Machine.resume", () => { initial: () => states.initial.ChildOutput(new ChildOutput({ value: 0 })) }).handle({ Parent: { - invoke: Machine.invokeMachine({ + invoke: Machine.invoke({ child: Child, - onDone: ({ output }) => new ChildOutput({ value: output }) - }), - on: { ChildOutput: ({ event, target }) => target.full.ChildOutput(event) } + onDone: ({ output, target }) => target.full.ChildOutput(new ChildOutput({ value: output })) + }) }, ChildOutput: {} }) diff --git a/test/machine/visualization/text.ts b/test/machine/visualization/text.ts index 41e39ef..0ba5f68 100644 --- a/test/machine/visualization/text.ts +++ b/test/machine/visualization/text.ts @@ -31,6 +31,11 @@ interface TransitionDefinition { | { readonly type: "choice" } + | { + readonly type: "invoke" + readonly id: string + readonly outcome: "done" | "failure" | "snapshot" + } readonly reenter: boolean readonly targets: | { @@ -43,10 +48,6 @@ interface TransitionDefinition { } type ActivityDefinition = - | { - readonly source: string - readonly type: "dynamic" - } | { readonly source: string readonly id: string @@ -65,8 +66,7 @@ type ActivityDefinition = readonly source: string readonly id: string readonly type: "timer" - readonly duration: string - readonly event: string + readonly duration: string | "dynamic" } | { readonly source: string @@ -125,6 +125,8 @@ const triggerLabels = (definitions: ReadonlyArray): Readon labels.push(`◇ done${definition.reenter ? " [reenter]" : ""}${targets(definition)}`) } else if (definition.trigger.type === "choice") { labels.push(`◇ choice${targets(definition)}`) + } else if (definition.trigger.type === "invoke") { + labels.push(`◇ invoke ${definition.trigger.id} ${definition.trigger.outcome}${targets(definition)}`) } } return labels @@ -132,14 +134,12 @@ const triggerLabels = (definitions: ReadonlyArray): Readon const activityLabel = (definition: ActivityDefinition): string => { switch (definition.type) { - case "dynamic": - return "◆ activity: dynamic" case "process": return `◆ process: ${definition.id}` case "effect": return `◆ effect: ${definition.id} [success: ${definition.outcomes.success}, failure: ${definition.outcomes.failure}]` case "timer": - return `◆ timer: ${definition.id} [${definition.duration}] → ${definition.event}` + return `◆ timer: ${definition.id} [${definition.duration}]` case "machine": { const identity = definition.child.machineId === null ? definition.child.id : definition.child.machineId return `◆ machine: ${definition.id} → ${identity}` diff --git a/test/testing/Probe.test.ts b/test/testing/Probe.test.ts index 61ee8f0..48218e5 100644 --- a/test/testing/Probe.test.ts +++ b/test/testing/Probe.test.ts @@ -149,9 +149,9 @@ describe("MachineTest probe", () => { Loading: { invoke: Machine.invoke({ id: "loader", - src: () => { + effect: () => { starts += 1 - return Machine.effect(Effect.never) + return Effect.never } }) } diff --git a/test/testing/Runtime.test.ts b/test/testing/Runtime.test.ts index f19372f..7c5a5c1 100644 --- a/test/testing/Runtime.test.ts +++ b/test/testing/Runtime.test.ts @@ -225,7 +225,7 @@ describe("MachineTest runtime commands", () => { yield* ref.stop })) - it.effect("advances TestClock deterministically through Machine.after", () => + it.effect("advances TestClock deterministically through an inline timer", () => Effect.gen(function*() { class Waiting extends Schema.TaggedClass("Waiting")("Waiting", {}) {} class TimedOut extends Schema.TaggedClass("TimedOut")("TimedOut", {}) {} @@ -238,8 +238,11 @@ describe("MachineTest runtime commands", () => { initial: () => states.initial.Waiting(new Waiting({})) }).handle({ Waiting: { - invoke: Machine.after("1 second", new Timeout({})), - on: { Timeout: ({ target }) => target.full.TimedOut(new TimedOut({})) } + invoke: Machine.invoke({ + id: "timeout", + after: "1 second", + onDone: ({ target }) => target.full.TimedOut(new TimedOut({})) + }) }, TimedOut: {} }) @@ -715,8 +718,11 @@ describe("MachineTest causal runtime commands", () => { initial: () => states.initial.Waiting(new Waiting({})) }).handle({ Waiting: { - invoke: Machine.after("1 second", new Timeout({})), - on: { Timeout: ({ target }) => target.full.TimedOut(new TimedOut({})) } + invoke: Machine.invoke({ + id: "timeout", + after: "1 second", + onDone: ({ target }) => target.full.TimedOut(new TimedOut({})) + }) }, TimedOut: {} }) diff --git a/test/unstable/cluster/ClusterMachine.test.ts b/test/unstable/cluster/ClusterMachine.test.ts index 44e2c16..d89236a 100644 --- a/test/unstable/cluster/ClusterMachine.test.ts +++ b/test/unstable/cluster/ClusterMachine.test.ts @@ -567,7 +567,8 @@ describe("ClusterMachine", () => { Count: { invoke: Machine.invoke({ id: "child", - src: () => Machine.effect(Effect.void) + effect: Effect.void, + onDone: () => undefined }) } }) diff --git a/test/unstable/reactivity/AtomMachine.test.ts b/test/unstable/reactivity/AtomMachine.test.ts index 70ef7b9..5124df6 100644 --- a/test/unstable/reactivity/AtomMachine.test.ts +++ b/test/unstable/reactivity/AtomMachine.test.ts @@ -100,11 +100,11 @@ describe("AtomMachine", () => { Count: { invoke: Machine.invoke({ id: "active", - src: () => - Machine.logic({ - initial: () => Ref.update(invokeStarts, (n) => n + 1).pipe(Effect.as(undefined)), - run: () => Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(invokeStopped, void 0))) - }) + address: Machine.childAddress("active"), + logic: Machine.logic({ + initial: () => Ref.update(invokeStarts, (n) => n + 1).pipe(Effect.as(undefined)), + run: () => Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(invokeStopped, void 0))) + }) }), on: { Finish: ({ event, state, target }) => target.full.Count(new Count({ value: state.value + event.by })) @@ -158,7 +158,7 @@ describe("AtomMachine", () => { } }, ValueRead: { - invoke: Machine.invokeMachine({ child: Child }), + invoke: Machine.invoke({ child: Child, onDone: () => undefined }), on: { ReadValue: () => MachineInitial.Count(new Count({ value: 0 })) } diff --git a/typetest/machine/Activities.tst.ts b/typetest/machine/Activities.tst.ts index 15532a1..55b1829 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.after("1 second", new TimedOut({}), { id: "timeout" }) + invoke: Machine.invoke({ id: "timeout", after: "1 second", onDone: () => undefined }) }, Dynamic: { - invoke: () => Machine.after("2 seconds", new TimedOut({})) + invoke: Machine.invoke({ id: "dynamic", after: () => "2 seconds" as const, onDone: () => undefined }) } }) @@ -25,7 +25,7 @@ describe("Machine activity inspection", () => { const definition = Machine.activityDefinitions(machine)[0]! expect(definition.source).type.toBe<"Loading" | "Dynamic">() - expect(definition.type).type.toBe<"process" | "effect" | "timer" | "machine" | "dynamic">() + expect(definition.type).type.toBe<"process" | "effect" | "timer" | "machine">() }) it("narrows kind-specific descriptive metadata", () => { @@ -33,10 +33,7 @@ describe("Machine activity inspection", () => { if (definition.type === "timer") { expect(definition.id).type.toBe() - expect(definition.duration).type.toBe() - expect(definition.event).type.toBe() - } else if (definition.type === "dynamic") { - expect(definition).type.not.toHaveProperty("id") + expect(definition.duration).type.toBe() } }) }) diff --git a/typetest/machine/EventConstructors.tst.ts b/typetest/machine/EventConstructors.tst.ts index 570ac58..9589714 100644 --- a/typetest/machine/EventConstructors.tst.ts +++ b/typetest/machine/EventConstructors.tst.ts @@ -71,17 +71,21 @@ describe("Machine event constructor collections", () => { .toRaiseError() }) - it("accepts internal constructions from invokeEffect and after", () => { + it("accepts internal constructions raised from invocation handlers", () => { expect( machine.handle({ Idle: { invoke: [ - Machine.invokeEffect({ + Machine.invoke({ id: "load", effect: Effect.succeed("ready"), - onSuccess: (value) => internalEvents.Loaded({ value }) + onDone: ({ output }, enqueue) => enqueue.raise(internalEvents.Loaded({ value: output })) }), - Machine.after("1 second", internalEvents.Failed()) + Machine.invoke({ + id: "timeout", + after: "1 second", + onDone: (_, enqueue) => enqueue.raise(internalEvents.Failed()) + }) ] } }) diff --git a/typetest/machine/Machine.tst.ts b/typetest/machine/Machine.tst.ts index d8c9585..19362a0 100644 --- a/typetest/machine/Machine.tst.ts +++ b/typetest/machine/Machine.tst.ts @@ -484,17 +484,14 @@ describe("Machine", () => { expect>().type.toBe() }) - it("effect infers output, errors, and requirements without an event protocol", () => { - const success = Machine.effect(Effect.as(DeferredRequirement, 1 as const)) - const failure = Machine.effect(Effect.fail("effect-failed" as const)) + it("spawn accepts reusable logic rather than one-shot Effects", () => { + const logic = Machine.logic({ + initial: undefined, + run: () => Effect.as(DeferredRequirement, 1 as const) + }) - expect(success).type.toBeAssignableTo< - Machine.Logic - >() - expect(failure).type.toBeAssignableTo< - Machine.Logic - >() - expect(Machine.effect).type.not.toBeCallableWith(() => Effect.succeed(1)) + expect(Machine.spawn).type.toBeCallableWith(logic) + expect(Machine.spawn).type.not.toBeCallableWith(Effect.succeed(1)) }) it("logic exposes public machine-scoped context types", () => { @@ -522,31 +519,185 @@ describe("Machine", () => { }) }) - it("invoke requires one-shot outputs to be parent machine events or void", () => { + it("invoke handles one-shot outputs directly in the owning state", () => { const machine = Machine.make({ states: UpStates.states, events: [SignIn], initial: () => UpStates.initial.down(new Down({})) }) - expect(machine.handle).type.toBeCallableWith({ + machine.handle({ down: { invoke: Machine.invoke({ id: "valid", - src: () => Machine.effect(Effect.succeed(new SignIn({ userId: "user-1" }))) + effect: Effect.succeed(1), + onDone: ({ output, state, target }) => { + expect(output).type.toBe() + expect(state).type.toBe() + return target.full.down(new Down({})) + } }) } }) - expect(machine.handle).type.not.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "invalid", + effect: Effect.succeed(1) + }) + }) + + it("contextually types dynamic Effect sources on direct inline objects", () => { + const machine = Machine.make({ + states: UpStates.states, + events: [SignIn], + initial: () => UpStates.initial.down(new Down({})) + }) + + machine.handle({ + down: { + invoke: { + id: "dynamic", + effect: ({ state }) => { + expect(state).type.toBe() + return Effect.succeed(state._tag) + }, + onDone: () => undefined + } + } + }) + }) + + it("infers dynamic Machine.invoke Effect channels from the source return", () => { + class LoadFailure { + readonly _tag = "LoadFailure" + } + const load = (userId: string) => Effect.fail(new LoadFailure()).pipe(Effect.as({ userId })) + const machine = Machine.make({ + states: UpStates.states, + events: [SignIn], + initial: () => UpStates.initial.down(new Down({})) + }) + + machine.handle({ down: { invoke: Machine.invoke({ - id: "invalid", - src: () => Machine.effect(Effect.succeed(1)) + id: "dynamic", + effect: ({ state }) => { + expect(state).type.toBe() + return load(state._tag) + }, + onDone: ({ output }) => { + expect(output).type.toBe<{ userId: string }>() + }, + onFailure: ({ error }) => { + expect(error).type.toBe() + } }) } }) }) + it("requires only reachable handlers for dynamic Machine.invoke Effects", () => { + class LoadFailure { + readonly _tag = "LoadFailure" + } + const machine = Machine.make({ + states: UpStates.states, + events: [SignIn], + initial: () => UpStates.initial.down(new Down({})) + }) + + machine.handle({ + down: { + invoke: [ + Machine.invoke({ + id: "success", + effect: ({ state }) => Effect.succeed(state._tag), + onDone: ({ output }) => { + expect(output).type.toBe<"Down">() + } + }), + Machine.invoke({ + id: "failure", + effect: ({ state }) => Effect.fail(new LoadFailure()).pipe(Effect.annotateLogs("state", state._tag)), + onFailure: ({ error }) => { + expect(error).type.toBe() + } + }), + Machine.invoke({ + id: "never", + effect: ({ state }) => Effect.never.pipe(Effect.annotateLogs("state", state._tag)) + }), + Machine.invoke({ + id: "requirements", + effect: ({ state }) => Effect.as(EntryRequirement, state._tag), + onDone: ({ output }) => { + expect(output).type.toBe<"Down">() + } + }) + ] + } + }) + + const requirementsHandled = machine.handle({ + down: { + invoke: Machine.invoke({ + id: "requirements-only", + effect: ({ state }) => Effect.as(EntryRequirement, state._tag), + onDone: ({ output }) => { + expect(output).type.toBe<"Down">() + } + }) + } + }) + + expect>().type.not.toBe() + expect().type.toBeAssignableTo>() + expect().type.not.toBeAssignableTo>() + }) + + it("rejects unreachable and missing handlers for dynamic Machine.invoke Effects", () => { + type Context = Machine.Machine.InvokeContext< + typeof UpStates.states, + readonly [typeof SignIn], + readonly [], + "down" + > + const success = (_: Context) => Effect.succeed("user-1") + const failure = (_: Context) => Effect.fail("unavailable" as const) + const pending = (_: Context) => Effect.never + + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "missing-done", + effect: success + }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "unreachable-failure", + effect: success, + onDone: () => undefined, + onFailure: () => undefined + }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "missing-failure", + effect: failure + }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "unreachable-done", + effect: failure, + onDone: () => undefined, + onFailure: () => undefined + }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "pending-done", + effect: pending, + onDone: () => undefined + }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "pending-failure", + effect: pending, + onFailure: () => undefined + }) + }) + it("separates public input events from the complete internal protocol", () => { const machine = Machine.make({ states: UpStates.states, @@ -603,15 +754,9 @@ describe("Machine", () => { }) }) - it("invokeEffect requires typed failure mapping and preserves mapped events", () => { + it("invoke requires only the lifecycle handlers reachable from the source type", () => { const failure = Effect.fail("unavailable" as const) const erasedFailure = failure as Effect.Effect - const invoked = Machine.invokeEffect({ - id: "sign-in", - effect: failure, - onSuccess: (userId: string) => new SignInCompleted({ userId }), - onFailure: (reason) => new SignInCompleted({ userId: reason }) - }) const machine = Machine.make({ states: UpStates.states, events: [SignIn], @@ -619,34 +764,29 @@ describe("Machine", () => { initial: () => UpStates.initial.down(new Down({})) }) - expect(Machine.invokeEffect).type.not.toBeCallableWith({ - id: "sign-in", - effect: failure, - onSuccess: (userId: string) => new SignInCompleted({ userId }) + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "missing-failure", + effect: failure }) - expect(Machine.invokeEffect).type.not.toBeCallableWith({ - id: "sign-in", + expect(Machine.invoke).type.not.toBeCallableWith({ + id: "unreachable-failure", effect: Effect.succeed("user-1"), - onSuccess: (userId: string) => new SignInCompleted({ userId }), - onFailure: () => new SignInCompleted({ userId: "unreachable" }) - }) - expect(Machine.invokeEffect).type.not.toBeCallableWith({ - id: "erased-failure", - effect: erasedFailure, - onSuccess: () => new SignInCompleted({ userId: "unreachable" }) + onDone: () => undefined, + onFailure: () => undefined }) - expect(Machine.invokeEffect).type.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ id: "erased-failure", - effect: erasedFailure, - onSuccess: () => new SignInCompleted({ userId: "unreachable" }), - onFailure: () => new SignInCompleted({ userId: "failed" }) + effect: erasedFailure }) - expect(machine.handle).type.toBeCallableWith({ + machine.handle({ down: { - invoke: invoked, - on: { - SignInCompleted: () => undefined - } + invoke: Machine.invoke({ + id: "erased-failure", + effect: erasedFailure, + onFailure: ({ error }) => { + expect(error).type.toBe() + } + }) } }) }) @@ -677,32 +817,7 @@ describe("Machine", () => { expect(Machine.retag).type.not.toBeCallableWith(unionTarget, source) }) - it("invoke factories receive typed state context and preserve child channels", () => { - const machine = Machine.make({ - states: UpStates.states, - events: [SignIn], - initial: () => UpStates.initial.down(new Down({})) - }).handle({ - down: { - invoke: ({ event, state }) => { - expect(state).type.toBe() - expect(event).type.toBe() - return Machine.invoke({ - id: "worker", - src: () => Machine.effect(Effect.as(DeferredRequirement, new SignIn({ userId: "user-1" }))) - }) - } - } - }) - const started = Machine.start(machine) - - expect>().type.toBe() - expect().type.toBeAssignableTo< - Effect.Error["join"]> - >() - }) - - it("invokeMachine composes complete machines with type-safe protocols", () => { + it("child invocation composes complete machines with type-safe protocols", () => { const ChildInput = Schema.Struct({ userId: Schema.String }) const childStates = Machine.defineStates({ done: { @@ -725,27 +840,29 @@ describe("Machine", () => { const Child = Machine.child("child", child) expect(Machine.sendTo).type.toBeCallableWith(Child, new SignIn({ userId: "child" })) expect(Machine.sendTo).type.not.toBeCallableWith(Child, new Down({})) - const invocation = Machine.invokeMachine({ - child: Child, - input: { userId: "child" }, - snapshot: ({ snapshot }) => { - expect(snapshot.state).type.toBe>() - return new SignIn({ userId: "snapshot" }) - }, - onDone: ({ output }) => { - expect(output).type.toBe() - return output - } - }) const parent = Machine.make({ states: UpStates.states, events: [SignIn], initial: () => UpStates.initial.down(new Down({})) }) - expect(parent.handle).type.toBeCallableWith({ down: { invoke: invocation } }) - expect(Machine.invokeMachine).type.not.toBeCallableWith({ - child: Child + parent.handle({ + down: { + invoke: Machine.invoke({ + child: Child, + input: { userId: "child" }, + onSnapshot: ({ snapshot }) => { + expect(snapshot.state).type.toBe>() + }, + onDone: ({ output, state }) => { + expect(output).type.toBe() + expect(state).type.toBe() + } + }) + } + }) + expect(parent.handle).type.not.toBeCallableWith({ + down: { invoke: { child: Child, onDone: () => undefined } } }) const incompatibleEmits = Machine.make({ @@ -761,50 +878,55 @@ describe("Machine", () => { }) expect(parent.handle).type.not.toBeCallableWith({ down: { - invoke: Machine.invokeMachine({ + invoke: { child: Machine.child("incompatible", incompatibleEmits), - input: { userId: "child" } - }) + input: { userId: "child" }, + onDone: () => undefined + } } }) expect(parent.handle).type.not.toBeCallableWith({ down: { - invoke: Machine.invokeMachine({ + invoke: { child: Child, input: { userId: "child" }, - snapshot: () => new Down({}) - }) + onSnapshot: () => new Down({}), + onDone: () => undefined + } } }) expect(parent.handle).type.not.toBeCallableWith({ down: { - invoke: Machine.invokeMachine({ + invoke: { child: Child, input: { userId: "child" }, onDone: () => new Down({}) - }) + } } }) }) - it("rejects invalid invoke outputs nested alongside valid sibling handlers", () => { + it("types nested invocation output handlers against their owning state", () => { const machine = Machine.make({ states: UpStates.states, events: [SignIn], initial: () => UpStates.initial.down(new Down({})) }) - expect(machine.handle).type.not.toBeCallableWith({ + machine.handle({ up: { states: { auth: { states: { signedOut: { - invoke: () => - Machine.invoke({ - id: "invalid", - src: () => Machine.effect(Effect.succeed(Option.some(1))) - }) + invoke: Machine.invoke({ + id: "nested", + effect: Effect.succeed(Option.some(1)), + onDone: ({ output, state }) => { + expect(output).type.toBe>() + expect(state).type.toBe() + } + }) } } }, @@ -832,35 +954,6 @@ describe("Machine", () => { Machine.transition(0, (_state: number, _event: Down) => Effect.succeed(1)), { id: worker } ) - expect(Machine.invoke).type.toBeCallableWith({ - id: "worker-lifecycle", - address: worker, - src: () => child - }) - expect(Machine.invoke).type.toBeCallableWith({ - id: "inert-lifecycle", - address: inert, - src: () => Machine.effect(Effect.never) - }) - expect(Machine.invoke).type.not.toBeCallableWith({ - id: "worker-lifecycle", - address: Machine.childAddress("worker"), - src: () => child - }) - expect(Machine.invoke).type.not.toBeCallableWith({ - id: worker, - src: () => child - }) - expect(Machine.invokeEffect).type.not.toBeCallableWith({ - id: worker, - effect: Effect.succeed(new SignIn({ userId: "user-1" })), - onSuccess: (event: SignIn) => event - }) - expect(Machine.after).type.not.toBeCallableWith( - "1 second", - new SignIn({ userId: "user-1" }), - { id: worker } - ) }) it("keeps child startup failures out of successfully spawned refs", () => { @@ -910,15 +1003,8 @@ describe("Machine", () => { | Machine.MachineSchemaDecodeError | Machine.StoppedError >() - const invocation = Machine.invokeMachine({ - child: Child, - snapshot: () => undefined - }) - expect>().type.toBe< - | Machine.InfiniteTransitionError - | Machine.MachineSchemaDecodeError - | Machine.StoppedError - >() + const invocation = Machine.invoke({ child: Child, onDone: () => undefined }) + expect>().type.toBe() }) it("plan and getters require snapshots", () => { @@ -1220,7 +1306,7 @@ describe("Machine", () => { }) expect(Machine.planInitial).type.not.toBeCallableWith(machine) expect(Machine.start).type.not.toBeCallableWith(machine) - expect(Machine.invokeMachine).type.not.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ child: Machine.child("incomplete", machine), onDone: () => undefined }) diff --git a/typetest/machine/Readiness.tst.ts b/typetest/machine/Readiness.tst.ts index 0da8213..22f9465 100644 --- a/typetest/machine/Readiness.tst.ts +++ b/typetest/machine/Readiness.tst.ts @@ -73,7 +73,7 @@ describe("executable machine readiness", () => { expect(Machine.plan).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(choiceIncomplete) expect(Machine.resume).type.not.toBeCallableWith(choiceIncomplete, choiceSnapshot) - expect(Machine.invokeMachine).type.not.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ child: Machine.child("choice", choiceIncomplete) }) expect(MachineTest.run).type.not.toBeCallableWith(choiceIncomplete, { events: [] }) @@ -89,7 +89,7 @@ describe("executable machine readiness", () => { expect(Machine.plan).type.not.toBeCallableWith(historyIncomplete, historySnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(historyIncomplete) expect(Machine.resume).type.not.toBeCallableWith(historyIncomplete, historySnapshot) - expect(Machine.invokeMachine).type.not.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ child: Machine.child("history", historyIncomplete) }) expect(MachineTest.run).type.not.toBeCallableWith(historyIncomplete, { events: [] }) @@ -105,7 +105,7 @@ describe("executable machine readiness", () => { expect(Machine.plan).type.not.toBeCallableWith(outputIncomplete, outputSnapshot, new Tick({})) expect(Machine.start).type.not.toBeCallableWith(outputIncomplete) expect(Machine.resume).type.not.toBeCallableWith(outputIncomplete, outputSnapshot) - expect(Machine.invokeMachine).type.not.toBeCallableWith({ + expect(Machine.invoke).type.not.toBeCallableWith({ child: Machine.child("output", outputIncomplete), onDone: () => undefined }) @@ -169,7 +169,7 @@ describe("executable machine readiness", () => { const planned = Machine.plan(complete, completeSnapshot, new Tick({})) const started = Machine.start(complete) const resumed = Machine.resume(complete, completeSnapshot) - const invocation = Machine.invokeMachine({ + const invocation = Machine.invoke({ child: Machine.child("complete", complete), onDone: () => undefined }) @@ -199,7 +199,7 @@ describe("executable machine readiness", () => { expect["send"]>().type.toBe< (event: Machine.Machine.EventInput) => Effect.Effect >() - expect(invocation).type.toBeAssignableTo() + expect(invocation).type.toBeAssignableTo>() expect>().type.toBe>() expect>().type.toBe< readonly [ diff --git a/typetest/machine/StructuralStates.tst.ts b/typetest/machine/StructuralStates.tst.ts index 1ab5ebd..3a98a95 100644 --- a/typetest/machine/StructuralStates.tst.ts +++ b/typetest/machine/StructuralStates.tst.ts @@ -138,12 +138,6 @@ describe("structural active state types", () => { entry: ({ state }) => { expect(state).type.toBe() }, - invoke: ({ parent, parents, state }) => { - expect(state).type.toBe() - expect(parent).type.toBe() - expect(parents).type.toBe<{}>() - return [] - }, on: { Select: ({ parent, parents, state, target }) => { expect(state).type.toBe() diff --git a/typetest/testing/MachineTest.tst.ts b/typetest/testing/MachineTest.tst.ts index 001e6cd..4ccf762 100644 --- a/typetest/testing/MachineTest.tst.ts +++ b/typetest/testing/MachineTest.tst.ts @@ -100,12 +100,12 @@ describe("MachineTest", () => { initial: () => States.initial.idle(new Idle({})) }).handle({ idle: { - invoke: Machine.invokeEffect({ + invoke: Machine.invoke({ id: "service-backed-invoke", effect: Effect.gen(function*() { yield* InvokeRequirement }), - onSuccess: () => undefined + onDone: () => undefined }) } }) diff --git a/typetest/unstable/reactivity/AtomMachine.tst.ts b/typetest/unstable/reactivity/AtomMachine.tst.ts index d8beeec..e3f0290 100644 --- a/typetest/unstable/reactivity/AtomMachine.tst.ts +++ b/typetest/unstable/reactivity/AtomMachine.tst.ts @@ -125,7 +125,7 @@ describe("AtomMachine", () => { initial: () => States.initial.Idle(new Idle({})) }).handle({ Idle: { - invoke: Machine.invokeMachine({ child: Child }) + invoke: Machine.invoke({ child: Child }) } }) const child = AtomMachine.make(parentMachine).child(Child)