Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 109 additions & 8 deletions packages/data/src/data-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
VariationStatuses,
VariationAllocation,
eventType,
GenericListMatchingOptions
GenericListMatchingOptions,
RuleObjectAudience
} from '@convertcom/js-sdk-types';

import {
Expand All @@ -54,6 +55,7 @@
DATA_ENTITIES_MAP,
ERROR_MESSAGES,
MESSAGES,
MutualExclusionRuleType,
RuleError,
SegmentsKeys,
SystemEvents,
Expand All @@ -62,6 +64,23 @@

import {DataStoreManager} from './data-store-manager';
const LOCAL_STORE_LIMIT = 10000;

/**
* qs-03 / SDK-3: minimal shape of a single `bucketed_into_experience_key`
* mutual-exclusion rule element, covering only the fields `_resolveBucketingExclusion`
* reads. NOT part of the generated `RuleElement` union (`packages/types/src/config` is
* auto-generated from the OpenAPI spec and must never be hand-edited to add this rule
* type) -- rule items are matched against this local interface instead.
*/
interface BucketingExclusionRuleItem {
rule_type: string;
value: string;
matching?: {
match_type?: string;
negated?: boolean;
};
}

/**
* Provides logic for data. Stores bucket with help of dataStore if it's provided
* @category Modules
Expand Down Expand Up @@ -381,7 +400,8 @@
audiencesToCheck,
visitorProperties,
'audience',
identityField
identityField,
visitorId
);
// Return rule errors if present
matchedErrors = matchedAudiences.filter((match) =>
Expand Down Expand Up @@ -1213,17 +1233,93 @@
return true;
}

/**
* qs-03 / SDK-3: walk an audience's rule tree (same OR -> AND -> OR_WHEN traversal
* shape as `RuleManager.isRuleMatched`) looking for a `bucketed_into_experience_key`
* mutual-exclusion rule element. Documented assumption: at most one such rule per
* exclusion audience -- returns the first one found, or `null` if the audience
* carries no mutual-exclusion rule (the generic rule-matching path applies instead).
* @param {RuleObjectAudience} rules
* @return {BucketingExclusionRuleItem | null}
* @private
*/
private _isBucketingExclusionRule(

Check failure on line 1246 in packages/data/src/data-manager.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=convertcom_javascript-sdk&issues=AZ9BsKEftC2Iz5tH5Frv&open=AZ9BsKEftC2Iz5tH5Frv&pullRequest=416
rules: RuleObjectAudience
): BucketingExclusionRuleItem | null {
if (!arrayNotEmpty(rules?.OR)) return null;
for (const andBlock of rules.OR) {
if (!arrayNotEmpty(andBlock?.AND)) continue;
for (const orWhenBlock of andBlock.AND) {
if (!arrayNotEmpty(orWhenBlock?.OR_WHEN)) continue;
for (const ruleItem of orWhenBlock.OR_WHEN) {
if (
(ruleItem as unknown as BucketingExclusionRuleItem)?.rule_type ===
MutualExclusionRuleType.BUCKETED_INTO_EXPERIENCE_KEY
) {
return ruleItem as unknown as BucketingExclusionRuleItem;
}
}
}
}
return null;
}

/**
* qs-03 / SDK-3: presence-only resolution of a `bucketed_into_experience_key`
* mutual-exclusion rule. Read-only -- resolves the rule's target experience by KEY
* via `getEntity` and checks ONLY whether the visitor's stored bucketing map
* (`getData().bucketing`) already carries an entry keyed by the target's id; it
* NEVER calls `retrieveVariation`/the BucketingManager, so evaluating this rule can
* never bucket the target experience as a side effect (AC5). If the target key does
* not resolve in the served config, warns naming the unresolved key and treats the
* visitor as not bucketed into it (`bucketedRaw = false`) rather than failing the
* whole audience evaluation. Negation is applied LAST, after the raw presence check.
* @param {BucketingExclusionRuleItem} rule
* @param {string} visitorId
* @return {boolean}
* @private
*/
private _resolveBucketingExclusion(
rule: BucketingExclusionRuleItem,
visitorId: string
): boolean {
const target = this.getEntity(
rule.value,
'experiences'
) as ConfigExperience;
let bucketedRaw = false;
if (!target) {
this._loggerManager?.warn?.(
'DataManager._resolveBucketingExclusion()',
ERROR_MESSAGES.BUCKETING_EXCLUSION_TARGET_NOT_FOUND.replace(
'#',
rule.value
)
);
} else {
const {bucketing} = this.getData(visitorId) || {};
bucketedRaw = Boolean((bucketing || {})[String(target.id)]);

Check warning on line 1301 in packages/data/src/data-manager.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=convertcom_javascript-sdk&issues=AZ9BsKEftC2Iz5tH5Frw&open=AZ9BsKEftC2Iz5tH5Frw&pullRequest=416
}
return rule.matching?.negated ? !bucketedRaw : bucketedRaw;
}
Comment thread
abbaseya marked this conversation as resolved.

/**
* Get audiences that meet the visitorProperties
* @param {Array<Record<any, any>>} items
* @param {Record<string, any>} visitorProperties
* @param {string} entityType
* @param {IdentityField=} field Defaults to 'id'
* @param {string=} visitorId Required only when an item's rules resolve to a
* `bucketed_into_experience_key` mutual-exclusion rule (qs-03 / SDK-3); unused by
* the generic rule-matching path.
* @return {Array<Record<string, any> | RuleError>}
*/
filterMatchedRecordsWithRule(
items: Array<Record<string, any>>,
visitorProperties: Record<string, any>,
entityType: string,
field: IdentityField = 'id'
field: IdentityField = 'id',
visitorId?: string
): Array<Record<string, any> | RuleError> {
this._loggerManager?.trace?.(
'DataManager.filterMatchedRecordsWithRule()',
Expand All @@ -1237,11 +1333,16 @@
if (arrayNotEmpty(items)) {
for (let i = 0, length = items.length; i < length; i++) {
if (!items?.[i]?.rules) continue;
match = this._ruleManager.isRuleMatched(
visitorProperties,
items[i].rules,
`${camelCase(entityType)} #${items[i][field]}`
);
const exclusionRule = this._isBucketingExclusionRule(items[i].rules);
if (exclusionRule) {
match = this._resolveBucketingExclusion(exclusionRule, visitorId);
} else {
match = this._ruleManager.isRuleMatched(
visitorProperties,
items[i].rules,
`${camelCase(entityType)} #${items[i][field]}`
);
}
if (match === true) {
matchedRecords.push(items[i]);
} else if (match !== false) {
Expand Down
138 changes: 138 additions & 0 deletions packages/data/tests/mutual-exclusion-rule-fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
{
"specRef": "_bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md#inline-cross-sdk-fixture",
"description": "qs-03 mutual-exclusion audience rule (bucketed_into_experience_key) cross-SDK fixture. The 8 rows and their expected values are the normative contract, identical across every sibling SDK spec (JS/PHP/Python/Ruby/Android/iOS). Consumed verbatim by packages/rules/tests, packages/data/tests, and packages/js-sdk/tests/browser via a relative filesystem read -- never duplicated. Changing an expected value here is a cross-SDK contract change, not a local test tweak.",
"config": {
"experiences": [
{
"id": "100111",
"name": "exp-a",
"key": "exp-a",
"type": "a/b_fullstack",
"status": "active",
"audiences": [],
"goals": [],
"variations": [
{
"id": "100901",
"name": "exp-a variation",
"status": "running",
"is_baseline": true
}
]
},
{
"id": "100222",
"name": "exp-b",
"key": "exp-b",
"type": "a/b_fullstack",
"status": "active",
"audiences": [],
"goals": [],
"variations": [
{
"id": "100902",
"name": "exp-b variation",
"status": "running",
"is_baseline": true
}
]
}
]
},
"ruleDefaults": {
"rule_type": "bucketed_into_experience_key",
"matching": {
"match_type": "equals"
}
},
"rows": [
{
"row": 1,
"description": "empty stored bucketing, non-negated, target exp-a -> visitor not in exp-a -> not matched",
"storedBucketing": {},
"dataStoreOnly": false,
"ruleValue": "exp-a",
"negated": false,
"expectedMatched": false,
"expectWarn": false
},
{
"row": 2,
"description": "empty stored bucketing, negated, target exp-a -> visitor not in exp-a -> NOT-in-exp-a matches",
"storedBucketing": {},
"dataStoreOnly": false,
"ruleValue": "exp-a",
"negated": true,
"expectedMatched": true,
"expectWarn": false
},
{
"row": 3,
"description": "stored bucketing has exp-a's variation (in memory), non-negated, target exp-a -> matched",
"storedBucketing": {
"100111": "100901"
},
"dataStoreOnly": false,
"ruleValue": "exp-a",
"negated": false,
"expectedMatched": true,
"expectWarn": false
},
{
"row": 4,
"description": "stored bucketing has exp-a's variation (in memory), negated, target exp-a -> visitor IS in exp-a -> NOT-in-exp-a does not match",
"storedBucketing": {
"100111": "100901"
},
"dataStoreOnly": false,
"ruleValue": "exp-a",
"negated": true,
"expectedMatched": false,
"expectWarn": false
},
{
"row": 5,
"description": "stored bucketing has exp-b's variation only (in memory), negated, target exp-a -> visitor not in exp-a -> NOT-in-exp-a matches",
"storedBucketing": {
"100222": "100902"
},
"dataStoreOnly": false,
"ruleValue": "exp-a",
"negated": true,
"expectedMatched": true,
"expectWarn": false
},
{
"row": 6,
"description": "empty stored bucketing, non-negated, target exp-zz absent from config -> bucketedRaw resolves false -> not matched, warns naming the unresolved key",
"storedBucketing": {},
"dataStoreOnly": false,
"ruleValue": "exp-zz",
"negated": false,
"expectedMatched": false,
"expectWarn": true
},
{
"row": 7,
"description": "empty stored bucketing, negated, target exp-zz absent from config -> bucketedRaw resolves false -> NOT-in-exp-zz matches, warns naming the unresolved key",
"storedBucketing": {},
"dataStoreOnly": false,
"ruleValue": "exp-zz",
"negated": true,
"expectedMatched": true,
"expectWarn": true
},
{
"row": 8,
"description": "exp-a's variation stored ONLY in the configured DataStore (never written to the in-memory visitor store), negated, target exp-a -> getData() merges the DataStore in -> visitor IS in exp-a -> NOT-in-exp-a does not match",
"storedBucketing": {
"100111": "100901"
},
"dataStoreOnly": true,
"ruleValue": "exp-a",
"negated": true,
"expectedMatched": false,
"expectWarn": false
}
]
}
Loading