@naturalcycles/abba 2.7.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/abba.js CHANGED
@@ -8,17 +8,13 @@ import { LRUMemoCache } from '@naturalcycles/nodejs-lib/lruMemoCache';
8
8
  import { bucketDao } from './dao/bucket.dao.js';
9
9
  import { experimentDao } from './dao/experiment.dao.js';
10
10
  import { userAssignmentDao } from './dao/userAssignment.dao.js';
11
- import { AssignmentStatus } from './types.js';
11
+ import { AbbaErrorCode, AssignmentStatus } from './types.js';
12
12
  import { canGenerateNewAssignments, generateUserAssignmentData, getUserExclusionSet, validateTotalBucketRatio, } from './util.js';
13
13
  /**
14
14
  * 10 minutes
15
15
  */
16
16
  const CACHE_TTL = 600_000;
17
17
  export class Abba {
18
- cfg;
19
- experimentDao;
20
- bucketDao;
21
- userAssignmentDao;
22
18
  constructor(cfg) {
23
19
  this.cfg = cfg;
24
20
  this.experimentDao = experimentDao(cfg.db);
@@ -72,7 +68,7 @@ export class Abba {
72
68
  async mergeAssignmentsForUserIds(fromUserId, intoUserId) {
73
69
  const fromAssignments = await this.userAssignmentDao.getBy('userId', fromUserId);
74
70
  const existingIntoAssignments = await this.userAssignmentDao.getBy('userId', intoUserId);
75
- await pMap(fromAssignments, async from => {
71
+ await pMap(fromAssignments, async (from) => {
76
72
  if (!existingIntoAssignments.some(into => into.experimentId === from.experimentId)) {
77
73
  await this.userAssignmentDao.patch(from, { userId: intoUserId });
78
74
  }
@@ -109,7 +105,7 @@ export class Abba {
109
105
  validateTotalBucketRatio(buckets);
110
106
  }
111
107
  const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
112
- const updatedBuckets = await pMap(buckets, async bucket => {
108
+ const updatedBuckets = await pMap(buckets, async (bucket) => {
113
109
  return await this.bucketDao.save({ ...bucket, experimentId: updatedExperiment.id }, { saveMethod: bucket.id ? 'update' : undefined });
114
110
  });
115
111
  await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
@@ -154,7 +150,7 @@ export class Abba {
154
150
  const experiment = await this.experimentDao.requireById(experimentId);
155
151
  const hasBeenInactiveFor15Mins = experiment.status === AssignmentStatus.Inactive &&
156
152
  localTime(experiment.updated).isOlderThan(15, 'minute');
157
- _assert(hasBeenInactiveFor15Mins, 'Experiment must be inactive for at least 15 minutes before deletion');
153
+ _assert(hasBeenInactiveFor15Mins, 'Experiment must be inactive for at least 15 minutes before deletion', { code: AbbaErrorCode.ExperimentDeletionTooSoon });
158
154
  await this.userAssignmentDao.deleteByExperimentId(experimentId);
159
155
  await this.bucketDao.deleteByExperimentId(experimentId);
160
156
  await this.experimentDao.deleteById(experimentId);
@@ -171,7 +167,9 @@ export class Abba {
171
167
  */
172
168
  async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
173
169
  const experiment = await this.experimentDao.getByKey(experimentKey);
174
- _assert(experiment, `Experiment does not exist: ${experimentKey}`);
170
+ _assert(experiment, `Experiment does not exist: ${experimentKey}`, {
171
+ code: AbbaErrorCode.ExperimentNotFound,
172
+ });
175
173
  // Inactive experiments should never return an assignment
176
174
  if (experiment.status === AssignmentStatus.Inactive) {
177
175
  return null;
@@ -197,7 +195,9 @@ export class Abba {
197
195
  if (!canGenerateNewAssignments(experiment, exclusionSet)) {
198
196
  return null;
199
197
  }
200
- _assert(segmentationData, 'Segmentation data required when creating a new assignment');
198
+ _assert(segmentationData, 'Segmentation data required when creating a new assignment', {
199
+ code: AbbaErrorCode.SegmentationDataRequired,
200
+ });
201
201
  const experimentWithBuckets = { ...experiment, buckets };
202
202
  const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData);
203
203
  if (!assignment) {
@@ -221,7 +221,7 @@ export class Abba {
221
221
  */
222
222
  async getAllExistingUserAssignments(userId) {
223
223
  const assignments = await this.userAssignmentDao.getBy('userId', userId);
224
- return await pMap(assignments, async assignment => {
224
+ return await pMap(assignments, async (assignment) => {
225
225
  const experiment = await this.experimentDao.requireById(assignment.experimentId);
226
226
  const bucket = await this.bucketDao.getById(assignment.bucketId);
227
227
  return {
@@ -288,7 +288,7 @@ export class Abba {
288
288
  async getExperimentAssignmentStatistics(experimentId) {
289
289
  const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
290
290
  const buckets = await this.bucketDao.getByExperimentId(experimentId);
291
- const bucketAssignments = await pMap(buckets, async bucket => {
291
+ const bucketAssignments = await pMap(buckets, async (bucket) => {
292
292
  const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
293
293
  return {
294
294
  bucketId: bucket.id,
@@ -19,11 +19,7 @@ export function experimentDao(db) {
19
19
  beforeBMToDBM: bm => ({
20
20
  ...bm,
21
21
  rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
22
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
23
- // TODO: Remove after some time when we are certain only strings are stored
24
- exclusions: bm.exclusions.length
25
- ? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
26
- : null,
22
+ exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
27
23
  data: bm.data ? JSON.stringify(bm.data) : null,
28
24
  }),
29
25
  beforeDBMToBM: dbm => ({
@@ -31,11 +27,7 @@ export function experimentDao(db) {
31
27
  startDateIncl: parseMySQLDate(dbm.startDateIncl),
32
28
  endDateExcl: parseMySQLDate(dbm.endDateExcl),
33
29
  rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
34
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
35
- // TODO: Remove after some time when we are certain only strings are stored
36
- exclusions: (dbm.exclusions &&
37
- JSON.parse(dbm.exclusions).map((exclusion) => exclusion.toString())) ||
38
- [],
30
+ exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
39
31
  data: dbm.data ? JSON.parse(dbm.data) : null,
40
32
  }),
41
33
  },
package/dist/types.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib';
2
2
  import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib/types';
3
+ export declare const AbbaErrorCode: {
4
+ readonly ExperimentNotFound: 'abba/experimentNotFound';
5
+ readonly ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon';
6
+ readonly SegmentationDataRequired: 'abba/segmentationDataRequired';
7
+ readonly InvalidBucketRatio: 'abba/invalidBucketRatio';
8
+ readonly BucketDeterminationFailed: 'abba/bucketDeterminationFailed';
9
+ };
3
10
  export interface AbbaConfig {
4
11
  db: CommonDB;
5
12
  }
package/dist/types.js CHANGED
@@ -1,5 +1,11 @@
1
- export { AssignmentStatus };
2
- var AssignmentStatus;
1
+ export const AbbaErrorCode = {
2
+ ExperimentNotFound: 'abba/experimentNotFound',
3
+ ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon',
4
+ SegmentationDataRequired: 'abba/segmentationDataRequired',
5
+ InvalidBucketRatio: 'abba/invalidBucketRatio',
6
+ BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
7
+ };
8
+ export var AssignmentStatus;
3
9
  (function (AssignmentStatus) {
4
10
  /**
5
11
  * Will return existing assignments and generate new assignments
@@ -14,8 +20,7 @@ var AssignmentStatus;
14
20
  */
15
21
  AssignmentStatus[AssignmentStatus["Inactive"] = 3] = "Inactive";
16
22
  })(AssignmentStatus || (AssignmentStatus = {}));
17
- export { SegmentationRuleOperator };
18
- var SegmentationRuleOperator;
23
+ export var SegmentationRuleOperator;
19
24
  (function (SegmentationRuleOperator) {
20
25
  SegmentationRuleOperator["IsSet"] = "isSet";
21
26
  SegmentationRuleOperator["IsNotSet"] = "isNotSet";
package/dist/util.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { localDate } from '@naturalcycles/js-lib/datetime';
2
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
2
3
  import { satisfies } from 'semver';
3
- import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
4
+ import { AbbaErrorCode, AssignmentStatus, SegmentationRuleOperator } from './types.js';
4
5
  /**
5
6
  * Generate a new assignment for a given user.
6
7
  * Doesn't save it.
@@ -53,9 +54,9 @@ export function determineBucket(buckets) {
53
54
  return b;
54
55
  }
55
56
  });
56
- if (!bucket) {
57
- throw new Error('Could not detetermine bucket from ratios');
58
- }
57
+ _assert(bucket, 'Could not determine bucket from ratios', {
58
+ code: AbbaErrorCode.BucketDeterminationFailed,
59
+ });
59
60
  return bucket;
60
61
  }
61
62
  /**
@@ -63,9 +64,7 @@ export function determineBucket(buckets) {
63
64
  */
64
65
  export function validateTotalBucketRatio(buckets) {
65
66
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
66
- if (bucketSum !== 100) {
67
- throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
68
- }
67
+ _assert(bucketSum === 100, 'Total bucket ratio must be 100 before you can activate an experiment', { code: AbbaErrorCode.InvalidBucketRatio });
69
68
  }
70
69
  /**
71
70
  * Validate a users segmentation data against multiple rules. Returns false if any fail
@@ -93,12 +92,15 @@ export const segmentationRuleMap = {
93
92
  return !keyValue;
94
93
  },
95
94
  [SegmentationRuleOperator.EqualsText](keyValue, ruleValue) {
95
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
96
96
  return keyValue?.toString() === ruleValue.toString();
97
97
  },
98
98
  [SegmentationRuleOperator.NotEqualsText](keyValue, ruleValue) {
99
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
99
100
  return keyValue?.toString() !== ruleValue.toString();
100
101
  },
101
102
  [SegmentationRuleOperator.Semver](keyValue, ruleValue) {
103
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
102
104
  return satisfies(keyValue?.toString() || '', ruleValue.toString());
103
105
  },
104
106
  [SegmentationRuleOperator.Regex](keyValue, ruleValue) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
3
  "type": "module",
4
- "version": "2.7.3",
4
+ "version": "2.9.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/semver": "^7",
14
- "@typescript/native-preview": "7.0.0-dev.20260301.1",
14
+ "@typescript/native-preview": "7.0.0-dev.20260401.1",
15
15
  "@naturalcycles/dev-lib": "18.4.2"
16
16
  },
17
17
  "exports": {
package/src/abba.ts CHANGED
@@ -23,7 +23,7 @@ import type {
23
23
  UserAssignment,
24
24
  UserExperiment,
25
25
  } from './types.js'
26
- import { AssignmentStatus } from './types.js'
26
+ import { AbbaErrorCode, AssignmentStatus } from './types.js'
27
27
  import {
28
28
  canGenerateNewAssignments,
29
29
  generateUserAssignmentData,
@@ -233,6 +233,7 @@ export class Abba {
233
233
  _assert(
234
234
  hasBeenInactiveFor15Mins,
235
235
  'Experiment must be inactive for at least 15 minutes before deletion',
236
+ { code: AbbaErrorCode.ExperimentDeletionTooSoon },
236
237
  )
237
238
 
238
239
  await this.userAssignmentDao.deleteByExperimentId(experimentId)
@@ -257,7 +258,9 @@ export class Abba {
257
258
  segmentationData?: SegmentationData,
258
259
  ): Promise<DecoratedUserAssignment | null> {
259
260
  const experiment = await this.experimentDao.getByKey(experimentKey)
260
- _assert(experiment, `Experiment does not exist: ${experimentKey}`)
261
+ _assert(experiment, `Experiment does not exist: ${experimentKey}`, {
262
+ code: AbbaErrorCode.ExperimentNotFound,
263
+ })
261
264
 
262
265
  // Inactive experiments should never return an assignment
263
266
  if (experiment.status === AssignmentStatus.Inactive) {
@@ -291,7 +294,9 @@ export class Abba {
291
294
  return null
292
295
  }
293
296
 
294
- _assert(segmentationData, 'Segmentation data required when creating a new assignment')
297
+ _assert(segmentationData, 'Segmentation data required when creating a new assignment', {
298
+ code: AbbaErrorCode.SegmentationDataRequired,
299
+ })
295
300
 
296
301
  const experimentWithBuckets = { ...experiment, buckets }
297
302
  const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData)
@@ -26,11 +26,7 @@ export function experimentDao(db: CommonDB): ExperimentDao {
26
26
  beforeBMToDBM: bm => ({
27
27
  ...bm,
28
28
  rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
29
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
30
- // TODO: Remove after some time when we are certain only strings are stored
31
- exclusions: bm.exclusions.length
32
- ? JSON.stringify(bm.exclusions.map(exclusion => exclusion.toString()))
33
- : null,
29
+ exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
34
30
  data: bm.data ? JSON.stringify(bm.data) : null,
35
31
  }),
36
32
  beforeDBMToBM: dbm => ({
@@ -38,12 +34,7 @@ export function experimentDao(db: CommonDB): ExperimentDao {
38
34
  startDateIncl: parseMySQLDate(dbm.startDateIncl),
39
35
  endDateExcl: parseMySQLDate(dbm.endDateExcl),
40
36
  rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
41
- // We add the map here to account for backwards compatibility where exclusion experimentIds were stored as a number
42
- // TODO: Remove after some time when we are certain only strings are stored
43
- exclusions:
44
- (dbm.exclusions &&
45
- JSON.parse(dbm.exclusions).map((exclusion: string | number) => exclusion.toString())) ||
46
- [],
37
+ exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
47
38
  data: dbm.data ? JSON.parse(dbm.data) : null,
48
39
  }),
49
40
  },
package/src/types.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib'
2
2
  import type { AnyObject, BaseDBEntity, IsoDate } from '@naturalcycles/js-lib/types'
3
3
 
4
+ export const AbbaErrorCode = {
5
+ ExperimentNotFound: 'abba/experimentNotFound',
6
+ ExperimentDeletionTooSoon: 'abba/experimentDeletionTooSoon',
7
+ SegmentationDataRequired: 'abba/segmentationDataRequired',
8
+ InvalidBucketRatio: 'abba/invalidBucketRatio',
9
+ BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
10
+ } as const
11
+
4
12
  export interface AbbaConfig {
5
13
  db: CommonDB
6
14
  }
package/src/util.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { localDate } from '@naturalcycles/js-lib/datetime'
2
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js'
2
3
  import type { Unsaved } from '@naturalcycles/js-lib/types'
3
4
  import { satisfies } from 'semver'
4
5
  import type {
@@ -13,7 +14,7 @@ import type {
13
14
  UserAssignment,
14
15
  UserExperiment,
15
16
  } from './types.js'
16
- import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
17
+ import { AbbaErrorCode, AssignmentStatus, SegmentationRuleOperator } from './types.js'
17
18
 
18
19
  /**
19
20
  * Generate a new assignment for a given user.
@@ -78,9 +79,9 @@ export function determineBucket(buckets: Bucket[]): Bucket {
78
79
  }
79
80
  })
80
81
 
81
- if (!bucket) {
82
- throw new Error('Could not detetermine bucket from ratios')
83
- }
82
+ _assert(bucket, 'Could not determine bucket from ratios', {
83
+ code: AbbaErrorCode.BucketDeterminationFailed,
84
+ })
84
85
 
85
86
  return bucket
86
87
  }
@@ -90,9 +91,11 @@ export function determineBucket(buckets: Bucket[]): Bucket {
90
91
  */
91
92
  export function validateTotalBucketRatio(buckets: (Unsaved<Bucket> | BucketInput)[]): void {
92
93
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
93
- if (bucketSum !== 100) {
94
- throw new Error('Total bucket ratio must be 100 before you can activate an experiment')
95
- }
94
+ _assert(
95
+ bucketSum === 100,
96
+ 'Total bucket ratio must be 100 before you can activate an experiment',
97
+ { code: AbbaErrorCode.InvalidBucketRatio },
98
+ )
96
99
  }
97
100
 
98
101
  /**
@@ -124,12 +127,15 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
124
127
  return !keyValue
125
128
  },
126
129
  [SegmentationRuleOperator.EqualsText](keyValue, ruleValue) {
130
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
127
131
  return keyValue?.toString() === ruleValue.toString()
128
132
  },
129
133
  [SegmentationRuleOperator.NotEqualsText](keyValue, ruleValue) {
134
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
130
135
  return keyValue?.toString() !== ruleValue.toString()
131
136
  },
132
137
  [SegmentationRuleOperator.Semver](keyValue, ruleValue) {
138
+ // oxlint-disable-next-line typescript/no-unnecessary-type-conversion
133
139
  return satisfies(keyValue?.toString() || '', ruleValue.toString())
134
140
  },
135
141
  [SegmentationRuleOperator.Regex](keyValue, ruleValue) {