npm install @rotorsoft/act
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"));
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();
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();
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();
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")
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 });
Every state change is a pure function of previous state and events. Immutability and replayability by design.
Model your domain as composable, strongly-typed state machines. No classes, just functions and data.
Type safety and inference everywhere. Catch errors at compile time, not at runtime.
Reactions let you build event-driven flows and side effects with ease.
Production-ready Postgres adapter for scalable, persistent event storage. Switch between in-memory and Postgres with a single line of code.
Minimal and dependency-light. No codegen, no runtime bloat, and a tiny bundle size.