react-native-nitro-storage 0.4.5 → 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.
Files changed (37) hide show
  1. package/README.md +254 -945
  2. package/SECURITY.md +26 -0
  3. package/docs/api-reference.md +281 -0
  4. package/docs/batch-transactions-migrations.md +200 -0
  5. package/docs/benchmarks.md +37 -0
  6. package/docs/mmkv-migration.md +80 -0
  7. package/docs/react-hooks.md +113 -0
  8. package/docs/recipes.md +302 -0
  9. package/docs/secure-storage.md +190 -0
  10. package/docs/web-backends.md +141 -0
  11. package/lib/commonjs/index.js +265 -14
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +220 -11
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/storage-events.js +117 -0
  16. package/lib/commonjs/storage-events.js.map +1 -0
  17. package/lib/commonjs/storage-runtime.js.map +1 -1
  18. package/lib/module/index.js +265 -14
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/index.web.js +220 -11
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/module/storage-events.js +112 -0
  23. package/lib/module/storage-events.js.map +1 -0
  24. package/lib/module/storage-runtime.js.map +1 -1
  25. package/lib/typescript/index.d.ts +19 -2
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/index.web.d.ts +19 -2
  28. package/lib/typescript/index.web.d.ts.map +1 -1
  29. package/lib/typescript/storage-events.d.ts +37 -0
  30. package/lib/typescript/storage-events.d.ts.map +1 -0
  31. package/lib/typescript/storage-runtime.d.ts +32 -0
  32. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  33. package/package.json +25 -11
  34. package/src/index.ts +601 -14
  35. package/src/index.web.ts +535 -22
  36. package/src/storage-events.ts +184 -0
  37. package/src/storage-runtime.ts +35 -0
@@ -0,0 +1,113 @@
1
+ # React Hooks
2
+
3
+ Nitro Storage hooks connect a `StorageItem<T>` to React with `useSyncExternalStore`. No provider is needed, and the same item can also be used outside React.
4
+
5
+ Keep storage items at module scope. Creating items during render creates new subscriptions and breaks cache reuse.
6
+
7
+ ## useStorage
8
+
9
+ Use `useStorage(item)` when a component both reads and writes the full value.
10
+
11
+ ```tsx
12
+ import {
13
+ createStorageItem,
14
+ StorageScope,
15
+ useStorage,
16
+ } from "react-native-nitro-storage";
17
+
18
+ const themeItem = createStorageItem({
19
+ key: "theme",
20
+ scope: StorageScope.Disk,
21
+ defaultValue: "system",
22
+ });
23
+
24
+ export function ThemeToggle() {
25
+ const [theme, setTheme] = useStorage(themeItem);
26
+
27
+ return (
28
+ <Button
29
+ title={theme}
30
+ onPress={() => setTheme(theme === "dark" ? "light" : "dark")}
31
+ />
32
+ );
33
+ }
34
+ ```
35
+
36
+ Updater functions read the current stored value first:
37
+
38
+ ```ts
39
+ const countItem = createStorageItem({
40
+ key: "count",
41
+ scope: StorageScope.Memory,
42
+ defaultValue: 0,
43
+ });
44
+
45
+ const [, setCount] = useStorage(countItem);
46
+ setCount((current) => current + 1);
47
+ ```
48
+
49
+ Direct `set(value)` writes do not read the current value first.
50
+
51
+ ## useStorageSelector
52
+
53
+ Use `useStorageSelector(item, selector, isEqual?)` when the stored value is larger than what the component renders.
54
+
55
+ ```tsx
56
+ type Profile = {
57
+ name: string;
58
+ email: string;
59
+ plan: "free" | "pro";
60
+ };
61
+
62
+ const profileItem = createStorageItem<Profile>({
63
+ key: "profile",
64
+ scope: StorageScope.Disk,
65
+ defaultValue: { name: "", email: "", plan: "free" },
66
+ });
67
+
68
+ export function PlanBadge() {
69
+ const [plan] = useStorageSelector(profileItem, (profile) => profile.plan);
70
+ return <Text>{plan}</Text>;
71
+ }
72
+ ```
73
+
74
+ Pass a custom equality function when the selector returns an object:
75
+
76
+ ```ts
77
+ const [contact] = useStorageSelector(
78
+ profileItem,
79
+ (profile) => ({ name: profile.name, email: profile.email }),
80
+ (prev, next) => prev.name === next.name && prev.email === next.email,
81
+ );
82
+ ```
83
+
84
+ ## useSetStorage
85
+
86
+ Use `useSetStorage(item)` for controls that write without rendering from the value.
87
+
88
+ ```tsx
89
+ const dismissItem = createStorageItem({
90
+ key: "welcomeDismissed",
91
+ scope: StorageScope.Disk,
92
+ defaultValue: false,
93
+ });
94
+
95
+ export function DismissWelcomeButton() {
96
+ const setDismissed = useSetStorage(dismissItem);
97
+ return <Button title="Dismiss" onPress={() => setDismissed(true)} />;
98
+ }
99
+ ```
100
+
101
+ ## Subscribe Outside React
102
+
103
+ Every item has a low-level subscription API:
104
+
105
+ ```ts
106
+ const unsubscribe = themeItem.subscribe(() => {
107
+ analytics.track("theme_changed", { theme: themeItem.get() });
108
+ });
109
+
110
+ unsubscribe();
111
+ ```
112
+
113
+ Subscriptions fire after item writes and after TTL expiry is detected during a read.
@@ -0,0 +1,302 @@
1
+ # Recipes
2
+
3
+ These examples cover the public features most apps reach for first.
4
+
5
+ ## Persisted Preferences
6
+
7
+ ```ts
8
+ import { createStorageItem, StorageScope } from "react-native-nitro-storage";
9
+
10
+ type Preferences = {
11
+ theme: "system" | "light" | "dark";
12
+ reduceMotion: boolean;
13
+ };
14
+
15
+ export const preferencesItem = createStorageItem<Preferences>({
16
+ key: "preferences",
17
+ scope: StorageScope.Disk,
18
+ defaultValue: { theme: "system", reduceMotion: false },
19
+ validate: (value): value is Preferences =>
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ "theme" in value &&
23
+ "reduceMotion" in value,
24
+ });
25
+ ```
26
+
27
+ ## Auth Tokens
28
+
29
+ ```ts
30
+ import {
31
+ AccessControl,
32
+ createSecureAuthStorage,
33
+ } from "react-native-nitro-storage";
34
+
35
+ export const auth = createSecureAuthStorage(
36
+ {
37
+ accessToken: { ttlMs: 15 * 60 * 1000 },
38
+ refreshToken: {
39
+ accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
40
+ },
41
+ },
42
+ { namespace: "auth" },
43
+ );
44
+
45
+ auth.refreshToken.set("opaque-refresh-token");
46
+ ```
47
+
48
+ ## Feature Flags with Validation
49
+
50
+ ```ts
51
+ type Flags = {
52
+ newCheckout: boolean;
53
+ paywallVariant: "control" | "variant-a" | "variant-b";
54
+ };
55
+
56
+ export const flagsItem = createStorageItem<Flags>({
57
+ key: "remoteFlags",
58
+ scope: StorageScope.Disk,
59
+ defaultValue: { newCheckout: false, paywallVariant: "control" },
60
+ validate: (value): value is Flags => {
61
+ if (typeof value !== "object" || value === null) {
62
+ return false;
63
+ }
64
+
65
+ const flags = value as Partial<Flags>;
66
+ return (
67
+ typeof flags.newCheckout === "boolean" &&
68
+ (flags.paywallVariant === "control" ||
69
+ flags.paywallVariant === "variant-a" ||
70
+ flags.paywallVariant === "variant-b")
71
+ );
72
+ },
73
+ onValidationError: () => ({ newCheckout: false, paywallVariant: "control" }),
74
+ });
75
+ ```
76
+
77
+ ## Biometric Secret
78
+
79
+ ```ts
80
+ import {
81
+ BiometricLevel,
82
+ createStorageItem,
83
+ StorageScope,
84
+ } from "react-native-nitro-storage";
85
+
86
+ export const vaultKeyItem = createStorageItem<string>({
87
+ key: "vaultKey",
88
+ scope: StorageScope.Secure,
89
+ defaultValue: "",
90
+ biometric: true,
91
+ biometricLevel: BiometricLevel.BiometryOrPasscode,
92
+ });
93
+ ```
94
+
95
+ ## Multi-tenant State
96
+
97
+ ```ts
98
+ function createTenantThemeItem(tenantId: string) {
99
+ return createStorageItem({
100
+ key: "theme",
101
+ namespace: `tenant:${tenantId}`,
102
+ scope: StorageScope.Disk,
103
+ defaultValue: "system",
104
+ });
105
+ }
106
+
107
+ storage.clearNamespace("tenant:42", StorageScope.Disk);
108
+ ```
109
+
110
+ ## Temporary OTP
111
+
112
+ ```ts
113
+ export const otpItem = createStorageItem<string>({
114
+ key: "otp",
115
+ scope: StorageScope.Memory,
116
+ defaultValue: "",
117
+ expiration: { ttlMs: 2 * 60 * 1000 },
118
+ onExpired: () => {
119
+ showOtpExpiredMessage();
120
+ },
121
+ });
122
+ ```
123
+
124
+ ## Bulk Bootstrap
125
+
126
+ ```ts
127
+ const [preferences, flags] = getBatch(
128
+ [preferencesItem, flagsItem],
129
+ StorageScope.Disk,
130
+ );
131
+
132
+ setBatch(
133
+ [
134
+ { item: preferencesItem, value: { theme: "dark", reduceMotion: false } },
135
+ {
136
+ item: flagsItem,
137
+ value: { newCheckout: true, paywallVariant: "variant-a" },
138
+ },
139
+ ],
140
+ StorageScope.Disk,
141
+ );
142
+ ```
143
+
144
+ ## Transactional Balance Transfer
145
+
146
+ ```ts
147
+ runTransaction(StorageScope.Disk, (tx) => {
148
+ const from = tx.getItem(fromBalanceItem);
149
+
150
+ if (from < 25) {
151
+ throw new Error("Insufficient balance");
152
+ }
153
+
154
+ tx.setItem(fromBalanceItem, from - 25);
155
+ tx.setItem(toBalanceItem, tx.getItem(toBalanceItem) + 25);
156
+ });
157
+ ```
158
+
159
+ ## Custom Codec
160
+
161
+ ```ts
162
+ type CompactFlag = {
163
+ id: number;
164
+ active: boolean;
165
+ };
166
+
167
+ const compactFlagItem = createStorageItem<CompactFlag>({
168
+ key: "compactFlag",
169
+ scope: StorageScope.Disk,
170
+ defaultValue: { id: 0, active: false },
171
+ serialize: (value) => `${value.id}|${value.active ? "1" : "0"}`,
172
+ deserialize: (value) => {
173
+ const [id, active] = value.split("|");
174
+ return { id: Number(id), active: active === "1" };
175
+ },
176
+ });
177
+ ```
178
+
179
+ ## Coalesced Writes
180
+
181
+ ```ts
182
+ const draftItem = createStorageItem({
183
+ key: "draft",
184
+ scope: StorageScope.Disk,
185
+ defaultValue: "",
186
+ coalesceDiskWrites: true,
187
+ });
188
+
189
+ draftItem.set("first edit");
190
+ draftItem.set("second edit");
191
+ storage.flushDiskWrites();
192
+ ```
193
+
194
+ For Secure scope:
195
+
196
+ ```ts
197
+ const tokenItem = createStorageItem({
198
+ key: "token",
199
+ scope: StorageScope.Secure,
200
+ defaultValue: "",
201
+ coalesceSecureWrites: true,
202
+ });
203
+
204
+ tokenItem.set("token");
205
+ storage.flushSecureWrites();
206
+ ```
207
+
208
+ ## Raw Import and Export
209
+
210
+ ```ts
211
+ const snapshot = storage.export(StorageScope.Disk);
212
+
213
+ storage.import(snapshot, StorageScope.Disk);
214
+ ```
215
+
216
+ Raw export/import reads and writes strings exactly as stored. It does not run item serializers. Secure exports contain raw secret values, so do not log them or attach them to diagnostics.
217
+
218
+ ## Snapshot and Cleanup
219
+
220
+ ```ts
221
+ const allDiskValues = storage.getAll(StorageScope.Disk);
222
+ const allDiskKeys = storage.getAllKeys(StorageScope.Disk);
223
+
224
+ storage.clearNamespace("settings", StorageScope.Disk);
225
+ ```
226
+
227
+ ## Prefix Inspection
228
+
229
+ ```ts
230
+ const flagKeys = storage.getKeysByPrefix("flags:", StorageScope.Disk);
231
+ const flagValues = storage.getByPrefix("flags:", StorageScope.Disk);
232
+ ```
233
+
234
+ ## Optimistic Writes
235
+
236
+ ```ts
237
+ const current = preferencesItem.getWithVersion();
238
+
239
+ const didWrite = preferencesItem.setIfVersion(current.version, {
240
+ ...current.value,
241
+ theme: "dark",
242
+ });
243
+ ```
244
+
245
+ ## Metrics
246
+
247
+ ```ts
248
+ storage.setMetricsObserver((event) => {
249
+ console.log(event.operation, event.scope, event.durationMs);
250
+ });
251
+
252
+ preferencesItem.get();
253
+
254
+ const snapshot = storage.getMetricsSnapshot();
255
+ storage.resetMetrics();
256
+ ```
257
+
258
+ ## Event Logging
259
+
260
+ ```ts
261
+ const unsubscribe = storage.subscribePrefix(
262
+ StorageScope.Disk,
263
+ "settings:",
264
+ (event) => {
265
+ if (event.type === "batch") {
266
+ console.log("settings changed", event.changes.length);
267
+ return;
268
+ }
269
+
270
+ console.log(event.key, event.operation);
271
+ },
272
+ );
273
+
274
+ storage.setEventObserver((event) => {
275
+ if (event.scope !== StorageScope.Secure) {
276
+ console.log(event.type, event.operation);
277
+ }
278
+ });
279
+ ```
280
+
281
+ Use `subscribePrefix()` or `subscribeNamespace()` for targeted integrations. Use `setEventObserver()` for devtools-style logging. Secure events can include raw secret values, so filter them out of logs.
282
+
283
+ ## Capability Checks
284
+
285
+ ```ts
286
+ const capabilities = storage.getCapabilities();
287
+ const security = storage.getSecurityCapabilities();
288
+
289
+ if (security.secureStorage !== "available") {
290
+ console.warn("Secure storage is not available on this runtime");
291
+ }
292
+ ```
293
+
294
+ ## Low-level Raw API
295
+
296
+ ```ts
297
+ storage.setString("raw-key", "raw-value", StorageScope.Disk);
298
+ const rawValue = storage.getString("raw-key", StorageScope.Disk);
299
+ storage.deleteString("raw-key", StorageScope.Disk);
300
+ ```
301
+
302
+ Use raw APIs for migrations and integrations. Use `createStorageItem` for app state.
@@ -0,0 +1,190 @@
1
+ # Secure Storage
2
+
3
+ Secure scope is for secrets: refresh tokens, credentials, API tokens, and device-bound keys. It uses iOS Keychain on iOS and Android Keystore-backed EncryptedSharedPreferences on Android.
4
+
5
+ Use Disk scope for non-secret persisted state. Secure storage has stronger boundaries but more platform rules, especially around biometric prompts, device lock state, and backup/restore behavior.
6
+
7
+ ## Store a Secure Token
8
+
9
+ ```ts
10
+ import {
11
+ AccessControl,
12
+ createStorageItem,
13
+ StorageScope,
14
+ } from "react-native-nitro-storage";
15
+
16
+ export const refreshTokenItem = createStorageItem<string>({
17
+ key: "refreshToken",
18
+ namespace: "auth",
19
+ scope: StorageScope.Secure,
20
+ defaultValue: "",
21
+ accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
22
+ });
23
+
24
+ refreshTokenItem.set("opaque-refresh-token");
25
+ ```
26
+
27
+ ## Biometric Secrets
28
+
29
+ ```ts
30
+ import {
31
+ BiometricLevel,
32
+ createStorageItem,
33
+ StorageScope,
34
+ } from "react-native-nitro-storage";
35
+
36
+ export const recoveryCodeItem = createStorageItem<string>({
37
+ key: "recoveryCode",
38
+ namespace: "vault",
39
+ scope: StorageScope.Secure,
40
+ defaultValue: "",
41
+ biometric: true,
42
+ biometricLevel: BiometricLevel.BiometryOnly,
43
+ });
44
+ ```
45
+
46
+ `BiometricLevel.BiometryOnly` does not allow passcode fallback. Use `BiometricLevel.BiometryOrPasscode` when passcode fallback is acceptable.
47
+
48
+ ## Access Control
49
+
50
+ `accessControl` maps to platform accessibility rules where available.
51
+
52
+ | Value | Use when |
53
+ | ---------------------------------------------- | ----------------------------------------------------------------- |
54
+ | `AccessControl.WhenUnlocked` | The secret should be readable only after the device is unlocked. |
55
+ | `AccessControl.AfterFirstUnlock` | Background refresh needs access after first unlock until restart. |
56
+ | `AccessControl.WhenPasscodeSetThisDeviceOnly` | The secret must stay on this device and require a passcode. |
57
+ | `AccessControl.WhenUnlockedThisDeviceOnly` | The secret should not migrate through backup/restore. |
58
+ | `AccessControl.AfterFirstUnlockThisDeviceOnly` | Background refresh is needed, but migration is not allowed. |
59
+
60
+ ## Secure Auth Item Map
61
+
62
+ `createSecureAuthStorage()` creates a namespaced map of secure string items.
63
+
64
+ ```ts
65
+ import {
66
+ AccessControl,
67
+ BiometricLevel,
68
+ createSecureAuthStorage,
69
+ } from "react-native-nitro-storage";
70
+
71
+ export const authStorage = createSecureAuthStorage(
72
+ {
73
+ accessToken: { ttlMs: 15 * 60 * 1000 },
74
+ refreshToken: {
75
+ accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
76
+ },
77
+ recoveryCode: {
78
+ biometric: true,
79
+ biometricLevel: BiometricLevel.BiometryOrPasscode,
80
+ },
81
+ },
82
+ { namespace: "auth" },
83
+ );
84
+
85
+ authStorage.refreshToken.set("opaque-refresh-token");
86
+ ```
87
+
88
+ ## Runtime Capabilities
89
+
90
+ Use capability APIs to decide which support messages or diagnostics to show.
91
+
92
+ ```ts
93
+ import { storage } from "react-native-nitro-storage";
94
+
95
+ const capabilities = storage.getSecurityCapabilities();
96
+
97
+ if (capabilities.secureStorage === "available") {
98
+ // Secure scope is backed by the configured native or web secure backend.
99
+ }
100
+ ```
101
+
102
+ Capability fields are status values, not guarantees beyond the active backend. Hardware-backed storage is reported as `unknown` unless the platform can prove it.
103
+
104
+ ## Metadata Without Values
105
+
106
+ Use metadata APIs when rendering diagnostics or support dumps where secret values must stay out of memory.
107
+
108
+ ```ts
109
+ import { storage } from "react-native-nitro-storage";
110
+
111
+ const oneKey = storage.getSecureMetadata("auth:refreshToken");
112
+ const allKeys = storage.getAllSecureMetadata();
113
+ ```
114
+
115
+ `getSecureMetadata()` and `getAllSecureMetadata()` never return stored secret values. They report key existence, storage kind, backend name, access-control metadata, and whether a metadata path accidentally exposed a value.
116
+
117
+ ## Secure Export Warning
118
+
119
+ `storage.export(StorageScope.Secure)` returns raw secret values so it can round-trip with `storage.import(data, StorageScope.Secure)`.
120
+
121
+ ```ts
122
+ import { storage, StorageScope } from "react-native-nitro-storage";
123
+
124
+ const secureSnapshot = storage.export(StorageScope.Secure);
125
+ storage.import(secureSnapshot, StorageScope.Secure);
126
+ ```
127
+
128
+ Only keep Secure exports in memory for the shortest possible workflow. Do not log them or include them in diagnostics, analytics, crash reports, or support bundles.
129
+
130
+ ## Secure Event Warning
131
+
132
+ Secure scope event subscriptions and `storage.setEventObserver()` can receive raw secret values in `oldValue`, `newValue`, or batch `changes`.
133
+
134
+ Use Secure events for in-memory coordination only. Do not log Secure event payloads or send them to analytics, crash reporting, support bundles, or devtools sessions that persist outside the device.
135
+
136
+ ## Locked Keychain Errors
137
+
138
+ ```ts
139
+ import { isKeychainLockedError } from "react-native-nitro-storage";
140
+
141
+ try {
142
+ refreshTokenItem.get();
143
+ } catch (error) {
144
+ if (isKeychainLockedError(error)) {
145
+ // Defer token refresh until the device is unlocked.
146
+ }
147
+ }
148
+ ```
149
+
150
+ The helper recognizes iOS locked Keychain cases and Android invalidated/locked key cases surfaced by the native bridge.
151
+
152
+ ## Android Secure Write Mode
153
+
154
+ Android secure writes default to synchronous persistence. Enable async writes when write throughput is more important than immediate durability:
155
+
156
+ ```ts
157
+ import { storage } from "react-native-nitro-storage";
158
+
159
+ storage.setSecureWritesAsync(true);
160
+ refreshTokenItem.set("opaque-refresh-token");
161
+ storage.flushSecureWrites();
162
+ ```
163
+
164
+ Call `flushSecureWrites()` before assertions, namespace clears, or any boundary that requires deterministic persistence.
165
+
166
+ ## Web Secure Backend
167
+
168
+ Browsers cannot provide iOS Keychain or Android Keystore guarantees. On web, Secure scope is only as strong as the backend you configure.
169
+
170
+ ```ts
171
+ import { setWebSecureStorageBackend } from "react-native-nitro-storage";
172
+ import { createIndexedDBBackend } from "react-native-nitro-storage/indexeddb-backend";
173
+
174
+ const backend = await createIndexedDBBackend();
175
+ setWebSecureStorageBackend(backend);
176
+ ```
177
+
178
+ See [web-backends.md](web-backends.md) for backend contracts and IndexedDB setup.
179
+
180
+ ## Release Checks
181
+
182
+ Before releasing secure-storage changes, run:
183
+
184
+ ```sh
185
+ bun run test -- --filter=react-native-nitro-storage
186
+ bun run test:cpp -- --filter=react-native-nitro-storage
187
+ bun run --cwd packages/react-native-nitro-storage check:pack
188
+ ```
189
+
190
+ Also run an end-to-end auth flow on a locked/unlocked real device when changing biometric or Keychain behavior.