react-native-nitro-storage 0.3.1 → 0.4.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 (44) hide show
  1. package/README.md +334 -34
  2. package/android/CMakeLists.txt +2 -0
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +26 -2
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +4 -0
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +90 -18
  6. package/cpp/bindings/HybridStorage.cpp +214 -23
  7. package/cpp/bindings/HybridStorage.hpp +31 -3
  8. package/cpp/core/NativeStorageAdapter.hpp +4 -0
  9. package/ios/IOSStorageAdapterCpp.hpp +17 -0
  10. package/ios/IOSStorageAdapterCpp.mm +140 -10
  11. package/lib/commonjs/index.js +555 -288
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +750 -309
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/internal.js +25 -0
  16. package/lib/commonjs/internal.js.map +1 -1
  17. package/lib/commonjs/storage-hooks.js +36 -0
  18. package/lib/commonjs/storage-hooks.js.map +1 -0
  19. package/lib/module/index.js +537 -287
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +732 -308
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/module/internal.js +24 -0
  24. package/lib/module/internal.js.map +1 -1
  25. package/lib/module/storage-hooks.js +30 -0
  26. package/lib/module/storage-hooks.js.map +1 -0
  27. package/lib/typescript/Storage.nitro.d.ts +4 -0
  28. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  29. package/lib/typescript/index.d.ts +41 -4
  30. package/lib/typescript/index.d.ts.map +1 -1
  31. package/lib/typescript/index.web.d.ts +45 -4
  32. package/lib/typescript/index.web.d.ts.map +1 -1
  33. package/lib/typescript/internal.d.ts +1 -0
  34. package/lib/typescript/internal.d.ts.map +1 -1
  35. package/lib/typescript/storage-hooks.d.ts +10 -0
  36. package/lib/typescript/storage-hooks.d.ts.map +1 -0
  37. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +4 -0
  39. package/package.json +5 -3
  40. package/src/Storage.nitro.ts +4 -0
  41. package/src/index.ts +704 -324
  42. package/src/index.web.ts +929 -346
  43. package/src/internal.ts +28 -0
  44. package/src/storage-hooks.ts +48 -0
package/src/index.web.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { useRef, useSyncExternalStore } from "react";
2
1
  import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
3
2
  import {
4
3
  MIGRATION_VERSION_KEY,
@@ -8,6 +7,7 @@ import {
8
7
  assertValidScope,
9
8
  serializeWithPrimitiveFastPath,
10
9
  deserializeWithPrimitiveFastPath,
10
+ toVersionToken,
11
11
  prefixKey,
12
12
  isNamespaced,
13
13
  } from "./internal";
@@ -19,6 +19,31 @@ export type Validator<T> = (value: unknown) => value is T;
19
19
  export type ExpirationConfig = {
20
20
  ttlMs: number;
21
21
  };
22
+ export type StorageVersion = string;
23
+ export type VersionedValue<T> = {
24
+ value: T;
25
+ version: StorageVersion;
26
+ };
27
+ export type StorageMetricsEvent = {
28
+ operation: string;
29
+ scope: StorageScope;
30
+ durationMs: number;
31
+ keysCount: number;
32
+ };
33
+ export type StorageMetricsObserver = (event: StorageMetricsEvent) => void;
34
+ export type StorageMetricSummary = {
35
+ count: number;
36
+ totalDurationMs: number;
37
+ avgDurationMs: number;
38
+ maxDurationMs: number;
39
+ };
40
+ export type WebSecureStorageBackend = {
41
+ getItem: (key: string) => string | null;
42
+ setItem: (key: string, value: string) => void;
43
+ removeItem: (key: string) => void;
44
+ clear: () => void;
45
+ getAllKeys: () => string[];
46
+ };
22
47
 
23
48
  export type MigrationContext = {
24
49
  scope: StorageScope;
@@ -52,11 +77,25 @@ type RawBatchPathItem = {
52
77
  _secureAccessControl?: AccessControl;
53
78
  };
54
79
 
55
- function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
56
- return item as unknown as StorageItemInternal<any>;
80
+ function asInternal<T>(item: StorageItem<T>): StorageItemInternal<T> {
81
+ return item as StorageItemInternal<T>;
82
+ }
83
+
84
+ function isUpdater<T>(
85
+ valueOrFn: T | ((prev: T) => T),
86
+ ): valueOrFn is (prev: T) => T {
87
+ return typeof valueOrFn === "function";
88
+ }
89
+
90
+ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
91
+ return Object.keys(record) as K[];
57
92
  }
58
93
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
59
- type PendingSecureWrite = { key: string; value: string | undefined };
94
+ type PendingSecureWrite = {
95
+ key: string;
96
+ value: string | undefined;
97
+ accessControl?: AccessControl;
98
+ };
60
99
  type BrowserStorageLike = {
61
100
  setItem: (key: string, value: string) => void;
62
101
  getItem: (key: string) => string | null;
@@ -84,17 +123,21 @@ export interface Storage {
84
123
  clear(scope: number): void;
85
124
  has(key: string, scope: number): boolean;
86
125
  getAllKeys(scope: number): string[];
126
+ getKeysByPrefix(prefix: string, scope: number): string[];
87
127
  size(scope: number): number;
88
128
  setBatch(keys: string[], values: string[], scope: number): void;
89
129
  getBatch(keys: string[], scope: number): (string | undefined)[];
90
130
  removeBatch(keys: string[], scope: number): void;
131
+ removeByPrefix(prefix: string, scope: number): void;
91
132
  addOnChange(
92
133
  scope: number,
93
134
  callback: (key: string, value: string | undefined) => void,
94
135
  ): () => void;
95
136
  setSecureAccessControl(level: number): void;
137
+ setSecureWritesAsync(enabled: boolean): void;
96
138
  setKeychainAccessGroup(group: string): void;
97
139
  setSecureBiometric(key: string, value: string): void;
140
+ setSecureBiometricWithLevel(key: string, value: string, level: number): void;
98
141
  getSecureBiometric(key: string): string | undefined;
99
142
  deleteSecureBiometric(key: string): void;
100
143
  hasSecureBiometric(key: string): boolean;
@@ -113,18 +156,100 @@ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
113
156
  [StorageScope.Secure, new Map()],
114
157
  ],
115
158
  );
159
+ const webScopeKeyIndex = new Map<NonMemoryScope, Set<string>>([
160
+ [StorageScope.Disk, new Set()],
161
+ [StorageScope.Secure, new Set()],
162
+ ]);
163
+ const hydratedWebScopeKeyIndex = new Set<NonMemoryScope>();
116
164
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
117
165
  let secureFlushScheduled = false;
118
166
  const SECURE_WEB_PREFIX = "__secure_";
119
167
  const BIOMETRIC_WEB_PREFIX = "__bio_";
120
168
  let hasWarnedAboutWebBiometricFallback = false;
169
+ let hasWebStorageEventSubscription = false;
170
+ let metricsObserver: StorageMetricsObserver | undefined;
171
+ const metricsCounters = new Map<
172
+ string,
173
+ { count: number; totalDurationMs: number; maxDurationMs: number }
174
+ >();
175
+
176
+ function recordMetric(
177
+ operation: string,
178
+ scope: StorageScope,
179
+ durationMs: number,
180
+ keysCount = 1,
181
+ ): void {
182
+ const existing = metricsCounters.get(operation);
183
+ if (!existing) {
184
+ metricsCounters.set(operation, {
185
+ count: 1,
186
+ totalDurationMs: durationMs,
187
+ maxDurationMs: durationMs,
188
+ });
189
+ } else {
190
+ existing.count += 1;
191
+ existing.totalDurationMs += durationMs;
192
+ existing.maxDurationMs = Math.max(existing.maxDurationMs, durationMs);
193
+ }
194
+ metricsObserver?.({ operation, scope, durationMs, keysCount });
195
+ }
196
+
197
+ function measureOperation<T>(
198
+ operation: string,
199
+ scope: StorageScope,
200
+ fn: () => T,
201
+ keysCount = 1,
202
+ ): T {
203
+ const start = Date.now();
204
+ try {
205
+ return fn();
206
+ } finally {
207
+ recordMetric(operation, scope, Date.now() - start, keysCount);
208
+ }
209
+ }
210
+
211
+ function createLocalStorageWebSecureBackend(): WebSecureStorageBackend {
212
+ return {
213
+ getItem: (key) => globalThis.localStorage?.getItem(key) ?? null,
214
+ setItem: (key, value) => globalThis.localStorage?.setItem(key, value),
215
+ removeItem: (key) => globalThis.localStorage?.removeItem(key),
216
+ clear: () => globalThis.localStorage?.clear(),
217
+ getAllKeys: () => {
218
+ const storage = globalThis.localStorage;
219
+ if (!storage) return [];
220
+ const keys: string[] = [];
221
+ for (let index = 0; index < storage.length; index += 1) {
222
+ const key = storage.key(index);
223
+ if (key) {
224
+ keys.push(key);
225
+ }
226
+ }
227
+ return keys;
228
+ },
229
+ };
230
+ }
231
+
232
+ let webSecureStorageBackend: WebSecureStorageBackend | undefined =
233
+ createLocalStorageWebSecureBackend();
121
234
 
122
235
  function getBrowserStorage(scope: number): BrowserStorageLike | undefined {
123
236
  if (scope === StorageScope.Disk) {
124
237
  return globalThis.localStorage;
125
238
  }
126
239
  if (scope === StorageScope.Secure) {
127
- return globalThis.localStorage;
240
+ if (!webSecureStorageBackend) {
241
+ return undefined;
242
+ }
243
+ return {
244
+ setItem: (key, value) => webSecureStorageBackend?.setItem(key, value),
245
+ getItem: (key) => webSecureStorageBackend?.getItem(key) ?? null,
246
+ removeItem: (key) => webSecureStorageBackend?.removeItem(key),
247
+ clear: () => webSecureStorageBackend?.clear(),
248
+ key: (index) => webSecureStorageBackend?.getAllKeys()[index] ?? null,
249
+ get length() {
250
+ return webSecureStorageBackend?.getAllKeys().length ?? 0;
251
+ },
252
+ };
128
253
  }
129
254
  return undefined;
130
255
  }
@@ -145,6 +270,119 @@ function fromBiometricStorageKey(key: string): string {
145
270
  return key.slice(BIOMETRIC_WEB_PREFIX.length);
146
271
  }
147
272
 
273
+ function getWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
274
+ return webScopeKeyIndex.get(scope)!;
275
+ }
276
+
277
+ function hydrateWebScopeKeyIndex(scope: NonMemoryScope): void {
278
+ if (hydratedWebScopeKeyIndex.has(scope)) {
279
+ return;
280
+ }
281
+
282
+ const storage = getBrowserStorage(scope);
283
+ const keyIndex = getWebScopeKeyIndex(scope);
284
+ keyIndex.clear();
285
+ if (storage) {
286
+ for (let index = 0; index < storage.length; index += 1) {
287
+ const key = storage.key(index);
288
+ if (!key) {
289
+ continue;
290
+ }
291
+ if (scope === StorageScope.Disk) {
292
+ if (
293
+ !key.startsWith(SECURE_WEB_PREFIX) &&
294
+ !key.startsWith(BIOMETRIC_WEB_PREFIX)
295
+ ) {
296
+ keyIndex.add(key);
297
+ }
298
+ continue;
299
+ }
300
+
301
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
302
+ keyIndex.add(fromSecureStorageKey(key));
303
+ continue;
304
+ }
305
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
306
+ keyIndex.add(fromBiometricStorageKey(key));
307
+ }
308
+ }
309
+ }
310
+ hydratedWebScopeKeyIndex.add(scope);
311
+ }
312
+
313
+ function ensureWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
314
+ hydrateWebScopeKeyIndex(scope);
315
+ return getWebScopeKeyIndex(scope);
316
+ }
317
+
318
+ function handleWebStorageEvent(event: StorageEvent): void {
319
+ const key = event.key;
320
+ if (key === null) {
321
+ clearScopeRawCache(StorageScope.Disk);
322
+ clearScopeRawCache(StorageScope.Secure);
323
+ ensureWebScopeKeyIndex(StorageScope.Disk).clear();
324
+ ensureWebScopeKeyIndex(StorageScope.Secure).clear();
325
+ notifyAllListeners(getScopedListeners(StorageScope.Disk));
326
+ notifyAllListeners(getScopedListeners(StorageScope.Secure));
327
+ return;
328
+ }
329
+
330
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
331
+ const plainKey = fromSecureStorageKey(key);
332
+ if (event.newValue === null) {
333
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
334
+ cacheRawValue(StorageScope.Secure, plainKey, undefined);
335
+ } else {
336
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
337
+ cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
338
+ }
339
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
340
+ return;
341
+ }
342
+
343
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
344
+ const plainKey = fromBiometricStorageKey(key);
345
+ if (event.newValue === null) {
346
+ if (
347
+ getBrowserStorage(StorageScope.Secure)?.getItem(
348
+ toSecureStorageKey(plainKey),
349
+ ) === null
350
+ ) {
351
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
352
+ }
353
+ cacheRawValue(StorageScope.Secure, plainKey, undefined);
354
+ } else {
355
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
356
+ cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
357
+ }
358
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
359
+ return;
360
+ }
361
+
362
+ if (event.newValue === null) {
363
+ ensureWebScopeKeyIndex(StorageScope.Disk).delete(key);
364
+ cacheRawValue(StorageScope.Disk, key, undefined);
365
+ } else {
366
+ ensureWebScopeKeyIndex(StorageScope.Disk).add(key);
367
+ cacheRawValue(StorageScope.Disk, key, event.newValue);
368
+ }
369
+ notifyKeyListeners(getScopedListeners(StorageScope.Disk), key);
370
+ }
371
+
372
+ function ensureWebStorageEventSubscription(): void {
373
+ if (hasWebStorageEventSubscription) {
374
+ return;
375
+ }
376
+ if (
377
+ typeof window === "undefined" ||
378
+ typeof window.addEventListener !== "function"
379
+ ) {
380
+ return;
381
+ }
382
+ window.addEventListener("storage", handleWebStorageEvent);
383
+ hasWebStorageEventSubscription = true;
384
+ }
385
+
148
386
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
149
387
  return webScopeListeners.get(scope)!;
150
388
  }
@@ -234,29 +472,46 @@ function flushSecureWrites(): void {
234
472
  const writes = Array.from(pendingSecureWrites.values());
235
473
  pendingSecureWrites.clear();
236
474
 
237
- const keysToSet: string[] = [];
238
- const valuesToSet: string[] = [];
475
+ const groupedSetWrites = new Map<
476
+ AccessControl,
477
+ { keys: string[]; values: string[] }
478
+ >();
239
479
  const keysToRemove: string[] = [];
240
480
 
241
- writes.forEach(({ key, value }) => {
481
+ writes.forEach(({ key, value, accessControl }) => {
242
482
  if (value === undefined) {
243
483
  keysToRemove.push(key);
244
484
  } else {
245
- keysToSet.push(key);
246
- valuesToSet.push(value);
485
+ const resolvedAccessControl = accessControl ?? AccessControl.WhenUnlocked;
486
+ const existingGroup = groupedSetWrites.get(resolvedAccessControl);
487
+ const group = existingGroup ?? { keys: [], values: [] };
488
+ group.keys.push(key);
489
+ group.values.push(value);
490
+ if (!existingGroup) {
491
+ groupedSetWrites.set(resolvedAccessControl, group);
492
+ }
247
493
  }
248
494
  });
249
495
 
250
- if (keysToSet.length > 0) {
251
- WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
252
- }
496
+ groupedSetWrites.forEach((group, accessControl) => {
497
+ WebStorage.setSecureAccessControl(accessControl);
498
+ WebStorage.setBatch(group.keys, group.values, StorageScope.Secure);
499
+ });
253
500
  if (keysToRemove.length > 0) {
254
501
  WebStorage.removeBatch(keysToRemove, StorageScope.Secure);
255
502
  }
256
503
  }
257
504
 
258
- function scheduleSecureWrite(key: string, value: string | undefined): void {
259
- pendingSecureWrites.set(key, { key, value });
505
+ function scheduleSecureWrite(
506
+ key: string,
507
+ value: string | undefined,
508
+ accessControl?: AccessControl,
509
+ ): void {
510
+ const pendingWrite: PendingSecureWrite = { key, value };
511
+ if (accessControl !== undefined) {
512
+ pendingWrite.accessControl = accessControl;
513
+ }
514
+ pendingSecureWrites.set(key, pendingWrite);
260
515
  if (secureFlushScheduled) {
261
516
  return;
262
517
  }
@@ -277,6 +532,7 @@ const WebStorage: Storage = {
277
532
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
278
533
  storage.setItem(storageKey, value);
279
534
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
535
+ ensureWebScopeKeyIndex(scope).add(key);
280
536
  notifyKeyListeners(getScopedListeners(scope), key);
281
537
  }
282
538
  },
@@ -298,6 +554,7 @@ const WebStorage: Storage = {
298
554
  storage.removeItem(key);
299
555
  }
300
556
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
557
+ ensureWebScopeKeyIndex(scope).delete(key);
301
558
  notifyKeyListeners(getScopedListeners(scope), key);
302
559
  }
303
560
  },
@@ -335,6 +592,7 @@ const WebStorage: Storage = {
335
592
  storage.clear();
336
593
  }
337
594
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
595
+ ensureWebScopeKeyIndex(scope).clear();
338
596
  notifyAllListeners(getScopedListeners(scope));
339
597
  }
340
598
  },
@@ -345,11 +603,17 @@ const WebStorage: Storage = {
345
603
  }
346
604
 
347
605
  keys.forEach((key, index) => {
606
+ const value = values[index];
607
+ if (value === undefined) {
608
+ return;
609
+ }
348
610
  const storageKey =
349
611
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
350
- storage.setItem(storageKey, values[index]);
612
+ storage.setItem(storageKey, value);
351
613
  });
352
614
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
615
+ const keyIndex = ensureWebScopeKeyIndex(scope);
616
+ keys.forEach((key) => keyIndex.add(key));
353
617
  const listeners = getScopedListeners(scope);
354
618
  keys.forEach((key) => notifyKeyListeners(listeners, key));
355
619
  }
@@ -363,9 +627,41 @@ const WebStorage: Storage = {
363
627
  });
364
628
  },
365
629
  removeBatch: (keys: string[], scope: number) => {
366
- keys.forEach((key) => {
367
- WebStorage.remove(key, scope);
368
- });
630
+ const storage = getBrowserStorage(scope);
631
+ if (!storage) {
632
+ return;
633
+ }
634
+
635
+ if (scope === StorageScope.Secure) {
636
+ keys.forEach((key) => {
637
+ storage.removeItem(toSecureStorageKey(key));
638
+ storage.removeItem(toBiometricStorageKey(key));
639
+ });
640
+ } else {
641
+ keys.forEach((key) => {
642
+ storage.removeItem(key);
643
+ });
644
+ }
645
+
646
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
647
+ const keyIndex = ensureWebScopeKeyIndex(scope);
648
+ keys.forEach((key) => keyIndex.delete(key));
649
+ const listeners = getScopedListeners(scope);
650
+ keys.forEach((key) => notifyKeyListeners(listeners, key));
651
+ }
652
+ },
653
+ removeByPrefix: (prefix: string, scope: number) => {
654
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
655
+ return;
656
+ }
657
+
658
+ const keyIndex = ensureWebScopeKeyIndex(scope);
659
+ const keys = Array.from(keyIndex).filter((key) => key.startsWith(prefix));
660
+ if (keys.length === 0) {
661
+ return;
662
+ }
663
+
664
+ WebStorage.removeBatch(keys, scope);
369
665
  },
370
666
  addOnChange: (
371
667
  _scope: number,
@@ -384,38 +680,36 @@ const WebStorage: Storage = {
384
680
  return storage?.getItem(key) !== null;
385
681
  },
386
682
  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);
683
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
684
+ return [];
685
+ }
686
+ return Array.from(ensureWebScopeKeyIndex(scope));
687
+ },
688
+ getKeysByPrefix: (prefix: string, scope: number) => {
689
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
690
+ return [];
410
691
  }
411
- return Array.from(keys);
692
+ return Array.from(ensureWebScopeKeyIndex(scope)).filter((key) =>
693
+ key.startsWith(prefix),
694
+ );
412
695
  },
413
696
  size: (scope: number) => {
414
- return WebStorage.getAllKeys(scope).length;
697
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
698
+ return ensureWebScopeKeyIndex(scope).size;
699
+ }
700
+ return 0;
415
701
  },
416
702
  setSecureAccessControl: () => {},
703
+ setSecureWritesAsync: (_enabled: boolean) => {},
417
704
  setKeychainAccessGroup: () => {},
418
705
  setSecureBiometric: (key: string, value: string) => {
706
+ WebStorage.setSecureBiometricWithLevel(
707
+ key,
708
+ value,
709
+ BiometricLevel.BiometryOnly,
710
+ );
711
+ },
712
+ setSecureBiometricWithLevel: (key: string, value: string, _level: number) => {
419
713
  if (
420
714
  typeof __DEV__ !== "undefined" &&
421
715
  __DEV__ &&
@@ -426,25 +720,37 @@ const WebStorage: Storage = {
426
720
  "[NitroStorage] Biometric storage is not supported on web. Using localStorage.",
427
721
  );
428
722
  }
429
- globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
723
+ getBrowserStorage(StorageScope.Secure)?.setItem(
724
+ toBiometricStorageKey(key),
725
+ value,
726
+ );
727
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
430
728
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
431
729
  },
432
730
  getSecureBiometric: (key: string) => {
433
731
  return (
434
- globalThis.localStorage?.getItem(toBiometricStorageKey(key)) ?? undefined
732
+ getBrowserStorage(StorageScope.Secure)?.getItem(
733
+ toBiometricStorageKey(key),
734
+ ) ?? undefined
435
735
  );
436
736
  },
437
737
  deleteSecureBiometric: (key: string) => {
438
- globalThis.localStorage?.removeItem(toBiometricStorageKey(key));
738
+ const storage = getBrowserStorage(StorageScope.Secure);
739
+ storage?.removeItem(toBiometricStorageKey(key));
740
+ if (storage?.getItem(toSecureStorageKey(key)) === null) {
741
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(key);
742
+ }
439
743
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
440
744
  },
441
745
  hasSecureBiometric: (key: string) => {
442
746
  return (
443
- globalThis.localStorage?.getItem(toBiometricStorageKey(key)) !== null
747
+ getBrowserStorage(StorageScope.Secure)?.getItem(
748
+ toBiometricStorageKey(key),
749
+ ) !== null
444
750
  );
445
751
  },
446
752
  clearSecureBiometric: () => {
447
- const storage = globalThis.localStorage;
753
+ const storage = getBrowserStorage(StorageScope.Secure);
448
754
  if (!storage) return;
449
755
  const keysToNotify: string[] = [];
450
756
  const toRemove: string[] = [];
@@ -456,6 +762,12 @@ const WebStorage: Storage = {
456
762
  }
457
763
  }
458
764
  toRemove.forEach((k) => storage.removeItem(k));
765
+ const keyIndex = ensureWebScopeKeyIndex(StorageScope.Secure);
766
+ keysToNotify.forEach((key) => {
767
+ if (storage.getItem(toSecureStorageKey(key)) === null) {
768
+ keyIndex.delete(key);
769
+ }
770
+ });
459
771
  const listeners = getScopedListeners(StorageScope.Secure);
460
772
  keysToNotify.forEach((key) => notifyKeyListeners(listeners, key));
461
773
  },
@@ -525,90 +837,190 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
525
837
 
526
838
  export const storage = {
527
839
  clear: (scope: StorageScope) => {
528
- if (scope === StorageScope.Memory) {
529
- memoryStore.clear();
530
- notifyAllListeners(memoryListeners);
531
- return;
532
- }
840
+ measureOperation("storage:clear", scope, () => {
841
+ if (scope === StorageScope.Memory) {
842
+ memoryStore.clear();
843
+ notifyAllListeners(memoryListeners);
844
+ return;
845
+ }
533
846
 
534
- if (scope === StorageScope.Secure) {
535
- flushSecureWrites();
536
- pendingSecureWrites.clear();
537
- }
847
+ if (scope === StorageScope.Secure) {
848
+ flushSecureWrites();
849
+ pendingSecureWrites.clear();
850
+ }
538
851
 
539
- clearScopeRawCache(scope);
540
- WebStorage.clear(scope);
541
- if (scope === StorageScope.Secure) {
542
- WebStorage.clearSecureBiometric();
543
- }
852
+ clearScopeRawCache(scope);
853
+ WebStorage.clear(scope);
854
+ });
544
855
  },
545
856
  clearAll: () => {
546
- storage.clear(StorageScope.Memory);
547
- storage.clear(StorageScope.Disk);
548
- storage.clear(StorageScope.Secure);
857
+ measureOperation(
858
+ "storage:clearAll",
859
+ StorageScope.Memory,
860
+ () => {
861
+ storage.clear(StorageScope.Memory);
862
+ storage.clear(StorageScope.Disk);
863
+ storage.clear(StorageScope.Secure);
864
+ },
865
+ 3,
866
+ );
549
867
  },
550
868
  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);
869
+ measureOperation("storage:clearNamespace", scope, () => {
870
+ assertValidScope(scope);
871
+ if (scope === StorageScope.Memory) {
872
+ for (const key of memoryStore.keys()) {
873
+ if (isNamespaced(key, namespace)) {
874
+ memoryStore.delete(key);
875
+ }
556
876
  }
877
+ notifyAllListeners(memoryListeners);
878
+ return;
557
879
  }
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));
880
+
881
+ const keyPrefix = prefixKey(namespace, "");
569
882
  if (scope === StorageScope.Secure) {
570
- namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
883
+ flushSecureWrites();
571
884
  }
572
- }
885
+ clearScopeRawCache(scope);
886
+ WebStorage.removeByPrefix(keyPrefix, scope);
887
+ });
573
888
  },
574
889
  clearBiometric: () => {
575
- WebStorage.clearSecureBiometric();
890
+ measureOperation("storage:clearBiometric", StorageScope.Secure, () => {
891
+ WebStorage.clearSecureBiometric();
892
+ });
576
893
  },
577
894
  has: (key: string, scope: StorageScope): boolean => {
578
- assertValidScope(scope);
579
- if (scope === StorageScope.Memory) return memoryStore.has(key);
580
- return WebStorage.has(key, scope);
895
+ return measureOperation("storage:has", scope, () => {
896
+ assertValidScope(scope);
897
+ if (scope === StorageScope.Memory) return memoryStore.has(key);
898
+ return WebStorage.has(key, scope);
899
+ });
581
900
  },
582
901
  getAllKeys: (scope: StorageScope): string[] => {
583
- assertValidScope(scope);
584
- if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
585
- return WebStorage.getAllKeys(scope);
902
+ return measureOperation("storage:getAllKeys", scope, () => {
903
+ assertValidScope(scope);
904
+ if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
905
+ return WebStorage.getAllKeys(scope);
906
+ });
907
+ },
908
+ getKeysByPrefix: (prefix: string, scope: StorageScope): string[] => {
909
+ return measureOperation("storage:getKeysByPrefix", scope, () => {
910
+ assertValidScope(scope);
911
+ if (scope === StorageScope.Memory) {
912
+ return Array.from(memoryStore.keys()).filter((key) =>
913
+ key.startsWith(prefix),
914
+ );
915
+ }
916
+ return WebStorage.getKeysByPrefix(prefix, scope);
917
+ });
918
+ },
919
+ getByPrefix: (
920
+ prefix: string,
921
+ scope: StorageScope,
922
+ ): Record<string, string> => {
923
+ return measureOperation("storage:getByPrefix", scope, () => {
924
+ const result: Record<string, string> = {};
925
+ const keys = storage.getKeysByPrefix(prefix, scope);
926
+ if (keys.length === 0) {
927
+ return result;
928
+ }
929
+
930
+ if (scope === StorageScope.Memory) {
931
+ keys.forEach((key) => {
932
+ const value = memoryStore.get(key);
933
+ if (typeof value === "string") {
934
+ result[key] = value;
935
+ }
936
+ });
937
+ return result;
938
+ }
939
+
940
+ const values = WebStorage.getBatch(keys, scope);
941
+ keys.forEach((key, index) => {
942
+ const value = values[index];
943
+ if (value !== undefined) {
944
+ result[key] = value;
945
+ }
946
+ });
947
+ return result;
948
+ });
586
949
  },
587
950
  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;
951
+ return measureOperation("storage:getAll", scope, () => {
952
+ assertValidScope(scope);
953
+ const result: Record<string, string> = {};
954
+ if (scope === StorageScope.Memory) {
955
+ memoryStore.forEach((value, key) => {
956
+ if (typeof value === "string") result[key] = value;
957
+ });
958
+ return result;
959
+ }
960
+ const keys = WebStorage.getAllKeys(scope);
961
+ keys.forEach((key) => {
962
+ const val = WebStorage.get(key, scope);
963
+ if (val !== undefined) result[key] = val;
593
964
  });
594
965
  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
966
  });
601
- return result;
602
967
  },
603
968
  size: (scope: StorageScope): number => {
604
- assertValidScope(scope);
605
- if (scope === StorageScope.Memory) return memoryStore.size;
606
- return WebStorage.size(scope);
969
+ return measureOperation("storage:size", scope, () => {
970
+ assertValidScope(scope);
971
+ if (scope === StorageScope.Memory) return memoryStore.size;
972
+ return WebStorage.size(scope);
973
+ });
974
+ },
975
+ setAccessControl: (_level: AccessControl) => {
976
+ recordMetric("storage:setAccessControl", StorageScope.Secure, 0);
977
+ },
978
+ setSecureWritesAsync: (_enabled: boolean) => {
979
+ recordMetric("storage:setSecureWritesAsync", StorageScope.Secure, 0);
980
+ },
981
+ flushSecureWrites: () => {
982
+ measureOperation("storage:flushSecureWrites", StorageScope.Secure, () => {
983
+ flushSecureWrites();
984
+ });
985
+ },
986
+ setKeychainAccessGroup: (_group: string) => {
987
+ recordMetric("storage:setKeychainAccessGroup", StorageScope.Secure, 0);
988
+ },
989
+ setMetricsObserver: (observer?: StorageMetricsObserver) => {
990
+ metricsObserver = observer;
991
+ },
992
+ getMetricsSnapshot: (): Record<string, StorageMetricSummary> => {
993
+ const snapshot: Record<string, StorageMetricSummary> = {};
994
+ metricsCounters.forEach((value, key) => {
995
+ snapshot[key] = {
996
+ count: value.count,
997
+ totalDurationMs: value.totalDurationMs,
998
+ avgDurationMs:
999
+ value.count === 0 ? 0 : value.totalDurationMs / value.count,
1000
+ maxDurationMs: value.maxDurationMs,
1001
+ };
1002
+ });
1003
+ return snapshot;
1004
+ },
1005
+ resetMetrics: () => {
1006
+ metricsCounters.clear();
607
1007
  },
608
- setAccessControl: (_level: AccessControl) => {},
609
- setKeychainAccessGroup: (_group: string) => {},
610
1008
  };
611
1009
 
1010
+ export function setWebSecureStorageBackend(
1011
+ backend?: WebSecureStorageBackend,
1012
+ ): void {
1013
+ webSecureStorageBackend = backend ?? createLocalStorageWebSecureBackend();
1014
+ hydratedWebScopeKeyIndex.delete(StorageScope.Secure);
1015
+ clearScopeRawCache(StorageScope.Secure);
1016
+ }
1017
+
1018
+ export function getWebSecureStorageBackend():
1019
+ | WebSecureStorageBackend
1020
+ | undefined {
1021
+ return webSecureStorageBackend;
1022
+ }
1023
+
612
1024
  export interface StorageItemConfig<T> {
613
1025
  key: string;
614
1026
  scope: StorageScope;
@@ -623,12 +1035,18 @@ export interface StorageItemConfig<T> {
623
1035
  coalesceSecureWrites?: boolean;
624
1036
  namespace?: string;
625
1037
  biometric?: boolean;
1038
+ biometricLevel?: BiometricLevel;
626
1039
  accessControl?: AccessControl;
627
1040
  }
628
1041
 
629
1042
  export interface StorageItem<T> {
630
1043
  get: () => T;
1044
+ getWithVersion: () => VersionedValue<T>;
631
1045
  set: (value: T | ((prev: T) => T)) => void;
1046
+ setIfVersion: (
1047
+ version: StorageVersion,
1048
+ value: T | ((prev: T) => T),
1049
+ ) => boolean;
632
1050
  delete: () => void;
633
1051
  has: () => boolean;
634
1052
  subscribe: (callback: () => void) => () => void;
@@ -644,6 +1062,7 @@ type StorageItemInternal<T> = StorageItem<T> & {
644
1062
  _hasExpiration: boolean;
645
1063
  _readCacheEnabled: boolean;
646
1064
  _isBiometric: boolean;
1065
+ _defaultValue: T;
647
1066
  _secureAccessControl?: AccessControl;
648
1067
  };
649
1068
 
@@ -656,6 +1075,14 @@ function canUseRawBatchPath(item: RawBatchPathItem): boolean {
656
1075
  );
657
1076
  }
658
1077
 
1078
+ function canUseSecureRawBatchPath(item: RawBatchPathItem): boolean {
1079
+ return (
1080
+ item._hasExpiration === false &&
1081
+ item._hasValidation === false &&
1082
+ item._isBiometric !== true
1083
+ );
1084
+ }
1085
+
659
1086
  function defaultSerialize<T>(value: T): string {
660
1087
  return serializeWithPrimitiveFastPath(value);
661
1088
  }
@@ -671,8 +1098,14 @@ export function createStorageItem<T = undefined>(
671
1098
  const serialize = config.serialize ?? defaultSerialize;
672
1099
  const deserialize = config.deserialize ?? defaultDeserialize;
673
1100
  const isMemory = config.scope === StorageScope.Memory;
674
- const isBiometric =
675
- config.biometric === true && config.scope === StorageScope.Secure;
1101
+ const resolvedBiometricLevel =
1102
+ config.scope === StorageScope.Secure
1103
+ ? (config.biometricLevel ??
1104
+ (config.biometric === true
1105
+ ? BiometricLevel.BiometryOnly
1106
+ : BiometricLevel.None))
1107
+ : BiometricLevel.None;
1108
+ const isBiometric = resolvedBiometricLevel !== BiometricLevel.None;
676
1109
  const secureAccessControl = config.accessControl;
677
1110
  const validate = config.validate;
678
1111
  const onValidationError = config.onValidationError;
@@ -685,8 +1118,8 @@ export function createStorageItem<T = undefined>(
685
1118
  const coalesceSecureWrites =
686
1119
  config.scope === StorageScope.Secure &&
687
1120
  config.coalesceSecureWrites === true &&
688
- !isBiometric &&
689
- secureAccessControl === undefined;
1121
+ !isBiometric;
1122
+ const defaultValue = config.defaultValue as T;
690
1123
  const nonMemoryScope: NonMemoryScope | null =
691
1124
  config.scope === StorageScope.Disk
692
1125
  ? StorageScope.Disk
@@ -703,11 +1136,13 @@ export function createStorageItem<T = undefined>(
703
1136
  let lastRaw: unknown = undefined;
704
1137
  let lastValue: T | undefined;
705
1138
  let hasLastValue = false;
1139
+ let lastExpiresAt: number | null | undefined = undefined;
706
1140
 
707
1141
  const invalidateParsedCache = () => {
708
1142
  lastRaw = undefined;
709
1143
  lastValue = undefined;
710
1144
  hasLastValue = false;
1145
+ lastExpiresAt = undefined;
711
1146
  };
712
1147
 
713
1148
  const ensureSubscription = () => {
@@ -725,6 +1160,7 @@ export function createStorageItem<T = undefined>(
725
1160
  return;
726
1161
  }
727
1162
 
1163
+ ensureWebStorageEventSubscription();
728
1164
  unsubscribe = addKeyListener(
729
1165
  getScopedListeners(nonMemoryScope!),
730
1166
  storageKey,
@@ -744,7 +1180,7 @@ export function createStorageItem<T = undefined>(
744
1180
  return undefined;
745
1181
  }
746
1182
  }
747
- return memoryStore.get(storageKey) as T | undefined;
1183
+ return memoryStore.get(storageKey);
748
1184
  }
749
1185
 
750
1186
  if (
@@ -772,14 +1208,22 @@ export function createStorageItem<T = undefined>(
772
1208
 
773
1209
  const writeStoredRaw = (rawValue: string): void => {
774
1210
  if (isBiometric) {
775
- WebStorage.setSecureBiometric(storageKey, rawValue);
1211
+ WebStorage.setSecureBiometricWithLevel(
1212
+ storageKey,
1213
+ rawValue,
1214
+ resolvedBiometricLevel,
1215
+ );
776
1216
  return;
777
1217
  }
778
1218
 
779
1219
  cacheRawValue(nonMemoryScope!, storageKey, rawValue);
780
1220
 
781
1221
  if (coalesceSecureWrites) {
782
- scheduleSecureWrite(storageKey, rawValue);
1222
+ scheduleSecureWrite(
1223
+ storageKey,
1224
+ rawValue,
1225
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1226
+ );
783
1227
  return;
784
1228
  }
785
1229
 
@@ -799,7 +1243,11 @@ export function createStorageItem<T = undefined>(
799
1243
  cacheRawValue(nonMemoryScope!, storageKey, undefined);
800
1244
 
801
1245
  if (coalesceSecureWrites) {
802
- scheduleSecureWrite(storageKey, undefined);
1246
+ scheduleSecureWrite(
1247
+ storageKey,
1248
+ undefined,
1249
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1250
+ );
803
1251
  return;
804
1252
  }
805
1253
 
@@ -839,7 +1287,7 @@ export function createStorageItem<T = undefined>(
839
1287
  return onValidationError(invalidValue);
840
1288
  }
841
1289
 
842
- return config.defaultValue as T;
1290
+ return defaultValue;
843
1291
  };
844
1292
 
845
1293
  const ensureValidatedValue = (
@@ -852,7 +1300,7 @@ export function createStorageItem<T = undefined>(
852
1300
 
853
1301
  const resolved = resolveInvalidValue(candidate);
854
1302
  if (validate && !validate(resolved)) {
855
- return config.defaultValue as T;
1303
+ return defaultValue;
856
1304
  }
857
1305
  if (hadStoredValue) {
858
1306
  writeValueWithoutValidation(resolved);
@@ -860,39 +1308,64 @@ export function createStorageItem<T = undefined>(
860
1308
  return resolved;
861
1309
  };
862
1310
 
863
- const get = (): T => {
1311
+ const getInternal = (): T => {
864
1312
  const raw = readStoredRaw();
865
1313
 
866
- const canUseCachedValue = !expiration && !memoryExpiration;
867
- if (canUseCachedValue && raw === lastRaw && hasLastValue) {
868
- return lastValue as T;
1314
+ if (!memoryExpiration && raw === lastRaw && hasLastValue) {
1315
+ if (!expiration || lastExpiresAt === null) {
1316
+ return lastValue as T;
1317
+ }
1318
+
1319
+ if (typeof lastExpiresAt === "number") {
1320
+ if (lastExpiresAt > Date.now()) {
1321
+ return lastValue as T;
1322
+ }
1323
+
1324
+ removeStoredRaw();
1325
+ invalidateParsedCache();
1326
+ onExpired?.(storageKey);
1327
+ lastValue = ensureValidatedValue(defaultValue, false);
1328
+ hasLastValue = true;
1329
+ return lastValue;
1330
+ }
869
1331
  }
870
1332
 
871
1333
  lastRaw = raw;
872
1334
 
873
1335
  if (raw === undefined) {
874
- lastValue = ensureValidatedValue(config.defaultValue, false);
1336
+ lastExpiresAt = undefined;
1337
+ lastValue = ensureValidatedValue(defaultValue, false);
875
1338
  hasLastValue = true;
876
1339
  return lastValue;
877
1340
  }
878
1341
 
879
1342
  if (isMemory) {
1343
+ lastExpiresAt = undefined;
880
1344
  lastValue = ensureValidatedValue(raw, true);
881
1345
  hasLastValue = true;
882
1346
  return lastValue;
883
1347
  }
884
1348
 
885
- let deserializableRaw = raw as string;
1349
+ if (typeof raw !== "string") {
1350
+ lastExpiresAt = undefined;
1351
+ lastValue = ensureValidatedValue(defaultValue, false);
1352
+ hasLastValue = true;
1353
+ return lastValue;
1354
+ }
1355
+
1356
+ let deserializableRaw = raw;
886
1357
 
887
1358
  if (expiration) {
1359
+ let envelopeExpiresAt: number | null = null;
888
1360
  try {
889
- const parsed = JSON.parse(raw as string) as unknown;
1361
+ const parsed = JSON.parse(raw) as unknown;
890
1362
  if (isStoredEnvelope(parsed)) {
1363
+ envelopeExpiresAt = parsed.expiresAt;
891
1364
  if (parsed.expiresAt <= Date.now()) {
892
1365
  removeStoredRaw();
893
1366
  invalidateParsedCache();
894
1367
  onExpired?.(storageKey);
895
- lastValue = ensureValidatedValue(config.defaultValue, false);
1368
+ lastValue = ensureValidatedValue(defaultValue, false);
896
1369
  hasLastValue = true;
897
1370
  return lastValue;
898
1371
  }
@@ -902,6 +1375,9 @@ export function createStorageItem<T = undefined>(
902
1375
  } catch {
903
1376
  // Keep backward compatibility with legacy raw values.
904
1377
  }
1378
+ lastExpiresAt = envelopeExpiresAt;
1379
+ } else {
1380
+ lastExpiresAt = undefined;
905
1381
  }
906
1382
 
907
1383
  lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
@@ -909,44 +1385,74 @@ export function createStorageItem<T = undefined>(
909
1385
  return lastValue;
910
1386
  };
911
1387
 
1388
+ const getCurrentVersion = (): StorageVersion => {
1389
+ const raw = readStoredRaw();
1390
+ return toVersionToken(raw);
1391
+ };
1392
+
1393
+ const get = (): T =>
1394
+ measureOperation("item:get", config.scope, () => getInternal());
1395
+
1396
+ const getWithVersion = (): VersionedValue<T> =>
1397
+ measureOperation("item:getWithVersion", config.scope, () => ({
1398
+ value: getInternal(),
1399
+ version: getCurrentVersion(),
1400
+ }));
1401
+
912
1402
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
913
- const currentValue = get();
914
- const newValue =
915
- typeof valueOrFn === "function"
916
- ? (valueOrFn as (prev: T) => T)(currentValue)
1403
+ measureOperation("item:set", config.scope, () => {
1404
+ const newValue = isUpdater(valueOrFn)
1405
+ ? valueOrFn(getInternal())
917
1406
  : valueOrFn;
918
1407
 
919
- invalidateParsedCache();
1408
+ invalidateParsedCache();
920
1409
 
921
- if (validate && !validate(newValue)) {
922
- throw new Error(
923
- `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
924
- );
925
- }
1410
+ if (validate && !validate(newValue)) {
1411
+ throw new Error(
1412
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1413
+ );
1414
+ }
926
1415
 
927
- writeValueWithoutValidation(newValue);
1416
+ writeValueWithoutValidation(newValue);
1417
+ });
928
1418
  };
929
1419
 
1420
+ const setIfVersion = (
1421
+ version: StorageVersion,
1422
+ valueOrFn: T | ((prev: T) => T),
1423
+ ): boolean =>
1424
+ measureOperation("item:setIfVersion", config.scope, () => {
1425
+ const currentVersion = getCurrentVersion();
1426
+ if (currentVersion !== version) {
1427
+ return false;
1428
+ }
1429
+ set(valueOrFn);
1430
+ return true;
1431
+ });
1432
+
930
1433
  const deleteItem = (): void => {
931
- invalidateParsedCache();
1434
+ measureOperation("item:delete", config.scope, () => {
1435
+ invalidateParsedCache();
932
1436
 
933
- if (isMemory) {
934
- if (memoryExpiration) {
935
- memoryExpiration.delete(storageKey);
1437
+ if (isMemory) {
1438
+ if (memoryExpiration) {
1439
+ memoryExpiration.delete(storageKey);
1440
+ }
1441
+ memoryStore.delete(storageKey);
1442
+ notifyKeyListeners(memoryListeners, storageKey);
1443
+ return;
936
1444
  }
937
- memoryStore.delete(storageKey);
938
- notifyKeyListeners(memoryListeners, storageKey);
939
- return;
940
- }
941
1445
 
942
- removeStoredRaw();
1446
+ removeStoredRaw();
1447
+ });
943
1448
  };
944
1449
 
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);
949
- };
1450
+ const hasItem = (): boolean =>
1451
+ measureOperation("item:has", config.scope, () => {
1452
+ if (isMemory) return memoryStore.has(storageKey);
1453
+ if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
1454
+ return WebStorage.has(storageKey, config.scope);
1455
+ });
950
1456
 
951
1457
  const subscribe = (callback: () => void): (() => void) => {
952
1458
  ensureSubscription();
@@ -962,7 +1468,9 @@ export function createStorageItem<T = undefined>(
962
1468
 
963
1469
  const storageItem: StorageItemInternal<T> = {
964
1470
  get,
1471
+ getWithVersion,
965
1472
  set,
1473
+ setIfVersion,
966
1474
  delete: deleteItem,
967
1475
  has: hasItem,
968
1476
  subscribe,
@@ -976,54 +1484,18 @@ export function createStorageItem<T = undefined>(
976
1484
  _hasExpiration: expiration !== undefined,
977
1485
  _readCacheEnabled: readCache,
978
1486
  _isBiometric: isBiometric,
979
- _secureAccessControl: secureAccessControl,
1487
+ _defaultValue: defaultValue,
1488
+ ...(secureAccessControl !== undefined
1489
+ ? { _secureAccessControl: secureAccessControl }
1490
+ : {}),
980
1491
  scope: config.scope,
981
1492
  key: storageKey,
982
1493
  };
983
1494
 
984
- return storageItem as StorageItem<T>;
985
- }
986
-
987
- export function useStorage<T>(
988
- item: StorageItem<T>,
989
- ): [T, (value: T | ((prev: T) => T)) => void] {
990
- const value = useSyncExternalStore(item.subscribe, item.get, item.get);
991
- return [value, item.set];
1495
+ return storageItem;
992
1496
  }
993
1497
 
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
-
1024
- export function useSetStorage<T>(item: StorageItem<T>) {
1025
- return item.set;
1026
- }
1498
+ export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
1027
1499
 
1028
1500
  type BatchReadItem<T> = Pick<
1029
1501
  StorageItem<T>,
@@ -1033,6 +1505,7 @@ type BatchReadItem<T> = Pick<
1033
1505
  _hasExpiration?: boolean;
1034
1506
  _readCacheEnabled?: boolean;
1035
1507
  _isBiometric?: boolean;
1508
+ _defaultValue?: unknown;
1036
1509
  _secureAccessControl?: AccessControl;
1037
1510
  };
1038
1511
  type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
@@ -1046,108 +1519,174 @@ export function getBatch(
1046
1519
  items: readonly BatchReadItem<unknown>[],
1047
1520
  scope: StorageScope,
1048
1521
  ): unknown[] {
1049
- assertBatchScope(items, scope);
1522
+ return measureOperation(
1523
+ "batch:get",
1524
+ scope,
1525
+ () => {
1526
+ assertBatchScope(items, scope);
1050
1527
 
1051
- if (scope === StorageScope.Memory) {
1052
- return items.map((item) => item.get());
1053
- }
1528
+ if (scope === StorageScope.Memory) {
1529
+ return items.map((item) => item.get());
1530
+ }
1054
1531
 
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);
1532
+ const useRawBatchPath = items.every((item) =>
1533
+ scope === StorageScope.Secure
1534
+ ? canUseSecureRawBatchPath(item)
1535
+ : canUseRawBatchPath(item),
1536
+ );
1537
+ if (!useRawBatchPath) {
1538
+ return items.map((item) => item.get());
1539
+ }
1060
1540
 
1061
- const rawValues = new Array<string | undefined>(items.length);
1062
- const keysToFetch: string[] = [];
1063
- const keyIndexes: number[] = [];
1541
+ const rawValues = new Array<string | undefined>(items.length);
1542
+ const keysToFetch: string[] = [];
1543
+ const keyIndexes: number[] = [];
1064
1544
 
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
- }
1545
+ items.forEach((item, index) => {
1546
+ if (scope === StorageScope.Secure) {
1547
+ if (hasPendingSecureWrite(item.key)) {
1548
+ rawValues[index] = readPendingSecureWrite(item.key);
1549
+ return;
1550
+ }
1551
+ }
1072
1552
 
1073
- if (useBatchCache) {
1074
- if (hasCachedRawValue(scope, item.key)) {
1075
- rawValues[index] = readCachedRawValue(scope, item.key);
1076
- return;
1077
- }
1078
- }
1553
+ if (item._readCacheEnabled === true) {
1554
+ if (hasCachedRawValue(scope, item.key)) {
1555
+ rawValues[index] = readCachedRawValue(scope, item.key);
1556
+ return;
1557
+ }
1558
+ }
1079
1559
 
1080
- keysToFetch.push(item.key);
1081
- keyIndexes.push(index);
1082
- });
1560
+ keysToFetch.push(item.key);
1561
+ keyIndexes.push(index);
1562
+ });
1083
1563
 
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
- }
1564
+ if (keysToFetch.length > 0) {
1565
+ const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
1566
+ fetchedValues.forEach((value, index) => {
1567
+ const key = keysToFetch[index];
1568
+ const targetIndex = keyIndexes[index];
1569
+ if (key === undefined || targetIndex === undefined) {
1570
+ return;
1571
+ }
1572
+ rawValues[targetIndex] = value;
1573
+ cacheRawValue(scope, key, value);
1574
+ });
1575
+ }
1093
1576
 
1094
- return items.map((item, index) => {
1095
- const raw = rawValues[index];
1096
- if (raw === undefined) {
1097
- return item.get();
1098
- }
1099
- return item.deserialize(raw);
1100
- });
1577
+ return items.map((item, index) => {
1578
+ const raw = rawValues[index];
1579
+ if (raw === undefined) {
1580
+ return asInternal(item as StorageItem<unknown>)._defaultValue;
1581
+ }
1582
+ return item.deserialize(raw);
1583
+ });
1584
+ },
1585
+ items.length,
1586
+ );
1101
1587
  }
1102
1588
 
1103
1589
  export function setBatch<T>(
1104
1590
  items: readonly StorageBatchSetItem<T>[],
1105
1591
  scope: StorageScope,
1106
1592
  ): void {
1107
- assertBatchScope(
1108
- items.map((batchEntry) => batchEntry.item),
1593
+ measureOperation(
1594
+ "batch:set",
1109
1595
  scope,
1110
- );
1596
+ () => {
1597
+ assertBatchScope(
1598
+ items.map((batchEntry) => batchEntry.item),
1599
+ scope,
1600
+ );
1111
1601
 
1112
- if (scope === StorageScope.Memory) {
1113
- items.forEach(({ item, value }) => item.set(value));
1114
- return;
1115
- }
1602
+ if (scope === StorageScope.Memory) {
1603
+ items.forEach(({ item, value }) => item.set(value));
1604
+ return;
1605
+ }
1116
1606
 
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
- }
1607
+ if (scope === StorageScope.Secure) {
1608
+ const secureEntries = items.map(({ item, value }) => ({
1609
+ item,
1610
+ value,
1611
+ internal: asInternal(item),
1612
+ }));
1613
+ const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
1614
+ canUseSecureRawBatchPath(internal),
1615
+ );
1616
+ if (!canUseSecureBatchPath) {
1617
+ items.forEach(({ item, value }) => item.set(value));
1618
+ return;
1619
+ }
1124
1620
 
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]));
1621
+ flushSecureWrites();
1622
+ const groupedByAccessControl = new Map<
1623
+ number,
1624
+ { keys: string[]; values: string[] }
1625
+ >();
1626
+
1627
+ secureEntries.forEach(({ item, value, internal }) => {
1628
+ const accessControl =
1629
+ internal._secureAccessControl ?? AccessControl.WhenUnlocked;
1630
+ const existingGroup = groupedByAccessControl.get(accessControl);
1631
+ const group = existingGroup ?? { keys: [], values: [] };
1632
+ group.keys.push(item.key);
1633
+ group.values.push(item.serialize(value));
1634
+ if (!existingGroup) {
1635
+ groupedByAccessControl.set(accessControl, group);
1636
+ }
1637
+ });
1638
+
1639
+ groupedByAccessControl.forEach((group, accessControl) => {
1640
+ WebStorage.setSecureAccessControl(accessControl);
1641
+ WebStorage.setBatch(group.keys, group.values, scope);
1642
+ group.keys.forEach((key, index) =>
1643
+ cacheRawValue(scope, key, group.values[index]),
1644
+ );
1645
+ });
1646
+ return;
1647
+ }
1648
+
1649
+ const useRawBatchPath = items.every(({ item }) =>
1650
+ canUseRawBatchPath(asInternal(item)),
1651
+ );
1652
+ if (!useRawBatchPath) {
1653
+ items.forEach(({ item, value }) => item.set(value));
1654
+ return;
1655
+ }
1656
+
1657
+ const keys = items.map((entry) => entry.item.key);
1658
+ const values = items.map((entry) => entry.item.serialize(entry.value));
1659
+ WebStorage.setBatch(keys, values, scope);
1660
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1661
+ },
1662
+ items.length,
1663
+ );
1132
1664
  }
1133
1665
 
1134
1666
  export function removeBatch(
1135
1667
  items: readonly BatchRemoveItem[],
1136
1668
  scope: StorageScope,
1137
1669
  ): void {
1138
- assertBatchScope(items, scope);
1670
+ measureOperation(
1671
+ "batch:remove",
1672
+ scope,
1673
+ () => {
1674
+ assertBatchScope(items, scope);
1139
1675
 
1140
- if (scope === StorageScope.Memory) {
1141
- items.forEach((item) => item.delete());
1142
- return;
1143
- }
1676
+ if (scope === StorageScope.Memory) {
1677
+ items.forEach((item) => item.delete());
1678
+ return;
1679
+ }
1144
1680
 
1145
- const keys = items.map((item) => item.key);
1146
- if (scope === StorageScope.Secure) {
1147
- flushSecureWrites();
1148
- }
1149
- WebStorage.removeBatch(keys, scope);
1150
- keys.forEach((key) => cacheRawValue(scope, key, undefined));
1681
+ const keys = items.map((item) => item.key);
1682
+ if (scope === StorageScope.Secure) {
1683
+ flushSecureWrites();
1684
+ }
1685
+ WebStorage.removeBatch(keys, scope);
1686
+ keys.forEach((key) => cacheRawValue(scope, key, undefined));
1687
+ },
1688
+ items.length,
1689
+ );
1151
1690
  }
1152
1691
 
1153
1692
  export function registerMigration(version: number, migration: Migration): void {
@@ -1165,92 +1704,124 @@ export function registerMigration(version: number, migration: Migration): void {
1165
1704
  export function migrateToLatest(
1166
1705
  scope: StorageScope = StorageScope.Disk,
1167
1706
  ): 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);
1707
+ return measureOperation("migration:run", scope, () => {
1708
+ assertValidScope(scope);
1709
+ const currentVersion = readMigrationVersion(scope);
1710
+ const versions = Array.from(registeredMigrations.keys())
1711
+ .filter((version) => version > currentVersion)
1712
+ .sort((a, b) => a - b);
1713
+
1714
+ let appliedVersion = currentVersion;
1715
+ const context: MigrationContext = {
1716
+ scope,
1717
+ getRaw: (key) => getRawValue(key, scope),
1718
+ setRaw: (key, value) => setRawValue(key, value, scope),
1719
+ removeRaw: (key) => removeRawValue(key, scope),
1720
+ };
1173
1721
 
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
- };
1722
+ versions.forEach((version) => {
1723
+ const migration = registeredMigrations.get(version);
1724
+ if (!migration) {
1725
+ return;
1726
+ }
1727
+ migration(context);
1728
+ writeMigrationVersion(scope, version);
1729
+ appliedVersion = version;
1730
+ });
1181
1731
 
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;
1732
+ return appliedVersion;
1190
1733
  });
1191
-
1192
- return appliedVersion;
1193
1734
  }
1194
1735
 
1195
1736
  export function runTransaction<T>(
1196
1737
  scope: StorageScope,
1197
1738
  transaction: (context: TransactionContext) => T,
1198
1739
  ): T {
1199
- assertValidScope(scope);
1200
- if (scope === StorageScope.Secure) {
1201
- flushSecureWrites();
1202
- }
1740
+ return measureOperation("transaction:run", scope, () => {
1741
+ assertValidScope(scope);
1742
+ if (scope === StorageScope.Secure) {
1743
+ flushSecureWrites();
1744
+ }
1203
1745
 
1204
- const rollback = new Map<string, string | undefined>();
1746
+ const rollback = new Map<string, string | undefined>();
1205
1747
 
1206
- const rememberRollback = (key: string) => {
1207
- if (rollback.has(key)) {
1208
- return;
1209
- }
1210
- rollback.set(key, getRawValue(key, scope));
1211
- };
1748
+ const rememberRollback = (key: string) => {
1749
+ if (rollback.has(key)) {
1750
+ return;
1751
+ }
1752
+ rollback.set(key, getRawValue(key, scope));
1753
+ };
1212
1754
 
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
- };
1755
+ const tx: TransactionContext = {
1756
+ scope,
1757
+ getRaw: (key) => getRawValue(key, scope),
1758
+ setRaw: (key, value) => {
1759
+ rememberRollback(key);
1760
+ setRawValue(key, value, scope);
1761
+ },
1762
+ removeRaw: (key) => {
1763
+ rememberRollback(key);
1764
+ removeRawValue(key, scope);
1765
+ },
1766
+ getItem: (item) => {
1767
+ assertBatchScope([item], scope);
1768
+ return item.get();
1769
+ },
1770
+ setItem: (item, value) => {
1771
+ assertBatchScope([item], scope);
1772
+ rememberRollback(item.key);
1773
+ item.set(value);
1774
+ },
1775
+ removeItem: (item) => {
1776
+ assertBatchScope([item], scope);
1777
+ rememberRollback(item.key);
1778
+ item.delete();
1779
+ },
1780
+ };
1239
1781
 
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);
1782
+ try {
1783
+ return transaction(tx);
1784
+ } catch (error) {
1785
+ const rollbackEntries = Array.from(rollback.entries()).reverse();
1786
+ if (scope === StorageScope.Memory) {
1787
+ rollbackEntries.forEach(([key, previousValue]) => {
1788
+ if (previousValue === undefined) {
1789
+ removeRawValue(key, scope);
1790
+ } else {
1791
+ setRawValue(key, previousValue, scope);
1792
+ }
1793
+ });
1794
+ } else {
1795
+ const keysToSet: string[] = [];
1796
+ const valuesToSet: string[] = [];
1797
+ const keysToRemove: string[] = [];
1798
+
1799
+ rollbackEntries.forEach(([key, previousValue]) => {
1800
+ if (previousValue === undefined) {
1801
+ keysToRemove.push(key);
1802
+ } else {
1803
+ keysToSet.push(key);
1804
+ valuesToSet.push(previousValue);
1805
+ }
1806
+ });
1807
+
1808
+ if (scope === StorageScope.Secure) {
1809
+ flushSecureWrites();
1250
1810
  }
1251
- });
1252
- throw error;
1253
- }
1811
+ if (keysToSet.length > 0) {
1812
+ WebStorage.setBatch(keysToSet, valuesToSet, scope);
1813
+ keysToSet.forEach((key, index) =>
1814
+ cacheRawValue(scope, key, valuesToSet[index]),
1815
+ );
1816
+ }
1817
+ if (keysToRemove.length > 0) {
1818
+ WebStorage.removeBatch(keysToRemove, scope);
1819
+ keysToRemove.forEach((key) => cacheRawValue(scope, key, undefined));
1820
+ }
1821
+ }
1822
+ throw error;
1823
+ }
1824
+ });
1254
1825
  }
1255
1826
 
1256
1827
  export type SecureAuthStorageConfig<K extends string = string> = Record<
@@ -1258,6 +1829,7 @@ export type SecureAuthStorageConfig<K extends string = string> = Record<
1258
1829
  {
1259
1830
  ttlMs?: number;
1260
1831
  biometric?: boolean;
1832
+ biometricLevel?: BiometricLevel;
1261
1833
  accessControl?: AccessControl;
1262
1834
  }
1263
1835
  >;
@@ -1267,20 +1839,31 @@ export function createSecureAuthStorage<K extends string>(
1267
1839
  options?: { namespace?: string },
1268
1840
  ): Record<K, StorageItem<string>> {
1269
1841
  const ns = options?.namespace ?? "auth";
1270
- const result = {} as Record<K, StorageItem<string>>;
1842
+ const result: Partial<Record<K, StorageItem<string>>> = {};
1271
1843
 
1272
- for (const key of Object.keys(config) as K[]) {
1844
+ for (const key of typedKeys(config)) {
1273
1845
  const itemConfig = config[key];
1846
+ const expirationConfig =
1847
+ itemConfig.ttlMs !== undefined ? { ttlMs: itemConfig.ttlMs } : undefined;
1274
1848
  result[key] = createStorageItem<string>({
1275
1849
  key,
1276
1850
  scope: StorageScope.Secure,
1277
1851
  defaultValue: "",
1278
1852
  namespace: ns,
1279
- biometric: itemConfig.biometric,
1280
- accessControl: itemConfig.accessControl,
1281
- expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
1853
+ ...(itemConfig.biometric !== undefined
1854
+ ? { biometric: itemConfig.biometric }
1855
+ : {}),
1856
+ ...(itemConfig.biometricLevel !== undefined
1857
+ ? { biometricLevel: itemConfig.biometricLevel }
1858
+ : {}),
1859
+ ...(itemConfig.accessControl !== undefined
1860
+ ? { accessControl: itemConfig.accessControl }
1861
+ : {}),
1862
+ ...(expirationConfig !== undefined
1863
+ ? { expiration: expirationConfig }
1864
+ : {}),
1282
1865
  });
1283
1866
  }
1284
1867
 
1285
- return result;
1868
+ return result as Record<K, StorageItem<string>>;
1286
1869
  }