react-native-nitro-storage 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +334 -34
  2. package/android/CMakeLists.txt +2 -0
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +26 -2
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +4 -0
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +90 -18
  6. package/cpp/bindings/HybridStorage.cpp +214 -23
  7. package/cpp/bindings/HybridStorage.hpp +31 -3
  8. package/cpp/core/NativeStorageAdapter.hpp +4 -0
  9. package/ios/IOSStorageAdapterCpp.hpp +17 -0
  10. package/ios/IOSStorageAdapterCpp.mm +140 -10
  11. package/lib/commonjs/index.js +555 -288
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +750 -309
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/internal.js +25 -0
  16. package/lib/commonjs/internal.js.map +1 -1
  17. package/lib/commonjs/storage-hooks.js +36 -0
  18. package/lib/commonjs/storage-hooks.js.map +1 -0
  19. package/lib/module/index.js +537 -287
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +732 -308
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/module/internal.js +24 -0
  24. package/lib/module/internal.js.map +1 -1
  25. package/lib/module/storage-hooks.js +30 -0
  26. package/lib/module/storage-hooks.js.map +1 -0
  27. package/lib/typescript/Storage.nitro.d.ts +4 -0
  28. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  29. package/lib/typescript/index.d.ts +41 -4
  30. package/lib/typescript/index.d.ts.map +1 -1
  31. package/lib/typescript/index.web.d.ts +45 -4
  32. package/lib/typescript/index.web.d.ts.map +1 -1
  33. package/lib/typescript/internal.d.ts +1 -0
  34. package/lib/typescript/internal.d.ts.map +1 -1
  35. package/lib/typescript/storage-hooks.d.ts +10 -0
  36. package/lib/typescript/storage-hooks.d.ts.map +1 -0
  37. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +4 -0
  39. package/package.json +5 -3
  40. package/src/Storage.nitro.ts +4 -0
  41. package/src/index.ts +704 -324
  42. package/src/index.web.ts +929 -346
  43. package/src/internal.ts +28 -0
  44. package/src/storage-hooks.ts +48 -0
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { useRef, useSyncExternalStore } from "react";
2
1
  import { NitroModules } from "react-native-nitro-modules";
3
2
  import type { Storage } from "./Storage.nitro";
4
3
  import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
@@ -11,6 +10,7 @@ import {
11
10
  decodeNativeBatchValue,
12
11
  serializeWithPrimitiveFastPath,
13
12
  deserializeWithPrimitiveFastPath,
13
+ toVersionToken,
14
14
  prefixKey,
15
15
  isNamespaced,
16
16
  } from "./internal";
@@ -23,6 +23,31 @@ export type Validator<T> = (value: unknown) => value is T;
23
23
  export type ExpirationConfig = {
24
24
  ttlMs: number;
25
25
  };
26
+ export type StorageVersion = string;
27
+ export type VersionedValue<T> = {
28
+ value: T;
29
+ version: StorageVersion;
30
+ };
31
+ export type StorageMetricsEvent = {
32
+ operation: string;
33
+ scope: StorageScope;
34
+ durationMs: number;
35
+ keysCount: number;
36
+ };
37
+ export type StorageMetricsObserver = (event: StorageMetricsEvent) => void;
38
+ export type StorageMetricSummary = {
39
+ count: number;
40
+ totalDurationMs: number;
41
+ avgDurationMs: number;
42
+ maxDurationMs: number;
43
+ };
44
+ export type WebSecureStorageBackend = {
45
+ getItem: (key: string) => string | null;
46
+ setItem: (key: string, value: string) => void;
47
+ removeItem: (key: string) => void;
48
+ clear: () => void;
49
+ getAllKeys: () => string[];
50
+ };
26
51
 
27
52
  export type MigrationContext = {
28
53
  scope: StorageScope;
@@ -56,11 +81,25 @@ type RawBatchPathItem = {
56
81
  _secureAccessControl?: AccessControl;
57
82
  };
58
83
 
59
- function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
60
- return item as unknown as StorageItemInternal<any>;
84
+ function asInternal<T>(item: StorageItem<T>): StorageItemInternal<T> {
85
+ return item as StorageItemInternal<T>;
86
+ }
87
+
88
+ function isUpdater<T>(
89
+ valueOrFn: T | ((prev: T) => T),
90
+ ): valueOrFn is (prev: T) => T {
91
+ return typeof valueOrFn === "function";
92
+ }
93
+
94
+ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
95
+ return Object.keys(record) as K[];
61
96
  }
62
97
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
63
- type PendingSecureWrite = { key: string; value: string | undefined };
98
+ type PendingSecureWrite = {
99
+ key: string;
100
+ value: string | undefined;
101
+ accessControl?: AccessControl;
102
+ };
64
103
 
65
104
  const registeredMigrations = new Map<number, Migration>();
66
105
  const runMicrotask =
@@ -95,6 +134,52 @@ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
95
134
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
96
135
  let secureFlushScheduled = false;
97
136
  let secureDefaultAccessControl: AccessControl = AccessControl.WhenUnlocked;
137
+ let metricsObserver: StorageMetricsObserver | undefined;
138
+ const metricsCounters = new Map<
139
+ string,
140
+ { count: number; totalDurationMs: number; maxDurationMs: number }
141
+ >();
142
+
143
+ function recordMetric(
144
+ operation: string,
145
+ scope: StorageScope,
146
+ durationMs: number,
147
+ keysCount = 1,
148
+ ): void {
149
+ const existing = metricsCounters.get(operation);
150
+ if (!existing) {
151
+ metricsCounters.set(operation, {
152
+ count: 1,
153
+ totalDurationMs: durationMs,
154
+ maxDurationMs: durationMs,
155
+ });
156
+ } else {
157
+ existing.count += 1;
158
+ existing.totalDurationMs += durationMs;
159
+ existing.maxDurationMs = Math.max(existing.maxDurationMs, durationMs);
160
+ }
161
+
162
+ metricsObserver?.({
163
+ operation,
164
+ scope,
165
+ durationMs,
166
+ keysCount,
167
+ });
168
+ }
169
+
170
+ function measureOperation<T>(
171
+ operation: string,
172
+ scope: StorageScope,
173
+ fn: () => T,
174
+ keysCount = 1,
175
+ ): T {
176
+ const start = Date.now();
177
+ try {
178
+ return fn();
179
+ } finally {
180
+ recordMetric(operation, scope, Date.now() - start, keysCount);
181
+ }
182
+ }
98
183
 
99
184
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
100
185
  return scopedListeners.get(scope)!;
@@ -185,31 +270,47 @@ function flushSecureWrites(): void {
185
270
  const writes = Array.from(pendingSecureWrites.values());
186
271
  pendingSecureWrites.clear();
187
272
 
188
- const keysToSet: string[] = [];
189
- const valuesToSet: string[] = [];
273
+ const groupedSetWrites = new Map<
274
+ AccessControl,
275
+ { keys: string[]; values: string[] }
276
+ >();
190
277
  const keysToRemove: string[] = [];
191
278
 
192
- writes.forEach(({ key, value }) => {
279
+ writes.forEach(({ key, value, accessControl }) => {
193
280
  if (value === undefined) {
194
281
  keysToRemove.push(key);
195
282
  } else {
196
- keysToSet.push(key);
197
- valuesToSet.push(value);
283
+ const resolvedAccessControl = accessControl ?? secureDefaultAccessControl;
284
+ const existingGroup = groupedSetWrites.get(resolvedAccessControl);
285
+ const group = existingGroup ?? { keys: [], values: [] };
286
+ group.keys.push(key);
287
+ group.values.push(value);
288
+ if (!existingGroup) {
289
+ groupedSetWrites.set(resolvedAccessControl, group);
290
+ }
198
291
  }
199
292
  });
200
293
 
201
294
  const storageModule = getStorageModule();
202
- storageModule.setSecureAccessControl(secureDefaultAccessControl);
203
- if (keysToSet.length > 0) {
204
- storageModule.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
205
- }
295
+ groupedSetWrites.forEach((group, accessControl) => {
296
+ storageModule.setSecureAccessControl(accessControl);
297
+ storageModule.setBatch(group.keys, group.values, StorageScope.Secure);
298
+ });
206
299
  if (keysToRemove.length > 0) {
207
300
  storageModule.removeBatch(keysToRemove, StorageScope.Secure);
208
301
  }
209
302
  }
210
303
 
211
- function scheduleSecureWrite(key: string, value: string | undefined): void {
212
- pendingSecureWrites.set(key, { key, value });
304
+ function scheduleSecureWrite(
305
+ key: string,
306
+ value: string | undefined,
307
+ accessControl?: AccessControl,
308
+ ): void {
309
+ const pendingWrite: PendingSecureWrite = { key, value };
310
+ if (accessControl !== undefined) {
311
+ pendingWrite.accessControl = accessControl;
312
+ }
313
+ pendingSecureWrites.set(key, pendingWrite);
213
314
  if (secureFlushScheduled) {
214
315
  return;
215
316
  }
@@ -323,103 +424,212 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
323
424
 
324
425
  export const storage = {
325
426
  clear: (scope: StorageScope) => {
326
- if (scope === StorageScope.Memory) {
327
- memoryStore.clear();
328
- notifyAllListeners(memoryListeners);
329
- return;
330
- }
427
+ measureOperation("storage:clear", scope, () => {
428
+ if (scope === StorageScope.Memory) {
429
+ memoryStore.clear();
430
+ notifyAllListeners(memoryListeners);
431
+ return;
432
+ }
331
433
 
332
- if (scope === StorageScope.Secure) {
333
- flushSecureWrites();
334
- pendingSecureWrites.clear();
335
- }
434
+ if (scope === StorageScope.Secure) {
435
+ flushSecureWrites();
436
+ pendingSecureWrites.clear();
437
+ }
336
438
 
337
- clearScopeRawCache(scope);
338
- getStorageModule().clear(scope);
339
- if (scope === StorageScope.Secure) {
340
- getStorageModule().clearSecureBiometric();
341
- }
439
+ clearScopeRawCache(scope);
440
+ getStorageModule().clear(scope);
441
+ });
342
442
  },
343
443
  clearAll: () => {
344
- storage.clear(StorageScope.Memory);
345
- storage.clear(StorageScope.Disk);
346
- storage.clear(StorageScope.Secure);
444
+ measureOperation(
445
+ "storage:clearAll",
446
+ StorageScope.Memory,
447
+ () => {
448
+ storage.clear(StorageScope.Memory);
449
+ storage.clear(StorageScope.Disk);
450
+ storage.clear(StorageScope.Secure);
451
+ },
452
+ 3,
453
+ );
347
454
  },
348
455
  clearNamespace: (namespace: string, scope: StorageScope) => {
349
- assertValidScope(scope);
350
- if (scope === StorageScope.Memory) {
351
- for (const key of memoryStore.keys()) {
352
- if (isNamespaced(key, namespace)) {
353
- memoryStore.delete(key);
456
+ measureOperation("storage:clearNamespace", scope, () => {
457
+ assertValidScope(scope);
458
+ if (scope === StorageScope.Memory) {
459
+ for (const key of memoryStore.keys()) {
460
+ if (isNamespaced(key, namespace)) {
461
+ memoryStore.delete(key);
462
+ }
354
463
  }
464
+ notifyAllListeners(memoryListeners);
465
+ return;
355
466
  }
356
- notifyAllListeners(memoryListeners);
357
- return;
358
- }
359
- if (scope === StorageScope.Secure) {
360
- flushSecureWrites();
361
- }
362
- const keys = getStorageModule().getAllKeys(scope);
363
- const namespacedKeys = keys.filter((k) => isNamespaced(k, namespace));
364
- if (namespacedKeys.length > 0) {
365
- getStorageModule().removeBatch(namespacedKeys, scope);
366
- namespacedKeys.forEach((k) => cacheRawValue(scope, k, undefined));
467
+
468
+ const keyPrefix = prefixKey(namespace, "");
367
469
  if (scope === StorageScope.Secure) {
368
- namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
470
+ flushSecureWrites();
369
471
  }
370
- }
472
+
473
+ clearScopeRawCache(scope);
474
+ getStorageModule().removeByPrefix(keyPrefix, scope);
475
+ });
371
476
  },
372
477
  clearBiometric: () => {
373
- getStorageModule().clearSecureBiometric();
478
+ measureOperation("storage:clearBiometric", StorageScope.Secure, () => {
479
+ getStorageModule().clearSecureBiometric();
480
+ });
374
481
  },
375
482
  has: (key: string, scope: StorageScope): boolean => {
376
- assertValidScope(scope);
377
- if (scope === StorageScope.Memory) {
378
- return memoryStore.has(key);
379
- }
380
- return getStorageModule().has(key, scope);
483
+ return measureOperation("storage:has", scope, () => {
484
+ assertValidScope(scope);
485
+ if (scope === StorageScope.Memory) {
486
+ return memoryStore.has(key);
487
+ }
488
+ return getStorageModule().has(key, scope);
489
+ });
381
490
  },
382
491
  getAllKeys: (scope: StorageScope): string[] => {
383
- assertValidScope(scope);
384
- if (scope === StorageScope.Memory) {
385
- return Array.from(memoryStore.keys());
386
- }
387
- return getStorageModule().getAllKeys(scope);
492
+ return measureOperation("storage:getAllKeys", scope, () => {
493
+ assertValidScope(scope);
494
+ if (scope === StorageScope.Memory) {
495
+ return Array.from(memoryStore.keys());
496
+ }
497
+ return getStorageModule().getAllKeys(scope);
498
+ });
499
+ },
500
+ getKeysByPrefix: (prefix: string, scope: StorageScope): string[] => {
501
+ return measureOperation("storage:getKeysByPrefix", scope, () => {
502
+ assertValidScope(scope);
503
+ if (scope === StorageScope.Memory) {
504
+ return Array.from(memoryStore.keys()).filter((key) =>
505
+ key.startsWith(prefix),
506
+ );
507
+ }
508
+ return getStorageModule().getKeysByPrefix(prefix, scope);
509
+ });
510
+ },
511
+ getByPrefix: (
512
+ prefix: string,
513
+ scope: StorageScope,
514
+ ): Record<string, string> => {
515
+ return measureOperation("storage:getByPrefix", scope, () => {
516
+ const result: Record<string, string> = {};
517
+ const keys = storage.getKeysByPrefix(prefix, scope);
518
+ if (keys.length === 0) {
519
+ return result;
520
+ }
521
+
522
+ if (scope === StorageScope.Memory) {
523
+ keys.forEach((key) => {
524
+ const value = memoryStore.get(key);
525
+ if (typeof value === "string") {
526
+ result[key] = value;
527
+ }
528
+ });
529
+ return result;
530
+ }
531
+
532
+ const values = getStorageModule().getBatch(keys, scope);
533
+ keys.forEach((key, idx) => {
534
+ const value = decodeNativeBatchValue(values[idx]);
535
+ if (value !== undefined) {
536
+ result[key] = value;
537
+ }
538
+ });
539
+ return result;
540
+ });
388
541
  },
389
542
  getAll: (scope: StorageScope): Record<string, string> => {
390
- assertValidScope(scope);
391
- const result: Record<string, string> = {};
392
- if (scope === StorageScope.Memory) {
393
- memoryStore.forEach((value, key) => {
394
- if (typeof value === "string") result[key] = value;
543
+ return measureOperation("storage:getAll", scope, () => {
544
+ assertValidScope(scope);
545
+ const result: Record<string, string> = {};
546
+ if (scope === StorageScope.Memory) {
547
+ memoryStore.forEach((value, key) => {
548
+ if (typeof value === "string") result[key] = value;
549
+ });
550
+ return result;
551
+ }
552
+ const keys = getStorageModule().getAllKeys(scope);
553
+ if (keys.length === 0) return result;
554
+ const values = getStorageModule().getBatch(keys, scope);
555
+ keys.forEach((key, idx) => {
556
+ const val = decodeNativeBatchValue(values[idx]);
557
+ if (val !== undefined) result[key] = val;
395
558
  });
396
559
  return result;
397
- }
398
- const keys = getStorageModule().getAllKeys(scope);
399
- if (keys.length === 0) return result;
400
- const values = getStorageModule().getBatch(keys, scope);
401
- keys.forEach((key, idx) => {
402
- const val = decodeNativeBatchValue(values[idx]);
403
- if (val !== undefined) result[key] = val;
404
560
  });
405
- return result;
406
561
  },
407
562
  size: (scope: StorageScope): number => {
408
- assertValidScope(scope);
409
- if (scope === StorageScope.Memory) {
410
- return memoryStore.size;
411
- }
412
- return getStorageModule().size(scope);
563
+ return measureOperation("storage:size", scope, () => {
564
+ assertValidScope(scope);
565
+ if (scope === StorageScope.Memory) {
566
+ return memoryStore.size;
567
+ }
568
+ return getStorageModule().size(scope);
569
+ });
413
570
  },
414
571
  setAccessControl: (level: AccessControl) => {
415
- secureDefaultAccessControl = level;
416
- getStorageModule().setSecureAccessControl(level);
572
+ measureOperation("storage:setAccessControl", StorageScope.Secure, () => {
573
+ secureDefaultAccessControl = level;
574
+ getStorageModule().setSecureAccessControl(level);
575
+ });
576
+ },
577
+ setSecureWritesAsync: (enabled: boolean) => {
578
+ measureOperation(
579
+ "storage:setSecureWritesAsync",
580
+ StorageScope.Secure,
581
+ () => {
582
+ getStorageModule().setSecureWritesAsync(enabled);
583
+ },
584
+ );
585
+ },
586
+ flushSecureWrites: () => {
587
+ measureOperation("storage:flushSecureWrites", StorageScope.Secure, () => {
588
+ flushSecureWrites();
589
+ });
417
590
  },
418
591
  setKeychainAccessGroup: (group: string) => {
419
- getStorageModule().setKeychainAccessGroup(group);
592
+ measureOperation(
593
+ "storage:setKeychainAccessGroup",
594
+ StorageScope.Secure,
595
+ () => {
596
+ getStorageModule().setKeychainAccessGroup(group);
597
+ },
598
+ );
599
+ },
600
+ setMetricsObserver: (observer?: StorageMetricsObserver) => {
601
+ metricsObserver = observer;
602
+ },
603
+ getMetricsSnapshot: (): Record<string, StorageMetricSummary> => {
604
+ const snapshot: Record<string, StorageMetricSummary> = {};
605
+ metricsCounters.forEach((value, key) => {
606
+ snapshot[key] = {
607
+ count: value.count,
608
+ totalDurationMs: value.totalDurationMs,
609
+ avgDurationMs:
610
+ value.count === 0 ? 0 : value.totalDurationMs / value.count,
611
+ maxDurationMs: value.maxDurationMs,
612
+ };
613
+ });
614
+ return snapshot;
615
+ },
616
+ resetMetrics: () => {
617
+ metricsCounters.clear();
420
618
  },
421
619
  };
422
620
 
621
+ export function setWebSecureStorageBackend(
622
+ _backend?: WebSecureStorageBackend,
623
+ ): void {
624
+ // Native platforms do not use web secure backends.
625
+ }
626
+
627
+ export function getWebSecureStorageBackend():
628
+ | WebSecureStorageBackend
629
+ | undefined {
630
+ return undefined;
631
+ }
632
+
423
633
  export interface StorageItemConfig<T> {
424
634
  key: string;
425
635
  scope: StorageScope;
@@ -434,12 +644,18 @@ export interface StorageItemConfig<T> {
434
644
  coalesceSecureWrites?: boolean;
435
645
  namespace?: string;
436
646
  biometric?: boolean;
647
+ biometricLevel?: BiometricLevel;
437
648
  accessControl?: AccessControl;
438
649
  }
439
650
 
440
651
  export interface StorageItem<T> {
441
652
  get: () => T;
653
+ getWithVersion: () => VersionedValue<T>;
442
654
  set: (value: T | ((prev: T) => T)) => void;
655
+ setIfVersion: (
656
+ version: StorageVersion,
657
+ value: T | ((prev: T) => T),
658
+ ) => boolean;
443
659
  delete: () => void;
444
660
  has: () => boolean;
445
661
  subscribe: (callback: () => void) => () => void;
@@ -455,6 +671,7 @@ type StorageItemInternal<T> = StorageItem<T> & {
455
671
  _hasExpiration: boolean;
456
672
  _readCacheEnabled: boolean;
457
673
  _isBiometric: boolean;
674
+ _defaultValue: T;
458
675
  _secureAccessControl?: AccessControl;
459
676
  };
460
677
 
@@ -467,6 +684,14 @@ function canUseRawBatchPath(item: RawBatchPathItem): boolean {
467
684
  );
468
685
  }
469
686
 
687
+ function canUseSecureRawBatchPath(item: RawBatchPathItem): boolean {
688
+ return (
689
+ item._hasExpiration === false &&
690
+ item._hasValidation === false &&
691
+ item._isBiometric !== true
692
+ );
693
+ }
694
+
470
695
  function defaultSerialize<T>(value: T): string {
471
696
  return serializeWithPrimitiveFastPath(value);
472
697
  }
@@ -482,8 +707,14 @@ export function createStorageItem<T = undefined>(
482
707
  const serialize = config.serialize ?? defaultSerialize;
483
708
  const deserialize = config.deserialize ?? defaultDeserialize;
484
709
  const isMemory = config.scope === StorageScope.Memory;
485
- const isBiometric =
486
- config.biometric === true && config.scope === StorageScope.Secure;
710
+ const resolvedBiometricLevel =
711
+ config.scope === StorageScope.Secure
712
+ ? (config.biometricLevel ??
713
+ (config.biometric === true
714
+ ? BiometricLevel.BiometryOnly
715
+ : BiometricLevel.None))
716
+ : BiometricLevel.None;
717
+ const isBiometric = resolvedBiometricLevel !== BiometricLevel.None;
487
718
  const secureAccessControl = config.accessControl;
488
719
  const validate = config.validate;
489
720
  const onValidationError = config.onValidationError;
@@ -496,8 +727,8 @@ export function createStorageItem<T = undefined>(
496
727
  const coalesceSecureWrites =
497
728
  config.scope === StorageScope.Secure &&
498
729
  config.coalesceSecureWrites === true &&
499
- !isBiometric &&
500
- secureAccessControl === undefined;
730
+ !isBiometric;
731
+ const defaultValue = config.defaultValue as T;
501
732
  const nonMemoryScope: NonMemoryScope | null =
502
733
  config.scope === StorageScope.Disk
503
734
  ? StorageScope.Disk
@@ -514,11 +745,13 @@ export function createStorageItem<T = undefined>(
514
745
  let lastRaw: unknown = undefined;
515
746
  let lastValue: T | undefined;
516
747
  let hasLastValue = false;
748
+ let lastExpiresAt: number | null | undefined = undefined;
517
749
 
518
750
  const invalidateParsedCache = () => {
519
751
  lastRaw = undefined;
520
752
  lastValue = undefined;
521
753
  hasLastValue = false;
754
+ lastExpiresAt = undefined;
522
755
  };
523
756
 
524
757
  const ensureSubscription = () => {
@@ -556,7 +789,7 @@ export function createStorageItem<T = undefined>(
556
789
  return undefined;
557
790
  }
558
791
  }
559
- return memoryStore.get(storageKey) as T | undefined;
792
+ return memoryStore.get(storageKey);
560
793
  }
561
794
 
562
795
  if (
@@ -584,14 +817,22 @@ export function createStorageItem<T = undefined>(
584
817
 
585
818
  const writeStoredRaw = (rawValue: string): void => {
586
819
  if (isBiometric) {
587
- getStorageModule().setSecureBiometric(storageKey, rawValue);
820
+ getStorageModule().setSecureBiometricWithLevel(
821
+ storageKey,
822
+ rawValue,
823
+ resolvedBiometricLevel,
824
+ );
588
825
  return;
589
826
  }
590
827
 
591
828
  cacheRawValue(nonMemoryScope!, storageKey, rawValue);
592
829
 
593
830
  if (coalesceSecureWrites) {
594
- scheduleSecureWrite(storageKey, rawValue);
831
+ scheduleSecureWrite(
832
+ storageKey,
833
+ rawValue,
834
+ secureAccessControl ?? secureDefaultAccessControl,
835
+ );
595
836
  return;
596
837
  }
597
838
 
@@ -614,7 +855,11 @@ export function createStorageItem<T = undefined>(
614
855
  cacheRawValue(nonMemoryScope!, storageKey, undefined);
615
856
 
616
857
  if (coalesceSecureWrites) {
617
- scheduleSecureWrite(storageKey, undefined);
858
+ scheduleSecureWrite(
859
+ storageKey,
860
+ undefined,
861
+ secureAccessControl ?? secureDefaultAccessControl,
862
+ );
618
863
  return;
619
864
  }
620
865
 
@@ -654,7 +899,7 @@ export function createStorageItem<T = undefined>(
654
899
  return onValidationError(invalidValue);
655
900
  }
656
901
 
657
- return config.defaultValue as T;
902
+ return defaultValue;
658
903
  };
659
904
 
660
905
  const ensureValidatedValue = (
@@ -667,7 +912,7 @@ export function createStorageItem<T = undefined>(
667
912
 
668
913
  const resolved = resolveInvalidValue(candidate);
669
914
  if (validate && !validate(resolved)) {
670
- return config.defaultValue as T;
915
+ return defaultValue;
671
916
  }
672
917
  if (hadStoredValue) {
673
918
  writeValueWithoutValidation(resolved);
@@ -675,39 +920,64 @@ export function createStorageItem<T = undefined>(
675
920
  return resolved;
676
921
  };
677
922
 
678
- const get = (): T => {
923
+ const getInternal = (): T => {
679
924
  const raw = readStoredRaw();
680
925
 
681
- const canUseCachedValue = !expiration && !memoryExpiration;
682
- if (canUseCachedValue && raw === lastRaw && hasLastValue) {
683
- return lastValue as T;
926
+ if (!memoryExpiration && raw === lastRaw && hasLastValue) {
927
+ if (!expiration || lastExpiresAt === null) {
928
+ return lastValue as T;
929
+ }
930
+
931
+ if (typeof lastExpiresAt === "number") {
932
+ if (lastExpiresAt > Date.now()) {
933
+ return lastValue as T;
934
+ }
935
+
936
+ removeStoredRaw();
937
+ invalidateParsedCache();
938
+ onExpired?.(storageKey);
939
+ lastValue = ensureValidatedValue(defaultValue, false);
940
+ hasLastValue = true;
941
+ return lastValue;
942
+ }
684
943
  }
685
944
 
686
945
  lastRaw = raw;
687
946
 
688
947
  if (raw === undefined) {
689
- lastValue = ensureValidatedValue(config.defaultValue, false);
948
+ lastExpiresAt = undefined;
949
+ lastValue = ensureValidatedValue(defaultValue, false);
690
950
  hasLastValue = true;
691
951
  return lastValue;
692
952
  }
693
953
 
694
954
  if (isMemory) {
955
+ lastExpiresAt = undefined;
695
956
  lastValue = ensureValidatedValue(raw, true);
696
957
  hasLastValue = true;
697
958
  return lastValue;
698
959
  }
699
960
 
700
- let deserializableRaw = raw as string;
961
+ if (typeof raw !== "string") {
962
+ lastExpiresAt = undefined;
963
+ lastValue = ensureValidatedValue(defaultValue, false);
964
+ hasLastValue = true;
965
+ return lastValue;
966
+ }
967
+
968
+ let deserializableRaw = raw;
701
969
 
702
970
  if (expiration) {
971
+ let envelopeExpiresAt: number | null = null;
703
972
  try {
704
- const parsed = JSON.parse(raw as string) as unknown;
973
+ const parsed = JSON.parse(raw) as unknown;
705
974
  if (isStoredEnvelope(parsed)) {
975
+ envelopeExpiresAt = parsed.expiresAt;
706
976
  if (parsed.expiresAt <= Date.now()) {
707
977
  removeStoredRaw();
708
978
  invalidateParsedCache();
709
979
  onExpired?.(storageKey);
710
- lastValue = ensureValidatedValue(config.defaultValue, false);
980
+ lastValue = ensureValidatedValue(defaultValue, false);
711
981
  hasLastValue = true;
712
982
  return lastValue;
713
983
  }
@@ -717,6 +987,9 @@ export function createStorageItem<T = undefined>(
717
987
  } catch {
718
988
  // Keep backward compatibility with legacy raw values.
719
989
  }
990
+ lastExpiresAt = envelopeExpiresAt;
991
+ } else {
992
+ lastExpiresAt = undefined;
720
993
  }
721
994
 
722
995
  lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
@@ -724,44 +997,74 @@ export function createStorageItem<T = undefined>(
724
997
  return lastValue;
725
998
  };
726
999
 
1000
+ const getCurrentVersion = (): StorageVersion => {
1001
+ const raw = readStoredRaw();
1002
+ return toVersionToken(raw);
1003
+ };
1004
+
1005
+ const get = (): T =>
1006
+ measureOperation("item:get", config.scope, () => getInternal());
1007
+
1008
+ const getWithVersion = (): VersionedValue<T> =>
1009
+ measureOperation("item:getWithVersion", config.scope, () => ({
1010
+ value: getInternal(),
1011
+ version: getCurrentVersion(),
1012
+ }));
1013
+
727
1014
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
728
- const currentValue = get();
729
- const newValue =
730
- typeof valueOrFn === "function"
731
- ? (valueOrFn as (prev: T) => T)(currentValue)
1015
+ measureOperation("item:set", config.scope, () => {
1016
+ const newValue = isUpdater(valueOrFn)
1017
+ ? valueOrFn(getInternal())
732
1018
  : valueOrFn;
733
1019
 
734
- invalidateParsedCache();
1020
+ invalidateParsedCache();
735
1021
 
736
- if (validate && !validate(newValue)) {
737
- throw new Error(
738
- `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
739
- );
740
- }
1022
+ if (validate && !validate(newValue)) {
1023
+ throw new Error(
1024
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
1025
+ );
1026
+ }
741
1027
 
742
- writeValueWithoutValidation(newValue);
1028
+ writeValueWithoutValidation(newValue);
1029
+ });
743
1030
  };
744
1031
 
1032
+ const setIfVersion = (
1033
+ version: StorageVersion,
1034
+ valueOrFn: T | ((prev: T) => T),
1035
+ ): boolean =>
1036
+ measureOperation("item:setIfVersion", config.scope, () => {
1037
+ const currentVersion = getCurrentVersion();
1038
+ if (currentVersion !== version) {
1039
+ return false;
1040
+ }
1041
+ set(valueOrFn);
1042
+ return true;
1043
+ });
1044
+
745
1045
  const deleteItem = (): void => {
746
- invalidateParsedCache();
1046
+ measureOperation("item:delete", config.scope, () => {
1047
+ invalidateParsedCache();
747
1048
 
748
- if (isMemory) {
749
- if (memoryExpiration) {
750
- memoryExpiration.delete(storageKey);
1049
+ if (isMemory) {
1050
+ if (memoryExpiration) {
1051
+ memoryExpiration.delete(storageKey);
1052
+ }
1053
+ memoryStore.delete(storageKey);
1054
+ notifyKeyListeners(memoryListeners, storageKey);
1055
+ return;
751
1056
  }
752
- memoryStore.delete(storageKey);
753
- notifyKeyListeners(memoryListeners, storageKey);
754
- return;
755
- }
756
1057
 
757
- removeStoredRaw();
1058
+ removeStoredRaw();
1059
+ });
758
1060
  };
759
1061
 
760
- const hasItem = (): boolean => {
761
- if (isMemory) return memoryStore.has(storageKey);
762
- if (isBiometric) return getStorageModule().hasSecureBiometric(storageKey);
763
- return getStorageModule().has(storageKey, config.scope);
764
- };
1062
+ const hasItem = (): boolean =>
1063
+ measureOperation("item:has", config.scope, () => {
1064
+ if (isMemory) return memoryStore.has(storageKey);
1065
+ if (isBiometric) return getStorageModule().hasSecureBiometric(storageKey);
1066
+ return getStorageModule().has(storageKey, config.scope);
1067
+ });
765
1068
 
766
1069
  const subscribe = (callback: () => void): (() => void) => {
767
1070
  ensureSubscription();
@@ -780,7 +1083,9 @@ export function createStorageItem<T = undefined>(
780
1083
 
781
1084
  const storageItem: StorageItemInternal<T> = {
782
1085
  get,
1086
+ getWithVersion,
783
1087
  set,
1088
+ setIfVersion,
784
1089
  delete: deleteItem,
785
1090
  has: hasItem,
786
1091
  subscribe,
@@ -794,54 +1099,18 @@ export function createStorageItem<T = undefined>(
794
1099
  _hasExpiration: expiration !== undefined,
795
1100
  _readCacheEnabled: readCache,
796
1101
  _isBiometric: isBiometric,
797
- _secureAccessControl: secureAccessControl,
1102
+ _defaultValue: defaultValue,
1103
+ ...(secureAccessControl !== undefined
1104
+ ? { _secureAccessControl: secureAccessControl }
1105
+ : {}),
798
1106
  scope: config.scope,
799
1107
  key: storageKey,
800
1108
  };
801
1109
 
802
- return storageItem as StorageItem<T>;
803
- }
804
-
805
- export function useStorage<T>(
806
- item: StorageItem<T>,
807
- ): [T, (value: T | ((prev: T) => T)) => void] {
808
- const value = useSyncExternalStore(item.subscribe, item.get, item.get);
809
- return [value, item.set];
810
- }
811
-
812
- export function useStorageSelector<T, TSelected>(
813
- item: StorageItem<T>,
814
- selector: (value: T) => TSelected,
815
- isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
816
- ): [TSelected, (value: T | ((prev: T) => T)) => void] {
817
- const selectedRef = useRef<
818
- { hasValue: false } | { hasValue: true; value: TSelected }
819
- >({
820
- hasValue: false,
821
- });
822
-
823
- const getSelectedSnapshot = () => {
824
- const nextSelected = selector(item.get());
825
- const current = selectedRef.current;
826
- if (current.hasValue && isEqual(current.value, nextSelected)) {
827
- return current.value;
828
- }
829
-
830
- selectedRef.current = { hasValue: true, value: nextSelected };
831
- return nextSelected;
832
- };
833
-
834
- const selectedValue = useSyncExternalStore(
835
- item.subscribe,
836
- getSelectedSnapshot,
837
- getSelectedSnapshot,
838
- );
839
- return [selectedValue, item.set];
1110
+ return storageItem;
840
1111
  }
841
1112
 
842
- export function useSetStorage<T>(item: StorageItem<T>) {
843
- return item.set;
844
- }
1113
+ export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
845
1114
 
846
1115
  type BatchReadItem<T> = Pick<
847
1116
  StorageItem<T>,
@@ -851,6 +1120,7 @@ type BatchReadItem<T> = Pick<
851
1120
  _hasExpiration?: boolean;
852
1121
  _readCacheEnabled?: boolean;
853
1122
  _isBiometric?: boolean;
1123
+ _defaultValue?: unknown;
854
1124
  _secureAccessControl?: AccessControl;
855
1125
  };
856
1126
  type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
@@ -864,113 +1134,179 @@ export function getBatch(
864
1134
  items: readonly BatchReadItem<unknown>[],
865
1135
  scope: StorageScope,
866
1136
  ): unknown[] {
867
- assertBatchScope(items, scope);
1137
+ return measureOperation(
1138
+ "batch:get",
1139
+ scope,
1140
+ () => {
1141
+ assertBatchScope(items, scope);
868
1142
 
869
- if (scope === StorageScope.Memory) {
870
- return items.map((item) => item.get());
871
- }
1143
+ if (scope === StorageScope.Memory) {
1144
+ return items.map((item) => item.get());
1145
+ }
872
1146
 
873
- const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
874
- if (!useRawBatchPath) {
875
- return items.map((item) => item.get());
876
- }
877
- const useBatchCache = items.every((item) => item._readCacheEnabled === true);
1147
+ const useRawBatchPath = items.every((item) =>
1148
+ scope === StorageScope.Secure
1149
+ ? canUseSecureRawBatchPath(item)
1150
+ : canUseRawBatchPath(item),
1151
+ );
1152
+ if (!useRawBatchPath) {
1153
+ return items.map((item) => item.get());
1154
+ }
878
1155
 
879
- const rawValues = new Array<string | undefined>(items.length);
880
- const keysToFetch: string[] = [];
881
- const keyIndexes: number[] = [];
1156
+ const rawValues = new Array<string | undefined>(items.length);
1157
+ const keysToFetch: string[] = [];
1158
+ const keyIndexes: number[] = [];
882
1159
 
883
- items.forEach((item, index) => {
884
- if (scope === StorageScope.Secure) {
885
- if (hasPendingSecureWrite(item.key)) {
886
- rawValues[index] = readPendingSecureWrite(item.key);
887
- return;
888
- }
889
- }
1160
+ items.forEach((item, index) => {
1161
+ if (scope === StorageScope.Secure) {
1162
+ if (hasPendingSecureWrite(item.key)) {
1163
+ rawValues[index] = readPendingSecureWrite(item.key);
1164
+ return;
1165
+ }
1166
+ }
890
1167
 
891
- if (useBatchCache) {
892
- if (hasCachedRawValue(scope, item.key)) {
893
- rawValues[index] = readCachedRawValue(scope, item.key);
894
- return;
895
- }
896
- }
1168
+ if (item._readCacheEnabled === true) {
1169
+ if (hasCachedRawValue(scope, item.key)) {
1170
+ rawValues[index] = readCachedRawValue(scope, item.key);
1171
+ return;
1172
+ }
1173
+ }
897
1174
 
898
- keysToFetch.push(item.key);
899
- keyIndexes.push(index);
900
- });
1175
+ keysToFetch.push(item.key);
1176
+ keyIndexes.push(index);
1177
+ });
901
1178
 
902
- if (keysToFetch.length > 0) {
903
- const fetchedValues = getStorageModule()
904
- .getBatch(keysToFetch, scope)
905
- .map((value) => decodeNativeBatchValue(value));
1179
+ if (keysToFetch.length > 0) {
1180
+ const fetchedValues = getStorageModule()
1181
+ .getBatch(keysToFetch, scope)
1182
+ .map((value) => decodeNativeBatchValue(value));
906
1183
 
907
- fetchedValues.forEach((value, index) => {
908
- const key = keysToFetch[index];
909
- const targetIndex = keyIndexes[index];
910
- rawValues[targetIndex] = value;
911
- cacheRawValue(scope, key, value);
912
- });
913
- }
1184
+ fetchedValues.forEach((value, index) => {
1185
+ const key = keysToFetch[index];
1186
+ const targetIndex = keyIndexes[index];
1187
+ if (key === undefined || targetIndex === undefined) {
1188
+ return;
1189
+ }
1190
+ rawValues[targetIndex] = value;
1191
+ cacheRawValue(scope, key, value);
1192
+ });
1193
+ }
914
1194
 
915
- return items.map((item, index) => {
916
- const raw = rawValues[index];
917
- if (raw === undefined) {
918
- return item.get();
919
- }
920
- return item.deserialize(raw);
921
- });
1195
+ return items.map((item, index) => {
1196
+ const raw = rawValues[index];
1197
+ if (raw === undefined) {
1198
+ return asInternal(item as StorageItem<unknown>)._defaultValue;
1199
+ }
1200
+ return item.deserialize(raw);
1201
+ });
1202
+ },
1203
+ items.length,
1204
+ );
922
1205
  }
923
1206
 
924
1207
  export function setBatch<T>(
925
1208
  items: readonly StorageBatchSetItem<T>[],
926
1209
  scope: StorageScope,
927
1210
  ): void {
928
- assertBatchScope(
929
- items.map((batchEntry) => batchEntry.item),
1211
+ measureOperation(
1212
+ "batch:set",
930
1213
  scope,
931
- );
1214
+ () => {
1215
+ assertBatchScope(
1216
+ items.map((batchEntry) => batchEntry.item),
1217
+ scope,
1218
+ );
932
1219
 
933
- if (scope === StorageScope.Memory) {
934
- items.forEach(({ item, value }) => item.set(value));
935
- return;
936
- }
1220
+ if (scope === StorageScope.Memory) {
1221
+ items.forEach(({ item, value }) => item.set(value));
1222
+ return;
1223
+ }
937
1224
 
938
- const useRawBatchPath = items.every(({ item }) =>
939
- canUseRawBatchPath(asInternal(item)),
940
- );
941
- if (!useRawBatchPath) {
942
- items.forEach(({ item, value }) => item.set(value));
943
- return;
944
- }
1225
+ if (scope === StorageScope.Secure) {
1226
+ const secureEntries = items.map(({ item, value }) => ({
1227
+ item,
1228
+ value,
1229
+ internal: asInternal(item),
1230
+ }));
1231
+ const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
1232
+ canUseSecureRawBatchPath(internal),
1233
+ );
1234
+ if (!canUseSecureBatchPath) {
1235
+ items.forEach(({ item, value }) => item.set(value));
1236
+ return;
1237
+ }
945
1238
 
946
- const keys = items.map((entry) => entry.item.key);
947
- const values = items.map((entry) => entry.item.serialize(entry.value));
1239
+ flushSecureWrites();
1240
+ const storageModule = getStorageModule();
1241
+ const groupedByAccessControl = new Map<
1242
+ number,
1243
+ { keys: string[]; values: string[] }
1244
+ >();
1245
+
1246
+ secureEntries.forEach(({ item, value, internal }) => {
1247
+ const accessControl =
1248
+ internal._secureAccessControl ?? secureDefaultAccessControl;
1249
+ const existingGroup = groupedByAccessControl.get(accessControl);
1250
+ const group = existingGroup ?? { keys: [], values: [] };
1251
+ group.keys.push(item.key);
1252
+ group.values.push(item.serialize(value));
1253
+ if (!existingGroup) {
1254
+ groupedByAccessControl.set(accessControl, group);
1255
+ }
1256
+ });
1257
+
1258
+ groupedByAccessControl.forEach((group, accessControl) => {
1259
+ storageModule.setSecureAccessControl(accessControl);
1260
+ storageModule.setBatch(group.keys, group.values, scope);
1261
+ group.keys.forEach((key, index) =>
1262
+ cacheRawValue(scope, key, group.values[index]),
1263
+ );
1264
+ });
1265
+ return;
1266
+ }
948
1267
 
949
- if (scope === StorageScope.Secure) {
950
- flushSecureWrites();
951
- getStorageModule().setSecureAccessControl(secureDefaultAccessControl);
952
- }
953
- getStorageModule().setBatch(keys, values, scope);
954
- keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1268
+ const useRawBatchPath = items.every(({ item }) =>
1269
+ canUseRawBatchPath(asInternal(item)),
1270
+ );
1271
+ if (!useRawBatchPath) {
1272
+ items.forEach(({ item, value }) => item.set(value));
1273
+ return;
1274
+ }
1275
+
1276
+ const keys = items.map((entry) => entry.item.key);
1277
+ const values = items.map((entry) => entry.item.serialize(entry.value));
1278
+
1279
+ getStorageModule().setBatch(keys, values, scope);
1280
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1281
+ },
1282
+ items.length,
1283
+ );
955
1284
  }
956
1285
 
957
1286
  export function removeBatch(
958
1287
  items: readonly BatchRemoveItem[],
959
1288
  scope: StorageScope,
960
1289
  ): void {
961
- assertBatchScope(items, scope);
1290
+ measureOperation(
1291
+ "batch:remove",
1292
+ scope,
1293
+ () => {
1294
+ assertBatchScope(items, scope);
962
1295
 
963
- if (scope === StorageScope.Memory) {
964
- items.forEach((item) => item.delete());
965
- return;
966
- }
1296
+ if (scope === StorageScope.Memory) {
1297
+ items.forEach((item) => item.delete());
1298
+ return;
1299
+ }
967
1300
 
968
- const keys = items.map((item) => item.key);
969
- if (scope === StorageScope.Secure) {
970
- flushSecureWrites();
971
- }
972
- getStorageModule().removeBatch(keys, scope);
973
- keys.forEach((key) => cacheRawValue(scope, key, undefined));
1301
+ const keys = items.map((item) => item.key);
1302
+ if (scope === StorageScope.Secure) {
1303
+ flushSecureWrites();
1304
+ }
1305
+ getStorageModule().removeBatch(keys, scope);
1306
+ keys.forEach((key) => cacheRawValue(scope, key, undefined));
1307
+ },
1308
+ items.length,
1309
+ );
974
1310
  }
975
1311
 
976
1312
  export function registerMigration(version: number, migration: Migration): void {
@@ -988,92 +1324,124 @@ export function registerMigration(version: number, migration: Migration): void {
988
1324
  export function migrateToLatest(
989
1325
  scope: StorageScope = StorageScope.Disk,
990
1326
  ): number {
991
- assertValidScope(scope);
992
- const currentVersion = readMigrationVersion(scope);
993
- const versions = Array.from(registeredMigrations.keys())
994
- .filter((version) => version > currentVersion)
995
- .sort((a, b) => a - b);
1327
+ return measureOperation("migration:run", scope, () => {
1328
+ assertValidScope(scope);
1329
+ const currentVersion = readMigrationVersion(scope);
1330
+ const versions = Array.from(registeredMigrations.keys())
1331
+ .filter((version) => version > currentVersion)
1332
+ .sort((a, b) => a - b);
1333
+
1334
+ let appliedVersion = currentVersion;
1335
+ const context: MigrationContext = {
1336
+ scope,
1337
+ getRaw: (key) => getRawValue(key, scope),
1338
+ setRaw: (key, value) => setRawValue(key, value, scope),
1339
+ removeRaw: (key) => removeRawValue(key, scope),
1340
+ };
996
1341
 
997
- let appliedVersion = currentVersion;
998
- const context: MigrationContext = {
999
- scope,
1000
- getRaw: (key) => getRawValue(key, scope),
1001
- setRaw: (key, value) => setRawValue(key, value, scope),
1002
- removeRaw: (key) => removeRawValue(key, scope),
1003
- };
1342
+ versions.forEach((version) => {
1343
+ const migration = registeredMigrations.get(version);
1344
+ if (!migration) {
1345
+ return;
1346
+ }
1347
+ migration(context);
1348
+ writeMigrationVersion(scope, version);
1349
+ appliedVersion = version;
1350
+ });
1004
1351
 
1005
- versions.forEach((version) => {
1006
- const migration = registeredMigrations.get(version);
1007
- if (!migration) {
1008
- return;
1009
- }
1010
- migration(context);
1011
- writeMigrationVersion(scope, version);
1012
- appliedVersion = version;
1352
+ return appliedVersion;
1013
1353
  });
1014
-
1015
- return appliedVersion;
1016
1354
  }
1017
1355
 
1018
1356
  export function runTransaction<T>(
1019
1357
  scope: StorageScope,
1020
1358
  transaction: (context: TransactionContext) => T,
1021
1359
  ): T {
1022
- assertValidScope(scope);
1023
- if (scope === StorageScope.Secure) {
1024
- flushSecureWrites();
1025
- }
1360
+ return measureOperation("transaction:run", scope, () => {
1361
+ assertValidScope(scope);
1362
+ if (scope === StorageScope.Secure) {
1363
+ flushSecureWrites();
1364
+ }
1026
1365
 
1027
- const rollback = new Map<string, string | undefined>();
1366
+ const rollback = new Map<string, string | undefined>();
1028
1367
 
1029
- const rememberRollback = (key: string) => {
1030
- if (rollback.has(key)) {
1031
- return;
1032
- }
1033
- rollback.set(key, getRawValue(key, scope));
1034
- };
1368
+ const rememberRollback = (key: string) => {
1369
+ if (rollback.has(key)) {
1370
+ return;
1371
+ }
1372
+ rollback.set(key, getRawValue(key, scope));
1373
+ };
1035
1374
 
1036
- const tx: TransactionContext = {
1037
- scope,
1038
- getRaw: (key) => getRawValue(key, scope),
1039
- setRaw: (key, value) => {
1040
- rememberRollback(key);
1041
- setRawValue(key, value, scope);
1042
- },
1043
- removeRaw: (key) => {
1044
- rememberRollback(key);
1045
- removeRawValue(key, scope);
1046
- },
1047
- getItem: (item) => {
1048
- assertBatchScope([item], scope);
1049
- return item.get();
1050
- },
1051
- setItem: (item, value) => {
1052
- assertBatchScope([item], scope);
1053
- rememberRollback(item.key);
1054
- item.set(value);
1055
- },
1056
- removeItem: (item) => {
1057
- assertBatchScope([item], scope);
1058
- rememberRollback(item.key);
1059
- item.delete();
1060
- },
1061
- };
1375
+ const tx: TransactionContext = {
1376
+ scope,
1377
+ getRaw: (key) => getRawValue(key, scope),
1378
+ setRaw: (key, value) => {
1379
+ rememberRollback(key);
1380
+ setRawValue(key, value, scope);
1381
+ },
1382
+ removeRaw: (key) => {
1383
+ rememberRollback(key);
1384
+ removeRawValue(key, scope);
1385
+ },
1386
+ getItem: (item) => {
1387
+ assertBatchScope([item], scope);
1388
+ return item.get();
1389
+ },
1390
+ setItem: (item, value) => {
1391
+ assertBatchScope([item], scope);
1392
+ rememberRollback(item.key);
1393
+ item.set(value);
1394
+ },
1395
+ removeItem: (item) => {
1396
+ assertBatchScope([item], scope);
1397
+ rememberRollback(item.key);
1398
+ item.delete();
1399
+ },
1400
+ };
1062
1401
 
1063
- try {
1064
- return transaction(tx);
1065
- } catch (error) {
1066
- Array.from(rollback.entries())
1067
- .reverse()
1068
- .forEach(([key, previousValue]) => {
1069
- if (previousValue === undefined) {
1070
- removeRawValue(key, scope);
1071
- } else {
1072
- setRawValue(key, previousValue, scope);
1402
+ try {
1403
+ return transaction(tx);
1404
+ } catch (error) {
1405
+ const rollbackEntries = Array.from(rollback.entries()).reverse();
1406
+ if (scope === StorageScope.Memory) {
1407
+ rollbackEntries.forEach(([key, previousValue]) => {
1408
+ if (previousValue === undefined) {
1409
+ removeRawValue(key, scope);
1410
+ } else {
1411
+ setRawValue(key, previousValue, scope);
1412
+ }
1413
+ });
1414
+ } else {
1415
+ const keysToSet: string[] = [];
1416
+ const valuesToSet: string[] = [];
1417
+ const keysToRemove: string[] = [];
1418
+
1419
+ rollbackEntries.forEach(([key, previousValue]) => {
1420
+ if (previousValue === undefined) {
1421
+ keysToRemove.push(key);
1422
+ } else {
1423
+ keysToSet.push(key);
1424
+ valuesToSet.push(previousValue);
1425
+ }
1426
+ });
1427
+
1428
+ if (scope === StorageScope.Secure) {
1429
+ flushSecureWrites();
1073
1430
  }
1074
- });
1075
- throw error;
1076
- }
1431
+ if (keysToSet.length > 0) {
1432
+ getStorageModule().setBatch(keysToSet, valuesToSet, scope);
1433
+ keysToSet.forEach((key, index) =>
1434
+ cacheRawValue(scope, key, valuesToSet[index]),
1435
+ );
1436
+ }
1437
+ if (keysToRemove.length > 0) {
1438
+ getStorageModule().removeBatch(keysToRemove, scope);
1439
+ keysToRemove.forEach((key) => cacheRawValue(scope, key, undefined));
1440
+ }
1441
+ }
1442
+ throw error;
1443
+ }
1444
+ });
1077
1445
  }
1078
1446
 
1079
1447
  export type SecureAuthStorageConfig<K extends string = string> = Record<
@@ -1081,6 +1449,7 @@ export type SecureAuthStorageConfig<K extends string = string> = Record<
1081
1449
  {
1082
1450
  ttlMs?: number;
1083
1451
  biometric?: boolean;
1452
+ biometricLevel?: BiometricLevel;
1084
1453
  accessControl?: AccessControl;
1085
1454
  }
1086
1455
  >;
@@ -1090,20 +1459,31 @@ export function createSecureAuthStorage<K extends string>(
1090
1459
  options?: { namespace?: string },
1091
1460
  ): Record<K, StorageItem<string>> {
1092
1461
  const ns = options?.namespace ?? "auth";
1093
- const result = {} as Record<K, StorageItem<string>>;
1462
+ const result: Partial<Record<K, StorageItem<string>>> = {};
1094
1463
 
1095
- for (const key of Object.keys(config) as K[]) {
1464
+ for (const key of typedKeys(config)) {
1096
1465
  const itemConfig = config[key];
1466
+ const expirationConfig =
1467
+ itemConfig.ttlMs !== undefined ? { ttlMs: itemConfig.ttlMs } : undefined;
1097
1468
  result[key] = createStorageItem<string>({
1098
1469
  key,
1099
1470
  scope: StorageScope.Secure,
1100
1471
  defaultValue: "",
1101
1472
  namespace: ns,
1102
- biometric: itemConfig.biometric,
1103
- accessControl: itemConfig.accessControl,
1104
- expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
1473
+ ...(itemConfig.biometric !== undefined
1474
+ ? { biometric: itemConfig.biometric }
1475
+ : {}),
1476
+ ...(itemConfig.biometricLevel !== undefined
1477
+ ? { biometricLevel: itemConfig.biometricLevel }
1478
+ : {}),
1479
+ ...(itemConfig.accessControl !== undefined
1480
+ ? { accessControl: itemConfig.accessControl }
1481
+ : {}),
1482
+ ...(expirationConfig !== undefined
1483
+ ? { expiration: expirationConfig }
1484
+ : {}),
1105
1485
  });
1106
1486
  }
1107
1487
 
1108
- return result;
1488
+ return result as Record<K, StorageItem<string>>;
1109
1489
  }