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/fresh-machines-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typeonce/effect-machine": minor
---

Add first-class logical snapshot resumption with `Machine.resume`, plus lazy
`AtomMachine.resume` and bound-runtime integration. Resumed machines validate
decoded snapshots, preserve logical history and completion metadata, and create
fresh managed invokes, children, scopes, and timers without replaying historical
statechart work.
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,38 @@ contain the machine definition, machine version, services, subscriptions, or
running child processes. Store machine identity and migration/version metadata
alongside it.

Resume a decoded logical snapshot explicitly:

```ts
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
const decoded = yield * Machine.decodeSnapshot(machine, encoded)
const ref = yield * Machine.resume(machine, decoded)
```

`resume` does not call `initial`, require machine input, or replay entry,
transition, completion, eventless, raised-event, or emitted-event work that
produced the snapshot. The decoded snapshot is the first published logical
state. A final snapshot immediately yields a completed ref with its output.

Resumption creates a fresh runtime. Invokes owned by active states start once in
normal ancestor/document order and receive `Machine.InitialEvent` as their
lifecycle event. `invokeEffect` runs again, invoked machines start from their
own initial state, and `Machine.after` timers restart from their full declared
duration. Spawned children, queued events, subscriptions, fibers, scopes,
elapsed timer time, child snapshots, and prior `RuntimeSnapshot` status/errors
are not restored. Completion and history metadata remain logical state and are
not replayed. A changed machine definition does not cause `resume` itself to
evaluate newly enabled `always` or `onDone` transitions.

Reactive applications use `AtomMachine.resume(machine, decoded)` for a
service-free machine or `AtomMachine.bind(runtime).resume(machine, decoded)`
for a service-backed machine. These bridges have the same lazy one-runtime-per-
registry ownership and disposal behavior as `AtomMachine.make`.

`ClusterMachine` provides a separate persisted entity adapter. Its process-local
restrictions and delivery guarantees are documented on that API.
restrictions, checkpoint planning, and delivery guarantees are documented on
that API. `Machine.resume` is logical resumption, not durable process or cluster
restoration.

## Current limits

Expand Down
45 changes: 41 additions & 4 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ Choose one helper from the intent, and reach for the lower-level form only when
its extra control is required:

- Bind a shared Atom runtime once with `AtomMachine.bind(runtime)`, then use the
returned `make`. Use `AtomMachine.make(machine)` for a service-free machine.
returned `make` or `resume`. Use `AtomMachine.make(machine)` and
`AtomMachine.resume(machine, snapshot)` for service-free machines.
- Use `Machine.invokeEffect` for a typed one-shot Effect and `Machine.after` for
a timer. Use `Machine.invoke` with `Machine.effect` only for custom child
process behavior or snapshot mapping.
Expand Down Expand Up @@ -668,15 +669,51 @@ Use `Machine.encodeSnapshot` and `Machine.decodeSnapshot` for validated logical
statechart data. Persist machine identity and an application migration/version
next to the encoded snapshot.

The canonical resumption boundary is explicit:

```ts
const encoded = yield* Machine.encodeSnapshot(machine, snapshot)
const decoded = yield* Machine.decodeSnapshot(machine, encoded)
const ref = yield* Machine.resume(machine, decoded)
```

Pass only a decoded `Machine.Snapshot` to `resume`; encoded or arbitrary
transport data belongs at `decodeSnapshot`. Resumption validates and normalizes
the logical snapshot again, then publishes it as the fresh runtime's first
state. It does not call the initial function, require machine input, or include
initial-only failures and services in its Effect type.

Encoding does not preserve:

- running invokes or spawned children;
- subscriptions, timers, or services;
- subscriptions, queued events, fibers, scopes, timers, or services;
- the machine definition;
- application migration metadata.

Do not treat decoding as resuming the previous process. It reconstructs logical
state only.
`resume` reconstructs runtime ownership from logical state only:

- no historical entry, transition, completion, eventless, raise, or emit work
is replayed;
- completion and history records survive but do not retrigger `onDone`;
- active-state invokes start once in ordinary ancestor/document order with
`Machine.InitialEvent`;
- `invokeEffect` restarts, `invokeMachine` creates a fresh child from its normal
initial state, and `Machine.after` restarts its complete duration;
- inactive invokes, spawned children, child snapshots, elapsed timer time, and
prior `RuntimeSnapshot` status/errors are not restored;
- a final logical snapshot creates an immediately completed ref;
- `resume` itself does not evaluate `always` or `onDone`, including transitions
newly enabled by a changed machine definition. Later events use ordinary
planning semantics.

Use `AtomMachine.resume(machine, decoded)` or
`AtomMachine.bind(runtime).resume(machine, decoded)` for the same contract in a
lazy atom bridge. Registry disposal stops the fresh invokes and timers exactly
as it does for `AtomMachine.make`.

This is not durable runtime restoration. `ClusterMachine` has a separate
checkpoint/planning contract and process-local restrictions; do not substitute
`Machine.resume` for cluster recovery.

## Common compiler errors

Expand Down
115 changes: 104 additions & 11 deletions src/AtomMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ type MachineRequirements<InitialR, R, Events, Emits> = ExcludeCompatibleMachineR
Emits
>

type MachineResumeRequirements<R, Events, Emits> = ExcludeCompatibleMachineRuntime<
Machine.ExecutionServices<R>,
Events,
Emits
>

type MachineRuntimeError<E, R> =
| E
| Machine.ActionError<R>
Expand All @@ -75,6 +81,17 @@ type MachineStartError<InitialE, E, InitialR, R, RuntimeError = never> =
| Machine.StoppedError
| RuntimeError

const runMachineAtomEffect = <State, Event, Error, Output, StartError, Requirements>(
get: Atom.AtomContext,
start: Effect.Effect<Machine.MachineRef<State, Event, Error, Output>, StartError, Requirements>
): Effect.Effect<never, StartError, Requirements> =>
Effect.scoped(
Effect.acquireRelease(start, (ref) => ref.stop).pipe(
Effect.tap((ref) => Effect.sync(() => get.setSelf(AsyncResult.success(ref)))),
Effect.flatMap(() => Effect.never)
)
)

const startMachineAtomEffect = <
const States extends Machine.Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.Machine.TaggedSchema>,
Expand Down Expand Up @@ -118,16 +135,13 @@ const startMachineAtomEffect = <
>,
MachineStartError<InitialE, E, InitialR, R>,
MachineRequirements<InitialR, R, Machine.Machine.EventOf<Events>, Machine.Machine.EmitOf<Emits>>
> =>
Effect.scoped(
Effect.acquireRelease(
Machine.start(machine, ...args),
(ref) => ref.stop
).pipe(
Effect.tap((ref) => Effect.sync(() => get.setSelf(AsyncResult.success(ref)))),
Effect.flatMap(() => Effect.never)
)
)
> => runMachineAtomEffect(get, Machine.start(machine, ...args))

const resumeMachineAtomEffect = (
get: Atom.AtomContext,
machine: Machine.Machine.Any,
snapshot: Machine.Machine.Snapshot<any>
) => runMachineAtomEffect(get, Machine.resume(machine as any, snapshot as any))

/**
* Atoms backed by one running machine instance in an `AtomRegistry`.
Expand Down Expand Up @@ -781,12 +795,37 @@ type EnsureBoundRequirements<Services, M extends Machine.Machine.Any> = IsAny<Ma
readonly [BoundRequirementsTypeId]: MissingBoundRequirements<Services, M>
}

type MachineResumeRequirementsOf<M extends Machine.Machine.Any> = MachineResumeRequirements<
Machine.Machine.Services<M>,
Machine.Machine.Event<M>,
Machine.Machine.Emit<M>
>

type MissingBoundResumeRequirements<Services, M extends Machine.Machine.Any> = Exclude<
ExternalRequirements<MachineResumeRequirementsOf<M>>,
Services
>

type EnsureBoundResumeRequirements<Services, M extends Machine.Machine.Any> =
IsAny<MachineResumeRequirementsOf<M>> extends true ? {
readonly [BoundRequirementsTypeId]: MachineResumeRequirementsOf<M>
}
: [MissingBoundResumeRequirements<Services, M>] extends [never] ? unknown
: {
readonly [BoundRequirementsTypeId]: MissingBoundResumeRequirements<Services, M>
}

type EnsureMachineOutputImplementations<M extends Machine.Machine.Any> = IsAny<Machine.Machine.States<M>> extends true ?
{
readonly "~effect/reactivity/AtomMachine/ConcreteMachineRequired": M
}
: Machine.Machine.EnsureOutputImplementations<Machine.Machine.States<M>, Machine.Machine.OutputStates<M>>

type EnsureMachineHistoryImplementations<M extends Machine.Machine.Any> = Machine.Machine.EnsureHistoryImplementations<
Machine.Machine.States<M>,
Machine.Machine.UnhandledStates<M>
>

type MachineInputArgsOf<M extends Machine.Machine.Any> = [
...Machine.Machine.InputArgs<Machine.Machine.Input<M>>
]
Expand All @@ -805,6 +844,14 @@ type MachineAtomOf<M extends Machine.Machine.Any, RuntimeError> = MachineAtom<
>
>

type ResumedMachineAtomOf<M extends Machine.Machine.Any, RuntimeError> = MachineAtom<
Machine.Machine.Snapshot<Machine.Machine.States<M>>,
Machine.Machine.InputEvent<M>,
MachineRuntimeError<Machine.Machine.Error<M>, Machine.Machine.Services<M>>,
Machine.Machine.Output<M>,
Machine.MachineSchemaDecodeError | RuntimeError
>

/**
* An `AtomMachine` factory with one owned Effect runtime.
*
Expand All @@ -827,6 +874,16 @@ export interface Bound<Services, RuntimeError = never> {
& EnsureMachineOutputImplementations<NoInfer<M>>,
...args: MachineInputArgsOf<M>
) => MachineAtomOf<M, RuntimeError>

/** Creates a lazy bridge from a decoded logical snapshot. */
readonly resume: <M extends Machine.Machine.Any>(
machine:
& M
& EnsureBoundResumeRequirements<Services, NoInfer<M>>
& EnsureMachineOutputImplementations<NoInfer<M>>
& EnsureMachineHistoryImplementations<NoInfer<M>>,
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
) => ResumedMachineAtomOf<M, RuntimeError>
}

/**
Expand Down Expand Up @@ -892,6 +949,30 @@ export const make: {
return makeFromRefAtom(ref as any)
}) as any

/**
* Creates a lazy atom bridge from a decoded logical snapshot.
*
* The bridge owns one freshly resumed runtime per `AtomRegistry`, with the same
* lazy start and disposal semantics as {@link make}. The machine initial
* function and its input, errors, and services are not involved.
*
* @category constructors
* @since 4.0.0
*/
export const resume: {
<M extends Machine.Machine.Any>(
machine:
& M
& EnsureNoExternalRequirements<MachineResumeRequirementsOf<NoInfer<M>>>
& EnsureMachineOutputImplementations<NoInfer<M>>
& EnsureMachineHistoryImplementations<NoInfer<M>>,
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
): ResumedMachineAtomOf<M, never>
} = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) => {
const ref = Atom.make((get) => resumeMachineAtomEffect(get, machine, snapshot))
return makeFromRefAtom(ref as any)
}) as any

const makeWithRuntime = (
runtime: Atom.AtomRuntime<any, any>,
machine: Machine.Machine.Any,
Expand All @@ -901,6 +982,15 @@ const makeWithRuntime = (
return makeFromRefAtom(ref as any)
}

const resumeWithRuntime = (
runtime: Atom.AtomRuntime<any, any>,
machine: Machine.Machine.Any,
snapshot: Machine.Machine.Snapshot<any>
): MachineAtom<any, any, any, any, any> => {
const ref = runtime.atom((get) => resumeMachineAtomEffect(get, machine, snapshot))
return makeFromRefAtom(ref as any)
}

/**
* Creates an `AtomMachine` factory that owns a shared Effect runtime.
*
Expand All @@ -919,5 +1009,8 @@ export const bind = <Services, RuntimeError>(
makeWithRuntime(runtime, machine, args)) as Bound<
Services,
RuntimeError
>["make"]
>["make"],
resume:
((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) =>
resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"]
})
82 changes: 82 additions & 0 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7394,3 +7394,85 @@ export const start: <
Machine.EmitOf<Emits>
>
> = internalProcess.start as any

/**
* Starts a fresh managed runtime from a decoded logical snapshot.
*
* **Details**
*
* `resume` validates and normalizes the supplied snapshot before publishing it
* as the first state. It does not call the machine's initial function, replay
* entry or transition actions, re-deliver raised or emitted events, or
* re-evaluate historical completion and eventless transitions. Active-state
* invokes start once in ancestor and document order with {@link InitialEvent};
* delayed invokes restart their complete duration and invoked machines start
* from their own initial state.
*
* Only logical state, completion, and history metadata are resumed. Queues,
* scopes, subscriptions, fibers, spawned children, invoke progress, and prior
* runtime status are process-local and are not restored. A final snapshot
* immediately produces a completed ref with its current-machine output.
*
* Decode encoded data explicitly with {@link decodeSnapshot} before calling
* this function. Stable snapshots are hosted as supplied; newly enabled
* eventless or completion transitions in a changed machine definition are not
* evaluated merely because the runtime was resumed; only ordinary subsequent
* transition planning can enter and stabilize states.
*
* @see {@link decodeSnapshot} for the schema and transport boundary.
* @see {@link start} for ordinary initial startup.
* @category constructors
* @since 4.0.0
*/
export const resume: <
const States extends Machine.StateSchemas,
const Events extends ReadonlyArray<Machine.TaggedSchema>,
const Emits extends ReadonlyArray<Machine.TaggedSchema> = readonly [],
const Input extends Schema.Top = typeof Schema.Void,
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
E = never,
R = never,
InitialE = never,
InitialR = never,
FinalStates extends Machine.StateIdentifier<States> = never,
Output = never,
OutputStates extends Machine.StateIdentifier<States> = never,
InputEvents extends ReadonlyArray<Machine.TaggedSchema> = Events
>(
machine:
& Machine<
States,
Events,
Input,
UnhandledStates,
E,
R,
InitialE,
InitialR,
FinalStates,
Output,
Emits,
OutputStates,
InputEvents
>
& Machine.EnsureOutputImplementations<States, OutputStates>
& Machine.EnsureHistoryImplementations<States, UnhandledStates>,
snapshot: Machine.Snapshot<States>
) => Effect.Effect<
MachineRef<
Machine.Snapshot<States>,
Machine.EventOf<InputEvents>,
| E
| ActionError<R>
| InfiniteTransitionError
| MachineSchemaDecodeError
| StoppedError,
Output
>,
MachineSchemaDecodeError,
ExcludeCompatibleRuntime<
ExecutionServices<R>,
Machine.EventOf<Events>,
Machine.EmitOf<Emits>
>
> = internalProcess.resume as any
Loading