@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Saved } from '@naturalcycles/js-lib';
2
- import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, GeneratedUserAssignment, UserAssignment } from './types';
3
- import { SegmentationData, AssignmentStatistics } from '.';
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: BucketInput[]): Promise<ExperimentWithBuckets>;
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: number): Promise<void>;
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(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment | null>;
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: number): Promise<AssignmentStatistics>;
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: experiment.id })));
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: experiment.id })));
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(experimentId, userId, existingOnly, segmentationData) {
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 === experimentId);
120
+ const existing = existingAssignments.find(a => a.experimentId === experiment.id);
115
121
  if (existing) {
116
- let bucketKey = null;
117
- if (existing.bucketId) {
118
- const { key } = await this.bucketDao.requireById(existing.bucketId);
119
- bucketKey = key;
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
- if (existingOnly)
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 assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
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 saved = await this.userAssignmentDao.save(assignment);
140
+ const newAssignment = await this.userAssignmentDao.save(assignment);
136
141
  return {
137
- ...saved,
138
- bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
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 statistics = {
195
- sampled: await this.userAssignmentDao
196
- .query()
197
- .filterEq('experimentId', experimentId)
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
- statistics.buckets[bucket.id] = await this.userAssignmentDao
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 statistics;
217
+ return {
218
+ totalAssignments,
219
+ bucketAssignments,
220
+ };
209
221
  }
210
222
  }
211
223
  exports.Abba = Abba;
@@ -8,8 +8,5 @@ exports.BucketDao = BucketDao;
8
8
  const bucketDao = (db) => new BucketDao({
9
9
  db,
10
10
  table: 'Bucket',
11
- createId: false, // mysql auto_increment is used instead
12
- idType: 'number',
13
- assignGeneratedIds: true,
14
11
  });
15
12
  exports.bucketDao = bucketDao;
@@ -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
- exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
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
- exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
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;
@@ -1,39 +1,41 @@
1
1
  -- CreateTable
2
2
  CREATE TABLE IF NOT EXISTS `Bucket` (
3
- `id` INTEGER NOT NULL AUTO_INCREMENT,
4
- `experimentId` INTEGER NOT NULL,
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` INTEGER(11) NOT NULL,
8
- `updated` INTEGER(11) NOT NULL,
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` INTEGER NOT NULL AUTO_INCREMENT,
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` INTEGER(11) NOT NULL,
22
- `updated` INTEGER(11) NOT NULL,
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` INTEGER NOT NULL AUTO_INCREMENT,
33
+ `id` VARCHAR(50) NOT NULL,
32
34
  `userId` VARCHAR(191) NOT NULL,
33
- `experimentId` INTEGER NOT NULL,
34
- `bucketId` INTEGER NULL,
35
- `created` INTEGER(11) NOT NULL,
36
- `updated` INTEGER(11) NOT NULL,
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<number> & {
7
- id: number;
8
- status: number;
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: number[];
35
+ exclusions: string[];
17
36
  };
18
37
  export type ExperimentWithBuckets = Saved<Experiment> & {
19
38
  buckets: Saved<Bucket>[];
20
39
  };
21
- export interface BucketInput {
22
- experimentId: number;
40
+ export type Bucket = BaseDBEntity & {
41
+ experimentId: string;
23
42
  key: string;
24
43
  ratio: number;
25
- }
26
- export type Bucket = BaseDBEntity<number> & BucketInput;
27
- export type UserAssignment = BaseDBEntity<number> & {
44
+ };
45
+ export type UserAssignment = BaseDBEntity & {
28
46
  userId: string;
29
- experimentId: number;
30
- bucketId: number | null;
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
- * @deprecated
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
- Equals = "==",
89
+ totalAssignments: number;
67
90
  /**
68
- * @deprecated
91
+ * Number of users that were assigned to each bucket in the experiment
69
92
  */
70
- NotEquals = "!="
93
+ bucketAssignments: BucketAssignmentStatistics[];
71
94
  }
72
- export type SegmentationRuleFn = (segmentationProp: string | boolean | number | null | undefined, ruleValue: SegmentationRule['value']) => boolean;
73
- export interface AssignmentStatistics {
74
- sampled: number;
75
- buckets: {
76
- [id: string]: number;
77
- };
95
+ export interface BucketAssignmentStatistics {
96
+ bucketId: string;
97
+ totalAssignments: number;
78
98
  }
79
- export type ExclusionSet = Set<number>;
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, exclusionSet: ExclusionSet) => boolean;
39
+ export declare const canGenerateNewAssignments: (experiment: Saved<Experiment>, exclusionSet: ExclusionSet) => boolean;
40
40
  /**
41
41
  * Returns an object that includes keys of all experimentIds a user should not be assigned to
42
42
  * based on a combination of existing assignments and mutual exclusion configuration
package/dist/util.js CHANGED
@@ -117,9 +117,6 @@ exports.segmentationRuleMap = {
117
117
  // Anything else cannot be true
118
118
  return keyValue?.toString() !== 'true';
119
119
  },
120
- // Deprecated
121
- [types_1.SegmentationRuleOperator.Equals]: (keyValue, ruleValue) => keyValue === ruleValue,
122
- [types_1.SegmentationRuleOperator.NotEquals]: (keyValue, ruleValue) => keyValue !== ruleValue,
123
120
  };
124
121
  /**
125
122
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build": "build",
package/src/abba.ts CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  AbbaConfig,
8
8
  AssignmentStatus,
9
9
  Bucket,
10
- BucketInput,
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, AssignmentStatistics } from '.'
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: BucketInput[],
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: experiment.id })),
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: experiment.id })),
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: number, updatedExclusions: number[]): Promise<void> {
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: number): Promise<void> {
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
- experimentId: number,
155
+ experimentKey: string,
158
156
  userId: string,
159
157
  existingOnly: boolean,
160
158
  segmentationData?: SegmentationData,
161
159
  ): Promise<GeneratedUserAssignment | null> {
162
- const existingAssignments = await this.getAllExistingUserAssignments(userId)
160
+ const experiment = await this.experimentDao.getOneBy('key', experimentKey)
161
+ _assert(experiment, `Experiment does not exist: ${experimentKey}`)
163
162
 
164
- const existing = existingAssignments.find(a => a.experimentId === experimentId)
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
- let bucketKey = null
167
- if (existing.bucketId) {
168
- const { key } = await this.bucketDao.requireById(existing.bucketId)
169
- bucketKey = key
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
- if (existingOnly) return null
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 assignment = generateUserAssignmentData(experiment, userId, segmentationData)
186
+ const experimentWithBuckets = { ...experiment, buckets }
187
+ const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
186
188
  if (!assignment) return null
187
189
 
188
- const saved = await this.userAssignmentDao.save(assignment)
190
+ const newAssignment = await this.userAssignmentDao.save(assignment)
189
191
 
190
192
  return {
191
- ...saved,
192
- bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
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(experimentId: number): Promise<AssignmentStatistics> {
262
- const statistics: AssignmentStatistics = {
263
- sampled: await this.userAssignmentDao
264
- .query()
265
- .filterEq('experimentId', experimentId)
266
- .runQueryCount(),
267
- buckets: {},
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
- statistics.buckets[bucket.id] = await this.userAssignmentDao
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 statistics
286
+ return {
287
+ totalAssignments,
288
+ bucketAssignments,
289
+ }
279
290
  }
280
291
  }
@@ -7,7 +7,4 @@ export const bucketDao = (db: CommonDB): BucketDao =>
7
7
  new BucketDao({
8
8
  db,
9
9
  table: 'Bucket',
10
- createId: false, // mysql auto_increment is used instead
11
- idType: 'number',
12
- assignGeneratedIds: true,
13
10
  })
@@ -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
- exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
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
- exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
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
  })
@@ -7,7 +7,4 @@ export const userAssignmentDao = (db: CommonDB): UserAssignmentDao =>
7
7
  new UserAssignmentDao({
8
8
  db,
9
9
  table: 'UserAssignment',
10
- createId: false, // mysql auto_increment is used instead
11
- idType: 'number',
12
- assignGeneratedIds: true,
13
10
  })
@@ -1,39 +1,41 @@
1
1
  -- CreateTable
2
2
  CREATE TABLE IF NOT EXISTS `Bucket` (
3
- `id` INTEGER NOT NULL AUTO_INCREMENT,
4
- `experimentId` INTEGER NOT NULL,
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` INTEGER(11) NOT NULL,
8
- `updated` INTEGER(11) NOT NULL,
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` INTEGER NOT NULL AUTO_INCREMENT,
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` INTEGER(11) NOT NULL,
22
- `updated` INTEGER(11) NOT NULL,
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` INTEGER NOT NULL AUTO_INCREMENT,
33
+ `id` VARCHAR(50) NOT NULL,
32
34
  `userId` VARCHAR(191) NOT NULL,
33
- `experimentId` INTEGER NOT NULL,
34
- `bucketId` INTEGER NULL,
35
- `created` INTEGER(11) NOT NULL,
36
- `updated` INTEGER(11) NOT NULL,
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<number> & {
9
- id: number
10
- status: number
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: number[]
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 interface BucketInput {
27
- experimentId: number
45
+ export type Bucket = BaseDBEntity & {
46
+ experimentId: string
28
47
  key: string
29
48
  ratio: number
30
49
  }
31
50
 
32
- export type Bucket = BaseDBEntity<number> & BucketInput
33
-
34
- export type UserAssignment = BaseDBEntity<number> & {
51
+ export type UserAssignment = BaseDBEntity & {
35
52
  userId: string
36
- experimentId: number
37
- bucketId: number | null
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 AssignmentStatistics {
92
- sampled: number
93
- buckets: {
94
- [id: string]: number
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<number>
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 (