FabricFabricPlatform
Platform referenceAction pipeline

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): ProjectionState

That's it. A projection is:

  1. An initial state.
  2. A reducer.
  3. 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 by recordedAt with deterministic tiebreakers.
  • Deduplication — duplicate ids are dropped, with a duplicate_event warning.
  • Gap detectionmissing_sequence warnings flag holes in the per-subject sequence.
  • Cursor resume — if the snapshot's eventCursor matches an event in the stream, replay starts after it. If not, a cursor_not_found warning fires.
  • Subject-scope completeness — passing subjectType without subjectId (or vice versa) emits subject_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 id as 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 caseScopeReducer behavior
Single-subject viewsubject-scopedMaintain a per-subject record (e.g. an offer's status).
Aggregate countersglobal, filteredIncrement counters keyed by (tenantId, spaceId, …).
Audit queryglobal, time-windowedAppend-only — every event is a row in the audit table.
Search indexglobal, derivedCompute 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. Use recordedAt + 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

On this page