Skip to content

defineProjection

defineProjection<TState>(name, build, options) declares a read-side view folded from events. CQRS write path goes through actors; read path goes through projections.

Signature

ts
function defineProjection<TState>(
  name: string,
  build: (ctx: ProjectionBuilderContext<TState>) => void,
  options: ProjectionOptions<TState>,
): ProjectionDefinition<TState>

ProjectionBuilderContext

The builder closure receives the one event verb, when — each call registers a fold and adds the event to what the projection listens for.

ts
interface ProjectionBuilderContext<TState> {
  when<E>(event: E, reducer: (state: TState, event: EventPayload<E>) => TState): void
}

ProjectionOptions

ts
interface ProjectionOptions<TState> {
  initial: () => TState                                // factory for empty state

  // Studio-aware
  description?: string
  freshness?: { p95MsBehindStream?: number }           // declared lag SLO
}

type ProjectionReducer<TState, E> = (state: TState, event: EventPayload<E>) => TState

Example

ts
type SubmissionView = {
  id: string
  studentId: string
  status: "submitted" | "under-review" | "graded"
  submittedAt: string
  gradedAt?: string
}

export const SubmissionsByStudent = defineProjection<{
  byStudent: Record<string, SubmissionView[]>
}>(
  "submissions-by-student",
  ({ when }) => {
    when(AnswerSubmittedEvent, (state, e) => ({
      byStudent: {
        ...state.byStudent,
        [e.studentId]: [
          { id: e.submissionId, studentId: e.studentId, status: "submitted", submittedAt: e.submittedAt },
          ...(state.byStudent[e.studentId] ?? []),
        ],
      },
    }))

    when(SubmissionAutoGradedEvent, (state, e) => updateOne(state, e.studentId, e.submissionId, {
      status: "graded", gradedAt: e.gradedAt,
    }))

    // …
  },
  {
    description: "Per-student submission index, newest first.",
    freshness: { p95MsBehindStream: 50 },
    initial: () => ({ byStudent: {} }),
  },
)

function updateOne(state, studentId, submissionId, patch) {
  return {
    byStudent: {
      ...state.byStudent,
      [studentId]: (state.byStudent[studentId] ?? []).map((s) =>
        s.id === submissionId ? { ...s, ...patch } : s,
      ),
    },
  }
}

One reducer per event

defineProjection throws at definition time if you register two when reducers for the same event:

defineProjection("submissions-by-student"): event "submissions.auto-graded"
has more than one when() reducer.

Pure reducers

Projection reducers MUST be pure: (state, event) => state. No I/O, no side effects, no reads from external systems. Why:

  • The runtime can replay the event log to rebuild state (backfill, migration)
  • The same fold produces the same state — deterministic
  • Studio can render projection state alongside actor state without worrying about side effects

If you need a side effect on an event — that's a reaction (when(Event, fn)), not a projection.

Multi-tenancy

Projection state is partitioned by envelope.tenant. Each tenant gets its own folded state from its own slice of events. The framework handles this automatically — your reducer signature doesn't change.

Storage

Default: InMemoryProjectionStore (lost on restart). For durability:

ts
import { MongoProjectionStore } from "@nwire/mongo"
import { FileProjectionStore } from "@nwire/store-file"

createApp({
  modules,
  projectionStore: new MongoProjectionStore(mongoClient.db("nwire")),
})

For massive projections that can't fit in memory, see the streaming projection store contract in @nwire/mongo README — load() / save() happen per-tenant, so each tenant's state is held only when that tenant has an active dispatch.

Replay / backfill

ts
// Reset a tenant's projection state and rebuild from events.
await projectionStore.save("submissions-by-student", initialState, "school-tlv")

// Then re-feed events through runtime.publish — Nwire's event store +
// replay is a v0.2 deliverable. For now, replay by reading from your
// event log (e.g., NATS JetStream) and dispatching applyExternalEvent.

Studio rendering

Projections appear as green stickies on the EventStorm canvas. The edges from events into projections come from the when calls. The freshness SLO is scored against observed lag (from projection.folded telemetry timestamps vs event.published timestamps).

See also

MIT licensed.