@naturalcycles/abba 1.9.0 → 1.9.1
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.d.ts +35 -48
- package/dist/abba.js +88 -83
- package/dist/dao/experiment.dao.js +1 -1
- package/dist/util.d.ts +0 -16
- package/dist/util.js +0 -16
- package/package.json +4 -3
- package/src/abba.ts +111 -98
- package/src/dao/experiment.dao.ts +1 -1
- package/src/types.ts +8 -0
- package/src/util.ts +0 -16
package/dist/abba.d.ts
CHANGED
|
@@ -1,88 +1,75 @@
|
|
|
1
1
|
import { Saved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { AbbaConfig, Bucket, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
|
|
2
|
+
import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
|
|
3
3
|
import { SegmentationData, AssignmentStatistics } from '.';
|
|
4
4
|
export declare class Abba {
|
|
5
|
+
cfg: AbbaConfig;
|
|
6
|
+
constructor(cfg: AbbaConfig);
|
|
5
7
|
private experimentDao;
|
|
6
8
|
private bucketDao;
|
|
7
9
|
private userAssignmentDao;
|
|
8
|
-
constructor({ db }: AbbaConfig);
|
|
9
10
|
/**
|
|
10
|
-
* Returns all experiments
|
|
11
|
+
* Returns all (active and inactive) experiments.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
+
* Cold method, not cached.
|
|
13
14
|
*/
|
|
14
|
-
getAllExperiments(
|
|
15
|
+
getAllExperiments(): Promise<ExperimentWithBuckets[]>;
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* @param buckets
|
|
20
|
-
* @returns
|
|
17
|
+
* Returns only active experiments.
|
|
18
|
+
* Hot method.
|
|
19
|
+
* Cached in-memory for N minutes (currently 10).
|
|
21
20
|
*/
|
|
22
|
-
|
|
21
|
+
getActiveExperiments(): Promise<ExperimentWithBuckets[]>;
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param id
|
|
27
|
-
* @param experiment
|
|
28
|
-
* @param rules
|
|
29
|
-
* @param buckets
|
|
30
|
-
* @returns
|
|
23
|
+
* Creates a new experiment.
|
|
24
|
+
* Cold method.
|
|
31
25
|
*/
|
|
32
|
-
|
|
26
|
+
createExperiment(experiment: Experiment, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
|
|
33
27
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
28
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
29
|
+
* Cold method.
|
|
30
|
+
*/
|
|
31
|
+
saveExperiment(experiment: Experiment & {
|
|
32
|
+
id: number;
|
|
33
|
+
}, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
36
|
+
* Cold method.
|
|
37
37
|
*/
|
|
38
38
|
deleteExperiment(id: number): Promise<void>;
|
|
39
39
|
/**
|
|
40
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
40
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
41
|
+
* Cold method.
|
|
41
42
|
*
|
|
42
43
|
* @param experimentId
|
|
43
44
|
* @param userId
|
|
44
45
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
45
46
|
* @param segmentationData Required if existingOnly is false
|
|
46
|
-
* @returns
|
|
47
47
|
*/
|
|
48
48
|
getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<Saved<UserAssignment> | null>;
|
|
49
49
|
/**
|
|
50
|
-
* Get all existing user assignments
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* @returns
|
|
50
|
+
* Get all existing user assignments.
|
|
51
|
+
* Hot method.
|
|
52
|
+
* Not cached, because Assignments are fast-changing.
|
|
54
53
|
*/
|
|
55
54
|
getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]>;
|
|
56
55
|
/**
|
|
57
|
-
* Generate user assignments for all active experiments.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* @param segmentationData
|
|
61
|
-
* @returns
|
|
56
|
+
* Generate user assignments for all active experiments.
|
|
57
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
58
|
+
* Hot method.
|
|
62
59
|
*/
|
|
63
60
|
generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<Saved<UserAssignment>[]>;
|
|
64
61
|
/**
|
|
65
|
-
* Get assignment statistics for an experiment
|
|
66
|
-
*
|
|
67
|
-
* @param experimentId
|
|
68
|
-
* @returns
|
|
62
|
+
* Get assignment statistics for an experiment.
|
|
63
|
+
* Cold method.
|
|
69
64
|
*/
|
|
70
65
|
getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
|
|
71
66
|
/**
|
|
72
|
-
* Generate a new assignment for a given user
|
|
73
|
-
*
|
|
74
|
-
* @param experimentId
|
|
75
|
-
* @param userId
|
|
76
|
-
* @param segmentationData
|
|
77
|
-
* @returns
|
|
67
|
+
* Generate a new assignment for a given user.
|
|
68
|
+
* Doesn't save it.
|
|
78
69
|
*/
|
|
79
|
-
private
|
|
70
|
+
private generateUserAssignmentData;
|
|
80
71
|
/**
|
|
81
72
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
82
|
-
*
|
|
83
|
-
* @param experimentId
|
|
84
|
-
* @param userId
|
|
85
|
-
* @returns
|
|
86
73
|
*/
|
|
87
74
|
private getExistingUserAssignment;
|
|
88
75
|
}
|
package/dist/abba.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Abba = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
4
5
|
const js_lib_1 = require("@naturalcycles/js-lib");
|
|
6
|
+
const nodejs_lib_1 = require("@naturalcycles/nodejs-lib");
|
|
5
7
|
const types_1 = require("./types");
|
|
6
8
|
const util_1 = require("./util");
|
|
7
9
|
const experiment_dao_1 = require("./dao/experiment.dao");
|
|
8
10
|
const userAssignment_dao_1 = require("./dao/userAssignment.dao");
|
|
9
11
|
const bucket_dao_1 = require("./dao/bucket.dao");
|
|
12
|
+
const CACHE_TTL = 600000; // 10 minutes
|
|
10
13
|
class Abba {
|
|
11
|
-
constructor(
|
|
14
|
+
constructor(cfg) {
|
|
15
|
+
this.cfg = cfg;
|
|
16
|
+
const { db } = cfg;
|
|
12
17
|
this.experimentDao = (0, experiment_dao_1.experimentDao)(db);
|
|
13
18
|
this.bucketDao = (0, bucket_dao_1.bucketDao)(db);
|
|
14
19
|
this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
|
|
15
20
|
}
|
|
16
|
-
// TODO: Cache me
|
|
17
21
|
/**
|
|
18
|
-
* Returns all experiments
|
|
22
|
+
* Returns all (active and inactive) experiments.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
24
|
+
* Cold method, not cached.
|
|
21
25
|
*/
|
|
22
|
-
async getAllExperiments(
|
|
23
|
-
const
|
|
24
|
-
if (excludeInactive) {
|
|
25
|
-
query.filter('status', '!=', types_1.AssignmentStatus.Inactive);
|
|
26
|
-
}
|
|
27
|
-
const experiments = await this.experimentDao.runQuery(query);
|
|
26
|
+
async getAllExperiments() {
|
|
27
|
+
const experiments = await this.experimentDao.query().runQuery();
|
|
28
28
|
const buckets = await this.bucketDao
|
|
29
29
|
.query()
|
|
30
30
|
.filter('experimentId', 'in', experiments.map(e => e.id))
|
|
@@ -35,115 +35,120 @@ class Abba {
|
|
|
35
35
|
}));
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
* Returns only active experiments.
|
|
39
|
+
* Hot method.
|
|
40
|
+
* Cached in-memory for N minutes (currently 10).
|
|
41
|
+
*/
|
|
42
|
+
async getActiveExperiments() {
|
|
43
|
+
const experiments = await this.experimentDao
|
|
44
|
+
.query()
|
|
45
|
+
.filter('status', '!=', types_1.AssignmentStatus.Inactive)
|
|
46
|
+
.runQuery();
|
|
47
|
+
const buckets = await this.bucketDao
|
|
48
|
+
.query()
|
|
49
|
+
.filter('experimentId', 'in', experiments.map(e => e.id))
|
|
50
|
+
.runQuery();
|
|
51
|
+
return experiments.map(experiment => ({
|
|
52
|
+
...experiment,
|
|
53
|
+
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Creates a new experiment.
|
|
58
|
+
* Cold method.
|
|
43
59
|
*/
|
|
44
60
|
async createExperiment(experiment, buckets) {
|
|
45
61
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
46
62
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
47
63
|
}
|
|
48
|
-
|
|
64
|
+
await this.experimentDao.save(experiment);
|
|
49
65
|
return {
|
|
50
|
-
...
|
|
51
|
-
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId:
|
|
66
|
+
...experiment,
|
|
67
|
+
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
|
|
52
68
|
};
|
|
53
69
|
}
|
|
54
70
|
/**
|
|
55
|
-
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
56
|
-
*
|
|
57
|
-
* @param id
|
|
58
|
-
* @param experiment
|
|
59
|
-
* @param rules
|
|
60
|
-
* @param buckets
|
|
61
|
-
* @returns
|
|
71
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
72
|
+
* Cold method.
|
|
62
73
|
*/
|
|
63
|
-
async saveExperiment(
|
|
74
|
+
async saveExperiment(experiment, buckets) {
|
|
64
75
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
65
76
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
66
77
|
}
|
|
67
|
-
|
|
68
|
-
...experiment,
|
|
69
|
-
id,
|
|
70
|
-
});
|
|
78
|
+
await this.experimentDao.save(experiment);
|
|
71
79
|
return {
|
|
72
|
-
...
|
|
73
|
-
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
|
|
80
|
+
...experiment,
|
|
81
|
+
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
|
|
74
82
|
};
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
|
-
* Delete an experiment. Removes all user assignments and buckets
|
|
78
|
-
*
|
|
79
|
-
* @param id
|
|
85
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
86
|
+
* Cold method.
|
|
80
87
|
*/
|
|
81
88
|
async deleteExperiment(id) {
|
|
82
89
|
await this.experimentDao.deleteById(id);
|
|
83
90
|
}
|
|
84
91
|
/**
|
|
85
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
92
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
93
|
+
* Cold method.
|
|
86
94
|
*
|
|
87
95
|
* @param experimentId
|
|
88
96
|
* @param userId
|
|
89
97
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
90
98
|
* @param segmentationData Required if existingOnly is false
|
|
91
|
-
* @returns
|
|
92
99
|
*/
|
|
93
100
|
async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
|
|
94
|
-
const experiment = await this.experimentDao.requireById(experimentId);
|
|
95
|
-
if (!experiment)
|
|
96
|
-
throw new Error('Experiment not found');
|
|
97
|
-
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
98
101
|
const existing = await this.getExistingUserAssignment(experimentId, userId);
|
|
99
102
|
if (existing)
|
|
100
103
|
return existing;
|
|
101
|
-
if (
|
|
104
|
+
if (existingOnly)
|
|
102
105
|
return null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
const experiment = await this.experimentDao.requireById(experimentId);
|
|
107
|
+
if (experiment.status !== types_1.AssignmentStatus.Active)
|
|
108
|
+
return null;
|
|
109
|
+
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
110
|
+
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
111
|
+
const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
|
|
112
|
+
if (!assignment)
|
|
113
|
+
return null;
|
|
114
|
+
return await this.userAssignmentDao.save(assignment);
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
108
|
-
* Get all existing user assignments
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* @returns
|
|
117
|
+
* Get all existing user assignments.
|
|
118
|
+
* Hot method.
|
|
119
|
+
* Not cached, because Assignments are fast-changing.
|
|
112
120
|
*/
|
|
113
121
|
async getAllExistingUserAssignments(userId) {
|
|
114
122
|
return await this.userAssignmentDao.getBy('userId', userId);
|
|
115
123
|
}
|
|
116
124
|
/**
|
|
117
|
-
* Generate user assignments for all active experiments.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* @param segmentationData
|
|
121
|
-
* @returns
|
|
125
|
+
* Generate user assignments for all active experiments.
|
|
126
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
127
|
+
* Hot method.
|
|
122
128
|
*/
|
|
123
129
|
async generateUserAssignments(userId, segmentationData) {
|
|
124
|
-
const experiments = await this.
|
|
130
|
+
const experiments = await this.getActiveExperiments(); // cached
|
|
125
131
|
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
126
|
-
const
|
|
132
|
+
const allAssignments = [];
|
|
127
133
|
const generatedAssignments = [];
|
|
128
134
|
for (const experiment of experiments) {
|
|
129
135
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
130
136
|
if (existing) {
|
|
131
|
-
|
|
132
|
-
continue;
|
|
137
|
+
allAssignments.push(existing);
|
|
133
138
|
}
|
|
134
139
|
else {
|
|
135
|
-
|
|
140
|
+
const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData);
|
|
141
|
+
if (assignment) {
|
|
142
|
+
generatedAssignments.push(assignment);
|
|
143
|
+
}
|
|
136
144
|
}
|
|
137
145
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return [...assignments, ...filtered];
|
|
146
|
+
await this.userAssignmentDao.saveBatch(generatedAssignments);
|
|
147
|
+
return [...allAssignments, ...generatedAssignments];
|
|
141
148
|
}
|
|
142
149
|
/**
|
|
143
|
-
* Get assignment statistics for an experiment
|
|
144
|
-
*
|
|
145
|
-
* @param experimentId
|
|
146
|
-
* @returns
|
|
150
|
+
* Get assignment statistics for an experiment.
|
|
151
|
+
* Cold method.
|
|
147
152
|
*/
|
|
148
153
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
149
154
|
const statistics = {
|
|
@@ -155,40 +160,40 @@ class Abba {
|
|
|
155
160
|
};
|
|
156
161
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
157
162
|
await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
statistics[bucket.id] = await this.userAssignmentDao
|
|
164
|
+
.query()
|
|
165
|
+
.filterEq('bucketId', bucket.id)
|
|
166
|
+
.runQueryCount();
|
|
160
167
|
});
|
|
161
168
|
return statistics;
|
|
162
169
|
}
|
|
163
170
|
/**
|
|
164
|
-
* Generate a new assignment for a given user
|
|
165
|
-
*
|
|
166
|
-
* @param experimentId
|
|
167
|
-
* @param userId
|
|
168
|
-
* @param segmentationData
|
|
169
|
-
* @returns
|
|
171
|
+
* Generate a new assignment for a given user.
|
|
172
|
+
* Doesn't save it.
|
|
170
173
|
*/
|
|
171
|
-
|
|
174
|
+
generateUserAssignmentData(experiment, userId, segmentationData) {
|
|
172
175
|
const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
|
|
173
176
|
if (!segmentationMatch)
|
|
174
177
|
return null;
|
|
175
|
-
return
|
|
178
|
+
return {
|
|
176
179
|
userId,
|
|
177
180
|
experimentId: experiment.id,
|
|
178
181
|
bucketId: (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets),
|
|
179
|
-
}
|
|
182
|
+
};
|
|
180
183
|
}
|
|
181
184
|
/**
|
|
182
185
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
183
|
-
*
|
|
184
|
-
* @param experimentId
|
|
185
|
-
* @param userId
|
|
186
|
-
* @returns
|
|
187
186
|
*/
|
|
188
187
|
async getExistingUserAssignment(experimentId, userId) {
|
|
189
|
-
const
|
|
190
|
-
|
|
188
|
+
const [assignment] = await this.userAssignmentDao
|
|
189
|
+
.query()
|
|
190
|
+
.filterEq('userId', userId)
|
|
191
|
+
.filterEq('experimentId', experimentId)
|
|
192
|
+
.runQuery();
|
|
191
193
|
return assignment || null;
|
|
192
194
|
}
|
|
193
195
|
}
|
|
196
|
+
tslib_1.__decorate([
|
|
197
|
+
(0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
198
|
+
], Abba.prototype, "getActiveExperiments", null);
|
|
194
199
|
exports.Abba = Abba;
|
|
@@ -13,7 +13,7 @@ const experimentDao = (db) => new ExperimentDao({
|
|
|
13
13
|
assignGeneratedIds: true,
|
|
14
14
|
hooks: {
|
|
15
15
|
beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
|
|
16
|
-
beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
|
|
16
|
+
beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
19
|
exports.experimentDao = experimentDao;
|
package/dist/util.d.ts
CHANGED
|
@@ -2,30 +2,18 @@ import { Saved } from '@naturalcycles/js-lib';
|
|
|
2
2
|
import { Bucket, SegmentationData, SegmentationRule } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Generate a random number between 0 and 100
|
|
5
|
-
*
|
|
6
|
-
* @returns
|
|
7
5
|
*/
|
|
8
6
|
export declare const rollDie: () => number;
|
|
9
7
|
/**
|
|
10
8
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
11
|
-
*
|
|
12
|
-
* @param sampling
|
|
13
|
-
* @param buckets
|
|
14
|
-
* @returns
|
|
15
9
|
*/
|
|
16
10
|
export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) => number | null;
|
|
17
11
|
/**
|
|
18
12
|
* Determines which bucket a user assignment will recieve
|
|
19
|
-
*
|
|
20
|
-
* @param buckets
|
|
21
|
-
* @returns
|
|
22
13
|
*/
|
|
23
14
|
export declare const determineBucket: (buckets: Saved<Bucket>[]) => number;
|
|
24
15
|
/**
|
|
25
16
|
* Validate the total ratio of the buckets equals 100
|
|
26
|
-
*
|
|
27
|
-
* @param buckets
|
|
28
|
-
* @returns
|
|
29
17
|
*/
|
|
30
18
|
export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
|
|
31
19
|
/**
|
|
@@ -38,9 +26,5 @@ export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
|
|
|
38
26
|
export declare const validateSegmentationRules: (rules: SegmentationRule[], segmentationData: SegmentationData) => boolean;
|
|
39
27
|
/**
|
|
40
28
|
* Validate a users segmentation data against a single rule
|
|
41
|
-
*
|
|
42
|
-
* @param rule
|
|
43
|
-
* @param segmentationData
|
|
44
|
-
* @returns
|
|
45
29
|
*/
|
|
46
30
|
export declare const validateSegmentationRule: (rule: SegmentationRule, data: SegmentationData) => boolean;
|
package/dist/util.js
CHANGED
|
@@ -4,8 +4,6 @@ exports.validateSegmentationRule = exports.validateSegmentationRules = exports.v
|
|
|
4
4
|
const semver_1 = require("semver");
|
|
5
5
|
/**
|
|
6
6
|
* Generate a random number between 0 and 100
|
|
7
|
-
*
|
|
8
|
-
* @returns
|
|
9
7
|
*/
|
|
10
8
|
const rollDie = () => {
|
|
11
9
|
return Math.random() * 100;
|
|
@@ -13,10 +11,6 @@ const rollDie = () => {
|
|
|
13
11
|
exports.rollDie = rollDie;
|
|
14
12
|
/**
|
|
15
13
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
-
*
|
|
17
|
-
* @param sampling
|
|
18
|
-
* @param buckets
|
|
19
|
-
* @returns
|
|
20
14
|
*/
|
|
21
15
|
const determineAssignment = (sampling, buckets) => {
|
|
22
16
|
// Should this person be considered for the experiment?
|
|
@@ -29,9 +23,6 @@ const determineAssignment = (sampling, buckets) => {
|
|
|
29
23
|
exports.determineAssignment = determineAssignment;
|
|
30
24
|
/**
|
|
31
25
|
* Determines which bucket a user assignment will recieve
|
|
32
|
-
*
|
|
33
|
-
* @param buckets
|
|
34
|
-
* @returns
|
|
35
26
|
*/
|
|
36
27
|
const determineBucket = (buckets) => {
|
|
37
28
|
const bucketRoll = (0, exports.rollDie)();
|
|
@@ -55,9 +46,6 @@ const determineBucket = (buckets) => {
|
|
|
55
46
|
exports.determineBucket = determineBucket;
|
|
56
47
|
/**
|
|
57
48
|
* Validate the total ratio of the buckets equals 100
|
|
58
|
-
*
|
|
59
|
-
* @param buckets
|
|
60
|
-
* @returns
|
|
61
49
|
*/
|
|
62
50
|
const validateTotalBucketRatio = (buckets) => {
|
|
63
51
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
@@ -83,10 +71,6 @@ const validateSegmentationRules = (rules, segmentationData) => {
|
|
|
83
71
|
exports.validateSegmentationRules = validateSegmentationRules;
|
|
84
72
|
/**
|
|
85
73
|
* Validate a users segmentation data against a single rule
|
|
86
|
-
*
|
|
87
|
-
* @param rule
|
|
88
|
-
* @param segmentationData
|
|
89
|
-
* @returns
|
|
90
74
|
*/
|
|
91
75
|
const validateSegmentationRule = (rule, data) => {
|
|
92
76
|
const { key, value, operator } = rule;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepare": "husky install",
|
|
6
6
|
"build": "build",
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@naturalcycles/db-lib": "^8.40.1",
|
|
11
11
|
"@naturalcycles/js-lib": "^14.98.2",
|
|
12
|
+
"@naturalcycles/nodejs-lib": "^12.70.1",
|
|
12
13
|
"semver": "^7.3.5"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@naturalcycles/dev-lib": "^12.19.2",
|
|
16
|
-
"@types/node": "^
|
|
17
|
+
"@types/node": "^17.0.34",
|
|
17
18
|
"@types/semver": "^7.3.9",
|
|
18
|
-
"jest": "^
|
|
19
|
+
"jest": "^28.1.0"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist",
|
package/src/abba.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { pMap, Saved } from '@naturalcycles/js-lib'
|
|
1
|
+
import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
|
|
2
|
+
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
2
3
|
import {
|
|
3
4
|
AbbaConfig,
|
|
4
5
|
AssignmentStatus,
|
|
5
6
|
Bucket,
|
|
7
|
+
BucketInput,
|
|
6
8
|
Experiment,
|
|
7
9
|
ExperimentWithBuckets,
|
|
8
10
|
UserAssignment,
|
|
@@ -11,32 +13,30 @@ import { determineAssignment, validateSegmentationRules, validateTotalBucketRati
|
|
|
11
13
|
import { ExperimentDao, experimentDao } from './dao/experiment.dao'
|
|
12
14
|
import { UserAssignmentDao, userAssignmentDao } from './dao/userAssignment.dao'
|
|
13
15
|
import { BucketDao, bucketDao } from './dao/bucket.dao'
|
|
14
|
-
import { SegmentationData,
|
|
16
|
+
import { SegmentationData, AssignmentStatistics } from '.'
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
private experimentDao: ExperimentDao
|
|
18
|
-
private bucketDao: BucketDao
|
|
19
|
-
private userAssignmentDao: UserAssignmentDao
|
|
18
|
+
const CACHE_TTL = 600_000 // 10 minutes
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
export class Abba {
|
|
21
|
+
constructor(public cfg: AbbaConfig) {
|
|
22
|
+
const { db } = cfg
|
|
22
23
|
this.experimentDao = experimentDao(db)
|
|
23
24
|
this.bucketDao = bucketDao(db)
|
|
24
25
|
this.userAssignmentDao = userAssignmentDao(db)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
private experimentDao: ExperimentDao
|
|
29
|
+
private bucketDao: BucketDao
|
|
30
|
+
private userAssignmentDao: UserAssignmentDao
|
|
31
|
+
|
|
28
32
|
/**
|
|
29
|
-
* Returns all experiments
|
|
33
|
+
* Returns all (active and inactive) experiments.
|
|
30
34
|
*
|
|
31
|
-
*
|
|
35
|
+
* Cold method, not cached.
|
|
32
36
|
*/
|
|
33
|
-
async getAllExperiments(
|
|
34
|
-
const
|
|
35
|
-
if (excludeInactive) {
|
|
36
|
-
query.filter('status', '!=', AssignmentStatus.Inactive)
|
|
37
|
-
}
|
|
37
|
+
async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
|
|
38
|
+
const experiments = await this.experimentDao.query().runQuery()
|
|
38
39
|
|
|
39
|
-
const experiments = await this.experimentDao.runQuery(query)
|
|
40
40
|
const buckets = await this.bucketDao
|
|
41
41
|
.query()
|
|
42
42
|
.filter(
|
|
@@ -53,76 +53,92 @@ export class Abba {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
* Returns only active experiments.
|
|
57
|
+
* Hot method.
|
|
58
|
+
* Cached in-memory for N minutes (currently 10).
|
|
59
|
+
*/
|
|
60
|
+
@_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
61
|
+
async getActiveExperiments(): Promise<ExperimentWithBuckets[]> {
|
|
62
|
+
const experiments = await this.experimentDao
|
|
63
|
+
.query()
|
|
64
|
+
.filter('status', '!=', AssignmentStatus.Inactive)
|
|
65
|
+
.runQuery()
|
|
66
|
+
|
|
67
|
+
const buckets = await this.bucketDao
|
|
68
|
+
.query()
|
|
69
|
+
.filter(
|
|
70
|
+
'experimentId',
|
|
71
|
+
'in',
|
|
72
|
+
experiments.map(e => e.id),
|
|
73
|
+
)
|
|
74
|
+
.runQuery()
|
|
75
|
+
|
|
76
|
+
return experiments.map(experiment => ({
|
|
77
|
+
...experiment,
|
|
78
|
+
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a new experiment.
|
|
84
|
+
* Cold method.
|
|
61
85
|
*/
|
|
62
86
|
async createExperiment(
|
|
63
87
|
experiment: Experiment,
|
|
64
|
-
buckets:
|
|
88
|
+
buckets: BucketInput[],
|
|
65
89
|
): Promise<ExperimentWithBuckets> {
|
|
66
90
|
if (experiment.status === AssignmentStatus.Active) {
|
|
67
91
|
validateTotalBucketRatio(buckets)
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
|
|
94
|
+
await this.experimentDao.save(experiment)
|
|
71
95
|
|
|
72
96
|
return {
|
|
73
|
-
...
|
|
97
|
+
...(experiment as Saved<Experiment>),
|
|
74
98
|
buckets: await this.bucketDao.saveBatch(
|
|
75
|
-
buckets.map(b => ({ ...b, experimentId:
|
|
99
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id! })),
|
|
76
100
|
),
|
|
77
101
|
}
|
|
78
102
|
}
|
|
79
103
|
|
|
80
104
|
/**
|
|
81
|
-
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
82
|
-
*
|
|
83
|
-
* @param id
|
|
84
|
-
* @param experiment
|
|
85
|
-
* @param rules
|
|
86
|
-
* @param buckets
|
|
87
|
-
* @returns
|
|
105
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
106
|
+
* Cold method.
|
|
88
107
|
*/
|
|
89
108
|
async saveExperiment(
|
|
90
|
-
id: number,
|
|
91
|
-
experiment: Experiment,
|
|
109
|
+
experiment: Experiment & { id: number },
|
|
92
110
|
buckets: Bucket[],
|
|
93
111
|
): Promise<ExperimentWithBuckets> {
|
|
94
112
|
if (experiment.status === AssignmentStatus.Active) {
|
|
95
113
|
validateTotalBucketRatio(buckets)
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
...experiment,
|
|
100
|
-
id,
|
|
101
|
-
})
|
|
116
|
+
await this.experimentDao.save(experiment)
|
|
102
117
|
|
|
103
118
|
return {
|
|
104
|
-
...
|
|
105
|
-
buckets: await this.bucketDao.saveBatch(
|
|
119
|
+
...(experiment as Saved<Experiment>),
|
|
120
|
+
buckets: await this.bucketDao.saveBatch(
|
|
121
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
122
|
+
),
|
|
106
123
|
}
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
/**
|
|
110
|
-
* Delete an experiment. Removes all user assignments and buckets
|
|
111
|
-
*
|
|
112
|
-
* @param id
|
|
127
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
128
|
+
* Cold method.
|
|
113
129
|
*/
|
|
114
130
|
async deleteExperiment(id: number): Promise<void> {
|
|
115
131
|
await this.experimentDao.deleteById(id)
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
/**
|
|
119
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
135
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
136
|
+
* Cold method.
|
|
120
137
|
*
|
|
121
138
|
* @param experimentId
|
|
122
139
|
* @param userId
|
|
123
140
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
124
141
|
* @param segmentationData Required if existingOnly is false
|
|
125
|
-
* @returns
|
|
126
142
|
*/
|
|
127
143
|
async getUserAssignment(
|
|
128
144
|
experimentId: number,
|
|
@@ -130,69 +146,71 @@ export class Abba {
|
|
|
130
146
|
existingOnly: boolean,
|
|
131
147
|
segmentationData?: SegmentationData,
|
|
132
148
|
): Promise<Saved<UserAssignment> | null> {
|
|
133
|
-
const experiment = await this.experimentDao.requireById(experimentId)
|
|
134
|
-
if (!experiment) throw new Error('Experiment not found')
|
|
135
|
-
|
|
136
|
-
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
137
|
-
|
|
138
149
|
const existing = await this.getExistingUserAssignment(experimentId, userId)
|
|
139
150
|
if (existing) return existing
|
|
151
|
+
if (existingOnly) return null
|
|
152
|
+
|
|
153
|
+
const experiment = await this.experimentDao.requireById(experimentId)
|
|
154
|
+
if (experiment.status !== AssignmentStatus.Active) return null
|
|
155
|
+
|
|
156
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
140
157
|
|
|
141
|
-
|
|
158
|
+
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
142
159
|
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const assignment = this.generateUserAssignmentData(
|
|
161
|
+
{ ...experiment, buckets },
|
|
162
|
+
userId,
|
|
163
|
+
segmentationData,
|
|
164
|
+
)
|
|
165
|
+
if (!assignment) return null
|
|
145
166
|
|
|
146
|
-
return await this.
|
|
167
|
+
return await this.userAssignmentDao.save(assignment)
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
/**
|
|
150
|
-
* Get all existing user assignments
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* @returns
|
|
171
|
+
* Get all existing user assignments.
|
|
172
|
+
* Hot method.
|
|
173
|
+
* Not cached, because Assignments are fast-changing.
|
|
154
174
|
*/
|
|
155
175
|
async getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]> {
|
|
156
176
|
return await this.userAssignmentDao.getBy('userId', userId)
|
|
157
177
|
}
|
|
158
178
|
|
|
159
179
|
/**
|
|
160
|
-
* Generate user assignments for all active experiments.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* @param segmentationData
|
|
164
|
-
* @returns
|
|
180
|
+
* Generate user assignments for all active experiments.
|
|
181
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
182
|
+
* Hot method.
|
|
165
183
|
*/
|
|
166
184
|
async generateUserAssignments(
|
|
167
185
|
userId: string,
|
|
168
186
|
segmentationData: SegmentationData,
|
|
169
187
|
): Promise<Saved<UserAssignment>[]> {
|
|
170
|
-
const experiments = await this.
|
|
188
|
+
const experiments = await this.getActiveExperiments() // cached
|
|
171
189
|
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
172
190
|
|
|
173
|
-
const
|
|
174
|
-
const generatedAssignments:
|
|
191
|
+
const allAssignments: Saved<UserAssignment>[] = []
|
|
192
|
+
const generatedAssignments: UserAssignment[] = []
|
|
175
193
|
|
|
176
194
|
for (const experiment of experiments) {
|
|
177
195
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
|
|
178
196
|
if (existing) {
|
|
179
|
-
|
|
180
|
-
continue
|
|
197
|
+
allAssignments.push(existing)
|
|
181
198
|
} else {
|
|
182
|
-
|
|
199
|
+
const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData)
|
|
200
|
+
if (assignment) {
|
|
201
|
+
generatedAssignments.push(assignment)
|
|
202
|
+
}
|
|
183
203
|
}
|
|
184
204
|
}
|
|
185
205
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return [...
|
|
206
|
+
await this.userAssignmentDao.saveBatch(generatedAssignments)
|
|
207
|
+
|
|
208
|
+
return [...allAssignments, ...(generatedAssignments as Saved<UserAssignment>[])]
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
/**
|
|
192
|
-
* Get assignment statistics for an experiment
|
|
193
|
-
*
|
|
194
|
-
* @param experimentId
|
|
195
|
-
* @returns
|
|
212
|
+
* Get assignment statistics for an experiment.
|
|
213
|
+
* Cold method.
|
|
196
214
|
*/
|
|
197
215
|
async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
|
|
198
216
|
const statistics = {
|
|
@@ -205,52 +223,47 @@ export class Abba {
|
|
|
205
223
|
|
|
206
224
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
207
225
|
await pMap(buckets, async bucket => {
|
|
208
|
-
|
|
209
|
-
|
|
226
|
+
statistics[bucket.id] = await this.userAssignmentDao
|
|
227
|
+
.query()
|
|
228
|
+
.filterEq('bucketId', bucket.id)
|
|
229
|
+
.runQueryCount()
|
|
210
230
|
})
|
|
211
231
|
|
|
212
232
|
return statistics
|
|
213
233
|
}
|
|
214
234
|
|
|
215
235
|
/**
|
|
216
|
-
* Generate a new assignment for a given user
|
|
217
|
-
*
|
|
218
|
-
* @param experimentId
|
|
219
|
-
* @param userId
|
|
220
|
-
* @param segmentationData
|
|
221
|
-
* @returns
|
|
236
|
+
* Generate a new assignment for a given user.
|
|
237
|
+
* Doesn't save it.
|
|
222
238
|
*/
|
|
223
|
-
private
|
|
239
|
+
private generateUserAssignmentData(
|
|
224
240
|
experiment: ExperimentWithBuckets,
|
|
225
241
|
userId: string,
|
|
226
242
|
segmentationData: SegmentationData,
|
|
227
|
-
):
|
|
228
|
-
const segmentationMatch = validateSegmentationRules(
|
|
229
|
-
experiment.rules as unknown as SegmentationRule[],
|
|
230
|
-
segmentationData,
|
|
231
|
-
)
|
|
243
|
+
): UserAssignment | null {
|
|
244
|
+
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
|
|
232
245
|
if (!segmentationMatch) return null
|
|
233
246
|
|
|
234
|
-
return
|
|
247
|
+
return {
|
|
235
248
|
userId,
|
|
236
249
|
experimentId: experiment.id,
|
|
237
250
|
bucketId: determineAssignment(experiment.sampling, experiment.buckets),
|
|
238
|
-
}
|
|
251
|
+
}
|
|
239
252
|
}
|
|
240
253
|
|
|
241
254
|
/**
|
|
242
255
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
243
|
-
*
|
|
244
|
-
* @param experimentId
|
|
245
|
-
* @param userId
|
|
246
|
-
* @returns
|
|
247
256
|
*/
|
|
248
257
|
private async getExistingUserAssignment(
|
|
249
258
|
experimentId: number,
|
|
250
259
|
userId: string,
|
|
251
260
|
): Promise<Saved<UserAssignment> | null> {
|
|
252
|
-
const
|
|
253
|
-
|
|
261
|
+
const [assignment] = await this.userAssignmentDao
|
|
262
|
+
.query()
|
|
263
|
+
.filterEq('userId', userId)
|
|
264
|
+
.filterEq('experimentId', experimentId)
|
|
265
|
+
.runQuery()
|
|
266
|
+
|
|
254
267
|
return assignment || null
|
|
255
268
|
}
|
|
256
269
|
}
|
|
@@ -17,6 +17,6 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
17
17
|
assignGeneratedIds: true,
|
|
18
18
|
hooks: {
|
|
19
19
|
beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
|
|
20
|
-
beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
|
|
20
|
+
beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
|
|
21
21
|
},
|
|
22
22
|
})
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { BaseDBEntity, Saved } from '@naturalcycles/js-lib'
|
|
|
3
3
|
|
|
4
4
|
export interface AbbaConfig {
|
|
5
5
|
db: CommonDB
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Determines for how long to cache "hot" requests.
|
|
9
|
+
* Caches in memory.
|
|
10
|
+
* Default is 10.
|
|
11
|
+
* Set to 0 to disable cache (every request will hit the DB).
|
|
12
|
+
*/
|
|
13
|
+
// cacheMinutes?: number
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export type BaseExperiment = BaseDBEntity<number> & {
|
package/src/util.ts
CHANGED
|
@@ -4,8 +4,6 @@ import { Bucket, SegmentationData, SegmentationRule } from './types'
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Generate a random number between 0 and 100
|
|
7
|
-
*
|
|
8
|
-
* @returns
|
|
9
7
|
*/
|
|
10
8
|
export const rollDie = (): number => {
|
|
11
9
|
return Math.random() * 100
|
|
@@ -13,10 +11,6 @@ export const rollDie = (): number => {
|
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
-
*
|
|
17
|
-
* @param sampling
|
|
18
|
-
* @param buckets
|
|
19
|
-
* @returns
|
|
20
14
|
*/
|
|
21
15
|
export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]): number | null => {
|
|
22
16
|
// Should this person be considered for the experiment?
|
|
@@ -30,9 +24,6 @@ export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]):
|
|
|
30
24
|
|
|
31
25
|
/**
|
|
32
26
|
* Determines which bucket a user assignment will recieve
|
|
33
|
-
*
|
|
34
|
-
* @param buckets
|
|
35
|
-
* @returns
|
|
36
27
|
*/
|
|
37
28
|
export const determineBucket = (buckets: Saved<Bucket>[]): number => {
|
|
38
29
|
const bucketRoll = rollDie()
|
|
@@ -58,9 +49,6 @@ export const determineBucket = (buckets: Saved<Bucket>[]): number => {
|
|
|
58
49
|
|
|
59
50
|
/**
|
|
60
51
|
* Validate the total ratio of the buckets equals 100
|
|
61
|
-
*
|
|
62
|
-
* @param buckets
|
|
63
|
-
* @returns
|
|
64
52
|
*/
|
|
65
53
|
export const validateTotalBucketRatio = (buckets: Bucket[]): void => {
|
|
66
54
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
|
|
@@ -88,10 +76,6 @@ export const validateSegmentationRules = (
|
|
|
88
76
|
|
|
89
77
|
/**
|
|
90
78
|
* Validate a users segmentation data against a single rule
|
|
91
|
-
*
|
|
92
|
-
* @param rule
|
|
93
|
-
* @param segmentationData
|
|
94
|
-
* @returns
|
|
95
79
|
*/
|
|
96
80
|
export const validateSegmentationRule = (
|
|
97
81
|
rule: SegmentationRule,
|