effect-machine 0.14.0 → 0.15.0

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/dist/machine.js CHANGED
@@ -395,11 +395,11 @@ const make = Machine.make;
395
395
  * slots: { canRetry: ({ max }) => attempts < max },
396
396
  * });
397
397
  *
398
- * // With persistence
398
+ * // With lifecycle (recovery + durability)
399
399
  * const actor = yield* Machine.spawn(machine, {
400
- * persist: {
401
- * load: () => storage.get("actor-state"),
402
- * save: (state) => storage.set("actor-state", state),
400
+ * lifecycle: {
401
+ * recovery: { resolve: (ctx) => storage.get("actor-state") },
402
+ * durability: { save: (commit) => storage.set("actor-state", commit.nextState) },
403
403
  * },
404
404
  * });
405
405
  * ```
@@ -409,7 +409,7 @@ const spawn = Effect.fn("effect-machine.spawn")(function* (machine, idOrOptions)
409
409
  const actor = yield* createActor(opts?.id ?? `actor-${(yield* Random.next).toString(36).slice(2)}`, materializeMachine(machine, opts?.slots), {
410
410
  initialState: opts?.hydrate,
411
411
  supervision: opts?.supervision,
412
- persist: opts?.persist
412
+ lifecycle: opts?.lifecycle
413
413
  });
414
414
  const maybeScope = yield* Effect.serviceOption(Scope.Scope);
415
415
  if (Option.isSome(maybeScope)) yield* Scope.addFinalizer(maybeScope.value, actor.stop);
package/dist/schema.d.ts CHANGED
@@ -111,7 +111,9 @@ interface MachineSchemaBase<D extends Record<string, Schema.Struct.Fields>, Bran
111
111
  * The D type parameter captures the definition, creating a unique brand
112
112
  * per distinct schema definition shape.
113
113
  */
114
- type MachineStateSchema<D extends Record<string, Schema.Struct.Fields>> = Schema.Codec<VariantsUnion<D> & FullStateBrand<D>, unknown, never, never> & MachineSchemaBase<D, FullStateBrand<D>> & VariantConstructors<D, FullStateBrand<D>>;
114
+ type MachineStateSchema<D extends Record<string, Schema.Struct.Fields>> = Schema.Codec<VariantsUnion<D> & FullStateBrand<D>, unknown, never, never> & MachineSchemaBase<D, FullStateBrand<D>> & VariantConstructors<D, FullStateBrand<D>> & {
115
+ /** Unbranded schema for persistence — same structure without FullStateBrand. */readonly plain: Schema.Schema<VariantsUnion<D>>;
116
+ };
115
117
  /**
116
118
  * Schema-first event definition (same structure as state, different brand)
117
119
  *
package/dist/schema.js CHANGED
@@ -123,6 +123,7 @@ const createMachineSchema = (definition) => {
123
123
  variants,
124
124
  _definition,
125
125
  _replySchemas: replySchemas,
126
+ plain: schema,
126
127
  $is,
127
128
  $match,
128
129
  derive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-machine",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cevr/effect-machine.git"
@@ -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 { Context, Deferred, Effect, Layer, Option, PubSub, Queue, Ref, Scope, Stream, SubscriptionRef } from "effect";
9
9
 
@@ -54,6 +54,12 @@ 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
+ * Called automatically by `system.spawn`. For `Machine.spawn`, the caller
60
+ * must call `start` explicitly. Events sent before start() are queued.
61
+ */
62
+ readonly start: Effect.Effect<void>;
57
63
  /** Get current state snapshot. */
58
64
  readonly snapshot: Effect.Effect<State>;
59
65
  /** Check if current state matches tag. */
@@ -161,7 +167,7 @@ interface ActorSystem {
161
167
  }, R, SD extends SlotsDef = Record<string, never>>(id: string, machine: Machine<S, E, R, any, any, SD>, options?: {
162
168
  readonly supervision?: Supervision.Policy;
163
169
  readonly slots?: ProvideSlots<SD, any>;
164
- readonly persist?: PersistConfig<S>;
170
+ readonly lifecycle?: Lifecycle<S, E>;
165
171
  }) => Effect.Effect<ActorRef<S, E>, DuplicateActorError, R>;
166
172
  /**
167
173
  * Get an existing actor by ID
@@ -204,7 +210,7 @@ declare const buildActorRefCore: <S extends {
204
210
  readonly _tag: string;
205
211
  }, E extends {
206
212
  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>;
213
+ }, 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
214
  /**
209
215
  * Create and start an actor for a machine.
210
216
  * Delegates to the shared runtime kernel with actor-specific lifecycle hooks.
@@ -216,7 +222,7 @@ declare const createActor: <S extends {
216
222
  }, R, SD extends SlotsDef>(id: string, machine: Machine<S, E, R, any, any, SD>, options?: {
217
223
  initialState?: S;
218
224
  supervision?: Supervision.Policy;
219
- persist?: PersistConfig<S>; /** @internal Called by system after each restart — emits ActorRestarted system event */
225
+ lifecycle?: Lifecycle<S, E>; /** @internal Called by system after each restart — emits ActorRestarted system event */
220
226
  onRestart?: (generation: number, exit: ActorExit<unknown>) => Effect.Effect<void>;
221
227
  } | undefined) => Effect.Effect<ActorRef<S, E>, never, never>;
222
228
  /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
package/v3/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
  */
@@ -242,7 +231,15 @@ const runSupervisionLoop = (params) => Effect.gen(function* () {
242
231
  yield* Deferred.succeed(params.terminalExitDeferred, generationExit);
243
232
  return;
244
233
  }
245
- const restartState = params.persist !== void 0 ? yield* loadAndRestore(params.persist, params.machine.initial) : params.machine.initial;
234
+ let restartState = params.machine.initial;
235
+ if (params.lifecycle?.recovery !== void 0) {
236
+ const resolved = yield* params.lifecycle.recovery.resolve({
237
+ actorId: params.id,
238
+ generation: generation + 1,
239
+ machineInitial: params.machine.initial
240
+ });
241
+ if (Option.isSome(resolved)) restartState = resolved.value;
242
+ }
246
243
  yield* settlePendingReplies(params.pendingReplies, params.id);
247
244
  const freshQueue = yield* Queue.unbounded();
248
245
  yield* Ref.set(params.eventQueueRef, freshQueue);
@@ -255,6 +252,7 @@ const runSupervisionLoop = (params) => Effect.gen(function* () {
255
252
  } }) : params.machine;
256
253
  const newRuntime = yield* params.spawnGeneration(machineForRestart);
257
254
  params.runtimeRef.current = newRuntime;
255
+ yield* newRuntime.start;
258
256
  generation++;
259
257
  if (params.onRestart !== void 0) yield* params.onRestart(generation, generationExit);
260
258
  notifyListeners(params.listeners, restartState);
@@ -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,6 +284,7 @@ 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 };
296
289
  /** Build lifecycle hooks for a generation */
297
290
  const buildLifecycle = () => {
@@ -304,10 +297,17 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
304
297
  event,
305
298
  timestamp
306
299
  })) : void 0,
307
- onStateChange: (result, _event) => Effect.gen(function* () {
300
+ onStateChange: (result, event) => Effect.gen(function* () {
308
301
  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);
302
+ if (lifecycle?.durability !== void 0 && result.transitioned) {
303
+ const durability = lifecycle.durability;
304
+ if (durability.shouldSave === void 0 || durability.shouldSave(result.newState, result.previousState)) yield* durability.save({
305
+ actorId: id,
306
+ generation,
307
+ previousState: result.previousState,
308
+ nextState: result.newState,
309
+ event
310
+ });
311
311
  }
312
312
  yield* Effect.annotateCurrentSpan("effect_machine.transition.matched", true);
313
313
  if (result.lifecycleRan) {
@@ -374,34 +374,62 @@ const createActor = Effect.fn("effect-machine.actor.spawn")(function* (id, machi
374
374
  }));
375
375
  })
376
376
  })));
377
- const runtime = yield* spawnGeneration(machineWithState);
378
- runtimeRef.current = runtime;
377
+ runtimeRef.current = yield* spawnGeneration(machineWithState);
379
378
  const supervision = options?.supervision;
380
- let supervisorFiber;
381
- if (supervision !== void 0) supervisorFiber = yield* Effect.forkDaemon(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.forkDaemon(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);
379
+ const supervisorFiberRef = { current: void 0 };
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.forkDaemon(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
+ onRestart: options?.onRestart
425
+ }));
426
+ else {
427
+ const currentRuntime = runtimeRef.current;
428
+ if (currentRuntime !== void 0) yield* Effect.forkDaemon(Deferred.await(currentRuntime.exitDeferred).pipe(Effect.tap((exit) => Deferred.succeed(terminalExitDeferred, exit))));
429
+ }
430
+ const currentRuntime = runtimeRef.current;
431
+ if (currentRuntime !== void 0) yield* currentRuntime.start;
432
+ }).pipe(Effect.withSpan("effect-machine.actor.start"), Effect.asVoid), system, childrenMap, pendingReplies, transitionsPubSub, terminalExitDeferred);
405
433
  });
406
434
  /** Fail all pending call/ask Deferreds with ActorStoppedError. Safe to call multiple times. */
407
435
  const settlePendingReplies = (pendingReplies, actorId) => Effect.sync(() => {
@@ -462,7 +490,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
462
490
  let actorRef;
463
491
  const actor = yield* createActor(id, materialized, {
464
492
  supervision: spawnOptions?.supervision,
465
- persist: spawnOptions?.persist,
493
+ lifecycle: spawnOptions?.lifecycle,
466
494
  onRestart: spawnOptions?.supervision !== void 0 ? (generation, exit) => actorRef !== void 0 ? emitSystemEvent({
467
495
  _tag: "ActorRestarted",
468
496
  id,
@@ -472,7 +500,9 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
472
500
  }) : Effect.void : void 0
473
501
  });
474
502
  actorRef = actor;
475
- return yield* registerActor(id, actor);
503
+ yield* registerActor(id, actor);
504
+ yield* actor.start.pipe(Effect.catchAllCause((cause) => actor.stop.pipe(Effect.andThen(Effect.failCause(cause)))));
505
+ return actor;
476
506
  });
477
507
  const spawn = (id, machine, options) => withSpawnGate(spawnRegular(id, machine, options));
478
508
  const get = Effect.fn("effect-machine.actorSystem.get")(function* (id) {
@@ -50,6 +50,7 @@ const EntityMachine = { layer: (entity, machine, options) => {
50
50
  hooks: options?.hooks,
51
51
  childIdPrefix: `${entityId}/`
52
52
  });
53
+ yield* runtime.start;
53
54
  if (persistCtx.adapter !== void 0) {
54
55
  const { adapter: pAdapter, key } = persistCtx;
55
56
  yield* Effect.addFinalizer(() => Effect.gen(function* () {
@@ -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>>;
156
+ _stoppedRef: Ref.Ref<boolean>;
157
+ exitDeferred: Deferred.Deferred<ActorExit<S>, never>;
158
+ actorScope: Scope.CloseableScope;
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.catchAllCause((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.catchAllCause((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.forkDaemon(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.isInterruptedOnly(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.forkDaemon(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.forkDaemon(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.isInterruptedOnly(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.forkDaemon(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.catchAllCause((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,
@@ -10,7 +10,7 @@ import { Cause, Context, Duration, Effect, Option, Schema, Scope } from "effect"
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: ({ machineInitial }) => storage.get("actor-state") },
349
+ * durability: { save: ({ nextState }) => storage.set("actor-state", 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 };