@naturalcycles/abba 2.6.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/abba.d.ts CHANGED
@@ -1,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,12 @@ import { canGenerateNewAssignments, generateUserAssignmentData, getUserExclusion
15
15
  */
16
16
  const CACHE_TTL = 600_000;
17
17
  export class Abba {
18
+ cfg;
19
+ experimentDao = experimentDao(this.cfg.db);
20
+ bucketDao = bucketDao(this.cfg.db);
21
+ userAssignmentDao = userAssignmentDao(this.cfg.db);
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);
23
24
  }
24
25
  /**
25
26
  * Returns all experiments.
@@ -68,7 +69,7 @@ export class Abba {
68
69
  async mergeAssignmentsForUserIds(fromUserId, intoUserId) {
69
70
  const fromAssignments = await this.userAssignmentDao.getBy('userId', fromUserId);
70
71
  const existingIntoAssignments = await this.userAssignmentDao.getBy('userId', intoUserId);
71
- await pMap(fromAssignments, async (from) => {
72
+ await pMap(fromAssignments, async from => {
72
73
  if (!existingIntoAssignments.some(into => into.experimentId === from.experimentId)) {
73
74
  await this.userAssignmentDao.patch(from, { userId: intoUserId });
74
75
  }
@@ -78,10 +79,16 @@ export class Abba {
78
79
  * Creates a new experiment.
79
80
  * Cold method.
80
81
  */
81
- async createExperiment(experiment, buckets) {
82
- if (experiment.status === AssignmentStatus.Active) {
82
+ async createExperiment(input, buckets) {
83
+ if (input.status === AssignmentStatus.Active) {
83
84
  validateTotalBucketRatio(buckets);
84
85
  }
86
+ const experiment = {
87
+ ...input,
88
+ deleted: false,
89
+ description: input.description ?? null,
90
+ rules: input.rules ?? [],
91
+ };
85
92
  const created = await this.experimentDao.save(experiment);
86
93
  const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id })));
87
94
  await this.updateExclusions(created.id, created.exclusions);
@@ -99,7 +106,7 @@ export class Abba {
99
106
  validateTotalBucketRatio(buckets);
100
107
  }
101
108
  const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
102
- const updatedBuckets = await pMap(buckets, async (bucket) => {
109
+ const updatedBuckets = await pMap(buckets, async bucket => {
103
110
  return await this.bucketDao.save({ ...bucket, experimentId: updatedExperiment.id }, { saveMethod: bucket.id ? 'update' : undefined });
104
111
  });
105
112
  await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
@@ -211,7 +218,7 @@ export class Abba {
211
218
  */
212
219
  async getAllExistingUserAssignments(userId) {
213
220
  const assignments = await this.userAssignmentDao.getBy('userId', userId);
214
- return await pMap(assignments, async (assignment) => {
221
+ return await pMap(assignments, async assignment => {
215
222
  const experiment = await this.experimentDao.requireById(assignment.experimentId);
216
223
  const bucket = await this.bucketDao.getById(assignment.bucketId);
217
224
  return {
@@ -278,7 +285,7 @@ export class Abba {
278
285
  async getExperimentAssignmentStatistics(experimentId) {
279
286
  const totalAssignments = await this.userAssignmentDao.getCountByExperimentId(experimentId);
280
287
  const buckets = await this.bucketDao.getByExperimentId(experimentId);
281
- const bucketAssignments = await pMap(buckets, async (bucket) => {
288
+ const bucketAssignments = await pMap(buckets, async bucket => {
282
289
  const totalAssignments = await this.userAssignmentDao.getCountByBucketId(bucket.id);
283
290
  return {
284
291
  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;
@@ -88,7 +107,9 @@ export declare enum SegmentationRuleOperator {
88
107
  NotEqualsText = "notEqualsText",
89
108
  Semver = "semver",
90
109
  Regex = "regex",
91
- Boolean = "boolean"
110
+ Boolean = "boolean",
111
+ GreaterThan = "greaterThan",
112
+ LessThan = "lessThan"
92
113
  }
93
114
  export type SegmentationRuleFn = (segmentationProp: string | boolean | number | null | undefined, ruleValue: SegmentationRule['value']) => boolean;
94
115
  export interface ExperimentAssignmentStatistics {
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,6 +23,7 @@ export var SegmentationRuleOperator;
21
23
  SegmentationRuleOperator["NotEqualsText"] = "notEqualsText";
22
24
  SegmentationRuleOperator["Semver"] = "semver";
23
25
  SegmentationRuleOperator["Regex"] = "regex";
24
- /* eslint-disable id-blacklist*/
25
26
  SegmentationRuleOperator["Boolean"] = "boolean";
27
+ SegmentationRuleOperator["GreaterThan"] = "greaterThan";
28
+ SegmentationRuleOperator["LessThan"] = "lessThan";
26
29
  })(SegmentationRuleOperator || (SegmentationRuleOperator = {}));
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/dist/util.js CHANGED
@@ -111,6 +111,16 @@ export const segmentationRuleMap = {
111
111
  // Anything else cannot be true
112
112
  return keyValue?.toString() !== 'true';
113
113
  },
114
+ [SegmentationRuleOperator.GreaterThan](keyValue, ruleValue) {
115
+ if (keyValue === null || keyValue === undefined)
116
+ return false;
117
+ return keyValue > ruleValue;
118
+ },
119
+ [SegmentationRuleOperator.LessThan](keyValue, ruleValue) {
120
+ if (keyValue === null || keyValue === undefined)
121
+ return false;
122
+ return keyValue < ruleValue;
123
+ },
114
124
  };
115
125
  /**
116
126
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
3
  "type": "module",
4
- "version": "2.6.0",
4
+ "version": "2.7.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/semver": "^7",
14
- "@naturalcycles/dev-lib": "19.11.0"
14
+ "@typescript/native-preview": "7.0.0-dev.20260301.1",
15
+ "@naturalcycles/dev-lib": "18.4.2"
15
16
  },
16
17
  "exports": {
17
18
  ".": "./dist/index.js"
@@ -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,
@@ -117,13 +120,20 @@ export class Abba {
117
120
  * Cold method.
118
121
  */
119
122
  async createExperiment(
120
- experiment: Experiment,
121
- buckets: Bucket[],
123
+ input: ExperimentInput,
124
+ buckets: BucketInput[],
122
125
  ): Promise<ExperimentWithBuckets> {
123
- if (experiment.status === AssignmentStatus.Active) {
126
+ if (input.status === AssignmentStatus.Active) {
124
127
  validateTotalBucketRatio(buckets)
125
128
  }
126
129
 
130
+ const experiment = {
131
+ ...input,
132
+ deleted: false,
133
+ description: input.description ?? null,
134
+ rules: input.rules ?? [],
135
+ } satisfies Unsaved<Experiment>
136
+
127
137
  const created = await this.experimentDao.save(experiment)
128
138
  const createdbuckets = await this.bucketDao.saveBatch(
129
139
  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,8 +121,9 @@ export enum SegmentationRuleOperator {
100
121
  NotEqualsText = 'notEqualsText',
101
122
  Semver = 'semver',
102
123
  Regex = 'regex',
103
- /* eslint-disable id-blacklist*/
104
124
  Boolean = 'boolean',
125
+ GreaterThan = 'greaterThan',
126
+ LessThan = 'lessThan',
105
127
  }
106
128
 
107
129
  export type SegmentationRuleFn = (
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')
@@ -140,6 +141,14 @@ export const segmentationRuleMap: Record<SegmentationRuleOperator, SegmentationR
140
141
  // Anything else cannot be true
141
142
  return keyValue?.toString() !== 'true'
142
143
  },
144
+ [SegmentationRuleOperator.GreaterThan](keyValue, ruleValue) {
145
+ if (keyValue === null || keyValue === undefined) return false
146
+ return keyValue > ruleValue
147
+ },
148
+ [SegmentationRuleOperator.LessThan](keyValue, ruleValue) {
149
+ if (keyValue === null || keyValue === undefined) return false
150
+ return keyValue < ruleValue
151
+ },
143
152
  }
144
153
 
145
154
  /**
@@ -1,54 +0,0 @@
1
- -- CreateTable
2
- CREATE TABLE IF NOT EXISTS `Bucket` (
3
- `id` VARCHAR(50) NOT NULL,
4
- `experimentId` VARCHAR(50) NOT NULL,
5
- `key` VARCHAR(10) NOT NULL,
6
- `ratio` INTEGER NOT NULL,
7
- `data` JSON NULL,
8
- `created` INT NOT NULL,
9
- `updated` INT NOT NULL,
10
-
11
- PRIMARY KEY (`id`)
12
- ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
13
-
14
- -- CreateTable
15
- CREATE TABLE IF NOT EXISTS `Experiment` (
16
- `id` VARCHAR(50) NOT NULL,
17
- `key` VARCHAR(50) NOT NULL,
18
- `status` INTEGER NOT NULL,
19
- `sampling` INTEGER NOT NULL,
20
- `description` VARCHAR(240) NULL,
21
- `startDateIncl` DATE NOT NULL,
22
- `endDateExcl` DATE NOT NULL,
23
- `created` INT NOT NULL,
24
- `updated` INT NOT NULL,
25
- `rules` JSON NULL,
26
- `exclusions` JSON NULL,
27
- `data` JSON NULL,
28
- `deleted` BOOLEAN NOT NULL DEFAULT FALSE,
29
-
30
- PRIMARY KEY (`id`),
31
- UNIQUE INDEX `key_unique` (`key`)
32
- ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
33
-
34
- -- CreateTable
35
- CREATE TABLE IF NOT EXISTS `UserAssignment` (
36
- `id` VARCHAR(50) NOT NULL,
37
- `userId` VARCHAR(191) NOT NULL,
38
- `experimentId` VARCHAR(50) NOT NULL,
39
- `bucketId` VARCHAR(50) NULL,
40
- `created` INT NOT NULL,
41
- `updated` INT NOT NULL,
42
-
43
- UNIQUE INDEX `UserAssignment_userId_experimentId_key`(`userId`, `experimentId`),
44
- PRIMARY KEY (`id`)
45
- ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
46
-
47
- -- AddForeignKey
48
- ALTER TABLE `Bucket` ADD CONSTRAINT `Bucket_experimentId_fkey` FOREIGN KEY (`experimentId`) REFERENCES `Experiment`(`id`);
49
-
50
- -- AddForeignKey
51
- ALTER TABLE `UserAssignment` ADD CONSTRAINT `UserAssignment_bucketId_fkey` FOREIGN KEY (`bucketId`) REFERENCES `Bucket`(`id`);
52
-
53
- -- AddForeignKey
54
- ALTER TABLE `UserAssignment` ADD CONSTRAINT `UserAssignment_experimentId_fkey` FOREIGN KEY (`experimentId`) REFERENCES `Experiment`(`id`);