@naturalcycles/abba 1.0.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/LICENSE +21 -0
- package/dist/abba.d.ts +102 -0
- package/dist/abba.js +239 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +9 -0
- package/dist/util.d.ts +53 -0
- package/dist/util.js +123 -0
- package/package.json +42 -0
- package/readme.md +284 -0
- package/src/abba.ts +300 -0
- package/src/index.ts +5 -0
- package/src/types.ts +34 -0
- package/src/util.ts +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Natural Cycles
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/abba.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { UserAssignment } from '../prisma/generated/output';
|
|
2
|
+
import { BucketInput, ExperimentWithBuckets, ExperimentInput, SegmentationData, AssignmentStatistics } from '.';
|
|
3
|
+
export declare class Abba {
|
|
4
|
+
private client;
|
|
5
|
+
constructor();
|
|
6
|
+
/**
|
|
7
|
+
* Returns all experiments
|
|
8
|
+
*
|
|
9
|
+
* @returns
|
|
10
|
+
*/
|
|
11
|
+
getAllExperiments(excludeInactive?: boolean): Promise<ExperimentWithBuckets[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new experiment
|
|
14
|
+
*
|
|
15
|
+
* @param experiment
|
|
16
|
+
* @param buckets
|
|
17
|
+
* @returns
|
|
18
|
+
*/
|
|
19
|
+
createExperiment(experiment: ExperimentInput, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
|
|
20
|
+
/**
|
|
21
|
+
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
22
|
+
*
|
|
23
|
+
* @param id
|
|
24
|
+
* @param experiment
|
|
25
|
+
* @param rules
|
|
26
|
+
* @param buckets
|
|
27
|
+
* @returns
|
|
28
|
+
*/
|
|
29
|
+
saveExperiment(id: number, experiment: ExperimentInput, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
|
|
30
|
+
/**
|
|
31
|
+
* Delete an experiment. Removes all user assignments and buckets
|
|
32
|
+
*
|
|
33
|
+
* @param id
|
|
34
|
+
*/
|
|
35
|
+
deleteExperiment(id: number): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
38
|
+
*
|
|
39
|
+
* @param experimentId
|
|
40
|
+
* @param userId
|
|
41
|
+
* @param createNew
|
|
42
|
+
* @param segmentationData
|
|
43
|
+
* @returns
|
|
44
|
+
*/
|
|
45
|
+
getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<UserAssignment | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Get all existing user assignments
|
|
48
|
+
*
|
|
49
|
+
* @param userId G
|
|
50
|
+
* @returns
|
|
51
|
+
*/
|
|
52
|
+
getAllExistingUserAssignments(userId: string): Promise<UserAssignment[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
|
|
55
|
+
*
|
|
56
|
+
* @param userId
|
|
57
|
+
* @param segmentationData
|
|
58
|
+
* @returns
|
|
59
|
+
*/
|
|
60
|
+
generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<UserAssignment[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Get assignment statistics for an experiment
|
|
63
|
+
*
|
|
64
|
+
* @param experimentId
|
|
65
|
+
* @returns
|
|
66
|
+
*/
|
|
67
|
+
getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
|
|
68
|
+
/**
|
|
69
|
+
* Generate a new assignment for a given user
|
|
70
|
+
*
|
|
71
|
+
* @param experimentId
|
|
72
|
+
* @param userId
|
|
73
|
+
* @param segmentationData
|
|
74
|
+
* @returns
|
|
75
|
+
*/
|
|
76
|
+
private generateUserAssignment;
|
|
77
|
+
/**
|
|
78
|
+
* Queries to retrieve an existing user assignment for a given experiment
|
|
79
|
+
*
|
|
80
|
+
* @param experimentId
|
|
81
|
+
* @param userId
|
|
82
|
+
* @returns
|
|
83
|
+
*/
|
|
84
|
+
private getExistingUserAssignment;
|
|
85
|
+
/**
|
|
86
|
+
* Update experiment information
|
|
87
|
+
*
|
|
88
|
+
* @param id
|
|
89
|
+
* @param experiment
|
|
90
|
+
* @param rules
|
|
91
|
+
* @returns
|
|
92
|
+
*/
|
|
93
|
+
private updateExperiment;
|
|
94
|
+
/**
|
|
95
|
+
* Upserts bucket info
|
|
96
|
+
*
|
|
97
|
+
* @param id
|
|
98
|
+
* @param experiment
|
|
99
|
+
* @returns
|
|
100
|
+
*/
|
|
101
|
+
private saveBuckets;
|
|
102
|
+
}
|
package/dist/abba.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Abba = void 0;
|
|
4
|
+
const output_1 = require("../prisma/generated/output");
|
|
5
|
+
const types_1 = require("./types");
|
|
6
|
+
const util_1 = require("./util");
|
|
7
|
+
// Note: Schema currently contains an output dir which generates all the files to the prisma dir
|
|
8
|
+
// it would be tidier not to include it here when possible later on:
|
|
9
|
+
// Explanation is here: https://github.com/prisma/prisma/issues/9435#issuecomment-960290681
|
|
10
|
+
class Abba {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.client = new output_1.PrismaClient();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Returns all experiments
|
|
16
|
+
*
|
|
17
|
+
* @returns
|
|
18
|
+
*/
|
|
19
|
+
async getAllExperiments(excludeInactive = false) {
|
|
20
|
+
return await this.client.experiment.findMany({
|
|
21
|
+
where: excludeInactive ? { NOT: { status: types_1.AssignmentStatus.Inactive } } : undefined,
|
|
22
|
+
include: { buckets: true },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Creates a new experiment
|
|
27
|
+
*
|
|
28
|
+
* @param experiment
|
|
29
|
+
* @param buckets
|
|
30
|
+
* @returns
|
|
31
|
+
*/
|
|
32
|
+
async createExperiment(experiment, buckets) {
|
|
33
|
+
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
34
|
+
(0, util_1.validateBuckets)(buckets);
|
|
35
|
+
}
|
|
36
|
+
const created = await this.client.experiment.create({
|
|
37
|
+
data: {
|
|
38
|
+
...experiment,
|
|
39
|
+
rules: experiment.rules,
|
|
40
|
+
buckets: {
|
|
41
|
+
createMany: {
|
|
42
|
+
data: buckets,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
include: {
|
|
47
|
+
buckets: true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return created;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
54
|
+
*
|
|
55
|
+
* @param id
|
|
56
|
+
* @param experiment
|
|
57
|
+
* @param rules
|
|
58
|
+
* @param buckets
|
|
59
|
+
* @returns
|
|
60
|
+
*/
|
|
61
|
+
async saveExperiment(id, experiment, buckets) {
|
|
62
|
+
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
63
|
+
(0, util_1.validateBuckets)(buckets);
|
|
64
|
+
}
|
|
65
|
+
const updatedExperiment = await this.updateExperiment(id, experiment);
|
|
66
|
+
const updatedBuckets = await this.saveBuckets(buckets);
|
|
67
|
+
return {
|
|
68
|
+
...updatedExperiment,
|
|
69
|
+
buckets: updatedBuckets,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Delete an experiment. Removes all user assignments and buckets
|
|
74
|
+
*
|
|
75
|
+
* @param id
|
|
76
|
+
*/
|
|
77
|
+
async deleteExperiment(id) {
|
|
78
|
+
await this.client.experiment.delete({ where: { id } });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
82
|
+
*
|
|
83
|
+
* @param experimentId
|
|
84
|
+
* @param userId
|
|
85
|
+
* @param createNew
|
|
86
|
+
* @param segmentationData
|
|
87
|
+
* @returns
|
|
88
|
+
*/
|
|
89
|
+
async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
|
|
90
|
+
const experiment = await this.client.experiment.findUnique({
|
|
91
|
+
where: { id: experimentId },
|
|
92
|
+
include: { buckets: true },
|
|
93
|
+
});
|
|
94
|
+
if (!experiment)
|
|
95
|
+
throw new Error('Experiment not found');
|
|
96
|
+
const existing = await this.getExistingUserAssignment(experimentId, userId);
|
|
97
|
+
if (existing)
|
|
98
|
+
return existing;
|
|
99
|
+
if (experiment.status !== types_1.AssignmentStatus.Active || existingOnly)
|
|
100
|
+
return null;
|
|
101
|
+
if (!segmentationData)
|
|
102
|
+
throw new Error('Segmentation data required when creating a new assignment');
|
|
103
|
+
return await this.generateUserAssignment(experiment, userId, segmentationData);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get all existing user assignments
|
|
107
|
+
*
|
|
108
|
+
* @param userId G
|
|
109
|
+
* @returns
|
|
110
|
+
*/
|
|
111
|
+
async getAllExistingUserAssignments(userId) {
|
|
112
|
+
return await this.client.userAssignment.findMany({ where: { userId } });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
|
|
116
|
+
*
|
|
117
|
+
* @param userId
|
|
118
|
+
* @param segmentationData
|
|
119
|
+
* @returns
|
|
120
|
+
*/
|
|
121
|
+
async generateUserAssignments(userId, segmentationData) {
|
|
122
|
+
const experiments = await this.getAllExperiments(true);
|
|
123
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
124
|
+
const assignments = [];
|
|
125
|
+
const generatedAssignments = [];
|
|
126
|
+
for (const experiment of experiments) {
|
|
127
|
+
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
128
|
+
if (existing) {
|
|
129
|
+
assignments.push(existing);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const generated = await Promise.all(generatedAssignments);
|
|
137
|
+
const filtered = generated.filter((ua) => ua !== null);
|
|
138
|
+
return [...assignments, ...filtered];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Get assignment statistics for an experiment
|
|
142
|
+
*
|
|
143
|
+
* @param experimentId
|
|
144
|
+
* @returns
|
|
145
|
+
*/
|
|
146
|
+
async getExperimentAssignmentStatistics(experimentId) {
|
|
147
|
+
const statistics = {
|
|
148
|
+
sampled: await this.client.userAssignment.count({ where: { experimentId } }),
|
|
149
|
+
buckets: {},
|
|
150
|
+
};
|
|
151
|
+
const buckets = await this.client.bucket.findMany({ where: { experimentId } });
|
|
152
|
+
const assignmentCounts = await this.client.userAssignment.groupBy({
|
|
153
|
+
where: { experimentId },
|
|
154
|
+
by: ['bucketId'],
|
|
155
|
+
_count: {
|
|
156
|
+
_all: true,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
buckets.forEach(({ id }) => {
|
|
160
|
+
statistics.buckets[`${id}`] = assignmentCounts.find(i => i.bucketId === id)?._count?._all || 0;
|
|
161
|
+
});
|
|
162
|
+
return statistics;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Generate a new assignment for a given user
|
|
166
|
+
*
|
|
167
|
+
* @param experimentId
|
|
168
|
+
* @param userId
|
|
169
|
+
* @param segmentationData
|
|
170
|
+
* @returns
|
|
171
|
+
*/
|
|
172
|
+
async generateUserAssignment(experiment, userId, segmentationData) {
|
|
173
|
+
const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
|
|
174
|
+
if (!segmentationMatch)
|
|
175
|
+
return null;
|
|
176
|
+
const bucketId = (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets);
|
|
177
|
+
return await this.client.userAssignment.create({
|
|
178
|
+
data: { userId, experimentId: experiment.id, bucketId },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Queries to retrieve an existing user assignment for a given experiment
|
|
183
|
+
*
|
|
184
|
+
* @param experimentId
|
|
185
|
+
* @param userId
|
|
186
|
+
* @returns
|
|
187
|
+
*/
|
|
188
|
+
async getExistingUserAssignment(experimentId, userId) {
|
|
189
|
+
return await this.client.userAssignment.findFirst({ where: { userId, experimentId } });
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Update experiment information
|
|
193
|
+
*
|
|
194
|
+
* @param id
|
|
195
|
+
* @param experiment
|
|
196
|
+
* @param rules
|
|
197
|
+
* @returns
|
|
198
|
+
*/
|
|
199
|
+
async updateExperiment(id, experiment) {
|
|
200
|
+
return await this.client.experiment.update({
|
|
201
|
+
where: { id },
|
|
202
|
+
data: {
|
|
203
|
+
...experiment,
|
|
204
|
+
rules: experiment.rules,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Upserts bucket info
|
|
210
|
+
*
|
|
211
|
+
* @param id
|
|
212
|
+
* @param experiment
|
|
213
|
+
* @returns
|
|
214
|
+
*/
|
|
215
|
+
async saveBuckets(buckets) {
|
|
216
|
+
const savedBuckets = [];
|
|
217
|
+
for (const bucket of buckets) {
|
|
218
|
+
const { id, ...data } = bucket;
|
|
219
|
+
if (id) {
|
|
220
|
+
savedBuckets.push(this.client.bucket.update({ where: { id }, data }));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
savedBuckets.push(this.client.bucket.create({
|
|
224
|
+
data: {
|
|
225
|
+
key: data.key,
|
|
226
|
+
ratio: data.ratio,
|
|
227
|
+
experiment: {
|
|
228
|
+
connect: {
|
|
229
|
+
id: data.experimentId,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return await Promise.all(savedBuckets);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
exports.Abba = Abba;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Abba = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
(0, tslib_1.__exportStar)(require("./types"), exports);
|
|
6
|
+
var abba_1 = require("./abba");
|
|
7
|
+
Object.defineProperty(exports, "Abba", { enumerable: true, get: function () { return abba_1.Abba; } });
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Bucket, Experiment } from '../prisma/generated/output';
|
|
2
|
+
export declare type Unsaved<T> = Omit<T, 'createdAt' | 'updatedAt'> & {
|
|
3
|
+
id?: number;
|
|
4
|
+
};
|
|
5
|
+
export declare type ExperimentWithBuckets = Experiment & {
|
|
6
|
+
buckets: Bucket[];
|
|
7
|
+
};
|
|
8
|
+
export declare type ExperimentInput = Unsaved<Experiment>;
|
|
9
|
+
export declare type BucketInput = Unsaved<Bucket>;
|
|
10
|
+
export declare type SegmentationData = Record<string, string | boolean | number>;
|
|
11
|
+
export declare enum AssignmentStatus {
|
|
12
|
+
Active = 1,
|
|
13
|
+
Paused = 2,
|
|
14
|
+
Inactive = 3
|
|
15
|
+
}
|
|
16
|
+
export interface SegmentationRule {
|
|
17
|
+
key: string;
|
|
18
|
+
operator: '==' | '!=' | 'semver' | 'regex' | 'boolean';
|
|
19
|
+
value: string | boolean | number;
|
|
20
|
+
}
|
|
21
|
+
export interface AssignmentStatistics {
|
|
22
|
+
sampled: number;
|
|
23
|
+
buckets: {
|
|
24
|
+
[id: string]: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AssignmentStatus = void 0;
|
|
4
|
+
var AssignmentStatus;
|
|
5
|
+
(function (AssignmentStatus) {
|
|
6
|
+
AssignmentStatus[AssignmentStatus["Active"] = 1] = "Active";
|
|
7
|
+
AssignmentStatus[AssignmentStatus["Paused"] = 2] = "Paused";
|
|
8
|
+
AssignmentStatus[AssignmentStatus["Inactive"] = 3] = "Inactive";
|
|
9
|
+
})(AssignmentStatus = exports.AssignmentStatus || (exports.AssignmentStatus = {}));
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Bucket, Experiment } from '../prisma/generated/output';
|
|
2
|
+
import { BucketInput, SegmentationData, SegmentationRule } from '.';
|
|
3
|
+
/**
|
|
4
|
+
* Generate a random number between 0 and 100
|
|
5
|
+
*
|
|
6
|
+
* @returns
|
|
7
|
+
*/
|
|
8
|
+
export declare function rollDie(): number;
|
|
9
|
+
/**
|
|
10
|
+
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
11
|
+
*
|
|
12
|
+
* @param sampling
|
|
13
|
+
* @param buckets
|
|
14
|
+
* @returns
|
|
15
|
+
*/
|
|
16
|
+
export declare function determineAssignment(sampling: number, buckets: Bucket[]): number | null;
|
|
17
|
+
/**
|
|
18
|
+
* Determines whether a user will be considered for a bucket assignment based on the experiment sampling rate
|
|
19
|
+
*
|
|
20
|
+
* @param experiment
|
|
21
|
+
* @returns
|
|
22
|
+
*/
|
|
23
|
+
export declare function determineExperiment(experiment: Experiment): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Determines which bucket a user assignment will recieve
|
|
26
|
+
*
|
|
27
|
+
* @param buckets
|
|
28
|
+
* @returns
|
|
29
|
+
*/
|
|
30
|
+
export declare function determineBucket(buckets: Bucket[]): number;
|
|
31
|
+
/**
|
|
32
|
+
* Validate the total ratio of the buckets equals 100
|
|
33
|
+
*
|
|
34
|
+
* @param buckets
|
|
35
|
+
* @returns
|
|
36
|
+
*/
|
|
37
|
+
export declare function validateBuckets(buckets: BucketInput[]): void;
|
|
38
|
+
/**
|
|
39
|
+
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
40
|
+
*
|
|
41
|
+
* @param rules
|
|
42
|
+
* @param segmentationData
|
|
43
|
+
* @returns
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateSegmentationRules(rules: SegmentationRule[], segmentationData: SegmentationData): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Validate a users segmentation data against a single rule
|
|
48
|
+
*
|
|
49
|
+
* @param rule
|
|
50
|
+
* @param segmentationData
|
|
51
|
+
* @returns
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateSegmentationRule(rule: SegmentationRule, data: SegmentationData): boolean;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateSegmentationRule = exports.validateSegmentationRules = exports.validateBuckets = exports.determineBucket = exports.determineExperiment = exports.determineAssignment = exports.rollDie = void 0;
|
|
4
|
+
const semver_1 = require("semver");
|
|
5
|
+
/**
|
|
6
|
+
* Generate a random number between 0 and 100
|
|
7
|
+
*
|
|
8
|
+
* @returns
|
|
9
|
+
*/
|
|
10
|
+
function rollDie() {
|
|
11
|
+
return Math.random() * 100;
|
|
12
|
+
}
|
|
13
|
+
exports.rollDie = rollDie;
|
|
14
|
+
/**
|
|
15
|
+
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
+
*
|
|
17
|
+
* @param sampling
|
|
18
|
+
* @param buckets
|
|
19
|
+
* @returns
|
|
20
|
+
*/
|
|
21
|
+
function determineAssignment(sampling, buckets) {
|
|
22
|
+
// Should this person be considered for the experiment?
|
|
23
|
+
const isIncludedInSample = rollDie() <= sampling;
|
|
24
|
+
if (!isIncludedInSample) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
// get their bucket
|
|
28
|
+
return determineBucket(buckets);
|
|
29
|
+
}
|
|
30
|
+
exports.determineAssignment = determineAssignment;
|
|
31
|
+
/**
|
|
32
|
+
* Determines whether a user will be considered for a bucket assignment based on the experiment sampling rate
|
|
33
|
+
*
|
|
34
|
+
* @param experiment
|
|
35
|
+
* @returns
|
|
36
|
+
*/
|
|
37
|
+
function determineExperiment(experiment) {
|
|
38
|
+
return rollDie() <= experiment.sampling; // between 0 and 100
|
|
39
|
+
}
|
|
40
|
+
exports.determineExperiment = determineExperiment;
|
|
41
|
+
/**
|
|
42
|
+
* Determines which bucket a user assignment will recieve
|
|
43
|
+
*
|
|
44
|
+
* @param buckets
|
|
45
|
+
* @returns
|
|
46
|
+
*/
|
|
47
|
+
function determineBucket(buckets) {
|
|
48
|
+
const bucketRoll = rollDie();
|
|
49
|
+
let range;
|
|
50
|
+
const bucket = buckets.find(b => {
|
|
51
|
+
if (!range) {
|
|
52
|
+
range = [0, b.ratio];
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
range = [range[1], range[1] + b.ratio];
|
|
56
|
+
}
|
|
57
|
+
if (bucketRoll > range[0] && bucketRoll <= range[1]) {
|
|
58
|
+
return b;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (!bucket) {
|
|
62
|
+
throw new Error('Could not detetermine bucket from ratios');
|
|
63
|
+
}
|
|
64
|
+
return bucket.id;
|
|
65
|
+
}
|
|
66
|
+
exports.determineBucket = determineBucket;
|
|
67
|
+
/**
|
|
68
|
+
* Validate the total ratio of the buckets equals 100
|
|
69
|
+
*
|
|
70
|
+
* @param buckets
|
|
71
|
+
* @returns
|
|
72
|
+
*/
|
|
73
|
+
function validateBuckets(buckets) {
|
|
74
|
+
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
75
|
+
if (bucketSum !== 100) {
|
|
76
|
+
throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
exports.validateBuckets = validateBuckets;
|
|
80
|
+
/**
|
|
81
|
+
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
82
|
+
*
|
|
83
|
+
* @param rules
|
|
84
|
+
* @param segmentationData
|
|
85
|
+
* @returns
|
|
86
|
+
*/
|
|
87
|
+
function validateSegmentationRules(rules, segmentationData) {
|
|
88
|
+
for (const rule of rules) {
|
|
89
|
+
if (!validateSegmentationRule(rule, segmentationData))
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
exports.validateSegmentationRules = validateSegmentationRules;
|
|
95
|
+
/**
|
|
96
|
+
* Validate a users segmentation data against a single rule
|
|
97
|
+
*
|
|
98
|
+
* @param rule
|
|
99
|
+
* @param segmentationData
|
|
100
|
+
* @returns
|
|
101
|
+
*/
|
|
102
|
+
function validateSegmentationRule(rule, data) {
|
|
103
|
+
const { key, value, operator } = rule;
|
|
104
|
+
if (operator === '==') {
|
|
105
|
+
return data[key] === value;
|
|
106
|
+
}
|
|
107
|
+
else if (operator === '!=') {
|
|
108
|
+
return data[key] !== value;
|
|
109
|
+
}
|
|
110
|
+
else if (operator === 'semver') {
|
|
111
|
+
return (0, semver_1.satisfies)(data[key]?.toString() || '', value.toString());
|
|
112
|
+
}
|
|
113
|
+
else if (operator === 'regex') {
|
|
114
|
+
return new RegExp(value.toString()).test(data[key]?.toString() || '');
|
|
115
|
+
}
|
|
116
|
+
else if (operator === 'boolean') {
|
|
117
|
+
return Boolean(value) === data[key];
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
exports.validateSegmentationRule = validateSegmentationRule;
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@naturalcycles/abba",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"prepare": "husky install"
|
|
6
|
+
},
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@prisma/client": "^3.9.2",
|
|
9
|
+
"semver": "^7.3.5"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@naturalcycles/dev-lib": "^12.0.0",
|
|
13
|
+
"@types/json-logic-js": "^1.2.1",
|
|
14
|
+
"@types/node": "^16.0.0",
|
|
15
|
+
"@types/semver": "^7.3.9",
|
|
16
|
+
"jest": "^27.5.1",
|
|
17
|
+
"prisma": "^3.9.2"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/test",
|
|
23
|
+
"!src/**/*.test.ts",
|
|
24
|
+
"!src/**/__snapshots__",
|
|
25
|
+
"!src/**/__exclude"
|
|
26
|
+
],
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"types": "dist/index.d.ts",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/NaturalCycles/abba"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=14.15.0"
|
|
38
|
+
},
|
|
39
|
+
"description": "AB test assignment configuration tool for Node.js",
|
|
40
|
+
"author": "Natural Cycles Team",
|
|
41
|
+
"license": "MIT"
|
|
42
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<div id="top"></div>
|
|
2
|
+
|
|
3
|
+
<!-- PROJECT LOGO -->
|
|
4
|
+
<br />
|
|
5
|
+
<div align="center">
|
|
6
|
+
<h3 align="center">ABBA</h3>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
A tool for generating and persisting AB test assignments
|
|
10
|
+
<br />
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<!-- TABLE OF CONTENTS -->
|
|
15
|
+
<details>
|
|
16
|
+
<summary>Table of Contents</summary>
|
|
17
|
+
<ol>
|
|
18
|
+
<li>
|
|
19
|
+
<a href="#concepts">Concepts</a>
|
|
20
|
+
</li>
|
|
21
|
+
<li>
|
|
22
|
+
<a href="#getting-started">Getting Started</a>
|
|
23
|
+
<ul>
|
|
24
|
+
<li><a href="#prerequisites">Prerequisites</a></li>
|
|
25
|
+
<li><a href="#installation">Installation</a></li>
|
|
26
|
+
</ul>
|
|
27
|
+
</li>
|
|
28
|
+
<li><a href="#usage">Usage</a></li>
|
|
29
|
+
<li><a href="#segmentation">Segmentation</a></li>
|
|
30
|
+
</ol>
|
|
31
|
+
</details>
|
|
32
|
+
|
|
33
|
+
<!-- CONCEPTS -->
|
|
34
|
+
|
|
35
|
+
## Concepts
|
|
36
|
+
|
|
37
|
+
- **Experiment:** An individual experiment that will test a hypothesis
|
|
38
|
+
- **Segmentation:** The target audience for the experiment
|
|
39
|
+
- **Sampling:** Restrictions on what proportion of the target audience will be involved in the
|
|
40
|
+
experiment
|
|
41
|
+
- **Bucket:** An allocation that defines what variant of a particular experience the user will have
|
|
42
|
+
|
|
43
|
+
<!-- BUILTWITH -->
|
|
44
|
+
|
|
45
|
+
### Built With
|
|
46
|
+
|
|
47
|
+
- [Prisma](https://www.prisma.io/)
|
|
48
|
+
|
|
49
|
+
<p align="right">(<a href="#top">back to top</a>)</p>
|
|
50
|
+
|
|
51
|
+
<!-- GETTING STARTED -->
|
|
52
|
+
|
|
53
|
+
## Getting Started
|
|
54
|
+
|
|
55
|
+
<div id="gettingStarted"></div>
|
|
56
|
+
|
|
57
|
+
### Prerequisites
|
|
58
|
+
|
|
59
|
+
<div id="prerequisites"></div>
|
|
60
|
+
|
|
61
|
+
- A running MYSQL instance
|
|
62
|
+
|
|
63
|
+
### Installation
|
|
64
|
+
|
|
65
|
+
<div id="installation"></div>
|
|
66
|
+
|
|
67
|
+
_Below is an example of how you can instruct your audience on installing and setting up your app.
|
|
68
|
+
This template doesn't rely on any external dependencies or services._
|
|
69
|
+
|
|
70
|
+
1. Install NPM packages<br/>
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
yarn add @naturalcyles/abba
|
|
74
|
+
|
|
75
|
+
or
|
|
76
|
+
|
|
77
|
+
npm install @naturalcyles/abba
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
2. Execute the [sql script found here](/src/prisma/migrations/init.md) to generate the required DB
|
|
81
|
+
Schema
|
|
82
|
+
|
|
83
|
+
3. Create a `.env` file add add your Database Url using the following key:
|
|
84
|
+
```sh
|
|
85
|
+
EXPERIMENT_MANAGER_DB_URL="{{insert your instance url}}"
|
|
86
|
+
```
|
|
87
|
+
4. Create a new instance of the AssignmentManager class
|
|
88
|
+
```js
|
|
89
|
+
const manager = new AssignmentManager()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
<p align="right">(<a href="#top">back to top</a>)</p>
|
|
93
|
+
|
|
94
|
+
<!-- USAGE EXAMPLES -->
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
<div id="usage"></div>
|
|
99
|
+
|
|
100
|
+
### Create a new experiment
|
|
101
|
+
|
|
102
|
+
Creates a new experiment
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
async manager.createExperiment(
|
|
106
|
+
input: ExperimentInput,
|
|
107
|
+
buckets: BucketInput[]
|
|
108
|
+
): Promise<Experiment>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Update an experiment
|
|
112
|
+
|
|
113
|
+
Updates an existing experiment.
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
async manager.updateExperiment(
|
|
117
|
+
id: number,
|
|
118
|
+
input: ExperimentInput,
|
|
119
|
+
buckets: BucketInput[]
|
|
120
|
+
): Promise<Experiment>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Delete an experiment
|
|
124
|
+
|
|
125
|
+
Delete an experiment. Removes all users assignments and buckets
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
async manager.deleteExperiment(
|
|
129
|
+
id: number
|
|
130
|
+
): Promise<void>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Get all existing user assignments
|
|
134
|
+
|
|
135
|
+
Gets all existing user assignments
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
async getAllExistingUserAssignments(
|
|
139
|
+
userId: string
|
|
140
|
+
): Promise<UserAssignment[]>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Get a users assignment
|
|
144
|
+
|
|
145
|
+
Get an assignment for a given user. If `existingOnly` is false, it will attempt generate a new
|
|
146
|
+
assignment. `segmentationData` becomse required when `existingOnly` is false
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
async getUserAssignment(
|
|
150
|
+
experimentId: number,
|
|
151
|
+
userId: string,
|
|
152
|
+
existingOnly: boolean,
|
|
153
|
+
segmentationData?: SegmentationData,
|
|
154
|
+
): Promise<UserAssignment | null>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Generate user assignments
|
|
158
|
+
|
|
159
|
+
Generate user assignments for all active experiments. Will return any existing assignments and
|
|
160
|
+
attempt to generate new assignments.
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
async generateUserAssignments(
|
|
164
|
+
userId: string,
|
|
165
|
+
segmentationData: SegmentationData,
|
|
166
|
+
): Promise<UserAssignment[]>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Getting assignment statistics
|
|
170
|
+
|
|
171
|
+
Get assignment statistics for an experiment.
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
async getExperimentAssignmentStatistics(
|
|
175
|
+
experimentId: number
|
|
176
|
+
): Promise<AssignmentStatistics>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
<p align="right">(<a href="#top">back to top</a>)</p>
|
|
180
|
+
|
|
181
|
+
## Segmentation
|
|
182
|
+
|
|
183
|
+
<div id="segmentation"></div>
|
|
184
|
+
|
|
185
|
+
Experiments can be configured to target specific audiences using segmentation rules. When generating
|
|
186
|
+
assignments it is possible to test these rules using user segmentation data which is an object
|
|
187
|
+
containing key/value pairs unique to each user. (Allowed value types: `string`, `number`,
|
|
188
|
+
`boolean`). A segmentation rule consist of the following properties:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
key: string, // the key of the corresponding segmentationData property.
|
|
192
|
+
operator: '==' | '!=' | 'semver' | 'regex' | 'boolean', // the operator that will be used to execute the rule
|
|
193
|
+
value: string | number | boolean, // the value the operator will be executed against
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Segmentation rule operators
|
|
197
|
+
|
|
198
|
+
### Equals (==)
|
|
199
|
+
|
|
200
|
+
Rule:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
{ key: 'country', operator: '==', value: 'SE }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Example segmentation data:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
{
|
|
210
|
+
country: 'SE', // valid
|
|
211
|
+
country: 'NO' // not valid
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Not equals (!=)
|
|
216
|
+
|
|
217
|
+
Rule:
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
{ key: 'country', operator: '!=', value: 'SE' }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Example segmentation data:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
{
|
|
227
|
+
country: 'NO', // valid
|
|
228
|
+
country: 'SE' // not valid
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Boolean (boolean)
|
|
233
|
+
|
|
234
|
+
Rule:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
{ key: 'isEligible', operator: 'boolean', value: true }
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Example segmentation data:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
{
|
|
244
|
+
isEligible: true, // valid
|
|
245
|
+
isEligible: false // not valid
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Semver (semver)
|
|
250
|
+
|
|
251
|
+
Rule:
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
{ key: 'appVersion', operator: 'semver', value: '>1.1.0' }
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Example segmentation data:
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
{
|
|
261
|
+
appVersion: '1.2.0', // valid
|
|
262
|
+
appVersion: '1' // not valid
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Regex (regex)
|
|
267
|
+
|
|
268
|
+
Rule:
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
{ key: 'country', operator: 'regex', value: 'SE|NO' }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Example segmentation data:
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
{
|
|
278
|
+
country: 'SE', // valid
|
|
279
|
+
country: 'NO', // valid
|
|
280
|
+
country: 'GB' // not valid
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
<p align="right">(<a href="#top">back to top</a>)</p>
|
package/src/abba.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Bucket,
|
|
3
|
+
Experiment,
|
|
4
|
+
Prisma,
|
|
5
|
+
PrismaClient,
|
|
6
|
+
UserAssignment,
|
|
7
|
+
} from '../prisma/generated/output'
|
|
8
|
+
import { AssignmentStatus } from './types'
|
|
9
|
+
import { determineAssignment, validateSegmentationRules, validateBuckets } from './util'
|
|
10
|
+
import {
|
|
11
|
+
BucketInput,
|
|
12
|
+
ExperimentWithBuckets,
|
|
13
|
+
ExperimentInput,
|
|
14
|
+
SegmentationData,
|
|
15
|
+
SegmentationRule,
|
|
16
|
+
AssignmentStatistics,
|
|
17
|
+
} from '.'
|
|
18
|
+
|
|
19
|
+
// Note: Schema currently contains an output dir which generates all the files to the prisma dir
|
|
20
|
+
// it would be tidier not to include it here when possible later on:
|
|
21
|
+
// Explanation is here: https://github.com/prisma/prisma/issues/9435#issuecomment-960290681
|
|
22
|
+
|
|
23
|
+
export class Abba {
|
|
24
|
+
private client: PrismaClient
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.client = new PrismaClient()
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Returns all experiments
|
|
31
|
+
*
|
|
32
|
+
* @returns
|
|
33
|
+
*/
|
|
34
|
+
async getAllExperiments(excludeInactive: boolean = false): Promise<ExperimentWithBuckets[]> {
|
|
35
|
+
return await this.client.experiment.findMany({
|
|
36
|
+
where: excludeInactive ? { NOT: { status: AssignmentStatus.Inactive } } : undefined,
|
|
37
|
+
include: { buckets: true },
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a new experiment
|
|
43
|
+
*
|
|
44
|
+
* @param experiment
|
|
45
|
+
* @param buckets
|
|
46
|
+
* @returns
|
|
47
|
+
*/
|
|
48
|
+
async createExperiment(
|
|
49
|
+
experiment: ExperimentInput,
|
|
50
|
+
buckets: BucketInput[],
|
|
51
|
+
): Promise<ExperimentWithBuckets> {
|
|
52
|
+
if (experiment.status === AssignmentStatus.Active) {
|
|
53
|
+
validateBuckets(buckets)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const created = await this.client.experiment.create({
|
|
57
|
+
data: {
|
|
58
|
+
...experiment,
|
|
59
|
+
rules: experiment.rules as Prisma.InputJsonArray,
|
|
60
|
+
buckets: {
|
|
61
|
+
createMany: {
|
|
62
|
+
data: buckets,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
include: {
|
|
67
|
+
buckets: true,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
return created
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
75
|
+
*
|
|
76
|
+
* @param id
|
|
77
|
+
* @param experiment
|
|
78
|
+
* @param rules
|
|
79
|
+
* @param buckets
|
|
80
|
+
* @returns
|
|
81
|
+
*/
|
|
82
|
+
async saveExperiment(
|
|
83
|
+
id: number,
|
|
84
|
+
experiment: ExperimentInput,
|
|
85
|
+
buckets: BucketInput[],
|
|
86
|
+
): Promise<ExperimentWithBuckets> {
|
|
87
|
+
if (experiment.status === AssignmentStatus.Active) {
|
|
88
|
+
validateBuckets(buckets)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const updatedExperiment = await this.updateExperiment(id, experiment)
|
|
92
|
+
const updatedBuckets = await this.saveBuckets(buckets)
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...updatedExperiment,
|
|
96
|
+
buckets: updatedBuckets,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Delete an experiment. Removes all user assignments and buckets
|
|
102
|
+
*
|
|
103
|
+
* @param id
|
|
104
|
+
*/
|
|
105
|
+
async deleteExperiment(id: number): Promise<void> {
|
|
106
|
+
await this.client.experiment.delete({ where: { id } })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
111
|
+
*
|
|
112
|
+
* @param experimentId
|
|
113
|
+
* @param userId
|
|
114
|
+
* @param createNew
|
|
115
|
+
* @param segmentationData
|
|
116
|
+
* @returns
|
|
117
|
+
*/
|
|
118
|
+
async getUserAssignment(
|
|
119
|
+
experimentId: number,
|
|
120
|
+
userId: string,
|
|
121
|
+
existingOnly: boolean,
|
|
122
|
+
segmentationData?: SegmentationData,
|
|
123
|
+
): Promise<UserAssignment | null> {
|
|
124
|
+
const experiment = await this.client.experiment.findUnique({
|
|
125
|
+
where: { id: experimentId },
|
|
126
|
+
include: { buckets: true },
|
|
127
|
+
})
|
|
128
|
+
if (!experiment) throw new Error('Experiment not found')
|
|
129
|
+
|
|
130
|
+
const existing = await this.getExistingUserAssignment(experimentId, userId)
|
|
131
|
+
if (existing) return existing
|
|
132
|
+
|
|
133
|
+
if (experiment.status !== AssignmentStatus.Active || existingOnly) return null
|
|
134
|
+
|
|
135
|
+
if (!segmentationData)
|
|
136
|
+
throw new Error('Segmentation data required when creating a new assignment')
|
|
137
|
+
|
|
138
|
+
return await this.generateUserAssignment(experiment, userId, segmentationData)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get all existing user assignments
|
|
143
|
+
*
|
|
144
|
+
* @param userId G
|
|
145
|
+
* @returns
|
|
146
|
+
*/
|
|
147
|
+
async getAllExistingUserAssignments(userId: string): Promise<UserAssignment[]> {
|
|
148
|
+
return await this.client.userAssignment.findMany({ where: { userId } })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
|
|
153
|
+
*
|
|
154
|
+
* @param userId
|
|
155
|
+
* @param segmentationData
|
|
156
|
+
* @returns
|
|
157
|
+
*/
|
|
158
|
+
async generateUserAssignments(
|
|
159
|
+
userId: string,
|
|
160
|
+
segmentationData: SegmentationData,
|
|
161
|
+
): Promise<UserAssignment[]> {
|
|
162
|
+
const experiments = await this.getAllExperiments(true)
|
|
163
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
164
|
+
|
|
165
|
+
const assignments: UserAssignment[] = []
|
|
166
|
+
const generatedAssignments: Promise<UserAssignment | null>[] = []
|
|
167
|
+
|
|
168
|
+
for (const experiment of experiments) {
|
|
169
|
+
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
|
|
170
|
+
if (existing) {
|
|
171
|
+
assignments.push(existing)
|
|
172
|
+
continue
|
|
173
|
+
} else {
|
|
174
|
+
generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData))
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const generated = await Promise.all(generatedAssignments)
|
|
179
|
+
const filtered = generated.filter((ua): ua is UserAssignment => ua !== null)
|
|
180
|
+
return [...assignments, ...filtered]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get assignment statistics for an experiment
|
|
185
|
+
*
|
|
186
|
+
* @param experimentId
|
|
187
|
+
* @returns
|
|
188
|
+
*/
|
|
189
|
+
async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
|
|
190
|
+
const statistics = {
|
|
191
|
+
sampled: await this.client.userAssignment.count({ where: { experimentId } }),
|
|
192
|
+
buckets: {},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const buckets = await this.client.bucket.findMany({ where: { experimentId } })
|
|
196
|
+
const assignmentCounts = await this.client.userAssignment.groupBy({
|
|
197
|
+
where: { experimentId },
|
|
198
|
+
by: ['bucketId'],
|
|
199
|
+
_count: {
|
|
200
|
+
_all: true,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
buckets.forEach(({ id }) => {
|
|
205
|
+
statistics.buckets[`${id}`] = assignmentCounts.find(i => i.bucketId === id)?._count?._all || 0
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return statistics
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generate a new assignment for a given user
|
|
213
|
+
*
|
|
214
|
+
* @param experimentId
|
|
215
|
+
* @param userId
|
|
216
|
+
* @param segmentationData
|
|
217
|
+
* @returns
|
|
218
|
+
*/
|
|
219
|
+
private async generateUserAssignment(
|
|
220
|
+
experiment: ExperimentWithBuckets,
|
|
221
|
+
userId: string,
|
|
222
|
+
segmentationData: SegmentationData,
|
|
223
|
+
): Promise<UserAssignment | null> {
|
|
224
|
+
const segmentationMatch = validateSegmentationRules(
|
|
225
|
+
experiment.rules as unknown as SegmentationRule[],
|
|
226
|
+
segmentationData,
|
|
227
|
+
)
|
|
228
|
+
if (!segmentationMatch) return null
|
|
229
|
+
|
|
230
|
+
const bucketId = determineAssignment(experiment.sampling, experiment.buckets)
|
|
231
|
+
return await this.client.userAssignment.create({
|
|
232
|
+
data: { userId, experimentId: experiment.id, bucketId },
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Queries to retrieve an existing user assignment for a given experiment
|
|
238
|
+
*
|
|
239
|
+
* @param experimentId
|
|
240
|
+
* @param userId
|
|
241
|
+
* @returns
|
|
242
|
+
*/
|
|
243
|
+
private async getExistingUserAssignment(
|
|
244
|
+
experimentId: number,
|
|
245
|
+
userId: string,
|
|
246
|
+
): Promise<UserAssignment | null> {
|
|
247
|
+
return await this.client.userAssignment.findFirst({ where: { userId, experimentId } })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Update experiment information
|
|
252
|
+
*
|
|
253
|
+
* @param id
|
|
254
|
+
* @param experiment
|
|
255
|
+
* @param rules
|
|
256
|
+
* @returns
|
|
257
|
+
*/
|
|
258
|
+
private async updateExperiment(id: number, experiment: Partial<Experiment>): Promise<Experiment> {
|
|
259
|
+
return await this.client.experiment.update({
|
|
260
|
+
where: { id },
|
|
261
|
+
data: {
|
|
262
|
+
...experiment,
|
|
263
|
+
rules: experiment.rules as Prisma.InputJsonObject,
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Upserts bucket info
|
|
270
|
+
*
|
|
271
|
+
* @param id
|
|
272
|
+
* @param experiment
|
|
273
|
+
* @returns
|
|
274
|
+
*/
|
|
275
|
+
private async saveBuckets(buckets: BucketInput[]): Promise<Bucket[]> {
|
|
276
|
+
const savedBuckets: Promise<Bucket>[] = []
|
|
277
|
+
for (const bucket of buckets) {
|
|
278
|
+
const { id, ...data } = bucket
|
|
279
|
+
if (id) {
|
|
280
|
+
savedBuckets.push(this.client.bucket.update({ where: { id }, data }))
|
|
281
|
+
} else {
|
|
282
|
+
savedBuckets.push(
|
|
283
|
+
this.client.bucket.create({
|
|
284
|
+
data: {
|
|
285
|
+
key: data.key,
|
|
286
|
+
ratio: data.ratio,
|
|
287
|
+
experiment: {
|
|
288
|
+
connect: {
|
|
289
|
+
id: data.experimentId,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return await Promise.all(savedBuckets)
|
|
299
|
+
}
|
|
300
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Bucket, Experiment } from '../prisma/generated/output'
|
|
2
|
+
|
|
3
|
+
export type Unsaved<T> = Omit<T, 'createdAt' | 'updatedAt'> & {
|
|
4
|
+
id?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ExperimentWithBuckets = Experiment & {
|
|
8
|
+
buckets: Bucket[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ExperimentInput = Unsaved<Experiment>
|
|
12
|
+
|
|
13
|
+
export type BucketInput = Unsaved<Bucket>
|
|
14
|
+
|
|
15
|
+
export type SegmentationData = Record<string, string | boolean | number>
|
|
16
|
+
|
|
17
|
+
export enum AssignmentStatus {
|
|
18
|
+
Active = 1, // Generating assignments
|
|
19
|
+
Paused = 2, // Not generating new assignments, still returning existing
|
|
20
|
+
Inactive = 3, // Will not return any assignments
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SegmentationRule {
|
|
24
|
+
key: string
|
|
25
|
+
operator: '==' | '!=' | 'semver' | 'regex' | 'boolean'
|
|
26
|
+
value: string | boolean | number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AssignmentStatistics {
|
|
30
|
+
sampled: number
|
|
31
|
+
buckets: {
|
|
32
|
+
[id: string]: number
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { satisfies } from 'semver'
|
|
2
|
+
import { Bucket, Experiment } from '../prisma/generated/output'
|
|
3
|
+
import { BucketInput, SegmentationData, SegmentationRule } from '.'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a random number between 0 and 100
|
|
7
|
+
*
|
|
8
|
+
* @returns
|
|
9
|
+
*/
|
|
10
|
+
export function rollDie(): number {
|
|
11
|
+
return Math.random() * 100
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
+
*
|
|
17
|
+
* @param sampling
|
|
18
|
+
* @param buckets
|
|
19
|
+
* @returns
|
|
20
|
+
*/
|
|
21
|
+
export function determineAssignment(sampling: number, buckets: Bucket[]): number | null {
|
|
22
|
+
// Should this person be considered for the experiment?
|
|
23
|
+
const isIncludedInSample = rollDie() <= sampling
|
|
24
|
+
if (!isIncludedInSample) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// get their bucket
|
|
29
|
+
return determineBucket(buckets)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Determines whether a user will be considered for a bucket assignment based on the experiment sampling rate
|
|
34
|
+
*
|
|
35
|
+
* @param experiment
|
|
36
|
+
* @returns
|
|
37
|
+
*/
|
|
38
|
+
export function determineExperiment(experiment: Experiment): boolean {
|
|
39
|
+
return rollDie() <= experiment.sampling // between 0 and 100
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Determines which bucket a user assignment will recieve
|
|
44
|
+
*
|
|
45
|
+
* @param buckets
|
|
46
|
+
* @returns
|
|
47
|
+
*/
|
|
48
|
+
export function determineBucket(buckets: Bucket[]): number {
|
|
49
|
+
const bucketRoll = rollDie()
|
|
50
|
+
let range: [number, number] | undefined
|
|
51
|
+
const bucket = buckets.find(b => {
|
|
52
|
+
if (!range) {
|
|
53
|
+
range = [0, b.ratio]
|
|
54
|
+
} else {
|
|
55
|
+
range = [range[1], range[1] + b.ratio]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (bucketRoll > range[0] && bucketRoll <= range[1]) {
|
|
59
|
+
return b
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (!bucket) {
|
|
64
|
+
throw new Error('Could not detetermine bucket from ratios')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return bucket.id
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate the total ratio of the buckets equals 100
|
|
72
|
+
*
|
|
73
|
+
* @param buckets
|
|
74
|
+
* @returns
|
|
75
|
+
*/
|
|
76
|
+
export function validateBuckets(buckets: BucketInput[]): void {
|
|
77
|
+
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
|
|
78
|
+
if (bucketSum !== 100) {
|
|
79
|
+
throw new Error('Total bucket ratio must be 100 before you can activate an experiment')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
85
|
+
*
|
|
86
|
+
* @param rules
|
|
87
|
+
* @param segmentationData
|
|
88
|
+
* @returns
|
|
89
|
+
*/
|
|
90
|
+
export function validateSegmentationRules(
|
|
91
|
+
rules: SegmentationRule[],
|
|
92
|
+
segmentationData: SegmentationData,
|
|
93
|
+
): boolean {
|
|
94
|
+
for (const rule of rules) {
|
|
95
|
+
if (!validateSegmentationRule(rule, segmentationData)) return false
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate a users segmentation data against a single rule
|
|
102
|
+
*
|
|
103
|
+
* @param rule
|
|
104
|
+
* @param segmentationData
|
|
105
|
+
* @returns
|
|
106
|
+
*/
|
|
107
|
+
export function validateSegmentationRule(rule: SegmentationRule, data: SegmentationData): boolean {
|
|
108
|
+
const { key, value, operator } = rule
|
|
109
|
+
if (operator === '==') {
|
|
110
|
+
return data[key] === value
|
|
111
|
+
} else if (operator === '!=') {
|
|
112
|
+
return data[key] !== value
|
|
113
|
+
} else if (operator === 'semver') {
|
|
114
|
+
return satisfies(data[key]?.toString() || '', value.toString())
|
|
115
|
+
} else if (operator === 'regex') {
|
|
116
|
+
return new RegExp(value.toString()).test(data[key]?.toString() || '')
|
|
117
|
+
} else if (operator === 'boolean') {
|
|
118
|
+
return Boolean(value) === data[key]
|
|
119
|
+
} else {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|