npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@marianmeres/actor

v1.5.8

Published

[![NPM Version](https://img.shields.io/npm/v/@marianmeres/actor)](https://www.npmjs.com/package/@marianmeres/actor) [![JSR Version](https://jsr.io/badges/@marianmeres/actor)](https://jsr.io/@marianmeres/actor)

Readme

@marianmeres/actor

NPM Version JSR Version

A lightweight, type-safe Actor Model implementation for TypeScript/JavaScript.

What is an Actor?

An actor is a message-driven stateful computation unit. It processes messages sequentially through a mailbox, ensuring safe state mutations without complex async coordination.

Key benefits:

  • Sequential processing: Messages are queued and processed one at a time (FIFO)
  • No async race conditions: Concurrent sends are serialized automatically
  • Reactive subscriptions: Subscribe to state changes
  • Error isolation: Handler errors don't break the mailbox
  • Framework-agnostic: Works with any UI framework or Node.js

Installation

# Deno
deno add jsr:@marianmeres/actor
# npm
npm install @marianmeres/actor

Quick Start

import { createStateActor } from "@marianmeres/actor";

// Define message types
type Message =
  | { type: "INCREMENT" }
  | { type: "DECREMENT" }
  | { type: "ADD"; payload: number };

// Create a counter actor
const counter = createStateActor<number, Message>(0, (state, msg) => {
  switch (msg.type) {
    case "INCREMENT": return state + 1;
    case "DECREMENT": return state - 1;
    case "ADD": return state + msg.payload;
  }
});

// Send messages
await counter.send({ type: "INCREMENT" });
await counter.send({ type: "ADD", payload: 10 });

console.log(counter.getState()); // 11

// Subscribe to changes (with optional previous state, Svelte-compatible)
const unsubscribe = counter.subscribe(({ current, previous }) => {
  console.log("Counter:", current, "(was:", previous, ")");
});

// Cleanup
counter.destroy();

API Reference

For complete API documentation, see API.md.

| Function | Description | |----------|-------------| | createStateActor(initial, handler, options?) | Simple actor where handler returns the new state | | createActor(options) | Full control with optional reducer, onError, debug, logger | | defineMessage(type) | Creates typed message factories (like Redux action creators) | | createTypedStateActor(initial, handlers, options?) | Exhaustive message handling with compile-time checking | | createTypedActor(options) | Full control + exhaustive handlers + reducer + debug logging | | createMessageFactory() | DTOKit factory for validating external messages |

createActor and createTypedActor support optional debug: true and custom logger for verbose debug logging.

| Actor Method | Description | |--------------|-------------| | send(msg) | Queue message, returns Promise<TResponse> | | subscribe(fn) | Subscribe to state changes. Callback receives { current, previous? } - called immediately with undefined previous, then on each change with actual previous state. Svelte-compatible single parameter. | | getState() | Get current state synchronously | | destroy() | Clear mailbox and subscribers | | debug | Read-only debug flag value (boolean \| undefined) | | logger | Read-only logger instance (custom or console) |

How send() Works: The Deferred Promise Pattern

The send() method uses a "deferred promise" pattern internally to bridge the gap between when you call send() and when your message is actually processed:

send(message) {
  return new Promise((resolve, reject) => {
    mailbox.push({ message, resolve, reject });  // Store callbacks with message
    processMailbox();
  });
}

Why this design?

  1. Callbacks stored with message: Each message is queued alongside its own resolve/reject functions. When processMailbox eventually processes this specific message, it can resolve/reject the correct caller's promise.

  2. Non-blocking queue trigger: processMailbox() is called after every push, but has a guard (if (processing) return) so it's a no-op when already running. The while loop inside ensures all queued messages are processed sequentially before releasing the lock.

  3. Async by design: Even if your handler is synchronous, send() returns a Promise because:

    • Your message may wait behind others in the queue (FIFO order)
    • The handler might be async (I/O, network calls)
    • Callers need to know when their message was processed and get the result

This pattern enables the core actor guarantee: sequential message processing with async-friendly callers.

Understanding the Reducer

The reducer option separates what the handler returns from how state is updated.

Without reducer (createStateActor): handler's return value IS the new state.

With reducer: handler can return rich data, reducer extracts what goes into state:

const actor = createActor<
  number,                              // State type
  string,                              // Message type
  { delta: number; log: string }       // Response type (different from state!)
>({
  initialState: 0,
  handler: (state, msg) => {
    return { delta: msg.length, log: `Processed: ${msg}` };
  },
  reducer: (state, response) => state + response.delta,
});

const result = await actor.send("hello");
// result = { delta: 5, log: "Processed: hello" }  ← caller gets full response
// state = 5                                        ← but state only stores delta

Use cases: async operations returning { data, metadata }, validation returning { valid, errors }, side effects returning status while reducer decides what to persist.

Previous State in Subscribers

Subscribers receive { current, previous } as a single parameter (Svelte-compatible), enabling powerful change detection patterns:

const user = createStateActor<
  { name: string; email: string; preferences: object },
  UpdateMessage
>(initialState, handler);

user.subscribe(({ current, previous }) => {
  // On initial subscription, previous is undefined
  if (previous === undefined) {
    console.log("Initial state loaded");
    return;
  }

  // React only to specific property changes
  if (current.email !== previous.email) {
    sendEmailVerification(current.email);
  }

  if (current.preferences !== previous.preferences) {
    syncPreferencesToServer(current.preferences);
  }
});

Common use cases for previous state:

  • Conditional side effects (only run when specific fields change)
  • Computing diffs for analytics or debugging
  • Animations/transitions between states
  • Undo/redo without external history tracking

When to Use Actors

Good fit:

  • Complex async workflows needing serialization
  • Shared state with multiple async writers
  • WebSocket/SSE message handling
  • Form submission with validation
  • Background task queues
  • Undo/redo stacks
  • Game state machines

Probably overkill:

  • Simple component state (use signals/stores instead)
  • Synchronous state updates only
  • Single-writer scenarios

Design Philosophy: Single-Actor Simplicity

This library intentionally implements a single-actor pattern without actor addresses, hierarchies, or spawning capabilities. This is a deliberate design choice.

What's NOT included (and why)

In the traditional Actor Model (Erlang/Elixir OTP, Akka), actors have:

  • Addresses - unique identifiers allowing actors to send messages to each other
  • Spawning - actors can create child actors and receive their addresses
  • Supervision - parent actors monitor and restart failed children

These features are essential for building distributed, fault-tolerant systems with many coordinating actors. However, they add significant complexity.

What this library provides instead

A focused, lightweight primitive for the most common use case: serialized message processing with encapsulated state. Each actor is self-contained and handles:

  • Sequential (FIFO) message processing via mailbox
  • Safe state mutations without complex async coordination
  • Reactive subscriptions for state changes
  • Error isolation within the handler

When you might need the full Actor Model

If your use case requires actors communicating with each other, building actor hierarchies, or distributed fault-tolerance, consider:

  • xstate - State machines with actor spawning
  • Comlink - Web Workers as actors
  • A backend runtime like Elixir/Erlang for true distributed actors

For most frontend and simple backend scenarios, this single-actor approach provides the core benefits (serialization, isolation, reactivity) without the cognitive overhead of a full actor system.

DTOKit Integration: Exhaustive Message Handling

For actors with multiple message types, the optional @marianmeres/dtokit integration provides compile-time exhaustive checking - TypeScript will error if you forget to handle any message type.

The Problem

With standard createStateActor, forgetting a message type in your switch statement produces no TypeScript error:

type Message =
  | { type: "INC" }
  | { type: "DEC" }
  | { type: "ADD"; amount: number };

const counter = createStateActor<number, Message>(0, (state, msg) => {
  switch (msg.type) {
    case "INC": return state + 1;
    case "DEC": return state - 1;
    // Oops! Forgot "ADD" - TypeScript doesn't complain!
  }
});

The Solution

With createTypedStateActor, missing handlers cause compile-time errors:

import { createTypedStateActor } from "@marianmeres/actor";

// Define schemas: keys MUST match the "type" discriminator values
type Schemas = {
  INC: { type: "INC" };
  DEC: { type: "DEC" };
  ADD: { type: "ADD"; amount: number };
};

const counter = createTypedStateActor<Schemas, number>(0, {
  INC: (msg, state) => state + 1,
  DEC: (msg, state) => state - 1,
  // TypeScript ERROR: Property 'ADD' is missing in type...
});

How It Works

  1. Single source of truth: Define message types once in a schemas object
  2. Exhaustive handlers: Provide a handler for each schema key (not a switch statement)
  3. Type inference: Each handler receives the correctly-typed message
  4. Compile-time safety: Add a new message type → compiler shows where to add handlers

Detailed Comparison

| Aspect | createStateActor | createTypedStateActor | |--------|-------------------|------------------------| | Message definition | Union type: { type: "A" } \| { type: "B" } | Schema object: { A: {...}, B: {...} } | | Handler style | switch (msg.type) | Handler object: { A: fn, B: fn } | | Missing case | No error (silent bug) | Compile error | | Adding message | Edit union + add case | Edit schema + add handler (compiler guides you) | | Refactoring | Manual find/replace | Compiler catches all locations |

Complete Example

import { createTypedStateActor, createMessageFactory } from "@marianmeres/actor";

// 1. Define your message schemas (single source of truth)
type TodoSchemas = {
  ADD: { type: "ADD"; text: string };
  REMOVE: { type: "REMOVE"; id: number };
  TOGGLE: { type: "TOGGLE"; id: number };
  CLEAR_DONE: { type: "CLEAR_DONE" };
};

type Todo = { id: number; text: string; done: boolean };

// 2. Create actor with exhaustive handlers
const todos = createTypedStateActor<TodoSchemas, Todo[]>([], {
  ADD: (msg, state) => [...state, { id: Date.now(), text: msg.text, done: false }],
  REMOVE: (msg, state) => state.filter(t => t.id !== msg.id),
  TOGGLE: (msg, state) => state.map(t =>
    t.id === msg.id ? { ...t, done: !t.done } : t
  ),
  CLEAR_DONE: (_, state) => state.filter(t => !t.done),
});

// 3. Full type safety on send()
await todos.send({ type: "ADD", text: "Buy milk" });
await todos.send({ type: "TOGGLE", id: 123 });
// await todos.send({ type: "TYPO" }); // TypeScript error!

// 4. Optional: Validate external messages (WebSocket, API, etc.)
const factory = createMessageFactory<TodoSchemas>();

websocket.onmessage = (event) => {
  const msg = factory.parse(JSON.parse(event.data));
  if (msg) {
    todos.send(msg); // Type-safe after validation!
  }
};

With Rich Response Data

Use createTypedActor when handlers return data different from state:

import { createTypedActor } from "@marianmeres/actor";

type Schemas = {
  PROCESS: { type: "PROCESS"; data: string };
  RESET: { type: "RESET" };
};

type State = { result: string | null };
type Response = { result: string; metadata: { processedAt: number } };

const processor = createTypedActor<Schemas, State, Response>({
  initialState: { result: null },
  handlers: {
    PROCESS: (msg, state) => ({
      result: msg.data.toUpperCase(),
      metadata: { processedAt: Date.now() }
    }),
    RESET: () => ({ result: "", metadata: { processedAt: 0 } }),
  },
  reducer: (state, response) => ({ result: response.result }),
});

const response = await processor.send({ type: "PROCESS", data: "hello" });
// response.result = "HELLO"
// response.metadata.processedAt = 1701234567890
// processor.getState() = { result: "HELLO" }

When to Use DTOKit Integration

Use createTypedStateActor when:

  • You have 3+ message types
  • Team members add new message types
  • You want refactoring safety
  • Messages come from external sources needing validation

Stick with createStateActor when:

  • Simple actor with 1-2 message types
  • You're prototyping quickly
  • You prefer switch statement style

Note on Dependencies

The createTypedStateActor, createTypedActor, and createMessageFactory functions use @marianmeres/dtokit internally for exhaustive type checking. This dependency is included with the package - no additional installation needed.

// All exports from single entry point
import {
  createStateActor,        // Basic actor
  createTypedStateActor,   // With exhaustive checking
  createMessageFactory,    // For external message validation
} from "@marianmeres/actor";

Examples

Some examples below use the amazing @marianmeres/fsm library. Pure coincidence.

Async Data Fetching

interface DataState {
  data: unknown | null;
  loading: boolean;
  error: string | null;
}

const fetcher = createActor<DataState, { type: "FETCH"; url: string }, DataState>({
  initialState: { data: null, loading: false, error: null },
  handler: async (state, msg) => {
    const res = await fetch(msg.url);
    const data = await res.json();
    return { data, loading: false, error: null };
  },
  reducer: (_, response) => response,
});

// Multiple rapid fetches are serialized - no race conditions!
fetcher.send({ type: "FETCH", url: "/api/data" });
fetcher.send({ type: "FETCH", url: "/api/data" });
// Responses arrive in order, one at a time

Orchestrating Multiple Actors with FSM

A common DDD pattern: use a finite state machine to coordinate multiple domain actors. Each actor manages its own domain state, while the FSM orchestrates the workflow.

import { createTypedStateActor } from "@marianmeres/actor";
import { createFsm } from "@marianmeres/fsm";

// Domain actors - each manages its own bounded context

type CartSchemas = {
  ADD_ITEM: { type: "ADD_ITEM"; item: { sku: string; qty: number } };
  CLEAR: { type: "CLEAR" };
};

const cart = createTypedStateActor<CartSchemas, { items: Array<{ sku: string; qty: number }>; total: number }>(
  { items: [], total: 0 },
  {
    ADD_ITEM: (msg, state) => ({ ...state, items: [...state.items, msg.item] }),
    CLEAR: () => ({ items: [], total: 0 }),
  }
);

type PaymentSchemas = {
  PROCESS: { type: "PROCESS" };
  SUCCESS: { type: "SUCCESS"; txId: string };
  FAIL: { type: "FAIL" };
};

const payment = createTypedStateActor<PaymentSchemas, { status: string; txId: string | null }>(
  { status: "idle", txId: null },
  {
    PROCESS: () => ({ status: "processing", txId: null }),
    SUCCESS: (msg) => ({ status: "success", txId: msg.txId }),
    FAIL: () => ({ status: "failed", txId: null }),
  }
);

type InventorySchemas = {
  RESERVE: { type: "RESERVE"; items: Array<{ sku: string; qty: number }> };
  RELEASE: { type: "RELEASE" };
};

const inventory = createTypedStateActor<InventorySchemas, { reserved: Array<{ sku: string; qty: number }> }>(
  { reserved: [] },
  {
    RESERVE: (msg, state) => ({ reserved: [...state.reserved, ...msg.items] }),
    RELEASE: () => ({ reserved: [] }),
  }
);

// FSM orchestrates the checkout workflow
const checkoutFsm = createFsm({
  initial: "IDLE",
  context: { error: null },
  states: {
    IDLE: {
      on: { start: "RESERVING" }
    },
    RESERVING: {
      onEnter: async (ctx) => {
        await inventory.send({ type: "RESERVE", items: cart.getState().items });
        checkoutFsm.transition("reserved");
      },
      on: { reserved: "CHARGING", fail: "FAILED" }
    },
    CHARGING: {
      onEnter: async (ctx) => {
        await payment.send({ type: "PROCESS" });
        // Simulate payment processing
        const success = Math.random() > 0.1;
        if (success) {
          await payment.send({ type: "SUCCESS", txId: "tx_123" });
          checkoutFsm.transition("charged");
        } else {
          await payment.send({ type: "FAIL" });
          checkoutFsm.transition("fail");
        }
      },
      on: { charged: "COMPLETED", fail: "ROLLING_BACK" }
    },
    ROLLING_BACK: {
      onEnter: async () => {
        await inventory.send({ type: "RELEASE" });
        checkoutFsm.transition("rolled_back");
      },
      on: { rolled_back: "FAILED" }
    },
    COMPLETED: {
      onEnter: async () => {
        await cart.send({ type: "CLEAR" });
      }
    },
    FAILED: {}
  }
});

// Usage: subscribe to FSM for workflow state, actors for domain state
checkoutFsm.subscribe(({ state }) => console.log("Checkout:", state));
payment.subscribe((s) => console.log("Payment:", s.status));

Multi-Actor Notification System

// User preferences actor
const preferences = createStateActor(
  { email: true, push: true, sms: false },
  (state, msg) => ({ ...state, [msg.channel]: msg.enabled })
);

// Notification queue actor - serializes delivery
const notificationQueue = createActor({
  initialState: { pending: 0, sent: 0 },
  handler: async (state, msg) => {
    const prefs = preferences.getState();
    const results = [];

    if (prefs.email && msg.channels.includes("email")) {
      await sendEmail(msg.to, msg.content);
      results.push("email");
    }
    if (prefs.push && msg.channels.includes("push")) {
      await sendPush(msg.to, msg.content);
      results.push("push");
    }

    return { delivered: results, messageId: msg.id };
  },
  reducer: (state, response) => ({
    pending: state.pending - 1,
    sent: state.sent + response.delivered.length
  })
});

// FSM controls notification campaign lifecycle
const campaignFsm = createFsm({
  initial: "DRAFT",
  context: { recipientCount: 0, sentCount: 0 },
  states: {
    DRAFT: { on: { schedule: "SCHEDULED", send: "SENDING" } },
    SCHEDULED: { on: { trigger: "SENDING", cancel: "DRAFT" } },
    SENDING: {
      onEnter: async (ctx) => {
        for (const recipient of ctx.recipients) {
          await notificationQueue.send({
            id: crypto.randomUUID(),
            to: recipient,
            content: ctx.message,
            channels: ["email", "push"]
          });
          ctx.sentCount++;
        }
        campaignFsm.transition("complete");
      },
      on: { complete: "COMPLETED", pause: "PAUSED" }
    },
    PAUSED: { on: { resume: "SENDING" } },
    COMPLETED: {}
  }
});

Actor as Aggregate Root

In DDD, the aggregate root coordinates changes within a bounded context:

import { createTypedStateActor } from "@marianmeres/actor";

// Define the domain types
type Item = { sku: string; qty: number; price: number };
type Payment = { amount: number; method: string };
type Shipment = { trackingId: string; carrier: string };

type OrderState = {
  id: string;
  status: "pending" | "submitted" | "paid" | "shipped";
  items: Item[];
  payments: Payment[];
  shipments: Shipment[];
};

// Define all order commands (message schemas)
type OrderSchemas = {
  ADD_ITEM: { type: "ADD_ITEM"; item: Item };
  SUBMIT: { type: "SUBMIT" };
  RECORD_PAYMENT: { type: "RECORD_PAYMENT"; payment: Payment };
  SHIP: { type: "SHIP"; shipment: Shipment };
};

// Order aggregate - the actor IS the aggregate root
const createOrderActor = (orderId: string) => createTypedStateActor<OrderSchemas, OrderState>(
  {
    id: orderId,
    status: "pending",
    items: [],
    payments: [],
    shipments: []
  },
  {
    ADD_ITEM: (msg, state) => {
      if (state.status !== "pending") return state; // Invariant
      return { ...state, items: [...state.items, msg.item] };
    },
    SUBMIT: (_, state) => {
      if (state.items.length === 0) return state; // Invariant
      return { ...state, status: "submitted" };
    },
    RECORD_PAYMENT: (msg, state) => ({
      ...state,
      payments: [...state.payments, msg.payment],
      status: calculateStatus(state.payments, msg.payment)
    }),
    SHIP: (msg, state) => {
      if (state.status !== "paid") return state; // Invariant
      return { ...state, shipments: [...state.shipments, msg.shipment] };
    },
  }
);

// Each order is an independent actor maintaining its own invariants
const order1 = createOrderActor("order-1");
const order2 = createOrderActor("order-2");

// Messages to different orders process independently
await order1.send({ type: "ADD_ITEM", item: { sku: "A", qty: 2, price: 10 } });
await order2.send({ type: "ADD_ITEM", item: { sku: "B", qty: 1, price: 25 } });

Error Handling with FSM Orchestration

Use onError to propagate actor failures to the FSM layer:

// FSM with error handling - payload stored via onEnter
const workflowFsm = createFsm({
  initial: "IDLE",
  context: { error: null },
  states: {
    IDLE: { on: { start: "PROCESSING" } },
    PROCESSING: { on: { done: "COMPLETED", error: "ERROR" } },
    COMPLETED: {},
    ERROR: {
      onEnter: (ctx, payload) => { ctx.error = payload; }  // Store error from payload
    }
  }
});

// Actor with onError wired to FSM
const processor = createActor({
  initialState: { result: null },
  handler: async (state, msg) => {
    const res = await fetch(msg.url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return { result: await res.json() };
  },
  reducer: (_, response) => response,
  onError: (error, message) => {
    // Pass error as transition payload
    workflowFsm.transition("error", { error: String(error), message });
  }
});

// Usage
workflowFsm.transition("start");
await processor.send({ url: "/api/data" });  // If this throws, FSM → ERROR
workflowFsm.transition("done");              // Only reached on success

Nested FSM: Actor-Level + App-Level Orchestration

Each actor can have its own internal FSM for complex lifecycle management, while a global FSM orchestrates multiple actors at the application level.

import { createActor } from "@marianmeres/actor";
import { createFsm } from "@marianmeres/fsm";

// Factory: creates a "job" actor with its own internal FSM lifecycle
const createJobActor = (id: string, work: () => Promise<unknown>) => {
  // Inner FSM - manages this job's lifecycle
  const jobFsm = createFsm({
    initial: "IDLE",
    context: { result: null, error: null },
    states: {
      IDLE: { on: { start: "RUNNING" } },
      RUNNING: { on: { complete: "DONE", fail: "FAILED" } },
      DONE: {},
      FAILED: { on: { retry: "RUNNING" } }  // Can retry from failed
    }
  });

  // Actor wraps the FSM and handles async work
  const actor = createActor<
    { id: string; status: string; result: unknown; error: unknown },
    { type: "START" } | { type: "RETRY" },
    { status: string }
  >({
    initialState: { id, status: "IDLE", result: null, error: null },
    handler: async (state, msg) => {
      if (msg.type === "START" || msg.type === "RETRY") {
        jobFsm.transition(msg.type === "START" ? "start" : "retry");
        try {
          const result = await work();
          jobFsm.transition("complete");
          jobFsm.context.result = result;
          return { status: "DONE" };
        } catch (e) {
          jobFsm.transition("fail");
          jobFsm.context.error = e;
          return { status: "FAILED" };
        }
      }
      return { status: jobFsm.state };
    },
    reducer: (state, response) => ({
      ...state,
      status: response.status,
      result: jobFsm.context.result,
      error: jobFsm.context.error
    })
  });

  return { actor, fsm: jobFsm };
};

// Create job actors with different work
const job1 = createJobActor("job-1", async () => {
  await delay(100);
  return { data: "result-1" };
});
const job2 = createJobActor("job-2", async () => {
  await delay(200);
  if (Math.random() < 0.3) throw new Error("Random failure");
  return { data: "result-2" };
});

// App-level FSM - orchestrates the batch of jobs
const batchFsm = createFsm({
  initial: "IDLE",
  context: { completed: 0, failed: 0, total: 2 },
  states: {
    IDLE: { on: { run: "RUNNING" } },
    RUNNING: {
      onEnter: async (ctx) => {
        // Start all jobs in parallel
        const results = await Promise.all([
          job1.actor.send({ type: "START" }),
          job2.actor.send({ type: "START" })
        ]);

        // Tally results
        results.forEach(r => {
          if (r.status === "DONE") ctx.completed++;
          else ctx.failed++;
        });

        batchFsm.transition(ctx.failed > 0 ? "partial" : "success");
      },
      on: { success: "COMPLETED", partial: "PARTIAL_FAILURE" }
    },
    PARTIAL_FAILURE: {
      on: {
        retry: "RETRYING",
        accept: "COMPLETED"
      }
    },
    RETRYING: {
      onEnter: async (ctx) => {
        // Retry only failed jobs
        const failedJobs = [job1, job2].filter(j => j.fsm.state === "FAILED");
        for (const job of failedJobs) {
          const result = await job.actor.send({ type: "RETRY" });
          if (result.status === "DONE") {
            ctx.failed--;
            ctx.completed++;
          }
        }
        batchFsm.transition(ctx.failed > 0 ? "partial" : "success");
      },
      on: { success: "COMPLETED", partial: "PARTIAL_FAILURE" }
    },
    COMPLETED: {}
  }
});

// Subscribe to see the layered state
batchFsm.subscribe(({ state }) => console.log("Batch:", state));
job1.actor.subscribe(s => console.log("Job1:", s.status));
job2.actor.subscribe(s => console.log("Job2:", s.status));

// Run the batch
batchFsm.transition("run");

System State History (Audit Trail)

Track state changes across multiple actors and FSM for debugging, audit, or undo/redo:

// Historian actor - records all state changes across the system
const historian = createStateActor<
  { entries: Array<{ ts: number; source: string; state: unknown }> },
  { type: "RECORD"; source: string; state: unknown } | { type: "CLEAR" }
>(
  { entries: [] },
  (state, msg) => {
    switch (msg.type) {
      case "RECORD":
        return {
          entries: [...state.entries, { ts: Date.now(), source: msg.source, state: msg.state }]
        };
      case "CLEAR":
        return { entries: [] };
      default:
        return state;
    }
  }
);

// Helper to wire up any actor to the historian
const track = <T>(name: string, actor: { subscribe: (fn: (s: T) => void) => void }) => {
  actor.subscribe((state) => historian.send({ type: "RECORD", source: name, state }));
};

// Domain actors
const cart = createStateActor({ items: [] }, (state, msg) => { /* ... */ });
const payment = createStateActor({ status: "idle" }, (state, msg) => { /* ... */ });

// Wire them up
track("cart", cart);
track("payment", payment);

// Also track FSM transitions
const checkoutFsm = createFsm({ /* ... */ });
checkoutFsm.subscribe(({ state }) => {
  historian.send({ type: "RECORD", source: "checkout-fsm", state });
});

// Now historian.getState().entries contains full system history:
// [
//   { ts: 1701234567890, source: "cart", state: { items: [] } },
//   { ts: 1701234567891, source: "payment", state: { status: "idle" } },
//   { ts: 1701234567892, source: "checkout-fsm", state: "IDLE" },
//   { ts: 1701234567900, source: "cart", state: { items: [{ sku: "A" }] } },
//   { ts: 1701234567901, source: "checkout-fsm", state: "RESERVING" },
//   ...
// ]

License

MIT