react-native-revnix 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -20,6 +20,11 @@ import {
20
20
  encodeDeviceHeader,
21
21
  } from "./device.js";
22
22
  import { SDK_VERSION } from "./version.js";
23
+ import {
24
+ getStoreAdapter,
25
+ type StoreAdapter,
26
+ type StoreTransaction,
27
+ } from "./store.js";
23
28
  import {
24
29
  browserLifecycle,
25
30
  DEFAULT_SESSION_TIMEOUT_MS,
@@ -62,7 +67,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
62
67
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
63
68
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
64
69
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
70
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
71
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
65
72
  const DEFAULT_TIMEOUT_MS = 10_000;
73
+ const EVENT_QUEUE_CAP = 1000;
74
+ const EVENT_BATCH_SIZE = 100;
75
+ const EVENT_FLUSH_THRESHOLD = 20;
76
+ const EVENT_FLUSH_DELAY_MS = 10_000;
77
+ const EVENT_RETRY_HOLD_MS = 30_000;
78
+ const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
79
+ const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3_000;
80
+ const PURCHASE_QUEUE_CAP = 100;
81
+ const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
66
82
 
67
83
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
68
84
  const ENTITLEMENTS_CACHE_MAX = 4;
@@ -100,6 +116,57 @@ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
100
116
  interface PendingPurchase {
101
117
  customerId: string;
102
118
  input: RegisterPurchaseInput;
119
+ queuedAt?: number;
120
+ }
121
+
122
+ interface PlacementCacheEntry {
123
+ data: PlacementResolution;
124
+ fetchedAt: number;
125
+ customerId?: string;
126
+ }
127
+
128
+ interface QueuedEvent {
129
+ path: string;
130
+ body: Record<string, unknown>;
131
+ }
132
+
133
+ function batchResults(
134
+ response: unknown,
135
+ path: string
136
+ ): Array<{ status: number; error?: string }> {
137
+ const results = (response as { results?: unknown } | null)?.results;
138
+ if (
139
+ Array.isArray(results) &&
140
+ results.every(
141
+ (r) => typeof r === "object" && r !== null && typeof r.status === "number"
142
+ )
143
+ ) {
144
+ return results;
145
+ }
146
+ throw new RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
147
+ }
148
+
149
+ export function paywallImageUrls(value: unknown): string[] {
150
+ const found = new Set<string>();
151
+ const visit = (node: unknown): void => {
152
+ if (Array.isArray(node)) {
153
+ node.forEach(visit);
154
+ return;
155
+ }
156
+ if (typeof node !== "object" || node === null) return;
157
+ const o = node as Record<string, unknown>;
158
+ const background = o.background as { image?: { url?: unknown } } | null | undefined;
159
+ for (const url of [
160
+ o.heroImageUrl,
161
+ o.type === "image" ? o.url : undefined,
162
+ background?.image?.url,
163
+ ]) {
164
+ if (typeof url === "string" && url.length > 0) found.add(url);
165
+ }
166
+ Object.values(o).forEach(visit);
167
+ };
168
+ visit(value);
169
+ return [...found];
103
170
  }
104
171
 
105
172
  interface InstallResponse {
@@ -185,6 +252,7 @@ export class RevnixClient {
185
252
  private implicitConfigPromise: Promise<ReadonlySet<string>> | null = null;
186
253
  private implicitConfigRetryAt = 0;
187
254
  private lifecycleUnsubscribe: (() => void) | null = null;
255
+ private stopStoreObserver: (() => void) | null = null;
188
256
  /** The cold-start batch while it runs, resolving to whether it presented a
189
257
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
190
258
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -207,6 +275,9 @@ export class RevnixClient {
207
275
  * /v1/installs uses, and independent of the device header (which an app
208
276
  * can turn off entirely). */
209
277
  private freshInstall = false;
278
+ private eventFlush: Promise<void> | null = null;
279
+ private eventTimer: ReturnType<typeof setTimeout> | null = null;
280
+ private eventRetryAt = 0;
210
281
 
211
282
  constructor(config: RevnixConfig) {
212
283
  if (!config.apiKey) throw new Error("Revnix: apiKey is required");
@@ -253,6 +324,14 @@ export class RevnixClient {
253
324
  void this.retryPendingPurchases().catch((err) =>
254
325
  this.diag("retryPendingPurchases", err)
255
326
  );
327
+ const store = getStoreAdapter();
328
+ if (store) {
329
+ this.stopStoreObserver = store.observe((tx) =>
330
+ void this.registerStoreTransaction(store, tx).catch((err) =>
331
+ this.diag("storeObserver", err)
332
+ )
333
+ );
334
+ }
256
335
  // REV-157: warm the placement cache at launch, so the first paywall of the
257
336
  // session can render offline. Fire-and-forget; a failed key just stays on
258
337
  // whatever an earlier launch cached.
@@ -269,7 +348,11 @@ export class RevnixClient {
269
348
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
270
349
  this.sessionTimeoutMs =
271
350
  config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
272
- if (this.implicitEnabled) this.startImplicit(config.lifecycle);
351
+ this.subscribeLifecycle(config.lifecycle);
352
+ if (this.implicitEnabled) this.startImplicit();
353
+ void this.readEvents().then((events) => {
354
+ if (events.length > 0) this.startEventFlush();
355
+ });
273
356
  const { document, location } = globalThis as {
274
357
  document?: unknown;
275
358
  location?: { href: string; search: string };
@@ -382,6 +465,7 @@ export class RevnixClient {
382
465
  const customerId = await this.customerIdPromise;
383
466
  try {
384
467
  const result = await this.postPurchase(customerId, input);
468
+ await this.invalidatePlacements();
385
469
  // Identity resolution may have merged this device's id away (restore
386
470
  // after login), so the server's id is the one to keep using.
387
471
  //
@@ -425,6 +509,77 @@ export class RevnixClient {
425
509
  }
426
510
  }
427
511
 
512
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
513
+ * registers it with Revnix, then finishes the store transaction. Resolves
514
+ * null when the customer cancelled or the purchase is pending approval.
515
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
516
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
517
+ async purchase(
518
+ productId: string,
519
+ opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">
520
+ ): Promise<RegisterPurchaseResult | null> {
521
+ const store = this.requireStore();
522
+ const customerId = await this.customerIdPromise;
523
+ const tx = await store.purchase(productId, { customerId });
524
+ if (!tx) return null;
525
+ return this.registerStoreTransaction(store, tx, opts);
526
+ }
527
+
528
+ /** Re-registers every purchase the store still holds for this device and
529
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
530
+ async restore(): Promise<number> {
531
+ const store = this.requireStore();
532
+ let restored = 0;
533
+ for (const tx of await store.restore()) {
534
+ try {
535
+ await this.registerStoreTransaction(store, tx, undefined, tx.store === "google");
536
+ restored += 1;
537
+ } catch (err) {
538
+ this.diag("storeRestore", err);
539
+ }
540
+ }
541
+ return restored;
542
+ }
543
+
544
+ private requireStore(): StoreAdapter {
545
+ const store = getStoreAdapter();
546
+ if (!store) {
547
+ throw new Error(
548
+ 'Revnix: native purchases need `import "react-native-revnix/store"` before Revnix.configure(...) and a rebuilt app (pod install / Expo development build)'
549
+ );
550
+ }
551
+ return store;
552
+ }
553
+
554
+ private async registerStoreTransaction(
555
+ store: StoreAdapter,
556
+ tx: StoreTransaction,
557
+ opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">,
558
+ finish = true
559
+ ): Promise<RegisterPurchaseResult> {
560
+ const finishTx = () =>
561
+ finish
562
+ ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
563
+ : undefined;
564
+ try {
565
+ const result = await this.registerPurchase({
566
+ source: tx.store,
567
+ token: tx.token,
568
+ productId: tx.productId,
569
+ transactionId: tx.transactionId,
570
+ occurredAt: tx.occurredAt,
571
+ expiresAt: tx.expiresAt,
572
+ signedTransactionInfo: tx.signedTransactionInfo,
573
+ ...opts,
574
+ });
575
+ await finishTx();
576
+ return result;
577
+ } catch (err) {
578
+ if (!(err instanceof PurchaseBlockedError)) await finishTx();
579
+ throw err;
580
+ }
581
+ }
582
+
428
583
  private postPurchase(
429
584
  customerId: string,
430
585
  input: RegisterPurchaseInput
@@ -437,23 +592,28 @@ export class RevnixClient {
437
592
 
438
593
  /** C7: retry purchases that failed to register and were persisted locally.
439
594
  * Runs automatically at construction; call again when connectivity returns.
440
- * Idempotent and safe to call repeatedly. */
595
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
596
+ * days ago are dropped with a diagnostic. */
441
597
  async retryPendingPurchases(): Promise<void> {
598
+ const now = Date.now();
599
+ await this.mutateQueue((queue) =>
600
+ queue.some((q) => q.queuedAt === undefined)
601
+ ? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
602
+ : queue
603
+ );
442
604
  const queue = await this.readPurchaseQueue();
443
605
  for (const item of queue) {
444
606
  if (this.disposed) return;
445
- try {
446
- await this.postPurchase(item.customerId, item.input);
447
- // Delivered — remove it.
448
- } catch (err) {
449
- if (worthQueueing(err)) {
450
- // REV-200 (M26): a queued purchase that STILL cannot land is the
451
- // most important background failure there is — report it.
607
+ if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
608
+ this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
609
+ } else {
610
+ try {
611
+ await this.postPurchase(item.customerId, item.input);
612
+ await this.invalidatePlacements();
613
+ } catch (err) {
452
614
  this.diag("retryPendingPurchases", err);
453
- continue; // transient — keep for next time
615
+ if (worthQueueing(err)) continue;
454
616
  }
455
- // Permanent rejection — remove it.
456
- this.diag("retryPendingPurchases", err);
457
617
  }
458
618
  // Remove THIS item from the queue as it stands now, not from the
459
619
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -515,11 +675,15 @@ export class RevnixClient {
515
675
  /** The authoritative network read: no TTL, no cache fallback — throws so
516
676
  * the caller sees the real failure (waitForEntitlements needs 429s). */
517
677
  private async fetchFreshEntitlements(
518
- customerId: string
678
+ customerId: string,
679
+ timeoutMs?: number
519
680
  ): Promise<CustomerEntitlements> {
520
681
  const fresh = await this.request<CustomerEntitlements>(
521
682
  "GET",
522
- `/v1/customers/${encodeURIComponent(customerId)}/entitlements`
683
+ `/v1/customers/${encodeURIComponent(customerId)}/entitlements`,
684
+ undefined,
685
+ undefined,
686
+ timeoutMs
523
687
  );
524
688
  await this.writeEntitlementsCache(customerId, fresh);
525
689
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
@@ -530,8 +694,14 @@ export class RevnixClient {
530
694
  private async fetchEntitlementsWithFallback(
531
695
  customerId: string
532
696
  ): Promise<CustomerEntitlements> {
697
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
533
698
  try {
534
- return await this.fetchFreshEntitlements(customerId);
699
+ return await this.fetchFreshEntitlements(
700
+ customerId,
701
+ hasCache
702
+ ? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
703
+ : undefined
704
+ );
535
705
  } catch (err) {
536
706
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
537
707
  // might succeed next time — network, timeout, 429, 5xx. A deliberate
@@ -655,54 +825,82 @@ export class RevnixClient {
655
825
  }
656
826
 
657
827
  /** Resolve a named in-app location to the currently published offering —
658
- * the one-line paywall call. REV-157: reads the network first and caches
659
- * the result; if that read FAILS (offline / incident) it falls back to the
660
- * last cached resolution with `stale: true`, so the paywall renders the
661
- * last published version rather than not at all. Throws only when there
662
- * is nothing cached. */
828
+ * the one-line paywall call. Served instantly from a cache under 24 h old
829
+ * that was fetched for this customer since the last `setAttributes` or
830
+ * purchase, refreshed in the background. Otherwise reads the network and
831
+ * caches the result; if that read FAILS (offline / incident) it falls back
832
+ * to the last cached resolution with `stale: true`, so the paywall renders
833
+ * the last published version rather than not at all. Throws only when
834
+ * there is nothing cached. A deliberate rejection seen by the background
835
+ * refresh drops the cache, so the next resolve throws it. */
663
836
  async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
664
837
  const customerId = await this.customerIdPromise;
838
+ const cached = await this.readPlacementCache(placementKey, customerId);
839
+ const age = Date.now() - (cached?.fetchedAt ?? 0);
840
+ if (
841
+ cached &&
842
+ cached.customerId === customerId &&
843
+ cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
844
+ age >= 0 &&
845
+ age <= PLACEMENT_FRESH_MS
846
+ ) {
847
+ void this.refreshPlacement(placementKey);
848
+ return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
849
+ }
665
850
  try {
666
- // REV-219: identify the caller so a running experiment on this
667
- // placement can assign a (sticky, deterministic) variant. Servers
668
- // without experiments simply ignore the parameter. An EPHEMERAL anon
669
- // id (memory-only storage — new id every launch) is deliberately NOT
670
- // sent: each launch would enroll a phantom customer and flip the same
671
- // user between variants; the server serves the placement default
672
- // when no customer is identified (same guard class as M5 installs).
673
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
674
- // REV-268: the device facts ride along so the server can evaluate
675
- // targeting rules against THIS device on THIS request, and store them
676
- // as device.* attributes. Not sent for an ephemeral anon id: the server
677
- // would have no customer to keep them on, and it serves such callers
678
- // the untargeted default anyway.
679
- const deviceHeader = this.ephemeralAnonId
680
- ? null
681
- : await this.deviceHeaderPromise;
682
- const fresh = await this.request<PlacementResolution>(
683
- "GET",
684
- this.ephemeralAnonId
685
- ? path
686
- : `${path}?customer=${encodeURIComponent(customerId)}`,
687
- undefined,
688
- deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
689
- );
690
- await this.writePlacementCache(placementKey, fresh, customerId);
691
- return { ...fresh, stale: false, fetchedAt: Date.now() };
851
+ return await this.fetchPlacement(placementKey);
692
852
  } catch (err) {
693
853
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
694
854
  // (409) or deleted placement (404) is the server's deliberate answer,
695
855
  // and caching over it meant an unpublished paywall kept rendering
696
856
  // fleet-wide with no way to pull it.
697
857
  if (err instanceof RevnixError && !err.isRetryable) throw err;
698
- const cached = await this.readPlacementCache(placementKey, customerId);
699
- if (cached) {
700
- return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
858
+ const fallback = await this.readPlacementCache(placementKey, customerId);
859
+ if (fallback) {
860
+ return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
701
861
  }
702
862
  throw err;
703
863
  }
704
864
  }
705
865
 
866
+ /** Fetch these placements now so the next `resolvePlacement` answers from
867
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
868
+ * images are warmed by `prefetchPaywallImages` from
869
+ * `react-native-revnix/ui`. */
870
+ async preload(placementKeys: string[]): Promise<void> {
871
+ await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
872
+ }
873
+
874
+ private async refreshPlacement(placementKey: string): Promise<void> {
875
+ try {
876
+ await this.fetchPlacement(placementKey);
877
+ } catch (err) {
878
+ if (err instanceof RevnixError && !err.isRetryable) {
879
+ await this.deletePlacementCache(placementKey);
880
+ }
881
+ this.diag("resolvePlacement", err);
882
+ }
883
+ }
884
+
885
+ private async fetchPlacement(placementKey: string): Promise<PlacementResolution> {
886
+ const customerId = await this.customerIdPromise;
887
+ const fetchedAt = Date.now();
888
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
889
+ const deviceHeader = this.ephemeralAnonId
890
+ ? null
891
+ : await this.deviceHeaderPromise;
892
+ const fresh = await this.request<PlacementResolution>(
893
+ "GET",
894
+ this.ephemeralAnonId
895
+ ? path
896
+ : `${path}?customer=${encodeURIComponent(customerId)}`,
897
+ undefined,
898
+ deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
899
+ );
900
+ await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
901
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
902
+ }
903
+
706
904
  /** REV-157: the last resolution cached for `placementKey`, with NO network
707
905
  * call — for an instant, offline-safe paywall at launch. Always
708
906
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -811,7 +1009,8 @@ export class RevnixClient {
811
1009
  * - `restore` — restore purchases was tapped
812
1010
  * - `error` — the paywall itself failed (config, products, render)
813
1011
  *
814
- * Fire-and-forget like the other beacons: never blocks, never throws.
1012
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
1013
+ * never throws.
815
1014
  *
816
1015
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
817
1016
  * it is what threads the whole life of one impression together and puts
@@ -828,7 +1027,7 @@ export class RevnixClient {
828
1027
  * which caps the report at one per display per event type. Pass one per
829
1028
  * occurrence (and reuse it across your own retries) to record every
830
1029
  * occurrence — e.g. a customer who fails, retries and fails again. */
831
- logPaywallEvent(
1030
+ async logPaywallEvent(
832
1031
  event: PaywallInteraction,
833
1032
  viewId: string,
834
1033
  options?: {
@@ -841,29 +1040,8 @@ export class RevnixClient {
841
1040
  /** Idempotency key for this occurrence; defaults to `viewId`. */
842
1041
  eventId?: string;
843
1042
  }
844
- ): void {
1043
+ ): Promise<void> {
845
1044
  if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
846
- void this.customerIdPromise
847
- .then((customerId) =>
848
- this.request("POST", "/v1/paywalls/events", {
849
- customerId,
850
- viewId,
851
- event,
852
- occurredAt: Date.now(),
853
- sdkVersion: SDK_VERSION,
854
- ...(options?.eventId ? { eventId: options.eventId } : {}),
855
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
856
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
857
- ...(options?.productId ? { productId: options.productId } : {}),
858
- ...(options?.code ? { code: options.code } : {}),
859
- ...(options?.message
860
- ? { message: options.message.slice(0, 1024) }
861
- : {}),
862
- })
863
- )
864
- .catch((err) => this.diag("logPaywallEvent", err));
865
- // REV-272: backing out of the store sheet is the `transaction_abandon`
866
- // moment. Reuses the paywall.purchase_abandoned event just reported.
867
1045
  if (event === "purchase_abandoned") {
868
1046
  this.fireImplicitFromPaywall(
869
1047
  "transaction_abandon",
@@ -871,6 +1049,23 @@ export class RevnixClient {
871
1049
  options?.paywallId
872
1050
  );
873
1051
  }
1052
+ const customerId = await this.customerIdPromise;
1053
+ await this.enqueueEvent({
1054
+ path: "/v1/paywalls/events",
1055
+ body: {
1056
+ customerId,
1057
+ viewId,
1058
+ event,
1059
+ eventId: options?.eventId ?? viewId,
1060
+ occurredAt: Date.now(),
1061
+ sdkVersion: SDK_VERSION,
1062
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
1063
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
1064
+ ...(options?.productId ? { productId: options.productId } : {}),
1065
+ ...(options?.code ? { code: options.code } : {}),
1066
+ ...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
1067
+ },
1068
+ });
874
1069
  }
875
1070
 
876
1071
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
@@ -899,28 +1094,136 @@ export class RevnixClient {
899
1094
  }
900
1095
 
901
1096
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
902
- * Fire-and-forget like the other beacons: never blocks, never throws. */
903
- track(
1097
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
1098
+ * never throws. */
1099
+ async track(
904
1100
  event: string,
905
1101
  properties?: Record<string, string | number | boolean>,
906
1102
  options?: { eventId?: string }
907
- ): void {
1103
+ ): Promise<void> {
908
1104
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
909
1105
  this.diag("track", new Error(`invalid event name: ${event}`));
910
1106
  return;
911
1107
  }
912
1108
  const eventId = options?.eventId ?? generateEventId();
913
- void this.customerIdPromise
914
- .then((customerId) =>
915
- this.request("POST", "/v1/events", {
916
- customerId,
917
- event,
918
- eventId,
919
- occurredAt: Date.now(),
920
- ...(properties ? { properties } : {}),
921
- })
922
- )
923
- .catch((err) => this.diag("track", err));
1109
+ const customerId = await this.customerIdPromise;
1110
+ await this.enqueueEvent({
1111
+ path: "/v1/events",
1112
+ body: {
1113
+ customerId,
1114
+ event,
1115
+ eventId,
1116
+ occurredAt: Date.now(),
1117
+ ...(properties ? { properties } : {}),
1118
+ },
1119
+ });
1120
+ }
1121
+
1122
+ /** Send every queued `track` and paywall interaction event now. Events are
1123
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
1124
+ * app goes to the background, and on the next launch. Never throws; a
1125
+ * transient failure keeps the events and holds further sends for 30 s. */
1126
+ async flushEvents(): Promise<void> {
1127
+ await this.eventFlush;
1128
+ this.startEventFlush();
1129
+ await this.eventFlush;
1130
+ }
1131
+
1132
+ private async readEvents(): Promise<QueuedEvent[]> {
1133
+ try {
1134
+ const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
1135
+ return Array.isArray(parsed) ? (parsed as QueuedEvent[]) : [];
1136
+ } catch {
1137
+ return [];
1138
+ }
1139
+ }
1140
+
1141
+ private mutateEvents(mutate: (events: QueuedEvent[]) => QueuedEvent[]): Promise<number> {
1142
+ return this.withStorageLock(async () => {
1143
+ const next = mutate(await this.readEvents());
1144
+ try {
1145
+ await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
1146
+ } catch (err) {
1147
+ this.diag("storageWrite", err);
1148
+ }
1149
+ return next.length;
1150
+ });
1151
+ }
1152
+
1153
+ // ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
1154
+ private async enqueueEvent(event: QueuedEvent): Promise<void> {
1155
+ const count = await this.mutateEvents((events) => {
1156
+ const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
1157
+ if (kept.length < events.length) {
1158
+ this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
1159
+ }
1160
+ return [...kept, event];
1161
+ });
1162
+ if (count >= EVENT_FLUSH_THRESHOLD) this.startEventFlush();
1163
+ else this.armEventTimer(EVENT_FLUSH_DELAY_MS);
1164
+ }
1165
+
1166
+ private armEventTimer(ms: number): void {
1167
+ if (this.eventTimer !== null || this.disposed) return;
1168
+ this.eventTimer = setTimeout(() => {
1169
+ this.eventTimer = null;
1170
+ this.startEventFlush();
1171
+ }, ms);
1172
+ }
1173
+
1174
+ private startEventFlush(): void {
1175
+ if (this.eventFlush !== null || this.disposed) return;
1176
+ const hold = this.eventRetryAt - Date.now();
1177
+ if (hold > 0) {
1178
+ this.armEventTimer(hold);
1179
+ return;
1180
+ }
1181
+ this.eventFlush = this.runEventFlush().finally(() => {
1182
+ this.eventFlush = null;
1183
+ });
1184
+ }
1185
+
1186
+ private async runEventFlush(): Promise<void> {
1187
+ const byPath = new Map<string, QueuedEvent[]>();
1188
+ for (const event of await this.withStorageLock(() => this.readEvents())) {
1189
+ byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
1190
+ }
1191
+ for (const [path, events] of byPath) {
1192
+ for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
1193
+ const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
1194
+ try {
1195
+ const response = await this.request<unknown>("POST", path, {
1196
+ events: chunk.map((e) => e.body),
1197
+ });
1198
+ batchResults(response, path).forEach((result, i) => {
1199
+ if (result.status < 400) return;
1200
+ this.diag(
1201
+ "eventQueue",
1202
+ new RevnixError(
1203
+ result.status,
1204
+ `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`
1205
+ )
1206
+ );
1207
+ });
1208
+ await this.removeEvents(chunk);
1209
+ } catch (err) {
1210
+ if (worthQueueing(err)) {
1211
+ this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
1212
+ this.diag("eventQueue", err);
1213
+ return;
1214
+ }
1215
+ await this.removeEvents(chunk);
1216
+ this.diag("eventQueue", err);
1217
+ }
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ private async removeEvents(sent: QueuedEvent[]): Promise<void> {
1223
+ const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
1224
+ await this.mutateEvents((events) =>
1225
+ events.filter((e) => !sentJson.has(JSON.stringify(e)))
1226
+ );
924
1227
  }
925
1228
 
926
1229
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
@@ -1027,6 +1330,7 @@ export class RevnixClient {
1027
1330
  `/v1/customers/${encodeURIComponent(customerId)}/attributes`,
1028
1331
  { attributes }
1029
1332
  );
1333
+ await this.invalidatePlacements();
1030
1334
  }
1031
1335
 
1032
1336
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
@@ -1407,6 +1711,19 @@ export class RevnixClient {
1407
1711
  return extra;
1408
1712
  }
1409
1713
 
1714
+ private subscribeLifecycle(configured: RevnixConfig["lifecycle"]): void {
1715
+ if (configured === false) return;
1716
+ const lifecycle: RevnixLifecycle | null = configured ?? browserLifecycle();
1717
+ if (!lifecycle) return;
1718
+ try {
1719
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1720
+ this.onAppState(state)
1721
+ );
1722
+ } catch (err) {
1723
+ this.diag("lifecycleSubscribe", err);
1724
+ }
1725
+ }
1726
+
1410
1727
  /** REV-272: the launch-time moments, plus the foreground listener that
1411
1728
  * `session_start` is built on.
1412
1729
  *
@@ -1415,7 +1732,7 @@ export class RevnixClient {
1415
1732
  * install too when this launch generated the anonymous id. All three are
1416
1733
  * independent placements, so an app may have any subset configured and the
1417
1734
  * others cost nothing. */
1418
- private startImplicit(configured: RevnixConfig["lifecycle"]): void {
1735
+ private startImplicit(): void {
1419
1736
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1420
1737
  // off but did not await — so read it after that promise settles rather
1421
1738
  // than now, when it is still false on a genuine first launch.
@@ -1425,25 +1742,6 @@ export class RevnixClient {
1425
1742
  // are still reported — a launch is a launch whether or not a paywall
1426
1743
  // showed — but an app that configured all three must not have three
1427
1744
  // paywalls pushed onto its first frame.
1428
- // Subscribed BEFORE the batch, which can take a full network timeout per
1429
- // moment when offline: a customer who backgrounds the app during that
1430
- // window and comes back an hour later is a session, and missing the
1431
- // background transition would lose it.
1432
- if (configured !== false) {
1433
- const lifecycle: RevnixLifecycle | null =
1434
- configured ?? browserLifecycle();
1435
- if (lifecycle) {
1436
- try {
1437
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1438
- this.onAppState(state)
1439
- );
1440
- } catch (err) {
1441
- // A broken adapter costs session detection, not the client.
1442
- this.diag("lifecycleSubscribe", err);
1443
- }
1444
- }
1445
- }
1446
-
1447
1745
  const sessionExtra = this.consumePreviousSessionMs().catch(
1448
1746
  (err: unknown) => {
1449
1747
  this.diag("session", err);
@@ -1483,6 +1781,8 @@ export class RevnixClient {
1483
1781
 
1484
1782
  private onAppState(state: RevnixAppState): void {
1485
1783
  if (this.disposed) return;
1784
+ if (state === "background") this.startEventFlush();
1785
+ if (!this.implicitEnabled) return;
1486
1786
  if (state === "background") {
1487
1787
  // First report wins: a platform that repeats "background" (a paused
1488
1788
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1532,6 +1832,10 @@ export class RevnixClient {
1532
1832
  // every return alongside its successor.
1533
1833
  this.lifecycleUnsubscribe?.();
1534
1834
  this.lifecycleUnsubscribe = null;
1835
+ this.stopStoreObserver?.();
1836
+ this.stopStoreObserver = null;
1837
+ if (this.eventTimer !== null) clearTimeout(this.eventTimer);
1838
+ this.eventTimer = null;
1535
1839
  }
1536
1840
 
1537
1841
  /** A block paywall reporting a paint string it could not read. Routed to the
@@ -1780,25 +2084,13 @@ export class RevnixClient {
1780
2084
  // common no-experiment offline paywall keeps working across identity
1781
2085
  // changes.
1782
2086
 
1783
- private async readPlacementMap(): Promise<
1784
- Record<
1785
- string,
1786
- { data: PlacementResolution; fetchedAt: number; customerId?: string }
1787
- >
1788
- > {
2087
+ private async readPlacementMap(): Promise<Record<string, PlacementCacheEntry>> {
1789
2088
  try {
1790
2089
  const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
1791
2090
  if (!raw) return {};
1792
2091
  const parsed = JSON.parse(raw);
1793
2092
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1794
- ? (parsed as Record<
1795
- string,
1796
- {
1797
- data: PlacementResolution;
1798
- fetchedAt: number;
1799
- customerId?: string;
1800
- }
1801
- >)
2093
+ ? (parsed as Record<string, PlacementCacheEntry>)
1802
2094
  : {};
1803
2095
  } catch {
1804
2096
  return {};
@@ -1808,7 +2100,7 @@ export class RevnixClient {
1808
2100
  private async readPlacementCache(
1809
2101
  placementKey: string,
1810
2102
  customerId: string
1811
- ): Promise<{ data: PlacementResolution; fetchedAt: number } | null> {
2103
+ ): Promise<PlacementCacheEntry | null> {
1812
2104
  const map = await this.readPlacementMap();
1813
2105
  const entry = map[placementKey];
1814
2106
  if (!entry) return null;
@@ -1826,40 +2118,74 @@ export class RevnixClient {
1826
2118
  return entry;
1827
2119
  }
1828
2120
 
1829
- private async writePlacementCache(
2121
+ private writePlacementCache(
1830
2122
  placementKey: string,
1831
2123
  data: PlacementResolution,
1832
- customerId: string
2124
+ customerId: string,
2125
+ fetchedAt: number
2126
+ ): Promise<void> {
2127
+ return this.mutatePlacements((map) => {
2128
+ map[placementKey] = { data, fetchedAt, customerId };
2129
+ });
2130
+ }
2131
+
2132
+ private deletePlacementCache(placementKey: string): Promise<void> {
2133
+ return this.mutatePlacements((map) => {
2134
+ delete map[placementKey];
2135
+ });
2136
+ }
2137
+
2138
+ private mutatePlacements(
2139
+ mutate: (map: Record<string, PlacementCacheEntry>) => void
1833
2140
  ): Promise<void> {
2141
+ return this.withStorageLock(async () => {
2142
+ try {
2143
+ const map = await this.readPlacementMap();
2144
+ mutate(map);
2145
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
2146
+ } catch (err) {
2147
+ this.diag("storageWrite", err);
2148
+ }
2149
+ });
2150
+ }
2151
+
2152
+ private async placementsInvalidatedAt(): Promise<number> {
1834
2153
  try {
1835
- const map = await this.readPlacementMap();
1836
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1837
- await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
2154
+ return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
2155
+ } catch {
2156
+ return 0;
2157
+ }
2158
+ }
2159
+
2160
+ private async invalidatePlacements(): Promise<void> {
2161
+ try {
2162
+ await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
1838
2163
  } catch (err) {
1839
- // Best-effort, but reported (REV-200 M26).
1840
2164
  this.diag("storageWrite", err);
1841
2165
  }
1842
2166
  }
1843
2167
 
1844
2168
  // ——— C7: persisted purchase retry queue ———
1845
2169
 
1846
- // Serializes every queue read-modify-write. Storage adapters may be async,
1847
- // so two interleaved mutations (an enqueue during the retry pass, retry
1848
- // passes from constructor + a manual call) would otherwise clobber each
1849
- // other's writes and lose purchases.
1850
- private queueLock: Promise<void> = Promise.resolve();
2170
+ private storageLock: Promise<void> = Promise.resolve();
2171
+
2172
+ private withStorageLock<T>(task: () => Promise<T>): Promise<T> {
2173
+ const run = this.storageLock.then(task);
2174
+ this.storageLock = run.then(
2175
+ () => {},
2176
+ () => {}
2177
+ );
2178
+ return run;
2179
+ }
1851
2180
 
1852
2181
  private mutateQueue(
1853
2182
  mutate: (queue: PendingPurchase[]) => PendingPurchase[]
1854
2183
  ): Promise<void> {
1855
- const run = this.queueLock.then(async () => {
2184
+ return this.withStorageLock(async () => {
1856
2185
  const queue = await this.readPurchaseQueue();
1857
2186
  const next = mutate(queue);
1858
2187
  if (next !== queue) await this.writePurchaseQueue(next);
1859
2188
  });
1860
- // The chain must survive a failed mutation or every later one deadlocks.
1861
- this.queueLock = run.catch(() => {});
1862
- return run;
1863
2189
  }
1864
2190
 
1865
2191
  private async readPurchaseQueue(): Promise<PendingPurchase[]> {
@@ -1887,26 +2213,29 @@ export class RevnixClient {
1887
2213
  customerId: string,
1888
2214
  input: RegisterPurchaseInput
1889
2215
  ): Promise<void> {
1890
- // Repeated failures of the same purchase collapse to one queued entry.
1891
- return this.mutateQueue((queue) =>
1892
- queue.some((q) => pendingKey(q.input) === pendingKey(input))
1893
- ? queue
1894
- : [...queue, { customerId, input }]
1895
- );
2216
+ return this.mutateQueue((queue) => {
2217
+ if (queue.some((q) => pendingKey(q.input) === pendingKey(input))) return queue;
2218
+ const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
2219
+ for (const dropped of queue.slice(0, queue.length - kept.length)) {
2220
+ this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
2221
+ }
2222
+ return [...kept, { customerId, input, queuedAt: Date.now() }];
2223
+ });
1896
2224
  }
1897
2225
 
1898
2226
  private async request<T>(
1899
2227
  method: "GET" | "POST",
1900
2228
  path: string,
1901
2229
  body?: unknown,
1902
- extraHeaders?: Record<string, string>
2230
+ extraHeaders?: Record<string, string>,
2231
+ timeoutMs = this.requestTimeoutMs
1903
2232
  ): Promise<T> {
1904
2233
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1905
2234
  // can't hang the app. AbortController is guarded for very old runtimes.
1906
2235
  const controller =
1907
2236
  typeof AbortController !== "undefined" ? new AbortController() : undefined;
1908
2237
  const timer = controller
1909
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
2238
+ ? setTimeout(() => controller.abort(), timeoutMs)
1910
2239
  : undefined;
1911
2240
 
1912
2241
  // REV-200 (M26): piggyback the background-failure counter on this
@@ -1938,7 +2267,7 @@ export class RevnixClient {
1938
2267
  (err instanceof Error && err.name === "AbortError")
1939
2268
  ) {
1940
2269
  throw new RevnixTimeoutError(
1941
- `Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`
2270
+ `Revnix request to ${path} timed out after ${timeoutMs}ms`
1942
2271
  );
1943
2272
  }
1944
2273
  throw new RevnixNetworkError(