@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 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;
@@ -0,0 +1,3 @@
1
+ export type { Bucket, Experiment, UserAssignment } from '../prisma/generated/output';
2
+ export * from './types';
3
+ export { Abba } from './abba';
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; } });
@@ -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
@@ -0,0 +1,5 @@
1
+ export type { Bucket, Experiment, UserAssignment } from '../prisma/generated/output'
2
+
3
+ export * from './types'
4
+
5
+ export { Abba } from './abba'
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
+ }