Skip to main content

State Management

Act models domain logic as state machines — each entity is a state definition with actions that emit events, and events that patch state.

State Builder

States are built using a fluent API:

import { 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 }, state) => ({ count: state.count + data.amount }),
})
.on({ increment: z.object({ by: z.number() }) })
.emit((action) => ["Incremented", { amount: action.by }])
.build();

Builder Chain

state({}).init().emits({}) → optional .patch({}).on({}) → optional .given([]).emit().build()

  • .emits() declares events with passthrough reducers by default (({ data }) => data)
  • .patch() overrides only events that need custom reducers
  • .emit("EventName") passes the action payload through directly as event data
  • .emit((action, snapshot, target) => [name, data]) for computed event data

Partial States

Multiple states sharing the same name merge automatically when composed in slices or the act orchestrator:

const TicketCreation = state({ Ticket: z.object({ title: z.string() }) })
.init(() => ({ title: "" }))
.emits({ TicketOpened: z.object({ title: z.string() }) })
.on({ OpenTicket: z.object({ title: z.string() }) })
.emit("TicketOpened")
.build();

const TicketOperations = state({ Ticket: z.object({ status: z.string() }) })
.init(() => ({ status: "open" }))
.emits({ TicketClosed: z.object({ reason: z.string() }) })
.on({ CloseTicket: z.object({ reason: z.string() }) })
.emit("TicketClosed")
.build();

// These merge into a single "Ticket" state with both actions and events

Invariants

Business rules enforced before actions execute:

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

const mustBeOpen: Invariant<{ status: string }> = {
description: "Ticket must be open",
valid: (state) => state.status === "open",
};

.on({ CloseTicket: z.object({ reason: z.string() }) })
.given([mustBeOpen])
.emit("TicketClosed")

When an invariant fails, the framework throws an InvariantError with the description.

Snapshots

For long-lived streams, configure snapshotting to avoid replaying the entire event history on cold starts:

.snap((snap) => snap.patches >= 50)  // snapshot every 50 events

Snapshots are persisted as __snapshot__ events in the store and used as a starting point when the cache is cold (process restart, LRU eviction).

Cache

Cache is always-on with InMemoryCache (LRU, maxSize 1000) as the default. It stores the latest state checkpoint per stream:

  • On load() — cache is checked first; only events after the cached position are replayed from the store
  • On action() — cache is updated after each successful commit
  • On ConcurrencyError — stale cache entries are invalidated automatically

Cache and snapshots are the same checkpoint pattern at different layers. Cache eliminates store round-trips on warm hits; snapshots limit replay on cache miss.

Projections

Projections are read-model updaters that react to events:

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

const TicketProjection = projection("tickets")
.on({ TicketOpened })
.do(async ({ stream, data }) => {
await db.insert(tickets).values({ id: stream, ...data });
})
.build();

Projection handlers receive (event, stream) — no dispatcher, no state mutations.

Slices

Slices group partial states with scoped reactions into vertical feature modules:

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

const TicketSlice = slice()
.withState(TicketCreation)
.withState(TicketOperations)
.withProjection(TicketProjection)
.on("TicketOpened")
.do(async (event, stream, app) => {
await app.do("AssignTicket", target, payload, event);
})
.to((event) => ({ target: event.stream }))
.build();

Slice handlers receive (event, stream, app) where app is a typed Dispatcher.

Act Orchestrator

The orchestrator composes everything:

const app = act()
.withState(Counter)
.withSlice(TicketSlice)
.withProjection(AuditProjection)
.build();

await app.do("increment", { stream: "counter1", actor }, { by: 5 });
const snapshot = await app.load(Counter, "counter1");

Utility Types

Extract inferred types from built State objects:

import type { InferEvents, InferActions } from "@rotorsoft/act";

type Events = InferEvents<typeof Counter>;
type Actions = InferActions<typeof Counter>;