Reventless PPX Guide
The Reventless PPX eliminates boilerplate from application code. Instead of manually declaring let name, module Id, let moduleUrl, and @s.matches(DcbTag.string), you add a single annotation and the PPX injects everything at compile time.
Setup
Add the PPX to your package's rescript.json. It must come before sury-ppx:
{
"ppx-flags": ["@reventlessdev/reventless-ppx/bin", "sury-ppx/bin"]
}
The PPX reads package.json (for the npm package name) and rescript.json (for namespace and dependencies) from the nearest parent directory. Both files must exist.
Annotations
This section explains the annotations you will use most. For the complete list, run pnpm exec reventless-ppx-read --vocabulary in your app. It prints, as JSON, every attribute the PPX installed with your app reads, where it reads each one, and whether it takes an argument.
@@reventless.spec
Use on all spec files: aggregate specs, read model specs, extension point specs, DCB slice specs, event mapping specs, and side effect specs.
What it injects:
| Binding | Condition | Value |
|---|---|---|
let name | Not already declared | Derived from filename |
module Id | Not already declared, and reventless-spec is a dependency | Reventless.Id.String |
let moduleUrl | Not already declared | Computed npm specifier |
open Reventless.ReadModel + let config + let subIdConfig | Filename contains ReadModel, @schema type state present, let config not declared | ReadModel defaults |
Name derivation strips known component suffixes from the filename:
| Filename | Derived name |
|---|---|
Category.res | "Category" |
ProductsReadModel.res | "Products" |
AddCategory.res | "AddCategory" |
CategoriesView.res | "Categories" |
Products_ExtensionPoint.res | "Products" |
Product_Behavior.res | "Product" |
Stripped suffixes (each tried first with a leading underscore, e.g. _Behavior, then bare): ExtensionPointMapping, ExtensionPoint, ReadModel, Behavior, Projections, Projection, Aggregate, Plugin, Slice, Spec, View.
Dotted names in spec packages: When the rescript.json namespace ends in Spec (e.g., CatalogSpec), the PPX automatically prefixes the derived name with the plugin name:
| Filename | Namespace | Derived name |
|---|---|---|
Products_ExtensionPoint.res | CatalogSpec | "Catalog.Products" |
Orders_ExtensionPoint.res | OrderingSpec | "Ordering.Orders" |
The plugin name is the namespace with Spec stripped.
Explicit name override:
@@reventless.spec("CustomName")
Use this when the derived name doesn't match your intent. The PPX still injects module Id and moduleUrl.
module Id is skipped when reventless-spec is not in the package's rescript.json dependencies. This allows lightweight spec packages (extension point specs) to use @@reventless.spec without depending on the full framework.
@@reventless.behavior
Use on all behavior files.
What it injects:
| Binding | Condition | Value |
|---|---|---|
open Spec | Not already present | Opens the spec module |
module Spec = Spec | Not already declared | Aliases the spec module |
let moduleUrl | Not already declared | Computed npm specifier |
Spec module derivation: strips _Behavior (or a bare Behavior) from the filename.
| Filename | Derived spec |
|---|---|
Category_Behavior.res | open Category; module Spec = Category |
Order_Behavior.res | open Order; module Spec = Order |
ProductDemand_Behavior.res | open ProductDemand; module Spec = ProductDemand |
Explicit spec override:
@@reventless.behavior(PluginSpec)
Use this when the spec module name doesn't match {Filename minus Behavior} — for example when the spec file is named differently than the behavior file's prefix.
@@reventless.dcbTags
Use on DCB slice files outside a slice folder that have entity ID fields in @schema types. Files inside any slice folder (StateChange/, StateView/ incl. StateViewStream/, Automation/, InboundTranslation/, OutboundTranslation/) get dcbTags automatically via @@reventless.spec — no explicit @@reventless.dcbTags needed.
What it does: Scans all @schema-annotated variant types and injects @s.matches(Reventless.DcbTag.string) on fields that match these rules (unless @s.matches(...) is already present):
| Field pattern | Type | Injection |
|---|---|---|
*Id: string | scalar | @s.matches(DcbTag.string) on the type |
*Id: array<string> | array (singular name) | @s.matches(DcbTag.string) on the element type — for cross-entity queries |
*Ids: array<string> | array (plural name) | @s.matches(DcbTag.string) on the element type — for multi-value storage |
The PPX generates the fully qualified Reventless.DcbTag.string, so no open Reventless is needed just for DCB tags.
Before (manual):
@schema
type command = AddProduct({
productId: @s.matches(DcbTag.string) string,
name: string,
})
@schema
type event = ProductAdded({
productId: @s.matches(DcbTag.string) string,
name: string,
})
After (with PPX):
@@reventless.dcbTags
@schema
type command = AddProduct({
productId: string,
name: string,
})
@schema
type event = ProductAdded({
productId: string,
name: string,
})
Combine with @@reventless.spec: Most DCB files outside slice folders use both annotations:
@@reventless.spec
@@reventless.dcbTags
@partitionTag, @noDcbTag, @dcbTag — field-level DCB tag control
These field attributes give fine-grained control over DCB tag injection. They work in any @@reventless.spec or @@reventless.behavior file, regardless of whether dcbTags auto-inference is active.
| Annotation | Placed on | Effect |
|---|---|---|
@partitionTag | A *Id: string field on a produced event | Injects @s.matches(DcbTag.partition) — marks this field as the partition key. Required only when inference is ambiguous — the framework infers the partition key from the slice graph otherwise. See When you still need @partitionTag. |
@crossPartition | A string (or array<string> element) field | Injects @s.matches(DcbTag.crossPartition). Rarely needed — cross-entity reference reads are inferred from the slice graph; this is the escape hatch only for M:N capacity reads of a slice's own event type. See @crossPartition below. |
@noDcbTag | A *Id: string field | Suppresses auto-tagging — the field stays as plain string. Use on an event field that is payload data, not a DCB query key. A command needs it no longer: a reference the slice decides nothing by is left out of the query anyway (Command references nothing decides by). |
@dcbTag | Any string field | Injects @s.matches(DcbTag.declared) — explicit opt-in for fields that don't follow *Id naming (e.g., sku, slug, reference). On a command it also keeps a reference in the decision query, which fences the command per that key. |
The PPX strips all four attributes from the output AST, so the compiler never sees them as unknown attributes.
@partitionTag — when inference cannot choose:
The framework infers each slice's partition key: the *Id fields on the events it writes, minus the ids it reads from another slice's events. A single id left is the partition; when several are left, the one every event in the slice's chapter (src/<Chapter>/<Kind>/) carries wins. You annotate only when that still cannot choose — a join such as demand recorded per product and order, whose chapter's events all carry both. The annotation is read from the produced event, never from the command. See Event Log Partitioning for the full rules and error messages.
// A join: productId and orderId are both this slice's own ids. Only the domain
// says demand is counted per product, so inference cannot choose.
@schema
type event =
| ProductDemandRecorded({
@partitionTag productId: string, // partition key
orderId: string, // also tagged as DcbTag.string
})
A @partitionTag that inference contradicts fails the build; one that inference already agrees with is logged as redundant and can be removed.
@noDcbTag — payload-only field:
@schema
type event =
| DemandRecorded({
productId: string,
@noDcbTag orderId: string, // not a DCB tag — plain string in the event store
})
@dcbTag — non-*Id field name:
@@reventless.dcbTags
@schema
type event =
| SkuAdded({
@dcbTag sku: string, // not *Id naming, but should be a DCB tag
name: string,
})
Markers on a record inside an array
A command or event field may hold a list of records — an order line, a
shipment row, an approval step — and the markers on those records' fields are
still found. A @ref on a nested field resolves to a picker, a DCB tag on one
routes the decision read and indexes the write, and the enclosing field's GraphQL
input type is named for a client to declare.
// A NAMED record type, not an inline one: the PPX walks a type declaration,
// which is what puts the `@ref` (and with it the DCB tag) on `productId`.
@schema
type lineItem = {
@ref("AvailableProducts") productId: string,
quantity: int,
}
@schema
type command =
PlaceOrder({
orderId: string,
lineItems: array<lineItem>,
})
Two rules decide whether this works, and both are silent if got wrong:
- The tag key is the nested field's name, never the enclosing one.
lineItems[].productIdproduces keyproductId, which is what makes it the same tag the producing slice writes. A key derived fromlineItemswould name a tag nobody writes and the decision read would come back empty — the identical symptom to extracting no tags at all.@dcbTag("explicitKey")on the nested field still overrides it. - Duplicate keys collapse by value. Two lines for the same product yield one tag, so a decision query carries the clause once and the written event carries one index entry.
Everything else is unchanged: a nested tag is the same tag as a flat one, with the same key and the same scope rules; only the place the walk finds it moves.
Naming. A reference is published under its path — lineItems[].productId,
[] for "each element of" and . for "property of", with no index, because a
marker lives on the element schema and so applies to every element or to none.
A flat field keeps its bare name. The item's GraphQL input type name rides on the
command's JSON Schema as x-reventless-graphql-input, so a client assembling the
mutation can declare the variable without knowing the server's naming rule.
Depth stops at one record. The walk follows exactly the wrappers a field's
own value can wear — option, array, and one record inside them. A marker two
records deep is not found.
@compositePartitionTag — composite DCB partition key
@compositePartitionTag lets you form the DynamoDB partition key from multiple fields, concatenated in declaration order with a configurable separator. Use it when a single field is too coarse for partitioning and a composite identity (e.g. environment/platform/plugin) distributes events better across partitions. A composite key is never inferred: it is explicit and applies to the whole plugin's event log.
Each annotated field is still a regular DCB tag (individually queryable). The composite key is derived automatically at runtime from the stored tag values.
Syntax:
@compositePartitionTag // uses "/" after this field (default)
@compositePartitionTag("/") // explicit default — identical behaviour
@compositePartitionTag(":") // uses ":" after this field
The separator on the last annotated field is ignored (nothing follows it).
Example — three-segment composite key env/platform/plugin:
@@reventless.spec
@schema
type event =
| PluginSynced({
@compositePartitionTag environment: string, // partition: env/...
@compositePartitionTag platformName: string, // partition: env/platform/...
@compositePartitionTag pluginName: string, // last field — sep ignored
version: string,
})
// Composite partition key value: "{environment}/{platformName}/{pluginName}"
// Each field is also individually queryable as a DcbTag.string.
Constraints:
| Rule | Behaviour |
|---|---|
Non-string field annotated | No-op — field is left untouched |
@compositePartitionTag and @partitionTag on the same schema | Throws at startup |
| Fewer than 2 fields annotated | Throws at startup |
| A member's value is the empty string | Permitted — see below |
Empty tag values. A composite key often describes a hierarchy, and a member of that hierarchy can be genuinely absent — an entity owned at the outer level has no inner-level name to give. The framework accepts an empty tag value rather than forcing a placeholder that fabricates identity:
- The event records the tag with its empty value, on every backend.
- The value participates in the composite key (
key:valuepairs joined with#), so the composite partition key, composite reads and the OCC fences all behave exactly as they do for a non-empty member. - The value is not individually indexed. On DynamoDB each tag key gets a
tag_<key>GSI whose hash key is that attribute, and a key attribute cannot hold an empty string — so the adapter skips the attribute and the index goes sparse (its intended mechanism). Reads by the partition are unaffected; they go against the base table.
The one consequence to design around: a @crossPartition read of a tag key resolves
through that per-tag index, so it never returns events whose value for that key is
empty (querying for an empty key value is not expressible in DynamoDB either). If a
value must be findable across partitions, it must be non-empty.
Placement: Place @compositePartitionTag before the field name, exactly like @partitionTag:
// CORRECT
@compositePartitionTag environment: string
// WRONG — annotation on the type, not the field (silently ignored)
environment: @compositePartitionTag string
@crossPartition — cross-partition (secondary-tag) reads
You usually don't need this. When a slice reads another entity's lifecycle by its id (e.g.
AddProductchecking acategoryIdthatCategoryowns), the framework infers the cross-partition read from the slice graph — declare the consumed events and the foreign field, and write no annotation.@crossPartitionis the escape hatch for the one case inference can't see: an M:N capacity read where a slice reads its own event type by a secondary key across all of that key's partitions (below). A@crossPartitionthat the framework resolves as the slice's own partition is flagged as a contradiction at build time.
A DCB event is stored under exactly one partition (its partition key). A single-tag decision read of any other tag the event carries is, by default, partition-scoped — it only sees events whose partition key is that tag, so a tag that is secondary on the event is invisible to such a read.
@crossPartition opts a tag into a cross-partition read: a single-tag read
of that key returns every event carrying it across all partitions. This is
the canonical shape for an M:N capacity invariant — a slice both produces and
reads an event that ties two entities, partitioned by one, so it must also read by
the other; inference treats that own-stream read as partition-scoped, so you opt
in explicitly.
// Course subscription: partition by courseId; studentId is read across every
// course partition the student appears in. The annotation goes on BOTH the
// command (its tags build the read query) and the produced event (its tags
// drive partitioning, GSI indexing, and the fence) — never on consumedEvent.
// The event carries two candidate ids, so @partitionTag on it names the partition.
@schema
type command =
| SubscribeStudent({
courseId: string, // → clause [courseId] — partition read
@crossPartition studentId: string, // → clause [studentId] — cross-partition read
})
@schema
type event =
| StudentSubscribed({
@partitionTag courseId: string,
@crossPartition studentId: string,
})
SubscribeStudent then builds two single-tag reads — "all of the course"
(by courseId) AND "all of the student" (by studentId) — instead of one
composite read of the exact {course, student} pair.
How it works under the hood (no slice-contract change beyond the annotation):
- Read routing. A
@crossPartitionclause reads the per-tagtag_<key>GSI (aQueryfor keys + aBatchGet/GetItemfor payloads) instead of the base-table partition. GSI reads are eventually consistent — the append fence catches any staleness at commit, costing at most a retry. - Fence scope follows read scope. A
@crossPartitiontag's consistency fence is bumped by every carrier (primary or secondary), so optimistic concurrency detects a concurrent secondary-tag writer. Partition-scoped tags keep the narrow "bump only your own partition" rule.
Notes and constraints:
- Default is partition-scoped. Leave the annotation off unless you genuinely need the cross-partition fold — it makes the tag's fence hotter (every writer of that tag contends on one fence) and the read O(entity degree). For capacity checks ("≤ N …"), bound the read with a count/limit rather than folding the whole set.
- Scope is a property of the tag key and must agree across every event type
that carries it — the fence is driven by writers, so a key cannot be
cross-partition for one producer and partition-scoped for another.
Dcb_Builderreports a scope mismatch at build time. - Placement: before the field name, like
@partitionTag. Works on astringfield or the element type of anarray<string>field.
@authorize, @@reventless.authorize — who may call
@@reventless.authorize(<rule>) at the top of a spec file sets the rule for the
whole component; @authorize(<rule>) before a command constructor sets it for
that one command. Without either, the rule is AllowAuthenticated.
// src/Roles.res — the roles this plugin's rules name
type t =
| Admin
| Merchandiser
// src/Product/StateChange/AddProduct.res
@schema
type command =
| @authorize(AllowRoles([Admin, Merchandiser])) AddProduct({productId: ProductId.t, name: string})
The PPX copies the rule unchanged into a generated binding, typed
Reventless.Authorization.rule<role>: authorization for a view, and
authorizationOf for a command carrier — a switch on the constructor's name, so
the framework can ask who may issue AddProduct before any command exists:
let authorizationOf = (name): Reventless.Authorization.rule<role> =>
switch name {
| "AddProduct" => AllowRoles([Admin, Merchandiser])
| _ => AllowAuthenticated // the file's @@reventless.authorize, or this default
}
A constructor without @authorize — a spliced one among them — gets the default.
A spec that writes the binding by hand writes the same switch on names; one still
declaring the older commandAuthorization is a compile error naming this one.
Beside the binding, the PPX declares type role:
| Spec | type role |
|---|---|
declares its own type role | left alone |
PPX writes the rule, and the plugin has src/Roles.res (or the file a module Roles) | Roles.t |
any other — no roles, or a rule written by hand over Reventless.Authorization.permission | Reventless.Role.name |
Because the binding is typed by the plugin's Roles.t, the cases are written bare
and a misspelled one fails at the case: "The constructor Merchandisr does not
belong to type Roles.t". See Authorization for how a role
maps to a group and what the platform checks at deploy.
@noApi — exclude commands from GraphQL/MCP exposure
Use on command types or individual command variants to exclude them from automatic GraphQL mutation and MCP tool generation.
| Annotation | Placed on | Effect |
|---|---|---|
@noApi | @schema type command | Entire command type hidden from API |
@noApi | A single variant in a command type | Only that variant hidden, others remain public |
Type-level @noApi — entire command hidden:
// Driven by an extension reacting to another plugin's events — never by a client.
@schema @noApi
type command =
| RecordDemand({productId: string, orderId: string})
| RevokeDemand({productId: string, orderId: string})
All variants of this command type are excluded from GraphQL mutations and MCP tools. Use this for commands that only ever arrive from an extension, an automation, or another internal path.
Variant-level @noApi — individual variants hidden:
@schema
type command =
| CancelOrder({orderId: string}) // Public — exposed as GraphQL mutation + MCP tool
| @noApi ReopenOrder({orderId: string}) // Internal — hidden from API
The @noApi annotation is stripped from the compiled output by the PPX. Filtering happens at schema generation time in Plugin_Builder (for Aggregates) and Dcb_Builder (for StateChangeSlices).
@lifecycle — mark the field a record's lifecycle lives in
The field whose value identifies where the record is in its life: the enum a spec's commandTransition names its states in, the one a board draws its columns from, a progress tracker walks and a state diagram renders. AutoUI's command-menu filter reads it per row and matches it against each command's from-set (commandDef.allowedStates).
There are two ways to declare it, and a record uses whichever fits.
Name the field lifecycle and write nothing. The name is the declaration:
@@reventless.spec
@schema
type lifecycle = Placed | Shipped | Cancelled
@schema
type state = {
orderId: string,
customerId: string,
lifecycle: lifecycle,
}
Or annotate whatever the field is called, for a record whose lifecycle field has an honest name of its own:
@schema
type accountStatus = Active | Deactivated
@schema
type state = {
customerId: string,
@lifecycle accountStatus: accountStatus,
}
The PPX emits the annotated field name into stateAnnotationSpec.lifecycle (sury metadata attached to the state schema). Codegen reads it when building queryableDef.lifecycleField.
An enum-shaped field is not automatically a candidate. A field tracking a background job's progress — an export, a delivery attempt, a geocoder — is enum-shaped and often named like a status, and annotating it would section the list by how far that job got and filter the command menu against states no command mentions. The annotation is keyed on lifecycle rather than status for exactly this reason — a record often carries several statuses and at most one lifecycle.
Resolution order (codegen, Plugin_Structure.lifecycleFieldFromStateSchema): (1) field annotated @lifecycle; (2) field literally named "lifecycle" whose shape is an enum (a free-text lifecycle: string does not count, because the command filter needs states to compare against); (3) None, and the per-row filter is inert.
Why the convention is keyed on lifecycle and not status. status is a promiscuous name — geocoding progress, todo-queue progress, translation audit outcome and plugin connection state all wear it — so a convention keyed on it genuinely guesses, and guesses often. lifecycle is a deliberate word nobody types by accident, so matching it is close to reading a declaration written in the field name.
A framework-generated row declares rather than following the convention. Its field name is chosen by the framework and read by every client, so it is the wrong thing to make load-bearing: the platform's own Plugins read model keeps its published status field and annotates it.
Constraint: at most one @lifecycle per state record. The PPX errors on duplicate annotations within the same record.
Renamed from @status. The old spelling is a compile error naming this one; there is no alias, because a silently-accepted old name is exactly the ambiguity the rename removes.
@groupBy — section the list view by a field
Use on a single @schema type state field in a ReadModel or StateViewSlice spec to mark the field the UI's list view should group rows by. AutoUI's list view renders the read model's rows in sections keyed on the annotated field, ordered by the field's enum declaration order when the field is an enum.
@@reventless.spec
@schema
type kind = Domain | PlatformInfrastructure | Commercial | Marketplace
@schema
type state = {
pluginId: string,
name: string,
@groupBy kind: kind,
}
The PPX emits the field name into stateAnnotationSpec.groupBy (sury metadata attached to the state schema), and SuryToJsonSchema.deriveObjectSchema stamps x-reventless-group-by: true on the named field of the read model's JSON Schema. The UI reads that extension property; there is no runtime or projection change — this is a schema hint only.
Section ordering: when the group field is an enum, the UI orders sections by the enum's declaration order. To change the section order, reorder the enum constructors — no UI change required.
Constraint: at most one @groupBy per state record. The PPX errors on duplicate annotations within the same record. Stacks with other field annotations (e.g. @scan @groupBy kind: kind makes the field both server-side filterable and the list section key).
@live — declare the view's live-updates default
Use on the @schema type state declaration (not a field) of a ReadModel or StateViewSlice spec to declare whether live updates make sense for the view. @live(false) marks investigative/historical views (catalogues, comparisons, audit histories) where a live-updates control is pointless; @live(true) marks operational views where one should be offered.
@@reventless.spec
@live(false)
@schema
type state = {
@id productId: string,
name: string,
}
The PPX emits the bool into stateAnnotationSpec.live (sury metadata attached to the state schema), and SuryToJsonSchema.deriveObjectSchema stamps top-level x-reventless-live: bool on the read model's JSON Schema. The framework only transports the declaration — UI consumers decide what to do with it (the annotation typically governs whether a Live control is offered at all). Absent annotation ⇒ absent key ⇒ the consumer's own default applies. There is no runtime or projection change — this is a schema hint only.
Constraint: the payload is exactly one bool literal (@live(true) or @live(false)); anything else is a compile error. Only ReadModel and StateViewSlice (incl. Stream) spec files accept it — the PPX errors on a @live state declaration in any other spec file.
commandTransition: the states a command applies in
Which lifecycle states a command is legal in, and which state it moves the row to, is declared as a value, not as an annotation. The spec names its lifecycle enum once, then writes a switch over its commands:
// In Order.res (an Order aggregate spec):
@@reventless.spec
@schema
type command =
| Place({customerId: string, productIds: array<string>})
| Ship
| Cancel
| AddNote({text: string})
type lifecycleState = Orders.lifecycle
let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {
open Reventless.Transition
switch command {
| Place(_) => Creates(Orders.Placed)
| Ship => Moves([Orders.Placed], Orders.Shipped)
| Cancel => Moves([Orders.Placed], Orders.Cancelled)
| AddNote(_) => Unrestricted
}
}
| Arm | Meaning |
|---|---|
Creates(s) | Brings the row into existence in state s. There is no state it comes from. |
Moves([…], s) | Legal in the listed states, and lands the row in s. |
Guards([…]) | Legal in the listed states, and leaves the row where it is. |
Unrestricted | Legal in every state, and moves nothing. Use it for a report that must not be refused because the row has moved on in the meantime. |
lifecycleState is the type of the view's @lifecycle field, so the states are that view's own constructors. The compiler checks three things an annotation could not:
- The switch is exhaustive. A command without an arm is a build error, including a command spliced in from a trait by a variant spread.
- A misspelled state is a build error.
Orders.Plaseddoes not compile. - Every arm uses one lifecycle. A from-set taken from one enum with a target taken from another does not compile.
The reference costs nothing at run time. A lifecycle enum's cases have no payload, so [Orders.Placed] compiles to ["Placed"], and the aggregate imports nothing from its view.
What reads it. Plugin_Structure evaluates the switch once per command constructor while it assembles the plugin structure. It publishes the from-set as commandDef.allowedStates and the target as commandDef.targetState. AutoUI shows a command only on rows whose lifecycle value is in the from-set. A command with no from-set (Creates, Unrestricted) is shown everywhere.
The lifecycle model. Where the plugin's harvested lifecycle model (src/LifecycleModel.res, what its GWT scenarios show) records a from-set or a target for a command, that is published instead of the switch's, and commandDef.allowedStatesSource says which one answered. The exception is Unrestricted: a set of scenarios covers only the states someone wrote a scenario for, so it never narrows a command declared legal everywhere.
Without a switch. A spec that writes no commandTransition gets one from the PPX. It answers Undeclared for every command. That reads like Unrestricted, except that the lifecycle model may give it a from-set. A command type that splices another type's constructors must write the switch. The build refuses it otherwise, because the PPX cannot tell a guard left out on purpose from one forgotten.
Removed annotations. @transition, @allowedStates and @targetState on a command constructor are compile errors, and the message says which arm to write instead. See Domain traits for why the value form replaced them.
@id, @compositeId — partition key derivation
Use on @schema type state fields in ReadModel and StateViewSlice spec files. The PPX generates let makeId from the annotated field(s). This replaces a manual let makeId declaration.
| Annotation | Usage | Generated code |
|---|---|---|
@id | One string field | let makeId = (state: state) => state.fieldName |
@compositeId | Multiple string fields | let makeId = (state: state) => `${state.f1}/${state.f2}/...` |
@compositeId(~sep=":") | Multiple string fields, custom separator | Same with : between segments |
@id — simple entity key:
@@reventless.spec
@schema
type state = {
@id productId: string,
name: string,
price: float,
}
// PPX generates: let makeId = (state: state) => state.productId
@compositeId — multi-segment key:
@@reventless.spec
@schema
type state = {
@compositeId tenantId: string,
@compositeId productId: string,
name: string,
}
// PPX generates: let makeId = (state: state) => `${state.tenantId}/${state.productId}`
Constraints: @id and @compositeId cannot both appear on the same type. Both require string fields.
@subId, @compositeSubId — sort key derivation
Use on @schema type state fields in ReadModel and StateViewSlice spec files. The PPX generates let subIdConfig from the annotated field(s). This replaces the default let subIdConfig = None injected by @@reventless.spec.
| Annotation | Usage | Generated code |
|---|---|---|
@subId | One string field | let subIdConfig = Some({ subIdField: "fieldName", getSubId: state => state.fieldName }) |
@compositeSubId | Multiple string fields | Synthetic _subId attribute: let subIdConfig = Some({ subIdField: "_subId", getSubId: state => `${state.f1}/${state.f2}/...` }) |
@compositeSubId(~sep=":") | Multiple string fields, custom separator | Same with : |
@subId — version as sort key:
@@reventless.spec
@schema
type state = {
@id productId: string,
@subId version: string,
name: string,
}
// Enables: productById(id: ID!): ProductByIdConnection!
// with sort key args: prefix, from, to, eq, reverse, limit, nextToken
@compositeSubId — composite sort key:
@@reventless.spec
@schema
type state = {
@id orderId: string,
@compositeSubId createdAt: string,
@compositeSubId lineItemId: string,
amount: float,
}
// PPX generates: let subIdConfig = Some({ subIdField: "_subId", getSubId: ... })
// Stored _subId value: "{createdAt}/{lineItemId}"
Constraints: @subId and @compositeSubId cannot both appear on the same type. @subId requires a string field.
@index, @indexSubId — secondary index annotations
Use on @schema type state fields to declare DynamoDB secondary indexes. The PPX aggregates all index annotations and generates let config with an indexes array.
@index — simple secondary index (no sort key):
@schema
type state = {
@id productId: string,
@index categoryId: string,
name: string,
}
// secondary index: partition key = categoryId, ALL projection
// Query field generated:
// ProductByCategoryId(categoryId: String!, first: Int, after: String,
// last: Int, before: String,
// includeRetired: Boolean): ProductConnection!
// Pages forward on first/after. `last`/`before` are declared but refused —
// the cursor is the store's forward-only continuation token, and both
// backends say so rather than answering the forward page.
@index with projection options:
// KEYS_ONLY projection
@index({projection: "KEYS_ONLY"}) categoryId: string,
// INCLUDE projection
@index({projection: "INCLUDE", fields: ["name", "price"]}) categoryId: string,
Named @index with @indexSubId — secondary index with sort key:
Use the same name on both annotations to link them. The named index gets both a partition key and a sort key.
@schema
type state = {
@id productId: string,
@index("byCategoryDate") categoryId: string,
@indexSubId("byCategoryDate") createdAt: string,
name: string,
}
// secondary index: partition = categoryId, sort = createdAt
Composite secondary index keys — annotate multiple fields with the same name:
@schema
type state = {
@id productId: string,
@index("byTenantCategory") tenantId: string,
@index("byTenantCategory") categoryId: string, // composite pk: tenantId/categoryId
@indexSubId("byTenantCategory") region: string,
@indexSubId("byTenantCategory") createdAt: string, // composite sk: region/createdAt
name: string,
}
// Synthetic attributes injected at save: _byTenantCategory_pk, _byTenantCategory_sk
Authorization — restrict secondary index access by Cognito group:
@index({group: "admin", authTable: "PlatformAuth"}) tenantId: string,
Constraints: @indexSubId("name") without a matching @index("name") is an error.
@resolves, @resolvesMany — cross-table resolvers
Use on @schema type state fields to generate virtual GraphQL fields that resolve IDs to objects from another QueryDb table.
@resolves — resolve a single ID to its object:
@schema
type state = {
@id orderId: string,
@resolves({table: "Products", field: "product"}) productId: string,
quantity: int,
}
// Adds virtual GraphQL field: product: Product
// Resolved by GetItem on the Products table using productId
**@resolves via secondary index:
@resolves({table: "Orders", field: "currentOrder", via: "byProductId"}) productId: string,
// Resolved by querying Orders table's byProductId secondary index
@resolvesMany — resolve an array of IDs:
@schema
type state = {
@id cartId: string,
@resolvesMany({table: "Products", field: "products"}) productIds: array<string>,
}
// Adds virtual GraphQL field: products: [Catalog_Product!]
// Resolved by BatchGetItem on the Products table
Note: @resolves and @resolvesMany use record payload syntax (({key: "value"})), not labeled-arg syntax. The keywords ~to and ~as are reserved in ReScript and cannot be used as labeled args.
The target must be a queryable of the same plugin. table names another
ReadModel or StateViewSlice by its spec name; the field is emitted with that
view's GraphQL type ({Plugin}_{Singular}). A target the plugin does not expose
is refused at build time, and so is a plugin: key naming a different plugin:
each plugin's GraphQL document has to be valid standalone before the merge, and
on AWS the target table's grant is attached to the declaring plugin's API role.
To read across a plugin boundary, query the other view directly.
The target must be guarded the way the declaring view is. A cross-table field
is read through its parent and answers under the parent's authorization, so a
target declaring a different @authorize rule is refused at build time. A target
open to everyone is allowed — it can only narrow.
The target's rules narrow the rows. The row comes out of the target's table,
so the target's @owner and @retired declarations decide what the caller sees
— on every backend. A cross-table field takes no includeRetired argument, so a
retired row never travels through one; {list}Refs plus @namedWhenRetired is
the door that still names a row the archive took.
Nullability. The single form is nullable (the foreign key may name a row that
was never written); the batch form is [T!] — ids that match nothing drop out,
so the list is shorter rather than null-holed.
@storageRef, @offload — object-store field markers
These field markers declare that a field's value lives in one of the platform's object stores, and provision that store. Place them before the field name (like the DCB tag markers).
@storageRef — a field holding a store ref (string / array<string>):
The value is a store-minted origin-relative path; the ref is the value the reader renders. @storageRef("store") names a store of this plugin; @storageRef("Plugin.store") points at another plugin's.
@schema
type command =
| ChangeProductImage({
@dcbTag productId: string,
@storageRef("productImages") imageUrl: string,
})
@offload — an inline-or-reference field:
A small value stays embedded in the command/event; a large one is content-addressed to the store by the client (which uploads the bytes before issuing the command) and carried as a reference — so the event shrinks and byte-identical values across versions dedupe to one object. A reader resolves either arm back to the value with Offload.resolve.
@schema
type event =
| ReportGenerated({
reportId: string,
@offload("reports") body: option<reportBody>,
})
- Forms:
@offload("store"),@offload("plugin.store"), and the record form@offload({store: "store"}); works on a field of typeXoroption<X>. - Per-field threshold:
@offload({store: "s", threshold: 16384})sets the inline-vs-offloaded byte cut. A client resolves the effective cut withOffload.effectiveThreshold— precedence per-field marker → platform default → 8 KB — and passes it toOffload.preparewhen uploading. Retuning it is always safe; it only changes how future values split.
Constraints: the field's inner type must be a plain named type (reportBody, M.t); for an array<…> or type-parameterised inner type, apply the schema by hand with @s.matches(Reventless.Offload.optionSchema(~store="s", <schema>)).
@owner — the field that ties a row to its caller
@owner names the field holding the id of the principal a record belongs to. It goes on a field of a @schema type command variant, a @schema type state, a variant of a slice's @schema type consumedEvent (or an aggregate's @schema type event), or several of these. Place it before the field name, like the other field markers.
Three things follow, all enforced server-side:
- On a command: the framework overwrites the field with the authenticated caller's id before publishing. An absent field and a forged field therefore produce the same row — the client's value is ignored, not trusted.
- On a queryable's state: reads of that view are narrowed to the caller's own rows, on every transport.
- On a consumed event: the field names the owner of what the slice's commands act on. Before
decide, the handler reads that owner from the events of the command's own partition (events read across partitions do not count) and refuses a caller who is not the owner witherrorCode: "Forbidden". Exempt callers and commands the platform issues itself are not refused. Two different owners in one partition refuse everyone, as a data defect. An aggregate that marks an owner on its events skips persisted snapshots, which hold its state but not its owner.
// StateChange/PlaceOrder.res
@schema
type command =
PlaceOrder({
orderId: string,
@noDcbTag @owner customerId: string,
})
// StateViewStream/Orders.res
@schema
type state = {
orderId: string,
@owner customerId: string,
lifecycle: lifecycle,
}
- One per record or variant payload. A second
@owneris a compile error: every reader resolves the owner by taking the first marked field, so the second would be inert and the view would scope on whatever declaration order happened to put first. stringoroption<string>only. A row has one owner, so an array field cannot be one — annotating it is a compile error rather than a marker no reader looks at. Thef?: stringform works too.- It composes; it never subtracts. The marker is applied after the DCB-tag passes and wraps whatever schema the field already resolved to, so an owner field keeps its DCB tag (
@owner customerIdin a slice), its partition key (@partitionTag @owner), or its reference (@ref("Seller") @owner). Use@noDcbTagwhen the owner field is payload rather than a query key.
Who is exempt is deployment configuration, not part of the annotation: a caller whose group is listed in REVENTLESS_ELEVATED_GROUPS is neither stamped nor scoped, and an internal system caller is exempt too. A deployment that declares @owner and configures no elevated groups is warned per view at startup — otherwise its operators would silently see only their own rows.
The scope gets an index. The rows that belong to me is the most frequent read a deployed application makes, so @owner on a state also provisions the index that serves it: a GSI named _owner, partitioned by the owner field and sorted by the record's @subId when it declares one and by id when it does not, projecting every attribute. The generated list resolver then reads it — a scoped caller gets a Query whose key condition is the ownership predicate, while an exempt caller keeps the table read unchanged. Without it the predicate is a filter applied after the page is read, so a caller who owns a hundred of a million rows pays for the million and receives short pages.
Two consequences worth knowing:
- Creating the index is slow, once. DynamoDB builds a GSI on the control plane and the deploy blocks until it is
ACTIVE— on the order of ten minutes, largely independent of table size. It happens on the deploy that first adds@ownerto a view and not again. Existing rows need no rebuild:UpdateTablepopulates the new index from the rows that already carry its key attributes, which for this index is every row the view has (idis the table's key, and the owner field is the one you just annotated — if the projection already wrote it). - Check the owner field's cardinality. The index is well distributed when owners are users. It is a hot partition when one "owner" is a tenant holding most of the estate.
The index carries no query field of its own — a <view>By<OwnerField> door any caller could name is not the read it serves — and it is skipped when the field already carries an @index, since the author's index is then the one the read targets. A view small enough to accept the scan declines it with @owner({index: false}), which is also the only case the "keys no index" deploy-time warning still fires for.
@retired — what withdraws a row from ordinary reads
@retired names what means this row is retired from ordinary use — a deactivated customer, an archived category. Place it before the field name, on a @schema type state field of a ReadModel or StateViewSlice spec, or before a constructor of the lifecycle enum that the state holds.
It has two forms, and which one is right depends on whether the record has a lifecycle.
The boolean form — the field is a flag, and the row is retired when it is true:
@schema
type state = {
@id customerId: string,
displayName: string,
@retired deactivated: bool,
}
The state form — the field is the record's @lifecycle, and the row is retired in one of its states:
@schema
type accountStatus =
| Active
| Deactivated
@schema
type state = {
@id customerId: string,
@displayName email: string,
@retired(Deactivated) @lifecycle accountStatus: accountStatus,
}
When the enum is declared in the same file, mark the state on the constructor instead. This is the preferred way to write the state form, because a constructor cannot name a state that does not exist. More than one state may carry the marker, and the field that holds the enum becomes the retirement field:
@schema
type shelfStatus =
| Listed
| @retired Archived
| @retired Discontinued
@schema
type state = {
@id productId: string,
name: string,
@lifecycle shelf: shelfStatus,
}
The constructor form takes no argument, and it is a compile error if the field holding the enum carries @retired as well. It is also a compile error if no field of type state holds the enum, or if two fields do. Use the field form, @retired(Deactivated), when the enum is declared in another file, where this file's PPX cannot reach its constructors.
Reach for the state form whenever the record has a lifecycle at all, and the reason is what it does to commands. Without it, a record whose retirement is part of its life carries the same fact twice — once as the boolean the query layer filters on, once as a value of the enum that board columns, group sections, the progress tracker and commandTransition are all expressed in terms of. Two sources of one truth, free to drift, with no rule keeping them in step.
With one field, the spec's commandTransition decides which commands a retired row still offers, with no new annotation at all:
| UpdateEmail(_) => Guards([Customers.Active])
| Deactivate => Moves([Customers.Active], Customers.Deactivated)
| Reactivate => Moves([Customers.Deactivated], Customers.Active)
That last line is the point. A consumer filtering a per-row command menu against each command's from-set already exists and already works; what was missing was never a way to describe a command's stance on retirement — it was retirement being expressible in the vocabulary that stance is already written in.
The boolean form stays the right choice where retirement genuinely is a flag rather than a state: a Products view with an archived boolean and no lifecycle should not have to invent a two-valued enum.
Two things follow from the one annotation, and the second is why this is not a presentation hint like @groupBy:
- On the schema:
SuryToJsonSchema.deriveObjectSchemastampsx-reventless-retired: {label?, showWhenFalse, value?}on the named property, so a consumer renders retirement as a state of the row rather than as one more data column.valueis present only in the state form, and its absence is what tells a consumer which form the view declared. - On every read: rows that are retired — the flag true, or the field equal to the named state — are withheld from callers who are not exempt — the list query, the single-entity query, the by-ids and by-index doors, and the payload of a live change frame. Codegen carries the field name as
queryableDef.retiredFieldand the state asretiredValue, so a client holding the def holds the whole predicate; it is pushed into SQL and into the DynamoDBFilterExpressionrather than applied to the returned page, sofirst: 2yields two live rows.
Payload forms. Bare, a state constructor, a string label, or a record:
@retired deactivated: bool,
@retired(Deactivated) accountStatus: accountStatus,
@retired("Archived") archived: bool,
@retired({label: "Archived", showWhenFalse: true}) archived: bool,
@retired({value: Deactivated, label: "Closed"}) accountStatus: accountStatus,
The state is a constructor reference, not a string literal, like the states in commandTransition: both name states in the same vocabulary, which is the whole reason the state form exists. A string would read the same and check nothing; what survives the constructor reference (a value that is not one of the field's declared cases) is reported when the plugin structure is built, where the schema is in hand.
The label defaults to empty, which the schema emitter omits so a consumer can tell "not stated" from "stated as empty" and derive one from the field name. showWhenFalse defaults to false and always travels: it asks a consumer to surface the flag in its negative state too, and since a non-exempt caller never receives a retired row, a default-on marker would appear on every row they can read and carry no information.
- One per state record. A second
@retiredis a compile error. Two retirement flags do not narrow the read further, they leave it undecided — the query layer tests a single field. - The field type check inverts on the payload. With no state named, the field must be
booloroption<bool>(thef?: boolform works too) — the predicate is "is this true?", and on a non-boolean there is nothing to evaluate. With a state named, the field must NOT be a boolean, because it holds the enum that state belongs to. Either mistake is a compile error with the message for that branch; getting it wrong would leave the annotation riding the schema, rendering as a marker and narrowing nothing. - The state form must sit on the record's
@lifecyclefield. A retirement state anywhere else keeps the read narrowing but loses the command filtering that motivates it, becausecommandTransitionis written in terms of the lifecycle field — a state no command can name. Reported when the structure is built, along with a state the field does not declare. - Absent means not retired, on all four backends. A row written before the annotation existed carries no flag, and excluding those would empty the view the day someone adds
@retired. - Annotation or nothing. Unlike
@lifecycle, there is no conventional fallback: a boolean namedarchivedthat nobody annotated stays exactly as visible as it was. Guessing wrong here makes rows disappear.
Reaching the archive. A list query gains an includeRetired: Boolean argument, honoured only for a caller who was going to be allowed those rows anyway; a scoped caller passing it is ignored rather than refused. Note that elevation alone does not lift the restriction — an exempt caller is excluded until they ask, because an archive that is always underfoot is not an archive.
Who is exempt is the same deployment-wide REVENTLESS_ELEVATED_GROUPS that @owner uses, and deliberately so: two views must not disagree about who an operator is. There is no per-annotation elevation list.
@retired neither needs nor implies @scan. @scan widens the client's filter surface; this predicate is the resolver's, derived from who the caller is. A <field>Eq on the filter input would only ever return an empty page for the callers who could pass it.
Cost. An equality predicate over an enum-valued attribute indexes exactly as one over a boolean does, so nothing below changes between the two forms. The framework warns at deploy time when the annotated field keys no index, mirroring the @owner warning and for the same pathology: a FilterExpression is applied after the page is read, so pages shrink as the archive's share of the table grows. It warns rather than refuses — a small table may legitimately accept the cost. @scan deliberately does not silence it: it adds no index and removes no read unit.
Live updates. The state-change channel is keyed by view and entity and shared by every subscriber, so a publish cannot be scoped per caller. A save that leaves the row retired therefore publishes metadata only — Updated with no state — which asks every subscriber to refetch and lets the query layer answer for each of them. Un-retiring publishes full state as before. A subscriber still learns that an entity with a given id changed; removing that residual would need per-caller channels.
Tagged unions as state fields
A variant held by a @schema type state field is one fact with several shapes, and it is the honest
alternative to several fields that nothing keeps in step:
@schema
type geolocation =
| Pending({requestedFor: string})
| Located({point: Reventless.GeoPoint.t})
| Unresolvable({reason: string})
@schema
type state = {
@id customerId: string,
geolocation: geolocation,
}
The value is stored as sury encodes it — {"TAG":"Located","point":{…}} — and reaches GraphQL as a
union with one object type per arm. Consumers select it with inline fragments
(... on Ordering_CustomersGeolocationLocated { point { lat lng } }), never bare.
There is no annotation to write. Inside a ReadModel or StateViewSlice spec, the PPX names the
union on its schema — <Plugin>_<Spec><Type>, the same shape the enum beside it is already
emitted under — and every member type is that name plus the arm's own. The name has to live on the
schema because two halves of the framework need it and neither can derive it from the other: the SDL
emitter reaches a field through a path, and the write path, which stamps each stored value with the
__typename GraphQL resolves the member by, has only the schema in hand. Elsewhere — a union
declared in a framework module, say — the same line is written by hand:
let geolocationSchema = Reventless.TaggedUnion.named(~name="Geolocation", geolocationSchema)
A union with no name is not emitted as one: the field falls back to String, and the deploy log
names the view and the field so it is not silent.
Every arm must declare at least one named field. Three shapes are compile errors, and all three compile, encode and decode perfectly well — what refuses them is GraphQL:
| Refused | Why |
|---|---|
| Pending | payload-less, so it is the bare string "Pending" on the wire; a union member must be an object type |
| Pending({}) | an object, but the member type it implies has zero fields, which is invalid |
| Located(GeoPoint.t) | its field is published under the compiler's name _0 — in the SDL, in the stored row, and in every consumer's query |
The pressure this creates is usually productive: an arm that seems to carry nothing normally carries when, or what it was trying, and the declaration is the right place to be asked.
An enum is untouched. A variant whose arms all carry nothing is an enum, emitted as one, and none of the above applies to it.
Keys, filters, sorts and predicates are refused on a union field: @id, @subId (and the
composite forms), @index, @indexSubId, @scan, @scanSort, @groupBy, @lifecycle and
@retired — including @retired on an arm. All of them end up comparing the field to a scalar,
and this field's value is an object whose shape depends on the row. The comparison would compile,
publish a filter input, and never match. Put the annotation on a scalar field beside the union.
"List the rows whose geolocation is Unresolvable" wants a derived arm-name field beside the union,
not a filter over it.
Command and event variants are unaffected. They are decomposed into one mutation per constructor,
so a payload-less command like Deactivate stays exactly as it is.
Examples
Aggregate spec
// Category.res
@@reventless.spec
@schema
type command =
| Add({name: string})
| Rename({name: string})
| Archive
@schema
type event =
| Added({name: string})
| Renamed({name: string})
| Archived
@schema
type error =
| CategoryAlreadyExists
| CategoryNotFound
| CategoryAlreadyArchived
PPX injects: let name = "Category", module Id = Reventless.Id.String, let moduleUrl = "...".
Behavior
// CategoryBehavior.res
@@reventless.behavior
@schema
type state =
| NotCreated
| Active({name: string})
| Archived
let initialState = NotCreated
let evolve = (state, event) =>
switch (state, event) {
| (NotCreated, Added({name})) => Active({name: name})
| (Active(_), Renamed({name})) => Active({name: name})
| (Active(_), Category.Archived) => Archived
| _ => state
}
let decide = (state, command) =>
switch (state, command) {
| (NotCreated, Add({name})) => Ok([Added({name: name})])
| (Active(_), Add(_)) => Error(CategoryAlreadyExists)
| (Active(_), Archive) => Ok([Category.Archived])
| (Archived, Archive) => Ok([]) // idempotent
| _ => Error(CategoryNotFound)
}
PPX injects: open Category, module Spec = Category, let moduleUrl = "...".
Read model spec
// CategoriesReadModel.res
@@reventless.spec
@schema
type state = {
name: string,
archived: bool,
}
PPX derives: let name = "Categories" (strips ReadModel suffix). Because the filename contains ReadModel and the file has @schema type state with no let config, the PPX also auto-injects:
open Reventless.ReadModel
let config = config()
let subIdConfig = None
To override, declare let config explicitly — the PPX skips injection when let config is present.
Extension point spec (in a *Spec package)
// Products_ExtensionPoint.res (in CatalogSpec namespace)
@@reventless.spec
@schema
type command = unit
@schema
type event =
| ProductBecameAvailable({productId: string, name: string, price: float})
| ProductPriceChanged({productId: string, price: float})
@schema
type directive = unit
PPX derives: let name = "Catalog.Products" (namespace CatalogSpec → "Catalog" + filename → "Products"). No module Id injected (no reventless-spec dependency).
DCB StateChangeSlice
// AddCategory.res
@@reventless.spec
@@reventless.dcbTags
type state = {exists: bool, archived: bool}
let initialState = {exists: false, archived: false}
@schema
type consumedEvent =
| CategoryAdded
| CategoryArchived
let evolve = (state, event) =>
switch event {
| CategoryAdded => {exists: true, archived: false}
| CategoryArchived => {...state, archived: true}
}
@schema
type command = AddCategory({categoryId: string, name: string})
@schema
type error = CategoryAlreadyExists
@schema
type event = CategoryAdded({categoryId: string, name: string})
let decide = (state, command) =>
switch command {
| AddCategory({categoryId, name}) =>
if state.exists {
Error(CategoryAlreadyExists)
} else {
Ok([CategoryAdded({categoryId, name})])
}
}
PPX injects let name = "AddCategory", module Id, let moduleUrl, and @s.matches(Reventless.DcbTag.string) on the categoryId fields in both command and event types.
Conventions
PPX ordering
reventless-ppx must come before sury-ppx in ppx-flags. The reventless PPX injects @s.matches annotations that sury-ppx then processes into schema code.
Namespace conventions
| Package type | Namespace pattern | Effect on name derivation |
|---|---|---|
| Spec package | CatalogSpec | Dotted names: "Catalog.Products" |
| Plugin package | CatalogPlugin | Simple names: "Category" |
| Platform package | true or custom | Simple names |
When to use explicit names
Use @@reventless.spec("ExplicitName") when:
- The desired name differs from the filename (rare)
- The file is a framework-internal component (e.g.,
PluginSpec.res→"Plugin"works, but being explicit is clearer)
Use @@reventless.behavior(SpecName) when:
- The spec module name doesn't match
{filename minus Behavior}(e.g.,PluginBehavior.resopensPluginSpec, notPlugin)
Files that cannot use PPX annotations
These patterns cannot be auto-generated:
moduleUrlfor ExtensionPoint inline modules inside functor bodies — requires top-level%rawcaptured by closure- Spec definitions inside inner modules in test fixtures
For these cases, use the manual declarations.
@@reventless.mappings
File-level attribute on <Plural>_Projections.res (multi-source ReadModel projections in ReadModel/) and <Entity>_Mappings.res (Aggregate event-mapping siblings in Aggregate/).
What it injects (at the top of the file):
| Binding | Condition | Value |
|---|---|---|
open Reventless.<Domain> | Not already opened | Projection (in ReadModel/) or EventMapping (in Aggregate/) or AutomationSlice (in Automation/) |
open Reventless.Message | In ReadModel/ and not already opened | — |
module Target | Not already declared | Alias to the spec module (<Stem> with _Mappings / _Projections suffix stripped) |
module M | Not already declared | Reventless.<Domain>.Mappings.Make(Target) |
module type Mapping | Not already declared | M.Mapping |
let moduleUrl | Not already declared | Computed npm specifier |
let counter = None | In Aggregate/ and not already declared | — |
The PPX also scans inner modules: any module X = { ... } containing both let name = "..." and @schema type event is treated as a DCB Source — module Id = Reventless.Id.String is injected (if absent) and dcbTags are applied to the event type's *Id fields.
Before (manual):
open Reventless.Message
open Reventless.Projection
module ProductMapping = Mapping.Make(
Product,
Products,
{
open Product
let project = ({event, id, _}) =>
switch event {
| Added({name}) => Set(id, {Products.name: name})
| _ => Ignore
}
},
)
module M = Mappings.Make(Products)
module type Mapping = M.Mapping
let moduleUrl: string = %raw(`import.meta.url`)
let mappings: array<module(Mapping)> = [module(ProductMapping)]
After (with PPX):
@@reventless.mappings
module ProductMapping = Mapping.Make(
Product,
Products,
{
open Product
let project = ({event, id, _}) =>
switch event {
| Added({name}) => Set(id, {Products.name: name})
| _ => Ignore
}
},
)
let mappings: array<module(Mapping)> = [module(ProductMapping)]
The plugin generator references the projections module directly: module ProductsReadModel = Platform.ReadModel.Make(Products, Products_Projections). No more @reventless.projections wrapper module in Plugin.res.
@reventless.delegate
Use on Delegate module bindings outside *ExtensionPointMapping* files that need the same auto-transformation. Inside *ExtensionPointMapping* files, any module named Delegate is auto-transformed by @@reventless.spec without this attribute. Works at any nesting depth.
What it injects (into the module body):
| Binding | Condition | Value |
|---|---|---|
module Id | Not already declared | Reventless.Id.String |
@schema type command = unit | Not already declared | Sury generates commandSchema from this |
dcbTags on @schema type event | Event type present | @s.matches(Reventless.DcbTag.string) on *Id: string fields |
@schema type error = unit | Not already declared | Sury generates errorSchema from this |
let moduleUrl | Not already declared | Computed npm specifier |
Before (manual):
module Delegate = {
let name = "CatalogEventLog"
module Id = Id.String
@schema type command = unit
@schema
type event =
| ProductAdded({productId: @s.matches(DcbTag.string) string, name: string, price: float})
| ProductPriceChanged({productId: @s.matches(DcbTag.string) string, price: float})
@schema type error = unit
let commandSchema = S.unit
let moduleUrl: string = %raw(`import.meta.url`)
}
After (with PPX):
@reventless.delegate
module Delegate = {
let name = "CatalogEventLog"
@schema
type event =
| ProductAdded({productId: string, name: string, price: float})
| ProductPriceChanged({productId: string, price: float})
}
The developer only writes let name and the @schema type event. Everything else is auto-generated. The @s.matches(Reventless.DcbTag.string) annotation is applied automatically to *Id: string fields via the same logic as @@reventless.dcbTags.
What the PPX replaces
| Before (manual) | After (PPX) |
|---|---|
open Reventless | (not needed for specs — module Id uses fully qualified path) |
module Id = Id.String | Auto-injected by @@reventless.spec |
let name = "Category" | Derived from filename |
let moduleUrl: string = %raw(\import.meta.url`)` | Computed at compile time |
open Spec; module Spec = Spec | Auto-injected by @@reventless.behavior |
@s.matches(DcbTag.string) on *Id/*Ids fields | Auto-injected by @@reventless.dcbTags (or automatically in slice folders) |
@s.matches(DcbTag.partition) on partition key field | Use @partitionTag field annotation |
@s.matches(DcbTag.string) on non-*Id field | Use @dcbTag field annotation |
Suppress auto-tagging on a *Id field | Use @noDcbTag field annotation |
module M = Mappings.Make(...) + boilerplate | Auto-injected by @@reventless.mappings (file-level) |
open Reventless.ReadModel; let config = config(); let subIdConfig = None | Auto-injected by @@reventless.spec for *ReadModel* files |
module Id, @schema command/error = unit, @s.matches, moduleUrl in Delegate | Auto-injected in *ExtensionPointMapping* files; use @reventless.delegate elsewhere |
let makeId = ... in ReadModel/StateViewSlice spec | Use @id or @compositeId on @schema type state fields |
let subIdConfig = Some({...}) in ReadModel/StateViewSlice spec | Use @subId or @compositeSubId on @schema type state fields |
let config = config(~indexes=[...]) with manual indexConfig records | Use @index/@indexSubId on @schema type state fields |
Manual idResolverConfig/idsResolverConfig entries in let config | Use @resolves/@resolvesMany on @schema type state fields |
Hand-written @s.matches(StorageRef.forStore(...)) / @s.matches(Offload.optionSchema(...)) | Use @storageRef / @offload field annotations |
Hand-written @s.matches(Owner.string) on the caller-identifying field | Use the @owner field annotation |