Read Model
For a short summary of a ReadModel, see Reventless Components Overview.
This component follows the Reventless Component Structure Pattern, using separate files for interface definitions, builder logic, and runtime callbacks.
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
@@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:
@@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:
@@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).
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:
@@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
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 fieldgetSubId: 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 resolvedsubId:Field(<fieldName>): field name for sub idArgument(<argumentName>): argument name (provided by query) to be used as sub idNoSubId: no sub id
resolvedField: name of the field in the API response data, where the referenced data is providedSingle(<fieldName>)TODOMulti(<fieldName>)TODO
target: specification, how to resolve the given source:tableName: name of the table to get the resolved data fromidField: field name in the target table to match with source idIdTODOIndex(<targetFieldName>)TODOIndexWithId(<indexName>, <targetFieldName>)TODO
subIdField: (optional) field name of the target table to match with source sub idpluginName: (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 resolvedresolvedField: 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 fromidField: 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:
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
@@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 stateUpdateWithDefault(<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 stateDeleteIf(<id>, <state> => bool): Delete state (conditional)
- Create:
- 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 statesUpdateManyWithDefault([<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 statesDeleteManyIf([<id>], <state> => bool): Delete many states (conditional)
- Create:
- 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)
- Create:
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:
@@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):
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.
Related Components
- 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