Skip to content

Commit 065461f

Browse files
Merge pull request #396 from splitio/prerequisites_refactors
Prerequisites support
2 parents 9c1de66 + 68590fa commit 065461f

File tree

17 files changed

+215
-86
lines changed

17 files changed

+215
-86
lines changed

CHANGES.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
2.4.0 (May 20, 2025)
1+
2.4.0 (May 27, 2025)
22
- Added support for targeting rules based on rule-based segments.
3+
- Added support for feature flag prerequisites.
34

45
2.3.0 (May 16, 2025)
56
- Updated the Redis storage to:

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.3.0",
3+
"version": "2.4.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",

src/dtos/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export interface ISplit {
220220
changeNumber: number,
221221
status: 'ACTIVE' | 'ARCHIVED',
222222
conditions: ISplitCondition[],
223+
prerequisites?: {
224+
n: string,
225+
ts: string[]
226+
}[]
223227
killed: boolean,
224228
defaultTreatment: string,
225229
trafficTypeName: string,

src/evaluator/Engine.ts

Lines changed: 42 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { get } from '../utils/lang';
1+
import { get, isString } from '../utils/lang';
22
import { parser } from './parser';
33
import { keyParser } from '../utils/key';
44
import { thenable } from '../utils/promise/thenable';
5-
import { EXCEPTION, NO_CONDITION_MATCH, SPLIT_ARCHIVED, SPLIT_KILLED } from '../utils/labels';
5+
import { NO_CONDITION_MATCH, SPLIT_ARCHIVED, SPLIT_KILLED, PREREQUISITES_NOT_MET } from '../utils/labels';
66
import { CONTROL } from '../utils/constants';
77
import { ISplit, MaybeThenable } from '../dtos/types';
88
import SplitIO from '../../types/splitio';
99
import { IStorageAsync, IStorageSync } from '../storages/types';
10-
import { IEvaluation, IEvaluationResult, IEvaluator, ISplitEvaluator } from './types';
10+
import { IEvaluation, IEvaluationResult, ISplitEvaluator } from './types';
1111
import { ILogger } from '../logger/types';
12+
import { ENGINE_DEFAULT } from '../logger/constants';
13+
import { prerequisitesMatcherContext } from './matchers/prerequisites';
1214

1315
function evaluationResult(result: IEvaluation | undefined, defaultTreatment: string): IEvaluationResult {
1416
return {
@@ -17,84 +19,55 @@ function evaluationResult(result: IEvaluation | undefined, defaultTreatment: str
1719
};
1820
}
1921

20-
export class Engine {
22+
export function engineParser(log: ILogger, split: ISplit, storage: IStorageSync | IStorageAsync) {
23+
const { killed, seed, trafficAllocation, trafficAllocationSeed, status, conditions, prerequisites } = split;
2124

22-
constructor(private baseInfo: ISplit, private evaluator: IEvaluator) {
25+
const defaultTreatment = isString(split.defaultTreatment) ? split.defaultTreatment : CONTROL;
2326

24-
// in case we don't have a default treatment in the instantiation, use 'control'
25-
if (typeof this.baseInfo.defaultTreatment !== 'string') {
26-
this.baseInfo.defaultTreatment = CONTROL;
27-
}
28-
}
27+
const evaluator = parser(log, conditions, storage);
28+
const prerequisiteMatcher = prerequisitesMatcherContext(prerequisites, storage, log);
29+
30+
return {
2931

30-
static parse(log: ILogger, splitFlatStructure: ISplit, storage: IStorageSync | IStorageAsync) {
31-
const conditions = splitFlatStructure.conditions;
32-
const evaluator = parser(log, conditions, storage);
32+
getTreatment(key: SplitIO.SplitKey, attributes: SplitIO.Attributes | undefined, splitEvaluator: ISplitEvaluator): MaybeThenable<IEvaluationResult> {
3333

34-
return new Engine(splitFlatStructure, evaluator);
35-
}
34+
const parsedKey = keyParser(key);
3635

37-
getKey() {
38-
return this.baseInfo.name;
39-
}
36+
function evaluate(prerequisitesMet: boolean) {
37+
if (!prerequisitesMet) {
38+
log.debug(ENGINE_DEFAULT, ['Prerequisite not met']);
39+
return {
40+
treatment: defaultTreatment,
41+
label: PREREQUISITES_NOT_MET
42+
};
43+
}
4044

41-
getTreatment(key: SplitIO.SplitKey, attributes: SplitIO.Attributes | undefined, splitEvaluator: ISplitEvaluator): MaybeThenable<IEvaluationResult> {
42-
const {
43-
killed,
44-
seed,
45-
defaultTreatment,
46-
trafficAllocation,
47-
trafficAllocationSeed
48-
} = this.baseInfo;
49-
let parsedKey;
50-
let treatment;
51-
let label;
45+
const evaluation = evaluator(parsedKey, seed, trafficAllocation, trafficAllocationSeed, attributes, splitEvaluator) as MaybeThenable<IEvaluation>;
46+
47+
return thenable(evaluation) ?
48+
evaluation.then(result => evaluationResult(result, defaultTreatment)) :
49+
evaluationResult(evaluation, defaultTreatment);
50+
}
5251

53-
try {
54-
parsedKey = keyParser(key);
55-
} catch (err) {
56-
return {
52+
if (status === 'ARCHIVED') return {
5753
treatment: CONTROL,
58-
label: EXCEPTION
54+
label: SPLIT_ARCHIVED
5955
};
60-
}
61-
62-
if (this.isGarbage()) {
63-
treatment = CONTROL;
64-
label = SPLIT_ARCHIVED;
65-
} else if (killed) {
66-
treatment = defaultTreatment;
67-
label = SPLIT_KILLED;
68-
} else {
69-
const evaluation = this.evaluator(
70-
parsedKey,
71-
seed,
72-
trafficAllocation,
73-
trafficAllocationSeed,
74-
attributes,
75-
splitEvaluator
76-
) as MaybeThenable<IEvaluation>;
7756

78-
// Evaluation could be async, so we should handle that case checking for a
79-
// thenable object
80-
if (thenable(evaluation)) {
81-
return evaluation.then(result => evaluationResult(result, defaultTreatment));
82-
} else {
83-
return evaluationResult(evaluation, defaultTreatment);
57+
if (killed) {
58+
log.debug(ENGINE_DEFAULT, ['Flag is killed']);
59+
return {
60+
treatment: defaultTreatment,
61+
label: SPLIT_KILLED
62+
};
8463
}
85-
}
8664

87-
return {
88-
treatment,
89-
label
90-
};
91-
}
65+
const prerequisitesMet = prerequisiteMatcher({ key, attributes }, splitEvaluator);
9266

93-
isGarbage() {
94-
return this.baseInfo.status === 'ARCHIVED';
95-
}
67+
return thenable(prerequisitesMet) ?
68+
prerequisitesMet.then(evaluate) :
69+
evaluate(prerequisitesMet);
70+
}
71+
};
9672

97-
getChangeNumber() {
98-
return this.baseInfo.changeNumber;
99-
}
10073
}

src/evaluator/index.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Engine } from './Engine';
1+
import { engineParser } from './Engine';
22
import { thenable } from '../utils/promise/thenable';
33
import { EXCEPTION, SPLIT_NOT_FOUND } from '../utils/labels';
44
import { CONTROL } from '../utils/constants';
@@ -99,9 +99,7 @@ export function evaluateFeaturesByFlagSets(
9999
): MaybeThenable<Record<string, IEvaluationResult>> {
100100
let storedFlagNames: MaybeThenable<Set<string>[]>;
101101

102-
function evaluate(
103-
featureFlagsByFlagSets: Set<string>[],
104-
) {
102+
function evaluate(featureFlagsByFlagSets: Set<string>[]) {
105103
let featureFlags = new Set<string>();
106104
for (let i = 0; i < flagSets.length; i++) {
107105
const featureFlagByFlagSet = featureFlagsByFlagSets[i];
@@ -148,20 +146,20 @@ function getEvaluation(
148146
};
149147

150148
if (splitJSON) {
151-
const split = Engine.parse(log, splitJSON, storage);
149+
const split = engineParser(log, splitJSON, storage);
152150
evaluation = split.getTreatment(key, attributes, evaluateFeature);
153151

154152
// If the storage is async and the evaluated flag uses segments or dependencies, evaluation is thenable
155153
if (thenable(evaluation)) {
156154
return evaluation.then(result => {
157-
result.changeNumber = split.getChangeNumber();
155+
result.changeNumber = splitJSON.changeNumber;
158156
result.config = splitJSON.configurations && splitJSON.configurations[result.treatment] || null;
159157
result.impressionsDisabled = splitJSON.impressionsDisabled;
160158

161159
return result;
162160
});
163161
} else {
164-
evaluation.changeNumber = split.getChangeNumber(); // Always sync and optional
162+
evaluation.changeNumber = splitJSON.changeNumber;
165163
evaluation.config = splitJSON.configurations && splitJSON.configurations[evaluation.treatment] || null;
166164
evaluation.impressionsDisabled = splitJSON.impressionsDisabled;
167165
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { evaluateFeature } from '../../index';
2+
import { IStorageSync } from '../../../storages/types';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
import { ISplit } from '../../../dtos/types';
5+
import { ALWAYS_ON_SPLIT, ALWAYS_OFF_SPLIT } from '../../../storages/__tests__/testUtils';
6+
import { prerequisitesMatcherContext } from '../prerequisites';
7+
8+
const STORED_SPLITS: Record<string, ISplit> = {
9+
'always-on': ALWAYS_ON_SPLIT,
10+
'always-off': ALWAYS_OFF_SPLIT
11+
};
12+
13+
const mockStorage = {
14+
splits: {
15+
getSplit: (name: string) => STORED_SPLITS[name]
16+
}
17+
} as IStorageSync;
18+
19+
test('MATCHER PREREQUISITES / should return true when all prerequisites are met', () => {
20+
// A single prerequisite
21+
const matcherTrueAlwaysOn = prerequisitesMatcherContext([{
22+
n: 'always-on',
23+
ts: ['not-existing', 'on', 'other'] // We should match from a list of treatments
24+
}], mockStorage, loggerMock);
25+
expect(matcherTrueAlwaysOn({ key: 'a-key' }, evaluateFeature)).toBe(true); // Feature flag returns one of the expected treatments, so the matcher returns true
26+
27+
const matcherFalseAlwaysOn = prerequisitesMatcherContext([{
28+
n: 'always-on',
29+
ts: ['off', 'v1']
30+
}], mockStorage, loggerMock);
31+
expect(matcherFalseAlwaysOn({ key: 'a-key' }, evaluateFeature)).toBe(false); // Feature flag returns treatment "on", but we are expecting ["off", "v1"], so the matcher returns false
32+
33+
const matcherTrueAlwaysOff = prerequisitesMatcherContext([{
34+
n: 'always-off',
35+
ts: ['not-existing', 'off']
36+
}], mockStorage, loggerMock);
37+
expect(matcherTrueAlwaysOff({ key: 'a-key' }, evaluateFeature)).toBe(true); // Feature flag returns one of the expected treatments, so the matcher returns true
38+
39+
const matcherFalseAlwaysOff = prerequisitesMatcherContext([{
40+
n: 'always-off',
41+
ts: ['v1', 'on']
42+
}], mockStorage, loggerMock);
43+
expect(matcherFalseAlwaysOff({ key: 'a-key' }, evaluateFeature)).toBe(false); // Feature flag returns treatment "on", but we are expecting ["off", "v1"], so the matcher returns false
44+
45+
// Multiple prerequisites
46+
const matcherTrueMultiplePrerequisites = prerequisitesMatcherContext([
47+
{
48+
n: 'always-on',
49+
ts: ['on']
50+
},
51+
{
52+
n: 'always-off',
53+
ts: ['off']
54+
}
55+
], mockStorage, loggerMock);
56+
expect(matcherTrueMultiplePrerequisites({ key: 'a-key' }, evaluateFeature)).toBe(true); // All prerequisites are met, so the matcher returns true
57+
58+
const matcherFalseMultiplePrerequisites = prerequisitesMatcherContext([
59+
{
60+
n: 'always-on',
61+
ts: ['on']
62+
},
63+
{
64+
n: 'always-off',
65+
ts: ['on']
66+
}
67+
], mockStorage, loggerMock);
68+
expect(matcherFalseMultiplePrerequisites({ key: 'a-key' }, evaluateFeature)).toBe(false); // One of the prerequisites is not met, so the matcher returns false
69+
});
70+
71+
test('MATCHER PREREQUISITES / Edge cases', () => {
72+
// No prerequisites
73+
const matcherTrueNoPrerequisites = prerequisitesMatcherContext(undefined, mockStorage, loggerMock);
74+
expect(matcherTrueNoPrerequisites({ key: 'a-key' }, evaluateFeature)).toBe(true);
75+
76+
const matcherTrueEmptyPrerequisites = prerequisitesMatcherContext([], mockStorage, loggerMock);
77+
expect(matcherTrueEmptyPrerequisites({ key: 'a-key' }, evaluateFeature)).toBe(true);
78+
79+
// Non existent feature flag
80+
const matcherParentNotExist = prerequisitesMatcherContext([{
81+
n: 'not-existent-feature-flag',
82+
ts: ['on', 'off']
83+
}], mockStorage, loggerMock);
84+
expect(matcherParentNotExist({ key: 'a-key' }, evaluateFeature)).toBe(false); // If the feature flag does not exist, matcher should return false
85+
86+
// Empty treatments list
87+
const matcherNoTreatmentsExpected = prerequisitesMatcherContext([
88+
{
89+
n: 'always-on',
90+
ts: []
91+
}], mockStorage, loggerMock);
92+
expect(matcherNoTreatmentsExpected({ key: 'a-key' }, evaluateFeature)).toBe(false); // If treatments expectation list is empty, matcher should return false (no treatment will match)
93+
94+
const matcherExpectedTreatmentWrongTypeMatching = prerequisitesMatcherContext([{
95+
n: 'always-on', // @ts-ignore
96+
ts: [null, [1, 2], 3, {}, true, 'on']
97+
98+
}], mockStorage, loggerMock);
99+
expect(matcherExpectedTreatmentWrongTypeMatching({ key: 'a-key' }, evaluateFeature)).toBe(true); // If treatments expectation list has elements of the wrong type, those elements are overlooked.
100+
101+
const matcherExpectedTreatmentWrongTypeNotMatching = prerequisitesMatcherContext([{
102+
n: 'always-off', // @ts-ignore
103+
ts: [null, [1, 2], 3, {}, true, 'on']
104+
}], mockStorage, loggerMock);
105+
expect(matcherExpectedTreatmentWrongTypeNotMatching({ key: 'a-key' }, evaluateFeature)).toBe(false); // If treatments expectation list has elements of the wrong type, those elements are overlooked.
106+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ISplit, MaybeThenable } from '../../dtos/types';
2+
import { IStorageAsync, IStorageSync } from '../../storages/types';
3+
import { ILogger } from '../../logger/types';
4+
import { thenable } from '../../utils/promise/thenable';
5+
import { IDependencyMatcherValue, ISplitEvaluator } from '../types';
6+
7+
export function prerequisitesMatcherContext(prerequisites: ISplit['prerequisites'] = [], storage: IStorageSync | IStorageAsync, log: ILogger) {
8+
9+
return function prerequisitesMatcher({ key, attributes }: IDependencyMatcherValue, splitEvaluator: ISplitEvaluator): MaybeThenable<boolean> {
10+
11+
function evaluatePrerequisite(prerequisite: { n: string; ts: string[] }): MaybeThenable<boolean> {
12+
const evaluation = splitEvaluator(log, key, prerequisite.n, attributes, storage);
13+
return thenable(evaluation) ?
14+
evaluation.then(evaluation => prerequisite.ts.indexOf(evaluation.treatment!) !== -1) :
15+
prerequisite.ts.indexOf(evaluation.treatment!) !== -1;
16+
}
17+
18+
return prerequisites.reduce<MaybeThenable<boolean>>((prerequisitesMet, prerequisite) => {
19+
return thenable(prerequisitesMet) ?
20+
prerequisitesMet.then(prerequisitesMet => prerequisitesMet ? evaluatePrerequisite(prerequisite) : false) :
21+
prerequisitesMet ? evaluatePrerequisite(prerequisite) : false;
22+
}, true);
23+
};
24+
}

src/evaluator/matchers/rbsegment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export function ruleBasedSegmentMatcherContext(segmentName: string, storage: ISt
3939
storage.segments.isInSegment(name, matchingKey) :
4040
type === RULE_BASED_SEGMENT ?
4141
ruleBasedSegmentMatcherContext(name, storage, log)({ key, attributes }, splitEvaluator) :
42-
type === LARGE_SEGMENT && (storage as IStorageSync).largeSegments ?
43-
(storage as IStorageSync).largeSegments!.isInSegment(name, matchingKey) :
42+
type === LARGE_SEGMENT && storage.largeSegments ?
43+
storage.largeSegments.isInSegment(name, matchingKey) :
4444
false;
4545
}
4646

src/logger/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const SYNC_TASK_EXECUTE = 37;
2828
export const SYNC_TASK_STOP = 38;
2929
export const SETTINGS_SPLITS_FILTER = 39;
3030
export const ENGINE_MATCHER_RESULT = 40;
31+
export const ENGINE_DEFAULT = 41;
3132

3233
export const CLIENT_READY_FROM_CACHE = 100;
3334
export const CLIENT_READY = 101;

src/logger/messages/debug.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const codesDebug: [number, string][] = codesInfo.concat([
1212
[c.ENGINE_VALUE, c.LOG_PREFIX_ENGINE_VALUE + 'Extracted attribute `%s`. %s will be used for matching.'],
1313
[c.ENGINE_SANITIZE, c.LOG_PREFIX_ENGINE + ':sanitize: Attempted to sanitize %s which should be of type %s. Sanitized and processed value => %s'],
1414
[c.ENGINE_MATCHER_RESULT, c.LOG_PREFIX_ENGINE_MATCHER + '[%s] Result: %s. Rule value: %s. Evaluation value: %s'],
15+
[c.ENGINE_DEFAULT, c.LOG_PREFIX_ENGINE + 'Evaluates to default treatment. %s'],
1516
// SDK
1617
[c.CLEANUP_REGISTERING, c.LOG_PREFIX_CLEANUP + 'Registering cleanup handler %s'],
1718
[c.CLEANUP_DEREGISTERING, c.LOG_PREFIX_CLEANUP + 'Deregistering cleanup handler %s'],

0 commit comments

Comments
 (0)