Skip to main content

Actions → {State} ← Reactions

Fluent event sourcing for TypeScript. Declare actions, state and reactions with typed builders; Act stores the events, runs the reactions and keeps the projections up to date.

Get startedGitHub

WolfDesk, the ticketing example, drawn from its builders. Each tab is a scripted walk through its flows: an action passes the state's checks, its event is stored, and the reactions and timers it triggers run next. The repo includes a Claude Code skill that makes the same page for your own app.

New in 2.0: rules across streams

A unique email, a limited number of seats, moving something between streams: a rule that spans streams needs no saga. All of it lands, or none of it does.

  1. Load. .loads brings another stream's state into the decision.
  2. Decide. Preconditions and the action see that state, so the rule reads like any other.
  3. Commit. Every stream's events land in one atomic commit, each guarded by the version it was loaded at. A race retries with fresh state.
enrollment.ts
const Enrollment = state({ Enrollment: Enroll })
.init(() => ({ course: "", student: "" }))
.emits({ Enrolled: Enroll })
.on({ Enroll })
// Load the course's stream for this decision.
.loads(Seats, (data) => `course-${data.course}`, ["SeatTaken"])
.requires([{
description: "the course has a free seat",
holds: (_, __, ___, { Seats }) => Seats.taken < 3,
}])
// Both events commit together, or neither does.
.emit((data) => [
["Enrolled", data],
["SeatTaken", { student: data.student }],
])
.build();

Quickstart

A calculator with one state, one action and three events, on the in-memory store. Swap in the Postgres or SQLite store when you need persistence.

  1. Install npm install @rotorsoft/act zod
  2. Declare the state. A Zod schema, the events it emits, how each event patches it, and the action that decides which event to emit.
  3. Run it. Every app.do appends to the log; app.load folds the stream back into state.
Read the guide
quickstart.ts
import { act, state, ZodEmpty } from "@rotorsoft/act";
import { z } from "zod";

const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
const OPERATORS = ["+", "-", "*", "/"] as const;
type Digit = (typeof DIGITS)[number];
type Operator = (typeof OPERATORS)[number];

const Calculator = z.object({
left: z.string().optional(),
right: z.string().optional(),
operator: z.enum(OPERATORS).optional(),
result: z.number(),
});

const ops: Record<Operator, (l: number, r: number) => number> = {
"+": (l, r) => l + r,
"-": (l, r) => l - r,
"*": (l, r) => l * r,
"/": (l, r) => l / r,
};

const calc = state({ Calculator })
.init(() => ({ result: 0 }))
.emits({
DigitPressed: z.object({ digit: z.enum(DIGITS) }),
OperatorPressed: z.object({ operator: z.enum(OPERATORS) }),
EqualsPressed: ZodEmpty,
})
.patch({
DigitPressed: ({ data }, s) =>
s.operator ? { right: (s.right ?? "") + data.digit } : { left: (s.left ?? "") + data.digit },
OperatorPressed: ({ data }) => ({ operator: data.operator }),
EqualsPressed: (_e, s) =>
s.operator && s.left && s.right ? { result: ops[s.operator](Number(s.left), Number(s.right)) } : {},
})
.on({ PressKey: z.object({ key: z.enum([...DIGITS, ...OPERATORS, "="]) }) })
.emit(({ key }) => {
if (key === "=") return ["EqualsPressed", {}];
return DIGITS.includes(key as Digit)
? ["DigitPressed", { digit: key as Digit }]
: ["OperatorPressed", { operator: key as Operator }];
})
.build();

const app = act().withState(calc).build();
const actor = { id: "1", name: "User" };

async function run() {
for (const key of ["4", "+", "2", "="] as const)
await app.do("PressKey", { stream: "calc-1", actor }, { key });
console.log((await app.load(calc, "calc-1")).state.result); // 6
}
run();

Composition patterns

How larger models are put together, shown on WolfDesk. The full source is in packages/wolfdesk.

Each slice owns a set of partial states and their scoped reactions. Handlers receive the full IAct interface 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.

Generated APIs

@rotorsoft/act-http reads the actions of a built app and creates a tRPC router, Hono REST routes and an OpenAPI 3.1 document from them, with the same actor and stream handling and the same error shape on each.

tRPC
A typed client from typeof router, on any fetch runtime.
Hono REST
POST /api/actions/<name> on Node, Bun, Workers, Vercel Edge or Lambda.
OpenAPI 3.1
A plain document of the live surface. Pair it with Scalar or Redoc.
Read the API guide
import { trpc } from "@rotorsoft/act-http/trpc";
import { hono } from "@rotorsoft/act-http/hono";
import { openapi } from "@rotorsoft/act-http/openapi";

// One Act, three transports — same actor / stream seams,
// same ApiError envelope, no codegen.
const tRouter = trpc(app, { actor, stream });
const restApi = hono(app, { actor, stream });
const apiDoc = openapi(app, {
info: { title: "Wolfdesk API", version: "1.0.0" },
});

Features

What the framework gives you, in short.

Event Sourcing

State is folded from events with pure patch functions, so any stream can be replayed to rebuild it.

Composable Builders

States, slices and projections are built with typed builders and composed into one app. No classes.

Typed End to End

Action, event and state types are inferred from Zod schemas, and the same schemas validate at runtime.

Reactions

Reactions run after events are committed, with retries, backoff and timers handled by the framework.

Store Adapters

Postgres and SQLite stores, plus an in-memory store for tests. The store is set in one call.

Small Footprint

The core package depends only on its own patch utility, with Zod as a peer. No code generation.

Get started

Begin with the quickstart, then read the calculator and WolfDesk examples.