Skip to content

defineProjection

defineProjection<TState>(name, 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,
  options: ProjectionOptions<TState>,
): ProjectionDefinition<TState>

ProjectionOptions

ts
interface ProjectionOptions<TState> {
  listens: readonly EventDefinition[]                  // events to subscribe to
  initial: () => TState                                // factory for empty state
  on: Readonly<Record<string, ProjectionReducer<TState>>>

  // 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", {
  description: "Per-student submission index, newest first.",
  freshness: { p95MsBehindStream: 50 },

  listens: [
    AnswerSubmittedEvent,
    SubmissionAutoGradedEvent,
    SubmissionFlaggedForReviewEvent,
    SubmissionManuallyGradedEvent,
  ],

  initial: () => ({ byStudent: {} }),

  on: {
    [AnswerSubmittedEvent.name]: (state, e) => ({
      byStudent: {
        ...state.byStudent,
        [e.studentId]: [
          { id: e.submissionId, studentId: e.studentId, status: "submitted", submittedAt: e.submittedAt },
          ...(state.byStudent[e.studentId] ?? []),
        ],
      },
    }),

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

    // …
  },
})

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

Required: every listens entry has a reducer

defineProjection throws at definition time if you list an event in listens but forget the matching on reducer:

defineProjection("submissions-by-student"): event "submissions.auto-graded"
is in 'listens' but has no reducer in 'on'.

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/store-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/store-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 listens. The freshness SLO is scored against observed lag (from projection.folded telemetry timestamps vs event.published timestamps).

See also

MIT licensed.