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/src/client.ts CHANGED
@@ -67,7 +67,18 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
67
67
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
68
68
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
69
69
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
70
+ const EVENT_QUEUE_KEY = "revnix.eventQueue";
71
+ const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
70
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;
71
82
 
72
83
  // REV-204 (L10): how many customers keep a cached entitlements snapshot.
73
84
  const ENTITLEMENTS_CACHE_MAX = 4;
@@ -105,6 +116,57 @@ const CLOCK_ROLLBACK_TOLERANCE_MS = 5 * 60 * 1000;
105
116
  interface PendingPurchase {
106
117
  customerId: string;
107
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];
108
170
  }
109
171
 
110
172
  interface InstallResponse {
@@ -213,6 +275,9 @@ export class RevnixClient {
213
275
  * /v1/installs uses, and independent of the device header (which an app
214
276
  * can turn off entirely). */
215
277
  private freshInstall = false;
278
+ private eventFlush: Promise<void> | null = null;
279
+ private eventTimer: ReturnType<typeof setTimeout> | null = null;
280
+ private eventRetryAt = 0;
216
281
 
217
282
  constructor(config: RevnixConfig) {
218
283
  if (!config.apiKey) throw new Error("Revnix: apiKey is required");
@@ -283,7 +348,11 @@ export class RevnixClient {
283
348
  config.implicitPlacements ?? config.onImplicitPaywall !== undefined;
284
349
  this.sessionTimeoutMs =
285
350
  config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
286
- 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
+ });
287
356
  const { document, location } = globalThis as {
288
357
  document?: unknown;
289
358
  location?: { href: string; search: string };
@@ -396,6 +465,7 @@ export class RevnixClient {
396
465
  const customerId = await this.customerIdPromise;
397
466
  try {
398
467
  const result = await this.postPurchase(customerId, input);
468
+ await this.invalidatePlacements();
399
469
  // Identity resolution may have merged this device's id away (restore
400
470
  // after login), so the server's id is the one to keep using.
401
471
  //
@@ -522,23 +592,28 @@ export class RevnixClient {
522
592
 
523
593
  /** C7: retry purchases that failed to register and were persisted locally.
524
594
  * Runs automatically at construction; call again when connectivity returns.
525
- * 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. */
526
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
+ );
527
604
  const queue = await this.readPurchaseQueue();
528
605
  for (const item of queue) {
529
606
  if (this.disposed) return;
530
- try {
531
- await this.postPurchase(item.customerId, item.input);
532
- // Delivered — remove it.
533
- } catch (err) {
534
- if (worthQueueing(err)) {
535
- // REV-200 (M26): a queued purchase that STILL cannot land is the
536
- // 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) {
537
614
  this.diag("retryPendingPurchases", err);
538
- continue; // transient — keep for next time
615
+ if (worthQueueing(err)) continue;
539
616
  }
540
- // Permanent rejection — remove it.
541
- this.diag("retryPendingPurchases", err);
542
617
  }
543
618
  // Remove THIS item from the queue as it stands now, not from the
544
619
  // snapshot iterated above: each postPurchase can spend seconds in a
@@ -600,11 +675,15 @@ export class RevnixClient {
600
675
  /** The authoritative network read: no TTL, no cache fallback — throws so
601
676
  * the caller sees the real failure (waitForEntitlements needs 429s). */
602
677
  private async fetchFreshEntitlements(
603
- customerId: string
678
+ customerId: string,
679
+ timeoutMs?: number
604
680
  ): Promise<CustomerEntitlements> {
605
681
  const fresh = await this.request<CustomerEntitlements>(
606
682
  "GET",
607
- `/v1/customers/${encodeURIComponent(customerId)}/entitlements`
683
+ `/v1/customers/${encodeURIComponent(customerId)}/entitlements`,
684
+ undefined,
685
+ undefined,
686
+ timeoutMs
608
687
  );
609
688
  await this.writeEntitlementsCache(customerId, fresh);
610
689
  const result = { ...fresh, stale: false, fetchedAt: Date.now() };
@@ -615,8 +694,14 @@ export class RevnixClient {
615
694
  private async fetchEntitlementsWithFallback(
616
695
  customerId: string
617
696
  ): Promise<CustomerEntitlements> {
697
+ const hasCache = (await this.readEntitlementsCache(customerId)) !== null;
618
698
  try {
619
- 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
+ );
620
705
  } catch (err) {
621
706
  // REV-198 (CTO-R2 M22): the cache exists to ride out failures that
622
707
  // might succeed next time — network, timeout, 429, 5xx. A deliberate
@@ -740,54 +825,82 @@ export class RevnixClient {
740
825
  }
741
826
 
742
827
  /** Resolve a named in-app location to the currently published offering —
743
- * the one-line paywall call. REV-157: reads the network first and caches
744
- * the result; if that read FAILS (offline / incident) it falls back to the
745
- * last cached resolution with `stale: true`, so the paywall renders the
746
- * last published version rather than not at all. Throws only when there
747
- * 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. */
748
836
  async resolvePlacement(placementKey: string): Promise<PlacementResolution> {
749
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
+ }
750
850
  try {
751
- // REV-219: identify the caller so a running experiment on this
752
- // placement can assign a (sticky, deterministic) variant. Servers
753
- // without experiments simply ignore the parameter. An EPHEMERAL anon
754
- // id (memory-only storage — new id every launch) is deliberately NOT
755
- // sent: each launch would enroll a phantom customer and flip the same
756
- // user between variants; the server serves the placement default
757
- // when no customer is identified (same guard class as M5 installs).
758
- const path = `/v1/placements/${encodeURIComponent(placementKey)}/offering`;
759
- // REV-268: the device facts ride along so the server can evaluate
760
- // targeting rules against THIS device on THIS request, and store them
761
- // as device.* attributes. Not sent for an ephemeral anon id: the server
762
- // would have no customer to keep them on, and it serves such callers
763
- // the untargeted default anyway.
764
- const deviceHeader = this.ephemeralAnonId
765
- ? null
766
- : await this.deviceHeaderPromise;
767
- const fresh = await this.request<PlacementResolution>(
768
- "GET",
769
- this.ephemeralAnonId
770
- ? path
771
- : `${path}?customer=${encodeURIComponent(customerId)}`,
772
- undefined,
773
- deviceHeader ? { [DEVICE_HEADER]: deviceHeader } : undefined
774
- );
775
- await this.writePlacementCache(placementKey, fresh, customerId);
776
- return { ...fresh, stale: false, fetchedAt: Date.now() };
851
+ return await this.fetchPlacement(placementKey);
777
852
  } catch (err) {
778
853
  // REV-198 (M22): same rule as getEntitlements — an unpublished catalog
779
854
  // (409) or deleted placement (404) is the server's deliberate answer,
780
855
  // and caching over it meant an unpublished paywall kept rendering
781
856
  // fleet-wide with no way to pull it.
782
857
  if (err instanceof RevnixError && !err.isRetryable) throw err;
783
- const cached = await this.readPlacementCache(placementKey, customerId);
784
- if (cached) {
785
- 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 };
786
861
  }
787
862
  throw err;
788
863
  }
789
864
  }
790
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
+
791
904
  /** REV-157: the last resolution cached for `placementKey`, with NO network
792
905
  * call — for an instant, offline-safe paywall at launch. Always
793
906
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -896,7 +1009,8 @@ export class RevnixClient {
896
1009
  * - `restore` — restore purchases was tapped
897
1010
  * - `error` — the paywall itself failed (config, products, render)
898
1011
  *
899
- * 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.
900
1014
  *
901
1015
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
902
1016
  * it is what threads the whole life of one impression together and puts
@@ -913,7 +1027,7 @@ export class RevnixClient {
913
1027
  * which caps the report at one per display per event type. Pass one per
914
1028
  * occurrence (and reuse it across your own retries) to record every
915
1029
  * occurrence — e.g. a customer who fails, retries and fails again. */
916
- logPaywallEvent(
1030
+ async logPaywallEvent(
917
1031
  event: PaywallInteraction,
918
1032
  viewId: string,
919
1033
  options?: {
@@ -926,29 +1040,8 @@ export class RevnixClient {
926
1040
  /** Idempotency key for this occurrence; defaults to `viewId`. */
927
1041
  eventId?: string;
928
1042
  }
929
- ): void {
1043
+ ): Promise<void> {
930
1044
  if (options?.placementKey === PREVIEW_PLACEMENT_KEY) return;
931
- void this.customerIdPromise
932
- .then((customerId) =>
933
- this.request("POST", "/v1/paywalls/events", {
934
- customerId,
935
- viewId,
936
- event,
937
- occurredAt: Date.now(),
938
- sdkVersion: SDK_VERSION,
939
- ...(options?.eventId ? { eventId: options.eventId } : {}),
940
- ...(options?.placementKey ? { placementKey: options.placementKey } : {}),
941
- ...(options?.paywallId ? { paywallId: options.paywallId } : {}),
942
- ...(options?.productId ? { productId: options.productId } : {}),
943
- ...(options?.code ? { code: options.code } : {}),
944
- ...(options?.message
945
- ? { message: options.message.slice(0, 1024) }
946
- : {}),
947
- })
948
- )
949
- .catch((err) => this.diag("logPaywallEvent", err));
950
- // REV-272: backing out of the store sheet is the `transaction_abandon`
951
- // moment. Reuses the paywall.purchase_abandoned event just reported.
952
1045
  if (event === "purchase_abandoned") {
953
1046
  this.fireImplicitFromPaywall(
954
1047
  "transaction_abandon",
@@ -956,6 +1049,23 @@ export class RevnixClient {
956
1049
  options?.paywallId
957
1050
  );
958
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
+ });
959
1069
  }
960
1070
 
961
1071
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
@@ -984,28 +1094,136 @@ export class RevnixClient {
984
1094
  }
985
1095
 
986
1096
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
987
- * Fire-and-forget like the other beacons: never blocks, never throws. */
988
- track(
1097
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
1098
+ * never throws. */
1099
+ async track(
989
1100
  event: string,
990
1101
  properties?: Record<string, string | number | boolean>,
991
1102
  options?: { eventId?: string }
992
- ): void {
1103
+ ): Promise<void> {
993
1104
  if (!/^[a-z0-9_]{1,64}$/.test(event)) {
994
1105
  this.diag("track", new Error(`invalid event name: ${event}`));
995
1106
  return;
996
1107
  }
997
1108
  const eventId = options?.eventId ?? generateEventId();
998
- void this.customerIdPromise
999
- .then((customerId) =>
1000
- this.request("POST", "/v1/events", {
1001
- customerId,
1002
- event,
1003
- eventId,
1004
- occurredAt: Date.now(),
1005
- ...(properties ? { properties } : {}),
1006
- })
1007
- )
1008
- .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
+ );
1009
1227
  }
1010
1228
 
1011
1229
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
@@ -1112,6 +1330,7 @@ export class RevnixClient {
1112
1330
  `/v1/customers/${encodeURIComponent(customerId)}/attributes`,
1113
1331
  { attributes }
1114
1332
  );
1333
+ await this.invalidatePlacements();
1115
1334
  }
1116
1335
 
1117
1336
  /** REV-272: hand the SDK the URL that opened your app, from wherever you
@@ -1492,6 +1711,19 @@ export class RevnixClient {
1492
1711
  return extra;
1493
1712
  }
1494
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
+
1495
1727
  /** REV-272: the launch-time moments, plus the foreground listener that
1496
1728
  * `session_start` is built on.
1497
1729
  *
@@ -1500,7 +1732,7 @@ export class RevnixClient {
1500
1732
  * install too when this launch generated the anonymous id. All three are
1501
1733
  * independent placements, so an app may have any subset configured and the
1502
1734
  * others cost nothing. */
1503
- private startImplicit(configured: RevnixConfig["lifecycle"]): void {
1735
+ private startImplicit(): void {
1504
1736
  // freshInstall is set inside loadCustomerId, which the constructor kicked
1505
1737
  // off but did not await — so read it after that promise settles rather
1506
1738
  // than now, when it is still false on a genuine first launch.
@@ -1510,25 +1742,6 @@ export class RevnixClient {
1510
1742
  // are still reported — a launch is a launch whether or not a paywall
1511
1743
  // showed — but an app that configured all three must not have three
1512
1744
  // paywalls pushed onto its first frame.
1513
- // Subscribed BEFORE the batch, which can take a full network timeout per
1514
- // moment when offline: a customer who backgrounds the app during that
1515
- // window and comes back an hour later is a session, and missing the
1516
- // background transition would lose it.
1517
- if (configured !== false) {
1518
- const lifecycle: RevnixLifecycle | null =
1519
- configured ?? browserLifecycle();
1520
- if (lifecycle) {
1521
- try {
1522
- this.lifecycleUnsubscribe = lifecycle.onStateChange((state) =>
1523
- this.onAppState(state)
1524
- );
1525
- } catch (err) {
1526
- // A broken adapter costs session detection, not the client.
1527
- this.diag("lifecycleSubscribe", err);
1528
- }
1529
- }
1530
- }
1531
-
1532
1745
  const sessionExtra = this.consumePreviousSessionMs().catch(
1533
1746
  (err: unknown) => {
1534
1747
  this.diag("session", err);
@@ -1568,6 +1781,8 @@ export class RevnixClient {
1568
1781
 
1569
1782
  private onAppState(state: RevnixAppState): void {
1570
1783
  if (this.disposed) return;
1784
+ if (state === "background") this.startEventFlush();
1785
+ if (!this.implicitEnabled) return;
1571
1786
  if (state === "background") {
1572
1787
  // First report wins: a platform that repeats "background" (a paused
1573
1788
  // Activity re-reporting) must not keep resetting the clock forward.
@@ -1619,6 +1834,8 @@ export class RevnixClient {
1619
1834
  this.lifecycleUnsubscribe = null;
1620
1835
  this.stopStoreObserver?.();
1621
1836
  this.stopStoreObserver = null;
1837
+ if (this.eventTimer !== null) clearTimeout(this.eventTimer);
1838
+ this.eventTimer = null;
1622
1839
  }
1623
1840
 
1624
1841
  /** A block paywall reporting a paint string it could not read. Routed to the
@@ -1867,25 +2084,13 @@ export class RevnixClient {
1867
2084
  // common no-experiment offline paywall keeps working across identity
1868
2085
  // changes.
1869
2086
 
1870
- private async readPlacementMap(): Promise<
1871
- Record<
1872
- string,
1873
- { data: PlacementResolution; fetchedAt: number; customerId?: string }
1874
- >
1875
- > {
2087
+ private async readPlacementMap(): Promise<Record<string, PlacementCacheEntry>> {
1876
2088
  try {
1877
2089
  const raw = await this.storage.getItem(PLACEMENTS_CACHE_KEY);
1878
2090
  if (!raw) return {};
1879
2091
  const parsed = JSON.parse(raw);
1880
2092
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1881
- ? (parsed as Record<
1882
- string,
1883
- {
1884
- data: PlacementResolution;
1885
- fetchedAt: number;
1886
- customerId?: string;
1887
- }
1888
- >)
2093
+ ? (parsed as Record<string, PlacementCacheEntry>)
1889
2094
  : {};
1890
2095
  } catch {
1891
2096
  return {};
@@ -1895,7 +2100,7 @@ export class RevnixClient {
1895
2100
  private async readPlacementCache(
1896
2101
  placementKey: string,
1897
2102
  customerId: string
1898
- ): Promise<{ data: PlacementResolution; fetchedAt: number } | null> {
2103
+ ): Promise<PlacementCacheEntry | null> {
1899
2104
  const map = await this.readPlacementMap();
1900
2105
  const entry = map[placementKey];
1901
2106
  if (!entry) return null;
@@ -1913,40 +2118,74 @@ export class RevnixClient {
1913
2118
  return entry;
1914
2119
  }
1915
2120
 
1916
- private async writePlacementCache(
2121
+ private writePlacementCache(
1917
2122
  placementKey: string,
1918
2123
  data: PlacementResolution,
1919
- 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
1920
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> {
1921
2153
  try {
1922
- const map = await this.readPlacementMap();
1923
- map[placementKey] = { data, fetchedAt: Date.now(), customerId };
1924
- 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()));
1925
2163
  } catch (err) {
1926
- // Best-effort, but reported (REV-200 M26).
1927
2164
  this.diag("storageWrite", err);
1928
2165
  }
1929
2166
  }
1930
2167
 
1931
2168
  // ——— C7: persisted purchase retry queue ———
1932
2169
 
1933
- // Serializes every queue read-modify-write. Storage adapters may be async,
1934
- // so two interleaved mutations (an enqueue during the retry pass, retry
1935
- // passes from constructor + a manual call) would otherwise clobber each
1936
- // other's writes and lose purchases.
1937
- 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
+ }
1938
2180
 
1939
2181
  private mutateQueue(
1940
2182
  mutate: (queue: PendingPurchase[]) => PendingPurchase[]
1941
2183
  ): Promise<void> {
1942
- const run = this.queueLock.then(async () => {
2184
+ return this.withStorageLock(async () => {
1943
2185
  const queue = await this.readPurchaseQueue();
1944
2186
  const next = mutate(queue);
1945
2187
  if (next !== queue) await this.writePurchaseQueue(next);
1946
2188
  });
1947
- // The chain must survive a failed mutation or every later one deadlocks.
1948
- this.queueLock = run.catch(() => {});
1949
- return run;
1950
2189
  }
1951
2190
 
1952
2191
  private async readPurchaseQueue(): Promise<PendingPurchase[]> {
@@ -1974,26 +2213,29 @@ export class RevnixClient {
1974
2213
  customerId: string,
1975
2214
  input: RegisterPurchaseInput
1976
2215
  ): Promise<void> {
1977
- // Repeated failures of the same purchase collapse to one queued entry.
1978
- return this.mutateQueue((queue) =>
1979
- queue.some((q) => pendingKey(q.input) === pendingKey(input))
1980
- ? queue
1981
- : [...queue, { customerId, input }]
1982
- );
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
+ });
1983
2224
  }
1984
2225
 
1985
2226
  private async request<T>(
1986
2227
  method: "GET" | "POST",
1987
2228
  path: string,
1988
2229
  body?: unknown,
1989
- extraHeaders?: Record<string, string>
2230
+ extraHeaders?: Record<string, string>,
2231
+ timeoutMs = this.requestTimeoutMs
1990
2232
  ): Promise<T> {
1991
2233
  // C7: abort the request after requestTimeoutMs so a slow or dead backend
1992
2234
  // can't hang the app. AbortController is guarded for very old runtimes.
1993
2235
  const controller =
1994
2236
  typeof AbortController !== "undefined" ? new AbortController() : undefined;
1995
2237
  const timer = controller
1996
- ? setTimeout(() => controller.abort(), this.requestTimeoutMs)
2238
+ ? setTimeout(() => controller.abort(), timeoutMs)
1997
2239
  : undefined;
1998
2240
 
1999
2241
  // REV-200 (M26): piggyback the background-failure counter on this
@@ -2025,7 +2267,7 @@ export class RevnixClient {
2025
2267
  (err instanceof Error && err.name === "AbortError")
2026
2268
  ) {
2027
2269
  throw new RevnixTimeoutError(
2028
- `Revnix request to ${path} timed out after ${this.requestTimeoutMs}ms`
2270
+ `Revnix request to ${path} timed out after ${timeoutMs}ms`
2029
2271
  );
2030
2272
  }
2031
2273
  throw new RevnixNetworkError(