Skip to content

defineSchema

defineSchema({ ... }) declares an actor's data shape + lifecycle states + storage hints as a named, standalone artifact. The actor borrows from it.

Why it's separate from defineActor:

  • Studio can render the data model without booting the actor.
  • Persistent stores (@nwire/store-mongo, @nwire/store-prisma) read indexes + unique to build adapter-level constraints.
  • Projections can reference the same schema for view typing.
  • Validation at boot catches bad state names + key references before any event flows.

Signature

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

function defineSchema<TFields extends Record<string, ZodTypeAny>>(
  options: SchemaOptions<TFields>,
): SchemaDefinition<TFields>

SchemaOptions

ts
interface SchemaOptions<TFields> {
  name: string                                       // unique within the app
  key: keyof TFields & string                        // which field is the actor id
  fields: TFields                                    // { fieldName: z.string(), ... }
  states: Record<string, { initial?: true; final?: true }>
  storage?: {
    indexes?: ReadonlyArray<ReadonlyArray<string>>   // adapter hint
    unique?:  ReadonlyArray<ReadonlyArray<string>>   // adapter hint
  }
}

Validation at define time:

  • Exactly one state must be marked initial: true.
  • key must exist in fields.

Example

ts
import { z } from "zod"
import { defineSchema } from "@nwire/forge"

export const SubmissionData = defineSchema({
  name: "submission",
  key: "submissionId",

  fields: {
    submissionId: z.string(),
    studentId:    z.string(),
    exerciseId:   z.string(),
    answer:       z.string(),
    confidence:   z.number().optional(),
    verdict:      z.string().optional(),
    submittedAt:  z.string().datetime(),
    gradedAt:     z.string().datetime().optional(),
  },

  states: {
    submitted:      { initial: true },
    "under-review": {},
    graded:         { final: true },
  },

  storage: {
    indexes: [["studentId"], ["exerciseId"]],
    unique:  [["studentId", "exerciseId"]],
  },
})

What you get back

ts
SubmissionData.$kind        // "schema"
SubmissionData.name         // "submission"
SubmissionData.key          // "submissionId"
SubmissionData.initial      // "submitted"        (computed from states)
SubmissionData.finals       // ["graded"]         (computed from states)
SubmissionData.zodSchema    // z.object(fields)   (compiled at define time)
SubmissionData.storage      // { indexes, unique }

Binding to an actor

The schema doesn't carry transitions — those live on defineActor. Pass the schema as schema: and declare per-state on: / after: blocks:

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

export const Submission = defineActor({
  schema: SubmissionData,
  states: {
    submitted: {
      on: {
        [AnswerWasSubmitted.name]: { assign: (_s, e) => ({ ...e }) },
        [AnswerWasFlagged.name]: {
          target: "under-review",
          assign: (_s, e) => ({ confidence: e.confidence }),
        },
      },
    },
    "under-review": {
      on: {
        [SubmissionWasManuallyGraded.name]: {
          target: "graded",
          assign: (_s, e) => ({ verdict: e.verdict }),
        },
      },
    },
    // `graded` is `final` in the schema — omit it here.
  },
  methods: { ... },
})

The actor inherits name, key, initial, and the set of valid state names from the schema. The actor's states block fills in transitions and timers for non-final states only.

Errors caught at boot:

  • Declaring on: for a state the schema marks final: true.
  • Declaring transitions for a state not listed in the schema (catches typos).

Storage hints

indexes and unique are hints, not promises. Each adapter honors what it can:

AdapterHonors indexesHonors unique
InMemoryActorStoreignoredignored
@nwire/store-fileignoredignored
@nwire/store-mongocreates real indexes at bootcreates unique indexes
@nwire/store-prisma (planned)generated in migrationsgenerated in migrations

Studio renders the hints alongside the actor view so reviewers know which fields are query-fast.

Reusing the zod schema

SchemaDef.zodSchema is a compiled z.object(fields). Use it anywhere you need to validate or type a record of the actor's shape — projection serializers, test fixtures, OpenAPI emitters:

ts
const parsed = SubmissionData.zodSchema.parse(payload)
type Submission = z.output<typeof SubmissionData.zodSchema>

See also

MIT licensed.