Skip to content

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 a domain literal (OrderEntity, CustomerEntity, ProductEntity, RefundEntity, ShipmentEntity, InvoiceEntity, CartEntity). Cents columns are converted to major units in every loader, same convention as the original OrderEntity.
  • 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 original getOrderEntity query shape. getOrderEntity still exists with its original signature and is registered as entityLoaders.order.

processAutomation no longer calls getOrderEntity directly. Instead:

  1. resolveDomain(triggerType) maps the event’s trigger type to an EntityDomain. Today every registered trigger (ORDER_PLACED_TRIGGER, ORDER_STATUS_CHANGED_TRIGGER) maps to "order" — there is no entityDomain field on TriggerEvent itself; domain is derived purely from trigger type via this small static map.
  2. entityLoaders[domain](db, event.tenantId, event.entityId ?? event.entityKey) loads the entity.
  3. Because conditions.ts/executors.ts still only type against OrderEntity, the loaded DomainEntity is narrowed with a .domain === "order" check before continuing. Both this narrowing check and resolveDomain’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.

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_PATTERN matches any {{<domain>.<field>}} shape (was {{order.<field>}} only).
  • One field map per domain (ORDER_FIELD_MAP — the renamed original FIELD_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, including order.bogus -> "" and the INTERPOLATION_TOKENS export (still the flat 8-entry order.* list consumed by EmailConfig/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.

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.

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.