Skip to content

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

FieldWhat 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

MIT licensed.