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
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.
interface ProjectionBuilderContext<TState> {
when<E>(event: E, reducer: (state: TState, event: EventPayload<E>) => TState): void
}ProjectionOptions
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>) => 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",
({ 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:
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
// 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
- Concepts → Projection
- defineQuery — read functions over projections
- @nwire/mongo