Skip to content

marianmeres/actor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

51 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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

All actor factories 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

About

A lightweight, type-safe Single Actor Model implementation

Resources

License

Stars

Watchers

Forks

Packages

No packages published