pulse-updates 1.3.7 → 1.3.8

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.
@@ -18,6 +18,14 @@ exports.normalizeDeferredHandoffToken = normalizeDeferredHandoffToken;
18
18
  * optional account bridge below.
19
19
  */
20
20
 
21
+ /** Explicit receiver decision for one terminal-delivery attempt. */
22
+
23
+ /**
24
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
25
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
26
+ * can never make the client navigate, including when Encore reports a shadow observation.
27
+ */
28
+
21
29
  /** Implemented by the separate native `PulseAttribution` module. */
22
30
 
23
31
  const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
@@ -34,6 +42,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
34
42
  const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
35
43
  const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
36
44
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
45
+ const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
46
+ const CAPTURE_PRIORITY = {
47
+ direct_token: 3,
48
+ ios_user_paste: 2,
49
+ android_install_referrer: 1
50
+ };
51
+ const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
52
+ const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
37
53
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
38
54
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
39
55
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -44,10 +60,14 @@ const MAX_APPLIED_IDS = 32;
44
60
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
45
61
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
46
62
  const MAX_PERSISTED_BYTES = 131_072;
63
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
47
64
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
48
65
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
49
66
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
50
67
  const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
68
+
69
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
70
+
51
71
  const emptyState = () => ({
52
72
  version: 1,
53
73
  status: 'idle',
@@ -56,6 +76,7 @@ const emptyState = () => ({
56
76
  appliedIds: [],
57
77
  notifiedOutcomes: [],
58
78
  outcomeQueue: [],
79
+ terminalDelivery: null,
59
80
  firstOpen: {
60
81
  installAttemptId: null,
61
82
  completed: false,
@@ -156,6 +177,7 @@ function createPulseLinkClient(options) {
156
177
  }
157
178
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
158
179
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
180
+ const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
159
181
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
160
182
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
161
183
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -164,11 +186,21 @@ function createPulseLinkClient(options) {
164
186
  let state = readState(options.storage, storageKey);
165
187
  let disposed = false;
166
188
  let processing = null;
189
+ let firstOpenMatching = null;
167
190
  let processRequested = false;
168
191
  let retryTimer = null;
169
192
  let outcomeSending = null;
193
+ let terminalDeliverySending = null;
194
+ let terminalDeliveryGeneration = 0;
170
195
  let accountRetryAt = 0;
171
196
  let accountAttempts = 0;
197
+ let deterministicEpoch = 0;
198
+ // Host navigation is irreversible once any routing callback has started: it may perform its
199
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
200
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
201
+ // automatically opening a second journey behind the first one.
202
+ let routingApplicationInFlight = false;
203
+ let deterministicTokenDeferredAfterCommittedJourney = null;
172
204
  let lastFirstOpenContext = null;
173
205
  let unsubscribeAccount = null;
174
206
  const listeners = new Set();
@@ -177,6 +209,25 @@ function createPulseLinkClient(options) {
177
209
  options.onError?.(error);
178
210
  } catch {/* diagnostic hooks never break link handling */}
179
211
  };
212
+
213
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
214
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
215
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
216
+ if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
217
+ const tombstone = {
218
+ eventId: state.terminalDelivery.eventId,
219
+ disposition: 'drop',
220
+ attempts: state.terminalDelivery.attempts,
221
+ nextRetryAt: 0
222
+ };
223
+ state = {
224
+ ...state,
225
+ terminalDelivery: tombstone
226
+ };
227
+ if (!writeState(options.storage, storageKey, state)) {
228
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
229
+ }
230
+ }
180
231
  const snapshot = () => ({
181
232
  status: state.status,
182
233
  pending: state.pending ? {
@@ -188,8 +239,7 @@ function createPulseLinkClient(options) {
188
239
  ...state.firstOpen
189
240
  }
190
241
  });
191
- const persistAndNotify = () => {
192
- writeState(options.storage, storageKey, state);
242
+ const notifyState = () => {
193
243
  const value = snapshot();
194
244
  for (const listener of listeners) {
195
245
  try {
@@ -205,6 +255,11 @@ function createPulseLinkClient(options) {
205
255
  }
206
256
  scheduleWake();
207
257
  };
258
+ const persistAndNotify = () => {
259
+ const persisted = writeState(options.storage, storageKey, state);
260
+ notifyState();
261
+ return persisted;
262
+ };
208
263
  const patchState = patch => {
209
264
  state = {
210
265
  ...state,
@@ -222,6 +277,205 @@ function createPulseLinkClient(options) {
222
277
  };
223
278
  persistAndNotify();
224
279
  };
280
+ const terminalDeliveryOutcome = queued => ({
281
+ eventId: queued.eventId,
282
+ status: queued.status,
283
+ rail: queued.rail,
284
+ routed: queued.routed,
285
+ retryable: false,
286
+ reason: queued.reason,
287
+ occurredAt: queued.occurredAt,
288
+ ...(queued.matchBasis !== undefined ? {
289
+ matchBasis: queued.matchBasis
290
+ } : {}),
291
+ ...(queued.confidence !== undefined ? {
292
+ confidence: queued.confidence
293
+ } : {}),
294
+ ...(queued.campaignId !== undefined ? {
295
+ campaignId: queued.campaignId
296
+ } : {}),
297
+ ...(queued.experimentId !== undefined ? {
298
+ experimentId: queued.experimentId
299
+ } : {}),
300
+ ...(queued.variantId !== undefined ? {
301
+ variantId: queued.variantId
302
+ } : {})
303
+ });
304
+ const scheduleTerminalDeliveryRetry = queued => {
305
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
306
+ const attempts = Math.min(queued.attempts + 1, 100_000);
307
+ state = {
308
+ ...state,
309
+ terminalDelivery: {
310
+ ...queued,
311
+ attempts,
312
+ nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
313
+ }
314
+ };
315
+ persistAndNotify();
316
+ };
317
+ const acknowledgeTerminalDelivery = queued => {
318
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
319
+ const acknowledgedState = {
320
+ ...state,
321
+ terminalDelivery: null
322
+ };
323
+ // Clearing the record is itself transactional. If this write fails after the receiver
324
+ // accepted the event, retain and replay the same eventId: that is why the contract is
325
+ // at-least-once and why receivers must deduplicate.
326
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
327
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
328
+ scheduleTerminalDeliveryRetry(queued);
329
+ return false;
330
+ }
331
+ state = acknowledgedState;
332
+ notifyState();
333
+ return true;
334
+ };
335
+ const flushTerminalDelivery = () => {
336
+ if (disposed) return Promise.resolve(false);
337
+ if (terminalDeliverySending) return terminalDeliverySending;
338
+ const queued = state.terminalDelivery;
339
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
340
+ if (queued.disposition === 'drop') {
341
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
342
+ }
343
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
344
+ const generation = terminalDeliveryGeneration;
345
+ const run = async () => {
346
+ // Never call the receiver before the complete record is durable. With no storage adapter,
347
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
348
+ if (!writeState(options.storage, storageKey, state)) {
349
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
350
+ scheduleTerminalDeliveryRetry(queued);
351
+ return false;
352
+ }
353
+ let rawDisposition;
354
+ try {
355
+ rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
356
+ } catch (error) {
357
+ reportError(error);
358
+ scheduleTerminalDeliveryRetry(queued);
359
+ return false;
360
+ }
361
+ let disposition;
362
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
363
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
364
+ } else {
365
+ try {
366
+ const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
367
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
368
+ } catch (error) {
369
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
370
+ if (!disposed && terminalDeliveryGeneration === generation) {
371
+ scheduleTerminalDeliveryRetry(queued);
372
+ }
373
+ return false;
374
+ }
375
+ }
376
+ if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
377
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
378
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
379
+ scheduleTerminalDeliveryRetry(queued);
380
+ return false;
381
+ };
382
+ let owned;
383
+ owned = run().finally(() => {
384
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
385
+ const pending = state.terminalDelivery;
386
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
387
+ void flushTerminalDelivery();
388
+ }
389
+ });
390
+ terminalDeliverySending = owned;
391
+ return owned;
392
+ };
393
+ const dropTerminalDelivery = queued => {
394
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
395
+ const tombstone = {
396
+ eventId: queued.eventId,
397
+ disposition: 'drop',
398
+ attempts: queued.attempts,
399
+ nextRetryAt: 0
400
+ };
401
+ state = {
402
+ ...state,
403
+ terminalDelivery: tombstone
404
+ };
405
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
406
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
407
+ if (!writeState(options.storage, storageKey, state)) {
408
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
409
+ scheduleTerminalDeliveryRetry(tombstone);
410
+ return false;
411
+ }
412
+ notifyState();
413
+ return acknowledgeTerminalDelivery(tombstone);
414
+ };
415
+ const completeFirstOpen = (status, outcome) => {
416
+ let shouldQueue = Boolean(options.onFirstOpenResult);
417
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
418
+ try {
419
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
420
+ } catch (error) {
421
+ shouldQueue = false;
422
+ reportError(error);
423
+ }
424
+ }
425
+ if (!shouldQueue) {
426
+ state = {
427
+ ...state,
428
+ status,
429
+ terminalDelivery: null,
430
+ firstOpen: {
431
+ ...state.firstOpen,
432
+ completed: true,
433
+ attempts: 0,
434
+ nextRetryAt: 0
435
+ }
436
+ };
437
+ persistAndNotify();
438
+ return;
439
+ }
440
+ const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
441
+ const delivery = state.terminalDelivery ?? {
442
+ ...outcome,
443
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
444
+ retryable: false,
445
+ occurredAt: new Date(now()).toISOString(),
446
+ attempts: 0,
447
+ nextRetryAt: 0
448
+ };
449
+ state = {
450
+ ...state,
451
+ status,
452
+ terminalDelivery: delivery,
453
+ firstOpen: {
454
+ ...state.firstOpen,
455
+ completed: true,
456
+ attempts: 0,
457
+ nextRetryAt: 0
458
+ }
459
+ };
460
+ if (!persistAndNotify()) {
461
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
462
+ }
463
+ // Delivery is deliberately detached from routing/matcher completion.
464
+ void flushTerminalDelivery();
465
+ };
466
+ const blockFirstOpenForDeterministic = () => {
467
+ deterministicEpoch += 1;
468
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
469
+ state = {
470
+ ...state,
471
+ firstOpen: {
472
+ ...state.firstOpen,
473
+ completed: true,
474
+ nextRetryAt: 0
475
+ }
476
+ };
477
+ persistAndNotify();
478
+ };
225
479
  const isAccountReady = () => {
226
480
  try {
227
481
  return options.isAccountReady?.() ?? false;
@@ -454,9 +708,24 @@ function createPulseLinkClient(options) {
454
708
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
455
709
  return false;
456
710
  }
711
+ let applied = false;
712
+ let applicationFailed = false;
713
+ let applicationError;
714
+ const applicationEpoch = deterministicEpoch;
715
+ const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
716
+ // A provenance upgrade or rejected lower-priority capture for the token already being
717
+ // applied does not represent another destination and must not leave that token pending.
718
+ && (sourceToken === null || state.pending.token !== sourceToken);
719
+ routingApplicationInFlight = true;
457
720
  emitOutcome(link, 'app_open_confirmed');
458
721
  emitOutcome(link, 'deferred_link_resolved');
459
- let applied;
722
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
723
+ // a newer accepted destination, it still arrived before the routing callback and must win.
724
+ if (hasNewerPendingIntent()) {
725
+ routingApplicationInFlight = false;
726
+ void process();
727
+ return false;
728
+ }
460
729
  try {
461
730
  if (link.deepLink) {
462
731
  applied = await options.onDeepLink(link.deepLink, link);
@@ -467,18 +736,41 @@ function createPulseLinkClient(options) {
467
736
  return false;
468
737
  }
469
738
  } catch (error) {
470
- reportError(error);
739
+ applicationFailed = true;
740
+ applicationError = error;
741
+ } finally {
742
+ routingApplicationInFlight = false;
743
+ }
744
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
745
+ if (applicationFailed) {
746
+ reportError(applicationError);
747
+ if (newerIntentArrivedDuringApplication) {
748
+ // The current action did not commit. Resume the newer accepted intent that was captured
749
+ // while its callback was suspended, without scheduling a retry for the superseded one.
750
+ void process();
751
+ return false;
752
+ }
471
753
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
472
754
  return false;
473
755
  }
474
756
  if (applied === false) {
757
+ if (newerIntentArrivedDuringApplication) {
758
+ void process();
759
+ return false;
760
+ }
475
761
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
476
762
  return false;
477
763
  }
764
+ if (newerIntentArrivedDuringApplication) {
765
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
766
+ // callback may already have navigated, so automatically applying the newly captured token
767
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
768
+ deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
769
+ }
478
770
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
479
771
  const newlyAppliedSet = new Set(newlyApplied);
480
772
  const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
481
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
773
+ const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
482
774
  state = {
483
775
  ...state,
484
776
  status: stillPending ? 'pending' : 'applied',
@@ -492,7 +784,9 @@ function createPulseLinkClient(options) {
492
784
  accountRetryAt = 0;
493
785
  }
494
786
  emitOutcome(link, 'action_applied');
495
- if (stillPending) void process();
787
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
788
+ void process();
789
+ }
496
790
  return true;
497
791
  };
498
792
  const resolvePublic = async (token, basis) => {
@@ -529,8 +823,13 @@ function createPulseLinkClient(options) {
529
823
  };
530
824
  const processOnce = async () => {
531
825
  if (disposed) return;
826
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
827
+ // application concurrently, and never auto-drain the exact token preserved after an already
828
+ // committed journey in this client lifecycle.
829
+ if (routingApplicationInFlight) return;
532
830
  const pending = state.pending;
533
831
  if (pending) {
832
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
534
833
  if (pending.nextRetryAt > now()) return;
535
834
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
536
835
  clearPendingAsTerminal();
@@ -557,7 +856,7 @@ function createPulseLinkClient(options) {
557
856
  return;
558
857
  }
559
858
  try {
560
- const raw = await options.accountBridge.claim(token);
859
+ const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
561
860
  if (disposed || state.pending?.token !== token) return;
562
861
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
563
862
  const claimed = normalizedClaim ? {
@@ -566,7 +865,10 @@ function createPulseLinkClient(options) {
566
865
  matchGuaranteed: true,
567
866
  confidence: 1
568
867
  } : null;
569
- if (claimed) await applyResolved(claimed, token);else clearPendingAsTerminal();
868
+ if (claimed) {
869
+ blockFirstOpenForDeterministic();
870
+ await applyResolved(claimed, token);
871
+ } else clearPendingAsTerminal();
570
872
  } catch (error) {
571
873
  reportError(error);
572
874
  if (state.pending?.token === token) schedulePendingRetry();
@@ -574,8 +876,13 @@ function createPulseLinkClient(options) {
574
876
  return;
575
877
  }
576
878
  if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
879
+ const accountPendingEpoch = deterministicEpoch;
577
880
  try {
578
- const raw = await options.accountBridge.pending();
881
+ const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
882
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
883
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
884
+ // captured token; otherwise both deterministic destinations could be applied.
885
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
579
886
  if (!raw) {
580
887
  accountAttempts = 0;
581
888
  accountRetryAt = 0;
@@ -594,14 +901,21 @@ function createPulseLinkClient(options) {
594
901
  matchGuaranteed: true,
595
902
  confidence: 1
596
903
  } : null;
597
- if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
598
- await applyResolved(pendingLink, null);
599
- } else if (pendingLink && state.status !== 'applied') {
600
- patchState({
601
- status: 'applied'
602
- });
904
+ if (pendingLink) {
905
+ // Reserve the first-open journey for deterministic recovery before invoking any host
906
+ // callback. This also covers an already-applied account item restored from older SDK
907
+ // state, which must still prevent a second probabilistic destination.
908
+ blockFirstOpenForDeterministic();
909
+ if (!state.appliedIds.includes(pendingLink.id)) {
910
+ await applyResolved(pendingLink, null);
911
+ } else if (state.status !== 'applied') {
912
+ patchState({
913
+ status: 'applied'
914
+ });
915
+ }
603
916
  }
604
917
  } catch (error) {
918
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
605
919
  reportError(error);
606
920
  // No token was consumed; the authenticated server outbox remains authoritative.
607
921
  scheduleAccountRetry();
@@ -609,6 +923,8 @@ function createPulseLinkClient(options) {
609
923
  };
610
924
  const process = async () => {
611
925
  if (disposed) return;
926
+ // Application delivery is independent: never await it on the routing drain.
927
+ void flushTerminalDelivery();
612
928
  processRequested = true;
613
929
  if (processing) return processing;
614
930
  const run = (async () => {
@@ -632,6 +948,7 @@ function createPulseLinkClient(options) {
632
948
  const token = normalizeDeferredHandoffToken(rawToken);
633
949
  if (!token) return false;
634
950
  if (state.appliedIds.includes(token)) {
951
+ deterministicEpoch += 1;
635
952
  if (!state.firstOpen.completed) patchFirstOpen({
636
953
  completed: true,
637
954
  nextRetryAt: 0
@@ -639,7 +956,8 @@ function createPulseLinkClient(options) {
639
956
  return true;
640
957
  }
641
958
  if (state.pending?.token === token) {
642
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
959
+ deterministicEpoch += 1;
960
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
643
961
  state = {
644
962
  ...state,
645
963
  pending: {
@@ -662,6 +980,20 @@ function createPulseLinkClient(options) {
662
980
  void process();
663
981
  return true;
664
982
  }
983
+
984
+ // Exact transports are deterministic, but when more than one arrives during cold start their
985
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
986
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
987
+ if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
988
+ deterministicEpoch += 1;
989
+ if (!state.firstOpen.completed) patchFirstOpen({
990
+ completed: true,
991
+ nextRetryAt: 0
992
+ });
993
+ void process();
994
+ return true;
995
+ }
996
+ deterministicEpoch += 1;
665
997
  state = {
666
998
  ...state,
667
999
  status: 'pending',
@@ -682,14 +1014,27 @@ function createPulseLinkClient(options) {
682
1014
  void process();
683
1015
  return true;
684
1016
  };
685
- const matchFirstOpen = async context => {
1017
+ const matchFirstOpenOnce = async context => {
686
1018
  if (disposed) return 'ineligible';
687
1019
  lastFirstOpenContext = context;
688
1020
  if (state.pending) return 'deterministic_pending';
689
1021
  if (state.firstOpen.completed) return 'already_completed';
690
1022
  if (state.firstOpen.nextRetryAt > now()) return 'backoff';
691
1023
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1024
+
1025
+ // The client starts account recovery at construction. Join that deterministic rail before
1026
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1027
+ // after the probabilistic destination and the user observes both journeys.
1028
+ await process();
1029
+ if (disposed) return 'ineligible';
1030
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1031
+ if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1032
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1033
+ return 'retry_scheduled';
1034
+ }
1035
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
692
1036
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1037
+ const startingDeterministicEpoch = deterministicEpoch;
693
1038
  state = {
694
1039
  ...state,
695
1040
  status: 'resolving',
@@ -717,26 +1062,23 @@ function createPulseLinkClient(options) {
717
1062
  },
718
1063
  body: JSON.stringify(body)
719
1064
  }, requestTimeoutMs);
720
- if (state.pending) return 'deterministic_pending';
721
- if (state.firstOpen.completed) return 'deterministic_pending';
1065
+ if (disposed) return 'ineligible';
1066
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
722
1067
  if (response.status === 204) {
723
- patchFirstOpen({
724
- completed: true,
725
- attempts: 0,
726
- nextRetryAt: 0
727
- });
728
- patchState({
729
- status: 'idle'
1068
+ completeFirstOpen('idle', {
1069
+ status: 'NOT_FOUND',
1070
+ rail: 'no_route',
1071
+ routed: false,
1072
+ reason: firstOpenNoRouteReason(response)
730
1073
  });
731
1074
  return 'no_match';
732
1075
  }
733
- if (response.status === 400 || response.status === 404 || response.status === 410) {
734
- patchFirstOpen({
735
- completed: true,
736
- nextRetryAt: 0
737
- });
738
- patchState({
739
- status: 'terminal_error'
1076
+ if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
1077
+ completeFirstOpen('terminal_error', {
1078
+ status: 'FAILURE',
1079
+ rail: 'no_route',
1080
+ routed: false,
1081
+ reason: 'invalid_request'
740
1082
  });
741
1083
  return 'terminal_error';
742
1084
  }
@@ -745,44 +1087,95 @@ function createPulseLinkClient(options) {
745
1087
  scheduleFirstOpenRetry();
746
1088
  return 'retry_scheduled';
747
1089
  }
748
- patchFirstOpen({
749
- completed: true,
750
- nextRetryAt: 0
1090
+ completeFirstOpen('terminal_error', {
1091
+ status: 'FAILURE',
1092
+ rail: 'no_route',
1093
+ routed: false,
1094
+ reason: 'invalid_response'
751
1095
  });
752
- patchState({
753
- status: 'terminal_error'
1096
+ return 'terminal_error';
1097
+ }
1098
+ let rawLink;
1099
+ try {
1100
+ rawLink = await response.json();
1101
+ } catch {
1102
+ completeFirstOpen('terminal_error', {
1103
+ status: 'FAILURE',
1104
+ rail: 'no_route',
1105
+ routed: false,
1106
+ reason: 'invalid_response'
754
1107
  });
755
1108
  return 'terminal_error';
756
1109
  }
757
- const link = normalizeResolved(await response.json(), installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
758
- // The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
759
- // evidence to guaranteed or return a deterministic/account basis.
760
- if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.action && sensitiveActions.has(link.action)) {
761
- patchFirstOpen({
762
- completed: true,
763
- nextRetryAt: 0
1110
+ if (disposed) return 'ineligible';
1111
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1112
+ const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
1113
+ if (!link) {
1114
+ completeFirstOpen('terminal_error', {
1115
+ status: 'FAILURE',
1116
+ rail: 'no_route',
1117
+ routed: false,
1118
+ reason: 'invalid_response'
764
1119
  });
765
- patchState({
766
- status: 'terminal_error'
1120
+ return 'terminal_error';
1121
+ }
1122
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1123
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1124
+ // and even an app-owned deepLink are rejected before application code runs.
1125
+ if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
1126
+ completeFirstOpen('terminal_error', {
1127
+ status: 'FAILURE',
1128
+ rail: 'no_route',
1129
+ routed: false,
1130
+ reason: 'policy_rejected'
767
1131
  });
768
1132
  return 'terminal_error';
769
1133
  }
1134
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
770
1135
  if (await applyResolved(link, null)) {
771
- patchFirstOpen({
772
- completed: true,
773
- attempts: 0,
774
- nextRetryAt: 0
1136
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1137
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1138
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1139
+ // truth that this probabilistic journey really routed.
1140
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1141
+ status: 'FOUND',
1142
+ rail: 'fast_route',
1143
+ routed: true,
1144
+ reason: 'matched',
1145
+ matchBasis: link.matchBasis,
1146
+ confidence: link.confidence,
1147
+ ...(link.campaignId !== undefined ? {
1148
+ campaignId: link.campaignId
1149
+ } : {}),
1150
+ ...(link.experimentId !== undefined ? {
1151
+ experimentId: link.experimentId
1152
+ } : {}),
1153
+ ...(link.variantId !== undefined ? {
1154
+ variantId: link.variantId
1155
+ } : {})
775
1156
  });
776
1157
  return 'matched';
777
1158
  }
1159
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
778
1160
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
779
1161
  return 'retry_scheduled';
780
1162
  } catch (error) {
1163
+ if (disposed) return 'ineligible';
781
1164
  reportError(error);
1165
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
782
1166
  scheduleFirstOpenRetry();
783
1167
  return 'retry_scheduled';
784
1168
  }
785
1169
  };
1170
+ const matchFirstOpen = context => {
1171
+ if (firstOpenMatching) return firstOpenMatching;
1172
+ let owned;
1173
+ owned = matchFirstOpenOnce(context).finally(() => {
1174
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1175
+ });
1176
+ firstOpenMatching = owned;
1177
+ return owned;
1178
+ };
786
1179
  const captureAndroidInstallReferrer = async bridge => {
787
1180
  let raw;
788
1181
  try {
@@ -807,13 +1200,14 @@ function createPulseLinkClient(options) {
807
1200
  retryTimer = null;
808
1201
  }
809
1202
  if (disposed || options.autoRetry === false) return;
810
- const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
1203
+ const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0, state.terminalDelivery?.nextRetryAt ?? 0].filter(value => value > now());
811
1204
  if (candidates.length === 0) return;
812
1205
  const next = Math.min(...candidates);
813
1206
  retryTimer = setTimeout(() => {
814
1207
  retryTimer = null;
815
1208
  void process();
816
1209
  void flushResolverOutcomes();
1210
+ void flushTerminalDelivery();
817
1211
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
818
1212
  void matchFirstOpen(lastFirstOpenContext);
819
1213
  }
@@ -833,6 +1227,7 @@ function createPulseLinkClient(options) {
833
1227
  scheduleWake();
834
1228
  void process();
835
1229
  void flushResolverOutcomes();
1230
+ void flushTerminalDelivery();
836
1231
  return {
837
1232
  capture,
838
1233
  captureUrl: url => {
@@ -850,6 +1245,8 @@ function createPulseLinkClient(options) {
850
1245
  dispose: () => {
851
1246
  if (disposed) return;
852
1247
  disposed = true;
1248
+ deterministicEpoch += 1;
1249
+ terminalDeliveryGeneration += 1;
853
1250
  if (retryTimer) clearTimeout(retryTimer);
854
1251
  retryTimer = null;
855
1252
  unsubscribeAccount?.();
@@ -859,6 +1256,10 @@ function createPulseLinkClient(options) {
859
1256
  reset: () => {
860
1257
  accountRetryAt = 0;
861
1258
  accountAttempts = 0;
1259
+ deterministicEpoch += 1;
1260
+ terminalDeliveryGeneration += 1;
1261
+ terminalDeliverySending = null;
1262
+ deterministicTokenDeferredAfterCommittedJourney = null;
862
1263
  state = emptyState();
863
1264
  persistAndNotify();
864
1265
  }
@@ -902,11 +1303,11 @@ function stableOutcomeKey(linkId, name) {
902
1303
  }
903
1304
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
904
1305
  }
905
- function boundedMetadata(value) {
1306
+ function boundedMetadata(value, maxLength = 256) {
906
1307
  if (value === null) return null;
907
1308
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
908
1309
  const result = String(value).trim();
909
- return result.length > 0 && result.length <= 256 ? result : undefined;
1310
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
910
1311
  }
911
1312
  function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
912
1313
  if (!raw || typeof raw !== 'object') return null;
@@ -928,9 +1329,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
928
1329
  if (!Number.isFinite(expiry) || expiry <= now) return null;
929
1330
  }
930
1331
  const source = boundedMetadata(value.source);
931
- const campaignId = boundedMetadata(value.campaignId);
932
- const experimentId = boundedMetadata(value.experimentId);
933
- const variantId = boundedMetadata(value.variantId);
1332
+ const campaignId = boundedMetadata(value.campaignId, 128);
1333
+ const experimentId = boundedMetadata(value.experimentId, 128);
1334
+ const variantId = boundedMetadata(value.variantId, 128);
934
1335
  return {
935
1336
  id,
936
1337
  ...(action ? {
@@ -967,6 +1368,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
967
1368
  function normalizeLocale(locale) {
968
1369
  return locale.trim().replace(/_/g, '-');
969
1370
  }
1371
+ function firstOpenNoRouteReason(response) {
1372
+ let raw = '';
1373
+ try {
1374
+ raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
1375
+ } catch {
1376
+ raw = '';
1377
+ }
1378
+ return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
1379
+ }
970
1380
  function normalizeAnonymousFirstOpenSignals(context) {
971
1381
  const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
972
1382
  const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
@@ -1058,6 +1468,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
1058
1468
  clearTimeout(timer);
1059
1469
  }
1060
1470
  }
1471
+ async function withPromiseTimeout(factory, timeoutMs, operation) {
1472
+ let timer = null;
1473
+ try {
1474
+ return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
1475
+ timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
1476
+ })]);
1477
+ } finally {
1478
+ if (timer) clearTimeout(timer);
1479
+ }
1480
+ }
1481
+ async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
1482
+ let timer = null;
1483
+ try {
1484
+ return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
1485
+ timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
1486
+ })]);
1487
+ } finally {
1488
+ if (timer) clearTimeout(timer);
1489
+ }
1490
+ }
1491
+ function normalizeTerminalDeliveryDisposition(value) {
1492
+ if (value === true || value === 'accepted') return 'accepted';
1493
+ if (value === 'drop') return 'drop';
1494
+ return 'retry';
1495
+ }
1061
1496
  async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
1062
1497
  return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
1063
1498
  method: 'POST',
@@ -1124,6 +1559,56 @@ function normalizeOccurredAt(value) {
1124
1559
  if (!Number.isFinite(timestamp)) return null;
1125
1560
  return new Date(timestamp).toISOString() === value ? value : null;
1126
1561
  }
1562
+ function readTerminalDelivery(raw) {
1563
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1564
+ const value = raw;
1565
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
1566
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
1567
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
1568
+ const reason = typeof value.reason === 'string' ? value.reason : null;
1569
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
1570
+ const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
1571
+ const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
1572
+ if (value.disposition === 'drop') {
1573
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
1574
+ if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
1575
+ return {
1576
+ eventId,
1577
+ disposition: 'drop',
1578
+ attempts,
1579
+ nextRetryAt
1580
+ };
1581
+ }
1582
+ const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
1583
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
1584
+ const metadata = {};
1585
+ let metadataValid = true;
1586
+ for (const key of ['campaignId', 'experimentId', 'variantId']) {
1587
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1588
+ const field = value[key];
1589
+ if (field === null) metadata[key] = null;else if (typeof field === 'string' && field.length > 0 && field.length <= 128 && field.trim() === field) metadata[key] = field;else metadataValid = false;
1590
+ }
1591
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
1592
+ const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis') || Object.prototype.hasOwnProperty.call(value, 'confidence') || Object.prototype.hasOwnProperty.call(value, 'campaignId') || Object.prototype.hasOwnProperty.call(value, 'experimentId') || Object.prototype.hasOwnProperty.call(value, 'variantId');
1593
+ const validSemanticOutcome = status === 'FOUND' ? rail === 'fast_route' && value.routed === true && reason === 'matched' && hasFoundDimensions && matchBasis === 'unique_probabilistic' && confidence !== null && metadataValid : status === 'NOT_FOUND' ? rail === 'no_route' && value.routed === false && reason !== null && NO_ROUTE_REASONS.has(reason) && !hasAnyAttributionDimension : status === 'FAILURE' ? rail === 'no_route' && value.routed === false && reason !== null && FAILURE_REASONS.has(reason) && !hasAnyAttributionDimension : false;
1594
+ if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
1595
+ return {
1596
+ eventId,
1597
+ status,
1598
+ rail,
1599
+ routed: value.routed,
1600
+ retryable: false,
1601
+ reason,
1602
+ occurredAt,
1603
+ ...(status === 'FOUND' ? {
1604
+ matchBasis: matchBasis,
1605
+ confidence: confidence,
1606
+ ...metadata
1607
+ } : {}),
1608
+ attempts,
1609
+ nextRetryAt
1610
+ };
1611
+ }
1127
1612
  function readState(storage, key) {
1128
1613
  if (!storage) return emptyState();
1129
1614
  try {
@@ -1150,6 +1635,7 @@ function readState(storage, key) {
1150
1635
  const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
1151
1636
  const notifiedOutcomes = Array.isArray(parsed.notifiedOutcomes) ? parsed.notifiedOutcomes.filter(value => typeof value === 'string' && /^[a-f0-9]{16}\|(app_open_confirmed|deferred_link_resolved|action_applied)$/.test(value)).slice(-MAX_NOTIFIED_OUTCOMES) : [];
1152
1637
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
1638
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1153
1639
  return {
1154
1640
  version: 1,
1155
1641
  status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
@@ -1158,6 +1644,7 @@ function readState(storage, key) {
1158
1644
  appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
1159
1645
  notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
1160
1646
  outcomeQueue,
1647
+ terminalDelivery,
1161
1648
  firstOpen: {
1162
1649
  installAttemptId: attemptId,
1163
1650
  completed: firstValue.completed === true,
@@ -1170,11 +1657,24 @@ function readState(storage, key) {
1170
1657
  return emptyState();
1171
1658
  }
1172
1659
  }
1173
- function writeState(storage, key, state) {
1174
- if (!storage) return;
1660
+ function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
1661
+ if (!storage) return true;
1662
+ const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
1663
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
1175
1664
  try {
1176
- void storage.set(key, JSON.stringify(state));
1177
- } catch {/* in-memory operation continues */}
1665
+ const result = storage.set(key, JSON.stringify(state));
1666
+ if (isThenable(result)) {
1667
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
1668
+ void Promise.resolve(result).catch(() => undefined);
1669
+ return false;
1670
+ }
1671
+ return !knownAsync;
1672
+ } catch {
1673
+ return false;
1674
+ }
1675
+ }
1676
+ function isThenable(value) {
1677
+ return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
1178
1678
  }
1179
1679
  function safeTimestamp(value) {
1180
1680
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;