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

@@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"
ProductsExtensionPoint.res"Products"
ProductBehavior.res"Product"

Stripped suffixes: 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
ProductsExtensionPoint.resCatalogSpec"Catalog.Products"
OrdersExtensionPoint.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 from the filename.

FilenameDerived spec
CategoryBehavior.resopen Category; module Spec = Category
OrderBehavior.resopen Order; module Spec = Order
ProductDemandBehavior.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 *Slice/ folders that have entity ID fields in @schema types. Files inside any *Slice/ folder (StateChangeSlice, StateViewSlice, AutomationSlice, InboundTranslationSlice, OutboundTranslationSlice) 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 fieldInjects @s.matches(DcbTag.partition) — marks this field as the partition key. Required when a variant has multiple *Id fields.
@crossPartitionA string (or array<string> element) fieldInjects @s.matches(DcbTag.crossPartition) — marks this tag as readable across all partitions (a secondary-tag read), not just the one it keys. See @crossPartition below.
@noDcbTagA *Id: string fieldSuppresses auto-tagging — the field stays as plain string. Use when the field is payload data, not a DCB query key.
@dcbTagAny string fieldInjects @s.matches(DcbTag.string) — explicit opt-in for fields that don't follow *Id naming (e.g., sku, slug, reference).

The PPX strips all four attributes from the output AST, so the compiler never sees them as unknown attributes.

@partitionTag — multiple *Id fields:

// In a StateChangeSlice file — both productId and orderId would otherwise
// both get auto-tagged, making partition derivation ambiguous
@schema
type event =
| DemandRecorded({
@partitionTag productId: string, // partition key
orderId: string, // also tagged as DcbTag.string
})

@noDcbTag — payload-only field:

@schema
type event =
| DemandRecorded({
@partitionTag 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,
})

@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.

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 schemaderivePartitionTag throws at startup
Fewer than 2 fields annotatedderivePartitionTag throws at startup

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

A DCB event is stored under exactly one partition (its @partitionTag). 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 invariant — an event ties two entities but can be partitioned by only one, so the decision must read by both, and the non-partition side is inherently a secondary-tag read.

// 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.
@schema
type command =
| SubscribeStudent({
@partitionTag 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.

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

@schema @noApi
type command =
| IssueRefund({orderId: string, reason: string})

All variants of this command type are excluded from GraphQL mutations and MCP tools. Use this for internal automation-only workflows.

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).


@status — mark the lifecycle status field

Use on a single @schema type state field in a ReadModel or StateViewSlice spec to mark the field whose value identifies the entity's lifecycle status. AutoUI's command-menu filter reads the marked field per row and matches it against each command's @allowedStates set.

@@reventless.spec

@schema
type status = Placed | Shipped | Cancelled

@schema
type state = {
orderId: string,
customerId: string,
@status status: status,
}

The PPX emits the field name into stateAnnotationSpec.status (sury metadata attached to the state schema). Codegen reads it when building queryableDef.statusField.

Resolution order (codegen, Plugin_Structure.statusFieldFromStateSchema): (1) field annotated @status; (2) field literally named "status" (conventional fallback — mirrors how @displayName/labelField falls back to a conventionally-named string field); (3) None. The convention is convenience only — an explicit @status on any other field shadows the implicit status-by-name match.

Constraint: at most one @status per state record. The PPX errors on duplicate annotations within the same record.


@allowedStates — per-variant command state guard

Use on individual command variants in an aggregate or DCB-slice @schema type command to declare which lifecycle states the command is meaningful in. The payload is an expression list of status-type constructor references. AutoUI hides the command on rows whose status isn't in the set.

// In Order.res (an Order aggregate spec):
@@reventless.spec

@schema
type command =
| Place({customerId: string, productIds: array<string>})
| @allowedStates([Orders.Placed]) Ship
| @allowedStates([Orders.Placed]) Cancel
AnnotationPlaced onEffect
@allowedStates([…])A single command variantVariant is shown only on rows where the row's statusField value is in the set
(no annotation)A single command variantVariant is always shown (back-compat default)
@allowedStates([])A single command variantVariant is never shown (defensive "never available" form)

The PPX extracts each constructor's leaf identifier as a string and emits let commandSchema = ReventlessInfra.Api.markAllowedStates(commandSchema, [|("Ship", [|"Placed"|]); …|]) attaching the per-variant map to the schema metadata. The wire format on Platform_ComponentDefinitions is commandDef.allowedStates: option<array<string>>None means "always show", Some([…]) means "filter".

Supports payloadless and payload variants uniformly. The PPX walks the attribute payload as syntactic AST and extracts the leaf identifier; it works for Submitted (payloadless), OrdersStatus.Submitted (qualified payloadless), Shipped({trackingNumber}) (payload), and any combination. AutoUI's filter compares against the row's serialised status tag — sury emits payloadless variants as bare JSON strings and payload variants as {TAG: …, …} objects, both surfacing the constructor name.

Limitation: no compile-time existence check. The constructor name is extracted as a string without verifying the constructor actually exists on the read model's status type. A typo (@allowedStates([Orders.Plased])) compiles cleanly; the filter just never matches that string. ReScript's dependency analysis runs pre-PPX, so a witness-binding approach (emitting let _ = Orders.Placed) would force ReScript to build the witness's module first — which it doesn't see as a dep, breaking the build order. Runtime cross-validation against the status-field schema is captured as future work in AllowedStatesAnnotation.ml.


@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: ID!): ...

@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

@resolves with cross-plugin table:

@resolves({table: "Products", field: "product", plugin: "CatalogPlugin"}) productId: string,

@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: [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.


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)

// ProductsExtensionPoint.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 AutomationSlice/)
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