Skip to content

Commit a0260af

Browse files
Add logical runtime resumption
1 parent 5656f41 commit a0260af

11 files changed

Lines changed: 1189 additions & 22 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add first-class logical snapshot resumption with `Machine.resume`, plus lazy
6+
`AtomMachine.resume` and bound-runtime integration. Resumed machines validate
7+
decoded snapshots, preserve logical history and completion metadata, and create
8+
fresh managed invokes, children, scopes, and timers without replaying historical
9+
statechart work.

README.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -597,8 +597,38 @@ contain the machine definition, machine version, services, subscriptions, or
597597
running child processes. Store machine identity and migration/version metadata
598598
alongside it.
599599

600+
Resume a decoded logical snapshot explicitly:
601+
602+
```ts
603+
const encoded = yield * Machine.encodeSnapshot(machine, snapshot)
604+
const decoded = yield * Machine.decodeSnapshot(machine, encoded)
605+
const ref = yield * Machine.resume(machine, decoded)
606+
```
607+
608+
`resume` does not call `initial`, require machine input, or replay entry,
609+
transition, completion, eventless, raised-event, or emitted-event work that
610+
produced the snapshot. The decoded snapshot is the first published logical
611+
state. A final snapshot immediately yields a completed ref with its output.
612+
613+
Resumption creates a fresh runtime. Invokes owned by active states start once in
614+
normal ancestor/document order and receive `Machine.InitialEvent` as their
615+
lifecycle event. `invokeEffect` runs again, invoked machines start from their
616+
own initial state, and `Machine.after` timers restart from their full declared
617+
duration. Spawned children, queued events, subscriptions, fibers, scopes,
618+
elapsed timer time, child snapshots, and prior `RuntimeSnapshot` status/errors
619+
are not restored. Completion and history metadata remain logical state and are
620+
not replayed. A changed machine definition does not cause `resume` itself to
621+
evaluate newly enabled `always` or `onDone` transitions.
622+
623+
Reactive applications use `AtomMachine.resume(machine, decoded)` for a
624+
service-free machine or `AtomMachine.bind(runtime).resume(machine, decoded)`
625+
for a service-backed machine. These bridges have the same lazy one-runtime-per-
626+
registry ownership and disposal behavior as `AtomMachine.make`.
627+
600628
`ClusterMachine` provides a separate persisted entity adapter. Its process-local
601-
restrictions and delivery guarantees are documented on that API.
629+
restrictions, checkpoint planning, and delivery guarantees are documented on
630+
that API. `Machine.resume` is logical resumption, not durable process or cluster
631+
restoration.
602632

603633
## Current limits
604634

docs/agent-guide.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ Choose one helper from the intent, and reach for the lower-level form only when
9292
its extra control is required:
9393

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

672+
The canonical resumption boundary is explicit:
673+
674+
```ts
675+
const encoded = yield* Machine.encodeSnapshot(machine, snapshot)
676+
const decoded = yield* Machine.decodeSnapshot(machine, encoded)
677+
const ref = yield* Machine.resume(machine, decoded)
678+
```
679+
680+
Pass only a decoded `Machine.Snapshot` to `resume`; encoded or arbitrary
681+
transport data belongs at `decodeSnapshot`. Resumption validates and normalizes
682+
the logical snapshot again, then publishes it as the fresh runtime's first
683+
state. It does not call the initial function, require machine input, or include
684+
initial-only failures and services in its Effect type.
685+
671686
Encoding does not preserve:
672687

673688
- running invokes or spawned children;
674-
- subscriptions, timers, or services;
689+
- subscriptions, queued events, fibers, scopes, timers, or services;
675690
- the machine definition;
676691
- application migration metadata.
677692

678-
Do not treat decoding as resuming the previous process. It reconstructs logical
679-
state only.
693+
`resume` reconstructs runtime ownership from logical state only:
694+
695+
- no historical entry, transition, completion, eventless, raise, or emit work
696+
is replayed;
697+
- completion and history records survive but do not retrigger `onDone`;
698+
- active-state invokes start once in ordinary ancestor/document order with
699+
`Machine.InitialEvent`;
700+
- `invokeEffect` restarts, `invokeMachine` creates a fresh child from its normal
701+
initial state, and `Machine.after` restarts its complete duration;
702+
- inactive invokes, spawned children, child snapshots, elapsed timer time, and
703+
prior `RuntimeSnapshot` status/errors are not restored;
704+
- a final logical snapshot creates an immediately completed ref;
705+
- `resume` itself does not evaluate `always` or `onDone`, including transitions
706+
newly enabled by a changed machine definition. Later events use ordinary
707+
planning semantics.
708+
709+
Use `AtomMachine.resume(machine, decoded)` or
710+
`AtomMachine.bind(runtime).resume(machine, decoded)` for the same contract in a
711+
lazy atom bridge. Registry disposal stops the fresh invokes and timers exactly
712+
as it does for `AtomMachine.make`.
713+
714+
This is not durable runtime restoration. `ClusterMachine` has a separate
715+
checkpoint/planning contract and process-local restrictions; do not substitute
716+
`Machine.resume` for cluster recovery.
680717

681718
## Common compiler errors
682719

src/AtomMachine.ts

Lines changed: 104 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ type MachineRequirements<InitialR, R, Events, Emits> = ExcludeCompatibleMachineR
5858
Emits
5959
>
6060

61+
type MachineResumeRequirements<R, Events, Emits> = ExcludeCompatibleMachineRuntime<
62+
Machine.ExecutionServices<R>,
63+
Events,
64+
Emits
65+
>
66+
6167
type MachineRuntimeError<E, R> =
6268
| E
6369
| Machine.ActionError<R>
@@ -75,6 +81,17 @@ type MachineStartError<InitialE, E, InitialR, R, RuntimeError = never> =
7581
| Machine.StoppedError
7682
| RuntimeError
7783

84+
const runMachineAtomEffect = <State, Event, Error, Output, StartError, Requirements>(
85+
get: Atom.AtomContext,
86+
start: Effect.Effect<Machine.MachineRef<State, Event, Error, Output>, StartError, Requirements>
87+
): Effect.Effect<never, StartError, Requirements> =>
88+
Effect.scoped(
89+
Effect.acquireRelease(start, (ref) => ref.stop).pipe(
90+
Effect.tap((ref) => Effect.sync(() => get.setSelf(AsyncResult.success(ref)))),
91+
Effect.flatMap(() => Effect.never)
92+
)
93+
)
94+
7895
const startMachineAtomEffect = <
7996
const States extends Machine.Machine.StateSchemas,
8097
const Events extends ReadonlyArray<Machine.Machine.TaggedSchema>,
@@ -118,16 +135,13 @@ const startMachineAtomEffect = <
118135
>,
119136
MachineStartError<InitialE, E, InitialR, R>,
120137
MachineRequirements<InitialR, R, Machine.Machine.EventOf<Events>, Machine.Machine.EmitOf<Emits>>
121-
> =>
122-
Effect.scoped(
123-
Effect.acquireRelease(
124-
Machine.start(machine, ...args),
125-
(ref) => ref.stop
126-
).pipe(
127-
Effect.tap((ref) => Effect.sync(() => get.setSelf(AsyncResult.success(ref)))),
128-
Effect.flatMap(() => Effect.never)
129-
)
130-
)
138+
> => runMachineAtomEffect(get, Machine.start(machine, ...args))
139+
140+
const resumeMachineAtomEffect = (
141+
get: Atom.AtomContext,
142+
machine: Machine.Machine.Any,
143+
snapshot: Machine.Machine.Snapshot<any>
144+
) => runMachineAtomEffect(get, Machine.resume(machine as any, snapshot as any))
131145

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

798+
type MachineResumeRequirementsOf<M extends Machine.Machine.Any> = MachineResumeRequirements<
799+
Machine.Machine.Services<M>,
800+
Machine.Machine.Event<M>,
801+
Machine.Machine.Emit<M>
802+
>
803+
804+
type MissingBoundResumeRequirements<Services, M extends Machine.Machine.Any> = Exclude<
805+
ExternalRequirements<MachineResumeRequirementsOf<M>>,
806+
Services
807+
>
808+
809+
type EnsureBoundResumeRequirements<Services, M extends Machine.Machine.Any> =
810+
IsAny<MachineResumeRequirementsOf<M>> extends true ? {
811+
readonly [BoundRequirementsTypeId]: MachineResumeRequirementsOf<M>
812+
}
813+
: [MissingBoundResumeRequirements<Services, M>] extends [never] ? unknown
814+
: {
815+
readonly [BoundRequirementsTypeId]: MissingBoundResumeRequirements<Services, M>
816+
}
817+
784818
type EnsureMachineOutputImplementations<M extends Machine.Machine.Any> = IsAny<Machine.Machine.States<M>> extends true ?
785819
{
786820
readonly "~effect/reactivity/AtomMachine/ConcreteMachineRequired": M
787821
}
788822
: Machine.Machine.EnsureOutputImplementations<Machine.Machine.States<M>, Machine.Machine.OutputStates<M>>
789823

824+
type EnsureMachineHistoryImplementations<M extends Machine.Machine.Any> = Machine.Machine.EnsureHistoryImplementations<
825+
Machine.Machine.States<M>,
826+
Machine.Machine.UnhandledStates<M>
827+
>
828+
790829
type MachineInputArgsOf<M extends Machine.Machine.Any> = [
791830
...Machine.Machine.InputArgs<Machine.Machine.Input<M>>
792831
]
@@ -805,6 +844,14 @@ type MachineAtomOf<M extends Machine.Machine.Any, RuntimeError> = MachineAtom<
805844
>
806845
>
807846

847+
type ResumedMachineAtomOf<M extends Machine.Machine.Any, RuntimeError> = MachineAtom<
848+
Machine.Machine.Snapshot<Machine.Machine.States<M>>,
849+
Machine.Machine.InputEvent<M>,
850+
MachineRuntimeError<Machine.Machine.Error<M>, Machine.Machine.Services<M>>,
851+
Machine.Machine.Output<M>,
852+
Machine.MachineSchemaDecodeError | RuntimeError
853+
>
854+
808855
/**
809856
* An `AtomMachine` factory with one owned Effect runtime.
810857
*
@@ -827,6 +874,16 @@ export interface Bound<Services, RuntimeError = never> {
827874
& EnsureMachineOutputImplementations<NoInfer<M>>,
828875
...args: MachineInputArgsOf<M>
829876
) => MachineAtomOf<M, RuntimeError>
877+
878+
/** Creates a lazy bridge from a decoded logical snapshot. */
879+
readonly resume: <M extends Machine.Machine.Any>(
880+
machine:
881+
& M
882+
& EnsureBoundResumeRequirements<Services, NoInfer<M>>
883+
& EnsureMachineOutputImplementations<NoInfer<M>>
884+
& EnsureMachineHistoryImplementations<NoInfer<M>>,
885+
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
886+
) => ResumedMachineAtomOf<M, RuntimeError>
830887
}
831888

832889
/**
@@ -892,6 +949,30 @@ export const make: {
892949
return makeFromRefAtom(ref as any)
893950
}) as any
894951

952+
/**
953+
* Creates a lazy atom bridge from a decoded logical snapshot.
954+
*
955+
* The bridge owns one freshly resumed runtime per `AtomRegistry`, with the same
956+
* lazy start and disposal semantics as {@link make}. The machine initial
957+
* function and its input, errors, and services are not involved.
958+
*
959+
* @category constructors
960+
* @since 4.0.0
961+
*/
962+
export const resume: {
963+
<M extends Machine.Machine.Any>(
964+
machine:
965+
& M
966+
& EnsureNoExternalRequirements<MachineResumeRequirementsOf<NoInfer<M>>>
967+
& EnsureMachineOutputImplementations<NoInfer<M>>
968+
& EnsureMachineHistoryImplementations<NoInfer<M>>,
969+
snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
970+
): ResumedMachineAtomOf<M, never>
971+
} = ((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) => {
972+
const ref = Atom.make((get) => resumeMachineAtomEffect(get, machine, snapshot))
973+
return makeFromRefAtom(ref as any)
974+
}) as any
975+
895976
const makeWithRuntime = (
896977
runtime: Atom.AtomRuntime<any, any>,
897978
machine: Machine.Machine.Any,
@@ -901,6 +982,15 @@ const makeWithRuntime = (
901982
return makeFromRefAtom(ref as any)
902983
}
903984

985+
const resumeWithRuntime = (
986+
runtime: Atom.AtomRuntime<any, any>,
987+
machine: Machine.Machine.Any,
988+
snapshot: Machine.Machine.Snapshot<any>
989+
): MachineAtom<any, any, any, any, any> => {
990+
const ref = runtime.atom((get) => resumeMachineAtomEffect(get, machine, snapshot))
991+
return makeFromRefAtom(ref as any)
992+
}
993+
904994
/**
905995
* Creates an `AtomMachine` factory that owns a shared Effect runtime.
906996
*
@@ -919,5 +1009,8 @@ export const bind = <Services, RuntimeError>(
9191009
makeWithRuntime(runtime, machine, args)) as Bound<
9201010
Services,
9211011
RuntimeError
922-
>["make"]
1012+
>["make"],
1013+
resume:
1014+
((machine: Machine.Machine.Any, snapshot: Machine.Machine.Snapshot<any>) =>
1015+
resumeWithRuntime(runtime, machine, snapshot)) as Bound<Services, RuntimeError>["resume"]
9231016
})

src/Machine.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7394,3 +7394,85 @@ export const start: <
73947394
Machine.EmitOf<Emits>
73957395
>
73967396
> = internalProcess.start as any
7397+
7398+
/**
7399+
* Starts a fresh managed runtime from a decoded logical snapshot.
7400+
*
7401+
* **Details**
7402+
*
7403+
* `resume` validates and normalizes the supplied snapshot before publishing it
7404+
* as the first state. It does not call the machine's initial function, replay
7405+
* entry or transition actions, re-deliver raised or emitted events, or
7406+
* re-evaluate historical completion and eventless transitions. Active-state
7407+
* invokes start once in ancestor and document order with {@link InitialEvent};
7408+
* delayed invokes restart their complete duration and invoked machines start
7409+
* from their own initial state.
7410+
*
7411+
* Only logical state, completion, and history metadata are resumed. Queues,
7412+
* scopes, subscriptions, fibers, spawned children, invoke progress, and prior
7413+
* runtime status are process-local and are not restored. A final snapshot
7414+
* immediately produces a completed ref with its current-machine output.
7415+
*
7416+
* Decode encoded data explicitly with {@link decodeSnapshot} before calling
7417+
* this function. Stable snapshots are hosted as supplied; newly enabled
7418+
* eventless or completion transitions in a changed machine definition are not
7419+
* evaluated merely because the runtime was resumed; only ordinary subsequent
7420+
* transition planning can enter and stabilize states.
7421+
*
7422+
* @see {@link decodeSnapshot} for the schema and transport boundary.
7423+
* @see {@link start} for ordinary initial startup.
7424+
* @category constructors
7425+
* @since 4.0.0
7426+
*/
7427+
export const resume: <
7428+
const States extends Machine.StateSchemas,
7429+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
7430+
const Emits extends ReadonlyArray<Machine.TaggedSchema> = readonly [],
7431+
const Input extends Schema.Top = typeof Schema.Void,
7432+
UnhandledStates extends Machine.StateIdentifier<States> = Machine.StateIdentifier<States>,
7433+
E = never,
7434+
R = never,
7435+
InitialE = never,
7436+
InitialR = never,
7437+
FinalStates extends Machine.StateIdentifier<States> = never,
7438+
Output = never,
7439+
OutputStates extends Machine.StateIdentifier<States> = never,
7440+
InputEvents extends ReadonlyArray<Machine.TaggedSchema> = Events
7441+
>(
7442+
machine:
7443+
& Machine<
7444+
States,
7445+
Events,
7446+
Input,
7447+
UnhandledStates,
7448+
E,
7449+
R,
7450+
InitialE,
7451+
InitialR,
7452+
FinalStates,
7453+
Output,
7454+
Emits,
7455+
OutputStates,
7456+
InputEvents
7457+
>
7458+
& Machine.EnsureOutputImplementations<States, OutputStates>
7459+
& Machine.EnsureHistoryImplementations<States, UnhandledStates>,
7460+
snapshot: Machine.Snapshot<States>
7461+
) => Effect.Effect<
7462+
MachineRef<
7463+
Machine.Snapshot<States>,
7464+
Machine.EventOf<InputEvents>,
7465+
| E
7466+
| ActionError<R>
7467+
| InfiniteTransitionError
7468+
| MachineSchemaDecodeError
7469+
| StoppedError,
7470+
Output
7471+
>,
7472+
MachineSchemaDecodeError,
7473+
ExcludeCompatibleRuntime<
7474+
ExecutionServices<R>,
7475+
Machine.EventOf<Events>,
7476+
Machine.EmitOf<Emits>
7477+
>
7478+
> = internalProcess.resume as any

0 commit comments

Comments
 (0)