Actor
A state machine bound to a single domain entity. The actor decides how its own state changes; handlers never write to actors directly.
DDD readers: this is the aggregate root. Erlang readers: not the same as BEAM actors (no mailbox), but the lifecycle is similar.
Shape
The actor borrows its data shape, key, and lifecycle states from a defineSchema and fills in transitions per state:
ts
import { defineActor, defineSchema } from "@nwire/forge";
import { z } from "zod";
export const SubmissionData = defineSchema({
name: "submission",
key: "submissionId",
fields: {
submissionId: z.string(),
studentId: z.string(),
verdict: z.string().optional(),
},
states: {
submitted: { initial: true },
"under-review": {},
graded: { final: true },
},
});
export const Submission = defineActor({
schema: SubmissionData,
states: {
submitted: {
on: {
[AnswerWasSubmitted.name]: {
// assign updates the actor's data — pure, no side effects
assign: (_state, event) => ({ ...event }),
},
[SubmissionWasAutoGraded.name]: {
target: "graded", // transition to a new state
assign: (_state, event) => ({ ...event }),
},
[SubmissionWasFlaggedForReview.name]: {
target: "under-review",
},
},
},
"under-review": {
on: {
[SubmissionWasManuallyGraded.name]: { target: "graded" },
},
after: {
// Schedule a deferred action while in this state. Cancelled
// automatically on state transition.
"review-reminder": {
delay: "3d",
action: "submissions.send-review-reminder",
},
},
},
// `graded` is final in the schema — omit it here.
},
// Studio-aware: when in this state for > threshold, surface as "stuck"
stuckThresholds: {
"under-review": 48 * 60 * 60 * 1000,
},
// Hard SLA — Studio raises an alert and routes to escalateTo
slas: {
"under-review": {
maxDurationMs: 7 * 24 * 60 * 60 * 1000,
escalateTo: "curriculum-lead",
},
},
// Optional pure invariant-enforcing methods callable via ctx.use(Submission, id)
methods: {
flag(state, reason: string) {
if (state.status !== "submitted") {
throw new Error("only submitted submissions can be flagged");
}
return SubmissionFlaggedForReview({ submissionId: state.submissionId, reason });
},
},
});Usage from a handler
ts
defineHandler(flagSubmission, async (input, ctx) => {
const submission = await ctx.use(Submission, input.submissionId);
// submission.state, submission.stateName, submission.key — plus methods
return submission.flag(input.reason); // throws if invariant violated
});What goes in assign vs methods
assign(state, event)— pure data fold. Called during the runtime's state-transition phase. Cannot read or call anything.methods.<name>(state, ...args)— pure invariant-enforcer called from handlers. Receives state, returns an event (or throws). Used for "only X can do Y when in state Z" rules.
Studio-aware metadata
| Field | What Studio does with it |
|---|---|
stuckThresholds: Record<state, ms> | Surfaces actor instances exceeding the threshold in the stuck-state inbox |
slas: Record<state, { maxDurationMs, escalateTo? }> | Hard SLA alerts with escalation routing |
See also
- defineActor — full API
- Event — what folds into actors
- Timer scheduling —
afterblock +fireDueTimers