@chipmobilesdk/rn-payment 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,10 +9,9 @@
9
9
  * schedule without burning real time.
10
10
  */
11
11
 
12
- import type { BackoffConfig } from '../config/types';
12
+ import { computeBackoff } from '@chipmobilesdk/rn-core';
13
13
 
14
- /** Opaque timer handle: a Node Timeout and a browser numeric id are both valid. */
15
- type TimerHandle = ReturnType<typeof setTimeout> | number;
14
+ import type { BackoffConfig } from '../config/types';
16
15
 
17
16
  /**
18
17
  * Delay before `attempt` (1-based), capped and optionally jittered.
@@ -25,109 +24,15 @@ export function backoffDelayMs(
25
24
  config: Required<BackoffConfig>,
26
25
  random: () => number = Math.random,
27
26
  ): 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;
27
+ return computeBackoff(attempt, toSpec(config), random);
31
28
  }
32
29
 
33
- /** The full schedule, for asserting a retry policy without waiting for it. */
34
- export function backoffSchedule(config: Required<BackoffConfig>): number[] {
35
- const delays: number[] = [];
36
- for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
37
- delays.push(backoffDelayMs(attempt, config, () => 1));
38
- }
39
- return delays;
40
- }
41
-
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
- }
50
-
51
- export interface RetryRunnerOptions {
52
- config: Required<BackoffConfig>;
53
- /** Return true when the attempt succeeded and retrying should stop. */
54
- attempt: (attemptNumber: number) => Promise<boolean>;
55
- /** Called once the per-session budget is spent without success. */
56
- onExhausted?: (attempts: number) => void;
57
- /**
58
- * Injected so a test can run the whole schedule synchronously instead of
59
- * waiting minutes of real backoff. The handle is opaque, because a Node
60
- * `Timeout` and a browser numeric id are both valid here.
61
- */
62
- setTimeoutFn?: (fn: () => void, ms: number) => TimerHandle;
63
- clearTimeoutFn?: (handle: TimerHandle) => void;
64
- random?: () => number;
65
- }
66
-
67
- /**
68
- * Runs `attempt` under the bounded schedule.
69
- *
70
- * Exhaustion is reported rather than thrown: for finalization, a spent session
71
- * 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.
73
- */
74
- 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
-
30
+ function toSpec(config: Required<BackoffConfig>) {
122
31
  return {
123
- cancel(): void {
124
- cancelled = true;
125
- if (pending !== undefined) {
126
- clearTimeoutFn(pending);
127
- pending = undefined;
128
- }
129
- },
130
- attempts: () => attemptCount,
131
- exhausted: () => attemptCount >= config.maxAttempts,
32
+ initialDelayMs: config.initialDelayMs,
33
+ maxDelayMs: config.maxDelayMs,
34
+ factor: config.factor,
35
+ jitter: config.jitter ? ('full' as const) : ('none' as const),
36
+ maxAttempts: config.maxAttempts,
132
37
  };
133
38
  }
@@ -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
  }
@@ -238,7 +257,7 @@ export function createEmitter(
238
257
  /**
239
258
  * Fields that must never appear in an event payload.
240
259
  *
241
- * Used by `__tests__/redaction.test.ts` to walk every emitted event across the
260
+ * Used by `__tests__/diagnostics.test.ts` to walk every emitted event across the
242
261
  * whole suite. Enumerating the ban here rather than in the test keeps the rule
243
262
  * next to the type it constrains.
244
263
  */
@@ -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
  }