flagsmith-nodejs 6.2.0 → 7.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.github/workflows/publish.yml +8 -5
  2. package/.github/workflows/pull_request.yaml +3 -0
  3. package/.gitmodules +1 -1
  4. package/.husky/pre-commit +1 -0
  5. package/.nvmrc +1 -0
  6. package/.release-please-manifest.json +1 -1
  7. package/CHANGELOG.md +49 -0
  8. package/build/cjs/flagsmith-engine/environments/models.d.ts +2 -1
  9. package/build/cjs/flagsmith-engine/environments/models.js +3 -1
  10. package/build/cjs/flagsmith-engine/environments/util.js +1 -1
  11. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/evaluationContext.types.d.ts +230 -0
  12. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/evaluationContext.types.js +8 -0
  13. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/mappers.d.ts +5 -0
  14. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/mappers.js +156 -0
  15. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/types.d.ts +216 -0
  16. package/build/cjs/flagsmith-engine/evaluation/evaluationContext/types.js +8 -0
  17. package/build/cjs/flagsmith-engine/evaluation/evaluationResult/evaluationResult.types.d.ts +68 -0
  18. package/build/cjs/flagsmith-engine/evaluation/evaluationResult/evaluationResult.types.js +8 -0
  19. package/build/cjs/flagsmith-engine/evaluation/models.d.ts +50 -0
  20. package/build/cjs/flagsmith-engine/evaluation/models.js +26 -0
  21. package/build/cjs/flagsmith-engine/features/models.js +1 -1
  22. package/build/cjs/flagsmith-engine/features/types.d.ts +5 -0
  23. package/build/cjs/flagsmith-engine/features/types.js +9 -0
  24. package/build/cjs/flagsmith-engine/features/util.d.ts +1 -0
  25. package/build/cjs/flagsmith-engine/features/util.js +5 -1
  26. package/build/cjs/flagsmith-engine/index.d.ts +61 -9
  27. package/build/cjs/flagsmith-engine/index.js +176 -56
  28. package/build/cjs/flagsmith-engine/segments/constants.d.ts +1 -0
  29. package/build/cjs/flagsmith-engine/segments/constants.js +2 -1
  30. package/build/cjs/flagsmith-engine/segments/evaluators.d.ts +41 -7
  31. package/build/cjs/flagsmith-engine/segments/evaluators.js +136 -24
  32. package/build/cjs/flagsmith-engine/segments/models.d.ts +9 -4
  33. package/build/cjs/flagsmith-engine/segments/models.js +115 -13
  34. package/build/cjs/flagsmith-engine/utils/hashing/index.d.ts +1 -1
  35. package/build/cjs/flagsmith-engine/utils/hashing/index.js +4 -4
  36. package/build/cjs/sdk/index.d.ts +1 -3
  37. package/build/cjs/sdk/index.js +22 -19
  38. package/build/cjs/sdk/models.d.ts +8 -1
  39. package/build/cjs/sdk/models.js +29 -1
  40. package/build/esm/flagsmith-engine/environments/models.d.ts +2 -1
  41. package/build/esm/flagsmith-engine/environments/models.js +3 -1
  42. package/build/esm/flagsmith-engine/environments/util.js +1 -1
  43. package/build/esm/flagsmith-engine/evaluation/evaluationContext/evaluationContext.types.d.ts +230 -0
  44. package/build/esm/flagsmith-engine/evaluation/evaluationContext/evaluationContext.types.js +7 -0
  45. package/build/esm/flagsmith-engine/evaluation/evaluationContext/mappers.d.ts +5 -0
  46. package/build/esm/flagsmith-engine/evaluation/evaluationContext/mappers.js +152 -0
  47. package/build/esm/flagsmith-engine/evaluation/evaluationContext/types.d.ts +216 -0
  48. package/build/esm/flagsmith-engine/evaluation/evaluationContext/types.js +7 -0
  49. package/build/esm/flagsmith-engine/evaluation/evaluationResult/evaluationResult.types.d.ts +68 -0
  50. package/build/esm/flagsmith-engine/evaluation/evaluationResult/evaluationResult.types.js +7 -0
  51. package/build/esm/flagsmith-engine/evaluation/models.d.ts +50 -0
  52. package/build/esm/flagsmith-engine/evaluation/models.js +9 -0
  53. package/build/esm/flagsmith-engine/features/models.js +2 -2
  54. package/build/esm/flagsmith-engine/features/types.d.ts +5 -0
  55. package/build/esm/flagsmith-engine/features/types.js +6 -0
  56. package/build/esm/flagsmith-engine/features/util.d.ts +1 -0
  57. package/build/esm/flagsmith-engine/features/util.js +3 -0
  58. package/build/esm/flagsmith-engine/index.d.ts +61 -9
  59. package/build/esm/flagsmith-engine/index.js +161 -43
  60. package/build/esm/flagsmith-engine/segments/constants.d.ts +1 -0
  61. package/build/esm/flagsmith-engine/segments/constants.js +1 -0
  62. package/build/esm/flagsmith-engine/segments/evaluators.d.ts +41 -7
  63. package/build/esm/flagsmith-engine/segments/evaluators.js +137 -25
  64. package/build/esm/flagsmith-engine/segments/models.d.ts +9 -4
  65. package/build/esm/flagsmith-engine/segments/models.js +115 -13
  66. package/build/esm/flagsmith-engine/utils/hashing/index.d.ts +1 -1
  67. package/build/esm/flagsmith-engine/utils/hashing/index.js +2 -2
  68. package/build/esm/sdk/index.d.ts +1 -3
  69. package/build/esm/sdk/index.js +21 -18
  70. package/build/esm/sdk/models.d.ts +8 -1
  71. package/build/esm/sdk/models.js +29 -1
  72. package/flagsmith-engine/environments/models.ts +3 -1
  73. package/flagsmith-engine/environments/util.ts +2 -1
  74. package/flagsmith-engine/evaluation/evaluationContext/evaluationContext.types.ts +247 -0
  75. package/flagsmith-engine/evaluation/evaluationContext/mappers.ts +204 -0
  76. package/flagsmith-engine/evaluation/evaluationContext/types.ts +233 -0
  77. package/flagsmith-engine/evaluation/evaluationResult/evaluationResult.types.ts +71 -0
  78. package/flagsmith-engine/evaluation/models.ts +96 -0
  79. package/flagsmith-engine/features/models.ts +3 -2
  80. package/flagsmith-engine/features/types.ts +5 -0
  81. package/flagsmith-engine/features/util.ts +4 -0
  82. package/flagsmith-engine/index.ts +229 -72
  83. package/flagsmith-engine/segments/constants.ts +1 -0
  84. package/flagsmith-engine/segments/evaluators.ts +178 -62
  85. package/flagsmith-engine/segments/models.ts +171 -23
  86. package/flagsmith-engine/utils/hashing/index.ts +2 -2
  87. package/package.json +13 -2
  88. package/sdk/index.ts +36 -23
  89. package/sdk/models.ts +44 -2
  90. package/tests/engine/e2e/engine.test.ts +43 -38
  91. package/tests/engine/unit/engine.test.ts +306 -59
  92. package/tests/engine/unit/mappers.test.ts +353 -0
  93. package/tests/engine/unit/segments/segment_evaluators.test.ts +391 -49
  94. package/tests/engine/unit/segments/segments_model.test.ts +85 -0
  95. package/tests/engine/unit/utils/utils.test.ts +7 -7
  96. package/tests/engine/unit/utils.ts +1 -1
  97. package/tests/sdk/data/environment.json +1 -0
  98. package/tests/sdk/flagsmith.test.ts +29 -3
  99. package/tests/sdk/offline-handlers.test.ts +3 -1
  100. package/vitest.config.esm.ts +34 -0
@@ -1,102 +1,259 @@
1
- import { EnvironmentModel } from './environments/models.js';
2
- import { FeatureStateModel } from './features/models.js';
3
- import { IdentityModel } from './identities/models.js';
4
- import { TraitModel } from './identities/traits/models.js';
1
+ import {
2
+ EvaluationContextWithMetadata,
3
+ EvaluationResultSegments,
4
+ EvaluationResultWithMetadata,
5
+ FeatureContextWithMetadata,
6
+ SDKFeatureMetadata,
7
+ FlagResultWithMetadata,
8
+ GenericEvaluationContext
9
+ } from './evaluation/models.js';
5
10
  import { getIdentitySegments } from './segments/evaluators.js';
6
- import { SegmentModel } from './segments/models.js';
7
- import { FeatureStateNotFound } from './utils/errors.js';
8
-
11
+ import { EvaluationResultFlags } from './evaluation/models.js';
12
+ import { TARGETING_REASONS } from './features/types.js';
13
+ import { getHashedPercentageForObjIds } from './utils/hashing/index.js';
9
14
  export { EnvironmentModel } from './environments/models.js';
10
- export { FeatureModel, FeatureStateModel } from './features/models.js';
11
15
  export { IdentityModel } from './identities/models.js';
12
16
  export { TraitModel } from './identities/traits/models.js';
13
17
  export { SegmentModel } from './segments/models.js';
18
+ export { FeatureModel, FeatureStateModel } from './features/models.js';
14
19
  export { OrganisationModel } from './organisations/models.js';
15
20
 
16
- function getIdentityFeatureStatesDict(
17
- environment: EnvironmentModel,
18
- identity: IdentityModel,
19
- overrideTraits?: TraitModel[]
20
- ) {
21
- // Get feature states from the environment
22
- const featureStates: { [key: number]: FeatureStateModel } = {};
23
- for (const fs of environment.featureStates) {
24
- featureStates[fs.feature.id] = fs;
25
- }
21
+ type SegmentOverride = {
22
+ feature: FeatureContextWithMetadata<SDKFeatureMetadata>;
23
+ segmentName: string;
24
+ };
26
25
 
27
- // Override with any feature states defined by matching segments
28
- const identitySegments: SegmentModel[] = getIdentitySegments(
29
- environment,
30
- identity,
31
- overrideTraits
32
- );
33
- for (const matchingSegment of identitySegments) {
34
- for (const featureState of matchingSegment.featureStates) {
35
- if (featureStates[featureState.feature.id]) {
36
- if (featureStates[featureState.feature.id].isHigherSegmentPriority(featureState)) {
37
- continue;
38
- }
26
+ export type SegmentOverrides = Record<string, SegmentOverride>;
27
+
28
+ /**
29
+ * Evaluates flags and segments for the given context.
30
+ *
31
+ * This is the main entry point for the evaluation engine. It processes segments,
32
+ * applies feature overrides based on segment priority, and returns the final flag states with
33
+ * evaluation reasons.
34
+ *
35
+ * @param context - EvaluationContext containing environment, identity, and segment data
36
+ * @returns EvaluationResult with flags, segments, and original context
37
+ */
38
+ export function getEvaluationResult(
39
+ context: EvaluationContextWithMetadata
40
+ ): EvaluationResultWithMetadata {
41
+ const enrichedContext = getEnrichedContext(context);
42
+ const { segments, segmentOverrides } = evaluateSegments(enrichedContext);
43
+ const flags = evaluateFeatures(enrichedContext, segmentOverrides);
44
+
45
+ return { flags, segments };
46
+ }
47
+
48
+ function getEnrichedContext(context: EvaluationContextWithMetadata): EvaluationContextWithMetadata {
49
+ const identityKey = getIdentityKey(context);
50
+ if (!identityKey) return context;
51
+
52
+ return {
53
+ ...context,
54
+ ...(context.identity && {
55
+ identity: {
56
+ identifier: context.identity.identifier,
57
+ key: identityKey,
58
+ traits: context.identity.traits || {}
39
59
  }
40
- featureStates[featureState.feature.id] = featureState;
41
- }
60
+ })
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Evaluates which segments the identity belongs to and collects feature overrides.
66
+ *
67
+ * @param context - EvaluationContext containing identity and segment definitions
68
+ * @returns Object containing segments the identity belongs to and any feature overrides
69
+ */
70
+ export function evaluateSegments(context: EvaluationContextWithMetadata): {
71
+ segments: EvaluationResultSegments;
72
+ segmentOverrides: Record<string, SegmentOverride>;
73
+ } {
74
+ if (!context.identity || !context.segments) {
75
+ return {
76
+ segments: [],
77
+ segmentOverrides: {} as Record<string, SegmentOverride>
78
+ };
42
79
  }
80
+ const identitySegments = getIdentitySegments(context);
81
+
82
+ const segments = identitySegments.map(segment => ({
83
+ name: segment.name,
84
+ ...(segment.metadata
85
+ ? {
86
+ metadata: {
87
+ ...segment.metadata
88
+ }
89
+ }
90
+ : {})
91
+ })) as EvaluationResultSegments;
92
+ const segmentOverrides = processSegmentOverrides(identitySegments);
93
+
94
+ return { segments, segmentOverrides };
95
+ }
96
+
97
+ /**
98
+ * Processes feature overrides from segments, applying priority rules.
99
+ *
100
+ * When multiple segments override the same feature, the segment with
101
+ * higher priority (lower numeric value) takes precedence.
102
+ *
103
+ * @param identitySegments - Segments that the identity belongs to
104
+ * @returns Map of feature keys to their highest-priority segment overrides
105
+ */
106
+ export function processSegmentOverrides(identitySegments: any[]): Record<string, SegmentOverride> {
107
+ const segmentOverrides: Record<string, SegmentOverride> = {};
108
+
109
+ for (const segment of identitySegments) {
110
+ if (!segment.overrides) continue;
43
111
 
44
- // Override with any feature states defined directly the identity
45
- for (const fs of identity.identityFeatures) {
46
- if (featureStates[fs.feature.id]) {
47
- featureStates[fs.feature.id] = fs;
112
+ const overridesList = Array.isArray(segment.overrides) ? segment.overrides : [];
113
+
114
+ for (const override of overridesList) {
115
+ if (shouldApplyOverride(override, segmentOverrides)) {
116
+ segmentOverrides[override.name] = {
117
+ feature: override,
118
+ segmentName: segment.name
119
+ };
120
+ }
48
121
  }
49
122
  }
50
- return featureStates;
123
+
124
+ return segmentOverrides;
51
125
  }
52
126
 
53
- export function getIdentityFeatureState(
54
- environment: EnvironmentModel,
55
- identity: IdentityModel,
56
- featureName: string,
57
- overrideTraits?: TraitModel[]
58
- ): FeatureStateModel {
59
- const featureStates = getIdentityFeatureStatesDict(environment, identity, overrideTraits);
127
+ /**
128
+ * Evaluates all features in the context, applying segment overrides where applicable.
129
+ * For each feature:
130
+ * - Checks if a segment override exists
131
+ * - Uses override values if present, otherwise evaluates the base feature
132
+ * - Determines appropriate evaluation reason
133
+ * - Handles multivariate evaluation for features without overrides
134
+ *
135
+ * @param context - EvaluationContext containing features and identity
136
+ * @param segmentOverrides - Map of feature keys to their segment overrides
137
+ * @returns EvaluationResultFlags containing evaluated flag results
138
+ */
139
+ export function evaluateFeatures(
140
+ context: EvaluationContextWithMetadata,
141
+ segmentOverrides: Record<string, SegmentOverride>
142
+ ): EvaluationResultFlags<SDKFeatureMetadata> {
143
+ const flags: EvaluationResultFlags<SDKFeatureMetadata> = {};
60
144
 
61
- const matchingFeature = Object.values(featureStates).filter(
62
- f => f.feature.name === featureName
63
- );
145
+ for (const feature of Object.values(context.features || {})) {
146
+ const segmentOverride = segmentOverrides[feature.name];
147
+ const finalFeature = segmentOverride ? segmentOverride.feature : feature;
148
+
149
+ const { value: evaluatedValue, reason: evaluatedReason } = evaluateFeatureValue(
150
+ finalFeature,
151
+ getIdentityKey(context)
152
+ );
64
153
 
65
- if (matchingFeature.length === 0) {
66
- throw new FeatureStateNotFound('Feature State Not Found');
154
+ flags[finalFeature.name] = {
155
+ name: finalFeature.name,
156
+ enabled: finalFeature.enabled,
157
+ value: evaluatedValue,
158
+ ...(finalFeature.metadata ? { metadata: finalFeature.metadata } : {}),
159
+ reason:
160
+ evaluatedReason ??
161
+ getTargetingMatchReason({ type: 'SEGMENT', override: segmentOverride })
162
+ } as FlagResultWithMetadata<SDKFeatureMetadata>;
67
163
  }
68
164
 
69
- return matchingFeature[0];
165
+ return flags;
70
166
  }
71
167
 
72
- export function getIdentityFeatureStates(
73
- environment: EnvironmentModel,
74
- identity: IdentityModel,
75
- overrideTraits?: TraitModel[]
76
- ): FeatureStateModel[] {
77
- const featureStates = Object.values(
78
- getIdentityFeatureStatesDict(environment, identity, overrideTraits)
79
- );
80
-
81
- if (environment.project.hideDisabledFlags) {
82
- return featureStates.filter(fs => !!fs.enabled);
168
+ function evaluateFeatureValue(
169
+ feature: FeatureContextWithMetadata,
170
+ identityKey?: string
171
+ ): { value: any; reason?: string } {
172
+ if (!!feature.variants && feature.variants.length > 0 && !!identityKey) {
173
+ return getMultivariateFeatureValue(feature, identityKey);
83
174
  }
84
- return featureStates;
175
+
176
+ return { value: feature.value, reason: undefined };
85
177
  }
86
178
 
87
- export function getEnvironmentFeatureState(environment: EnvironmentModel, featureName: string) {
88
- const featuresStates = environment.featureStates.filter(f => f.feature.name === featureName);
179
+ /**
180
+ * Evaluates a multivariate feature flag to determine which variant value to return for a given identity.
181
+ *
182
+ * Uses deterministic hashing to ensure the same identity always receives the same variant,
183
+ * while distributing variants according to their configured weight percentages.
184
+ *
185
+ * @param feature - The feature context containing variants and their weights
186
+ * @param identityKey - The identity key used for deterministic variant selection
187
+ * @returns The variant value if the identity falls within a variant's range, otherwise the default feature value
188
+ */
189
+ function getMultivariateFeatureValue(
190
+ feature: FeatureContextWithMetadata,
191
+ identityKey?: string
192
+ ): { value: any; reason?: string } {
193
+ const percentageValue = getHashedPercentageForObjIds([feature.key, identityKey]);
194
+ const sortedVariants = [...(feature?.variants || [])].sort((a, b) => {
195
+ return (a.priority ?? Infinity) - (b.priority ?? Infinity);
196
+ });
89
197
 
90
- if (featuresStates.length === 0) {
91
- throw new FeatureStateNotFound('Feature State Not Found');
198
+ let startPercentage = 0;
199
+ for (const variant of sortedVariants) {
200
+ const limit = startPercentage + variant.weight;
201
+ if (startPercentage <= percentageValue && percentageValue < limit) {
202
+ return {
203
+ value: variant.value,
204
+ reason: getTargetingMatchReason({ type: 'SPLIT', weight: variant.weight })
205
+ };
206
+ }
207
+ startPercentage = limit;
92
208
  }
93
209
 
94
- return featuresStates[0];
210
+ return { value: feature.value, reason: undefined };
95
211
  }
96
212
 
97
- export function getEnvironmentFeatureStates(environment: EnvironmentModel): FeatureStateModel[] {
98
- if (environment.project.hideDisabledFlags) {
99
- return environment.featureStates.filter(fs => !!fs.enabled);
100
- }
101
- return environment.featureStates;
213
+ export function shouldApplyOverride(
214
+ override: any,
215
+ existingOverrides: Record<string, SegmentOverride>
216
+ ): boolean {
217
+ const currentOverride = existingOverrides[override.name];
218
+ return (
219
+ !currentOverride || isHigherPriority(override.priority, currentOverride.feature.priority)
220
+ );
102
221
  }
222
+
223
+ export function isHigherPriority(
224
+ priorityA: number | undefined,
225
+ priorityB: number | undefined
226
+ ): boolean {
227
+ return (priorityA ?? Infinity) < (priorityB ?? Infinity);
228
+ }
229
+
230
+ export type TargetingMatchReason =
231
+ | {
232
+ type: 'SEGMENT';
233
+ override: SegmentOverride;
234
+ }
235
+ | {
236
+ type: 'SPLIT';
237
+ weight: number;
238
+ };
239
+
240
+ const getTargetingMatchReason = (matchObject: TargetingMatchReason) => {
241
+ const { type } = matchObject;
242
+
243
+ if (type === 'SEGMENT') {
244
+ return matchObject.override
245
+ ? `${TARGETING_REASONS.TARGETING_MATCH}; segment=${matchObject.override.segmentName}`
246
+ : TARGETING_REASONS.DEFAULT;
247
+ }
248
+
249
+ if (type === 'SPLIT') {
250
+ return `${TARGETING_REASONS.SPLIT}; weight=${matchObject.weight}`;
251
+ }
252
+
253
+ return TARGETING_REASONS.DEFAULT;
254
+ };
255
+
256
+ const getIdentityKey = (context: GenericEvaluationContext): string | undefined => {
257
+ if (!context.identity) return undefined;
258
+ return context.identity.key || `${context.environment.key}_${context.identity?.identifier}`;
259
+ };
@@ -4,6 +4,7 @@ export const ANY_RULE = 'ANY';
4
4
  export const NONE_RULE = 'NONE';
5
5
 
6
6
  export const RULE_TYPES = [ALL_RULE, ANY_RULE, NONE_RULE];
7
+ export const IDENTITY_OVERRIDE_SEGMENT_NAME = 'identity_overrides';
7
8
 
8
9
  // Segment Condition Operators
9
10
  export const EQUAL = 'EQUAL';
@@ -1,76 +1,192 @@
1
- import { EnvironmentModel } from '../environments/models.js';
2
- import { IdentityModel } from '../identities/models.js';
3
- import { TraitModel } from '../identities/traits/models.js';
4
- import { getHashedPercentateForObjIds } from '../utils/hashing/index.js';
5
- import { PERCENTAGE_SPLIT, IS_SET, IS_NOT_SET } from './constants.js';
6
- import { SegmentConditionModel, SegmentModel, SegmentRuleModel } from './models.js';
7
-
8
- export function getIdentitySegments(
9
- environment: EnvironmentModel,
10
- identity: IdentityModel,
11
- overrideTraits?: TraitModel[]
12
- ): SegmentModel[] {
13
- return environment.project.segments.filter(segment =>
14
- evaluateIdentityInSegment(identity, segment, overrideTraits)
15
- );
1
+ import * as jsonpathModule from 'jsonpath';
2
+ import {
3
+ GenericEvaluationContext,
4
+ InSegmentCondition,
5
+ SegmentCondition,
6
+ SegmentContext,
7
+ SegmentRule
8
+ } from '../evaluation/models.js';
9
+ import { getHashedPercentageForObjIds } from '../utils/hashing/index.js';
10
+ import { SegmentConditionModel } from './models.js';
11
+ import { IS_NOT_SET, IS_SET, PERCENTAGE_SPLIT } from './constants.js';
12
+
13
+ // Handle ESM/CJS interop - jsonpath exports default in ESM
14
+ const jsonpath = (jsonpathModule as any).default || jsonpathModule;
15
+
16
+ /**
17
+ * Returns all segments that the identity belongs to based on segment rules evaluation.
18
+ *
19
+ * An identity belongs to a segment if it matches ALL of the segment's rules.
20
+ * If the context has no identity or segments, returns an empty array.
21
+ *
22
+ * @param context - Evaluation context containing identity and segment definitions
23
+ * @returns Array of segments that the identity matches
24
+ */
25
+ export function getIdentitySegments(context: GenericEvaluationContext): SegmentContext[] {
26
+ if (!context.identity || !context.segments) return [];
27
+
28
+ return Object.values(context.segments).filter(segment => {
29
+ if (segment.rules.length === 0) return false;
30
+ return segment.rules.every(rule => traitsMatchSegmentRule(rule, segment.key, context));
31
+ });
16
32
  }
17
33
 
18
- export function evaluateIdentityInSegment(
19
- identity: IdentityModel,
20
- segment: SegmentModel,
21
- overrideTraits?: TraitModel[]
34
+ /**
35
+ * Evaluates whether a segment condition matches the identity's traits or context values.
36
+ *
37
+ * Handles different types of conditions:
38
+ * - PERCENTAGE_SPLIT: Deterministic percentage-based bucketing using identity key
39
+ * - IS_SET/IS_NOT_SET: Checks for trait existence
40
+ * - Standard operators: EQUAL, NOT_EQUAL, etc. via SegmentConditionModel
41
+ * - JSONPath expressions: $.identity.identifier, $.environment.name, etc.
42
+ *
43
+ * @param condition - The condition to evaluate (property, operator, value)
44
+ * @param segmentKey - Key of the segment (used for percentage split hashing)
45
+ * @param context - Evaluation context containing identity, traits, and environment
46
+ * @returns true if the condition matches
47
+ */
48
+ export function traitsMatchSegmentCondition(
49
+ condition: SegmentCondition | InSegmentCondition,
50
+ segmentKey: string,
51
+ context?: GenericEvaluationContext
22
52
  ): boolean {
23
- return (
24
- segment.rules.length > 0 &&
25
- segment.rules.filter(rule =>
26
- traitsMatchSegmentRule(
27
- overrideTraits || identity.identityTraits,
28
- rule,
29
- segment.id,
30
- identity.djangoID || identity.compositeKey
31
- )
32
- ).length === segment.rules.length
33
- );
53
+ if (condition.operator === PERCENTAGE_SPLIT) {
54
+ let splitKey: string | undefined;
55
+
56
+ if (!condition.property) {
57
+ splitKey = context?.identity?.key;
58
+ } else {
59
+ splitKey = getContextValue(condition.property, context);
60
+ }
61
+
62
+ if (!splitKey) {
63
+ return false;
64
+ }
65
+ const hashedPercentage = getHashedPercentageForObjIds([segmentKey, splitKey]);
66
+ return hashedPercentage <= parseFloat(String(condition.value));
67
+ }
68
+ if (!condition.property) {
69
+ return false;
70
+ }
71
+
72
+ const traitValue = getTraitValue(condition.property, context);
73
+
74
+ if (condition.operator === IS_SET) {
75
+ return traitValue !== undefined && traitValue !== null;
76
+ }
77
+ if (condition.operator === IS_NOT_SET) {
78
+ return traitValue === undefined || traitValue === null;
79
+ }
80
+
81
+ if (traitValue !== undefined && traitValue !== null) {
82
+ const segmentCondition = new SegmentConditionModel(
83
+ condition.operator,
84
+ condition.value as string,
85
+ condition.property
86
+ );
87
+ return segmentCondition.matchesTraitValue(traitValue);
88
+ }
89
+
90
+ return false;
34
91
  }
35
92
 
36
93
  function traitsMatchSegmentRule(
37
- identityTraits: TraitModel[],
38
- rule: SegmentRuleModel,
39
- segmentId: number | string,
40
- identityId: number | string
94
+ rule: SegmentRule,
95
+ segmentKey: string,
96
+ context?: GenericEvaluationContext
97
+ ): boolean {
98
+ const matchesConditions = evaluateConditions(rule, segmentKey, context);
99
+ const matchesSubRules = evaluateSubRules(rule, segmentKey, context);
100
+
101
+ return matchesConditions && matchesSubRules;
102
+ }
103
+
104
+ function evaluateConditions(
105
+ rule: SegmentRule,
106
+ segmentKey: string,
107
+ context?: GenericEvaluationContext
41
108
  ): boolean {
42
- const matchesConditions =
43
- rule.conditions.length > 0
44
- ? rule.matchingFunction()(
45
- rule.conditions.map(condition =>
46
- traitsMatchSegmentCondition(identityTraits, condition, segmentId, identityId)
47
- )
48
- )
49
- : true;
50
- return (
51
- matchesConditions &&
52
- rule.rules.filter(rule =>
53
- traitsMatchSegmentRule(identityTraits, rule, segmentId, identityId)
54
- ).length === rule.rules.length
109
+ if (!rule.conditions || rule.conditions.length === 0) return true;
110
+
111
+ const conditionResults = rule.conditions.map((condition: SegmentCondition) =>
112
+ traitsMatchSegmentCondition(condition, segmentKey, context)
55
113
  );
114
+
115
+ return evaluateRuleConditions(rule.type, conditionResults);
56
116
  }
57
117
 
58
- export function traitsMatchSegmentCondition(
59
- identityTraits: TraitModel[],
60
- condition: SegmentConditionModel,
61
- segmentId: number | string,
62
- identityId: number | string
118
+ function evaluateSubRules(
119
+ rule: SegmentRule,
120
+ segmentKey: string,
121
+ context?: GenericEvaluationContext
63
122
  ): boolean {
64
- if (condition.operator == PERCENTAGE_SPLIT) {
65
- var hashedPercentage = getHashedPercentateForObjIds([segmentId, identityId]);
66
- return hashedPercentage <= parseFloat(String(condition.value));
123
+ if (!rule.rules || rule.rules.length === 0) return true;
124
+
125
+ return rule.rules.every((subRule: SegmentRule) =>
126
+ traitsMatchSegmentRule(subRule, segmentKey, context)
127
+ );
128
+ }
129
+
130
+ function evaluateRuleConditions(ruleType: string, conditionResults: boolean[]): boolean {
131
+ switch (ruleType) {
132
+ case 'ALL':
133
+ return conditionResults.length === 0 || conditionResults.every(result => result);
134
+ case 'ANY':
135
+ return conditionResults.length > 0 && conditionResults.some(result => result);
136
+ case 'NONE':
137
+ return conditionResults.length === 0 || conditionResults.every(result => !result);
138
+ default:
139
+ return false;
67
140
  }
68
- const traits = identityTraits.filter(t => t.traitKey === condition.property_);
69
- const trait = traits.length > 0 ? traits[0] : undefined;
70
- if (condition.operator === IS_SET) {
71
- return !!trait;
72
- } else if (condition.operator === IS_NOT_SET) {
73
- return trait == undefined;
141
+ }
142
+
143
+ function getTraitValue(property: string, context?: GenericEvaluationContext): any {
144
+ if (property.startsWith('$.')) {
145
+ const contextValue = getContextValue(property, context);
146
+ if (contextValue !== undefined && isPrimitive(contextValue)) {
147
+ return contextValue;
148
+ }
74
149
  }
75
- return trait ? condition.matchesTraitValue(trait.traitValue) : false;
150
+ const traits = context?.identity?.traits || {};
151
+
152
+ return traits[property];
153
+ }
154
+
155
+ function isPrimitive(value: any): boolean {
156
+ if (value === null || value === undefined) {
157
+ return true;
158
+ }
159
+
160
+ // Objects and arrays are non-primitive
161
+ return typeof value !== 'object';
162
+ }
163
+
164
+ /**
165
+ * Evaluates JSONPath expressions against the evaluation context.
166
+ *
167
+ * Supports accessing nested context values using JSONPath syntax.
168
+ * Commonly used paths:
169
+ * - $.identity.identifier - User's unique identifier
170
+ * - $.identity.key - User's internal key
171
+ * - $.environment.name - Environment name
172
+ * - $.environment.key - Environment key
173
+ *
174
+ * @param jsonPath - JSONPath expression starting with '$.'
175
+ * @param context - Evaluation context to query against
176
+ * @returns The resolved value, or undefined if path doesn't exist or is invalid
177
+ */
178
+ export function getContextValue(jsonPath: string, context?: GenericEvaluationContext): any {
179
+ if (!context || !jsonPath?.startsWith('$.')) return undefined;
180
+
181
+ try {
182
+ const normalizedPath = normalizeJsonPath(jsonPath);
183
+ const results = jsonpath.query(context, normalizedPath);
184
+ return results.length > 0 ? results[0] : undefined;
185
+ } catch (error) {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ function normalizeJsonPath(jsonPath: string): string {
191
+ return jsonPath.replace(/\.([^.\[\]]+)$/, "['$1']");
76
192
  }