@forgecart/sdk 1.202610230547.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 name
51
- * `"{salt}:{subjectKey}:{utcDay}"`, where `utcDay` is the calendar date
52
- * (`YYYY-MM-DD`) of the exposure's `occurredAt` instant in UTC. This gives
53
- * every client an identical, idempotent exposure id per subject per UTC day.
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
- * `uuidV5(EXPOSURE_EVENT_ID_NAMESPACE, "{salt}:{subjectKey}:{utcDay}")`.
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 name
52
- * `"{salt}:{subjectKey}:{utcDay}"`, where `utcDay` is the calendar date
53
- * (`YYYY-MM-DD`) of the exposure's `occurredAt` instant in UTC. This gives
54
- * every client an identical, idempotent exposure id per subject per UTC day.
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
- * `uuidV5(EXPOSURE_EVENT_ID_NAMESPACE, "{salt}:{subjectKey}:{utcDay}")`.
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
- const utcDay = getUtcDay(occurredAt);
229
- return uuidV5(exports.EXPOSURE_EVENT_ID_NAMESPACE, `${salt}:${subjectKey}:${utcDay}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/sdk",
3
- "version": "1.202610230547.0",
3
+ "version": "1.202610231804.0",
4
4
  "description": "ForgeCart Client SDK",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -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 name
51
- * `"{salt}:{subjectKey}:{utcDay}"`, where `utcDay` is the calendar date
52
- * (`YYYY-MM-DD`) of the exposure's `occurredAt` instant in UTC. This gives
53
- * every client an identical, idempotent exposure id per subject per UTC day.
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
- * `uuidV5(EXPOSURE_EVENT_ID_NAMESPACE, "{salt}:{subjectKey}:{utcDay}")`.
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
- const utcDay = getUtcDay(occurredAt);
294
- return uuidV5(EXPOSURE_EVENT_ID_NAMESPACE, `${salt}:${subjectKey}:${utcDay}`);
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';