@naturalcycles/abba 2.0.1 → 2.1.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.d.ts +12 -9
- package/dist/abba.js +98 -101
- package/dist/dao/bucket.dao.d.ts +3 -3
- package/dist/dao/bucket.dao.js +20 -15
- package/dist/dao/experiment.dao.d.ts +8 -4
- package/dist/dao/experiment.dao.js +39 -28
- package/dist/dao/userAssignment.dao.d.ts +5 -1
- package/dist/dao/userAssignment.dao.js +21 -4
- package/dist/migrations/init.sql +1 -0
- package/dist/types.d.ts +16 -11
- package/dist/util.d.ts +8 -8
- package/dist/util.js +18 -18
- package/package.json +3 -2
- package/src/abba.ts +122 -105
- package/src/dao/bucket.dao.ts +9 -5
- package/src/dao/experiment.dao.ts +26 -9
- package/src/dao/userAssignment.dao.ts +29 -3
- package/src/migrations/init.sql +1 -0
- package/src/types.ts +16 -12
- package/src/util.ts +16 -18
package/dist/util.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment } from './types.js';
|
|
2
|
+
import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment, UserExperiment } from './types.js';
|
|
3
3
|
import { SegmentationRuleOperator } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Generate a new assignment for a given user.
|
|
6
6
|
* Doesn't save it.
|
|
7
7
|
*/
|
|
8
|
-
export declare
|
|
8
|
+
export declare function generateUserAssignmentData(experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData): Unsaved<UserAssignment> | null;
|
|
9
9
|
declare class RandomService {
|
|
10
10
|
/**
|
|
11
11
|
* Generate a random number between 0 and 100
|
|
@@ -16,15 +16,15 @@ export declare const randomService: RandomService;
|
|
|
16
16
|
/**
|
|
17
17
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
18
18
|
*/
|
|
19
|
-
export declare
|
|
19
|
+
export declare function determineAssignment(sampling: number, buckets: Bucket[]): Bucket | null;
|
|
20
20
|
/**
|
|
21
21
|
* Determines which bucket a user assignment will recieve
|
|
22
22
|
*/
|
|
23
|
-
export declare
|
|
23
|
+
export declare function determineBucket(buckets: Bucket[]): Bucket;
|
|
24
24
|
/**
|
|
25
25
|
* Validate the total ratio of the buckets equals 100
|
|
26
26
|
*/
|
|
27
|
-
export declare
|
|
27
|
+
export declare function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void;
|
|
28
28
|
/**
|
|
29
29
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
30
30
|
*
|
|
@@ -32,7 +32,7 @@ export declare const validateTotalBucketRatio: (buckets: Unsaved<Bucket>[]) => v
|
|
|
32
32
|
* @param segmentationData
|
|
33
33
|
* @returns
|
|
34
34
|
*/
|
|
35
|
-
export declare
|
|
35
|
+
export declare function validateSegmentationRules(rules: SegmentationRule[], segmentationData: SegmentationData): boolean;
|
|
36
36
|
/**
|
|
37
37
|
* Map of segmentation rule validators
|
|
38
38
|
*/
|
|
@@ -40,10 +40,10 @@ export declare const segmentationRuleMap: Record<SegmentationRuleOperator, Segme
|
|
|
40
40
|
/**
|
|
41
41
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
42
42
|
*/
|
|
43
|
-
export declare
|
|
43
|
+
export declare function canGenerateNewAssignments(experiment: Experiment, exclusionSet: ExclusionSet): boolean;
|
|
44
44
|
/**
|
|
45
45
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
46
46
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
47
47
|
*/
|
|
48
|
-
export declare
|
|
48
|
+
export declare function getUserExclusionSet(experiments: UserExperiment[]): ExclusionSet;
|
|
49
49
|
export {};
|
package/dist/util.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
|
|
|
5
5
|
* Generate a new assignment for a given user.
|
|
6
6
|
* Doesn't save it.
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
8
|
+
export function generateUserAssignmentData(experiment, userId, segmentationData) {
|
|
9
9
|
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData);
|
|
10
10
|
if (!segmentationMatch)
|
|
11
11
|
return null;
|
|
@@ -15,7 +15,7 @@ export const generateUserAssignmentData = (experiment, userId, segmentationData)
|
|
|
15
15
|
experimentId: experiment.id,
|
|
16
16
|
bucketId: bucket?.id || null,
|
|
17
17
|
};
|
|
18
|
-
}
|
|
18
|
+
}
|
|
19
19
|
class RandomService {
|
|
20
20
|
/**
|
|
21
21
|
* Generate a random number between 0 and 100
|
|
@@ -28,18 +28,18 @@ export const randomService = new RandomService();
|
|
|
28
28
|
/**
|
|
29
29
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
30
30
|
*/
|
|
31
|
-
export
|
|
31
|
+
export function determineAssignment(sampling, buckets) {
|
|
32
32
|
// Should this person be considered for the experiment?
|
|
33
33
|
if (randomService.rollDie() > sampling) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
// get their bucket
|
|
37
37
|
return determineBucket(buckets);
|
|
38
|
-
}
|
|
38
|
+
}
|
|
39
39
|
/**
|
|
40
40
|
* Determines which bucket a user assignment will recieve
|
|
41
41
|
*/
|
|
42
|
-
export
|
|
42
|
+
export function determineBucket(buckets) {
|
|
43
43
|
const bucketRoll = randomService.rollDie();
|
|
44
44
|
let range;
|
|
45
45
|
const bucket = buckets.find(b => {
|
|
@@ -57,16 +57,16 @@ export const determineBucket = (buckets) => {
|
|
|
57
57
|
throw new Error('Could not detetermine bucket from ratios');
|
|
58
58
|
}
|
|
59
59
|
return bucket;
|
|
60
|
-
}
|
|
60
|
+
}
|
|
61
61
|
/**
|
|
62
62
|
* Validate the total ratio of the buckets equals 100
|
|
63
63
|
*/
|
|
64
|
-
export
|
|
64
|
+
export function validateTotalBucketRatio(buckets) {
|
|
65
65
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
66
66
|
if (bucketSum !== 100) {
|
|
67
67
|
throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
|
|
68
68
|
}
|
|
69
|
-
}
|
|
69
|
+
}
|
|
70
70
|
/**
|
|
71
71
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
72
72
|
*
|
|
@@ -74,14 +74,14 @@ export const validateTotalBucketRatio = (buckets) => {
|
|
|
74
74
|
* @param segmentationData
|
|
75
75
|
* @returns
|
|
76
76
|
*/
|
|
77
|
-
export
|
|
77
|
+
export function validateSegmentationRules(rules, segmentationData) {
|
|
78
78
|
for (const rule of rules) {
|
|
79
79
|
const { key, value, operator } = rule;
|
|
80
80
|
if (!segmentationRuleMap[operator](segmentationData[key], value))
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
83
83
|
return true;
|
|
84
|
-
}
|
|
84
|
+
}
|
|
85
85
|
/**
|
|
86
86
|
* Map of segmentation rule validators
|
|
87
87
|
*/
|
|
@@ -115,24 +115,24 @@ export const segmentationRuleMap = {
|
|
|
115
115
|
/**
|
|
116
116
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
117
117
|
*/
|
|
118
|
-
export
|
|
118
|
+
export function canGenerateNewAssignments(experiment, exclusionSet) {
|
|
119
119
|
return (!exclusionSet.has(experiment.id) &&
|
|
120
120
|
experiment.status === AssignmentStatus.Active &&
|
|
121
121
|
localDate.today().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)'));
|
|
122
|
-
}
|
|
122
|
+
}
|
|
123
123
|
/**
|
|
124
124
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
125
125
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
126
126
|
*/
|
|
127
|
-
export
|
|
127
|
+
export function getUserExclusionSet(experiments) {
|
|
128
128
|
const exclusionSet = new Set();
|
|
129
|
-
|
|
129
|
+
experiments.forEach(experiment => {
|
|
130
|
+
const { userAssignment } = experiment;
|
|
130
131
|
// Users who are excluded from an experiment due to sampling
|
|
131
132
|
// should not prevent potential assignment to other mutually exclusive experiments
|
|
132
|
-
if (
|
|
133
|
+
if (!userAssignment || userAssignment?.bucketId === null)
|
|
133
134
|
return;
|
|
134
|
-
|
|
135
|
-
experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
135
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
136
136
|
});
|
|
137
137
|
return exclusionSet;
|
|
138
|
-
}
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.0
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"prepare": "husky",
|
|
7
7
|
"build": "dev-lib build",
|
|
@@ -46,5 +46,6 @@
|
|
|
46
46
|
},
|
|
47
47
|
"description": "AB test assignment configuration tool for Node.js",
|
|
48
48
|
"author": "Natural Cycles Team",
|
|
49
|
-
"license": "MIT"
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
|
50
51
|
}
|
package/src/abba.ts
CHANGED
|
@@ -2,18 +2,19 @@ import type { Unsaved } from '@naturalcycles/js-lib'
|
|
|
2
2
|
import { _assert, _Memo, _shuffle, localTime, pMap } from '@naturalcycles/js-lib'
|
|
3
3
|
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
4
4
|
import { bucketDao } from './dao/bucket.dao.js'
|
|
5
|
-
import { experimentDao } from './dao/experiment.dao.js'
|
|
5
|
+
import { experimentDao, type GetAllExperimentsOpts } from './dao/experiment.dao.js'
|
|
6
6
|
import { userAssignmentDao } from './dao/userAssignment.dao.js'
|
|
7
7
|
import type {
|
|
8
8
|
AbbaConfig,
|
|
9
9
|
Bucket,
|
|
10
10
|
BucketAssignmentStatistics,
|
|
11
|
+
DecoratedUserAssignment,
|
|
11
12
|
Experiment,
|
|
12
13
|
ExperimentAssignmentStatistics,
|
|
13
14
|
ExperimentWithBuckets,
|
|
14
|
-
GeneratedUserAssignment,
|
|
15
15
|
SegmentationData,
|
|
16
16
|
UserAssignment,
|
|
17
|
+
UserExperiment,
|
|
17
18
|
} from './types.js'
|
|
18
19
|
import { AssignmentStatus } from './types.js'
|
|
19
20
|
import {
|
|
@@ -40,23 +41,19 @@ export class Abba {
|
|
|
40
41
|
* Cached (see CACHE_TTL)
|
|
41
42
|
*/
|
|
42
43
|
@_Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
43
|
-
async
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Updates all user assignments with a given userId with the provided userId.
|
|
49
|
-
*/
|
|
50
|
-
async updateUserId(oldId: string, newId: string): Promise<void> {
|
|
51
|
-
const query = this.userAssignmentDao.query().filterEq('userId', oldId)
|
|
52
|
-
await this.userAssignmentDao.patchByQuery(query, { userId: newId })
|
|
44
|
+
async getAllExperimentsWithBuckets(
|
|
45
|
+
opts?: GetAllExperimentsOpts,
|
|
46
|
+
): Promise<ExperimentWithBuckets[]> {
|
|
47
|
+
return await this.getAllExperimentsWithBucketsNoCache(opts)
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
/**
|
|
56
51
|
* Returns all experiments.
|
|
57
52
|
*/
|
|
58
|
-
async
|
|
59
|
-
|
|
53
|
+
async getAllExperimentsWithBucketsNoCache(
|
|
54
|
+
opts?: GetAllExperimentsOpts,
|
|
55
|
+
): Promise<ExperimentWithBuckets[]> {
|
|
56
|
+
const experiments = await this.experimentDao.getAllExperiments(opts)
|
|
60
57
|
const buckets = await this.bucketDao.getAll()
|
|
61
58
|
|
|
62
59
|
return experiments.map(experiment => ({
|
|
@@ -65,6 +62,45 @@ export class Abba {
|
|
|
65
62
|
}))
|
|
66
63
|
}
|
|
67
64
|
|
|
65
|
+
async getUserExperiments(userId: string): Promise<UserExperiment[]> {
|
|
66
|
+
const experiments = await this.getAllExperimentsWithBuckets({ includeDeleted: false })
|
|
67
|
+
|
|
68
|
+
const experimentIds = experiments.map(e => e.id)
|
|
69
|
+
const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(
|
|
70
|
+
userId,
|
|
71
|
+
experimentIds,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return experiments.map(experiment => {
|
|
75
|
+
const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id)
|
|
76
|
+
const existingAssignmentBucket = experiment.buckets.find(
|
|
77
|
+
b => b.id === existingAssignment?.bucketId,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
...experiment,
|
|
82
|
+
...(existingAssignment && {
|
|
83
|
+
userAssignment: {
|
|
84
|
+
...existingAssignment,
|
|
85
|
+
experimentId: experiment.id,
|
|
86
|
+
experimentData: experiment.data,
|
|
87
|
+
experimentKey: experiment.key,
|
|
88
|
+
bucketData: existingAssignmentBucket?.data || null,
|
|
89
|
+
bucketKey: existingAssignmentBucket?.key || null,
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Updates all user assignments with a given userId with the provided userId.
|
|
98
|
+
*/
|
|
99
|
+
async updateUserId(oldId: string, newId: string): Promise<void> {
|
|
100
|
+
const query = this.userAssignmentDao.query().filterEq('userId', oldId)
|
|
101
|
+
await this.userAssignmentDao.patchByQuery(query, { userId: newId })
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
/**
|
|
69
105
|
* Creates a new experiment.
|
|
70
106
|
* Cold method.
|
|
@@ -148,6 +184,11 @@ export class Abba {
|
|
|
148
184
|
await this.experimentDao.saveBatch(requiresUpdating, { saveMethod: 'update' })
|
|
149
185
|
}
|
|
150
186
|
|
|
187
|
+
async softDeleteExperiment(experimentId: string): Promise<void> {
|
|
188
|
+
await this.experimentDao.patchById(experimentId, { deleted: true, exclusions: [] })
|
|
189
|
+
await this.updateExclusions(experimentId, [])
|
|
190
|
+
}
|
|
191
|
+
|
|
151
192
|
/**
|
|
152
193
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
153
194
|
* Requires the experiment to have been inactive for at least 15 minutes in order to
|
|
@@ -191,49 +232,40 @@ export class Abba {
|
|
|
191
232
|
userId: string,
|
|
192
233
|
existingOnly: boolean,
|
|
193
234
|
segmentationData?: SegmentationData,
|
|
194
|
-
): Promise<
|
|
195
|
-
const experiment = await this.experimentDao.
|
|
235
|
+
): Promise<DecoratedUserAssignment | null> {
|
|
236
|
+
const experiment = await this.experimentDao.getByKey(experimentKey)
|
|
196
237
|
_assert(experiment, `Experiment does not exist: ${experimentKey}`)
|
|
197
238
|
|
|
198
239
|
// Inactive experiments should never return an assignment
|
|
199
240
|
if (experiment.status === AssignmentStatus.Inactive) {
|
|
200
|
-
return
|
|
201
|
-
experiment,
|
|
202
|
-
assignment: null,
|
|
203
|
-
}
|
|
241
|
+
return null
|
|
204
242
|
}
|
|
205
243
|
|
|
206
|
-
const buckets = await this.bucketDao.
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
244
|
+
const buckets = await this.bucketDao.getByExperimentId(experiment.id)
|
|
245
|
+
const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(
|
|
246
|
+
userId,
|
|
247
|
+
experiment.id,
|
|
248
|
+
)
|
|
249
|
+
if (userAssignment) {
|
|
250
|
+
const bucket = buckets.find(b => b.id === userAssignment.bucketId)
|
|
211
251
|
return {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
bucketData: bucket?.data || null,
|
|
218
|
-
},
|
|
252
|
+
...userAssignment,
|
|
253
|
+
experimentData: experiment.data,
|
|
254
|
+
experimentKey: experiment.key,
|
|
255
|
+
bucketKey: bucket?.key || null,
|
|
256
|
+
bucketData: bucket?.data || null,
|
|
219
257
|
}
|
|
220
258
|
}
|
|
221
259
|
|
|
222
260
|
// No existing assignment, but we don't want to generate a new one
|
|
223
261
|
if (existingOnly || experiment.status === AssignmentStatus.Paused) {
|
|
224
|
-
return
|
|
225
|
-
experiment,
|
|
226
|
-
assignment: null,
|
|
227
|
-
}
|
|
262
|
+
return null
|
|
228
263
|
}
|
|
229
264
|
|
|
230
|
-
const experiments = await this.
|
|
231
|
-
const exclusionSet = getUserExclusionSet(experiments
|
|
265
|
+
const experiments = await this.getUserExperiments(userId)
|
|
266
|
+
const exclusionSet = getUserExclusionSet(experiments)
|
|
232
267
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
233
|
-
return
|
|
234
|
-
experiment,
|
|
235
|
-
assignment: null,
|
|
236
|
-
}
|
|
268
|
+
return null
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
@@ -241,10 +273,7 @@ export class Abba {
|
|
|
241
273
|
const experimentWithBuckets = { ...experiment, buckets }
|
|
242
274
|
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
243
275
|
if (!assignment) {
|
|
244
|
-
return
|
|
245
|
-
experiment,
|
|
246
|
-
assignment: null,
|
|
247
|
-
}
|
|
276
|
+
return null
|
|
248
277
|
}
|
|
249
278
|
|
|
250
279
|
const newAssignment = await this.userAssignmentDao.save(assignment)
|
|
@@ -252,13 +281,11 @@ export class Abba {
|
|
|
252
281
|
const bucket = buckets.find(b => b.id === newAssignment.bucketId)
|
|
253
282
|
|
|
254
283
|
return {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
bucketData: bucket?.data || null,
|
|
261
|
-
},
|
|
284
|
+
...newAssignment,
|
|
285
|
+
experimentData: experiment.data,
|
|
286
|
+
experimentKey: experiment.key,
|
|
287
|
+
bucketKey: bucket?.key || null,
|
|
288
|
+
bucketData: bucket?.data || null,
|
|
262
289
|
}
|
|
263
290
|
}
|
|
264
291
|
|
|
@@ -268,19 +295,17 @@ export class Abba {
|
|
|
268
295
|
* Not cached, because Assignments are fast-changing.
|
|
269
296
|
* Only to be used for testing
|
|
270
297
|
*/
|
|
271
|
-
async
|
|
298
|
+
async getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]> {
|
|
272
299
|
const assignments = await this.userAssignmentDao.getBy('userId', userId)
|
|
273
300
|
return await pMap(assignments, async assignment => {
|
|
274
301
|
const experiment = await this.experimentDao.requireById(assignment.experimentId)
|
|
275
302
|
const bucket = await this.bucketDao.getById(assignment.bucketId)
|
|
276
303
|
return {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
bucketData: bucket?.data || null,
|
|
283
|
-
},
|
|
304
|
+
...assignment,
|
|
305
|
+
experimentData: experiment.data,
|
|
306
|
+
experimentKey: experiment.key,
|
|
307
|
+
bucketKey: bucket?.key || null,
|
|
308
|
+
bucketData: bucket?.data || null,
|
|
284
309
|
}
|
|
285
310
|
})
|
|
286
311
|
}
|
|
@@ -294,12 +319,9 @@ export class Abba {
|
|
|
294
319
|
userId: string,
|
|
295
320
|
segmentationData: SegmentationData,
|
|
296
321
|
existingOnly = false,
|
|
297
|
-
): Promise<
|
|
298
|
-
const experiments = await this.
|
|
299
|
-
const
|
|
300
|
-
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
301
|
-
const assignments: GeneratedUserAssignment[] = []
|
|
302
|
-
const newAssignments: UserAssignment[] = []
|
|
322
|
+
): Promise<DecoratedUserAssignment[]> {
|
|
323
|
+
const experiments = await this.getUserExperiments(userId)
|
|
324
|
+
const exclusionSet = getUserExclusionSet(experiments)
|
|
303
325
|
|
|
304
326
|
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
305
327
|
// as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
|
|
@@ -310,41 +332,43 @@ export class Abba {
|
|
|
310
332
|
),
|
|
311
333
|
)
|
|
312
334
|
|
|
335
|
+
const assignments: DecoratedUserAssignment[] = []
|
|
336
|
+
const newAssignments: Unsaved<UserAssignment>[] = []
|
|
337
|
+
|
|
313
338
|
for (const experiment of availableExperiments) {
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
339
|
+
const { userAssignment } = experiment
|
|
340
|
+
// Already assigned to this experiment
|
|
341
|
+
if (userAssignment) {
|
|
342
|
+
assignments.push(userAssignment)
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Not already assigned, but we don't want to generate a new assignment
|
|
347
|
+
if (existingOnly) continue
|
|
348
|
+
// We are not allowed to generate new assignments for this experiment
|
|
349
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet)) continue
|
|
350
|
+
|
|
351
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
|
|
352
|
+
if (assignment) {
|
|
353
|
+
// Add to list of new assignments to be saved
|
|
354
|
+
const newAssignment = this.userAssignmentDao.create(assignment)
|
|
355
|
+
newAssignments.push(newAssignment)
|
|
356
|
+
// Add the assignment to the list of assignments
|
|
357
|
+
const bucket = experiment.buckets.find(b => b.id === assignment.bucketId)
|
|
317
358
|
assignments.push({
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
bucketData: bucket?.data || null,
|
|
324
|
-
},
|
|
359
|
+
...newAssignment,
|
|
360
|
+
experimentKey: experiment.key,
|
|
361
|
+
experimentData: experiment.data,
|
|
362
|
+
bucketKey: bucket?.key || null,
|
|
363
|
+
bucketData: bucket?.data || null,
|
|
325
364
|
})
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (assignment) {
|
|
329
|
-
const created = this.userAssignmentDao.create(assignment)
|
|
330
|
-
newAssignments.push(created)
|
|
331
|
-
const bucket = experiment.buckets.find(b => b.id === created.bucketId)
|
|
332
|
-
assignments.push({
|
|
333
|
-
experiment,
|
|
334
|
-
assignment: {
|
|
335
|
-
...created,
|
|
336
|
-
experimentKey: experiment.key,
|
|
337
|
-
bucketKey: bucket?.key || null,
|
|
338
|
-
bucketData: bucket?.data || null,
|
|
339
|
-
},
|
|
340
|
-
})
|
|
341
|
-
// Prevent future exclusion clashes
|
|
342
|
-
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
343
|
-
}
|
|
365
|
+
// Prevent future exclusion clashes
|
|
366
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
344
367
|
}
|
|
345
368
|
}
|
|
346
369
|
|
|
347
370
|
await this.userAssignmentDao.saveBatch(newAssignments)
|
|
371
|
+
|
|
348
372
|
return assignments
|
|
349
373
|
}
|
|
350
374
|
|
|
@@ -355,18 +379,11 @@ export class Abba {
|
|
|
355
379
|
async getExperimentAssignmentStatistics(
|
|
356
380
|
experimentId: string,
|
|
357
381
|
): Promise<ExperimentAssignmentStatistics> {
|
|
358
|
-
const totalAssignments = await this.userAssignmentDao
|
|
359
|
-
|
|
360
|
-
.filterEq('experimentId', experimentId)
|
|
361
|
-
.runQueryCount()
|
|
382
|
+
const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId)
|
|
383
|
+
const buckets = await this.bucketDao.getByExperimentId(experimentId)
|
|
362
384
|
|
|
363
|
-
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
364
385
|
const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
|
|
365
|
-
const totalAssignments = await this.userAssignmentDao
|
|
366
|
-
.query()
|
|
367
|
-
.filterEq('bucketId', bucket.id)
|
|
368
|
-
.runQueryCount()
|
|
369
|
-
|
|
386
|
+
const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id)
|
|
370
387
|
return {
|
|
371
388
|
bucketId: bucket.id,
|
|
372
389
|
totalAssignments,
|
package/src/dao/bucket.dao.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
-
import type { Saved } from '@naturalcycles/js-lib'
|
|
4
3
|
import type { BaseBucket, Bucket } from '../types.js'
|
|
5
4
|
|
|
6
|
-
type BucketDBM =
|
|
5
|
+
type BucketDBM = BaseBucket & {
|
|
7
6
|
data: string | null
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
export class BucketDao extends CommonDao<Bucket, BucketDBM> {
|
|
9
|
+
export class BucketDao extends CommonDao<Bucket, BucketDBM> {
|
|
10
|
+
async getByExperimentId(experimentId: string): Promise<Bucket[]> {
|
|
11
|
+
return await this.query().filterEq('experimentId', experimentId).runQuery()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
11
14
|
|
|
12
|
-
export
|
|
13
|
-
new BucketDao({
|
|
15
|
+
export function bucketDao(db: CommonDB): BucketDao {
|
|
16
|
+
return new BucketDao({
|
|
14
17
|
db,
|
|
15
18
|
table: 'Bucket',
|
|
16
19
|
hooks: {
|
|
@@ -26,3 +29,4 @@ export const bucketDao = (db: CommonDB): BucketDao =>
|
|
|
26
29
|
}),
|
|
27
30
|
},
|
|
28
31
|
})
|
|
32
|
+
}
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
-
import type { IsoDate
|
|
3
|
+
import type { IsoDate } from '@naturalcycles/js-lib'
|
|
4
4
|
import { localDate } from '@naturalcycles/js-lib'
|
|
5
5
|
import type { BaseExperiment, Experiment } from '../types.js'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
7
|
+
export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
|
|
8
|
+
async getAllExperiments(opt?: GetAllExperimentsOpts): Promise<Experiment[]> {
|
|
9
|
+
if (!opt?.includeDeleted) {
|
|
10
|
+
return await this.getAll()
|
|
11
|
+
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
return await this.query().filterEq('deleted', false).runQuery()
|
|
14
|
+
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
async getByKey(key: string): Promise<Experiment | null> {
|
|
17
|
+
return await this.getOneBy('key', key)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function experimentDao(db: CommonDB): ExperimentDao {
|
|
22
|
+
return new ExperimentDao({
|
|
17
23
|
db,
|
|
18
24
|
table: 'Experiment',
|
|
19
25
|
hooks: {
|
|
@@ -42,6 +48,7 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
42
48
|
}),
|
|
43
49
|
},
|
|
44
50
|
})
|
|
51
|
+
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
|
|
@@ -53,3 +60,13 @@ function parseMySQLDate(date: string): IsoDate {
|
|
|
53
60
|
if (date instanceof Date) return localDate(date).toISODate()
|
|
54
61
|
return date as IsoDate
|
|
55
62
|
}
|
|
63
|
+
|
|
64
|
+
type ExperimentDBM = BaseExperiment & {
|
|
65
|
+
rules: string | null
|
|
66
|
+
exclusions: string | null
|
|
67
|
+
data: string | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GetAllExperimentsOpts {
|
|
71
|
+
includeDeleted?: boolean
|
|
72
|
+
}
|
|
@@ -2,10 +2,36 @@ import type { CommonDB } from '@naturalcycles/db-lib'
|
|
|
2
2
|
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
3
|
import type { UserAssignment } from '../types.js'
|
|
4
4
|
|
|
5
|
-
export class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
5
|
+
export class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
6
|
+
async getUserAssignmentByExperimentId(
|
|
7
|
+
userId: string,
|
|
8
|
+
experimentId: string,
|
|
9
|
+
): Promise<UserAssignment | null> {
|
|
10
|
+
const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId)
|
|
11
|
+
const [userAssignment] = await this.runQuery(query)
|
|
12
|
+
return userAssignment || null
|
|
13
|
+
}
|
|
6
14
|
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
async getUserAssigmentsByExperimentIds(
|
|
16
|
+
userId: string,
|
|
17
|
+
experimentIds: string[],
|
|
18
|
+
): Promise<UserAssignment[]> {
|
|
19
|
+
const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds)
|
|
20
|
+
return await this.runQuery(query)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getCountByExperimentId(experimentId: string): Promise<number> {
|
|
24
|
+
return await this.query().filterEq('experimentId', experimentId).runQueryCount()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getCountByBucketId(bucketId: string): Promise<number> {
|
|
28
|
+
return await this.query().filterEq('bucketId', bucketId).runQueryCount()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function userAssignmentDao(db: CommonDB): UserAssignmentDao {
|
|
33
|
+
return new UserAssignmentDao({
|
|
9
34
|
db,
|
|
10
35
|
table: 'UserAssignment',
|
|
11
36
|
})
|
|
37
|
+
}
|