@naturalcycles/abba 2.0.1 → 2.1.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/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.1",
4
+ "version": "2.1.0",
5
5
  "scripts": {
6
6
  "prepare": "husky",
7
7
  "build": "dev-lib build",
@@ -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
@@ -2,18 +2,19 @@ import type { Unsaved } from '@naturalcycles/js-lib'
2
2
  import { _assert, _Memo, _shuffle, localTime, pMap } from '@naturalcycles/js-lib'
3
3
  import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
4
4
  import { bucketDao } from './dao/bucket.dao.js'
5
- import { experimentDao } from './dao/experiment.dao.js'
5
+ import { experimentDao, type GetAllExperimentsOpts } from './dao/experiment.dao.js'
6
6
  import { userAssignmentDao } from './dao/userAssignment.dao.js'
7
7
  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,23 +41,19 @@ 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()
45
- }
46
-
47
- /**
48
- * Updates all user assignments with a given userId with the provided userId.
49
- */
50
- async updateUserId(oldId: string, newId: string): Promise<void> {
51
- const query = this.userAssignmentDao.query().filterEq('userId', oldId)
52
- await this.userAssignmentDao.patchByQuery(query, { userId: newId })
44
+ async getAllExperimentsWithBuckets(
45
+ opts?: GetAllExperimentsOpts,
46
+ ): Promise<ExperimentWithBuckets[]> {
47
+ return await this.getAllExperimentsWithBucketsNoCache(opts)
53
48
  }
54
49
 
55
50
  /**
56
51
  * Returns all experiments.
57
52
  */
58
- async getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]> {
59
- const experiments = await this.experimentDao.getAll()
53
+ async getAllExperimentsWithBucketsNoCache(
54
+ opts?: GetAllExperimentsOpts,
55
+ ): Promise<ExperimentWithBuckets[]> {
56
+ const experiments = await this.experimentDao.getAllExperiments(opts)
60
57
  const buckets = await this.bucketDao.getAll()
61
58
 
62
59
  return experiments.map(experiment => ({
@@ -65,6 +62,45 @@ export class Abba {
65
62
  }))
66
63
  }
67
64
 
65
+ async getUserExperiments(userId: string): Promise<UserExperiment[]> {
66
+ const experiments = await this.getAllExperimentsWithBuckets({ includeDeleted: false })
67
+
68
+ const experimentIds = experiments.map(e => e.id)
69
+ const userAssignments = await this.userAssignmentDao.getUserAssigmentsByExperimentIds(
70
+ userId,
71
+ experimentIds,
72
+ )
73
+
74
+ return experiments.map(experiment => {
75
+ const existingAssignment = userAssignments.find(ua => ua.experimentId === experiment.id)
76
+ const existingAssignmentBucket = experiment.buckets.find(
77
+ b => b.id === existingAssignment?.bucketId,
78
+ )
79
+
80
+ return {
81
+ ...experiment,
82
+ ...(existingAssignment && {
83
+ userAssignment: {
84
+ ...existingAssignment,
85
+ experimentId: experiment.id,
86
+ experimentData: experiment.data,
87
+ experimentKey: experiment.key,
88
+ bucketData: existingAssignmentBucket?.data || null,
89
+ bucketKey: existingAssignmentBucket?.key || null,
90
+ },
91
+ }),
92
+ }
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Updates all user assignments with a given userId with the provided userId.
98
+ */
99
+ async updateUserId(oldId: string, newId: string): Promise<void> {
100
+ const query = this.userAssignmentDao.query().filterEq('userId', oldId)
101
+ await this.userAssignmentDao.patchByQuery(query, { userId: newId })
102
+ }
103
+
68
104
  /**
69
105
  * Creates a new experiment.
70
106
  * Cold method.
@@ -148,6 +184,11 @@ export class Abba {
148
184
  await this.experimentDao.saveBatch(requiresUpdating, { saveMethod: 'update' })
149
185
  }
150
186
 
187
+ async softDeleteExperiment(experimentId: string): Promise<void> {
188
+ await this.experimentDao.patchById(experimentId, { deleted: true, exclusions: [] })
189
+ await this.updateExclusions(experimentId, [])
190
+ }
191
+
151
192
  /**
152
193
  * Delete an experiment. Removes all user assignments and buckets.
153
194
  * Requires the experiment to have been inactive for at least 15 minutes in order to
@@ -191,49 +232,40 @@ export class Abba {
191
232
  userId: string,
192
233
  existingOnly: boolean,
193
234
  segmentationData?: SegmentationData,
194
- ): Promise<GeneratedUserAssignment> {
195
- const experiment = await this.experimentDao.getOneBy('key', experimentKey)
235
+ ): Promise<DecoratedUserAssignment | null> {
236
+ const experiment = await this.experimentDao.getByKey(experimentKey)
196
237
  _assert(experiment, `Experiment does not exist: ${experimentKey}`)
197
238
 
198
239
  // Inactive experiments should never return an assignment
199
240
  if (experiment.status === AssignmentStatus.Inactive) {
200
- return {
201
- experiment,
202
- assignment: null,
203
- }
241
+ return null
204
242
  }
205
243
 
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)
244
+ const buckets = await this.bucketDao.getByExperimentId(experiment.id)
245
+ const userAssignment = await this.userAssignmentDao.getUserAssignmentByExperimentId(
246
+ userId,
247
+ experiment.id,
248
+ )
249
+ if (userAssignment) {
250
+ const bucket = buckets.find(b => b.id === userAssignment.bucketId)
211
251
  return {
212
- experiment,
213
- assignment: {
214
- ...existing,
215
- experimentKey: experiment.key,
216
- bucketKey: bucket?.key || null,
217
- bucketData: bucket?.data || null,
218
- },
252
+ ...userAssignment,
253
+ experimentData: experiment.data,
254
+ experimentKey: experiment.key,
255
+ bucketKey: bucket?.key || null,
256
+ bucketData: bucket?.data || null,
219
257
  }
220
258
  }
221
259
 
222
260
  // No existing assignment, but we don't want to generate a new one
223
261
  if (existingOnly || experiment.status === AssignmentStatus.Paused) {
224
- return {
225
- experiment,
226
- assignment: null,
227
- }
262
+ return null
228
263
  }
229
264
 
230
- const experiments = await this.getAllExperiments()
231
- const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
265
+ const experiments = await this.getUserExperiments(userId)
266
+ const exclusionSet = getUserExclusionSet(experiments)
232
267
  if (!canGenerateNewAssignments(experiment, exclusionSet)) {
233
- return {
234
- experiment,
235
- assignment: null,
236
- }
268
+ return null
237
269
  }
238
270
 
239
271
  _assert(segmentationData, 'Segmentation data required when creating a new assignment')
@@ -241,10 +273,7 @@ export class Abba {
241
273
  const experimentWithBuckets = { ...experiment, buckets }
242
274
  const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
243
275
  if (!assignment) {
244
- return {
245
- experiment,
246
- assignment: null,
247
- }
276
+ return null
248
277
  }
249
278
 
250
279
  const newAssignment = await this.userAssignmentDao.save(assignment)
@@ -252,13 +281,11 @@ export class Abba {
252
281
  const bucket = buckets.find(b => b.id === newAssignment.bucketId)
253
282
 
254
283
  return {
255
- experiment,
256
- assignment: {
257
- ...newAssignment,
258
- experimentKey: experiment.key,
259
- bucketKey: bucket?.key || null,
260
- bucketData: bucket?.data || null,
261
- },
284
+ ...newAssignment,
285
+ experimentData: experiment.data,
286
+ experimentKey: experiment.key,
287
+ bucketKey: bucket?.key || null,
288
+ bucketData: bucket?.data || null,
262
289
  }
263
290
  }
264
291
 
@@ -268,19 +295,17 @@ export class Abba {
268
295
  * Not cached, because Assignments are fast-changing.
269
296
  * Only to be used for testing
270
297
  */
271
- async getAllExistingGeneratedUserAssignments(userId: string): Promise<GeneratedUserAssignment[]> {
298
+ async getAllExistingUserAssignments(userId: string): Promise<DecoratedUserAssignment[]> {
272
299
  const assignments = await this.userAssignmentDao.getBy('userId', userId)
273
300
  return await pMap(assignments, async assignment => {
274
301
  const experiment = await this.experimentDao.requireById(assignment.experimentId)
275
302
  const bucket = await this.bucketDao.getById(assignment.bucketId)
276
303
  return {
277
- experiment,
278
- assignment: {
279
- ...assignment,
280
- experimentKey: experiment.key,
281
- bucketKey: bucket?.key || null,
282
- bucketData: bucket?.data || null,
283
- },
304
+ ...assignment,
305
+ experimentData: experiment.data,
306
+ experimentKey: experiment.key,
307
+ bucketKey: bucket?.key || null,
308
+ bucketData: bucket?.data || null,
284
309
  }
285
310
  })
286
311
  }
@@ -294,12 +319,9 @@ export class Abba {
294
319
  userId: string,
295
320
  segmentationData: SegmentationData,
296
321
  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[] = []
322
+ ): Promise<DecoratedUserAssignment[]> {
323
+ const experiments = await this.getUserExperiments(userId)
324
+ const exclusionSet = getUserExclusionSet(experiments)
303
325
 
304
326
  // Shuffling means that randomisation occurs in the mutual exclusion
305
327
  // 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 +332,43 @@ export class Abba {
310
332
  ),
311
333
  )
312
334
 
335
+ const assignments: DecoratedUserAssignment[] = []
336
+ const newAssignments: Unsaved<UserAssignment>[] = []
337
+
313
338
  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)
339
+ const { userAssignment } = experiment
340
+ // Already assigned to this experiment
341
+ if (userAssignment) {
342
+ assignments.push(userAssignment)
343
+ continue
344
+ }
345
+
346
+ // Not already assigned, but we don't want to generate a new assignment
347
+ if (existingOnly) continue
348
+ // We are not allowed to generate new assignments for this experiment
349
+ if (!canGenerateNewAssignments(experiment, exclusionSet)) continue
350
+
351
+ const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
352
+ if (assignment) {
353
+ // Add to list of new assignments to be saved
354
+ const newAssignment = this.userAssignmentDao.create(assignment)
355
+ newAssignments.push(newAssignment)
356
+ // Add the assignment to the list of assignments
357
+ const bucket = experiment.buckets.find(b => b.id === assignment.bucketId)
317
358
  assignments.push({
318
- experiment,
319
- assignment: {
320
- ...existing,
321
- experimentKey: experiment.key,
322
- bucketKey: bucket?.key || null,
323
- bucketData: bucket?.data || null,
324
- },
359
+ ...newAssignment,
360
+ experimentKey: experiment.key,
361
+ experimentData: experiment.data,
362
+ bucketKey: bucket?.key || null,
363
+ bucketData: bucket?.data || null,
325
364
  })
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
- }
365
+ // Prevent future exclusion clashes
366
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
344
367
  }
345
368
  }
346
369
 
347
370
  await this.userAssignmentDao.saveBatch(newAssignments)
371
+
348
372
  return assignments
349
373
  }
350
374
 
@@ -355,18 +379,11 @@ export class Abba {
355
379
  async getExperimentAssignmentStatistics(
356
380
  experimentId: string,
357
381
  ): Promise<ExperimentAssignmentStatistics> {
358
- const totalAssignments = await this.userAssignmentDao
359
- .query()
360
- .filterEq('experimentId', experimentId)
361
- .runQueryCount()
382
+ const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId)
383
+ const buckets = await this.bucketDao.getByExperimentId(experimentId)
362
384
 
363
- const buckets = await this.bucketDao.getBy('experimentId', experimentId)
364
385
  const bucketAssignments: BucketAssignmentStatistics[] = await pMap(buckets, async bucket => {
365
- const totalAssignments = await this.userAssignmentDao
366
- .query()
367
- .filterEq('bucketId', bucket.id)
368
- .runQueryCount()
369
-
386
+ const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id)
370
387
  return {
371
388
  bucketId: bucket.id,
372
389
  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,25 @@
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> & {
8
- rules: string | null
9
- exclusions: string | null
10
- data: string | null
11
- }
7
+ export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
8
+ async getAllExperiments(opt?: GetAllExperimentsOpts): Promise<Experiment[]> {
9
+ if (!opt?.includeDeleted) {
10
+ return await this.getAll()
11
+ }
12
12
 
13
- export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {}
13
+ return await this.query().filterEq('deleted', false).runQuery()
14
+ }
14
15
 
15
- export const experimentDao = (db: CommonDB): ExperimentDao =>
16
- new ExperimentDao({
16
+ async getByKey(key: string): Promise<Experiment | null> {
17
+ return await this.getOneBy('key', key)
18
+ }
19
+ }
20
+
21
+ export function experimentDao(db: CommonDB): ExperimentDao {
22
+ return new ExperimentDao({
17
23
  db,
18
24
  table: 'Experiment',
19
25
  hooks: {
@@ -42,6 +48,7 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
42
48
  }),
43
49
  },
44
50
  })
51
+ }
45
52
 
46
53
  /**
47
54
  * https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
@@ -53,3 +60,13 @@ function parseMySQLDate(date: string): IsoDate {
53
60
  if (date instanceof Date) return localDate(date).toISODate()
54
61
  return date as IsoDate
55
62
  }
63
+
64
+ type ExperimentDBM = BaseExperiment & {
65
+ rules: string | null
66
+ exclusions: string | null
67
+ data: string | null
68
+ }
69
+
70
+ export interface GetAllExperimentsOpts {
71
+ includeDeleted?: boolean
72
+ }
@@ -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
+ }
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS `Experiment` (
25
25
  `rules` JSON NULL,
26
26
  `exclusions` JSON NULL,
27
27
  `data` JSON NULL,
28
+ `deleted` BOOLEAN NOT NULL DEFAULT FALSE,
28
29
 
29
30
  PRIMARY KEY (`id`),
30
31
  UNIQUE INDEX `key_unique` (`key`)