@naturalcycles/abba 2.11.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.
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.
@@ -4,6 +4,12 @@ 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>;
@@ -10,6 +10,17 @@ export class UserAssignmentDao extends CommonDao {
10
10
  const query = this.query().filterEq('userId', userId).filterIn('experimentId', experimentIds);
11
11
  return await this.runQuery(query);
12
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
+ }
13
24
  async deleteByExperimentId(experimentId) {
14
25
  await this.query().filterEq('experimentId', experimentId).deleteByQuery();
15
26
  }
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.11.0",
4
+ "version": "2.12.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/db-lib": "^10",
7
7
  "@naturalcycles/js-lib": "^15",
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.
@@ -21,6 +21,20 @@ export class UserAssignmentDao extends CommonDao<UserAssignment> {
21
21
  return await this.runQuery(query)
22
22
  }
23
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
+
24
38
  async deleteByExperimentId(experimentId: string): Promise<void> {
25
39
  await this.query().filterEq('experimentId', experimentId).deleteByQuery()
26
40
  }
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']