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

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:

BindingConditionValue
let nameNot already declaredDerived from filename
module IdNot already declared, and reventless-spec is a dependencyReventless.Id.String
let moduleUrlNot already declaredComputed npm specifier
open Reventless.ReadModel + let config + let subIdConfigFilename contains ReadModel, @schema type state present, let config not declaredReadModel defaults

Name derivation strips known component suffixes from the filename:

FilenameDerived 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:

FilenameNamespaceDerived name
Products_ExtensionPoint.resCatalogSpec"Catalog.Products"
Orders_ExtensionPoint.resOrderingSpec"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:

BindingConditionValue
open SpecNot already presentOpens the spec module
module Spec = SpecNot already declaredAliases the spec module
let moduleUrlNot already declaredComputed npm specifier

Spec module derivation: strips _Behavior (or a bare Behavior) from the filename.

FilenameDerived spec
Category_Behavior.resopen Category; module Spec = Category
Order_Behavior.resopen Order; module Spec = Order
ProductDemand_Behavior.resopen 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 patternTypeInjection
*Id: stringscalar@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.

AnnotationPlaced onEffect
@partitionTagA *Id: string field on a produced eventInjects @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.
@crossPartitionA string (or array<string> element) fieldInjects @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.
@noDcbTagA *Id: string fieldSuppresses 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).
@dcbTagAny string fieldInjects @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[].productId produces key productId, which is what makes it the same tag the producing slice writes. A key derived from lineItems would 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:

RuleBehaviour
Non-string field annotatedNo-op — field is left untouched
@compositePartitionTag and @partitionTag on the same schemaThrows at startup
Fewer than 2 fields annotatedThrows at startup
A member's value is the empty stringPermitted — 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:value pairs 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. AddProduct checking a categoryId that Category owns), the framework infers the cross-partition read from the slice graph — declare the consumed events and the foreign field, and write no annotation. @crossPartition is 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 @crossPartition that 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 @crossPartition clause reads the per-tag tag_<key> GSI (a Query for keys + a BatchGet/GetItem for 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 @crossPartition tag'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_Builder reports a scope mismatch at build time.
  • Placement: before the field name, like @partitionTag. Works on a string field or the element type of an array<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:

Spectype role
declares its own type roleleft 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.permissionReventless.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.

AnnotationPlaced onEffect
@noApi@schema type commandEntire command type hidden from API
@noApiA single variant in a command typeOnly 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
}
}
ArmMeaning
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.
UnrestrictedLegal 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.Plased does 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.

AnnotationUsageGenerated code
@idOne string fieldlet makeId = (state: state) => state.fieldName
@compositeIdMultiple string fieldslet makeId = (state: state) => `${state.f1}/${state.f2}/...`
@compositeId(~sep=":")Multiple string fields, custom separatorSame 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.

AnnotationUsageGenerated code
@subIdOne string fieldlet subIdConfig = Some({ subIdField: "fieldName", getSubId: state => state.fieldName })
@compositeSubIdMultiple string fieldsSynthetic _subId attribute: let subIdConfig = Some({ subIdField: "_subId", getSubId: state => `${state.f1}/${state.f2}/...` })
@compositeSubId(~sep=":")Multiple string fields, custom separatorSame 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 type X or option<X>.
  • Per-field threshold: @offload({store: "s", threshold: 16384}) sets the inline-vs-offloaded byte cut. A client resolves the effective cut with Offload.effectiveThreshold — precedence per-field marker → platform default → 8 KB — and passes it to Offload.prepare when 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 with errorCode: "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 @owner is 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.
  • string or option<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. The f?: string form 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 customerId in a slice), its partition key (@partitionTag @owner), or its reference (@ref("Seller") @owner). Use @noDcbTag when 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 @owner to a view and not again. Existing rows need no rebuild: UpdateTable populates the new index from the rows that already carry its key attributes, which for this index is every row the view has (id is 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.deriveObjectSchema stamps x-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. value is 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.retiredField and the state as retiredValue, so a client holding the def holds the whole predicate; it is pushed into SQL and into the DynamoDB FilterExpression rather than applied to the returned page, so first: 2 yields 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 @retired is 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 bool or option<bool> (the f?: bool form 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 @lifecycle field. A retirement state anywhere else keeps the read narrowing but loses the command filtering that motivates it, because commandTransition is 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 named archived that 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:

RefusedWhy
| Pendingpayload-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 typeNamespace patternEffect on name derivation
Spec packageCatalogSpecDotted names: "Catalog.Products"
Plugin packageCatalogPluginSimple names: "Category"
Platform packagetrue or customSimple 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.res opens PluginSpec, not Plugin)

Files that cannot use PPX annotations​

These patterns cannot be auto-generated:

  • moduleUrl for ExtensionPoint inline modules inside functor bodies — requires top-level %raw captured 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):

BindingConditionValue
open Reventless.<Domain>Not already openedProjection (in ReadModel/) or EventMapping (in Aggregate/) or AutomationSlice (in Automation/)
open Reventless.MessageIn ReadModel/ and not already opened—
module TargetNot already declaredAlias to the spec module (<Stem> with _Mappings / _Projections suffix stripped)
module MNot already declaredReventless.<Domain>.Mappings.Make(Target)
module type MappingNot already declaredM.Mapping
let moduleUrlNot already declaredComputed npm specifier
let counter = NoneIn 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):

BindingConditionValue
module IdNot already declaredReventless.Id.String
@schema type command = unitNot already declaredSury generates commandSchema from this
dcbTags on @schema type eventEvent type present@s.matches(Reventless.DcbTag.string) on *Id: string fields
@schema type error = unitNot already declaredSury generates errorSchema from this
let moduleUrlNot already declaredComputed 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.StringAuto-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 = SpecAuto-injected by @@reventless.behavior
@s.matches(DcbTag.string) on *Id/*Ids fieldsAuto-injected by @@reventless.dcbTags (or automatically in slice folders)
@s.matches(DcbTag.partition) on partition key fieldUse @partitionTag field annotation
@s.matches(DcbTag.string) on non-*Id fieldUse @dcbTag field annotation
Suppress auto-tagging on a *Id fieldUse @noDcbTag field annotation
module M = Mappings.Make(...) + boilerplateAuto-injected by @@reventless.mappings (file-level)
open Reventless.ReadModel; let config = config(); let subIdConfig = NoneAuto-injected by @@reventless.spec for *ReadModel* files
module Id, @schema command/error = unit, @s.matches, moduleUrl in DelegateAuto-injected in *ExtensionPointMapping* files; use @reventless.delegate elsewhere
let makeId = ... in ReadModel/StateViewSlice specUse @id or @compositeId on @schema type state fields
let subIdConfig = Some({...}) in ReadModel/StateViewSlice specUse @subId or @compositeSubId on @schema type state fields
let config = config(~indexes=[...]) with manual indexConfig recordsUse @index/@indexSubId on @schema type state fields
Manual idResolverConfig/idsResolverConfig entries in let configUse @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 fieldUse the @owner field annotation