@naturalcycles/abba 2.10.0 → 2.12.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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Unsaved } from '@naturalcycles/js-lib/types';
2
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';
3
+ import type { AbbaConfig, Bucket, BucketInput, DecoratedUserAssignment, Experiment, ExperimentAssignmentStatistics, ExperimentInput, ExperimentWithBuckets, ManualUserAssignmentInput, SegmentationData, UserExperiment } from './types.js';
4
4
  export declare class Abba {
5
5
  cfg: AbbaConfig;
6
6
  private experimentDao;
@@ -52,6 +52,20 @@ export declare class Abba {
52
52
  * @param segmentationData Required if existingOnly is false
53
53
  */
54
54
  getUserAssignment(experimentKey: string, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<DecoratedUserAssignment | null>;
55
+ /**
56
+ * Manually assigns users to specific buckets, overwriting any existing assignments.
57
+ * Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
58
+ * QA / internal testing where deterministic bucket placement is required.
59
+ *
60
+ * An empty input returns an empty array. If the input contains duplicate
61
+ * (userId, experimentKey) pairs, the last occurrence wins.
62
+ *
63
+ * Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
64
+ * first invalid row; no rows are written if validation fails.
65
+ *
66
+ * Cold method.
67
+ */
68
+ saveManualUserAssignments(inputs: readonly ManualUserAssignmentInput[]): Promise<DecoratedUserAssignment[]>;
55
69
  /**
56
70
  * Get all existing user assignments.
57
71
  * Hot method.
@@ -65,6 +79,13 @@ export declare class Abba {
65
79
  * Hot method.
66
80
  */
67
81
  generateUserAssignments(userId: string, segmentationData: SegmentationData, existingOnly?: boolean): Promise<DecoratedUserAssignment[]>;
82
+ /**
83
+ * Returns all user IDs that are assigned to any of the given buckets in the given experiment.
84
+ *
85
+ * @param experimentKey - The `key` field of the experiment (not the DB id).
86
+ * @param bucketKeys - The `key` fields of the buckets to include.
87
+ */
88
+ getUserIdsInBuckets(experimentKey: string, bucketKeys: readonly string[]): Promise<Set<string>>;
68
89
  /**
69
90
  * Get assignment statistics for an experiment.
70
91
  * Cold method.
package/dist/abba.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { __decorate } from "tslib";
2
- import { _shuffle } from '@naturalcycles/js-lib/array/array.util.js';
2
+ import { _mapBy, _shuffle, _uniq } from '@naturalcycles/js-lib/array/array.util.js';
3
3
  import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js';
4
4
  import { _Memo } from '@naturalcycles/js-lib/decorators/memo.decorator.js';
5
5
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
@@ -223,6 +223,61 @@ export class Abba {
223
223
  bucketData: bucket?.data || null,
224
224
  };
225
225
  }
226
+ /**
227
+ * Manually assigns users to specific buckets, overwriting any existing assignments.
228
+ * Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
229
+ * QA / internal testing where deterministic bucket placement is required.
230
+ *
231
+ * An empty input returns an empty array. If the input contains duplicate
232
+ * (userId, experimentKey) pairs, the last occurrence wins.
233
+ *
234
+ * Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
235
+ * first invalid row; no rows are written if validation fails.
236
+ *
237
+ * Cold method.
238
+ */
239
+ async saveManualUserAssignments(inputs) {
240
+ if (!inputs.length)
241
+ return [];
242
+ const dedupedInputs = Array.from(_mapBy(inputs, input => `${input.userId}|${input.experimentKey}`).values());
243
+ const experimentKeys = _uniq(dedupedInputs.map(input => input.experimentKey));
244
+ const experiments = await pMap(experimentKeys, async (experimentKey) => {
245
+ const experiment = await this.experimentDao.getByKey(experimentKey);
246
+ _assert(experiment && !experiment.deleted, `Experiment does not exist: ${experimentKey}`, {
247
+ code: AbbaErrorCode.ExperimentNotFound,
248
+ });
249
+ const buckets = await this.bucketDao.getByExperimentId(experiment.id);
250
+ return { ...experiment, buckets };
251
+ });
252
+ const experimentByKey = _mapBy(experiments, experiment => experiment.key);
253
+ const resolvedInputs = dedupedInputs.map(input => {
254
+ const experiment = experimentByKey.get(input.experimentKey);
255
+ const bucket = experiment.buckets.find(bucket => bucket.key === input.bucketKey);
256
+ _assert(bucket, `Bucket does not exist on experiment ${input.experimentKey}: ${input.bucketKey}`, { code: AbbaErrorCode.BucketNotFound });
257
+ return { input, experiment, bucket };
258
+ });
259
+ const userIds = _uniq(dedupedInputs.map(input => input.userId));
260
+ const experimentIds = experiments.map(experiment => experiment.id);
261
+ const existingAssignments = await this.userAssignmentDao.getByUserIdsAndExperimentIds(userIds, experimentIds);
262
+ const existingByKey = _mapBy(existingAssignments, assignment => `${assignment.userId}|${assignment.experimentId}`);
263
+ const toSave = resolvedInputs.map(({ input, experiment, bucket }) => ({
264
+ ...existingByKey.get(`${input.userId}|${experiment.id}`),
265
+ userId: input.userId,
266
+ experimentId: experiment.id,
267
+ bucketId: bucket.id,
268
+ }));
269
+ const savedAssignments = await this.userAssignmentDao.saveBatch(toSave);
270
+ return savedAssignments.map((savedAssignment, i) => {
271
+ const resolvedInput = resolvedInputs[i];
272
+ return {
273
+ ...savedAssignment,
274
+ experimentKey: resolvedInput.experiment.key,
275
+ experimentData: resolvedInput.experiment.data,
276
+ bucketKey: resolvedInput.bucket.key,
277
+ bucketData: resolvedInput.bucket.data,
278
+ };
279
+ });
280
+ }
226
281
  /**
227
282
  * Get all existing user assignments.
228
283
  * Hot method.
@@ -291,6 +346,26 @@ export class Abba {
291
346
  await this.userAssignmentDao.saveBatch(newAssignments);
292
347
  return assignments;
293
348
  }
349
+ /**
350
+ * Returns all user IDs that are assigned to any of the given buckets in the given experiment.
351
+ *
352
+ * @param experimentKey - The `key` field of the experiment (not the DB id).
353
+ * @param bucketKeys - The `key` fields of the buckets to include.
354
+ */
355
+ async getUserIdsInBuckets(experimentKey, bucketKeys) {
356
+ if (!bucketKeys.length)
357
+ return new Set();
358
+ const experiment = await this.experimentDao.getByKey(experimentKey);
359
+ if (!experiment)
360
+ return new Set();
361
+ const bucketKeySet = new Set(bucketKeys);
362
+ const experimentBuckets = await this.bucketDao.getByExperimentId(experiment.id);
363
+ const matchedBucketIds = experimentBuckets.filter(b => bucketKeySet.has(b.key)).map(b => b.id);
364
+ if (!matchedBucketIds.length)
365
+ return new Set();
366
+ const userIds = await this.userAssignmentDao.getUserIdsByBucketIds(matchedBucketIds);
367
+ return new Set(userIds);
368
+ }
294
369
  /**
295
370
  * Get assignment statistics for an experiment.
296
371
  * Cold method.
@@ -4,8 +4,15 @@ import type { UserAssignment } from '../types.js';
4
4
  export declare class UserAssignmentDao extends CommonDao<UserAssignment> {
5
5
  getUserAssignmentByExperimentId(userId: string, experimentId: string): Promise<UserAssignment | null>;
6
6
  getUserAssigmentsByExperimentIds(userId: string, experimentIds: string[]): Promise<UserAssignment[]>;
7
+ /**
8
+ * Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
9
+ * is in `experimentIds`. This is the cross-product, not paired lookup: callers must
10
+ * filter the result by the specific (userId, experimentId) pairs they care about.
11
+ */
12
+ getByUserIdsAndExperimentIds(userIds: string[], experimentIds: string[]): Promise<UserAssignment[]>;
7
13
  deleteByExperimentId(experimentId: string): Promise<void>;
8
14
  getCountByExperimentId(experimentId: string): Promise<number>;
9
15
  getCountByBucketId(bucketId: string): Promise<number>;
16
+ getUserIdsByBucketIds(bucketIds: string[]): Promise<string[]>;
10
17
  }
11
18
  export declare function userAssignmentDao(db: CommonDB): UserAssignmentDao;
@@ -1,4 +1,5 @@
1
1
  import { CommonDao } from '@naturalcycles/db-lib/dao';
2
+ import { _uniq } from '@naturalcycles/js-lib/array';
2
3
  export class UserAssignmentDao extends CommonDao {
3
4
  async getUserAssignmentByExperimentId(userId, experimentId) {
4
5
  const query = this.query().filterEq('userId', userId).filterEq('experimentId', experimentId);
@@ -9,6 +10,17 @@ export class UserAssignmentDao extends CommonDao {
9
10
  const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds);
10
11
  return await this.runQuery(query);
11
12
  }
13
+ /**
14
+ * Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
15
+ * is in `experimentIds`. This is the cross-product, not paired lookup: callers must
16
+ * filter the result by the specific (userId, experimentId) pairs they care about.
17
+ */
18
+ async getByUserIdsAndExperimentIds(userIds, experimentIds) {
19
+ if (!userIds.length || !experimentIds.length)
20
+ return [];
21
+ const query = this.query().filterIn('userId', userIds).filterIn('experimentId', experimentIds);
22
+ return await this.runQuery(query);
23
+ }
12
24
  async deleteByExperimentId(experimentId) {
13
25
  await this.query().filterEq('experimentId', experimentId).deleteByQuery();
14
26
  }
@@ -18,6 +30,16 @@ export class UserAssignmentDao extends CommonDao {
18
30
  async getCountByBucketId(bucketId) {
19
31
  return await this.query().filterEq('bucketId', bucketId).runQueryCount();
20
32
  }
33
+ async getUserIdsByBucketIds(bucketIds) {
34
+ if (!bucketIds.length)
35
+ return [];
36
+ const userIds = await this.query()
37
+ .filterIn('bucketId', bucketIds)
38
+ .select(['userId'])
39
+ .distinct()
40
+ .runQuerySingleColumn();
41
+ return _uniq(userIds);
42
+ }
21
43
  }
22
44
  export function userAssignmentDao(db) {
23
45
  return new UserAssignmentDao({
package/dist/types.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare const AbbaErrorCode: {
6
6
  readonly SegmentationDataRequired: 'abba/segmentationDataRequired';
7
7
  readonly InvalidBucketRatio: 'abba/invalidBucketRatio';
8
8
  readonly BucketDeterminationFailed: 'abba/bucketDeterminationFailed';
9
+ readonly BucketNotFound: 'abba/bucketNotFound';
9
10
  };
10
11
  export interface AbbaConfig {
11
12
  db: CommonDB;
@@ -81,6 +82,11 @@ export type UserAssignment = BaseDBEntity & {
81
82
  experimentId: string;
82
83
  bucketId: string | null;
83
84
  };
85
+ export interface ManualUserAssignmentInput {
86
+ userId: string;
87
+ experimentKey: string;
88
+ bucketKey: string;
89
+ }
84
90
  export type DecoratedUserAssignment = UserAssignment & {
85
91
  experimentKey: Experiment['key'];
86
92
  experimentData: Experiment['data'];
package/dist/types.js CHANGED
@@ -4,6 +4,7 @@ export const AbbaErrorCode = {
4
4
  SegmentationDataRequired: 'abba/segmentationDataRequired',
5
5
  InvalidBucketRatio: 'abba/invalidBucketRatio',
6
6
  BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
7
+ BucketNotFound: 'abba/bucketNotFound',
7
8
  };
8
9
  export var AssignmentStatus;
9
10
  (function (AssignmentStatus) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
3
  "type": "module",
4
- "version": "2.10.0",
4
+ "version": "2.12.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.20260415.1",
14
+ "@typescript/native-preview": "beta",
15
15
  "@naturalcycles/dev-lib": "18.4.2"
16
16
  },
17
17
  "exports": {
package/src/abba.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _shuffle } from '@naturalcycles/js-lib/array/array.util.js'
1
+ import { _mapBy, _shuffle, _uniq } from '@naturalcycles/js-lib/array/array.util.js'
2
2
  import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js'
3
3
  import { _Memo } from '@naturalcycles/js-lib/decorators/memo.decorator.js'
4
4
  import { _assert } from '@naturalcycles/js-lib/error/assert.js'
@@ -19,6 +19,7 @@ import type {
19
19
  ExperimentAssignmentStatistics,
20
20
  ExperimentInput,
21
21
  ExperimentWithBuckets,
22
+ ManualUserAssignmentInput,
22
23
  SegmentationData,
23
24
  UserAssignment,
24
25
  UserExperiment,
@@ -328,6 +329,84 @@ export class Abba {
328
329
  }
329
330
  }
330
331
 
332
+ /**
333
+ * Manually assigns users to specific buckets, overwriting any existing assignments.
334
+ * Bypasses sampling, segmentation, exclusions, and experiment status. Intended for
335
+ * QA / internal testing where deterministic bucket placement is required.
336
+ *
337
+ * An empty input returns an empty array. If the input contains duplicate
338
+ * (userId, experimentKey) pairs, the last occurrence wins.
339
+ *
340
+ * Throws AbbaErrorCode.ExperimentNotFound or AbbaErrorCode.BucketNotFound on the
341
+ * first invalid row; no rows are written if validation fails.
342
+ *
343
+ * Cold method.
344
+ */
345
+ async saveManualUserAssignments(
346
+ inputs: readonly ManualUserAssignmentInput[],
347
+ ): Promise<DecoratedUserAssignment[]> {
348
+ if (!inputs.length) return []
349
+
350
+ const dedupedInputs = Array.from(
351
+ _mapBy(inputs, input => `${input.userId}|${input.experimentKey}`).values(),
352
+ )
353
+
354
+ const experimentKeys = _uniq(dedupedInputs.map(input => input.experimentKey))
355
+ const experiments = await pMap(experimentKeys, async experimentKey => {
356
+ const experiment = await this.experimentDao.getByKey(experimentKey)
357
+ _assert(experiment && !experiment.deleted, `Experiment does not exist: ${experimentKey}`, {
358
+ code: AbbaErrorCode.ExperimentNotFound,
359
+ })
360
+ const buckets = await this.bucketDao.getByExperimentId(experiment.id)
361
+ return { ...experiment, buckets }
362
+ })
363
+ const experimentByKey = _mapBy(experiments, experiment => experiment.key)
364
+
365
+ const resolvedInputs = dedupedInputs.map(input => {
366
+ const experiment = experimentByKey.get(input.experimentKey)!
367
+ const bucket = experiment.buckets.find(bucket => bucket.key === input.bucketKey)
368
+ _assert(
369
+ bucket,
370
+ `Bucket does not exist on experiment ${input.experimentKey}: ${input.bucketKey}`,
371
+ { code: AbbaErrorCode.BucketNotFound },
372
+ )
373
+ return { input, experiment, bucket }
374
+ })
375
+
376
+ const userIds = _uniq(dedupedInputs.map(input => input.userId))
377
+ const experimentIds = experiments.map(experiment => experiment.id)
378
+ const existingAssignments = await this.userAssignmentDao.getByUserIdsAndExperimentIds(
379
+ userIds,
380
+ experimentIds,
381
+ )
382
+ const existingByKey = _mapBy(
383
+ existingAssignments,
384
+ assignment => `${assignment.userId}|${assignment.experimentId}`,
385
+ )
386
+
387
+ const toSave: Unsaved<UserAssignment>[] = resolvedInputs.map(
388
+ ({ input, experiment, bucket }) => ({
389
+ ...existingByKey.get(`${input.userId}|${experiment.id}`),
390
+ userId: input.userId,
391
+ experimentId: experiment.id,
392
+ bucketId: bucket.id,
393
+ }),
394
+ )
395
+
396
+ const savedAssignments = await this.userAssignmentDao.saveBatch(toSave)
397
+
398
+ return savedAssignments.map((savedAssignment, i) => {
399
+ const resolvedInput = resolvedInputs[i]!
400
+ return {
401
+ ...savedAssignment,
402
+ experimentKey: resolvedInput.experiment.key,
403
+ experimentData: resolvedInput.experiment.data,
404
+ bucketKey: resolvedInput.bucket.key,
405
+ bucketData: resolvedInput.bucket.data,
406
+ }
407
+ })
408
+ }
409
+
331
410
  /**
332
411
  * Get all existing user assignments.
333
412
  * Hot method.
@@ -411,6 +490,31 @@ export class Abba {
411
490
  return assignments
412
491
  }
413
492
 
493
+ /**
494
+ * Returns all user IDs that are assigned to any of the given buckets in the given experiment.
495
+ *
496
+ * @param experimentKey - The `key` field of the experiment (not the DB id).
497
+ * @param bucketKeys - The `key` fields of the buckets to include.
498
+ */
499
+ async getUserIdsInBuckets(
500
+ experimentKey: string,
501
+ bucketKeys: readonly string[],
502
+ ): Promise<Set<string>> {
503
+ if (!bucketKeys.length) return new Set()
504
+
505
+ const experiment = await this.experimentDao.getByKey(experimentKey)
506
+ if (!experiment) return new Set()
507
+
508
+ const bucketKeySet = new Set(bucketKeys)
509
+ const experimentBuckets = await this.bucketDao.getByExperimentId(experiment.id)
510
+ const matchedBucketIds = experimentBuckets.filter(b => bucketKeySet.has(b.key)).map(b => b.id)
511
+
512
+ if (!matchedBucketIds.length) return new Set()
513
+
514
+ const userIds = await this.userAssignmentDao.getUserIdsByBucketIds(matchedBucketIds)
515
+ return new Set(userIds)
516
+ }
517
+
414
518
  /**
415
519
  * Get assignment statistics for an experiment.
416
520
  * Cold method.
@@ -1,5 +1,6 @@
1
1
  import type { CommonDB } from '@naturalcycles/db-lib'
2
2
  import { CommonDao } from '@naturalcycles/db-lib/dao'
3
+ import { _uniq } from '@naturalcycles/js-lib/array'
3
4
  import type { UserAssignment } from '../types.js'
4
5
 
5
6
  export class UserAssignmentDao extends CommonDao<UserAssignment> {
@@ -20,6 +21,20 @@ export class UserAssignmentDao extends CommonDao<UserAssignment> {
20
21
  return await this.runQuery(query)
21
22
  }
22
23
 
24
+ /**
25
+ * Returns every UserAssignment whose `userId` is in `userIds` AND whose `experimentId`
26
+ * is in `experimentIds`. This is the cross-product, not paired lookup: callers must
27
+ * filter the result by the specific (userId, experimentId) pairs they care about.
28
+ */
29
+ async getByUserIdsAndExperimentIds(
30
+ userIds: string[],
31
+ experimentIds: string[],
32
+ ): Promise<UserAssignment[]> {
33
+ if (!userIds.length || !experimentIds.length) return []
34
+ const query = this.query().filterIn('userId', userIds).filterIn('experimentId', experimentIds)
35
+ return await this.runQuery(query)
36
+ }
37
+
23
38
  async deleteByExperimentId(experimentId: string): Promise<void> {
24
39
  await this.query().filterEq('experimentId', experimentId).deleteByQuery()
25
40
  }
@@ -31,6 +46,18 @@ export class UserAssignmentDao extends CommonDao<UserAssignment> {
31
46
  async getCountByBucketId(bucketId: string): Promise<number> {
32
47
  return await this.query().filterEq('bucketId', bucketId).runQueryCount()
33
48
  }
49
+
50
+ async getUserIdsByBucketIds(bucketIds: string[]): Promise<string[]> {
51
+ if (!bucketIds.length) return []
52
+
53
+ const userIds = await this.query()
54
+ .filterIn('bucketId', bucketIds)
55
+ .select(['userId'])
56
+ .distinct()
57
+ .runQuerySingleColumn<string>()
58
+
59
+ return _uniq(userIds)
60
+ }
34
61
  }
35
62
 
36
63
  export function userAssignmentDao(db: CommonDB): UserAssignmentDao {
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ export const AbbaErrorCode = {
7
7
  SegmentationDataRequired: 'abba/segmentationDataRequired',
8
8
  InvalidBucketRatio: 'abba/invalidBucketRatio',
9
9
  BucketDeterminationFailed: 'abba/bucketDeterminationFailed',
10
+ BucketNotFound: 'abba/bucketNotFound',
10
11
  } as const
11
12
 
12
13
  export interface AbbaConfig {
@@ -92,6 +93,12 @@ export type UserAssignment = BaseDBEntity & {
92
93
  bucketId: string | null
93
94
  }
94
95
 
96
+ export interface ManualUserAssignmentInput {
97
+ userId: string
98
+ experimentKey: string
99
+ bucketKey: string
100
+ }
101
+
95
102
  export type DecoratedUserAssignment = UserAssignment & {
96
103
  experimentKey: Experiment['key']
97
104
  experimentData: Experiment['data']