react-native-nitro-storage 0.1.4 → 0.3.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 (48) hide show
  1. package/README.md +432 -345
  2. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +191 -3
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +21 -41
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +181 -29
  5. package/android/src/main/java/com/nitrostorage/NitroStoragePackage.kt +2 -2
  6. package/app.plugin.js +9 -7
  7. package/cpp/bindings/HybridStorage.cpp +239 -10
  8. package/cpp/bindings/HybridStorage.hpp +10 -0
  9. package/cpp/core/NativeStorageAdapter.hpp +22 -0
  10. package/ios/IOSStorageAdapterCpp.hpp +25 -0
  11. package/ios/IOSStorageAdapterCpp.mm +315 -33
  12. package/lib/commonjs/Storage.types.js +23 -1
  13. package/lib/commonjs/Storage.types.js.map +1 -1
  14. package/lib/commonjs/index.js +680 -68
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/index.web.js +801 -133
  17. package/lib/commonjs/index.web.js.map +1 -1
  18. package/lib/commonjs/internal.js +112 -0
  19. package/lib/commonjs/internal.js.map +1 -0
  20. package/lib/module/Storage.types.js +22 -0
  21. package/lib/module/Storage.types.js.map +1 -1
  22. package/lib/module/index.js +660 -71
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/index.web.js +766 -125
  25. package/lib/module/index.web.js.map +1 -1
  26. package/lib/module/internal.js +100 -0
  27. package/lib/module/internal.js.map +1 -0
  28. package/lib/typescript/Storage.nitro.d.ts +10 -0
  29. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  30. package/lib/typescript/Storage.types.d.ts +20 -0
  31. package/lib/typescript/Storage.types.d.ts.map +1 -1
  32. package/lib/typescript/index.d.ts +68 -9
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/index.web.d.ts +79 -13
  35. package/lib/typescript/index.web.d.ts.map +1 -1
  36. package/lib/typescript/internal.d.ts +21 -0
  37. package/lib/typescript/internal.d.ts.map +1 -0
  38. package/lib/typescript/migration.d.ts +2 -3
  39. package/lib/typescript/migration.d.ts.map +1 -1
  40. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +10 -0
  41. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +10 -0
  42. package/package.json +22 -8
  43. package/src/Storage.nitro.ts +11 -2
  44. package/src/Storage.types.ts +22 -0
  45. package/src/index.ts +943 -84
  46. package/src/index.web.ts +1082 -137
  47. package/src/internal.ts +144 -0
  48. package/src/migration.ts +3 -3
package/src/index.web.ts CHANGED
@@ -1,37 +1,267 @@
1
- import { useSyncExternalStore } from "react";
1
+ import { useRef, useSyncExternalStore } from "react";
2
+ import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
3
+ import {
4
+ MIGRATION_VERSION_KEY,
5
+ type StoredEnvelope,
6
+ isStoredEnvelope,
7
+ assertBatchScope,
8
+ assertValidScope,
9
+ serializeWithPrimitiveFastPath,
10
+ deserializeWithPrimitiveFastPath,
11
+ prefixKey,
12
+ isNamespaced,
13
+ } from "./internal";
2
14
 
3
- export enum StorageScope {
4
- Memory = 0,
5
- Disk = 1,
6
- Secure = 2,
15
+ export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
16
+ export { migrateFromMMKV } from "./migration";
17
+
18
+ export type Validator<T> = (value: unknown) => value is T;
19
+ export type ExpirationConfig = {
20
+ ttlMs: number;
21
+ };
22
+
23
+ export type MigrationContext = {
24
+ scope: StorageScope;
25
+ getRaw: (key: string) => string | undefined;
26
+ setRaw: (key: string, value: string) => void;
27
+ removeRaw: (key: string) => void;
28
+ };
29
+
30
+ export type Migration = (context: MigrationContext) => void;
31
+
32
+ export type TransactionContext = {
33
+ scope: StorageScope;
34
+ getRaw: (key: string) => string | undefined;
35
+ setRaw: (key: string, value: string) => void;
36
+ removeRaw: (key: string) => void;
37
+ getItem: <T>(item: Pick<StorageItem<T>, "scope" | "key" | "get">) => T;
38
+ setItem: <T>(
39
+ item: Pick<StorageItem<T>, "scope" | "key" | "set">,
40
+ value: T,
41
+ ) => void;
42
+ removeItem: (
43
+ item: Pick<StorageItem<unknown>, "scope" | "key" | "delete">,
44
+ ) => void;
45
+ };
46
+
47
+ type KeyListenerRegistry = Map<string, Set<() => void>>;
48
+ type RawBatchPathItem = {
49
+ _hasValidation?: boolean;
50
+ _hasExpiration?: boolean;
51
+ _isBiometric?: boolean;
52
+ _secureAccessControl?: AccessControl;
53
+ };
54
+
55
+ function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
56
+ return item as unknown as StorageItemInternal<any>;
7
57
  }
58
+ type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
59
+ type PendingSecureWrite = { key: string; value: string | undefined };
60
+ type BrowserStorageLike = {
61
+ setItem: (key: string, value: string) => void;
62
+ getItem: (key: string) => string | null;
63
+ removeItem: (key: string) => void;
64
+ clear: () => void;
65
+ key: (index: number) => string | null;
66
+ readonly length: number;
67
+ };
68
+
69
+ const registeredMigrations = new Map<number, Migration>();
70
+ const runMicrotask =
71
+ typeof queueMicrotask === "function"
72
+ ? queueMicrotask
73
+ : (task: () => void) => {
74
+ Promise.resolve().then(task);
75
+ };
8
76
 
9
77
  export interface Storage {
10
78
  name: string;
11
- equals: (other: any) => boolean;
79
+ equals: (other: unknown) => boolean;
12
80
  dispose: () => void;
13
81
  set(key: string, value: string, scope: number): void;
14
82
  get(key: string, scope: number): string | undefined;
15
83
  remove(key: string, scope: number): void;
16
84
  clear(scope: number): void;
85
+ has(key: string, scope: number): boolean;
86
+ getAllKeys(scope: number): string[];
87
+ size(scope: number): number;
17
88
  setBatch(keys: string[], values: string[], scope: number): void;
18
89
  getBatch(keys: string[], scope: number): (string | undefined)[];
19
90
  removeBatch(keys: string[], scope: number): void;
20
91
  addOnChange(
21
92
  scope: number,
22
- callback: (key: string, value: string | undefined) => void
93
+ callback: (key: string, value: string | undefined) => void,
23
94
  ): () => void;
95
+ setSecureAccessControl(level: number): void;
96
+ setKeychainAccessGroup(group: string): void;
97
+ setSecureBiometric(key: string, value: string): void;
98
+ getSecureBiometric(key: string): string | undefined;
99
+ deleteSecureBiometric(key: string): void;
100
+ hasSecureBiometric(key: string): boolean;
101
+ clearSecureBiometric(): void;
102
+ }
103
+
104
+ const memoryStore = new Map<string, unknown>();
105
+ const memoryListeners: KeyListenerRegistry = new Map();
106
+ const webScopeListeners = new Map<NonMemoryScope, KeyListenerRegistry>([
107
+ [StorageScope.Disk, new Map()],
108
+ [StorageScope.Secure, new Map()],
109
+ ]);
110
+ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
111
+ [
112
+ [StorageScope.Disk, new Map()],
113
+ [StorageScope.Secure, new Map()],
114
+ ],
115
+ );
116
+ const pendingSecureWrites = new Map<string, PendingSecureWrite>();
117
+ let secureFlushScheduled = false;
118
+ const SECURE_WEB_PREFIX = "__secure_";
119
+ const BIOMETRIC_WEB_PREFIX = "__bio_";
120
+ let hasWarnedAboutWebBiometricFallback = false;
121
+
122
+ function getBrowserStorage(scope: number): BrowserStorageLike | undefined {
123
+ if (scope === StorageScope.Disk) {
124
+ return globalThis.localStorage;
125
+ }
126
+ if (scope === StorageScope.Secure) {
127
+ return globalThis.localStorage;
128
+ }
129
+ return undefined;
24
130
  }
25
131
 
26
- const diskListeners = new Map<string, Set<() => void>>();
27
- const secureListeners = new Map<string, Set<() => void>>();
132
+ function toSecureStorageKey(key: string): string {
133
+ return `${SECURE_WEB_PREFIX}${key}`;
134
+ }
28
135
 
29
- function notifyDiskListeners(key: string) {
30
- diskListeners.get(key)?.forEach((cb) => cb());
136
+ function fromSecureStorageKey(key: string): string {
137
+ return key.slice(SECURE_WEB_PREFIX.length);
31
138
  }
32
139
 
33
- function notifySecureListeners(key: string) {
34
- secureListeners.get(key)?.forEach((cb) => cb());
140
+ function toBiometricStorageKey(key: string): string {
141
+ return `${BIOMETRIC_WEB_PREFIX}${key}`;
142
+ }
143
+
144
+ function fromBiometricStorageKey(key: string): string {
145
+ return key.slice(BIOMETRIC_WEB_PREFIX.length);
146
+ }
147
+
148
+ function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
149
+ return webScopeListeners.get(scope)!;
150
+ }
151
+
152
+ function getScopeRawCache(
153
+ scope: NonMemoryScope,
154
+ ): Map<string, string | undefined> {
155
+ return scopedRawCache.get(scope)!;
156
+ }
157
+
158
+ function cacheRawValue(
159
+ scope: NonMemoryScope,
160
+ key: string,
161
+ value: string | undefined,
162
+ ): void {
163
+ getScopeRawCache(scope).set(key, value);
164
+ }
165
+
166
+ function readCachedRawValue(
167
+ scope: NonMemoryScope,
168
+ key: string,
169
+ ): string | undefined {
170
+ return getScopeRawCache(scope).get(key);
171
+ }
172
+
173
+ function hasCachedRawValue(scope: NonMemoryScope, key: string): boolean {
174
+ return getScopeRawCache(scope).has(key);
175
+ }
176
+
177
+ function clearScopeRawCache(scope: NonMemoryScope): void {
178
+ getScopeRawCache(scope).clear();
179
+ }
180
+
181
+ function notifyKeyListeners(registry: KeyListenerRegistry, key: string): void {
182
+ registry.get(key)?.forEach((listener) => listener());
183
+ }
184
+
185
+ function notifyAllListeners(registry: KeyListenerRegistry): void {
186
+ registry.forEach((listeners) => {
187
+ listeners.forEach((listener) => listener());
188
+ });
189
+ }
190
+
191
+ function addKeyListener(
192
+ registry: KeyListenerRegistry,
193
+ key: string,
194
+ listener: () => void,
195
+ ): () => void {
196
+ let listeners = registry.get(key);
197
+ if (!listeners) {
198
+ listeners = new Set();
199
+ registry.set(key, listeners);
200
+ }
201
+ listeners.add(listener);
202
+
203
+ return () => {
204
+ const scopedListeners = registry.get(key);
205
+ if (!scopedListeners) {
206
+ return;
207
+ }
208
+ scopedListeners.delete(listener);
209
+ if (scopedListeners.size === 0) {
210
+ registry.delete(key);
211
+ }
212
+ };
213
+ }
214
+
215
+ function readPendingSecureWrite(key: string): string | undefined {
216
+ return pendingSecureWrites.get(key)?.value;
217
+ }
218
+
219
+ function hasPendingSecureWrite(key: string): boolean {
220
+ return pendingSecureWrites.has(key);
221
+ }
222
+
223
+ function clearPendingSecureWrite(key: string): void {
224
+ pendingSecureWrites.delete(key);
225
+ }
226
+
227
+ function flushSecureWrites(): void {
228
+ secureFlushScheduled = false;
229
+
230
+ if (pendingSecureWrites.size === 0) {
231
+ return;
232
+ }
233
+
234
+ const writes = Array.from(pendingSecureWrites.values());
235
+ pendingSecureWrites.clear();
236
+
237
+ const keysToSet: string[] = [];
238
+ const valuesToSet: string[] = [];
239
+ const keysToRemove: string[] = [];
240
+
241
+ writes.forEach(({ key, value }) => {
242
+ if (value === undefined) {
243
+ keysToRemove.push(key);
244
+ } else {
245
+ keysToSet.push(key);
246
+ valuesToSet.push(value);
247
+ }
248
+ });
249
+
250
+ if (keysToSet.length > 0) {
251
+ WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
252
+ }
253
+ if (keysToRemove.length > 0) {
254
+ WebStorage.removeBatch(keysToRemove, StorageScope.Secure);
255
+ }
256
+ }
257
+
258
+ function scheduleSecureWrite(key: string, value: string | undefined): void {
259
+ pendingSecureWrites.set(key, { key, value });
260
+ if (secureFlushScheduled) {
261
+ return;
262
+ }
263
+ secureFlushScheduled = true;
264
+ runMicrotask(flushSecureWrites);
35
265
  }
36
266
 
37
267
  const WebStorage: Storage = {
@@ -39,77 +269,277 @@ const WebStorage: Storage = {
39
269
  equals: (other) => other === WebStorage,
40
270
  dispose: () => {},
41
271
  set: (key: string, value: string, scope: number) => {
42
- if (scope === StorageScope.Disk) {
43
- localStorage?.setItem(key, value);
44
- notifyDiskListeners(key);
45
- } else if (scope === StorageScope.Secure) {
46
- sessionStorage?.setItem(key, value);
47
- notifySecureListeners(key);
272
+ const storage = getBrowserStorage(scope);
273
+ if (!storage) {
274
+ return;
275
+ }
276
+ const storageKey =
277
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
278
+ storage.setItem(storageKey, value);
279
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
280
+ notifyKeyListeners(getScopedListeners(scope), key);
48
281
  }
49
282
  },
50
-
51
283
  get: (key: string, scope: number) => {
52
- if (scope === StorageScope.Disk) {
53
- return localStorage?.getItem(key) ?? undefined;
54
- } else if (scope === StorageScope.Secure) {
55
- return sessionStorage?.getItem(key) ?? undefined;
56
- }
57
- return undefined;
284
+ const storage = getBrowserStorage(scope);
285
+ const storageKey =
286
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
287
+ return storage?.getItem(storageKey) ?? undefined;
58
288
  },
59
289
  remove: (key: string, scope: number) => {
60
- if (scope === StorageScope.Disk) {
61
- localStorage?.removeItem(key);
62
- notifyDiskListeners(key);
63
- } else if (scope === StorageScope.Secure) {
64
- sessionStorage?.removeItem(key);
65
- notifySecureListeners(key);
290
+ const storage = getBrowserStorage(scope);
291
+ if (!storage) {
292
+ return;
293
+ }
294
+ if (scope === StorageScope.Secure) {
295
+ storage.removeItem(toSecureStorageKey(key));
296
+ storage.removeItem(toBiometricStorageKey(key));
297
+ } else {
298
+ storage.removeItem(key);
299
+ }
300
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
301
+ notifyKeyListeners(getScopedListeners(scope), key);
66
302
  }
67
303
  },
68
-
69
304
  clear: (scope: number) => {
70
- if (scope === StorageScope.Disk) {
71
- localStorage?.clear();
72
- diskListeners.forEach((listeners) => {
73
- listeners.forEach((cb) => cb());
74
- });
75
- } else if (scope === StorageScope.Secure) {
76
- sessionStorage?.clear();
77
- secureListeners.forEach((listeners) => {
78
- listeners.forEach((cb) => cb());
79
- });
305
+ const storage = getBrowserStorage(scope);
306
+ if (!storage) {
307
+ return;
308
+ }
309
+ if (scope === StorageScope.Secure) {
310
+ const keysToRemove: string[] = [];
311
+ for (let i = 0; i < storage.length; i++) {
312
+ const key = storage.key(i);
313
+ if (
314
+ key?.startsWith(SECURE_WEB_PREFIX) ||
315
+ key?.startsWith(BIOMETRIC_WEB_PREFIX)
316
+ ) {
317
+ keysToRemove.push(key);
318
+ }
319
+ }
320
+ keysToRemove.forEach((key) => storage.removeItem(key));
321
+ } else if (scope === StorageScope.Disk) {
322
+ const keysToRemove: string[] = [];
323
+ for (let i = 0; i < storage.length; i++) {
324
+ const key = storage.key(i);
325
+ if (
326
+ key &&
327
+ !key.startsWith(SECURE_WEB_PREFIX) &&
328
+ !key.startsWith(BIOMETRIC_WEB_PREFIX)
329
+ ) {
330
+ keysToRemove.push(key);
331
+ }
332
+ }
333
+ keysToRemove.forEach((key) => storage.removeItem(key));
334
+ } else {
335
+ storage.clear();
336
+ }
337
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
338
+ notifyAllListeners(getScopedListeners(scope));
80
339
  }
81
340
  },
82
341
  setBatch: (keys: string[], values: string[], scope: number) => {
83
- keys.forEach((key, i) => WebStorage.set(key, values[i], scope));
342
+ const storage = getBrowserStorage(scope);
343
+ if (!storage) {
344
+ return;
345
+ }
346
+
347
+ keys.forEach((key, index) => {
348
+ const storageKey =
349
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
350
+ storage.setItem(storageKey, values[index]);
351
+ });
352
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
353
+ const listeners = getScopedListeners(scope);
354
+ keys.forEach((key) => notifyKeyListeners(listeners, key));
355
+ }
84
356
  },
85
357
  getBatch: (keys: string[], scope: number) => {
86
- return keys.map((key) => WebStorage.get(key, scope));
358
+ const storage = getBrowserStorage(scope);
359
+ return keys.map((key) => {
360
+ const storageKey =
361
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
362
+ return storage?.getItem(storageKey) ?? undefined;
363
+ });
87
364
  },
88
365
  removeBatch: (keys: string[], scope: number) => {
89
- keys.forEach((key) => WebStorage.remove(key, scope));
366
+ keys.forEach((key) => {
367
+ WebStorage.remove(key, scope);
368
+ });
90
369
  },
91
370
  addOnChange: (
92
371
  _scope: number,
93
- _callback: (key: string, value: string | undefined) => void
372
+ _callback: (key: string, value: string | undefined) => void,
94
373
  ) => {
95
374
  return () => {};
96
375
  },
376
+ has: (key: string, scope: number) => {
377
+ const storage = getBrowserStorage(scope);
378
+ if (scope === StorageScope.Secure) {
379
+ return (
380
+ storage?.getItem(toSecureStorageKey(key)) !== null ||
381
+ storage?.getItem(toBiometricStorageKey(key)) !== null
382
+ );
383
+ }
384
+ return storage?.getItem(key) !== null;
385
+ },
386
+ getAllKeys: (scope: number) => {
387
+ const storage = getBrowserStorage(scope);
388
+ if (!storage) return [];
389
+ const keys = new Set<string>();
390
+ for (let i = 0; i < storage.length; i++) {
391
+ const k = storage.key(i);
392
+ if (!k) {
393
+ continue;
394
+ }
395
+ if (scope === StorageScope.Secure) {
396
+ if (k.startsWith(SECURE_WEB_PREFIX)) {
397
+ keys.add(fromSecureStorageKey(k));
398
+ } else if (k.startsWith(BIOMETRIC_WEB_PREFIX)) {
399
+ keys.add(fromBiometricStorageKey(k));
400
+ }
401
+ continue;
402
+ }
403
+ if (
404
+ k.startsWith(SECURE_WEB_PREFIX) ||
405
+ k.startsWith(BIOMETRIC_WEB_PREFIX)
406
+ ) {
407
+ continue;
408
+ }
409
+ keys.add(k);
410
+ }
411
+ return Array.from(keys);
412
+ },
413
+ size: (scope: number) => {
414
+ return WebStorage.getAllKeys(scope).length;
415
+ },
416
+ setSecureAccessControl: () => {},
417
+ setKeychainAccessGroup: () => {},
418
+ setSecureBiometric: (key: string, value: string) => {
419
+ if (
420
+ typeof __DEV__ !== "undefined" &&
421
+ __DEV__ &&
422
+ !hasWarnedAboutWebBiometricFallback
423
+ ) {
424
+ hasWarnedAboutWebBiometricFallback = true;
425
+ console.warn(
426
+ "[NitroStorage] Biometric storage is not supported on web. Using localStorage.",
427
+ );
428
+ }
429
+ globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
430
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
431
+ },
432
+ getSecureBiometric: (key: string) => {
433
+ return (
434
+ globalThis.localStorage?.getItem(toBiometricStorageKey(key)) ?? undefined
435
+ );
436
+ },
437
+ deleteSecureBiometric: (key: string) => {
438
+ globalThis.localStorage?.removeItem(toBiometricStorageKey(key));
439
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
440
+ },
441
+ hasSecureBiometric: (key: string) => {
442
+ return (
443
+ globalThis.localStorage?.getItem(toBiometricStorageKey(key)) !== null
444
+ );
445
+ },
446
+ clearSecureBiometric: () => {
447
+ const storage = globalThis.localStorage;
448
+ if (!storage) return;
449
+ const keysToNotify: string[] = [];
450
+ const toRemove: string[] = [];
451
+ for (let i = 0; i < storage.length; i++) {
452
+ const k = storage.key(i);
453
+ if (k?.startsWith(BIOMETRIC_WEB_PREFIX)) {
454
+ toRemove.push(k);
455
+ keysToNotify.push(fromBiometricStorageKey(k));
456
+ }
457
+ }
458
+ toRemove.forEach((k) => storage.removeItem(k));
459
+ const listeners = getScopedListeners(StorageScope.Secure);
460
+ keysToNotify.forEach((key) => notifyKeyListeners(listeners, key));
461
+ },
97
462
  };
98
463
 
99
- const memoryStore = new Map<string, any>();
100
- const memoryListeners = new Set<(key: string, value: any) => void>();
464
+ function getRawValue(key: string, scope: StorageScope): string | undefined {
465
+ assertValidScope(scope);
466
+ if (scope === StorageScope.Memory) {
467
+ const value = memoryStore.get(key);
468
+ return typeof value === "string" ? value : undefined;
469
+ }
470
+
471
+ if (scope === StorageScope.Secure && hasPendingSecureWrite(key)) {
472
+ return readPendingSecureWrite(key);
473
+ }
474
+
475
+ return WebStorage.get(key, scope);
476
+ }
477
+
478
+ function setRawValue(key: string, value: string, scope: StorageScope): void {
479
+ assertValidScope(scope);
480
+ if (scope === StorageScope.Memory) {
481
+ memoryStore.set(key, value);
482
+ notifyKeyListeners(memoryListeners, key);
483
+ return;
484
+ }
101
485
 
102
- function notifyMemoryListeners(key: string, value: any) {
103
- memoryListeners.forEach((listener) => listener(key, value));
486
+ if (scope === StorageScope.Secure) {
487
+ flushSecureWrites();
488
+ clearPendingSecureWrite(key);
489
+ }
490
+
491
+ WebStorage.set(key, value, scope);
492
+ cacheRawValue(scope, key, value);
493
+ }
494
+
495
+ function removeRawValue(key: string, scope: StorageScope): void {
496
+ assertValidScope(scope);
497
+ if (scope === StorageScope.Memory) {
498
+ memoryStore.delete(key);
499
+ notifyKeyListeners(memoryListeners, key);
500
+ return;
501
+ }
502
+
503
+ if (scope === StorageScope.Secure) {
504
+ flushSecureWrites();
505
+ clearPendingSecureWrite(key);
506
+ }
507
+
508
+ WebStorage.remove(key, scope);
509
+ cacheRawValue(scope, key, undefined);
510
+ }
511
+
512
+ function readMigrationVersion(scope: StorageScope): number {
513
+ const raw = getRawValue(MIGRATION_VERSION_KEY, scope);
514
+ if (raw === undefined) {
515
+ return 0;
516
+ }
517
+
518
+ const parsed = Number.parseInt(raw, 10);
519
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
520
+ }
521
+
522
+ function writeMigrationVersion(scope: StorageScope, version: number): void {
523
+ setRawValue(MIGRATION_VERSION_KEY, String(version), scope);
104
524
  }
105
525
 
106
526
  export const storage = {
107
527
  clear: (scope: StorageScope) => {
108
528
  if (scope === StorageScope.Memory) {
109
529
  memoryStore.clear();
110
- notifyMemoryListeners("", undefined);
111
- } else {
112
- WebStorage.clear(scope);
530
+ notifyAllListeners(memoryListeners);
531
+ return;
532
+ }
533
+
534
+ if (scope === StorageScope.Secure) {
535
+ flushSecureWrites();
536
+ pendingSecureWrites.clear();
537
+ }
538
+
539
+ clearScopeRawCache(scope);
540
+ WebStorage.clear(scope);
541
+ if (scope === StorageScope.Secure) {
542
+ WebStorage.clearSecureBiometric();
113
543
  }
114
544
  },
115
545
  clearAll: () => {
@@ -117,6 +547,66 @@ export const storage = {
117
547
  storage.clear(StorageScope.Disk);
118
548
  storage.clear(StorageScope.Secure);
119
549
  },
550
+ clearNamespace: (namespace: string, scope: StorageScope) => {
551
+ assertValidScope(scope);
552
+ if (scope === StorageScope.Memory) {
553
+ for (const key of memoryStore.keys()) {
554
+ if (isNamespaced(key, namespace)) {
555
+ memoryStore.delete(key);
556
+ }
557
+ }
558
+ notifyAllListeners(memoryListeners);
559
+ return;
560
+ }
561
+ if (scope === StorageScope.Secure) {
562
+ flushSecureWrites();
563
+ }
564
+ const keys = WebStorage.getAllKeys(scope);
565
+ const namespacedKeys = keys.filter((k) => isNamespaced(k, namespace));
566
+ if (namespacedKeys.length > 0) {
567
+ WebStorage.removeBatch(namespacedKeys, scope);
568
+ namespacedKeys.forEach((k) => cacheRawValue(scope, k, undefined));
569
+ if (scope === StorageScope.Secure) {
570
+ namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
571
+ }
572
+ }
573
+ },
574
+ clearBiometric: () => {
575
+ WebStorage.clearSecureBiometric();
576
+ },
577
+ has: (key: string, scope: StorageScope): boolean => {
578
+ assertValidScope(scope);
579
+ if (scope === StorageScope.Memory) return memoryStore.has(key);
580
+ return WebStorage.has(key, scope);
581
+ },
582
+ getAllKeys: (scope: StorageScope): string[] => {
583
+ assertValidScope(scope);
584
+ if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
585
+ return WebStorage.getAllKeys(scope);
586
+ },
587
+ getAll: (scope: StorageScope): Record<string, string> => {
588
+ assertValidScope(scope);
589
+ const result: Record<string, string> = {};
590
+ if (scope === StorageScope.Memory) {
591
+ memoryStore.forEach((value, key) => {
592
+ if (typeof value === "string") result[key] = value;
593
+ });
594
+ return result;
595
+ }
596
+ const keys = WebStorage.getAllKeys(scope);
597
+ keys.forEach((key) => {
598
+ const val = WebStorage.get(key, scope);
599
+ if (val !== undefined) result[key] = val;
600
+ });
601
+ return result;
602
+ },
603
+ size: (scope: StorageScope): number => {
604
+ assertValidScope(scope);
605
+ if (scope === StorageScope.Memory) return memoryStore.size;
606
+ return WebStorage.size(scope);
607
+ },
608
+ setAccessControl: (_level: AccessControl) => {},
609
+ setKeychainAccessGroup: (_group: string) => {},
120
610
  };
121
611
 
122
612
  export interface StorageItemConfig<T> {
@@ -125,98 +615,297 @@ export interface StorageItemConfig<T> {
125
615
  defaultValue?: T;
126
616
  serialize?: (value: T) => string;
127
617
  deserialize?: (value: string) => T;
618
+ validate?: Validator<T>;
619
+ onValidationError?: (invalidValue: unknown) => T;
620
+ expiration?: ExpirationConfig;
621
+ onExpired?: (key: string) => void;
622
+ readCache?: boolean;
623
+ coalesceSecureWrites?: boolean;
624
+ namespace?: string;
625
+ biometric?: boolean;
626
+ accessControl?: AccessControl;
128
627
  }
129
628
 
130
629
  export interface StorageItem<T> {
131
630
  get: () => T;
132
631
  set: (value: T | ((prev: T) => T)) => void;
133
632
  delete: () => void;
633
+ has: () => boolean;
134
634
  subscribe: (callback: () => void) => () => void;
135
635
  serialize: (value: T) => string;
136
636
  deserialize: (value: string) => T;
137
- _triggerListeners: () => void;
138
637
  scope: StorageScope;
139
638
  key: string;
140
639
  }
141
640
 
641
+ type StorageItemInternal<T> = StorageItem<T> & {
642
+ _triggerListeners: () => void;
643
+ _hasValidation: boolean;
644
+ _hasExpiration: boolean;
645
+ _readCacheEnabled: boolean;
646
+ _isBiometric: boolean;
647
+ _secureAccessControl?: AccessControl;
648
+ };
649
+
650
+ function canUseRawBatchPath(item: RawBatchPathItem): boolean {
651
+ return (
652
+ item._hasExpiration === false &&
653
+ item._hasValidation === false &&
654
+ item._isBiometric !== true &&
655
+ item._secureAccessControl === undefined
656
+ );
657
+ }
658
+
142
659
  function defaultSerialize<T>(value: T): string {
143
- return JSON.stringify(value);
660
+ return serializeWithPrimitiveFastPath(value);
144
661
  }
145
662
 
146
663
  function defaultDeserialize<T>(value: string): T {
147
- return JSON.parse(value) as T;
664
+ return deserializeWithPrimitiveFastPath(value);
148
665
  }
149
666
 
150
667
  export function createStorageItem<T = undefined>(
151
- config: StorageItemConfig<T>
668
+ config: StorageItemConfig<T>,
152
669
  ): StorageItem<T> {
670
+ const storageKey = prefixKey(config.namespace, config.key);
153
671
  const serialize = config.serialize ?? defaultSerialize;
154
672
  const deserialize = config.deserialize ?? defaultDeserialize;
155
673
  const isMemory = config.scope === StorageScope.Memory;
674
+ const isBiometric =
675
+ config.biometric === true && config.scope === StorageScope.Secure;
676
+ const secureAccessControl = config.accessControl;
677
+ const validate = config.validate;
678
+ const onValidationError = config.onValidationError;
679
+ const expiration = config.expiration;
680
+ const onExpired = config.onExpired;
681
+ const expirationTtlMs = expiration?.ttlMs;
682
+ const memoryExpiration =
683
+ expiration && isMemory ? new Map<string, number>() : null;
684
+ const readCache = !isMemory && config.readCache === true;
685
+ const coalesceSecureWrites =
686
+ config.scope === StorageScope.Secure &&
687
+ config.coalesceSecureWrites === true &&
688
+ !isBiometric &&
689
+ secureAccessControl === undefined;
690
+ const nonMemoryScope: NonMemoryScope | null =
691
+ config.scope === StorageScope.Disk
692
+ ? StorageScope.Disk
693
+ : config.scope === StorageScope.Secure
694
+ ? StorageScope.Secure
695
+ : null;
696
+
697
+ if (expiration && expiration.ttlMs <= 0) {
698
+ throw new Error("expiration.ttlMs must be greater than 0.");
699
+ }
156
700
 
157
701
  const listeners = new Set<() => void>();
158
702
  let unsubscribe: (() => void) | null = null;
159
- let lastRaw: string | undefined;
703
+ let lastRaw: unknown = undefined;
160
704
  let lastValue: T | undefined;
705
+ let hasLastValue = false;
706
+
707
+ const invalidateParsedCache = () => {
708
+ lastRaw = undefined;
709
+ lastValue = undefined;
710
+ hasLastValue = false;
711
+ };
161
712
 
162
713
  const ensureSubscription = () => {
163
- if (!unsubscribe) {
164
- if (isMemory) {
165
- const listener = (key: string) => {
166
- if (key === "" || key === config.key) {
167
- lastRaw = undefined;
168
- lastValue = undefined;
169
- listeners.forEach((l) => l());
170
- }
171
- };
172
- memoryListeners.add(listener);
173
- unsubscribe = () => memoryListeners.delete(listener);
174
- } else if (config.scope === StorageScope.Disk) {
175
- const listener = () => {
176
- lastRaw = undefined;
177
- lastValue = undefined;
178
- listeners.forEach((l) => l());
179
- };
180
- if (!diskListeners.has(config.key)) {
181
- diskListeners.set(config.key, new Set());
182
- }
183
- diskListeners.get(config.key)!.add(listener);
184
- unsubscribe = () => diskListeners.get(config.key)?.delete(listener);
185
- } else if (config.scope === StorageScope.Secure) {
186
- const listener = () => {
187
- lastRaw = undefined;
188
- lastValue = undefined;
189
- listeners.forEach((l) => l());
190
- };
191
- if (!secureListeners.has(config.key)) {
192
- secureListeners.set(config.key, new Set());
714
+ if (unsubscribe) {
715
+ return;
716
+ }
717
+
718
+ const listener = () => {
719
+ invalidateParsedCache();
720
+ listeners.forEach((callback) => callback());
721
+ };
722
+
723
+ if (isMemory) {
724
+ unsubscribe = addKeyListener(memoryListeners, storageKey, listener);
725
+ return;
726
+ }
727
+
728
+ unsubscribe = addKeyListener(
729
+ getScopedListeners(nonMemoryScope!),
730
+ storageKey,
731
+ listener,
732
+ );
733
+ };
734
+
735
+ const readStoredRaw = (): unknown => {
736
+ if (isMemory) {
737
+ if (memoryExpiration) {
738
+ const expiresAt = memoryExpiration.get(storageKey);
739
+ if (expiresAt !== undefined && expiresAt <= Date.now()) {
740
+ memoryExpiration.delete(storageKey);
741
+ memoryStore.delete(storageKey);
742
+ notifyKeyListeners(memoryListeners, storageKey);
743
+ onExpired?.(storageKey);
744
+ return undefined;
193
745
  }
194
- secureListeners.get(config.key)!.add(listener);
195
- unsubscribe = () => secureListeners.get(config.key)?.delete(listener);
746
+ }
747
+ return memoryStore.get(storageKey) as T | undefined;
748
+ }
749
+
750
+ if (
751
+ nonMemoryScope === StorageScope.Secure &&
752
+ !isBiometric &&
753
+ hasPendingSecureWrite(storageKey)
754
+ ) {
755
+ return readPendingSecureWrite(storageKey);
756
+ }
757
+
758
+ if (readCache) {
759
+ if (hasCachedRawValue(nonMemoryScope!, storageKey)) {
760
+ return readCachedRawValue(nonMemoryScope!, storageKey);
196
761
  }
197
762
  }
763
+
764
+ if (isBiometric) {
765
+ return WebStorage.getSecureBiometric(storageKey);
766
+ }
767
+
768
+ const raw = WebStorage.get(storageKey, config.scope);
769
+ cacheRawValue(nonMemoryScope!, storageKey, raw);
770
+ return raw;
198
771
  };
199
772
 
200
- const get = (): T => {
201
- let raw: string | undefined;
773
+ const writeStoredRaw = (rawValue: string): void => {
774
+ if (isBiometric) {
775
+ WebStorage.setSecureBiometric(storageKey, rawValue);
776
+ return;
777
+ }
778
+
779
+ cacheRawValue(nonMemoryScope!, storageKey, rawValue);
780
+
781
+ if (coalesceSecureWrites) {
782
+ scheduleSecureWrite(storageKey, rawValue);
783
+ return;
784
+ }
785
+
786
+ if (nonMemoryScope === StorageScope.Secure) {
787
+ clearPendingSecureWrite(storageKey);
788
+ }
789
+
790
+ WebStorage.set(storageKey, rawValue, config.scope);
791
+ };
792
+
793
+ const removeStoredRaw = (): void => {
794
+ if (isBiometric) {
795
+ WebStorage.deleteSecureBiometric(storageKey);
796
+ return;
797
+ }
798
+
799
+ cacheRawValue(nonMemoryScope!, storageKey, undefined);
800
+
801
+ if (coalesceSecureWrites) {
802
+ scheduleSecureWrite(storageKey, undefined);
803
+ return;
804
+ }
805
+
806
+ if (nonMemoryScope === StorageScope.Secure) {
807
+ clearPendingSecureWrite(storageKey);
808
+ }
809
+
810
+ WebStorage.remove(storageKey, config.scope);
811
+ };
812
+
813
+ const writeValueWithoutValidation = (value: T): void => {
202
814
  if (isMemory) {
203
- raw = memoryStore.get(config.key);
204
- } else {
205
- raw = WebStorage.get(config.key, config.scope);
815
+ if (memoryExpiration) {
816
+ memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
817
+ }
818
+ memoryStore.set(storageKey, value);
819
+ notifyKeyListeners(memoryListeners, storageKey);
820
+ return;
206
821
  }
207
822
 
208
- if (raw === lastRaw && lastValue !== undefined) {
209
- return lastValue;
823
+ const serialized = serialize(value);
824
+ if (expiration) {
825
+ const envelope: StoredEnvelope = {
826
+ __nitroStorageEnvelope: true,
827
+ expiresAt: Date.now() + expiration.ttlMs,
828
+ payload: serialized,
829
+ };
830
+ writeStoredRaw(JSON.stringify(envelope));
831
+ return;
832
+ }
833
+
834
+ writeStoredRaw(serialized);
835
+ };
836
+
837
+ const resolveInvalidValue = (invalidValue: unknown): T => {
838
+ if (onValidationError) {
839
+ return onValidationError(invalidValue);
840
+ }
841
+
842
+ return config.defaultValue as T;
843
+ };
844
+
845
+ const ensureValidatedValue = (
846
+ candidate: unknown,
847
+ hadStoredValue: boolean,
848
+ ): T => {
849
+ if (!validate || validate(candidate)) {
850
+ return candidate as T;
851
+ }
852
+
853
+ const resolved = resolveInvalidValue(candidate);
854
+ if (validate && !validate(resolved)) {
855
+ return config.defaultValue as T;
856
+ }
857
+ if (hadStoredValue) {
858
+ writeValueWithoutValidation(resolved);
859
+ }
860
+ return resolved;
861
+ };
862
+
863
+ const get = (): T => {
864
+ const raw = readStoredRaw();
865
+
866
+ const canUseCachedValue = !expiration && !memoryExpiration;
867
+ if (canUseCachedValue && raw === lastRaw && hasLastValue) {
868
+ return lastValue as T;
210
869
  }
211
870
 
212
871
  lastRaw = raw;
213
872
 
214
873
  if (raw === undefined) {
215
- lastValue = config.defaultValue as T;
216
- } else {
217
- lastValue = isMemory ? (raw as T) : deserialize(raw);
874
+ lastValue = ensureValidatedValue(config.defaultValue, false);
875
+ hasLastValue = true;
876
+ return lastValue;
877
+ }
878
+
879
+ if (isMemory) {
880
+ lastValue = ensureValidatedValue(raw, true);
881
+ hasLastValue = true;
882
+ return lastValue;
883
+ }
884
+
885
+ let deserializableRaw = raw as string;
886
+
887
+ if (expiration) {
888
+ try {
889
+ const parsed = JSON.parse(raw as string) as unknown;
890
+ if (isStoredEnvelope(parsed)) {
891
+ if (parsed.expiresAt <= Date.now()) {
892
+ removeStoredRaw();
893
+ invalidateParsedCache();
894
+ onExpired?.(storageKey);
895
+ lastValue = ensureValidatedValue(config.defaultValue, false);
896
+ hasLastValue = true;
897
+ return lastValue;
898
+ }
899
+
900
+ deserializableRaw = parsed.payload;
901
+ }
902
+ } catch {
903
+ // Keep backward compatibility with legacy raw values.
904
+ }
218
905
  }
219
906
 
907
+ lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
908
+ hasLastValue = true;
220
909
  return lastValue;
221
910
  };
222
911
 
@@ -227,25 +916,36 @@ export function createStorageItem<T = undefined>(
227
916
  ? (valueOrFn as (prev: T) => T)(currentValue)
228
917
  : valueOrFn;
229
918
 
230
- lastRaw = undefined;
919
+ invalidateParsedCache();
231
920
 
232
- if (isMemory) {
233
- memoryStore.set(config.key, newValue);
234
- notifyMemoryListeners(config.key, newValue);
235
- } else {
236
- WebStorage.set(config.key, serialize(newValue), config.scope);
921
+ if (validate && !validate(newValue)) {
922
+ throw new Error(
923
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
924
+ );
237
925
  }
926
+
927
+ writeValueWithoutValidation(newValue);
238
928
  };
239
929
 
240
930
  const deleteItem = (): void => {
241
- lastRaw = undefined;
931
+ invalidateParsedCache();
242
932
 
243
933
  if (isMemory) {
244
- memoryStore.delete(config.key);
245
- notifyMemoryListeners(config.key, undefined);
246
- } else {
247
- WebStorage.remove(config.key, config.scope);
934
+ if (memoryExpiration) {
935
+ memoryExpiration.delete(storageKey);
936
+ }
937
+ memoryStore.delete(storageKey);
938
+ notifyKeyListeners(memoryListeners, storageKey);
939
+ return;
248
940
  }
941
+
942
+ removeStoredRaw();
943
+ };
944
+
945
+ const hasItem = (): boolean => {
946
+ if (isMemory) return memoryStore.has(storageKey);
947
+ if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
948
+ return WebStorage.has(storageKey, config.scope);
249
949
  };
250
950
 
251
951
  const subscribe = (callback: () => void): (() => void) => {
@@ -260,47 +960,139 @@ export function createStorageItem<T = undefined>(
260
960
  };
261
961
  };
262
962
 
263
- return {
963
+ const storageItem: StorageItemInternal<T> = {
264
964
  get,
265
965
  set,
266
966
  delete: deleteItem,
967
+ has: hasItem,
267
968
  subscribe,
268
969
  serialize,
269
970
  deserialize,
270
971
  _triggerListeners: () => {
271
- lastRaw = undefined;
272
- lastValue = undefined;
273
- listeners.forEach((l) => l());
972
+ invalidateParsedCache();
973
+ listeners.forEach((listener) => listener());
274
974
  },
975
+ _hasValidation: validate !== undefined,
976
+ _hasExpiration: expiration !== undefined,
977
+ _readCacheEnabled: readCache,
978
+ _isBiometric: isBiometric,
979
+ _secureAccessControl: secureAccessControl,
275
980
  scope: config.scope,
276
- key: config.key,
981
+ key: storageKey,
277
982
  };
983
+
984
+ return storageItem as StorageItem<T>;
278
985
  }
279
986
 
280
987
  export function useStorage<T>(
281
- item: StorageItem<T>
988
+ item: StorageItem<T>,
282
989
  ): [T, (value: T | ((prev: T) => T)) => void] {
283
990
  const value = useSyncExternalStore(item.subscribe, item.get, item.get);
284
991
  return [value, item.set];
285
992
  }
286
993
 
994
+ export function useStorageSelector<T, TSelected>(
995
+ item: StorageItem<T>,
996
+ selector: (value: T) => TSelected,
997
+ isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
998
+ ): [TSelected, (value: T | ((prev: T) => T)) => void] {
999
+ const selectedRef = useRef<
1000
+ { hasValue: false } | { hasValue: true; value: TSelected }
1001
+ >({
1002
+ hasValue: false,
1003
+ });
1004
+
1005
+ const getSelectedSnapshot = () => {
1006
+ const nextSelected = selector(item.get());
1007
+ const current = selectedRef.current;
1008
+ if (current.hasValue && isEqual(current.value, nextSelected)) {
1009
+ return current.value;
1010
+ }
1011
+
1012
+ selectedRef.current = { hasValue: true, value: nextSelected };
1013
+ return nextSelected;
1014
+ };
1015
+
1016
+ const selectedValue = useSyncExternalStore(
1017
+ item.subscribe,
1018
+ getSelectedSnapshot,
1019
+ getSelectedSnapshot,
1020
+ );
1021
+ return [selectedValue, item.set];
1022
+ }
1023
+
287
1024
  export function useSetStorage<T>(item: StorageItem<T>) {
288
1025
  return item.set;
289
1026
  }
290
1027
 
1028
+ type BatchReadItem<T> = Pick<
1029
+ StorageItem<T>,
1030
+ "key" | "scope" | "get" | "deserialize"
1031
+ > & {
1032
+ _hasValidation?: boolean;
1033
+ _hasExpiration?: boolean;
1034
+ _readCacheEnabled?: boolean;
1035
+ _isBiometric?: boolean;
1036
+ _secureAccessControl?: AccessControl;
1037
+ };
1038
+ type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
1039
+
1040
+ export type StorageBatchSetItem<T> = {
1041
+ item: StorageItem<T>;
1042
+ value: T;
1043
+ };
1044
+
291
1045
  export function getBatch(
292
- items: StorageItem<any>[],
293
- scope: StorageScope
294
- ): any[] {
1046
+ items: readonly BatchReadItem<unknown>[],
1047
+ scope: StorageScope,
1048
+ ): unknown[] {
1049
+ assertBatchScope(items, scope);
1050
+
295
1051
  if (scope === StorageScope.Memory) {
296
1052
  return items.map((item) => item.get());
297
1053
  }
298
1054
 
299
- const keys = items.map((item) => item.key);
300
- const rawValues = WebStorage.getBatch(keys, scope);
1055
+ const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
1056
+ if (!useRawBatchPath) {
1057
+ return items.map((item) => item.get());
1058
+ }
1059
+ const useBatchCache = items.every((item) => item._readCacheEnabled === true);
1060
+
1061
+ const rawValues = new Array<string | undefined>(items.length);
1062
+ const keysToFetch: string[] = [];
1063
+ const keyIndexes: number[] = [];
301
1064
 
302
- return items.map((item, idx) => {
303
- const raw = rawValues[idx];
1065
+ items.forEach((item, index) => {
1066
+ if (scope === StorageScope.Secure) {
1067
+ if (hasPendingSecureWrite(item.key)) {
1068
+ rawValues[index] = readPendingSecureWrite(item.key);
1069
+ return;
1070
+ }
1071
+ }
1072
+
1073
+ if (useBatchCache) {
1074
+ if (hasCachedRawValue(scope, item.key)) {
1075
+ rawValues[index] = readCachedRawValue(scope, item.key);
1076
+ return;
1077
+ }
1078
+ }
1079
+
1080
+ keysToFetch.push(item.key);
1081
+ keyIndexes.push(index);
1082
+ });
1083
+
1084
+ if (keysToFetch.length > 0) {
1085
+ const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
1086
+ fetchedValues.forEach((value, index) => {
1087
+ const key = keysToFetch[index];
1088
+ const targetIndex = keyIndexes[index];
1089
+ rawValues[targetIndex] = value;
1090
+ cacheRawValue(scope, key, value);
1091
+ });
1092
+ }
1093
+
1094
+ return items.map((item, index) => {
1095
+ const raw = rawValues[index];
304
1096
  if (raw === undefined) {
305
1097
  return item.get();
306
1098
  }
@@ -308,34 +1100,187 @@ export function getBatch(
308
1100
  });
309
1101
  }
310
1102
 
311
- export function setBatch(
312
- items: { item: StorageItem<any>; value: any }[],
313
- scope: StorageScope
1103
+ export function setBatch<T>(
1104
+ items: readonly StorageBatchSetItem<T>[],
1105
+ scope: StorageScope,
314
1106
  ): void {
1107
+ assertBatchScope(
1108
+ items.map((batchEntry) => batchEntry.item),
1109
+ scope,
1110
+ );
1111
+
315
1112
  if (scope === StorageScope.Memory) {
316
1113
  items.forEach(({ item, value }) => item.set(value));
317
1114
  return;
318
1115
  }
319
1116
 
320
- const keys = items.map((i) => i.item.key);
321
- const values = items.map((i) => i.item.serialize(i.value));
322
- WebStorage.setBatch(keys, values, scope);
1117
+ const useRawBatchPath = items.every(({ item }) =>
1118
+ canUseRawBatchPath(asInternal(item)),
1119
+ );
1120
+ if (!useRawBatchPath) {
1121
+ items.forEach(({ item, value }) => item.set(value));
1122
+ return;
1123
+ }
323
1124
 
324
- items.forEach(({ item }) => {
325
- item._triggerListeners();
326
- });
1125
+ const keys = items.map((entry) => entry.item.key);
1126
+ const values = items.map((entry) => entry.item.serialize(entry.value));
1127
+ if (scope === StorageScope.Secure) {
1128
+ flushSecureWrites();
1129
+ }
1130
+ WebStorage.setBatch(keys, values, scope);
1131
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
327
1132
  }
328
1133
 
329
1134
  export function removeBatch(
330
- items: StorageItem<any>[],
331
- scope: StorageScope
1135
+ items: readonly BatchRemoveItem[],
1136
+ scope: StorageScope,
332
1137
  ): void {
1138
+ assertBatchScope(items, scope);
1139
+
333
1140
  if (scope === StorageScope.Memory) {
334
1141
  items.forEach((item) => item.delete());
335
1142
  return;
336
1143
  }
337
1144
 
338
1145
  const keys = items.map((item) => item.key);
1146
+ if (scope === StorageScope.Secure) {
1147
+ flushSecureWrites();
1148
+ }
339
1149
  WebStorage.removeBatch(keys, scope);
340
- items.forEach((item) => item.delete());
1150
+ keys.forEach((key) => cacheRawValue(scope, key, undefined));
1151
+ }
1152
+
1153
+ export function registerMigration(version: number, migration: Migration): void {
1154
+ if (!Number.isInteger(version) || version <= 0) {
1155
+ throw new Error("Migration version must be a positive integer.");
1156
+ }
1157
+
1158
+ if (registeredMigrations.has(version)) {
1159
+ throw new Error(`Migration version ${version} is already registered.`);
1160
+ }
1161
+
1162
+ registeredMigrations.set(version, migration);
1163
+ }
1164
+
1165
+ export function migrateToLatest(
1166
+ scope: StorageScope = StorageScope.Disk,
1167
+ ): number {
1168
+ assertValidScope(scope);
1169
+ const currentVersion = readMigrationVersion(scope);
1170
+ const versions = Array.from(registeredMigrations.keys())
1171
+ .filter((version) => version > currentVersion)
1172
+ .sort((a, b) => a - b);
1173
+
1174
+ let appliedVersion = currentVersion;
1175
+ const context: MigrationContext = {
1176
+ scope,
1177
+ getRaw: (key) => getRawValue(key, scope),
1178
+ setRaw: (key, value) => setRawValue(key, value, scope),
1179
+ removeRaw: (key) => removeRawValue(key, scope),
1180
+ };
1181
+
1182
+ versions.forEach((version) => {
1183
+ const migration = registeredMigrations.get(version);
1184
+ if (!migration) {
1185
+ return;
1186
+ }
1187
+ migration(context);
1188
+ writeMigrationVersion(scope, version);
1189
+ appliedVersion = version;
1190
+ });
1191
+
1192
+ return appliedVersion;
1193
+ }
1194
+
1195
+ export function runTransaction<T>(
1196
+ scope: StorageScope,
1197
+ transaction: (context: TransactionContext) => T,
1198
+ ): T {
1199
+ assertValidScope(scope);
1200
+ if (scope === StorageScope.Secure) {
1201
+ flushSecureWrites();
1202
+ }
1203
+
1204
+ const rollback = new Map<string, string | undefined>();
1205
+
1206
+ const rememberRollback = (key: string) => {
1207
+ if (rollback.has(key)) {
1208
+ return;
1209
+ }
1210
+ rollback.set(key, getRawValue(key, scope));
1211
+ };
1212
+
1213
+ const tx: TransactionContext = {
1214
+ scope,
1215
+ getRaw: (key) => getRawValue(key, scope),
1216
+ setRaw: (key, value) => {
1217
+ rememberRollback(key);
1218
+ setRawValue(key, value, scope);
1219
+ },
1220
+ removeRaw: (key) => {
1221
+ rememberRollback(key);
1222
+ removeRawValue(key, scope);
1223
+ },
1224
+ getItem: (item) => {
1225
+ assertBatchScope([item], scope);
1226
+ return item.get();
1227
+ },
1228
+ setItem: (item, value) => {
1229
+ assertBatchScope([item], scope);
1230
+ rememberRollback(item.key);
1231
+ item.set(value);
1232
+ },
1233
+ removeItem: (item) => {
1234
+ assertBatchScope([item], scope);
1235
+ rememberRollback(item.key);
1236
+ item.delete();
1237
+ },
1238
+ };
1239
+
1240
+ try {
1241
+ return transaction(tx);
1242
+ } catch (error) {
1243
+ Array.from(rollback.entries())
1244
+ .reverse()
1245
+ .forEach(([key, previousValue]) => {
1246
+ if (previousValue === undefined) {
1247
+ removeRawValue(key, scope);
1248
+ } else {
1249
+ setRawValue(key, previousValue, scope);
1250
+ }
1251
+ });
1252
+ throw error;
1253
+ }
1254
+ }
1255
+
1256
+ export type SecureAuthStorageConfig<K extends string = string> = Record<
1257
+ K,
1258
+ {
1259
+ ttlMs?: number;
1260
+ biometric?: boolean;
1261
+ accessControl?: AccessControl;
1262
+ }
1263
+ >;
1264
+
1265
+ export function createSecureAuthStorage<K extends string>(
1266
+ config: SecureAuthStorageConfig<K>,
1267
+ options?: { namespace?: string },
1268
+ ): Record<K, StorageItem<string>> {
1269
+ const ns = options?.namespace ?? "auth";
1270
+ const result = {} as Record<K, StorageItem<string>>;
1271
+
1272
+ for (const key of Object.keys(config) as K[]) {
1273
+ const itemConfig = config[key];
1274
+ result[key] = createStorageItem<string>({
1275
+ key,
1276
+ scope: StorageScope.Secure,
1277
+ defaultValue: "",
1278
+ namespace: ns,
1279
+ biometric: itemConfig.biometric,
1280
+ accessControl: itemConfig.accessControl,
1281
+ expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
1282
+ });
1283
+ }
1284
+
1285
+ return result;
341
1286
  }