effect-machine 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,6 +98,7 @@ const actor =
98
98
  }),
99
99
  },
100
100
  });
101
+ yield * actor.start;
101
102
  ```
102
103
 
103
104
  The same machine can run with different slot implementations in tests, local apps, or production. Slots are accepted everywhere the machine runs:
@@ -109,7 +110,7 @@ The same machine can run with different slot implementations in tests, local app
109
110
 
110
111
  ## Running Actors
111
112
 
112
- `Machine.spawn` gives you a live actor with a queue and lifecycle management.
113
+ `Machine.spawn` allocates an actor but does not start it. Call `actor.start` to fork the event loop, background effects, and spawn effects. Events sent before `start()` are queued.
113
114
 
114
115
  ```ts
115
116
  const program = Effect.gen(function* () {
@@ -123,6 +124,7 @@ const program = Effect.gen(function* () {
123
124
  ),
124
125
  },
125
126
  });
127
+ yield* actor.start;
126
128
 
127
129
  yield* actor.send(CheckoutEvent.Submit);
128
130
  const finalState = yield* actor.awaitFinal;
@@ -133,6 +135,7 @@ Effect.runPromise(Effect.scoped(program));
133
135
 
134
136
  Key actor operations:
135
137
 
138
+ - `start` forks the event loop (idempotent, required after `Machine.spawn`)
136
139
  - `send(event)` queues and returns immediately
137
140
  - `call(event)` returns full transition info
138
141
  - `ask(event)` returns a typed domain reply (requires `Event.reply(...)`)
@@ -140,7 +143,7 @@ Key actor operations:
140
143
  - `stop` interrupts now; `drain` processes the remaining queue first
141
144
  - `watch(other)` completes when another actor stops
142
145
 
143
- For named actors or shared lookup, use an actor system:
146
+ For named actors or shared lookup, use an actor system. `system.spawn` auto-starts — no `actor.start` needed:
144
147
 
145
148
  ```ts
146
149
  import { ActorSystemDefault, ActorSystemService } from "effect-machine";
package/dist/actor.d.ts CHANGED
@@ -3,7 +3,7 @@ import { ActorStoppedError, DuplicateActorError, NoReplyError } from "./errors.j
3
3
  import { ProvideSlots, SlotsDef } from "./slot.js";
4
4
  import { ActorExit, Supervision } from "./supervision.js";
5
5
  import { ProcessEventError, ProcessEventHooks, ProcessEventResult, processEventCore, resolveTransition, runSpawnEffects } from "./internal/transition.js";
6
- import { Machine, PersistConfig } from "./machine.js";
6
+ import { Lifecycle, Machine } from "./machine.js";
7
7
  import { RuntimeQueuedEvent } from "./internal/runtime.js";
8
8
  import { Deferred, Effect, Layer, Option, PubSub, Queue, Ref, Scope, ServiceMap, Stream, SubscriptionRef } from "effect";
9
9
 
@@ -54,6 +54,15 @@ interface ActorRef<State extends {
54
54
  readonly state: SubscriptionRef.SubscriptionRef<State>;
55
55
  /** Stop the actor gracefully. */
56
56
  readonly stop: Effect.Effect<void>;
57
+ /**
58
+ * Start the actor — fork event loop, background effects, spawn effects.
59
+ * Idempotent: first caller runs initialization, subsequent callers await completion.
60
+ * Events sent before start() are queued and processed when start() runs.
61
+ *
62
+ * Called automatically by `system.spawn`. For `Machine.spawn`, the caller
63
+ * must call `start` explicitly.
64
+ */
65
+ readonly start: Effect.Effect<void>;
57
66
  /** Get current state snapshot. */
58
67
  readonly snapshot: Effect.Effect<State>;
59
68
  /** Check if current state matches tag. */
@@ -161,7 +170,7 @@ interface ActorSystem {
161
170
  }, R, SD extends SlotsDef = Record<string, never>>(id: string, machine: Machine<S, E, R, any, any, SD>, options?: {
162
171
  readonly supervision?: Supervision.Policy;
163
172
  readonly slots?: ProvideSlots<SD, any>;
164
- readonly persist?: PersistConfig<S>;
173
+ readonly lifecycle?: Lifecycle<S, E>;
165
174
  }) => Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
166
175
  /**
167
176
  * Get an existing actor by ID
@@ -204,7 +213,7 @@ declare const buildActorRefCore: <S extends {
204
213
  readonly _tag: string;
205
214
  }, E extends {
206
215
  readonly _tag: string;
207
- }, R, SD extends SlotsDef>(id: string, machine: Machine<S, E, R, any, any, SD>, stateRef: SubscriptionRef.SubscriptionRef<S>, eventQueueRef: Ref.Ref<Queue.Queue<QueuedEvent<E>>>, stoppedRef: Ref.Ref<boolean>, listeners: Listeners<S>, stop: Effect.Effect<void>, system: ActorSystem, childrenMap: ReadonlyMap<string, ActorRef<AnyState, unknown>>, pendingReplies: Set<Deferred.Deferred<unknown, unknown>>, transitionsPubSub: PubSub.PubSub<TransitionInfo<S, E>> | undefined, exitDeferred: Deferred.Deferred<ActorExit<S>, never>) => ActorRef<S, E>;
216
+ }, R, SD extends SlotsDef>(id: string, machine: Machine<S, E, R, any, any, SD>, stateRef: SubscriptionRef.SubscriptionRef<S>, eventQueueRef: Ref.Ref<Queue.Queue<QueuedEvent<E>>>, stoppedRef: Ref.Ref<boolean>, listeners: Listeners<S>, stop: Effect.Effect<void>, start: Effect.Effect<void>, system: ActorSystem, childrenMap: ReadonlyMap<string, ActorRef<AnyState, unknown>>, pendingReplies: Set<Deferred.Deferred<unknown, unknown>>, transitionsPubSub: PubSub.PubSub<TransitionInfo<S, E>> | undefined, exitDeferred: Deferred.Deferred<ActorExit<S>, never>) => ActorRef<S, E>;
208
217
  /**
209
218
  * Create and start an actor for a machine.
210
219
  * Delegates to the shared runtime kernel with actor-specific lifecycle hooks.
@@ -216,7 +225,7 @@ declare const createActor: <S extends {
216
225
  }, R, SD extends SlotsDef>(id: string, machine: Machine<S, E, R, any, any, SD>, options?: {
217
226
  initialState?: S;
218
227
  supervision?: Supervision.Policy;
219
- persist?: PersistConfig<S>; /** @internal Called by system after each restart — emits ActorRestarted system event */
228
+ lifecycle?: Lifecycle<S, E>; /** @internal Called by system after each restart — emits ActorRestarted system event */
220
229
  onRestart?: (generation: number, exit: ActorExit<unknown>) => Effect.Effect<void>;
221
230
  } | undefined) => Effect.Effect<ActorRef<S, E>, never, never>;
222
231
  /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
package/dist/actor.js CHANGED
@@ -29,7 +29,7 @@ const notifyListeners = (listeners, state) => {
29
29
  /**
30
30
  * Build core ActorRef methods.
31
31
  */
32
- const buildActorRefCore = (id, machine, stateRef, eventQueueRef, stoppedRef, listeners, stop, system, childrenMap, pendingReplies, transitionsPubSub, exitDeferred) => {
32
+ const buildActorRefCore = (id, machine, stateRef, eventQueueRef, stoppedRef, listeners, stop, start, system, childrenMap, pendingReplies, transitionsPubSub, exitDeferred) => {
33
33
  const send = Effect.fn("effect-machine.actor.send")(function* (event) {
34
34
  if (yield* Ref.get(stoppedRef)) return;
35
35
  const q = yield* Ref.get(eventQueueRef);
@@ -116,6 +116,7 @@ const buildActorRefCore = (id, machine, stateRef, eventQueueRef, stoppedRef, lis
116
116
  ask,
117
117
  state: stateRef,
118
118
  stop,
119
+ start,
119
120
  snapshot,
120
121
  matches,
121
122
  can,
@@ -191,18 +192,6 @@ const buildInspectionHooks = (actorId, inspector) => ({
191
192
  }))
192
193
  });
193
194
  /**
194
- * Load persisted state and run onRestore hook if present.
195
- * Returns the resolved initial state (loaded, restored, or fallback to machineInitial).
196
- * @internal
197
- */
198
- const loadAndRestore = (persist, machineInitial) => Effect.gen(function* () {
199
- const loaded = yield* persist.load();
200
- if (Option.isNone(loaded)) return machineInitial;
201
- if (persist.onRestore === void 0) return loaded.value;
202
- const restored = yield* persist.onRestore(loaded.value, { initial: machineInitial });
203
- return Option.getOrElse(restored, () => machineInitial);
204
- });
205
- /**
206
195
  * Resolve actor system from context, creating an implicit one if none exists.
207
196
  * @internal
208
197
  */
@@ -225,7 +214,6 @@ const resolveActorSystem = Effect.fn("effect-machine.resolveActorSystem")(functi
225
214
  */
226
215
  const runSupervisionLoop = (params) => Effect.gen(function* () {
227
216
  const step = yield* Schedule.toStepWithSleep(params.supervision.schedule);
228
- let generation = 0;
229
217
  while (true) {
230
218
  const currentRuntime = params.runtimeRef.current;
231
219
  if (currentRuntime === void 0) return;
@@ -242,7 +230,17 @@ const runSupervisionLoop = (params) => Effect.gen(function* () {
242
230
  yield* Deferred.succeed(params.terminalExitDeferred, generationExit);
243
231
  return;
244
232
  }
245
- const restartState = params.persist !== void 0 ? yield* loadAndRestore(params.persist, params.machine.initial) : params.machine.initial;
233
+ const nextGeneration = params.generationRef.get() + 1;
234
+ params.generationRef.set(nextGeneration);
235
+ let restartState = params.machine.initial;
236
+ if (params.lifecycle?.recovery !== void 0) {
237
+ const resolved = yield* params.lifecycle.recovery.resolve({
238
+ actorId: params.id,
239
+ generation: nextGeneration,
240
+ machineInitial: params.machine.initial
241
+ });
242
+ if (Option.isSome(resolved)) restartState = resolved.value;
243
+ }
246
244
  yield* settlePendingReplies(params.pendingReplies, params.id);
247
245
  const freshQueue = yield* Queue.unbounded();
248
246
  yield* Ref.set(params.eventQueueRef, freshQueue);
@@ -255,8 +253,8 @@ const runSupervisionLoop = (params) => Effect.gen(function* () {
255
253
  } }) : params.machine;
256
254
  const newRuntime = yield* params.spawnGeneration(machineForRestart);
257
255
  params.runtimeRef.current = newRuntime;
258
- generation++;
259
- if (params.onRestart !== void 0) yield* params.onRestart(generation, generationExit);
256
+ yield* newRuntime.start;
257
+ if (params.onRestart !== void 0) yield* params.onRestart(nextGeneration, generationExit);
260
258
  notifyListeners(params.listeners, restartState);
261
259
  }
262
260
  });
@@ -265,8 +263,8 @@ const runSupervisionLoop = (params) => Effect.gen(function* () {
265
263
  * Delegates to the shared runtime kernel with actor-specific lifecycle hooks.
266
264
  */
267
265
  const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machine, options) {
268
- const persist = options?.persist;
269
- const initial = options?.initialState ?? (persist !== void 0 ? yield* loadAndRestore(persist, machine.initial) : machine.initial);
266
+ const lifecycle = options?.lifecycle;
267
+ const initial = options?.initialState ?? machine.initial;
270
268
  yield* Effect.annotateCurrentSpan("effect_machine.actor.id", id);
271
269
  yield* Effect.annotateCurrentSpan("effect_machine.actor.initial_state", initial._tag);
272
270
  const { system, implicitSystemScope } = yield* resolveActorSystem();
@@ -275,12 +273,6 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
275
273
  const pendingReplies = /* @__PURE__ */ new Set();
276
274
  const listeners = /* @__PURE__ */ new Set();
277
275
  const transitionsPubSub = yield* PubSub.unbounded();
278
- yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
279
- type: "@machine.spawn",
280
- actorId: id,
281
- initialState: initial,
282
- timestamp
283
- }));
284
276
  const hooks = inspectorValue !== void 0 ? buildInspectionHooks(id, inspectorValue) : void 0;
285
277
  const machineWithState = initial !== machine.initial ? Object.create(machine, { initial: {
286
278
  value: initial,
@@ -292,9 +284,11 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
292
284
  const eventQueueRef = yield* Ref.make(initialQueue);
293
285
  const terminalExitDeferred = yield* Deferred.make();
294
286
  let stopEmitted = false;
287
+ let generation = 0;
295
288
  const runtimeRef = { current: void 0 };
289
+ const supervisorFiberRef = { current: void 0 };
296
290
  /** Build lifecycle hooks for a generation */
297
- const buildLifecycle = () => {
291
+ const buildRuntimeLifecycle = () => {
298
292
  stopEmitted = false;
299
293
  return {
300
294
  onEvent: inspectorValue !== void 0 ? (state, event) => emitWithTimestamp(inspectorValue, (timestamp) => ({
@@ -304,10 +298,17 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
304
298
  event,
305
299
  timestamp
306
300
  })) : void 0,
307
- onStateChange: (result, _event) => Effect.gen(function* () {
301
+ onStateChange: (result, event) => Effect.gen(function* () {
308
302
  notifyListeners(listeners, result.newState);
309
- if (persist !== void 0 && result.transitioned) {
310
- if (persist.shouldSave === void 0 || persist.shouldSave(result.newState, result.previousState)) yield* persist.save(result.newState);
303
+ if (lifecycle?.durability !== void 0 && result.transitioned) {
304
+ const durability = lifecycle.durability;
305
+ if (durability.shouldSave === void 0 || durability.shouldSave(result.newState, result.previousState)) yield* durability.save({
306
+ actorId: id,
307
+ generation,
308
+ previousState: result.previousState,
309
+ nextState: result.newState,
310
+ event
311
+ });
311
312
  }
312
313
  yield* Effect.annotateCurrentSpan("effect_machine.transition.matched", true);
313
314
  if (result.lifecycleRan) {
@@ -360,7 +361,7 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
360
361
  stoppedRef,
361
362
  eventQueue: currentQueue
362
363
  },
363
- lifecycle: buildLifecycle(),
364
+ lifecycle: buildRuntimeLifecycle(),
364
365
  wrapProcess: (state, event, inner) => Effect.withSpan("effect-machine.event.process", { attributes: {
365
366
  "effect_machine.actor.id": id,
366
367
  "effect_machine.state.current": state._tag,
@@ -374,34 +375,67 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
374
375
  }));
375
376
  })
376
377
  })));
377
- const runtime = yield* spawnGeneration(machineWithState);
378
- runtimeRef.current = runtime;
378
+ runtimeRef.current = yield* spawnGeneration(machineWithState);
379
379
  const supervision = options?.supervision;
380
- let supervisorFiber;
381
- if (supervision !== void 0) supervisorFiber = yield* Effect.forkDetach(runSupervisionLoop({
382
- supervision,
383
- machine,
384
- id,
385
- runtimeRef,
386
- terminalExitDeferred,
387
- pendingReplies,
388
- eventQueueRef,
389
- stateRef,
390
- stoppedRef,
391
- childrenMap,
392
- listeners,
393
- spawnGeneration,
394
- persist,
395
- onRestart: options?.onRestart
396
- }));
397
- else yield* Effect.forkDetach(Deferred.await(runtime.exitDeferred).pipe(Effect.tap((exit) => Deferred.succeed(terminalExitDeferred, exit))));
398
- return buildActorRefCore(id, machine, stateRef, eventQueueRef, stoppedRef, listeners, Effect.gen(function* () {
399
- if (supervisorFiber !== void 0) yield* Fiber.interrupt(supervisorFiber);
380
+ const stop = Effect.gen(function* () {
381
+ if (supervisorFiberRef.current !== void 0) yield* Fiber.interrupt(supervisorFiberRef.current);
400
382
  const currentRuntime = runtimeRef.current;
401
383
  if (currentRuntime !== void 0) yield* currentRuntime.stop;
402
384
  yield* Deferred.succeed(terminalExitDeferred, { _tag: "Stopped" });
403
385
  if (implicitSystemScope !== void 0) yield* Scope.close(implicitSystemScope, Exit.void);
404
- }).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid), system, childrenMap, pendingReplies, transitionsPubSub, terminalExitDeferred);
386
+ }).pipe(Effect.withSpan("effect-machine.actor.stop"), Effect.asVoid);
387
+ const isHydrated = options?.initialState !== void 0;
388
+ return buildActorRefCore(id, machine, stateRef, eventQueueRef, stoppedRef, listeners, stop, Effect.gen(function* () {
389
+ if (lifecycle?.recovery !== void 0 && !isHydrated) {
390
+ const resolved = yield* lifecycle.recovery.resolve({
391
+ actorId: id,
392
+ generation,
393
+ machineInitial: machine.initial
394
+ });
395
+ if (Option.isSome(resolved)) {
396
+ yield* SubscriptionRef.set(stateRef, resolved.value);
397
+ runtimeRef.current = yield* spawnGeneration(Object.create(machine, { initial: {
398
+ value: resolved.value,
399
+ enumerable: true
400
+ } }));
401
+ }
402
+ }
403
+ const currentState = yield* SubscriptionRef.get(stateRef);
404
+ yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
405
+ type: "@machine.spawn",
406
+ actorId: id,
407
+ initialState: currentState,
408
+ timestamp
409
+ }));
410
+ if (supervision !== void 0) supervisorFiberRef.current = yield* Effect.forkDetach(runSupervisionLoop({
411
+ supervision,
412
+ machine,
413
+ id,
414
+ runtimeRef,
415
+ terminalExitDeferred,
416
+ pendingReplies,
417
+ eventQueueRef,
418
+ stateRef,
419
+ stoppedRef,
420
+ childrenMap,
421
+ listeners,
422
+ spawnGeneration,
423
+ lifecycle,
424
+ generationRef: {
425
+ get: () => generation,
426
+ set: (g) => {
427
+ generation = g;
428
+ }
429
+ },
430
+ onRestart: options?.onRestart
431
+ }));
432
+ else {
433
+ const currentRuntime = runtimeRef.current;
434
+ if (currentRuntime !== void 0) yield* Effect.forkDetach(Deferred.await(currentRuntime.exitDeferred).pipe(Effect.tap((exit) => Deferred.succeed(terminalExitDeferred, exit))));
435
+ }
436
+ const currentRuntime = runtimeRef.current;
437
+ if (currentRuntime !== void 0) yield* currentRuntime.start;
438
+ }).pipe(Effect.withSpan("effect-machine.actor.start"), Effect.asVoid), system, childrenMap, pendingReplies, transitionsPubSub, terminalExitDeferred);
405
439
  });
406
440
  /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
407
441
  const settlePendingReplies = (pendingReplies, actorId) => Effect.sync(() => {
@@ -462,7 +496,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
462
496
  let actorRef;
463
497
  const actor = yield* createActor(id, materialized, {
464
498
  supervision: spawnOptions?.supervision,
465
- persist: spawnOptions?.persist,
499
+ lifecycle: spawnOptions?.lifecycle,
466
500
  onRestart: spawnOptions?.supervision !== void 0 ? (generation, exit) => actorRef !== void 0 ? emitSystemEvent({
467
501
  _tag: "ActorRestarted",
468
502
  id,
@@ -472,7 +506,9 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
472
506
  }) : Effect.void : void 0
473
507
  });
474
508
  actorRef = actor;
475
- return yield* registerActor(id, actor);
509
+ yield* registerActor(id, actor);
510
+ yield* actor.start.pipe(Effect.catchCause((cause) => actor.stop.pipe(Effect.andThen(Effect.failCause(cause)))));
511
+ return actor;
476
512
  });
477
513
  const spawn = (id, machine, options) => withSpawnGate(spawnRegular(id, machine, options));
478
514
  const get = Effect.fn("effect-machine.actorSystem.get")(function* (id) {
@@ -60,6 +60,7 @@ const EntityMachine = { layer: (entity, machine, options) => {
60
60
  eventQueue
61
61
  }
62
62
  });
63
+ yield* runtime.start;
63
64
  if (persistCtx.adapter !== void 0) {
64
65
  const { adapter: pAdapter, key } = persistCtx;
65
66
  const strategy = persistence?.strategy ?? "snapshot";
package/dist/index.d.ts CHANGED
@@ -4,8 +4,8 @@ import { ActorStoppedError, AssertionError, DuplicateActorError, InvalidSchemaEr
4
4
  import { HasSlotKeys, MachineContext, ProvideSlots, Slot, SlotCall, SlotCalls, SlotFnDef, SlotHandler, SlotInvocation, SlotRequest, SlotResult, SlotsDef, SlotsSchema } from "./slot.js";
5
5
  import { ActorExit, CellPhase, DefectPhase, Supervision } from "./supervision.js";
6
6
  import { ProcessEventResult } from "./internal/transition.js";
7
- import { BackgroundEffect, HandlerContext, Machine, MachineRef, MakeConfig, SpawnEffect, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, machine_d_exports } from "./machine.js";
7
+ import { BackgroundEffect, Durability, DurabilityCommit, HandlerContext, Lifecycle, Machine, MachineRef, MakeConfig, Recovery, RecoveryContext, SpawnEffect, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, machine_d_exports } from "./machine.js";
8
8
  import { ActorRef, ActorRefSync, ActorSystem, Default, SystemEvent, SystemEventListener, TransitionInfo } from "./actor.js";
9
9
  import { SimulationResult, TestHarness, TestHarnessOptions, assertNeverReaches, assertPath, assertReaches, createTestHarness, simulate } from "./testing.js";
10
10
  import { AnyInspectionEvent, EffectEvent, ErrorEvent, EventReceivedEvent, InspectionEvent, Inspector, InspectorHandler, SpawnEvent, StopEvent, TaskEvent, TracingInspectorOptions, TransitionEvent, collectingInspector, combineInspectors, consoleInspector, makeInspector, makeInspectorEffect, tracingInspector } from "./inspection.js";
11
- export { ActorExit, type ActorRef, type ActorRefSync, ActorStoppedError, type ActorSystem, Default as ActorSystemDefault, ActorSystem as ActorSystemService, type AnyInspectionEvent, AssertionError, type BackgroundEffect, type CellPhase, type DefectPhase, type DeferReplyResult, DuplicateActorError, type EffectEvent, type ErrorEvent, Event, type EventReceivedEvent, type HandlerContext, type HasSlotKeys, type InspectionEvent, type Inspector, type InspectorHandler, Inspector as InspectorService, InvalidSchemaError, machine_d_exports as Machine, type MachineContext, type MachineEventSchema, type MachineRef, type MachineStateSchema, type Machine as MachineType, type MakeConfig, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, type ProcessEventResult, type ProvideSlots, ProvisionValidationError, type ReplyFields, type ReplyResult, type SimulationResult, Slot, type SlotCall, type SlotCalls, SlotCodecError, type SlotFnDef, type SlotHandler, type SlotInvocation, SlotProvisionError, type SlotRequest, type SlotResult, type SlotsDef, type SlotsSchema, type SpawnEffect, type SpawnEvent, State, type StateHandlerContext, type StopEvent, Supervision, type SystemEvent, type SystemEventListener, type TaskEvent, type TaskOptions, type TestHarness, type TestHarnessOptions, type TimeoutConfig, type TracingInspectorOptions, type Transition, type TransitionEvent, type TransitionInfo, VersionConflictError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
11
+ export { ActorExit, type ActorRef, type ActorRefSync, ActorStoppedError, type ActorSystem, Default as ActorSystemDefault, ActorSystem as ActorSystemService, type AnyInspectionEvent, AssertionError, type BackgroundEffect, type CellPhase, type DefectPhase, type DeferReplyResult, DuplicateActorError, type Durability, type DurabilityCommit, type EffectEvent, type ErrorEvent, Event, type EventReceivedEvent, type HandlerContext, type HasSlotKeys, type InspectionEvent, type Inspector, type InspectorHandler, Inspector as InspectorService, InvalidSchemaError, type Lifecycle, machine_d_exports as Machine, type MachineContext, type MachineEventSchema, type MachineRef, type MachineStateSchema, type Machine as MachineType, type MakeConfig, MissingMatchHandlerError, MissingSchemaError, NoReplyError, PersistenceError, type ProcessEventResult, type ProvideSlots, ProvisionValidationError, type Recovery, type RecoveryContext, type ReplyFields, type ReplyResult, type SimulationResult, Slot, type SlotCall, type SlotCalls, SlotCodecError, type SlotFnDef, type SlotHandler, type SlotInvocation, SlotProvisionError, type SlotRequest, type SlotResult, type SlotsDef, type SlotsSchema, type SpawnEffect, type SpawnEvent, State, type StateHandlerContext, type StopEvent, Supervision, type SystemEvent, type SystemEventListener, type TaskEvent, type TaskOptions, type TestHarness, type TestHarnessOptions, type TimeoutConfig, type TracingInspectorOptions, type Transition, type TransitionEvent, type TransitionInfo, VersionConflictError, assertNeverReaches, assertPath, assertReaches, collectingInspector, combineInspectors, consoleInspector, createTestHarness, makeInspector, makeInspectorEffect, simulate, tracingInspector };
@@ -55,6 +55,12 @@ interface RuntimeHandle<S, E> {
55
55
  readonly isStopped: Effect.Effect<boolean>;
56
56
  /** Stop the runtime (interrupt event loop, clean up) */
57
57
  readonly stop: Effect.Effect<void>;
58
+ /**
59
+ * Start the runtime — fork event loop, background effects, spawn effects.
60
+ * Idempotent: first caller runs initialization, subsequent callers await completion.
61
+ * Events sent before start() are queued and processed when start() runs.
62
+ */
63
+ readonly start: Effect.Effect<void>;
58
64
  /** @internal — raw event queue for direct enqueue (actor.ts uses this for pendingReplies tracking) */
59
65
  readonly _queue: Queue.Queue<RuntimeQueuedEvent<E>>;
60
66
  /** @internal — stopped ref for direct access */
@@ -137,6 +143,19 @@ declare const createRuntime: <S extends {
137
143
  readonly _tag: string;
138
144
  }, E extends {
139
145
  readonly _tag: string;
140
- }, R, SD extends SlotsDef>(machine: Machine<S, E, R, any, any, SD>, system: ActorSystem, config: RuntimeConfig<S, E>) => Effect.Effect<RuntimeHandle<S, E>, never, Scope.Scope | Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
146
+ }, R, SD extends SlotsDef>(machine: Machine<S, E, R, any, any, SD>, system: ActorSystem, config: RuntimeConfig<S, E>) => Effect.Effect<{
147
+ stop: Effect.Effect<void, never, never>;
148
+ start: Effect.Effect<void, unknown, Exclude<R, MachineContext<S, E, MachineRef<E>>> | Exclude<Exclude<R, MachineContext<S, E, MachineRef<E>>>, Scope.Scope>>;
149
+ send: (event: E) => Effect.Effect<void>;
150
+ sendWait: (event: E) => Effect.Effect<void, unknown>;
151
+ ask: (event: E) => Effect.Effect<unknown, NoReplyError>;
152
+ getState: Effect.Effect<S, never, never>;
153
+ stateRef: SubscriptionRef.SubscriptionRef<S>;
154
+ isStopped: Effect.Effect<boolean>;
155
+ _queue: Queue.Queue<RuntimeQueuedEvent<E>, never>;
156
+ _stoppedRef: Ref.Ref<boolean>;
157
+ exitDeferred: Deferred.Deferred<ActorExit<S>, never>;
158
+ actorScope: Scope.Closeable;
159
+ }, never, Scope.Scope>;
141
160
  //#endregion
142
161
  export { ProcessQueuedResult, RuntimeCellResources, RuntimeConfig, RuntimeHandle, RuntimeLifecycleHooks, RuntimeQueuedEvent, createRuntime };
@@ -72,7 +72,6 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
72
72
  })
73
73
  };
74
74
  const stateScopeRef = { current: yield* Scope.make() };
75
- const backgroundFibers = [];
76
75
  const initEvent = { _tag: INTERNAL_INIT_EVENT };
77
76
  const ctx = {
78
77
  actorId,
@@ -82,59 +81,71 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
82
81
  system
83
82
  };
84
83
  const slots = machine._slots;
85
- for (const bg of machine.backgroundEffects) {
86
- const fiber = yield* bg.handler({
87
- actorId,
88
- state: machine.initial,
89
- event: initEvent,
90
- self,
91
- slots,
92
- system
93
- }).pipe(Effect.provideService(machine.Context, ctx), Effect.forkIn(actorScope));
94
- backgroundFibers.push(fiber);
95
- }
96
- if (lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(machine.initial);
97
84
  const loopFiberRef = { current: void 0 };
98
- const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid);
99
- yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal).pipe(Effect.catchCause((cause) => {
100
- return Effect.gen(function* () {
85
+ /** Set the exit deferred exactly once. */
86
+ const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
87
+ const startDeferred = yield* Deferred.make();
88
+ const startedRef = yield* Ref.make(false);
89
+ const start = Effect.gen(function* () {
90
+ if (yield* Ref.getAndSet(startedRef, true)) {
91
+ yield* Deferred.await(startDeferred);
92
+ return;
93
+ }
94
+ const backgroundFibers = [];
95
+ for (const bg of machine.backgroundEffects) {
96
+ const fiber = yield* bg.handler({
97
+ actorId,
98
+ state: machine.initial,
99
+ event: initEvent,
100
+ self,
101
+ slots,
102
+ system
103
+ }).pipe(Effect.provideService(machine.Context, ctx), Effect.forkIn(actorScope));
104
+ backgroundFibers.push(fiber);
105
+ }
106
+ if (lifecycle?.onInitialSpawnEffects !== void 0) yield* lifecycle.onInitialSpawnEffects(machine.initial);
107
+ const initialSpawnDefectSignal = (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid);
108
+ yield* runSpawnEffects(machine, machine.initial, initEvent, self, stateScopeRef.current, system, actorId, hooks?.onError, initialSpawnDefectSignal).pipe(Effect.catchCause((cause) => {
109
+ return Effect.gen(function* () {
110
+ yield* Ref.set(stoppedRef, true);
111
+ yield* Scope.close(stateScopeRef.current, Exit.void);
112
+ yield* Scope.close(actorScope, Exit.void);
113
+ yield* Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn"));
114
+ return yield* Effect.failCause(cause);
115
+ });
116
+ }));
117
+ if (machine.finalStates.has(machine.initial._tag)) {
118
+ if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
101
119
  yield* Ref.set(stoppedRef, true);
102
120
  yield* Scope.close(stateScopeRef.current, Exit.void);
103
121
  yield* Scope.close(actorScope, Exit.void);
104
- yield* Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "initial-spawn"));
105
- return yield* Effect.failCause(cause);
106
- });
107
- }));
108
- /** Set the exit deferred exactly once. */
109
- const setExit = (exit) => Deferred.succeed(exitDeferred, exit).pipe(Effect.asVoid);
110
- if (machine.finalStates.has(machine.initial._tag)) {
111
- if (lifecycle?.onFinal !== void 0) yield* lifecycle.onFinal(machine.initial);
112
- yield* Ref.set(stoppedRef, true);
113
- yield* Scope.close(stateScopeRef.current, Exit.void);
114
- yield* Scope.close(actorScope, Exit.void);
115
- yield* setExit(ActorExit.Final(machine.initial));
116
- return makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope);
117
- }
118
- const augmentedHooks = {
119
- ...hooks,
120
- onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid)
121
- };
122
- const loopFiber = yield* Effect.forkDetach(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, augmentedHooks, deferredReplyRef, lifecycle, config.wrapProcess, fork));
123
- loopFiberRef.current = loopFiber;
124
- if (backgroundFibers.length > 0) yield* Effect.raceAll(backgroundFibers.map((fiber) => Fiber.await(fiber).pipe(Effect.flatMap((exit) => {
125
- if (exit._tag === "Failure" && !Cause.hasInterruptsOnly(exit.cause)) return setExit(ActorExit.Defect(exit.cause, "background")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Fiber.interrupt(loopFiber)));
126
- return Effect.never;
127
- })))).pipe(Effect.forkIn(actorScope));
128
- yield* Effect.forkDetach(Effect.gen(function* () {
129
- const loopExit = yield* Fiber.await(loopFiber);
130
- if (loopExit._tag === "Success") yield* Scope.close(actorScope, Exit.void);
131
- else yield* Scope.close(actorScope, loopExit);
132
- }));
122
+ yield* setExit(ActorExit.Final(machine.initial));
123
+ yield* Deferred.succeed(startDeferred, void 0);
124
+ return;
125
+ }
126
+ const augmentedHooks = {
127
+ ...hooks,
128
+ onSpawnDefect: (cause) => Deferred.succeed(exitDeferred, ActorExit.Defect(cause, "spawn")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Effect.suspend(() => loopFiberRef.current !== void 0 ? Fiber.interrupt(loopFiberRef.current) : Effect.void)), Effect.asVoid)
129
+ };
130
+ const loopFiber = yield* Effect.forkDetach(runtimeEventLoop(machine, stateRef, eventQueue, stoppedRef, self, stateScopeRef, actorId, system, exitDeferred, augmentedHooks, deferredReplyRef, lifecycle, config.wrapProcess, fork));
131
+ loopFiberRef.current = loopFiber;
132
+ if (backgroundFibers.length > 0) yield* Effect.raceAll(backgroundFibers.map((fiber) => Fiber.await(fiber).pipe(Effect.flatMap((exit) => {
133
+ if (exit._tag === "Failure" && !Cause.hasInterruptsOnly(exit.cause)) return setExit(ActorExit.Defect(exit.cause, "background")).pipe(Effect.andThen(Ref.set(stoppedRef, true)), Effect.andThen(Fiber.interrupt(loopFiber)));
134
+ return Effect.never;
135
+ })))).pipe(Effect.forkIn(actorScope));
136
+ yield* Effect.forkDetach(Effect.gen(function* () {
137
+ const loopExit = yield* Fiber.await(loopFiber);
138
+ if (loopExit._tag === "Success") yield* Scope.close(actorScope, Exit.void);
139
+ else yield* Scope.close(actorScope, loopExit);
140
+ }));
141
+ yield* Deferred.succeed(startDeferred, void 0);
142
+ }).pipe(Effect.catchCause((cause) => Deferred.failCause(startDeferred, cause).pipe(Effect.andThen(Effect.failCause(cause)))));
133
143
  const stop = Effect.gen(function* () {
134
144
  if (yield* Ref.get(stoppedRef)) return;
135
145
  if (lifecycle?.onShutdown !== void 0) yield* lifecycle.onShutdown();
136
146
  yield* Ref.set(stoppedRef, true);
137
- yield* Fiber.interrupt(loopFiber);
147
+ const loopFiber = loopFiberRef.current;
148
+ if (loopFiber !== void 0) yield* Fiber.interrupt(loopFiber);
138
149
  yield* Scope.close(stateScopeRef.current, Exit.void);
139
150
  yield* Scope.close(actorScope, Exit.void);
140
151
  yield* setExit(ActorExit.Stopped);
@@ -142,7 +153,8 @@ const createRuntime = Effect.fn("effect-machine.runtime.create")(function* (mach
142
153
  if (config.skipFinalizer !== true) yield* Effect.addFinalizer(() => stop);
143
154
  return {
144
155
  ...makeHandle(stateRef, stoppedRef, eventQueue, exitDeferred, actorScope),
145
- stop
156
+ stop,
157
+ start
146
158
  };
147
159
  });
148
160
  /**
@@ -184,6 +196,7 @@ const makeHandle = (stateRef, stoppedRef, eventQueue, exitDeferred, actorScope)
184
196
  stateRef,
185
197
  isStopped: Ref.get(stoppedRef),
186
198
  stop: Effect.void,
199
+ start: Effect.void,
187
200
  _queue: eventQueue,
188
201
  _stoppedRef: stoppedRef,
189
202
  exitDeferred,
package/dist/machine.d.ts CHANGED
@@ -10,7 +10,7 @@ import { Cause, Duration, Effect, Option, Schema, Scope, ServiceMap } from "effe
10
10
 
11
11
  //#region src/machine.d.ts
12
12
  declare namespace machine_d_exports {
13
- export { BackgroundEffect, DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, PersistConfig, ReplyResult, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, make, materializeMachine, replay, reply, spawn };
13
+ export { BackgroundEffect, DeferReplyResult, Durability, DurabilityCommit, HandlerContext, Lifecycle, Machine, MachineRef, MakeConfig, Recovery, RecoveryContext, ReplyResult, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, make, materializeMachine, replay, reply, spawn };
14
14
  }
15
15
  /**
16
16
  * Self reference for sending events back to the machine
@@ -88,32 +88,39 @@ interface TaskOptions<State, Event, SD extends SlotsDef, A, E1, ES, EF> {
88
88
  readonly name?: string;
89
89
  }
90
90
  /**
91
- * Local persistence configuration for Machine.spawn.
91
+ * Recovery resolves the initial state for a generation. Runs during actor.start.
92
92
  *
93
- * Fully-resolved callbacks no service dependency.
94
- * Separate from cluster EntityPersistence which uses a service-based adapter.
93
+ * For initial start (generation 0): loads persisted state.
94
+ * For supervision restart (generation 1+): reloads state after crash.
95
95
  */
96
- interface PersistConfig<S> {
97
- /** Load saved state on actor start (and restart). Returns None for cold start. */
98
- readonly load: () => Effect.Effect<Option.Option<S>>;
99
- /** Save state after each transition. Runs inline (blocks next event). */
100
- readonly save: (state: S) => Effect.Effect<void>;
101
- /** Optional filter — return false to skip saving for this transition. */
96
+ interface Recovery<S> {
97
+ readonly resolve: (ctx: RecoveryContext<S>) => Effect.Effect<Option.Option<S>>;
98
+ }
99
+ interface RecoveryContext<S> {
100
+ readonly actorId: string;
101
+ readonly generation: number;
102
+ readonly machineInitial: S;
103
+ }
104
+ /**
105
+ * Durability saves state after committed transitions. Runs during runtime.
106
+ */
107
+ interface Durability<S, E> {
108
+ readonly save: (commit: DurabilityCommit<S, E>) => Effect.Effect<void>;
102
109
  readonly shouldSave?: (state: S, previousState: S) => boolean;
103
- /**
104
- * Called after load() returns Some(state). Inspect the restored state
105
- * and decide how to proceed before the machine starts.
106
- *
107
- * Return Some(state) to use that state, or None to discard and start fresh.
108
- * Called on both initial spawn and supervision restart.
109
- * Receives `initial` (machine.initial) for comparison.
110
- *
111
- * Use cases: validate persisted state against external systems,
112
- * migrate schema changes, downgrade to a safe state on partial corruption.
113
- */
114
- readonly onRestore?: (state: S, context: {
115
- readonly initial: S;
116
- }) => Effect.Effect<Option.Option<S>>;
110
+ }
111
+ interface DurabilityCommit<S, E> {
112
+ readonly actorId: string;
113
+ readonly generation: number;
114
+ readonly previousState: S;
115
+ readonly nextState: S;
116
+ readonly event: E;
117
+ }
118
+ /**
119
+ * Actor lifecycle configuration.
120
+ */
121
+ interface Lifecycle<S, E> {
122
+ readonly recovery?: Recovery<S>;
123
+ readonly durability?: Durability<S, E>;
117
124
  }
118
125
  /**
119
126
  * Configuration for `.timeout()` — gen_statem-style state timeouts.
@@ -335,11 +342,11 @@ declare const make: typeof Machine.make;
335
342
  * slots: { canRetry: ({ max }) => attempts < max },
336
343
  * });
337
344
  *
338
- * // With persistence
345
+ * // With lifecycle (recovery + durability)
339
346
  * const actor = yield* Machine.spawn(machine, {
340
- * persist: {
341
- * load: () => storage.get("actor-state"),
342
- * save: (state) => storage.set("actor-state", state),
347
+ * lifecycle: {
348
+ * recovery: { resolve: (ctx) => storage.get("actor-state") },
349
+ * durability: { save: (commit) => storage.set("actor-state", commit.nextState) },
343
350
  * },
344
351
  * });
345
352
  * ```
@@ -353,7 +360,7 @@ declare const spawn: <S extends {
353
360
  hydrate?: S;
354
361
  slots?: ProvideSlots<SD, any>;
355
362
  supervision?: Supervision.Policy;
356
- persist?: PersistConfig<S>;
363
+ lifecycle?: Lifecycle<S, E>;
357
364
  }) => Effect.Effect<ActorRef<S, E>, never, R>;
358
365
  declare const replay: {
359
366
  <S extends {
@@ -368,4 +375,4 @@ declare const replay: {
368
375
  declare const reply: <State, Reply>(state: State, reply: Reply) => ReplyResult<State, Reply>;
369
376
  declare const deferReply: <State>(state: State) => DeferReplyResult<State>;
370
377
  //#endregion
371
- export { BackgroundEffect, type DeferReplyResult, HandlerContext, Machine, MachineRef, MakeConfig, PersistConfig, type ReplyResult, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, machine_d_exports, make, materializeMachine, replay, reply, spawn };
378
+ export { BackgroundEffect, type DeferReplyResult, Durability, DurabilityCommit, HandlerContext, Lifecycle, Machine, MachineRef, MakeConfig, Recovery, RecoveryContext, type ReplyResult, SpawnEffect, StateEffectHandler, StateHandlerContext, TaskOptions, TimeoutConfig, Transition, TransitionHandler, deferReply, findTransitions, machine_d_exports, make, materializeMachine, replay, reply, spawn };