@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.
@@ -11,6 +11,8 @@
11
11
  * it configured something it did not.
12
12
  */
13
13
 
14
+ import { checkRange, type Range } from '@chipmobilesdk/rn-core';
15
+
14
16
  import { PaymentConfigError } from '../diagnostics/errors';
15
17
  import type { BackoffConfig, RetryConfig, TimeoutConfig } from '../config/types';
16
18
 
@@ -113,30 +115,31 @@ export const DEFAULT_FINALIZE_BACKOFF: Required<BackoffConfig> = {
113
115
 
114
116
  export const BACKOFF_MAX_DELAY_BOUNDS = { min: 5_000, max: 600_000 } as const;
115
117
  export const BACKOFF_ATTEMPT_BOUNDS = { min: 1, max: 10 } as const;
118
+ /** A first retry sooner than this is a storm; later than this, the user has given up. */
119
+ export const BACKOFF_INITIAL_DELAY_BOUNDS = { min: 100, max: 60_000 } as const;
120
+ /** 1 is a constant delay; 0 or below would retry at once, forever (CORE-05). */
121
+ export const BACKOFF_FACTOR_BOUNDS = { min: 1, max: 10 } as const;
116
122
 
117
- interface Bounds {
118
- readonly min: number;
119
- readonly max: number;
123
+ function invalidConfig(label: string) {
124
+ return (message: string) =>
125
+ new PaymentConfigError({
126
+ code: 'PAYMENT_INVALID_CONFIG',
127
+ message,
128
+ operation: 'configure',
129
+ offenders: [label],
130
+ });
120
131
  }
121
132
 
122
133
  function requireInBounds(
123
134
  label: string,
124
135
  value: number | undefined,
125
136
  fallback: number,
126
- bounds: Bounds,
137
+ bounds: Range,
138
+ unit = 'ms',
127
139
  ): number {
128
- if (value === undefined) {
129
- return fallback;
130
- }
131
- if (!Number.isFinite(value) || value < bounds.min || value > bounds.max) {
132
- throw new PaymentConfigError({
133
- code: 'PAYMENT_INVALID_CATALOG',
134
- message: `${label} must be between ${bounds.min} and ${bounds.max}ms, received ${value}`,
135
- operation: 'configure',
136
- offenders: [label],
137
- });
138
- }
139
- return value;
140
+ return value === undefined
141
+ ? fallback
142
+ : checkRange(label, value, bounds, invalidConfig(label), unit);
140
143
  }
141
144
 
142
145
  export interface ResolvedTimeouts {
@@ -205,11 +208,30 @@ function resolveBackoff(
205
208
  config?.maxAttempts,
206
209
  fallback.maxAttempts,
207
210
  BACKOFF_ATTEMPT_BOUNDS,
211
+ '',
208
212
  );
213
+ const initialDelayMs = requireInBounds(
214
+ `${label}.initialDelayMs`,
215
+ config?.initialDelayMs,
216
+ fallback.initialDelayMs,
217
+ BACKOFF_INITIAL_DELAY_BOUNDS,
218
+ );
219
+ const factor = requireInBounds(
220
+ `${label}.factor`,
221
+ config?.factor,
222
+ fallback.factor,
223
+ BACKOFF_FACTOR_BOUNDS,
224
+ '',
225
+ );
226
+ if (initialDelayMs > maxDelayMs) {
227
+ throw invalidConfig(`${label}.initialDelayMs`)(
228
+ `${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms)`,
229
+ );
230
+ }
209
231
  return {
210
- initialDelayMs: config?.initialDelayMs ?? fallback.initialDelayMs,
232
+ initialDelayMs,
211
233
  maxDelayMs,
212
- factor: config?.factor ?? fallback.factor,
234
+ factor,
213
235
  maxAttempts,
214
236
  jitter: config?.jitter ?? fallback.jitter,
215
237
  };
@@ -6,6 +6,10 @@
6
6
  * internal refactor and must never leak a store's own code.
7
7
  */
8
8
 
9
+ import { REDACTED, SdkError, createRedactor } from '@chipmobilesdk/rn-core';
10
+
11
+ const identifiers = createRedactor();
12
+
9
13
  export const PAYMENT_ERROR_CODES = [
10
14
  'PAYMENT_NOT_CONFIGURED',
11
15
  'PAYMENT_INVALID_CATALOG',
@@ -31,6 +35,11 @@ export const PAYMENT_ERROR_CODES = [
31
35
  'PAYMENT_STORAGE_UNAVAILABLE',
32
36
  /** `consume` on a transaction that is not a consumable, added in 0.3.2. */
33
37
  'PAYMENT_NOT_CONSUMABLE',
38
+ /**
39
+ * A timeout, backoff or interval outside its bounds, added in 0.5.0. These
40
+ * used to be reported as `PAYMENT_INVALID_CATALOG`, which is about products.
41
+ */
42
+ 'PAYMENT_INVALID_CONFIG',
34
43
  ] as const;
35
44
 
36
45
  export type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[number];
@@ -49,10 +58,9 @@ export type PaymentOperation =
49
58
  | 'validate'
50
59
  | 'persist';
51
60
 
52
- export class PaymentError extends Error {
53
- readonly code: PaymentErrorCode;
61
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-payment'`. */
62
+ export class PaymentError extends SdkError<PaymentErrorCode> {
54
63
  readonly operation: PaymentOperation;
55
- readonly retryable: boolean;
56
64
  /** Redacted where appropriate — never a raw receipt, token, or store object (FR-074). */
57
65
  readonly cause?: string;
58
66
 
@@ -63,11 +71,13 @@ export class PaymentError extends Error {
63
71
  retryable?: boolean;
64
72
  cause?: string;
65
73
  }) {
66
- super(init.message);
74
+ super('rn-payment', init.code, {
75
+ message: init.message,
76
+ retryable: init.retryable ?? false,
77
+ redactedCause: init.cause,
78
+ });
67
79
  this.name = 'PaymentError';
68
- this.code = init.code;
69
80
  this.operation = init.operation;
70
- this.retryable = init.retryable ?? false;
71
81
  this.cause = init.cause;
72
82
  }
73
83
  }
@@ -107,11 +117,8 @@ export class PaymentConfigError extends PaymentError {
107
117
  * purchase token or an account id and nothing here can tell which.
108
118
  */
109
119
  export function redact(value: unknown): string {
110
- if (value instanceof PaymentError) {
111
- return value.code;
112
- }
113
- if (value instanceof Error) {
114
- return value.name;
115
- }
116
- return '[redacted]';
120
+ // An SDK error reduces to its code and any other Error to its class name
121
+ // (rn-core's identifier-only redactor). Strings never survive here, not even
122
+ // a leading constant: a store message is free text from a payments SDK.
123
+ return value instanceof Error ? identifiers.text(value) : REDACTED;
117
124
  }
@@ -10,6 +10,16 @@
10
10
  * obfuscated account identifier, and the cache-scope token.
11
11
  */
12
12
 
13
+ import {
14
+ createDiagnosticChannel,
15
+ createEmitter as createCoreEmitter,
16
+ } from '@chipmobilesdk/rn-core';
17
+
18
+ /** Every delivered event also goes to the app's SDK sinks (`installSdkSink`, CORE-08). */
19
+ const channel = createDiagnosticChannel<PaymentEvent['type'] | 'listener_reporter_failed'>(
20
+ 'rn-payment',
21
+ );
22
+
13
23
  import { redact, type PaymentErrorCode, type PaymentOperation } from './errors';
14
24
  import type {
15
25
  EntitlementKey,
@@ -188,49 +198,58 @@ export interface PaymentEmitter {
188
198
  export function createEmitter(
189
199
  onListenerError?: (error: unknown, event: PaymentEvent) => void,
190
200
  ): PaymentEmitter {
191
- const listeners = new Set<PaymentEventListener>();
201
+ // The event whose listeners are running, so a failure is reported against it.
202
+ let dispatching: PaymentEvent | null = null;
192
203
  let isDisposed = false;
193
-
194
- const emit = (event: PaymentEvent): void => {
195
- if (isDisposed) {
196
- return;
197
- }
198
- // Snapshot first: a listener that subscribes or unsubscribes during
199
- // delivery must not mutate the set being iterated.
200
- for (const listener of [...listeners]) {
204
+ // rn-core's emitter: a snapshot per emit, each listener isolated, idempotent
205
+ // unsubscribe. Even the error reporter is isolated: if it throws, rn-core
206
+ // rethrows that on a fresh task rather than into the emit.
207
+ const emitter = createCoreEmitter<PaymentEvent>({
208
+ onListenerError: error => {
209
+ if (!dispatching) return;
201
210
  try {
202
- listener(event);
203
- } catch (error) {
204
- try {
205
- onListenerError?.(error, event);
206
- } catch {
207
- // Even the error reporter is not trusted to behave.
208
- }
211
+ onListenerError?.(error, dispatching);
212
+ } catch {
213
+ // The app's own reporter failed: nothing in the app is left to tell, so
214
+ // the failure goes to the SDK sinks rather than becoming an uncaught
215
+ // error on some later tick (FR-080).
216
+ channel.emit('listener_reporter_failed', 'error', {
217
+ eventType: dispatching.type,
218
+ });
209
219
  }
210
- }
211
- };
220
+ },
221
+ });
212
222
 
213
223
  return {
214
- emit,
215
- subscribe(listener: PaymentEventListener): Unsubscribe {
224
+ emit(event) {
216
225
  if (isDisposed) {
217
- return () => undefined;
226
+ return;
218
227
  }
219
- listeners.add(listener);
220
- let removed = false;
221
- return () => {
222
- if (removed) {
223
- return;
228
+ const previous = dispatching;
229
+ dispatching = event;
230
+ try {
231
+ if (emitter.emit(event)) {
232
+ // The type and the cycle only: payment events carry product and
233
+ // entitlement keys an app may treat as personal.
234
+ channel.emit(
235
+ event.type,
236
+ /failed|error|blocked|rejected|revoked/.test(event.type) ? 'warn' : 'info',
237
+ undefined,
238
+ event.correlationId,
239
+ );
224
240
  }
225
- removed = true;
226
- listeners.delete(listener);
227
- };
241
+ } finally {
242
+ dispatching = previous;
243
+ }
244
+ },
245
+ subscribe(listener: PaymentEventListener): Unsubscribe {
246
+ return isDisposed ? () => undefined : emitter.subscribe(listener);
228
247
  },
229
248
  dispose(): void {
230
249
  isDisposed = true;
231
- listeners.clear();
250
+ emitter.dispose();
232
251
  },
233
- listenerCount: () => listeners.size,
252
+ listenerCount: () => emitter.listenerCount(),
234
253
  disposed: () => isDisposed,
235
254
  };
236
255
  }
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  /** Copy an end user may reasonably be shown. */
17
+ import { defineMessages } from '@chipmobilesdk/rn-core';
18
+
17
19
  export interface PaymentUserMessageMap {
18
20
  'payment.state.pending': string;
19
21
  'payment.state.alreadyOwned': string;
@@ -68,20 +70,24 @@ export const DEFAULT_DEVELOPER_MESSAGES: PaymentDeveloperMessageMap = {
68
70
  'Purchase blocked: this environment may not create a real transaction',
69
71
  };
70
72
 
71
- export const DEFAULT_MESSAGES: PaymentMessageMap = {
72
- ...DEFAULT_USER_MESSAGES,
73
- ...DEFAULT_DEVELOPER_MESSAGES,
74
- };
73
+ /**
74
+ * The catalog as an rn-core message set. Register `paymentMessages.user` with
75
+ * rn-i18n to translate it like app copy; the developer copy stays out of bundles.
76
+ */
77
+ export const paymentMessages = defineMessages({
78
+ user: DEFAULT_USER_MESSAGES,
79
+ developer: DEFAULT_DEVELOPER_MESSAGES,
80
+ });
81
+
82
+ export const DEFAULT_MESSAGES: PaymentMessageMap = paymentMessages.defaults;
75
83
 
76
84
  /** Every user-actionable key, for an app building its translation resource. */
77
- export const USER_MESSAGE_KEYS = Object.keys(DEFAULT_USER_MESSAGES) as PaymentUserMessageKey[];
85
+ export const USER_MESSAGE_KEYS = paymentMessages.userKeys as PaymentUserMessageKey[];
78
86
 
79
87
  /** Every developer-facing key, so an app can exclude them from user-visible bundles. */
80
- export const DEVELOPER_MESSAGE_KEYS = Object.keys(
81
- DEFAULT_DEVELOPER_MESSAGES,
82
- ) as PaymentDeveloperMessageKey[];
88
+ export const DEVELOPER_MESSAGE_KEYS = paymentMessages.developerKeys as PaymentDeveloperMessageKey[];
83
89
 
84
90
  /** True for keys safe to show an end user. Lets an app assert that at its boundary. */
85
91
  export function isUserMessageKey(key: PaymentMessageKey): key is PaymentUserMessageKey {
86
- return key in DEFAULT_USER_MESSAGES;
92
+ return paymentMessages.audienceOf(key) === 'user';
87
93
  }
@@ -10,6 +10,8 @@
10
10
  * which is what keeps the binding replaceable and iOS additive (FR-002, FR-007).
11
11
  */
12
12
 
13
+ import { createListenerSet, currentPlatform } from '@chipmobilesdk/rn-core';
14
+
13
15
  import {
14
16
  assertEnvironmentSafe,
15
17
  mayCreateRealTransaction,
@@ -60,6 +62,7 @@ import {
60
62
  initialEntitlements,
61
63
  type EntitlementMap,
62
64
  type EntitlementState,
65
+ shareUnchanged,
63
66
  } from '../entitlements/aggregate';
64
67
  import { productKeyForStoreId } from '../entitlements/mapping';
65
68
  import { derivePending, pendingTransitions, samePending, UNKNOWN_PENDING } from './pending';
@@ -156,7 +159,10 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
156
159
  // Both run before anything connects: a bad catalog or an unsafe environment
157
160
  // must fail at configuration time, not when a user taps Buy.
158
161
  assertEnvironmentSafe(config);
159
- const platform = deps.platform ?? 'android';
162
+ // rn-core's one platform source (CORE-09): this used to assume Android, so an
163
+ // iOS app validated its catalog against the Android product ids. Where neither
164
+ // OS applies, the store provider refuses everything as unsupported anyway.
165
+ const platform = deps.platform ?? (currentPlatform() === 'ios' ? 'ios' : 'android');
160
166
  const resolved = validateConfig(config, platform);
161
167
 
162
168
  const now = deps.now ?? (() => Date.now());
@@ -190,9 +196,15 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
190
196
 
191
197
  let pending: PendingPurchasesState = UNKNOWN_PENDING;
192
198
 
193
- const statusListeners = new Set<(status: PaymentStatus) => void>();
194
- const entitlementListeners = new Set<(map: EntitlementMap) => void>();
195
- const pendingListeners = new Set<(pending: PendingPurchasesState) => void>();
199
+ // A broken consumer callback must not stop the others nor the state machine
200
+ // (FR-081); rn-core isolates each one and the failure goes to the logger.
201
+ const listenerOptions = {
202
+ onListenerError: (error: unknown) =>
203
+ config.logger?.error?.('payment listener threw', { reason: redact(error) }),
204
+ };
205
+ const statusListeners = createListenerSet<[PaymentStatus]>(listenerOptions);
206
+ const entitlementListeners = createListenerSet<[EntitlementMap]>(listenerOptions);
207
+ const pendingListeners = createListenerSet<[PendingPurchasesState]>(listenerOptions);
196
208
  let connectionSub: Unsubscribe | undefined;
197
209
  let purchaseSub: Unsubscribe | undefined;
198
210
  let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
@@ -222,14 +234,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
222
234
  unavailableReason: status.unavailableReason,
223
235
  retryable: status.retryable,
224
236
  });
225
- for (const listener of [...statusListeners]) {
226
- // A throwing status listener must not break the state machine (FR-081).
227
- try {
228
- listener(status);
229
- } catch {
230
- /* reported through the emitter's own guard */
231
- }
232
- }
237
+ statusListeners.notify(status);
233
238
  };
234
239
 
235
240
  /**
@@ -244,13 +249,16 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
244
249
  return;
245
250
  }
246
251
  const previous = entitlements;
247
- entitlements = aggregate({
248
- config: resolved,
249
- snapshot: currentSnapshot,
250
- hasSynced,
251
- reportsGraceAndHoldDistinctly: provider.capabilities().reportsGraceAndHoldDistinctly,
252
- now: now(),
253
- });
252
+ entitlements = shareUnchanged(
253
+ previous,
254
+ aggregate({
255
+ config: resolved,
256
+ snapshot: currentSnapshot,
257
+ hasSynced,
258
+ reportsGraceAndHoldDistinctly: provider.capabilities().reportsGraceAndHoldDistinctly,
259
+ now: now(),
260
+ }),
261
+ );
254
262
 
255
263
  for (const change of diffEntitlements(previous, entitlements)) {
256
264
  if (change.next === 'entitled') {
@@ -268,12 +276,9 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
268
276
  }
269
277
  }
270
278
 
271
- for (const listener of [...entitlementListeners]) {
272
- try {
273
- listener(entitlements);
274
- } catch {
275
- /* a broken consumer callback must not stop the others (FR-081) */
276
- }
279
+ // Unchanged map, same object: nothing to tell a subscriber (PERF-03).
280
+ if (entitlements !== previous) {
281
+ entitlementListeners.notify(entitlements);
277
282
  }
278
283
 
279
284
  publishPending(
@@ -298,13 +303,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
298
303
  return;
299
304
  }
300
305
  pending = next;
301
- for (const listener of [...pendingListeners]) {
302
- try {
303
- listener(pending);
304
- } catch {
305
- /* a broken consumer callback must not stop the others (FR-081) */
306
- }
307
- }
306
+ pendingListeners.notify(pending);
308
307
  };
309
308
 
310
309
  const commit = (next: PersistedSnapshot): void => {
@@ -668,15 +667,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
668
667
  if (disposed) {
669
668
  return () => undefined;
670
669
  }
671
- statusListeners.add(listener);
672
- let removed = false;
673
- return () => {
674
- if (removed) {
675
- return;
676
- }
677
- removed = true;
678
- statusListeners.delete(listener);
679
- };
670
+ return statusListeners.add(listener);
680
671
  },
681
672
 
682
673
  /** Synchronous, in-memory, zero I/O — it is read on render paths (PERF-001). */
@@ -699,30 +690,14 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
699
690
  if (disposed) {
700
691
  return () => undefined;
701
692
  }
702
- pendingListeners.add(listener);
703
- let removed = false;
704
- return () => {
705
- if (removed) {
706
- return;
707
- }
708
- removed = true;
709
- pendingListeners.delete(listener);
710
- };
693
+ return pendingListeners.add(listener);
711
694
  },
712
695
 
713
696
  subscribeEntitlements(listener): Unsubscribe {
714
697
  if (disposed) {
715
698
  return () => undefined;
716
699
  }
717
- entitlementListeners.add(listener);
718
- let removed = false;
719
- return () => {
720
- if (removed) {
721
- return;
722
- }
723
- removed = true;
724
- entitlementListeners.delete(listener);
725
- };
700
+ return entitlementListeners.add(listener);
726
701
  },
727
702
 
728
703
  async sync(trigger: SyncTrigger = 'manual'): Promise<SyncOutcome> {
@@ -862,13 +837,7 @@ export function createEngine(config: PaymentConfig, deps: EngineDeps): PaymentEn
862
837
  // Back to `unknown`, not `not_entitled`: after a logout the package has no
863
838
  // basis for either claim until the next sync.
864
839
  entitlements = initialEntitlements(resolved);
865
- for (const listener of [...entitlementListeners]) {
866
- try {
867
- listener(entitlements);
868
- } catch {
869
- /* ignore */
870
- }
871
- }
840
+ entitlementListeners.notify(entitlements);
872
841
  // Same reasoning, and no `purchase_pending_resolved`: the records were
873
842
  // discarded locally, not ended at the store.
874
843
  publishPending(UNKNOWN_PENDING);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Opt-in foreground sync over rn-core's shared lifecycle port (PERF-07, FR-059).
3
+ *
4
+ * `handleForeground()` stays the app's to call: the engine does not decide what a
5
+ * foreground return is. An app that is happy with "back from the background" calls
6
+ * this once instead of wiring `AppState` itself, and shares the one native
7
+ * subscription every package uses. A trip through `inactive` alone (a biometric
8
+ * prompt, iOS Control Center) is not a return. On Android the Play purchase sheet
9
+ * does send the app to the background, so a purchase is followed by a sync; the
10
+ * engine's foreground rate limit keeps that to one request per interval.
11
+ */
12
+ import {
13
+ appLifecycle,
14
+ onForeground,
15
+ type AppLifecyclePort,
16
+ type Unsubscribe,
17
+ } from '@chipmobilesdk/rn-core';
18
+
19
+ import type { PaymentEngine } from './engine';
20
+
21
+ export function syncOnForeground(
22
+ engine: Pick<PaymentEngine, 'handleForeground'>,
23
+ lifecycle: AppLifecyclePort = appLifecycle(),
24
+ ): Unsubscribe {
25
+ return onForeground(() => {
26
+ // Sync reports its own failures through status and diagnostics.
27
+ engine.handleForeground().catch(() => undefined);
28
+ }, lifecycle);
29
+ }
@@ -16,6 +16,7 @@
16
16
  * and the package says so rather than asserting the user did not pay.
17
17
  */
18
18
 
19
+ import { shallowEqual } from '@chipmobilesdk/rn-core';
19
20
  import type { ResolvedConfig } from '../config/validate';
20
21
  import type { EntitlementKey, ProductKey } from '../config/types';
21
22
  import type { PersistedPurchase, PersistedSnapshot } from '../persistence/snapshot';
@@ -225,3 +226,36 @@ export function diffEntitlements(
225
226
  }
226
227
  return changes;
227
228
  }
229
+
230
+ function sameEntitlement(a: EntitlementState, b: EntitlementState): boolean {
231
+ return (
232
+ a.status === b.status &&
233
+ a.isStale === b.isStale &&
234
+ a.lastSyncedAt === b.lastSyncedAt &&
235
+ a.sources.length === b.sources.length &&
236
+ a.sources.every((source, index) => source === b.sources[index]) &&
237
+ shallowEqual(a.subscription, b.subscription)
238
+ );
239
+ }
240
+
241
+ /**
242
+ * `next`, with every entitlement that did not change replaced by the object it
243
+ * replaces, and `previous` itself when nothing changed (PERF-03, PAY-08). A hook
244
+ * selecting one entitlement then sees the same object and does not re-render.
245
+ */
246
+ export function shareUnchanged(previous: EntitlementMap, next: EntitlementMap): EntitlementMap {
247
+ const keys = Object.keys(next);
248
+ let changed = keys.length !== Object.keys(previous).length;
249
+ const shared: EntitlementMap = {};
250
+ for (const key of keys) {
251
+ const before = previous[key];
252
+ const after = next[key]!;
253
+ if (before && sameEntitlement(before, after)) {
254
+ shared[key] = before;
255
+ } else {
256
+ shared[key] = after;
257
+ changed = true;
258
+ }
259
+ }
260
+ return changed ? shared : previous;
261
+ }
package/src/index.ts CHANGED
@@ -120,6 +120,8 @@ export type {
120
120
  SyncOutcome,
121
121
  } from './engine/engine';
122
122
 
123
+ export { syncOnForeground } from './engine/foreground';
124
+
123
125
  // --- React -----------------------------------------------------------------
124
126
  export { PaymentProvider, usePaymentEngine } from './react/PaymentProvider';
125
127
  export type { PaymentProviderProps } from './react/PaymentProvider';
@@ -150,6 +152,7 @@ export type {
150
152
  export {
151
153
  DEFAULT_DEVELOPER_MESSAGES,
152
154
  DEFAULT_MESSAGES,
155
+ paymentMessages,
153
156
  DEFAULT_USER_MESSAGES,
154
157
  DEVELOPER_MESSAGE_KEYS,
155
158
  USER_MESSAGE_KEYS,
@@ -14,19 +14,21 @@
14
14
  * attached to a transaction.
15
15
  */
16
16
 
17
+ import { createMemoryKeyValue, lazyPeer, openDurable, type MmkvLike } from '@chipmobilesdk/rn-core';
18
+
17
19
  import { emptySnapshot, migrateSnapshot, type PersistedSnapshot } from './snapshot';
18
20
 
19
21
  const INSTANCE_ID = 'chipmobilesdk.payment';
20
22
  const KEY_PREFIX = 'snapshot';
21
23
 
22
- /** The slice of a react-native-mmkv v4 instance this store uses (v4 names). */
23
- interface MmkvLike {
24
- getString(key: string): string | undefined;
25
- set(key: string, value: string): void;
26
- remove(key: string): unknown;
27
- clearAll(): void;
28
- getAllKeys(): string[];
29
- }
24
+ const mmkv = lazyPeer('react-native-mmkv', () => {
25
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
26
+ try {
27
+ return require('react-native-mmkv') as { createMMKV: (config?: { id?: string }) => MmkvLike };
28
+ } catch (cause) {
29
+ throw cause;
30
+ }
31
+ });
30
32
 
31
33
  export interface SnapshotStore {
32
34
  load(scopeToken?: string): PersistedSnapshot;
@@ -43,20 +45,6 @@ function scopeKey(scopeToken?: string): string {
43
45
  return `${KEY_PREFIX}:${scopeToken ?? '__default__'}`;
44
46
  }
45
47
 
46
- /** In-memory fallback, used when the native module is absent. */
47
- function createMemoryBacking(): MmkvLike {
48
- const map = new Map<string, string>();
49
- return {
50
- getString: key => map.get(key),
51
- set(key, value) {
52
- map.set(key, value);
53
- },
54
- remove: key => map.delete(key),
55
- clearAll: () => map.clear(),
56
- getAllKeys: () => [...map.keys()],
57
- };
58
- }
59
-
60
48
  /**
61
49
  * Opens the MMKV instance through v4's `createMMKV`.
62
50
  *
@@ -67,15 +55,20 @@ function createMemoryBacking(): MmkvLike {
67
55
  * Tests could not see it, because the test double still exported a v3 class.
68
56
  */
69
57
  function resolveBacking(): { backing: MmkvLike; durable: boolean } {
70
- try {
71
- // Required lazily so the package loads where the native module is absent.
72
- const { createMMKV } = require('react-native-mmkv') as {
73
- createMMKV: (config?: { id?: string }) => MmkvLike;
74
- };
75
- return { backing: createMMKV({ id: INSTANCE_ID }), durable: true };
76
- } catch {
77
- return { backing: createMemoryBacking(), durable: false };
78
- }
58
+ // rn-core's port: MMKV when it opens, otherwise memory with `durable: false`.
59
+ const port = openDurable(() =>
60
+ mmkv.require(peer => new Error(`${peer} is not installed`)).createMMKV({ id: INSTANCE_ID }),
61
+ );
62
+ return {
63
+ backing: {
64
+ getString: key => port.getString(key),
65
+ set: (key, value) => port.set(key, value),
66
+ remove: key => port.remove(key),
67
+ clearAll: () => port.clearAll(),
68
+ getAllKeys: () => port.keys(),
69
+ },
70
+ durable: port.durable,
71
+ };
79
72
  }
80
73
 
81
74
  /**
@@ -151,5 +144,15 @@ export function createSnapshotStore(injected?: {
151
144
 
152
145
  /** Test seam for driving the store without the native module. */
153
146
  export function createMemorySnapshotStore(): SnapshotStore {
154
- return createSnapshotStore({ backing: createMemoryBacking(), durable: false });
147
+ const memory = createMemoryKeyValue();
148
+ return createSnapshotStore({
149
+ backing: {
150
+ getString: key => memory.getString(key),
151
+ set: (key, value) => memory.set(key, value),
152
+ remove: key => memory.remove(key),
153
+ clearAll: () => memory.clearAll(),
154
+ getAllKeys: () => memory.keys(),
155
+ },
156
+ durable: false,
157
+ });
155
158
  }