@naturalcycles/abba 2.7.3 → 2.9.0
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.
- package/dist/abba.js +12 -12
- package/dist/dao/experiment.dao.js +2 -10
- package/dist/types.d.ts +7 -0
- package/dist/types.js +9 -4
- package/dist/util.js +9 -7
- package/package.json +2 -2
- package/src/abba.ts +8 -3
- package/src/dao/experiment.dao.ts +2 -11
- package/src/types.ts +8 -0
- package/src/util.ts +13 -7
package/dist/abba.js
CHANGED
|
@@ -8,17 +8,13 @@ import { LRUMemoCache } from '@naturalcycles/nodejs-lib/lruMemoCache';
|
|
|
8
8
|
import { bucketDao } from './dao/bucket.dao.js';
|
|
9
9
|
import { experimentDao } from './dao/experiment.dao.js';
|
|
10
10
|
import { userAssignmentDao } from './dao/userAssignment.dao.js';
|
|
11
|
-
import { AssignmentStatus } from './types.js';
|
|
11
|
+
import { AbbaErrorCode, AssignmentStatus } from './types.js';
|
|
12
12
|
import { canGenerateNewAssignments, generateUserAssignmentData, getUserExclusionSet, validateTotalBucketRatio, } from './util.js';
|
|
13
13
|
/**
|
|
14
14
|
* 10 minutes
|
|
15
15
|
*/
|
|
16
16
|
const CACHE_TTL = 600_000;
|
|
17
17
|
export class Abba {
|
|
18
|
-
cfg;
|
|
19
|
-
experimentDao;
|
|
20
|
-
bucketDao;
|
|
21
|
-
userAssignmentDao;
|
|
22
18
|
constructor(cfg) {
|
|
23
19
|
this.cfg = cfg;
|
|
24
20
|
this.experimentDao = experimentDao(cfg.db);
|
|
@@ -72,7 +68,7 @@ export class Abba {
|
|
|
72
68
|
async mergeAssignmentsForUserIds(fromUserId, intoUserId) {
|
|
73
69
|
const fromAssignments = await this.userAssignmentDao.getBy('userId', fromUserId);
|
|
74
70
|
const existingIntoAssignments = await this.userAssignmentDao.getBy('userId', intoUserId);
|
|
75
|
-
await pMap(fromAssignments, async from => {
|
|
71
|
+
await pMap(fromAssignments, async (from) => {
|
|
76
72
|
if (!existingIntoAssignments.some(into => into.experimentId === from.experimentId)) {
|
|
77
73
|
await this.userAssignmentDao.patch(from, { userId: intoUserId });
|
|
78
74
|
}
|
|
@@ -109,7 +105,7 @@ export class Abba {
|
|
|
109
105
|
validateTotalBucketRatio(buckets);
|
|
110
106
|
}
|
|
111
107
|
const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
|
|
112
|
-
const updatedBuckets = await pMap(buckets, async bucket => {
|
|
108
|
+
const updatedBuckets = await pMap(buckets, async (bucket) => {
|
|
113
109
|
return await this.bucketDao.save({ ...bucket, experimentId: updatedExperiment.id }, { saveMethod: bucket.id ? 'update' : undefined });
|
|
114
110
|
});
|
|
115
111
|
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
|
|
@@ -154,7 +150,7 @@ export class Abba {
|
|
|
154
150
|
const experiment = await this.experimentDao.requireById(experimentId);
|
|
155
151
|
const hasBeenInactiveFor15Mins = experiment.status === AssignmentStatus.Inactive &&
|
|
156
152
|
localTime(experiment.updated).isOlderThan(15, 'minute');
|
|
157
|
-
_assert(hasBeenInactiveFor15Mins, 'Experiment must be inactive for at least 15 minutes before deletion');
|
|
153
|
+
_assert(hasBeenInactiveFor15Mins, 'Experiment must be inactive for at least 15 minutes before deletion', { code: AbbaErrorCode.ExperimentDeletionTooSoon });
|
|
158
154
|
await this.userAssignmentDao.deleteByExperimentId(experimentId);
|
|
159
155
|
await this.bucketDao.deleteByExperimentId(experimentId);
|
|
160
156
|
await this.experimentDao.deleteById(experimentId);
|
|
@@ -171,7 +167,9 @@ export class Abba {
|
|
|
171
167
|
*/
|
|
172
168
|
async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
|
|
173
169
|
const experiment = await this.experimentDao.getByKey(experimentKey);
|
|
174
|
-
_assert(experiment, `Experiment does not exist: ${experimentKey}
|
|
170
|
+
_assert(experiment, `Experiment does not exist: ${experimentKey}`, {
|
|
171
|
+
code: AbbaErrorCode.ExperimentNotFound,
|
|
172
|
+
});
|
|
175
173
|
// Inactive experiments should never return an assignment
|
|
176
174
|
if (experiment.status === AssignmentStatus.Inactive) {
|
|
177
175
|
return null;
|
|
@@ -197,7 +195,9 @@ export class Abba {
|
|
|
197
195
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
198
196
|
return null;
|
|
199
197
|
}
|
|
200
|
-
_assert(segmentationData, 'Segmentation data required when creating a new assignment'
|
|
198
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment', {
|
|
199
|
+
code: AbbaErrorCode.SegmentationDataRequired,
|
|
200
|
+
});
|
|
201
201
|
const experimentWithBuckets = { ...experiment, buckets };
|
|
202
202
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData);
|
|
203
203
|
if (!assignment) {
|
|
@@ -221,7 +221,7 @@ export class Abba {
|
|
|
221
221
|
*/
|
|
222
222
|
async getAllExistingUserAssignments(userId) {
|
|
223
223
|
const assignments = await this.userAssignmentDao.getBy('userId', userId);
|
|
224
|
-
return await pMap(assignments, async assignment => {
|
|
224
|
+
return await pMap(assignments, async (assignment) => {
|
|
225
225
|
const experiment = await this.experimentDao.requireById(assignment.experimentId);
|
|
226
226
|
const bucket = await this.bucketDao.getById(assignment.bucketId);
|
|
227
227
|
return {
|
|
@@ -288,7 +288,7 @@ export class Abba {
|
|
|
288
288
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
289
289
|
const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
|
|
290
290
|
const buckets = await this.bucketDao.getByExperimentId(experimentId);
|
|
291
|
-
const bucketAssignments = await pMap(buckets, async bucket => {
|
|
291
|
+
const bucketAssignments = await pMap(buckets, async (bucket) => {
|
|
292
292
|
const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
|
|
293
293
|
return {
|
|
294
294
|
bucketId: bucket.id,
|
|
@@ -19,11 +19,7 @@ export function experimentDao(db) {
|
|
|
19
19
|
beforeBMToDBM: bm => ({
|
|
20
20
|
...bm,
|
|
21
21
|
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
22
|
-
|
|
23
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
24
|
-
exclusions: bm.exclusions.length
|
|
25
|
-
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
26
|
-
: null,
|
|
22
|
+
exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
|
|
27
23
|
data: bm.data ? JSON.stringify(bm.data) : null,
|
|
28
24
|
}),
|
|
29
25
|
beforeDBMToBM: dbm => ({
|
|
@@ -31,11 +27,7 @@ export function experimentDao(db) {
|
|
|
31
27
|
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
32
28
|
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
33
29
|
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
34
|
-
|
|
35
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
36
|
-
exclusions: (dbm.exclusions &&
|
|
37
|
-
JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
|
|
38
|
-
[],
|
|
30
|
+
exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
|
|
39
31
|
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
40
32
|
}),
|
|
41
33
|
},
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
2
|
import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib/types';
|
|
3
|
+
export declare const AbbaErrorCode: {
|
|
4
|
+
readonly ExperimentNotFound: 'abba/experimentNotFound';
|
|
5
|
+
readonly ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon';
|
|
6
|
+
readonly SegmentationDataRequired: 'abba/segmentationDataRequired';
|
|
7
|
+
readonly InvalidBucketRatio: 'abba/invalidBucketRatio';
|
|
8
|
+
readonly BucketDeterminationFailed: 'abba/bucketDeterminationFailed';
|
|
9
|
+
};
|
|
3
10
|
export interface AbbaConfig {
|
|
4
11
|
db: CommonDB;
|
|
5
12
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export const AbbaErrorCode = {
|
|
2
|
+
ExperimentNotFound: 'abba/experimentNotFound',
|
|
3
|
+
ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon',
|
|
4
|
+
SegmentationDataRequired: 'abba/segmentationDataRequired',
|
|
5
|
+
InvalidBucketRatio: 'abba/invalidBucketRatio',
|
|
6
|
+
BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
|
|
7
|
+
};
|
|
8
|
+
export var AssignmentStatus;
|
|
3
9
|
(function (AssignmentStatus) {
|
|
4
10
|
/**
|
|
5
11
|
* Will return existing assignments and generate new assignments
|
|
@@ -14,8 +20,7 @@ var AssignmentStatus;
|
|
|
14
20
|
*/
|
|
15
21
|
AssignmentStatus[AssignmentStatus["Inactive"] = 3] = "Inactive";
|
|
16
22
|
})(AssignmentStatus || (AssignmentStatus = {}));
|
|
17
|
-
export
|
|
18
|
-
var SegmentationRuleOperator;
|
|
23
|
+
export var SegmentationRuleOperator;
|
|
19
24
|
(function (SegmentationRuleOperator) {
|
|
20
25
|
SegmentationRuleOperator["IsSet"] = "isSet";
|
|
21
26
|
SegmentationRuleOperator["IsNotSet"] = "isNotSet";
|
package/dist/util.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { localDate } from '@naturalcycles/js-lib/datetime';
|
|
2
|
+
import { _assert } from '@naturalcycles/js-lib/error/assert.js';
|
|
2
3
|
import { satisfies } from 'semver';
|
|
3
|
-
import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
|
|
4
|
+
import { AbbaErrorCode, AssignmentStatus, SegmentationRuleOperator } from './types.js';
|
|
4
5
|
/**
|
|
5
6
|
* Generate a new assignment for a given user.
|
|
6
7
|
* Doesn't save it.
|
|
@@ -53,9 +54,9 @@ export function determineBucket(buckets) {
|
|
|
53
54
|
return b;
|
|
54
55
|
}
|
|
55
56
|
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
57
|
+
_assert(bucket, 'Could not determine bucket from ratios', {
|
|
58
|
+
code: AbbaErrorCode.BucketDeterminationFailed,
|
|
59
|
+
});
|
|
59
60
|
return bucket;
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -63,9 +64,7 @@ export function determineBucket(buckets) {
|
|
|
63
64
|
*/
|
|
64
65
|
export function validateTotalBucketRatio(buckets) {
|
|
65
66
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
66
|
-
|
|
67
|
-
throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
|
|
68
|
-
}
|
|
67
|
+
_assert(bucketSum === 100, 'Total bucket ratio must be 100 before you can activate an experiment', { code: AbbaErrorCode.InvalidBucketRatio });
|
|
69
68
|
}
|
|
70
69
|
/**
|
|
71
70
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
@@ -93,12 +92,15 @@ export const segmentationRuleMap = {
|
|
|
93
92
|
return !keyValue;
|
|
94
93
|
},
|
|
95
94
|
[SegmentationRuleOperator.EqualsText](keyValue, ruleValue) {
|
|
95
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
96
96
|
return keyValue?.toString() === ruleValue.toString();
|
|
97
97
|
},
|
|
98
98
|
[SegmentationRuleOperator.NotEqualsText](keyValue, ruleValue) {
|
|
99
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
99
100
|
return keyValue?.toString() !== ruleValue.toString();
|
|
100
101
|
},
|
|
101
102
|
[SegmentationRuleOperator.Semver](keyValue, ruleValue) {
|
|
103
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
102
104
|
return satisfies(keyValue?.toString() || '', ruleValue.toString());
|
|
103
105
|
},
|
|
104
106
|
[SegmentationRuleOperator.Regex](keyValue, ruleValue) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.9.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@naturalcycles/db-lib": "^10",
|
|
7
7
|
"@naturalcycles/js-lib": "^15",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@types/semver": "^7",
|
|
14
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
14
|
+
"@typescript/native-preview": "7.0.0-dev.20260401.1",
|
|
15
15
|
"@naturalcycles/dev-lib": "18.4.2"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
package/src/abba.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
UserAssignment,
|
|
24
24
|
UserExperiment,
|
|
25
25
|
} from './types.js'
|
|
26
|
-
import { AssignmentStatus } from './types.js'
|
|
26
|
+
import { AbbaErrorCode, AssignmentStatus } from './types.js'
|
|
27
27
|
import {
|
|
28
28
|
canGenerateNewAssignments,
|
|
29
29
|
generateUserAssignmentData,
|
|
@@ -233,6 +233,7 @@ export class Abba {
|
|
|
233
233
|
_assert(
|
|
234
234
|
hasBeenInactiveFor15Mins,
|
|
235
235
|
'Experiment must be inactive for at least 15 minutes before deletion',
|
|
236
|
+
{ code: AbbaErrorCode.ExperimentDeletionTooSoon },
|
|
236
237
|
)
|
|
237
238
|
|
|
238
239
|
await this.userAssignmentDao.deleteByExperimentId(experimentId)
|
|
@@ -257,7 +258,9 @@ export class Abba {
|
|
|
257
258
|
segmentationData?: SegmentationData,
|
|
258
259
|
): Promise<DecoratedUserAssignment | null> {
|
|
259
260
|
const experiment = await this.experimentDao.getByKey(experimentKey)
|
|
260
|
-
_assert(experiment, `Experiment does not exist: ${experimentKey}
|
|
261
|
+
_assert(experiment, `Experiment does not exist: ${experimentKey}`, {
|
|
262
|
+
code: AbbaErrorCode.ExperimentNotFound,
|
|
263
|
+
})
|
|
261
264
|
|
|
262
265
|
// Inactive experiments should never return an assignment
|
|
263
266
|
if (experiment.status === AssignmentStatus.Inactive) {
|
|
@@ -291,7 +294,9 @@ export class Abba {
|
|
|
291
294
|
return null
|
|
292
295
|
}
|
|
293
296
|
|
|
294
|
-
_assert(segmentationData, 'Segmentation data required when creating a new assignment'
|
|
297
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment', {
|
|
298
|
+
code: AbbaErrorCode.SegmentationDataRequired,
|
|
299
|
+
})
|
|
295
300
|
|
|
296
301
|
const experimentWithBuckets = { ...experiment, buckets }
|
|
297
302
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
@@ -26,11 +26,7 @@ export function experimentDao(db: CommonDB): ExperimentDao {
|
|
|
26
26
|
beforeBMToDBM: bm => ({
|
|
27
27
|
...bm,
|
|
28
28
|
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
29
|
-
|
|
30
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
31
|
-
exclusions: bm.exclusions.length
|
|
32
|
-
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
33
|
-
: null,
|
|
29
|
+
exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
|
|
34
30
|
data: bm.data ? JSON.stringify(bm.data) : null,
|
|
35
31
|
}),
|
|
36
32
|
beforeDBMToBM: dbm => ({
|
|
@@ -38,12 +34,7 @@ export function experimentDao(db: CommonDB): ExperimentDao {
|
|
|
38
34
|
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
39
35
|
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
40
36
|
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
41
|
-
|
|
42
|
-
// TODO: Remove after some time when we are certain only strings are stored
|
|
43
|
-
exclusions:
|
|
44
|
-
(dbm.exclusions &&
|
|
45
|
-
JSON.parse(dbm.exclusions).map((exclusion: string | number) => exclusion.toString())) ||
|
|
46
|
-
[],
|
|
37
|
+
exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
|
|
47
38
|
data: dbm.data ? JSON.parse(dbm.data) : null,
|
|
48
39
|
}),
|
|
49
40
|
},
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
2
|
import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib/types'
|
|
3
3
|
|
|
4
|
+
export const AbbaErrorCode = {
|
|
5
|
+
ExperimentNotFound: 'abba/experimentNotFound',
|
|
6
|
+
ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon',
|
|
7
|
+
SegmentationDataRequired: 'abba/segmentationDataRequired',
|
|
8
|
+
InvalidBucketRatio: 'abba/invalidBucketRatio',
|
|
9
|
+
BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
4
12
|
export interface AbbaConfig {
|
|
5
13
|
db: CommonDB
|
|
6
14
|
}
|
package/src/util.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { localDate } from '@naturalcycles/js-lib/datetime'
|
|
2
|
+
import { _assert } from '@naturalcycles/js-lib/error/assert.js'
|
|
2
3
|
import type { Unsaved } from '@naturalcycles/js-lib/types'
|
|
3
4
|
import { satisfies } from 'semver'
|
|
4
5
|
import type {
|
|
@@ -13,7 +14,7 @@ import type {
|
|
|
13
14
|
UserAssignment,
|
|
14
15
|
UserExperiment,
|
|
15
16
|
} from './types.js'
|
|
16
|
-
import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
|
|
17
|
+
import { AbbaErrorCode, AssignmentStatus, SegmentationRuleOperator } from './types.js'
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Generate a new assignment for a given user.
|
|
@@ -78,9 +79,9 @@ export function determineBucket(buckets: Bucket[]): Bucket {
|
|
|
78
79
|
}
|
|
79
80
|
})
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
82
|
+
_assert(bucket, 'Could not determine bucket from ratios', {
|
|
83
|
+
code: AbbaErrorCode.BucketDeterminationFailed,
|
|
84
|
+
})
|
|
84
85
|
|
|
85
86
|
return bucket
|
|
86
87
|
}
|
|
@@ -90,9 +91,11 @@ export function determineBucket(buckets: Bucket[]): Bucket {
|
|
|
90
91
|
*/
|
|
91
92
|
export function validateTotalBucketRatio(buckets: (Unsaved<Bucket> | BucketInput)[]): void {
|
|
92
93
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
_assert(
|
|
95
|
+
bucketSum === 100,
|
|
96
|
+
'Total bucket ratio must be 100 before you can activate an experiment',
|
|
97
|
+
{ code: AbbaErrorCode.InvalidBucketRatio },
|
|
98
|
+
)
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -124,12 +127,15 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
|
|
|
124
127
|
return !keyValue
|
|
125
128
|
},
|
|
126
129
|
[SegmentationRuleOperator.EqualsText](keyValue, ruleValue) {
|
|
130
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
127
131
|
return keyValue?.toString() === ruleValue.toString()
|
|
128
132
|
},
|
|
129
133
|
[SegmentationRuleOperator.NotEqualsText](keyValue, ruleValue) {
|
|
134
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
130
135
|
return keyValue?.toString() !== ruleValue.toString()
|
|
131
136
|
},
|
|
132
137
|
[SegmentationRuleOperator.Semver](keyValue, ruleValue) {
|
|
138
|
+
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion
|
|
133
139
|
return satisfies(keyValue?.toString() || '', ruleValue.toString())
|
|
134
140
|
},
|
|
135
141
|
[SegmentationRuleOperator.Regex](keyValue, ruleValue) {
|