@chipmobilesdk/rn-payment 0.4.0 → 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 +482 -334
- package/README.md +925 -897
- package/package.json +52 -54
- package/skills/sdk-rn-payment/SKILL.md +175 -174
- package/src/adapter/createStoreProvider.ts +116 -118
- package/src/adapter/iap-adapter.ts +627 -660
- package/src/adapter/port.ts +209 -215
- package/src/config/types.ts +298 -310
- package/src/config/validate.ts +224 -232
- package/src/core/mutex.ts +25 -128
- package/src/core/retry.ts +95 -133
- package/src/core/timers.ts +272 -267
- package/src/diagnostics/errors.ts +124 -117
- package/src/diagnostics/events.ts +282 -266
- package/src/diagnostics/messageKeys.ts +93 -95
- package/src/engine/engine.ts +921 -999
- package/src/engine/finalize.ts +343 -346
- package/src/engine/foreground.ts +29 -0
- package/src/engine/products.ts +259 -259
- package/src/engine/purchase.ts +589 -614
- package/src/engine/subscriptions.ts +146 -157
- package/src/entitlements/aggregate.ts +261 -242
- package/src/entitlements/mapping.ts +67 -67
- package/src/entitlements/policy.ts +121 -129
- package/src/index.ts +5 -9
- package/src/persistence/mmkv-store.ts +158 -158
- package/src/persistence/snapshot.ts +220 -247
- package/src/react/PaymentProvider.tsx +90 -79
- package/src/react/hooks.ts +236 -274
- package/src/testing/conformance.ts +169 -0
- package/src/testing/fakeStore.ts +360 -393
- package/src/testing/index.ts +19 -10
|
@@ -1,129 +1,121 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Grace and hold entitlement policy.
|
|
3
|
-
*
|
|
4
|
-
* This is a **business decision, not a technical constant** (FR-049), so it is
|
|
5
|
-
* configurable with documented defaults.
|
|
6
|
-
*
|
|
7
|
-
* ## The defaults, and why
|
|
8
|
-
*
|
|
9
|
-
* `gracePolicy: 'grant'` — a grace period means the store is still retrying a
|
|
10
|
-
* payment method that failed for reasons usually outside the user's control: an
|
|
11
|
-
* expired card, a bank decline, a temporary hold. Revoking immediately punishes a
|
|
12
|
-
* paying customer for a billing hiccup they may not know about yet.
|
|
13
|
-
*
|
|
14
|
-
* `holdPolicy: 'withhold'` — account hold means the store has given up retrying.
|
|
15
|
-
* The subscription is effectively unpaid, and continuing to serve paid features
|
|
16
|
-
* indefinitely is giving away the product.
|
|
17
|
-
*
|
|
18
|
-
* ## What Android can actually tell us
|
|
19
|
-
*
|
|
20
|
-
* Client-side Play Billing **cannot** separate grace from hold: a grace-period
|
|
21
|
-
* subscription is still returned as active, and one in account hold stops being
|
|
22
|
-
* returned at all — indistinguishable from expired, cancelled, or refunded.
|
|
23
|
-
*
|
|
24
|
-
* Fortunately the platform's own behavior matches the defaults above. Play keeps
|
|
25
|
-
* returning a grace-period purchase, so the entitlement holds. Play stops
|
|
26
|
-
* returning a held one, so it lapses at the next sync. **The default policy is
|
|
27
|
-
* therefore correct with no backend at all.**
|
|
28
|
-
*
|
|
29
|
-
* What is *not* achievable client-side is a non-default policy — withholding
|
|
30
|
-
* during grace, or granting during hold — because that needs the state the client
|
|
31
|
-
* cannot see. Such an app supplies it through the validation hook (FR-069). This
|
|
32
|
-
* limitation is documented rather than papered over (FR-058).
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import type { LapsePolicy } from '../config/types';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A subscription's renewal state as far as the package can honestly tell.
|
|
39
|
-
*
|
|
40
|
-
* `indeterminate` is not a failure — it is the truthful answer when the platform
|
|
41
|
-
* reports a subscription as active but cannot say whether that is a healthy
|
|
42
|
-
* renewal, a cancelled-but-still-paid period, or a grace period.
|
|
43
|
-
*/
|
|
44
|
-
export type RenewalState =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
export interface
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return true;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** True once a subscription's paid period has genuinely elapsed (FR-048). */
|
|
127
|
-
export function hasExpired(expiresAt: number | undefined, now: number): boolean {
|
|
128
|
-
return expiresAt !== undefined && expiresAt <= now;
|
|
129
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Grace and hold entitlement policy.
|
|
3
|
+
*
|
|
4
|
+
* This is a **business decision, not a technical constant** (FR-049), so it is
|
|
5
|
+
* configurable with documented defaults.
|
|
6
|
+
*
|
|
7
|
+
* ## The defaults, and why
|
|
8
|
+
*
|
|
9
|
+
* `gracePolicy: 'grant'` — a grace period means the store is still retrying a
|
|
10
|
+
* payment method that failed for reasons usually outside the user's control: an
|
|
11
|
+
* expired card, a bank decline, a temporary hold. Revoking immediately punishes a
|
|
12
|
+
* paying customer for a billing hiccup they may not know about yet.
|
|
13
|
+
*
|
|
14
|
+
* `holdPolicy: 'withhold'` — account hold means the store has given up retrying.
|
|
15
|
+
* The subscription is effectively unpaid, and continuing to serve paid features
|
|
16
|
+
* indefinitely is giving away the product.
|
|
17
|
+
*
|
|
18
|
+
* ## What Android can actually tell us
|
|
19
|
+
*
|
|
20
|
+
* Client-side Play Billing **cannot** separate grace from hold: a grace-period
|
|
21
|
+
* subscription is still returned as active, and one in account hold stops being
|
|
22
|
+
* returned at all — indistinguishable from expired, cancelled, or refunded.
|
|
23
|
+
*
|
|
24
|
+
* Fortunately the platform's own behavior matches the defaults above. Play keeps
|
|
25
|
+
* returning a grace-period purchase, so the entitlement holds. Play stops
|
|
26
|
+
* returning a held one, so it lapses at the next sync. **The default policy is
|
|
27
|
+
* therefore correct with no backend at all.**
|
|
28
|
+
*
|
|
29
|
+
* What is *not* achievable client-side is a non-default policy — withholding
|
|
30
|
+
* during grace, or granting during hold — because that needs the state the client
|
|
31
|
+
* cannot see. Such an app supplies it through the validation hook (FR-069). This
|
|
32
|
+
* limitation is documented rather than papered over (FR-058).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { LapsePolicy } from '../config/types';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A subscription's renewal state as far as the package can honestly tell.
|
|
39
|
+
*
|
|
40
|
+
* `indeterminate` is not a failure — it is the truthful answer when the platform
|
|
41
|
+
* reports a subscription as active but cannot say whether that is a healthy
|
|
42
|
+
* renewal, a cancelled-but-still-paid period, or a grace period.
|
|
43
|
+
*/
|
|
44
|
+
export type RenewalState = 'active' | 'cancelled_valid' | 'grace' | 'hold' | 'indeterminate';
|
|
45
|
+
|
|
46
|
+
export interface LapsePolicyConfig {
|
|
47
|
+
gracePolicy: LapsePolicy;
|
|
48
|
+
holdPolicy: LapsePolicy;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SubscriptionFacts {
|
|
52
|
+
expiresAt?: number;
|
|
53
|
+
willAutoRenew?: boolean;
|
|
54
|
+
/** True only where the provider genuinely distinguishes grace/hold. */
|
|
55
|
+
reportsGraceAndHoldDistinctly: boolean;
|
|
56
|
+
/** Provider-reported, only meaningful when the flag above is true. */
|
|
57
|
+
providerRenewalState?: 'active' | 'grace' | 'hold';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Derives the renewal state without inventing information.
|
|
62
|
+
*
|
|
63
|
+
* The temptation is to read `willAutoRenew === false` as "grace period", but that
|
|
64
|
+
* same value is what a deliberate cancellation looks like. Guessing would mean
|
|
65
|
+
* showing "there's a problem with your payment method" to someone who simply
|
|
66
|
+
* chose not to renew — so when the provider cannot distinguish, neither do we.
|
|
67
|
+
*/
|
|
68
|
+
export function deriveRenewalState(facts: SubscriptionFacts): RenewalState {
|
|
69
|
+
if (facts.reportsGraceAndHoldDistinctly && facts.providerRenewalState) {
|
|
70
|
+
switch (facts.providerRenewalState) {
|
|
71
|
+
case 'grace':
|
|
72
|
+
return 'grace';
|
|
73
|
+
case 'hold':
|
|
74
|
+
return 'hold';
|
|
75
|
+
case 'active':
|
|
76
|
+
return facts.willAutoRenew === false ? 'cancelled_valid' : 'active';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (facts.willAutoRenew === false) {
|
|
81
|
+
// Still inside a paid period with auto-renew off. Genuinely ambiguous
|
|
82
|
+
// between "cancelled" and "in grace", so report the one that is certainly
|
|
83
|
+
// true — the period is paid for — and let the app decide how to present it.
|
|
84
|
+
return 'cancelled_valid';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (facts.willAutoRenew === true) {
|
|
88
|
+
return 'active';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return 'indeterminate';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a subscription in this state should confer its entitlement.
|
|
96
|
+
*
|
|
97
|
+
* `hold` reaching this function at all requires a provider that reports it,
|
|
98
|
+
* which Android does not. On Android a held subscription simply stops being
|
|
99
|
+
* returned, so it never becomes a source and the entitlement lapses — the same
|
|
100
|
+
* result the `withhold` default expresses.
|
|
101
|
+
*/
|
|
102
|
+
export function grantsUnderPolicy(state: RenewalState, policy: LapsePolicyConfig): boolean {
|
|
103
|
+
switch (state) {
|
|
104
|
+
case 'active':
|
|
105
|
+
case 'cancelled_valid':
|
|
106
|
+
return true;
|
|
107
|
+
case 'grace':
|
|
108
|
+
return policy.gracePolicy === 'grant';
|
|
109
|
+
case 'hold':
|
|
110
|
+
return policy.holdPolicy === 'grant';
|
|
111
|
+
case 'indeterminate':
|
|
112
|
+
// The store is still reporting the purchase as owned. Withholding here
|
|
113
|
+
// would revoke from a paying user because of our own uncertainty.
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True once a subscription's paid period has genuinely elapsed (FR-048). */
|
|
119
|
+
export function hasExpired(expiresAt: number | undefined, now: number): boolean {
|
|
120
|
+
return expiresAt !== undefined && expiresAt <= now;
|
|
121
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -91,10 +91,7 @@ export type {
|
|
|
91
91
|
} from './engine/products';
|
|
92
92
|
|
|
93
93
|
// --- Subscriptions ---------------------------------------------------------
|
|
94
|
-
export type {
|
|
95
|
-
PriceChangeResult,
|
|
96
|
-
ProrationMode,
|
|
97
|
-
} from './engine/subscriptions';
|
|
94
|
+
export type { PriceChangeResult, ProrationMode } from './engine/subscriptions';
|
|
98
95
|
|
|
99
96
|
// --- Entitlements ----------------------------------------------------------
|
|
100
97
|
export type {
|
|
@@ -123,6 +120,8 @@ export type {
|
|
|
123
120
|
SyncOutcome,
|
|
124
121
|
} from './engine/engine';
|
|
125
122
|
|
|
123
|
+
export { syncOnForeground } from './engine/foreground';
|
|
124
|
+
|
|
126
125
|
// --- React -----------------------------------------------------------------
|
|
127
126
|
export { PaymentProvider, usePaymentEngine } from './react/PaymentProvider';
|
|
128
127
|
export type { PaymentProviderProps } from './react/PaymentProvider';
|
|
@@ -135,11 +134,7 @@ export {
|
|
|
135
134
|
usePurchase,
|
|
136
135
|
useSync,
|
|
137
136
|
} from './react/hooks';
|
|
138
|
-
export type {
|
|
139
|
-
UseProductsResult,
|
|
140
|
-
UsePurchaseResult,
|
|
141
|
-
UseSyncResult,
|
|
142
|
-
} from './react/hooks';
|
|
137
|
+
export type { UseProductsResult, UsePurchaseResult, UseSyncResult } from './react/hooks';
|
|
143
138
|
|
|
144
139
|
// --- Diagnostics -----------------------------------------------------------
|
|
145
140
|
export { PaymentConfigError, PaymentError, PAYMENT_ERROR_CODES } from './diagnostics/errors';
|
|
@@ -157,6 +152,7 @@ export type {
|
|
|
157
152
|
export {
|
|
158
153
|
DEFAULT_DEVELOPER_MESSAGES,
|
|
159
154
|
DEFAULT_MESSAGES,
|
|
155
|
+
paymentMessages,
|
|
160
156
|
DEFAULT_USER_MESSAGES,
|
|
161
157
|
DEVELOPER_MESSAGE_KEYS,
|
|
162
158
|
USER_MESSAGE_KEYS,
|
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MMKV-backed snapshot store.
|
|
3
|
-
*
|
|
4
|
-
* Every MMKV import is confined to this file, so the rest of the package stays
|
|
5
|
-
* testable without a native module.
|
|
6
|
-
*
|
|
7
|
-
* MMKV is chosen because it is **synchronous**, which is what lets
|
|
8
|
-
* `getEntitlement` be an in-memory read with no I/O on render paths (PERF-001).
|
|
9
|
-
* An async store would force either an async entitlement read or a hidden
|
|
10
|
-
* hydration race on every call.
|
|
11
|
-
*
|
|
12
|
-
* Records are partitioned by cache-scope token (FR-057a). The token is a
|
|
13
|
-
* partition key and nothing more: never interpreted, never transmitted, never
|
|
14
|
-
* attached to a transaction.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MMKV-backed snapshot store.
|
|
3
|
+
*
|
|
4
|
+
* Every MMKV import is confined to this file, so the rest of the package stays
|
|
5
|
+
* testable without a native module.
|
|
6
|
+
*
|
|
7
|
+
* MMKV is chosen because it is **synchronous**, which is what lets
|
|
8
|
+
* `getEntitlement` be an in-memory read with no I/O on render paths (PERF-001).
|
|
9
|
+
* An async store would force either an async entitlement read or a hidden
|
|
10
|
+
* hydration race on every call.
|
|
11
|
+
*
|
|
12
|
+
* Records are partitioned by cache-scope token (FR-057a). The token is a
|
|
13
|
+
* partition key and nothing more: never interpreted, never transmitted, never
|
|
14
|
+
* attached to a transaction.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createMemoryKeyValue, lazyPeer, openDurable, type MmkvLike } from '@chipmobilesdk/rn-core';
|
|
18
|
+
|
|
19
|
+
import { emptySnapshot, migrateSnapshot, type PersistedSnapshot } from './snapshot';
|
|
20
|
+
|
|
21
|
+
const INSTANCE_ID = 'chipmobilesdk.payment';
|
|
22
|
+
const KEY_PREFIX = 'snapshot';
|
|
23
|
+
|
|
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
|
+
});
|
|
32
|
+
|
|
33
|
+
export interface SnapshotStore {
|
|
34
|
+
load(scopeToken?: string): PersistedSnapshot;
|
|
35
|
+
save(snapshot: PersistedSnapshot): void;
|
|
36
|
+
/** Clears every scope — for logout and data-deletion requests (FR-057). */
|
|
37
|
+
clearAll(): void;
|
|
38
|
+
/** Whether writes actually persist. False means the memory fallback is active. */
|
|
39
|
+
isDurable(): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scopeKey(scopeToken?: string): string {
|
|
43
|
+
// An explicit sentinel rather than an empty segment, so the unscoped record
|
|
44
|
+
// can never collide with a scope whose token happens to be empty.
|
|
45
|
+
return `${KEY_PREFIX}:${scopeToken ?? '__default__'}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Opens the MMKV instance through v4's `createMMKV`.
|
|
50
|
+
*
|
|
51
|
+
* Up to 0.2.0 this called `new MMKV()`, the v3 constructor. In v4 — the only
|
|
52
|
+
* major the peer range allows — `MMKV` is a type, not a value, so the call threw
|
|
53
|
+
* and the store fell back to memory on every device: entitlements read `unknown`
|
|
54
|
+
* after each cold start, and a pending purchase was forgotten after an app kill.
|
|
55
|
+
* Tests could not see it, because the test double still exported a v3 class.
|
|
56
|
+
*/
|
|
57
|
+
function resolveBacking(): { backing: MmkvLike; durable: boolean } {
|
|
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
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates the store.
|
|
76
|
+
*
|
|
77
|
+
* The memory fallback is a **documented degradation**, not a silent one: without
|
|
78
|
+
* MMKV the snapshot does not survive a cold start, so entitlements read `unknown`
|
|
79
|
+
* until the first sync. That is safe by construction — `unknown`, never
|
|
80
|
+
* `not_entitled` (FR-047) — and FR-059's startup sync repairs it. `isDurable()`
|
|
81
|
+
* lets the engine warn, so the condition is visible rather than mysterious.
|
|
82
|
+
*/
|
|
83
|
+
export function createSnapshotStore(injected?: {
|
|
84
|
+
backing: MmkvLike;
|
|
85
|
+
durable: boolean;
|
|
86
|
+
}): SnapshotStore {
|
|
87
|
+
const { backing, durable } = injected ?? resolveBacking();
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
load(scopeToken?: string): PersistedSnapshot {
|
|
91
|
+
const raw = backing.getString(scopeKey(scopeToken));
|
|
92
|
+
if (raw === undefined) {
|
|
93
|
+
return emptySnapshot(scopeToken);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(raw);
|
|
99
|
+
} catch {
|
|
100
|
+
// Corrupt JSON is discarded rather than repaired: the store is the
|
|
101
|
+
// authoritative source and the next sync rebuilds from it.
|
|
102
|
+
return emptySnapshot(scopeToken);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const outcome = migrateSnapshot(parsed);
|
|
106
|
+
if (!outcome.ok) {
|
|
107
|
+
return emptySnapshot(scopeToken);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A record whose stored token disagrees with the requested scope must not
|
|
111
|
+
// be served: that is precisely the account-switch inheritance FR-057a
|
|
112
|
+
// exists to prevent.
|
|
113
|
+
if (outcome.snapshot.scopeToken !== scopeToken) {
|
|
114
|
+
return emptySnapshot(scopeToken);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return outcome.snapshot;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
save(snapshot: PersistedSnapshot): void {
|
|
121
|
+
try {
|
|
122
|
+
backing.set(scopeKey(snapshot.scopeToken), JSON.stringify(snapshot));
|
|
123
|
+
} catch {
|
|
124
|
+
// A failed write must never take down a purchase flow. The in-memory
|
|
125
|
+
// state stays correct for this session and the next sync re-derives it.
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
clearAll(): void {
|
|
130
|
+
try {
|
|
131
|
+
for (const key of backing.getAllKeys()) {
|
|
132
|
+
if (key.startsWith(`${KEY_PREFIX}:`)) {
|
|
133
|
+
backing.remove(key);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Best effort; a failure here must not throw into a logout flow.
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
isDurable: () => durable,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test seam for driving the store without the native module. */
|
|
146
|
+
export function createMemorySnapshotStore(): SnapshotStore {
|
|
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
|
+
});
|
|
158
|
+
}
|