@naturalcycles/abba 1.24.0 → 1.25.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 +2 -2
- package/dist/abba.js +60 -29
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +8 -5
- package/dist/util.js +1 -1
- package/package.json +10 -5
- package/src/abba.ts +62 -27
- package/src/index.ts +1 -1
- package/src/types.ts +9 -4
- package/src/util.ts +2 -2
package/dist/abba.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment } from './types';
|
|
3
2
|
import { SegmentationData } from '.';
|
|
3
|
+
import { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment } from './types';
|
|
4
4
|
export declare class Abba {
|
|
5
5
|
cfg: AbbaConfig;
|
|
6
6
|
private experimentDao;
|
|
@@ -48,7 +48,7 @@ export declare class Abba {
|
|
|
48
48
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
49
49
|
* @param segmentationData Required if existingOnly is false
|
|
50
50
|
*/
|
|
51
|
-
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment
|
|
51
|
+
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment>;
|
|
52
52
|
/**
|
|
53
53
|
* Get all existing user assignments.
|
|
54
54
|
* Hot method.
|
package/dist/abba.js
CHANGED
|
@@ -32,7 +32,7 @@ class Abba {
|
|
|
32
32
|
*/
|
|
33
33
|
async updateUserId(oldId, newId) {
|
|
34
34
|
const query = this.userAssignmentDao.query().filterEq('userId', oldId);
|
|
35
|
-
await this.userAssignmentDao.
|
|
35
|
+
await this.userAssignmentDao.patchByQuery(query, { userId: newId });
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* Returns all experiments.
|
|
@@ -128,39 +128,61 @@ class Abba {
|
|
|
128
128
|
const experiment = await this.experimentDao.getOneBy('key', experimentKey);
|
|
129
129
|
(0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentKey}`);
|
|
130
130
|
// Inactive experiments should never return an assignment
|
|
131
|
-
if (experiment.status === types_1.AssignmentStatus.Inactive)
|
|
132
|
-
return
|
|
131
|
+
if (experiment.status === types_1.AssignmentStatus.Inactive) {
|
|
132
|
+
return {
|
|
133
|
+
experiment,
|
|
134
|
+
assignment: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
133
137
|
const buckets = await this.bucketDao.getBy('experimentId', experiment.id);
|
|
134
138
|
const existingAssignments = await this.userAssignmentDao.getBy('userId', userId);
|
|
135
139
|
const existing = existingAssignments.find(a => a.experimentId === experiment.id);
|
|
136
140
|
if (existing) {
|
|
137
141
|
const bucket = buckets.find(b => b.id === existing.bucketId);
|
|
138
142
|
return {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
experiment,
|
|
144
|
+
assignment: {
|
|
145
|
+
...existing,
|
|
146
|
+
experimentKey: experiment.key,
|
|
147
|
+
bucketKey: bucket?.key || null,
|
|
148
|
+
bucketData: bucket?.data || null,
|
|
149
|
+
},
|
|
143
150
|
};
|
|
144
151
|
}
|
|
145
152
|
// No existing assignment, but we don't want to generate a new one
|
|
146
|
-
if (existingOnly || experiment.status === types_1.AssignmentStatus.Paused)
|
|
147
|
-
return
|
|
153
|
+
if (existingOnly || experiment.status === types_1.AssignmentStatus.Paused) {
|
|
154
|
+
return {
|
|
155
|
+
experiment,
|
|
156
|
+
assignment: null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
148
159
|
const experiments = await this.getAllExperiments();
|
|
149
160
|
const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
|
|
150
|
-
if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
|
|
151
|
-
return
|
|
161
|
+
if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet)) {
|
|
162
|
+
return {
|
|
163
|
+
experiment,
|
|
164
|
+
assignment: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
152
167
|
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
153
168
|
const experimentWithBuckets = { ...experiment, buckets };
|
|
154
169
|
const assignment = (0, util_1.generateUserAssignmentData)(experimentWithBuckets, userId, segmentationData);
|
|
155
|
-
if (!assignment)
|
|
156
|
-
return
|
|
170
|
+
if (!assignment) {
|
|
171
|
+
return {
|
|
172
|
+
experiment,
|
|
173
|
+
assignment: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
157
176
|
const newAssignment = await this.userAssignmentDao.save(assignment);
|
|
158
177
|
const bucket = buckets.find(b => b.id === newAssignment.bucketId);
|
|
159
178
|
return {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
experiment,
|
|
180
|
+
assignment: {
|
|
181
|
+
...newAssignment,
|
|
182
|
+
experimentKey: experiment.key,
|
|
183
|
+
bucketKey: bucket?.key || null,
|
|
184
|
+
bucketData: bucket?.data || null,
|
|
185
|
+
},
|
|
164
186
|
};
|
|
165
187
|
}
|
|
166
188
|
/**
|
|
@@ -175,10 +197,13 @@ class Abba {
|
|
|
175
197
|
const experiment = await this.experimentDao.requireById(assignment.experimentId);
|
|
176
198
|
const bucket = await this.bucketDao.getById(assignment.bucketId);
|
|
177
199
|
return {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
200
|
+
experiment,
|
|
201
|
+
assignment: {
|
|
202
|
+
...assignment,
|
|
203
|
+
experimentKey: experiment.key,
|
|
204
|
+
bucketKey: bucket?.key || null,
|
|
205
|
+
bucketData: bucket?.data || null,
|
|
206
|
+
},
|
|
182
207
|
};
|
|
183
208
|
});
|
|
184
209
|
}
|
|
@@ -202,10 +227,13 @@ class Abba {
|
|
|
202
227
|
if (existing) {
|
|
203
228
|
const bucket = experiment.buckets.find(b => b.id === existing.bucketId);
|
|
204
229
|
assignments.push({
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
experiment,
|
|
231
|
+
assignment: {
|
|
232
|
+
...existing,
|
|
233
|
+
experimentKey: experiment.key,
|
|
234
|
+
bucketKey: bucket?.key || null,
|
|
235
|
+
bucketData: bucket?.data || null,
|
|
236
|
+
},
|
|
209
237
|
});
|
|
210
238
|
}
|
|
211
239
|
else if (!existingOnly && (0, util_1.canGenerateNewAssignments)(experiment, exclusionSet)) {
|
|
@@ -215,10 +243,13 @@ class Abba {
|
|
|
215
243
|
newAssignments.push(created);
|
|
216
244
|
const bucket = experiment.buckets.find(b => b.id === created.bucketId);
|
|
217
245
|
assignments.push({
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
246
|
+
experiment,
|
|
247
|
+
assignment: {
|
|
248
|
+
...created,
|
|
249
|
+
experimentKey: experiment.key,
|
|
250
|
+
bucketKey: bucket?.key || null,
|
|
251
|
+
bucketData: bucket?.data || null,
|
|
252
|
+
},
|
|
222
253
|
});
|
|
223
254
|
// Prevent future exclusion clashes
|
|
224
255
|
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
|
-
tslib_1.__exportStar(require("./types"), exports);
|
|
5
4
|
tslib_1.__exportStar(require("./abba"), exports);
|
|
5
|
+
tslib_1.__exportStar(require("./types"), exports);
|
package/dist/types.d.ts
CHANGED
|
@@ -51,11 +51,14 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
51
51
|
experimentId: string;
|
|
52
52
|
bucketId: string | null;
|
|
53
53
|
};
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
export interface GeneratedUserAssignment {
|
|
55
|
+
assignment: (Saved<UserAssignment> & {
|
|
56
|
+
experimentKey: string;
|
|
57
|
+
bucketKey: string | null;
|
|
58
|
+
bucketData: AnyObject | null;
|
|
59
|
+
}) | null;
|
|
60
|
+
experiment: Saved<Experiment>;
|
|
61
|
+
}
|
|
59
62
|
export type SegmentationData = AnyObject;
|
|
60
63
|
export declare enum AssignmentStatus {
|
|
61
64
|
/**
|
package/dist/util.js
CHANGED
|
@@ -108,7 +108,7 @@ exports.segmentationRuleMap = {
|
|
|
108
108
|
return (0, semver_1.satisfies)(keyValue?.toString() || '', ruleValue.toString());
|
|
109
109
|
},
|
|
110
110
|
[types_1.SegmentationRuleOperator.Regex](keyValue, ruleValue) {
|
|
111
|
-
return new RegExp(
|
|
111
|
+
return new RegExp(ruleValue).test(keyValue?.toString() || '');
|
|
112
112
|
},
|
|
113
113
|
[types_1.SegmentationRuleOperator.Boolean](keyValue, ruleValue) {
|
|
114
114
|
// If it's true, then must be true
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.1",
|
|
4
4
|
"scripts": {
|
|
5
|
-
"prepare": "husky"
|
|
5
|
+
"prepare": "husky",
|
|
6
|
+
"build": "dev-lib build",
|
|
7
|
+
"test": "dev-lib test",
|
|
8
|
+
"lint": "dev-lib lint",
|
|
9
|
+
"bt": "dev-lib bt",
|
|
10
|
+
"lbt": "dev-lib lbt"
|
|
6
11
|
},
|
|
7
12
|
"dependencies": {
|
|
8
13
|
"@naturalcycles/db-lib": "^9.14.1",
|
|
@@ -11,8 +16,8 @@
|
|
|
11
16
|
"semver": "^7.3.5"
|
|
12
17
|
},
|
|
13
18
|
"devDependencies": {
|
|
14
|
-
"@naturalcycles/dev-lib": "^
|
|
15
|
-
"@types/node": "^
|
|
19
|
+
"@naturalcycles/dev-lib": "^15.19.0",
|
|
20
|
+
"@types/node": "^22.7.4",
|
|
16
21
|
"@types/semver": "^7.3.9",
|
|
17
22
|
"jest": "^29.3.1"
|
|
18
23
|
},
|
|
@@ -34,7 +39,7 @@
|
|
|
34
39
|
"url": "https://github.com/NaturalCycles/abba"
|
|
35
40
|
},
|
|
36
41
|
"engines": {
|
|
37
|
-
"node": ">=
|
|
42
|
+
"node": ">=20.13.0"
|
|
38
43
|
},
|
|
39
44
|
"description": "AB test assignment configuration tool for Node.js",
|
|
40
45
|
"author": "Natural Cycles Team",
|
package/src/abba.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { _assert, _Memo, _shuffle, pMap, Unsaved } from '@naturalcycles/js-lib'
|
|
2
2
|
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
3
|
+
import { SegmentationData } from '.'
|
|
3
4
|
import { bucketDao } from './dao/bucket.dao'
|
|
4
5
|
import { experimentDao } from './dao/experiment.dao'
|
|
5
6
|
import { userAssignmentDao } from './dao/userAssignment.dao'
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
getUserExclusionSet,
|
|
21
22
|
validateTotalBucketRatio,
|
|
22
23
|
} from './util'
|
|
23
|
-
import { SegmentationData } from '.'
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* 10 minutes
|
|
@@ -48,7 +48,7 @@ export class Abba {
|
|
|
48
48
|
*/
|
|
49
49
|
async updateUserId(oldId: string, newId: string): Promise<void> {
|
|
50
50
|
const query = this.userAssignmentDao.query().filterEq('userId', oldId)
|
|
51
|
-
await this.userAssignmentDao.
|
|
51
|
+
await this.userAssignmentDao.patchByQuery(query, { userId: newId })
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
@@ -178,12 +178,17 @@ export class Abba {
|
|
|
178
178
|
userId: string,
|
|
179
179
|
existingOnly: boolean,
|
|
180
180
|
segmentationData?: SegmentationData,
|
|
181
|
-
): Promise<GeneratedUserAssignment
|
|
181
|
+
): Promise<GeneratedUserAssignment> {
|
|
182
182
|
const experiment = await this.experimentDao.getOneBy('key', experimentKey)
|
|
183
183
|
_assert(experiment, `Experiment does not exist: ${experimentKey}`)
|
|
184
184
|
|
|
185
185
|
// Inactive experiments should never return an assignment
|
|
186
|
-
if (experiment.status === AssignmentStatus.Inactive)
|
|
186
|
+
if (experiment.status === AssignmentStatus.Inactive) {
|
|
187
|
+
return {
|
|
188
|
+
experiment,
|
|
189
|
+
assignment: null,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
187
192
|
|
|
188
193
|
const buckets = await this.bucketDao.getBy('experimentId', experiment.id)
|
|
189
194
|
const existingAssignments = await this.userAssignmentDao.getBy('userId', userId)
|
|
@@ -191,35 +196,56 @@ export class Abba {
|
|
|
191
196
|
if (existing) {
|
|
192
197
|
const bucket = buckets.find(b => b.id === existing.bucketId)
|
|
193
198
|
return {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
199
|
+
experiment,
|
|
200
|
+
assignment: {
|
|
201
|
+
...existing,
|
|
202
|
+
experimentKey: experiment.key,
|
|
203
|
+
bucketKey: bucket?.key || null,
|
|
204
|
+
bucketData: bucket?.data || null,
|
|
205
|
+
},
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
// No existing assignment, but we don't want to generate a new one
|
|
202
|
-
if (existingOnly || experiment.status === AssignmentStatus.Paused)
|
|
210
|
+
if (existingOnly || experiment.status === AssignmentStatus.Paused) {
|
|
211
|
+
return {
|
|
212
|
+
experiment,
|
|
213
|
+
assignment: null,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
203
216
|
|
|
204
217
|
const experiments = await this.getAllExperiments()
|
|
205
218
|
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
206
|
-
if (!canGenerateNewAssignments(experiment, exclusionSet))
|
|
219
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
220
|
+
return {
|
|
221
|
+
experiment,
|
|
222
|
+
assignment: null,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
207
225
|
|
|
208
226
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
209
227
|
|
|
210
228
|
const experimentWithBuckets = { ...experiment, buckets }
|
|
211
229
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
212
|
-
if (!assignment)
|
|
230
|
+
if (!assignment) {
|
|
231
|
+
return {
|
|
232
|
+
experiment,
|
|
233
|
+
assignment: null,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
213
236
|
|
|
214
237
|
const newAssignment = await this.userAssignmentDao.save(assignment)
|
|
215
238
|
|
|
216
239
|
const bucket = buckets.find(b => b.id === newAssignment.bucketId)
|
|
217
240
|
|
|
218
241
|
return {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
experiment,
|
|
243
|
+
assignment: {
|
|
244
|
+
...newAssignment,
|
|
245
|
+
experimentKey: experiment.key,
|
|
246
|
+
bucketKey: bucket?.key || null,
|
|
247
|
+
bucketData: bucket?.data || null,
|
|
248
|
+
},
|
|
223
249
|
}
|
|
224
250
|
}
|
|
225
251
|
|
|
@@ -235,10 +261,13 @@ export class Abba {
|
|
|
235
261
|
const experiment = await this.experimentDao.requireById(assignment.experimentId)
|
|
236
262
|
const bucket = await this.bucketDao.getById(assignment.bucketId)
|
|
237
263
|
return {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
264
|
+
experiment,
|
|
265
|
+
assignment: {
|
|
266
|
+
...assignment,
|
|
267
|
+
experimentKey: experiment.key,
|
|
268
|
+
bucketKey: bucket?.key || null,
|
|
269
|
+
bucketData: bucket?.data || null,
|
|
270
|
+
},
|
|
242
271
|
}
|
|
243
272
|
})
|
|
244
273
|
}
|
|
@@ -273,10 +302,13 @@ export class Abba {
|
|
|
273
302
|
if (existing) {
|
|
274
303
|
const bucket = experiment.buckets.find(b => b.id === existing.bucketId)
|
|
275
304
|
assignments.push({
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
305
|
+
experiment,
|
|
306
|
+
assignment: {
|
|
307
|
+
...existing,
|
|
308
|
+
experimentKey: experiment.key,
|
|
309
|
+
bucketKey: bucket?.key || null,
|
|
310
|
+
bucketData: bucket?.data || null,
|
|
311
|
+
},
|
|
280
312
|
})
|
|
281
313
|
} else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
282
314
|
const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
|
|
@@ -285,10 +317,13 @@ export class Abba {
|
|
|
285
317
|
newAssignments.push(created)
|
|
286
318
|
const bucket = experiment.buckets.find(b => b.id === created.bucketId)
|
|
287
319
|
assignments.push({
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
320
|
+
experiment,
|
|
321
|
+
assignment: {
|
|
322
|
+
...created,
|
|
323
|
+
experimentKey: experiment.key,
|
|
324
|
+
bucketKey: bucket?.key || null,
|
|
325
|
+
bucketData: bucket?.data || null,
|
|
326
|
+
},
|
|
292
327
|
})
|
|
293
328
|
// Prevent future exclusion clashes
|
|
294
329
|
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -59,10 +59,15 @@ export type UserAssignment = BaseDBEntity & {
|
|
|
59
59
|
bucketId: string | null
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
export
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
export interface GeneratedUserAssignment {
|
|
63
|
+
assignment:
|
|
64
|
+
| (Saved<UserAssignment> & {
|
|
65
|
+
experimentKey: string
|
|
66
|
+
bucketKey: string | null
|
|
67
|
+
bucketData: AnyObject | null
|
|
68
|
+
})
|
|
69
|
+
| null
|
|
70
|
+
experiment: Saved<Experiment>
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export type SegmentationData = AnyObject
|
package/src/util.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { localDate, Unsaved } from '@naturalcycles/js-lib'
|
|
2
2
|
import { satisfies } from 'semver'
|
|
3
3
|
import {
|
|
4
4
|
AssignmentStatus,
|
|
@@ -127,7 +127,7 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
|
|
|
127
127
|
return satisfies(keyValue?.toString() || '', ruleValue.toString())
|
|
128
128
|
},
|
|
129
129
|
[SegmentationRuleOperator.Regex](keyValue, ruleValue) {
|
|
130
|
-
return new RegExp(
|
|
130
|
+
return new RegExp(ruleValue).test(keyValue?.toString() || '')
|
|
131
131
|
},
|
|
132
132
|
[SegmentationRuleOperator.Boolean](keyValue, ruleValue) {
|
|
133
133
|
// If it's true, then must be true
|