@chipmobilesdk/rn-ads 0.5.0 → 0.5.1

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.
@@ -1,127 +1,127 @@
1
- /**
2
- * Bounded, opt-in retry for failed loads (FR-134 – FR-139).
3
- *
4
- * 0.1.0 shipped no retry on purpose, and the reason was sound: every retry is a
5
- * billable request, and only the app knows what that costs. But the practical
6
- * result was that every app rewrote the same backoff loop, and the cost of
7
- * writing it wrong landed on a bill. This ships the loop once, keeps it **off by
8
- * default**, and makes every attempt visible.
9
- *
10
- * Three properties do the work:
11
- *
12
- * - Off unless configured, so upgrading from 0.1.0 issues not one extra
13
- * request (FR-134, and the compat test proves it rather than asserting it).
14
- * - Only `retryable` errors. Configuration and consent errors, an unsupported
15
- * platform, and a disabled or undecided instance are states another attempt
16
- * cannot change, so retrying them would spend money on a certainty (FR-135).
17
- * - Every attempt is an event carrying the load cycle's correlation id, so a
18
- * bill can be reconciled against the event stream alone (FR-138).
19
- *
20
- * Pure — the schedule is computed here, the waiting happens in the instance's
21
- * TimerSet so `dispose()` can prove it released everything.
22
- */
23
-
24
- import { AdsConfigError } from '../diagnostics/errors';
25
- import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
26
- import type { RetryPolicy } from '../config/types';
27
-
28
- export interface ResolvedRetryPolicy {
29
- readonly maxRetries: number;
30
- readonly initialDelayMs: number;
31
- readonly maxDelayMs: number;
32
- }
33
-
34
- /**
35
- * Validates one policy against the bounds.
36
- *
37
- * Out-of-range values **throw** rather than being clamped, matching the rule
38
- * `timers.ts` already follows: silently clamping a request for ten retries down
39
- * to five tells the app it configured something it did not, and the discrepancy
40
- * only surfaces later as a bill that does not match the code.
41
- *
42
- * `label` names the offending field precisely — an error that says only
43
- * "invalid retry policy" makes an integrator hunt through a catalog by hand.
44
- */
45
- export function resolveRetryPolicy(policy: RetryPolicy, label: string): ResolvedRetryPolicy {
46
- const maxRetries = checkBand(
47
- `${label}.maxRetries`,
48
- policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
49
- RETRY_BOUNDS.maxRetries,
50
- );
51
- const initialDelayMs = checkBand(
52
- `${label}.initialDelayMs`,
53
- policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
54
- RETRY_BOUNDS.initialDelayMs,
55
- );
56
- const maxDelayMs = checkBand(
57
- `${label}.maxDelayMs`,
58
- policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
59
- RETRY_BOUNDS.maxDelayMs,
60
- );
61
-
62
- if (!Number.isInteger(maxRetries)) {
63
- throw new AdsConfigError('retry_out_of_bounds', {
64
- message: `${label}.maxRetries must be a whole number; received ${maxRetries}`,
65
- });
66
- }
67
- if (initialDelayMs > maxDelayMs) {
68
- throw new AdsConfigError('retry_out_of_bounds', {
69
- message: `${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms); the first retry would already be past the ceiling`,
70
- });
71
- }
72
-
73
- return { maxRetries, initialDelayMs, maxDelayMs };
74
- }
75
-
76
- /**
77
- * Resolves the effective policy for one placement.
78
- *
79
- * An override **replaces** the instance-wide policy rather than merging into it.
80
- * Merging would mean the effective policy for a placement could not be read off
81
- * the configuration — you would have to simulate the merge to know what it was —
82
- * and a per-field merge makes "this placement retries twice" quietly depend on a
83
- * value written somewhere else.
84
- *
85
- * Both are validated against the same bounds, so an override narrows behavior
86
- * but can never exceed a ceiling (FR-136a).
87
- */
88
- export function resolveEffectiveRetry(
89
- instancePolicy: RetryPolicy | undefined,
90
- placementPolicy: RetryPolicy | false | undefined,
91
- placementKey: string,
92
- ): ResolvedRetryPolicy | null {
93
- if (placementPolicy === false) {
94
- // Explicit opt-out for this placement alone. A banner retry is cheap and
95
- // invisible; a rewarded retry happens with a user waiting on the other
96
- // side of it. An app may reasonably want one and not the other.
97
- return null;
98
- }
99
- if (placementPolicy) {
100
- return resolveRetryPolicy(placementPolicy, `placements.${placementKey}.retry`);
101
- }
102
- if (instancePolicy) {
103
- return resolveRetryPolicy(instancePolicy, 'retry');
104
- }
105
- // Nothing configured anywhere: retry is entirely off, which is the default
106
- // and the only behavior a 0.1.0 consumer can observe (FR-134).
107
- return null;
108
- }
109
-
110
- /**
111
- * Delay before the given attempt. `attempt` is 1-based — the first retry is 1.
112
- *
113
- * Exponential with a fixed x2 multiplier and **no jitter**. Jitter exists to
114
- * desynchronize a fleet of clients hitting one server; a single device retrying
115
- * its own load has no herd to avoid. Leaving it out keeps "two retries, 2s then
116
- * 4s" an exact assertion instead of a statistical one, which matters more here
117
- * than a property that does not apply.
118
- */
119
- export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
120
- const raw = policy.initialDelayMs * Math.pow(2, Math.max(0, attempt - 1));
121
- return Math.min(raw, policy.maxDelayMs);
122
- }
123
-
124
- /** Whether another attempt is permitted. `attemptsMade` counts retries only. */
125
- export function canRetry(policy: ResolvedRetryPolicy | null, attemptsMade: number): boolean {
126
- return policy !== null && attemptsMade < policy.maxRetries;
127
- }
1
+ /**
2
+ * Bounded, opt-in retry for failed loads (FR-134 – FR-139).
3
+ *
4
+ * 0.1.0 shipped no retry on purpose, and the reason was sound: every retry is a
5
+ * billable request, and only the app knows what that costs. But the practical
6
+ * result was that every app rewrote the same backoff loop, and the cost of
7
+ * writing it wrong landed on a bill. This ships the loop once, keeps it **off by
8
+ * default**, and makes every attempt visible.
9
+ *
10
+ * Three properties do the work:
11
+ *
12
+ * - Off unless configured, so upgrading from 0.1.0 issues not one extra
13
+ * request (FR-134, and the compat test proves it rather than asserting it).
14
+ * - Only `retryable` errors. Configuration and consent errors, an unsupported
15
+ * platform, and a disabled or undecided instance are states another attempt
16
+ * cannot change, so retrying them would spend money on a certainty (FR-135).
17
+ * - Every attempt is an event carrying the load cycle's correlation id, so a
18
+ * bill can be reconciled against the event stream alone (FR-138).
19
+ *
20
+ * Pure — the schedule is computed here, the waiting happens in the instance's
21
+ * TimerSet so `dispose()` can prove it released everything.
22
+ */
23
+
24
+ import { AdsConfigError } from '../diagnostics/errors';
25
+ import { checkBand, DEFAULT_RETRY_POLICY, RETRY_BOUNDS } from './timers';
26
+ import type { RetryPolicy } from '../config/types';
27
+
28
+ export interface ResolvedRetryPolicy {
29
+ readonly maxRetries: number;
30
+ readonly initialDelayMs: number;
31
+ readonly maxDelayMs: number;
32
+ }
33
+
34
+ /**
35
+ * Validates one policy against the bounds.
36
+ *
37
+ * Out-of-range values **throw** rather than being clamped, matching the rule
38
+ * `timers.ts` already follows: silently clamping a request for ten retries down
39
+ * to five tells the app it configured something it did not, and the discrepancy
40
+ * only surfaces later as a bill that does not match the code.
41
+ *
42
+ * `label` names the offending field precisely — an error that says only
43
+ * "invalid retry policy" makes an integrator hunt through a catalog by hand.
44
+ */
45
+ export function resolveRetryPolicy(policy: RetryPolicy, label: string): ResolvedRetryPolicy {
46
+ const maxRetries = checkBand(
47
+ `${label}.maxRetries`,
48
+ policy.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
49
+ RETRY_BOUNDS.maxRetries,
50
+ );
51
+ const initialDelayMs = checkBand(
52
+ `${label}.initialDelayMs`,
53
+ policy.initialDelayMs ?? DEFAULT_RETRY_POLICY.initialDelayMs,
54
+ RETRY_BOUNDS.initialDelayMs,
55
+ );
56
+ const maxDelayMs = checkBand(
57
+ `${label}.maxDelayMs`,
58
+ policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
59
+ RETRY_BOUNDS.maxDelayMs,
60
+ );
61
+
62
+ if (!Number.isInteger(maxRetries)) {
63
+ throw new AdsConfigError('retry_out_of_bounds', {
64
+ message: `${label}.maxRetries must be a whole number; received ${maxRetries}`,
65
+ });
66
+ }
67
+ if (initialDelayMs > maxDelayMs) {
68
+ throw new AdsConfigError('retry_out_of_bounds', {
69
+ message: `${label}.initialDelayMs (${initialDelayMs}ms) exceeds ${label}.maxDelayMs (${maxDelayMs}ms); the first retry would already be past the ceiling`,
70
+ });
71
+ }
72
+
73
+ return { maxRetries, initialDelayMs, maxDelayMs };
74
+ }
75
+
76
+ /**
77
+ * Resolves the effective policy for one placement.
78
+ *
79
+ * An override **replaces** the instance-wide policy rather than merging into it.
80
+ * Merging would mean the effective policy for a placement could not be read off
81
+ * the configuration — you would have to simulate the merge to know what it was —
82
+ * and a per-field merge makes "this placement retries twice" quietly depend on a
83
+ * value written somewhere else.
84
+ *
85
+ * Both are validated against the same bounds, so an override narrows behavior
86
+ * but can never exceed a ceiling (FR-136a).
87
+ */
88
+ export function resolveEffectiveRetry(
89
+ instancePolicy: RetryPolicy | undefined,
90
+ placementPolicy: RetryPolicy | false | undefined,
91
+ placementKey: string,
92
+ ): ResolvedRetryPolicy | null {
93
+ if (placementPolicy === false) {
94
+ // Explicit opt-out for this placement alone. A banner retry is cheap and
95
+ // invisible; a rewarded retry happens with a user waiting on the other
96
+ // side of it. An app may reasonably want one and not the other.
97
+ return null;
98
+ }
99
+ if (placementPolicy) {
100
+ return resolveRetryPolicy(placementPolicy, `placements.${placementKey}.retry`);
101
+ }
102
+ if (instancePolicy) {
103
+ return resolveRetryPolicy(instancePolicy, 'retry');
104
+ }
105
+ // Nothing configured anywhere: retry is entirely off, which is the default
106
+ // and the only behavior a 0.1.0 consumer can observe (FR-134).
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Delay before the given attempt. `attempt` is 1-based — the first retry is 1.
112
+ *
113
+ * Exponential with a fixed x2 multiplier and **no jitter**. Jitter exists to
114
+ * desynchronize a fleet of clients hitting one server; a single device retrying
115
+ * its own load has no herd to avoid. Leaving it out keeps "two retries, 2s then
116
+ * 4s" an exact assertion instead of a statistical one, which matters more here
117
+ * than a property that does not apply.
118
+ */
119
+ export function delayForAttempt(policy: ResolvedRetryPolicy, attempt: number): number {
120
+ const raw = policy.initialDelayMs * Math.pow(2, Math.max(0, attempt - 1));
121
+ return Math.min(raw, policy.maxDelayMs);
122
+ }
123
+
124
+ /** Whether another attempt is permitted. `attemptsMade` counts retries only. */
125
+ export function canRetry(policy: ResolvedRetryPolicy | null, attemptsMade: number): boolean {
126
+ return policy !== null && attemptsMade < policy.maxRetries;
127
+ }
@@ -1,84 +1,84 @@
1
- /**
2
- * Rewarded session bookkeeping (FR-046 – FR-049).
3
- *
4
- * Two independent booleans, never derived from one another:
5
- *
6
- * rewardConfirmed=false rewardConfirmed=true
7
- * dismissed=false in progress reward emitted, awaiting dismissal
8
- * dismissed=true dismissed_no_reward earned
9
- *
10
- * The defect this exists to prevent is inferring "earned" from dismissal. It is
11
- * the most common way rewarded integrations go wrong, and it is a direct revenue
12
- * and fairness fault: a user who closed the ad after two seconds gets the prize,
13
- * and the publisher is billed for nothing.
14
- *
15
- * Tracking two flags and deriving the outcome makes both callback orderings
16
- * produce the same result *by construction* rather than by the provider
17
- * happening to fire in a convenient order (SC-007).
18
- */
19
-
20
- import type { Reward } from '../config/types';
21
-
22
- export type RewardedOutcome = 'in_progress' | 'earned' | 'dismissed_without_reward';
23
-
24
- export interface RewardedSession {
25
- /**
26
- * Records a provider reward confirmation. Returns the reward the first time
27
- * and `null` on every repeat, which is what collapses a duplicate provider
28
- * emission into exactly one public event (FR-054).
29
- */
30
- confirmReward(reward: Reward): Reward | null;
31
- /** Records dismissal. Returns true the first time only. */
32
- dismiss(): boolean;
33
- outcome(): RewardedOutcome;
34
- /** True only once the provider actually confirmed. Never inferred (FR-046). */
35
- get rewardEarned(): boolean;
36
- get dismissed(): boolean;
37
- reward(): Reward | null;
38
- }
39
-
40
- export function createRewardedSession(): RewardedSession {
41
- let confirmed = false;
42
- let dismissed = false;
43
- let earnedReward: Reward | null = null;
44
-
45
- return {
46
- confirmReward(reward) {
47
- if (confirmed) {
48
- return null;
49
- }
50
- confirmed = true;
51
- earnedReward = reward;
52
- return reward;
53
- },
54
-
55
- dismiss() {
56
- if (dismissed) {
57
- return false;
58
- }
59
- dismissed = true;
60
- return true;
61
- },
62
-
63
- outcome() {
64
- if (!dismissed) {
65
- return 'in_progress';
66
- }
67
- // Read at dismissal time, so a reward that arrived first is honored and
68
- // one that never arrives is not invented.
69
- return confirmed ? 'earned' : 'dismissed_without_reward';
70
- },
71
-
72
- get rewardEarned() {
73
- return confirmed;
74
- },
75
-
76
- get dismissed() {
77
- return dismissed;
78
- },
79
-
80
- reward() {
81
- return earnedReward;
82
- },
83
- };
84
- }
1
+ /**
2
+ * Rewarded session bookkeeping (FR-046 – FR-049).
3
+ *
4
+ * Two independent booleans, never derived from one another:
5
+ *
6
+ * rewardConfirmed=false rewardConfirmed=true
7
+ * dismissed=false in progress reward emitted, awaiting dismissal
8
+ * dismissed=true dismissed_no_reward earned
9
+ *
10
+ * The defect this exists to prevent is inferring "earned" from dismissal. It is
11
+ * the most common way rewarded integrations go wrong, and it is a direct revenue
12
+ * and fairness fault: a user who closed the ad after two seconds gets the prize,
13
+ * and the publisher is billed for nothing.
14
+ *
15
+ * Tracking two flags and deriving the outcome makes both callback orderings
16
+ * produce the same result *by construction* rather than by the provider
17
+ * happening to fire in a convenient order (SC-007).
18
+ */
19
+
20
+ import type { Reward } from '../config/types';
21
+
22
+ export type RewardedOutcome = 'in_progress' | 'earned' | 'dismissed_without_reward';
23
+
24
+ export interface RewardedSession {
25
+ /**
26
+ * Records a provider reward confirmation. Returns the reward the first time
27
+ * and `null` on every repeat, which is what collapses a duplicate provider
28
+ * emission into exactly one public event (FR-054).
29
+ */
30
+ confirmReward(reward: Reward): Reward | null;
31
+ /** Records dismissal. Returns true the first time only. */
32
+ dismiss(): boolean;
33
+ outcome(): RewardedOutcome;
34
+ /** True only once the provider actually confirmed. Never inferred (FR-046). */
35
+ get rewardEarned(): boolean;
36
+ get dismissed(): boolean;
37
+ reward(): Reward | null;
38
+ }
39
+
40
+ export function createRewardedSession(): RewardedSession {
41
+ let confirmed = false;
42
+ let dismissed = false;
43
+ let earnedReward: Reward | null = null;
44
+
45
+ return {
46
+ confirmReward(reward) {
47
+ if (confirmed) {
48
+ return null;
49
+ }
50
+ confirmed = true;
51
+ earnedReward = reward;
52
+ return reward;
53
+ },
54
+
55
+ dismiss() {
56
+ if (dismissed) {
57
+ return false;
58
+ }
59
+ dismissed = true;
60
+ return true;
61
+ },
62
+
63
+ outcome() {
64
+ if (!dismissed) {
65
+ return 'in_progress';
66
+ }
67
+ // Read at dismissal time, so a reward that arrived first is honored and
68
+ // one that never arrives is not invented.
69
+ return confirmed ? 'earned' : 'dismissed_without_reward';
70
+ },
71
+
72
+ get rewardEarned() {
73
+ return confirmed;
74
+ },
75
+
76
+ get dismissed() {
77
+ return dismissed;
78
+ },
79
+
80
+ reward() {
81
+ return earnedReward;
82
+ },
83
+ };
84
+ }