@forgecart/sdk 1.202610171250.0 → 1.202610231804.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.
|
@@ -47,10 +47,17 @@
|
|
|
47
47
|
*
|
|
48
48
|
* 6. EXPOSURE EVENT ID. `deriveExposureEventId` = RFC 4122 UUID version 5
|
|
49
49
|
* (SHA-1 name-based) with the fixed ForgeCart namespace
|
|
50
|
-
* `EXPOSURE_EVENT_ID_NAMESPACE` over the
|
|
51
|
-
* `"{salt}:{subjectKey}:{utcDay}"
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over a name keyed on the exposure's
|
|
51
|
+
* assignment source (rule 5b/5c): `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
52
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
53
|
+
* one, where `utcDay` is the calendar date (`YYYY-MM-DD`) of the
|
|
54
|
+
* exposure's `occurredAt` instant in UTC. This gives every client an
|
|
55
|
+
* identical, idempotent exposure id per subject per UTC day per source:
|
|
56
|
+
* re-renders under one source resubmit one id, while a subject hashed
|
|
57
|
+
* into a variant who follows a `?fc-exp=` campaign link the same UTC day
|
|
58
|
+
* mints a second id, so the forced exposure reaches the forced cohort
|
|
59
|
+
* instead of being deduped onto the hashed one. A hashed name ends in its
|
|
60
|
+
* date and a forced name in `:forced`, so the two never collide.
|
|
54
61
|
*
|
|
55
62
|
* Zero imports by design: the module must stay portable, tree-shakeable, and
|
|
56
63
|
* runnable in browsers, Node, and edge runtimes alike.
|
|
@@ -66,6 +73,8 @@ export declare const EXPERIMENT_MAX_VARIANT_WEIGHT = 1000000;
|
|
|
66
73
|
export declare const EXPOSURE_EVENT_ID_NAMESPACE = "7d9f1c34-8a52-4b6e-9f0d-2c5e8a713b46";
|
|
67
74
|
export type ExperimentLifecycle = 'draft' | 'running' | 'paused' | 'completed';
|
|
68
75
|
export type AssignmentSource = 'lifecycle' | 'forced' | 'hashed';
|
|
76
|
+
/** The assignment sources that emit an exposure (spec rule 5b/5c). */
|
|
77
|
+
export type ExposureAssignmentSource = Extract<AssignmentSource, 'forced' | 'hashed'>;
|
|
69
78
|
export interface ExperimentVariantWeight {
|
|
70
79
|
/** Unique variant key within the experiment. */
|
|
71
80
|
key: string;
|
|
@@ -138,7 +147,11 @@ export declare function getUtcDay(occurredAt: Date): string;
|
|
|
138
147
|
*/
|
|
139
148
|
export declare function uuidV5(namespaceUuid: string, name: string): string;
|
|
140
149
|
/**
|
|
141
|
-
* Deterministic exposure event id (spec rule 6):
|
|
142
|
-
* `
|
|
150
|
+
* Deterministic exposure event id (spec rule 6): `uuidV5` under
|
|
151
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
152
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
153
|
+
* one — idempotent per (subject, UTC day, assignment source). Pass the
|
|
154
|
+
* exposing `AssignmentResult`'s `assignmentSource`; it defaults to
|
|
155
|
+
* `'hashed'`, the randomized exposure.
|
|
143
156
|
*/
|
|
144
|
-
export declare function deriveExposureEventId(salt: string, subjectKey: string, occurredAt: Date): string;
|
|
157
|
+
export declare function deriveExposureEventId(salt: string, subjectKey: string, occurredAt: Date, assignmentSource?: ExposureAssignmentSource): string;
|
|
@@ -48,10 +48,17 @@
|
|
|
48
48
|
*
|
|
49
49
|
* 6. EXPOSURE EVENT ID. `deriveExposureEventId` = RFC 4122 UUID version 5
|
|
50
50
|
* (SHA-1 name-based) with the fixed ForgeCart namespace
|
|
51
|
-
* `EXPOSURE_EVENT_ID_NAMESPACE` over the
|
|
52
|
-
* `"{salt}:{subjectKey}:{utcDay}"
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over a name keyed on the exposure's
|
|
52
|
+
* assignment source (rule 5b/5c): `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
53
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
54
|
+
* one, where `utcDay` is the calendar date (`YYYY-MM-DD`) of the
|
|
55
|
+
* exposure's `occurredAt` instant in UTC. This gives every client an
|
|
56
|
+
* identical, idempotent exposure id per subject per UTC day per source:
|
|
57
|
+
* re-renders under one source resubmit one id, while a subject hashed
|
|
58
|
+
* into a variant who follows a `?fc-exp=` campaign link the same UTC day
|
|
59
|
+
* mints a second id, so the forced exposure reaches the forced cohort
|
|
60
|
+
* instead of being deduped onto the hashed one. A hashed name ends in its
|
|
61
|
+
* date and a forced name in `:forced`, so the two never collide.
|
|
55
62
|
*
|
|
56
63
|
* Zero imports by design: the module must stay portable, tree-shakeable, and
|
|
57
64
|
* runnable in browsers, Node, and edge runtimes alike.
|
|
@@ -219,14 +226,19 @@ function uuidV5(namespaceUuid, name) {
|
|
|
219
226
|
return formatUuidBytes(uuidBytes);
|
|
220
227
|
}
|
|
221
228
|
/**
|
|
222
|
-
* Deterministic exposure event id (spec rule 6):
|
|
223
|
-
* `
|
|
229
|
+
* Deterministic exposure event id (spec rule 6): `uuidV5` under
|
|
230
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
231
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
232
|
+
* one — idempotent per (subject, UTC day, assignment source). Pass the
|
|
233
|
+
* exposing `AssignmentResult`'s `assignmentSource`; it defaults to
|
|
234
|
+
* `'hashed'`, the randomized exposure.
|
|
224
235
|
*/
|
|
225
|
-
function deriveExposureEventId(salt, subjectKey, occurredAt) {
|
|
236
|
+
function deriveExposureEventId(salt, subjectKey, occurredAt, assignmentSource = 'hashed') {
|
|
226
237
|
assertNonEmptyString(salt, 'salt');
|
|
227
238
|
assertNonEmptyString(subjectKey, 'subjectKey');
|
|
228
|
-
|
|
229
|
-
|
|
239
|
+
assertExposureAssignmentSource(assignmentSource);
|
|
240
|
+
const subjectDay = `${salt}:${subjectKey}:${getUtcDay(occurredAt)}`;
|
|
241
|
+
return uuidV5(exports.EXPOSURE_EVENT_ID_NAMESPACE, exposureEventIdName(subjectDay, assignmentSource));
|
|
230
242
|
}
|
|
231
243
|
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
232
244
|
function resolveRunningAssignment(config, subject, controlKey) {
|
|
@@ -297,6 +309,19 @@ function assertValidConfig(config) {
|
|
|
297
309
|
throw new TypeError(`winnerVariantKey ${String(config.winnerVariantKey)} is not a variant of the experiment`);
|
|
298
310
|
}
|
|
299
311
|
}
|
|
312
|
+
const EXPOSURE_ASSIGNMENT_SOURCES = ['hashed', 'forced'];
|
|
313
|
+
function assertExposureAssignmentSource(assignmentSource) {
|
|
314
|
+
if (!EXPOSURE_ASSIGNMENT_SOURCES.includes(assignmentSource)) {
|
|
315
|
+
throw new TypeError(`unknown exposure assignment source: ${String(assignmentSource)}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Spec rule 6 name: the hashed exposure's is the subject-day itself. */
|
|
319
|
+
function exposureEventIdName(subjectDay, assignmentSource) {
|
|
320
|
+
if (assignmentSource === 'hashed') {
|
|
321
|
+
return subjectDay;
|
|
322
|
+
}
|
|
323
|
+
return `${subjectDay}:${assignmentSource}`;
|
|
324
|
+
}
|
|
300
325
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
301
326
|
function parseUuidBytes(uuid) {
|
|
302
327
|
if (!UUID_PATTERN.test(uuid)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -22,5 +22,5 @@ export type { AcfFieldType } from './admin-types.generated';
|
|
|
22
22
|
export { extractErrorCode, extractError, extractErrorMessage, isErrorCode } from './error-utils';
|
|
23
23
|
export type { ExtractedError } from './error-utils';
|
|
24
24
|
export { EXPERIMENT_BUCKET_COUNT, EXPERIMENT_MAX_VARIANT_WEIGHT, EXPOSURE_EVENT_ID_NAMESPACE, murmurHash3x86_32, computeAssignmentBucket, computeTrafficBucket, isSubjectAdmitted, deriveVariantRanges, resolveBucketVariant, resolveExperimentAssignment, getUtcDay, uuidV5, deriveExposureEventId } from './experiment-assignment';
|
|
25
|
-
export type { ExperimentLifecycle, AssignmentSource, ExperimentVariantWeight, VariantRange, ExperimentAssignmentConfig, AssignmentSubject, AssignmentResult } from './experiment-assignment';
|
|
25
|
+
export type { ExperimentLifecycle, AssignmentSource, ExposureAssignmentSource, ExperimentVariantWeight, VariantRange, ExperimentAssignmentConfig, AssignmentSubject, AssignmentResult } from './experiment-assignment';
|
|
26
26
|
export { Observable, firstValueFrom, lastValueFrom } from 'rxjs';
|
package/package.json
CHANGED
|
@@ -47,10 +47,17 @@
|
|
|
47
47
|
*
|
|
48
48
|
* 6. EXPOSURE EVENT ID. `deriveExposureEventId` = RFC 4122 UUID version 5
|
|
49
49
|
* (SHA-1 name-based) with the fixed ForgeCart namespace
|
|
50
|
-
* `EXPOSURE_EVENT_ID_NAMESPACE` over the
|
|
51
|
-
* `"{salt}:{subjectKey}:{utcDay}"
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over a name keyed on the exposure's
|
|
51
|
+
* assignment source (rule 5b/5c): `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
52
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
53
|
+
* one, where `utcDay` is the calendar date (`YYYY-MM-DD`) of the
|
|
54
|
+
* exposure's `occurredAt` instant in UTC. This gives every client an
|
|
55
|
+
* identical, idempotent exposure id per subject per UTC day per source:
|
|
56
|
+
* re-renders under one source resubmit one id, while a subject hashed
|
|
57
|
+
* into a variant who follows a `?fc-exp=` campaign link the same UTC day
|
|
58
|
+
* mints a second id, so the forced exposure reaches the forced cohort
|
|
59
|
+
* instead of being deduped onto the hashed one. A hashed name ends in its
|
|
60
|
+
* date and a forced name in `:forced`, so the two never collide.
|
|
54
61
|
*
|
|
55
62
|
* Zero imports by design: the module must stay portable, tree-shakeable, and
|
|
56
63
|
* runnable in browsers, Node, and edge runtimes alike.
|
|
@@ -72,6 +79,9 @@ export type ExperimentLifecycle = 'draft' | 'running' | 'paused' | 'completed';
|
|
|
72
79
|
|
|
73
80
|
export type AssignmentSource = 'lifecycle' | 'forced' | 'hashed';
|
|
74
81
|
|
|
82
|
+
/** The assignment sources that emit an exposure (spec rule 5b/5c). */
|
|
83
|
+
export type ExposureAssignmentSource = Extract<AssignmentSource, 'forced' | 'hashed'>;
|
|
84
|
+
|
|
75
85
|
export interface ExperimentVariantWeight {
|
|
76
86
|
/** Unique variant key within the experiment. */
|
|
77
87
|
key: string;
|
|
@@ -280,18 +290,24 @@ export function uuidV5(namespaceUuid: string, name: string): string {
|
|
|
280
290
|
}
|
|
281
291
|
|
|
282
292
|
/**
|
|
283
|
-
* Deterministic exposure event id (spec rule 6):
|
|
284
|
-
* `
|
|
293
|
+
* Deterministic exposure event id (spec rule 6): `uuidV5` under
|
|
294
|
+
* `EXPOSURE_EVENT_ID_NAMESPACE` over `"{salt}:{subjectKey}:{utcDay}"` for a
|
|
295
|
+
* hashed exposure and `"{salt}:{subjectKey}:{utcDay}:forced"` for a forced
|
|
296
|
+
* one — idempotent per (subject, UTC day, assignment source). Pass the
|
|
297
|
+
* exposing `AssignmentResult`'s `assignmentSource`; it defaults to
|
|
298
|
+
* `'hashed'`, the randomized exposure.
|
|
285
299
|
*/
|
|
286
300
|
export function deriveExposureEventId(
|
|
287
301
|
salt: string,
|
|
288
302
|
subjectKey: string,
|
|
289
303
|
occurredAt: Date,
|
|
304
|
+
assignmentSource: ExposureAssignmentSource = 'hashed',
|
|
290
305
|
): string {
|
|
291
306
|
assertNonEmptyString(salt, 'salt');
|
|
292
307
|
assertNonEmptyString(subjectKey, 'subjectKey');
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
assertExposureAssignmentSource(assignmentSource);
|
|
309
|
+
const subjectDay = `${salt}:${subjectKey}:${getUtcDay(occurredAt)}`;
|
|
310
|
+
return uuidV5(EXPOSURE_EVENT_ID_NAMESPACE, exposureEventIdName(subjectDay, assignmentSource));
|
|
295
311
|
}
|
|
296
312
|
|
|
297
313
|
// ── Internal helpers ────────────────────────────────────────────────────────
|
|
@@ -390,6 +406,25 @@ function assertValidConfig(config: ExperimentAssignmentConfig): void {
|
|
|
390
406
|
}
|
|
391
407
|
}
|
|
392
408
|
|
|
409
|
+
const EXPOSURE_ASSIGNMENT_SOURCES: readonly ExposureAssignmentSource[] = ['hashed', 'forced'];
|
|
410
|
+
|
|
411
|
+
function assertExposureAssignmentSource(assignmentSource: ExposureAssignmentSource): void {
|
|
412
|
+
if (!EXPOSURE_ASSIGNMENT_SOURCES.includes(assignmentSource)) {
|
|
413
|
+
throw new TypeError(`unknown exposure assignment source: ${String(assignmentSource)}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Spec rule 6 name: the hashed exposure's is the subject-day itself. */
|
|
418
|
+
function exposureEventIdName(
|
|
419
|
+
subjectDay: string,
|
|
420
|
+
assignmentSource: ExposureAssignmentSource,
|
|
421
|
+
): string {
|
|
422
|
+
if (assignmentSource === 'hashed') {
|
|
423
|
+
return subjectDay;
|
|
424
|
+
}
|
|
425
|
+
return `${subjectDay}:${assignmentSource}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
393
428
|
const UUID_PATTERN =
|
|
394
429
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
395
430
|
|
|
@@ -1024,5 +1024,43 @@
|
|
|
1024
1024
|
"utcDay": "2026-01-01",
|
|
1025
1025
|
"eventId": "47148c15-7472-5c73-bfd6-4d4c304d03e8"
|
|
1026
1026
|
}
|
|
1027
|
+
],
|
|
1028
|
+
"forcedExposureEventIds": [
|
|
1029
|
+
{
|
|
1030
|
+
"name": "midday-utc-forced",
|
|
1031
|
+
"salt": "s-hero-2026",
|
|
1032
|
+
"subjectKey": "v_9f3b1c7a",
|
|
1033
|
+
"occurredAt": "2026-07-16T12:34:56.789Z",
|
|
1034
|
+
"utcDay": "2026-07-16",
|
|
1035
|
+
"assignmentSource": "forced",
|
|
1036
|
+
"eventId": "ac4448f7-a1fb-5f3b-a647-9f38926c9be0"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
"name": "same-utc-day-last-millisecond-forced-dedupes",
|
|
1040
|
+
"salt": "s-hero-2026",
|
|
1041
|
+
"subjectKey": "v_9f3b1c7a",
|
|
1042
|
+
"occurredAt": "2026-07-16T23:59:59.999Z",
|
|
1043
|
+
"utcDay": "2026-07-16",
|
|
1044
|
+
"assignmentSource": "forced",
|
|
1045
|
+
"eventId": "ac4448f7-a1fb-5f3b-a647-9f38926c9be0"
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
"name": "midnight-utc-crossing-forced-mints-new-id",
|
|
1049
|
+
"salt": "s-hero-2026",
|
|
1050
|
+
"subjectKey": "v_9f3b1c7a",
|
|
1051
|
+
"occurredAt": "2026-07-17T00:00:00.000Z",
|
|
1052
|
+
"utcDay": "2026-07-17",
|
|
1053
|
+
"assignmentSource": "forced",
|
|
1054
|
+
"eventId": "30509c45-8a88-59f9-b036-697a09c15837"
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
"name": "year-boundary-after-forced",
|
|
1058
|
+
"salt": "x7f2e9ab4c",
|
|
1059
|
+
"subjectKey": "cust_01H8XGJWBWBAQ4",
|
|
1060
|
+
"occurredAt": "2026-01-01T00:00:00.000Z",
|
|
1061
|
+
"utcDay": "2026-01-01",
|
|
1062
|
+
"assignmentSource": "forced",
|
|
1063
|
+
"eventId": "fdd7d848-180a-5b75-b3c5-cbefd471cb99"
|
|
1064
|
+
}
|
|
1027
1065
|
]
|
|
1028
1066
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,5 +22,5 @@ export type { AcfFieldType } from './admin-types.generated';
|
|
|
22
22
|
export { extractErrorCode, extractError, extractErrorMessage, isErrorCode } from './error-utils';
|
|
23
23
|
export type { ExtractedError } from './error-utils';
|
|
24
24
|
export { EXPERIMENT_BUCKET_COUNT, EXPERIMENT_MAX_VARIANT_WEIGHT, EXPOSURE_EVENT_ID_NAMESPACE, murmurHash3x86_32, computeAssignmentBucket, computeTrafficBucket, isSubjectAdmitted, deriveVariantRanges, resolveBucketVariant, resolveExperimentAssignment, getUtcDay, uuidV5, deriveExposureEventId } from './experiment-assignment';
|
|
25
|
-
export type { ExperimentLifecycle, AssignmentSource, ExperimentVariantWeight, VariantRange, ExperimentAssignmentConfig, AssignmentSubject, AssignmentResult } from './experiment-assignment';
|
|
25
|
+
export type { ExperimentLifecycle, AssignmentSource, ExposureAssignmentSource, ExperimentVariantWeight, VariantRange, ExperimentAssignmentConfig, AssignmentSubject, AssignmentResult } from './experiment-assignment';
|
|
26
26
|
export { Observable, firstValueFrom, lastValueFrom } from 'rxjs';
|