StateViewSlice
For a short summary of StateViewSlice, see Reventless Components Overview.
This component follows the Reventless Component Structure Pattern, using separate files for interface definitions (StateViewSlice.res), builder logic (StateViewSlice_Builder.res), and callback/handler logic (StateViewSlice_Callback.res).
Overview
The StateViewSlice is a DCB (Dynamic Consistency Boundary) component that projects events from a shared DcbEventLog into a QueryDb-backed read model. It implements the projection pattern where events are transformed into state updates.
Purpose and Responsibilities
- Responsibility: Listen to events from DcbEventLog and project them into a QueryDb for efficient reading
- In: Events from DcbEventLog (subscribed via EventCollector)
- Out: State updates to QueryDb
- Key Feature: Complements StateChangeSlice by providing read-optimized views of the event-sourced state
Relationship with DCB
StateViewSlice works alongside StateChangeSlice in the DCB architecture:
Component Spec
A StateViewSlice is split into two files:
<Name>.res— the spec (@@reventless.spec): the localconsumedEventandstate@schematypes.<Name>_Projection.res— the projection (@@reventless.projection): a singlelet project = ({event}) => [...]function receiving aconsumedenvelope{event, meta, recordedAt}.
The spec file. @@reventless.spec injects name, module Id, moduleUrl,
let config = config(), and let subIdConfig = None:
@@reventless.spec
@schema
type consumedEvent =
| Created({id: string, name: string})
| Updated({id: string, name: string})
| Deleted({id: string})
@schema
type state = {name: string}
The projection file. @@reventless.projection injects open Reventless.Projection
(so Set, Update, UpdateWithDefault, Delete are in scope unqualified) and
brings the spec module into scope. project receives a consumed envelope
{event, meta, recordedAt}; destructure ({event}) when you only need the
payload. meta is Reventless.Message.meta (producer info incl. meta.time, the
producer timestamp, and meta.user) and recordedAt: string is the storage
timestamp:
@@reventless.projection
let project = ({event}) =>
switch event {
| Created({id, name}) => [Set(id, {name: name})]
| Updated({id, name}) => [Update(id, state => {...state, name})]
| Deleted({id}) => [Delete(id)]
}
There is no DcbEventLogSpec reference — the slice declares its own local
consumedEvent union.
Spec Fields Explained
In the spec file (@@reventless.spec injects name, module Id, moduleUrl):
| Field | Type | Description |
|---|---|---|
consumedEvent | @schema type | The local subset of event variants this slice projects |
state | @schema type | State type for the read model (schema auto-generated) |
In the _Projection.res file:
| Field | Type | Description |
|---|---|---|
project | Reventless.StateViewSlice.consumed<consumedEvent> => array<Projection.action<state>> | Function to transform a consumed event envelope into state actions |
Runtime Behavior
Event Processing Flow
Projection Actions
In the _Projection.res file, @@reventless.projection injects
open Reventless.Projection, so the action constructors (Set, Update,
UpdateWithDefault, Delete) are in scope unqualified:
@@reventless.projection
let project = ({event}) =>
switch event {
| ItemCreated({itemId, name}) => [Set(itemId, {name, createdAt: Date.now()})]
| ItemRenamed({itemId, newName}) => [Update(itemId, state => {...state, name: newName})]
| ItemDeleted({itemId}) => [Delete(itemId)]
| StockAdjusted({itemId, delta}) =>
// UpdateWithDefault handles case where item doesn't exist yet
[UpdateWithDefault(itemId, {count: 0}, state => {...state, count: state.count + delta})]
}
Key Design Annotations
StateViewSlice supports the same PPX annotations on @schema type state as ReadModel. Annotations on state fields automatically generate let makeId, let subIdConfig, and let config. These annotations go on the spec file's @schema type state:
@@reventless.spec
@schema
type state = {
@id orderId: string, // generates: let makeId
@subId lineItemId: string, // generates: let subIdConfig — enables sort key queries
@index categoryId: string, // generates: let config with a secondary index
@resolves({table: "Products", field: "product"}) productId: string,
quantity: int,
}
@schema
type consumedEvent =
| LineItemAdded({orderId: string, lineItemId: string, productId: string, categoryId: string, quantity: int})
| LineItemRemoved({orderId: string, lineItemId: string})
The project function lives in the sibling _Projection.res file, where
@@reventless.projection injects open Reventless.Projection (so Set,
Update, UpdateWithDefault, and Delete are in scope unqualified):
@@reventless.projection
let project = ({event}) =>
switch event {
| LineItemAdded({orderId, lineItemId, productId, categoryId, quantity}) =>
[Set(lineItemId, {orderId, lineItemId, productId, categoryId, quantity})]
| LineItemRemoved({lineItemId}) => [Delete(lineItemId)]
}
For the full annotation reference, see PPX annotations.
Recording When a Row Reached Each State
A view's lifecycle field says which state a row is in. Declare a trail field beside it and the projection machinery also records when it got there — every state entered, with the instant, in the order it happened:
@schema
type lifecycle =
| Placed
| Shipped
| Cancelled
@schema
type state = {
@id orderId: string,
lifecycle: lifecycle,
trail: Reventless.Lifecycle.Trail.t<lifecycle>,
}
That is the whole of the domain's work. The projection writes trail: [] in the
action that creates the row and never touches the field again:
let project = ({event}) =>
switch event {
| OrderPlaced({orderId}) => [Set(orderId, {orderId, lifecycle: Placed, trail: []})]
// No `shippedAt` to remember — the trail's `Shipped` entry is the date.
| OrderShipped({orderId}) => [Update(orderId, state => {...state, lifecycle: Shipped})]
| OrderCancelled({orderId}) => [Update(orderId, state => {...state, lifecycle: Cancelled})]
}
Whenever an action changes the lifecycle field, {state, at} is appended, with
at taken from the event envelope's own meta.time — never a clock, so a
rebuild reproduces the trail exactly. A state a row never reached has no entry.
Four things worth knowing:
- Which field is the lifecycle is the usual rule:
@lifecycle, else a field literally namedlifecycleholding an enum. A view with no lifecycle field records nothing. - The trail is ordered, not keyed by state. A lifecycle that revisits a state
— a reopened order back to
Placed— carries both visits, at their own instants. - On the wire an entry is
{"state": "Placed", "at": "…"}, andstateemits the same GraphQL enum the lifecycle field emits. - It replaces per-state timestamp fields. Adding a state costs no new field,
no schema change and no projection edit — which is what a
shippedAtbeside acancelledAtthat nobody remembered to add cost before.
Sub-state rows (a view with @subId, projected with UpdateMultiState) are the
one exception: pairing a before-row with an after-row needs the sub-id, which is
not resolved until the action is applied, so those actions record no entry.
Comparison with ReadModel
| Aspect | ReadModel | StateViewSlice |
|---|---|---|
| Event Source | Multiple EventTopics | Single DcbEventLog |
| Mappings | Complex mapping system | Single projection function |
| Spec | Reventless.ReadModel.Spec | Custom Spec with project function |
| Key annotations | @id, @subId, @index, @resolves on state fields | Same — identical PPX annotation support |
| Use Case | General-purpose read models | DCB-specific view projections |
Comparison with StateChangeSlice
| Aspect | StateChangeSlice | StateViewSlice |
|---|---|---|
| Purpose | Handle commands and decide on events | Project events into read model |
| Input | Commands from CommandTopic | Events from DcbEventLog EventTopic |
| Output | Appends events to DcbEventLog | Updates QueryDb state |
| Pattern | Decision/command pattern | Projection pattern |
Best Practices
1. Use UpdateWithDefault for Optional Creation
// Good: handles both new and existing rows
let project = ({event}) =>
switch event {
| ItemAdjusted({itemId, delta}) => [
UpdateWithDefault(itemId, {count: 0}, state => {...state, count: state.count + delta}),
]
}
// Avoid: Update on a row that may not exist yet is a no-op
let project = ({event}) =>
switch event {
| ItemAdjusted({itemId, delta}) => [Update(itemId, state => {...state, count: state.count + delta})]
}
2. Keep Projections Idempotent
// Good: setting an absolute value is idempotent on replay
let project = ({event}) =>
switch event {
| QuantitySet({itemId, qty}) => [Update(itemId, state => {...state, qty})]
}
// Be careful: relative deltas can double-count if events are re-delivered
3. Denormalize Only What This View Can Maintain
A projection sees one row at a time, found by the view's @id. So it can keep a
copy of another entity's value current only if every event that changes the
original also names the row holding the copy. Copy a value that fails that test
and nothing ever refreshes it: it is not a cache, it is a value frozen by
accident, and the row ends up carrying two answers with nothing saying which one
is current.
Values the projection computes itself always pass — a running total is derived from the very events the view consumes.
// Good: pre-aggregated from the events this view already consumes
type state = {
@id itemId: string,
itemName: string,
totalQuantity: int,
}
// Good: frozen on purpose, because these are the terms of a transaction
type state = {
@id orderId: string,
// What the product was called and cost when the order was placed. The view
// must not go and ask the catalog what it costs now — a later price change
// did not change this sale.
productName: string,
unitPrice: Reventless.Money.t,
}
// Avoid: a copy this view has no way to refresh
type state = {
@id productId: string,
categoryId: string,
// Keyed by `productId`, so a `CategoryRenamed` would have to rewrite every row
// filed under that category — which a single-key projection cannot do. And a
// rename is a correction to a label, which is exactly the case where the new
// value should be what everyone reads.
categoryName: string,
}
Where the value is a live classification rather than a recorded fact, carry the reference and let the reader resolve it:
type state = {
@id productId: string,
// `@index` so the server can answer "the products in this category";
// `@groupBy` sections the list by it.
@index @groupBy categoryId: string,
}
Resolving a reference at read time is not a join in the projection. Two doors do
it: @resolves({table, field}) puts the target row on this view's GraphQL type
(see Key Design Annotations above), and the
{list}Refs(ids) query generated for every view answers {id, label, retired}
for ids a client already holds.
4. Match consumedEvent Exhaustively
// consumedEvent lists exactly the events this view reads, so the switch is exhaustive.
// For an event that should not change state, return [] (or [Ignore]).
let project = ({event}) =>
switch event {
| KnownEvent1({id}) => [Update(id, state => state)]
| KnownEvent2({id}) => [Delete(id)]
| NoteAdded(_) => [] // no state change
}
Pulumi Outputs
type outputs = {
resources: array<Reventless.Adapter.resource>,
queryDb: QueryDb.outputs,
}
The StateViewSlice creates its own QueryDb:
- DynamoDB table for state storage
- AppSync resolvers for querying
- Related IAM roles and policies
Related Components
- DcbEventLog - Shared event log for DCB slices
- StateChangeSlice - Processes commands and appends events
- QueryDb - Read model storage
- ReadModel - General-purpose read model component
- Plugin - Hosts DCB slices and creates shared infrastructure
- EventCollector - Consumes events for projection