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

Read Model

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

Framework Implementation

This component follows the Reventless Component Structure Pattern, using separate files for interface definitions, builder logic, and runtime callbacks.

d2 diagram

A Read Model's business logic is defined by it's Spec, Projections and Config.

Events may trigger updates to the state of a Read Model. A Read Model may act on Events of several different Aggregates. The logic how to react to Events is implemented in a Projection per Aggregate. A Read Model's state is persisted in the Query DB and can be made available to the API. The state is mutable (many events may change the data of the same state - one after another) and eventual consistent.

Read Model Spec

Example

Product/ReadModel/Products.res
@@reventless.spec

@schema
type state = {
name: string,
description: string,
price: float,
}

The @@reventless.spec annotation auto-injects let name, let config = config(), and let subIdConfig = None. Key-related config (makeId, subIdConfig, config) is typically generated by PPX field annotations directly on @schema type state — see Using annotations on state fields below.

Using annotations on state fields

PPX annotations on @schema type state fields replace manual let config, let subIdConfig, and let makeId declarations:

Order/ReadModel/OrderLineItems.res
@@reventless.spec

@schema
type state = {
@id orderId: string, // generates: let makeId = state => state.orderId
@subId lineItemId: string, // generates: let subIdConfig = Some({...})
// enables: orderLineItemsById(id, from?, to?, prefix?, ...)
@index categoryId: string, // generates: let config with a secondary index for categoryId
@resolves({table: "Products", field: "product"}) productId: string,
// generates: virtual GraphQL field product: Product
quantity: int,
price: float,
}

For the full annotation reference see PPX annotations.

Custom indexes can also be added by explicitly declaring let config:

Customer/ReadModel/Customers.res
@@reventless.spec

@schema
type state = {
email: string,
address: string,
deactivated: bool,
}

let config = Reventless.ReadModel.config(
~indexes=[
{
index: "email",
_type: "S",
projectionType: #ALL,
},
],
(),
)

For information about @schema see Schema annotation.

Id

See Id.

name

A name is a string which must be unique in the scope of Read Model names in one plugin and should describe the Read Model aptly. The name will also be used "behind the scenes".

state

The record type (shape) of values, which will be persisted into the database (Query DB).

warning

This needs to be a record type. Other types lead to runtime errors upon storing the state into the database!

subIdConfig

In the Read Model, a sub id field can be used additionally to the id field. If you only provide the (primary) id value, then multiple items may be returned, sorted by the sub id value.

In this example, there is no sub id used, therefore None is provided

Visibility (hide from AutoUI)

By default every ReadModel discovered by AutoUI gets a menu entry and a page in the generated host shell. Helper views — used only as join targets, automation decision state, or cross-plugin lookups — can opt out by adding a file-level attribute:

CustomerLookups/ReadModel/CustomerLookups.res
@@reventless.spec
@@reventless.visibility(Internal)

@schema
type state = {
@id customerId: string,
displayName: string,
}
  • Public (default) — the ReadModel appears in the AutoUI manifest.
  • Internal — the ReadModel is hidden from AutoUI panels and pages. GraphQL exposure, authorization rules, resolver provisioning and the platform event graph are unaffected — other plugins can still query it. Visibility is a UX hint, not a security boundary; use @@reventless.authorize(<rule>) for access control.

The flag also propagates to the generated JSON Schema as x-reventless-visibility: "Internal", which lets host shells, docs generators and dashboards surface internal status without re-running ReScript codegen. The default (Public) is omitted from the JSON Schema to keep it compact.

The same attribute works on StateViewSlice files. It is rejected on Aggregate / *Slice (command-carrying) files with a clear compile error.

Live updates (@live)

Views that render fast-moving operational state benefit from live updates; investigative or historical views (catalogues, comparisons, audit histories) do not. Declare which kind a view is directly on its state declaration:

Catalog/ReadModel/Products.res
@@reventless.spec

@live(false)
@schema
type state = {
@id productId: string,
name: string,
}

The bool propagates to the generated JSON Schema as a top-level x-reventless-live: bool. The framework only transports the declaration — UI consumers decide what to do with it (typically whether a Live control is offered for the view at all). An absent annotation emits no key, so the consumer's own default applies.

The same annotation works on StateViewSlice state declarations. It is rejected on any other spec file's state declaration with a clear compile error, and the payload must be exactly one bool literal.

config

Reventless.ReadModel.config is a convenience function to create the actual config value. The function takes these optional arguments:

  • indexes: enable performant access to the Read Model via different ids - an additional id may be any field of the state type
    An index configuration is a record with these fields:
    • index: name of the state's field to act as an additional id, needs to be unique in all indexes of this Read Model
    • _type: data type descriptor of the state's field type (e.g: S= string, N = number)
    • projectionType: projection type, defines which fields will be copied to the index (and therefore accessable by queries using this index)
      either:
      • #KEYS_ONLY: only the keys will be copied to the index
      • #ALL: all fields will be copied to the index
      • #INCLUDE(array<string>): all keys and the specified fields will be copied to the index
  • other optional configurations see Advanced: hand-written config

Advanced: hand-written config

The config and subIdConfig values above are normally generated for you from the annotations on your state fields — @id, @subId, @index, @resolves, @resolvesMany. Writing them by hand is only necessary when an annotation cannot express what you need.

Cross-table resolvers in particular are annotations now: @resolves for a single reference and @resolvesMany for an array of them, both taking a record payload naming the target table and the field the resolved data appears under. See the PPX annotation catalog for the full option list, and the QueryDb key design guide for how the keys and indexes those annotations produce are laid out.

Projections

Projections live in a sibling file <Plural>_Projections.res annotated with @@reventless.mappings. The PPX infers the Reventless.Projection domain from the ReadModel/ folder and injects open Reventless.Projection (so action constructors Set, Update, UpdateWithDefault, Delete are in scope unqualified), module Target, module M = Mapping.Make, module type Mapping, and let moduleUrl. You write the per-source Mapping.Make modules and the mappings array.

Example

Customer/ReadModel/Customers_Projections.res
@@reventless.mappings

module CustomerMapping = Mapping.Make(
Customer,
Customers,
{
open Customer
let project = ({event, id, _}) =>
switch event {
| Registered({email, address}) => Set(id, {Customers.email: email, address, deactivated: false})
| EmailUpdated({email}) => Update(id, state => {...state, email})
| AddressUpdated({address}) => Update(id, state => {...state, address})
| Deactivated => Update(id, state => {...state, deactivated: true})
}
},
)

let mappings: array<module(Mapping)> = [module(CustomerMapping)]

In order to update a Read Model by Events from an Aggregate (or a DCB source), a Projection from that source to the Read Model must be provided.

You do so by calling the Mapping.Make module function (in scope via the PPX) with the Aggregate Spec, the Read Model Spec and a project function. The project function receives a record with the event, the id and the event meta data and returns an action that is applied to the Query DB. These actions are supported:

  • Single state
    • Create:
      • Create(<id>, <state>): Create new state if none exists
    • Update
      • Update(<id>, <old state> => <new state>): Update existing state
      • UpdateWithDefault(<id>, <default state>, <old state> => <new state>): Update existing state or use default if not existing
    • Set
      • Set(<id>, <state>): Set fixed state
    • Delete
    • Delete(<id>): Delete state
    • DeleteIf(<id>, <state> => bool): Delete state (conditional)
  • Many States (different ids)
    • Create:
      • CreateMany([(<id>, <state>)]): Create many new states if none exists
    • Update
      • UpdateMany([<id>], (<id>, <old state>) => <new state>): Update many existing states
      • UpdateManyWithDefault([<id>], <default state>, (<id>, <old state>) => <new state>): Update many existing states or use default if not existing
    • Set
      • SetMany([<id>], <id> => <state>): Set many fixed states
    • Delete
    • DeleteMany([<id>]): Delete many states
    • DeleteManyIf([<id>], <state> => bool): Delete many states (conditional)
  • MultiState (same id, different sub ids)
    • Create:
      • CreateMultiState(<id>, [<state>]): Create multiStates (multiple sub states with same id)
    • Update
      • UpdateMultiState(<id>, [<old state>] => [<new state>]): Update multiState (Create/Update/Delete multiple sub states with same id)
  • Ignore: Ignore event

Multiple sources

The mappings array can hold one Mapping.Make module per source — an Aggregate and a DCB source can both feed the same ReadModel:

Customer/ReadModelStream/Customers_Projections.res
@@reventless.mappings

// A DCB source: `name` MUST equal `<pluginName>DcbEventLog`.
module OrderEvents = {
let name = "OrderingDcbEventLog"
@schema
type event = OrderPlaced({orderId: string, customerId: string})
}

module CustomerMapping = Mapping.Make(Customer, Customers, { /* project profile ... */ })
module CustomerOrdersMapping = Mapping.Make(OrderEvents, Customers, { /* project orderCount ... */ })

let mappings: array<module(Mapping)> = [module(CustomerMapping), module(CustomerOrdersMapping)]

Both mappings write to the same row id (customerId), so the Customer aggregate's profile and the DCB-derived orderCount merge into one record.

Wiring (generated)

You never wire the Read Model by hand. The plugin generator scans the ReadModel/ (or ReadModelStream/) folder and emits the wiring into the generated src/Plugin.res using the two-arg factory Platform.ReadModel.Make(Spec, Projections) (Platform.ReadModelStream.Make(...) for the stream variant):

src/Plugin.res (generated — do not edit)
module CustomersReadModel = Platform.ReadModel.Make(Customers, Customers_Projections)

Pulumi

The Read Model's Pulumi root component is named in this pattern: Spec.name and has a type of reventless:ReadModel.

  • EventCollector - Collects events from EventTopics for ReadModel projections
  • QueryDb - Stores the ReadModel's projected state for querying
  • EventTopic - Publishes events that the ReadModel subscribes to
  • Aggregate - Source of events that the ReadModel projects
  • API - Queries the ReadModel's state via GraphQL resolvers