modernx-admin — Automations engine entity model
The automations engine (lib/automations/engine/) evaluates trigger events
against conditions and fires executors (e.g. send email, update Magento).
Until 2026-07-27 it only understood one kind of entity: orders. That’s now
generalized so future non-order triggers have somewhere to plug in.
DomainEntity union and entityLoaders registry
Section titled “DomainEntity union and entityLoaders registry”lib/automations/engine/entities.ts exports:
EntityDomain—"order" | "customer" | "product" | "refund" | "shipment" | "invoice" | "cart".DomainEntity— a discriminated union of one interface per domain, each tagged with adomainliteral (OrderEntity,CustomerEntity,ProductEntity,RefundEntity,ShipmentEntity,InvoiceEntity,CartEntity). Cents columns are converted to major units in every loader, same convention as the originalOrderEntity.entityLoaders: Record<EntityDomain, (db, tenantId, sourceId) => Promise<DomainEntity | null>>— one point-lookup loader per domain, each reading its own warehouse table (whCustomers,whProducts,whCreditmemos,whShipments,whInvoices,whQuotes) keyed by(tenantId, source*Id), mirroring the originalgetOrderEntityquery shape.getOrderEntitystill exists with its original signature and is registered asentityLoaders.order.
Dispatch in execute.ts
Section titled “Dispatch in execute.ts”processAutomation no longer calls getOrderEntity directly. Instead:
resolveDomain(triggerType)maps the event’s trigger type to anEntityDomain. Today every registered trigger (ORDER_PLACED_TRIGGER,ORDER_STATUS_CHANGED_TRIGGER) maps to"order"— there is noentityDomainfield onTriggerEventitself; domain is derived purely from trigger type via this small static map.entityLoaders[domain](db, event.tenantId, event.entityId ?? event.entityKey)loads the entity.- Because
conditions.ts/executors.tsstill only type againstOrderEntity, the loadedDomainEntityis narrowed with a.domain === "order"check before continuing. Both this narrowing check andresolveDomain’s default case throw a descriptive internal error if hit — unreachable today since every registered trigger resolves to"order", but real code paths rather than silent fallthrough if that invariant is ever violated by a future registration bug.
Missing/null entity handling is unchanged: finishRun records the run as
"failed" with ENTITY_NOT_FOUND_ERROR, no executor fires.
Interpolation tokens namespaced by domain
Section titled “Interpolation tokens namespaced by domain”lib/automations/engine/interpolate.ts widened from entity: OrderEntity to
entity: DomainEntity and now dispatches by entity.domain (a switch, so
each case narrows to its concrete entity type under strict TS):
TOKEN_PATTERNmatches any{{<domain>.<field>}}shape (was{{order.<field>}}only).- One field map per domain (
ORDER_FIELD_MAP— the renamed originalFIELD_MAP— plus new maps for customer, product, refund, shipment, invoice, cart). - If a token’s domain doesn’t match the entity currently being interpolated (unrecognized domain, or a recognized domain that isn’t this entity’s), the token passes through unchanged as literal text — same as an unmatched token always has.
order.*tokens resolve byte-identically to today, includingorder.bogus->""and theINTERPOLATION_TOKENSexport (still the flat 8-entryorder.*list consumed byEmailConfig/SlackConfig/OrderCommentConfig).
No non-order trigger is wired into the engine or UI yet, so the new
namespaces are inert in production — nothing today produces a
{{customer.*}} etc. token or an entity for interpolate to resolve it
against.
Authoring-time domain compatibility check
Section titled “Authoring-time domain compatibility check”The engine-side generalization above has an authoring-side counterpart in
lib/automations/ (outside engine/): NodeTypeDef in
lib/automations/nodes/registry.ts gains an optional
entityDomain?: EntityDomain field (same EntityDomain union, imported from
engine/entities.ts), so a trigger or condition node type can declare which
entity domain it operates on. Absent means "order" — every currently
registered node type has no tag, so every existing node type and graph keeps
validating exactly as before.
validateAutomationGraph (lib/automations/validate.ts) uses this tag to
close a gap: nothing previously stopped wiring an order-only condition (e.g.
orderTotal) after a non-order trigger — it would just silently no-op at
runtime. Now, for every condition node reachable from a trigger, if its
effective domain (declared, or "order" by default) differs from that
trigger’s effective domain, validation raises a node-scoped issue naming both
domains (e.g. "Order total" needs a order trigger — this automation's trigger is customer.). This also covers the multi-trigger case: a condition
reachable only from an illegal second trigger is now flagged as
domain-mismatched against the first (only-run) trigger’s domain, alongside
the existing “only one trigger allowed” issue, instead of being silently
skipped.
Actions are out of scope — only trigger and condition category nodes carry
entityDomain. No canvas/UI change was needed: the ValidationPanel
already renders whatever validateAutomationGraph returns.
Why this exists
Section titled “Why this exists”This is scaffolding only — no new trigger types are registered and no
existing automation behavior changes. It’s the prerequisite slice for a
larger trigger-breadth arc (parent epic modernx-3073, tracked in the
sibling modernx tracker) that will add customer/product/refund/shipment/
invoice/cart triggers on top of this registry. When adding a new trigger
type for one of the already-scaffolded domains, the loader and interpolation
field map already exist — the remaining work is registering the trigger,
updating resolveDomain, widening conditions.ts/executors.ts beyond
OrderEntity if the new domain needs its own condition/executor logic, and
tagging the new trigger/condition node types with the matching
entityDomain so the compatibility check above covers them.
See also Ingest delta detection for
how the TriggerEvents this engine consumes get produced on the ingest side,
Scan-trigger infrastructure
for the periodic-scan counterpart this engine also dispatches through
(execute.ts’s activatedAt check plays the same backfill-guard role
there), and AI automation builder: trigger params
for how the natural-language builder compiles trigger nodes that plug into
this same engine, and Automations: Wait/delay step
for how a run can pause mid-graph and resume through this same execution
core.
Source: modernx-admin PR #389, PR #390, PR #392.