@abuhasanrumi/rules-feed-engine
v1.1.12
Published
Shared rules-feed engine for Dynamatic widget repositories.
Readme
@abuhasanrumi/rules-feed-engine
A shared TypeScript rules-feed engine for Dynamatic widget and cart storefront integrations. It evaluates IF/THEN rule logic across product feeds, plans and batches all network fetches, assembles and deduplicates the final product list, reports API errors automatically with rate limiting, and provides a cart output adapter and full pipeline diagnostics.
Current version: 1.1.5 — Published to npm as an ES module. No runtime dependencies; devDependencies contains only typescript.
Table of Contents
- Tech Stack
- Installation
- Quick Start
- Module Structure
- Architecture Overview
- Input Shape
- Core Concepts
- IF Logic Evaluation
- Return Sources
- Batch API Call Behavior
- Source Caching and Deduplication
- Product Assembly
- Exclusion Handling
- Output Shape
- Backward Compatibility
- Error Reporting
- Diagnostics
- Debug Logging
- Key Types and Interfaces
- Public API Reference
- End-to-End Example
- Development
- Version History
Tech Stack
- Language: TypeScript 5.x, compiled to ES module output (
"type": "module") - Runtime / build tool: Bun
- Package registry: npm, published as
@abuhasanrumi/rules-feed-engine(scoped public package) - Entry points:
dist/index.js(main),dist/index.d.ts(types) - Runtime dependencies: none
Installation
npm install @abuhasanrumi/rules-feed-engine
# or
bun add @abuhasanrumi/rules-feed-engineThe package is an ES module. main points to dist/index.js and types to dist/index.d.ts.
Quick Start
import { runRulesFeedEngine, toCartRulesFeedOutput } from '@abuhasanrumi/rules-feed-engine';
const result = await runRulesFeedEngine({
feeds,
context: {
inputProducts: [...],
cart: { items: [], item_count: 0 },
customer: {},
geo: {},
},
runtime: {
channel: 'webpage', // or 'cart'
shopDomain: 'example.myshopify.com',
storefrontAccessToken: 'public-storefront-token',
shopifyRoutesRoot: '/',
metadata: { // optional — included in error reports
widget_id: 123,
campaign_id: 456,
},
},
config: { includeDiagnostics: true },
cache: {}, // pass the same object across runs to reuse cached results
signal, // optional AbortSignal to cancel in-flight requests
});
// Webpage widget — use result.products directly
const products = result.products;
// Cart upsell — convert to cart output shape
const cartPayload = toCartRulesFeedOutput(result);Module Structure
Each file in src/ has a single responsibility:
| File | Purpose |
|---|---|
| src/index.ts | Package entry point; re-exports all public types, functions, and constants |
| src/types.ts | All TypeScript interfaces and type aliases (RulesFeedRuntime, NormalizedContext, RulesFeedEngineResult, etc.) |
| src/runRulesFeedEngine.ts | Main entry point; orchestrates the full pipeline; deduplicates concurrent calls per channel + feedId |
| src/validation.ts | Applies runtime defaults, normalizes feeds, initializes cache maps and diagnostics |
| src/normalizeContext.ts | Builds NormalizedContext — product id/title/vendor/type/handle arrays and precomputed cart totals from raw context |
| src/evaluateRules.ts | Iterates feeds and rules; checks for invalid logic types; calls evaluateLogic; respects isMatched early-exit |
| src/evaluateLogic.ts | Evaluates a single RulesFeedLogic node; memoizes results per name + type + value; exports comparison primitives |
| src/normalizeReturns.ts | Normalizes return name aliases; classifies returns as product/metadata/API; exports METADATA_RETURN_NAMES |
| src/returnPlanning.ts | Splits returns into local and API-backed; groups API returns per feed into one batch request; computes cache keys |
| src/recommendationSeed.ts | Resolves the recommendation seed (cart items, current page product, or input products) per feed configuration |
| src/requirements.ts | Detects which logic names require pre-fetched product details; exports PRODUCT_INFO_LOGIC_NAMES and getRequiredProductDetails |
| src/sourceFetching.ts | Executes all source fetches (Shopify Storefront GraphQL, Shopify recommendations, Dynamatic API); handles caching, in-flight deduplication, and error reporting |
| src/fetchWithTimeout.ts | Low-level fetch wrapper; uses XHR in browsers and native fetch in Node/SSR; enforces requestTimeoutMs; supports AbortSignal |
| src/productAssembly.ts | Builds final product lists from planned rules and fetch results; applies exclusions, variant filtering, limits, and discounts |
| src/postProcessing.ts | Compiles feed exclusions into fast lookup sets; applies them to local source products; globally deduplicates by product_id |
| src/errorReporter.ts | Posts API failure details to the Dynamatic error log endpoint; rate-limited per shop + source + error class (5-minute cooldown via localStorage) |
| src/adapters.ts | Converts RulesFeedEngineResult to CartRulesFeedOutput shape |
| src/logger.ts | Color-coded browser console logger; gated behind localStorage.getItem('DYN_RULES_FEED_ENGINE_DEBUG') === 'true' |
| src/utils.ts | Shared utilities: normalizeId, getProductId, safeHash, stableStringify, formatDiscount, markTiming, and engine constants |
Architecture Overview
runRulesFeedEngine runs each input through a linear pipeline. Each stage has one job.
validateInput(input)
↓
normalizeContext(context, channel)
↓
evaluateRules(feeds, context) — IF logic matrix
↓
planReturns(passedRules, context) — source keys, batch grouping
↓
fetchSources(sourceRequests, ...) — network calls with cache and timeout
↓
assembleProducts(plannedRules, results) — limits, discounts, usedApiKeys guard
↓
dedupeProducts(products) — global dedupe by product_id
↓
return RulesFeedEngineResult| Stage | File | What it does |
|---|---|---|
| Validation | src/validation.ts | Applies runtime defaults, normalizes feeds, initializes cache maps and diagnostics |
| Context normalization | src/normalizeContext.ts | Builds product id/title/vendor/type/handle arrays and internal caches from raw context |
| IF evaluation | src/evaluateRules.ts, src/evaluateLogic.ts | Evaluates each rule's logics matrix; channel-aware for subscription and variant title checks |
| Return planning | src/returnPlanning.ts, src/normalizeReturns.ts | Splits product vs metadata returns; dedupes source keys; groups all API-backed returns from the same feed into one request |
| Fetching | src/sourceFetching.ts, src/fetchWithTimeout.ts | Runs source requests with timeout, cache, in-flight dedupe, and AbortSignal support. Uses XHR in browsers; falls back to native fetch in Node/SSR. |
| Product assembly | src/productAssembly.ts | Adds batch API products once per rule group; applies exclusions; enforces limits; attaches discounts |
| Post-processing | src/postProcessing.ts | Applies compiled feed exclusions to local sources; globally dedupes by product id |
| Error reporting | src/errorReporter.ts | Posts API failure details to the error log endpoint; rate-limited per shop + source + error class |
| Adapters | src/adapters.ts | Converts engine result to cart output shape |
Input Shape
interface RulesFeedEngineInput {
feeds: RulesFeedFeed[];
context: RulesFeedContext;
runtime: RulesFeedRuntime;
config?: RulesFeedEngineConfig;
cache?: RulesFeedSourceCache;
signal?: AbortSignal;
}Feeds
interface RulesFeedFeed {
id: string | number;
feedId?: string | number;
rules: RulesFeedRule[];
exclusions: RulesFeedExclusion[];
recommendation_seed: RecommendationSeed; // 'auto' | 'cart' | 'currentPageProduct'
filter_input_product?: boolean;
return_ordering?: ReturnOrdering; // 'sequential' | 'round-robin' | 'shuffle'
[key: string]: unknown;
}Validation normalizes feeds before evaluation:
- Null rules are removed.
- Missing
exclusionsbecomes[]. - Invalid
recommendation_seedvalues become'auto'. filter_input_productis coerced to boolean.
recommendation_seed — controls which products seed Shopify and Dynamatic recommendation calls:
| Value | Seeding behavior |
|---|---|
| 'auto' | Cart channel: use lineItems. Webpage with currentPageProduct: use it. Webpage without: fall back to lineItems, then inputProducts. |
| 'cart' | Always use context.lineItems. |
| 'currentPageProduct' | Always use context.currentPageProduct. |
filter_input_product — when true, products that appear in any products IF logic in this feed's rules are excluded from local return sources. Does not affect API return sources.
Rules
interface RulesFeedRule {
id: string | number; // rules without id are skipped
name?: string;
logics: RulesFeedLogic[][];
returns: RulesFeedReturn[];
isMatched?: boolean;
returnOrdering?: string; // forwarded to Dynamatic API; defaults to 'shuffle'
[key: string]: unknown;
}- A rule without
idis skipped with diagnostic reasonmissing-rule-id. - A rule with a blank or null
typein any logic is skipped with reasoninvalid-logic-type(exceptcustomer-logged-inandequals-anything, which are exempt). isMatched: truestops further rule evaluation in the current feed after this rule passes. Other feeds continue normally.returnOrderingis forwarded asreturn_orderingin the API payload. Defaults to'shuffle'when absent.
Returns
interface RulesFeedReturn {
name: string;
type: string;
value: unknown;
mode?: 'include' | 'exclude'; // defaults to 'include' when sent to the API
filters?: RulesFeedReturnFilter[]; // forwarded to API as-is; not evaluated engine-side
[key: string]: unknown;
}
interface RulesFeedReturnFilter {
id: string;
name: string;
type: string;
value: unknown;
products?: RulesFeedProduct[];
}Return name normalization — applied before source routing and API payload construction:
| Input name | Normalized to |
|---|---|
| shopify_recommendation | shopify-recommendation |
| dhopify_recommendation | shopify-recommendation |
| dynamatic_recommendation | dynamatic-recommendation |
| specific-tag-products | products-with-tags |
| anything else | unchanged |
mode — tells the Dynamatic backend whether this return is an inclusion or exclusion. Old returns without mode default to 'include'.
filters — per-return filter constraints forwarded to the API unchanged. Not evaluated engine-side. Old returns without filters have the field omitted from the JSON.
Metadata return names — recognized as metadata, not product sources:
| Name | Engine behavior |
|---|---|
| product-limit | Integer cap on products from this rule. Default is 10. |
| title | Sets customization.title. Last matched rule wins. |
| subtitle | Sets customization.subtitle. Last matched rule wins. |
| hide-widget | Sets customization.hideWidget when value is true or 'true'. |
| product-discount | Formatted and attached to every product from this rule as discount and productDiscount. percentage type appends %; other types use String(value). |
| exclude-specific-product | Applied engine-side to shopify-recommendation results only. Removes matched product ids, handles, or specific variant ids. |
| shuffle | Recognized as metadata; ordering is backend-controlled. No engine-side effect. |
Context
interface RulesFeedContext {
inputProducts?: RulesFeedProduct[];
currentPageProduct?: RulesFeedProduct | null;
selectedVariantId?: string | number;
cart: RulesFeedCart; // required
lineItems?: RulesFeedCartItem[];
customer: RulesFeedCustomer; // required
geo: RulesFeedGeo; // required
url?: string;
orderTags?: string[];
now?: Date | string;
vehicleCodes?: string[];
}| Field | Default | Used for |
|---|---|---|
| inputProducts | lineItems mapped to products when empty | IF logic evaluation (tags, collections, vendor, type, handle, inventory, metafields) |
| lineItems | context.lineItems \|\| context.cart?.items \|\| [] | Dynamatic API lineitems, cart recommendation seed, subscription logic (cart channel) |
| currentPageProduct | null | currentPageProduct returns, recommendation seed (webpage channel) |
| selectedVariantId | undefined | any-selected-variant-title / each-selected-variant-title logic on webpage channel |
| cart | — | cart-subtotal, cart-item-count, cart-line-count logic; API payload cartData |
| customer | — | Customer tag, order count, spend, and login state logic |
| geo | — | Country code and country name logic |
| url | '' | URL text match logic |
| orderTags | [] | Order tag logic |
| now | new Date() | Passed to new Date(context.now) for date-related evaluations |
| vehicleCodes | [] | Merged with runtime.vehicleCodes (runtime takes precedence); sent to Dynamatic API as dynamatic-metafield-recommendation when non-empty |
Cart subtotal: cart.totalPrice and cart.total_price are treated as cents and divided by 100 for cart-subtotal logic.
Runtime
interface RulesFeedRuntime {
channel?: 'webpage' | 'cart';
shopDomain?: string;
storefrontAccessToken?: string;
shopifyRoutesRoot?: string;
recommendationApiUrl?: string;
recommendationApiUrlV2?: string;
recommendationPublicKey?: string;
requestTimeoutMs?: number;
useRecommendationV2ForShops?: string[];
storefrontCollectionsPageSize?: number; // default 250, clamped to 1–250
storefrontVariantsPageSize?: number; // default 249, clamped to 1–250
sourceCacheMaxEntries?: number; // default 50
vehicleCodes?: string[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}Runtime defaults applied during validateInput:
| Field | Default |
|---|---|
| recommendationApiUrl | 'https://recov2.dynamatics.app/v2' |
| recommendationApiUrlV2 | 'https://recov2.dynamatics.app/v2' |
| requestTimeoutMs | 8000 |
| useRecommendationV2ForShops | [] |
channel — controls the tracking query parameter on every request (dyn_web or dyn_cart) and switches channel-aware IF logic for subscriptions and variant titles.
storefrontCollectionsPageSize / storefrontVariantsPageSize — control first: arguments in the Storefront GraphQL query. Collections are only fetched for feeds that define collection exclusions.
sourceCacheMaxEntries — caps each internal source cache map at this many entries (default 50). When exceeded, the oldest entry is evicted (causes a refetch; never serves stale data).
useRecommendationV2ForShops — if runtime.shopDomain is in this array, the engine uses recommendationApiUrlV2 instead of recommendationApiUrl. Both default to https://recov2.dynamatics.app/v2.
vehicleCodes — pre-resolved vehicle codes used to seed metafield_recommendation returns. When non-empty, they are written into context.vehicleCodes after context normalization and sent to the Dynamatic API as dynamatic-metafield-recommendation. If vehicleCodes is empty, the metafield_recommendation source falls back to Shopify recommendations instead of calling the Dynamatic API.
metadata — arbitrary key/value data included verbatim in every error report sent by the engine. Use this to pass context like widget id, campaign id, variation id, and zone id so errors can be traced back to the exact widget configuration:
metadata: {
widget_id: 6595,
experience_id: 4776,
variation_id: 4814,
audience_id: 1474,
campaign_id: 2339,
zone_id: 3260,
widget_template: 61,
}Security note — storefrontAccessToken must be a public Storefront API token. Do not pass Admin API secrets into browser runtime code.
Config
interface RulesFeedEngineConfig {
dedupeKey?: 'product_id';
variantPolicy?: 'selected-return-variants';
stockPolicy?: 'engine-agnostic';
currentProductPolicy?: 'only-when-returned';
includeDiagnostics?: boolean; // default false
includeRawProducts?: boolean; // default true
}Effective behavior:
- Products are globally deduped by
product_id. First occurrence wins. specific-productfetches restrict assembled variants to the variant ids listed in the return value.- Stock levels are not filtered engine-side.
currentPageProductis only returned when a rule explicitly includes acurrentPageProductreturn.- Diagnostics are only included in the result when
includeDiagnostics: true. includeRawProducts: falseremoves theproduct.rawfield from fetched products, roughly halving per-product memory usage in caches and results.
Core Concepts
NormalizedContext
normalizeContext(context, channel) transforms the raw RulesFeedContext into a NormalizedContext that every downstream stage reads from:
interface NormalizedContext {
channel: 'webpage' | 'cart';
inputProducts: RulesFeedProduct[];
currentPageProduct?: RulesFeedProduct | null;
selectedVariantId?: string | number;
lineItems: RulesFeedCartItem[];
cart: RulesFeedCart;
customer: RulesFeedCustomer;
geo: RulesFeedGeo;
url: string;
orderTags: string[];
now: Date;
vehicleCodes: string[];
// Precomputed for O(1) logic evaluation
productIds: number[];
productIdSet: Set<number>;
productTitles: string[];
variantTitles: string[];
vendors: string[];
productTypes: string[];
productHandles: string[];
cartSubtotal: number;
cartLineCount: number;
cartItemCount: number;
logicResultCache: Map<string, boolean>;
seedKeyCache: Map<string, string>;
}Logic results are memoized in logicResultCache per name|type|stableStringify(value). The seed key for recommendation calls is cached in seedKeyCache.
RulesFeedRuntime
RulesFeedRuntime is the connection layer between the engine and the Shopify/Dynamatic infrastructure. It carries shop credentials, API URLs, timeout settings, and tracing metadata. It is validated and defaulted by validateInput before any pipeline stage runs.
Rules Evaluation Pipeline
The function signature of the main entry point:
function runRulesFeedEngine(input: RulesFeedEngineInput): Promise<RulesFeedEngineResult>The engine deduplicates concurrent calls: if a second call arrives with the same channel + feedId key while a run is in progress, the second caller receives the same Promise. The in-flight map entry is cleared once the promise settles.
IF Logic Evaluation
logics is a two-level array:
logics: [
[logicA, logicB], // group 0 — inner items are OR
[logicC], // group 1 — OR
// outer groups are AND
]A rule passes when every group passes. A group passes when at least one logic inside it is true.
rule.logics.every((group) => group.some((logic) => evaluateLogic(logic, context)))Rules with empty logics arrays never pass. Logic results are memoized per name + type + value within one engine run.
Logic types
Array comparison — used for tags, collections, customer tags, order tags, product ids:
| logic.type | Returns true when |
|---|---|
| equals-anything | Target array has at least one non-empty value |
| contains | At least one logic value exists in the target array |
| contains-all / contain-all | Every logic value exists in the target array |
| does-not-contain | Every logic value is absent from the target array |
| equals | Target and logic arrays contain exactly the same values (order-independent) |
| does-not-equal / does-not-equals | Arrays are not exactly equal |
Text comparison — used for product title, vendor, type, handle, variant title, country, URL:
| logic.type | Returns true when |
|---|---|
| equals-anything | Any source value is non-empty |
| contains | Source text includes expected text |
| does-not-contain | Source text does not include expected text |
| equals | Source text exactly equals expected text |
| does-not-equal / does-not-equals | Source text does not equal expected text |
All text comparisons are case-insensitive.
Numeric comparison — used for inventory, cart subtotal, cart counts, customer order count, total spent:
| logic.type | Returns true when |
|---|---|
| is-less-than | Actual < expected |
| is-greater-than | Actual > expected |
| is-equal-to | Actual = expected |
| is-not-equal-to | Actual ≠ expected |
Logic name reference
| Logic name | Matcher type | Source | Notes |
|---|---|---|---|
| products | Array / special | context.inputProducts product ids | Also handles have-subscription and doesnot-have-subscription type values |
| any-product-tags | Array | Tags from any input product | |
| each-product-tags | Array | Tags from every input product | |
| any-product-title | Text | Title of any input product | |
| each-product-title | Text | Title of every input product | |
| any-product-vendor | Text | Vendor of any input product | |
| each-product-vendor | Text | Vendor of every input product | |
| any-product-type | Text | Type of any input product | |
| each-product-type | Text | Type of every input product | |
| any-product-handle | Text | Handle of any input product | |
| each-product-handle | Text | Handle of every input product | |
| any-product-collection | Array | Collections of any input product | |
| each-product-collection | Array | Collections of every input product | |
| any-product-meta | Boolean/namespace.key | Metafield on any input product | Pre-fetch required; use getRequiredProductDetails |
| each-product-meta | Boolean/namespace.key | Metafield on every input product | Pre-fetch required; use getRequiredProductDetails |
| any-cart-inventory | Numeric | totalInventory of any input product | |
| each-cart-inventory | Numeric | totalInventory of every input product | |
| any-selected-variant-title | Text | Cart: variant_title on all line items. Webpage: title of variant matching selectedVariantId, falls back to all variant titles. | Channel-aware |
| each-selected-variant-title | Text | Cart: every line item variant title. Webpage: every title from selected variant(s). | Channel-aware |
| customer-logged-in | — | Truthy customer.id. Pass value: 'false' to match logged-out customers. | |
| customer-tag | Array | customer.tags or customer.customerTags | |
| customer-order-count | Numeric | customer.ordersCount ?? customer.totalOrder ?? 0 | |
| customer-total-spent | Numeric | customer.totalSpent ?? 0 | |
| cart-subtotal | Numeric | (cart.totalPrice ?? cart.total_price) / 100 | |
| cart-line-count | Numeric | Number of distinct line items (from cart.items or lineItems) | |
| cart-item-count | Numeric | cart.item_count or summed line item quantities | |
| country-name | Text | geo.countryName | |
| country-code | Text | geo.countryCode | |
| url | Text | context.url | |
| order-tags | Array | context.orderTags | |
Note on products logic with subscription types: when logic.name === 'products', the logic.type values have-subscription and doesnot-have-subscription are handled as special cases: on the cart channel they inspect selling_plan_allocation on line items; on the webpage channel they inspect selling_plan_groups on input products.
Return Sources
Local sources (no Dynamatic API)
| Return name | Network call | Behavior |
|---|---|---|
| currentPageProduct | None | Returns [context.currentPageProduct] or [] |
| specific-product | Shopify Storefront GraphQL | Fetches by handle; all handles in a run share one GraphQL query |
| shopify-recommendation | Shopify recommendations endpoint | Seeds from the resolved recommendation seed product |
API sources (Dynamatic recommendations API)
| Return name | Notes |
|---|---|
| dynamatic-recommendation | General recommendation return |
| products-with-tags | Also normalized from specific-tag-products |
| specific-collection-products | Collection-scoped products |
| metafield_recommendation | Vehicle/metafield-based recommendation. When vehicleCodes is empty, skips the Dynamatic API entirely and falls back to Shopify recommendations. When the API returns 0 products, also falls back to Shopify recommendations. |
| product-price | Fallback product source; only used when no higher-priority product return exists on a rule |
All API-backed returns from matched rules within the same feed are grouped into one Dynamatic API request per feed.
metafield_recommendation and vehicleCodes
runtime.vehicleCodes drives metafield_recommendation behaviour. After context normalization, runRulesFeedEngine copies runtime.vehicleCodes into context.vehicleCodes when it is non-empty. The fetching layer then:
- If
vehicleCodesis empty and every return in the batch ismetafield_recommendation: skip the Dynamatic API and go straight to Shopify recommendations. - Otherwise: call the Dynamatic API and include
"dynamatic-metafield-recommendation": vehicleCodesin the top-level payload. - If the API call succeeds but returns 0 products for
metafield_recommendation: fall back to Shopify recommendations for that slot.
The fallback is logged in debug mode:
[FETCH] [metafield_recommendation] API returned 0 products — falling back to Shopify recommendationsStorefront API details (specific-product)
Endpoint: https://{shopDomain}/api/2025-01/graphql.json?{dyn_web|dyn_cart}
Headers:
content-type: application/json
x-shopify-storefront-access-token: {runtime.storefrontAccessToken}Requires: runtime.shopDomain, runtime.storefrontAccessToken
Fetched fields per product: id, handle, title, vendor, productType, tags, totalInventory, priceRange.minVariantPrice.amount, variants(first: 249), collections(first: 250) — collections only fetched when the feed defines collection exclusions.
Shopify GID prefixes (gid://shopify/Product/, gid://shopify/ProductVariant/, gid://shopify/Collection/) are stripped from all returned ids.
Shopify recommendations details (shopify-recommendation)
Endpoint: {shopifyRoutesRoot}recommendations/products.json?product_id={id}&intent={intent}&{dyn_web|dyn_cart}
intent comes from return.value, defaulting to 'related'.
Requires a resolvable recommendation seed product. Fails gracefully with a diagnostics entry when no seed product exists.
Batch API Call Behavior
All matched rules from the same feed that have API-backed returns are collected into a single network request. N matched rules sharing a feed produce one Dynamatic API call.
The batch cache key format is:
api:feed:<feedId>:<fnv1a-hash(rules + seed + vehicleCodes)>Where:
rulesis the array of{ ruleId, returns }entries for all matched rules in the feedseedis the resolved recommendation seed key (encodes cart contents or current page product id)vehicleCodesiscontext.vehicleCodes
The SourceFetchRequest carries batchRules: BatchRuleEntry[]:
interface BatchRuleEntry {
ruleId: string | number;
returns: RulesFeedReturn[];
rule: RulesFeedRule;
}Dynamatic API request payload
{
"lineitems": [
{ "product_id": "123", "variants": [{ "variant_id": "456" }] }
],
"cartData": {
"currency": "USD",
"items_count": 2,
"total_price": 2598
},
"ruleData": {
"rules": [
{
"rule_id": "rule-id-1",
"return_ordering": "shuffle",
"returns": [
{ "name": "dynamatic-recommendation", "type": null, "value": "surprise", "mode": "include" }
]
}
],
"exclusions": [
{ "filterType": "tags", "tags": ["hidden"] }
]
},
"dynamatic-metafield-recommendation": ["CODE123"]
}Payload notes:
ruleData.rulescontains all matched rules from the feed.ruleData.exclusionsis a sibling ofrulesinsideruleData, not nested inside each rule.return_orderingcomes fromrule.returnOrdering; defaults to'shuffle'when absent.modeon each return defaults to'include'when absent on old feeds.filterson each return is forwarded as-is; omitted from the JSON when there are no filters.dynamatic-metafield-recommendationis added to the top-level payload only whencontext.vehicleCodesis non-empty.
API endpoint selection
POST {baseUrl}/api/recommendations?{dyn_web|dyn_cart}Headers:
content-type: application/json
x-public-key: {runtime.recommendationPublicKey}
x-shop-domain: {runtime.shopDomain}If runtime.shopDomain is in runtime.useRecommendationV2ForShops, baseUrl is runtime.recommendationApiUrlV2; otherwise runtime.recommendationApiUrl. Both default to https://recov2.dynamatics.app/v2.
Source Caching and Deduplication
The optional cache object is a RulesFeedSourceCache:
interface RulesFeedSourceCache {
shopifyProductHandles?: Map<string, RulesFeedProduct[]>;
shopifyRecommendations?: Map<string, RulesFeedProduct[]>;
dynamaticRecommendations?: Map<string, Record<string, RulesFeedProduct[]>>;
pendingShopifyProductHandles?: Map<string, Promise<RulesFeedProduct[]>>;
pendingShopifyRecommendations?: Map<string, Promise<RulesFeedProduct[]>>;
pendingDynamaticRecommendations?: Map<string, Promise<Record<string, RulesFeedProduct[]>>>;
}Pass an empty object {} and the engine initializes all six maps automatically.
Bounded eviction — each result cache map is bounded by runtime.sourceCacheMaxEntries (default 50). When the cap is hit, the oldest entry (insertion order) is evicted. Eviction costs a refetch — it never serves stale data. Cache keys embed the cart hash and channel so long sessions with changing carts do not collide.
In-flight deduplication (source level) — the three pending maps hold in-flight promises. When two simultaneous engine runs request the same source key, the second run waits for the first run's promise instead of starting a duplicate request. The pending entry is deleted once the promise settles.
In-flight deduplication (engine level) — runRulesFeedEngine deduplicates at the call level. If a second call arrives for the same channel + feedId while a run is in progress, the second caller gets the same Promise. Once the promise settles, the in-flight entry is removed so future calls start fresh.
usedApiKeys — a Set<string> local to each assembleProducts call. Prevents batch API products from being added more than once when multiple rules in the same feed share the same batch cache key. The first rule adds the bucket; later rules with the same key skip it.
Cache key format: {shopDomain.toLowerCase()}:{dyn_web|dyn_cart}:{sourceKey}[:noraw]
Product Assembly
After fetching, assembleProducts builds the final product list from plannedRules in order:
- Apply customization metadata (
title,subtitle,hide-widget) to the sharedcustomizationobject. Later rules overwrite earlier values. - Find product source returns.
product-priceis only used when no other product return exists on the rule. - For each API-backed return: if the rule's
apiSourceKeyhas not been used yet, add the full API product bucket and mark it used. Skip the bucket on all later rules sharing the same key. - For each local return (
specific-product,shopify-recommendation,currentPageProduct):specific-product— filters assembled variants to the variant ids listed inreturn.value.shopify-recommendation— appliesexclude-specific-productreturn exclusions engine-side.- All local sources — apply compiled feed exclusions and
filter_input_productviaapplyCompiledProductFilters.
- Collect products from all buckets in order, stopping at the product limit. Default is
10; overridden by aproduct-limitreturn. - Attach
ruleId,feedId,discount, andproductDiscountto each product. - Append to the global product list.
After all rules: dedupeProducts removes duplicates by normalized product_id. First occurrence wins; products without a usable id are dropped.
Exclusion Handling
Feed-level exclusions are applied differently depending on the return source type:
| Source type | Where exclusions are applied |
|---|---|
| API-backed returns (dynamatic-recommendation, products-with-tags, specific-collection-products, metafield_recommendation, product-price) | Sent to the Dynamatic backend inside ruleData.exclusions. Not applied engine-side. |
| Local returns (specific-product, shopify-recommendation, currentPageProduct) | Applied engine-side via applyCompiledProductFilters. |
Exclusion shape:
interface RulesFeedExclusion {
filterType?: 'products' | 'collections' | 'tags' | string;
products?: Array<{ product_id: string | number; product_handle: string }>;
collections?: RulesFeedCollection[];
tags?: string[];
}| filterType | Engine-side behavior (local sources) |
|---|---|
| 'products' | Excludes products by normalized product_id |
| 'collections' | Excludes products whose collections match any excluded collection handle or id |
| 'tags' | Excludes products with any tag matching an excluded tag (case-insensitive) |
| Other / missing | No effect |
Collection exclusions also affect the Storefront GraphQL query: collections is only fetched for feeds that define collection exclusions. The request key includes :nc for feeds without them to keep the slimmer cached result separate.
filter_input_product: true additionally excludes, from local sources, any product whose id or handle appears in a products IF logic node within the feed's rules.
Output Shape
RulesFeedEngineResult
interface RulesFeedEngineResult {
isActive: boolean; // true when products.length > 0
products: RulesFeedProduct[]; // deduped, with ruleId/feedId/discount attached
customization: {
title?: string | null;
subtitle?: string | null;
hideWidget?: boolean;
};
passedRules: PassedRule[];
diagnostics?: RulesFeedDiagnostics; // only when config.includeDiagnostics is true
}Cart adapter output (toCartRulesFeedOutput)
interface CartRulesFeedOutput {
isActive: boolean;
products: Array<{
product_handle?: string;
product_variants: Array<{ variant_id: string | number }>;
ruleId?: string | number;
feedId?: string | number;
productDiscount?: string;
}>;
title?: string | null;
subtitle?: string | null;
hideWidget?: boolean;
}title, subtitle, and hideWidget are only included when truthy.
Backward Compatibility
All fields added after the initial release have safe defaults. Old feeds require no migration:
| Missing field | Behavior |
|---|---|
| rule.returnOrdering | 'shuffle' sent to Dynamatic API |
| return.mode | 'include' sent to Dynamatic API |
| return.filters | Field omitted from JSON payload |
| feed.exclusions | exclusions: [] sent in ruleData |
| config.includeRawProducts | true (raw API payload attached to each product) |
| runtime.metadata | Omitted from error reports |
| runtime.vehicleCodes | [] — dynamatic-metafield-recommendation not sent to the API |
Error Reporting
The engine automatically reports API failures to the Dynamatic error log endpoint (https://dev.dynamatics.app/api/dynamatic/cart/error-log). No extra error handling code is required.
What gets reported
An error report is sent when a network request fails and is not a timeout or a cancelled request (those are expected during navigation). This covers:
- HTTP error responses (4xx, 5xx)
- Network or connectivity failures
- Empty responses from the Dynamatic API (flat array with zero items)
Shopify-side errors (4xx responses and JSON parse failures from the Storefront API) are detected and not forwarded to the error reporter, since these indicate configuration issues rather than infrastructure failures.
Rate limiting
Reports are rate-limited to one report per error class per source per shop every 5 minutes. Cooldown timestamps are persisted in localStorage under the key rfe_err_cooldowns. Error classes:
| Class | Covers |
|---|---|
| 5xx | Server errors (http-500, http-502, etc.) |
| 4xx | Client errors (http-400, http-422, etc.) |
| network | Network failures, CORS blocks |
What each report contains
{
"shop_url": "example.myshopify.com",
"errors": {
"errorType": "Rules Feed Engine API Error",
"timestamp": "2026-06-24T10:00:00.000Z",
"channel": "cart",
"source": "dynamatic-recommendation",
"status": 502,
"reason": "http-502: Bad Gateway",
"url": "https://recov2.dynamatics.app/v2/api/recommendations",
"feedId": 509,
"ruleId": "708f93c7-...",
"ruleLabel": "Rule 1 (708f93c7-...)",
"metadata": {
"widget_id": 6595,
"campaign_id": 2339
},
"requestBody": { "lineitems": [], "cartData": { "..." : "..." }, "ruleData": { "...": "..." } },
"responseBody": "Bad Gateway",
"curl": "curl -X POST 'https://recov2.dynamatics.app/v2/api/recommendations?dyn_cart' -H 'content-type: application/json' -H 'x-public-key: ...' -H 'x-shop-domain: example.myshopify.com' -d '{...}'",
"shopify": { "shop": "example.myshopify.com" },
"page": { "url": "https://...", "pathname": "/cart" }
}
}Key fields:
| Field | Description |
|---|---|
| status | HTTP status code (e.g. 502). Absent for network-level failures. |
| reason | Human-readable failure reason (e.g. http-502: Bad Gateway). |
| responseBody | Raw response text from the server, up to 1000 characters. |
| requestBody | The exact JSON body (or query params for GET) that was sent when the error occurred. |
| curl | A ready-to-run single-line curl command to reproduce the failing request. |
| metadata | The runtime.metadata object passed by the channel. |
Passing metadata from your channel
// Webpage channel
runtime: {
channel: 'webpage',
metadata: {
widget_id: property?.id,
experience_id: property?.experienceId,
variation_id: property?.variationId,
audience_id: property?.audienceId,
campaign_id: property?.campaignId,
zone_id: property?.zoneId,
widget_template: property?.templateId,
},
}
// Cart channel
runtime: {
channel: 'cart',
metadata: {
widget_id: widget?.id,
experience_id: widget?.experience_id,
variation_id: widget?.variation_id,
campaign_id: widget?.campaign_id,
zone_id: widget?.zone_id,
},
}Diagnostics
Enable with config: { includeDiagnostics: true }.
interface RulesFeedDiagnostics {
matchedRules: Array<{ feedId?: string | number; ruleId?: string | number; ruleName?: string }>;
skippedRules: Array<{ feedId?: string | number; ruleId?: string | number; reason: string }>;
fetchPlan: Array<{ source: string; key: string; deduped: boolean }>;
failedSources: Array<{ source: string; key: string; reason: string; timedOut?: boolean; aborted?: boolean }>;
timings: Record<string, number>;
fallbacks: Array<{ source: string; fallback: string; reason: string }>;
validationWarnings: string[];
}matchedRules— every rule that passed IF evaluation, in evaluation order.skippedRules— rules skipped before evaluation, with reason (missing-rule-id,invalid-logic-type).fetchPlan— every source request that was planned. Entries after the first with the same batch key havededuped: true.failedSources— network errors, timeouts, aborts, or missing configuration. Failures are recorded here; they do not throw.timings— millisecond durations keyed byfetch:{sourceKey}andtotal.validationWarnings— emitted whenshopDomainorstorefrontAccessTokenare missing.
Diagnostics do not include raw cart contents, customer data, or API tokens.
Debug Logging
The engine emits detailed, color-coded browser console logs for every stage of the pipeline. Logs are gated behind localStorage.getItem('DYN_RULES_FEED_ENGINE_DEBUG') === 'true'.
Enabling debug logs
Open your browser's DevTools console and run:
localStorage.setItem('DYN_RULES_FEED_ENGINE_DEBUG', 'true')Then trigger the widget (navigate, reload, or add to cart). To turn off:
localStorage.removeItem('DYN_RULES_FEED_ENGINE_DEBUG')Always-on summary lines
Four summary lines always appear regardless of the debug flag. The prefix reflects the channel:
[DYNAMATIC WEBPAGE RULES FEED] — 10 products · 1 rules passed
[DYNAMATIC WEBPAGE RULES FEED] Final Products ▶ Array(10)
[DYNAMATIC WEBPAGE RULES FEED] Passed Rules ▶ Array(1)
[DYNAMATIC WEBPAGE RULES FEED] Customization ▶ Object {}Badge color reference
| Badge color | Label | Meaning |
|---|---|---|
| Cyan [ENGINE] | [ENGINE] | Pipeline milestones (validation, dedupe, timing) |
| Purple [RULE] | [RULE] | Rule being evaluated |
| Green [PASS] | [PASS] | Logic group passed / rule matched / products assembled |
| Red [SKIP] | [SKIP] | Logic group failed / rule skipped / invalid logic type |
| Amber [LOGIC] | [LOGIC] | Per-logic debug values |
| Sky [FETCH] | [FETCH] | Fetch plan, network requests, and cache events |
| Amber [WARN] | [WARN] | Non-fatal issue (null value, missing config, deduped products) |
Channel prefixes: [DYNAMATIC WEBPAGE RULES FEED] (purple) or [DYNAMATIC CART RULES FEED] (hot pink).
Key Types and Interfaces
All types are defined in src/types.ts and re-exported from the package root.
Input types: RulesFeedEngineInput, RulesFeedFeed, RulesFeedRule, RulesFeedReturn, RulesFeedReturnFilter, RulesFeedExclusion, RulesFeedContext, RulesFeedRuntime, RulesFeedEngineConfig, RulesFeedSourceCache
Product model: RulesFeedProduct, RulesFeedVariant, RulesFeedCollection, RulesFeedMetafield, RulesFeedCartItem, RulesFeedCart, RulesFeedCustomer, RulesFeedGeo
Pipeline internals: NormalizedContext, PassedRule, PlannedRuleReturn, ReturnPlan, EvaluationResult, SourceFetchRequest, SourceFetchResult, BatchRuleEntry, RuleReturnMeta
Output types: RulesFeedEngineResult, CartRulesFeedOutput, RulesFeedDiagnostics, RulesFeedCustomization
Union types: ReturnSourceKey, ReturnOrdering, RecommendationSeed
Public API Reference
All exports are from @abuhasanrumi/rules-feed-engine.
Functions
| Export | Signature | Description |
|---|---|---|
| runRulesFeedEngine | (input: RulesFeedEngineInput) => Promise<RulesFeedEngineResult> | Main entry point. Runs the full pipeline. |
| toCartRulesFeedOutput | (result: RulesFeedEngineResult) => CartRulesFeedOutput | Converts engine result to cart output shape. |
| evaluateRules | (feeds, context, baseDiagnostics?) => EvaluationResult | IF logic stage only. |
| planReturns | (passedRules, context, diagnostics, runtime?) => ReturnPlan | Return planning and batch grouping stage. |
| fetchSources | (requests, context, runtime, cache, diagnostics, signal?, config?) => Promise<SourceFetchResult[]> | Source fetching stage. |
| normalizeContext | (context, channel) => NormalizedContext | Context normalization. |
| recommendationSeedProductId | (context: NormalizedContext) => string | Returns the product id used for seeding recommendations. |
| validateInput | (input: RulesFeedEngineInput) => ValidatedInput | Applies defaults, normalizes feeds, initializes caches. |
| defaultConfig | (config?: RulesFeedEngineConfig) => Required<RulesFeedEngineConfig> | Returns config with all defaults filled in. |
| applyExclusions | (products, exclusions) => RulesFeedProduct[] | Applies an exclusion array to a product list. |
| dedupeProducts | (products: RulesFeedProduct[]) => RulesFeedProduct[] | Global dedupe by product id. |
| feedHasCollectionExclusions | (feed?: RulesFeedFeed) => boolean | Returns true when the feed defines at least one collection exclusion. |
| filterInputProducts | (products, rules, enabled) => RulesFeedProduct[] | Applies filter_input_product logic to a list. |
| getRequiredProductDetails | (feeds: RulesFeedFeed[]) => { requiresProductInfo: boolean; metafieldNamespace?: string; metafieldKey?: string } | Detects whether rules need pre-fetched product details. |
| evaluateLogic | (logic: RulesFeedLogic \| null \| undefined, context) => boolean | Evaluates a single logic node. Returns false for null or nameless logic. |
| checkArrayMatchCondition | (actual, expected, type) => boolean | Array comparison primitive. |
| checkNumericCondition | (actual, expected, type) => boolean | Numeric comparison primitive. |
| checkTextMatchCondition | (actual, expected, type) => boolean | Text comparison primitive. |
| selectedVariantIdsFromReturnValue | (value) => Set<string> | Extracts variant ids from a return value. |
| buildRuleReturnMeta | (returns) => RuleReturnMeta | Splits returns into product returns and metadata returns. |
| isApiReturn | (ret: RulesFeedReturn) => boolean | Returns true for API-backed return names. |
| isProductReturn | (ret: RulesFeedReturn) => boolean | Returns true for product-producing return names. |
| asSourceKey | (name?: string) => ReturnSourceKey \| null | Maps a normalized return name to its ReturnSourceKey. |
| normalizeReturnKey | (name?: string) => string | Applies return name normalization aliases. |
| specificProductKey | (ret: RulesFeedReturn) => string | Derives the cache key for a specific-product return. |
| specificProductRequestKey | (ret, feed?) => string | Derives the request key (includes :nc when feed has no collection exclusions). |
| shopifyRecommendationKey | (ret, context, runtime?, feed?) => string | Derives the cache key for a shopify-recommendation return. |
| currentPageKey | (context: NormalizedContext) => string | Derives the cache key for a currentPageProduct return. |
Constants
| Export | Type | Description |
|---|---|---|
| METADATA_RETURN_NAMES | Set<string> | The set of recognized metadata return names (product-limit, title, subtitle, hide-widget, product-discount, exclude-specific-product, shuffle). |
| PRODUCT_INFO_LOGIC_NAMES | Set<string> | Logic names that require pre-fetched product details (products, any-product-tags, each-product-tags, any-product-collection, each-product-collection, any-cart-inventory, each-cart-inventory, any-product-meta, each-product-meta). |
End-to-End Example
Two rules in one feed, both with API-backed returns, producing one batched Dynamatic API call.
import { runRulesFeedEngine, toCartRulesFeedOutput } from '@abuhasanrumi/rules-feed-engine';
const result = await runRulesFeedEngine({
runtime: {
channel: 'webpage',
shopDomain: 'example.myshopify.com',
storefrontAccessToken: 'public-storefront-token',
shopifyRoutesRoot: '/',
requestTimeoutMs: 8000,
metadata: {
widget_id: 6595,
experience_id: 4776,
variation_id: 4814,
campaign_id: 2339,
},
},
config: { includeDiagnostics: true },
context: {
inputProducts: [
{
product_id: 1,
product_handle: 'trigger-product',
product_title: 'Trigger Product',
tags: ['Starter'],
collections: [{ id: 111, handle: 'frontpage', title: 'Frontpage' }],
product_variants: [{ variant_id: 11, variant_title: 'Default' }],
totalInventory: 5,
},
],
currentPageProduct: {
product_id: 1,
product_handle: 'trigger-product',
product_variants: [{ variant_id: 11 }],
},
cart: { total_price: 2500, item_count: 1, currency: 'USD' },
customer: {},
geo: {},
url: 'https://example.myshopify.com/products/trigger-product',
},
cache: {},
feeds: [
{
id: 'feed-1',
rules: [],
exclusions: [{ filterType: 'tags', tags: ['hidden'] }],
filter_input_product: true,
recommendation_seed: 'currentPageProduct',
rules: [
{
id: 'rule-a',
name: 'Starter tag — general recommendations',
returnOrdering: 'shuffle',
logics: [
[{ name: 'any-product-tags', type: 'contains', value: ['Starter'] }],
],
returns: [
{ name: 'dynamatic-recommendation', type: null, value: 'surprise', mode: 'include' },
{ name: 'product-discount', type: 'percentage', value: 10 },
{ name: 'title', type: null, value: 'You may also like' },
{ name: 'product-limit', type: null, value: 5 },
],
},
{
id: 'rule-b',
name: 'Starter tag — specific collection',
isMatched: true,
logics: [
[{ name: 'any-product-tags', type: 'contains', value: ['Starter'] }],
],
returns: [
{
name: 'specific-collection-products',
type: 'specific-collection',
mode: 'include',
value: [{ id: '123', handle: 'sale' }],
filters: [
{ id: 'f1', name: 'product-price', type: 'less-than', value: '200' },
],
},
{ name: 'product-limit', type: null, value: 5 },
],
},
],
},
],
});
// Webpage: products array with ruleId, feedId, discount attached
console.log(result.products);
console.log(result.customization); // { title: 'You may also like' }
// Cart: compact handle + variant id shape
const cart = toCartRulesFeedOutput(result);What happens internally:
- Both rules pass because the input product has tag
Starter. rule-bhasisMatched: true— no further rules in this feed are evaluated after it passes.- Both rules' API-backed returns are grouped into one
SourceFetchRequestwithbatchRulescontaining both rule entries. - One POST is sent to
https://recov2.dynamatics.app/v2/api/recommendations?dyn_webwithruleData.rulescarrying both rules andruleData.exclusions: [{ filterType: 'tags', tags: ['hidden'] }]. - Assembly adds batch API products on first encounter (
rule-a).rule-b's bucket is skipped by theusedApiKeysguard. Product limit of 5 applies per rule. - Discount
'10%'is attached to products fromrule-a. filter_input_product: truemeans the trigger product (id1) is excluded from any local return results in this feed.- Final
dedupeProductsremoves any duplicates byproduct_id. - If any API call fails during fetching, an error report is automatically sent to the Dynamatic error log with the full request body, response, and a ready-to-run curl command.
Development
Requirements: Bun
bun install
bun run build # removes dist/ with node and compiles TypeScript via tsc
bun run test # runs bun run build first, then executes test/*.test.mjs
bun run typecheck # type-checks without emitting output (tsc --noEmit)The build script is bun run clean && tsc -p tsconfig.json. The clean script removes dist/ using node -e "require('fs').rmSync(...)". No Bun-specific build APIs are used — the compiler is standard tsc.
Publishing:
bun run build
npm publish --access publicThe files field in package.json restricts the published package to dist/ and README.md.
Version History
Based on recent git log:
| Version | Commit | Summary |
|---|---|---|
| 1.1.5 | 2eb9e19 | Version bump |
| 1.1.4 | 9d60427 | Fix: metafield-recommendation was always sent in published 1.1.3 dist |
| 1.1.3 | 8d3df68 | Version bump |
| 1.1.2 | e912d15 | Version bump |
| 1.1.1 | d3f14cf | Enhanced error reporting with Shopify error detection |
| 1.1.0 | a8aa4b6 | Version bump |
| — | 5b6b0d0 | Fix: updated vehicleCodes condition and removed vehicleCodesEnabled flag from RulesFeedRuntime |
| — | 235e2be | Feat: enhanced vehicle code handling for dynamatic-metafield-recommendation; added vehicleCodesEnabled flag (later removed) |
| — | 64d59b4 | Simplified payload assignment for dynamatic-metafield-recommendation; added fallback for missing vehicle codes |
| — | 32cc1e2 | Updated README to clarify engine capabilities and error reporting details |
