FabricFabricPlatform
Platform referenceReference

Projection mechanics reference

replayEvents API, snapshot model, warning catalog, and ordering rules.

Projection mechanics

Source: packages/platform/projections/index.ts.

replayEvents

async function replayEvents<State, Event extends ReplayableAssetEvent>(
  options: ReplayEventsOptions<State, Event>,
): Promise<ReplayEventsResult<State, Event>>;
interface ReplayEventsOptions<State, Event> {
  events: Iterable<Event> | AsyncIterable<Event>;
  scope: ReplayScope;
  initialState: State;
  snapshot?: ReplaySnapshot<State> | null;
  applyEvent: (state: State, event: Event) => State | Promise<State>;
}

interface ReplayEventsResult<State, Event> {
  state: State;
  appliedEvents: Event[];
  warnings: ReplayWarning[];
  eventCursor: string | null;
  eventSequence: number;
}

ReplayableAssetEvent

A subset of AssetEventEnvelope — the fields the engine actually needs:

interface ReplayableAssetEvent<Payload = unknown> {
  id: string;
  tenantId: string;
  spaceId: string;
  eventType: string;
  subjectType: string;
  subjectId: string;
  payload: Payload;
  sequence: number;
  occurredAt: Date | string;
  recordedAt: Date | string;
  actionInvocationId: string;
  correlationId: string;
}

ReplayScope

interface ReplayScope {
  tenantId: string;
  spaceId: string;
  subjectType?: string;
  subjectId?: string;
}
ScopeEffective ordering
Tenant + space (no subject)Global: recordedAt, then deterministic ties.
Tenant + space + subjectType + subjectIdPer-subject: sequence, then recordedAt.
Subject type without subject ID (or vice versa)subject_scope_incomplete warning, no filter applied.

ReplaySnapshot

interface ReplaySnapshot<State> {
  snapshotData: State;
  eventCursor?: string | null;
  eventSequence?: number | null;
}

If eventCursor is set, replay starts after the matching event. If the cursor is not in the scoped stream, a cursor_not_found warning fires and replay starts from the beginning of the stream.

For subject-scoped replay, if eventSequence is present (and the cursor is absent or unmatched), only events with sequence > eventSequence are applied.

Warning catalog

CodeWhen it fires
cursor_not_foundSnapshot cursor was not in the scoped event stream.
duplicate_eventTwo events with the same id were seen — the duplicate is dropped.
missing_sequenceA per-subject sequence jumped (e.g. expected 5, got 7).
subject_scope_incompletesubjectType and subjectId were not both provided.

Warnings are non-fatal. They are returned in result.warnings for the caller to log or surface.

Ordering — the deterministic tiebreakers

Subject-scoped

sequence → recordedAt → occurredAt → id

Global

recordedAt → occurredAt → actionInvocationId → correlationId →
subjectType → subjectId → sequence → id

Both orderings are total. Two replays of the same event set yield the same applied order — including across machines.

Reducer contract

type ApplyEvent<State, Event> = (state: State, event: Event) => State | Promise<State>;

The reducer must:

  • Be deterministic. Same (state, event) → same next state.
  • Not write to the event log. Reducers consume; they do not produce events.
  • Treat events as immutable. Don't mutate event.payload.

The reducer may:

  • Return a new object or mutate-and-return the same state reference.
  • Be async (the engine awaits each call).
  • Read from external sources, but understand: replay is reproducibility. External reads at replay time can break determinism.

Idempotent persistence

A projection that writes its state to a database must do so idempotently — re-running the reducer on the same event must converge. Patterns:

  • Insert-on-conflict-do-nothing keyed on event.id.
  • Upsert by stable key (e.g. subjectId) where the reducer's output is the desired final shape.
  • High-water-mark cursor stored alongside the projection — only events past the cursor are applied.

The replay engine's eventCursor in the result is the high-water mark.

Cold rebuild

A projection's correctness condition: replaying from initialState over the full event stream must produce the same final state as the snapshot-resumed path. This is testable. Run both paths in CI and assert equality.

If they differ, you have either non-determinism in the reducer or an event-source bug. Fix the reducer, not the snapshot.

See also

On this page