Skip to content

defineActor

defineActor declares a state machine bound to a single domain entity. DDD readers: this is the aggregate root.

Two forms — schema-bound (recommended) and classic positional.

Define data + lifecycle once via defineSchema, then bind transitions per state.

ts
import { defineActor } from "@nwire/forge"
import { SubmissionData } from "./submission.schema"

export const Submission = defineActor({
  schema: SubmissionData,           // brings name, key, initial, final states
  states: {
    submitted: {
      on: {
        [AnswerWasFlagged.name]: {
          target: "under-review",
          assign: (_s, e) => ({ confidence: e.confidence }),
        },
      },
    },
    "under-review": {
      on: { [SubmissionWasManuallyGraded.name]: { target: "graded" } },
      after: { reminder: { delay: "3d", action: "submissions.send-reminder" } },
    },
    // `graded` is `final` in the schema — omit it.
  },
  methods: { /* ... */ },
})

The actor inherits name, key, initial, and the set of valid states from the schema. Typos are caught at boot:

  • on: reactions on a final state → error
  • Transitions for an unknown state → error

Signature (schema-bound)

ts
function defineActor<TFields, TMethods>(
  options: ActorOptionsBound<TFields, TMethods>,
): ActorDefinition

interface ActorOptionsBound<TFields, TMethods> {
  name?: string                   // defaults to schema.name
  schema: SchemaDefinition<TFields>
  states?: Record<string, { on?: ...; after?: ... }>
  methods?: TMethods
  stuckThresholds?: Record<string, number>
  slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>
}

Classic positional form

The original signature still works — useful when you don't want the schema artifact (small actors, tests, one-offs).

ts
function defineActor<TSchema extends ZodTypeAny, TMethods>(
  name: string,
  options: ActorOptions<TSchema, TMethods>,
): ActorDefinition<TSchema, TMethods>

ActorOptions (classic)

ts
interface ActorOptions<TSchema, TMethods> {
  schema: TSchema                 // zod object describing the actor's data
  key: string                     // field name in event payloads → actor id
  initial: string                 // initial state name
  states: Record<string, ActorStateConfig>
  methods?: TMethods              // pure invariant-enforcers callable via ctx.use(…)

  // Studio-aware
  stuckThresholds?: Record<string, number>     // state → ms before "stuck"
  slas?: Record<string, { maxDurationMs: number; escalateTo?: string }>
}

interface ActorStateConfig<TCtx> {
  final?: boolean
  on?: Record<string, ActorReaction<TCtx>>      // event reactions
  after?: Record<string, ActorTimerSpec>        // deferred dispatches
}

interface ActorReaction<TCtx, TPayload> {
  target?: string                  // transition target state; omit to stay
  assign?: (state: Readonly<TCtx>, event: TPayload) => Partial<TCtx>
}

type ActorTimerSpec =
  | string                                              // action name; delay = timer key
  | { delay: string; action: string; buildInput?: (state, key) => unknown }

Example

ts
const Submission = defineActor("submission", {
  schema: SubmissionData,           // z.object({ submissionId, studentId, status, verdict?, ... })
  key: "submissionId",
  initial: "submitted",

  states: {
    submitted: {
      on: {
        [AnswerSubmittedEvent.name]: {
          assign: (_state, event) => ({ ...event, status: "submitted" }),
        },
        [SubmissionAutoGradedEvent.name]: {
          target: "graded",
          assign: (_state, event) => ({ status: "graded", verdict: event.verdict }),
        },
        [SubmissionFlaggedForReviewEvent.name]: {
          target: "under-review",
        },
      },
    },

    "under-review": {
      on: {
        [SubmissionManuallyGradedEvent.name]: { target: "graded" },
      },
      after: {
        // Schedule a deferred action while in this state.
        // Cancelled automatically on state transition.
        "review-reminder": {
          delay: "3d",                                     // or "1h", "5m", "30s"
          action: "submissions.send-review-reminder",
          buildInput: (_state, submissionId) => ({ submissionId }),
        },
      },
    },

    graded: { final: true },          // terminal state — no further events
  },

  // Studio: surfaces stuck instances after 48h in under-review
  stuckThresholds: {
    "under-review": 48 * 60 * 60 * 1000,
  },

  // Studio: hard SLA + escalation routing
  slas: {
    "under-review": {
      maxDurationMs: 7 * 24 * 60 * 60 * 1000,
      escalateTo: "curriculum-lead",
    },
  },

  // 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 })
    },
  },
})

Lifecycle

  1. First event arrives with this actor's key field
  2. Runtime creates an actor instance in the initial state if none exists
  3. Looks up states[current].on[event.name] for a matching reaction
  4. Applies assign to update data
  5. If reaction has target, transitions state — cancels old after timers, schedules new ones
  6. Persists via actorStore.save()
  7. Emits actor.transitioned telemetry on state change

Multi-tenancy

Actor instances are partitioned by envelope.tenant. School A's submissions and school B's submissions live in separate keys — (actor: "submission", key: "sub-1", tenant: "school-a") vs (..., tenant: "school-b").

ctx.use — the actor view

ts
defineHandler(flagSubmission, async (input, ctx) => {
  const submission = await ctx.use(Submission, input.submissionId)

  submission.state          // current data (readonly snapshot)
  submission.stateName      // "submitted" | "under-review" | "graded"
  submission.key            // "sub-abc-123"

  return submission.flag(input.reason)    // methods pre-bound to state
})

The view is a snapshot — subsequent dispatches don't refresh it. Re-call ctx.use after a dispatch that mutated the actor.

Methods

Methods are pure: take state, return event(s) or read values. The runtime treats them as part of the actor's contract:

ts
methods: {
  // Mints an event
  flag(state, reason) {
    if (state.status !== "submitted") throw new Error("invariant")
    return SubmissionFlaggedForReview({ ... })
  },

  // Pure read
  canBeReviewed(state) {
    return state.status === "under-review"
  },

  // Multiple events
  closeOut(state) {
    return [SubmissionGraded({ ... }), MetricsRecorded({ ... })]
  },
},

The framework calls them with the actor's current state pre-bound.

When state stays the same

If no target is specified, the actor stays in its current state. Data may still update via assign. actor.transitioned only emits on actual state changes — pure data updates don't fire it.

Final states

final: true marks a state as absorbing. The validator rejects on: entries on final states (events to that actor are silently dropped after final). Useful for archived, cancelled, completed etc.

Timers (after)

ts
"under-review": {
  after: {
    "review-reminder": { delay: "3d", action: "submissions.send-review-reminder" },
  },
}

When the actor enters under-review:

  1. Runtime schedules a timer with fireAt = now + parseDelay("3d")
  2. Stored on the actor instance
  3. A periodic runtime.fireDueTimers() (or BullMQ scheduler) dispatches the action when due
  4. If the actor transitions OUT of under-review before then, the timer is cancelled

parseDelay accepts: "500ms", "30s", "5m", "3h", "7d".

Timer-fired actions inherit the actor's tenant envelope, so cross-tenant state stays isolated.

See also

MIT licensed.