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.

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 Read Model Spec

Advanced Read Model Spec

Example

Customer/ReadModel/Customers.res
let subIdConfig = Some({
Reventless.ReadModel.Spec.subIdField: "subId",
getSubId: state => state.subId,
})

let config = Reventless.ReadModel.Spec.config(
~idResolvers=[
{
source: {
idField: "orderId",
subId: NoSubId,
resolvedField: Single("order"),
},
target: {
tableName: "Order",
idField: Id
}
}
],
(),
)

subIdConfig

In this example, there is a sub id field subId used, therefore the following fields are provided:

  • subIdField: name of the sub id field
  • getSubId: function to extract the sub id out of the state

config

  • idResolvers: Other Read Models can be referenced by id in the state . If you (additionally to the id) want to provide the data of that referenced Read Model over the API, you have to specify how to resolve those ids here. The idResolvers configuration is an array of records with these fields:
    • source: specification, which id has to be resolved, by providing these fields:
      • idField: name of the id field to be resolved
      • subId:
        • Field(<fieldName>): field name for sub id
        • Argument(<argumentName>): argument name (provided by query) to be used as sub id
        • NoSubId: no sub id
      • resolvedField: name of the field in the API response data, where the referenced data is provided
        • Single(<fieldName>) TODO
        • Multi(<fieldName>) TODO
    • target: specification, how to resolve the given source:
      • tableName: name of the table to get the resolved data from
      • idField: field name in the target table to match with source id
        • Id TODO
        • Index(<targetFieldName>) TODO
        • IndexWithId(<indexName>, <targetFieldName>) TODO
      • subIdField: (optional) field name of the target table to match with source sub id
      • pluginName: (optional) name of the plugin to find the given table. If not provided, the same plugin as the source is used
  • idsResolvers: To resolve an array of reference ids (no sub ids supported) you can specify an array of records with these fields:
    • source: specification, which id has to be resolved, by providing these fields:
      • idField: name of the id field to be resolved
      • resolvedField: name of the field in the API response data, where the referenced data is provided.
    • target: specification, how to resolve the given source:
      • tableName: name of the table to get the resolved data from
      • idField: field name in the target table to match with source id

The following diagram depicts the relations between the Query DB tables for the given example:

d2 diagram

Example result of customer("1234") API query:

{
"customer": {
"id": "customer-1234",
"orderId" : "order-5678",
"order": {
"id": "order-5678",
"items" :[]
}
}
}

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:

CatalogActivity/ReadModel/CatalogActivity_Projections.res
@@reventless.mappings

// A DCB source: `name` MUST equal `<pluginName>DcbEventLog`.
module CatalogDcbSource = {
let name = "CatalogDcbEventLog"
@schema
type event = ProductAdded({productId: string, name: string})
}

module CategoryActivityMapping = Mapping.Make(Category, CatalogActivity, { /* project ... */ })
module ProductActivityMapping = Mapping.Make(CatalogDcbSource, CatalogActivity, { /* project ... */ })

let mappings: array<module(Mapping)> = [module(CategoryActivityMapping), module(ProductActivityMapping)]

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