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;
}| Scope | Effective ordering |
|---|---|
| Tenant + space (no subject) | Global: recordedAt, then deterministic ties. |
Tenant + space + subjectType + subjectId | Per-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
| Code | When it fires |
|---|---|
cursor_not_found | Snapshot cursor was not in the scoped event stream. |
duplicate_event | Two events with the same id were seen — the duplicate is dropped. |
missing_sequence | A per-subject sequence jumped (e.g. expected 5, got 7). |
subject_scope_incomplete | subjectType 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 → idGlobal
recordedAt → occurredAt → actionInvocationId → correlationId →
subjectType → subjectId → sequence → idBoth 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
statereference. - 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
- Action pipeline → projections — the design overview.
- Event schema — what the engine consumes.
- Examples — runnable snippets.