react-native-nitro-storage 0.4.4 → 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.
Files changed (48) hide show
  1. package/README.md +237 -862
  2. package/SECURITY.md +26 -0
  3. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +61 -10
  4. package/docs/api-reference.md +217 -0
  5. package/docs/batch-transactions-migrations.md +186 -0
  6. package/docs/benchmarks.md +37 -0
  7. package/docs/mmkv-migration.md +80 -0
  8. package/docs/react-hooks.md +113 -0
  9. package/docs/recipes.md +281 -0
  10. package/docs/secure-storage.md +171 -0
  11. package/docs/web-backends.md +141 -0
  12. package/ios/IOSStorageAdapterCpp.mm +44 -14
  13. package/lib/commonjs/index.js +271 -5
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +498 -202
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/indexeddb-backend.js +129 -7
  18. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  19. package/lib/commonjs/storage-runtime.js +41 -0
  20. package/lib/commonjs/storage-runtime.js.map +1 -0
  21. package/lib/commonjs/web-storage-backend.js +90 -0
  22. package/lib/commonjs/web-storage-backend.js.map +1 -0
  23. package/lib/module/index.js +263 -5
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/index.web.js +490 -202
  26. package/lib/module/index.web.js.map +1 -1
  27. package/lib/module/indexeddb-backend.js +129 -7
  28. package/lib/module/indexeddb-backend.js.map +1 -1
  29. package/lib/module/storage-runtime.js +36 -0
  30. package/lib/module/storage-runtime.js.map +1 -0
  31. package/lib/module/web-storage-backend.js +86 -0
  32. package/lib/module/web-storage-backend.js.map +1 -0
  33. package/lib/typescript/index.d.ts +14 -7
  34. package/lib/typescript/index.d.ts.map +1 -1
  35. package/lib/typescript/index.web.d.ts +15 -8
  36. package/lib/typescript/index.web.d.ts.map +1 -1
  37. package/lib/typescript/indexeddb-backend.d.ts +6 -2
  38. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  39. package/lib/typescript/storage-runtime.d.ts +48 -0
  40. package/lib/typescript/storage-runtime.d.ts.map +1 -0
  41. package/lib/typescript/web-storage-backend.d.ts +30 -0
  42. package/lib/typescript/web-storage-backend.d.ts.map +1 -0
  43. package/package.json +21 -8
  44. package/src/index.ts +330 -20
  45. package/src/index.web.ts +673 -245
  46. package/src/indexeddb-backend.ts +147 -6
  47. package/src/storage-runtime.ts +129 -0
  48. package/src/web-storage-backend.ts +129 -0
@@ -1,9 +1,17 @@
1
- import type { WebSecureStorageBackend } from "./index.web";
1
+ import type {
2
+ WebSecureStorageBackend,
3
+ WebStorageChangeEvent,
4
+ } from "./web-storage-backend";
2
5
 
3
6
  const DEFAULT_DB_NAME = "nitro-storage-secure";
4
7
  const DEFAULT_STORE_NAME = "keyvalue";
5
8
  const DB_VERSION = 1;
6
9
 
10
+ export type IndexedDBBackendOptions = {
11
+ channelName?: string;
12
+ onError?: (error: Error) => void;
13
+ };
14
+
7
15
  /**
8
16
  * Opens (or creates) an IndexedDB database and returns the underlying IDBDatabase.
9
17
  * Rejects if IndexedDB is unavailable in the current environment.
@@ -59,9 +67,62 @@ function openDB(dbName: string, storeName: string): Promise<IDBDatabase> {
59
67
  export async function createIndexedDBBackend(
60
68
  dbName = DEFAULT_DB_NAME,
61
69
  storeName = DEFAULT_STORE_NAME,
70
+ options: IndexedDBBackendOptions = {},
62
71
  ): Promise<WebSecureStorageBackend> {
63
72
  const db = await openDB(dbName, storeName);
64
73
  const cache = new Map<string, string>();
74
+ const pendingWrites = new Set<Promise<void>>();
75
+ const subscribers = new Set<(event: WebStorageChangeEvent) => void>();
76
+ const sourceId = `nitro-storage-${Math.random().toString(36).slice(2)}`;
77
+ const channelName =
78
+ options.channelName ?? `nitro-storage:${dbName}:${storeName}`;
79
+ const channel =
80
+ typeof BroadcastChannel !== "undefined"
81
+ ? new BroadcastChannel(channelName)
82
+ : null;
83
+
84
+ function emitExternal(event: WebStorageChangeEvent): void {
85
+ subscribers.forEach((subscriber) => {
86
+ subscriber(event);
87
+ });
88
+ }
89
+
90
+ function handleAsyncError(error: unknown): void {
91
+ const normalized =
92
+ error instanceof Error
93
+ ? error
94
+ : new Error(String(error ?? "Unknown IndexedDB error"));
95
+ options.onError?.(normalized);
96
+ }
97
+
98
+ channel?.addEventListener("message", (event: MessageEvent) => {
99
+ const data = event.data as
100
+ | (WebStorageChangeEvent & { sourceId?: string })
101
+ | undefined;
102
+ if (!data || data.sourceId === sourceId) {
103
+ return;
104
+ }
105
+
106
+ if (data.key === null) {
107
+ cache.clear();
108
+ } else if (data.newValue === null) {
109
+ cache.delete(data.key);
110
+ } else {
111
+ cache.set(data.key, data.newValue);
112
+ }
113
+
114
+ emitExternal({
115
+ key: data.key,
116
+ newValue: data.newValue,
117
+ });
118
+ });
119
+
120
+ function publish(event: WebStorageChangeEvent): void {
121
+ channel?.postMessage({
122
+ ...event,
123
+ sourceId,
124
+ });
125
+ }
65
126
 
66
127
  // Hydrate the in-memory cache from IndexedDB.
67
128
  await new Promise<void>((resolve, reject) => {
@@ -85,14 +146,39 @@ export async function createIndexedDBBackend(
85
146
  reject(tx.error ?? new Error("Failed to load IndexedDB entries."));
86
147
  });
87
148
 
88
- /** Fire-and-forget IndexedDB write. Errors are silently ignored to avoid
89
- * breaking the synchronous caller the in-memory cache is always authoritative. */
149
+ function trackWrite(tx: IDBTransaction): void {
150
+ const pending = new Promise<void>((resolve) => {
151
+ tx.oncomplete = () => resolve();
152
+ tx.onerror = () => {
153
+ handleAsyncError(
154
+ tx.error ?? new Error("Failed to persist IndexedDB transaction."),
155
+ );
156
+ resolve();
157
+ };
158
+ tx.onabort = () => {
159
+ handleAsyncError(
160
+ tx.error ?? new Error("IndexedDB transaction was aborted."),
161
+ );
162
+ resolve();
163
+ };
164
+ });
165
+ pendingWrites.add(pending);
166
+ void pending.finally(() => {
167
+ pendingWrites.delete(pending);
168
+ });
169
+ }
170
+
171
+ /** Fire-and-forget IndexedDB write. The in-memory cache remains authoritative,
172
+ * but async persistence failures are surfaced through `onError` and `flush()`. */
90
173
  function persistSet(key: string, value: string): void {
91
174
  try {
92
175
  const tx = db.transaction(storeName, "readwrite");
93
176
  tx.objectStore(storeName).put(value, key);
177
+ trackWrite(tx);
94
178
  } catch {
95
- // Best-effort; cache is the source of truth.
179
+ handleAsyncError(
180
+ new Error(`Failed to queue IndexedDB write for "${key}".`),
181
+ );
96
182
  }
97
183
  }
98
184
 
@@ -100,8 +186,11 @@ export async function createIndexedDBBackend(
100
186
  try {
101
187
  const tx = db.transaction(storeName, "readwrite");
102
188
  tx.objectStore(storeName).delete(key);
189
+ trackWrite(tx);
103
190
  } catch {
104
- // Best-effort.
191
+ handleAsyncError(
192
+ new Error(`Failed to queue IndexedDB delete for "${key}".`),
193
+ );
105
194
  }
106
195
  }
107
196
 
@@ -109,12 +198,14 @@ export async function createIndexedDBBackend(
109
198
  try {
110
199
  const tx = db.transaction(storeName, "readwrite");
111
200
  tx.objectStore(storeName).clear();
201
+ trackWrite(tx);
112
202
  } catch {
113
- // Best-effort.
203
+ handleAsyncError(new Error("Failed to queue IndexedDB clear."));
114
204
  }
115
205
  }
116
206
 
117
207
  const backend: WebSecureStorageBackend = {
208
+ name: `indexeddb:${dbName}/${storeName}`,
118
209
  getItem(key: string): string | null {
119
210
  return cache.get(key) ?? null;
120
211
  },
@@ -122,21 +213,71 @@ export async function createIndexedDBBackend(
122
213
  setItem(key: string, value: string): void {
123
214
  cache.set(key, value);
124
215
  persistSet(key, value);
216
+ publish({ key, newValue: value });
125
217
  },
126
218
 
127
219
  removeItem(key: string): void {
128
220
  cache.delete(key);
129
221
  persistDelete(key);
222
+ publish({ key, newValue: null });
130
223
  },
131
224
 
132
225
  clear(): void {
133
226
  cache.clear();
134
227
  persistClear();
228
+ publish({ key: null, newValue: null });
135
229
  },
136
230
 
137
231
  getAllKeys(): string[] {
138
232
  return Array.from(cache.keys());
139
233
  },
234
+ getMany(keys: string[]): (string | null)[] {
235
+ return keys.map((key) => cache.get(key) ?? null);
236
+ },
237
+ setMany(entries): void {
238
+ entries.forEach(([key, value]) => {
239
+ cache.set(key, value);
240
+ });
241
+ try {
242
+ const tx = db.transaction(storeName, "readwrite");
243
+ const store = tx.objectStore(storeName);
244
+ entries.forEach(([key, value]) => {
245
+ store.put(value, key);
246
+ publish({ key, newValue: value });
247
+ });
248
+ trackWrite(tx);
249
+ } catch {
250
+ handleAsyncError(new Error("Failed to queue IndexedDB batch write."));
251
+ }
252
+ },
253
+ removeMany(keys: string[]): void {
254
+ keys.forEach((key) => {
255
+ cache.delete(key);
256
+ });
257
+ try {
258
+ const tx = db.transaction(storeName, "readwrite");
259
+ const store = tx.objectStore(storeName);
260
+ keys.forEach((key) => {
261
+ store.delete(key);
262
+ publish({ key, newValue: null });
263
+ });
264
+ trackWrite(tx);
265
+ } catch {
266
+ handleAsyncError(new Error("Failed to queue IndexedDB batch delete."));
267
+ }
268
+ },
269
+ size(): number {
270
+ return cache.size;
271
+ },
272
+ subscribe(listener): () => void {
273
+ subscribers.add(listener);
274
+ return () => {
275
+ subscribers.delete(listener);
276
+ };
277
+ },
278
+ async flush(): Promise<void> {
279
+ await Promise.all(Array.from(pendingWrites));
280
+ },
140
281
  };
141
282
 
142
283
  return backend;
@@ -0,0 +1,129 @@
1
+ export type StorageErrorCode =
2
+ | "keychain_locked"
3
+ | "authentication_required"
4
+ | "key_invalidated"
5
+ | "storage_corruption"
6
+ | "biometric_unavailable"
7
+ | "unsupported";
8
+
9
+ export type StorageCapabilities = {
10
+ platform: "native" | "web";
11
+ backend: {
12
+ disk: string;
13
+ secure: string;
14
+ };
15
+ writeBuffering: {
16
+ disk: boolean;
17
+ secure: boolean;
18
+ };
19
+ errorClassification: boolean;
20
+ };
21
+
22
+ export type SecurityCapabilityStatus = "available" | "unavailable" | "unknown";
23
+
24
+ export type SecurityCapabilities = {
25
+ platform: "native" | "web";
26
+ secureStorage: {
27
+ backend: string;
28
+ encrypted: SecurityCapabilityStatus;
29
+ accessControl: SecurityCapabilityStatus;
30
+ keychainAccessGroup: SecurityCapabilityStatus;
31
+ hardwareBacked: SecurityCapabilityStatus;
32
+ };
33
+ biometric: {
34
+ storage: SecurityCapabilityStatus;
35
+ prompt: SecurityCapabilityStatus;
36
+ biometryOnly: SecurityCapabilityStatus;
37
+ biometryOrPasscode: SecurityCapabilityStatus;
38
+ };
39
+ metadata: {
40
+ perKey: boolean;
41
+ listsWithoutValues: boolean;
42
+ persistsTimestamps: boolean;
43
+ };
44
+ };
45
+
46
+ export type SecureStorageMetadata = {
47
+ key: string;
48
+ exists: boolean;
49
+ kind: "secure" | "biometric" | "missing";
50
+ backend: string;
51
+ encrypted: SecurityCapabilityStatus;
52
+ hardwareBacked: SecurityCapabilityStatus;
53
+ biometricProtected: boolean;
54
+ valueExposed: false;
55
+ };
56
+
57
+ const STORAGE_ERROR_TAG_PATTERN = /\[nitro-error:([a-z_]+)\]/;
58
+
59
+ export function getStorageErrorCode(
60
+ err: unknown,
61
+ ): StorageErrorCode | undefined {
62
+ if (!(err instanceof Error)) {
63
+ return undefined;
64
+ }
65
+
66
+ const message = err.message;
67
+ const taggedCode = message.match(STORAGE_ERROR_TAG_PATTERN)?.[1];
68
+
69
+ if (
70
+ taggedCode === "keychain_locked" ||
71
+ taggedCode === "authentication_required" ||
72
+ taggedCode === "key_invalidated" ||
73
+ taggedCode === "storage_corruption" ||
74
+ taggedCode === "biometric_unavailable" ||
75
+ taggedCode === "unsupported"
76
+ ) {
77
+ return taggedCode;
78
+ }
79
+
80
+ if (message.includes("errSecInteractionNotAllowed")) {
81
+ return "keychain_locked";
82
+ }
83
+
84
+ if (
85
+ message.includes("UserNotAuthenticatedException") ||
86
+ message.includes("KeyStoreException") ||
87
+ message.includes("android.security.keystore")
88
+ ) {
89
+ return "authentication_required";
90
+ }
91
+
92
+ if (
93
+ message.includes("KeyPermanentlyInvalidatedException") ||
94
+ message.includes("InvalidKeyException")
95
+ ) {
96
+ return "key_invalidated";
97
+ }
98
+
99
+ if (
100
+ message.includes("AEADBadTagException") ||
101
+ message.toLowerCase().includes("storage corruption") ||
102
+ message.toLowerCase().includes("corrupted storage")
103
+ ) {
104
+ return "storage_corruption";
105
+ }
106
+
107
+ if (
108
+ message.toLowerCase().includes("biometric storage unavailable") ||
109
+ message.toLowerCase().includes("biometric storage is not available")
110
+ ) {
111
+ return "biometric_unavailable";
112
+ }
113
+
114
+ if (message.toLowerCase().includes("unsupported")) {
115
+ return "unsupported";
116
+ }
117
+
118
+ return undefined;
119
+ }
120
+
121
+ export function isLockedStorageErrorCode(
122
+ code: StorageErrorCode | undefined,
123
+ ): boolean {
124
+ return (
125
+ code === "keychain_locked" ||
126
+ code === "authentication_required" ||
127
+ code === "key_invalidated"
128
+ );
129
+ }
@@ -0,0 +1,129 @@
1
+ import { StorageScope } from "./Storage.types";
2
+
3
+ export type WebStorageChangeEvent = {
4
+ key: string | null;
5
+ newValue: string | null;
6
+ };
7
+
8
+ export type WebStorageScope = StorageScope.Disk | StorageScope.Secure;
9
+
10
+ export type WebStorageBackend = {
11
+ getItem: (key: string) => string | null;
12
+ setItem: (key: string, value: string) => void;
13
+ removeItem: (key: string) => void;
14
+ clear: () => void;
15
+ getAllKeys: () => string[];
16
+ getMany?: (keys: string[]) => (string | null)[];
17
+ setMany?: (
18
+ entries: readonly (readonly [key: string, value: string])[],
19
+ ) => void;
20
+ removeMany?: (keys: string[]) => void;
21
+ size?: () => number;
22
+ subscribe?: (listener: (event: WebStorageChangeEvent) => void) => () => void;
23
+ flush?: () => Promise<void>;
24
+ name?: string;
25
+ };
26
+
27
+ export type WebDiskStorageBackend = WebStorageBackend;
28
+ export type WebSecureStorageBackend = WebStorageBackend;
29
+
30
+ type LocalStorageBackendOptions = {
31
+ includeKey?: (key: string) => boolean;
32
+ name?: string;
33
+ resolveStorage?: () => Storage | undefined;
34
+ };
35
+
36
+ function getResolvedStorage(
37
+ resolveStorage: (() => Storage | undefined) | undefined,
38
+ ): Storage | undefined {
39
+ if (resolveStorage) {
40
+ return resolveStorage();
41
+ }
42
+
43
+ if (typeof globalThis.localStorage === "undefined") {
44
+ return undefined;
45
+ }
46
+
47
+ return globalThis.localStorage;
48
+ }
49
+
50
+ export function createLocalStorageWebBackend(
51
+ options: LocalStorageBackendOptions = {},
52
+ ): WebStorageBackend {
53
+ const includeKey = options.includeKey;
54
+ const resolveStorage = options.resolveStorage;
55
+
56
+ const listKeys = (): string[] => {
57
+ const storage = getResolvedStorage(resolveStorage);
58
+ if (!storage) {
59
+ return [];
60
+ }
61
+
62
+ const keys: string[] = [];
63
+ for (let index = 0; index < storage.length; index += 1) {
64
+ const key = storage.key(index);
65
+ if (!key) {
66
+ continue;
67
+ }
68
+ if (includeKey && !includeKey(key)) {
69
+ continue;
70
+ }
71
+ keys.push(key);
72
+ }
73
+ return keys;
74
+ };
75
+
76
+ return {
77
+ name: options.name ?? "localStorage",
78
+ getItem(key: string): string | null {
79
+ return getResolvedStorage(resolveStorage)?.getItem(key) ?? null;
80
+ },
81
+ setItem(key: string, value: string): void {
82
+ getResolvedStorage(resolveStorage)?.setItem(key, value);
83
+ },
84
+ removeItem(key: string): void {
85
+ getResolvedStorage(resolveStorage)?.removeItem(key);
86
+ },
87
+ clear(): void {
88
+ const storage = getResolvedStorage(resolveStorage);
89
+ if (!storage) {
90
+ return;
91
+ }
92
+
93
+ listKeys().forEach((key) => {
94
+ storage.removeItem(key);
95
+ });
96
+ },
97
+ getAllKeys(): string[] {
98
+ return listKeys();
99
+ },
100
+ getMany(keys: string[]): (string | null)[] {
101
+ const storage = getResolvedStorage(resolveStorage);
102
+ if (!storage) {
103
+ return keys.map(() => null);
104
+ }
105
+ return keys.map((key) => storage.getItem(key));
106
+ },
107
+ setMany(entries): void {
108
+ const storage = getResolvedStorage(resolveStorage);
109
+ if (!storage) {
110
+ return;
111
+ }
112
+ entries.forEach(([key, value]) => {
113
+ storage.setItem(key, value);
114
+ });
115
+ },
116
+ removeMany(keys: string[]): void {
117
+ const storage = getResolvedStorage(resolveStorage);
118
+ if (!storage) {
119
+ return;
120
+ }
121
+ keys.forEach((key) => {
122
+ storage.removeItem(key);
123
+ });
124
+ },
125
+ size(): number {
126
+ return listKeys().length;
127
+ },
128
+ };
129
+ }