@chipmobilesdk/rn-payment 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,146 @@
1
1
  # @chipmobilesdk/rn-payment
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One diagnostic stream: install a sink with `installSdkSink` from
8
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
9
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
10
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
11
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
12
+
13
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
14
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
15
+ status, consent or placement listener is reported as an `internal_error` event.
16
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
17
+ with a stable `code`); without it, warnings go to `console.warn` in development
18
+ builds only. The warn-once memory is bounded.
19
+ - rn-chart: now depends on rn-core for its diagnostics.
20
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
21
+ instead of swallowing it.
22
+
23
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
24
+ dependency: `isSdkError` recognises them alongside every other package's, and each
25
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
26
+
27
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
28
+ deprecated alias.
29
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
30
+ e-mail addresses and UUIDs.
31
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
32
+ to its code.
33
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
34
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
35
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
36
+ `code: 'contrast'` (new `ThemeErrorCode` type).
37
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
38
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
39
+
40
+ - Timers, retries and bounds now come from `@chipmobilesdk/rn-core`, and the drift
41
+ between the copies is fixed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-ads: a retry policy out of bounds throws `retry_out_of_bounds`, as documented,
44
+ instead of `timeout_out_of_bounds`; a placement's `minRequestIntervalMs` out of
45
+ bounds throws `invalid_config`.
46
+ - rn-payment: backoff validates `initialDelayMs` (100 ms to 60 s, not above
47
+ `maxDelayMs`) and `factor` (1 to 10); `factor: 0` used to retry at once, forever.
48
+ Every timeout, backoff or interval out of bounds now throws the new
49
+ `PAYMENT_INVALID_CONFIG` instead of `PAYMENT_INVALID_CATALOG`, which is about
50
+ products.
51
+ - rn-ai: concurrent calls share one behavior-config request, so a cold start makes
52
+ one instead of one per call. A silent retry after a config reload waits
53
+ `recovery.retryBackoffMs`, which was parsed but never applied.
54
+
55
+ - Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
56
+ and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
57
+
58
+ - rn-auth: creating `auth` no longer loads react-native-app-auth or
59
+ react-native-keychain; each loads on first use. New entrypoints
60
+ `@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
61
+ rn-core.
62
+ - rn-camera: a preview whose VisionCamera feed cannot load fails the session with
63
+ `cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
64
+ showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
65
+ - rn-notification: `createMmkvStore` reports a fallback to memory as
66
+ `STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
67
+ exposes `durable`.
68
+ - rn-payment: an iOS app validates its catalog against the iOS product ids; the
69
+ default platform used to be Android everywhere. A missing react-native-iap is
70
+ `PAYMENT_UNAVAILABLE`.
71
+ - rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
72
+ importing the package no longer loads Unistyles; `applyThemeMode` loads it on
73
+ first use.
74
+ - rn-ads, rn-local-db: the same loading, with the same error codes as before.
75
+
76
+ - Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
77
+ re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
78
+
79
+ - rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
80
+ and the provider's context no longer changes on every transition. New
81
+ `useAuthSelector`. `auth.getState()` returns the same frozen object until the state
82
+ changes, instead of a copy per call.
83
+ - rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
84
+ engine keeps unchanged entitlement objects across syncs and does not notify for a
85
+ sync that changed nothing.
86
+ - rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
87
+ one subscription when given an inline listener.
88
+ - rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
89
+ `useLocale`'s `setLocale` is stable.
90
+ - rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
91
+ `client.getBehaviorState()`.
92
+
93
+ - Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
94
+
95
+ - rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
96
+ line, converting `{name}` variables to `{{name}}`.
97
+ - rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
98
+ `aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
99
+ two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
100
+ - rn-auth: new `authMessages`, the catalog split into user and developer copy.
101
+ - rn-payment: new `paymentMessages`.
102
+ - rn-chart: summaries, announcements, adjustable-action labels, built-in state text
103
+ and legend hints come from `chartStrings`; pass `strings` to translate them. The
104
+ `accessibility.summaryFormatter` prop, declared before, now replaces the summary.
105
+
106
+ - Port conformance suites: one list of checks that a package's fake and its real adapter
107
+ both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
108
+
109
+ - rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
110
+ `ensure` and `createFaultRegistry` (deterministic fault injection by named point).
111
+ - rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
112
+ `runCameraEngineConformance` and the test adapter, which stays on the main
113
+ entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
114
+ prompts.
115
+ - rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
116
+ the Play adapter.
117
+ - rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
118
+ AdMob adapter.
119
+ - rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
120
+ `runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
121
+ in-memory doubles and the keychain and app-auth adapters.
122
+
123
+ - One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
124
+
125
+ - rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
126
+ reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
127
+ return from the background and `createManualLifecycle` in `./testing`.
128
+ - rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
129
+ subscribing to `AppState` each, so an app holds one native listener. The camera
130
+ controller's core layer no longer imports `react-native`.
131
+ - rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
132
+ `handleForeground()` on each return from the background. Off by default; calling
133
+ `handleForeground()` yourself works as before.
134
+ - rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
135
+ storage adapter has the new optional `getItemSync`, and returns false otherwise.
136
+ `localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
137
+ `KeyValuePort` (MMKV).
138
+
139
+ ### Patch Changes
140
+
141
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
142
+ - @chipmobilesdk/rn-core@0.1.0
143
+
3
144
  ## 0.4.1
4
145
 
5
146
  ### Patch Changes
package/README.md CHANGED
@@ -192,6 +192,13 @@ what "foreground" means for an app that may be backgrounded by its own modal, a
192
192
  biometric prompt, or the store's own purchase sheet. It is rate-limited
193
193
  regardless, so calling it liberally is safe.
194
194
 
195
+ If "back from the background" is what you mean, opt in instead of wiring `AppState`:
196
+ `<PaymentProvider syncOnForeground>` or `syncOnForeground(engine)` (returns an
197
+ unsubscribe). Both listen through rn-core's shared lifecycle port, so they add no
198
+ native subscription of their own, and a trip through `inactive` alone does not count.
199
+ On Android the Play purchase sheet does background the app, so a purchase is followed
200
+ by one rate-limited sync.
201
+
195
202
  ---
196
203
 
197
204
  ## Catalog and entitlement mapping
@@ -604,7 +611,9 @@ import { DEFAULT_USER_MESSAGES, USER_MESSAGE_KEYS, isUserMessageKey } from '@chi
604
611
 
605
612
  User-actionable and developer copy are **separate maps**, so you cannot
606
613
  accidentally show "Payment catalog is invalid" to a customer. `isUserMessageKey()`
607
- lets you assert that at your own boundary.
614
+ lets you assert that at your own boundary. `paymentMessages` is the same catalog as
615
+ an rn-core message set; register the user copy with rn-i18n in one line:
616
+ `runtimeEntriesFromCatalog(paymentMessages.user)`.
608
617
 
609
618
  Message keys are public API; renaming one is a breaking change.
610
619
 
@@ -612,6 +621,9 @@ Message keys are public API; renaming one is a breaking change.
612
621
 
613
622
  ## Events and errors
614
623
 
624
+ Every error is a `PaymentError` (or `PaymentConfigError`) with a stable `code`, the
625
+ `operation` and a `retryable` flag. `PaymentError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text.
626
+
615
627
  20 event types, each carrying a timestamp and — within a purchase cycle — one
616
628
  `correlationId`. The bar is that the event stream **alone** is enough to
617
629
  reconstruct a transaction's lifecycle, so "the user says they paid but has no
@@ -647,6 +659,11 @@ The failure goes to your `logger`, not back into the event stream.
647
659
  Errors carry a stable `code`, a log-safe `message`, the `operation`, a `retryable`
648
660
  flag, and a redacted `cause`. Error codes are public API.
649
661
 
662
+ A timeout, backoff or interval outside its bounds throws `PaymentConfigError` with
663
+ `PAYMENT_INVALID_CONFIG`, naming the field in `offenders`. Backoff checks every
664
+ field: `initialDelayMs` 100 ms to 60 s and not above `maxDelayMs`, `factor` 1 to
665
+ 10, `maxDelayMs` 5 s to 10 min, `maxAttempts` 1 to 10.
666
+
650
667
  ---
651
668
 
652
669
  ## Wiring an entitlement to another system
@@ -710,6 +727,12 @@ store.pushPurchaseUpdate(...); // deliver a settlement with no sync
710
727
  `./testing` is a separate entrypoint so test doubles cannot reach a production
711
728
  bundle.
712
729
 
730
+ `runProviderPortConformance(harness)` holds the checks every store adapter must pass:
731
+ connection and a dropped service, products with the store's own price, pending
732
+ purchases flagged rather than filtered, a bought and a cancelled flow, and
733
+ finalization that is safe twice. The package runs it against the fake store and the
734
+ Play adapter; an iOS adapter, or a device run from a demo screen, uses the same suite.
735
+
713
736
  ### Licence testers, for device verification
714
737
 
715
738
  1. Play Console → **Setup → Licence testing**, add tester accounts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-payment",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "description": "Reusable in-app payment and entitlement capability for ChipMobileSdk React Native apps",
@@ -27,9 +27,12 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "scripts": {
30
- "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
31
31
  "pack:check": "npm pack --dry-run"
32
32
  },
33
+ "dependencies": {
34
+ "@chipmobilesdk/rn-core": "^0.1.0"
35
+ },
33
36
  "peerDependencies": {
34
37
  "react": ">=19",
35
38
  "react-native": ">=0.85",
@@ -91,6 +91,8 @@ Unlike `@chipmobilesdk/rn-ads`, there is **no test-id substitution**: Play has n
91
91
 
92
92
  Call `handleForeground()` yourself on app resume — the package will not guess what
93
93
  "foreground" means for a host backgrounded by its own modal or the store's purchase sheet.
94
+ When a return from the background is enough, opt in with `<PaymentProvider
95
+ syncOnForeground>` or `syncOnForeground(engine)` (rn-core's shared lifecycle port).
94
96
 
95
97
  ## Public API
96
98
 
@@ -103,8 +105,8 @@ environment; everything else returns a typed result) · `createStoreProvider` ·
103
105
  `DEFAULT_USER_MESSAGES`, `DEFAULT_DEVELOPER_MESSAGES`, `USER_MESSAGE_KEYS`,
104
106
  `DEVELOPER_MESSAGE_KEYS`, `isUserMessageKey` · timeout/retry/cache defaults and bounds
105
107
  (`DEFAULT_STALE_AFTER_MS`, `DEFAULT_PURCHASE_LAUNCH_TIMEOUT_MS`, `STALE_AFTER_BOUNDS`, …)
106
- · `createFakeStore`, `makePurchase` from `@chipmobilesdk/rn-payment/testing` (test
107
- doubles only).
108
+ · `createFakeStore`, `makePurchase`, `runProviderPortConformance` from
109
+ `@chipmobilesdk/rn-payment/testing` (test doubles and the store-adapter suite only).
108
110
 
109
111
  Engine: `initialize`, `cleanup`, `getStatus`, `subscribeStatus`, `getEntitlement`,
110
112
  `getAllEntitlements`, `subscribeEntitlements`, `getPendingPurchases`,
@@ -36,9 +36,12 @@
36
36
  * shortcut.
37
37
  */
38
38
 
39
+ import { createListenerSet, lazyPeer } from '@chipmobilesdk/rn-core';
40
+
39
41
  import type { RequestPurchaseProps, SubscriptionReplacementModeAndroid } from 'react-native-iap';
40
42
 
41
43
  import type { ProductType, Unsubscribe } from '../config/types';
44
+ import { PaymentError, redact } from '../diagnostics/errors';
42
45
  import type {
43
46
  ProviderCapabilities,
44
47
  ProviderConnectOutcome,
@@ -194,10 +197,27 @@ export function toRequestProps(request: ProviderPurchaseRequest): RequestPurchas
194
197
  const PLAY_STATE_PURCHASED = 1;
195
198
  const PLAY_STATE_PENDING = 2;
196
199
 
200
+ // Required lazily (rn-core's lazy peer) so importing the package does not require
201
+ // the native module.
202
+ const iapPeer = lazyPeer('react-native-iap', () => {
203
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
204
+ try {
205
+ return require('react-native-iap') as IapModule;
206
+ } catch (cause) {
207
+ throw cause;
208
+ }
209
+ });
210
+
197
211
  function loadModule(): IapModule {
198
- // Required lazily so importing the package does not require the native module.
199
- const mod = require('react-native-iap') as IapModule;
200
- return mod;
212
+ return iapPeer.require(
213
+ (peer, cause) =>
214
+ new PaymentError({
215
+ code: 'PAYMENT_UNAVAILABLE',
216
+ message: `${peer} is not installed or its native module is not linked`,
217
+ operation: 'initialize',
218
+ cause: redact(cause),
219
+ }),
220
+ );
201
221
  }
202
222
 
203
223
  function toMicros(value: string | number | undefined): number {
@@ -380,10 +400,10 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
380
400
  /** Tracked here, never read from the binding — see the openiap#408 note above. */
381
401
  let connected = false;
382
402
 
383
- const connectionListeners = new Set<(connected: boolean, reason?: string) => void>();
384
- const purchaseListeners = new Set<(purchase: ProviderPurchase) => void>();
403
+ const connectionListeners = createListenerSet<[boolean, string | undefined]>();
404
+ const purchaseListeners = createListenerSet<[ProviderPurchase]>();
385
405
  /** Only an in-flight `launchPurchase` listens here; see there. */
386
- const purchaseErrorListeners = new Set<(error: RawPurchaseError) => void>();
406
+ const purchaseErrorListeners = createListenerSet<[RawPurchaseError]>();
387
407
  let updateSub: { remove(): void } | undefined;
388
408
  let errorSub: { remove(): void } | undefined;
389
409
 
@@ -399,9 +419,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
399
419
  return;
400
420
  }
401
421
  connected = next;
402
- for (const listener of connectionListeners) {
403
- listener(next, reason);
404
- }
422
+ connectionListeners.notify(next, reason);
405
423
  };
406
424
 
407
425
  const attachListeners = (): void => {
@@ -411,10 +429,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
411
429
  detachListeners();
412
430
  const mod = module();
413
431
  updateSub = mod.purchaseUpdatedListener(raw => {
414
- const purchase = normalizePurchase(raw);
415
- for (const listener of purchaseListeners) {
416
- listener(purchase);
417
- }
432
+ purchaseListeners.notify(normalizePurchase(raw));
418
433
  });
419
434
  errorSub = mod.purchaseErrorListener(error => {
420
435
  // A `not-prepared` error is the one signal the binding gives that the
@@ -423,9 +438,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
423
438
  if (mapErrorCode(error.code) === 'not_ready') {
424
439
  notifyConnection(false, 'not_prepared');
425
440
  }
426
- for (const listener of purchaseErrorListeners) {
427
- listener(error);
428
- }
441
+ purchaseErrorListeners.notify(error);
429
442
  });
430
443
  };
431
444
 
@@ -486,15 +499,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
486
499
  },
487
500
 
488
501
  observeConnection(listener): Unsubscribe {
489
- connectionListeners.add(listener);
490
- let removed = false;
491
- return () => {
492
- if (removed) {
493
- return;
494
- }
495
- removed = true;
496
- connectionListeners.delete(listener);
497
- };
502
+ return connectionListeners.add(listener);
498
503
  },
499
504
 
500
505
  async queryProducts(
@@ -551,13 +556,13 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
551
556
  }
552
557
  settled = true;
553
558
  clearTimeout(grace);
554
- purchaseListeners.delete(onPurchase);
555
- purchaseErrorListeners.delete(onError);
559
+ stopPurchase();
560
+ stopError();
556
561
  resolve(outcome);
557
562
  }
558
563
 
559
- purchaseListeners.add(onPurchase);
560
- purchaseErrorListeners.add(onError);
564
+ const stopPurchase = purchaseListeners.add(onPurchase);
565
+ const stopError = purchaseErrorListeners.add(onError);
561
566
  try {
562
567
  module()
563
568
  .requestPurchase(toRequestProps(request))
@@ -581,15 +586,7 @@ export function createIapAdapter(options: IapAdapterOptions = {}): ProviderPort
581
586
  },
582
587
 
583
588
  observePurchases(listener): Unsubscribe {
584
- purchaseListeners.add(listener);
585
- let removed = false;
586
- return () => {
587
- if (removed) {
588
- return;
589
- }
590
- removed = true;
591
- purchaseListeners.delete(listener);
592
- };
589
+ return purchaseListeners.add(listener);
593
590
  },
594
591
 
595
592
  async acknowledge(token: ProviderToken): Promise<void> {
package/src/core/mutex.ts CHANGED
@@ -14,113 +14,12 @@
14
14
  * expects.
15
15
  */
16
16
 
17
- /**
18
- * Joins concurrent callers onto one in-flight operation.
19
- *
20
- * Callers arriving during the flight receive the same promise. Once it settles
21
- * the slot clears, so a later call starts fresh — this is de-duplication, not
22
- * caching.
23
- */
24
- export function createSingleFlight<T>(): {
25
- run(operation: () => Promise<T>): Promise<T>;
26
- inFlight(): boolean;
27
- } {
28
- let current: Promise<T> | undefined;
29
-
30
- return {
31
- run(operation: () => Promise<T>): Promise<T> {
32
- if (current) {
33
- return current;
34
- }
35
- const flight = operation().finally(() => {
36
- if (current === flight) {
37
- current = undefined;
38
- }
39
- });
40
- current = flight;
41
- return flight;
42
- },
43
- inFlight: () => current !== undefined,
44
- };
45
- }
46
-
47
- /** Distinguishes "refused because busy" from any other failure, without throwing. */
48
- export type ExclusiveOutcome<T> = { acquired: true; value: T } | { acquired: false };
49
-
50
- /**
51
- * Admits one operation at a time and refuses the rest.
52
- *
53
- * Refusal is returned, not thrown, because for a purchase it is a normal
54
- * outcome the app reports to the user — not an error to be caught.
55
- */
56
- export function createExclusive(): {
57
- run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>>;
58
- busy(): boolean;
59
- } {
60
- let held = false;
61
-
62
- return {
63
- async run<T>(operation: () => Promise<T>): Promise<ExclusiveOutcome<T>> {
64
- if (held) {
65
- return { acquired: false };
66
- }
67
- held = true;
68
- try {
69
- return { acquired: true, value: await operation() };
70
- } finally {
71
- held = false;
72
- }
73
- },
74
- busy: () => held,
75
- };
76
- }
77
-
78
- /**
79
- * Rate limiter for automatic foreground sync (PERF-002).
80
- *
81
- * Rejects rather than defers: a skipped automatic sync costs nothing because the
82
- * next foreground event will try again, whereas a queue of deferred syncs would
83
- * fire a burst the moment the interval elapsed — the exact storm this prevents.
84
- */
85
- export function createRateLimiter(
86
- minIntervalMs: number,
87
- now: () => number = Date.now,
88
- ): { tryAcquire(): boolean; reset(): void } {
89
- let lastAt: number | undefined;
90
-
91
- return {
92
- tryAcquire(): boolean {
93
- const at = now();
94
- if (lastAt !== undefined && at - lastAt < minIntervalMs) {
95
- return false;
96
- }
97
- lastAt = at;
98
- return true;
99
- },
100
- reset(): void {
101
- lastAt = undefined;
102
- },
103
- };
104
- }
105
-
106
- /**
107
- * Rejects with a `PAYMENT_TIMEOUT`-shaped error after `ms`.
108
- *
109
- * Always clears its timer, including on the success path: a package that leaks a
110
- * timer per operation fails the cleanup guarantee (FR-082, PERF-002).
111
- */
112
- export function withTimeout<T>(
113
- promise: Promise<T>,
114
- ms: number,
115
- onTimeout: () => Error,
116
- ): Promise<T> {
117
- let timer: ReturnType<typeof setTimeout> | undefined;
118
- const timeout = new Promise<never>((_, reject) => {
119
- timer = setTimeout(() => reject(onTimeout()), ms);
120
- });
121
- return Promise.race([promise, timeout]).finally(() => {
122
- if (timer !== undefined) {
123
- clearTimeout(timer);
124
- }
125
- }) as Promise<T>;
126
- }
17
+ // The primitives themselves are rn-core's; the reasoning above is why rn-payment
18
+ // uses each where it does.
19
+ export {
20
+ createExclusive,
21
+ createRateLimiter,
22
+ createSingleFlight,
23
+ withTimeout,
24
+ type ExclusiveOutcome,
25
+ } from '@chipmobilesdk/rn-core';
package/src/core/retry.ts CHANGED
@@ -9,6 +9,12 @@
9
9
  * schedule without burning real time.
10
10
  */
11
11
 
12
+ import {
13
+ computeBackoff,
14
+ runWithRetry as runCoreRetry,
15
+ type RetryHandle,
16
+ } from '@chipmobilesdk/rn-core';
17
+
12
18
  import type { BackoffConfig } from '../config/types';
13
19
 
14
20
  /** Opaque timer handle: a Node Timeout and a browser numeric id are both valid. */
@@ -25,9 +31,17 @@ export function backoffDelayMs(
25
31
  config: Required<BackoffConfig>,
26
32
  random: () => number = Math.random,
27
33
  ): number {
28
- const raw = config.initialDelayMs * Math.pow(config.factor, attempt - 1);
29
- const capped = Math.min(raw, config.maxDelayMs);
30
- return config.jitter ? Math.floor(capped * random()) : capped;
34
+ return computeBackoff(attempt, toSpec(config), random);
35
+ }
36
+
37
+ function toSpec(config: Required<BackoffConfig>) {
38
+ return {
39
+ initialDelayMs: config.initialDelayMs,
40
+ maxDelayMs: config.maxDelayMs,
41
+ factor: config.factor,
42
+ jitter: config.jitter ? ('full' as const) : ('none' as const),
43
+ maxAttempts: config.maxAttempts,
44
+ };
31
45
  }
32
46
 
33
47
  /** The full schedule, for asserting a retry policy without waiting for it. */
@@ -39,14 +53,7 @@ export function backoffSchedule(config: Required<BackoffConfig>): number[] {
39
53
  return delays;
40
54
  }
41
55
 
42
- export interface RetryHandle {
43
- /** Cancels any pending attempt. Idempotent, so cleanup can call it freely (FR-082). */
44
- cancel(): void;
45
- /** Attempts consumed so far, so a retry storm is observable rather than inferred. */
46
- attempts(): number;
47
- /** True once the per-session budget is spent. */
48
- exhausted(): boolean;
49
- }
56
+ export type { RetryHandle } from '@chipmobilesdk/rn-core';
50
57
 
51
58
  export interface RetryRunnerOptions {
52
59
  config: Required<BackoffConfig>;
@@ -65,69 +72,24 @@ export interface RetryRunnerOptions {
65
72
  }
66
73
 
67
74
  /**
68
- * Runs `attempt` under the bounded schedule.
75
+ * Runs `attempt` under the bounded schedule (rn-core's runner).
69
76
  *
70
77
  * Exhaustion is reported rather than thrown: for finalization, a spent session
71
78
  * budget is not a failure but a handoff to the next launch, and the caller is
72
- * the only one that knows which of the two it is.
79
+ * the only one that knows which of the two it is. A throwing attempt is a failed
80
+ * attempt, nothing more: letting it escape would silently drop a paid transaction.
73
81
  */
74
82
  export function runWithRetry(options: RetryRunnerOptions): RetryHandle {
75
- const {
76
- config,
77
- attempt,
78
- onExhausted,
79
- setTimeoutFn = setTimeout,
80
- clearTimeoutFn = clearTimeout,
81
- random = Math.random,
82
- } = options;
83
-
84
- let attemptCount = 0;
85
- let cancelled = false;
86
- let pending: TimerHandle | undefined;
87
-
88
- const schedule = (delayMs: number): void => {
89
- pending = setTimeoutFn(() => {
90
- pending = undefined;
91
- run().catch(() => undefined);
92
- }, delayMs);
93
- };
94
-
95
- const run = async (): Promise<void> => {
96
- if (cancelled) {
97
- return;
98
- }
99
- attemptCount += 1;
100
- let succeeded = false;
101
- try {
102
- succeeded = await attempt(attemptCount);
103
- } catch {
104
- // A throwing attempt is a failed attempt, nothing more. Letting it escape
105
- // would abandon the schedule and, for finalization, silently drop a paid
106
- // transaction.
107
- succeeded = false;
108
- }
109
- if (cancelled || succeeded) {
110
- return;
111
- }
112
- if (attemptCount >= config.maxAttempts) {
113
- onExhausted?.(attemptCount);
114
- return;
115
- }
116
- schedule(backoffDelayMs(attemptCount + 1, config, random));
117
- };
118
-
119
- // Fire the first attempt immediately; the schedule owns everything after it.
120
- run().catch(() => undefined);
121
-
122
- return {
123
- cancel(): void {
124
- cancelled = true;
125
- if (pending !== undefined) {
126
- clearTimeoutFn(pending);
127
- pending = undefined;
128
- }
83
+ const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
84
+ const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout;
85
+ return runCoreRetry({
86
+ spec: toSpec(options.config),
87
+ attempt: options.attempt,
88
+ onExhausted: options.onExhausted,
89
+ random: options.random,
90
+ scheduler: {
91
+ setTimeout: (callback, ms) => setTimeoutFn(callback, ms),
92
+ clearTimeout: handle => clearTimeoutFn(handle as TimerHandle),
129
93
  },
130
- attempts: () => attemptCount,
131
- exhausted: () => attemptCount >= config.maxAttempts,
132
- };
94
+ });
133
95
  }