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
9 changes: 9 additions & 0 deletions .changeset/invoke-error-channels-and-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typeonce/effect-machine": minor
---

`ChildAlreadyExistsError` now appears in a machine's error type only for invocations whose child id you choose: logic invocations with an `address` and `Machine.child` invocations. Effect, Stream, and timer invocations run under runtime-owned ids and cannot collide, so a machine that only invokes them no longer carries `ChildAlreadyExistsError` in `Machine.Error`. Invocation ids within one state must be distinct; a repeated source without distinct `id`s or a reused `id` is now a type error and is rejected when the machine is constructed.

A parent that invokes a fixed child through `children` now needs `onFailure` exactly when the child's own error type is reachable. Remove `onFailure: { none: true }` handlers that only swallowed `ChildAlreadyExistsError`; they are now rejected as unreachable, like an unreachable `onDone`.

Constraint failures now explain themselves instead of reporting `never`. Starting, resuming, preparing, or planning a machine with a required parent reports `Machine declares a required parent and cannot run as a root`, and an unreachable `onDone`, `onFailure`, `onElement`, or `onSnapshot` names the channel and source, for example `Invocation 'forever' never completes, so onDone is unreachable`.
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,14 @@ export const parallelOwnerRoutingMachine = Machine.make({
states: {
Print: {
initial: { Options: ({}) => ({}), Operation: ({}) => ({}) },
invoke: {
src: "source1",
onFailure: { target: "Print.Operation.Active", decoded: true, data: () => (new Active({})) }
},
invoke: { src: "source1" },
on: {
PrintRequested: {
target: "Print.Operation.Printing",
decoded: true,
data: () => (new Printing({}))
}
},
ChildSignal: { target: "Print.Operation.Active", decoded: true, data: () => (new Active({})) }
},
states: {
Options: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,6 @@ export const parentProtocolMachine = Machine.make({
target: "Complete",
decoded: true,
data: ({ output }) => (new ParentComplete({ result: output }))
},
onFailure: {
target: "Failed",
decoded: true,
data: ({ error }) => (new ParentFailed({ message: String(error) }))
}
},
on: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ describe("Static chart", () => {
transitions.map(({ label, target }) => ({ label, target })),
[
{ label: "PrintRequested", target: "Print.Operation.Printing" },
{ label: "parallel-owner-child", target: "Print.Operation.Active" }
{ label: "ChildSignal", target: "Print.Operation.Active" }
]
)
assert.isTrue(transitions.every(isParentChildTransition))
Expand Down
13 changes: 10 additions & 3 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,16 @@ const invocations = [

Include each handler demanded by the concrete program's type. Use an explicit
`id` for simultaneous instances of one source; effect, stream, and timer IDs
default to `src`. Logic requires a lifecycle `id` and a typed `Machine.childAddress`.
A child uses its `Machine.child` descriptor's identity and protocol. All
invocations are owned by their state and cancelled when it exits.
default to `src`. Invocation IDs within one state must be distinct. Logic
requires a lifecycle `id` and a typed `Machine.childAddress`. A child uses its
`Machine.child` descriptor's identity and protocol. All invocations are owned by
their state and cancelled when it exits.

Effect, Stream, and timer invocations run under runtime-owned child IDs, so
they never fail with `ChildAlreadyExistsError`. Logic addresses and child
descriptor IDs are chosen by you: starting one while another active child uses
the same ID fails the owning machine with `ChildAlreadyExistsError`. A parent's
`onFailure` for an invoked child receives only the child's own typed failures.

Effect services flow through the used sources and lifecycle effects into the
machine's requirements. Unused registrations add no requirements. Supply them
Expand Down
101 changes: 88 additions & 13 deletions packages/effect-machine/src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,20 @@ type StateDefinitionError<
readonly details: Details
}

type InvocationDefinitionError<
Message extends string,
Source extends string,
Field extends PropertyKey
> = {
readonly "~effect/Machine/InvocationDefinitionError": Message
readonly source: Source
readonly field: Field
}

type RootCompatibilityError<Message extends string> = {
readonly "~effect/Machine/RootCompatibilityError": Message
}

type ActiveStateKey<States extends Machine.StateSchemas> = Machine.ActiveStateKey<States>

type HistoryStateKey<States extends Machine.StateSchemas> = Machine.HistoryStateKey<States>
Expand Down Expand Up @@ -2579,9 +2593,23 @@ export declare namespace Machine {
infer Mode ? Mode extends ParentMode ? Parent<Mode, Events> : undefined
: never

/** Constraint applied to APIs that create an independent root runtime. */
/**
* Constraint applied to APIs that create an independent root runtime.
*
* **Details**
*
* A machine without a parent protocol, or with an optional one, is
* accepted. A machine that declares a required parent can only run as an
* owned child, so the constraint resolves to a diagnostic that names the
* reason instead of an unexplained `never`.
*
* @category utility types
* @since 0.27.0
*/
export type RootCompatible<Events extends ReadonlyArray<TaggedSchema>> = ParentModeOf<Events> extends "required"
? never
? RootCompatibilityError<
"Machine declares a required parent and cannot run as a root; invoke or spawn it from an owning machine"
>
: unknown

/**
Expand Down Expand Up @@ -5291,15 +5319,30 @@ export declare namespace Machine {
: Effect.Error<InvokeOutcomeReturn<Invoke>>
type InvokeOutcomeServices<Invoke> = IsAny<InvokeOutcomeReturn<Invoke>> extends true ? never
: Effect.Services<InvokeOutcomeReturn<Invoke>>
// Effect, Stream, and timer invocations run under a runtime-reserved child id
// derived from their state path and distinct lifecycle id. Only caller-chosen
// child ids (logic addresses and child descriptor ids) can collide.
type InvokeCollisionError<Invoke> = Invoke extends { readonly logic: unknown } | { readonly child: unknown }
? ChildAlreadyExistsError
: never
/**
* Extracts the parent transition error contribution from invoked children.
*
* **Details**
*
* The contribution contains the startup and lifecycle-handler failures of
* every invocation. `ChildAlreadyExistsError` is included only for
* invocations whose child id is chosen by the caller, namely logic
* invocations with an `address` and child machine invocations, which fail to
* start while another child with that id is active. Effect, Stream, and timer
* invocations run under runtime-reserved ids and cannot collide.
*
* @category utility types
* @since 0.4.0
*/
export type InvokeError<Config> = [InvokeReturn<Config>] extends [never] ? never
:
| ChildAlreadyExistsError
| InvokeCollisionError<InvokeReturn<Config>>
| InvokeInitialError<InvokeReturn<Config>>
| InvokeOutcomeError<InvokeReturn<Config>>
/**
Expand Down Expand Up @@ -7413,17 +7456,49 @@ export declare namespace Machine {
: "onElement"
: never)
| (SourceKind<R, K> extends "logic" | "children" ? "onSnapshot" : never)
type ValidateObjectInvoke<I, R> = I extends ReadonlyArray<unknown> ?
{ readonly [K in keyof I]: ValidateObjectInvoke<I[K], R> } :
I extends {
/** Name of the source registered in make. */
readonly src: infer Src extends PropertyKey
} ? {
readonly [K in keyof I]: K extends AllowedInvokeChannels<R, Src> ? ValidateObjectTransition<I[K]>
: K extends "src" | "id" | "address" | "input" ? unknown
: never
} :
type UnreachableInvokeChannelMessage<Channel extends string, Src extends string> = Channel extends "onDone"
? `Invocation '${Src}' never completes, so onDone is unreachable`
: Channel extends "onFailure" ? `Invocation '${Src}' cannot fail, so onFailure is unreachable`
: Channel extends "onElement" ? `Invocation '${Src}' emits no Stream elements, so onElement is unreachable`
: `Invocation '${Src}' has no snapshots; onSnapshot applies only to logic and child invocations`
type InvokeFieldError<K, Src extends string> = K extends "onDone" | "onFailure" | "onElement" | "onSnapshot"
? InvocationDefinitionError<UnreachableInvokeChannelMessage<K, Src>, Src, K>
: K extends string ? InvocationDefinitionError<`Invocation '${Src}' does not accept the field '${K}'`, Src, K>
: never
// Explicit ids or the registered source name for programs; descriptor ids for child machines.
type InvokeLifecycleIdOf<I, R> = I extends { readonly id: infer Id extends string } ? Id
: I extends { readonly src: infer Src extends PropertyKey } ?
SourceValue<R, Src> extends ChildMachine<infer Id, Any> ? Id : Extract<Src, string>
: never
type LiteralInvokeIdAt<I extends ReadonlyArray<unknown>, K, R> = K extends keyof I
? InvokeLifecycleIdOf<I[K], R> extends infer Id extends string ? string extends Id ? never : Id : never
: never
type DuplicateInvokeId<I extends ReadonlyArray<unknown>, K, R> = LiteralInvokeIdAt<I, K, R> extends infer Id ?
[Id] extends [never] ? never
: Id extends LiteralInvokeIdAt<I, Exclude<Extract<keyof I, `${number}`>, K>, R> ? Id
: never
: never
type ValidateInvokeEntry<I, R, Duplicate extends string> = I extends {
/** Name of the source registered in make. */
readonly src: infer Src extends PropertyKey
} ? {
readonly [K in keyof I]: [Duplicate] extends [never] ? ValidateInvokeField<I, K, R, Extract<Src, string>>
: K extends ("id" extends keyof I ? "id" : "src") ? InvocationDefinitionError<
`Invocation id '${Duplicate}' is declared more than once in this state`,
Extract<Src, string>,
K
>
: ValidateInvokeField<I, K, R, Extract<Src, string>>
} :
never
type ValidateInvokeField<I, K extends keyof I, R, Src extends string> = K extends AllowedInvokeChannels<R, Src>
? ValidateObjectTransition<I[K]>
: K extends "src" | "id" | "address" | "input" ? unknown
: InvokeFieldError<K, Src>
type ValidateObjectInvoke<I, R> = I extends ReadonlyArray<unknown> ?
number extends I["length"] ? { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, never> }
: { readonly [K in keyof I]: ValidateInvokeEntry<I[K], R, DuplicateInvokeId<I, K, R>> } :
ValidateInvokeEntry<I, R, never>
type ObjectDeclarations<C> =
| Extract<keyof C, "on" | "always" | "onDone" | "choice" | "invoke">
| (C extends { readonly states: infer Children }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export type ChildEntry =
ownerActive?: boolean
}

/**
* Prefix of the child ids the runtime assigns to Effect, Stream, and timer
* invocations. Caller-chosen ids in this namespace are always treated as taken,
* so runtime-owned invocation ids can never collide with another child.
*/
export const invocationChildIdPrefix = "Machine.invocation:"

export const isReservedChildId = (id: string): boolean => id.startsWith(invocationChildIdPrefix)

export type ChildSelector = string | ChildDescriptor
export type ChildKey = string | symbol

Expand Down
19 changes: 17 additions & 2 deletions packages/effect-machine/src/internal/machine/invocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import * as Stream from "effect/Stream"
import type { ChildMachine, ChildOwner, Inspection, Logic, Machine } from "../../Machine.js"
import * as ChildRegistry from "./childRegistry.js"
import * as Configuration from "./configuration.js"
import { InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from "./errors.js"
import { ChildAlreadyExistsError, InfiniteTransitionError, MachineSchemaDecodeError, StoppedError } from "./errors.js"
import * as InvocationDefinition from "./invocationDefinition.js"
import * as InvocationEvent from "./invocationEvent.js"
import * as Planner from "./planner.js"
Expand All @@ -33,7 +34,8 @@ export interface AnyConfig {
export const makeKey = (path: string, id: string): string => `${path.length}:${path}${id}`

/** @internal */
export const makeChildId = (path: string, id: string): string => `Machine.invocation:${makeKey(path, id)}`
export const makeChildId = (path: string, id: string): string =>
`${ChildRegistry.invocationChildIdPrefix}${makeKey(path, id)}`

const oneShot = (effect: Effect.Effect<any, any, any>): Logic<void, never, any, any, any> => ({
initial: () => Effect.void,
Expand Down Expand Up @@ -272,13 +274,24 @@ const startResolved = (
})
})

// Logic addresses and child descriptor ids are caller-chosen. Keep them out of
// the namespace reserved for runtime-owned invocation ids; runtime-owned ids
// skip this check.
const reservedChildIdFailure = (
childId: string,
invokeId: string
): Effect.Effect<never, ChildAlreadyExistsError> | undefined =>
ChildRegistry.isReservedChildId(childId) ? Effect.fail(new ChildAlreadyExistsError({ id: invokeId })) : undefined

const start = (
scope: Runtime.ProcessScope<any>,
ownedChildren: Runtime.OwnedChildRuntime,
path: string,
config: AnyConfig
): Effect.Effect<void, any, any> => {
const invokeId = String(config.id)
const reserved = config.address === undefined ? undefined : reservedChildIdFailure(String(config.address), invokeId)
if (reserved !== undefined) return reserved
return startResolved(
scope,
ownedChildren,
Expand All @@ -305,6 +318,8 @@ const startStaticChild = (
// source function so each running parent does not retain a resolved config
// object and an otherwise redundant source closure.
const descriptor = raw.child
const reserved = reservedChildIdFailure(descriptor.id, descriptor.id)
if (reserved !== undefined) return reserved
return startResolved(
scope,
ownedChildren,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,25 @@ export const capture = (config: Record<PropertyKey, unknown>, path: string): Inv
return Object.freeze(config) as unknown as InvocationDefinition
}

/** Lifecycle id that routes an invocation's outcomes within its owning state. */
export const lifecycleId = (definition: InvocationDefinition): string =>
"child" in definition ? definition.child.id : definition.id

/**
* Rejects one state's invocations when two share a lifecycle id. Distinct ids
* keep runtime-owned invocation child ids collision-free.
*/
export const assertDistinctIds = (definitions: ReadonlyArray<InvocationDefinition>, path: string): void => {
const seen = new Set<string>()
for (const definition of definitions) {
const id = lifecycleId(definition)
if (seen.has(id)) {
throw new Error(`Machine invocations for state "${path}" must have distinct ids; "${id}" is declared twice`)
}
seen.add(id)
}
}

/** Only captured state handlers cross this erased boundary. */
export const definitions = (invoke: unknown): ReadonlyArray<InvocationDefinition> =>
invoke === undefined
Expand Down
1 change: 1 addition & 0 deletions packages/effect-machine/src/internal/machine/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ const captureInvokeDefinition = (
}
return InvocationDefinition.capture(captured, path)
})
InvocationDefinition.assertDistinctIds(capturedDefinitions, path)
if (Array.isArray(invoke)) return Object.freeze(capturedDefinitions)
return capturedDefinitions[0]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type ChildObserver,
type ChildRegistry,
type ChildSelector,
isReservedChildId,
matchesChild,
offerChildObservation,
registerChild,
Expand Down Expand Up @@ -1137,7 +1138,7 @@ export const makeChildRuntimeSync = (
if (registry.closed) {
return Effect.interrupt
}
if (typeof key === "string" && registry.children.has(key)) {
if (typeof key === "string" && (registry.children.has(key) || isReservedChildId(key))) {
return Effect.fail(new ChildAlreadyExistsError({ id: key }))
}
registry.scope ??= Scope.makeUnsafe("parallel")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@ describe("machine planner and runtime strategies", () => {
},
states: {
ParentWaiting: {
invoke: { src: "source1", onFailure: { none: true } },
invoke: { src: "source1" },
on: {
ChildReady: { target: "ParentDone", decoded: true, data: () => (new ParentDone({})) }
}
Expand Down
Loading
Loading