@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 +3 -6
- package/dist/abba.js +19 -9
- package/dist/dao/experiment.dao.js +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.js +4 -3
- package/dist/util.d.ts +2 -2
- package/package.json +3 -2
- package/src/abba.ts +23 -9
- package/src/dao/experiment.dao.ts +1 -1
- package/src/types.ts +21 -1
- package/src/util.ts +2 -1
package/dist/abba.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Unsaved } from '@naturalcycles/js-lib/types';
|
|
2
|
-
import {
|
|
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(
|
|
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(
|
|
21
|
-
this.bucketDao = bucketDao(
|
|
22
|
-
this.userAssignmentDao = userAssignmentDao(
|
|
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
|
|
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(
|
|
82
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": ">=
|
|
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 {
|
|
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
|
|
38
|
-
private bucketDao
|
|
39
|
-
private userAssignmentDao
|
|
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
|
-
|
|
121
|
-
buckets:
|
|
127
|
+
input: ExperimentInput,
|
|
128
|
+
buckets: BucketInput[],
|
|
122
129
|
): Promise<ExperimentWithBuckets> {
|
|
123
|
-
if (
|
|
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')
|