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/README.md +138 -713
- package/android/build.gradle +34 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +247 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStorePackage.kt +14 -0
- package/dist/client.d.ts +58 -11
- package/dist/client.js +403 -114
- package/dist/esm/client.js +402 -114
- package/dist/esm/index.js +1 -0
- package/dist/esm/store/index.js +22 -0
- package/dist/esm/store.js +9 -0
- package/dist/esm/ui/RevnixPaywall.js +42 -8
- package/dist/esm/ui/index.js +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +25 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +13 -0
- package/dist/ui/RevnixPaywall.d.ts +17 -3
- package/dist/ui/RevnixPaywall.js +43 -8
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +2 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +18 -0
- package/ios/RevnixStore.swift +106 -0
- package/package.json +20 -2
- package/react-native-revnix.podspec +22 -0
- package/src/client.ts +475 -146
- package/src/index.ts +5 -0
- package/src/store/index.ts +44 -0
- package/src/store.ts +41 -0
- package/src/ui/RevnixPaywall.tsx +68 -17
- package/src/ui/index.ts +1 -0
- package/src/version.ts +1 -1
- package/store.d.ts +1 -0
- package/store.js +1 -0
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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;
|
|
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(
|
|
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.
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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
|
-
|
|
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
|
|
699
|
-
if (
|
|
700
|
-
return { ...
|
|
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
|
-
*
|
|
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
|
-
*
|
|
903
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
})
|
|
922
|
-
|
|
923
|
-
|
|
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(
|
|
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<
|
|
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
|
|
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
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
1892
|
-
queue.
|
|
1893
|
-
|
|
1894
|
-
|
|
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(),
|
|
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 ${
|
|
2270
|
+
`Revnix request to ${path} timed out after ${timeoutMs}ms`
|
|
1942
2271
|
);
|
|
1943
2272
|
}
|
|
1944
2273
|
throw new RevnixNetworkError(
|