Skip to content

defineAction

defineAction({ ... }) declares a typed command. The verb of your domain.

Signature

ts
import { defineAction } from "@nwire/forge"

function defineAction<TSchema extends ZodTypeAny>(
  meta: ActionMeta<TSchema>,
): ActionDefinition<TSchema>

ActionMeta

ts
interface ActionMeta<TSchema extends ZodTypeAny> {
  // Required
  name: string                   // routing key — unique across the app
  schema: TSchema                // zod schema; input is z.input<TSchema>

  // Optional contract
  description?: string           // persona narrative — surfaces in Studio + OpenAPI
  retry?: RetryPolicy            // dispatcher applies retry+DLQ
  policy?: string | string[]     // authz tag — opaque to framework; consumed by @nwire/auth
  emits?: readonly EventDefinition[]   // declared events this action produces

  // Studio-aware metadata (all optional, additive)
  persona?: string               // "Avi (9, beginner)" — Studio groups by persona
  journeyStep?: string           // "J3-submit-exercise" — drives EventStorm L2 layout
  capability?: string            // capability name for filtering/grouping
  slo?: { p95LatencyMs?: number; successRate?: number }
  tags?: readonly string[]

  // Inline handler (alternative to separate defineHandler)
  handler?: (input: z.output<TSchema>, ctx: HandlerContext) => Promise<HandlerReturn> | HandlerReturn
}

interface RetryPolicy {
  max: number                    // retry attempts after initial try (default 0)
  backoff?: "exponential" | "fixed"
  baseDelayMs?: number           // default 100
  maxDelayMs?: number            // default 30000
}

ActionDefinition (returned)

ts
interface ActionDefinition<TSchema = ZodTypeAny> {
  $kind: "action"
  name: string
  description?: string
  schema: TSchema
  retry?: RetryPolicy
  policy?: string | readonly string[]
  emits?: readonly EventDefinition[]
  handler?: HandlerDefinition    // present if inline handler was passed
  persona?: string
  journeyStep?: string
  capability?: string
  slo?: ActionSlo
  tags?: readonly string[]
}

// Extract the input type at the type level
type ActionInput<A> = A extends ActionDefinition<infer S> ? z.output<S> : never

Two registration shapes

Inline (compact)

ts
const submitAnswer = defineAction({
  name: "submissions.submit-answer",
  schema: SubmitAnswerInput,
  emits: [AnswerSubmittedEvent],
  handler: async (input) => AnswerSubmitted({ ...input, submittedAt: now() }),
})
// Pass to defineModule({ actions: [submitAnswer] })
// createApp auto-registers the inline handler.

Separated (for cross-module dispatch)

When module A dispatches an action whose handler lives in module B:

ts
// modules/submissions/grade-submission.action.ts — contract only
export const gradeSubmission = defineAction({
  name: "submissions.grade-submission",
  schema: GradeSubmissionInput,
  emits: [SubmissionAutoGradedEvent],
})

// modules/submissions/grade-submission.handler.ts
import { defineHandler } from "@nwire/forge"
import { gradeSubmission } from "./grade-submission.action"

export const gradeSubmissionHandler = defineHandler(gradeSubmission, async (input) =>
  SubmissionAutoGraded({ ... }),
)

// In the module:
defineModule("submissions", {
  actions: [gradeSubmission, submitAnswer, ...],
  handlers: [gradeSubmissionHandler, ...],
})

Cross-module callers import gradeSubmission (just the contract) and dispatch via ctx.request(gradeSubmission, ...). They never see the handler.

Studio-aware metadata effects

FieldWhat Studio does
personaGroups in EventStorm; lights up Avi's full journey when filtered
journeyStepDrives L2 (Process Flow) grid columns
capabilityFilter in Actions list
sloScore observed latency / success rate against the target
tagsFree-form filtering
emitsDraws Command → Event edges on the canvas

These are intent declarations — none affect runtime behavior. The runtime scores observed reality from telemetry against them.

Retry behavior

ts
defineAction({
  name: "stripe.create-customer",
  retry: { max: 3, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 5000 },
  // ...
})

The retry loop is inside runtime.dispatch. It:

  1. Tries the handler
  2. On throw, emits action.failed telemetry (per attempt) + waits backoff
  3. Up to max + 1 total attempts
  4. After exhausted, records to DLQ + emits dlq.recorded
  5. Re-raises to the caller

Middlewares run OUTSIDE the retry loop — one pass per dispatch.

Idempotency

The retry loop expects your handler to be idempotent. For non-idempotent side effects, push the side effect into a defineExternalCall with an idempotencyKey — the runtime threads the key through every retry.

What the handler can do

ts
defineHandler(submitAnswer, async (input, ctx) => {
  // Envelope (auto-threaded from the wire / parent action)
  ctx.envelope.tenant         // tenant id
  ctx.envelope.userId         // authenticated user id
  ctx.envelope.correlationId  // chain root
  ctx.requestId               // shortcut for envelope.messageId

  // Logging — envelope ids auto-attached
  ctx.logger.info("submitting", { exercise: input.exerciseId })

  // Read a projection (no mutation)
  const history = await ctx.query(submissionsByStudent, { studentId: input.studentId })

  // Dispatch another action — derived envelope, full causation chain
  await ctx.request(scoreSubmission, { ... })

  // Fire-and-forget
  await ctx.send(notifyStudent, { ... })

  // Load + use an actor view
  const submission = await ctx.use(Submission, input.submissionId)
  if (!submission.canBeFlagged()) throw new Error("invariant")
  return submission.flag(input.reason)  // returns event

  // External boundary
  const intent = await ctx.externalCall(chargeStripe, { ... })

  // Resolve a DI dep
  const mailer = ctx.resolve<Mailer>("mailer")

  return AnswerSubmitted({ ... })  // event the actor folds
})

What the handler MUST NOT do

WARNING

  • ❌ Touch actor state directly — return an event; the actor's assign folds it
  • ❌ Throw on input validation that zod should catch
  • ❌ Bypass the envelope — every nested call goes through ctx.*

HandlerReturn

ts
type HandlerReturn = void | EventMessage | EventMessage[] | null
  • Return void / null / undefined if the handler did pure side effects with no event
  • Return one EventMessage for the common case
  • Return EventMessage[] for multi-event handlers (rare; usually a smell — split the action)

See also

MIT licensed.