@naturalcycles/abba 2.0.0 → 2.0.2

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,5 +1,5 @@
1
1
  import type { Unsaved } from '@naturalcycles/js-lib';
2
- import type { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment, SegmentationData } from './types.js';
2
+ import type { AbbaConfig, Bucket, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, SegmentationData, UserExperiment } from './types.js';
3
3
  export declare class Abba {
4
4
  cfg: AbbaConfig;
5
5
  private experimentDao;
@@ -10,7 +10,8 @@ export declare class Abba {
10
10
  * Returns all experiments.
11
11
  * Cached (see CACHE_TTL)
12
12
  */
13
- getAllExperiments(): Promise<ExperimentWithBuckets[]>;
13
+ getAllExperimentsWithBuckets(): Promise<ExperimentWithBuckets[]>;
14
+ getAllExperimentsWithUserAssignments(userId: string): Promise<UserExperiment[]>;
14
15
  /**
15
16
  * Updates all user assignments with a given userId with the provided userId.
16
17
  */
@@ -18,7 +19,7 @@ export declare class Abba {
18
19
  /**
19
20
  * Returns all experiments.
20
21
  */
21
- getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]>;
22
+ getAllExperimentsWithBucketsNoCache(): Promise<ExperimentWithBuckets[]>;
22
23
  /**
23
24
  * Creates a new experiment.
24
25
  * Cold method.
@@ -49,20 +50,20 @@ export declare class Abba {
49
50
  * @param existingOnly Do not generate any new assignments for this experiment
50
51
  * @param segmentationData Required if existingOnly is false
51
52
  */
52
- getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment>;
53
+ getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<DecoratedUserAssignment | null>;
53
54
  /**
54
55
  * Get all existing user assignments.
55
56
  * Hot method.
56
57
  * Not cached, because Assignments are fast-changing.
57
58
  * Only to be used for testing
58
59
  */
59
- getAllExistingGeneratedUserAssignments(userId: string): Promise<GeneratedUserAssignment[]>;
60
+ getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]>;
60
61
  /**
61
62
  * Generate user assignments for all active experiments.
62
63
  * Will return any existing and attempt to generate any new assignments if existingOnly is false.
63
64
  * Hot method.
64
65
  */
65
- generateUserAssignments(userId: string, segmentationData: SegmentationData, existingOnly?: boolean): Promise<GeneratedUserAssignment[]>;
66
+ generateUserAssignments(userId: string, segmentationData: SegmentationData, existingOnly?: boolean): Promise<DecoratedUserAssignment[]>;
66
67
  /**
67
68
  * Get assignment statistics for an experiment.
68
69
  * Cold method.
package/dist/abba.js CHANGED
@@ -21,8 +21,30 @@ export class Abba {
21
21
  * Returns all experiments.
22
22
  * Cached (see CACHE_TTL)
23
23
  */
24
- async getAllExperiments() {
25
- return await this.getAllExperimentsNoCache();
24
+ async getAllExperimentsWithBuckets() {
25
+ return await this.getAllExperimentsWithBucketsNoCache();
26
+ }
27
+ async getAllExperimentsWithUserAssignments(userId) {
28
+ const experiments = await this.getAllExperimentsWithBuckets();
29
+ const experimentIds = experiments.map(e => e.id);
30
+ const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(userId, experimentIds);
31
+ return experiments.map(experiment => {
32
+ const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id);
33
+ const existingAssignmentBucket = experiment.buckets.find(b => b.id === existingAssignment?.bucketId);
34
+ return {
35
+ ...experiment,
36
+ ...(existingAssignment && {
37
+ userAssignment: {
38
+ ...existingAssignment,
39
+ experimentId: experiment.id,
40
+ experimentData: experiment.data,
41
+ experimentKey: experiment.key,
42
+ bucketData: existingAssignmentBucket?.data || null,
43
+ bucketKey: existingAssignmentBucket?.key || null,
44
+ },
45
+ }),
46
+ };
47
+ });
26
48
  }
27
49
  /**
28
50
  * Updates all user assignments with a given userId with the provided userId.
@@ -34,7 +56,7 @@ export class Abba {
34
56
  /**
35
57
  * Returns all experiments.
36
58
  */
37
- async getAllExperimentsNoCache() {
59
+ async getAllExperimentsWithBucketsNoCache() {
38
60
  const experiments = await this.experimentDao.getAll();
39
61
  const buckets = await this.bucketDao.getAll();
40
62
  return experiments.map(experiment => ({
@@ -128,64 +150,47 @@ export class Abba {
128
150
  * @param segmentationData Required if existingOnly is false
129
151
  */
130
152
  async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
131
- const experiment = await this.experimentDao.getOneBy('key', experimentKey);
153
+ const experiment = await this.experimentDao.getByKey(experimentKey);
132
154
  _assert(experiment, `Experiment does not exist: ${experimentKey}`);
133
155
  // Inactive experiments should never return an assignment
134
156
  if (experiment.status === AssignmentStatus.Inactive) {
135
- return {
136
- experiment,
137
- assignment: null,
138
- };
157
+ return null;
139
158
  }
140
- const buckets = await this.bucketDao.getBy('experimentId', experiment.id);
141
- const existingAssignments = await this.userAssignmentDao.getBy('userId', userId);
142
- const existing = existingAssignments.find(a => a.experimentId === experiment.id);
143
- if (existing) {
144
- const bucket = buckets.find(b => b.id === existing.bucketId);
159
+ const buckets = await this.bucketDao.getByExperimentId(experiment.id);
160
+ const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(userId, experiment.id);
161
+ if (userAssignment) {
162
+ const bucket = buckets.find(b => b.id === userAssignment.bucketId);
145
163
  return {
146
- experiment,
147
- assignment: {
148
- ...existing,
149
- experimentKey: experiment.key,
150
- bucketKey: bucket?.key || null,
151
- bucketData: bucket?.data || null,
152
- },
164
+ ...userAssignment,
165
+ experimentData: experiment.data,
166
+ experimentKey: experiment.key,
167
+ bucketKey: bucket?.key || null,
168
+ bucketData: bucket?.data || null,
153
169
  };
154
170
  }
155
171
  // No existing assignment, but we don't want to generate a new one
156
172
  if (existingOnly || experiment.status === AssignmentStatus.Paused) {
157
- return {
158
- experiment,
159
- assignment: null,
160
- };
173
+ return null;
161
174
  }
162
- const experiments = await this.getAllExperiments();
163
- const exclusionSet = getUserExclusionSet(experiments, existingAssignments);
175
+ const experiments = await this.getAllExperimentsWithUserAssignments(userId);
176
+ const exclusionSet = getUserExclusionSet(experiments);
164
177
  if (!canGenerateNewAssignments(experiment, exclusionSet)) {
165
- return {
166
- experiment,
167
- assignment: null,
168
- };
178
+ return null;
169
179
  }
170
180
  _assert(segmentationData, 'Segmentation data required when creating a new assignment');
171
181
  const experimentWithBuckets = { ...experiment, buckets };
172
182
  const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData);
173
183
  if (!assignment) {
174
- return {
175
- experiment,
176
- assignment: null,
177
- };
184
+ return null;
178
185
  }
179
186
  const newAssignment = await this.userAssignmentDao.save(assignment);
180
187
  const bucket = buckets.find(b => b.id === newAssignment.bucketId);
181
188
  return {
182
- experiment,
183
- assignment: {
184
- ...newAssignment,
185
- experimentKey: experiment.key,
186
- bucketKey: bucket?.key || null,
187
- bucketData: bucket?.data || null,
188
- },
189
+ ...newAssignment,
190
+ experimentData: experiment.data,
191
+ experimentKey: experiment.key,
192
+ bucketKey: bucket?.key || null,
193
+ bucketData: bucket?.data || null,
189
194
  };
190
195
  }
191
196
  /**
@@ -194,19 +199,17 @@ export class Abba {
194
199
  * Not cached, because Assignments are fast-changing.
195
200
  * Only to be used for testing
196
201
  */
197
- async getAllExistingGeneratedUserAssignments(userId) {
202
+ async getAllExistingUserAssignments(userId) {
198
203
  const assignments = await this.userAssignmentDao.getBy('userId', userId);
199
204
  return await pMap(assignments, async (assignment) => {
200
205
  const experiment = await this.experimentDao.requireById(assignment.experimentId);
201
206
  const bucket = await this.bucketDao.getById(assignment.bucketId);
202
207
  return {
203
- experiment,
204
- assignment: {
205
- ...assignment,
206
- experimentKey: experiment.key,
207
- bucketKey: bucket?.key || null,
208
- bucketData: bucket?.data || null,
209
- },
208
+ ...assignment,
209
+ experimentData: experiment.data,
210
+ experimentKey: experiment.key,
211
+ bucketKey: bucket?.key || null,
212
+ bucketData: bucket?.data || null,
210
213
  };
211
214
  });
212
215
  }
@@ -216,47 +219,43 @@ export class Abba {
216
219
  * Hot method.
217
220
  */
218
221
  async generateUserAssignments(userId, segmentationData, existingOnly = false) {
219
- const experiments = await this.getAllExperiments();
220
- const existingAssignments = await this.userAssignmentDao.getBy('userId', userId);
221
- const exclusionSet = getUserExclusionSet(experiments, existingAssignments);
222
- const assignments = [];
223
- const newAssignments = [];
222
+ const experiments = await this.getAllExperimentsWithUserAssignments(userId);
223
+ const exclusionSet = getUserExclusionSet(experiments);
224
224
  // Shuffling means that randomisation occurs in the mutual exclusion
225
225
  // as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
226
226
  // This is simmpler than trying to resolve after assignments have already been determined
227
227
  const availableExperiments = _shuffle(experiments.filter(e => e.status === AssignmentStatus.Active || e.status === AssignmentStatus.Paused));
228
+ const assignments = [];
229
+ const newAssignments = [];
228
230
  for (const experiment of availableExperiments) {
229
- const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
230
- if (existing) {
231
- const bucket = experiment.buckets.find(b => b.id === existing.bucketId);
231
+ const { userAssignment } = experiment;
232
+ // Already assigned to this experiment
233
+ if (userAssignment) {
234
+ assignments.push(userAssignment);
235
+ continue;
236
+ }
237
+ // Not already assigned, but we don't want to generate a new assignment
238
+ if (existingOnly)
239
+ continue;
240
+ // We are not allowed to generate new assignments for this experiment
241
+ if (!canGenerateNewAssignments(experiment, exclusionSet))
242
+ continue;
243
+ const assignment = generateUserAssignmentData(experiment, userId, segmentationData);
244
+ if (assignment) {
245
+ // Add to list of new assignments to be saved
246
+ const newAssignment = this.userAssignmentDao.create(assignment);
247
+ newAssignments.push(newAssignment);
248
+ // Add the assignment to the list of assignments
249
+ const bucket = experiment.buckets.find(b => b.id === assignment.bucketId);
232
250
  assignments.push({
233
- experiment,
234
- assignment: {
235
- ...existing,
236
- experimentKey: experiment.key,
237
- bucketKey: bucket?.key || null,
238
- bucketData: bucket?.data || null,
239
- },
251
+ ...newAssignment,
252
+ experimentKey: experiment.key,
253
+ experimentData: experiment.data,
254
+ bucketKey: bucket?.key || null,
255
+ bucketData: bucket?.data || null,
240
256
  });
241
- }
242
- else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
243
- const assignment = generateUserAssignmentData(experiment, userId, segmentationData);
244
- if (assignment) {
245
- const created = this.userAssignmentDao.create(assignment);
246
- newAssignments.push(created);
247
- const bucket = experiment.buckets.find(b => b.id === created.bucketId);
248
- assignments.push({
249
- experiment,
250
- assignment: {
251
- ...created,
252
- experimentKey: experiment.key,
253
- bucketKey: bucket?.key || null,
254
- bucketData: bucket?.data || null,
255
- },
256
- });
257
- // Prevent future exclusion clashes
258
- experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
259
- }
257
+ // Prevent future exclusion clashes
258
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
260
259
  }
261
260
  }
262
261
  await this.userAssignmentDao.saveBatch(newAssignments);
@@ -267,16 +266,10 @@ export class Abba {
267
266
  * Cold method.
268
267
  */
269
268
  async getExperimentAssignmentStatistics(experimentId) {
270
- const totalAssignments = await this.userAssignmentDao
271
- .query()
272
- .filterEq('experimentId', experimentId)
273
- .runQueryCount();
274
- const buckets = await this.bucketDao.getBy('experimentId', experimentId);
269
+ const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
270
+ const buckets = await this.bucketDao.getByExperimentId(experimentId);
275
271
  const bucketAssignments = await pMap(buckets, async (bucket) => {
276
- const totalAssignments = await this.userAssignmentDao
277
- .query()
278
- .filterEq('bucketId', bucket.id)
279
- .runQueryCount();
272
+ const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
280
273
  return {
281
274
  bucketId: bucket.id,
282
275
  totalAssignments,
@@ -290,4 +283,4 @@ export class Abba {
290
283
  }
291
284
  __decorate([
292
285
  _Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
293
- ], Abba.prototype, "getAllExperiments", null);
286
+ ], Abba.prototype, "getAllExperimentsWithBuckets", null);
@@ -1,11 +1,11 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib';
2
2
  import { CommonDao } from '@naturalcycles/db-lib';
3
- import type { Saved } from '@naturalcycles/js-lib';
4
3
  import type { BaseBucket, Bucket } from '../types.js';
5
- type BucketDBM = Saved<BaseBucket> & {
4
+ type BucketDBM = BaseBucket & {
6
5
  data: string | null;
7
6
  };
8
7
  export declare class BucketDao extends CommonDao<Bucket, BucketDBM> {
8
+ getByExperimentId(experimentId: string): Promise<Bucket[]>;
9
9
  }
10
- export declare const bucketDao: (db: CommonDB) => BucketDao;
10
+ export declare function bucketDao(db: CommonDB): BucketDao;
11
11
  export {};
@@ -1,19 +1,24 @@
1
1
  import { CommonDao } from '@naturalcycles/db-lib';
2
2
  export class BucketDao extends CommonDao {
3
+ async getByExperimentId(experimentId) {
4
+ return await this.query().filterEq('experimentId', experimentId).runQuery();
5
+ }
3
6
  }
4
- export const bucketDao = (db) => new BucketDao({
5
- db,
6
- table: 'Bucket',
7
- hooks: {
8
- beforeBMToDBM: bm => {
9
- return {
10
- ...bm,
11
- data: bm.data ? JSON.stringify(bm.data) : null,
12
- };
7
+ export function bucketDao(db) {
8
+ return new BucketDao({
9
+ db,
10
+ table: 'Bucket',
11
+ hooks: {
12
+ beforeBMToDBM: bm => {
13
+ return {
14
+ ...bm,
15
+ data: bm.data ? JSON.stringify(bm.data) : null,
16
+ };
17
+ },
18
+ beforeDBMToBM: dbm => ({
19
+ ...dbm,
20
+ data: dbm.data ? JSON.parse(dbm.data) : null,
21
+ }),
13
22
  },
14
- beforeDBMToBM: dbm => ({
15
- ...dbm,
16
- data: dbm.data ? JSON.parse(dbm.data) : null,
17
- }),
18
- },
19
- });
23
+ });
24
+ }
@@ -1,13 +1,13 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib';
2
2
  import { CommonDao } from '@naturalcycles/db-lib';
3
- import type { Saved } from '@naturalcycles/js-lib';
4
3
  import type { BaseExperiment, Experiment } from '../types.js';
5
- type ExperimentDBM = Saved<BaseExperiment> & {
4
+ type ExperimentDBM = BaseExperiment & {
6
5
  rules: string | null;
7
6
  exclusions: string | null;
8
7
  data: string | null;
9
8
  };
10
9
  export declare class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
10
+ getByKey(key: string): Promise<Experiment | null>;
11
11
  }
12
- export declare const experimentDao: (db: CommonDB) => ExperimentDao;
12
+ export declare function experimentDao(db: CommonDB): ExperimentDao;
13
13
  export {};
@@ -1,35 +1,40 @@
1
1
  import { CommonDao } from '@naturalcycles/db-lib';
2
2
  import { localDate } from '@naturalcycles/js-lib';
3
3
  export class ExperimentDao extends CommonDao {
4
+ async getByKey(key) {
5
+ return await this.getOneBy('key', key);
6
+ }
7
+ }
8
+ export function experimentDao(db) {
9
+ return new ExperimentDao({
10
+ db,
11
+ table: 'Experiment',
12
+ hooks: {
13
+ beforeBMToDBM: bm => ({
14
+ ...bm,
15
+ rules: bm.rules.length ? JSON.stringify(bm.rules) : 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,
21
+ data: bm.data ? JSON.stringify(bm.data) : null,
22
+ }),
23
+ beforeDBMToBM: dbm => ({
24
+ ...dbm,
25
+ startDateIncl: parseMySQLDate(dbm.startDateIncl),
26
+ endDateExcl: parseMySQLDate(dbm.endDateExcl),
27
+ rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
28
+ // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
29
+ // TODO: Remove after some time when we are certain only strings are stored
30
+ exclusions: (dbm.exclusions &&
31
+ JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
32
+ [],
33
+ data: dbm.data ? JSON.parse(dbm.data) : null,
34
+ }),
35
+ },
36
+ });
4
37
  }
5
- export const experimentDao = (db) => new ExperimentDao({
6
- db,
7
- table: 'Experiment',
8
- hooks: {
9
- beforeBMToDBM: bm => ({
10
- ...bm,
11
- rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
12
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
13
- // TODO: Remove after some time when we are certain only strings are stored
14
- exclusions: bm.exclusions.length
15
- ? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
16
- : null,
17
- data: bm.data ? JSON.stringify(bm.data) : null,
18
- }),
19
- beforeDBMToBM: dbm => ({
20
- ...dbm,
21
- startDateIncl: parseMySQLDate(dbm.startDateIncl),
22
- endDateExcl: parseMySQLDate(dbm.endDateExcl),
23
- rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
24
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
25
- // TODO: Remove after some time when we are certain only strings are stored
26
- exclusions: (dbm.exclusions &&
27
- JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
28
- [],
29
- data: dbm.data ? JSON.parse(dbm.data) : null,
30
- }),
31
- },
32
- });
33
38
  /**
34
39
  * https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
35
40
  * MySQL Automatically parses Date fields as Date objects
@@ -2,5 +2,9 @@ import type { CommonDB } from '@naturalcycles/db-lib';
2
2
  import { CommonDao } from '@naturalcycles/db-lib';
3
3
  import type { UserAssignment } from '../types.js';
4
4
  export declare class UserAssignmentDao extends CommonDao<UserAssignment> {
5
+ getUserAssignmentByExperimentId(userId: string, experimentId: string): Promise<UserAssignment | null>;
6
+ getUserAssigmentsByExperimentIds(userId: string, experimentIds: string[]): Promise<UserAssignment[]>;
7
+ getCountByExperimentId(experimentId: string): Promise<number>;
8
+ getCountByBucketId(bucketId: string): Promise<number>;
5
9
  }
6
- export declare const userAssignmentDao: (db: CommonDB) => UserAssignmentDao;
10
+ export declare function userAssignmentDao(db: CommonDB): UserAssignmentDao;
@@ -1,7 +1,24 @@
1
1
  import { CommonDao } from '@naturalcycles/db-lib';
2
2
  export class UserAssignmentDao extends CommonDao {
3
+ async getUserAssignmentByExperimentId(userId, experimentId) {
4
+ const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId);
5
+ const [userAssignment] = await this.runQuery(query);
6
+ return userAssignment || null;
7
+ }
8
+ async getUserAssigmentsByExperimentIds(userId, experimentIds) {
9
+ const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds);
10
+ return await this.runQuery(query);
11
+ }
12
+ async getCountByExperimentId(experimentId) {
13
+ return await this.query().filterEq('experimentId', experimentId).runQueryCount();
14
+ }
15
+ async getCountByBucketId(bucketId) {
16
+ return await this.query().filterEq('bucketId', bucketId).runQueryCount();
17
+ }
18
+ }
19
+ export function userAssignmentDao(db) {
20
+ return new UserAssignmentDao({
21
+ db,
22
+ table: 'UserAssignment',
23
+ });
3
24
  }
4
- export const userAssignmentDao = (db) => new UserAssignmentDao({
5
- db,
6
- table: 'UserAssignment',
7
- });
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib';
2
- import type { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib';
2
+ import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib';
3
3
  export interface AbbaConfig {
4
4
  db: CommonDB;
5
5
  }
@@ -35,8 +35,8 @@ export type Experiment = BaseExperiment & {
35
35
  exclusions: string[];
36
36
  data: AnyObject | null;
37
37
  };
38
- export type ExperimentWithBuckets = Saved<Experiment> & {
39
- buckets: Saved<Bucket>[];
38
+ export type ExperimentWithBuckets = Experiment & {
39
+ buckets: Bucket[];
40
40
  };
41
41
  export type BaseBucket = BaseDBEntity & {
42
42
  experimentId: string;
@@ -51,14 +51,12 @@ export type UserAssignment = BaseDBEntity & {
51
51
  experimentId: string;
52
52
  bucketId: string | null;
53
53
  };
54
- export interface GeneratedUserAssignment {
55
- assignment: (Saved<UserAssignment> & {
56
- experimentKey: string;
57
- bucketKey: string | null;
58
- bucketData: AnyObject | null;
59
- }) | null;
60
- experiment: Saved<Experiment>;
61
- }
54
+ export type DecoratedUserAssignment = UserAssignment & {
55
+ experimentKey: Experiment['key'];
56
+ experimentData: Experiment['data'];
57
+ bucketKey: Bucket['key'] | null;
58
+ bucketData: Bucket['data'];
59
+ };
62
60
  export type SegmentationData = AnyObject;
63
61
  export declare enum AssignmentStatus {
64
62
  /**
@@ -105,3 +103,6 @@ export interface BucketAssignmentStatistics {
105
103
  totalAssignments: number;
106
104
  }
107
105
  export type ExclusionSet = Set<string>;
106
+ export interface UserExperiment extends ExperimentWithBuckets {
107
+ userAssignment?: DecoratedUserAssignment;
108
+ }
package/dist/util.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { Unsaved } from '@naturalcycles/js-lib';
2
- import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment } from './types.js';
2
+ import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment, UserExperiment } from './types.js';
3
3
  import { SegmentationRuleOperator } from './types.js';
4
4
  /**
5
5
  * Generate a new assignment for a given user.
6
6
  * Doesn't save it.
7
7
  */
8
- export declare const generateUserAssignmentData: (experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData) => UserAssignment | null;
8
+ export declare function generateUserAssignmentData(experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData): Unsaved<UserAssignment> | null;
9
9
  declare class RandomService {
10
10
  /**
11
11
  * Generate a random number between 0 and 100
@@ -16,15 +16,15 @@ export declare const randomService: RandomService;
16
16
  /**
17
17
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
18
18
  */
19
- export declare const determineAssignment: (sampling: number, buckets: Bucket[]) => Bucket | null;
19
+ export declare function determineAssignment(sampling: number, buckets: Bucket[]): Bucket | null;
20
20
  /**
21
21
  * Determines which bucket a user assignment will recieve
22
22
  */
23
- export declare const determineBucket: (buckets: Bucket[]) => Bucket;
23
+ export declare function determineBucket(buckets: Bucket[]): Bucket;
24
24
  /**
25
25
  * Validate the total ratio of the buckets equals 100
26
26
  */
27
- export declare const validateTotalBucketRatio: (buckets: Unsaved<Bucket>[]) => void;
27
+ export declare function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void;
28
28
  /**
29
29
  * Validate a users segmentation data against multiple rules. Returns false if any fail
30
30
  *
@@ -32,7 +32,7 @@ export declare const validateTotalBucketRatio: (buckets: Unsaved<Bucket>[]) => v
32
32
  * @param segmentationData
33
33
  * @returns
34
34
  */
35
- export declare const validateSegmentationRules: (rules: SegmentationRule[], segmentationData: SegmentationData) => boolean;
35
+ export declare function validateSegmentationRules(rules: SegmentationRule[], segmentationData: SegmentationData): boolean;
36
36
  /**
37
37
  * Map of segmentation rule validators
38
38
  */
@@ -40,10 +40,10 @@ export declare const segmentationRuleMap: Record<SegmentationRuleOperator, Segme
40
40
  /**
41
41
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
42
42
  */
43
- export declare const canGenerateNewAssignments: (experiment: Experiment, exclusionSet: ExclusionSet) => boolean;
43
+ export declare function canGenerateNewAssignments(experiment: Experiment, exclusionSet: ExclusionSet): boolean;
44
44
  /**
45
45
  * Returns an object that includes keys of all experimentIds a user should not be assigned to
46
46
  * based on a combination of existing assignments and mutual exclusion configuration
47
47
  */
48
- export declare const getUserExclusionSet: (experiments: Experiment[], existingAssignments: UserAssignment[]) => ExclusionSet;
48
+ export declare function getUserExclusionSet(experiments: UserExperiment[]): ExclusionSet;
49
49
  export {};
package/dist/util.js CHANGED
@@ -5,7 +5,7 @@ import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
5
5
  * Generate a new assignment for a given user.
6
6
  * Doesn't save it.
7
7
  */
8
- export const generateUserAssignmentData = (experiment, userId, segmentationData) => {
8
+ export function generateUserAssignmentData(experiment, userId, segmentationData) {
9
9
  const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData);
10
10
  if (!segmentationMatch)
11
11
  return null;
@@ -15,7 +15,7 @@ export const generateUserAssignmentData = (experiment, userId, segmentationData)
15
15
  experimentId: experiment.id,
16
16
  bucketId: bucket?.id || null,
17
17
  };
18
- };
18
+ }
19
19
  class RandomService {
20
20
  /**
21
21
  * Generate a random number between 0 and 100
@@ -28,18 +28,18 @@ export const randomService = new RandomService();
28
28
  /**
29
29
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
30
30
  */
31
- export const determineAssignment = (sampling, buckets) => {
31
+ export function determineAssignment(sampling, buckets) {
32
32
  // Should this person be considered for the experiment?
33
33
  if (randomService.rollDie() > sampling) {
34
34
  return null;
35
35
  }
36
36
  // get their bucket
37
37
  return determineBucket(buckets);
38
- };
38
+ }
39
39
  /**
40
40
  * Determines which bucket a user assignment will recieve
41
41
  */
42
- export const determineBucket = (buckets) => {
42
+ export function determineBucket(buckets) {
43
43
  const bucketRoll = randomService.rollDie();
44
44
  let range;
45
45
  const bucket = buckets.find(b => {
@@ -57,16 +57,16 @@ export const determineBucket = (buckets) => {
57
57
  throw new Error('Could not detetermine bucket from ratios');
58
58
  }
59
59
  return bucket;
60
- };
60
+ }
61
61
  /**
62
62
  * Validate the total ratio of the buckets equals 100
63
63
  */
64
- export const validateTotalBucketRatio = (buckets) => {
64
+ export function validateTotalBucketRatio(buckets) {
65
65
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
66
66
  if (bucketSum !== 100) {
67
67
  throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
68
68
  }
69
- };
69
+ }
70
70
  /**
71
71
  * Validate a users segmentation data against multiple rules. Returns false if any fail
72
72
  *
@@ -74,14 +74,14 @@ export const validateTotalBucketRatio = (buckets) => {
74
74
  * @param segmentationData
75
75
  * @returns
76
76
  */
77
- export const validateSegmentationRules = (rules, segmentationData) => {
77
+ export function validateSegmentationRules(rules, segmentationData) {
78
78
  for (const rule of rules) {
79
79
  const { key, value, operator } = rule;
80
80
  if (!segmentationRuleMap[operator](segmentationData[key], value))
81
81
  return false;
82
82
  }
83
83
  return true;
84
- };
84
+ }
85
85
  /**
86
86
  * Map of segmentation rule validators
87
87
  */
@@ -115,24 +115,24 @@ export const segmentationRuleMap = {
115
115
  /**
116
116
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
117
117
  */
118
- export const canGenerateNewAssignments = (experiment, exclusionSet) => {
118
+ export function canGenerateNewAssignments(experiment, exclusionSet) {
119
119
  return (!exclusionSet.has(experiment.id) &&
120
120
  experiment.status === AssignmentStatus.Active &&
121
121
  localDate.today().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)'));
122
- };
122
+ }
123
123
  /**
124
124
  * Returns an object that includes keys of all experimentIds a user should not be assigned to
125
125
  * based on a combination of existing assignments and mutual exclusion configuration
126
126
  */
127
- export const getUserExclusionSet = (experiments, existingAssignments) => {
127
+ export function getUserExclusionSet(experiments) {
128
128
  const exclusionSet = new Set();
129
- existingAssignments.forEach(assignment => {
129
+ experiments.forEach(experiment => {
130
+ const { userAssignment } = experiment;
130
131
  // Users who are excluded from an experiment due to sampling
131
132
  // should not prevent potential assignment to other mutually exclusive experiments
132
- if (assignment.bucketId === null)
133
+ if (!userAssignment || userAssignment?.bucketId === null)
133
134
  return;
134
- const experiment = experiments.find(e => e.id === assignment.experimentId);
135
- experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
135
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
136
136
  });
137
137
  return exclusionSet;
138
- };
138
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "2.0.2",
5
5
  "scripts": {
6
6
  "prepare": "husky",
7
7
  "build": "dev-lib build",
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@naturalcycles/db-lib": "^10",
15
- "@naturalcycles/js-lib": "^14",
16
- "@naturalcycles/nodejs-lib": "^13",
15
+ "@naturalcycles/js-lib": "^15",
16
+ "@naturalcycles/nodejs-lib": "^14",
17
17
  "semver": "^7"
18
18
  },
19
19
  "devDependencies": {
20
- "@naturalcycles/dev-lib": "^17",
20
+ "@naturalcycles/dev-lib": "^18",
21
21
  "@types/node": "^22",
22
22
  "@types/semver": "^7",
23
23
  "@vitest/coverage-v8": "^3",
@@ -46,5 +46,6 @@
46
46
  },
47
47
  "description": "AB test assignment configuration tool for Node.js",
48
48
  "author": "Natural Cycles Team",
49
- "license": "MIT"
49
+ "license": "MIT",
50
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
50
51
  }
package/src/abba.ts CHANGED
@@ -8,12 +8,13 @@ import type {
8
8
  AbbaConfig,
9
9
  Bucket,
10
10
  BucketAssignmentStatistics,
11
+ DecoratedUserAssignment,
11
12
  Experiment,
12
13
  ExperimentAssignmentStatistics,
13
14
  ExperimentWithBuckets,
14
- GeneratedUserAssignment,
15
15
  SegmentationData,
16
16
  UserAssignment,
17
+ UserExperiment,
17
18
  } from './types.js'
18
19
  import { AssignmentStatus } from './types.js'
19
20
  import {
@@ -40,8 +41,39 @@ export class Abba {
40
41
  * Cached (see CACHE_TTL)
41
42
  */
42
43
  @_Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
43
- async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
44
- return await this.getAllExperimentsNoCache()
44
+ async getAllExperimentsWithBuckets(): Promise<ExperimentWithBuckets[]> {
45
+ return await this.getAllExperimentsWithBucketsNoCache()
46
+ }
47
+
48
+ async getAllExperimentsWithUserAssignments(userId: string): Promise<UserExperiment[]> {
49
+ const experiments = await this.getAllExperimentsWithBuckets()
50
+
51
+ const experimentIds = experiments.map(e => e.id)
52
+ const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(
53
+ userId,
54
+ experimentIds,
55
+ )
56
+
57
+ return experiments.map(experiment => {
58
+ const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id)
59
+ const existingAssignmentBucket = experiment.buckets.find(
60
+ b => b.id === existingAssignment?.bucketId,
61
+ )
62
+
63
+ return {
64
+ ...experiment,
65
+ ...(existingAssignment && {
66
+ userAssignment: {
67
+ ...existingAssignment,
68
+ experimentId: experiment.id,
69
+ experimentData: experiment.data,
70
+ experimentKey: experiment.key,
71
+ bucketData: existingAssignmentBucket?.data || null,
72
+ bucketKey: existingAssignmentBucket?.key || null,
73
+ },
74
+ }),
75
+ }
76
+ })
45
77
  }
46
78
 
47
79
  /**
@@ -55,7 +87,7 @@ export class Abba {
55
87
  /**
56
88
  * Returns all experiments.
57
89
  */
58
- async getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]> {
90
+ async getAllExperimentsWithBucketsNoCache(): Promise<ExperimentWithBuckets[]> {
59
91
  const experiments = await this.experimentDao.getAll()
60
92
  const buckets = await this.bucketDao.getAll()
61
93
 
@@ -191,49 +223,40 @@ export class Abba {
191
223
  userId: string,
192
224
  existingOnly: boolean,
193
225
  segmentationData?: SegmentationData,
194
- ): Promise<GeneratedUserAssignment> {
195
- const experiment = await this.experimentDao.getOneBy('key', experimentKey)
226
+ ): Promise<DecoratedUserAssignment | null> {
227
+ const experiment = await this.experimentDao.getByKey(experimentKey)
196
228
  _assert(experiment, `Experiment does not exist: ${experimentKey}`)
197
229
 
198
230
  // Inactive experiments should never return an assignment
199
231
  if (experiment.status === AssignmentStatus.Inactive) {
200
- return {
201
- experiment,
202
- assignment: null,
203
- }
232
+ return null
204
233
  }
205
234
 
206
- const buckets = await this.bucketDao.getBy('experimentId', experiment.id)
207
- const existingAssignments = await this.userAssignmentDao.getBy('userId', userId)
208
- const existing = existingAssignments.find(a => a.experimentId === experiment.id)
209
- if (existing) {
210
- const bucket = buckets.find(b => b.id === existing.bucketId)
235
+ const buckets = await this.bucketDao.getByExperimentId(experiment.id)
236
+ const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(
237
+ userId,
238
+ experiment.id,
239
+ )
240
+ if (userAssignment) {
241
+ const bucket = buckets.find(b => b.id === userAssignment.bucketId)
211
242
  return {
212
- experiment,
213
- assignment: {
214
- ...existing,
215
- experimentKey: experiment.key,
216
- bucketKey: bucket?.key || null,
217
- bucketData: bucket?.data || null,
218
- },
243
+ ...userAssignment,
244
+ experimentData: experiment.data,
245
+ experimentKey: experiment.key,
246
+ bucketKey: bucket?.key || null,
247
+ bucketData: bucket?.data || null,
219
248
  }
220
249
  }
221
250
 
222
251
  // No existing assignment, but we don't want to generate a new one
223
252
  if (existingOnly || experiment.status === AssignmentStatus.Paused) {
224
- return {
225
- experiment,
226
- assignment: null,
227
- }
253
+ return null
228
254
  }
229
255
 
230
- const experiments = await this.getAllExperiments()
231
- const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
256
+ const experiments = await this.getAllExperimentsWithUserAssignments(userId)
257
+ const exclusionSet = getUserExclusionSet(experiments)
232
258
  if (!canGenerateNewAssignments(experiment, exclusionSet)) {
233
- return {
234
- experiment,
235
- assignment: null,
236
- }
259
+ return null
237
260
  }
238
261
 
239
262
  _assert(segmentationData, 'Segmentation data required when creating a new assignment')
@@ -241,10 +264,7 @@ export class Abba {
241
264
  const experimentWithBuckets = { ...experiment, buckets }
242
265
  const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
243
266
  if (!assignment) {
244
- return {
245
- experiment,
246
- assignment: null,
247
- }
267
+ return null
248
268
  }
249
269
 
250
270
  const newAssignment = await this.userAssignmentDao.save(assignment)
@@ -252,13 +272,11 @@ export class Abba {
252
272
  const bucket = buckets.find(b => b.id === newAssignment.bucketId)
253
273
 
254
274
  return {
255
- experiment,
256
- assignment: {
257
- ...newAssignment,
258
- experimentKey: experiment.key,
259
- bucketKey: bucket?.key || null,
260
- bucketData: bucket?.data || null,
261
- },
275
+ ...newAssignment,
276
+ experimentData: experiment.data,
277
+ experimentKey: experiment.key,
278
+ bucketKey: bucket?.key || null,
279
+ bucketData: bucket?.data || null,
262
280
  }
263
281
  }
264
282
 
@@ -268,19 +286,17 @@ export class Abba {
268
286
  * Not cached, because Assignments are fast-changing.
269
287
  * Only to be used for testing
270
288
  */
271
- async getAllExistingGeneratedUserAssignments(userId: string): Promise<GeneratedUserAssignment[]> {
289
+ async getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]> {
272
290
  const assignments = await this.userAssignmentDao.getBy('userId', userId)
273
291
  return await pMap(assignments, async assignment => {
274
292
  const experiment = await this.experimentDao.requireById(assignment.experimentId)
275
293
  const bucket = await this.bucketDao.getById(assignment.bucketId)
276
294
  return {
277
- experiment,
278
- assignment: {
279
- ...assignment,
280
- experimentKey: experiment.key,
281
- bucketKey: bucket?.key || null,
282
- bucketData: bucket?.data || null,
283
- },
295
+ ...assignment,
296
+ experimentData: experiment.data,
297
+ experimentKey: experiment.key,
298
+ bucketKey: bucket?.key || null,
299
+ bucketData: bucket?.data || null,
284
300
  }
285
301
  })
286
302
  }
@@ -294,12 +310,9 @@ export class Abba {
294
310
  userId: string,
295
311
  segmentationData: SegmentationData,
296
312
  existingOnly = false,
297
- ): Promise<GeneratedUserAssignment[]> {
298
- const experiments = await this.getAllExperiments()
299
- const existingAssignments = await this.userAssignmentDao.getBy('userId', userId)
300
- const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
301
- const assignments: GeneratedUserAssignment[] = []
302
- const newAssignments: UserAssignment[] = []
313
+ ): Promise<DecoratedUserAssignment[]> {
314
+ const experiments = await this.getAllExperimentsWithUserAssignments(userId)
315
+ const exclusionSet = getUserExclusionSet(experiments)
303
316
 
304
317
  // Shuffling means that randomisation occurs in the mutual exclusion
305
318
  // as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
@@ -310,41 +323,43 @@ export class Abba {
310
323
  ),
311
324
  )
312
325
 
326
+ const assignments: DecoratedUserAssignment[] = []
327
+ const newAssignments: Unsaved<UserAssignment>[] = []
328
+
313
329
  for (const experiment of availableExperiments) {
314
- const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
315
- if (existing) {
316
- const bucket = experiment.buckets.find(b => b.id === existing.bucketId)
330
+ const { userAssignment } = experiment
331
+ // Already assigned to this experiment
332
+ if (userAssignment) {
333
+ assignments.push(userAssignment)
334
+ continue
335
+ }
336
+
337
+ // Not already assigned, but we don't want to generate a new assignment
338
+ if (existingOnly) continue
339
+ // We are not allowed to generate new assignments for this experiment
340
+ if (!canGenerateNewAssignments(experiment, exclusionSet)) continue
341
+
342
+ const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
343
+ if (assignment) {
344
+ // Add to list of new assignments to be saved
345
+ const newAssignment = this.userAssignmentDao.create(assignment)
346
+ newAssignments.push(newAssignment)
347
+ // Add the assignment to the list of assignments
348
+ const bucket = experiment.buckets.find(b => b.id === assignment.bucketId)
317
349
  assignments.push({
318
- experiment,
319
- assignment: {
320
- ...existing,
321
- experimentKey: experiment.key,
322
- bucketKey: bucket?.key || null,
323
- bucketData: bucket?.data || null,
324
- },
350
+ ...newAssignment,
351
+ experimentKey: experiment.key,
352
+ experimentData: experiment.data,
353
+ bucketKey: bucket?.key || null,
354
+ bucketData: bucket?.data || null,
325
355
  })
326
- } else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
327
- const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
328
- if (assignment) {
329
- const created = this.userAssignmentDao.create(assignment)
330
- newAssignments.push(created)
331
- const bucket = experiment.buckets.find(b => b.id === created.bucketId)
332
- assignments.push({
333
- experiment,
334
- assignment: {
335
- ...created,
336
- experimentKey: experiment.key,
337
- bucketKey: bucket?.key || null,
338
- bucketData: bucket?.data || null,
339
- },
340
- })
341
- // Prevent future exclusion clashes
342
- experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
343
- }
356
+ // Prevent future exclusion clashes
357
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
344
358
  }
345
359
  }
346
360
 
347
361
  await this.userAssignmentDao.saveBatch(newAssignments)
362
+
348
363
  return assignments
349
364
  }
350
365
 
@@ -355,18 +370,11 @@ export class Abba {
355
370
  async getExperimentAssignmentStatistics(
356
371
  experimentId: string,
357
372
  ): Promise<ExperimentAssignmentStatistics> {
358
- const totalAssignments = await this.userAssignmentDao
359
- .query()
360
- .filterEq('experimentId', experimentId)
361
- .runQueryCount()
373
+ const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId)
374
+ const buckets = await this.bucketDao.getByExperimentId(experimentId)
362
375
 
363
- const buckets = await this.bucketDao.getBy('experimentId', experimentId)
364
376
  const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
365
- const totalAssignments = await this.userAssignmentDao
366
- .query()
367
- .filterEq('bucketId', bucket.id)
368
- .runQueryCount()
369
-
377
+ const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id)
370
378
  return {
371
379
  bucketId: bucket.id,
372
380
  totalAssignments,
@@ -1,16 +1,19 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib'
2
2
  import { CommonDao } from '@naturalcycles/db-lib'
3
- import type { Saved } from '@naturalcycles/js-lib'
4
3
  import type { BaseBucket, Bucket } from '../types.js'
5
4
 
6
- type BucketDBM = Saved<BaseBucket> & {
5
+ type BucketDBM = BaseBucket & {
7
6
  data: string | null
8
7
  }
9
8
 
10
- export class BucketDao extends CommonDao<Bucket, BucketDBM> {}
9
+ export class BucketDao extends CommonDao<Bucket, BucketDBM> {
10
+ async getByExperimentId(experimentId: string): Promise<Bucket[]> {
11
+ return await this.query().filterEq('experimentId', experimentId).runQuery()
12
+ }
13
+ }
11
14
 
12
- export const bucketDao = (db: CommonDB): BucketDao =>
13
- new BucketDao({
15
+ export function bucketDao(db: CommonDB): BucketDao {
16
+ return new BucketDao({
14
17
  db,
15
18
  table: 'Bucket',
16
19
  hooks: {
@@ -26,3 +29,4 @@ export const bucketDao = (db: CommonDB): BucketDao =>
26
29
  }),
27
30
  },
28
31
  })
32
+ }
@@ -1,19 +1,23 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib'
2
2
  import { CommonDao } from '@naturalcycles/db-lib'
3
- import type { IsoDate, Saved } from '@naturalcycles/js-lib'
3
+ import type { IsoDate } from '@naturalcycles/js-lib'
4
4
  import { localDate } from '@naturalcycles/js-lib'
5
5
  import type { BaseExperiment, Experiment } from '../types.js'
6
6
 
7
- type ExperimentDBM = Saved<BaseExperiment> & {
7
+ type ExperimentDBM = BaseExperiment & {
8
8
  rules: string | null
9
9
  exclusions: string | null
10
10
  data: string | null
11
11
  }
12
12
 
13
- export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {}
13
+ export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
14
+ async getByKey(key: string): Promise<Experiment | null> {
15
+ return await this.getOneBy('key', key)
16
+ }
17
+ }
14
18
 
15
- export const experimentDao = (db: CommonDB): ExperimentDao =>
16
- new ExperimentDao({
19
+ export function experimentDao(db: CommonDB): ExperimentDao {
20
+ return new ExperimentDao({
17
21
  db,
18
22
  table: 'Experiment',
19
23
  hooks: {
@@ -42,6 +46,7 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
42
46
  }),
43
47
  },
44
48
  })
49
+ }
45
50
 
46
51
  /**
47
52
  * https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
@@ -2,10 +2,36 @@ import type { CommonDB } from '@naturalcycles/db-lib'
2
2
  import { CommonDao } from '@naturalcycles/db-lib'
3
3
  import type { UserAssignment } from '../types.js'
4
4
 
5
- export class UserAssignmentDao extends CommonDao<UserAssignment> {}
5
+ export class UserAssignmentDao extends CommonDao<UserAssignment> {
6
+ async getUserAssignmentByExperimentId(
7
+ userId: string,
8
+ experimentId: string,
9
+ ): Promise<UserAssignment | null> {
10
+ const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId)
11
+ const [userAssignment] = await this.runQuery(query)
12
+ return userAssignment || null
13
+ }
6
14
 
7
- export const userAssignmentDao = (db: CommonDB): UserAssignmentDao =>
8
- new UserAssignmentDao({
15
+ async getUserAssigmentsByExperimentIds(
16
+ userId: string,
17
+ experimentIds: string[],
18
+ ): Promise<UserAssignment[]> {
19
+ const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds)
20
+ return await this.runQuery(query)
21
+ }
22
+
23
+ async getCountByExperimentId(experimentId: string): Promise<number> {
24
+ return await this.query().filterEq('experimentId', experimentId).runQueryCount()
25
+ }
26
+
27
+ async getCountByBucketId(bucketId: string): Promise<number> {
28
+ return await this.query().filterEq('bucketId', bucketId).runQueryCount()
29
+ }
30
+ }
31
+
32
+ export function userAssignmentDao(db: CommonDB): UserAssignmentDao {
33
+ return new UserAssignmentDao({
9
34
  db,
10
35
  table: 'UserAssignment',
11
36
  })
37
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib'
2
- import type { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib'
2
+ import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib'
3
3
 
4
4
  export interface AbbaConfig {
5
5
  db: CommonDB
@@ -39,8 +39,8 @@ export type Experiment = BaseExperiment & {
39
39
  data: AnyObject | null
40
40
  }
41
41
 
42
- export type ExperimentWithBuckets = Saved<Experiment> & {
43
- buckets: Saved<Bucket>[]
42
+ export type ExperimentWithBuckets = Experiment & {
43
+ buckets: Bucket[]
44
44
  }
45
45
 
46
46
  export type BaseBucket = BaseDBEntity & {
@@ -59,15 +59,11 @@ export type UserAssignment = BaseDBEntity & {
59
59
  bucketId: string | null
60
60
  }
61
61
 
62
- export interface GeneratedUserAssignment {
63
- assignment:
64
- | (Saved<UserAssignment> & {
65
- experimentKey: string
66
- bucketKey: string | null
67
- bucketData: AnyObject | null
68
- })
69
- | null
70
- experiment: Saved<Experiment>
62
+ export type DecoratedUserAssignment = UserAssignment & {
63
+ experimentKey: Experiment['key']
64
+ experimentData: Experiment['data']
65
+ bucketKey: Bucket['key'] | null
66
+ bucketData: Bucket['data']
71
67
  }
72
68
 
73
69
  export type SegmentationData = AnyObject
@@ -127,3 +123,7 @@ export interface BucketAssignmentStatistics {
127
123
  }
128
124
 
129
125
  export type ExclusionSet = Set<string>
126
+
127
+ export interface UserExperiment extends ExperimentWithBuckets {
128
+ userAssignment?: DecoratedUserAssignment
129
+ }
package/src/util.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  SegmentationRule,
11
11
  SegmentationRuleFn,
12
12
  UserAssignment,
13
+ UserExperiment,
13
14
  } from './types.js'
14
15
  import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
15
16
 
@@ -17,11 +18,11 @@ import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
17
18
  * Generate a new assignment for a given user.
18
19
  * Doesn't save it.
19
20
  */
20
- export const generateUserAssignmentData = (
21
+ export function generateUserAssignmentData(
21
22
  experiment: ExperimentWithBuckets,
22
23
  userId: string,
23
24
  segmentationData: SegmentationData,
24
- ): UserAssignment | null => {
25
+ ): Unsaved<UserAssignment> | null {
25
26
  const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
26
27
  if (!segmentationMatch) return null
27
28
 
@@ -31,7 +32,7 @@ export const generateUserAssignmentData = (
31
32
  userId,
32
33
  experimentId: experiment.id,
33
34
  bucketId: bucket?.id || null,
34
- } as UserAssignment
35
+ }
35
36
  }
36
37
 
37
38
  class RandomService {
@@ -48,7 +49,7 @@ export const randomService = new RandomService()
48
49
  /**
49
50
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
50
51
  */
51
- export const determineAssignment = (sampling: number, buckets: Bucket[]): Bucket | null => {
52
+ export function determineAssignment(sampling: number, buckets: Bucket[]): Bucket | null {
52
53
  // Should this person be considered for the experiment?
53
54
  if (randomService.rollDie() > sampling) {
54
55
  return null
@@ -61,7 +62,7 @@ export const determineAssignment = (sampling: number, buckets: Bucket[]): Bucket
61
62
  /**
62
63
  * Determines which bucket a user assignment will recieve
63
64
  */
64
- export const determineBucket = (buckets: Bucket[]): Bucket => {
65
+ export function determineBucket(buckets: Bucket[]): Bucket {
65
66
  const bucketRoll = randomService.rollDie()
66
67
  let range: [number, number] | undefined
67
68
  const bucket = buckets.find(b => {
@@ -86,7 +87,7 @@ export const determineBucket = (buckets: Bucket[]): Bucket => {
86
87
  /**
87
88
  * Validate the total ratio of the buckets equals 100
88
89
  */
89
- export const validateTotalBucketRatio = (buckets: Unsaved<Bucket>[]): void => {
90
+ export function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void {
90
91
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
91
92
  if (bucketSum !== 100) {
92
93
  throw new Error('Total bucket ratio must be 100 before you can activate an experiment')
@@ -100,10 +101,10 @@ export const validateTotalBucketRatio = (buckets: Unsaved<Bucket>[]): void => {
100
101
  * @param segmentationData
101
102
  * @returns
102
103
  */
103
- export const validateSegmentationRules = (
104
+ export function validateSegmentationRules(
104
105
  rules: SegmentationRule[],
105
106
  segmentationData: SegmentationData,
106
- ): boolean => {
107
+ ): boolean {
107
108
  for (const rule of rules) {
108
109
  const { key, value, operator } = rule
109
110
  if (!segmentationRuleMap[operator](segmentationData[key], value)) return false
@@ -144,10 +145,10 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
144
145
  /**
145
146
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
146
147
  */
147
- export const canGenerateNewAssignments = (
148
+ export function canGenerateNewAssignments(
148
149
  experiment: Experiment,
149
150
  exclusionSet: ExclusionSet,
150
- ): boolean => {
151
+ ): boolean {
151
152
  return (
152
153
  !exclusionSet.has(experiment.id) &&
153
154
  experiment.status === AssignmentStatus.Active &&
@@ -159,18 +160,15 @@ export const canGenerateNewAssignments = (
159
160
  * Returns an object that includes keys of all experimentIds a user should not be assigned to
160
161
  * based on a combination of existing assignments and mutual exclusion configuration
161
162
  */
162
- export const getUserExclusionSet = (
163
- experiments: Experiment[],
164
- existingAssignments: UserAssignment[],
165
- ): ExclusionSet => {
163
+ export function getUserExclusionSet(experiments: UserExperiment[]): ExclusionSet {
166
164
  const exclusionSet: ExclusionSet = new Set()
167
- existingAssignments.forEach(assignment => {
165
+ experiments.forEach(experiment => {
166
+ const { userAssignment } = experiment
168
167
  // Users who are excluded from an experiment due to sampling
169
168
  // should not prevent potential assignment to other mutually exclusive experiments
170
- if (assignment.bucketId === null) return
169
+ if (!userAssignment || userAssignment?.bucketId === null) return
171
170
 
172
- const experiment = experiments.find(e => e.id === assignment.experimentId)
173
- experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
171
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
174
172
  })
175
173
  return exclusionSet
176
174
  }