@chordcommerce/analytics 1.23.0 → 1.24.1

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.
@@ -2,10 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  // eslint-disable-next-line import/no-extraneous-dependencies
4
4
  var globals_1 = require("@jest/globals");
5
+ var persistence_js_1 = require("./client/consent/persistence.js");
5
6
  // Make jest available globally
6
7
  globalThis.jest = globals_1.jest;
7
- // Mock sessionStorage for tests
8
- var sessionStorageMock = (function () {
8
+ var createStorageMock = function () {
9
9
  var store = {};
10
10
  return {
11
11
  getItem: function (key) { return store[key] || null; },
@@ -19,11 +19,21 @@ var sessionStorageMock = (function () {
19
19
  store = {};
20
20
  },
21
21
  };
22
- })();
22
+ };
23
+ var sessionStorageMock = createStorageMock();
24
+ var localStorageMock = createStorageMock();
23
25
  Object.defineProperty(window, 'sessionStorage', {
24
26
  value: sessionStorageMock,
25
27
  });
26
- // Clear sessionStorage before each test to ensure test isolation
28
+ Object.defineProperty(window, 'localStorage', {
29
+ value: localStorageMock,
30
+ });
31
+ // Clear storage before each test to ensure test isolation. localStorage now
32
+ // holds the last-tracked consent signature, which would otherwise leak between
33
+ // tests and suppress expected consent events. clearConsentMemory() covers the
34
+ // module-level fallback stores, which localStorage.clear() does not reach.
27
35
  beforeEach(function () {
28
36
  sessionStorageMock.clear();
37
+ localStorageMock.clear();
38
+ (0, persistence_js_1.clearConsentMemory)();
29
39
  });
@@ -88,10 +88,23 @@ type MiddlewarePayload = {
88
88
  page: PageContext;
89
89
  };
90
90
  };
91
+ /**
92
+ * The current user and anonymous ids, read from the CDP at send time.
93
+ * Segment's middleware payload exposes these as `userId()`/`anonymousId()`
94
+ * methods on a facade; they are plain values here.
95
+ */
96
+ userId?: string;
97
+ anonymousId?: string;
91
98
  };
92
99
  type MiddlewareArgs = {
93
100
  payload: MiddlewarePayload;
94
101
  next: <T>(arg: T) => T;
95
102
  };
96
- export type AnalyticsMiddleware = (args: MiddlewareArgs) => Partial<EventProperties> | void;
103
+ export type MiddlewareResult = Partial<EventProperties> | void;
104
+ /**
105
+ * A source middleware may be synchronous or asynchronous. An asynchronous one
106
+ * is awaited before the event is dispatched, bounded by a timeout — see
107
+ * `addSourceMiddleware` in the README.
108
+ */
109
+ export type AnalyticsMiddleware = (args: MiddlewareArgs) => MiddlewareResult | Promise<MiddlewareResult>;
97
110
  export {};
@@ -1,2 +1,21 @@
1
1
  import type { EventProperties } from './types/index.js';
2
2
  export declare const pruneNullValues: (props: EventProperties) => EventProperties;
3
+ /**
4
+ * How long a single asynchronous source middleware may take before its
5
+ * contribution is abandoned.
6
+ *
7
+ * Middleware runs on the path to the CDP, so an unbounded wait would let one
8
+ * slow lookup hold up delivery indefinitely. `Order Completed` in particular
9
+ * fires on a confirmation page shoppers close, so a late event is a lost one.
10
+ */
11
+ export declare const MIDDLEWARE_TIMEOUT_MS = 1000;
12
+ /** Returned by {@link withTimeout} when the promise did not settle in time. */
13
+ export declare const TIMED_OUT: unique symbol;
14
+ export declare const isPromise: (value: unknown) => value is Promise<unknown>;
15
+ /**
16
+ * Resolve `promise`, or {@link TIMED_OUT} if it has not settled within `ms`.
17
+ *
18
+ * The timer is cleared either way, so a promise that settles quickly does not
19
+ * leave a pending timeout behind.
20
+ */
21
+ export declare const withTimeout: <T>(promise: Promise<T>, ms: number) => Promise<typeof TIMED_OUT | T>;
package/dist/cjs/utils.js CHANGED
@@ -1,6 +1,42 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
13
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
2
38
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pruneNullValues = void 0;
39
+ exports.withTimeout = exports.isPromise = exports.TIMED_OUT = exports.MIDDLEWARE_TIMEOUT_MS = exports.pruneNullValues = void 0;
4
40
  var pruneNullValues = function (props) {
5
41
  if (typeof props !== 'object' || props === null)
6
42
  return props;
@@ -23,3 +59,45 @@ var pruneNullValues = function (props) {
23
59
  return props;
24
60
  };
25
61
  exports.pruneNullValues = pruneNullValues;
62
+ /**
63
+ * How long a single asynchronous source middleware may take before its
64
+ * contribution is abandoned.
65
+ *
66
+ * Middleware runs on the path to the CDP, so an unbounded wait would let one
67
+ * slow lookup hold up delivery indefinitely. `Order Completed` in particular
68
+ * fires on a confirmation page shoppers close, so a late event is a lost one.
69
+ */
70
+ exports.MIDDLEWARE_TIMEOUT_MS = 1000;
71
+ /** Returned by {@link withTimeout} when the promise did not settle in time. */
72
+ exports.TIMED_OUT = Symbol('timed-out');
73
+ var isPromise = function (value) {
74
+ return typeof (value === null || value === void 0 ? void 0 : value.then) === 'function';
75
+ };
76
+ exports.isPromise = isPromise;
77
+ /**
78
+ * Resolve `promise`, or {@link TIMED_OUT} if it has not settled within `ms`.
79
+ *
80
+ * The timer is cleared either way, so a promise that settles quickly does not
81
+ * leave a pending timeout behind.
82
+ */
83
+ var withTimeout = function (promise, ms) { return __awaiter(void 0, void 0, void 0, function () {
84
+ var timer, timeout;
85
+ return __generator(this, function (_a) {
86
+ switch (_a.label) {
87
+ case 0:
88
+ timeout = new Promise(function (resolve) {
89
+ timer = setTimeout(function () { return resolve(exports.TIMED_OUT); }, ms);
90
+ });
91
+ _a.label = 1;
92
+ case 1:
93
+ _a.trys.push([1, , 3, 4]);
94
+ return [4 /*yield*/, Promise.race([promise, timeout])];
95
+ case 2: return [2 /*return*/, _a.sent()];
96
+ case 3:
97
+ clearTimeout(timer);
98
+ return [7 /*endfinally*/];
99
+ case 4: return [2 /*return*/];
100
+ }
101
+ });
102
+ }); };
103
+ exports.withTimeout = withTimeout;
@@ -12,9 +12,12 @@ import { ketchConsentAdapter } from './ketch.js';
12
12
  import { onetrustConsentAdapter } from './onetrust.js';
13
13
  import { shopifyConsentAdapter } from './shopify.js';
14
14
  import { segmentConsentAdapter } from './segment.js';
15
+ import { claimConsentEmit, commitConsentEmit, releaseConsentEmit, } from './persistence.js';
15
16
  export const addCdpConsent = (options) => {
16
- let lastProcessedConsent = null;
17
17
  const POLLING_INTERVAL_MS = 50;
18
+ // Always populated: ChordAnalytics applies a `namespace` default before
19
+ // constructing the consent manager.
20
+ const namespace = options.namespace;
18
21
  const TIMEOUT_MS = options.awaitConsentTimeout
19
22
  ? options.awaitConsentTimeout
20
23
  : 60000;
@@ -64,24 +67,36 @@ export const addCdpConsent = (options) => {
64
67
  });
65
68
  });
66
69
  /**
67
- * Tracks consent preference changes.
68
- * Note: Per-event consent stamping is handled in ChordAnalytics via stampConsent().
70
+ * Tracks consent preference *transitions* only, deduped against the last
71
+ * state we emitted (persisted, so the guard survives navigation).
72
+ * Per-event consent stamping is handled by configureConsentOnCdp().
69
73
  */
70
74
  const trackConsentUpdate = (updatedConsent) => __awaiter(void 0, void 0, void 0, function* () {
71
- if (lastProcessedConsent &&
72
- JSON.stringify(lastProcessedConsent) === JSON.stringify(updatedConsent)) {
75
+ const chordAnalytics = window[namespace];
76
+ if (!chordAnalytics ||
77
+ typeof chordAnalytics.track !== 'function' ||
78
+ updatedConsent.consentCategories === undefined) {
73
79
  return;
74
80
  }
75
- lastProcessedConsent = updatedConsent;
76
- const chordAnalytics = window[options.namespace];
77
- if (chordAnalytics &&
78
- typeof chordAnalytics.track === 'function' &&
79
- updatedConsent.consentCategories !== undefined) {
81
+ if (!claimConsentEmit(namespace, updatedConsent)) {
82
+ if (options.enableLogging && options.debug) {
83
+ console.log('[Chord Analytics DEBUG]: Consent unchanged or emit already in flight, skipping');
84
+ }
85
+ return;
86
+ }
87
+ try {
80
88
  yield chordAnalytics.track('Consent Preferences Updated', updatedConsent);
89
+ // Only recorded once delivery resolves. Committing before this would
90
+ // suppress the event permanently if it were queued and never flushed.
91
+ commitConsentEmit(namespace, updatedConsent);
81
92
  if (options.enableLogging && options.debug) {
82
93
  console.log('[Chord Analytics DEBUG]: Consent preferences updated, sent tracking event');
83
94
  }
84
95
  }
96
+ catch (error) {
97
+ releaseConsentEmit(namespace, updatedConsent);
98
+ console.error('Error tracking consent preferences update:', error);
99
+ }
85
100
  });
86
101
  /**
87
102
  * Sets up consent management by initializing the adapter and handling consent updates.
@@ -94,10 +109,10 @@ export const addCdpConsent = (options) => {
94
109
  adapter.handleConsentUpdates((updatedConsent) => {
95
110
  trackConsentUpdate(updatedConsent);
96
111
  });
97
- // Return initial consent to be tracked after CDP is fully initialized
98
- const initialConsent = yield adapter.getCurrentConsent();
99
- lastProcessedConsent = initialConsent;
100
- return initialConsent;
112
+ // Return initial consent to be tracked after CDP is fully initialized.
113
+ // Not marked as processed here: whichever path emits first records the
114
+ // signature, and the other is then deduped against it.
115
+ return adapter.getCurrentConsent();
101
116
  });
102
117
  /**
103
118
  * Initializes consent management and sets up consent update handlers.
@@ -0,0 +1,49 @@
1
+ import type { ConsentConfiguration } from '../../types/index.js';
2
+ /**
3
+ * Build a stable, order-independent signature for a consent configuration.
4
+ *
5
+ * Category keys are sorted so a change in the order the consent manager
6
+ * reports its groups does not read as a change in consent.
7
+ */
8
+ export declare const consentSignature: (consent: ConsentConfiguration) => string;
9
+ /**
10
+ * Whether this consent configuration differs from the last one we tracked.
11
+ *
12
+ * Returns true when nothing has been stored yet, so every visitor still gets
13
+ * one baseline `Consent Preferences Updated` event recording their starting
14
+ * state. Subsequent page loads with unchanged consent return false.
15
+ */
16
+ export declare const hasConsentChanged: (namespace: string | undefined, consent: ConsentConfiguration) => boolean;
17
+ /**
18
+ * Claim the right to emit a consent event, atomically.
19
+ *
20
+ * Returns false when this state was already tracked, or when an emit for the
21
+ * same state is already in flight. Callers that get true MUST follow with
22
+ * `commitConsentEmit` on success or `releaseConsentEmit` on failure.
23
+ *
24
+ * The claim is deliberately not persisted: it exists only to dedupe within a
25
+ * page load. If delivery never completes, the next page load starts clean and
26
+ * retries rather than suppressing the event forever.
27
+ */
28
+ export declare const claimConsentEmit: (namespace: string | undefined, consent: ConsentConfiguration) => boolean;
29
+ /**
30
+ * Record a consent configuration as tracked, once delivery has actually
31
+ * completed. Later page loads dedupe against it.
32
+ */
33
+ export declare const commitConsentEmit: (namespace: string | undefined, consent: ConsentConfiguration) => void;
34
+ /**
35
+ * Give up a claim without recording it, so a later attempt can retry. Takes
36
+ * the consent it claimed so a failing emit cannot clear another state's claim.
37
+ */
38
+ export declare const releaseConsentEmit: (namespace: string | undefined, consent: ConsentConfiguration) => void;
39
+ /**
40
+ * Clear the stored consent signature so the next init emits a fresh baseline.
41
+ * Called on reset(), where a new anonymous ID would otherwise never get a
42
+ * consent record of its own.
43
+ */
44
+ export declare const forgetConsent: (namespace: string | undefined) => void;
45
+ /**
46
+ * Drop all module-level state. Test-only: `localStorage` is cleared between
47
+ * tests, but these would otherwise persist and suppress expected events.
48
+ */
49
+ export declare const clearConsentMemory: () => void;
@@ -0,0 +1,135 @@
1
+ const LAST_CONSENT_STORAGE_KEY = 'chordcommerce/analytics/last-consent';
2
+ /**
3
+ * In-memory fallback for environments where localStorage is unavailable
4
+ * (SSR, private browsing, storage disabled). Keyed by namespace so multiple
5
+ * ChordAnalytics instances on one page don't clobber each other.
6
+ */
7
+ const memoryStore = {};
8
+ const storageKey = (namespace) => `${LAST_CONSENT_STORAGE_KEY}/${namespace !== null && namespace !== void 0 ? namespace : 'default'}`;
9
+ /**
10
+ * Build a stable, order-independent signature for a consent configuration.
11
+ *
12
+ * Category keys are sorted so a change in the order the consent manager
13
+ * reports its groups does not read as a change in consent.
14
+ */
15
+ export const consentSignature = (consent) => {
16
+ var _a;
17
+ const categories = (_a = consent.consentCategories) !== null && _a !== void 0 ? _a : {};
18
+ const sorted = Object.keys(categories)
19
+ .sort()
20
+ .reduce((acc, key) => {
21
+ acc[key] = categories[key];
22
+ return acc;
23
+ }, {});
24
+ return JSON.stringify(sorted);
25
+ };
26
+ const readSignature = (namespace) => {
27
+ var _a;
28
+ const key = storageKey(namespace);
29
+ try {
30
+ if (typeof window !== 'undefined' && window.localStorage) {
31
+ return window.localStorage.getItem(key);
32
+ }
33
+ }
34
+ catch (_b) {
35
+ // localStorage can throw in private browsing; fall through to memory
36
+ }
37
+ return (_a = memoryStore[key]) !== null && _a !== void 0 ? _a : null;
38
+ };
39
+ const writeSignature = (namespace, signature) => {
40
+ const key = storageKey(namespace);
41
+ memoryStore[key] = signature;
42
+ try {
43
+ if (typeof window !== 'undefined' && window.localStorage) {
44
+ window.localStorage.setItem(key, signature);
45
+ }
46
+ }
47
+ catch (_a) {
48
+ // Quota or private browsing; the in-memory copy still dedupes within
49
+ // this page load.
50
+ }
51
+ };
52
+ /**
53
+ * Claims for emits currently mid-delivery, as `<storage key>::<signature>`.
54
+ * Delivery is awaited and a queued event can stay unresolved for a long time,
55
+ * so without this two callers in the same tick would both pass the guard and
56
+ * emit duplicates.
57
+ *
58
+ * Keyed by signature, not just namespace, so two different consent states can
59
+ * be in flight at once without clearing each other's claim.
60
+ */
61
+ const inFlight = new Set();
62
+ const inFlightKey = (namespace, signature) => `${storageKey(namespace)}::${signature}`;
63
+ /**
64
+ * Whether this consent configuration differs from the last one we tracked.
65
+ *
66
+ * Returns true when nothing has been stored yet, so every visitor still gets
67
+ * one baseline `Consent Preferences Updated` event recording their starting
68
+ * state. Subsequent page loads with unchanged consent return false.
69
+ */
70
+ export const hasConsentChanged = (namespace, consent) => readSignature(namespace) !== consentSignature(consent);
71
+ /**
72
+ * Claim the right to emit a consent event, atomically.
73
+ *
74
+ * Returns false when this state was already tracked, or when an emit for the
75
+ * same state is already in flight. Callers that get true MUST follow with
76
+ * `commitConsentEmit` on success or `releaseConsentEmit` on failure.
77
+ *
78
+ * The claim is deliberately not persisted: it exists only to dedupe within a
79
+ * page load. If delivery never completes, the next page load starts clean and
80
+ * retries rather than suppressing the event forever.
81
+ */
82
+ export const claimConsentEmit = (namespace, consent) => {
83
+ const signature = consentSignature(consent);
84
+ if (readSignature(namespace) === signature)
85
+ return false;
86
+ if (inFlight.has(inFlightKey(namespace, signature)))
87
+ return false;
88
+ inFlight.add(inFlightKey(namespace, signature));
89
+ return true;
90
+ };
91
+ /**
92
+ * Record a consent configuration as tracked, once delivery has actually
93
+ * completed. Later page loads dedupe against it.
94
+ */
95
+ export const commitConsentEmit = (namespace, consent) => {
96
+ const signature = consentSignature(consent);
97
+ inFlight.delete(inFlightKey(namespace, signature));
98
+ writeSignature(namespace, signature);
99
+ };
100
+ /**
101
+ * Give up a claim without recording it, so a later attempt can retry. Takes
102
+ * the consent it claimed so a failing emit cannot clear another state's claim.
103
+ */
104
+ export const releaseConsentEmit = (namespace, consent) => {
105
+ inFlight.delete(inFlightKey(namespace, consentSignature(consent)));
106
+ };
107
+ /**
108
+ * Clear the stored consent signature so the next init emits a fresh baseline.
109
+ * Called on reset(), where a new anonymous ID would otherwise never get a
110
+ * consent record of its own.
111
+ */
112
+ export const forgetConsent = (namespace) => {
113
+ const key = storageKey(namespace);
114
+ delete memoryStore[key];
115
+ inFlight.forEach((claim) => {
116
+ if (claim.startsWith(`${key}::`))
117
+ inFlight.delete(claim);
118
+ });
119
+ try {
120
+ if (typeof window !== 'undefined' && window.localStorage) {
121
+ window.localStorage.removeItem(key);
122
+ }
123
+ }
124
+ catch (_a) {
125
+ // Nothing to do; the in-memory copy is already gone.
126
+ }
127
+ };
128
+ /**
129
+ * Drop all module-level state. Test-only: `localStorage` is cleared between
130
+ * tests, but these would otherwise persist and suppress expected events.
131
+ */
132
+ export const clearConsentMemory = () => {
133
+ Object.keys(memoryStore).forEach((key) => delete memoryStore[key]);
134
+ inFlight.clear();
135
+ };
@@ -71,6 +71,7 @@ export declare class ChordAnalytics<T extends ObjectTypes = ObjectTypes> {
71
71
  setDebugMode: (enabled: boolean) => void;
72
72
  cdp: () => AnyAnalytics;
73
73
  ccdp: () => AnyAnalytics;
74
+ private currentUser;
74
75
  logger: (message?: any, ...optionalParams: any[]) => void;
75
76
  isResetting: () => boolean;
76
77
  private waitForReadyState;
@@ -9,8 +9,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { addCdpSnippet, cdpQueue } from './cdp.js';
11
11
  import { addCdpConsent } from './consent/index.js';
12
+ import { claimConsentEmit, commitConsentEmit, forgetConsent, releaseConsentEmit, } from './consent/persistence.js';
12
13
  import { eventSchemas } from '../validators/index.js';
13
- import { pruneNullValues } from '../utils.js';
14
+ import { isPromise, MIDDLEWARE_TIMEOUT_MS, pruneNullValues, TIMED_OUT, withTimeout, } from '../utils.js';
14
15
  const DEBUG_MODE_STORAGE_KEY = 'chordcommerce/analytics/debug';
15
16
  const CONSENT_QUEUE_STORAGE_KEY = 'chordcommerce/analytics/consent-queue';
16
17
  const CONSENT_QUEUE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -197,6 +198,32 @@ export class ChordAnalytics {
197
198
  return null;
198
199
  return (window === null || window === void 0 ? void 0 : window[`_${namespace}`]) || cdpQueue(namespace);
199
200
  };
201
+ /*
202
+ * The current user and anonymous ids as the CDP knows them.
203
+ *
204
+ * Exposed to source middleware, which on Segment could read these from the
205
+ * event facade. Either CDP may be absent or may not implement `user()`, so
206
+ * every step is guarded and an unknown id is simply undefined.
207
+ */
208
+ this.currentUser = () => {
209
+ const read = (analytics) => {
210
+ var _a, _b, _c;
211
+ try {
212
+ const user = (_a = analytics === null || analytics === void 0 ? void 0 : analytics.user) === null || _a === void 0 ? void 0 : _a.call(analytics);
213
+ if (!user)
214
+ return undefined;
215
+ const userId = (_b = user.userId) !== null && _b !== void 0 ? _b : undefined;
216
+ const anonymousId = (_c = user.anonymousId) !== null && _c !== void 0 ? _c : undefined;
217
+ if (userId === undefined && anonymousId === undefined)
218
+ return undefined;
219
+ return { userId, anonymousId };
220
+ }
221
+ catch (_d) {
222
+ return undefined;
223
+ }
224
+ };
225
+ return read(this.ccdp()) || read(this.cdp()) || {};
226
+ };
200
227
  /*
201
228
  * Log a message to the console if `options.enableLogging` is true.
202
229
  */
@@ -403,11 +430,13 @@ export class ChordAnalytics {
403
430
  const searchParams = typeof window !== 'undefined' ? window.location.search : '';
404
431
  const urlSearchParams = new URLSearchParams(searchParams);
405
432
  const searchData = Object.fromEntries(urlSearchParams.entries());
406
- // process middleware synchronously
407
- this.middleware.forEach((func) => {
433
+ const { userId, anonymousId } = this.currentUser();
434
+ const invoke = (func) => {
408
435
  try {
409
- const result = func({
436
+ return func({
410
437
  payload: {
438
+ userId,
439
+ anonymousId,
411
440
  obj: {
412
441
  properties: props,
413
442
  event,
@@ -421,12 +450,44 @@ export class ChordAnalytics {
421
450
  },
422
451
  next: (_) => _,
423
452
  });
424
- if (result && typeof result === 'object') {
425
- middlewareProps = Object.assign(Object.assign({}, middlewareProps), result);
426
- }
427
453
  }
428
454
  catch (error) {
429
455
  console.warn('Middleware error:', error);
456
+ return undefined;
457
+ }
458
+ };
459
+ /**
460
+ * Give an asynchronous middleware until MIDDLEWARE_TIMEOUT_MS to produce
461
+ * its properties. Neither a timeout nor a rejection is allowed to reach
462
+ * the caller: a middleware must never cost the event.
463
+ */
464
+ const settle = (returned) => {
465
+ if (!isPromise(returned))
466
+ return returned;
467
+ return withTimeout(returned, MIDDLEWARE_TIMEOUT_MS)
468
+ .then((settled) => {
469
+ if (settled === TIMED_OUT) {
470
+ console.warn(`Middleware timed out after ${MIDDLEWARE_TIMEOUT_MS}ms; its properties were dropped`);
471
+ return undefined;
472
+ }
473
+ return settled;
474
+ })
475
+ .catch((error) => {
476
+ console.warn('Middleware error:', error);
477
+ return undefined;
478
+ });
479
+ };
480
+ // Every middleware is invoked in registration order, as before. Only if
481
+ // one actually returned a promise is anything awaited -- so a set of
482
+ // purely synchronous middleware reaches the CDP on the same tick it
483
+ // always has, and callers that never await `track()` are unaffected.
484
+ const returned = this.middleware.map(invoke);
485
+ const results = returned.some(isPromise)
486
+ ? yield Promise.all(returned.map(settle))
487
+ : returned;
488
+ results.forEach((result) => {
489
+ if (result && typeof result === 'object') {
490
+ middlewareProps = Object.assign(Object.assign({}, middlewareProps), result);
430
491
  }
431
492
  });
432
493
  }
@@ -600,6 +661,9 @@ export class ChordAnalytics {
600
661
  this.performReset = () => __awaiter(this, void 0, void 0, function* () {
601
662
  var _e;
602
663
  try {
664
+ // The new identity has no consent event of its own yet, so drop the
665
+ // stored signature and let the next init emit a fresh baseline.
666
+ forgetConsent(this.options.namespace);
603
667
  const a = this.ccdp();
604
668
  if (a && typeof a.reset === 'function') {
605
669
  a.reset();
@@ -1235,13 +1299,33 @@ export class ChordAnalytics {
1235
1299
  // eslint-disable-next-line no-console
1236
1300
  console.log('[Chord Analytics DEBUG]: consentCategories defined?', (initialConsent === null || initialConsent === void 0 ? void 0 : initialConsent.consentCategories) !== undefined);
1237
1301
  }
1238
- // Now that CDP is fully ready, fire the initial consent track event
1302
+ // Now that CDP is fully ready, fire the initial consent track event.
1303
+ // Skipped when consent is unchanged since the last event we emitted —
1304
+ // otherwise this fires once per page load on multi-page storefronts.
1305
+ const { namespace } = this.options;
1239
1306
  if (initialConsent && initialConsent.consentCategories !== undefined) {
1307
+ if (!claimConsentEmit(namespace, initialConsent)) {
1308
+ if (this.options.enableLogging && this.options.debug) {
1309
+ // eslint-disable-next-line no-console
1310
+ console.log('[Chord Analytics DEBUG]: Initial consent unchanged or emit already in flight, skipping track');
1311
+ }
1312
+ return;
1313
+ }
1240
1314
  if (this.options.enableLogging && this.options.debug) {
1241
1315
  // eslint-disable-next-line no-console
1242
1316
  console.log('[Chord Analytics DEBUG]: About to call this.track() for Consent Preferences Updated');
1243
1317
  }
1244
- yield this.track('Consent Preferences Updated', initialConsent);
1318
+ try {
1319
+ yield this.track('Consent Preferences Updated', initialConsent);
1320
+ // Only recorded once delivery resolves. Committing before this would
1321
+ // suppress the event permanently if it were queued and never
1322
+ // flushed — the next page load must be free to retry.
1323
+ commitConsentEmit(namespace, initialConsent);
1324
+ }
1325
+ catch (error) {
1326
+ releaseConsentEmit(namespace, initialConsent);
1327
+ throw error;
1328
+ }
1245
1329
  if (this.options.enableLogging && this.options.debug) {
1246
1330
  // eslint-disable-next-line no-console
1247
1331
  console.log('[Chord Analytics DEBUG]: Initial consent preferences tracked');
@@ -1,9 +1,9 @@
1
1
  // eslint-disable-next-line import/no-extraneous-dependencies
2
2
  import { jest } from '@jest/globals';
3
+ import { clearConsentMemory } from './client/consent/persistence.js';
3
4
  // Make jest available globally
4
5
  globalThis.jest = jest;
5
- // Mock sessionStorage for tests
6
- const sessionStorageMock = (() => {
6
+ const createStorageMock = () => {
7
7
  let store = {};
8
8
  return {
9
9
  getItem: (key) => store[key] || null,
@@ -17,11 +17,21 @@ const sessionStorageMock = (() => {
17
17
  store = {};
18
18
  },
19
19
  };
20
- })();
20
+ };
21
+ const sessionStorageMock = createStorageMock();
22
+ const localStorageMock = createStorageMock();
21
23
  Object.defineProperty(window, 'sessionStorage', {
22
24
  value: sessionStorageMock,
23
25
  });
24
- // Clear sessionStorage before each test to ensure test isolation
26
+ Object.defineProperty(window, 'localStorage', {
27
+ value: localStorageMock,
28
+ });
29
+ // Clear storage before each test to ensure test isolation. localStorage now
30
+ // holds the last-tracked consent signature, which would otherwise leak between
31
+ // tests and suppress expected consent events. clearConsentMemory() covers the
32
+ // module-level fallback stores, which localStorage.clear() does not reach.
25
33
  beforeEach(() => {
26
34
  sessionStorageMock.clear();
35
+ localStorageMock.clear();
36
+ clearConsentMemory();
27
37
  });
@@ -88,10 +88,23 @@ type MiddlewarePayload = {
88
88
  page: PageContext;
89
89
  };
90
90
  };
91
+ /**
92
+ * The current user and anonymous ids, read from the CDP at send time.
93
+ * Segment's middleware payload exposes these as `userId()`/`anonymousId()`
94
+ * methods on a facade; they are plain values here.
95
+ */
96
+ userId?: string;
97
+ anonymousId?: string;
91
98
  };
92
99
  type MiddlewareArgs = {
93
100
  payload: MiddlewarePayload;
94
101
  next: <T>(arg: T) => T;
95
102
  };
96
- export type AnalyticsMiddleware = (args: MiddlewareArgs) => Partial<EventProperties> | void;
103
+ export type MiddlewareResult = Partial<EventProperties> | void;
104
+ /**
105
+ * A source middleware may be synchronous or asynchronous. An asynchronous one
106
+ * is awaited before the event is dispatched, bounded by a timeout — see
107
+ * `addSourceMiddleware` in the README.
108
+ */
109
+ export type AnalyticsMiddleware = (args: MiddlewareArgs) => MiddlewareResult | Promise<MiddlewareResult>;
97
110
  export {};