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
7 changes: 7 additions & 0 deletions .changeset/bright-parents-require.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,25 +308,23 @@ 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)

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()
})
}
Expand All @@ -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.

Expand Down Expand Up @@ -490,15 +494,15 @@ 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:

```ts
const machine = Machine.make({
events: Commands,
internalEvents: InternalEvents,
parentEvents: ParentEvents
parent: Machine.parent(ParentEvents)
// ...
}).handle({
Saving: {
Expand All @@ -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
})
Expand Down
56 changes: 30 additions & 26 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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<Event>(id)` for a low-level process address. A logic
invocation is addressable only when `Machine.invoke` receives that
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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())
})
}
}
Expand All @@ -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<Event>` values,
provided by the shared `MachineReferences<InputEvents, ParentEvents>` 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<Event>` 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
Expand Down Expand Up @@ -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: {
Expand All @@ -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
})
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions examples/pokemon/src/machines/replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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()
})
}
Expand Down
6 changes: 2 additions & 4 deletions examples/pokemon/src/machines/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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())
Expand Down
6 changes: 2 additions & 4 deletions scripts/fixtures/consumer/deep-bound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
41 changes: 40 additions & 1 deletion scripts/invoke-autocomplete.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down
Loading