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
function defineProjection<TState>(
name: string,
options: ProjectionOptions<TState>,
): ProjectionDefinition<TState>ProjectionOptions
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>) => TStateExample
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:
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
// 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
- Concepts → Projection
- defineQuery — read functions over projections
- @nwire/store-mongo