Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

StateViewSlice

For a short summary of StateViewSlice, see Reventless Components Overview.

Framework Implementation

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​

d2 diagram

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:

d2 diagram

Component Spec​

A StateViewSlice is split into two files:

  • <Name>.res — the spec (@@reventless.spec): the local consumedEvent and state @schema types.
  • <Name>_Projection.res — the projection (@@reventless.projection): a single let project = ({event}) => [...] function receiving a consumed envelope {event, meta, recordedAt}.

The spec file. @@reventless.spec injects name, module Id, moduleUrl, let config = config(), and let subIdConfig = None:

Item/StateViewStream/Items.res
@@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:

Item/StateViewStream/Items_Projection.res
@@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):

FieldTypeDescription
consumedEvent@schema typeThe local subset of event variants this slice projects
state@schema typeState type for the read model (schema auto-generated)

In the _Projection.res file:

FieldTypeDescription
projectReventless.StateViewSlice.consumed<consumedEvent> => array<Projection.action<state>>Function to transform a consumed event envelope into state actions

Runtime Behavior​

Event Processing Flow​

d2 diagram

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:

Item/StateViewStream/Items_Projection.res
@@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:

OrderLineItems/StateViewStream/OrderLineItems.res
@@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):

OrderLineItems/StateViewStream/OrderLineItems_Projection.res
@@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 named lifecycle holding 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": "…"}, and state emits 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 shippedAt beside a cancelledAt that 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​

AspectReadModelStateViewSlice
Event SourceMultiple EventTopicsSingle DcbEventLog
MappingsComplex mapping systemSingle projection function
SpecReventless.ReadModel.SpecCustom Spec with project function
Key annotations@id, @subId, @index, @resolves on state fieldsSame — identical PPX annotation support
Use CaseGeneral-purpose read modelsDCB-specific view projections

Comparison with StateChangeSlice​

AspectStateChangeSliceStateViewSlice
PurposeHandle commands and decide on eventsProject events into read model
InputCommands from CommandTopicEvents from DcbEventLog EventTopic
OutputAppends events to DcbEventLogUpdates QueryDb state
PatternDecision/command patternProjection 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