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.
@@ -7,6 +7,14 @@
7
7
  * optional account bridge below.
8
8
  */
9
9
 
10
+ /** Explicit receiver decision for one terminal-delivery attempt. */
11
+
12
+ /**
13
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
14
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
15
+ * can never make the client navigate, including when Encore reports a shadow observation.
16
+ */
17
+
10
18
  /** Implemented by the separate native `PulseAttribution` module. */
11
19
 
12
20
  const OPAQUE_TOKEN = /^[A-Za-z0-9_-]{16,512}$/;
@@ -23,6 +31,14 @@ const STATUSES = new Set(['idle', 'pending', 'resolving', 'waiting_for_account',
23
31
  const INSTALL_REFERRER_STATUSES = new Set(['OK', 'NO_TOKEN', 'FEATURE_NOT_SUPPORTED', 'SERVICE_UNAVAILABLE', 'DEVELOPER_ERROR', 'SERVICE_DISCONNECTED']);
24
32
  const DEFAULT_ACTIONS = ['open_home', 'open_premium', 'manage_subscription', 'refresh_entitlement'];
25
33
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'];
34
+ const PROBABILISTIC_ACTIONS = new Set(['open_home', 'open_premium']);
35
+ const CAPTURE_PRIORITY = {
36
+ direct_token: 3,
37
+ ios_user_paste: 2,
38
+ android_install_referrer: 1
39
+ };
40
+ const NO_ROUTE_REASONS = new Set(['unmatched', 'ambiguous', 'holdout', 'low_confidence', 'shadow_would_route', 'shadow_attributed', 'analytics_attributed', 'target_revoked', 'disabled', 'expired_replay']);
41
+ const FAILURE_REASONS = new Set(['invalid_request', 'invalid_response', 'policy_rejected']);
26
42
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
27
43
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
28
44
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -33,10 +49,14 @@ const MAX_APPLIED_IDS = 32;
33
49
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
34
50
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
35
51
  const MAX_PERSISTED_BYTES = 131_072;
52
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet();
36
53
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
37
54
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
38
55
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
39
56
  const DEVICE_TYPES = new Set(['phone', 'tablet', 'tv', 'desktop', 'gaming_console', 'unknown']);
57
+
58
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
59
+
40
60
  const emptyState = () => ({
41
61
  version: 1,
42
62
  status: 'idle',
@@ -45,6 +65,7 @@ const emptyState = () => ({
45
65
  appliedIds: [],
46
66
  notifiedOutcomes: [],
47
67
  outcomeQueue: [],
68
+ terminalDelivery: null,
48
69
  firstOpen: {
49
70
  installAttemptId: null,
50
71
  completed: false,
@@ -145,6 +166,7 @@ export function createPulseLinkClient(options) {
145
166
  }
146
167
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
147
168
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
169
+ const terminalDeliveryTimeoutMs = boundedDuration(options.terminalDeliveryTimeoutMs, requestTimeoutMs, 100, 60_000);
148
170
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
149
171
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
150
172
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -153,11 +175,21 @@ export function createPulseLinkClient(options) {
153
175
  let state = readState(options.storage, storageKey);
154
176
  let disposed = false;
155
177
  let processing = null;
178
+ let firstOpenMatching = null;
156
179
  let processRequested = false;
157
180
  let retryTimer = null;
158
181
  let outcomeSending = null;
182
+ let terminalDeliverySending = null;
183
+ let terminalDeliveryGeneration = 0;
159
184
  let accountRetryAt = 0;
160
185
  let accountAttempts = 0;
186
+ let deterministicEpoch = 0;
187
+ // Host navigation is irreversible once any routing callback has started: it may perform its
188
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
189
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
190
+ // automatically opening a second journey behind the first one.
191
+ let routingApplicationInFlight = false;
192
+ let deterministicTokenDeferredAfterCommittedJourney = null;
161
193
  let lastFirstOpenContext = null;
162
194
  let unsubscribeAccount = null;
163
195
  const listeners = new Set();
@@ -166,6 +198,25 @@ export function createPulseLinkClient(options) {
166
198
  options.onError?.(error);
167
199
  } catch {/* diagnostic hooks never break link handling */}
168
200
  };
201
+
202
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
203
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
204
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
205
+ if (!options.onFirstOpenResult && state.terminalDelivery && state.terminalDelivery.disposition !== 'drop') {
206
+ const tombstone = {
207
+ eventId: state.terminalDelivery.eventId,
208
+ disposition: 'drop',
209
+ attempts: state.terminalDelivery.attempts,
210
+ nextRetryAt: 0
211
+ };
212
+ state = {
213
+ ...state,
214
+ terminalDelivery: tombstone
215
+ };
216
+ if (!writeState(options.storage, storageKey, state)) {
217
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
218
+ }
219
+ }
169
220
  const snapshot = () => ({
170
221
  status: state.status,
171
222
  pending: state.pending ? {
@@ -177,8 +228,7 @@ export function createPulseLinkClient(options) {
177
228
  ...state.firstOpen
178
229
  }
179
230
  });
180
- const persistAndNotify = () => {
181
- writeState(options.storage, storageKey, state);
231
+ const notifyState = () => {
182
232
  const value = snapshot();
183
233
  for (const listener of listeners) {
184
234
  try {
@@ -194,6 +244,11 @@ export function createPulseLinkClient(options) {
194
244
  }
195
245
  scheduleWake();
196
246
  };
247
+ const persistAndNotify = () => {
248
+ const persisted = writeState(options.storage, storageKey, state);
249
+ notifyState();
250
+ return persisted;
251
+ };
197
252
  const patchState = patch => {
198
253
  state = {
199
254
  ...state,
@@ -211,6 +266,205 @@ export function createPulseLinkClient(options) {
211
266
  };
212
267
  persistAndNotify();
213
268
  };
269
+ const terminalDeliveryOutcome = queued => ({
270
+ eventId: queued.eventId,
271
+ status: queued.status,
272
+ rail: queued.rail,
273
+ routed: queued.routed,
274
+ retryable: false,
275
+ reason: queued.reason,
276
+ occurredAt: queued.occurredAt,
277
+ ...(queued.matchBasis !== undefined ? {
278
+ matchBasis: queued.matchBasis
279
+ } : {}),
280
+ ...(queued.confidence !== undefined ? {
281
+ confidence: queued.confidence
282
+ } : {}),
283
+ ...(queued.campaignId !== undefined ? {
284
+ campaignId: queued.campaignId
285
+ } : {}),
286
+ ...(queued.experimentId !== undefined ? {
287
+ experimentId: queued.experimentId
288
+ } : {}),
289
+ ...(queued.variantId !== undefined ? {
290
+ variantId: queued.variantId
291
+ } : {})
292
+ });
293
+ const scheduleTerminalDeliveryRetry = queued => {
294
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
295
+ const attempts = Math.min(queued.attempts + 1, 100_000);
296
+ state = {
297
+ ...state,
298
+ terminalDelivery: {
299
+ ...queued,
300
+ attempts,
301
+ nextRetryAt: now() + resolverOutcomeRetryDelay(queued.eventId, attempts, retryBaseMs, retryMaxMs)
302
+ }
303
+ };
304
+ persistAndNotify();
305
+ };
306
+ const acknowledgeTerminalDelivery = queued => {
307
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
308
+ const acknowledgedState = {
309
+ ...state,
310
+ terminalDelivery: null
311
+ };
312
+ // Clearing the record is itself transactional. If this write fails after the receiver
313
+ // accepted the event, retain and replay the same eventId: that is why the contract is
314
+ // at-least-once and why receivers must deduplicate.
315
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
316
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
317
+ scheduleTerminalDeliveryRetry(queued);
318
+ return false;
319
+ }
320
+ state = acknowledgedState;
321
+ notifyState();
322
+ return true;
323
+ };
324
+ const flushTerminalDelivery = () => {
325
+ if (disposed) return Promise.resolve(false);
326
+ if (terminalDeliverySending) return terminalDeliverySending;
327
+ const queued = state.terminalDelivery;
328
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
329
+ if (queued.disposition === 'drop') {
330
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
331
+ }
332
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
333
+ const generation = terminalDeliveryGeneration;
334
+ const run = async () => {
335
+ // Never call the receiver before the complete record is durable. With no storage adapter,
336
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
337
+ if (!writeState(options.storage, storageKey, state)) {
338
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
339
+ scheduleTerminalDeliveryRetry(queued);
340
+ return false;
341
+ }
342
+ let rawDisposition;
343
+ try {
344
+ rawDisposition = options.onFirstOpenResult(terminalDeliveryOutcome(queued));
345
+ } catch (error) {
346
+ reportError(error);
347
+ scheduleTerminalDeliveryRetry(queued);
348
+ return false;
349
+ }
350
+ let disposition;
351
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
352
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
353
+ } else {
354
+ try {
355
+ const resolved = await withTerminalDeliveryTimeout(Promise.resolve(rawDisposition), terminalDeliveryTimeoutMs);
356
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
357
+ } catch (error) {
358
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
359
+ if (!disposed && terminalDeliveryGeneration === generation) {
360
+ scheduleTerminalDeliveryRetry(queued);
361
+ }
362
+ return false;
363
+ }
364
+ }
365
+ if (disposed || terminalDeliveryGeneration !== generation || state.terminalDelivery?.eventId !== queued.eventId) return false;
366
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
367
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
368
+ scheduleTerminalDeliveryRetry(queued);
369
+ return false;
370
+ };
371
+ let owned;
372
+ owned = run().finally(() => {
373
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
374
+ const pending = state.terminalDelivery;
375
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
376
+ void flushTerminalDelivery();
377
+ }
378
+ });
379
+ terminalDeliverySending = owned;
380
+ return owned;
381
+ };
382
+ const dropTerminalDelivery = queued => {
383
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
384
+ const tombstone = {
385
+ eventId: queued.eventId,
386
+ disposition: 'drop',
387
+ attempts: queued.attempts,
388
+ nextRetryAt: 0
389
+ };
390
+ state = {
391
+ ...state,
392
+ terminalDelivery: tombstone
393
+ };
394
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
395
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
396
+ if (!writeState(options.storage, storageKey, state)) {
397
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
398
+ scheduleTerminalDeliveryRetry(tombstone);
399
+ return false;
400
+ }
401
+ notifyState();
402
+ return acknowledgeTerminalDelivery(tombstone);
403
+ };
404
+ const completeFirstOpen = (status, outcome) => {
405
+ let shouldQueue = Boolean(options.onFirstOpenResult);
406
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
407
+ try {
408
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
409
+ } catch (error) {
410
+ shouldQueue = false;
411
+ reportError(error);
412
+ }
413
+ }
414
+ if (!shouldQueue) {
415
+ state = {
416
+ ...state,
417
+ status,
418
+ terminalDelivery: null,
419
+ firstOpen: {
420
+ ...state.firstOpen,
421
+ completed: true,
422
+ attempts: 0,
423
+ nextRetryAt: 0
424
+ }
425
+ };
426
+ persistAndNotify();
427
+ return;
428
+ }
429
+ const existingEventIds = new Set([...state.outcomeQueue.map(queued => queued.eventId), ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []), ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : [])]);
430
+ const delivery = state.terminalDelivery ?? {
431
+ ...outcome,
432
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
433
+ retryable: false,
434
+ occurredAt: new Date(now()).toISOString(),
435
+ attempts: 0,
436
+ nextRetryAt: 0
437
+ };
438
+ state = {
439
+ ...state,
440
+ status,
441
+ terminalDelivery: delivery,
442
+ firstOpen: {
443
+ ...state.firstOpen,
444
+ completed: true,
445
+ attempts: 0,
446
+ nextRetryAt: 0
447
+ }
448
+ };
449
+ if (!persistAndNotify()) {
450
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
451
+ }
452
+ // Delivery is deliberately detached from routing/matcher completion.
453
+ void flushTerminalDelivery();
454
+ };
455
+ const blockFirstOpenForDeterministic = () => {
456
+ deterministicEpoch += 1;
457
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
458
+ state = {
459
+ ...state,
460
+ firstOpen: {
461
+ ...state.firstOpen,
462
+ completed: true,
463
+ nextRetryAt: 0
464
+ }
465
+ };
466
+ persistAndNotify();
467
+ };
214
468
  const isAccountReady = () => {
215
469
  try {
216
470
  return options.isAccountReady?.() ?? false;
@@ -443,9 +697,24 @@ export function createPulseLinkClient(options) {
443
697
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
444
698
  return false;
445
699
  }
700
+ let applied = false;
701
+ let applicationFailed = false;
702
+ let applicationError;
703
+ const applicationEpoch = deterministicEpoch;
704
+ const hasNewerPendingIntent = () => deterministicEpoch !== applicationEpoch && state.pending !== null
705
+ // A provenance upgrade or rejected lower-priority capture for the token already being
706
+ // applied does not represent another destination and must not leave that token pending.
707
+ && (sourceToken === null || state.pending.token !== sourceToken);
708
+ routingApplicationInFlight = true;
446
709
  emitOutcome(link, 'app_open_confirmed');
447
710
  emitOutcome(link, 'deferred_link_resolved');
448
- let applied;
711
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
712
+ // a newer accepted destination, it still arrived before the routing callback and must win.
713
+ if (hasNewerPendingIntent()) {
714
+ routingApplicationInFlight = false;
715
+ void process();
716
+ return false;
717
+ }
449
718
  try {
450
719
  if (link.deepLink) {
451
720
  applied = await options.onDeepLink(link.deepLink, link);
@@ -456,18 +725,41 @@ export function createPulseLinkClient(options) {
456
725
  return false;
457
726
  }
458
727
  } catch (error) {
459
- reportError(error);
728
+ applicationFailed = true;
729
+ applicationError = error;
730
+ } finally {
731
+ routingApplicationInFlight = false;
732
+ }
733
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
734
+ if (applicationFailed) {
735
+ reportError(applicationError);
736
+ if (newerIntentArrivedDuringApplication) {
737
+ // The current action did not commit. Resume the newer accepted intent that was captured
738
+ // while its callback was suspended, without scheduling a retry for the superseded one.
739
+ void process();
740
+ return false;
741
+ }
460
742
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
461
743
  return false;
462
744
  }
463
745
  if (applied === false) {
746
+ if (newerIntentArrivedDuringApplication) {
747
+ void process();
748
+ return false;
749
+ }
464
750
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();else scheduleAccountRetry();
465
751
  return false;
466
752
  }
753
+ if (newerIntentArrivedDuringApplication) {
754
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
755
+ // callback may already have navigated, so automatically applying the newly captured token
756
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
757
+ deterministicTokenDeferredAfterCommittedJourney = state.pending.token;
758
+ }
467
759
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
468
760
  const newlyAppliedSet = new Set(newlyApplied);
469
761
  const appliedIds = [...state.appliedIds.filter(id => !newlyAppliedSet.has(id)), ...newlyApplied].slice(-MAX_APPLIED_IDS);
470
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
762
+ const stillPending = sourceToken !== null ? state.pending?.token !== sourceToken : newerIntentArrivedDuringApplication;
471
763
  state = {
472
764
  ...state,
473
765
  status: stillPending ? 'pending' : 'applied',
@@ -481,7 +773,9 @@ export function createPulseLinkClient(options) {
481
773
  accountRetryAt = 0;
482
774
  }
483
775
  emitOutcome(link, 'action_applied');
484
- if (stillPending) void process();
776
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
777
+ void process();
778
+ }
485
779
  return true;
486
780
  };
487
781
  const resolvePublic = async (token, basis) => {
@@ -518,8 +812,13 @@ export function createPulseLinkClient(options) {
518
812
  };
519
813
  const processOnce = async () => {
520
814
  if (disposed) return;
815
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
816
+ // application concurrently, and never auto-drain the exact token preserved after an already
817
+ // committed journey in this client lifecycle.
818
+ if (routingApplicationInFlight) return;
521
819
  const pending = state.pending;
522
820
  if (pending) {
821
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
523
822
  if (pending.nextRetryAt > now()) return;
524
823
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
525
824
  clearPendingAsTerminal();
@@ -546,7 +845,7 @@ export function createPulseLinkClient(options) {
546
845
  return;
547
846
  }
548
847
  try {
549
- const raw = await options.accountBridge.claim(token);
848
+ const raw = await withPromiseTimeout(() => options.accountBridge.claim(token), requestTimeoutMs, 'account claim');
550
849
  if (disposed || state.pending?.token !== token) return;
551
850
  const normalizedClaim = normalizeResolved(raw, token, 'account_bound', allowedActions, now());
552
851
  const claimed = normalizedClaim ? {
@@ -555,7 +854,10 @@ export function createPulseLinkClient(options) {
555
854
  matchGuaranteed: true,
556
855
  confidence: 1
557
856
  } : null;
558
- if (claimed) await applyResolved(claimed, token);else clearPendingAsTerminal();
857
+ if (claimed) {
858
+ blockFirstOpenForDeterministic();
859
+ await applyResolved(claimed, token);
860
+ } else clearPendingAsTerminal();
559
861
  } catch (error) {
560
862
  reportError(error);
561
863
  if (state.pending?.token === token) schedulePendingRetry();
@@ -563,8 +865,13 @@ export function createPulseLinkClient(options) {
563
865
  return;
564
866
  }
565
867
  if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
868
+ const accountPendingEpoch = deterministicEpoch;
566
869
  try {
567
- const raw = await options.accountBridge.pending();
870
+ const raw = await withPromiseTimeout(() => options.accountBridge.pending(), requestTimeoutMs, 'account pending');
871
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
872
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
873
+ // captured token; otherwise both deterministic destinations could be applied.
874
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
568
875
  if (!raw) {
569
876
  accountAttempts = 0;
570
877
  accountRetryAt = 0;
@@ -583,14 +890,21 @@ export function createPulseLinkClient(options) {
583
890
  matchGuaranteed: true,
584
891
  confidence: 1
585
892
  } : null;
586
- if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
587
- await applyResolved(pendingLink, null);
588
- } else if (pendingLink && state.status !== 'applied') {
589
- patchState({
590
- status: 'applied'
591
- });
893
+ if (pendingLink) {
894
+ // Reserve the first-open journey for deterministic recovery before invoking any host
895
+ // callback. This also covers an already-applied account item restored from older SDK
896
+ // state, which must still prevent a second probabilistic destination.
897
+ blockFirstOpenForDeterministic();
898
+ if (!state.appliedIds.includes(pendingLink.id)) {
899
+ await applyResolved(pendingLink, null);
900
+ } else if (state.status !== 'applied') {
901
+ patchState({
902
+ status: 'applied'
903
+ });
904
+ }
592
905
  }
593
906
  } catch (error) {
907
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
594
908
  reportError(error);
595
909
  // No token was consumed; the authenticated server outbox remains authoritative.
596
910
  scheduleAccountRetry();
@@ -598,6 +912,8 @@ export function createPulseLinkClient(options) {
598
912
  };
599
913
  const process = async () => {
600
914
  if (disposed) return;
915
+ // Application delivery is independent: never await it on the routing drain.
916
+ void flushTerminalDelivery();
601
917
  processRequested = true;
602
918
  if (processing) return processing;
603
919
  const run = (async () => {
@@ -621,6 +937,7 @@ export function createPulseLinkClient(options) {
621
937
  const token = normalizeDeferredHandoffToken(rawToken);
622
938
  if (!token) return false;
623
939
  if (state.appliedIds.includes(token)) {
940
+ deterministicEpoch += 1;
624
941
  if (!state.firstOpen.completed) patchFirstOpen({
625
942
  completed: true,
626
943
  nextRetryAt: 0
@@ -628,7 +945,8 @@ export function createPulseLinkClient(options) {
628
945
  return true;
629
946
  }
630
947
  if (state.pending?.token === token) {
631
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
948
+ deterministicEpoch += 1;
949
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
632
950
  state = {
633
951
  ...state,
634
952
  pending: {
@@ -651,6 +969,20 @@ export function createPulseLinkClient(options) {
651
969
  void process();
652
970
  return true;
653
971
  }
972
+
973
+ // Exact transports are deterministic, but when more than one arrives during cold start their
974
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
975
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
976
+ if (state.pending && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
977
+ deterministicEpoch += 1;
978
+ if (!state.firstOpen.completed) patchFirstOpen({
979
+ completed: true,
980
+ nextRetryAt: 0
981
+ });
982
+ void process();
983
+ return true;
984
+ }
985
+ deterministicEpoch += 1;
654
986
  state = {
655
987
  ...state,
656
988
  status: 'pending',
@@ -671,14 +1003,27 @@ export function createPulseLinkClient(options) {
671
1003
  void process();
672
1004
  return true;
673
1005
  };
674
- const matchFirstOpen = async context => {
1006
+ const matchFirstOpenOnce = async context => {
675
1007
  if (disposed) return 'ineligible';
676
1008
  lastFirstOpenContext = context;
677
1009
  if (state.pending) return 'deterministic_pending';
678
1010
  if (state.firstOpen.completed) return 'already_completed';
679
1011
  if (state.firstOpen.nextRetryAt > now()) return 'backoff';
680
1012
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1013
+
1014
+ // The client starts account recovery at construction. Join that deterministic rail before
1015
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1016
+ // after the probabilistic destination and the user observes both journeys.
1017
+ await process();
1018
+ if (disposed) return 'ineligible';
1019
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1020
+ if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1021
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1022
+ return 'retry_scheduled';
1023
+ }
1024
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
681
1025
  const installAttemptId = state.firstOpen.installAttemptId ?? makeInstallAttemptId(options.randomUUID);
1026
+ const startingDeterministicEpoch = deterministicEpoch;
682
1027
  state = {
683
1028
  ...state,
684
1029
  status: 'resolving',
@@ -706,26 +1051,23 @@ export function createPulseLinkClient(options) {
706
1051
  },
707
1052
  body: JSON.stringify(body)
708
1053
  }, requestTimeoutMs);
709
- if (state.pending) return 'deterministic_pending';
710
- if (state.firstOpen.completed) return 'deterministic_pending';
1054
+ if (disposed) return 'ineligible';
1055
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
711
1056
  if (response.status === 204) {
712
- patchFirstOpen({
713
- completed: true,
714
- attempts: 0,
715
- nextRetryAt: 0
716
- });
717
- patchState({
718
- status: 'idle'
1057
+ completeFirstOpen('idle', {
1058
+ status: 'NOT_FOUND',
1059
+ rail: 'no_route',
1060
+ routed: false,
1061
+ reason: firstOpenNoRouteReason(response)
719
1062
  });
720
1063
  return 'no_match';
721
1064
  }
722
- if (response.status === 400 || response.status === 404 || response.status === 410) {
723
- patchFirstOpen({
724
- completed: true,
725
- nextRetryAt: 0
726
- });
727
- patchState({
728
- status: 'terminal_error'
1065
+ if (response.status === 400 || response.status === 404 || response.status === 410 || response.status === 413) {
1066
+ completeFirstOpen('terminal_error', {
1067
+ status: 'FAILURE',
1068
+ rail: 'no_route',
1069
+ routed: false,
1070
+ reason: 'invalid_request'
729
1071
  });
730
1072
  return 'terminal_error';
731
1073
  }
@@ -734,44 +1076,95 @@ export function createPulseLinkClient(options) {
734
1076
  scheduleFirstOpenRetry();
735
1077
  return 'retry_scheduled';
736
1078
  }
737
- patchFirstOpen({
738
- completed: true,
739
- nextRetryAt: 0
1079
+ completeFirstOpen('terminal_error', {
1080
+ status: 'FAILURE',
1081
+ rail: 'no_route',
1082
+ routed: false,
1083
+ reason: 'invalid_response'
740
1084
  });
741
- patchState({
742
- status: 'terminal_error'
1085
+ return 'terminal_error';
1086
+ }
1087
+ let rawLink;
1088
+ try {
1089
+ rawLink = await response.json();
1090
+ } catch {
1091
+ completeFirstOpen('terminal_error', {
1092
+ status: 'FAILURE',
1093
+ rail: 'no_route',
1094
+ routed: false,
1095
+ reason: 'invalid_response'
743
1096
  });
744
1097
  return 'terminal_error';
745
1098
  }
746
- const link = normalizeResolved(await response.json(), installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
747
- // The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
748
- // evidence to guaranteed or return a deterministic/account basis.
749
- if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.action && sensitiveActions.has(link.action)) {
750
- patchFirstOpen({
751
- completed: true,
752
- nextRetryAt: 0
1099
+ if (disposed) return 'ineligible';
1100
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
1101
+ const link = normalizeResolved(rawLink, installAttemptId.replace(/-/g, ''), 'unique_probabilistic', allowedActions, now());
1102
+ if (!link) {
1103
+ completeFirstOpen('terminal_error', {
1104
+ status: 'FAILURE',
1105
+ rail: 'no_route',
1106
+ routed: false,
1107
+ reason: 'invalid_response'
753
1108
  });
754
- patchState({
755
- status: 'terminal_error'
1109
+ return 'terminal_error';
1110
+ }
1111
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1112
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1113
+ // and even an app-owned deepLink are rejected before application code runs.
1114
+ if (link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed || link.deepLink !== undefined || !link.action || !PROBABILISTIC_ACTIONS.has(String(link.action)) || sensitiveActions.has(link.action) || !options.onAction) {
1115
+ completeFirstOpen('terminal_error', {
1116
+ status: 'FAILURE',
1117
+ rail: 'no_route',
1118
+ routed: false,
1119
+ reason: 'policy_rejected'
756
1120
  });
757
1121
  return 'terminal_error';
758
1122
  }
1123
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
759
1124
  if (await applyResolved(link, null)) {
760
- patchFirstOpen({
761
- completed: true,
762
- attempts: 0,
763
- nextRetryAt: 0
1125
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1126
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1127
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1128
+ // truth that this probabilistic journey really routed.
1129
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1130
+ status: 'FOUND',
1131
+ rail: 'fast_route',
1132
+ routed: true,
1133
+ reason: 'matched',
1134
+ matchBasis: link.matchBasis,
1135
+ confidence: link.confidence,
1136
+ ...(link.campaignId !== undefined ? {
1137
+ campaignId: link.campaignId
1138
+ } : {}),
1139
+ ...(link.experimentId !== undefined ? {
1140
+ experimentId: link.experimentId
1141
+ } : {}),
1142
+ ...(link.variantId !== undefined ? {
1143
+ variantId: link.variantId
1144
+ } : {})
764
1145
  });
765
1146
  return 'matched';
766
1147
  }
1148
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
767
1149
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
768
1150
  return 'retry_scheduled';
769
1151
  } catch (error) {
1152
+ if (disposed) return 'ineligible';
770
1153
  reportError(error);
1154
+ if (deterministicEpoch !== startingDeterministicEpoch || state.pending || state.firstOpen.completed) return 'deterministic_pending';
771
1155
  scheduleFirstOpenRetry();
772
1156
  return 'retry_scheduled';
773
1157
  }
774
1158
  };
1159
+ const matchFirstOpen = context => {
1160
+ if (firstOpenMatching) return firstOpenMatching;
1161
+ let owned;
1162
+ owned = matchFirstOpenOnce(context).finally(() => {
1163
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1164
+ });
1165
+ firstOpenMatching = owned;
1166
+ return owned;
1167
+ };
775
1168
  const captureAndroidInstallReferrer = async bridge => {
776
1169
  let raw;
777
1170
  try {
@@ -796,13 +1189,14 @@ export function createPulseLinkClient(options) {
796
1189
  retryTimer = null;
797
1190
  }
798
1191
  if (disposed || options.autoRetry === false) return;
799
- const candidates = [state.pending?.nextRetryAt ?? 0, lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0, accountRetryAt, state.outcomeQueue[0]?.nextRetryAt ?? 0].filter(value => value > now());
1192
+ 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());
800
1193
  if (candidates.length === 0) return;
801
1194
  const next = Math.min(...candidates);
802
1195
  retryTimer = setTimeout(() => {
803
1196
  retryTimer = null;
804
1197
  void process();
805
1198
  void flushResolverOutcomes();
1199
+ void flushTerminalDelivery();
806
1200
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
807
1201
  void matchFirstOpen(lastFirstOpenContext);
808
1202
  }
@@ -822,6 +1216,7 @@ export function createPulseLinkClient(options) {
822
1216
  scheduleWake();
823
1217
  void process();
824
1218
  void flushResolverOutcomes();
1219
+ void flushTerminalDelivery();
825
1220
  return {
826
1221
  capture,
827
1222
  captureUrl: url => {
@@ -839,6 +1234,8 @@ export function createPulseLinkClient(options) {
839
1234
  dispose: () => {
840
1235
  if (disposed) return;
841
1236
  disposed = true;
1237
+ deterministicEpoch += 1;
1238
+ terminalDeliveryGeneration += 1;
842
1239
  if (retryTimer) clearTimeout(retryTimer);
843
1240
  retryTimer = null;
844
1241
  unsubscribeAccount?.();
@@ -848,6 +1245,10 @@ export function createPulseLinkClient(options) {
848
1245
  reset: () => {
849
1246
  accountRetryAt = 0;
850
1247
  accountAttempts = 0;
1248
+ deterministicEpoch += 1;
1249
+ terminalDeliveryGeneration += 1;
1250
+ terminalDeliverySending = null;
1251
+ deterministicTokenDeferredAfterCommittedJourney = null;
851
1252
  state = emptyState();
852
1253
  persistAndNotify();
853
1254
  }
@@ -891,11 +1292,11 @@ function stableOutcomeKey(linkId, name) {
891
1292
  }
892
1293
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
893
1294
  }
894
- function boundedMetadata(value) {
1295
+ function boundedMetadata(value, maxLength = 256) {
895
1296
  if (value === null) return null;
896
1297
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
897
1298
  const result = String(value).trim();
898
- return result.length > 0 && result.length <= 256 ? result : undefined;
1299
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
899
1300
  }
900
1301
  function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now) {
901
1302
  if (!raw || typeof raw !== 'object') return null;
@@ -917,9 +1318,9 @@ function normalizeResolved(raw, fallbackId, fallbackBasis, allowedActions, now)
917
1318
  if (!Number.isFinite(expiry) || expiry <= now) return null;
918
1319
  }
919
1320
  const source = boundedMetadata(value.source);
920
- const campaignId = boundedMetadata(value.campaignId);
921
- const experimentId = boundedMetadata(value.experimentId);
922
- const variantId = boundedMetadata(value.variantId);
1321
+ const campaignId = boundedMetadata(value.campaignId, 128);
1322
+ const experimentId = boundedMetadata(value.experimentId, 128);
1323
+ const variantId = boundedMetadata(value.variantId, 128);
923
1324
  return {
924
1325
  id,
925
1326
  ...(action ? {
@@ -956,6 +1357,15 @@ function isEligibleFirstOpen(context, now, maxAgeMs) {
956
1357
  function normalizeLocale(locale) {
957
1358
  return locale.trim().replace(/_/g, '-');
958
1359
  }
1360
+ function firstOpenNoRouteReason(response) {
1361
+ let raw = '';
1362
+ try {
1363
+ raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? '';
1364
+ } catch {
1365
+ raw = '';
1366
+ }
1367
+ return NO_ROUTE_REASONS.has(raw) ? raw : 'unmatched';
1368
+ }
959
1369
  function normalizeAnonymousFirstOpenSignals(context) {
960
1370
  const appVersion = normalizeSignalString(context.appVersion, VERSION_SIGNAL, 64);
961
1371
  const osVersion = normalizeSignalString(context.osVersion, VERSION_SIGNAL, 64);
@@ -1047,6 +1457,31 @@ async function withTimeout(fetcher, input, init, timeoutMs) {
1047
1457
  clearTimeout(timer);
1048
1458
  }
1049
1459
  }
1460
+ async function withPromiseTimeout(factory, timeoutMs, operation) {
1461
+ let timer = null;
1462
+ try {
1463
+ return await Promise.race([Promise.resolve().then(factory), new Promise((_resolve, reject) => {
1464
+ timer = setTimeout(() => reject(new Error(`Pulse Links: ${operation} timed out`)), timeoutMs);
1465
+ })]);
1466
+ } finally {
1467
+ if (timer) clearTimeout(timer);
1468
+ }
1469
+ }
1470
+ async function withTerminalDeliveryTimeout(acknowledgement, timeoutMs) {
1471
+ let timer = null;
1472
+ try {
1473
+ return await Promise.race([acknowledgement, new Promise((_resolve, reject) => {
1474
+ timer = setTimeout(() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')), timeoutMs);
1475
+ })]);
1476
+ } finally {
1477
+ if (timer) clearTimeout(timer);
1478
+ }
1479
+ }
1480
+ function normalizeTerminalDeliveryDisposition(value) {
1481
+ if (value === true || value === 'accepted') return 'accepted';
1482
+ if (value === 'drop') return 'drop';
1483
+ return 'retry';
1484
+ }
1050
1485
  async function postResolverOutcome(fetcher, resolverBaseUrl, outcome, timeoutMs) {
1051
1486
  return withTimeout(fetcher, `${resolverBaseUrl}${encodeURIComponent(outcome.token)}/event/${outcome.name}`, {
1052
1487
  method: 'POST',
@@ -1113,6 +1548,56 @@ function normalizeOccurredAt(value) {
1113
1548
  if (!Number.isFinite(timestamp)) return null;
1114
1549
  return new Date(timestamp).toISOString() === value ? value : null;
1115
1550
  }
1551
+ function readTerminalDelivery(raw) {
1552
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1553
+ const value = raw;
1554
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId) ? value.eventId.toLowerCase() : null;
1555
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE' ? value.status : null;
1556
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
1557
+ const reason = typeof value.reason === 'string' ? value.reason : null;
1558
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
1559
+ const attempts = typeof value.attempts === 'number' && Number.isSafeInteger(value.attempts) && value.attempts >= 0 && value.attempts <= 100_000 ? value.attempts : null;
1560
+ const nextRetryAt = typeof value.nextRetryAt === 'number' && Number.isSafeInteger(value.nextRetryAt) && value.nextRetryAt >= 0 && value.nextRetryAt <= 8_640_000_000_000_000 ? value.nextRetryAt : null;
1561
+ if (value.disposition === 'drop') {
1562
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
1563
+ if (!eventId || attempts === null || nextRetryAt === null || Object.keys(value).some(key => !allowedKeys.has(key))) return null;
1564
+ return {
1565
+ eventId,
1566
+ disposition: 'drop',
1567
+ attempts,
1568
+ nextRetryAt
1569
+ };
1570
+ }
1571
+ const matchBasis = typeof value.matchBasis === 'string' && MATCH_BASES.has(value.matchBasis) ? value.matchBasis : null;
1572
+ const confidence = typeof value.confidence === 'number' && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1 ? value.confidence : null;
1573
+ const metadata = {};
1574
+ let metadataValid = true;
1575
+ for (const key of ['campaignId', 'experimentId', 'variantId']) {
1576
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
1577
+ const field = value[key];
1578
+ 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;
1579
+ }
1580
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis') && Object.prototype.hasOwnProperty.call(value, 'confidence');
1581
+ 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');
1582
+ 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;
1583
+ if (!eventId || !status || !rail || !reason || !occurredAt || attempts === null || nextRetryAt === null || Object.prototype.hasOwnProperty.call(value, 'disposition') || value.retryable !== false || !validSemanticOutcome) return null;
1584
+ return {
1585
+ eventId,
1586
+ status,
1587
+ rail,
1588
+ routed: value.routed,
1589
+ retryable: false,
1590
+ reason,
1591
+ occurredAt,
1592
+ ...(status === 'FOUND' ? {
1593
+ matchBasis: matchBasis,
1594
+ confidence: confidence,
1595
+ ...metadata
1596
+ } : {}),
1597
+ attempts,
1598
+ nextRetryAt
1599
+ };
1600
+ }
1116
1601
  function readState(storage, key) {
1117
1602
  if (!storage) return emptyState();
1118
1603
  try {
@@ -1139,6 +1624,7 @@ function readState(storage, key) {
1139
1624
  const attemptId = typeof firstValue.installAttemptId === 'string' && INSTALL_ATTEMPT_ID.test(firstValue.installAttemptId) ? firstValue.installAttemptId.toLowerCase() : null;
1140
1625
  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) : [];
1141
1626
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
1627
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1142
1628
  return {
1143
1629
  version: 1,
1144
1630
  status: status === 'resolving' ? pending ? 'pending' : 'idle' : status,
@@ -1147,6 +1633,7 @@ function readState(storage, key) {
1147
1633
  appliedIds: [...new Set(appliedIds)].slice(-MAX_APPLIED_IDS),
1148
1634
  notifiedOutcomes: [...new Set([...notifiedOutcomes, ...outcomeQueue.map(outcome => outcome.transitionKey)])].slice(-MAX_NOTIFIED_OUTCOMES),
1149
1635
  outcomeQueue,
1636
+ terminalDelivery,
1150
1637
  firstOpen: {
1151
1638
  installAttemptId: attemptId,
1152
1639
  completed: firstValue.completed === true,
@@ -1159,11 +1646,24 @@ function readState(storage, key) {
1159
1646
  return emptyState();
1160
1647
  }
1161
1648
  }
1162
- function writeState(storage, key, state) {
1163
- if (!storage) return;
1649
+ function writeState(storage, key, state, allowKnownAsyncBestEffort = true) {
1650
+ if (!storage) return true;
1651
+ const knownAsync = storage.supportsDurableSyncWrites === false || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage);
1652
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
1164
1653
  try {
1165
- void storage.set(key, JSON.stringify(state));
1166
- } catch {/* in-memory operation continues */}
1654
+ const result = storage.set(key, JSON.stringify(state));
1655
+ if (isThenable(result)) {
1656
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage);
1657
+ void Promise.resolve(result).catch(() => undefined);
1658
+ return false;
1659
+ }
1660
+ return !knownAsync;
1661
+ } catch {
1662
+ return false;
1663
+ }
1664
+ }
1665
+ function isThenable(value) {
1666
+ return typeof value === 'object' && value !== null || typeof value === 'function' ? typeof value.then === 'function' : false;
1167
1667
  }
1168
1668
  function safeTimestamp(value) {
1169
1669
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;