timeline
The timeline strategy builds read model state by replaying signal history. Instead of scanning a collection's current state, it walks the historical record of domain events and accumulates state using declared when handlers.
Compiles to: history_scan → order → group → accumulate → persist → page
Use this strategy for event-sourced read models, activity summaries that must reflect the full history of events (including soft-deleted records), and audit-grade views. It also supports time-travel queries and a raw event log mode.
Accumulator example
domain:
projections:
user_message_state_timeline:
strategy: timeline
signals: [message.created, message.rejected, message.deleted]
group_by: [payload.user_id]
order_by:
field: emitted_at
direction: asc
start:
message_count: 0
rejection_count: 0
last_message: null
last_channel: null
last_event_at: null
when:
message.created:
message_count:
$expr:
"+": [{ var: acc.message_count }, 1]
last_message:
$expr: { var: payload.message }
last_channel:
$expr: { var: payload.channel }
last_event_at:
$expr: { var: signal.emitted_at }
message.rejected:
rejection_count:
$expr:
"+": [{ var: acc.rejection_count }, 1]
last_event_at:
$expr: { var: signal.emitted_at }
message.deleted:
message_count:
$expr:
"-": [{ var: acc.message_count }, 1]
last_event_at:
$expr: { var: signal.emitted_at }
rebuild:
mode: full
persist:
retention: 365d
pagination:
mode: page
page_arg: page
page_size_arg: page_size
default_page_size: 20
max_page_size: 100
signals
signals lists the signal IDs whose history this projection consumes. The runtime scans historical signal records for all listed signals when building or rebuilding state.
signals: [message.created, message.rejected, message.deleted]
Every signal listed must be declared in domain.signals. Signal payload migrations are applied automatically during history replay, so the when handlers always receive the current payload schema regardless of when the signal was originally emitted.
group_by
group_by controls how accumulated state is partitioned. Each unique combination of values forms one row in the projection result.
group_by: [payload.user_id]
Fields reference the signal payload using payload.*. Every signal in the signals list must carry the fields used in group_by.
group_by: [] (empty list) enables event log mode — see Event log mode below.
order_by
order_by controls the order in which signals are processed within each group during accumulation.
order_by:
field: emitted_at
direction: asc
emitted_at is the only available field. direction: asc processes signals oldest-first, which is the correct order for accumulators.
start
start declares the initial state of the accumulator for each group before any signal is processed.
start:
message_count: 0
rejection_count: 0
last_message: null
last_channel: null
last_event_at: null
Every field that when handlers write to must appear in start. The runtime initializes a new group's accumulator from this object.
when
when maps each signal ID to a set of field updates. Each update computes a new accumulator field value using $expr.
when:
message.created:
message_count:
$expr:
"+": [{ var: acc.message_count }, 1]
last_message:
$expr: { var: payload.message }
Available context in when expressions:
| Variable | Description |
|---|---|
acc.* | Current accumulator state for this group |
payload.* | Signal payload (current version, after migration) |
signal.emitted_at | Timestamp when the signal was emitted |
A signal not listed in when is ignored during accumulation — it is still listed in signals if its history must be scanned for group formation.
rebuild
rebuild:
mode: full
mode: full rebuilds the projection from the complete signal history whenever the projection definition changes. This is the only supported mode.
persist
persist:
retention: 365d
retention controls how long persisted projection state is retained. Requires history.enabled: true in the app section with a matching or longer retention window.
Lifecycle metadata
Timeline projections expose build status metadata alongside result rows. The query result includes a meta object.
| Field | Type | Description |
|---|---|---|
meta.rebuild_status | pending | in_progress | complete | Current rebuild state |
meta.rebuild_progress | float 0.0–1.0 | Fraction of history scanned during an active rebuild |
meta.history_complete | bool | Whether the full signal history has been scanned at least once |
Callers should check meta.history_complete before presenting timeline data as authoritative. A projection that has never completed a full history scan may have incomplete state.
Time-travel queries
Timeline projections accept an optional as_of input that returns projection state as it was at a specific point in time.
queries:
- id: get_user_message_state
access:
roles: [authenticated]
in:
user_id: id!
as_of: timestamp
returns:
projection: user_message_state_timeline
When as_of is provided, the runtime replays signal history up to that timestamp and returns the accumulated state at that point. The current persisted state is not used for time-travel queries.
Time-travel queries require history.enabled: true in the app section. Without a history store, the runtime cannot replay past signal records.
Event log mode
Setting group_by: [] and when: null and start: null produces a raw, paged, queryable stream of historical signal records without any accumulation.
domain:
projections:
message_event_log:
strategy: timeline
signals: [message.created, message.rejected, message.deleted]
group_by: []
order_by:
field: emitted_at
direction: desc
start: null
when: null
rebuild:
mode: full
persist:
retention: 365d
pagination:
mode: page
page_arg: page
page_size_arg: page_size
default_page_size: 25
max_page_size: 100
In event log mode, each result row is a signal record with its full payload and emitted_at timestamp. This is the correct model for audit logs, activity feeds, and replay interfaces where the raw history is the product — not a derived accumulation from it.
Signal migrations in timeline context
When a signal's payload schema changes and gains a new migrate block, the runtime applies the migration during history replay. when handlers always receive the current payload schema, even when processing signals emitted under an older version.
This means you can safely evolve signal payloads and update when handlers without needing to manually backfill historical signal records. The migration is the backfill.
What's next
- Projections overview: strategy comparison and common fields
- Signals: versioning and migrations
- Queries and Mutations: exposing projections as public queries