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.
- package/README.md +35 -2
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/init.js +13 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/commonjs/links.js +561 -61
- package/lib/commonjs/links.js.map +1 -1
- package/lib/commonjs/track.js +198 -75
- package/lib/commonjs/track.js.map +1 -1
- package/lib/module/config.js.map +1 -1
- package/lib/module/init.js +14 -2
- package/lib/module/init.js.map +1 -1
- package/lib/module/links.js +561 -61
- package/lib/module/links.js.map +1 -1
- package/lib/module/track.js +197 -75
- package/lib/module/track.js.map +1 -1
- package/lib/typescript/config.d.ts +6 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +1 -0
- package/lib/typescript/init.d.ts.map +1 -1
- package/lib/typescript/links.d.ts +49 -0
- package/lib/typescript/links.d.ts.map +1 -1
- package/lib/typescript/track.d.ts +17 -6
- package/lib/typescript/track.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +6 -0
- package/src/init.ts +16 -1
- package/src/links.ts +810 -40
- package/src/track.ts +283 -81
package/src/links.ts
CHANGED
|
@@ -134,6 +134,51 @@ export type AnonymousFirstOpenResult =
|
|
|
134
134
|
| 'retry_scheduled'
|
|
135
135
|
| 'terminal_error';
|
|
136
136
|
|
|
137
|
+
export type AnonymousFirstOpenTerminalStatus = 'FOUND' | 'NOT_FOUND' | 'FAILURE';
|
|
138
|
+
|
|
139
|
+
/** Explicit receiver decision for one terminal-delivery attempt. */
|
|
140
|
+
export type AnonymousFirstOpenTerminalDisposition = 'accepted' | 'retry' | 'drop';
|
|
141
|
+
|
|
142
|
+
export type AnonymousFirstOpenTerminalReason =
|
|
143
|
+
| 'matched'
|
|
144
|
+
| 'unmatched'
|
|
145
|
+
| 'ambiguous'
|
|
146
|
+
| 'holdout'
|
|
147
|
+
| 'low_confidence'
|
|
148
|
+
| 'shadow_would_route'
|
|
149
|
+
| 'shadow_attributed'
|
|
150
|
+
| 'analytics_attributed'
|
|
151
|
+
| 'target_revoked'
|
|
152
|
+
| 'disabled'
|
|
153
|
+
| 'expired_replay'
|
|
154
|
+
| 'invalid_request'
|
|
155
|
+
| 'invalid_response'
|
|
156
|
+
| 'policy_rejected';
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
|
|
160
|
+
* install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
|
|
161
|
+
* can never make the client navigate, including when Encore reports a shadow observation.
|
|
162
|
+
*/
|
|
163
|
+
export interface AnonymousFirstOpenTerminalOutcome {
|
|
164
|
+
/** Stable UUIDv4 for receiver-side deduplication of at-least-once delivery. */
|
|
165
|
+
eventId: string;
|
|
166
|
+
status: AnonymousFirstOpenTerminalStatus;
|
|
167
|
+
rail: 'fast_route' | 'no_route';
|
|
168
|
+
routed: boolean;
|
|
169
|
+
retryable: false;
|
|
170
|
+
reason: AnonymousFirstOpenTerminalReason;
|
|
171
|
+
occurredAt: string;
|
|
172
|
+
/** Present only for FOUND and copied from the already-normalized resolver result. */
|
|
173
|
+
matchBasis?: DeferredLinkMatchBasis;
|
|
174
|
+
/** Present only for FOUND, finite and clamped to [0, 1]. */
|
|
175
|
+
confidence?: number;
|
|
176
|
+
/** Optional FOUND-only campaign dimensions; strings are trimmed and bounded to 128 chars. */
|
|
177
|
+
campaignId?: string | null;
|
|
178
|
+
experimentId?: string | null;
|
|
179
|
+
variantId?: string | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
137
182
|
export type AndroidInstallReferrerStatus =
|
|
138
183
|
| 'OK'
|
|
139
184
|
| 'NO_TOKEN'
|
|
@@ -163,7 +208,13 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
|
|
|
163
208
|
allowedActions?: readonly Action[];
|
|
164
209
|
/** Actions that require both a deterministic match and a stable signed-in account. */
|
|
165
210
|
sensitiveActions?: readonly Action[];
|
|
211
|
+
/**
|
|
212
|
+
* Routing callbacks are irreversible commit boundaries: return `false` when no navigation
|
|
213
|
+
* happened. If a newer accepted capture arrives while a successful callback is pending, Pulse
|
|
214
|
+
* persists it but will not auto-route a second journey in the same client lifecycle.
|
|
215
|
+
*/
|
|
166
216
|
onDeepLink: (deepLink: string, link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
217
|
+
/** Same commit-boundary contract as `onDeepLink`. */
|
|
167
218
|
onAction?: (link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
|
|
168
219
|
accountBridge?: DeferredLinkAccountBridge<Action>;
|
|
169
220
|
isAccountReady?: () => boolean;
|
|
@@ -179,11 +230,31 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
|
|
|
179
230
|
requestTimeoutMs?: number;
|
|
180
231
|
retryBaseMs?: number;
|
|
181
232
|
retryMaxMs?: number;
|
|
233
|
+
/** Timeout for one terminal-result acknowledgement attempt. Defaults to requestTimeoutMs. */
|
|
234
|
+
terminalDeliveryTimeoutMs?: number;
|
|
182
235
|
tokenMaxAgeMs?: number;
|
|
183
236
|
recentInstallMaxAgeMs?: number;
|
|
184
237
|
autoRetry?: boolean;
|
|
185
238
|
reportResolverOutcomes?: boolean;
|
|
186
239
|
onOutcome?: (event: DeferredLinkOutcomeEvent) => void | Promise<void>;
|
|
240
|
+
/**
|
|
241
|
+
* Synchronous privacy gate evaluated before a terminal record or eventId is created. A false
|
|
242
|
+
* result or throw completes first-open matching without retaining terminal metadata.
|
|
243
|
+
*/
|
|
244
|
+
shouldQueueFirstOpenResult?: () => boolean;
|
|
245
|
+
/**
|
|
246
|
+
* Durable at-least-once terminal delivery. Return `accepted` (or legacy `true`) only after the
|
|
247
|
+
* stable eventId is durably accepted, `retry`/false to replay it, or `drop` to deliberately and
|
|
248
|
+
* durably discard it (for example after consent revocation). Throw, rejection and timeout retry
|
|
249
|
+
* the same eventId. `drop` is sticky: failed local deletion retries never re-enter the receiver.
|
|
250
|
+
* Receivers must deduplicate because a crash after acceptance can replay it. When this handler
|
|
251
|
+
* is absent no terminal record is created or retained.
|
|
252
|
+
*/
|
|
253
|
+
onFirstOpenResult?: (
|
|
254
|
+
outcome: AnonymousFirstOpenTerminalOutcome,
|
|
255
|
+
) => AnonymousFirstOpenTerminalDisposition
|
|
256
|
+
| boolean
|
|
257
|
+
| Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
|
|
187
258
|
onStateChange?: (state: DeferredLinkState) => void;
|
|
188
259
|
onError?: (error: unknown) => void;
|
|
189
260
|
now?: () => number;
|
|
@@ -255,6 +326,29 @@ const DEFAULT_ACTIONS = [
|
|
|
255
326
|
'refresh_entitlement',
|
|
256
327
|
] as const;
|
|
257
328
|
const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'] as const;
|
|
329
|
+
const PROBABILISTIC_ACTIONS = new Set<string>(['open_home', 'open_premium']);
|
|
330
|
+
const CAPTURE_PRIORITY: Readonly<Record<DeferredLinkCaptureBasis, number>> = {
|
|
331
|
+
direct_token: 3,
|
|
332
|
+
ios_user_paste: 2,
|
|
333
|
+
android_install_referrer: 1,
|
|
334
|
+
};
|
|
335
|
+
const NO_ROUTE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
|
|
336
|
+
'unmatched',
|
|
337
|
+
'ambiguous',
|
|
338
|
+
'holdout',
|
|
339
|
+
'low_confidence',
|
|
340
|
+
'shadow_would_route',
|
|
341
|
+
'shadow_attributed',
|
|
342
|
+
'analytics_attributed',
|
|
343
|
+
'target_revoked',
|
|
344
|
+
'disabled',
|
|
345
|
+
'expired_replay',
|
|
346
|
+
]);
|
|
347
|
+
const FAILURE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
|
|
348
|
+
'invalid_request',
|
|
349
|
+
'invalid_response',
|
|
350
|
+
'policy_rejected',
|
|
351
|
+
]);
|
|
258
352
|
const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
|
|
259
353
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
260
354
|
const DEFAULT_RETRY_BASE_MS = 15_000;
|
|
@@ -265,6 +359,7 @@ const MAX_APPLIED_IDS = 32;
|
|
|
265
359
|
const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
|
|
266
360
|
const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
|
|
267
361
|
const MAX_PERSISTED_BYTES = 131_072;
|
|
362
|
+
const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet<object>();
|
|
268
363
|
const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
|
|
269
364
|
const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
|
|
270
365
|
const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -293,6 +388,24 @@ interface QueuedResolverOutcome {
|
|
|
293
388
|
nextRetryAt: number;
|
|
294
389
|
}
|
|
295
390
|
|
|
391
|
+
interface QueuedFirstOpenTerminalDelivery extends AnonymousFirstOpenTerminalOutcome {
|
|
392
|
+
disposition?: never;
|
|
393
|
+
attempts: number;
|
|
394
|
+
nextRetryAt: number;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
|
|
398
|
+
interface DroppedFirstOpenTerminalTombstone {
|
|
399
|
+
eventId: string;
|
|
400
|
+
disposition: 'drop';
|
|
401
|
+
attempts: number;
|
|
402
|
+
nextRetryAt: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
type PersistedFirstOpenTerminalDelivery =
|
|
406
|
+
| QueuedFirstOpenTerminalDelivery
|
|
407
|
+
| DroppedFirstOpenTerminalTombstone;
|
|
408
|
+
|
|
296
409
|
interface PersistedDeferredLinkState {
|
|
297
410
|
version: 1;
|
|
298
411
|
status: DeferredLinkStatus;
|
|
@@ -303,6 +416,8 @@ interface PersistedDeferredLinkState {
|
|
|
303
416
|
notifiedOutcomes: string[];
|
|
304
417
|
/** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
|
|
305
418
|
outcomeQueue: QueuedResolverOutcome[];
|
|
419
|
+
/** One-record at-least-once application delivery outbox, independent of matcher completion. */
|
|
420
|
+
terminalDelivery: PersistedFirstOpenTerminalDelivery | null;
|
|
306
421
|
firstOpen: DeferredLinkFirstOpenState;
|
|
307
422
|
}
|
|
308
423
|
|
|
@@ -319,6 +434,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
|
|
|
319
434
|
appliedIds: [],
|
|
320
435
|
notifiedOutcomes: [],
|
|
321
436
|
outcomeQueue: [],
|
|
437
|
+
terminalDelivery: null,
|
|
322
438
|
firstOpen: {
|
|
323
439
|
installAttemptId: null,
|
|
324
440
|
completed: false,
|
|
@@ -430,6 +546,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
430
546
|
}
|
|
431
547
|
const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
|
|
432
548
|
const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
|
|
549
|
+
const terminalDeliveryTimeoutMs = boundedDuration(
|
|
550
|
+
options.terminalDeliveryTimeoutMs,
|
|
551
|
+
requestTimeoutMs,
|
|
552
|
+
100,
|
|
553
|
+
60_000,
|
|
554
|
+
);
|
|
433
555
|
const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
|
|
434
556
|
const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
|
|
435
557
|
const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
|
|
@@ -443,11 +565,21 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
443
565
|
let state = readState(options.storage, storageKey);
|
|
444
566
|
let disposed = false;
|
|
445
567
|
let processing: Promise<void> | null = null;
|
|
568
|
+
let firstOpenMatching: Promise<AnonymousFirstOpenResult> | null = null;
|
|
446
569
|
let processRequested = false;
|
|
447
570
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
448
571
|
let outcomeSending: Promise<number> | null = null;
|
|
572
|
+
let terminalDeliverySending: Promise<boolean> | null = null;
|
|
573
|
+
let terminalDeliveryGeneration = 0;
|
|
449
574
|
let accountRetryAt = 0;
|
|
450
575
|
let accountAttempts = 0;
|
|
576
|
+
let deterministicEpoch = 0;
|
|
577
|
+
// Host navigation is irreversible once any routing callback has started: it may perform its
|
|
578
|
+
// side effect synchronously before returning a Promise. Serialize captures against that commit
|
|
579
|
+
// boundary and keep the newer accepted token durable for the next client lifecycle instead of
|
|
580
|
+
// automatically opening a second journey behind the first one.
|
|
581
|
+
let routingApplicationInFlight = false;
|
|
582
|
+
let deterministicTokenDeferredAfterCommittedJourney: string | null = null;
|
|
451
583
|
let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
|
|
452
584
|
let unsubscribeAccount: (() => void) | null = null;
|
|
453
585
|
const listeners = new Set<(value: DeferredLinkState) => void>();
|
|
@@ -456,6 +588,24 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
456
588
|
try { options.onError?.(error); } catch { /* diagnostic hooks never break link handling */ }
|
|
457
589
|
};
|
|
458
590
|
|
|
591
|
+
// A build without a receiver has not opted into retaining this analytics envelope. First replace
|
|
592
|
+
// an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
|
|
593
|
+
// may fail, but a later app version can then only retry local deletion, never resurrect delivery.
|
|
594
|
+
if (!options.onFirstOpenResult
|
|
595
|
+
&& state.terminalDelivery
|
|
596
|
+
&& state.terminalDelivery.disposition !== 'drop') {
|
|
597
|
+
const tombstone: DroppedFirstOpenTerminalTombstone = {
|
|
598
|
+
eventId: state.terminalDelivery.eventId,
|
|
599
|
+
disposition: 'drop',
|
|
600
|
+
attempts: state.terminalDelivery.attempts,
|
|
601
|
+
nextRetryAt: 0,
|
|
602
|
+
};
|
|
603
|
+
state = { ...state, terminalDelivery: tombstone };
|
|
604
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
605
|
+
reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
459
609
|
const snapshot = (): DeferredLinkState => ({
|
|
460
610
|
status: state.status,
|
|
461
611
|
pending: state.pending ? { ...state.pending } : null,
|
|
@@ -464,8 +614,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
464
614
|
firstOpen: { ...state.firstOpen },
|
|
465
615
|
});
|
|
466
616
|
|
|
467
|
-
const
|
|
468
|
-
writeState(options.storage, storageKey, state);
|
|
617
|
+
const notifyState = (): void => {
|
|
469
618
|
const value = snapshot();
|
|
470
619
|
for (const listener of listeners) {
|
|
471
620
|
try { listener(value); } catch (error) { reportError(error); }
|
|
@@ -474,6 +623,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
474
623
|
scheduleWake();
|
|
475
624
|
};
|
|
476
625
|
|
|
626
|
+
const persistAndNotify = (): boolean => {
|
|
627
|
+
const persisted = writeState(options.storage, storageKey, state);
|
|
628
|
+
notifyState();
|
|
629
|
+
return persisted;
|
|
630
|
+
};
|
|
631
|
+
|
|
477
632
|
const patchState = (patch: Partial<PersistedDeferredLinkState>): void => {
|
|
478
633
|
state = { ...state, ...patch };
|
|
479
634
|
persistAndNotify();
|
|
@@ -484,6 +639,229 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
484
639
|
persistAndNotify();
|
|
485
640
|
};
|
|
486
641
|
|
|
642
|
+
const terminalDeliveryOutcome = (
|
|
643
|
+
queued: QueuedFirstOpenTerminalDelivery,
|
|
644
|
+
): AnonymousFirstOpenTerminalOutcome => ({
|
|
645
|
+
eventId: queued.eventId,
|
|
646
|
+
status: queued.status,
|
|
647
|
+
rail: queued.rail,
|
|
648
|
+
routed: queued.routed,
|
|
649
|
+
retryable: false,
|
|
650
|
+
reason: queued.reason,
|
|
651
|
+
occurredAt: queued.occurredAt,
|
|
652
|
+
...(queued.matchBasis !== undefined ? { matchBasis: queued.matchBasis } : {}),
|
|
653
|
+
...(queued.confidence !== undefined ? { confidence: queued.confidence } : {}),
|
|
654
|
+
...(queued.campaignId !== undefined ? { campaignId: queued.campaignId } : {}),
|
|
655
|
+
...(queued.experimentId !== undefined ? { experimentId: queued.experimentId } : {}),
|
|
656
|
+
...(queued.variantId !== undefined ? { variantId: queued.variantId } : {}),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const scheduleTerminalDeliveryRetry = (
|
|
660
|
+
queued: PersistedFirstOpenTerminalDelivery,
|
|
661
|
+
): void => {
|
|
662
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
|
|
663
|
+
const attempts = Math.min(queued.attempts + 1, 100_000);
|
|
664
|
+
state = {
|
|
665
|
+
...state,
|
|
666
|
+
terminalDelivery: {
|
|
667
|
+
...queued,
|
|
668
|
+
attempts,
|
|
669
|
+
nextRetryAt: now() + resolverOutcomeRetryDelay(
|
|
670
|
+
queued.eventId,
|
|
671
|
+
attempts,
|
|
672
|
+
retryBaseMs,
|
|
673
|
+
retryMaxMs,
|
|
674
|
+
),
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
persistAndNotify();
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const acknowledgeTerminalDelivery = (
|
|
681
|
+
queued: PersistedFirstOpenTerminalDelivery,
|
|
682
|
+
): boolean => {
|
|
683
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
684
|
+
const acknowledgedState: PersistedDeferredLinkState = {
|
|
685
|
+
...state,
|
|
686
|
+
terminalDelivery: null,
|
|
687
|
+
};
|
|
688
|
+
// Clearing the record is itself transactional. If this write fails after the receiver
|
|
689
|
+
// accepted the event, retain and replay the same eventId: that is why the contract is
|
|
690
|
+
// at-least-once and why receivers must deduplicate.
|
|
691
|
+
if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
|
|
692
|
+
reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
|
|
693
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
state = acknowledgedState;
|
|
697
|
+
notifyState();
|
|
698
|
+
return true;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const flushTerminalDelivery = (): Promise<boolean> => {
|
|
702
|
+
if (disposed) return Promise.resolve(false);
|
|
703
|
+
if (terminalDeliverySending) return terminalDeliverySending;
|
|
704
|
+
const queued = state.terminalDelivery;
|
|
705
|
+
if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
|
|
706
|
+
if (queued.disposition === 'drop') {
|
|
707
|
+
return Promise.resolve(acknowledgeTerminalDelivery(queued));
|
|
708
|
+
}
|
|
709
|
+
if (!options.onFirstOpenResult) return Promise.resolve(false);
|
|
710
|
+
const generation = terminalDeliveryGeneration;
|
|
711
|
+
|
|
712
|
+
const run = async (): Promise<boolean> => {
|
|
713
|
+
// Never call the receiver before the complete record is durable. With no storage adapter,
|
|
714
|
+
// this is an explicitly memory-only degradation rather than a cross-restart guarantee.
|
|
715
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
716
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
717
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let rawDisposition:
|
|
722
|
+
| AnonymousFirstOpenTerminalDisposition
|
|
723
|
+
| boolean
|
|
724
|
+
| Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
|
|
725
|
+
try {
|
|
726
|
+
rawDisposition = options.onFirstOpenResult!(terminalDeliveryOutcome(queued));
|
|
727
|
+
} catch (error) {
|
|
728
|
+
reportError(error);
|
|
729
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let disposition: AnonymousFirstOpenTerminalDisposition;
|
|
734
|
+
if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
|
|
735
|
+
disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
|
|
736
|
+
} else {
|
|
737
|
+
try {
|
|
738
|
+
const resolved = await withTerminalDeliveryTimeout(
|
|
739
|
+
Promise.resolve(rawDisposition),
|
|
740
|
+
terminalDeliveryTimeoutMs,
|
|
741
|
+
);
|
|
742
|
+
disposition = normalizeTerminalDeliveryDisposition(resolved);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
|
|
745
|
+
if (!disposed && terminalDeliveryGeneration === generation) {
|
|
746
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (disposed
|
|
753
|
+
|| terminalDeliveryGeneration !== generation
|
|
754
|
+
|| state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
755
|
+
if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
|
|
756
|
+
if (disposition === 'drop') return dropTerminalDelivery(queued);
|
|
757
|
+
scheduleTerminalDeliveryRetry(queued);
|
|
758
|
+
return false;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
let owned: Promise<boolean>;
|
|
762
|
+
owned = run().finally(() => {
|
|
763
|
+
if (terminalDeliverySending === owned) terminalDeliverySending = null;
|
|
764
|
+
const pending = state.terminalDelivery;
|
|
765
|
+
if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
|
|
766
|
+
void flushTerminalDelivery();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
terminalDeliverySending = owned;
|
|
770
|
+
return owned;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const dropTerminalDelivery = (
|
|
774
|
+
queued: QueuedFirstOpenTerminalDelivery,
|
|
775
|
+
): boolean => {
|
|
776
|
+
if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
|
|
777
|
+
const tombstone: DroppedFirstOpenTerminalTombstone = {
|
|
778
|
+
eventId: queued.eventId,
|
|
779
|
+
disposition: 'drop',
|
|
780
|
+
attempts: queued.attempts,
|
|
781
|
+
nextRetryAt: 0,
|
|
782
|
+
};
|
|
783
|
+
state = { ...state, terminalDelivery: tombstone };
|
|
784
|
+
// Persist the sticky disposition before trying to delete it. A failed local deletion can then
|
|
785
|
+
// retry only deletion after restart; it must never call the receiver or resurrect collection.
|
|
786
|
+
if (!writeState(options.storage, storageKey, state)) {
|
|
787
|
+
reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
|
|
788
|
+
scheduleTerminalDeliveryRetry(tombstone);
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
notifyState();
|
|
792
|
+
return acknowledgeTerminalDelivery(tombstone);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const completeFirstOpen = (
|
|
796
|
+
status: DeferredLinkStatus,
|
|
797
|
+
outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
|
|
798
|
+
): void => {
|
|
799
|
+
let shouldQueue = Boolean(options.onFirstOpenResult);
|
|
800
|
+
if (shouldQueue && options.shouldQueueFirstOpenResult) {
|
|
801
|
+
try {
|
|
802
|
+
shouldQueue = options.shouldQueueFirstOpenResult() === true;
|
|
803
|
+
} catch (error) {
|
|
804
|
+
shouldQueue = false;
|
|
805
|
+
reportError(error);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (!shouldQueue) {
|
|
809
|
+
state = {
|
|
810
|
+
...state,
|
|
811
|
+
status,
|
|
812
|
+
terminalDelivery: null,
|
|
813
|
+
firstOpen: {
|
|
814
|
+
...state.firstOpen,
|
|
815
|
+
completed: true,
|
|
816
|
+
attempts: 0,
|
|
817
|
+
nextRetryAt: 0,
|
|
818
|
+
},
|
|
819
|
+
};
|
|
820
|
+
persistAndNotify();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const existingEventIds = new Set<string>([
|
|
825
|
+
...state.outcomeQueue.map((queued) => queued.eventId),
|
|
826
|
+
...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []),
|
|
827
|
+
...(state.terminalDelivery ? [state.terminalDelivery.eventId] : []),
|
|
828
|
+
]);
|
|
829
|
+
const delivery = state.terminalDelivery ?? {
|
|
830
|
+
...outcome,
|
|
831
|
+
eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
|
|
832
|
+
retryable: false as const,
|
|
833
|
+
occurredAt: new Date(now()).toISOString(),
|
|
834
|
+
attempts: 0,
|
|
835
|
+
nextRetryAt: 0,
|
|
836
|
+
};
|
|
837
|
+
state = {
|
|
838
|
+
...state,
|
|
839
|
+
status,
|
|
840
|
+
terminalDelivery: delivery,
|
|
841
|
+
firstOpen: {
|
|
842
|
+
...state.firstOpen,
|
|
843
|
+
completed: true,
|
|
844
|
+
attempts: 0,
|
|
845
|
+
nextRetryAt: 0,
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
if (!persistAndNotify()) {
|
|
849
|
+
reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
|
|
850
|
+
}
|
|
851
|
+
// Delivery is deliberately detached from routing/matcher completion.
|
|
852
|
+
void flushTerminalDelivery();
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const blockFirstOpenForDeterministic = (): void => {
|
|
856
|
+
deterministicEpoch += 1;
|
|
857
|
+
if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
|
|
858
|
+
state = {
|
|
859
|
+
...state,
|
|
860
|
+
firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
|
|
861
|
+
};
|
|
862
|
+
persistAndNotify();
|
|
863
|
+
};
|
|
864
|
+
|
|
487
865
|
const isAccountReady = (): boolean => {
|
|
488
866
|
try { return options.isAccountReady?.() ?? false; } catch (error) {
|
|
489
867
|
reportError(error);
|
|
@@ -740,9 +1118,25 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
740
1118
|
return false;
|
|
741
1119
|
}
|
|
742
1120
|
|
|
1121
|
+
let applied: boolean | void = false;
|
|
1122
|
+
let applicationFailed = false;
|
|
1123
|
+
let applicationError: unknown;
|
|
1124
|
+
const applicationEpoch = deterministicEpoch;
|
|
1125
|
+
const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
|
|
1126
|
+
&& state.pending !== null
|
|
1127
|
+
// A provenance upgrade or rejected lower-priority capture for the token already being
|
|
1128
|
+
// applied does not represent another destination and must not leave that token pending.
|
|
1129
|
+
&& (sourceToken === null || state.pending.token !== sourceToken);
|
|
1130
|
+
routingApplicationInFlight = true;
|
|
743
1131
|
emitOutcome(link, 'app_open_confirmed');
|
|
744
1132
|
emitOutcome(link, 'deferred_link_resolved');
|
|
745
|
-
|
|
1133
|
+
// `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
|
|
1134
|
+
// a newer accepted destination, it still arrived before the routing callback and must win.
|
|
1135
|
+
if (hasNewerPendingIntent()) {
|
|
1136
|
+
routingApplicationInFlight = false;
|
|
1137
|
+
void process();
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
746
1140
|
try {
|
|
747
1141
|
if (link.deepLink) {
|
|
748
1142
|
applied = await options.onDeepLink(link.deepLink, link);
|
|
@@ -753,24 +1147,51 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
753
1147
|
return false;
|
|
754
1148
|
}
|
|
755
1149
|
} catch (error) {
|
|
756
|
-
|
|
1150
|
+
applicationFailed = true;
|
|
1151
|
+
applicationError = error;
|
|
1152
|
+
} finally {
|
|
1153
|
+
routingApplicationInFlight = false;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
|
|
1157
|
+
if (applicationFailed) {
|
|
1158
|
+
reportError(applicationError);
|
|
1159
|
+
if (newerIntentArrivedDuringApplication) {
|
|
1160
|
+
// The current action did not commit. Resume the newer accepted intent that was captured
|
|
1161
|
+
// while its callback was suspended, without scheduling a retry for the superseded one.
|
|
1162
|
+
void process();
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
757
1165
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
758
1166
|
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
759
1167
|
else scheduleAccountRetry();
|
|
760
1168
|
return false;
|
|
761
1169
|
}
|
|
762
1170
|
if (applied === false) {
|
|
1171
|
+
if (newerIntentArrivedDuringApplication) {
|
|
1172
|
+
void process();
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
763
1175
|
if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
|
|
764
1176
|
else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
|
|
765
1177
|
else scheduleAccountRetry();
|
|
766
1178
|
return false;
|
|
767
1179
|
}
|
|
768
1180
|
|
|
1181
|
+
if (newerIntentArrivedDuringApplication) {
|
|
1182
|
+
// Invocation of any host routing callback is the last safe commit boundary. A successful
|
|
1183
|
+
// callback may already have navigated, so automatically applying the newly captured token
|
|
1184
|
+
// now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
|
|
1185
|
+
deterministicTokenDeferredAfterCommittedJourney = state.pending!.token;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
769
1188
|
const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
|
|
770
1189
|
const newlyAppliedSet = new Set(newlyApplied);
|
|
771
1190
|
const appliedIds = [...state.appliedIds.filter((id) => !newlyAppliedSet.has(id)), ...newlyApplied]
|
|
772
1191
|
.slice(-MAX_APPLIED_IDS);
|
|
773
|
-
const stillPending = sourceToken !== null
|
|
1192
|
+
const stillPending = sourceToken !== null
|
|
1193
|
+
? state.pending?.token !== sourceToken
|
|
1194
|
+
: newerIntentArrivedDuringApplication;
|
|
774
1195
|
state = {
|
|
775
1196
|
...state,
|
|
776
1197
|
status: stillPending ? 'pending' : 'applied',
|
|
@@ -784,7 +1205,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
784
1205
|
accountRetryAt = 0;
|
|
785
1206
|
}
|
|
786
1207
|
emitOutcome(link, 'action_applied');
|
|
787
|
-
if (stillPending
|
|
1208
|
+
if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
|
|
1209
|
+
void process();
|
|
1210
|
+
}
|
|
788
1211
|
return true;
|
|
789
1212
|
};
|
|
790
1213
|
|
|
@@ -822,8 +1245,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
822
1245
|
|
|
823
1246
|
const processOnce = async (): Promise<void> => {
|
|
824
1247
|
if (disposed) return;
|
|
1248
|
+
// `capture()` can re-enter while any host callback is awaiting. Never start another routing
|
|
1249
|
+
// application concurrently, and never auto-drain the exact token preserved after an already
|
|
1250
|
+
// committed journey in this client lifecycle.
|
|
1251
|
+
if (routingApplicationInFlight) return;
|
|
825
1252
|
const pending = state.pending;
|
|
826
1253
|
if (pending) {
|
|
1254
|
+
if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
|
|
827
1255
|
if (pending.nextRetryAt > now()) return;
|
|
828
1256
|
if (now() - pending.receivedAt > tokenMaxAgeMs) {
|
|
829
1257
|
clearPendingAsTerminal();
|
|
@@ -847,7 +1275,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
847
1275
|
return;
|
|
848
1276
|
}
|
|
849
1277
|
try {
|
|
850
|
-
const raw = await
|
|
1278
|
+
const raw = await withPromiseTimeout(
|
|
1279
|
+
() => options.accountBridge!.claim(token),
|
|
1280
|
+
requestTimeoutMs,
|
|
1281
|
+
'account claim',
|
|
1282
|
+
);
|
|
851
1283
|
if (disposed || state.pending?.token !== token) return;
|
|
852
1284
|
const normalizedClaim = normalizeResolved<Action>(
|
|
853
1285
|
raw,
|
|
@@ -862,7 +1294,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
862
1294
|
matchGuaranteed: true,
|
|
863
1295
|
confidence: 1,
|
|
864
1296
|
} : null;
|
|
865
|
-
if (claimed)
|
|
1297
|
+
if (claimed) {
|
|
1298
|
+
blockFirstOpenForDeterministic();
|
|
1299
|
+
await applyResolved(claimed, token);
|
|
1300
|
+
}
|
|
866
1301
|
else clearPendingAsTerminal();
|
|
867
1302
|
} catch (error) {
|
|
868
1303
|
reportError(error);
|
|
@@ -872,8 +1307,17 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
872
1307
|
}
|
|
873
1308
|
|
|
874
1309
|
if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
|
|
1310
|
+
const accountPendingEpoch = deterministicEpoch;
|
|
875
1311
|
try {
|
|
876
|
-
const raw = await
|
|
1312
|
+
const raw = await withPromiseTimeout(
|
|
1313
|
+
() => options.accountBridge!.pending(),
|
|
1314
|
+
requestTimeoutMs,
|
|
1315
|
+
'account pending',
|
|
1316
|
+
);
|
|
1317
|
+
// A URL/paste/referrer captured while the account lookup was in flight is newer explicit
|
|
1318
|
+
// user intent. Ignore the stale bridge response and let the requested drain resolve the
|
|
1319
|
+
// captured token; otherwise both deterministic destinations could be applied.
|
|
1320
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
877
1321
|
if (!raw) {
|
|
878
1322
|
accountAttempts = 0;
|
|
879
1323
|
accountRetryAt = 0;
|
|
@@ -898,12 +1342,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
898
1342
|
matchGuaranteed: true,
|
|
899
1343
|
confidence: 1,
|
|
900
1344
|
} : null;
|
|
901
|
-
if (pendingLink
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1345
|
+
if (pendingLink) {
|
|
1346
|
+
// Reserve the first-open journey for deterministic recovery before invoking any host
|
|
1347
|
+
// callback. This also covers an already-applied account item restored from older SDK
|
|
1348
|
+
// state, which must still prevent a second probabilistic destination.
|
|
1349
|
+
blockFirstOpenForDeterministic();
|
|
1350
|
+
if (!state.appliedIds.includes(pendingLink.id)) {
|
|
1351
|
+
await applyResolved(pendingLink, null);
|
|
1352
|
+
} else if (state.status !== 'applied') {
|
|
1353
|
+
patchState({ status: 'applied' });
|
|
1354
|
+
}
|
|
905
1355
|
}
|
|
906
1356
|
} catch (error) {
|
|
1357
|
+
if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
|
|
907
1358
|
reportError(error);
|
|
908
1359
|
// No token was consumed; the authenticated server outbox remains authoritative.
|
|
909
1360
|
scheduleAccountRetry();
|
|
@@ -912,6 +1363,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
912
1363
|
|
|
913
1364
|
const process = async (): Promise<void> => {
|
|
914
1365
|
if (disposed) return;
|
|
1366
|
+
// Application delivery is independent: never await it on the routing drain.
|
|
1367
|
+
void flushTerminalDelivery();
|
|
915
1368
|
processRequested = true;
|
|
916
1369
|
if (processing) return processing;
|
|
917
1370
|
const run = (async () => {
|
|
@@ -938,11 +1391,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
938
1391
|
const token = normalizeDeferredHandoffToken(rawToken);
|
|
939
1392
|
if (!token) return false;
|
|
940
1393
|
if (state.appliedIds.includes(token)) {
|
|
1394
|
+
deterministicEpoch += 1;
|
|
941
1395
|
if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
942
1396
|
return true;
|
|
943
1397
|
}
|
|
944
1398
|
if (state.pending?.token === token) {
|
|
945
|
-
|
|
1399
|
+
deterministicEpoch += 1;
|
|
1400
|
+
if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
946
1401
|
state = {
|
|
947
1402
|
...state,
|
|
948
1403
|
pending: { ...state.pending, matchBasis: basis },
|
|
@@ -955,6 +1410,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
955
1410
|
void process();
|
|
956
1411
|
return true;
|
|
957
1412
|
}
|
|
1413
|
+
|
|
1414
|
+
// Exact transports are deterministic, but when more than one arrives during cold start their
|
|
1415
|
+
// provenance still has an explicit precedence. A lower-priority late callback must not replace
|
|
1416
|
+
// the URL the user intentionally opened; equal priority keeps last-touch behaviour.
|
|
1417
|
+
if (state.pending
|
|
1418
|
+
&& CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
|
|
1419
|
+
deterministicEpoch += 1;
|
|
1420
|
+
if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
|
|
1421
|
+
void process();
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
deterministicEpoch += 1;
|
|
958
1426
|
state = {
|
|
959
1427
|
...state,
|
|
960
1428
|
status: 'pending',
|
|
@@ -976,7 +1444,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
976
1444
|
return true;
|
|
977
1445
|
};
|
|
978
1446
|
|
|
979
|
-
const
|
|
1447
|
+
const matchFirstOpenOnce = async (
|
|
980
1448
|
context: AnonymousFirstOpenContext,
|
|
981
1449
|
): Promise<AnonymousFirstOpenResult> => {
|
|
982
1450
|
if (disposed) return 'ineligible';
|
|
@@ -986,8 +1454,21 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
986
1454
|
if (state.firstOpen.nextRetryAt > now()) return 'backoff';
|
|
987
1455
|
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
988
1456
|
|
|
1457
|
+
// The client starts account recovery at construction. Join that deterministic rail before
|
|
1458
|
+
// asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
|
|
1459
|
+
// after the probabilistic destination and the user observes both journeys.
|
|
1460
|
+
await process();
|
|
1461
|
+
if (disposed) return 'ineligible';
|
|
1462
|
+
if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
|
|
1463
|
+
if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
|
|
1464
|
+
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1465
|
+
return 'retry_scheduled';
|
|
1466
|
+
}
|
|
1467
|
+
if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
|
|
1468
|
+
|
|
989
1469
|
const installAttemptId = state.firstOpen.installAttemptId
|
|
990
1470
|
?? makeInstallAttemptId(options.randomUUID);
|
|
1471
|
+
const startingDeterministicEpoch = deterministicEpoch;
|
|
991
1472
|
state = {
|
|
992
1473
|
...state,
|
|
993
1474
|
status: 'resolving',
|
|
@@ -1018,16 +1499,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1018
1499
|
},
|
|
1019
1500
|
requestTimeoutMs,
|
|
1020
1501
|
);
|
|
1021
|
-
if (
|
|
1022
|
-
if (
|
|
1502
|
+
if (disposed) return 'ineligible';
|
|
1503
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1504
|
+
|| state.pending
|
|
1505
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1023
1506
|
if (response.status === 204) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1507
|
+
completeFirstOpen('idle', {
|
|
1508
|
+
status: 'NOT_FOUND',
|
|
1509
|
+
rail: 'no_route',
|
|
1510
|
+
routed: false,
|
|
1511
|
+
reason: firstOpenNoRouteReason(response),
|
|
1512
|
+
});
|
|
1026
1513
|
return 'no_match';
|
|
1027
1514
|
}
|
|
1028
|
-
if (response.status === 400 || response.status === 404
|
|
1029
|
-
|
|
1030
|
-
|
|
1515
|
+
if (response.status === 400 || response.status === 404
|
|
1516
|
+
|| response.status === 410 || response.status === 413) {
|
|
1517
|
+
completeFirstOpen('terminal_error', {
|
|
1518
|
+
status: 'FAILURE',
|
|
1519
|
+
rail: 'no_route',
|
|
1520
|
+
routed: false,
|
|
1521
|
+
reason: 'invalid_request',
|
|
1522
|
+
});
|
|
1031
1523
|
return 'terminal_error';
|
|
1032
1524
|
}
|
|
1033
1525
|
if (!response.ok) {
|
|
@@ -1035,38 +1527,113 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1035
1527
|
scheduleFirstOpenRetry();
|
|
1036
1528
|
return 'retry_scheduled';
|
|
1037
1529
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1530
|
+
completeFirstOpen('terminal_error', {
|
|
1531
|
+
status: 'FAILURE',
|
|
1532
|
+
rail: 'no_route',
|
|
1533
|
+
routed: false,
|
|
1534
|
+
reason: 'invalid_response',
|
|
1535
|
+
});
|
|
1536
|
+
return 'terminal_error';
|
|
1537
|
+
}
|
|
1538
|
+
let rawLink: unknown;
|
|
1539
|
+
try {
|
|
1540
|
+
rawLink = await response.json();
|
|
1541
|
+
} catch {
|
|
1542
|
+
completeFirstOpen('terminal_error', {
|
|
1543
|
+
status: 'FAILURE',
|
|
1544
|
+
rail: 'no_route',
|
|
1545
|
+
routed: false,
|
|
1546
|
+
reason: 'invalid_response',
|
|
1547
|
+
});
|
|
1040
1548
|
return 'terminal_error';
|
|
1041
1549
|
}
|
|
1550
|
+
if (disposed) return 'ineligible';
|
|
1551
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1552
|
+
|| state.pending
|
|
1553
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1042
1554
|
const link = normalizeResolved<Action>(
|
|
1043
|
-
|
|
1555
|
+
rawLink,
|
|
1044
1556
|
installAttemptId.replace(/-/g, ''),
|
|
1045
1557
|
'unique_probabilistic',
|
|
1046
1558
|
allowedActions,
|
|
1047
1559
|
now(),
|
|
1048
1560
|
);
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1561
|
+
if (!link) {
|
|
1562
|
+
completeFirstOpen('terminal_error', {
|
|
1563
|
+
status: 'FAILURE',
|
|
1564
|
+
rail: 'no_route',
|
|
1565
|
+
routed: false,
|
|
1566
|
+
reason: 'invalid_response',
|
|
1567
|
+
});
|
|
1055
1568
|
return 'terminal_error';
|
|
1056
1569
|
}
|
|
1570
|
+
// A probabilistic result is navigation intent, never a remote URL transport. Only the two
|
|
1571
|
+
// harmless closed discovery actions are accepted; all billing/account/entitlement actions
|
|
1572
|
+
// and even an app-owned deepLink are rejected before application code runs.
|
|
1573
|
+
if (link.matchBasis !== 'unique_probabilistic'
|
|
1574
|
+
|| link.matchGuaranteed
|
|
1575
|
+
|| link.deepLink !== undefined
|
|
1576
|
+
|| !link.action
|
|
1577
|
+
|| !PROBABILISTIC_ACTIONS.has(String(link.action))
|
|
1578
|
+
|| sensitiveActions.has(link.action)
|
|
1579
|
+
|| !options.onAction) {
|
|
1580
|
+
completeFirstOpen('terminal_error', {
|
|
1581
|
+
status: 'FAILURE',
|
|
1582
|
+
rail: 'no_route',
|
|
1583
|
+
routed: false,
|
|
1584
|
+
reason: 'policy_rejected',
|
|
1585
|
+
});
|
|
1586
|
+
return 'terminal_error';
|
|
1587
|
+
}
|
|
1588
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1589
|
+
|| state.pending
|
|
1590
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1057
1591
|
if (await applyResolved(link, null)) {
|
|
1058
|
-
|
|
1592
|
+
// `applyResolved()` returns true only after the host callback crossed its irreversible
|
|
1593
|
+
// commit boundary successfully. A deterministic capture can arrive while that callback
|
|
1594
|
+
// awaits; it remains pending for the next lifecycle, but it must not erase the terminal
|
|
1595
|
+
// truth that this probabilistic journey really routed.
|
|
1596
|
+
completeFirstOpen(state.pending ? 'pending' : 'applied', {
|
|
1597
|
+
status: 'FOUND',
|
|
1598
|
+
rail: 'fast_route',
|
|
1599
|
+
routed: true,
|
|
1600
|
+
reason: 'matched',
|
|
1601
|
+
matchBasis: link.matchBasis,
|
|
1602
|
+
confidence: link.confidence,
|
|
1603
|
+
...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
|
|
1604
|
+
...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
|
|
1605
|
+
...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
|
|
1606
|
+
});
|
|
1059
1607
|
return 'matched';
|
|
1060
1608
|
}
|
|
1609
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1610
|
+
|| state.pending
|
|
1611
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1061
1612
|
if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
|
|
1062
1613
|
return 'retry_scheduled';
|
|
1063
1614
|
} catch (error) {
|
|
1615
|
+
if (disposed) return 'ineligible';
|
|
1064
1616
|
reportError(error);
|
|
1617
|
+
if (deterministicEpoch !== startingDeterministicEpoch
|
|
1618
|
+
|| state.pending
|
|
1619
|
+
|| state.firstOpen.completed) return 'deterministic_pending';
|
|
1065
1620
|
scheduleFirstOpenRetry();
|
|
1066
1621
|
return 'retry_scheduled';
|
|
1067
1622
|
}
|
|
1068
1623
|
};
|
|
1069
1624
|
|
|
1625
|
+
const matchFirstOpen = (
|
|
1626
|
+
context: AnonymousFirstOpenContext,
|
|
1627
|
+
): Promise<AnonymousFirstOpenResult> => {
|
|
1628
|
+
if (firstOpenMatching) return firstOpenMatching;
|
|
1629
|
+
let owned: Promise<AnonymousFirstOpenResult>;
|
|
1630
|
+
owned = matchFirstOpenOnce(context).finally(() => {
|
|
1631
|
+
if (firstOpenMatching === owned) firstOpenMatching = null;
|
|
1632
|
+
});
|
|
1633
|
+
firstOpenMatching = owned;
|
|
1634
|
+
return owned;
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1070
1637
|
const captureAndroidInstallReferrer = async (
|
|
1071
1638
|
bridge: AndroidInstallReferrerBridge,
|
|
1072
1639
|
): Promise<AndroidInstallReferrerResult> => {
|
|
@@ -1095,6 +1662,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1095
1662
|
lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
|
|
1096
1663
|
accountRetryAt,
|
|
1097
1664
|
state.outcomeQueue[0]?.nextRetryAt ?? 0,
|
|
1665
|
+
state.terminalDelivery?.nextRetryAt ?? 0,
|
|
1098
1666
|
].filter((value) => value > now());
|
|
1099
1667
|
if (candidates.length === 0) return;
|
|
1100
1668
|
const next = Math.min(...candidates);
|
|
@@ -1102,6 +1670,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1102
1670
|
retryTimer = null;
|
|
1103
1671
|
void process();
|
|
1104
1672
|
void flushResolverOutcomes();
|
|
1673
|
+
void flushTerminalDelivery();
|
|
1105
1674
|
if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
|
|
1106
1675
|
void matchFirstOpen(lastFirstOpenContext);
|
|
1107
1676
|
}
|
|
@@ -1120,6 +1689,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1120
1689
|
scheduleWake();
|
|
1121
1690
|
void process();
|
|
1122
1691
|
void flushResolverOutcomes();
|
|
1692
|
+
void flushTerminalDelivery();
|
|
1123
1693
|
|
|
1124
1694
|
return {
|
|
1125
1695
|
capture,
|
|
@@ -1138,6 +1708,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1138
1708
|
dispose: () => {
|
|
1139
1709
|
if (disposed) return;
|
|
1140
1710
|
disposed = true;
|
|
1711
|
+
deterministicEpoch += 1;
|
|
1712
|
+
terminalDeliveryGeneration += 1;
|
|
1141
1713
|
if (retryTimer) clearTimeout(retryTimer);
|
|
1142
1714
|
retryTimer = null;
|
|
1143
1715
|
unsubscribeAccount?.();
|
|
@@ -1147,6 +1719,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
|
|
|
1147
1719
|
reset: () => {
|
|
1148
1720
|
accountRetryAt = 0;
|
|
1149
1721
|
accountAttempts = 0;
|
|
1722
|
+
deterministicEpoch += 1;
|
|
1723
|
+
terminalDeliveryGeneration += 1;
|
|
1724
|
+
terminalDeliverySending = null;
|
|
1725
|
+
deterministicTokenDeferredAfterCommittedJourney = null;
|
|
1150
1726
|
state = emptyState();
|
|
1151
1727
|
persistAndNotify();
|
|
1152
1728
|
},
|
|
@@ -1199,11 +1775,11 @@ function stableOutcomeKey(linkId: string, name: DeferredLinkOutcomeName): string
|
|
|
1199
1775
|
return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
|
|
1200
1776
|
}
|
|
1201
1777
|
|
|
1202
|
-
function boundedMetadata(value: unknown): string | null | undefined {
|
|
1778
|
+
function boundedMetadata(value: unknown, maxLength = 256): string | null | undefined {
|
|
1203
1779
|
if (value === null) return null;
|
|
1204
1780
|
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
1205
1781
|
const result = String(value).trim();
|
|
1206
|
-
return result.length > 0 && result.length <=
|
|
1782
|
+
return result.length > 0 && result.length <= maxLength ? result : undefined;
|
|
1207
1783
|
}
|
|
1208
1784
|
|
|
1209
1785
|
function normalizeResolved<Action extends string>(
|
|
@@ -1246,9 +1822,9 @@ function normalizeResolved<Action extends string>(
|
|
|
1246
1822
|
if (!Number.isFinite(expiry) || expiry <= now) return null;
|
|
1247
1823
|
}
|
|
1248
1824
|
const source = boundedMetadata(value.source);
|
|
1249
|
-
const campaignId = boundedMetadata(value.campaignId);
|
|
1250
|
-
const experimentId = boundedMetadata(value.experimentId);
|
|
1251
|
-
const variantId = boundedMetadata(value.variantId);
|
|
1825
|
+
const campaignId = boundedMetadata(value.campaignId, 128);
|
|
1826
|
+
const experimentId = boundedMetadata(value.experimentId, 128);
|
|
1827
|
+
const variantId = boundedMetadata(value.variantId, 128);
|
|
1252
1828
|
return {
|
|
1253
1829
|
id,
|
|
1254
1830
|
...(action ? { action } : {}),
|
|
@@ -1286,6 +1862,15 @@ function normalizeLocale(locale: string): string {
|
|
|
1286
1862
|
return locale.trim().replace(/_/g, '-');
|
|
1287
1863
|
}
|
|
1288
1864
|
|
|
1865
|
+
function firstOpenNoRouteReason(response: Response): AnonymousFirstOpenTerminalReason {
|
|
1866
|
+
let raw = '';
|
|
1867
|
+
try { raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? ''; }
|
|
1868
|
+
catch { raw = ''; }
|
|
1869
|
+
return NO_ROUTE_REASONS.has(raw as AnonymousFirstOpenTerminalReason)
|
|
1870
|
+
? raw as AnonymousFirstOpenTerminalReason
|
|
1871
|
+
: 'unmatched';
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1289
1874
|
function normalizeAnonymousFirstOpenSignals(
|
|
1290
1875
|
context: AnonymousFirstOpenContext,
|
|
1291
1876
|
): Partial<Omit<AnonymousFirstOpenContext, 'appBundleId' | 'locale' | 'platform' | 'installedAt'>> {
|
|
@@ -1397,6 +1982,55 @@ async function withTimeout(
|
|
|
1397
1982
|
}
|
|
1398
1983
|
}
|
|
1399
1984
|
|
|
1985
|
+
async function withPromiseTimeout<T>(
|
|
1986
|
+
factory: () => Promise<T>,
|
|
1987
|
+
timeoutMs: number,
|
|
1988
|
+
operation: 'account claim' | 'account pending',
|
|
1989
|
+
): Promise<T> {
|
|
1990
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1991
|
+
try {
|
|
1992
|
+
return await Promise.race([
|
|
1993
|
+
Promise.resolve().then(factory),
|
|
1994
|
+
new Promise<T>((_resolve, reject) => {
|
|
1995
|
+
timer = setTimeout(
|
|
1996
|
+
() => reject(new Error(`Pulse Links: ${operation} timed out`)),
|
|
1997
|
+
timeoutMs,
|
|
1998
|
+
);
|
|
1999
|
+
}),
|
|
2000
|
+
]);
|
|
2001
|
+
} finally {
|
|
2002
|
+
if (timer) clearTimeout(timer);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async function withTerminalDeliveryTimeout(
|
|
2007
|
+
acknowledgement: Promise<AnonymousFirstOpenTerminalDisposition | boolean>,
|
|
2008
|
+
timeoutMs: number,
|
|
2009
|
+
): Promise<AnonymousFirstOpenTerminalDisposition | boolean> {
|
|
2010
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
2011
|
+
try {
|
|
2012
|
+
return await Promise.race([
|
|
2013
|
+
acknowledgement,
|
|
2014
|
+
new Promise<AnonymousFirstOpenTerminalDisposition | boolean>((_resolve, reject) => {
|
|
2015
|
+
timer = setTimeout(
|
|
2016
|
+
() => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')),
|
|
2017
|
+
timeoutMs,
|
|
2018
|
+
);
|
|
2019
|
+
}),
|
|
2020
|
+
]);
|
|
2021
|
+
} finally {
|
|
2022
|
+
if (timer) clearTimeout(timer);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function normalizeTerminalDeliveryDisposition(
|
|
2027
|
+
value: AnonymousFirstOpenTerminalDisposition | boolean,
|
|
2028
|
+
): AnonymousFirstOpenTerminalDisposition {
|
|
2029
|
+
if (value === true || value === 'accepted') return 'accepted';
|
|
2030
|
+
if (value === 'drop') return 'drop';
|
|
2031
|
+
return 'retry';
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1400
2034
|
async function postResolverOutcome(
|
|
1401
2035
|
fetcher: typeof fetch,
|
|
1402
2036
|
resolverBaseUrl: string,
|
|
@@ -1488,6 +2122,117 @@ function normalizeOccurredAt(value: unknown): string | null {
|
|
|
1488
2122
|
return new Date(timestamp).toISOString() === value ? value : null;
|
|
1489
2123
|
}
|
|
1490
2124
|
|
|
2125
|
+
function readTerminalDelivery(raw: unknown): PersistedFirstOpenTerminalDelivery | null {
|
|
2126
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
2127
|
+
const value = raw as Record<string, unknown>;
|
|
2128
|
+
const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
|
|
2129
|
+
? value.eventId.toLowerCase()
|
|
2130
|
+
: null;
|
|
2131
|
+
const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE'
|
|
2132
|
+
? value.status
|
|
2133
|
+
: null;
|
|
2134
|
+
const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
|
|
2135
|
+
const reason = typeof value.reason === 'string'
|
|
2136
|
+
? value.reason as AnonymousFirstOpenTerminalReason
|
|
2137
|
+
: null;
|
|
2138
|
+
const occurredAt = normalizeOccurredAt(value.occurredAt);
|
|
2139
|
+
const attempts = typeof value.attempts === 'number'
|
|
2140
|
+
&& Number.isSafeInteger(value.attempts)
|
|
2141
|
+
&& value.attempts >= 0
|
|
2142
|
+
&& value.attempts <= 100_000
|
|
2143
|
+
? value.attempts
|
|
2144
|
+
: null;
|
|
2145
|
+
const nextRetryAt = typeof value.nextRetryAt === 'number'
|
|
2146
|
+
&& Number.isSafeInteger(value.nextRetryAt)
|
|
2147
|
+
&& value.nextRetryAt >= 0
|
|
2148
|
+
&& value.nextRetryAt <= 8_640_000_000_000_000
|
|
2149
|
+
? value.nextRetryAt
|
|
2150
|
+
: null;
|
|
2151
|
+
if (value.disposition === 'drop') {
|
|
2152
|
+
const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
|
|
2153
|
+
if (!eventId
|
|
2154
|
+
|| attempts === null
|
|
2155
|
+
|| nextRetryAt === null
|
|
2156
|
+
|| Object.keys(value).some((key) => !allowedKeys.has(key))) return null;
|
|
2157
|
+
return { eventId, disposition: 'drop', attempts, nextRetryAt };
|
|
2158
|
+
}
|
|
2159
|
+
const matchBasis = typeof value.matchBasis === 'string'
|
|
2160
|
+
&& MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
|
|
2161
|
+
? value.matchBasis as DeferredLinkMatchBasis
|
|
2162
|
+
: null;
|
|
2163
|
+
const confidence = typeof value.confidence === 'number'
|
|
2164
|
+
&& Number.isFinite(value.confidence)
|
|
2165
|
+
&& value.confidence >= 0
|
|
2166
|
+
&& value.confidence <= 1
|
|
2167
|
+
? value.confidence
|
|
2168
|
+
: null;
|
|
2169
|
+
const metadata: Partial<Pick<
|
|
2170
|
+
AnonymousFirstOpenTerminalOutcome,
|
|
2171
|
+
'campaignId' | 'experimentId' | 'variantId'
|
|
2172
|
+
>> = {};
|
|
2173
|
+
let metadataValid = true;
|
|
2174
|
+
for (const key of ['campaignId', 'experimentId', 'variantId'] as const) {
|
|
2175
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
2176
|
+
const field = value[key];
|
|
2177
|
+
if (field === null) metadata[key] = null;
|
|
2178
|
+
else if (typeof field === 'string'
|
|
2179
|
+
&& field.length > 0
|
|
2180
|
+
&& field.length <= 128
|
|
2181
|
+
&& field.trim() === field) metadata[key] = field;
|
|
2182
|
+
else metadataValid = false;
|
|
2183
|
+
}
|
|
2184
|
+
const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
|
|
2185
|
+
&& Object.prototype.hasOwnProperty.call(value, 'confidence');
|
|
2186
|
+
const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
|
|
2187
|
+
|| Object.prototype.hasOwnProperty.call(value, 'confidence')
|
|
2188
|
+
|| Object.prototype.hasOwnProperty.call(value, 'campaignId')
|
|
2189
|
+
|| Object.prototype.hasOwnProperty.call(value, 'experimentId')
|
|
2190
|
+
|| Object.prototype.hasOwnProperty.call(value, 'variantId');
|
|
2191
|
+
const validSemanticOutcome = status === 'FOUND'
|
|
2192
|
+
? rail === 'fast_route'
|
|
2193
|
+
&& value.routed === true
|
|
2194
|
+
&& reason === 'matched'
|
|
2195
|
+
&& hasFoundDimensions
|
|
2196
|
+
&& matchBasis === 'unique_probabilistic'
|
|
2197
|
+
&& confidence !== null
|
|
2198
|
+
&& metadataValid
|
|
2199
|
+
: status === 'NOT_FOUND'
|
|
2200
|
+
? rail === 'no_route' && value.routed === false
|
|
2201
|
+
&& reason !== null && NO_ROUTE_REASONS.has(reason)
|
|
2202
|
+
&& !hasAnyAttributionDimension
|
|
2203
|
+
: status === 'FAILURE'
|
|
2204
|
+
? rail === 'no_route' && value.routed === false
|
|
2205
|
+
&& reason !== null && FAILURE_REASONS.has(reason)
|
|
2206
|
+
&& !hasAnyAttributionDimension
|
|
2207
|
+
: false;
|
|
2208
|
+
if (!eventId
|
|
2209
|
+
|| !status
|
|
2210
|
+
|| !rail
|
|
2211
|
+
|| !reason
|
|
2212
|
+
|| !occurredAt
|
|
2213
|
+
|| attempts === null
|
|
2214
|
+
|| nextRetryAt === null
|
|
2215
|
+
|| Object.prototype.hasOwnProperty.call(value, 'disposition')
|
|
2216
|
+
|| value.retryable !== false
|
|
2217
|
+
|| !validSemanticOutcome) return null;
|
|
2218
|
+
return {
|
|
2219
|
+
eventId,
|
|
2220
|
+
status,
|
|
2221
|
+
rail,
|
|
2222
|
+
routed: value.routed as boolean,
|
|
2223
|
+
retryable: false,
|
|
2224
|
+
reason,
|
|
2225
|
+
occurredAt,
|
|
2226
|
+
...(status === 'FOUND' ? {
|
|
2227
|
+
matchBasis: matchBasis as DeferredLinkMatchBasis,
|
|
2228
|
+
confidence: confidence as number,
|
|
2229
|
+
...metadata,
|
|
2230
|
+
} : {}),
|
|
2231
|
+
attempts,
|
|
2232
|
+
nextRetryAt,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
|
|
1491
2236
|
function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
|
|
1492
2237
|
if (!storage) return emptyState();
|
|
1493
2238
|
try {
|
|
@@ -1535,6 +2280,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
|
|
|
1535
2280
|
.slice(-MAX_NOTIFIED_OUTCOMES)
|
|
1536
2281
|
: [];
|
|
1537
2282
|
const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
|
|
2283
|
+
const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
|
|
1538
2284
|
return {
|
|
1539
2285
|
version: 1,
|
|
1540
2286
|
status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
|
|
@@ -1546,6 +2292,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
|
|
|
1546
2292
|
...outcomeQueue.map((outcome) => outcome.transitionKey),
|
|
1547
2293
|
])].slice(-MAX_NOTIFIED_OUTCOMES),
|
|
1548
2294
|
outcomeQueue,
|
|
2295
|
+
terminalDelivery,
|
|
1549
2296
|
firstOpen: {
|
|
1550
2297
|
installAttemptId: attemptId,
|
|
1551
2298
|
completed: firstValue.completed === true,
|
|
@@ -1563,9 +2310,32 @@ function writeState(
|
|
|
1563
2310
|
storage: ConfigStorage | undefined,
|
|
1564
2311
|
key: string,
|
|
1565
2312
|
state: PersistedDeferredLinkState,
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2313
|
+
allowKnownAsyncBestEffort = true,
|
|
2314
|
+
): boolean {
|
|
2315
|
+
if (!storage) return true;
|
|
2316
|
+
const knownAsync = storage.supportsDurableSyncWrites === false
|
|
2317
|
+
|| ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage as object);
|
|
2318
|
+
if (knownAsync && !allowKnownAsyncBestEffort) return false;
|
|
2319
|
+
try {
|
|
2320
|
+
const result = (storage.set as unknown as (storageKey: string, value: string) => unknown)(
|
|
2321
|
+
key,
|
|
2322
|
+
JSON.stringify(state),
|
|
2323
|
+
);
|
|
2324
|
+
if (isThenable(result)) {
|
|
2325
|
+
ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage as object);
|
|
2326
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
return !knownAsync;
|
|
2330
|
+
} catch {
|
|
2331
|
+
return false;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
2336
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
2337
|
+
? typeof (value as { then?: unknown }).then === 'function'
|
|
2338
|
+
: false;
|
|
1569
2339
|
}
|
|
1570
2340
|
|
|
1571
2341
|
function safeTimestamp(value: unknown): number {
|