@naturalcycles/abba 1.15.7 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/abba.d.ts 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,35 @@ class Abba {
109
109
  * @param existingOnly Do not generate any new assignments for this experiment
110
110
  * @param segmentationData Required if existingOnly is false
111
111
  */
112
- async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
112
+ async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
113
113
  const existingAssignments = await this.getAllExistingUserAssignments(userId);
114
- const existing = existingAssignments.find(a => a.experimentId === experimentId);
114
+ const experiment = await this.experimentDao.getOneBy('key', experimentKey);
115
+ (0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentKey}`);
116
+ const buckets = await this.bucketDao.getBy('experimentId', experiment.id);
117
+ const existing = existingAssignments.find(a => a.experimentId === experiment.id);
115
118
  if (existing) {
116
- let bucketKey = null;
117
- if (existing.bucketId) {
118
- const { key } = await this.bucketDao.requireById(existing.bucketId);
119
- bucketKey = key;
120
- }
121
- return { ...existing, bucketKey };
119
+ return {
120
+ ...existing,
121
+ experimentKey: experiment.key,
122
+ bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
123
+ };
122
124
  }
123
125
  if (existingOnly)
124
126
  return null;
125
127
  const experiments = await this.getAllExperiments();
126
- const experiment = experiments.find(e => e.id === experimentId);
127
- (0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentId}`);
128
128
  const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
129
129
  if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
130
130
  return null;
131
131
  (0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
132
- const assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
132
+ const experimentWithBuckets = { ...experiment, buckets };
133
+ const assignment = (0, util_1.generateUserAssignmentData)(experimentWithBuckets, userId, segmentationData);
133
134
  if (!assignment)
134
135
  return null;
135
- const saved = await this.userAssignmentDao.save(assignment);
136
+ const newAssignment = await this.userAssignmentDao.save(assignment);
136
137
  return {
137
- ...saved,
138
- bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
138
+ ...newAssignment,
139
+ experimentKey: experiment.key,
140
+ bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
139
141
  };
140
142
  }
141
143
  /**
@@ -166,6 +168,7 @@ class Abba {
166
168
  if (existing) {
167
169
  assignments.push({
168
170
  ...existing,
171
+ experimentKey: experiment.key,
169
172
  bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
170
173
  });
171
174
  }
@@ -176,6 +179,7 @@ class Abba {
176
179
  newAssignments.push(created);
177
180
  assignments.push({
178
181
  ...created,
182
+ experimentKey: experiment.key,
179
183
  bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
180
184
  });
181
185
  // Prevent future exclusion clashes
@@ -191,21 +195,25 @@ class Abba {
191
195
  * Cold method.
192
196
  */
193
197
  async getExperimentAssignmentStatistics(experimentId) {
194
- const statistics = {
195
- sampled: await this.userAssignmentDao
196
- .query()
197
- .filterEq('experimentId', experimentId)
198
- .runQueryCount(),
199
- buckets: {},
200
- };
198
+ const totalAssignments = await this.userAssignmentDao
199
+ .query()
200
+ .filterEq('experimentId', experimentId)
201
+ .runQueryCount();
201
202
  const buckets = await this.bucketDao.getBy('experimentId', experimentId);
202
- await (0, js_lib_1.pMap)(buckets, async (bucket) => {
203
- statistics.buckets[bucket.id] = await this.userAssignmentDao
203
+ const bucketAssignments = await (0, js_lib_1.pMap)(buckets, async (bucket) => {
204
+ const totalAssignments = await this.userAssignmentDao
204
205
  .query()
205
206
  .filterEq('bucketId', bucket.id)
206
207
  .runQueryCount();
208
+ return {
209
+ bucketId: bucket.id,
210
+ totalAssignments,
211
+ };
207
212
  });
208
- return statistics;
213
+ return {
214
+ totalAssignments,
215
+ bucketAssignments,
216
+ };
209
217
  }
210
218
  }
211
219
  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,
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,
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,
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,19 +1,19 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
- "version": "1.15.7",
3
+ "version": "1.17.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build": "build",
7
7
  "build-prod": "build-prod"
8
8
  },
9
9
  "dependencies": {
10
- "@naturalcycles/db-lib": "^8.40.1",
11
- "@naturalcycles/js-lib": "^14.98.2",
12
- "@naturalcycles/nodejs-lib": "^13.1.2",
10
+ "@naturalcycles/db-lib": "^8.59.0",
11
+ "@naturalcycles/js-lib": "^14.188.1",
12
+ "@naturalcycles/nodejs-lib": "^13.1.3",
13
13
  "semver": "^7.3.5"
14
14
  },
15
15
  "devDependencies": {
16
- "@naturalcycles/dev-lib": "^13.15.0",
16
+ "@naturalcycles/dev-lib": "^13.44.8",
17
17
  "@types/node": "^20.2.4",
18
18
  "@types/semver": "^7.3.9",
19
19
  "jest": "^29.3.1"
package/src/abba.ts CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  AbbaConfig,
8
8
  AssignmentStatus,
9
9
  Bucket,
10
- 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,45 @@ 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
160
  const existingAssignments = await this.getAllExistingUserAssignments(userId)
163
161
 
164
- const existing = existingAssignments.find(a => a.experimentId === experimentId)
162
+ const experiment = await this.experimentDao.getOneBy('key', experimentKey)
163
+ _assert(experiment, `Experiment does not exist: ${experimentKey}`)
164
+
165
+ const buckets = await this.bucketDao.getBy('experimentId', experiment.id)
166
+
167
+ const existing = existingAssignments.find(a => a.experimentId === experiment.id)
165
168
  if (existing) {
166
- let bucketKey = null
167
- if (existing.bucketId) {
168
- const { key } = await this.bucketDao.requireById(existing.bucketId)
169
- bucketKey = key
169
+ return {
170
+ ...existing,
171
+ experimentKey: experiment.key,
172
+ bucketKey: buckets.find(b => b.id === existing.bucketId)?.key || null,
170
173
  }
171
- return { ...existing, bucketKey }
172
174
  }
173
175
 
174
176
  if (existingOnly) return null
175
177
 
176
178
  const experiments = await this.getAllExperiments()
177
- const experiment = experiments.find(e => e.id === experimentId)
178
- _assert(experiment, `Experiment does not exist: ${experimentId}`)
179
-
180
179
  const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
181
180
  if (!canGenerateNewAssignments(experiment, exclusionSet)) return null
182
181
 
183
182
  _assert(segmentationData, 'Segmentation data required when creating a new assignment')
184
183
 
185
- const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
184
+ const experimentWithBuckets = { ...experiment, buckets }
185
+ const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
186
186
  if (!assignment) return null
187
187
 
188
- const saved = await this.userAssignmentDao.save(assignment)
188
+ const newAssignment = await this.userAssignmentDao.save(assignment)
189
189
 
190
190
  return {
191
- ...saved,
192
- bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
191
+ ...newAssignment,
192
+ experimentKey: experiment.key,
193
+ bucketKey: buckets.find(b => b.id === newAssignment.bucketId)?.key || null,
193
194
  }
194
195
  }
195
196
 
@@ -215,7 +216,6 @@ export class Abba {
215
216
  const experiments = await this.getAllExperiments()
216
217
  const existingAssignments = await this.getAllExistingUserAssignments(userId)
217
218
  const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
218
-
219
219
  const assignments: GeneratedUserAssignment[] = []
220
220
  const newAssignments: UserAssignment[] = []
221
221
 
@@ -233,6 +233,7 @@ export class Abba {
233
233
  if (existing) {
234
234
  assignments.push({
235
235
  ...existing,
236
+ experimentKey: experiment.key,
236
237
  bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
237
238
  })
238
239
  } else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
@@ -242,6 +243,7 @@ export class Abba {
242
243
  newAssignments.push(created)
243
244
  assignments.push({
244
245
  ...created,
246
+ experimentKey: experiment.key,
245
247
  bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
246
248
  })
247
249
  // Prevent future exclusion clashes
@@ -258,23 +260,30 @@ export class Abba {
258
260
  * Get assignment statistics for an experiment.
259
261
  * Cold method.
260
262
  */
261
- async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
262
- const statistics: AssignmentStatistics = {
263
- sampled: await this.userAssignmentDao
264
- .query()
265
- .filterEq('experimentId', experimentId)
266
- .runQueryCount(),
267
- buckets: {},
268
- }
263
+ async getExperimentAssignmentStatistics(
264
+ experimentId: string,
265
+ ): Promise<ExperimentAssignmentStatistics> {
266
+ const totalAssignments = await this.userAssignmentDao
267
+ .query()
268
+ .filterEq('experimentId', experimentId)
269
+ .runQueryCount()
269
270
 
270
271
  const buckets = await this.bucketDao.getBy('experimentId', experimentId)
271
- await pMap(buckets, async bucket => {
272
- statistics.buckets[bucket.id] = await this.userAssignmentDao
272
+ const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
273
+ const totalAssignments = await this.userAssignmentDao
273
274
  .query()
274
275
  .filterEq('bucketId', bucket.id)
275
276
  .runQueryCount()
277
+
278
+ return {
279
+ bucketId: bucket.id,
280
+ totalAssignments,
281
+ }
276
282
  })
277
283
 
278
- return statistics
284
+ return {
285
+ totalAssignments,
286
+ bucketAssignments,
287
+ }
279
288
  }
280
289
  }
@@ -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 (