Projection creation from events
How read models, dashboards, and analytics views are derived from the AssetEvent log.
Projections from events
Projections are derived views built by replaying events through a reducer. The platform's projection engine (packages/platform/projections/index.ts) provides the replay primitives; verticals define what the projections mean.
What a projection is
function applyEvent(state: ProjectionState, event: AssetEvent): ProjectionStateThat's it. A projection is:
- An initial state.
- A reducer.
- Optionally, a snapshot to skip past prior events.
Replay this over the event stream → you get the projection's current state.
What replayEvents does
The platform ships a replayEvents helper that handles the hard parts:
const result = await replayEvents({
events, // iterable or async iterable
scope, // { tenantId, spaceId, subjectType?, subjectId? }
initialState,
snapshot, // optional ReplaySnapshot
applyEvent,
});Behind the scenes:
- Scope filtering — drops events outside
tenantId/spaceId/ subject. - Sorting — subject-scoped replay sorts by per-subject
sequence. Global replay sorts byrecordedAtwith deterministic tiebreakers. - Deduplication — duplicate
ids are dropped, with aduplicate_eventwarning. - Gap detection —
missing_sequencewarnings flag holes in the per-subject sequence. - Cursor resume — if the snapshot's
eventCursormatches an event in the stream, replay starts after it. If not, acursor_not_foundwarning fires. - Subject-scope completeness — passing
subjectTypewithoutsubjectId(or vice versa) emitssubject_scope_incomplete.
Snapshots
A snapshot captures { snapshotData, eventCursor, eventSequence }. Store one periodically; on restart, replay only the events after the cursor.
interface ReplaySnapshot<State> {
snapshotData: State;
eventCursor?: string | null;
eventSequence?: number | null;
}Snapshots are an optimization, not a correctness mechanism. A cold rebuild from event 1 must yield the same state.
Idempotency on the consumer side
The replay engine assumes the reducer is deterministic and side-effect-free with respect to state. If a projection writes to a database, it must:
- Use the event's
idas an idempotency key (insert-on-conflict-do-nothing). - Tolerate replays — re-running the reducer on the same event must converge.
The platform makes the events deterministic; the reducer must do its part.
Diagram
Common projection shapes
| Use case | Scope | Reducer behavior |
|---|---|---|
| Single-subject view | subject-scoped | Maintain a per-subject record (e.g. an offer's status). |
| Aggregate counters | global, filtered | Increment counters keyed by (tenantId, spaceId, …). |
| Audit query | global, time-windowed | Append-only — every event is a row in the audit table. |
| Search index | global, derived | Compute search docs from event payloads. |
What projections must not do
- Don't mutate event payloads. Treat them as immutable inputs.
- Don't infer ordering across subjects from
sequence. That field is per-subject. UserecordedAt+ tiebreakers for global order. - Don't write back into the event log. Projections are downstream. Corrections happen by emitting new events from actions, never by editing past ones.
See also
- Events as evidence — the "why."
- Projection mechanics reference — full API and warning catalogue.
- Event schema reference — what reducers consume.