@naturalcycles/abba 1.16.0 → 1.17.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 +7 -9
- package/dist/abba.js +39 -27
- 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 +1 -1
- package/src/abba.ts +50 -39
- 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,39 @@ 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
|
+
const experiment = await this.experimentDao.getOneBy('key', experimentKey);
|
|
114
|
+
(0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentKey}`);
|
|
115
|
+
// Inactive experiments should never return an assignment
|
|
116
|
+
if (experiment.status === types_1.AssignmentStatus.Inactive)
|
|
117
|
+
return null;
|
|
118
|
+
const buckets = await this.bucketDao.getBy('experimentId', experiment.id);
|
|
113
119
|
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
114
|
-
const existing = existingAssignments.find(a => a.experimentId ===
|
|
120
|
+
const existing = existingAssignments.find(a => a.experimentId === experiment.id);
|
|
115
121
|
if (existing) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
bucketKey
|
|
120
|
-
}
|
|
121
|
-
return { ...existing, bucketKey };
|
|
122
|
+
return {
|
|
123
|
+
...existing,
|
|
124
|
+
experimentKey: experiment.key,
|
|
125
|
+
bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
126
|
+
};
|
|
122
127
|
}
|
|
123
|
-
|
|
128
|
+
// No existing assignment, but we don't want to generate a new one
|
|
129
|
+
if (existingOnly || experiment.status === types_1.AssignmentStatus.Paused)
|
|
124
130
|
return null;
|
|
125
131
|
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
132
|
const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
|
|
129
133
|
if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
|
|
130
134
|
return null;
|
|
131
135
|
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
132
|
-
const
|
|
136
|
+
const experimentWithBuckets = { ...experiment, buckets };
|
|
137
|
+
const assignment = (0, util_1.generateUserAssignmentData)(experimentWithBuckets, userId, segmentationData);
|
|
133
138
|
if (!assignment)
|
|
134
139
|
return null;
|
|
135
|
-
const
|
|
140
|
+
const newAssignment = await this.userAssignmentDao.save(assignment);
|
|
136
141
|
return {
|
|
137
|
-
...
|
|
138
|
-
|
|
142
|
+
...newAssignment,
|
|
143
|
+
experimentKey: experiment.key,
|
|
144
|
+
bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
|
|
139
145
|
};
|
|
140
146
|
}
|
|
141
147
|
/**
|
|
@@ -166,6 +172,7 @@ class Abba {
|
|
|
166
172
|
if (existing) {
|
|
167
173
|
assignments.push({
|
|
168
174
|
...existing,
|
|
175
|
+
experimentKey: experiment.key,
|
|
169
176
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
170
177
|
});
|
|
171
178
|
}
|
|
@@ -176,6 +183,7 @@ class Abba {
|
|
|
176
183
|
newAssignments.push(created);
|
|
177
184
|
assignments.push({
|
|
178
185
|
...created,
|
|
186
|
+
experimentKey: experiment.key,
|
|
179
187
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
180
188
|
});
|
|
181
189
|
// Prevent future exclusion clashes
|
|
@@ -191,21 +199,25 @@ class Abba {
|
|
|
191
199
|
* Cold method.
|
|
192
200
|
*/
|
|
193
201
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
.runQueryCount(),
|
|
199
|
-
buckets: {},
|
|
200
|
-
};
|
|
202
|
+
const totalAssignments = await this.userAssignmentDao
|
|
203
|
+
.query()
|
|
204
|
+
.filterEq('experimentId', experimentId)
|
|
205
|
+
.runQueryCount();
|
|
201
206
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
202
|
-
await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
203
|
-
|
|
207
|
+
const bucketAssignments = await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
208
|
+
const totalAssignments = await this.userAssignmentDao
|
|
204
209
|
.query()
|
|
205
210
|
.filterEq('bucketId', bucket.id)
|
|
206
211
|
.runQueryCount();
|
|
212
|
+
return {
|
|
213
|
+
bucketId: bucket.id,
|
|
214
|
+
totalAssignments,
|
|
215
|
+
};
|
|
207
216
|
});
|
|
208
|
-
return
|
|
217
|
+
return {
|
|
218
|
+
totalAssignments,
|
|
219
|
+
bucketAssignments,
|
|
220
|
+
};
|
|
209
221
|
}
|
|
210
222
|
}
|
|
211
223
|
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, // Always provided on create
|
|
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, // mysql auto_increment is used instead
|
|
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
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,47 @@ 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
|
-
const
|
|
160
|
+
const experiment = await this.experimentDao.getOneBy('key', experimentKey)
|
|
161
|
+
_assert(experiment, `Experiment does not exist: ${experimentKey}`)
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
// Inactive experiments should never return an assignment
|
|
164
|
+
if (experiment.status === AssignmentStatus.Inactive) return null
|
|
165
|
+
|
|
166
|
+
const buckets = await this.bucketDao.getBy('experimentId', experiment.id)
|
|
167
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
168
|
+
const existing = existingAssignments.find(a => a.experimentId === experiment.id)
|
|
165
169
|
if (existing) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
bucketKey
|
|
170
|
+
return {
|
|
171
|
+
...existing,
|
|
172
|
+
experimentKey: experiment.key,
|
|
173
|
+
bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
170
174
|
}
|
|
171
|
-
return { ...existing, bucketKey }
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
|
|
177
|
+
// No existing assignment, but we don't want to generate a new one
|
|
178
|
+
if (existingOnly || experiment.status === AssignmentStatus.Paused) return null
|
|
175
179
|
|
|
176
180
|
const experiments = await this.getAllExperiments()
|
|
177
|
-
const experiment = experiments.find(e => e.id === experimentId)
|
|
178
|
-
_assert(experiment, `Experiment does not exist: ${experimentId}`)
|
|
179
|
-
|
|
180
181
|
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
181
182
|
if (!canGenerateNewAssignments(experiment, exclusionSet)) return null
|
|
182
183
|
|
|
183
184
|
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
184
185
|
|
|
185
|
-
const
|
|
186
|
+
const experimentWithBuckets = { ...experiment, buckets }
|
|
187
|
+
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
|
|
186
188
|
if (!assignment) return null
|
|
187
189
|
|
|
188
|
-
const
|
|
190
|
+
const newAssignment = await this.userAssignmentDao.save(assignment)
|
|
189
191
|
|
|
190
192
|
return {
|
|
191
|
-
...
|
|
192
|
-
|
|
193
|
+
...newAssignment,
|
|
194
|
+
experimentKey: experiment.key,
|
|
195
|
+
bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
198
|
|
|
@@ -215,7 +218,6 @@ export class Abba {
|
|
|
215
218
|
const experiments = await this.getAllExperiments()
|
|
216
219
|
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
217
220
|
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
218
|
-
|
|
219
221
|
const assignments: GeneratedUserAssignment[] = []
|
|
220
222
|
const newAssignments: UserAssignment[] = []
|
|
221
223
|
|
|
@@ -233,6 +235,7 @@ export class Abba {
|
|
|
233
235
|
if (existing) {
|
|
234
236
|
assignments.push({
|
|
235
237
|
...existing,
|
|
238
|
+
experimentKey: experiment.key,
|
|
236
239
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
237
240
|
})
|
|
238
241
|
} else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
@@ -242,6 +245,7 @@ export class Abba {
|
|
|
242
245
|
newAssignments.push(created)
|
|
243
246
|
assignments.push({
|
|
244
247
|
...created,
|
|
248
|
+
experimentKey: experiment.key,
|
|
245
249
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
246
250
|
})
|
|
247
251
|
// Prevent future exclusion clashes
|
|
@@ -258,23 +262,30 @@ export class Abba {
|
|
|
258
262
|
* Get assignment statistics for an experiment.
|
|
259
263
|
* Cold method.
|
|
260
264
|
*/
|
|
261
|
-
async getExperimentAssignmentStatistics(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
265
|
+
async getExperimentAssignmentStatistics(
|
|
266
|
+
experimentId: string,
|
|
267
|
+
): Promise<ExperimentAssignmentStatistics> {
|
|
268
|
+
const totalAssignments = await this.userAssignmentDao
|
|
269
|
+
.query()
|
|
270
|
+
.filterEq('experimentId', experimentId)
|
|
271
|
+
.runQueryCount()
|
|
269
272
|
|
|
270
273
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
271
|
-
await pMap(buckets, async bucket => {
|
|
272
|
-
|
|
274
|
+
const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
|
|
275
|
+
const totalAssignments = await this.userAssignmentDao
|
|
273
276
|
.query()
|
|
274
277
|
.filterEq('bucketId', bucket.id)
|
|
275
278
|
.runQueryCount()
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
bucketId: bucket.id,
|
|
282
|
+
totalAssignments,
|
|
283
|
+
}
|
|
276
284
|
})
|
|
277
285
|
|
|
278
|
-
return
|
|
286
|
+
return {
|
|
287
|
+
totalAssignments,
|
|
288
|
+
bucketAssignments,
|
|
289
|
+
}
|
|
279
290
|
}
|
|
280
291
|
}
|
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 (
|