react-native-nitro-storage 0.3.2 → 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 (36) hide show
  1. package/README.md +141 -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/java/com/nitrostorage/AndroidStorageAdapter.kt +54 -5
  5. package/cpp/bindings/HybridStorage.cpp +167 -22
  6. package/cpp/bindings/HybridStorage.hpp +12 -1
  7. package/cpp/core/NativeStorageAdapter.hpp +3 -0
  8. package/ios/IOSStorageAdapterCpp.hpp +16 -0
  9. package/ios/IOSStorageAdapterCpp.mm +135 -11
  10. package/lib/commonjs/index.js +466 -275
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/index.web.js +564 -270
  13. package/lib/commonjs/index.web.js.map +1 -1
  14. package/lib/commonjs/internal.js +25 -0
  15. package/lib/commonjs/internal.js.map +1 -1
  16. package/lib/module/index.js +466 -277
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/index.web.js +564 -272
  19. package/lib/module/index.web.js.map +1 -1
  20. package/lib/module/internal.js +24 -0
  21. package/lib/module/internal.js.map +1 -1
  22. package/lib/typescript/Storage.nitro.d.ts +2 -0
  23. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  24. package/lib/typescript/index.d.ts +38 -1
  25. package/lib/typescript/index.d.ts.map +1 -1
  26. package/lib/typescript/index.web.d.ts +40 -1
  27. package/lib/typescript/index.web.d.ts.map +1 -1
  28. package/lib/typescript/internal.d.ts +1 -0
  29. package/lib/typescript/internal.d.ts.map +1 -1
  30. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
  31. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
  32. package/package.json +1 -1
  33. package/src/Storage.nitro.ts +2 -0
  34. package/src/index.ts +616 -296
  35. package/src/index.web.ts +728 -288
  36. 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,190 @@ 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();
699
1007
  },
700
- setKeychainAccessGroup: (_group: string) => {},
701
1008
  };
702
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
+
703
1024
  export interface StorageItemConfig<T> {
704
1025
  key: string;
705
1026
  scope: StorageScope;
@@ -714,12 +1035,18 @@ export interface StorageItemConfig<T> {
714
1035
  coalesceSecureWrites?: boolean;
715
1036
  namespace?: string;
716
1037
  biometric?: boolean;
1038
+ biometricLevel?: BiometricLevel;
717
1039
  accessControl?: AccessControl;
718
1040
  }
719
1041
 
720
1042
  export interface StorageItem<T> {
721
1043
  get: () => T;
1044
+ getWithVersion: () => VersionedValue<T>;
722
1045
  set: (value: T | ((prev: T) => T)) => void;
1046
+ setIfVersion: (
1047
+ version: StorageVersion,
1048
+ value: T | ((prev: T) => T),
1049
+ ) => boolean;
723
1050
  delete: () => void;
724
1051
  has: () => boolean;
725
1052
  subscribe: (callback: () => void) => () => void;
@@ -735,6 +1062,7 @@ type StorageItemInternal<T> = StorageItem<T> & {
735
1062
  _hasExpiration: boolean;
736
1063
  _readCacheEnabled: boolean;
737
1064
  _isBiometric: boolean;
1065
+ _defaultValue: T;
738
1066
  _secureAccessControl?: AccessControl;
739
1067
  };
740
1068
 
@@ -770,8 +1098,14 @@ export function createStorageItem<T = undefined>(
770
1098
  const serialize = config.serialize ?? defaultSerialize;
771
1099
  const deserialize = config.deserialize ?? defaultDeserialize;
772
1100
  const isMemory = config.scope === StorageScope.Memory;
773
- const isBiometric =
774
- 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;
775
1109
  const secureAccessControl = config.accessControl;
776
1110
  const validate = config.validate;
777
1111
  const onValidationError = config.onValidationError;
@@ -784,8 +1118,7 @@ export function createStorageItem<T = undefined>(
784
1118
  const coalesceSecureWrites =
785
1119
  config.scope === StorageScope.Secure &&
786
1120
  config.coalesceSecureWrites === true &&
787
- !isBiometric &&
788
- secureAccessControl === undefined;
1121
+ !isBiometric;
789
1122
  const defaultValue = config.defaultValue as T;
790
1123
  const nonMemoryScope: NonMemoryScope | null =
791
1124
  config.scope === StorageScope.Disk
@@ -827,6 +1160,7 @@ export function createStorageItem<T = undefined>(
827
1160
  return;
828
1161
  }
829
1162
 
1163
+ ensureWebStorageEventSubscription();
830
1164
  unsubscribe = addKeyListener(
831
1165
  getScopedListeners(nonMemoryScope!),
832
1166
  storageKey,
@@ -874,14 +1208,22 @@ export function createStorageItem<T = undefined>(
874
1208
 
875
1209
  const writeStoredRaw = (rawValue: string): void => {
876
1210
  if (isBiometric) {
877
- WebStorage.setSecureBiometric(storageKey, rawValue);
1211
+ WebStorage.setSecureBiometricWithLevel(
1212
+ storageKey,
1213
+ rawValue,
1214
+ resolvedBiometricLevel,
1215
+ );
878
1216
  return;
879
1217
  }
880
1218
 
881
1219
  cacheRawValue(nonMemoryScope!, storageKey, rawValue);
882
1220
 
883
1221
  if (coalesceSecureWrites) {
884
- scheduleSecureWrite(storageKey, rawValue);
1222
+ scheduleSecureWrite(
1223
+ storageKey,
1224
+ rawValue,
1225
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1226
+ );
885
1227
  return;
886
1228
  }
887
1229
 
@@ -901,7 +1243,11 @@ export function createStorageItem<T = undefined>(
901
1243
  cacheRawValue(nonMemoryScope!, storageKey, undefined);
902
1244
 
903
1245
  if (coalesceSecureWrites) {
904
- scheduleSecureWrite(storageKey, undefined);
1246
+ scheduleSecureWrite(
1247
+ storageKey,
1248
+ undefined,
1249
+ secureAccessControl ?? AccessControl.WhenUnlocked,
1250
+ );
905
1251
  return;
906
1252
  }
907
1253
 
@@ -962,7 +1308,7 @@ export function createStorageItem<T = undefined>(
962
1308
  return resolved;
963
1309
  };
964
1310
 
965
- const get = (): T => {
1311
+ const getInternal = (): T => {
966
1312
  const raw = readStoredRaw();
967
1313
 
968
1314
  if (!memoryExpiration && raw === lastRaw && hasLastValue) {
@@ -1039,40 +1385,74 @@ export function createStorageItem<T = undefined>(
1039
1385
  return lastValue;
1040
1386
  };
1041
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
+
1042
1402
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
1043
- const newValue = isUpdater(valueOrFn) ? valueOrFn(get()) : valueOrFn;
1403
+ measureOperation("item:set", config.scope, () => {
1404
+ const newValue = isUpdater(valueOrFn)
1405
+ ? valueOrFn(getInternal())
1406
+ : valueOrFn;
1044
1407
 
1045
- invalidateParsedCache();
1408
+ invalidateParsedCache();
1046
1409
 
1047
- if (validate && !validate(newValue)) {
1048
- throw new Error(
1049
- `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1050
- );
1051
- }
1410
+ if (validate && !validate(newValue)) {
1411
+ throw new Error(
1412
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1413
+ );
1414
+ }
1052
1415
 
1053
- writeValueWithoutValidation(newValue);
1416
+ writeValueWithoutValidation(newValue);
1417
+ });
1054
1418
  };
1055
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
+
1056
1433
  const deleteItem = (): void => {
1057
- invalidateParsedCache();
1434
+ measureOperation("item:delete", config.scope, () => {
1435
+ invalidateParsedCache();
1058
1436
 
1059
- if (isMemory) {
1060
- if (memoryExpiration) {
1061
- memoryExpiration.delete(storageKey);
1437
+ if (isMemory) {
1438
+ if (memoryExpiration) {
1439
+ memoryExpiration.delete(storageKey);
1440
+ }
1441
+ memoryStore.delete(storageKey);
1442
+ notifyKeyListeners(memoryListeners, storageKey);
1443
+ return;
1062
1444
  }
1063
- memoryStore.delete(storageKey);
1064
- notifyKeyListeners(memoryListeners, storageKey);
1065
- return;
1066
- }
1067
1445
 
1068
- removeStoredRaw();
1446
+ removeStoredRaw();
1447
+ });
1069
1448
  };
1070
1449
 
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
- };
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
+ });
1076
1456
 
1077
1457
  const subscribe = (callback: () => void): (() => void) => {
1078
1458
  ensureSubscription();
@@ -1088,7 +1468,9 @@ export function createStorageItem<T = undefined>(
1088
1468
 
1089
1469
  const storageItem: StorageItemInternal<T> = {
1090
1470
  get,
1471
+ getWithVersion,
1091
1472
  set,
1473
+ setIfVersion,
1092
1474
  delete: deleteItem,
1093
1475
  has: hasItem,
1094
1476
  subscribe,
@@ -1102,6 +1484,7 @@ export function createStorageItem<T = undefined>(
1102
1484
  _hasExpiration: expiration !== undefined,
1103
1485
  _readCacheEnabled: readCache,
1104
1486
  _isBiometric: isBiometric,
1487
+ _defaultValue: defaultValue,
1105
1488
  ...(secureAccessControl !== undefined
1106
1489
  ? { _secureAccessControl: secureAccessControl }
1107
1490
  : {}),
@@ -1122,6 +1505,7 @@ type BatchReadItem<T> = Pick<
1122
1505
  _hasExpiration?: boolean;
1123
1506
  _readCacheEnabled?: boolean;
1124
1507
  _isBiometric?: boolean;
1508
+ _defaultValue?: unknown;
1125
1509
  _secureAccessControl?: AccessControl;
1126
1510
  };
1127
1511
  type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
@@ -1135,154 +1519,174 @@ export function getBatch(
1135
1519
  items: readonly BatchReadItem<unknown>[],
1136
1520
  scope: StorageScope,
1137
1521
  ): unknown[] {
1138
- assertBatchScope(items, scope);
1522
+ return measureOperation(
1523
+ "batch:get",
1524
+ scope,
1525
+ () => {
1526
+ assertBatchScope(items, scope);
1139
1527
 
1140
- if (scope === StorageScope.Memory) {
1141
- return items.map((item) => item.get());
1142
- }
1528
+ if (scope === StorageScope.Memory) {
1529
+ return items.map((item) => item.get());
1530
+ }
1143
1531
 
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);
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
+ }
1153
1540
 
1154
- const rawValues = new Array<string | undefined>(items.length);
1155
- const keysToFetch: string[] = [];
1156
- const keyIndexes: number[] = [];
1541
+ const rawValues = new Array<string | undefined>(items.length);
1542
+ const keysToFetch: string[] = [];
1543
+ const keyIndexes: number[] = [];
1157
1544
 
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
- }
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
+ }
1165
1552
 
1166
- if (useBatchCache) {
1167
- if (hasCachedRawValue(scope, item.key)) {
1168
- rawValues[index] = readCachedRawValue(scope, item.key);
1169
- return;
1170
- }
1171
- }
1553
+ if (item._readCacheEnabled === true) {
1554
+ if (hasCachedRawValue(scope, item.key)) {
1555
+ rawValues[index] = readCachedRawValue(scope, item.key);
1556
+ return;
1557
+ }
1558
+ }
1172
1559
 
1173
- keysToFetch.push(item.key);
1174
- keyIndexes.push(index);
1175
- });
1560
+ keysToFetch.push(item.key);
1561
+ keyIndexes.push(index);
1562
+ });
1176
1563
 
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;
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
+ });
1184
1575
  }
1185
- rawValues[targetIndex] = value;
1186
- cacheRawValue(scope, key, value);
1187
- });
1188
- }
1189
1576
 
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
- });
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
+ );
1197
1587
  }
1198
1588
 
1199
1589
  export function setBatch<T>(
1200
1590
  items: readonly StorageBatchSetItem<T>[],
1201
1591
  scope: StorageScope,
1202
1592
  ): void {
1203
- assertBatchScope(
1204
- items.map((batchEntry) => batchEntry.item),
1593
+ measureOperation(
1594
+ "batch:set",
1205
1595
  scope,
1206
- );
1596
+ () => {
1597
+ assertBatchScope(
1598
+ items.map((batchEntry) => batchEntry.item),
1599
+ scope,
1600
+ );
1207
1601
 
1208
- if (scope === StorageScope.Memory) {
1209
- items.forEach(({ item, value }) => item.set(value));
1210
- return;
1211
- }
1602
+ if (scope === StorageScope.Memory) {
1603
+ items.forEach(({ item, value }) => item.set(value));
1604
+ return;
1605
+ }
1212
1606
 
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
- }
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
+ }
1226
1620
 
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);
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;
1242
1647
  }
1243
- });
1244
1648
 
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]),
1649
+ const useRawBatchPath = items.every(({ item }) =>
1650
+ canUseRawBatchPath(asInternal(item)),
1250
1651
  );
1251
- });
1252
- return;
1253
- }
1652
+ if (!useRawBatchPath) {
1653
+ items.forEach(({ item, value }) => item.set(value));
1654
+ return;
1655
+ }
1254
1656
 
1255
- const useRawBatchPath = items.every(({ item }) =>
1256
- canUseRawBatchPath(asInternal(item)),
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,
1257
1663
  );
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
1664
  }
1268
1665
 
1269
1666
  export function removeBatch(
1270
1667
  items: readonly BatchRemoveItem[],
1271
1668
  scope: StorageScope,
1272
1669
  ): void {
1273
- assertBatchScope(items, scope);
1670
+ measureOperation(
1671
+ "batch:remove",
1672
+ scope,
1673
+ () => {
1674
+ assertBatchScope(items, scope);
1274
1675
 
1275
- if (scope === StorageScope.Memory) {
1276
- items.forEach((item) => item.delete());
1277
- return;
1278
- }
1676
+ if (scope === StorageScope.Memory) {
1677
+ items.forEach((item) => item.delete());
1678
+ return;
1679
+ }
1279
1680
 
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));
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
+ );
1286
1690
  }
1287
1691
 
1288
1692
  export function registerMigration(version: number, migration: Migration): void {
@@ -1300,92 +1704,124 @@ export function registerMigration(version: number, migration: Migration): void {
1300
1704
  export function migrateToLatest(
1301
1705
  scope: StorageScope = StorageScope.Disk,
1302
1706
  ): 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);
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
+ };
1308
1721
 
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
- };
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
+ });
1316
1731
 
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;
1732
+ return appliedVersion;
1325
1733
  });
1326
-
1327
- return appliedVersion;
1328
1734
  }
1329
1735
 
1330
1736
  export function runTransaction<T>(
1331
1737
  scope: StorageScope,
1332
1738
  transaction: (context: TransactionContext) => T,
1333
1739
  ): T {
1334
- assertValidScope(scope);
1335
- if (scope === StorageScope.Secure) {
1336
- flushSecureWrites();
1337
- }
1740
+ return measureOperation("transaction:run", scope, () => {
1741
+ assertValidScope(scope);
1742
+ if (scope === StorageScope.Secure) {
1743
+ flushSecureWrites();
1744
+ }
1338
1745
 
1339
- const rollback = new Map<string, string | undefined>();
1746
+ const rollback = new Map<string, string | undefined>();
1340
1747
 
1341
- const rememberRollback = (key: string) => {
1342
- if (rollback.has(key)) {
1343
- return;
1344
- }
1345
- rollback.set(key, getRawValue(key, scope));
1346
- };
1748
+ const rememberRollback = (key: string) => {
1749
+ if (rollback.has(key)) {
1750
+ return;
1751
+ }
1752
+ rollback.set(key, getRawValue(key, scope));
1753
+ };
1347
1754
 
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
- };
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
+ };
1374
1781
 
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);
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();
1385
1810
  }
1386
- });
1387
- throw error;
1388
- }
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
+ });
1389
1825
  }
1390
1826
 
1391
1827
  export type SecureAuthStorageConfig<K extends string = string> = Record<
@@ -1393,6 +1829,7 @@ export type SecureAuthStorageConfig<K extends string = string> = Record<
1393
1829
  {
1394
1830
  ttlMs?: number;
1395
1831
  biometric?: boolean;
1832
+ biometricLevel?: BiometricLevel;
1396
1833
  accessControl?: AccessControl;
1397
1834
  }
1398
1835
  >;
@@ -1416,6 +1853,9 @@ export function createSecureAuthStorage<K extends string>(
1416
1853
  ...(itemConfig.biometric !== undefined
1417
1854
  ? { biometric: itemConfig.biometric }
1418
1855
  : {}),
1856
+ ...(itemConfig.biometricLevel !== undefined
1857
+ ? { biometricLevel: itemConfig.biometricLevel }
1858
+ : {}),
1419
1859
  ...(itemConfig.accessControl !== undefined
1420
1860
  ? { accessControl: itemConfig.accessControl }
1421
1861
  : {}),