@naturalcycles/abba 1.15.7 → 1.17.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 +7 -9
- package/dist/abba.js +34 -26
- package/dist/dao/bucket.dao.js +0 -3
- package/dist/dao/experiment.dao.js +10 -4
- package/dist/dao/userAssignment.dao.js +0 -3
- package/dist/migrations/init.sql +15 -13
- package/dist/types.d.ts +43 -23
- package/dist/types.js +0 -8
- package/dist/util.d.ts +1 -1
- package/dist/util.js +0 -3
- package/package.json +5 -5
- package/src/abba.ts +46 -37
- package/src/dao/bucket.dao.ts +0 -3
- package/src/dao/experiment.dao.ts +11 -4
- package/src/dao/userAssignment.dao.ts +0 -3
- package/src/migrations/init.sql +15 -13
- package/src/types.ts +45 -25
- package/src/util.ts +1 -4
package/dist/abba.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Saved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { AbbaConfig, Bucket,
|
|
3
|
-
import { SegmentationData
|
|
2
|
+
import { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment, UserAssignment } from './types';
|
|
3
|
+
import { SegmentationData } from '.';
|
|
4
4
|
export declare class Abba {
|
|
5
5
|
cfg: AbbaConfig;
|
|
6
6
|
private experimentDao;
|
|
@@ -20,14 +20,12 @@ export declare class Abba {
|
|
|
20
20
|
* Creates a new experiment.
|
|
21
21
|
* Cold method.
|
|
22
22
|
*/
|
|
23
|
-
createExperiment(experiment: Experiment, buckets:
|
|
23
|
+
createExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
24
24
|
/**
|
|
25
25
|
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
26
26
|
* Cold method.
|
|
27
27
|
*/
|
|
28
|
-
saveExperiment(experiment: Experiment
|
|
29
|
-
id: number;
|
|
30
|
-
}, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
28
|
+
saveExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
31
29
|
/**
|
|
32
30
|
* Ensures that mutual exclusions are maintained
|
|
33
31
|
*/
|
|
@@ -36,7 +34,7 @@ export declare class Abba {
|
|
|
36
34
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
37
35
|
* Cold method.
|
|
38
36
|
*/
|
|
39
|
-
deleteExperiment(experimentId:
|
|
37
|
+
deleteExperiment(experimentId: string): Promise<void>;
|
|
40
38
|
/**
|
|
41
39
|
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
42
40
|
* Cold method.
|
|
@@ -46,7 +44,7 @@ export declare class Abba {
|
|
|
46
44
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
47
45
|
* @param segmentationData Required if existingOnly is false
|
|
48
46
|
*/
|
|
49
|
-
getUserAssignment(
|
|
47
|
+
getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment | null>;
|
|
50
48
|
/**
|
|
51
49
|
* Get all existing user assignments.
|
|
52
50
|
* Hot method.
|
|
@@ -63,5 +61,5 @@ export declare class Abba {
|
|
|
63
61
|
* Get assignment statistics for an experiment.
|
|
64
62
|
* Cold method.
|
|
65
63
|
*/
|
|
66
|
-
getExperimentAssignmentStatistics(experimentId:
|
|
64
|
+
getExperimentAssignmentStatistics(experimentId: string): Promise<ExperimentAssignmentStatistics>;
|
|
67
65
|
}
|
package/dist/abba.js
CHANGED
|
@@ -47,7 +47,7 @@ class Abba {
|
|
|
47
47
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
48
48
|
}
|
|
49
49
|
const created = await this.experimentDao.save(experiment);
|
|
50
|
-
const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId:
|
|
50
|
+
const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id })));
|
|
51
51
|
await this.updateExclusions(created.id, created.exclusions);
|
|
52
52
|
return {
|
|
53
53
|
...created,
|
|
@@ -63,7 +63,7 @@ class Abba {
|
|
|
63
63
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
64
64
|
}
|
|
65
65
|
const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
|
|
66
|
-
const updatedBuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId:
|
|
66
|
+
const updatedBuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: updatedExperiment.id })));
|
|
67
67
|
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
|
|
68
68
|
return {
|
|
69
69
|
...updatedExperiment,
|
|
@@ -109,33 +109,35 @@ class Abba {
|
|
|
109
109
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
110
110
|
* @param segmentationData Required if existingOnly is false
|
|
111
111
|
*/
|
|
112
|
-
async getUserAssignment(
|
|
112
|
+
async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
|
|
113
113
|
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
114
|
-
const
|
|
114
|
+
const experiment = await this.experimentDao.getOneBy('key', experimentKey);
|
|
115
|
+
(0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentKey}`);
|
|
116
|
+
const buckets = await this.bucketDao.getBy('experimentId', experiment.id);
|
|
117
|
+
const existing = existingAssignments.find(a => a.experimentId === experiment.id);
|
|
115
118
|
if (existing) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
bucketKey
|
|
120
|
-
}
|
|
121
|
-
return { ...existing, bucketKey };
|
|
119
|
+
return {
|
|
120
|
+
...existing,
|
|
121
|
+
experimentKey: experiment.key,
|
|
122
|
+
bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
123
|
+
};
|
|
122
124
|
}
|
|
123
125
|
if (existingOnly)
|
|
124
126
|
return null;
|
|
125
127
|
const experiments = await this.getAllExperiments();
|
|
126
|
-
const experiment = experiments.find(e => e.id === experimentId);
|
|
127
|
-
(0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentId}`);
|
|
128
128
|
const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
|
|
129
129
|
if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
|
|
130
130
|
return null;
|
|
131
131
|
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
132
|
-
const
|
|
132
|
+
const experimentWithBuckets = { ...experiment, buckets };
|
|
133
|
+
const assignment = (0, util_1.generateUserAssignmentData)(experimentWithBuckets, userId, segmentationData);
|
|
133
134
|
if (!assignment)
|
|
134
135
|
return null;
|
|
135
|
-
const
|
|
136
|
+
const newAssignment = await this.userAssignmentDao.save(assignment);
|
|
136
137
|
return {
|
|
137
|
-
...
|
|
138
|
-
|
|
138
|
+
...newAssignment,
|
|
139
|
+
experimentKey: experiment.key,
|
|
140
|
+
bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
/**
|
|
@@ -166,6 +168,7 @@ class Abba {
|
|
|
166
168
|
if (existing) {
|
|
167
169
|
assignments.push({
|
|
168
170
|
...existing,
|
|
171
|
+
experimentKey: experiment.key,
|
|
169
172
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
170
173
|
});
|
|
171
174
|
}
|
|
@@ -176,6 +179,7 @@ class Abba {
|
|
|
176
179
|
newAssignments.push(created);
|
|
177
180
|
assignments.push({
|
|
178
181
|
...created,
|
|
182
|
+
experimentKey: experiment.key,
|
|
179
183
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
180
184
|
});
|
|
181
185
|
// Prevent future exclusion clashes
|
|
@@ -191,21 +195,25 @@ class Abba {
|
|
|
191
195
|
* Cold method.
|
|
192
196
|
*/
|
|
193
197
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
.runQueryCount(),
|
|
199
|
-
buckets: {},
|
|
200
|
-
};
|
|
198
|
+
const totalAssignments = await this.userAssignmentDao
|
|
199
|
+
.query()
|
|
200
|
+
.filterEq('experimentId', experimentId)
|
|
201
|
+
.runQueryCount();
|
|
201
202
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
202
|
-
await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
203
|
-
|
|
203
|
+
const bucketAssignments = await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
204
|
+
const totalAssignments = await this.userAssignmentDao
|
|
204
205
|
.query()
|
|
205
206
|
.filterEq('bucketId', bucket.id)
|
|
206
207
|
.runQueryCount();
|
|
208
|
+
return {
|
|
209
|
+
bucketId: bucket.id,
|
|
210
|
+
totalAssignments,
|
|
211
|
+
};
|
|
207
212
|
});
|
|
208
|
-
return
|
|
213
|
+
return {
|
|
214
|
+
totalAssignments,
|
|
215
|
+
bucketAssignments,
|
|
216
|
+
};
|
|
209
217
|
}
|
|
210
218
|
}
|
|
211
219
|
exports.Abba = Abba;
|
package/dist/dao/bucket.dao.js
CHANGED
|
@@ -9,20 +9,26 @@ exports.ExperimentDao = ExperimentDao;
|
|
|
9
9
|
const experimentDao = (db) => new ExperimentDao({
|
|
10
10
|
db,
|
|
11
11
|
table: 'Experiment',
|
|
12
|
-
createId: false,
|
|
13
|
-
idType: 'number',
|
|
14
12
|
hooks: {
|
|
15
13
|
beforeBMToDBM: bm => ({
|
|
16
14
|
...bm,
|
|
17
15
|
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
18
|
-
|
|
16
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
17
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
18
|
+
exclusions: bm.exclusions.length
|
|
19
|
+
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
20
|
+
: null,
|
|
19
21
|
}),
|
|
20
22
|
beforeDBMToBM: dbm => ({
|
|
21
23
|
...dbm,
|
|
22
24
|
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
23
25
|
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
24
26
|
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
25
|
-
|
|
27
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
28
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
29
|
+
exclusions: (dbm.exclusions &&
|
|
30
|
+
JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
|
|
31
|
+
[],
|
|
26
32
|
}),
|
|
27
33
|
},
|
|
28
34
|
});
|
|
@@ -8,8 +8,5 @@ exports.UserAssignmentDao = UserAssignmentDao;
|
|
|
8
8
|
const userAssignmentDao = (db) => new UserAssignmentDao({
|
|
9
9
|
db,
|
|
10
10
|
table: 'UserAssignment',
|
|
11
|
-
createId: false,
|
|
12
|
-
idType: 'number',
|
|
13
|
-
assignGeneratedIds: true,
|
|
14
11
|
});
|
|
15
12
|
exports.userAssignmentDao = userAssignmentDao;
|
package/dist/migrations/init.sql
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
-- CreateTable
|
|
2
2
|
CREATE TABLE IF NOT EXISTS `Bucket` (
|
|
3
|
-
`id`
|
|
4
|
-
`experimentId`
|
|
3
|
+
`id` VARCHAR(50) NOT NULL,
|
|
4
|
+
`experimentId` VARCHAR(50) NOT NULL,
|
|
5
5
|
`key` VARCHAR(10) NOT NULL,
|
|
6
6
|
`ratio` INTEGER NOT NULL,
|
|
7
|
-
`created`
|
|
8
|
-
`updated`
|
|
7
|
+
`created` INT NOT NULL,
|
|
8
|
+
`updated` INT NOT NULL,
|
|
9
9
|
|
|
10
10
|
PRIMARY KEY (`id`)
|
|
11
11
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
12
12
|
|
|
13
13
|
-- CreateTable
|
|
14
14
|
CREATE TABLE IF NOT EXISTS `Experiment` (
|
|
15
|
-
`id`
|
|
15
|
+
`id` VARCHAR(50) NOT NULL,
|
|
16
|
+
`key` VARCHAR(50) NOT NULL,
|
|
16
17
|
`status` INTEGER NOT NULL,
|
|
17
18
|
`sampling` INTEGER NOT NULL,
|
|
18
19
|
`description` VARCHAR(240) NULL,
|
|
19
20
|
`startDateIncl` DATE NOT NULL,
|
|
20
21
|
`endDateExcl` DATE NOT NULL,
|
|
21
|
-
`created`
|
|
22
|
-
`updated`
|
|
22
|
+
`created` INT NOT NULL,
|
|
23
|
+
`updated` INT NOT NULL,
|
|
23
24
|
`rules` JSON NULL,
|
|
24
25
|
`exclusions` JSON NULL,
|
|
25
26
|
|
|
26
|
-
PRIMARY KEY (`id`)
|
|
27
|
+
PRIMARY KEY (`id`),
|
|
28
|
+
UNIQUE INDEX `key_unique` (`key`)
|
|
27
29
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
28
30
|
|
|
29
31
|
-- CreateTable
|
|
30
32
|
CREATE TABLE IF NOT EXISTS `UserAssignment` (
|
|
31
|
-
`id`
|
|
33
|
+
`id` VARCHAR(50) NOT NULL,
|
|
32
34
|
`userId` VARCHAR(191) NOT NULL,
|
|
33
|
-
`experimentId`
|
|
34
|
-
`bucketId`
|
|
35
|
-
`created`
|
|
36
|
-
`updated`
|
|
35
|
+
`experimentId` VARCHAR(50) NOT NULL,
|
|
36
|
+
`bucketId` VARCHAR(50) NULL,
|
|
37
|
+
`created` INT NOT NULL,
|
|
38
|
+
`updated` INT NOT NULL,
|
|
37
39
|
|
|
38
40
|
UNIQUE INDEX `UserAssignment_userId_experimentId_key`(`userId`, `experimentId`),
|
|
39
41
|
PRIMARY KEY (`id`)
|
package/dist/types.d.ts
CHANGED
|
@@ -3,33 +3,52 @@ import { AnyObject, BaseDBEntity, IsoDateString, Saved } from '@naturalcycles/js
|
|
|
3
3
|
export interface AbbaConfig {
|
|
4
4
|
db: CommonDB;
|
|
5
5
|
}
|
|
6
|
-
export type BaseExperiment = BaseDBEntity
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
export type BaseExperiment = BaseDBEntity & {
|
|
7
|
+
/**
|
|
8
|
+
* Human readable name of the experiment
|
|
9
|
+
* To be used for referencing the experiment in the UI
|
|
10
|
+
*/
|
|
11
|
+
key: string;
|
|
12
|
+
/**
|
|
13
|
+
* Status of the experiment
|
|
14
|
+
*/
|
|
15
|
+
status: AssignmentStatus;
|
|
16
|
+
/**
|
|
17
|
+
* Percentage of eligible users to include in the experiment
|
|
18
|
+
*/
|
|
9
19
|
sampling: number;
|
|
20
|
+
/**
|
|
21
|
+
* Description of the experiment, such as the hypothesis
|
|
22
|
+
*/
|
|
10
23
|
description: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Date range start for the experiment assignments
|
|
26
|
+
*/
|
|
11
27
|
startDateIncl: IsoDateString;
|
|
28
|
+
/**
|
|
29
|
+
* Date range end for the experiment assignments
|
|
30
|
+
*/
|
|
12
31
|
endDateExcl: IsoDateString;
|
|
13
32
|
};
|
|
14
33
|
export type Experiment = BaseExperiment & {
|
|
15
34
|
rules: SegmentationRule[];
|
|
16
|
-
exclusions:
|
|
35
|
+
exclusions: string[];
|
|
17
36
|
};
|
|
18
37
|
export type ExperimentWithBuckets = Saved<Experiment> & {
|
|
19
38
|
buckets: Saved<Bucket>[];
|
|
20
39
|
};
|
|
21
|
-
export
|
|
22
|
-
experimentId:
|
|
40
|
+
export type Bucket = BaseDBEntity & {
|
|
41
|
+
experimentId: string;
|
|
23
42
|
key: string;
|
|
24
43
|
ratio: number;
|
|
25
|
-
}
|
|
26
|
-
export type
|
|
27
|
-
export type UserAssignment = BaseDBEntity<number> & {
|
|
44
|
+
};
|
|
45
|
+
export type UserAssignment = BaseDBEntity & {
|
|
28
46
|
userId: string;
|
|
29
|
-
experimentId:
|
|
30
|
-
bucketId:
|
|
47
|
+
experimentId: string;
|
|
48
|
+
bucketId: string | null;
|
|
31
49
|
};
|
|
32
50
|
export type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
51
|
+
experimentKey: string;
|
|
33
52
|
bucketKey: string | null;
|
|
34
53
|
};
|
|
35
54
|
export type SegmentationData = AnyObject;
|
|
@@ -59,21 +78,22 @@ export declare enum SegmentationRuleOperator {
|
|
|
59
78
|
NotEqualsText = "notEqualsText",
|
|
60
79
|
Semver = "semver",
|
|
61
80
|
Regex = "regex",
|
|
62
|
-
Boolean = "boolean"
|
|
81
|
+
Boolean = "boolean"
|
|
82
|
+
}
|
|
83
|
+
export type SegmentationRuleFn = (segmentationProp: string | boolean | number | null | undefined, ruleValue: SegmentationRule['value']) => boolean;
|
|
84
|
+
export interface ExperimentAssignmentStatistics {
|
|
63
85
|
/**
|
|
64
|
-
*
|
|
86
|
+
* Total number of users that were included in the experiment.
|
|
87
|
+
* This includes the users who were sampled and assigned to a bucket.
|
|
65
88
|
*/
|
|
66
|
-
|
|
89
|
+
totalAssignments: number;
|
|
67
90
|
/**
|
|
68
|
-
*
|
|
91
|
+
* Number of users that were assigned to each bucket in the experiment
|
|
69
92
|
*/
|
|
70
|
-
|
|
93
|
+
bucketAssignments: BucketAssignmentStatistics[];
|
|
71
94
|
}
|
|
72
|
-
export
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
buckets: {
|
|
76
|
-
[id: string]: number;
|
|
77
|
-
};
|
|
95
|
+
export interface BucketAssignmentStatistics {
|
|
96
|
+
bucketId: string;
|
|
97
|
+
totalAssignments: number;
|
|
78
98
|
}
|
|
79
|
-
export type ExclusionSet = Set<
|
|
99
|
+
export type ExclusionSet = Set<string>;
|
package/dist/types.js
CHANGED
|
@@ -26,12 +26,4 @@ var SegmentationRuleOperator;
|
|
|
26
26
|
SegmentationRuleOperator["Regex"] = "regex";
|
|
27
27
|
/* eslint-disable id-blacklist*/
|
|
28
28
|
SegmentationRuleOperator["Boolean"] = "boolean";
|
|
29
|
-
/**
|
|
30
|
-
* @deprecated
|
|
31
|
-
*/
|
|
32
|
-
SegmentationRuleOperator["Equals"] = "==";
|
|
33
|
-
/**
|
|
34
|
-
* @deprecated
|
|
35
|
-
*/
|
|
36
|
-
SegmentationRuleOperator["NotEquals"] = "!=";
|
|
37
29
|
})(SegmentationRuleOperator || (exports.SegmentationRuleOperator = SegmentationRuleOperator = {}));
|
package/dist/util.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare const segmentationRuleMap: Record<SegmentationRuleOperator, Segme
|
|
|
36
36
|
/**
|
|
37
37
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
38
38
|
*/
|
|
39
|
-
export declare const canGenerateNewAssignments: (experiment: Experiment
|
|
39
|
+
export declare const canGenerateNewAssignments: (experiment: Saved<Experiment>, exclusionSet: ExclusionSet) => boolean;
|
|
40
40
|
/**
|
|
41
41
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
42
42
|
* based on a combination of existing assignments and mutual exclusion configuration
|
package/dist/util.js
CHANGED
|
@@ -117,9 +117,6 @@ exports.segmentationRuleMap = {
|
|
|
117
117
|
// Anything else cannot be true
|
|
118
118
|
return keyValue?.toString() !== 'true';
|
|
119
119
|
},
|
|
120
|
-
// Deprecated
|
|
121
|
-
[types_1.SegmentationRuleOperator.Equals]: (keyValue, ruleValue) => keyValue === ruleValue,
|
|
122
|
-
[types_1.SegmentationRuleOperator.NotEquals]: (keyValue, ruleValue) => keyValue !== ruleValue,
|
|
123
120
|
};
|
|
124
121
|
/**
|
|
125
122
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepare": "husky install",
|
|
6
6
|
"build": "build",
|
|
7
7
|
"build-prod": "build-prod"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@naturalcycles/db-lib": "^8.
|
|
11
|
-
"@naturalcycles/js-lib": "^14.
|
|
12
|
-
"@naturalcycles/nodejs-lib": "^13.1.
|
|
10
|
+
"@naturalcycles/db-lib": "^8.59.0",
|
|
11
|
+
"@naturalcycles/js-lib": "^14.188.1",
|
|
12
|
+
"@naturalcycles/nodejs-lib": "^13.1.3",
|
|
13
13
|
"semver": "^7.3.5"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@naturalcycles/dev-lib": "^13.
|
|
16
|
+
"@naturalcycles/dev-lib": "^13.44.8",
|
|
17
17
|
"@types/node": "^20.2.4",
|
|
18
18
|
"@types/semver": "^7.3.9",
|
|
19
19
|
"jest": "^29.3.1"
|
package/src/abba.ts
CHANGED
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
AbbaConfig,
|
|
8
8
|
AssignmentStatus,
|
|
9
9
|
Bucket,
|
|
10
|
-
|
|
10
|
+
BucketAssignmentStatistics,
|
|
11
11
|
Experiment,
|
|
12
|
+
ExperimentAssignmentStatistics,
|
|
12
13
|
ExperimentWithBuckets,
|
|
13
14
|
GeneratedUserAssignment,
|
|
14
15
|
UserAssignment,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
getUserExclusionSet,
|
|
20
21
|
validateTotalBucketRatio,
|
|
21
22
|
} from './util'
|
|
22
|
-
import { SegmentationData
|
|
23
|
+
import { SegmentationData } from '.'
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* 10 minutes
|
|
@@ -61,7 +62,7 @@ export class Abba {
|
|
|
61
62
|
*/
|
|
62
63
|
async createExperiment(
|
|
63
64
|
experiment: Experiment,
|
|
64
|
-
buckets:
|
|
65
|
+
buckets: Bucket[],
|
|
65
66
|
): Promise<ExperimentWithBuckets> {
|
|
66
67
|
if (experiment.status === AssignmentStatus.Active) {
|
|
67
68
|
validateTotalBucketRatio(buckets)
|
|
@@ -69,7 +70,7 @@ export class Abba {
|
|
|
69
70
|
|
|
70
71
|
const created = await this.experimentDao.save(experiment)
|
|
71
72
|
const createdbuckets = await this.bucketDao.saveBatch(
|
|
72
|
-
buckets.map(b => ({ ...b, experimentId:
|
|
73
|
+
buckets.map(b => ({ ...b, experimentId: created.id })),
|
|
73
74
|
)
|
|
74
75
|
|
|
75
76
|
await this.updateExclusions(created.id, created.exclusions)
|
|
@@ -84,17 +85,14 @@ export class Abba {
|
|
|
84
85
|
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
85
86
|
* Cold method.
|
|
86
87
|
*/
|
|
87
|
-
async saveExperiment(
|
|
88
|
-
experiment: Experiment & { id: number },
|
|
89
|
-
buckets: Bucket[],
|
|
90
|
-
): Promise<ExperimentWithBuckets> {
|
|
88
|
+
async saveExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets> {
|
|
91
89
|
if (experiment.status === AssignmentStatus.Active) {
|
|
92
90
|
validateTotalBucketRatio(buckets)
|
|
93
91
|
}
|
|
94
92
|
|
|
95
93
|
const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' })
|
|
96
94
|
const updatedBuckets = await this.bucketDao.saveBatch(
|
|
97
|
-
buckets.map(b => ({ ...b, experimentId:
|
|
95
|
+
buckets.map(b => ({ ...b, experimentId: updatedExperiment.id })),
|
|
98
96
|
)
|
|
99
97
|
|
|
100
98
|
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions)
|
|
@@ -108,7 +106,7 @@ export class Abba {
|
|
|
108
106
|
/**
|
|
109
107
|
* Ensures that mutual exclusions are maintained
|
|
110
108
|
*/
|
|
111
|
-
private async updateExclusions(experimentId:
|
|
109
|
+
private async updateExclusions(experimentId: string, updatedExclusions: string[]): Promise<void> {
|
|
112
110
|
const experiments = await this.experimentDao.getAll()
|
|
113
111
|
|
|
114
112
|
const requiresUpdating: Experiment[] = []
|
|
@@ -139,7 +137,7 @@ export class Abba {
|
|
|
139
137
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
140
138
|
* Cold method.
|
|
141
139
|
*/
|
|
142
|
-
async deleteExperiment(experimentId:
|
|
140
|
+
async deleteExperiment(experimentId: string): Promise<void> {
|
|
143
141
|
await this.experimentDao.deleteById(experimentId)
|
|
144
142
|
await this.updateExclusions(experimentId, [])
|
|
145
143
|
}
|
|
@@ -154,42 +152,45 @@ export class Abba {
|
|
|
154
152
|
* @param segmentationData Required if existingOnly is false
|
|
155
153
|
*/
|
|
156
154
|
async getUserAssignment(
|
|
157
|
-
|
|
155
|
+
experimentKey: string,
|
|
158
156
|
userId: string,
|
|
159
157
|
existingOnly: boolean,
|
|
160
158
|
segmentationData?: SegmentationData,
|
|
161
159
|
): Promise<GeneratedUserAssignment | null> {
|
|
162
160
|
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
163
161
|
|
|
164
|
-
const
|
|
162
|
+
const experiment = await this.experimentDao.getOneBy('key', experimentKey)
|
|
163
|
+
_assert(experiment, `Experiment does not exist: ${experimentKey}`)
|
|
164
|
+
|
|
165
|
+
const buckets = await this.bucketDao.getBy('experimentId', experiment.id)
|
|
166
|
+
|
|
167
|
+
const existing = existingAssignments.find(a => a.experimentId === experiment.id)
|
|
165
168
|
if (existing) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
bucketKey
|
|
169
|
+
return {
|
|
170
|
+
...existing,
|
|
171
|
+
experimentKey: experiment.key,
|
|
172
|
+
bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
170
173
|
}
|
|
171
|
-
return { ...existing, bucketKey }
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
if (existingOnly) return null
|
|
175
177
|
|
|
176
178
|
const experiments = await this.getAllExperiments()
|
|
177
|
-
const experiment = experiments.find(e => e.id === experimentId)
|
|
178
|
-
_assert(experiment, `Experiment does not exist: ${experimentId}`)
|
|
179
|
-
|
|
180
179
|
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
181
180
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) return null
|
|
182
181
|
|
|
183
182
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
184
183
|
|
|
185
|
-
const
|
|
184
|
+
const experimentWithBuckets = { ...experiment, buckets }
|
|
185
|
+
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
186
186
|
if (!assignment) return null
|
|
187
187
|
|
|
188
|
-
const
|
|
188
|
+
const newAssignment = await this.userAssignmentDao.save(assignment)
|
|
189
189
|
|
|
190
190
|
return {
|
|
191
|
-
...
|
|
192
|
-
|
|
191
|
+
...newAssignment,
|
|
192
|
+
experimentKey: experiment.key,
|
|
193
|
+
bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
|
|
@@ -215,7 +216,6 @@ export class Abba {
|
|
|
215
216
|
const experiments = await this.getAllExperiments()
|
|
216
217
|
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
217
218
|
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
218
|
-
|
|
219
219
|
const assignments: GeneratedUserAssignment[] = []
|
|
220
220
|
const newAssignments: UserAssignment[] = []
|
|
221
221
|
|
|
@@ -233,6 +233,7 @@ export class Abba {
|
|
|
233
233
|
if (existing) {
|
|
234
234
|
assignments.push({
|
|
235
235
|
...existing,
|
|
236
|
+
experimentKey: experiment.key,
|
|
236
237
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
237
238
|
})
|
|
238
239
|
} else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
@@ -242,6 +243,7 @@ export class Abba {
|
|
|
242
243
|
newAssignments.push(created)
|
|
243
244
|
assignments.push({
|
|
244
245
|
...created,
|
|
246
|
+
experimentKey: experiment.key,
|
|
245
247
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
246
248
|
})
|
|
247
249
|
// Prevent future exclusion clashes
|
|
@@ -258,23 +260,30 @@ export class Abba {
|
|
|
258
260
|
* Get assignment statistics for an experiment.
|
|
259
261
|
* Cold method.
|
|
260
262
|
*/
|
|
261
|
-
async getExperimentAssignmentStatistics(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
263
|
+
async getExperimentAssignmentStatistics(
|
|
264
|
+
experimentId: string,
|
|
265
|
+
): Promise<ExperimentAssignmentStatistics> {
|
|
266
|
+
const totalAssignments = await this.userAssignmentDao
|
|
267
|
+
.query()
|
|
268
|
+
.filterEq('experimentId', experimentId)
|
|
269
|
+
.runQueryCount()
|
|
269
270
|
|
|
270
271
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
271
|
-
await pMap(buckets, async bucket => {
|
|
272
|
-
|
|
272
|
+
const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
|
|
273
|
+
const totalAssignments = await this.userAssignmentDao
|
|
273
274
|
.query()
|
|
274
275
|
.filterEq('bucketId', bucket.id)
|
|
275
276
|
.runQueryCount()
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
bucketId: bucket.id,
|
|
280
|
+
totalAssignments,
|
|
281
|
+
}
|
|
276
282
|
})
|
|
277
283
|
|
|
278
|
-
return
|
|
284
|
+
return {
|
|
285
|
+
totalAssignments,
|
|
286
|
+
bucketAssignments,
|
|
287
|
+
}
|
|
279
288
|
}
|
|
280
289
|
}
|
package/src/dao/bucket.dao.ts
CHANGED
|
@@ -13,20 +13,27 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
13
13
|
new ExperimentDao({
|
|
14
14
|
db,
|
|
15
15
|
table: 'Experiment',
|
|
16
|
-
createId: false, // Always provided on create
|
|
17
|
-
idType: 'number',
|
|
18
16
|
hooks: {
|
|
19
17
|
beforeBMToDBM: bm => ({
|
|
20
18
|
...bm,
|
|
21
19
|
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
22
|
-
|
|
20
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
21
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
22
|
+
exclusions: bm.exclusions.length
|
|
23
|
+
? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
|
|
24
|
+
: null,
|
|
23
25
|
}),
|
|
24
26
|
beforeDBMToBM: dbm => ({
|
|
25
27
|
...dbm,
|
|
26
28
|
startDateIncl: parseMySQLDate(dbm.startDateIncl),
|
|
27
29
|
endDateExcl: parseMySQLDate(dbm.endDateExcl),
|
|
28
30
|
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
29
|
-
|
|
31
|
+
// We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
|
|
32
|
+
// TODO: Remove after some time when we are certain only strings are stored
|
|
33
|
+
exclusions:
|
|
34
|
+
(dbm.exclusions &&
|
|
35
|
+
JSON.parse(dbm.exclusions).map((exclusion: string | number) => exclusion.toString())) ||
|
|
36
|
+
[],
|
|
30
37
|
}),
|
|
31
38
|
},
|
|
32
39
|
})
|
package/src/migrations/init.sql
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
-- CreateTable
|
|
2
2
|
CREATE TABLE IF NOT EXISTS `Bucket` (
|
|
3
|
-
`id`
|
|
4
|
-
`experimentId`
|
|
3
|
+
`id` VARCHAR(50) NOT NULL,
|
|
4
|
+
`experimentId` VARCHAR(50) NOT NULL,
|
|
5
5
|
`key` VARCHAR(10) NOT NULL,
|
|
6
6
|
`ratio` INTEGER NOT NULL,
|
|
7
|
-
`created`
|
|
8
|
-
`updated`
|
|
7
|
+
`created` INT NOT NULL,
|
|
8
|
+
`updated` INT NOT NULL,
|
|
9
9
|
|
|
10
10
|
PRIMARY KEY (`id`)
|
|
11
11
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
12
12
|
|
|
13
13
|
-- CreateTable
|
|
14
14
|
CREATE TABLE IF NOT EXISTS `Experiment` (
|
|
15
|
-
`id`
|
|
15
|
+
`id` VARCHAR(50) NOT NULL,
|
|
16
|
+
`key` VARCHAR(50) NOT NULL,
|
|
16
17
|
`status` INTEGER NOT NULL,
|
|
17
18
|
`sampling` INTEGER NOT NULL,
|
|
18
19
|
`description` VARCHAR(240) NULL,
|
|
19
20
|
`startDateIncl` DATE NOT NULL,
|
|
20
21
|
`endDateExcl` DATE NOT NULL,
|
|
21
|
-
`created`
|
|
22
|
-
`updated`
|
|
22
|
+
`created` INT NOT NULL,
|
|
23
|
+
`updated` INT NOT NULL,
|
|
23
24
|
`rules` JSON NULL,
|
|
24
25
|
`exclusions` JSON NULL,
|
|
25
26
|
|
|
26
|
-
PRIMARY KEY (`id`)
|
|
27
|
+
PRIMARY KEY (`id`),
|
|
28
|
+
UNIQUE INDEX `key_unique` (`key`)
|
|
27
29
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
28
30
|
|
|
29
31
|
-- CreateTable
|
|
30
32
|
CREATE TABLE IF NOT EXISTS `UserAssignment` (
|
|
31
|
-
`id`
|
|
33
|
+
`id` VARCHAR(50) NOT NULL,
|
|
32
34
|
`userId` VARCHAR(191) NOT NULL,
|
|
33
|
-
`experimentId`
|
|
34
|
-
`bucketId`
|
|
35
|
-
`created`
|
|
36
|
-
`updated`
|
|
35
|
+
`experimentId` VARCHAR(50) NOT NULL,
|
|
36
|
+
`bucketId` VARCHAR(50) NULL,
|
|
37
|
+
`created` INT NOT NULL,
|
|
38
|
+
`updated` INT NOT NULL,
|
|
37
39
|
|
|
38
40
|
UNIQUE INDEX `UserAssignment_userId_experimentId_key`(`userId`, `experimentId`),
|
|
39
41
|
PRIMARY KEY (`id`)
|
package/src/types.ts
CHANGED
|
@@ -5,39 +5,57 @@ export interface AbbaConfig {
|
|
|
5
5
|
db: CommonDB
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export type BaseExperiment = BaseDBEntity
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export type BaseExperiment = BaseDBEntity & {
|
|
9
|
+
/**
|
|
10
|
+
* Human readable name of the experiment
|
|
11
|
+
* To be used for referencing the experiment in the UI
|
|
12
|
+
*/
|
|
13
|
+
key: string
|
|
14
|
+
/**
|
|
15
|
+
* Status of the experiment
|
|
16
|
+
*/
|
|
17
|
+
status: AssignmentStatus
|
|
18
|
+
/**
|
|
19
|
+
* Percentage of eligible users to include in the experiment
|
|
20
|
+
*/
|
|
11
21
|
sampling: number
|
|
22
|
+
/**
|
|
23
|
+
* Description of the experiment, such as the hypothesis
|
|
24
|
+
*/
|
|
12
25
|
description: string | null
|
|
26
|
+
/**
|
|
27
|
+
* Date range start for the experiment assignments
|
|
28
|
+
*/
|
|
13
29
|
startDateIncl: IsoDateString
|
|
30
|
+
/**
|
|
31
|
+
* Date range end for the experiment assignments
|
|
32
|
+
*/
|
|
14
33
|
endDateExcl: IsoDateString
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export type Experiment = BaseExperiment & {
|
|
18
37
|
rules: SegmentationRule[]
|
|
19
|
-
exclusions:
|
|
38
|
+
exclusions: string[]
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
export type ExperimentWithBuckets = Saved<Experiment> & {
|
|
23
42
|
buckets: Saved<Bucket>[]
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
export
|
|
27
|
-
experimentId:
|
|
45
|
+
export type Bucket = BaseDBEntity & {
|
|
46
|
+
experimentId: string
|
|
28
47
|
key: string
|
|
29
48
|
ratio: number
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
export type
|
|
33
|
-
|
|
34
|
-
export type UserAssignment = BaseDBEntity<number> & {
|
|
51
|
+
export type UserAssignment = BaseDBEntity & {
|
|
35
52
|
userId: string
|
|
36
|
-
experimentId:
|
|
37
|
-
bucketId:
|
|
53
|
+
experimentId: string
|
|
54
|
+
bucketId: string | null
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
export type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
58
|
+
experimentKey: string
|
|
41
59
|
bucketKey: string | null
|
|
42
60
|
}
|
|
43
61
|
|
|
@@ -73,14 +91,6 @@ export enum SegmentationRuleOperator {
|
|
|
73
91
|
Regex = 'regex',
|
|
74
92
|
/* eslint-disable id-blacklist*/
|
|
75
93
|
Boolean = 'boolean',
|
|
76
|
-
/**
|
|
77
|
-
* @deprecated
|
|
78
|
-
*/
|
|
79
|
-
Equals = '==',
|
|
80
|
-
/**
|
|
81
|
-
* @deprecated
|
|
82
|
-
*/
|
|
83
|
-
NotEquals = '!=',
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
export type SegmentationRuleFn = (
|
|
@@ -88,11 +98,21 @@ export type SegmentationRuleFn = (
|
|
|
88
98
|
ruleValue: SegmentationRule['value'],
|
|
89
99
|
) => boolean
|
|
90
100
|
|
|
91
|
-
export interface
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
export interface ExperimentAssignmentStatistics {
|
|
102
|
+
/**
|
|
103
|
+
* Total number of users that were included in the experiment.
|
|
104
|
+
* This includes the users who were sampled and assigned to a bucket.
|
|
105
|
+
*/
|
|
106
|
+
totalAssignments: number
|
|
107
|
+
/**
|
|
108
|
+
* Number of users that were assigned to each bucket in the experiment
|
|
109
|
+
*/
|
|
110
|
+
bucketAssignments: BucketAssignmentStatistics[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BucketAssignmentStatistics {
|
|
114
|
+
bucketId: string
|
|
115
|
+
totalAssignments: number
|
|
96
116
|
}
|
|
97
117
|
|
|
98
|
-
export type ExclusionSet = Set<
|
|
118
|
+
export type ExclusionSet = Set<string>
|
package/src/util.ts
CHANGED
|
@@ -135,16 +135,13 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
|
|
|
135
135
|
// Anything else cannot be true
|
|
136
136
|
return keyValue?.toString() !== 'true'
|
|
137
137
|
},
|
|
138
|
-
// Deprecated
|
|
139
|
-
[SegmentationRuleOperator.Equals]: (keyValue, ruleValue) => keyValue === ruleValue,
|
|
140
|
-
[SegmentationRuleOperator.NotEquals]: (keyValue, ruleValue) => keyValue !== ruleValue,
|
|
141
138
|
}
|
|
142
139
|
|
|
143
140
|
/**
|
|
144
141
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
145
142
|
*/
|
|
146
143
|
export const canGenerateNewAssignments = (
|
|
147
|
-
experiment: Experiment
|
|
144
|
+
experiment: Saved<Experiment>,
|
|
148
145
|
exclusionSet: ExclusionSet,
|
|
149
146
|
): boolean => {
|
|
150
147
|
return (
|