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.
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.
- Load.
.loadsbrings another stream's state into the decision. - Decide. Preconditions and the action see that state, so the rule reads like any other.
- 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.
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.
- Install
npm install @rotorsoft/act zod - Declare the state. A Zod schema, the events it emits, how each event patches it, and the action that decides which event to emit.
- Run it. Every
app.doappends to the log;app.loadfolds the stream back into state.
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.
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.