Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/dynamic-child-machines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@typeonce/effect-machine": minor
---

Add process-owned child machine spawning for runtime-sized child sets.

Use `Machine.childFamily(machine)` to bind a child machine once, then call
`children.spawn(Family(id), { input })` inside an invoked Effect. Successfully
started children survive owner state changes and remain addressable through
machine references and `AtomMachine` until they stop or their parent stops.

`Logic.Scope.spawn` accepts the same child descriptors for lower-level process
logic. Dynamic spawn calls retain child input, startup failure and service
inference, and check the child's declared parent protocol.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,46 @@ entered. Use an Effect containing `Effect.sleep(...)` for generic work, while
`from.timer(...)` keeps timer intent explicit and makes static durations visible
through activity inspection.

### Spawn dynamic child machines

Use `from.child(...)` when a state owns a fixed child lifecycle. Use the
`children` context inside an invoked Effect when the machine process owns an
open set of children that must survive state changes:

```ts
const Plant = Machine.childFamily(plantMachine)

const central = Machine.make({
events: Machine.events(ResourcesOffered, PlantBroken)
// ...
}).handle({
Commissioning: {
invoke: (from) =>
from.effect("commission-wave", ({ children, state }) =>
Effect.forEach(
state.plants,
(input) => children.spawn(Plant(input.id), { input }),
{ discard: true }
))
.onDone((to) => to.full.Operating())
.onFailure((to) => to.full.CommissioningFailed())
}
})
```

`children.spawn` completes after initialization. The new child remains owned
by the machine process after the commissioning Effect completes or its state
exits. `children.sendTo` and `children.stop` address one active child from an
Effect; transition resolvers use `enqueue.sendTo` and `enqueue.stop` with the
same descriptor. Duplicate active ids fail with `ChildAlreadyExistsError` and
do not replace the existing child. Earlier successful spawns remain active if
a later spawn in the same wave fails.

The child machine's declared `Machine.parent(...)` events must be accepted by
the owner. This is checked at each spawn call even though ids and cardinality
remain dynamic. `scope.spawn(child, { input })` provides the same descriptor
form for lower-level process logic, where the process event protocol is known.

## Reactivity

`AtomMachine` runs one lazy machine instance per `AtomRegistry`:
Expand All @@ -603,6 +643,15 @@ The bridge exposes `ref`, `snapshot`, `state`, fail-aware `result`, writable
equality-aware derivations. React applications using `@effect/atom-react` need
a `RegistryProvider`.

Descriptors reconstructed from a `Machine.childFamily` resolve the same child
bridge by machine identity and id:

```ts
const Plant = Machine.childFamily(plantMachine)
const plantAtom = centralAtom.child(Plant(selectedPlantId))
const brokenAtom = AtomMachine.matchesChild(plantAtom, "Broken")
```

Emissions stay streams rather than becoming retained atom state:

```ts
Expand Down
31 changes: 31 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,37 @@ machine.
Do not start a promise inside a transition callback. A transition has no
lifetime in which to own that work. A state does.

### Choose state-owned or process-owned children

Use `from.child(...)` when the child belongs to one state and must stop when
that state exits. Use a child family and `children.spawn(...)` when runtime
events determine the ids or cardinality and the children must survive owner
state changes:

```ts
const Worker = Machine.childFamily(workerMachine)

Commissioning: {
invoke: (from) =>
from.effect("start-workers", ({ children, state }) =>
Effect.forEach(
state.workers,
(input) => children.spawn(Worker(input.id), { input }),
{ discard: true }
)
)
.onDone((to) => to.full.Running())
.onFailure((to) => to.full.Failed())
}
```

The Effect owns the startup attempt. The machine process owns every child that
starts successfully. Leaving `Commissioning` does not stop those children.
Stop one with `children.stop(Worker(id))` inside an Effect or
`enqueue.stop(Worker(id))` inside a transition. A duplicate active id fails
instead of replacing the existing child, and a partially successful group is
not rolled back automatically.

## Keep transition decisions synchronous

A transition should choose the next state from the current snapshot and event.
Expand Down
28 changes: 28 additions & 0 deletions docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,31 @@ the inferred dialog events.

Use `dialogId` in atom labels for diagnostics. Do not pass it into
`dialogMachine` as unused fake input.

## 4. Selecting process-owned child machines

Bind a machine definition once when a parent owns a runtime-sized set of child
machines:

```ts
const Plant = Machine.childFamily(plantMachine)

export const centralMachineAtom = machineAtoms.make(centralMachine)

export const plantScopeFamily = Atom.family((plantId: string) => {
const plant = centralMachineAtom.child(Plant(plantId))

return {
stateAtom: plant.state,
isBrokenAtom: AtomMachine.matchesChild(plant, "Broken"),
sendAtom: plant.send,
stopAtom: plant.stop
}
})
```

`Plant(plantId)` may be reconstructed wherever the id is available. Child
lookup and bridge reuse match by machine identity and id, not descriptor object
identity. Before the parent spawns that child, selectors contain `Option.none`
and `matchesChild` is `false`. They follow the child after startup and return to
the inactive values after it stops.
143 changes: 141 additions & 2 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2265,7 +2265,15 @@ export declare namespace Logic {
* @category models
* @since 0.4.0
*/
export interface Spawn {
export interface Spawn<OwnerEvent = unknown> {
<const Child extends ChildMachine.Any>(
child: Child & ChildMachine.Executable<Child> & ChildMachine.ParentCompatibility<Child, OwnerEvent>,
...options: ChildMachine.SpawnArgs<Child>
): Effect.Effect<
ChildMachine.Ref<Child>,
ChildAlreadyExistsError | ChildMachine.StartError<Child>,
ChildMachine.StartRequirements<Child>
>
<ChildState, ChildEvent, ChildError, ChildRequirements, ChildOutput, ChildInitialError = never>(
logic: Logic<ChildState, ChildEvent, ChildError, ChildRequirements, ChildOutput, ChildInitialError>
): Effect.Effect<
Expand Down Expand Up @@ -2305,7 +2313,7 @@ export declare namespace Logic {
readonly parent: Address<unknown> | undefined

/** Starts a child process owned by this scope. */
readonly spawn: Spawn
readonly spawn: Spawn<Event>

/** Sends an event to a machine target or typed parent-local child address. */
readonly sendTo: {
Expand Down Expand Up @@ -2345,6 +2353,7 @@ export declare namespace Logic {

const ChildAddressTypeId = "~effect/Machine/ChildAddress"
const ChildAddressCompatibilityErrorTypeId = "~effect/Machine/ChildAddressCompatibilityError"
const ChildParentCompatibilityErrorTypeId = "~effect/Machine/ChildParentCompatibilityError"
const ChildMachineTypeId = "~effect/Machine/ChildMachine"
type InvokeLifecycleId = string & { readonly [ChildAddressTypeId]?: never }

Expand Down Expand Up @@ -2389,6 +2398,95 @@ export declare namespace ChildMachine {
*/
export type Any = ChildMachine<string, Machine.Any>

/**
* Bound constructor for an open family of child descriptors that share one
* machine definition.
*
* @category models
* @since 0.20.0
*/
export interface Family<M extends Machine.Any> {
<const Id extends string>(id: Id): ChildMachine<Id, M>
}

/**
* Ensures a child machine's declared owner protocol is accepted by the
* process that will own it.
*
* @category utility types
* @since 0.20.0
*/
export type ParentCompatibility<Child extends Any, OwnerEvent> = Child extends ChildMachine<string, infer M> ?
Machine.Any extends M ? {
readonly [ChildParentCompatibilityErrorTypeId]: {
readonly child: unknown
readonly owner: OwnerEvent
}
}
: [Machine.EventOf<Machine.ParentEvents<M>>] extends [OwnerEvent] ? unknown :
{
readonly [ChildParentCompatibilityErrorTypeId]: {
readonly child: Machine.EventOf<Machine.ParentEvents<M>>
readonly owner: OwnerEvent
}
}
: never

/**
* Ensures the selected child machine has complete handlers and outputs.
*
* @category utility types
* @since 0.20.0
*/
export type Executable<Child extends Any> = Child["machine"] extends EnsureExecutable<
Machine.States<Child["machine"]>,
Machine.UnhandledStates<Child["machine"]>,
Machine.OutputStates<Child["machine"]>
> ? unknown
: never

/**
* Startup arguments accepted while spawning a child machine.
*
* @category utility types
* @since 0.20.0
*/
export type SpawnArgs<Child extends Any> = Machine.InputSchema<Child["machine"]> extends typeof Schema.Void ?
[options?: { readonly input?: never }]
: [options: { readonly input: Machine.Input<Child["machine"]> }]

/**
* Typed failures that may occur before a spawned child becomes active.
*
* @category utility types
* @since 0.20.0
*/
export type StartError<Child extends Any> = Child extends ChildMachine<string, infer M> ?
| Machine.InitialError<M>
| Machine.Error<M>
| ActionError<Machine.InitialServices<M> | Machine.Services<M>>
| InfiniteTransitionError
| MachineSchemaDecodeError
| StartupError
| StoppedError
: never

/**
* Services needed to initialize a spawned child machine.
*
* @category utility types
* @since 0.20.0
*/
export type StartRequirements<Child extends Any> = Child extends ChildMachine<string, infer M> ? Exclude<
ExcludeCompatibleRuntime<
Exclude<ExecutionServices<Machine.InitialServices<M> | Machine.Services<M>>, MachineRuntimeRequirement>,
Machine.Event<M>,
Machine.Emit<M>
>,
Scope.Scope
>
: never

/**
* Running machine reference selected by a child descriptor.
*
Expand Down Expand Up @@ -2418,6 +2516,34 @@ export declare namespace ChildMachine {
: never
}

/**
* Effectful operations for child machines owned directly by the current
* machine process.
*
* @category models
* @since 0.20.0
*/
export interface ChildOwner<OwnerEvent> {
/** Starts a process-owned child and returns once initialization succeeds. */
readonly spawn: <const Child extends ChildMachine.Any>(
child: Child & ChildMachine.Executable<Child> & ChildMachine.ParentCompatibility<Child, OwnerEvent>,
...options: ChildMachine.SpawnArgs<Child>
) => Effect.Effect<
ChildMachine.Ref<Child>,
ChildAlreadyExistsError | ChildMachine.StartError<Child>,
ChildMachine.StartRequirements<Child>
>

/** Sends an event to one active child. Missing children are ignored. */
readonly sendTo: <Child extends ChildMachine.Any>(
child: Child,
event: ChildMachine.Event<Child>
) => Effect.Effect<void, StoppedError>

/** Stops one active child. Missing children are ignored. */
readonly stop: <Child extends ChildMachine.Any>(child: Child) => Effect.Effect<void>
}

/**
* Parent-local address for a child process that can receive events.
*
Expand Down Expand Up @@ -4799,6 +4925,8 @@ export declare namespace Machine {
InputEvents extends ReadonlyArray<TaggedSchema> = Events,
ParentEvents extends ReadonlyArray<TaggedSchema> = readonly []
> = MachineReferences<InputEvents, ParentEvents> & {
/** Process-owned child operations for dynamic child machine lifecycles. */
readonly children: ChildOwner<EventOf<InputEvents>>
/** Value owned by the state that owns this invocation. */
readonly state: StateByIdentifier<States, StateId>
/** Value owned by the nearest schema-backed ancestor, when one exists. */
Expand Down Expand Up @@ -8917,6 +9045,17 @@ export const logic: <
export const child: <const Id extends string, M extends Machine.Any>(id: Id, machine: M) => ChildMachine<Id, M> =
internal.child

/**
* Binds one machine definition to an open family of runtime child ids.
*
* Descriptors created by the returned function are interchangeable with
* {@link child} descriptors for the same id and machine definition.
*
* @category constructors
* @since 0.20.0
*/
export const childFamily: <M extends Machine.Any>(machine: M) => ChildMachine.Family<M> = internal.childFamily

/**
* Creates a typed parent-local address for lower-level child process logic.
*
Expand Down
Loading