@naturalcycles/abba 2.7.0 → 2.7.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,6 +1,6 @@
1
1
  import type { Unsaved } from '@naturalcycles/js-lib/types';
2
- import { type GetAllExperimentsOpts } from './dao/experiment.dao.js';
3
- import type { AbbaConfig, Bucket, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, SegmentationData, UserExperiment } from './types.js';
2
+ import type { GetAllExperimentsOpts } from './dao/experiment.dao.js';
3
+ import type { AbbaConfig, Bucket, BucketInput, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentInput, ExperimentWithBuckets, SegmentationData, UserExperiment } from './types.js';
4
4
  export declare class Abba {
5
5
  cfg: AbbaConfig;
6
6
  private experimentDao;
@@ -26,15 +26,12 @@ export declare class Abba {
26
26
  * Creates a new experiment.
27
27
  * Cold method.
28
28
  */
29
- createExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
29
+ createExperiment(input: ExperimentInput, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
30
30
  /**
31
31
  * Update experiment information, will also validate the buckets' ratio if experiment.active is true
32
32
  * Cold method.
33
33
  */
34
34
  saveExperiment(experiment: Experiment, buckets: Unsaved<Bucket>[]): Promise<ExperimentWithBuckets>;
35
- /**
36
- * Ensures that mutual exclusions are maintained
37
- */
38
35
  private updateExclusions;
39
36
  softDeleteExperiment(experimentId: string): Promise<void>;
40
37
  /**
package/dist/abba.js CHANGED
@@ -15,11 +15,15 @@ import { canGenerateNewAssignments, generateUserAssignmentData, getUserExclusion
15
15
  */
16
16
  const CACHE_TTL = 600_000;
17
17
  export class Abba {
18
+ cfg;
19
+ experimentDao;
20
+ bucketDao;
21
+ userAssignmentDao;
18
22
  constructor(cfg) {
19
23
  this.cfg = cfg;
20
- this.experimentDao = experimentDao(this.cfg.db);
21
- this.bucketDao = bucketDao(this.cfg.db);
22
- this.userAssignmentDao = userAssignmentDao(this.cfg.db);
24
+ this.experimentDao = experimentDao(cfg.db);
25
+ this.bucketDao = bucketDao(cfg.db);
26
+ this.userAssignmentDao = userAssignmentDao(cfg.db);
23
27
  }
24
28
  /**
25
29
  * Returns all experiments.
@@ -68,7 +72,7 @@ export class Abba {
68
72
  async mergeAssignmentsForUserIds(fromUserId, intoUserId) {
69
73
  const fromAssignments = await this.userAssignmentDao.getBy('userId', fromUserId);
70
74
  const existingIntoAssignments = await this.userAssignmentDao.getBy('userId', intoUserId);
71
- await pMap(fromAssignments, async (from) => {
75
+ await pMap(fromAssignments, async from => {
72
76
  if (!existingIntoAssignments.some(into => into.experimentId === from.experimentId)) {
73
77
  await this.userAssignmentDao.patch(from, { userId: intoUserId });
74
78
  }
@@ -78,10 +82,16 @@ export class Abba {
78
82
  * Creates a new experiment.
79
83
  * Cold method.
80
84
  */
81
- async createExperiment(experiment, buckets) {
82
- if (experiment.status === AssignmentStatus.Active) {
85
+ async createExperiment(input, buckets) {
86
+ if (input.status === AssignmentStatus.Active) {
83
87
  validateTotalBucketRatio(buckets);
84
88
  }
89
+ const experiment = {
90
+ ...input,
91
+ deleted: false,
92
+ description: input.description ?? null,
93
+ rules: input.rules ?? [],
94
+ };
85
95
  const created = await this.experimentDao.save(experiment);
86
96
  const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id })));
87
97
  await this.updateExclusions(created.id, created.exclusions);
@@ -99,7 +109,7 @@ export class Abba {
99
109
  validateTotalBucketRatio(buckets);
100
110
  }
101
111
  const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
102
- const updatedBuckets = await pMap(buckets, async (bucket) => {
112
+ const updatedBuckets = await pMap(buckets, async bucket => {
103
113
  return await this.bucketDao.save({ ...bucket, experimentId: updatedExperiment.id }, { saveMethod: bucket.id ? 'update' : undefined });
104
114
  });
105
115
  await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
@@ -211,7 +221,7 @@ export class Abba {
211
221
  */
212
222
  async getAllExistingUserAssignments(userId) {
213
223
  const assignments = await this.userAssignmentDao.getBy('userId', userId);
214
- return await pMap(assignments, async (assignment) => {
224
+ return await pMap(assignments, async assignment => {
215
225
  const experiment = await this.experimentDao.requireById(assignment.experimentId);
216
226
  const bucket = await this.bucketDao.getById(assignment.bucketId);
217
227
  return {
@@ -278,7 +288,7 @@ export class Abba {
278
288
  async getExperimentAssignmentStatistics(experimentId) {
279
289
  const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
280
290
  const buckets = await this.bucketDao.getByExperimentId(experimentId);
281
- const bucketAssignments = await pMap(buckets, async (bucket) => {
291
+ const bucketAssignments = await pMap(buckets, async bucket => {
282
292
  const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
283
293
  return {
284
294
  bucketId: bucket.id,
@@ -47,7 +47,7 @@ export function experimentDao(db) {
47
47
  * For simplicity let's not do that by having this function...
48
48
  */
49
49
  function parseMySQLDate(date) {
50
- // @ts-expect-error
50
+ // @ts-expect-error MySQL may return Date instead of string
51
51
  if (date instanceof Date)
52
52
  return localDate(date).toISODate();
53
53
  return date;
package/dist/types.d.ts CHANGED
@@ -39,6 +39,18 @@ export type Experiment = BaseExperiment & {
39
39
  exclusions: string[];
40
40
  data: AnyObject | null;
41
41
  };
42
+ export interface ExperimentInput {
43
+ id?: string;
44
+ key: string;
45
+ status: AssignmentStatus;
46
+ sampling: number;
47
+ description?: string | null;
48
+ rules: SegmentationRule[] | null;
49
+ startDateIncl: IsoDate;
50
+ endDateExcl: IsoDate;
51
+ exclusions: string[];
52
+ data: AnyObject | null;
53
+ }
42
54
  export type ExperimentWithBuckets = Experiment & {
43
55
  buckets: Bucket[];
44
56
  };
@@ -50,6 +62,13 @@ export type BaseBucket = BaseDBEntity & {
50
62
  export type Bucket = BaseBucket & {
51
63
  data: AnyObject | null;
52
64
  };
65
+ export interface BucketInput {
66
+ id?: string;
67
+ key: string;
68
+ ratio: number;
69
+ data: AnyObject | null;
70
+ experimentId?: string;
71
+ }
53
72
  export type UserAssignment = BaseDBEntity & {
54
73
  userId: string;
55
74
  experimentId: string;
package/dist/types.js CHANGED
@@ -1,4 +1,5 @@
1
- export var AssignmentStatus;
1
+ export { AssignmentStatus };
2
+ var AssignmentStatus;
2
3
  (function (AssignmentStatus) {
3
4
  /**
4
5
  * Will return existing assignments and generate new assignments
@@ -13,7 +14,8 @@ export var AssignmentStatus;
13
14
  */
14
15
  AssignmentStatus[AssignmentStatus["Inactive"] = 3] = "Inactive";
15
16
  })(AssignmentStatus || (AssignmentStatus = {}));
16
- export var SegmentationRuleOperator;
17
+ export { SegmentationRuleOperator };
18
+ var SegmentationRuleOperator;
17
19
  (function (SegmentationRuleOperator) {
18
20
  SegmentationRuleOperator["IsSet"] = "isSet";
19
21
  SegmentationRuleOperator["IsNotSet"] = "isNotSet";
@@ -21,7 +23,6 @@ export var SegmentationRuleOperator;
21
23
  SegmentationRuleOperator["NotEqualsText"] = "notEqualsText";
22
24
  SegmentationRuleOperator["Semver"] = "semver";
23
25
  SegmentationRuleOperator["Regex"] = "regex";
24
- /* eslint-disable id-denylist */
25
26
  SegmentationRuleOperator["Boolean"] = "boolean";
26
27
  SegmentationRuleOperator["GreaterThan"] = "greaterThan";
27
28
  SegmentationRuleOperator["LessThan"] = "lessThan";
package/dist/util.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Unsaved } from '@naturalcycles/js-lib/types';
2
- import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment, UserExperiment } from './types.js';
2
+ import type { Bucket, BucketInput, 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.
@@ -24,7 +24,7 @@ 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 function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void;
27
+ export declare function validateTotalBucketRatio(buckets: (Unsaved<Bucket> | BucketInput)[]): void;
28
28
  /**
29
29
  * Validate a users segmentation data against multiple rules. Returns false if any fail
30
30
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
3
  "type": "module",
4
- "version": "2.7.0",
4
+ "version": "2.7.2",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/semver": "^7",
14
+ "@typescript/native-preview": "7.0.0-dev.20260301.1",
14
15
  "@naturalcycles/dev-lib": "18.4.2"
15
16
  },
16
17
  "exports": {
@@ -35,7 +36,7 @@
35
36
  "directory": "packages/abba"
36
37
  },
37
38
  "engines": {
38
- "node": ">=22.12.0"
39
+ "node": ">=24.10.0"
39
40
  },
40
41
  "description": "AB test assignment configuration tool for Node.js",
41
42
  "author": "Natural Cycles Team",
package/src/abba.ts CHANGED
@@ -6,15 +6,18 @@ import { pMap } from '@naturalcycles/js-lib/promise/pMap.js'
6
6
  import type { Unsaved } from '@naturalcycles/js-lib/types'
7
7
  import { LRUMemoCache } from '@naturalcycles/nodejs-lib/lruMemoCache'
8
8
  import { bucketDao } from './dao/bucket.dao.js'
9
- import { experimentDao, type GetAllExperimentsOpts } from './dao/experiment.dao.js'
9
+ import type { GetAllExperimentsOpts } from './dao/experiment.dao.js'
10
+ import { experimentDao } from './dao/experiment.dao.js'
10
11
  import { userAssignmentDao } from './dao/userAssignment.dao.js'
11
12
  import type {
12
13
  AbbaConfig,
13
14
  Bucket,
14
15
  BucketAssignmentStatistics,
16
+ BucketInput,
15
17
  DecoratedUserAssignment,
16
18
  Experiment,
17
19
  ExperimentAssignmentStatistics,
20
+ ExperimentInput,
18
21
  ExperimentWithBuckets,
19
22
  SegmentationData,
20
23
  UserAssignment,
@@ -34,11 +37,15 @@ import {
34
37
  const CACHE_TTL = 600_000
35
38
 
36
39
  export class Abba {
37
- private experimentDao = experimentDao(this.cfg.db)
38
- private bucketDao = bucketDao(this.cfg.db)
39
- private userAssignmentDao = userAssignmentDao(this.cfg.db)
40
-
41
- constructor(public cfg: AbbaConfig) {}
40
+ private experimentDao
41
+ private bucketDao
42
+ private userAssignmentDao
43
+
44
+ constructor(public cfg: AbbaConfig) {
45
+ this.experimentDao = experimentDao(cfg.db)
46
+ this.bucketDao = bucketDao(cfg.db)
47
+ this.userAssignmentDao = userAssignmentDao(cfg.db)
48
+ }
42
49
 
43
50
  /**
44
51
  * Returns all experiments.
@@ -117,13 +124,20 @@ export class Abba {
117
124
  * Cold method.
118
125
  */
119
126
  async createExperiment(
120
- experiment: Experiment,
121
- buckets: Bucket[],
127
+ input: ExperimentInput,
128
+ buckets: BucketInput[],
122
129
  ): Promise<ExperimentWithBuckets> {
123
- if (experiment.status === AssignmentStatus.Active) {
130
+ if (input.status === AssignmentStatus.Active) {
124
131
  validateTotalBucketRatio(buckets)
125
132
  }
126
133
 
134
+ const experiment = {
135
+ ...input,
136
+ deleted: false,
137
+ description: input.description ?? null,
138
+ rules: input.rules ?? [],
139
+ } satisfies Unsaved<Experiment>
140
+
127
141
  const created = await this.experimentDao.save(experiment)
128
142
  const createdbuckets = await this.bucketDao.saveBatch(
129
143
  buckets.map(b => ({ ...b, experimentId: created.id })),
@@ -56,7 +56,7 @@ export function experimentDao(db: CommonDB): ExperimentDao {
56
56
  * For simplicity let's not do that by having this function...
57
57
  */
58
58
  function parseMySQLDate(date: string): IsoDate {
59
- // @ts-expect-error
59
+ // @ts-expect-error MySQL may return Date instead of string
60
60
  if (date instanceof Date) return localDate(date).toISODate()
61
61
  return date as IsoDate
62
62
  }
package/src/types.ts CHANGED
@@ -43,6 +43,19 @@ export type Experiment = BaseExperiment & {
43
43
  data: AnyObject | null
44
44
  }
45
45
 
46
+ export interface ExperimentInput {
47
+ id?: string
48
+ key: string
49
+ status: AssignmentStatus
50
+ sampling: number
51
+ description?: string | null
52
+ rules: SegmentationRule[] | null
53
+ startDateIncl: IsoDate
54
+ endDateExcl: IsoDate
55
+ exclusions: string[]
56
+ data: AnyObject | null
57
+ }
58
+
46
59
  export type ExperimentWithBuckets = Experiment & {
47
60
  buckets: Bucket[]
48
61
  }
@@ -57,6 +70,14 @@ export type Bucket = BaseBucket & {
57
70
  data: AnyObject | null
58
71
  }
59
72
 
73
+ export interface BucketInput {
74
+ id?: string
75
+ key: string
76
+ ratio: number
77
+ data: AnyObject | null
78
+ experimentId?: string
79
+ }
80
+
60
81
  export type UserAssignment = BaseDBEntity & {
61
82
  userId: string
62
83
  experimentId: string
@@ -100,7 +121,6 @@ export enum SegmentationRuleOperator {
100
121
  NotEqualsText = 'notEqualsText',
101
122
  Semver = 'semver',
102
123
  Regex = 'regex',
103
- /* eslint-disable id-denylist */
104
124
  Boolean = 'boolean',
105
125
  GreaterThan = 'greaterThan',
106
126
  LessThan = 'lessThan',
package/src/util.ts CHANGED
@@ -3,6 +3,7 @@ import type { Unsaved } from '@naturalcycles/js-lib/types'
3
3
  import { satisfies } from 'semver'
4
4
  import type {
5
5
  Bucket,
6
+ BucketInput,
6
7
  ExclusionSet,
7
8
  Experiment,
8
9
  ExperimentWithBuckets,
@@ -87,7 +88,7 @@ export function determineBucket(buckets: Bucket[]): Bucket {
87
88
  /**
88
89
  * Validate the total ratio of the buckets equals 100
89
90
  */
90
- export function validateTotalBucketRatio(buckets: Unsaved<Bucket>[]): void {
91
+ export function validateTotalBucketRatio(buckets: (Unsaved<Bucket> | BucketInput)[]): void {
91
92
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
92
93
  if (bucketSum !== 100) {
93
94
  throw new Error('Total bucket ratio must be 100 before you can activate an experiment')