react-native-revnix 1.5.0 → 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/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RevnixClient = void 0;
4
+ exports.paywallImageUrls = paywallImageUrls;
4
5
  const errors_js_1 = require("./errors.js");
5
6
  const storage_js_1 = require("./storage.js");
6
7
  const device_js_1 = require("./device.js");
@@ -18,7 +19,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
18
19
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
19
20
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
20
21
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
22
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
23
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
21
24
  const DEFAULT_TIMEOUT_MS = 10000;
25
+ const EVENT_QUEUE_CAP = 1000;
26
+ const EVENT_BATCH_SIZE = 100;
27
+ const EVENT_FLUSH_THRESHOLD = 20;
28
+ const EVENT_FLUSH_DELAY_MS = 10000;
29
+ const EVENT_RETRY_HOLD_MS = 30000;
30
+ const PLACEMENT_FRESH_MS = 24 * 60 * 60 * 1000;
31
+ const CACHED_ENTITLEMENTS_TIMEOUT_MS = 3000;
32
+ const PURCHASE_QUEUE_CAP = 100;
33
+ const PURCHASE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
22
34
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
23
35
  const ENTITLEMENTS_CACHE_MAX = 4;
24
36
  // REV-204 (L10): http:// is tolerated only for development hosts.
@@ -44,6 +56,38 @@ const DEFAULT_OFFLINE_MAX_CACHE_AGE_MS = 14 * 24 * 60 * 60 * 1000;
44
56
  // — a rolled-back clock. Small negative skew is real (NTP corrections), a
45
57
  // large one is not.
46
58
  const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
59
+ function batchResults(response, path) {
60
+ const results = response?.results;
61
+ if (Array.isArray(results) &&
62
+ results.every((r) => typeof r === "object" && r !== null && typeof r.status === "number")) {
63
+ return results;
64
+ }
65
+ throw new errors_js_1.RevnixError(0, `Revnix: server returned an ill-shaped batch response for ${path}`);
66
+ }
67
+ function paywallImageUrls(value) {
68
+ const found = new Set();
69
+ const visit = (node) => {
70
+ if (Array.isArray(node)) {
71
+ node.forEach(visit);
72
+ return;
73
+ }
74
+ if (typeof node !== "object" || node === null)
75
+ return;
76
+ const o = node;
77
+ const background = o.background;
78
+ for (const url of [
79
+ o.heroImageUrl,
80
+ o.type === "image" ? o.url : undefined,
81
+ background?.image?.url,
82
+ ]) {
83
+ if (typeof url === "string" && url.length > 0)
84
+ found.add(url);
85
+ }
86
+ Object.values(o).forEach(visit);
87
+ };
88
+ visit(value);
89
+ return [...found];
90
+ }
47
91
  // One queue entry per store charge — repeated failures collapse onto it, and
48
92
  // the retry pass removes by this key rather than by array position.
49
93
  const pendingKey = (p) => `${p.source}:${p.token}:${p.transactionId}`;
@@ -99,6 +143,9 @@ class RevnixClient {
99
143
  * /v1/installs uses, and independent of the device header (which an app
100
144
  * can turn off entirely). */
101
145
  this.freshInstall = false;
146
+ this.eventFlush = null;
147
+ this.eventTimer = null;
148
+ this.eventRetryAt = 0;
102
149
  // REV-199 (CTO-R2 M21): the last fresh network read, held in memory so
103
150
  // reads within the soft TTL answer without a round trip — apps gate many
104
151
  // screens on isEntitled(), and every one of those was a full request for an
@@ -108,11 +155,7 @@ class RevnixClient {
108
155
  // REV-199 (M21): concurrent getEntitlements() calls share one request.
109
156
  this.inflightEntitlements = null;
110
157
  // ——— C7: persisted purchase retry queue ———
111
- // Serializes every queue read-modify-write. Storage adapters may be async,
112
- // so two interleaved mutations (an enqueue during the retry pass, retry
113
- // passes from constructor + a manual call) would otherwise clobber each
114
- // other's writes and lose purchases.
115
- this.queueLock = Promise.resolve();
158
+ this.storageLock = Promise.resolve();
116
159
  if (!config.apiKey)
117
160
  throw new Error("Revnix: apiKey is required");
118
161
  if (!config.baseUrl)
@@ -171,8 +214,13 @@ class RevnixClient {
171
214
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
172
215
  this.sessionTimeoutMs =
173
216
  config.sessionTimeoutMs ?? implicit_js_1.DEFAULT_SESSION_TIMEOUT_MS;
217
+ this.subscribeLifecycle(config.lifecycle);
174
218
  if (this.implicitEnabled)
175
- this.startImplicit(config.lifecycle);
219
+ this.startImplicit();
220
+ void this.readEvents().then((events) => {
221
+ if (events.length > 0)
222
+ this.startEventFlush();
223
+ });
176
224
  const { document, location } = globalThis;
177
225
  if (document &&
178
226
  location &&
@@ -258,6 +306,7 @@ class RevnixClient {
258
306
  const customerId = await this.customerIdPromise;
259
307
  try {
260
308
  const result = await this.postPurchase(customerId, input);
309
+ await this.invalidatePlacements();
261
310
  // Identity resolution may have merged this device's id away (restore
262
311
  // after login), so the server's id is the one to keep using.
263
312
  //
@@ -367,25 +416,30 @@ class RevnixClient {
367
416
  }
368
417
  /** C7: retry purchases that failed to register and were persisted locally.
369
418
  * Runs automatically at construction; call again when connectivity returns.
370
- * Idempotent and safe to call repeatedly. */
419
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
420
+ * days ago are dropped with a diagnostic. */
371
421
  async retryPendingPurchases() {
422
+ const now = Date.now();
423
+ await this.mutateQueue((queue) => queue.some((q) => q.queuedAt === undefined)
424
+ ? queue.map((q) => ({ ...q, queuedAt: q.queuedAt ?? now }))
425
+ : queue);
372
426
  const queue = await this.readPurchaseQueue();
373
427
  for (const item of queue) {
374
428
  if (this.disposed)
375
429
  return;
376
- try {
377
- await this.postPurchase(item.customerId, item.input);
378
- // Delivered — remove it.
430
+ if (now - (item.queuedAt ?? now) > PURCHASE_MAX_AGE_MS) {
431
+ this.diag("retryPendingPurchases", new Error(`expired ${pendingKey(item.input)}`));
379
432
  }
380
- catch (err) {
381
- if (worthQueueing(err)) {
382
- // REV-200 (M26): a queued purchase that STILL cannot land is the
383
- // most important background failure there is — report it.
433
+ else {
434
+ try {
435
+ await this.postPurchase(item.customerId, item.input);
436
+ await this.invalidatePlacements();
437
+ }
438
+ catch (err) {
384
439
  this.diag("retryPendingPurchases", err);
385
- continue; // transient — keep for next time
440
+ if (worthQueueing(err))
441
+ continue;
386
442
  }
387
- // Permanent rejection — remove it.
388
- this.diag("retryPendingPurchases", err);
389
443
  }
390
444
  // Remove THIS item from the queue as it stands now, not from the
391
445
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -427,16 +481,19 @@ class RevnixClient {
427
481
  }
428
482
  /** The authoritative network read: no TTL, no cache fallback — throws so
429
483
  * the caller sees the real failure (waitForEntitlements needs 429s). */
430
- async fetchFreshEntitlements(customerId) {
431
- const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`);
484
+ async fetchFreshEntitlements(customerId, timeoutMs) {
485
+ const fresh = await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/entitlements`, undefined, undefined, timeoutMs);
432
486
  await this.writeEntitlementsCache(customerId, fresh);
433
487
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
434
488
  this.freshEntitlements = { customerId, data: result, at: Date.now() };
435
489
  return result;
436
490
  }
437
491
  async fetchEntitlementsWithFallback(customerId) {
492
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
438
493
  try {
439
- return await this.fetchFreshEntitlements(customerId);
494
+ return await this.fetchFreshEntitlements(customerId, hasCache
495
+ ? Math.min(CACHED_ENTITLEMENTS_TIMEOUT_MS, this.requestTimeoutMs)
496
+ : undefined);
440
497
  }
441
498
  catch (err) {
442
499
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
@@ -547,35 +604,28 @@ class RevnixClient {
547
604
  return await this.request("GET", `/v1/customers/${encodeURIComponent(customerId)}/identity`);
548
605
  }
549
606
  /** Resolve a named in-app location to the currently published offering —
550
- * the one-line paywall call. REV-157: reads the network first and caches
551
- * the result; if that read FAILS (offline / incident) it falls back to the
552
- * last cached resolution with `stale: true`, so the paywall renders the
553
- * last published version rather than not at all. Throws only when there
554
- * is nothing cached. */
607
+ * the one-line paywall call. Served instantly from a cache under 24 h old
608
+ * that was fetched for this customer since the last `setAttributes` or
609
+ * purchase, refreshed in the background. Otherwise reads the network and
610
+ * caches the result; if that read FAILS (offline / incident) it falls back
611
+ * to the last cached resolution with `stale: true`, so the paywall renders
612
+ * the last published version rather than not at all. Throws only when
613
+ * there is nothing cached. A deliberate rejection seen by the background
614
+ * refresh drops the cache, so the next resolve throws it. */
555
615
  async resolvePlacement(placementKey) {
556
616
  const customerId = await this.customerIdPromise;
617
+ const cached = await this.readPlacementCache(placementKey, customerId);
618
+ const age = Date.now() - (cached?.fetchedAt ?? 0);
619
+ if (cached &&
620
+ cached.customerId === customerId &&
621
+ cached.fetchedAt > (await this.placementsInvalidatedAt()) &&
622
+ age >= 0 &&
623
+ age <= PLACEMENT_FRESH_MS) {
624
+ void this.refreshPlacement(placementKey);
625
+ return { ...cached.data, stale: false, fetchedAt: cached.fetchedAt };
626
+ }
557
627
  try {
558
- // REV-219: identify the caller so a running experiment on this
559
- // placement can assign a (sticky, deterministic) variant. Servers
560
- // without experiments simply ignore the parameter. An EPHEMERAL anon
561
- // id (memory-only storage — new id every launch) is deliberately NOT
562
- // sent: each launch would enroll a phantom customer and flip the same
563
- // user between variants; the server serves the placement default
564
- // when no customer is identified (same guard class as M5 installs).
565
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
566
- // REV-268: the device facts ride along so the server can evaluate
567
- // targeting rules against THIS device on THIS request, and store them
568
- // as device.* attributes. Not sent for an ephemeral anon id: the server
569
- // would have no customer to keep them on, and it serves such callers
570
- // the untargeted default anyway.
571
- const deviceHeader = this.ephemeralAnonId
572
- ? null
573
- : await this.deviceHeaderPromise;
574
- const fresh = await this.request("GET", this.ephemeralAnonId
575
- ? path
576
- : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [device_js_1.DEVICE_HEADER]: deviceHeader } : undefined);
577
- await this.writePlacementCache(placementKey, fresh, customerId);
578
- return { ...fresh, stale: false, fetchedAt: Date.now() };
628
+ return await this.fetchPlacement(placementKey);
579
629
  }
580
630
  catch (err) {
581
631
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
@@ -584,13 +634,44 @@ class RevnixClient {
584
634
  // fleet-wide with no way to pull it.
585
635
  if (err instanceof errors_js_1.RevnixError && !err.isRetryable)
586
636
  throw err;
587
- const cached = await this.readPlacementCache(placementKey, customerId);
588
- if (cached) {
589
- return { ...cached.data, stale: true, fetchedAt: cached.fetchedAt };
637
+ const fallback = await this.readPlacementCache(placementKey, customerId);
638
+ if (fallback) {
639
+ return { ...fallback.data, stale: true, fetchedAt: fallback.fetchedAt };
590
640
  }
591
641
  throw err;
592
642
  }
593
643
  }
644
+ /** Fetch these placements now so the next `resolvePlacement` answers from
645
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
646
+ * images are warmed by `prefetchPaywallImages` from
647
+ * `react-native-revnix/ui`. */
648
+ async preload(placementKeys) {
649
+ await Promise.all(placementKeys.map((key) => this.refreshPlacement(key)));
650
+ }
651
+ async refreshPlacement(placementKey) {
652
+ try {
653
+ await this.fetchPlacement(placementKey);
654
+ }
655
+ catch (err) {
656
+ if (err instanceof errors_js_1.RevnixError && !err.isRetryable) {
657
+ await this.deletePlacementCache(placementKey);
658
+ }
659
+ this.diag("resolvePlacement", err);
660
+ }
661
+ }
662
+ async fetchPlacement(placementKey) {
663
+ const customerId = await this.customerIdPromise;
664
+ const fetchedAt = Date.now();
665
+ const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
666
+ const deviceHeader = this.ephemeralAnonId
667
+ ? null
668
+ : await this.deviceHeaderPromise;
669
+ const fresh = await this.request("GET", this.ephemeralAnonId
670
+ ? path
671
+ : `${path}?customer=${encodeURIComponent(customerId)}`, undefined, deviceHeader ? { [device_js_1.DEVICE_HEADER]: deviceHeader } : undefined);
672
+ await this.writePlacementCache(placementKey, fresh, customerId, fetchedAt);
673
+ return { ...fresh, stale: false, fetchedAt: Date.now() };
674
+ }
594
675
  /** REV-157: the last resolution cached for `placementKey`, with NO network
595
676
  * call — for an instant, offline-safe paywall at launch. Always
596
677
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -684,7 +765,8 @@ class RevnixClient {
684
765
  * - `restore` — restore purchases was tapped
685
766
  * - `error` — the paywall itself failed (config, products, render)
686
767
  *
687
- * Fire-and-forget like the other beacons: never blocks, never throws.
768
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
769
+ * never throws.
688
770
  *
689
771
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
690
772
  * it is what threads the whole life of one impression together and puts
@@ -701,31 +783,29 @@ class RevnixClient {
701
783
  * which caps the report at one per display per event type. Pass one per
702
784
  * occurrence (and reuse it across your own retries) to record every
703
785
  * occurrence — e.g. a customer who fails, retries and fails again. */
704
- logPaywallEvent(event, viewId, options) {
786
+ async logPaywallEvent(event, viewId, options) {
705
787
  if (options?.placementKey === implicit_js_1.PREVIEW_PLACEMENT_KEY)
706
788
  return;
707
- void this.customerIdPromise
708
- .then((customerId) => this.request("POST", "/v1/paywalls/events", {
709
- customerId,
710
- viewId,
711
- event,
712
- occurredAt: Date.now(),
713
- sdkVersion: version_js_1.SDK_VERSION,
714
- ...(options?.eventId ? { eventId: options.eventId } : {}),
715
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
716
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
717
- ...(options?.productId ? { productId: options.productId } : {}),
718
- ...(options?.code ? { code: options.code } : {}),
719
- ...(options?.message
720
- ? { message: options.message.slice(0, 1024) }
721
- : {}),
722
- }))
723
- .catch((err) => this.diag("logPaywallEvent", err));
724
- // REV-272: backing out of the store sheet is the `transaction_abandon`
725
- // moment. Reuses the paywall.purchase_abandoned event just reported.
726
789
  if (event === "purchase_abandoned") {
727
790
  this.fireImplicitFromPaywall("transaction_abandon", viewId, options?.paywallId);
728
791
  }
792
+ const customerId = await this.customerIdPromise;
793
+ await this.enqueueEvent({
794
+ path: "/v1/paywalls/events",
795
+ body: {
796
+ customerId,
797
+ viewId,
798
+ event,
799
+ eventId: options?.eventId ?? viewId,
800
+ occurredAt: Date.now(),
801
+ sdkVersion: version_js_1.SDK_VERSION,
802
+ ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
803
+ ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
804
+ ...(options?.productId ? { productId: options.productId } : {}),
805
+ ...(options?.code ? { code: options.code } : {}),
806
+ ...(options?.message ? { message: options.message.slice(0, 1024) } : {}),
807
+ },
808
+ });
729
809
  }
730
810
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
731
811
  * Fire-and-forget like the other beacons: never blocks, never throws. */
@@ -750,22 +830,124 @@ class RevnixClient {
750
830
  .catch((err) => this.diag("logAdRevenue", err));
751
831
  }
752
832
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
753
- * Fire-and-forget like the other beacons: never blocks, never throws. */
754
- track(event, properties, options) {
833
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
834
+ * never throws. */
835
+ async track(event, properties, options) {
755
836
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
756
837
  this.diag("track", new Error(`invalid event name: ${event}`));
757
838
  return;
758
839
  }
759
840
  const eventId = options?.eventId ?? (0, storage_js_1.generateEventId)();
760
- void this.customerIdPromise
761
- .then((customerId) => this.request("POST", "/v1/events", {
762
- customerId,
763
- event,
764
- eventId,
765
- occurredAt: Date.now(),
766
- ...(properties ? { properties } : {}),
767
- }))
768
- .catch((err) => this.diag("track", err));
841
+ const customerId = await this.customerIdPromise;
842
+ await this.enqueueEvent({
843
+ path: "/v1/events",
844
+ body: {
845
+ customerId,
846
+ event,
847
+ eventId,
848
+ occurredAt: Date.now(),
849
+ ...(properties ? { properties } : {}),
850
+ },
851
+ });
852
+ }
853
+ /** Send every queued `track` and paywall interaction event now. Events are
854
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
855
+ * app goes to the background, and on the next launch. Never throws; a
856
+ * transient failure keeps the events and holds further sends for 30 s. */
857
+ async flushEvents() {
858
+ await this.eventFlush;
859
+ this.startEventFlush();
860
+ await this.eventFlush;
861
+ }
862
+ async readEvents() {
863
+ try {
864
+ const parsed = JSON.parse((await this.storage.getItem(EVENT_QUEUE_KEY)) ?? "[]");
865
+ return Array.isArray(parsed) ? parsed : [];
866
+ }
867
+ catch {
868
+ return [];
869
+ }
870
+ }
871
+ mutateEvents(mutate) {
872
+ return this.withStorageLock(async () => {
873
+ const next = mutate(await this.readEvents());
874
+ try {
875
+ await this.storage.setItem(EVENT_QUEUE_KEY, JSON.stringify(next));
876
+ }
877
+ catch (err) {
878
+ this.diag("storageWrite", err);
879
+ }
880
+ return next.length;
881
+ });
882
+ }
883
+ // ponytail: the whole queue is re-serialized per event; keep it in memory if 1000-item offline queues show up in profiles.
884
+ async enqueueEvent(event) {
885
+ const count = await this.mutateEvents((events) => {
886
+ const kept = events.slice(Math.max(0, events.length - EVENT_QUEUE_CAP + 1));
887
+ if (kept.length < events.length) {
888
+ this.diag("eventQueue", new Error("queue full, dropped the oldest event"));
889
+ }
890
+ return [...kept, event];
891
+ });
892
+ if (count >= EVENT_FLUSH_THRESHOLD)
893
+ this.startEventFlush();
894
+ else
895
+ this.armEventTimer(EVENT_FLUSH_DELAY_MS);
896
+ }
897
+ armEventTimer(ms) {
898
+ if (this.eventTimer !== null || this.disposed)
899
+ return;
900
+ this.eventTimer = setTimeout(() => {
901
+ this.eventTimer = null;
902
+ this.startEventFlush();
903
+ }, ms);
904
+ }
905
+ startEventFlush() {
906
+ if (this.eventFlush !== null || this.disposed)
907
+ return;
908
+ const hold = this.eventRetryAt - Date.now();
909
+ if (hold > 0) {
910
+ this.armEventTimer(hold);
911
+ return;
912
+ }
913
+ this.eventFlush = this.runEventFlush().finally(() => {
914
+ this.eventFlush = null;
915
+ });
916
+ }
917
+ async runEventFlush() {
918
+ const byPath = new Map();
919
+ for (const event of await this.withStorageLock(() => this.readEvents())) {
920
+ byPath.set(event.path, [...(byPath.get(event.path) ?? []), event]);
921
+ }
922
+ for (const [path, events] of byPath) {
923
+ for (let start = 0; start < events.length; start += EVENT_BATCH_SIZE) {
924
+ const chunk = events.slice(start, start + EVENT_BATCH_SIZE);
925
+ try {
926
+ const response = await this.request("POST", path, {
927
+ events: chunk.map((e) => e.body),
928
+ });
929
+ batchResults(response, path).forEach((result, i) => {
930
+ if (result.status < 400)
931
+ return;
932
+ this.diag("eventQueue", new errors_js_1.RevnixError(result.status, `dropped ${path} event ${String(chunk[i]?.body.eventId)}: ${result.status} ${result.error ?? ""}`));
933
+ });
934
+ await this.removeEvents(chunk);
935
+ }
936
+ catch (err) {
937
+ if (worthQueueing(err)) {
938
+ this.eventRetryAt = Date.now() + EVENT_RETRY_HOLD_MS;
939
+ this.diag("eventQueue", err);
940
+ return;
941
+ }
942
+ await this.removeEvents(chunk);
943
+ this.diag("eventQueue", err);
944
+ }
945
+ }
946
+ }
947
+ }
948
+ async removeEvents(sent) {
949
+ const sentJson = new Set(sent.map((e) => JSON.stringify(e)));
950
+ await this.mutateEvents((events) => events.filter((e) => !sentJson.has(JSON.stringify(e))));
769
951
  }
770
952
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
771
953
  * Revnix credits revenue to the right network/campaign. */
@@ -858,6 +1040,7 @@ class RevnixClient {
858
1040
  async setAttributes(attributes) {
859
1041
  const customerId = await this.customerIdPromise;
860
1042
  await this.request("POST", `/v1/customers/${encodeURIComponent(customerId)}/attributes`, { attributes });
1043
+ await this.invalidatePlacements();
861
1044
  }
862
1045
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
863
1046
  * already receive it (Linking.getInitialURL / onNewIntent / your router).
@@ -1205,6 +1388,19 @@ class RevnixClient {
1205
1388
  await this.storage.setItem(SESSION_STARTED_AT_KEY, String(Date.now()));
1206
1389
  return extra;
1207
1390
  }
1391
+ subscribeLifecycle(configured) {
1392
+ if (configured === false)
1393
+ return;
1394
+ const lifecycle = configured ?? (0, implicit_js_1.browserLifecycle)();
1395
+ if (!lifecycle)
1396
+ return;
1397
+ try {
1398
+ this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1399
+ }
1400
+ catch (err) {
1401
+ this.diag("lifecycleSubscribe", err);
1402
+ }
1403
+ }
1208
1404
  /** REV-272: the launch-time moments, plus the foreground listener that
1209
1405
  * `session_start` is built on.
1210
1406
  *
@@ -1213,7 +1409,7 @@ class RevnixClient {
1213
1409
  * install too when this launch generated the anonymous id. All three are
1214
1410
  * independent placements, so an app may have any subset configured and the
1215
1411
  * others cost nothing. */
1216
- startImplicit(configured) {
1412
+ startImplicit() {
1217
1413
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1218
1414
  // off but did not await — so read it after that promise settles rather
1219
1415
  // than now, when it is still false on a genuine first launch.
@@ -1223,22 +1419,6 @@ class RevnixClient {
1223
1419
  // are still reported — a launch is a launch whether or not a paywall
1224
1420
  // showed — but an app that configured all three must not have three
1225
1421
  // paywalls pushed onto its first frame.
1226
- // Subscribed BEFORE the batch, which can take a full network timeout per
1227
- // moment when offline: a customer who backgrounds the app during that
1228
- // window and comes back an hour later is a session, and missing the
1229
- // background transition would lose it.
1230
- if (configured !== false) {
1231
- const lifecycle = configured ?? (0, implicit_js_1.browserLifecycle)();
1232
- if (lifecycle) {
1233
- try {
1234
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) => this.onAppState(state));
1235
- }
1236
- catch (err) {
1237
- // A broken adapter costs session detection, not the client.
1238
- this.diag("lifecycleSubscribe", err);
1239
- }
1240
- }
1241
- }
1242
1422
  const sessionExtra = this.consumePreviousSessionMs().catch((err) => {
1243
1423
  this.diag("session", err);
1244
1424
  return undefined;
@@ -1273,6 +1453,10 @@ class RevnixClient {
1273
1453
  onAppState(state) {
1274
1454
  if (this.disposed)
1275
1455
  return;
1456
+ if (state === "background")
1457
+ this.startEventFlush();
1458
+ if (!this.implicitEnabled)
1459
+ return;
1276
1460
  if (state === "background") {
1277
1461
  // First report wins: a platform that repeats "background" (a paused
1278
1462
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1323,6 +1507,9 @@ class RevnixClient {
1323
1507
  this.lifecycleUnsubscribe = null;
1324
1508
  this.stopStoreObserver?.();
1325
1509
  this.stopStoreObserver = null;
1510
+ if (this.eventTimer !== null)
1511
+ clearTimeout(this.eventTimer);
1512
+ this.eventTimer = null;
1326
1513
  }
1327
1514
  /** A block paywall reporting a paint string it could not read. Routed to the
1328
1515
  * same sink as every other swallowed failure, so a host that already wired
@@ -1590,27 +1777,56 @@ class RevnixClient {
1590
1777
  }
1591
1778
  return entry;
1592
1779
  }
1593
- async writePlacementCache(placementKey, data, customerId) {
1780
+ writePlacementCache(placementKey, data, customerId, fetchedAt) {
1781
+ return this.mutatePlacements((map) => {
1782
+ map[placementKey] = { data, fetchedAt, customerId };
1783
+ });
1784
+ }
1785
+ deletePlacementCache(placementKey) {
1786
+ return this.mutatePlacements((map) => {
1787
+ delete map[placementKey];
1788
+ });
1789
+ }
1790
+ mutatePlacements(mutate) {
1791
+ return this.withStorageLock(async () => {
1792
+ try {
1793
+ const map = await this.readPlacementMap();
1794
+ mutate(map);
1795
+ await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1796
+ }
1797
+ catch (err) {
1798
+ this.diag("storageWrite", err);
1799
+ }
1800
+ });
1801
+ }
1802
+ async placementsInvalidatedAt() {
1803
+ try {
1804
+ return Number(await this.storage.getItem(PLACEMENTS_INVALIDATED_AT_KEY)) || 0;
1805
+ }
1806
+ catch {
1807
+ return 0;
1808
+ }
1809
+ }
1810
+ async invalidatePlacements() {
1594
1811
  try {
1595
- const map = await this.readPlacementMap();
1596
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1597
- await this.storage.setItem(PLACEMENTS_CACHE_KEY, JSON.stringify(map));
1812
+ await this.storage.setItem(PLACEMENTS_INVALIDATED_AT_KEY, String(Date.now()));
1598
1813
  }
1599
1814
  catch (err) {
1600
- // Best-effort, but reported (REV-200 M26).
1601
1815
  this.diag("storageWrite", err);
1602
1816
  }
1603
1817
  }
1818
+ withStorageLock(task) {
1819
+ const run = this.storageLock.then(task);
1820
+ this.storageLock = run.then(() => { }, () => { });
1821
+ return run;
1822
+ }
1604
1823
  mutateQueue(mutate) {
1605
- const run = this.queueLock.then(async () => {
1824
+ return this.withStorageLock(async () => {
1606
1825
  const queue = await this.readPurchaseQueue();
1607
1826
  const next = mutate(queue);
1608
1827
  if (next !== queue)
1609
1828
  await this.writePurchaseQueue(next);
1610
1829
  });
1611
- // The chain must survive a failed mutation or every later one deadlocks.
1612
- this.queueLock = run.catch(() => { });
1613
- return run;
1614
1830
  }
1615
1831
  async readPurchaseQueue() {
1616
1832
  try {
@@ -1635,17 +1851,22 @@ class RevnixClient {
1635
1851
  }
1636
1852
  }
1637
1853
  enqueuePurchase(customerId, input) {
1638
- // Repeated failures of the same purchase collapse to one queued entry.
1639
- return this.mutateQueue((queue) => queue.some((q) => pendingKey(q.input) === pendingKey(input))
1640
- ? queue
1641
- : [...queue, { customerId, input }]);
1854
+ return this.mutateQueue((queue) => {
1855
+ if (queue.some((q) => pendingKey(q.input) === pendingKey(input)))
1856
+ return queue;
1857
+ const kept = queue.slice(Math.max(0, queue.length - PURCHASE_QUEUE_CAP + 1));
1858
+ for (const dropped of queue.slice(0, queue.length - kept.length)) {
1859
+ this.diag("registerPurchase", new Error(`queue full, dropped ${pendingKey(dropped.input)}`));
1860
+ }
1861
+ return [...kept, { customerId, input, queuedAt: Date.now() }];
1862
+ });
1642
1863
  }
1643
- async request(method, path, body, extraHeaders) {
1864
+ async request(method, path, body, extraHeaders, timeoutMs = this.requestTimeoutMs) {
1644
1865
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1645
1866
  // can't hang the app. AbortController is guarded for very old runtimes.
1646
1867
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
1647
1868
  const timer = controller
1648
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
1869
+ ? setTimeout(() => controller.abort(), timeoutMs)
1649
1870
  : undefined;
1650
1871
  // REV-200 (M26): piggyback the background-failure counter on this
1651
1872
  // request so field pain reaches the backend's request logs. Cleared only
@@ -1674,7 +1895,7 @@ class RevnixClient {
1674
1895
  catch (err) {
1675
1896
  if (controller?.signal.aborted ||
1676
1897
  (err instanceof Error && err.name === "AbortError")) {
1677
- throw new errors_js_1.RevnixTimeoutError(`Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`);
1898
+ throw new errors_js_1.RevnixTimeoutError(`Revnix request to ${path} timed out after ${timeoutMs}ms`);
1678
1899
  }
1679
1900
  throw new errors_js_1.RevnixNetworkError(err instanceof Error ? err.message : "network request failed");
1680
1901
  }