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>>;
Task<ReplayEventsResult<State, Event>> ReplayEventsAsync<State, Event>(
    ReplayEventsOptions<State, Event> options)
    where Event : IReplayableAssetEvent;
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;
}
public interface IReplayEventsOptions<State, Event>
{
    IAsyncEnumerable<Event> Events { get; }
    ReplayScope Scope { get; }
    State InitialState { get; }
    IReplaySnapshot<State>? Snapshot { get; }
    Func<State, Event, Task<State>> ApplyEvent { get; }
}

public interface IReplayEventsResult<State, Event>
{
    State State { get; }
    IReadOnlyList<Event> AppliedEvents { get; }
    IReadOnlyList<ReplayWarning> Warnings { get; }
    string? EventCursor { get; }
    int EventSequence { get; }
}

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;
}
public interface IReplayableAssetEvent<Payload>
{
    string Id { get; }
    string TenantId { get; }
    string SpaceId { get; }
    string EventType { get; }
    string SubjectType { get; }
    string SubjectId { get; }
    Payload Payload { get; }
    int Sequence { get; }
    DateTime OccurredAt { get; }
    DateTime RecordedAt { get; }
    string ActionInvocationId { get; }
    string CorrelationId { get; }
}

ReplayScope

interface ReplayScope {
  tenantId: string;
  spaceId: string;
  subjectType?: string;
  subjectId?: string;
}
public class ReplayScope
{
    public string TenantId { get; set; }
    public string SpaceId { get; set; }
    public string? SubjectType { get; set; }
    public string? SubjectId { get; set; }
}
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;
}
public interface IReplaySnapshot<State>
{
    State SnapshotData { get; }
    string? EventCursor { get; }
    int? EventSequence { get; }
}

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