react-native-nitro-storage 0.3.2 → 0.4.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 (46) hide show
  1. package/README.md +192 -30
  2. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +22 -2
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +3 -0
  4. package/android/src/main/cpp/cpp-adapter.cpp +3 -1
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +54 -5
  6. package/cpp/bindings/HybridStorage.cpp +167 -22
  7. package/cpp/bindings/HybridStorage.hpp +12 -1
  8. package/cpp/core/NativeStorageAdapter.hpp +3 -0
  9. package/ios/IOSStorageAdapterCpp.hpp +16 -0
  10. package/ios/IOSStorageAdapterCpp.mm +135 -11
  11. package/lib/commonjs/index.js +522 -275
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +614 -270
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/indexeddb-backend.js +130 -0
  16. package/lib/commonjs/indexeddb-backend.js.map +1 -0
  17. package/lib/commonjs/internal.js +25 -0
  18. package/lib/commonjs/internal.js.map +1 -1
  19. package/lib/module/index.js +516 -277
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +608 -272
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/module/indexeddb-backend.js +126 -0
  24. package/lib/module/indexeddb-backend.js.map +1 -0
  25. package/lib/module/internal.js +24 -0
  26. package/lib/module/internal.js.map +1 -1
  27. package/lib/typescript/Storage.nitro.d.ts +2 -0
  28. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  29. package/lib/typescript/index.d.ts +40 -1
  30. package/lib/typescript/index.d.ts.map +1 -1
  31. package/lib/typescript/index.web.d.ts +42 -1
  32. package/lib/typescript/index.web.d.ts.map +1 -1
  33. package/lib/typescript/indexeddb-backend.d.ts +29 -0
  34. package/lib/typescript/indexeddb-backend.d.ts.map +1 -0
  35. package/lib/typescript/internal.d.ts +1 -0
  36. package/lib/typescript/internal.d.ts.map +1 -1
  37. package/nitrogen/generated/android/NitroStorageOnLoad.cpp +22 -17
  38. package/nitrogen/generated/android/NitroStorageOnLoad.hpp +13 -4
  39. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
  40. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
  41. package/package.json +7 -3
  42. package/src/Storage.nitro.ts +2 -0
  43. package/src/index.ts +671 -296
  44. package/src/index.web.ts +776 -288
  45. package/src/indexeddb-backend.ts +143 -0
  46. package/src/internal.ts +28 -0
package/src/index.web.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  assertValidScope,
8
8
  serializeWithPrimitiveFastPath,
9
9
  deserializeWithPrimitiveFastPath,
10
+ toVersionToken,
10
11
  prefixKey,
11
12
  isNamespaced,
12
13
  } from "./internal";
@@ -18,6 +19,31 @@ export type Validator<T> = (value: unknown) => value is T;
18
19
  export type ExpirationConfig = {
19
20
  ttlMs: number;
20
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
+ };
21
47
 
22
48
  export type MigrationContext = {
23
49
  scope: StorageScope;
@@ -65,7 +91,11 @@ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
65
91
  return Object.keys(record) as K[];
66
92
  }
67
93
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
68
- type PendingSecureWrite = { key: string; value: string | undefined };
94
+ type PendingSecureWrite = {
95
+ key: string;
96
+ value: string | undefined;
97
+ accessControl?: AccessControl;
98
+ };
69
99
  type BrowserStorageLike = {
70
100
  setItem: (key: string, value: string) => void;
71
101
  getItem: (key: string) => string | null;
@@ -93,6 +123,7 @@ export interface Storage {
93
123
  clear(scope: number): void;
94
124
  has(key: string, scope: number): boolean;
95
125
  getAllKeys(scope: number): string[];
126
+ getKeysByPrefix(prefix: string, scope: number): string[];
96
127
  size(scope: number): number;
97
128
  setBatch(keys: string[], values: string[], scope: number): void;
98
129
  getBatch(keys: string[], scope: number): (string | undefined)[];
@@ -106,6 +137,7 @@ export interface Storage {
106
137
  setSecureWritesAsync(enabled: boolean): void;
107
138
  setKeychainAccessGroup(group: string): void;
108
139
  setSecureBiometric(key: string, value: string): void;
140
+ setSecureBiometricWithLevel(key: string, value: string, level: number): void;
109
141
  getSecureBiometric(key: string): string | undefined;
110
142
  deleteSecureBiometric(key: string): void;
111
143
  hasSecureBiometric(key: string): boolean;
@@ -134,13 +166,90 @@ let secureFlushScheduled = false;
134
166
  const SECURE_WEB_PREFIX = "__secure_";
135
167
  const BIOMETRIC_WEB_PREFIX = "__bio_";
136
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();
137
234
 
138
235
  function getBrowserStorage(scope: number): BrowserStorageLike | undefined {
139
236
  if (scope === StorageScope.Disk) {
140
237
  return globalThis.localStorage;
141
238
  }
142
239
  if (scope === StorageScope.Secure) {
143
- 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
+ };
144
253
  }
145
254
  return undefined;
146
255
  }
@@ -206,6 +315,74 @@ function ensureWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
206
315
  return getWebScopeKeyIndex(scope);
207
316
  }
208
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
+
209
386
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
210
387
  return webScopeListeners.get(scope)!;
211
388
  }
@@ -295,29 +472,46 @@ function flushSecureWrites(): void {
295
472
  const writes = Array.from(pendingSecureWrites.values());
296
473
  pendingSecureWrites.clear();
297
474
 
298
- const keysToSet: string[] = [];
299
- const valuesToSet: string[] = [];
475
+ const groupedSetWrites = new Map<
476
+ AccessControl,
477
+ { keys: string[]; values: string[] }
478
+ >();
300
479
  const keysToRemove: string[] = [];
301
480
 
302
- writes.forEach(({ key, value }) => {
481
+ writes.forEach(({ key, value, accessControl }) => {
303
482
  if (value === undefined) {
304
483
  keysToRemove.push(key);
305
484
  } else {
306
- keysToSet.push(key);
307
- 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
+ }
308
493
  }
309
494
  });
310
495
 
311
- if (keysToSet.length > 0) {
312
- WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
313
- }
496
+ groupedSetWrites.forEach((group, accessControl) => {
497
+ WebStorage.setSecureAccessControl(accessControl);
498
+ WebStorage.setBatch(group.keys, group.values, StorageScope.Secure);
499
+ });
314
500
  if (keysToRemove.length > 0) {
315
501
  WebStorage.removeBatch(keysToRemove, StorageScope.Secure);
316
502
  }
317
503
  }
318
504
 
319
- function scheduleSecureWrite(key: string, value: string | undefined): void {
320
- 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);
321
515
  if (secureFlushScheduled) {
322
516
  return;
323
517
  }
@@ -491,6 +685,14 @@ const WebStorage: Storage = {
491
685
  }
492
686
  return Array.from(ensureWebScopeKeyIndex(scope));
493
687
  },
688
+ getKeysByPrefix: (prefix: string, scope: number) => {
689
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
690
+ return [];
691
+ }
692
+ return Array.from(ensureWebScopeKeyIndex(scope)).filter((key) =>
693
+ key.startsWith(prefix),
694
+ );
695
+ },
494
696
  size: (scope: number) => {
495
697
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
496
698
  return ensureWebScopeKeyIndex(scope).size;
@@ -501,6 +703,13 @@ const WebStorage: Storage = {
501
703
  setSecureWritesAsync: (_enabled: boolean) => {},
502
704
  setKeychainAccessGroup: () => {},
503
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) => {
504
713
  if (
505
714
  typeof __DEV__ !== "undefined" &&
506
715
  __DEV__ &&
@@ -511,17 +720,22 @@ const WebStorage: Storage = {
511
720
  "[NitroStorage] Biometric storage is not supported on web. Using localStorage.",
512
721
  );
513
722
  }
514
- globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
723
+ getBrowserStorage(StorageScope.Secure)?.setItem(
724
+ toBiometricStorageKey(key),
725
+ value,
726
+ );
515
727
  ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
516
728
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
517
729
  },
518
730
  getSecureBiometric: (key: string) => {
519
731
  return (
520
- globalThis.localStorage?.getItem(toBiometricStorageKey(key)) ?? undefined
732
+ getBrowserStorage(StorageScope.Secure)?.getItem(
733
+ toBiometricStorageKey(key),
734
+ ) ?? undefined
521
735
  );
522
736
  },
523
737
  deleteSecureBiometric: (key: string) => {
524
- const storage = globalThis.localStorage;
738
+ const storage = getBrowserStorage(StorageScope.Secure);
525
739
  storage?.removeItem(toBiometricStorageKey(key));
526
740
  if (storage?.getItem(toSecureStorageKey(key)) === null) {
527
741
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(key);
@@ -530,11 +744,13 @@ const WebStorage: Storage = {
530
744
  },
531
745
  hasSecureBiometric: (key: string) => {
532
746
  return (
533
- globalThis.localStorage?.getItem(toBiometricStorageKey(key)) !== null
747
+ getBrowserStorage(StorageScope.Secure)?.getItem(
748
+ toBiometricStorageKey(key),
749
+ ) !== null
534
750
  );
535
751
  },
536
752
  clearSecureBiometric: () => {
537
- const storage = globalThis.localStorage;
753
+ const storage = getBrowserStorage(StorageScope.Secure);
538
754
  if (!storage) return;
539
755
  const keysToNotify: string[] = [];
540
756
  const toRemove: string[] = [];
@@ -621,85 +837,213 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
621
837
 
622
838
  export const storage = {
623
839
  clear: (scope: StorageScope) => {
624
- if (scope === StorageScope.Memory) {
625
- memoryStore.clear();
626
- notifyAllListeners(memoryListeners);
627
- return;
628
- }
840
+ measureOperation("storage:clear", scope, () => {
841
+ if (scope === StorageScope.Memory) {
842
+ memoryStore.clear();
843
+ notifyAllListeners(memoryListeners);
844
+ return;
845
+ }
629
846
 
630
- if (scope === StorageScope.Secure) {
631
- flushSecureWrites();
632
- pendingSecureWrites.clear();
633
- }
847
+ if (scope === StorageScope.Secure) {
848
+ flushSecureWrites();
849
+ pendingSecureWrites.clear();
850
+ }
634
851
 
635
- clearScopeRawCache(scope);
636
- WebStorage.clear(scope);
852
+ clearScopeRawCache(scope);
853
+ WebStorage.clear(scope);
854
+ });
637
855
  },
638
856
  clearAll: () => {
639
- storage.clear(StorageScope.Memory);
640
- storage.clear(StorageScope.Disk);
641
- 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
+ );
642
867
  },
643
868
  clearNamespace: (namespace: string, scope: StorageScope) => {
644
- assertValidScope(scope);
645
- if (scope === StorageScope.Memory) {
646
- for (const key of memoryStore.keys()) {
647
- if (isNamespaced(key, namespace)) {
648
- 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
+ }
649
876
  }
877
+ notifyAllListeners(memoryListeners);
878
+ return;
650
879
  }
651
- notifyAllListeners(memoryListeners);
652
- return;
653
- }
654
- const keyPrefix = prefixKey(namespace, "");
655
- if (scope === StorageScope.Secure) {
656
- flushSecureWrites();
657
- }
658
- clearScopeRawCache(scope);
659
- WebStorage.removeByPrefix(keyPrefix, scope);
880
+
881
+ const keyPrefix = prefixKey(namespace, "");
882
+ if (scope === StorageScope.Secure) {
883
+ flushSecureWrites();
884
+ }
885
+ clearScopeRawCache(scope);
886
+ WebStorage.removeByPrefix(keyPrefix, scope);
887
+ });
660
888
  },
661
889
  clearBiometric: () => {
662
- WebStorage.clearSecureBiometric();
890
+ measureOperation("storage:clearBiometric", StorageScope.Secure, () => {
891
+ WebStorage.clearSecureBiometric();
892
+ });
663
893
  },
664
894
  has: (key: string, scope: StorageScope): boolean => {
665
- assertValidScope(scope);
666
- if (scope === StorageScope.Memory) return memoryStore.has(key);
667
- 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
+ });
668
900
  },
669
901
  getAllKeys: (scope: StorageScope): string[] => {
670
- assertValidScope(scope);
671
- if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
672
- 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
+ });
673
949
  },
674
950
  getAll: (scope: StorageScope): Record<string, string> => {
675
- assertValidScope(scope);
676
- const result: Record<string, string> = {};
677
- if (scope === StorageScope.Memory) {
678
- memoryStore.forEach((value, key) => {
679
- 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;
680
964
  });
681
965
  return result;
682
- }
683
- const keys = WebStorage.getAllKeys(scope);
684
- keys.forEach((key) => {
685
- const val = WebStorage.get(key, scope);
686
- if (val !== undefined) result[key] = val;
687
966
  });
688
- return result;
689
967
  },
690
968
  size: (scope: StorageScope): number => {
691
- assertValidScope(scope);
692
- if (scope === StorageScope.Memory) return memoryStore.size;
693
- 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);
694
980
  },
695
- setAccessControl: (_level: AccessControl) => {},
696
- setSecureWritesAsync: (_enabled: boolean) => {},
697
981
  flushSecureWrites: () => {
698
- 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();
1007
+ },
1008
+ import: (data: Record<string, string>, scope: StorageScope): void => {
1009
+ measureOperation(
1010
+ "storage:import",
1011
+ scope,
1012
+ () => {
1013
+ assertValidScope(scope);
1014
+ const keys = Object.keys(data);
1015
+ if (keys.length === 0) return;
1016
+ const values = keys.map((k) => data[k]!);
1017
+
1018
+ if (scope === StorageScope.Memory) {
1019
+ keys.forEach((key, index) => {
1020
+ memoryStore.set(key, values[index]);
1021
+ });
1022
+ keys.forEach((key) => notifyKeyListeners(memoryListeners, key));
1023
+ return;
1024
+ }
1025
+
1026
+ WebStorage.setBatch(keys, values, scope);
1027
+ },
1028
+ Object.keys(data).length,
1029
+ );
699
1030
  },
700
- setKeychainAccessGroup: (_group: string) => {},
701
1031
  };
702
1032
 
1033
+ export function setWebSecureStorageBackend(
1034
+ backend?: WebSecureStorageBackend,
1035
+ ): void {
1036
+ webSecureStorageBackend = backend ?? createLocalStorageWebSecureBackend();
1037
+ hydratedWebScopeKeyIndex.delete(StorageScope.Secure);
1038
+ clearScopeRawCache(StorageScope.Secure);
1039
+ }
1040
+
1041
+ export function getWebSecureStorageBackend():
1042
+ | WebSecureStorageBackend
1043
+ | undefined {
1044
+ return webSecureStorageBackend;
1045
+ }
1046
+
703
1047
  export interface StorageItemConfig<T> {
704
1048
  key: string;
705
1049
  scope: StorageScope;
@@ -714,12 +1058,18 @@ export interface StorageItemConfig<T> {
714
1058
  coalesceSecureWrites?: boolean;
715
1059
  namespace?: string;
716
1060
  biometric?: boolean;
1061
+ biometricLevel?: BiometricLevel;
717
1062
  accessControl?: AccessControl;
718
1063
  }
719
1064
 
720
1065
  export interface StorageItem<T> {
721
1066
  get: () => T;
1067
+ getWithVersion: () => VersionedValue<T>;
722
1068
  set: (value: T | ((prev: T) => T)) => void;
1069
+ setIfVersion: (
1070
+ version: StorageVersion,
1071
+ value: T | ((prev: T) => T),
1072
+ ) => boolean;
723
1073
  delete: () => void;
724
1074
  has: () => boolean;
725
1075
  subscribe: (callback: () => void) => () => void;
@@ -731,10 +1081,12 @@ export interface StorageItem<T> {
731
1081
 
732
1082
  type StorageItemInternal<T> = StorageItem<T> & {
733
1083
  _triggerListeners: () => void;
1084
+ _invalidateParsedCacheOnly: () => void;
734
1085
  _hasValidation: boolean;
735
1086
  _hasExpiration: boolean;
736
1087
  _readCacheEnabled: boolean;
737
1088
  _isBiometric: boolean;
1089
+ _defaultValue: T;
738
1090
  _secureAccessControl?: AccessControl;
739
1091
  };
740
1092
 
@@ -770,8 +1122,14 @@ export function createStorageItem<T = undefined>(
770
1122
  const serialize = config.serialize ?? defaultSerialize;
771
1123
  const deserialize = config.deserialize ?? defaultDeserialize;
772
1124
  const isMemory = config.scope === StorageScope.Memory;
773
- const isBiometric =
774
- config.biometric === true && config.scope === StorageScope.Secure;
1125
+ const resolvedBiometricLevel =
1126
+ config.scope === StorageScope.Secure
1127
+ ? (config.biometricLevel ??
1128
+ (config.biometric === true
1129
+ ? BiometricLevel.BiometryOnly
1130
+ : BiometricLevel.None))
1131
+ : BiometricLevel.None;
1132
+ const isBiometric = resolvedBiometricLevel !== BiometricLevel.None;
775
1133
  const secureAccessControl = config.accessControl;
776
1134
  const validate = config.validate;
777
1135
  const onValidationError = config.onValidationError;
@@ -784,8 +1142,7 @@ export function createStorageItem<T = undefined>(
784
1142
  const coalesceSecureWrites =
785
1143
  config.scope === StorageScope.Secure &&
786
1144
  config.coalesceSecureWrites === true &&
787
- !isBiometric &&
788
- secureAccessControl === undefined;
1145
+ !isBiometric;
789
1146
  const defaultValue = config.defaultValue as T;
790
1147
  const nonMemoryScope: NonMemoryScope | null =
791
1148
  config.scope === StorageScope.Disk
@@ -827,6 +1184,7 @@ export function createStorageItem<T = undefined>(
827
1184
  return;
828
1185
  }
829
1186
 
1187
+ ensureWebStorageEventSubscription();
830
1188
  unsubscribe = addKeyListener(
831
1189
  getScopedListeners(nonMemoryScope!),
832
1190
  storageKey,
@@ -874,14 +1232,22 @@ export function createStorageItem<T = undefined>(
874
1232
 
875
1233
  const writeStoredRaw = (rawValue: string): void => {
876
1234
  if (isBiometric) {
877
- WebStorage.setSecureBiometric(storageKey, rawValue);
1235
+ WebStorage.setSecureBiometricWithLevel(
1236
+ storageKey,
1237
+ rawValue,
1238
+ resolvedBiometricLevel,
1239
+ );
878
1240
  return;
879
1241
  }
880
1242
 
881
1243
  cacheRawValue(nonMemoryScope!, storageKey, rawValue);
882
1244
 
883
1245
  if (coalesceSecureWrites) {
884
- scheduleSecureWrite(storageKey, rawValue);
1246
+ scheduleSecureWrite(
1247
+ storageKey,
1248
+ rawValue,
1249
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1250
+ );
885
1251
  return;
886
1252
  }
887
1253
 
@@ -901,7 +1267,11 @@ export function createStorageItem<T = undefined>(
901
1267
  cacheRawValue(nonMemoryScope!, storageKey, undefined);
902
1268
 
903
1269
  if (coalesceSecureWrites) {
904
- scheduleSecureWrite(storageKey, undefined);
1270
+ scheduleSecureWrite(
1271
+ storageKey,
1272
+ undefined,
1273
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1274
+ );
905
1275
  return;
906
1276
  }
907
1277
 
@@ -962,7 +1332,7 @@ export function createStorageItem<T = undefined>(
962
1332
  return resolved;
963
1333
  };
964
1334
 
965
- const get = (): T => {
1335
+ const getInternal = (): T => {
966
1336
  const raw = readStoredRaw();
967
1337
 
968
1338
  if (!memoryExpiration && raw === lastRaw && hasLastValue) {
@@ -980,6 +1350,7 @@ export function createStorageItem<T = undefined>(
980
1350
  onExpired?.(storageKey);
981
1351
  lastValue = ensureValidatedValue(defaultValue, false);
982
1352
  hasLastValue = true;
1353
+ listeners.forEach((cb) => cb());
983
1354
  return lastValue;
984
1355
  }
985
1356
  }
@@ -1021,6 +1392,7 @@ export function createStorageItem<T = undefined>(
1021
1392
  onExpired?.(storageKey);
1022
1393
  lastValue = ensureValidatedValue(defaultValue, false);
1023
1394
  hasLastValue = true;
1395
+ listeners.forEach((cb) => cb());
1024
1396
  return lastValue;
1025
1397
  }
1026
1398
 
@@ -1039,40 +1411,74 @@ export function createStorageItem<T = undefined>(
1039
1411
  return lastValue;
1040
1412
  };
1041
1413
 
1414
+ const getCurrentVersion = (): StorageVersion => {
1415
+ const raw = readStoredRaw();
1416
+ return toVersionToken(raw);
1417
+ };
1418
+
1419
+ const get = (): T =>
1420
+ measureOperation("item:get", config.scope, () => getInternal());
1421
+
1422
+ const getWithVersion = (): VersionedValue<T> =>
1423
+ measureOperation("item:getWithVersion", config.scope, () => ({
1424
+ value: getInternal(),
1425
+ version: getCurrentVersion(),
1426
+ }));
1427
+
1042
1428
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
1043
- const newValue = isUpdater(valueOrFn) ? valueOrFn(get()) : valueOrFn;
1429
+ measureOperation("item:set", config.scope, () => {
1430
+ const newValue = isUpdater(valueOrFn)
1431
+ ? valueOrFn(getInternal())
1432
+ : valueOrFn;
1044
1433
 
1045
- invalidateParsedCache();
1434
+ invalidateParsedCache();
1046
1435
 
1047
- if (validate && !validate(newValue)) {
1048
- throw new Error(
1049
- `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1050
- );
1051
- }
1436
+ if (validate && !validate(newValue)) {
1437
+ throw new Error(
1438
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1439
+ );
1440
+ }
1052
1441
 
1053
- writeValueWithoutValidation(newValue);
1442
+ writeValueWithoutValidation(newValue);
1443
+ });
1054
1444
  };
1055
1445
 
1446
+ const setIfVersion = (
1447
+ version: StorageVersion,
1448
+ valueOrFn: T | ((prev: T) => T),
1449
+ ): boolean =>
1450
+ measureOperation("item:setIfVersion", config.scope, () => {
1451
+ const currentVersion = getCurrentVersion();
1452
+ if (currentVersion !== version) {
1453
+ return false;
1454
+ }
1455
+ set(valueOrFn);
1456
+ return true;
1457
+ });
1458
+
1056
1459
  const deleteItem = (): void => {
1057
- invalidateParsedCache();
1460
+ measureOperation("item:delete", config.scope, () => {
1461
+ invalidateParsedCache();
1058
1462
 
1059
- if (isMemory) {
1060
- if (memoryExpiration) {
1061
- memoryExpiration.delete(storageKey);
1463
+ if (isMemory) {
1464
+ if (memoryExpiration) {
1465
+ memoryExpiration.delete(storageKey);
1466
+ }
1467
+ memoryStore.delete(storageKey);
1468
+ notifyKeyListeners(memoryListeners, storageKey);
1469
+ return;
1062
1470
  }
1063
- memoryStore.delete(storageKey);
1064
- notifyKeyListeners(memoryListeners, storageKey);
1065
- return;
1066
- }
1067
1471
 
1068
- removeStoredRaw();
1472
+ removeStoredRaw();
1473
+ });
1069
1474
  };
1070
1475
 
1071
- const hasItem = (): boolean => {
1072
- if (isMemory) return memoryStore.has(storageKey);
1073
- if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
1074
- return WebStorage.has(storageKey, config.scope);
1075
- };
1476
+ const hasItem = (): boolean =>
1477
+ measureOperation("item:has", config.scope, () => {
1478
+ if (isMemory) return memoryStore.has(storageKey);
1479
+ if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
1480
+ return WebStorage.has(storageKey, config.scope);
1481
+ });
1076
1482
 
1077
1483
  const subscribe = (callback: () => void): (() => void) => {
1078
1484
  ensureSubscription();
@@ -1088,7 +1494,9 @@ export function createStorageItem<T = undefined>(
1088
1494
 
1089
1495
  const storageItem: StorageItemInternal<T> = {
1090
1496
  get,
1497
+ getWithVersion,
1091
1498
  set,
1499
+ setIfVersion,
1092
1500
  delete: deleteItem,
1093
1501
  has: hasItem,
1094
1502
  subscribe,
@@ -1098,10 +1506,14 @@ export function createStorageItem<T = undefined>(
1098
1506
  invalidateParsedCache();
1099
1507
  listeners.forEach((listener) => listener());
1100
1508
  },
1509
+ _invalidateParsedCacheOnly: () => {
1510
+ invalidateParsedCache();
1511
+ },
1101
1512
  _hasValidation: validate !== undefined,
1102
1513
  _hasExpiration: expiration !== undefined,
1103
1514
  _readCacheEnabled: readCache,
1104
1515
  _isBiometric: isBiometric,
1516
+ _defaultValue: defaultValue,
1105
1517
  ...(secureAccessControl !== undefined
1106
1518
  ? { _secureAccessControl: secureAccessControl }
1107
1519
  : {}),
@@ -1113,6 +1525,7 @@ export function createStorageItem<T = undefined>(
1113
1525
  }
1114
1526
 
1115
1527
  export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
1528
+ export { createIndexedDBBackend } from "./indexeddb-backend";
1116
1529
 
1117
1530
  type BatchReadItem<T> = Pick<
1118
1531
  StorageItem<T>,
@@ -1122,6 +1535,7 @@ type BatchReadItem<T> = Pick<
1122
1535
  _hasExpiration?: boolean;
1123
1536
  _readCacheEnabled?: boolean;
1124
1537
  _isBiometric?: boolean;
1538
+ _defaultValue?: unknown;
1125
1539
  _secureAccessControl?: AccessControl;
1126
1540
  };
1127
1541
  type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
@@ -1135,154 +1549,192 @@ export function getBatch(
1135
1549
  items: readonly BatchReadItem<unknown>[],
1136
1550
  scope: StorageScope,
1137
1551
  ): unknown[] {
1138
- assertBatchScope(items, scope);
1552
+ return measureOperation(
1553
+ "batch:get",
1554
+ scope,
1555
+ () => {
1556
+ assertBatchScope(items, scope);
1139
1557
 
1140
- if (scope === StorageScope.Memory) {
1141
- return items.map((item) => item.get());
1142
- }
1558
+ if (scope === StorageScope.Memory) {
1559
+ return items.map((item) => item.get());
1560
+ }
1143
1561
 
1144
- const useRawBatchPath = items.every((item) =>
1145
- scope === StorageScope.Secure
1146
- ? canUseSecureRawBatchPath(item)
1147
- : canUseRawBatchPath(item),
1148
- );
1149
- if (!useRawBatchPath) {
1150
- return items.map((item) => item.get());
1151
- }
1152
- const useBatchCache = items.every((item) => item._readCacheEnabled === true);
1562
+ const useRawBatchPath = items.every((item) =>
1563
+ scope === StorageScope.Secure
1564
+ ? canUseSecureRawBatchPath(item)
1565
+ : canUseRawBatchPath(item),
1566
+ );
1567
+ if (!useRawBatchPath) {
1568
+ return items.map((item) => item.get());
1569
+ }
1153
1570
 
1154
- const rawValues = new Array<string | undefined>(items.length);
1155
- const keysToFetch: string[] = [];
1156
- const keyIndexes: number[] = [];
1571
+ const rawValues = new Array<string | undefined>(items.length);
1572
+ const keysToFetch: string[] = [];
1573
+ const keyIndexes: number[] = [];
1157
1574
 
1158
- items.forEach((item, index) => {
1159
- if (scope === StorageScope.Secure) {
1160
- if (hasPendingSecureWrite(item.key)) {
1161
- rawValues[index] = readPendingSecureWrite(item.key);
1162
- return;
1163
- }
1164
- }
1575
+ items.forEach((item, index) => {
1576
+ if (scope === StorageScope.Secure) {
1577
+ if (hasPendingSecureWrite(item.key)) {
1578
+ rawValues[index] = readPendingSecureWrite(item.key);
1579
+ return;
1580
+ }
1581
+ }
1165
1582
 
1166
- if (useBatchCache) {
1167
- if (hasCachedRawValue(scope, item.key)) {
1168
- rawValues[index] = readCachedRawValue(scope, item.key);
1169
- return;
1170
- }
1171
- }
1583
+ if (item._readCacheEnabled === true) {
1584
+ if (hasCachedRawValue(scope, item.key)) {
1585
+ rawValues[index] = readCachedRawValue(scope, item.key);
1586
+ return;
1587
+ }
1588
+ }
1172
1589
 
1173
- keysToFetch.push(item.key);
1174
- keyIndexes.push(index);
1175
- });
1590
+ keysToFetch.push(item.key);
1591
+ keyIndexes.push(index);
1592
+ });
1176
1593
 
1177
- if (keysToFetch.length > 0) {
1178
- const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
1179
- fetchedValues.forEach((value, index) => {
1180
- const key = keysToFetch[index];
1181
- const targetIndex = keyIndexes[index];
1182
- if (key === undefined || targetIndex === undefined) {
1183
- return;
1594
+ if (keysToFetch.length > 0) {
1595
+ const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
1596
+ fetchedValues.forEach((value, index) => {
1597
+ const key = keysToFetch[index];
1598
+ const targetIndex = keyIndexes[index];
1599
+ if (key === undefined || targetIndex === undefined) {
1600
+ return;
1601
+ }
1602
+ rawValues[targetIndex] = value;
1603
+ cacheRawValue(scope, key, value);
1604
+ });
1184
1605
  }
1185
- rawValues[targetIndex] = value;
1186
- cacheRawValue(scope, key, value);
1187
- });
1188
- }
1189
1606
 
1190
- return items.map((item, index) => {
1191
- const raw = rawValues[index];
1192
- if (raw === undefined) {
1193
- return item.get();
1194
- }
1195
- return item.deserialize(raw);
1196
- });
1607
+ return items.map((item, index) => {
1608
+ const raw = rawValues[index];
1609
+ if (raw === undefined) {
1610
+ return asInternal(item as StorageItem<unknown>)._defaultValue;
1611
+ }
1612
+ return item.deserialize(raw);
1613
+ });
1614
+ },
1615
+ items.length,
1616
+ );
1197
1617
  }
1198
1618
 
1199
1619
  export function setBatch<T>(
1200
1620
  items: readonly StorageBatchSetItem<T>[],
1201
1621
  scope: StorageScope,
1202
1622
  ): void {
1203
- assertBatchScope(
1204
- items.map((batchEntry) => batchEntry.item),
1623
+ measureOperation(
1624
+ "batch:set",
1205
1625
  scope,
1206
- );
1626
+ () => {
1627
+ assertBatchScope(
1628
+ items.map((batchEntry) => batchEntry.item),
1629
+ scope,
1630
+ );
1207
1631
 
1208
- if (scope === StorageScope.Memory) {
1209
- items.forEach(({ item, value }) => item.set(value));
1210
- return;
1211
- }
1632
+ if (scope === StorageScope.Memory) {
1633
+ // Determine if any item needs per-item handling (validation or TTL)
1634
+ const needsIndividualSets = items.some(({ item }) => {
1635
+ const internal = asInternal(item as StorageItem<unknown>);
1636
+ return internal._hasValidation || internal._hasExpiration;
1637
+ });
1212
1638
 
1213
- if (scope === StorageScope.Secure) {
1214
- const secureEntries = items.map(({ item, value }) => ({
1215
- item,
1216
- value,
1217
- internal: asInternal(item),
1218
- }));
1219
- const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
1220
- canUseSecureRawBatchPath(internal),
1221
- );
1222
- if (!canUseSecureBatchPath) {
1223
- items.forEach(({ item, value }) => item.set(value));
1224
- return;
1225
- }
1639
+ if (needsIndividualSets) {
1640
+ items.forEach(({ item, value }) => item.set(value));
1641
+ return;
1642
+ }
1226
1643
 
1227
- flushSecureWrites();
1228
- const groupedByAccessControl = new Map<
1229
- number,
1230
- { keys: string[]; values: string[] }
1231
- >();
1232
-
1233
- secureEntries.forEach(({ item, value, internal }) => {
1234
- const accessControl =
1235
- internal._secureAccessControl ?? AccessControl.WhenUnlocked;
1236
- const existingGroup = groupedByAccessControl.get(accessControl);
1237
- const group = existingGroup ?? { keys: [], values: [] };
1238
- group.keys.push(item.key);
1239
- group.values.push(item.serialize(value));
1240
- if (!existingGroup) {
1241
- groupedByAccessControl.set(accessControl, group);
1644
+ // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
1645
+ items.forEach(({ item, value }) => {
1646
+ memoryStore.set(item.key, value);
1647
+ asInternal(item as StorageItem<unknown>)._invalidateParsedCacheOnly();
1648
+ });
1649
+ items.forEach(({ item }) =>
1650
+ notifyKeyListeners(memoryListeners, item.key),
1651
+ );
1652
+ return;
1653
+ }
1654
+
1655
+ if (scope === StorageScope.Secure) {
1656
+ const secureEntries = items.map(({ item, value }) => ({
1657
+ item,
1658
+ value,
1659
+ internal: asInternal(item),
1660
+ }));
1661
+ const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
1662
+ canUseSecureRawBatchPath(internal),
1663
+ );
1664
+ if (!canUseSecureBatchPath) {
1665
+ items.forEach(({ item, value }) => item.set(value));
1666
+ return;
1667
+ }
1668
+
1669
+ flushSecureWrites();
1670
+ const groupedByAccessControl = new Map<
1671
+ number,
1672
+ { keys: string[]; values: string[] }
1673
+ >();
1674
+
1675
+ secureEntries.forEach(({ item, value, internal }) => {
1676
+ const accessControl =
1677
+ internal._secureAccessControl ?? AccessControl.WhenUnlocked;
1678
+ const existingGroup = groupedByAccessControl.get(accessControl);
1679
+ const group = existingGroup ?? { keys: [], values: [] };
1680
+ group.keys.push(item.key);
1681
+ group.values.push(item.serialize(value));
1682
+ if (!existingGroup) {
1683
+ groupedByAccessControl.set(accessControl, group);
1684
+ }
1685
+ });
1686
+
1687
+ groupedByAccessControl.forEach((group, accessControl) => {
1688
+ WebStorage.setSecureAccessControl(accessControl);
1689
+ WebStorage.setBatch(group.keys, group.values, scope);
1690
+ group.keys.forEach((key, index) =>
1691
+ cacheRawValue(scope, key, group.values[index]),
1692
+ );
1693
+ });
1694
+ return;
1242
1695
  }
1243
- });
1244
1696
 
1245
- groupedByAccessControl.forEach((group, accessControl) => {
1246
- WebStorage.setSecureAccessControl(accessControl);
1247
- WebStorage.setBatch(group.keys, group.values, scope);
1248
- group.keys.forEach((key, index) =>
1249
- cacheRawValue(scope, key, group.values[index]),
1697
+ const useRawBatchPath = items.every(({ item }) =>
1698
+ canUseRawBatchPath(asInternal(item)),
1250
1699
  );
1251
- });
1252
- return;
1253
- }
1700
+ if (!useRawBatchPath) {
1701
+ items.forEach(({ item, value }) => item.set(value));
1702
+ return;
1703
+ }
1254
1704
 
1255
- const useRawBatchPath = items.every(({ item }) =>
1256
- canUseRawBatchPath(asInternal(item)),
1705
+ const keys = items.map((entry) => entry.item.key);
1706
+ const values = items.map((entry) => entry.item.serialize(entry.value));
1707
+ WebStorage.setBatch(keys, values, scope);
1708
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1709
+ },
1710
+ items.length,
1257
1711
  );
1258
- if (!useRawBatchPath) {
1259
- items.forEach(({ item, value }) => item.set(value));
1260
- return;
1261
- }
1262
-
1263
- const keys = items.map((entry) => entry.item.key);
1264
- const values = items.map((entry) => entry.item.serialize(entry.value));
1265
- WebStorage.setBatch(keys, values, scope);
1266
- keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1267
1712
  }
1268
1713
 
1269
1714
  export function removeBatch(
1270
1715
  items: readonly BatchRemoveItem[],
1271
1716
  scope: StorageScope,
1272
1717
  ): void {
1273
- assertBatchScope(items, scope);
1718
+ measureOperation(
1719
+ "batch:remove",
1720
+ scope,
1721
+ () => {
1722
+ assertBatchScope(items, scope);
1274
1723
 
1275
- if (scope === StorageScope.Memory) {
1276
- items.forEach((item) => item.delete());
1277
- return;
1278
- }
1724
+ if (scope === StorageScope.Memory) {
1725
+ items.forEach((item) => item.delete());
1726
+ return;
1727
+ }
1279
1728
 
1280
- const keys = items.map((item) => item.key);
1281
- if (scope === StorageScope.Secure) {
1282
- flushSecureWrites();
1283
- }
1284
- WebStorage.removeBatch(keys, scope);
1285
- keys.forEach((key) => cacheRawValue(scope, key, undefined));
1729
+ const keys = items.map((item) => item.key);
1730
+ if (scope === StorageScope.Secure) {
1731
+ flushSecureWrites();
1732
+ }
1733
+ WebStorage.removeBatch(keys, scope);
1734
+ keys.forEach((key) => cacheRawValue(scope, key, undefined));
1735
+ },
1736
+ items.length,
1737
+ );
1286
1738
  }
1287
1739
 
1288
1740
  export function registerMigration(version: number, migration: Migration): void {
@@ -1300,92 +1752,124 @@ export function registerMigration(version: number, migration: Migration): void {
1300
1752
  export function migrateToLatest(
1301
1753
  scope: StorageScope = StorageScope.Disk,
1302
1754
  ): number {
1303
- assertValidScope(scope);
1304
- const currentVersion = readMigrationVersion(scope);
1305
- const versions = Array.from(registeredMigrations.keys())
1306
- .filter((version) => version > currentVersion)
1307
- .sort((a, b) => a - b);
1755
+ return measureOperation("migration:run", scope, () => {
1756
+ assertValidScope(scope);
1757
+ const currentVersion = readMigrationVersion(scope);
1758
+ const versions = Array.from(registeredMigrations.keys())
1759
+ .filter((version) => version > currentVersion)
1760
+ .sort((a, b) => a - b);
1761
+
1762
+ let appliedVersion = currentVersion;
1763
+ const context: MigrationContext = {
1764
+ scope,
1765
+ getRaw: (key) => getRawValue(key, scope),
1766
+ setRaw: (key, value) => setRawValue(key, value, scope),
1767
+ removeRaw: (key) => removeRawValue(key, scope),
1768
+ };
1308
1769
 
1309
- let appliedVersion = currentVersion;
1310
- const context: MigrationContext = {
1311
- scope,
1312
- getRaw: (key) => getRawValue(key, scope),
1313
- setRaw: (key, value) => setRawValue(key, value, scope),
1314
- removeRaw: (key) => removeRawValue(key, scope),
1315
- };
1770
+ versions.forEach((version) => {
1771
+ const migration = registeredMigrations.get(version);
1772
+ if (!migration) {
1773
+ return;
1774
+ }
1775
+ migration(context);
1776
+ writeMigrationVersion(scope, version);
1777
+ appliedVersion = version;
1778
+ });
1316
1779
 
1317
- versions.forEach((version) => {
1318
- const migration = registeredMigrations.get(version);
1319
- if (!migration) {
1320
- return;
1321
- }
1322
- migration(context);
1323
- writeMigrationVersion(scope, version);
1324
- appliedVersion = version;
1780
+ return appliedVersion;
1325
1781
  });
1326
-
1327
- return appliedVersion;
1328
1782
  }
1329
1783
 
1330
1784
  export function runTransaction<T>(
1331
1785
  scope: StorageScope,
1332
1786
  transaction: (context: TransactionContext) => T,
1333
1787
  ): T {
1334
- assertValidScope(scope);
1335
- if (scope === StorageScope.Secure) {
1336
- flushSecureWrites();
1337
- }
1788
+ return measureOperation("transaction:run", scope, () => {
1789
+ assertValidScope(scope);
1790
+ if (scope === StorageScope.Secure) {
1791
+ flushSecureWrites();
1792
+ }
1338
1793
 
1339
- const rollback = new Map<string, string | undefined>();
1794
+ const rollback = new Map<string, string | undefined>();
1340
1795
 
1341
- const rememberRollback = (key: string) => {
1342
- if (rollback.has(key)) {
1343
- return;
1344
- }
1345
- rollback.set(key, getRawValue(key, scope));
1346
- };
1796
+ const rememberRollback = (key: string) => {
1797
+ if (rollback.has(key)) {
1798
+ return;
1799
+ }
1800
+ rollback.set(key, getRawValue(key, scope));
1801
+ };
1347
1802
 
1348
- const tx: TransactionContext = {
1349
- scope,
1350
- getRaw: (key) => getRawValue(key, scope),
1351
- setRaw: (key, value) => {
1352
- rememberRollback(key);
1353
- setRawValue(key, value, scope);
1354
- },
1355
- removeRaw: (key) => {
1356
- rememberRollback(key);
1357
- removeRawValue(key, scope);
1358
- },
1359
- getItem: (item) => {
1360
- assertBatchScope([item], scope);
1361
- return item.get();
1362
- },
1363
- setItem: (item, value) => {
1364
- assertBatchScope([item], scope);
1365
- rememberRollback(item.key);
1366
- item.set(value);
1367
- },
1368
- removeItem: (item) => {
1369
- assertBatchScope([item], scope);
1370
- rememberRollback(item.key);
1371
- item.delete();
1372
- },
1373
- };
1803
+ const tx: TransactionContext = {
1804
+ scope,
1805
+ getRaw: (key) => getRawValue(key, scope),
1806
+ setRaw: (key, value) => {
1807
+ rememberRollback(key);
1808
+ setRawValue(key, value, scope);
1809
+ },
1810
+ removeRaw: (key) => {
1811
+ rememberRollback(key);
1812
+ removeRawValue(key, scope);
1813
+ },
1814
+ getItem: (item) => {
1815
+ assertBatchScope([item], scope);
1816
+ return item.get();
1817
+ },
1818
+ setItem: (item, value) => {
1819
+ assertBatchScope([item], scope);
1820
+ rememberRollback(item.key);
1821
+ item.set(value);
1822
+ },
1823
+ removeItem: (item) => {
1824
+ assertBatchScope([item], scope);
1825
+ rememberRollback(item.key);
1826
+ item.delete();
1827
+ },
1828
+ };
1374
1829
 
1375
- try {
1376
- return transaction(tx);
1377
- } catch (error) {
1378
- Array.from(rollback.entries())
1379
- .reverse()
1380
- .forEach(([key, previousValue]) => {
1381
- if (previousValue === undefined) {
1382
- removeRawValue(key, scope);
1383
- } else {
1384
- setRawValue(key, previousValue, scope);
1830
+ try {
1831
+ return transaction(tx);
1832
+ } catch (error) {
1833
+ const rollbackEntries = Array.from(rollback.entries()).reverse();
1834
+ if (scope === StorageScope.Memory) {
1835
+ rollbackEntries.forEach(([key, previousValue]) => {
1836
+ if (previousValue === undefined) {
1837
+ removeRawValue(key, scope);
1838
+ } else {
1839
+ setRawValue(key, previousValue, scope);
1840
+ }
1841
+ });
1842
+ } else {
1843
+ const keysToSet: string[] = [];
1844
+ const valuesToSet: string[] = [];
1845
+ const keysToRemove: string[] = [];
1846
+
1847
+ rollbackEntries.forEach(([key, previousValue]) => {
1848
+ if (previousValue === undefined) {
1849
+ keysToRemove.push(key);
1850
+ } else {
1851
+ keysToSet.push(key);
1852
+ valuesToSet.push(previousValue);
1853
+ }
1854
+ });
1855
+
1856
+ if (scope === StorageScope.Secure) {
1857
+ flushSecureWrites();
1385
1858
  }
1386
- });
1387
- throw error;
1388
- }
1859
+ if (keysToSet.length > 0) {
1860
+ WebStorage.setBatch(keysToSet, valuesToSet, scope);
1861
+ keysToSet.forEach((key, index) =>
1862
+ cacheRawValue(scope, key, valuesToSet[index]),
1863
+ );
1864
+ }
1865
+ if (keysToRemove.length > 0) {
1866
+ WebStorage.removeBatch(keysToRemove, scope);
1867
+ keysToRemove.forEach((key) => cacheRawValue(scope, key, undefined));
1868
+ }
1869
+ }
1870
+ throw error;
1871
+ }
1872
+ });
1389
1873
  }
1390
1874
 
1391
1875
  export type SecureAuthStorageConfig<K extends string = string> = Record<
@@ -1393,6 +1877,7 @@ export type SecureAuthStorageConfig<K extends string = string> = Record<
1393
1877
  {
1394
1878
  ttlMs?: number;
1395
1879
  biometric?: boolean;
1880
+ biometricLevel?: BiometricLevel;
1396
1881
  accessControl?: AccessControl;
1397
1882
  }
1398
1883
  >;
@@ -1416,6 +1901,9 @@ export function createSecureAuthStorage<K extends string>(
1416
1901
  ...(itemConfig.biometric !== undefined
1417
1902
  ? { biometric: itemConfig.biometric }
1418
1903
  : {}),
1904
+ ...(itemConfig.biometricLevel !== undefined
1905
+ ? { biometricLevel: itemConfig.biometricLevel }
1906
+ : {}),
1419
1907
  ...(itemConfig.accessControl !== undefined
1420
1908
  ? { accessControl: itemConfig.accessControl }
1421
1909
  : {}),