@naturalcycles/abba 1.9.0 → 1.9.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,88 +1,75 @@
1
1
  import { Saved } from '@naturalcycles/js-lib';
2
- import { AbbaConfig, Bucket, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
2
+ import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
3
3
  import { SegmentationData, AssignmentStatistics } from '.';
4
4
  export declare class Abba {
5
+ cfg: AbbaConfig;
6
+ constructor(cfg: AbbaConfig);
5
7
  private experimentDao;
6
8
  private bucketDao;
7
9
  private userAssignmentDao;
8
- constructor({ db }: AbbaConfig);
9
10
  /**
10
- * Returns all experiments
11
+ * Returns all (active and inactive) experiments.
11
12
  *
12
- * @returns
13
+ * Cold method, not cached.
13
14
  */
14
- getAllExperiments(excludeInactive?: boolean): Promise<ExperimentWithBuckets[]>;
15
+ getAllExperiments(): Promise<ExperimentWithBuckets[]>;
15
16
  /**
16
- * Creates a new experiment
17
- *
18
- * @param experiment
19
- * @param buckets
20
- * @returns
17
+ * Returns only active experiments.
18
+ * Hot method.
19
+ * Cached in-memory for N minutes (currently 10).
21
20
  */
22
- createExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
21
+ getActiveExperiments(): Promise<ExperimentWithBuckets[]>;
23
22
  /**
24
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
25
- *
26
- * @param id
27
- * @param experiment
28
- * @param rules
29
- * @param buckets
30
- * @returns
23
+ * Creates a new experiment.
24
+ * Cold method.
31
25
  */
32
- saveExperiment(id: number, experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
26
+ createExperiment(experiment: Experiment, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
33
27
  /**
34
- * Delete an experiment. Removes all user assignments and buckets
35
- *
36
- * @param id
28
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
29
+ * Cold method.
30
+ */
31
+ saveExperiment(experiment: Experiment & {
32
+ id: number;
33
+ }, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
34
+ /**
35
+ * Delete an experiment. Removes all user assignments and buckets.
36
+ * Cold method.
37
37
  */
38
38
  deleteExperiment(id: number): Promise<void>;
39
39
  /**
40
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
40
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
41
+ * Cold method.
41
42
  *
42
43
  * @param experimentId
43
44
  * @param userId
44
45
  * @param existingOnly Do not generate any new assignments for this experiment
45
46
  * @param segmentationData Required if existingOnly is false
46
- * @returns
47
47
  */
48
48
  getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<Saved<UserAssignment> | null>;
49
49
  /**
50
- * Get all existing user assignments
51
- *
52
- * @param userId
53
- * @returns
50
+ * Get all existing user assignments.
51
+ * Hot method.
52
+ * Not cached, because Assignments are fast-changing.
54
53
  */
55
54
  getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]>;
56
55
  /**
57
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
58
- *
59
- * @param userId
60
- * @param segmentationData
61
- * @returns
56
+ * Generate user assignments for all active experiments.
57
+ * Will return any existing and attempt to generate any new assignments.
58
+ * Hot method.
62
59
  */
63
60
  generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<Saved<UserAssignment>[]>;
64
61
  /**
65
- * Get assignment statistics for an experiment
66
- *
67
- * @param experimentId
68
- * @returns
62
+ * Get assignment statistics for an experiment.
63
+ * Cold method.
69
64
  */
70
65
  getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
71
66
  /**
72
- * Generate a new assignment for a given user
73
- *
74
- * @param experimentId
75
- * @param userId
76
- * @param segmentationData
77
- * @returns
67
+ * Generate a new assignment for a given user.
68
+ * Doesn't save it.
78
69
  */
79
- private generateUserAssignment;
70
+ private generateUserAssignmentData;
80
71
  /**
81
72
  * Queries to retrieve an existing user assignment for a given experiment
82
- *
83
- * @param experimentId
84
- * @param userId
85
- * @returns
86
73
  */
87
74
  private getExistingUserAssignment;
88
75
  }
package/dist/abba.js CHANGED
@@ -1,30 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Abba = void 0;
4
+ const tslib_1 = require("tslib");
4
5
  const js_lib_1 = require("@naturalcycles/js-lib");
6
+ const nodejs_lib_1 = require("@naturalcycles/nodejs-lib");
5
7
  const types_1 = require("./types");
6
8
  const util_1 = require("./util");
7
9
  const experiment_dao_1 = require("./dao/experiment.dao");
8
10
  const userAssignment_dao_1 = require("./dao/userAssignment.dao");
9
11
  const bucket_dao_1 = require("./dao/bucket.dao");
12
+ const CACHE_TTL = 600000; // 10 minutes
10
13
  class Abba {
11
- constructor({ db }) {
14
+ constructor(cfg) {
15
+ this.cfg = cfg;
16
+ const { db } = cfg;
12
17
  this.experimentDao = (0, experiment_dao_1.experimentDao)(db);
13
18
  this.bucketDao = (0, bucket_dao_1.bucketDao)(db);
14
19
  this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
15
20
  }
16
- // TODO: Cache me
17
21
  /**
18
- * Returns all experiments
22
+ * Returns all (active and inactive) experiments.
19
23
  *
20
- * @returns
24
+ * Cold method, not cached.
21
25
  */
22
- async getAllExperiments(excludeInactive = false) {
23
- const query = this.experimentDao.query();
24
- if (excludeInactive) {
25
- query.filter('status', '!=', types_1.AssignmentStatus.Inactive);
26
- }
27
- const experiments = await this.experimentDao.runQuery(query);
26
+ async getAllExperiments() {
27
+ const experiments = await this.experimentDao.query().runQuery();
28
28
  const buckets = await this.bucketDao
29
29
  .query()
30
30
  .filter('experimentId', 'in', experiments.map(e => e.id))
@@ -35,115 +35,120 @@ class Abba {
35
35
  }));
36
36
  }
37
37
  /**
38
- * Creates a new experiment
39
- *
40
- * @param experiment
41
- * @param buckets
42
- * @returns
38
+ * Returns only active experiments.
39
+ * Hot method.
40
+ * Cached in-memory for N minutes (currently 10).
41
+ */
42
+ async getActiveExperiments() {
43
+ const experiments = await this.experimentDao
44
+ .query()
45
+ .filter('status', '!=', types_1.AssignmentStatus.Inactive)
46
+ .runQuery();
47
+ const buckets = await this.bucketDao
48
+ .query()
49
+ .filter('experimentId', 'in', experiments.map(e => e.id))
50
+ .runQuery();
51
+ return experiments.map(experiment => ({
52
+ ...experiment,
53
+ buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
54
+ }));
55
+ }
56
+ /**
57
+ * Creates a new experiment.
58
+ * Cold method.
43
59
  */
44
60
  async createExperiment(experiment, buckets) {
45
61
  if (experiment.status === types_1.AssignmentStatus.Active) {
46
62
  (0, util_1.validateTotalBucketRatio)(buckets);
47
63
  }
48
- const created = await this.experimentDao.save(experiment);
64
+ await this.experimentDao.save(experiment);
49
65
  return {
50
- ...created,
51
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id }))),
66
+ ...experiment,
67
+ buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
52
68
  };
53
69
  }
54
70
  /**
55
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
56
- *
57
- * @param id
58
- * @param experiment
59
- * @param rules
60
- * @param buckets
61
- * @returns
71
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
72
+ * Cold method.
62
73
  */
63
- async saveExperiment(id, experiment, buckets) {
74
+ async saveExperiment(experiment, buckets) {
64
75
  if (experiment.status === types_1.AssignmentStatus.Active) {
65
76
  (0, util_1.validateTotalBucketRatio)(buckets);
66
77
  }
67
- const updated = await this.experimentDao.save({
68
- ...experiment,
69
- id,
70
- });
78
+ await this.experimentDao.save(experiment);
71
79
  return {
72
- ...updated,
73
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
80
+ ...experiment,
81
+ buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
74
82
  };
75
83
  }
76
84
  /**
77
- * Delete an experiment. Removes all user assignments and buckets
78
- *
79
- * @param id
85
+ * Delete an experiment. Removes all user assignments and buckets.
86
+ * Cold method.
80
87
  */
81
88
  async deleteExperiment(id) {
82
89
  await this.experimentDao.deleteById(id);
83
90
  }
84
91
  /**
85
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
92
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
93
+ * Cold method.
86
94
  *
87
95
  * @param experimentId
88
96
  * @param userId
89
97
  * @param existingOnly Do not generate any new assignments for this experiment
90
98
  * @param segmentationData Required if existingOnly is false
91
- * @returns
92
99
  */
93
100
  async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
94
- const experiment = await this.experimentDao.requireById(experimentId);
95
- if (!experiment)
96
- throw new Error('Experiment not found');
97
- const buckets = await this.bucketDao.getBy('experimentId', experimentId);
98
101
  const existing = await this.getExistingUserAssignment(experimentId, userId);
99
102
  if (existing)
100
103
  return existing;
101
- if (experiment.status !== types_1.AssignmentStatus.Active || existingOnly)
104
+ if (existingOnly)
102
105
  return null;
103
- if (!segmentationData)
104
- throw new Error('Segmentation data required when creating a new assignment');
105
- return await this.generateUserAssignment({ ...experiment, buckets }, userId, segmentationData);
106
+ const experiment = await this.experimentDao.requireById(experimentId);
107
+ if (experiment.status !== types_1.AssignmentStatus.Active)
108
+ return null;
109
+ (0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
110
+ const buckets = await this.bucketDao.getBy('experimentId', experimentId);
111
+ const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
112
+ if (!assignment)
113
+ return null;
114
+ return await this.userAssignmentDao.save(assignment);
106
115
  }
107
116
  /**
108
- * Get all existing user assignments
109
- *
110
- * @param userId
111
- * @returns
117
+ * Get all existing user assignments.
118
+ * Hot method.
119
+ * Not cached, because Assignments are fast-changing.
112
120
  */
113
121
  async getAllExistingUserAssignments(userId) {
114
122
  return await this.userAssignmentDao.getBy('userId', userId);
115
123
  }
116
124
  /**
117
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
118
- *
119
- * @param userId
120
- * @param segmentationData
121
- * @returns
125
+ * Generate user assignments for all active experiments.
126
+ * Will return any existing and attempt to generate any new assignments.
127
+ * Hot method.
122
128
  */
123
129
  async generateUserAssignments(userId, segmentationData) {
124
- const experiments = await this.getAllExperiments(true);
130
+ const experiments = await this.getActiveExperiments(); // cached
125
131
  const existingAssignments = await this.getAllExistingUserAssignments(userId);
126
- const assignments = [];
132
+ const allAssignments = [];
127
133
  const generatedAssignments = [];
128
134
  for (const experiment of experiments) {
129
135
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
130
136
  if (existing) {
131
- assignments.push(existing);
132
- continue;
137
+ allAssignments.push(existing);
133
138
  }
134
139
  else {
135
- generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData));
140
+ const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData);
141
+ if (assignment) {
142
+ generatedAssignments.push(assignment);
143
+ }
136
144
  }
137
145
  }
138
- const generated = await Promise.all(generatedAssignments);
139
- const filtered = generated.filter((ua) => ua !== null);
140
- return [...assignments, ...filtered];
146
+ await this.userAssignmentDao.saveBatch(generatedAssignments);
147
+ return [...allAssignments, ...generatedAssignments];
141
148
  }
142
149
  /**
143
- * Get assignment statistics for an experiment
144
- *
145
- * @param experimentId
146
- * @returns
150
+ * Get assignment statistics for an experiment.
151
+ * Cold method.
147
152
  */
148
153
  async getExperimentAssignmentStatistics(experimentId) {
149
154
  const statistics = {
@@ -155,40 +160,40 @@ class Abba {
155
160
  };
156
161
  const buckets = await this.bucketDao.getBy('experimentId', experimentId);
157
162
  await (0, js_lib_1.pMap)(buckets, async (bucket) => {
158
- const count = this.userAssignmentDao.query().filterEq('bucketId', bucket.id);
159
- statistics[bucket.id] = await this.userAssignmentDao.runQueryCount(count);
163
+ statistics[bucket.id] = await this.userAssignmentDao
164
+ .query()
165
+ .filterEq('bucketId', bucket.id)
166
+ .runQueryCount();
160
167
  });
161
168
  return statistics;
162
169
  }
163
170
  /**
164
- * Generate a new assignment for a given user
165
- *
166
- * @param experimentId
167
- * @param userId
168
- * @param segmentationData
169
- * @returns
171
+ * Generate a new assignment for a given user.
172
+ * Doesn't save it.
170
173
  */
171
- async generateUserAssignment(experiment, userId, segmentationData) {
174
+ generateUserAssignmentData(experiment, userId, segmentationData) {
172
175
  const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
173
176
  if (!segmentationMatch)
174
177
  return null;
175
- return await this.userAssignmentDao.save({
178
+ return {
176
179
  userId,
177
180
  experimentId: experiment.id,
178
181
  bucketId: (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets),
179
- });
182
+ };
180
183
  }
181
184
  /**
182
185
  * Queries to retrieve an existing user assignment for a given experiment
183
- *
184
- * @param experimentId
185
- * @param userId
186
- * @returns
187
186
  */
188
187
  async getExistingUserAssignment(experimentId, userId) {
189
- const assignments = await this.userAssignmentDao.getBy('userId', userId);
190
- const assignment = assignments.find(assignment => assignment.experimentId === experimentId);
188
+ const [assignment] = await this.userAssignmentDao
189
+ .query()
190
+ .filterEq('userId', userId)
191
+ .filterEq('experimentId', experimentId)
192
+ .runQuery();
191
193
  return assignment || null;
192
194
  }
193
195
  }
196
+ tslib_1.__decorate([
197
+ (0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
198
+ ], Abba.prototype, "getActiveExperiments", null);
194
199
  exports.Abba = Abba;
@@ -13,7 +13,7 @@ const experimentDao = (db) => new ExperimentDao({
13
13
  assignGeneratedIds: true,
14
14
  hooks: {
15
15
  beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
16
- beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
16
+ beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
17
17
  },
18
18
  });
19
19
  exports.experimentDao = experimentDao;
package/dist/util.d.ts CHANGED
@@ -2,30 +2,18 @@ import { Saved } from '@naturalcycles/js-lib';
2
2
  import { Bucket, SegmentationData, SegmentationRule } from './types';
3
3
  /**
4
4
  * Generate a random number between 0 and 100
5
- *
6
- * @returns
7
5
  */
8
6
  export declare const rollDie: () => number;
9
7
  /**
10
8
  * 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
9
  */
16
10
  export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) => number | null;
17
11
  /**
18
12
  * Determines which bucket a user assignment will recieve
19
- *
20
- * @param buckets
21
- * @returns
22
13
  */
23
14
  export declare const determineBucket: (buckets: Saved<Bucket>[]) => number;
24
15
  /**
25
16
  * Validate the total ratio of the buckets equals 100
26
- *
27
- * @param buckets
28
- * @returns
29
17
  */
30
18
  export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
31
19
  /**
@@ -38,9 +26,5 @@ export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
38
26
  export declare const validateSegmentationRules: (rules: SegmentationRule[], segmentationData: SegmentationData) => boolean;
39
27
  /**
40
28
  * Validate a users segmentation data against a single rule
41
- *
42
- * @param rule
43
- * @param segmentationData
44
- * @returns
45
29
  */
46
30
  export declare const validateSegmentationRule: (rule: SegmentationRule, data: SegmentationData) => boolean;
package/dist/util.js CHANGED
@@ -4,8 +4,6 @@ exports.validateSegmentationRule = exports.validateSegmentationRules = exports.v
4
4
  const semver_1 = require("semver");
5
5
  /**
6
6
  * Generate a random number between 0 and 100
7
- *
8
- * @returns
9
7
  */
10
8
  const rollDie = () => {
11
9
  return Math.random() * 100;
@@ -13,10 +11,6 @@ const rollDie = () => {
13
11
  exports.rollDie = rollDie;
14
12
  /**
15
13
  * 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
14
  */
21
15
  const determineAssignment = (sampling, buckets) => {
22
16
  // Should this person be considered for the experiment?
@@ -29,9 +23,6 @@ const determineAssignment = (sampling, buckets) => {
29
23
  exports.determineAssignment = determineAssignment;
30
24
  /**
31
25
  * Determines which bucket a user assignment will recieve
32
- *
33
- * @param buckets
34
- * @returns
35
26
  */
36
27
  const determineBucket = (buckets) => {
37
28
  const bucketRoll = (0, exports.rollDie)();
@@ -55,9 +46,6 @@ const determineBucket = (buckets) => {
55
46
  exports.determineBucket = determineBucket;
56
47
  /**
57
48
  * Validate the total ratio of the buckets equals 100
58
- *
59
- * @param buckets
60
- * @returns
61
49
  */
62
50
  const validateTotalBucketRatio = (buckets) => {
63
51
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
@@ -83,10 +71,6 @@ const validateSegmentationRules = (rules, segmentationData) => {
83
71
  exports.validateSegmentationRules = validateSegmentationRules;
84
72
  /**
85
73
  * Validate a users segmentation data against a single rule
86
- *
87
- * @param rule
88
- * @param segmentationData
89
- * @returns
90
74
  */
91
75
  const validateSegmentationRule = (rule, data) => {
92
76
  const { key, value, operator } = rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build": "build",
@@ -9,13 +9,14 @@
9
9
  "dependencies": {
10
10
  "@naturalcycles/db-lib": "^8.40.1",
11
11
  "@naturalcycles/js-lib": "^14.98.2",
12
+ "@naturalcycles/nodejs-lib": "^12.70.1",
12
13
  "semver": "^7.3.5"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@naturalcycles/dev-lib": "^12.19.2",
16
- "@types/node": "^16.0.0",
17
+ "@types/node": "^17.0.34",
17
18
  "@types/semver": "^7.3.9",
18
- "jest": "^27.5.1"
19
+ "jest": "^28.1.0"
19
20
  },
20
21
  "files": [
21
22
  "dist",
package/src/abba.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { pMap, Saved } from '@naturalcycles/js-lib'
1
+ import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
2
+ import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
2
3
  import {
3
4
  AbbaConfig,
4
5
  AssignmentStatus,
5
6
  Bucket,
7
+ BucketInput,
6
8
  Experiment,
7
9
  ExperimentWithBuckets,
8
10
  UserAssignment,
@@ -11,32 +13,30 @@ import { determineAssignment, validateSegmentationRules, validateTotalBucketRati
11
13
  import { ExperimentDao, experimentDao } from './dao/experiment.dao'
12
14
  import { UserAssignmentDao, userAssignmentDao } from './dao/userAssignment.dao'
13
15
  import { BucketDao, bucketDao } from './dao/bucket.dao'
14
- import { SegmentationData, SegmentationRule, AssignmentStatistics } from '.'
16
+ import { SegmentationData, AssignmentStatistics } from '.'
15
17
 
16
- export class Abba {
17
- private experimentDao: ExperimentDao
18
- private bucketDao: BucketDao
19
- private userAssignmentDao: UserAssignmentDao
18
+ const CACHE_TTL = 600_000 // 10 minutes
20
19
 
21
- constructor({ db }: AbbaConfig) {
20
+ export class Abba {
21
+ constructor(public cfg: AbbaConfig) {
22
+ const { db } = cfg
22
23
  this.experimentDao = experimentDao(db)
23
24
  this.bucketDao = bucketDao(db)
24
25
  this.userAssignmentDao = userAssignmentDao(db)
25
26
  }
26
27
 
27
- // TODO: Cache me
28
+ private experimentDao: ExperimentDao
29
+ private bucketDao: BucketDao
30
+ private userAssignmentDao: UserAssignmentDao
31
+
28
32
  /**
29
- * Returns all experiments
33
+ * Returns all (active and inactive) experiments.
30
34
  *
31
- * @returns
35
+ * Cold method, not cached.
32
36
  */
33
- async getAllExperiments(excludeInactive: boolean = false): Promise<ExperimentWithBuckets[]> {
34
- const query = this.experimentDao.query()
35
- if (excludeInactive) {
36
- query.filter('status', '!=', AssignmentStatus.Inactive)
37
- }
37
+ async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
38
+ const experiments = await this.experimentDao.query().runQuery()
38
39
 
39
- const experiments = await this.experimentDao.runQuery(query)
40
40
  const buckets = await this.bucketDao
41
41
  .query()
42
42
  .filter(
@@ -53,76 +53,92 @@ export class Abba {
53
53
  }
54
54
 
55
55
  /**
56
- * Creates a new experiment
57
- *
58
- * @param experiment
59
- * @param buckets
60
- * @returns
56
+ * Returns only active experiments.
57
+ * Hot method.
58
+ * Cached in-memory for N minutes (currently 10).
59
+ */
60
+ @_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
61
+ async getActiveExperiments(): Promise<ExperimentWithBuckets[]> {
62
+ const experiments = await this.experimentDao
63
+ .query()
64
+ .filter('status', '!=', AssignmentStatus.Inactive)
65
+ .runQuery()
66
+
67
+ const buckets = await this.bucketDao
68
+ .query()
69
+ .filter(
70
+ 'experimentId',
71
+ 'in',
72
+ experiments.map(e => e.id),
73
+ )
74
+ .runQuery()
75
+
76
+ return experiments.map(experiment => ({
77
+ ...experiment,
78
+ buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
79
+ }))
80
+ }
81
+
82
+ /**
83
+ * Creates a new experiment.
84
+ * Cold method.
61
85
  */
62
86
  async createExperiment(
63
87
  experiment: Experiment,
64
- buckets: Bucket[],
88
+ buckets: BucketInput[],
65
89
  ): Promise<ExperimentWithBuckets> {
66
90
  if (experiment.status === AssignmentStatus.Active) {
67
91
  validateTotalBucketRatio(buckets)
68
92
  }
69
93
 
70
- const created = await this.experimentDao.save(experiment)
94
+ await this.experimentDao.save(experiment)
71
95
 
72
96
  return {
73
- ...created,
97
+ ...(experiment as Saved<Experiment>),
74
98
  buckets: await this.bucketDao.saveBatch(
75
- buckets.map(b => ({ ...b, experimentId: created.id })),
99
+ buckets.map(b => ({ ...b, experimentId: experiment.id! })),
76
100
  ),
77
101
  }
78
102
  }
79
103
 
80
104
  /**
81
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
82
- *
83
- * @param id
84
- * @param experiment
85
- * @param rules
86
- * @param buckets
87
- * @returns
105
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
106
+ * Cold method.
88
107
  */
89
108
  async saveExperiment(
90
- id: number,
91
- experiment: Experiment,
109
+ experiment: Experiment & { id: number },
92
110
  buckets: Bucket[],
93
111
  ): Promise<ExperimentWithBuckets> {
94
112
  if (experiment.status === AssignmentStatus.Active) {
95
113
  validateTotalBucketRatio(buckets)
96
114
  }
97
115
 
98
- const updated = await this.experimentDao.save({
99
- ...experiment,
100
- id,
101
- })
116
+ await this.experimentDao.save(experiment)
102
117
 
103
118
  return {
104
- ...updated,
105
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
119
+ ...(experiment as Saved<Experiment>),
120
+ buckets: await this.bucketDao.saveBatch(
121
+ buckets.map(b => ({ ...b, experimentId: experiment.id })),
122
+ ),
106
123
  }
107
124
  }
108
125
 
109
126
  /**
110
- * Delete an experiment. Removes all user assignments and buckets
111
- *
112
- * @param id
127
+ * Delete an experiment. Removes all user assignments and buckets.
128
+ * Cold method.
113
129
  */
114
130
  async deleteExperiment(id: number): Promise<void> {
115
131
  await this.experimentDao.deleteById(id)
116
132
  }
117
133
 
118
134
  /**
119
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
135
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
136
+ * Cold method.
120
137
  *
121
138
  * @param experimentId
122
139
  * @param userId
123
140
  * @param existingOnly Do not generate any new assignments for this experiment
124
141
  * @param segmentationData Required if existingOnly is false
125
- * @returns
126
142
  */
127
143
  async getUserAssignment(
128
144
  experimentId: number,
@@ -130,69 +146,71 @@ export class Abba {
130
146
  existingOnly: boolean,
131
147
  segmentationData?: SegmentationData,
132
148
  ): Promise<Saved<UserAssignment> | null> {
133
- const experiment = await this.experimentDao.requireById(experimentId)
134
- if (!experiment) throw new Error('Experiment not found')
135
-
136
- const buckets = await this.bucketDao.getBy('experimentId', experimentId)
137
-
138
149
  const existing = await this.getExistingUserAssignment(experimentId, userId)
139
150
  if (existing) return existing
151
+ if (existingOnly) return null
152
+
153
+ const experiment = await this.experimentDao.requireById(experimentId)
154
+ if (experiment.status !== AssignmentStatus.Active) return null
155
+
156
+ _assert(segmentationData, 'Segmentation data required when creating a new assignment')
140
157
 
141
- if (experiment.status !== AssignmentStatus.Active || existingOnly) return null
158
+ const buckets = await this.bucketDao.getBy('experimentId', experimentId)
142
159
 
143
- if (!segmentationData)
144
- throw new Error('Segmentation data required when creating a new assignment')
160
+ const assignment = this.generateUserAssignmentData(
161
+ { ...experiment, buckets },
162
+ userId,
163
+ segmentationData,
164
+ )
165
+ if (!assignment) return null
145
166
 
146
- return await this.generateUserAssignment({ ...experiment, buckets }, userId, segmentationData)
167
+ return await this.userAssignmentDao.save(assignment)
147
168
  }
148
169
 
149
170
  /**
150
- * Get all existing user assignments
151
- *
152
- * @param userId
153
- * @returns
171
+ * Get all existing user assignments.
172
+ * Hot method.
173
+ * Not cached, because Assignments are fast-changing.
154
174
  */
155
175
  async getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]> {
156
176
  return await this.userAssignmentDao.getBy('userId', userId)
157
177
  }
158
178
 
159
179
  /**
160
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
161
- *
162
- * @param userId
163
- * @param segmentationData
164
- * @returns
180
+ * Generate user assignments for all active experiments.
181
+ * Will return any existing and attempt to generate any new assignments.
182
+ * Hot method.
165
183
  */
166
184
  async generateUserAssignments(
167
185
  userId: string,
168
186
  segmentationData: SegmentationData,
169
187
  ): Promise<Saved<UserAssignment>[]> {
170
- const experiments = await this.getAllExperiments(true)
188
+ const experiments = await this.getActiveExperiments() // cached
171
189
  const existingAssignments = await this.getAllExistingUserAssignments(userId)
172
190
 
173
- const assignments: Saved<UserAssignment>[] = []
174
- const generatedAssignments: Promise<Saved<UserAssignment> | null>[] = []
191
+ const allAssignments: Saved<UserAssignment>[] = []
192
+ const generatedAssignments: UserAssignment[] = []
175
193
 
176
194
  for (const experiment of experiments) {
177
195
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
178
196
  if (existing) {
179
- assignments.push(existing)
180
- continue
197
+ allAssignments.push(existing)
181
198
  } else {
182
- generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData))
199
+ const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData)
200
+ if (assignment) {
201
+ generatedAssignments.push(assignment)
202
+ }
183
203
  }
184
204
  }
185
205
 
186
- const generated = await Promise.all(generatedAssignments)
187
- const filtered = generated.filter((ua): ua is Saved<UserAssignment> => ua !== null)
188
- return [...assignments, ...filtered]
206
+ await this.userAssignmentDao.saveBatch(generatedAssignments)
207
+
208
+ return [...allAssignments, ...(generatedAssignments as Saved<UserAssignment>[])]
189
209
  }
190
210
 
191
211
  /**
192
- * Get assignment statistics for an experiment
193
- *
194
- * @param experimentId
195
- * @returns
212
+ * Get assignment statistics for an experiment.
213
+ * Cold method.
196
214
  */
197
215
  async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
198
216
  const statistics = {
@@ -205,52 +223,47 @@ export class Abba {
205
223
 
206
224
  const buckets = await this.bucketDao.getBy('experimentId', experimentId)
207
225
  await pMap(buckets, async bucket => {
208
- const count = this.userAssignmentDao.query().filterEq('bucketId', bucket.id)
209
- statistics[bucket.id] = await this.userAssignmentDao.runQueryCount(count)
226
+ statistics[bucket.id] = await this.userAssignmentDao
227
+ .query()
228
+ .filterEq('bucketId', bucket.id)
229
+ .runQueryCount()
210
230
  })
211
231
 
212
232
  return statistics
213
233
  }
214
234
 
215
235
  /**
216
- * Generate a new assignment for a given user
217
- *
218
- * @param experimentId
219
- * @param userId
220
- * @param segmentationData
221
- * @returns
236
+ * Generate a new assignment for a given user.
237
+ * Doesn't save it.
222
238
  */
223
- private async generateUserAssignment(
239
+ private generateUserAssignmentData(
224
240
  experiment: ExperimentWithBuckets,
225
241
  userId: string,
226
242
  segmentationData: SegmentationData,
227
- ): Promise<Saved<UserAssignment> | null> {
228
- const segmentationMatch = validateSegmentationRules(
229
- experiment.rules as unknown as SegmentationRule[],
230
- segmentationData,
231
- )
243
+ ): UserAssignment | null {
244
+ const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
232
245
  if (!segmentationMatch) return null
233
246
 
234
- return await this.userAssignmentDao.save({
247
+ return {
235
248
  userId,
236
249
  experimentId: experiment.id,
237
250
  bucketId: determineAssignment(experiment.sampling, experiment.buckets),
238
- })
251
+ }
239
252
  }
240
253
 
241
254
  /**
242
255
  * Queries to retrieve an existing user assignment for a given experiment
243
- *
244
- * @param experimentId
245
- * @param userId
246
- * @returns
247
256
  */
248
257
  private async getExistingUserAssignment(
249
258
  experimentId: number,
250
259
  userId: string,
251
260
  ): Promise<Saved<UserAssignment> | null> {
252
- const assignments = await this.userAssignmentDao.getBy('userId', userId)
253
- const assignment = assignments.find(assignment => assignment.experimentId === experimentId)
261
+ const [assignment] = await this.userAssignmentDao
262
+ .query()
263
+ .filterEq('userId', userId)
264
+ .filterEq('experimentId', experimentId)
265
+ .runQuery()
266
+
254
267
  return assignment || null
255
268
  }
256
269
  }
@@ -17,6 +17,6 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
17
17
  assignGeneratedIds: true,
18
18
  hooks: {
19
19
  beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
20
- beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
20
+ beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
21
21
  },
22
22
  })
package/src/types.ts CHANGED
@@ -3,6 +3,14 @@ import { BaseDBEntity, Saved } from '@naturalcycles/js-lib'
3
3
 
4
4
  export interface AbbaConfig {
5
5
  db: CommonDB
6
+
7
+ /**
8
+ * Determines for how long to cache "hot" requests.
9
+ * Caches in memory.
10
+ * Default is 10.
11
+ * Set to 0 to disable cache (every request will hit the DB).
12
+ */
13
+ // cacheMinutes?: number
6
14
  }
7
15
 
8
16
  export type BaseExperiment = BaseDBEntity<number> & {
package/src/util.ts CHANGED
@@ -4,8 +4,6 @@ import { Bucket, SegmentationData, SegmentationRule } from './types'
4
4
 
5
5
  /**
6
6
  * Generate a random number between 0 and 100
7
- *
8
- * @returns
9
7
  */
10
8
  export const rollDie = (): number => {
11
9
  return Math.random() * 100
@@ -13,10 +11,6 @@ export const rollDie = (): number => {
13
11
 
14
12
  /**
15
13
  * 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
14
  */
21
15
  export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]): number | null => {
22
16
  // Should this person be considered for the experiment?
@@ -30,9 +24,6 @@ export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]):
30
24
 
31
25
  /**
32
26
  * Determines which bucket a user assignment will recieve
33
- *
34
- * @param buckets
35
- * @returns
36
27
  */
37
28
  export const determineBucket = (buckets: Saved<Bucket>[]): number => {
38
29
  const bucketRoll = rollDie()
@@ -58,9 +49,6 @@ export const determineBucket = (buckets: Saved<Bucket>[]): number => {
58
49
 
59
50
  /**
60
51
  * Validate the total ratio of the buckets equals 100
61
- *
62
- * @param buckets
63
- * @returns
64
52
  */
65
53
  export const validateTotalBucketRatio = (buckets: Bucket[]): void => {
66
54
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
@@ -88,10 +76,6 @@ export const validateSegmentationRules = (
88
76
 
89
77
  /**
90
78
  * Validate a users segmentation data against a single rule
91
- *
92
- * @param rule
93
- * @param segmentationData
94
- * @returns
95
79
  */
96
80
  export const validateSegmentationRule = (
97
81
  rule: SegmentationRule,