A c t
TypeScript-first Event Sourcing Framework
Build robust, auditable, and reactive systems with composable state machines, pure functions, and zero runtime bloat.
Install
npm install @rotorsoft/act
Minimal App
import { act, state } from "@rotorsoft/act";
import { z } from "zod";
const Counter = state({ Counter: z.object({ count: z.number() }) })
  .init(() => ({ count: 0 }))
  .emits({ Incremented: z.object({ amount: z.number() }) })
  .patch({ Incremented: ({ data }, s) => ({ count: s.count + data.amount }) }) // optional
  .on({ increment: z.object({ by: z.number() }) })
  .emit((action) => ["Incremented", { amount: action.by }])
  .build();
const app = act().withState(Counter).build();
await app.do("increment", { stream: "counter1", actor: { id: "1", name: "User" } }, { by: 1 });
console.log(await app.load(Counter, "counter1"));
Run & Explore
Run your app and see the output in your terminal or browser console.
Get Started

Adaptive Dual-Frontier Drain

Open in StackBlitz Source on GitHub

Advanced Composition Patterns

Group states and reactions into feature modules

Each slice owns a set of partial states and their scoped reactions. Handlers receive a typed Dispatcher for cross-state coordination.

const TicketCreationSlice = slice()
  .withState(TicketCreation)      // partial state
  .withState(TicketOperations)    // another partial state

  .on("TicketOpened")
  .do(async function assign(event, _stream, app) {
    const agent = assignAgent(event.stream, event.data.supportCategoryId);
    await app.do("AssignTicket",
      { stream: event.stream, actor: { id: randomUUID(), name: "assign" } },
      agent, event
    );
  })
  .build();
WolfDesk uses 3 slices: Creation, Operations, and Messaging — each a self-contained feature boundary.

Build read-model updaters from events

Projections react to events and update external state (databases, caches). Unlike slices, handlers are pure side effects — no Dispatcher needed.

const TicketProjection = projection("tickets")
  .on({ TicketOpened })
    .do(async function opened({ stream, data }) {
      await db.insert(tickets).values({ id: stream, ...data });
    })
  .on({ TicketClosed })
    .do(async function closed({ stream, data }) {
      await db.update(tickets).set(data).where(eq(tickets.id, stream));
    })
  .on({ MessageAdded })
    .do(async function messageAdded({ stream }) {
      await db.update(tickets)
        .set({ messages: sql`${tickets.messages} + 1` })
        .where(eq(tickets.id, stream));
    })
  .build();
Default target "tickets" is inherited by all handlers. Override per-handler with .to() or .void().

Compose multiple partial states into a single aggregate

Split a complex aggregate into focused partial states that share the same stream. Each partial declares only the fields it needs. Schemas merge automatically at build time.

// Three partials share the Ticket stream
const TicketCreation = state({ Ticket: TicketCreationState })
  .init(() => ({ title: "", productId: "", userId: "", priority: "Low", messages: {} }))
  .emits({ TicketOpened, TicketClosed, TicketResolved })
  .patch({ TicketOpened: ... })  // optional — only custom reducers
  .on({ OpenTicket }).emit(...)
  .on({ CloseTicket }).given([mustBeOpen]).emit("TicketClosed")  // passthrough
  .build();

const TicketOperations = state({ Ticket: TicketOperationsState })
  .init(() => ({ productId: "", userId: "", messages: {} }))
  .emits({ TicketAssigned, TicketEscalated, TicketReassigned })
  .on({ AssignTicket }).given([mustBeOpen]).emit("TicketAssigned")  // passthrough
  .build();

const TicketMessaging = state({ Ticket: TicketMessagingState })
  // ... handles AddMessage, MarkMessageDelivered, AcknowledgeMessage
  .build();
All three share the name "Ticket" — the orchestrator merges their schemas via ZodObject.extend().

Define reusable business rules across slices

Invariants are typed constraints checked before actions execute. Define them once, reuse across any state via .given([invariant]).

import { type Invariant } from "@rotorsoft/act";

export const mustBeOpen: Invariant<{ productId: string; closedById?: string }> = {
  description: "Ticket must be open",
  valid: (state) => !!state.productId && !state.closedById,
};

export const mustBeUser: Invariant<{ productId: string; userId: string }> = {
  description: "Must be the owner",
  valid: (state, actor) => state.userId === actor?.id,
};

// Use in any state builder:
.on({ CloseTicket }).given([mustBeOpen]).emit("TicketClosed")  // passthrough
.on({ RequestEscalation }).given([mustBeOpen, mustBeUser]).emit("TicketEscalationRequested")
Typed against minimal interfaces — TypeScript contravariance ensures Invariant<SuperType> is assignable to Invariant<SubType>.

Wire everything together with the Act orchestrator

The act() builder uses .withState(), .withSlice(), and .withProjection() for type-safe composition. Schemas and reactions merge automatically — one line per feature module.

import { act } from "@rotorsoft/act";

export const app = act()
  .withSlice(TicketCreationSlice)    // slice: states + scoped reactions
  .withSlice(TicketMessagingSlice)   // slice: states + scoped reactions
  .withSlice(TicketOpsSlice)         // slice: states + scoped reactions
  .withProjection(TicketProjection)  // projection: read-model updaters
  .build();

// Execute actions
await app.do("OpenTicket", { stream: "ticket-1", actor }, payload);

// Load merged state (all partials combined)
const snapshot = await app.load("Ticket", "ticket-1");

// Process reactions
await app.drain({ streamLimit: 100, eventLimit: 1000 });
3 slices + 1 projection compose into a complete ticketing system. Each feature module is independently testable.

Functional Event Sourcing

Every state change is a pure function of previous state and events. Immutability and replayability by design.

Composable State Machines

Model your domain as composable, strongly-typed state machines. No classes, just functions and data.

TypeScript Native

Type safety and inference everywhere. Catch errors at compile time, not at runtime.

Reactive by Default

Reactions let you build event-driven flows and side effects with ease.

Postgres Adapter Included

Production-ready Postgres adapter for scalable, persistent event storage. Switch between in-memory and Postgres with a single line of code.

Minimal Footprint

Minimal and dependency-light. No codegen, no runtime bloat, and a tiny bundle size.