From be029cc3850a6d3192420bf80990c7d6e6d55c61 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 19 Aug 2026 12:36:06 +0200 Subject: [PATCH] Add required and optional parent protocols --- .changeset/bright-parents-require.md | 7 + README.md | 30 +-- docs/agent-guide.md | 56 ++--- examples/pokemon/src/machines/replace.ts | 6 +- examples/pokemon/src/machines/selection.ts | 6 +- scripts/fixtures/consumer/deep-bound.ts | 6 +- scripts/invoke-autocomplete.test.mjs | 41 +++- src/Machine.ts | 218 +++++++++++++----- src/internal/machine/atom.ts | 6 +- src/internal/machine/cluster.ts | 23 +- src/internal/machine/executionPlan.ts | 10 +- src/internal/machine/machine.ts | 55 +++-- src/internal/machine/planner.ts | 40 ++-- src/testing/MachineTest.ts | 10 +- src/unstable/cluster/ClusterMachine.ts | 12 +- src/unstable/reactivity/AtomMachine.ts | 12 +- .../machine/strategyDifferential.test.ts | 57 ++++- test/machine/LiveInspection.test.ts | 4 +- test/machine/MachineReferences.test.ts | 22 +- test/machine/RuntimeDifferential.test.ts | 2 - typetest/machine/MachineReferences.tst.ts | 78 +++++-- 21 files changed, 514 insertions(+), 187 deletions(-) create mode 100644 .changeset/bright-parents-require.md diff --git a/.changeset/bright-parents-require.md b/.changeset/bright-parents-require.md new file mode 100644 index 0000000..bad201c --- /dev/null +++ b/.changeset/bright-parents-require.md @@ -0,0 +1,7 @@ +--- +"@typeonce/effect-machine": minor +--- + +Make owning-machine requirements explicit and statically safe. Declare `parent: Machine.parent(ParentEvents)` for a child-only machine; its behavior receives a non-optional `parent`, compatible owners are checked when the child is invoked, and independent root APIs reject the machine. + +Replace `parentEvents: ParentEvents` with `parent: Machine.optionalParent(ParentEvents)` when the same machine must remain valid as either a root or a child. Optional declarations retain the previous `parent | undefined` behavior. Machines without a parent declaration no longer expose `parent` in schema-first behavior contexts. diff --git a/README.md b/README.md index b58e889..80e7966 100644 --- a/README.md +++ b/README.md @@ -308,8 +308,8 @@ Invalid event and emission constructions fail the machine with a typed ### Send explicitly between machines `raise` targets the current machine in the same macrostep. `sendTo` targets a -machine mailbox and is processed later. A child declares the subset of parent -inputs it may send with `parentEvents`: +machine mailbox and is processed later. A machine that requires an owner +declares the subset of parent inputs it may send with `Machine.parent`: ```ts const ParentEvents = Machine.events(ChildFinished) @@ -317,16 +317,14 @@ const ParentEvents = Machine.events(ChildFinished) const child = Machine.make({ states: ChildStates.states, events: ChildEvents, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), initial: (to) => to.Working().resolve(({ target }) => target.from()) }).handle({ Working: { on: { Finish: (to) => to.full.Done().resolve(({ parent, target }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - } + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) return target.from() }) } @@ -338,10 +336,16 @@ const Child = Machine.child("worker", child) const ParentInputs = Machine.events(Start, ParentEvents) ``` -The same child remains isolated and may be started as a root, where `parent` is -`undefined`. When `Child` is invoked, the parent definition must accept every -event in `parentEvents`; otherwise `.handle(...)` is a compile-time error. +`parent` is statically present in every child callback, and root APIs such as +`Machine.start`, `Machine.planInitial`, Atom machines, and Cluster machines +reject this machine. When `Child` is invoked, the parent definition must accept +every declared parent event; otherwise `.handle(...)` is a compile-time error. Inside the child, the parent target accepts only those declared events. + +Use `parent: Machine.optionalParent(ParentEvents)` when the same machine is +intentionally valid both as a root and as a child. In that case `parent` is +`MachineTarget<...> | undefined` and must be narrowed before sending. When no +parent declaration is present, callbacks do not expose a `parent` property. `emit` never sends to the parent: it only publishes on the emitting machine's `emissions` stream. @@ -490,7 +494,7 @@ to enqueue commands. A block resolver may omit its return because it is contextually typed to return `undefined`. Inside `.handle(...)`, `Machine.invoke(...)` receives the owning machine's -public input and `parentEvents` protocols contextually. Its source and lifecycle +public input and declared parent protocol contextually. Its source and lifecycle callbacks can send through `self` and `parent` while retaining the invoked Effect's output and error inference: @@ -498,7 +502,7 @@ Effect's output and error inference: const machine = Machine.make({ events: Commands, internalEvents: InternalEvents, - parentEvents: ParentEvents + parent: Machine.parent(ParentEvents) // ... }).handle({ Saving: { @@ -508,9 +512,7 @@ const machine = Machine.make({ onDone: (to) => to.none.resolve(({ parent, self }, enqueue) => { enqueue.sendTo(self, Commands.Save()) - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - } + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) }), onFailure: (to) => to.none }) diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 2c2812e..1c6376f 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -38,8 +38,9 @@ declared: 1. Domain schemas used by state, and by event fields when they are shared. 2. `Machine.states`, using a tagged state union and `.cases` when state schemas need to be reused. -3. `Machine.events`, `Machine.internalEvents`, `Machine.emittedEvents`, and - `parentEvents`; pass `Schema.TaggedUnion({...})` or tagged classes directly. +3. `Machine.events`, `Machine.internalEvents`, `Machine.emittedEvents`, and any + protocol passed to `Machine.parent` or `Machine.optionalParent`; pass + `Schema.TaggedUnion({...})` or tagged classes directly. 4. `Machine.make({...}).handle({...})`. 5. Child descriptors, then runtime, Atom, or Cluster adapters. @@ -109,8 +110,9 @@ the deferred constructors preserve that identity after decoding. lookup. Independently constructed descriptors are equivalent only when both their id and machine identity match. - `events` is the public machine-input protocol. `internalEvents` contains - machine-local raised events. `parentEvents` describes the public events a - child may send to its owner. `emittedEvents` describes outward ephemeral + machine-local raised events. `parent: Machine.parent(events)` requires an + owner, while `parent: Machine.optionalParent(events)` permits a root and + exposes an optional owner. `emittedEvents` describes outward ephemeral notifications and is never delivered implicitly to a parent. - Event tags in `events` and `internalEvents` must be disjoint. - Event tags must also be unique within each protocol list. @@ -127,9 +129,9 @@ its extra control is required: externally produced values, `after` for a timer, `logic` for reusable process logic, and `child` for a complete child statechart. `Machine.invoke({...})` preserves owner state and source channels - across sibling lifecycle handlers. Inside `.handle(...)`, `self` and `parent` - use the owning definition's exact public input and `parentEvents` protocols; - no intermediate definition method is required. + across sibling lifecycle handlers. Inside `.handle(...)`, `self` and any + declared `parent` use the owning definition's exact protocols; no intermediate + definition method is required. - Use `Machine.child(id, machine)` for a complete statechart descriptor and `Machine.childAddress(id)` for a low-level process address. A logic invocation is addressable only when `Machine.invoke` receives that @@ -574,8 +576,9 @@ only schema-backed paths; use `matches` or `getSnapshot` for any active path. `context.containingState` is the immediate typed state value (`undefined` at a root or when that state is schema-less). `context.ancestors` contains only valued structural ancestors. This is separate from `context.parent`, which is -the owning machine target or `undefined` for a root machine. Use full state paths -when another ancestor value is needed: +present only when declared by the machine. `Machine.parent` makes it a required +owning-machine target; `Machine.optionalParent` makes it a target or +`undefined`. Use full state paths when another ancestor value is needed: ```ts ancestors["Route.Ready"] @@ -798,16 +801,14 @@ export const ParentEvents = Machine.events(ChildFinished) const child = Machine.make({ events: ChildEvents, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), // ... }).handle({ Working: { on: { Finish: (to) => to.none.resolve(({ parent }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ChildFinished()) - } + enqueue.sendTo(parent, ParentEvents.ChildFinished()) }) } } @@ -819,12 +820,14 @@ const parent = Machine.make({ }) ``` -Invoking the child under a parent that lacks any required `parentEvents` case -is a type error. Within child handlers, `parent` accepts only that protocol. -The same child may run as a root, where `parent` is `undefined`. `self` accepts -the machine's public inputs. Both are minimal `MachineTarget` values, -provided by the shared `MachineReferences` handler -context. Neither machine target is a structural state value; use +Invoking the child under a parent that lacks any required parent event is a +type error. Within child handlers, `parent` accepts only that protocol and is +not optional. Root APIs reject the machine. Use +`Machine.optionalParent(ParentEvents)` instead when the same definition must +also run as a root; then `parent` is optional. With no declaration, callbacks +have no `parent` property. `self` accepts the machine's public inputs. Both +targets are minimal `MachineTarget` values. Neither machine target is a +structural state value; use `containingState` and `ancestors` for statechart ancestry. Atom-backed machines retain the same transient semantics. Use @@ -996,14 +999,14 @@ invoke: Machine.invoke({ ``` Inside `.handle(...)`, the constructor receives the owning machine's public -input and `parentEvents` protocols contextually. Sources and lifecycle handlers +input and declared parent protocol contextually. Sources and lifecycle handlers can send through `self` and `parent` without naming the definition: ```ts const machine = Machine.make({ events: Commands, internalEvents: InternalEvents, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), // ... }).handle({ Saving: { @@ -1013,9 +1016,7 @@ const machine = Machine.make({ onDone: (to) => to.none.resolve(({ parent, self }, enqueue) => { enqueue.sendTo(self, Commands.Save()) - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) - } + enqueue.sendTo(parent, ParentEvents.ChildFinished({ id: "job-1" })) }), onFailure: (to) => to.none }) @@ -1392,13 +1393,16 @@ the parent's public events: ```ts export const ChildParentEvents = Machine.events(ChildFinished) -// child -parentEvents: ChildParentEvents +// child-only machine +parent: Machine.parent(ChildParentEvents) // parent events: Machine.events(Submit, ChildParentEvents) ``` +Use `Machine.optionalParent(ChildParentEvents)` only when the child is also a +valid independent root and narrow `parent` before sending. + ### An internal event is rejected by `send` This is intentional. Public input boundaries accept only schemas declared in diff --git a/examples/pokemon/src/machines/replace.ts b/examples/pokemon/src/machines/replace.ts index 3745caa..c6d0db9 100644 --- a/examples/pokemon/src/machines/replace.ts +++ b/examples/pokemon/src/machines/replace.ts @@ -35,7 +35,7 @@ export const ReplaceMachine = Machine.make({ states: ReplaceStates.states, events: ReplaceEvents, internalEvents: ReplaceInternalEvents, - parentEvents: TeamEvents, + parent: Machine.parent(TeamEvents), initial: (to) => to.Idle().resolve(({ target }) => target.from()) }).handle({ Idle: { @@ -56,9 +56,7 @@ export const ReplaceMachine = Machine.make({ on: { Replaced: (to) => to.full.Idle().resolve(({ event, parent, state, target }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, TeamEvents.ReplaceInTeam({ id: state.id, pokemon: event.pokemon })) - } + enqueue.sendTo(parent, TeamEvents.ReplaceInTeam({ id: state.id, pokemon: event.pokemon })) return target.from() }) } diff --git a/examples/pokemon/src/machines/selection.ts b/examples/pokemon/src/machines/selection.ts index dba6656..3bd73b9 100644 --- a/examples/pokemon/src/machines/selection.ts +++ b/examples/pokemon/src/machines/selection.ts @@ -72,7 +72,7 @@ export const SelectionEvents = Machine.events(SelectPokemon, UpdateSearchText, S export const SelectionMachine = Machine.make({ states: SelectionStates.states, events: SelectionEvents, - parentEvents: TeamEvents, + parent: Machine.parent(TeamEvents), initial: (to) => to.form.initial.resolve(({ target }) => target.from((form) => @@ -97,9 +97,7 @@ export const SelectionMachine = Machine.make({ on: { ReplacePokemon: (to) => to.full.form().resolve(({ event, parent, state, target }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, TeamEvents.ReplaceInTeam({ id: event.id, pokemon: state.pokemon })) - } + enqueue.sendTo(parent, TeamEvents.ReplaceInTeam({ id: event.id, pokemon: state.pokemon })) return target.from((form) => form .search.from({ searchText: "" }, (search) => search.NoPokemon.from()) diff --git a/scripts/fixtures/consumer/deep-bound.ts b/scripts/fixtures/consumer/deep-bound.ts index e8ab01e..d673f09 100644 --- a/scripts/fixtures/consumer/deep-bound.ts +++ b/scripts/fixtures/consumer/deep-bound.ts @@ -47,15 +47,13 @@ const ChildParentEvents = Machine.events(Internal.cases.ChildNotice) const childMachine = Machine.make({ states: ChildStates.states, events: Machine.events(), - parentEvents: ChildParentEvents, + parent: Machine.parent(ChildParentEvents), input: Schema.Struct({ value: Schema.String }), initial: (to) => to.Done().resolve(({ input, target }) => target(ChildState.cases.Done.make({ value: input.value }))) }).handle({ Done: { entry: ({ parent, state }, enqueue) => { - if (parent !== undefined) { - enqueue.sendTo(parent, ChildParentEvents.ChildNotice({ value: state.value })) - } + enqueue.sendTo(parent, ChildParentEvents.ChildNotice({ value: state.value })) }, output: ({ state }) => state.value } diff --git a/scripts/invoke-autocomplete.test.mjs b/scripts/invoke-autocomplete.test.mjs index 0fd0ee3..3c57714 100644 --- a/scripts/invoke-autocomplete.test.mjs +++ b/scripts/invoke-autocomplete.test.mjs @@ -37,6 +37,42 @@ definition.handle({ Failed: {} }) +const requiredParentDefinition = Machine.make({ + states: States.states, + events: Machine.events(), + parent: Machine.parent(Machine.events()), + initial: (to) => to.Loading() +}) + +requiredParentDefinition.handle({ + Loading: { + invoke: Machine.invoke({ + id: "required-parent", + effect: ({ /*required-parent-context*/ ...context }) => Effect.never + }) + }, + Done: {}, + Failed: {} +}) + +const optionalParentDefinition = Machine.make({ + states: States.states, + events: Machine.events(), + parent: Machine.optionalParent(Machine.events()), + initial: (to) => to.Loading() +}) + +optionalParentDefinition.handle({ + Loading: { + invoke: Machine.invoke({ + id: "optional-parent", + effect: ({ /*optional-parent-context*/ ...context }) => Effect.never + }) + }, + Done: {}, + Failed: {} +}) + definition.handle({ Loading: { invoke: Machine.invoke({ @@ -155,7 +191,10 @@ test("contextually completes Effect invocation factories while authoring", () => assert.equal(sourceContext.has("event"), true) assert.equal(sourceContext.has("snapshot"), false) assert.equal(sourceContext.has("self"), true) - assert.equal(sourceContext.has("parent"), true) + assert.equal(sourceContext.has("parent"), false) + + assert.equal(completions("required-parent-context").has("parent"), true) + assert.equal(completions("optional-parent-context").has("parent"), true) const done = completions("done-context") assert.equal(done.has("output"), true) diff --git a/src/Machine.ts b/src/Machine.ts index 29ec6fa..660611d 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -53,6 +53,9 @@ declare const MachineTypeId: unique symbol declare const EventConstructionTypeId: unique symbol declare const EmittedEventConstructionTypeId: unique symbol declare const EventProtocolTypeId: unique symbol +declare const ParentModeTypeId: unique symbol + +const ParentTypeId = "~effect/Machine/Parent" const ChildMachineLogicTypeId: typeof internal.ChildMachineLogicTypeId = internal.ChildMachineLogicTypeId @@ -184,16 +187,16 @@ export interface Machine< readonly emittedEvents: Machine.EventProtocol<"emitted", Emits> /** - * Public input events accepted by an owning machine when this machine is - * running as a child. + * Declared owning-machine protocol, or `undefined` when this machine does + * not communicate with its owner. * - * The protocol types the optional `parent` machine target exposed to - * handlers and is checked against a concrete parent's public events at - * composition boundaries. + * Required parents make the machine child-only and expose a non-optional + * `parent` target to handlers. Optional parents keep root execution valid + * and expose `parent` as possibly absent. * - * @since 0.10.0 + * @since 0.17.0 */ - readonly parentEvents: Machine.EventProtocol<"public", ParentEvents> + readonly parent: Machine.ParentDeclarationOf /** * Optional schema used to decode the machine input before initialization. @@ -462,22 +465,61 @@ export interface MachineTarget { readonly send: (event: Event) => Effect.Effect } +/** + * Availability declared for an owning-machine target. + * + * @category models + * @since 0.17.0 + */ +export type ParentMode = "required" | "optional" + +/** + * Owning-machine protocol declared by {@link parent} or + * {@link optionalParent}. + * + * @category models + * @since 0.17.0 + */ +export interface Parent> { + readonly [ParentTypeId]: typeof ParentTypeId + readonly mode: Mode + readonly events: Machine.EventProtocol<"public", Events> +} + +/** + * Namespace containing type-level members associated with {@link Parent}. + * + * @category models + * @since 0.17.0 + */ +export declare namespace Parent { + /** Any owning-machine protocol declaration. */ + export type Any = Parent> +} + /** * Machine targets available while evaluating machine behavior. * * @category models * @since 0.12.0 */ -export interface MachineReferences< +export type MachineReferences< InputEvents extends ReadonlyArray, ParentEvents extends ReadonlyArray -> { - /** Target for the current machine. Sending queues a later mailbox event. */ - readonly self: MachineTarget> - - /** Target for the owning machine, or `undefined` when running as a root. */ - readonly parent: MachineTarget> | undefined -} +> = + & { + /** Target for the current machine. Sending queues a later mailbox event. */ + readonly self: MachineTarget> + } + & (Machine.ParentModeOf extends "required" ? { + /** Required target for the machine that owns this child. */ + readonly parent: MachineTarget> + } : + Machine.ParentModeOf extends "optional" ? { + /** Target for the owning machine, or `undefined` when running as a root. */ + readonly parent: MachineTarget> | undefined + } : + {}) /** * Synchronous commands available while a machine transition is being @@ -2485,6 +2527,35 @@ export declare namespace Machine { readonly parentEvents: ParentEvents } + /** @internal Carries parent availability without widening the event tuple. */ + export type ParentEventSchemas< + Mode extends ParentMode, + Events extends ReadonlyArray + > = Events & { readonly [ParentModeTypeId]: Mode } + + /** Extracts the parent availability encoded in a machine event tuple. */ + export type ParentModeOf> = Events extends + { readonly [ParentModeTypeId]: infer Mode extends ParentMode } ? Mode + : Events extends readonly [] ? "none" + : "optional" + + /** Extracts the event schemas declared by an owning-machine protocol. */ + export type ParentEventsOf = Declaration extends Parent< + infer Mode, + infer Events + > ? ParentEventSchemas + : readonly [] + + /** Extracts the owning-machine declaration carried by a machine. */ + export type ParentDeclarationOf> = ParentModeOf extends + infer Mode ? Mode extends ParentMode ? Parent : undefined + : never + + /** Constraint applied to APIs that create an independent root runtime. */ + export type RootCompatible> = ParentModeOf extends "required" + ? never + : unknown + /** * Any schema-first machine. * @@ -2505,7 +2576,7 @@ export declare namespace Machine { readonly events: EventProtocol.Any<"public"> readonly internalEvents: EventProtocol.Any<"internal"> readonly emittedEvents: EventProtocol.Any<"emitted"> - readonly parentEvents: EventProtocol.Any<"public"> + readonly parent: Parent.Any | undefined readonly input: Schema.Top | undefined readonly id: string | undefined /** @internal */ @@ -2630,6 +2701,9 @@ export declare namespace Machine { /** Extracts the public input protocol required from an owning machine. */ export type ParentEvents = M[typeof MachineTypeId]["parentEvents"] + /** Extracts whether a machine requires or optionally accepts an owner. */ + export type ParentAvailability = ParentModeOf + /** Extracts an internal event schema tuple from the complete and public protocols. */ export type InternalEventSchemas< Events extends ReadonlyArray, @@ -4577,7 +4651,7 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface HandlerContext< + export type HandlerContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -4587,7 +4661,7 @@ export declare namespace Machine { R, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly state: StateByIdentifier readonly containingState: ParentStateValue readonly ancestors: ParentStateValues @@ -4610,14 +4684,14 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface StateActionContext< + export type StateActionContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly state: StateByIdentifier readonly containingState: ParentStateValue readonly ancestors: ParentStateValues @@ -4630,14 +4704,14 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface InvokeContext< + export type InvokeContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly state: StateByIdentifier readonly containingState: ParentStateValue readonly ancestors: ParentStateValues @@ -4650,7 +4724,7 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface InvokeSnapshotContext< + export type InvokeSnapshotContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -4660,7 +4734,7 @@ export declare namespace Machine { Output, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly id: string readonly state: StateByIdentifier readonly containingState: ParentStateValue @@ -4675,7 +4749,7 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface InvokeDoneContext< + export type InvokeDoneContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -4683,7 +4757,7 @@ export declare namespace Machine { Output, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly id: string readonly state: StateByIdentifier readonly containingState: ParentStateValue @@ -4694,7 +4768,7 @@ export declare namespace Machine { } /** Context passed to a Stream invocation's element transition. */ - export interface InvokeElementContext< + export type InvokeElementContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -4702,7 +4776,7 @@ export declare namespace Machine { Element, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly id: string readonly state: StateByIdentifier readonly containingState: ParentStateValue @@ -4713,7 +4787,7 @@ export declare namespace Machine { } /** Context passed to an invocation typed-failure transition. */ - export interface InvokeFailureContext< + export type InvokeFailureContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, @@ -4721,7 +4795,7 @@ export declare namespace Machine { Error, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly id: string readonly state: StateByIdentifier readonly containingState: ParentStateValue @@ -4737,14 +4811,14 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface AlwaysContext< + export type AlwaysContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly state: StateByIdentifier readonly containingState: ParentStateValue readonly ancestors: ParentStateValues @@ -4768,14 +4842,14 @@ export declare namespace Machine { * @category models * @since 0.4.0 */ - export interface DoneContext< + export type DoneContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, StateId extends StateIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly state: StateByIdentifier readonly containingState: ParentStateValue readonly ancestors: ParentStateValues @@ -4795,14 +4869,14 @@ export declare namespace Machine { } /** Context passed to a transient choice resolver. There is no `state` value. */ - export interface ChoiceContext< + export type ChoiceContext< States extends StateSchemas, Events extends ReadonlyArray, Emits extends ReadonlyArray, ChoiceId extends ChoiceIdentifier, InputEvents extends ReadonlyArray = Events, ParentEvents extends ReadonlyArray = readonly [] - > extends MachineReferences { + > = MachineReferences & { readonly containingState: StateByIdentifier< States, Extract, StateIdentifier> @@ -7591,7 +7665,7 @@ type MakeConfig< InitialE, InitialR, InternalEvents extends ReadonlyArray, - ParentEvents extends ReadonlyArray + ParentDeclaration extends Parent.Any | undefined > = { readonly id?: string readonly states: States & DefineStateTreeInput> @@ -7605,7 +7679,7 @@ type MakeConfig< NoInfer > readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> - readonly parentEvents?: Machine.EventProtocol<"public", ParentEvents> + readonly parent?: ParentDeclaration readonly input?: Input readonly initial: unknown } @@ -7618,7 +7692,7 @@ type MakeResult< InitialE, InitialR, InternalEvents extends ReadonlyArray, - ParentEvents extends ReadonlyArray + ParentDeclaration extends Parent.Any | undefined > = Definition< States, readonly [...InputEvents, ...InternalEvents], @@ -7629,7 +7703,7 @@ type MakeResult< Machine.TerminalOutput, Emits, InputEvents, - ParentEvents + Machine.ParentEventsOf > interface Make { @@ -7641,16 +7715,16 @@ interface Make { InitialE = never, InitialR = never, const InternalEvents extends ReadonlyArray = readonly [], - const ParentEvents extends ReadonlyArray = readonly [] + const ParentDeclaration extends Parent.Any | undefined = undefined >( config: & Omit< - MakeConfig, + MakeConfig, "initial" > & { readonly initial: Machine.InitialBuilderInput }, ..._validation: ValidateDefinedStates> - ): MakeResult + ): MakeResult < const States extends Machine.StateSchemas, const InputEvents extends ReadonlyArray, @@ -7659,10 +7733,13 @@ interface Make { InitialE = never, InitialR = never, const InternalEvents extends ReadonlyArray = readonly [], - const ParentEvents extends ReadonlyArray = readonly [] + const ParentDeclaration extends Parent.Any | undefined = undefined >( config: - & Omit, "states"> + & Omit< + MakeConfig, + "states" + > & { readonly states: InvalidDefinedStateTreeInput } ): never } @@ -7685,9 +7762,10 @@ interface Make { * * `Machine.events` defines the public input protocol. `Machine.internalEvents` * adds raised events and other machine-local deliveries. - * `Machine.emittedEvents` defines outward ephemeral notifications, while - * `parentEvents` declares the inputs a child may explicitly send to its owning - * machine. All descriptors expose deferred constructors while retaining their + * `Machine.emittedEvents` defines outward ephemeral notifications. The + * `parent` configuration accepts `Machine.parent(events)` for a required owner + * or `Machine.optionalParent(events)` for a root-capable machine. All + * descriptors expose deferred constructors while retaining their * schemas opaquely for runtime validation. Public and internal tags must be * disjoint. * @@ -7773,6 +7851,33 @@ export const events: { ): Machine.EventProtocol<"public", Machine.EventProtocolInputSchemasOf<"public", Inputs>> } = internal.events as any +/** + * Requires the machine to run as an owned child whose parent accepts the + * supplied public event protocol. + * + * Required-parent machines expose a non-optional `parent` target in behavior + * contexts and are rejected by root execution APIs. + * + * @category constructors + * @since 0.17.0 + */ +export const parent: >( + events: Machine.EventProtocol<"public", Events> +) => Parent<"required", Events> = internal.parent + +/** + * Declares public events a machine may send to its owner while preserving the + * ability to run that machine as a root. + * + * Optional-parent machines expose `parent` as a possibly absent target. + * + * @category constructors + * @since 0.17.0 + */ +export const optionalParent: >( + events: Machine.EventProtocol<"public", Events> +) => Parent<"optional", Events> = internal.optionalParent + /** * Defines an internal event protocol and returns deferred constructors for * every statically finite configured event tag. @@ -8235,7 +8340,7 @@ type ValidateTransitionBranchRecord = [keyof Branches] extends [never] * `address` must not be repeated. * * Inside `handle(...)`, the owning definition contextually supplies its public - * input and `parentEvents` protocols. Invocation sources and lifecycle handlers + * input and declared parent protocols. Invocation sources and lifecycle handlers * can therefore send through `self` and `parent` without naming the definition. * The standard `Machine.invoke(...)` constructor preserves these contexts * directly; no intermediate definition method is required. @@ -8649,7 +8754,8 @@ export const planInitial: < InputEvents, ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.RootCompatible, ...args: [...Machine.InputArgs] ) => Effect.Effect< & { @@ -8918,7 +9024,8 @@ export const plan: < InputEvents, ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.RootCompatible, state: Machine.Snapshot, event: Machine.EventInputOf ) => Effect.Effect< @@ -9180,7 +9287,8 @@ export const prepare: < InputEvents, ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.RootCompatible, ...args: [...Machine.InputArgs] ) => Effect.Effect< Prepared< @@ -9288,7 +9396,8 @@ export const start: < InputEvents, ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.RootCompatible, ...args: [...Machine.InputArgs] ) => Effect.Effect< MachineRef< @@ -9400,7 +9509,8 @@ export const resume: < InputEvents, ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.RootCompatible, snapshot: Machine.Snapshot ) => Effect.Effect< MachineRef< diff --git a/src/internal/machine/atom.ts b/src/internal/machine/atom.ts index 672636b..e8218f1 100644 --- a/src/internal/machine/atom.ts +++ b/src/internal/machine/atom.ts @@ -676,7 +676,8 @@ export const make: { Machine.Machine.EmitOf > > - & EnsureExecutable, + & EnsureExecutable + & Machine.Machine.RootCompatible, ...args: [...Machine.Machine.InputArgs] ): MachineAtom< Machine.Machine.Snapshot, @@ -699,7 +700,8 @@ export const resume: { machine: & M & EnsureNoExternalRequirements>> - & EnsureMachineExecutable>, + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>>, snapshot: Machine.Machine.Snapshot> ): ResumedMachineAtomOf } = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot) => { diff --git a/src/internal/machine/cluster.ts b/src/internal/machine/cluster.ts index c9cfade..9dc33e6 100644 --- a/src/internal/machine/cluster.ts +++ b/src/internal/machine/cluster.ts @@ -185,7 +185,8 @@ export const make = < Output, Emits extends ReadonlyArray, OutputStates extends Machine.Machine.StateIdentifier, - InputEvents extends ReadonlyArray = Events + InputEvents extends ReadonlyArray = Events, + ParentEvents extends ReadonlyArray = readonly [] >( type: Type, machine: @@ -202,9 +203,11 @@ export const make = < Output, Emits, OutputStates, - InputEvents + InputEvents, + ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.Machine.RootCompatible, options: { readonly version: string }, @@ -224,7 +227,8 @@ export const make = < Output, Emits, OutputStates, - InputEvents + InputEvents, + ParentEvents >, | ExcludeCompatibleRuntime< Machine.ExecutionServices, @@ -249,6 +253,7 @@ export const make = < OutputStates, InputEvents > + const rootMachine = machine as M & EnsureExecutable const eventSchema = Schema.Union(Protocol.inputEventSchemas(machine) as MachineEvents) const rpc = Rpc.make("send", { payload: eventSchema, @@ -305,7 +310,7 @@ export const make = < `Expected version ${options.version}, received ${checkpoint.version}` ) } - current = yield* internalMachine.decodeSnapshot(machine, checkpoint.snapshot).pipe( + current = yield* internalMachine.decodeSnapshot(rootMachine, checkpoint.snapshot).pipe( Effect.mapError((error) => reject("InvalidCheckpoint", String(error.cause))) ) } else if (loaded.processed) { @@ -313,7 +318,7 @@ export const make = < } if (current === undefined) { - const initial = yield* internalMachine.planInitial(machine, ...input as any) + const initial = yield* internalMachine.planInitial(rootMachine, ...input) if (initial.commands.length > 0) { return yield* fail( "UnsupportedProcessLocal", @@ -324,8 +329,8 @@ export const make = < emitted.push(...initial.emittedEvents as any) } - if (!internalMachine.isFinal(machine, current)) { - const planned = yield* internalMachine.plan(machine, current, request.payload) + if (!internalMachine.isFinal(rootMachine, current)) { + const planned = yield* internalMachine.plan(rootMachine, current, request.payload) if (planned.commands.length > 0) { return yield* fail( "UnsupportedProcessLocal", @@ -336,7 +341,7 @@ export const make = < emitted.push(...planned.emittedEvents as any) } - const encoded = yield* internalMachine.encodeSnapshot(machine, current) + const encoded = yield* internalMachine.encodeSnapshot(rootMachine, current) if (emitted.length > 0 && layerOptions?.enqueue === undefined) { return yield* fail("EmissionFailure", "No durable enqueue handler was configured") } diff --git a/src/internal/machine/executionPlan.ts b/src/internal/machine/executionPlan.ts index bf43109..5842749 100644 --- a/src/internal/machine/executionPlan.ts +++ b/src/internal/machine/executionPlan.ts @@ -86,10 +86,14 @@ const getPlanningMachineReferences = (machine: Machine.Any): PlanningMachineRefe const resolveMachineReferences = ( machine: Machine.Any, machineReferences: PlanningMachineReferences | undefined -): PlanningMachineReferences => - machineReferences === undefined +): { readonly self: MachineTarget; readonly parent?: MachineTarget | undefined } => { + const resolved = machineReferences === undefined ? getPlanningMachineReferences(machine) - : { self: machineReferences.self, parent: machineReferences.parent } + : machineReferences + return machine.parent === undefined + ? { self: resolved.self } + : { self: resolved.self, parent: resolved.parent } +} const indexedStateConfigKeys: ReadonlySet = new Set([ "initialize", diff --git a/src/internal/machine/machine.ts b/src/internal/machine/machine.ts index 9382d92..56db901 100644 --- a/src/internal/machine/machine.ts +++ b/src/internal/machine/machine.ts @@ -19,6 +19,8 @@ import type { MachineRef, MachineSchemaDecodeError, MachineSchemaEncodeError, + Parent, + ParentMode, Runtime, RuntimeOutcome, SpawnOptions, @@ -49,6 +51,7 @@ export { export { ChildMachineLogicTypeId, InitialEventTypeId, SnapshotBuilderStateTypeId } from "./symbols.js" const TypeId = "~effect/Machine" +const ParentTypeId = "~effect/Machine/Parent" export const InvokeTypeId: unique symbol = Symbol.for("effect/Machine/Invoke") export const TransitionTypeId: unique symbol = Symbol.for("effect/Machine/Transition") const ChildMachineTypeId = "~effect/Machine/ChildMachine" @@ -121,7 +124,7 @@ const makeWithHandlers = ( machine.events = self.events machine.internalEvents = self.internalEvents machine.emittedEvents = self.emittedEvents - machine.parentEvents = self.parentEvents + machine.parent = self.parent machine.input = self.input machine.id = self.id machine.initial = self.initial @@ -1532,7 +1535,7 @@ type MakeConfig< InitialE, InitialR, InternalEvents extends ReadonlyArray, - ParentEvents extends ReadonlyArray + ParentDeclaration extends Parent.Any | undefined > = { readonly id?: string readonly states: States & DefineStateTreeInput> @@ -1546,7 +1549,7 @@ type MakeConfig< NoInfer > readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> - readonly parentEvents?: Machine.EventProtocol<"public", ParentEvents> + readonly parent?: ParentDeclaration readonly input?: Input readonly initial: unknown } @@ -1559,7 +1562,7 @@ type MakeResult< InitialE, InitialR, InternalEvents extends ReadonlyArray, - ParentEvents extends ReadonlyArray + ParentDeclaration extends Parent.Any | undefined > = Definition< States, readonly [...InputEvents, ...InternalEvents], @@ -1570,7 +1573,7 @@ type MakeResult< Machine.TerminalOutput, Emits, InputEvents, - ParentEvents + Machine.ParentEventsOf > interface Make { @@ -1582,11 +1585,11 @@ interface Make { InitialE = never, InitialR = never, const InternalEvents extends ReadonlyArray = readonly [], - const ParentEvents extends ReadonlyArray = readonly [] + const ParentDeclaration extends Parent.Any | undefined = undefined >( - config: MakeConfig, + config: MakeConfig, ..._validation: ValidateDefinedStates> - ): MakeResult + ): MakeResult < const States extends Machine.StateSchemas, const InputEvents extends ReadonlyArray, @@ -1595,10 +1598,13 @@ interface Make { InitialE = never, InitialR = never, const InternalEvents extends ReadonlyArray = readonly [], - const ParentEvents extends ReadonlyArray = readonly [] + const ParentDeclaration extends Parent.Any | undefined = undefined >( config: - & Omit, "states"> + & Omit< + MakeConfig, + "states" + > & { readonly states: InvalidDefinedStateTreeInput } ): never } @@ -1611,7 +1617,7 @@ export const make: Make = (< InitialE = never, InitialR = never, const InternalEvents extends ReadonlyArray = readonly [], - const ParentEvents extends ReadonlyArray = readonly [] + const ParentDeclaration extends Parent.Any | undefined = undefined >( config: { readonly id?: string @@ -1619,18 +1625,18 @@ export const make: Make = (< readonly events: Machine.EventProtocol<"public", InputEvents> readonly internalEvents?: Machine.EventProtocol<"internal", InternalEvents> readonly emittedEvents?: Machine.EventProtocol<"emitted", Emits> - readonly parentEvents?: Machine.EventProtocol<"public", ParentEvents> + readonly parent?: ParentDeclaration readonly input?: Input readonly initial: unknown } -): MakeResult => { +): MakeResult => { StateDefinition.validateStateDefinitions(config.states, "Machine.make") const self = Object.create(Proto) self.states = config.states self.events = config.events self.internalEvents = config.internalEvents ?? Protocol.makeEventProtocol("internal", [] as const) self.emittedEvents = config.emittedEvents ?? Protocol.makeEventProtocol("emitted", [] as const) - self.parentEvents = config.parentEvents ?? Protocol.makeEventProtocol("public", [] as const) + self.parent = config.parent self.input = config.input self.id = config.id self.stateNodes = Topology.compileStateNodes(config.states) @@ -1662,6 +1668,27 @@ export const events = > +const makeParent = < + const Mode extends ParentMode, + const Events extends ReadonlyArray +>( + mode: Mode, + events: Machine.EventProtocol<"public", Events> +): Parent => { + if (!Protocol.isEventProtocol(events, "public")) { + throw new Error("Machine parent declarations require a protocol created with Machine.events") + } + return Object.freeze({ [ParentTypeId]: ParentTypeId, mode, events }) +} + +export const parent = >( + events: Machine.EventProtocol<"public", Events> +): Parent<"required", Events> => makeParent("required", events) + +export const optionalParent = >( + events: Machine.EventProtocol<"public", Events> +): Parent<"optional", Events> => makeParent("optional", events) + export const internalEvents = >>( ...inputs: Inputs ): Machine.EventProtocol<"internal", Machine.EventProtocolInputSchemasOf<"internal", Inputs>> => diff --git a/src/internal/machine/planner.ts b/src/internal/machine/planner.ts index 09be77d..7e1439a 100644 --- a/src/internal/machine/planner.ts +++ b/src/internal/machine/planner.ts @@ -7,13 +7,7 @@ import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import type * as Schema from "effect/Schema" -import type { - Enqueue, - InitialEvent as MachineInitialEvent, - Machine, - MachineReferences, - MachineTarget -} from "../../Machine.js" +import type { Enqueue, InitialEvent as MachineInitialEvent, Machine, MachineTarget } from "../../Machine.js" import { getTargetBuilder, makeCollector, type RuntimeCommand } from "./command.js" import { type ActiveConfiguration, @@ -118,24 +112,42 @@ type TransitionEvaluator = ( enqueue: Enqueue ) => TransitionEvaluation -const rootMachineReferences = new WeakMap>() -const scopedMachineReferences = new WeakMap>() +type PlanningMachineReferences = { + readonly self: MachineTarget + readonly parent: MachineTarget | undefined +} + +type BehaviorMachineReferences = { + readonly self: MachineTarget + readonly parent?: MachineTarget | undefined +} + +const rootMachineReferences = new WeakMap() +const scopedMachineReferences = new WeakMap() + +const exposeMachineReferences = ( + machine: Machine.Any, + machineReferences: PlanningMachineReferences +): BehaviorMachineReferences => + machine.parent === undefined + ? { self: machineReferences.self } + : { self: machineReferences.self, parent: machineReferences.parent } export const withMachineReferences = ( machine: Machine.Any, - machineReferences: MachineReferences + machineReferences: PlanningMachineReferences ): Machine.Any => { const scoped = Object.create(machine) as Machine.Any - scopedMachineReferences.set(scoped, { self: machineReferences.self, parent: machineReferences.parent }) + scopedMachineReferences.set(scoped, exposeMachineReferences(machine, machineReferences)) return scoped } const resolveMachineReferences = ( machine: Machine.Any, configuration: ActiveConfiguration -): MachineReferences => { +): BehaviorMachineReferences => { const scope = configuration.machineReferences - if (scope !== undefined) return scope + if (scope !== undefined) return exposeMachineReferences(machine, scope) const machineScope = scopedMachineReferences.get(machine) if (machineScope !== undefined) return machineScope const cached = rootMachineReferences.get(machine) @@ -145,7 +157,7 @@ const resolveMachineReferences = ( sessionId: "Machine.plan", send: () => Effect.fail(new StoppedError()) } - const root = { self, parent: undefined } + const root = exposeMachineReferences(machine, { self, parent: undefined }) rootMachineReferences.set(machine, root) return root } diff --git a/src/testing/MachineTest.ts b/src/testing/MachineTest.ts index f726dd0..6ef20da 100644 --- a/src/testing/MachineTest.ts +++ b/src/testing/MachineTest.ts @@ -138,6 +138,10 @@ type ReadyMachine = Machine.Machine.OutputStates > +type RootReadyMachine = + & ReadyMachine + & Machine.Machine.RootCompatible> + /** * A generated public-input scenario for a machine. * @@ -879,7 +883,7 @@ export { PlannerRuntimeAgreementError } from "../internal/testing/machine/verifi * @since 0.4.0 */ export const assertPlannerRuntimeAgreement: ( - machine: ReadyMachine, + machine: RootReadyMachine, transcript: CausalRuntimeEvidence ) => Effect.Effect, RunServices> = internal.assertPlannerRuntimeAgreement @@ -1462,7 +1466,7 @@ export type ExploreOptions( - machine: ReadyMachine, + machine: RootReadyMachine, options: ExploreOptions ) => Effect.Effect< Exploration, @@ -1643,7 +1647,7 @@ export type RunServices = IsAny< * @since 0.4.0 */ export const run: ( - machine: ReadyMachine, + machine: RootReadyMachine, scenario: Scenario ) => Effect.Effect, RunFailure, M>, RunServices> = internal.run diff --git a/src/unstable/cluster/ClusterMachine.ts b/src/unstable/cluster/ClusterMachine.ts index 78e5ba0..ae756ce 100644 --- a/src/unstable/cluster/ClusterMachine.ts +++ b/src/unstable/cluster/ClusterMachine.ts @@ -342,7 +342,8 @@ export const make: < Output, Emits extends ReadonlyArray, OutputStates extends Machine.Machine.StateIdentifier, - InputEvents extends ReadonlyArray = Events + InputEvents extends ReadonlyArray = Events, + ParentEvents extends ReadonlyArray = readonly [] >( type: Type, machine: @@ -359,9 +360,11 @@ export const make: < Output, Emits, OutputStates, - InputEvents + InputEvents, + ParentEvents > - & EnsureExecutable, + & EnsureExecutable + & Machine.Machine.RootCompatible, options: { readonly version: string }, @@ -381,7 +384,8 @@ export const make: < Output, Emits, OutputStates, - InputEvents + InputEvents, + ParentEvents >, | ExcludeCompatibleRuntime< Machine.ExecutionServices, diff --git a/src/unstable/reactivity/AtomMachine.ts b/src/unstable/reactivity/AtomMachine.ts index e2c148a..3d40c59 100644 --- a/src/unstable/reactivity/AtomMachine.ts +++ b/src/unstable/reactivity/AtomMachine.ts @@ -628,7 +628,8 @@ export interface Bound { machine: & M & EnsureBoundRequirements> - & EnsureMachineExecutable>, + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>>, ...args: MachineInputArgsOf ) => MachineAtomOf @@ -637,7 +638,8 @@ export interface Bound { machine: & M & EnsureBoundResumeRequirements> - & EnsureMachineExecutable>, + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>>, snapshot: Machine.Machine.Snapshot> ) => ResumedMachineAtomOf } @@ -714,7 +716,8 @@ export const make: { Machine.Machine.EmitOf > > - & EnsureExecutable, + & EnsureExecutable + & Machine.Machine.RootCompatible, ...args: [...Machine.Machine.InputArgs] ): MachineAtom< Machine.Machine.Snapshot, @@ -741,7 +744,8 @@ export const resume: { machine: & M & EnsureNoExternalRequirements>> - & EnsureMachineExecutable>, + & EnsureMachineExecutable> + & Machine.Machine.RootCompatible>>, snapshot: Machine.Machine.Snapshot> ): ResumedMachineAtomOf } = internal.resume diff --git a/test/internal/machine/strategyDifferential.test.ts b/test/internal/machine/strategyDifferential.test.ts index caee2f8..f280746 100644 --- a/test/internal/machine/strategyDifferential.test.ts +++ b/test/internal/machine/strategyDifferential.test.ts @@ -507,8 +507,7 @@ describe("machine planner and runtime strategies", () => { Idle: { on: { Publish: (to) => - to.none.resolve(({ parent, self }, enqueue) => { - assert.strictEqual(parent, undefined) + to.none.resolve(({ self }, enqueue) => { assert.ok(self.sessionId.startsWith("machine:")) enqueue.emit(Emissions.Published({ value } as never)) return undefined @@ -748,6 +747,60 @@ describe("machine planner and runtime strategies", () => { assert.deepStrictEqual(results[1], results[0]) }) as Effect.Effect) + it.effect("delivers required parent events in both runtime strategies", () => + Effect.gen(function*() { + class ChildIdle extends Schema.TaggedClass("StrategyRequiredParentChildIdle")("ChildIdle", {}) {} + class ParentWaiting extends Schema.TaggedClass("StrategyRequiredParentWaiting")( + "ParentWaiting", + {} + ) {} + class ParentDone extends Schema.TaggedClass("StrategyRequiredParentDone")("ParentDone", {}) {} + class ChildReady extends Schema.TaggedClass("StrategyRequiredParentReady")("ChildReady", {}) {} + + const ParentEvents = Machine.events(ChildReady) + const childStates = Machine.states({ ChildIdle }) + const childMachine = Machine.make({ + states: childStates.states, + events: Machine.events(), + parent: Machine.parent(ParentEvents), + initial: (to) => to.ChildIdle().resolve(({ target }) => target(new ChildIdle({}))) + }).handle({ + ChildIdle: { + invoke: Machine.invoke({ + id: "notify-parent", + effect: ({ parent }) => parent.send(ParentEvents.ChildReady()), + onDone: (to) => to.none, + onFailure: (to) => to.none + }) + } + }) + const Child = Machine.child("required-parent-child", childMachine) + const parentStates = Machine.states({ + ParentWaiting, + ParentDone: { schema: ParentDone, type: "final", output: Schema.String } + }) + const parentMachine = Machine.make({ + states: parentStates.states, + events: ParentEvents, + initial: (to) => to.ParentWaiting().resolve(({ target }) => target(new ParentWaiting({}))) + }).handle({ + ParentWaiting: { + invoke: Machine.invoke({ child: Child, onFailure: (to) => to.none }), + on: { + ChildReady: (to) => to.full.ParentDone().resolve(({ target }) => target(new ParentDone({}))) + } + }, + ParentDone: { output: () => "received" } + }) + + const outputs: Array = [] + for (const strategy of ["generic", "compiled"] as const) { + const ref = yield* openWithRuntimeStrategy(parentMachine, strategy) + outputs.push(yield* ref.join) + } + assert.deepStrictEqual(outputs, ["received", "received"]) + }) 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", { diff --git a/test/machine/LiveInspection.test.ts b/test/machine/LiveInspection.test.ts index 7b810e3..9459a32 100644 --- a/test/machine/LiveInspection.test.ts +++ b/test/machine/LiveInspection.test.ts @@ -222,14 +222,14 @@ describe("Machine live inspection", () => { id: "child-machine", states: childStates.states, events: ChildEvents, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), initial: (to) => to.ChildIdle().resolve(({ target }) => target(new ChildIdle({}))) }).handle({ ChildIdle: { on: { Trigger: (to) => to.none.resolve(({ parent }, enqueue) => { - if (parent !== undefined) enqueue.sendTo(parent, ParentEvents.ChildReady()) + enqueue.sendTo(parent, ParentEvents.ChildReady()) return undefined }) } diff --git a/test/machine/MachineReferences.test.ts b/test/machine/MachineReferences.test.ts index 7c0e8a4..4da97e1 100644 --- a/test/machine/MachineReferences.test.ts +++ b/test/machine/MachineReferences.test.ts @@ -6,6 +6,18 @@ const collectNext = (stream: Stream.Stream) => stream.pipe(Stream.take(1), Stream.runCollect, Effect.map(Array.from), Effect.forkChild({ startImmediately: true })) describe("machine reference event channels", () => { + it("constructs immutable required and optional parent declarations", () => { + class Notice extends Schema.TaggedClass("ParentDeclarationNotice")("Notice", {}) {} + const Events = Machine.events(Notice) + const required = Machine.parent(Events) + const optional = Machine.optionalParent(Events) + + assert.deepStrictEqual({ mode: required.mode, events: required.events }, { mode: "required", events: Events }) + assert.deepStrictEqual({ mode: optional.mode, events: optional.events }, { mode: "optional", events: Events }) + assert.isTrue(Object.isFrozen(required)) + assert.isTrue(Object.isFrozen(optional)) + }) + it.effect("observes initial emissions through a prepared machine", () => Effect.gen(function*() { class Idle extends Schema.TaggedClass("PreparedEmissionIdle")("Idle", {}) {} @@ -189,7 +201,7 @@ describe("machine reference event channels", () => { assert.deepStrictEqual(yield* Fiber.join(observed), []) })) - it.effect("types a child parent reference from parentEvents and keeps emissions external", () => + it.effect("keeps an optional parent reference available to root and child runtimes", () => Effect.gen(function*() { class Waiting extends Schema.TaggedClass("ParentEventsWaiting")("Waiting", {}) {} class Reported extends Schema.TaggedClass("ParentEventsReported")("Reported", {}) {} @@ -213,7 +225,7 @@ describe("machine reference event channels", () => { const childMachine = Machine.make({ states: childStates.states, events: ChildEvents, - parentEvents: ParentEvents, + parent: Machine.optionalParent(ParentEvents), emittedEvents: ChildEmissions, initial: (to) => to.Waiting().resolve(({ target }) => target(new Waiting({}))) }).handle({ @@ -232,6 +244,7 @@ describe("machine reference event channels", () => { }, Reported: {} }) + assert.strictEqual(childMachine.parent?.mode, "optional") const root = yield* Machine.start(childMachine) const rootChanged = yield* root.changes.pipe( @@ -291,19 +304,20 @@ describe("machine reference event channels", () => { const childDefinition = Machine.make({ states: childStates.states, events: Machine.events(), - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), initial: (to) => to.ChildIdle().resolve(({ target }) => target(new ChildIdle({}))) }) const childMachine = childDefinition.handle({ ChildIdle: { invoke: Machine.invoke({ id: "notify-ready", - effect: ({ parent }) => parent === undefined ? Effect.void : parent.send(ParentEvents.ChildReady()), + effect: ({ parent }) => parent.send(ParentEvents.ChildReady()), onDone: (to) => to.none, onFailure: (to) => to.none }) } }) + assert.strictEqual(childMachine.parent?.mode, "required") const Child = Machine.child("bound-invoke-child", childMachine) const parentStates = Machine.states({ ParentWaiting, diff --git a/test/machine/RuntimeDifferential.test.ts b/test/machine/RuntimeDifferential.test.ts index db9296d..83086f7 100644 --- a/test/machine/RuntimeDifferential.test.ts +++ b/test/machine/RuntimeDifferential.test.ts @@ -188,7 +188,6 @@ describe("pure planning and managed runtime differential", () => { if (snapshot.path !== "Running") throw new Error("expected Running snapshot") const expectedKeys = [ "self", - "parent", "state", "containingState", "ancestors", @@ -199,7 +198,6 @@ describe("pure planning and managed runtime differential", () => { assert.deepStrictEqual(Object.keys(context), expectedKeys) assert.deepStrictEqual(Object.keys(spread), expectedKeys) assert.strictEqual(spread.self, context.self) - assert.strictEqual(spread.parent, context.parent) assert.strictEqual(spread.state, state) assert.strictEqual(spread.containingState, containingState) assert.strictEqual(spread.ancestors, ancestors) diff --git a/typetest/machine/MachineReferences.tst.ts b/typetest/machine/MachineReferences.tst.ts index 00f059e..917caf4 100644 --- a/typetest/machine/MachineReferences.tst.ts +++ b/typetest/machine/MachineReferences.tst.ts @@ -2,6 +2,8 @@ import { Effect, Schema, Stream } from "effect" import { AtomRegistry } from "effect/unstable/reactivity" import { describe, expect, it } from "tstyche" import { Machine } from "../../src/index.js" +import { MachineTest } from "../../src/testing/index.js" +import { ClusterMachine } from "../../src/unstable/cluster/index.js" import { AtomMachine } from "../../src/unstable/reactivity/index.js" describe("machine reference event channels", () => { @@ -30,7 +32,7 @@ describe("machine reference event channels", () => { states: states.states, events: Events, internalEvents: InternalEvents, - parentEvents: ParentEvents, + parent: Machine.optionalParent(ParentEvents), emittedEvents: Emissions, initial: (to) => to.Idle().resolve(({ target }) => (target(new Idle({})))) }).handle({ @@ -50,12 +52,32 @@ describe("machine reference event channels", () => { expect< Machine.MachineReferences["parent"] >().type.toBe> | undefined>() + expect>().type.toBe<"self">() + expect(Machine.parent).type.not.toBeCallableWith(InternalEvents) + expect(Machine.optionalParent).type.not.toBeCallableWith(InternalEvents) + + const independentMachine = Machine.make({ + states: states.states, + events: Events, + initial: (to) => to.Idle().resolve(({ target }) => target(new Idle({}))) + }).handle({ + Idle: { + on: { + Ping: (to) => + to.none.resolve((context) => { + expect<"parent">().type.not.toBeAssignableTo() + return undefined + }) + } + } + }) + expect(independentMachine.parent).type.toBe() Machine.make({ states: states.states, events: Events, internalEvents: InternalEvents, - parentEvents: ParentEvents, + parent: Machine.optionalParent(ParentEvents), emittedEvents: Emissions, initial: (to) => to.Idle().resolve(({ target }) => (target(new Idle({})))) }).handle({ @@ -136,7 +158,7 @@ describe("machine reference event channels", () => { states: states.states, events: Events, internalEvents: InternalEvents, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), emittedEvents: Emissions, initial: (to) => to.Idle().resolve(({ target }) => (target(new Idle({})))) }).handle({ @@ -147,29 +169,25 @@ describe("machine reference event channels", () => { expect(self.send).type.toBeCallableWith(Events.Ping()) expect(self.send).type.not.toBeCallableWith(InternalEvents.Local()) expect(self.send).type.not.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) - if (parent !== undefined) { - expect(parent.send).type.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) - expect(parent.send).type.not.toBeCallableWith(Events.Ping()) - } + expect(parent.send).type.toBeCallableWith(ParentEvents.ParentNotice({ value: 1 })) + expect(parent.send).type.not.toBeCallableWith(Events.Ping()) return Effect.void }, onDone: (to) => to.none.resolve(({ parent, self }, enqueue) => { enqueue.sendTo(self, Events.Ping()) - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) - expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) - } + enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) + expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) return undefined }) }) } }) - Machine.make({ + const requiredParentMachine = Machine.make({ states: states.states, events: Events, - parentEvents: ParentEvents, + parent: Machine.parent(ParentEvents), initial: (to) => to.Idle().resolve(({ target }) => target.from()) }).handle({ Idle: { @@ -179,14 +197,40 @@ describe("machine reference event channels", () => { onFailure: (to) => to.none.resolve(({ parent, self }, enqueue) => { enqueue.sendTo(self, Events.Ping()) - if (parent !== undefined) { - enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) - expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) - } + enqueue.sendTo(parent, ParentEvents.ParentNotice({ value: 1 })) + expect(enqueue.sendTo).type.not.toBeCallableWith(parent, Events.Ping()) return undefined }) }) } }) + + expect(Machine.start).type.not.toBeCallableWith(requiredParentMachine) + expect(Machine.prepare).type.not.toBeCallableWith(requiredParentMachine) + expect(Machine.planInitial).type.not.toBeCallableWith(requiredParentMachine) + expect(Machine.plan).type.not.toBeCallableWith( + requiredParentMachine, + null as unknown as Machine.Machine.Snapshot, + Events.Ping() + ) + expect(Machine.resume).type.not.toBeCallableWith( + requiredParentMachine, + null as unknown as Machine.Machine.Snapshot + ) + expect(AtomMachine.make).type.not.toBeCallableWith(requiredParentMachine) + expect(AtomMachine.resume).type.not.toBeCallableWith( + requiredParentMachine, + null as unknown as Machine.Machine.Snapshot + ) + expect(ClusterMachine.make).type.not.toBeCallableWith("RequiredParent", requiredParentMachine, { version: "1" }) + expect(MachineTest.run).type.not.toBeCallableWith(requiredParentMachine, { events: [] }) + expect(MachineTest.explore).type.not.toBeCallableWith(requiredParentMachine, { + events: () => [], + stateKey: () => "idle" + }) + expect>().type.toBe<"required">() + expect>().type.toBe<"optional">() + expect(requiredParentMachine.parent.mode).type.toBe<"required">() + expect(childMachine.parent.mode).type.toBe<"optional">() }) })