@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.
@@ -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
- | 'active'
46
- | 'cancelled_valid'
47
- | 'grace'
48
- | 'hold'
49
- | 'indeterminate';
50
-
51
- export interface LapsePolicyConfig {
52
- gracePolicy: LapsePolicy;
53
- holdPolicy: LapsePolicy;
54
- }
55
-
56
- export interface SubscriptionFacts {
57
- expiresAt?: number;
58
- willAutoRenew?: boolean;
59
- /** True only where the provider genuinely distinguishes grace/hold. */
60
- reportsGraceAndHoldDistinctly: boolean;
61
- /** Provider-reported, only meaningful when the flag above is true. */
62
- providerRenewalState?: 'active' | 'grace' | 'hold';
63
- }
64
-
65
- /**
66
- * Derives the renewal state without inventing information.
67
- *
68
- * The temptation is to read `willAutoRenew === false` as "grace period", but that
69
- * same value is what a deliberate cancellation looks like. Guessing would mean
70
- * showing "there's a problem with your payment method" to someone who simply
71
- * chose not to renew — so when the provider cannot distinguish, neither do we.
72
- */
73
- export function deriveRenewalState(facts: SubscriptionFacts): RenewalState {
74
- if (facts.reportsGraceAndHoldDistinctly && facts.providerRenewalState) {
75
- switch (facts.providerRenewalState) {
76
- case 'grace':
77
- return 'grace';
78
- case 'hold':
79
- return 'hold';
80
- case 'active':
81
- return facts.willAutoRenew === false ? 'cancelled_valid' : 'active';
82
- }
83
- }
84
-
85
- if (facts.willAutoRenew === false) {
86
- // Still inside a paid period with auto-renew off. Genuinely ambiguous
87
- // between "cancelled" and "in grace", so report the one that is certainly
88
- // true — the period is paid for — and let the app decide how to present it.
89
- return 'cancelled_valid';
90
- }
91
-
92
- if (facts.willAutoRenew === true) {
93
- return 'active';
94
- }
95
-
96
- return 'indeterminate';
97
- }
98
-
99
- /**
100
- * Whether a subscription in this state should confer its entitlement.
101
- *
102
- * `hold` reaching this function at all requires a provider that reports it,
103
- * which Android does not. On Android a held subscription simply stops being
104
- * returned, so it never becomes a source and the entitlement lapses — the same
105
- * result the `withhold` default expresses.
106
- */
107
- export function grantsUnderPolicy(
108
- state: RenewalState,
109
- policy: LapsePolicyConfig,
110
- ): boolean {
111
- switch (state) {
112
- case 'active':
113
- case 'cancelled_valid':
114
- return true;
115
- case 'grace':
116
- return policy.gracePolicy === 'grant';
117
- case 'hold':
118
- return policy.holdPolicy === 'grant';
119
- case 'indeterminate':
120
- // The store is still reporting the purchase as owned. Withholding here
121
- // would revoke from a paying user because of our own uncertainty.
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
- emptySnapshot,
19
- migrateSnapshot,
20
- type PersistedSnapshot,
21
- } from './snapshot';
22
-
23
- const INSTANCE_ID = 'chipmobilesdk.payment';
24
- const KEY_PREFIX = 'snapshot';
25
-
26
- /** The slice of a react-native-mmkv v4 instance this store uses (v4 names). */
27
- interface MmkvLike {
28
- getString(key: string): string | undefined;
29
- set(key: string, value: string): void;
30
- remove(key: string): unknown;
31
- clearAll(): void;
32
- getAllKeys(): string[];
33
- }
34
-
35
- export interface SnapshotStore {
36
- load(scopeToken?: string): PersistedSnapshot;
37
- save(snapshot: PersistedSnapshot): void;
38
- /** Clears every scope — for logout and data-deletion requests (FR-057). */
39
- clearAll(): void;
40
- /** Whether writes actually persist. False means the memory fallback is active. */
41
- isDurable(): boolean;
42
- }
43
-
44
- function scopeKey(scopeToken?: string): string {
45
- // An explicit sentinel rather than an empty segment, so the unscoped record
46
- // can never collide with a scope whose token happens to be empty.
47
- return `${KEY_PREFIX}:${scopeToken ?? '__default__'}`;
48
- }
49
-
50
- /** In-memory fallback, used when the native module is absent. */
51
- function createMemoryBacking(): MmkvLike {
52
- const map = new Map<string, string>();
53
- return {
54
- getString: key => map.get(key),
55
- set(key, value) {
56
- map.set(key, value);
57
- },
58
- remove: key => map.delete(key),
59
- clearAll: () => map.clear(),
60
- getAllKeys: () => [...map.keys()],
61
- };
62
- }
63
-
64
- /**
65
- * Opens the MMKV instance through v4's `createMMKV`.
66
- *
67
- * Up to 0.2.0 this called `new MMKV()`, the v3 constructor. In v4 — the only
68
- * major the peer range allows — `MMKV` is a type, not a value, so the call threw
69
- * and the store fell back to memory on every device: entitlements read `unknown`
70
- * after each cold start, and a pending purchase was forgotten after an app kill.
71
- * Tests could not see it, because the test double still exported a v3 class.
72
- */
73
- function resolveBacking(): { backing: MmkvLike; durable: boolean } {
74
- try {
75
- // Required lazily so the package loads where the native module is absent.
76
- const { createMMKV } = require('react-native-mmkv') as {
77
- createMMKV: (config?: { id?: string }) => MmkvLike;
78
- };
79
- return { backing: createMMKV({ id: INSTANCE_ID }), durable: true };
80
- } catch {
81
- return { backing: createMemoryBacking(), durable: false };
82
- }
83
- }
84
-
85
- /**
86
- * Creates the store.
87
- *
88
- * The memory fallback is a **documented degradation**, not a silent one: without
89
- * MMKV the snapshot does not survive a cold start, so entitlements read `unknown`
90
- * until the first sync. That is safe by construction — `unknown`, never
91
- * `not_entitled` (FR-047) — and FR-059's startup sync repairs it. `isDurable()`
92
- * lets the engine warn, so the condition is visible rather than mysterious.
93
- */
94
- export function createSnapshotStore(
95
- injected?: { backing: MmkvLike; durable: boolean },
96
- ): SnapshotStore {
97
- const { backing, durable } = injected ?? resolveBacking();
98
-
99
- return {
100
- load(scopeToken?: string): PersistedSnapshot {
101
- const raw = backing.getString(scopeKey(scopeToken));
102
- if (raw === undefined) {
103
- return emptySnapshot(scopeToken);
104
- }
105
-
106
- let parsed: unknown;
107
- try {
108
- parsed = JSON.parse(raw);
109
- } catch {
110
- // Corrupt JSON is discarded rather than repaired: the store is the
111
- // authoritative source and the next sync rebuilds from it.
112
- return emptySnapshot(scopeToken);
113
- }
114
-
115
- const outcome = migrateSnapshot(parsed);
116
- if (!outcome.ok) {
117
- return emptySnapshot(scopeToken);
118
- }
119
-
120
- // A record whose stored token disagrees with the requested scope must not
121
- // be served: that is precisely the account-switch inheritance FR-057a
122
- // exists to prevent.
123
- if (outcome.snapshot.scopeToken !== scopeToken) {
124
- return emptySnapshot(scopeToken);
125
- }
126
-
127
- return outcome.snapshot;
128
- },
129
-
130
- save(snapshot: PersistedSnapshot): void {
131
- try {
132
- backing.set(scopeKey(snapshot.scopeToken), JSON.stringify(snapshot));
133
- } catch {
134
- // A failed write must never take down a purchase flow. The in-memory
135
- // state stays correct for this session and the next sync re-derives it.
136
- }
137
- },
138
-
139
- clearAll(): void {
140
- try {
141
- for (const key of backing.getAllKeys()) {
142
- if (key.startsWith(`${KEY_PREFIX}:`)) {
143
- backing.remove(key);
144
- }
145
- }
146
- } catch {
147
- // Best effort; a failure here must not throw into a logout flow.
148
- }
149
- },
150
-
151
- isDurable: () => durable,
152
- };
153
- }
154
-
155
- /** Test seam for driving the store without the native module. */
156
- export function createMemorySnapshotStore(): SnapshotStore {
157
- return createSnapshotStore({ backing: createMemoryBacking(), durable: false });
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
+ }