Skip to main content
Version: Current

ActionBuilder

@rotorsoft/act-root


@rotorsoft/act-root / act/src / ActionBuilder

Type Alias: ActionBuilder<TState, TEvents, TActions, TName, TSnap>

ActionBuilder<TState, TEvents, TActions, TName, TSnap> = object

Defined in: libs/act/src/builders/state-builder.ts:152

Builder interface for defining actions (commands) on a state.

Actions represent user/system intents to modify state. Each action is validated against a schema, can have business rule invariants, and must emit one or more events.

Seeโ€‹

state for complete usage examples

Type Parametersโ€‹

TStateโ€‹

TState extends Schema

State schema type

TEventsโ€‹

TEvents extends Schemas

Event schemas type

TActionsโ€‹

TActions extends Schemas

Action schemas type

TNameโ€‹

TName extends string = string

State name literal type

TSnapโ€‹

TSnap extends boolean = false

true once .snap(...) has been called. Gates the .autocloses({ keep }) rolling-window option โ€” a windowed close is meaningless without snapshots, so keep only typechecks after .snap in the chain.

Propertiesโ€‹

archivesโ€‹

archives: (archive) => ActionBuilder<TState, TEvents, TActions, TName, TSnap>

Defined in: libs/act/src/builders/state-builder.ts:561

Declares the archiver the online close cycle runs before truncating a stream this state's .autocloses(...) predicate accepted. Hosts use it to write events to durable storage (S3, an analytics warehouse, cold tier) before the tombstone lands, so the truncate doesn't lose history that the operator still needs.

Threads into CloseTarget.archive via the same plumbing app.close({ stream, archive }) already uses โ€” the cycle holds the stream's guard while the archiver runs, and a thrown archiver leaves the stream guarded but un-truncated. No partial truncate state, no data loss; the cycle retries the candidate on the next tick.

One archiver per state. A second .archives(...) call replaces the first (same shape as .snap / .discloses / .autocloses). Absent โ†’ the cycle truncates without an archive step.

Parametersโ€‹

archiveโ€‹

AutocloseArchiver<TEvents>

(stream, head) => Promise<void>. Runs while the stream is locked against new writes; the truncate runs immediately after a successful resolve.

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions, TName, TSnap>

The ActionBuilder for chaining.

Exampleโ€‹

Archive to S3 before truncate.

state({ Ticket: ticketSchema })
.emits({ TicketOpened, TicketResolved })
// ...
.autocloses({ is: "TicketResolved" })
.archives(async (stream) => {
const events = await loadEvents(stream);
await s3.upload(`tickets/${stream}.jsonl`, events);
})

autoclosesโ€‹

autocloses: (policy) => ActionBuilder<TState, TEvents, TActions, TName, TSnap>

Defined in: libs/act/src/builders/state-builder.ts:513

Declares the online close predicate for this state. The orchestrator's autoclose cycle iterates the state's streams once per tick and calls the predicate per candidate; truthy results are scheduled for atomic truncate-and-seed via Store.truncate on the next batch.

One predicate per state. A second .autocloses(...) call replaces the first (same shape as .snap / .discloses โ€” state-level, not per-event). Absent โ†’ the state opts out of online close entirely; the cycle skips it and pays zero per-tick cost for it.

Pass a declarative AutoclosePolicy object literal covering the three operational pressure points (after, is, reaches). Top-level fields combine with AND; an optional or: {...} block opens an alternative OR path. Validated via Zod at build time; misconfiguration throws before act().build() completes.

Under the hood this compiles to a synthesized reaction (#1090) that runs on a per-aggregate synthetic stream: it defers to head.created + the policy's min after while the cooldown holds and closes the stream once the policy matches. There is no background sweep.

The function-predicate form was removed (#1090). .autocloses no longer accepts (stream, head, count) => boolean; an opaque predicate has no derivable due-time or terminal event to react to. For conditions the declarative form can't express, call app.close(...) from your own logic.

Parametersโ€‹

policyโ€‹

[TSnap] extends [true] ? AutoclosePolicy : Omit<AutoclosePolicy, "keep"> & object

The declarative AutoclosePolicy bag.

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions, TName, TSnap>

The ActionBuilder for chaining.

Examplesโ€‹

Declarative โ€” cooldown after terminal (a Ticket closes

90 days after resolution).

.autocloses({ is: "TicketResolved", after: { days: 90 } })

Declarative โ€” multi-terminal (an Order closes on any of

three terminal events, no cooldown).

.autocloses({ is: ["Shipped", "Delivered", "Cancelled"] })

Declarative โ€” time-only retention (a Session closes

after 24h regardless of head event).

.autocloses({ after: { days: 1 } })

Declarative โ€” pure cardinality cap.

.autocloses({ reaches: 10_000 })

Declarative โ€” primary cooldown + safety-net backstop.

.autocloses({
is: "TicketResolved", // primary trigger
after: { days: 90 }, // AND aged 90 days
or: { reaches: 10_000 }, // OR cardinality safety net
})

Declarative โ€” pure OR (only backstops, no primary

cooldown).

.autocloses({ or: { is: "TicketResolved", reaches: 10_000 } })

Rolling window โ€” keep the last 180 days of real events on a

live stream (requires .snap(...) earlier in the chain; keep won't typecheck without it). Each eligible cycle prunes the prefix below the closest safe snapshot older than now โˆ’ keep.

.snap((s) => s.patches >= 100)
.autocloses({ keep: { days: 180 } })

Terminate AND prune โ€” close 90 days after resolution,

meanwhile keep open streams pruned to a 180-day window.

.snap((s) => s.patches >= 100)
.autocloses({ is: "TicketResolved", after: { days: 90 }, keep: { days: 180 } })

buildโ€‹

build: () => State<TState, TEvents, TActions, TName>

Defined in: libs/act/src/builders/state-builder.ts:583

Finalizes and builds the state definition.

Call this method after defining all actions, invariants, and patches to create the complete State object that can be registered with Act.

Returnsโ€‹

State<TState, TEvents, TActions, TName>

The complete strongly-typed State definition

Exampleโ€‹

const Counter = state({ Counter: schema })
.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(); // Returns State<TState, TEvents, TActions, TName>

disclosesโ€‹

discloses: (disclose) => ActionBuilder<TState, TEvents, TActions, TName, TSnap>

Defined in: libs/act/src/builders/state-builder.ts:422

Declares the disclosure predicate for sensitive(...)-marked event fields. Gates external reads: returning true allows the actor to see plaintext on the event; returning false substitutes "[REDACTED]". When absent, the framework default-denies on every external read โ€” fail-safe.

One predicate per state. A second .discloses(...) call replaces the first (same shape as snapshots being state-level, not per-event).

The predicate receives the full event including merged PII so it can branch on the payload itself (e.g. event.data.ownerId === actor.id). Reducers, projections, and reactions are unaffected โ€” they follow separate visibility rules documented in #855.

Parametersโ€‹

discloseโ€‹

(event, actor) => boolean

Predicate (event, actor) => boolean. true = plaintext, false = "[REDACTED]" substitution.

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions, TName, TSnap>

The ActionBuilder for chaining.

Exampleโ€‹

Owner-or-admin disclosure

state({ User: userSchema })
.init(() => ({ ... }))
.emits({ UserRegistered: z.object({ email: sensitive(z.string()) }) })
.discloses((event, actor) =>
actor.id === event.stream || actor.roles?.includes("admin"))

onโ€‹

on: <TKey, TNewActions>(entry, options?) => object

Defined in: libs/act/src/builders/state-builder.ts:219

Defines an action (command) that can be executed on this state.

Actions represent intents to change state - they should be named in imperative form (e.g., "CreateUser", "IncrementCounter", "PlaceOrder"). Actions are validated against their schema and must emit at least one event.

Pass a { ActionName: schema } record โ€” use shorthand { ActionName } when the variable name matches the action name. The key becomes the action name, the value the Zod schema.

Pass an optional second argument to declare a per-action retry policy โ€” the orchestrator retries this action on ConcurrencyError up to maxRetries extra times, applying backoff between attempts when set. Omit the argument to keep the current single-attempt behavior (ConcurrencyError surfaces on first conflict).

Type Parametersโ€‹

TKeyโ€‹

TKey extends string

Action name (string literal type)

TNewActionsโ€‹

TNewActions extends Schema

Action payload schema type

Parametersโ€‹

entryโ€‹

ActionEntry<TKey, TNewActions>

Single-key record { ActionName: schema }

options?โ€‹

ActionOptions

Optional per-action retry policy (ActionOptions).

Returnsโ€‹

An object with .given() and .emit() for further configuration

emitโ€‹

emit: {(handler): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>; (event_name): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>; }

Defines the action handler that emits events. Same two overloads as the post-.given() form above:

  • Function โ€” receives (action, snapshot) and returns one or more [EventName, data] tuples (or undefined).
  • String โ€” passthrough: the action payload becomes the event data directly. Must reference an event declared in .emits().

The two overloads are kept separate (rather than merged into a handler | string union) so that TS contextual typing of the function alternative isn't degraded by considering the string branch โ€” under the union form TState could collapse to its Schema constraint inside the callback.

Call Signatureโ€‹

(handler): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Parametersโ€‹
handlerโ€‹

ActionHandler<TState, TEvents, { [P in string]: TNewActions }, TKey>

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Call Signatureโ€‹

(event_name): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Parametersโ€‹
event_nameโ€‹

keyof TEvents & string

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Examplesโ€‹

Passthrough (action payload = event data)

.emit("Incremented")

Single event

.emit((action) => ["Incremented", { amount: action.by }])

Multiple events

.emit((action) => [
["Incremented", { amount: action.by }],
["LogUpdated", { message: `Incremented by ${action.by}` }]
])
givenโ€‹

given: (rules) => object

Adds business rule invariants that must hold before the action can execute.

Invariants are checked after loading the current state but before emitting events. Each invariant pairs a description with a valid(state, actor?) predicate โ€” when a predicate returns false, the action throws InvariantError carrying the description. All invariants must pass for the action to succeed.

Parametersโ€‹
rulesโ€‹

Invariant<TState>[]

Array of Invariant objects ({ description, valid })

Returnsโ€‹

An object with .emit() to finalize the action

emitโ€‹

emit: {(handler): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>; (event_name): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>; }

Defines the action handler that emits events.

The handler receives the action payload and current state snapshot, and must return one or more events to emit. Events are applied to state via the patch handlers defined earlier.

Pass a string event name for passthrough: the action payload becomes the event data directly.

Call Signatureโ€‹

(handler): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Custom handler โ€” receives (action, snapshot) and returns one or more [EventName, data] tuples (or undefined).

Parametersโ€‹
handlerโ€‹

ActionHandler<TState, TEvents, { [P in string]: TNewActions }, TKey>

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Call Signatureโ€‹

(event_name): ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Passthrough โ€” the action payload becomes the event data directly. Must reference an event declared in .emits().

Parametersโ€‹
event_nameโ€‹

keyof TEvents & string

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions & { [P in string]: TNewActions }, TName, TSnap>

Paramโ€‹

handler

Function that returns events to emit, or event name string for passthrough

Returnsโ€‹

The ActionBuilder for chaining more actions

Examplesโ€‹

Custom handler

.emit((action, snapshot) => {
const newBalance = snapshot.state.balance + action.amount;
return ["Deposited", { amount: action.amount, newBalance }];
})

Passthrough (action payload = event data)

.emit("TicketAssigned")
Exampleโ€‹
.given([
{ description: "Must be active", valid: (state) => state.status === "active" },
{ description: "Must be the owner", valid: (state, actor) => state.ownerId === actor?.id }
])

Examplesโ€‹

Simple action without invariants

.on({ increment: z.object({ by: z.number() }) })
.emit((action) => ["Incremented", { amount: action.by }])

Hot-stream action with retry + jittered exponential backoff

.on(
{ transfer: z.object({ amount: z.number() }) },
{
maxRetries: 5,
backoff: { strategy: "exponential", baseMs: 10, maxMs: 200, jitter: true },
}
)
.emit((action) => ["Transferred", { amount: action.amount }])

Action with business rules

.on({ withdraw: z.object({ amount: z.number() }) })
.given([
{ description: "Account must be open", valid: (state) => state.status === "open" },
{ description: "Funds must be available", valid: (state) => state.balance > 0 }
])
.emit((action) => ["Withdrawn", { amount: action.amount }])

Action with shorthand (variable name matches action name)

const OpenTicket = z.object({ title: z.string() });
.on({ OpenTicket })
.emit((action) => ["TicketOpened", { title: action.title }])

snapโ€‹

snap: (snap) => ActionBuilder<TState, TEvents, TActions, TName, true>

Defined in: libs/act/src/builders/state-builder.ts:390

Defines a snapshotting strategy to optimize state reconstruction.

Snapshots store the current state at a point in time, allowing faster state loading by avoiding replaying all events from the beginning. The snap function is called after each event is applied and should return true when a snapshot should be taken.

Parametersโ€‹

snapโ€‹

(snapshot) => boolean

Predicate function that returns true when a snapshot should be taken

Returnsโ€‹

ActionBuilder<TState, TEvents, TActions, TName, true>

The ActionBuilder for chaining

Examplesโ€‹

Snapshot every 10 events

.snap((snapshot) => snapshot.patches >= 10)

Snapshot based on state size

.snap((snapshot) => {
const estimatedSize = JSON.stringify(snapshot.state).length;
return estimatedSize > 10000 || snapshot.patches >= 50;
})

Time-based snapshotting

.snap((snapshot) => {
const hoursSinceLastSnapshot = snapshot.patches * 0.1; // Estimate
return hoursSinceLastSnapshot >= 24;
})