pulse-updates 1.3.1 → 1.3.2

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/src/links.ts CHANGED
@@ -190,6 +190,7 @@ export interface DeferredLinkClient {
190
190
  const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
191
191
  const PUBLIC_EXPOSURE = /^[a-f0-9]{32}$/i;
192
192
  const INSTALL_ATTEMPT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
193
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
193
194
  const APP_IDENTITY = /^[A-Za-z0-9.-]+$/;
194
195
  const LOCALE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
195
196
  const DEFAULT_PREFIXES = ['encore_handoff:', 'pulse_handoff:'] as const;
@@ -244,7 +245,22 @@ const DEFAULT_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
244
245
  const DEFAULT_RECENT_INSTALL_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
245
246
  const MAX_APPLIED_IDS = 32;
246
247
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
247
- const MAX_PERSISTED_BYTES = 16_384;
248
+ const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
249
+ const MAX_PERSISTED_BYTES = 131_072;
250
+
251
+ interface QueuedResolverOutcome {
252
+ /** Non-reversible local transition key; never sent over the network. */
253
+ transitionKey: string;
254
+ /** Kept only in app storage and the Encore request path. Never added to analytics/body. */
255
+ token: string;
256
+ name: DeferredLinkOutcomeName;
257
+ eventId: string;
258
+ occurredAt: string;
259
+ matchBasis: DeferredLinkMatchBasis;
260
+ confidence: number;
261
+ attempts: number;
262
+ nextRetryAt: number;
263
+ }
248
264
 
249
265
  interface PersistedDeferredLinkState {
250
266
  version: 1;
@@ -254,6 +270,8 @@ interface PersistedDeferredLinkState {
254
270
  appliedIds: string[];
255
271
  /** Internal transition-once ledger of non-reversible bounded hashes. */
256
272
  notifiedOutcomes: string[];
273
+ /** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
274
+ outcomeQueue: QueuedResolverOutcome[];
257
275
  firstOpen: DeferredLinkFirstOpenState;
258
276
  }
259
277
 
@@ -269,6 +287,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
269
287
  lastAppliedId: null,
270
288
  appliedIds: [],
271
289
  notifiedOutcomes: [],
290
+ outcomeQueue: [],
272
291
  firstOpen: {
273
292
  installAttemptId: null,
274
293
  completed: false,
@@ -395,6 +414,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
395
414
  let processing: Promise<void> | null = null;
396
415
  let processRequested = false;
397
416
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
417
+ let outcomeSending: Promise<number> | null = null;
398
418
  let accountRetryAt = 0;
399
419
  let accountAttempts = 0;
400
420
  let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
@@ -475,44 +495,164 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
475
495
  patchState({ pending: null, status: 'terminal_error' });
476
496
  };
477
497
 
498
+ const persistOutcomeState = (): void => {
499
+ writeState(options.storage, storageKey, state);
500
+ scheduleWake();
501
+ };
502
+
503
+ const flushResolverOutcomes = async (): Promise<number> => {
504
+ if (disposed) return 0;
505
+ if (outcomeSending) return outcomeSending;
506
+
507
+ const run = async (): Promise<number> => {
508
+ let delivered = 0;
509
+ while (!disposed) {
510
+ const queued = state.outcomeQueue[0];
511
+ if (!queued || queued.nextRetryAt > now()) break;
512
+
513
+ let response: Response;
514
+ try {
515
+ response = await postResolverOutcome(
516
+ fetcher,
517
+ resolverBaseUrl,
518
+ queued,
519
+ requestTimeoutMs,
520
+ );
521
+ } catch (error) {
522
+ if (disposed) break;
523
+ const index = state.outcomeQueue.findIndex((item) => item === queued);
524
+ if (index < 0) continue;
525
+ const current = state.outcomeQueue[index]!;
526
+ const attempts = Math.min(current.attempts + 1, 100_000);
527
+ const next = {
528
+ ...current,
529
+ attempts,
530
+ nextRetryAt: now() + resolverOutcomeRetryDelay(
531
+ current.eventId,
532
+ attempts,
533
+ retryBaseMs,
534
+ retryMaxMs,
535
+ ),
536
+ };
537
+ state = {
538
+ ...state,
539
+ outcomeQueue: state.outcomeQueue.map((item, itemIndex) => itemIndex === index ? next : item),
540
+ };
541
+ persistOutcomeState();
542
+ reportError(error);
543
+ break;
544
+ }
545
+
546
+ if (disposed) break;
547
+ const index = state.outcomeQueue.findIndex((item) => item === queued);
548
+ if (index < 0) continue;
549
+ const status = response.status;
550
+ if ((status >= 200 && status < 300) || status === 409 || status === 404 || status === 410) {
551
+ state = {
552
+ ...state,
553
+ outcomeQueue: state.outcomeQueue.filter((item) => item !== queued),
554
+ };
555
+ persistOutcomeState();
556
+ if ((status >= 200 && status < 300) || status === 409) delivered += 1;
557
+ continue;
558
+ }
559
+
560
+ if (status === 429 || status >= 500) {
561
+ const current = state.outcomeQueue[index]!;
562
+ const attempts = Math.min(current.attempts + 1, 100_000);
563
+ const next = {
564
+ ...current,
565
+ attempts,
566
+ nextRetryAt: now() + resolverOutcomeRetryDelay(
567
+ current.eventId,
568
+ attempts,
569
+ retryBaseMs,
570
+ retryMaxMs,
571
+ ),
572
+ };
573
+ state = {
574
+ ...state,
575
+ outcomeQueue: state.outcomeQueue.map((item, itemIndex) => itemIndex === index ? next : item),
576
+ };
577
+ persistOutcomeState();
578
+ reportError(new Error(`Pulse Links: Encore outcome HTTP ${status}`));
579
+ break;
580
+ }
581
+
582
+ // Other client/protocol errors cannot become valid by retrying the same payload.
583
+ state = {
584
+ ...state,
585
+ outcomeQueue: state.outcomeQueue.filter((item) => item !== queued),
586
+ };
587
+ persistOutcomeState();
588
+ reportError(new Error(`Pulse Links: terminal Encore outcome HTTP ${status}`));
589
+ }
590
+ return delivered;
591
+ };
592
+
593
+ let owned: Promise<number>;
594
+ owned = run().finally(() => {
595
+ if (outcomeSending === owned) outcomeSending = null;
596
+ const nextOutcome = state.outcomeQueue[0];
597
+ if (!disposed && nextOutcome && nextOutcome.nextRetryAt <= now()) {
598
+ void flushResolverOutcomes();
599
+ }
600
+ });
601
+ outcomeSending = owned;
602
+ return owned;
603
+ };
604
+
478
605
  const emitOutcome = (link: ResolvedDeferredLink<Action>, name: DeferredLinkOutcomeName): void => {
479
606
  const transitionKey = stableOutcomeKey(link.id, name);
480
- if (!state.notifiedOutcomes.includes(transitionKey)) {
481
- // Persist before handing off to the general Pulse outbox. A process death after the callback
482
- // queues an event must not queue the same product transition again on restart.
483
- state = {
484
- ...state,
485
- notifiedOutcomes: [...state.notifiedOutcomes, transitionKey].slice(-MAX_NOTIFIED_OUTCOMES),
486
- };
487
- writeState(options.storage, storageKey, state);
488
- const event: DeferredLinkOutcomeEvent = {
489
- name,
490
- matchBasis: link.matchBasis,
491
- confidence: link.confidence,
492
- ...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
493
- ...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
494
- ...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
495
- occurredAt: new Date(now()).toISOString(),
496
- };
497
- try {
498
- const result = options.onOutcome?.(event);
499
- if (result && typeof (result as Promise<void>).catch === 'function') {
500
- void (result as Promise<void>).catch(reportError);
501
- }
502
- } catch (error) {
503
- reportError(error);
607
+ if (state.notifiedOutcomes.includes(transitionKey)) return;
608
+
609
+ const occurredAt = new Date(now()).toISOString();
610
+ const shouldReportToResolver = (options.reportResolverOutcomes ?? true) && isPublicToken(link.id);
611
+ const queued: QueuedResolverOutcome | null = shouldReportToResolver ? {
612
+ transitionKey,
613
+ token: link.id,
614
+ name,
615
+ eventId: makeOutcomeEventId(
616
+ options.randomUUID,
617
+ new Set(state.outcomeQueue.map((outcome) => outcome.eventId)),
618
+ ),
619
+ occurredAt,
620
+ matchBasis: link.matchBasis,
621
+ confidence: link.confidence,
622
+ attempts: 0,
623
+ nextRetryAt: 0,
624
+ } : null;
625
+
626
+ // Persist the logical transition and complete Encore payload before either delivery path.
627
+ // The token remains only in this local record and in Encore's URL path.
628
+ state = {
629
+ ...state,
630
+ notifiedOutcomes: [...state.notifiedOutcomes, transitionKey].slice(-MAX_NOTIFIED_OUTCOMES),
631
+ outcomeQueue: queued
632
+ ? [...state.outcomeQueue, queued].slice(-MAX_RESOLVER_OUTCOMES)
633
+ : state.outcomeQueue,
634
+ };
635
+ persistOutcomeState();
636
+
637
+ const event: DeferredLinkOutcomeEvent = {
638
+ name,
639
+ matchBasis: link.matchBasis,
640
+ confidence: link.confidence,
641
+ ...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
642
+ ...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
643
+ ...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
644
+ occurredAt,
645
+ };
646
+ try {
647
+ const result = options.onOutcome?.(event);
648
+ if (result && typeof (result as Promise<void>).catch === 'function') {
649
+ void (result as Promise<void>).catch(reportError);
504
650
  }
651
+ } catch (error) {
652
+ reportError(error);
505
653
  }
506
654
 
507
- if ((options.reportResolverOutcomes ?? true) && isPublicToken(link.id)) {
508
- void postResolverOutcome(
509
- fetcher,
510
- resolverBaseUrl,
511
- link,
512
- name,
513
- requestTimeoutMs,
514
- ).catch(reportError);
515
- }
655
+ if (queued) void flushResolverOutcomes();
516
656
  };
517
657
 
518
658
  const applyResolved = async (
@@ -911,12 +1051,14 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
911
1051
  state.pending?.nextRetryAt ?? 0,
912
1052
  lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
913
1053
  accountRetryAt,
1054
+ state.outcomeQueue[0]?.nextRetryAt ?? 0,
914
1055
  ].filter((value) => value > now());
915
1056
  if (candidates.length === 0) return;
916
1057
  const next = Math.min(...candidates);
917
1058
  retryTimer = setTimeout(() => {
918
1059
  retryTimer = null;
919
1060
  void process();
1061
+ void flushResolverOutcomes();
920
1062
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
921
1063
  void matchFirstOpen(lastFirstOpenContext);
922
1064
  }
@@ -934,6 +1076,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
934
1076
  }
935
1077
  scheduleWake();
936
1078
  void process();
1079
+ void flushResolverOutcomes();
937
1080
 
938
1081
  return {
939
1082
  capture,
@@ -1106,10 +1249,45 @@ function makeInstallAttemptId(custom?: () => string): string {
1106
1249
  ?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
1107
1250
  if (candidate && INSTALL_ATTEMPT_ID.test(candidate)) return candidate.toLowerCase();
1108
1251
  } catch { /* use the non-identity fallback below */ }
1252
+ return fallbackUuidV4();
1253
+ }
1254
+
1255
+ function makeOutcomeEventId(custom?: () => string, existing: ReadonlySet<string> = new Set()): string {
1256
+ try {
1257
+ const candidate = custom?.()
1258
+ ?? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto?.randomUUID?.();
1259
+ if (candidate && UUID_V4.test(candidate) && !existing.has(candidate.toLowerCase())) {
1260
+ return candidate.toLowerCase();
1261
+ }
1262
+ } catch { /* use the UUIDv4 fallback below */ }
1263
+ let fallback = fallbackUuidV4();
1264
+ while (existing.has(fallback)) fallback = fallbackUuidV4();
1265
+ return fallback;
1266
+ }
1267
+
1268
+ function fallbackUuidV4(): string {
1109
1269
  const hex = (): string => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
1110
1270
  return `${hex()}${hex()}-${hex()}-4${hex().slice(1)}-${(8 + Math.floor(Math.random() * 4)).toString(16)}${hex().slice(1)}-${hex()}${hex()}${hex()}`;
1111
1271
  }
1112
1272
 
1273
+ /** Deterministic equal jitter keeps a persisted retry stable while spreading different events. */
1274
+ function resolverOutcomeRetryDelay(
1275
+ eventId: string,
1276
+ attempts: number,
1277
+ retryBaseMs: number,
1278
+ retryMaxMs: number,
1279
+ ): number {
1280
+ const exponent = Math.min(Math.max(attempts - 1, 0), 10);
1281
+ const ceiling = Math.min(retryMaxMs, retryBaseMs * 2 ** exponent);
1282
+ let hash = 0x811c9dc5;
1283
+ const seed = `${eventId}:${attempts}`;
1284
+ for (let index = 0; index < seed.length; index += 1) {
1285
+ hash = Math.imul(hash ^ seed.charCodeAt(index), 0x01000193) >>> 0;
1286
+ }
1287
+ const jitter = 0.5 + (hash / 0xffffffff) * 0.5;
1288
+ return Math.max(1, Math.floor(ceiling * jitter));
1289
+ }
1290
+
1113
1291
  async function withTimeout(
1114
1292
  fetcher: typeof fetch,
1115
1293
  input: string,
@@ -1125,27 +1303,90 @@ async function withTimeout(
1125
1303
  }
1126
1304
  }
1127
1305
 
1128
- async function postResolverOutcome<Action extends string>(
1306
+ async function postResolverOutcome(
1129
1307
  fetcher: typeof fetch,
1130
1308
  resolverBaseUrl: string,
1131
- link: ResolvedDeferredLink<Action>,
1132
- name: DeferredLinkOutcomeName,
1309
+ outcome: QueuedResolverOutcome,
1133
1310
  timeoutMs: number,
1134
- ): Promise<void> {
1135
- await withTimeout(
1311
+ ): Promise<Response> {
1312
+ return withTimeout(
1136
1313
  fetcher,
1137
- `${resolverBaseUrl}${encodeURIComponent(link.id)}/event/${name}`,
1314
+ `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`,
1138
1315
  {
1139
1316
  method: 'POST',
1140
1317
  headers: {
1141
1318
  'Content-Type': 'application/json',
1142
1319
  },
1143
- body: JSON.stringify({ matchBasis: link.matchBasis, confidence: link.confidence }),
1320
+ body: JSON.stringify({
1321
+ eventId: outcome.eventId,
1322
+ occurredAt: outcome.occurredAt,
1323
+ matchBasis: outcome.matchBasis,
1324
+ confidence: outcome.confidence,
1325
+ }),
1144
1326
  },
1145
1327
  timeoutMs,
1146
1328
  );
1147
1329
  }
1148
1330
 
1331
+ function readResolverOutcomeQueue(raw: unknown): QueuedResolverOutcome[] {
1332
+ if (!Array.isArray(raw)) return [];
1333
+ const queue: QueuedResolverOutcome[] = [];
1334
+ const seenTransitions = new Set<string>();
1335
+ const seenEvents = new Set<string>();
1336
+ const candidates = [...raw].reverse();
1337
+ for (const candidate of candidates) {
1338
+ if (queue.length >= MAX_RESOLVER_OUTCOMES) break;
1339
+ if (!candidate || typeof candidate !== 'object') continue;
1340
+ const value = candidate as Record<string, unknown>;
1341
+ const token = normalizeDeferredHandoffToken(value.token as string | undefined);
1342
+ const name = isDeferredLinkOutcomeName(value.name) ? value.name : null;
1343
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
1344
+ ? value.eventId.toLowerCase()
1345
+ : null;
1346
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
1347
+ const matchBasis = typeof value.matchBasis === 'string'
1348
+ && MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
1349
+ ? value.matchBasis as DeferredLinkMatchBasis
1350
+ : null;
1351
+ if (!token || !name || !eventId || !occurredAt || !matchBasis) continue;
1352
+ const transitionKey = stableOutcomeKey(token, name);
1353
+ if (value.transitionKey !== transitionKey
1354
+ || seenTransitions.has(transitionKey)
1355
+ || seenEvents.has(eventId)) continue;
1356
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence)
1357
+ ? clampConfidence(value.confidence, 0)
1358
+ : null;
1359
+ if (confidence === null) continue;
1360
+ seenTransitions.add(transitionKey);
1361
+ seenEvents.add(eventId);
1362
+ queue.unshift({
1363
+ transitionKey,
1364
+ token,
1365
+ name,
1366
+ eventId,
1367
+ occurredAt,
1368
+ matchBasis,
1369
+ confidence,
1370
+ attempts: safeInteger(value.attempts, 100_000),
1371
+ nextRetryAt: safeTimestamp(value.nextRetryAt),
1372
+ });
1373
+ }
1374
+ return queue;
1375
+ }
1376
+
1377
+ function isDeferredLinkOutcomeName(value: unknown): value is DeferredLinkOutcomeName {
1378
+ return value === 'app_open_confirmed'
1379
+ || value === 'deferred_link_resolved'
1380
+ || value === 'action_applied';
1381
+ }
1382
+
1383
+ function normalizeOccurredAt(value: unknown): string | null {
1384
+ if (typeof value !== 'string' || value.length > 40) return null;
1385
+ const timestamp = Date.parse(value);
1386
+ if (!Number.isFinite(timestamp)) return null;
1387
+ return new Date(timestamp).toISOString() === value ? value : null;
1388
+ }
1389
+
1149
1390
  function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
1150
1391
  if (!storage) return emptyState();
1151
1392
  try {
@@ -1192,13 +1433,18 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
1192
1433
  && /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value))
1193
1434
  .slice(-MAX_NOTIFIED_OUTCOMES)
1194
1435
  : [];
1436
+ const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
1195
1437
  return {
1196
1438
  version: 1,
1197
1439
  status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
1198
1440
  pending,
1199
1441
  lastAppliedId: lastApplied,
1200
1442
  appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
1201
- notifiedOutcomes: [...new Set(notifiedOutcomes)].slice(-MAX_NOTIFIED_OUTCOMES),
1443
+ notifiedOutcomes: [...new Set([
1444
+ ...notifiedOutcomes,
1445
+ ...outcomeQueue.map((outcome) => outcome.transitionKey),
1446
+ ])].slice(-MAX_NOTIFIED_OUTCOMES),
1447
+ outcomeQueue,
1202
1448
  firstOpen: {
1203
1449
  installAttemptId: attemptId,
1204
1450
  completed: firstValue.completed === true,