react-native-nitro-storage 0.3.0 → 0.3.2

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 (53) hide show
  1. package/README.md +594 -247
  2. package/android/CMakeLists.txt +2 -0
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +102 -11
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +16 -0
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +154 -34
  6. package/android/src/main/java/com/nitrostorage/NitroStoragePackage.kt +2 -2
  7. package/cpp/bindings/HybridStorage.cpp +176 -21
  8. package/cpp/bindings/HybridStorage.hpp +29 -2
  9. package/cpp/core/NativeStorageAdapter.hpp +16 -0
  10. package/ios/IOSStorageAdapterCpp.hpp +20 -0
  11. package/ios/IOSStorageAdapterCpp.mm +239 -32
  12. package/lib/commonjs/Storage.types.js +23 -1
  13. package/lib/commonjs/Storage.types.js.map +1 -1
  14. package/lib/commonjs/index.js +292 -75
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/index.web.js +473 -86
  17. package/lib/commonjs/index.web.js.map +1 -1
  18. package/lib/commonjs/internal.js +10 -0
  19. package/lib/commonjs/internal.js.map +1 -1
  20. package/lib/commonjs/storage-hooks.js +36 -0
  21. package/lib/commonjs/storage-hooks.js.map +1 -0
  22. package/lib/module/Storage.types.js +22 -0
  23. package/lib/module/Storage.types.js.map +1 -1
  24. package/lib/module/index.js +264 -75
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/index.web.js +445 -86
  27. package/lib/module/index.web.js.map +1 -1
  28. package/lib/module/internal.js +8 -0
  29. package/lib/module/internal.js.map +1 -1
  30. package/lib/module/storage-hooks.js +30 -0
  31. package/lib/module/storage-hooks.js.map +1 -0
  32. package/lib/typescript/Storage.nitro.d.ts +12 -0
  33. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  34. package/lib/typescript/Storage.types.d.ts +20 -0
  35. package/lib/typescript/Storage.types.d.ts.map +1 -1
  36. package/lib/typescript/index.d.ts +33 -10
  37. package/lib/typescript/index.d.ts.map +1 -1
  38. package/lib/typescript/index.web.d.ts +45 -10
  39. package/lib/typescript/index.web.d.ts.map +1 -1
  40. package/lib/typescript/internal.d.ts +2 -0
  41. package/lib/typescript/internal.d.ts.map +1 -1
  42. package/lib/typescript/storage-hooks.d.ts +10 -0
  43. package/lib/typescript/storage-hooks.d.ts.map +1 -0
  44. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +12 -0
  45. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +12 -0
  46. package/package.json +8 -3
  47. package/src/Storage.nitro.ts +13 -2
  48. package/src/Storage.types.ts +22 -0
  49. package/src/index.ts +382 -123
  50. package/src/index.web.ts +618 -134
  51. package/src/internal.ts +14 -4
  52. package/src/migration.ts +1 -1
  53. package/src/storage-hooks.ts +48 -0
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { useRef, useSyncExternalStore } from "react";
2
1
  import { NitroModules } from "react-native-nitro-modules";
3
2
  import type { Storage } from "./Storage.nitro";
4
- import { StorageScope } from "./Storage.types";
3
+ import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
5
4
  import {
6
5
  MIGRATION_VERSION_KEY,
7
6
  type StoredEnvelope,
@@ -11,9 +10,11 @@ import {
11
10
  decodeNativeBatchValue,
12
11
  serializeWithPrimitiveFastPath,
13
12
  deserializeWithPrimitiveFastPath,
13
+ prefixKey,
14
+ isNamespaced,
14
15
  } from "./internal";
15
16
 
16
- export { StorageScope } from "./Storage.types";
17
+ export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
17
18
  export type { Storage } from "./Storage.nitro";
18
19
  export { migrateFromMMKV } from "./migration";
19
20
 
@@ -37,15 +38,36 @@ export type TransactionContext = {
37
38
  setRaw: (key: string, value: string) => void;
38
39
  removeRaw: (key: string) => void;
39
40
  getItem: <T>(item: Pick<StorageItem<T>, "scope" | "key" | "get">) => T;
40
- setItem: <T>(item: Pick<StorageItem<T>, "scope" | "key" | "set">, value: T) => void;
41
- removeItem: (item: Pick<StorageItem<unknown>, "scope" | "key" | "delete">) => void;
41
+ setItem: <T>(
42
+ item: Pick<StorageItem<T>, "scope" | "key" | "set">,
43
+ value: T,
44
+ ) => void;
45
+ removeItem: (
46
+ item: Pick<StorageItem<unknown>, "scope" | "key" | "delete">,
47
+ ) => void;
42
48
  };
43
49
 
44
50
  type KeyListenerRegistry = Map<string, Set<() => void>>;
45
51
  type RawBatchPathItem = {
46
52
  _hasValidation?: boolean;
47
53
  _hasExpiration?: boolean;
54
+ _isBiometric?: boolean;
55
+ _secureAccessControl?: AccessControl;
48
56
  };
57
+
58
+ function asInternal<T>(item: StorageItem<T>): StorageItemInternal<T> {
59
+ return item as StorageItemInternal<T>;
60
+ }
61
+
62
+ function isUpdater<T>(
63
+ valueOrFn: T | ((prev: T) => T),
64
+ ): valueOrFn is (prev: T) => T {
65
+ return typeof valueOrFn === "function";
66
+ }
67
+
68
+ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
69
+ return Object.keys(record) as K[];
70
+ }
49
71
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
50
72
  type PendingSecureWrite = { key: string; value: string | undefined };
51
73
 
@@ -73,28 +95,37 @@ const scopedListeners = new Map<NonMemoryScope, KeyListenerRegistry>([
73
95
  [StorageScope.Secure, new Map()],
74
96
  ]);
75
97
  const scopedUnsubscribers = new Map<NonMemoryScope, () => void>();
76
- const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>([
77
- [StorageScope.Disk, new Map()],
78
- [StorageScope.Secure, new Map()],
79
- ]);
98
+ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
99
+ [
100
+ [StorageScope.Disk, new Map()],
101
+ [StorageScope.Secure, new Map()],
102
+ ],
103
+ );
80
104
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
81
105
  let secureFlushScheduled = false;
106
+ let secureDefaultAccessControl: AccessControl = AccessControl.WhenUnlocked;
82
107
 
83
108
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
84
109
  return scopedListeners.get(scope)!;
85
110
  }
86
111
 
87
- function getScopeRawCache(scope: NonMemoryScope): Map<string, string | undefined> {
112
+ function getScopeRawCache(
113
+ scope: NonMemoryScope,
114
+ ): Map<string, string | undefined> {
88
115
  return scopedRawCache.get(scope)!;
89
116
  }
90
117
 
91
- function cacheRawValue(scope: NonMemoryScope, key: string, value: string | undefined): void {
118
+ function cacheRawValue(
119
+ scope: NonMemoryScope,
120
+ key: string,
121
+ value: string | undefined,
122
+ ): void {
92
123
  getScopeRawCache(scope).set(key, value);
93
124
  }
94
125
 
95
126
  function readCachedRawValue(
96
127
  scope: NonMemoryScope,
97
- key: string
128
+ key: string,
98
129
  ): string | undefined {
99
130
  return getScopeRawCache(scope).get(key);
100
131
  }
@@ -120,7 +151,7 @@ function notifyAllListeners(registry: KeyListenerRegistry): void {
120
151
  function addKeyListener(
121
152
  registry: KeyListenerRegistry,
122
153
  key: string,
123
- listener: () => void
154
+ listener: () => void,
124
155
  ): () => void {
125
156
  let listeners = registry.get(key);
126
157
  if (!listeners) {
@@ -177,6 +208,7 @@ function flushSecureWrites(): void {
177
208
  });
178
209
 
179
210
  const storageModule = getStorageModule();
211
+ storageModule.setSecureAccessControl(secureDefaultAccessControl);
180
212
  if (keysToSet.length > 0) {
181
213
  storageModule.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
182
214
  }
@@ -260,6 +292,7 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
260
292
  if (scope === StorageScope.Secure) {
261
293
  flushSecureWrites();
262
294
  clearPendingSecureWrite(key);
295
+ getStorageModule().setSecureAccessControl(secureDefaultAccessControl);
263
296
  }
264
297
 
265
298
  getStorageModule().set(key, value, scope);
@@ -318,6 +351,81 @@ export const storage = {
318
351
  storage.clear(StorageScope.Disk);
319
352
  storage.clear(StorageScope.Secure);
320
353
  },
354
+ clearNamespace: (namespace: string, scope: StorageScope) => {
355
+ assertValidScope(scope);
356
+ if (scope === StorageScope.Memory) {
357
+ for (const key of memoryStore.keys()) {
358
+ if (isNamespaced(key, namespace)) {
359
+ memoryStore.delete(key);
360
+ }
361
+ }
362
+ notifyAllListeners(memoryListeners);
363
+ return;
364
+ }
365
+
366
+ const keyPrefix = prefixKey(namespace, "");
367
+ if (scope === StorageScope.Secure) {
368
+ flushSecureWrites();
369
+ }
370
+
371
+ clearScopeRawCache(scope);
372
+ getStorageModule().removeByPrefix(keyPrefix, scope);
373
+ },
374
+ clearBiometric: () => {
375
+ getStorageModule().clearSecureBiometric();
376
+ },
377
+ has: (key: string, scope: StorageScope): boolean => {
378
+ assertValidScope(scope);
379
+ if (scope === StorageScope.Memory) {
380
+ return memoryStore.has(key);
381
+ }
382
+ return getStorageModule().has(key, scope);
383
+ },
384
+ getAllKeys: (scope: StorageScope): string[] => {
385
+ assertValidScope(scope);
386
+ if (scope === StorageScope.Memory) {
387
+ return Array.from(memoryStore.keys());
388
+ }
389
+ return getStorageModule().getAllKeys(scope);
390
+ },
391
+ getAll: (scope: StorageScope): Record<string, string> => {
392
+ assertValidScope(scope);
393
+ const result: Record<string, string> = {};
394
+ if (scope === StorageScope.Memory) {
395
+ memoryStore.forEach((value, key) => {
396
+ if (typeof value === "string") result[key] = value;
397
+ });
398
+ return result;
399
+ }
400
+ const keys = getStorageModule().getAllKeys(scope);
401
+ if (keys.length === 0) return result;
402
+ const values = getStorageModule().getBatch(keys, scope);
403
+ keys.forEach((key, idx) => {
404
+ const val = decodeNativeBatchValue(values[idx]);
405
+ if (val !== undefined) result[key] = val;
406
+ });
407
+ return result;
408
+ },
409
+ size: (scope: StorageScope): number => {
410
+ assertValidScope(scope);
411
+ if (scope === StorageScope.Memory) {
412
+ return memoryStore.size;
413
+ }
414
+ return getStorageModule().size(scope);
415
+ },
416
+ setAccessControl: (level: AccessControl) => {
417
+ secureDefaultAccessControl = level;
418
+ getStorageModule().setSecureAccessControl(level);
419
+ },
420
+ setSecureWritesAsync: (enabled: boolean) => {
421
+ getStorageModule().setSecureWritesAsync(enabled);
422
+ },
423
+ flushSecureWrites: () => {
424
+ flushSecureWrites();
425
+ },
426
+ setKeychainAccessGroup: (group: string) => {
427
+ getStorageModule().setKeychainAccessGroup(group);
428
+ },
321
429
  };
322
430
 
323
431
  export interface StorageItemConfig<T> {
@@ -329,27 +437,50 @@ export interface StorageItemConfig<T> {
329
437
  validate?: Validator<T>;
330
438
  onValidationError?: (invalidValue: unknown) => T;
331
439
  expiration?: ExpirationConfig;
440
+ onExpired?: (key: string) => void;
332
441
  readCache?: boolean;
333
442
  coalesceSecureWrites?: boolean;
443
+ namespace?: string;
444
+ biometric?: boolean;
445
+ accessControl?: AccessControl;
334
446
  }
335
447
 
336
448
  export interface StorageItem<T> {
337
449
  get: () => T;
338
450
  set: (value: T | ((prev: T) => T)) => void;
339
451
  delete: () => void;
452
+ has: () => boolean;
340
453
  subscribe: (callback: () => void) => () => void;
341
454
  serialize: (value: T) => string;
342
455
  deserialize: (value: string) => T;
343
- _triggerListeners: () => void;
344
- _hasValidation?: boolean;
345
- _hasExpiration?: boolean;
346
- _readCacheEnabled?: boolean;
347
456
  scope: StorageScope;
348
457
  key: string;
349
458
  }
350
459
 
460
+ type StorageItemInternal<T> = StorageItem<T> & {
461
+ _triggerListeners: () => void;
462
+ _hasValidation: boolean;
463
+ _hasExpiration: boolean;
464
+ _readCacheEnabled: boolean;
465
+ _isBiometric: boolean;
466
+ _secureAccessControl?: AccessControl;
467
+ };
468
+
351
469
  function canUseRawBatchPath(item: RawBatchPathItem): boolean {
352
- return item._hasExpiration === false && item._hasValidation === false;
470
+ return (
471
+ item._hasExpiration === false &&
472
+ item._hasValidation === false &&
473
+ item._isBiometric !== true &&
474
+ item._secureAccessControl === undefined
475
+ );
476
+ }
477
+
478
+ function canUseSecureRawBatchPath(item: RawBatchPathItem): boolean {
479
+ return (
480
+ item._hasExpiration === false &&
481
+ item._hasValidation === false &&
482
+ item._isBiometric !== true
483
+ );
353
484
  }
354
485
 
355
486
  function defaultSerialize<T>(value: T): string {
@@ -361,19 +492,29 @@ function defaultDeserialize<T>(value: string): T {
361
492
  }
362
493
 
363
494
  export function createStorageItem<T = undefined>(
364
- config: StorageItemConfig<T>
495
+ config: StorageItemConfig<T>,
365
496
  ): StorageItem<T> {
497
+ const storageKey = prefixKey(config.namespace, config.key);
366
498
  const serialize = config.serialize ?? defaultSerialize;
367
499
  const deserialize = config.deserialize ?? defaultDeserialize;
368
500
  const isMemory = config.scope === StorageScope.Memory;
501
+ const isBiometric =
502
+ config.biometric === true && config.scope === StorageScope.Secure;
503
+ const secureAccessControl = config.accessControl;
369
504
  const validate = config.validate;
370
505
  const onValidationError = config.onValidationError;
371
506
  const expiration = config.expiration;
507
+ const onExpired = config.onExpired;
372
508
  const expirationTtlMs = expiration?.ttlMs;
373
- const memoryExpiration = expiration && isMemory ? new Map<string, number>() : null;
509
+ const memoryExpiration =
510
+ expiration && isMemory ? new Map<string, number>() : null;
374
511
  const readCache = !isMemory && config.readCache === true;
375
512
  const coalesceSecureWrites =
376
- config.scope === StorageScope.Secure && config.coalesceSecureWrites === true;
513
+ config.scope === StorageScope.Secure &&
514
+ config.coalesceSecureWrites === true &&
515
+ !isBiometric &&
516
+ secureAccessControl === undefined;
517
+ const defaultValue = config.defaultValue as T;
377
518
  const nonMemoryScope: NonMemoryScope | null =
378
519
  config.scope === StorageScope.Disk
379
520
  ? StorageScope.Disk
@@ -390,11 +531,13 @@ export function createStorageItem<T = undefined>(
390
531
  let lastRaw: unknown = undefined;
391
532
  let lastValue: T | undefined;
392
533
  let hasLastValue = false;
534
+ let lastExpiresAt: number | null | undefined = undefined;
393
535
 
394
536
  const invalidateParsedCache = () => {
395
537
  lastRaw = undefined;
396
538
  lastValue = undefined;
397
539
  hasLastValue = false;
540
+ lastExpiresAt = undefined;
398
541
  };
399
542
 
400
543
  const ensureSubscription = () => {
@@ -408,80 +551,106 @@ export function createStorageItem<T = undefined>(
408
551
  };
409
552
 
410
553
  if (isMemory) {
411
- unsubscribe = addKeyListener(memoryListeners, config.key, listener);
554
+ unsubscribe = addKeyListener(memoryListeners, storageKey, listener);
412
555
  return;
413
556
  }
414
557
 
415
558
  ensureNativeScopeSubscription(nonMemoryScope!);
416
- unsubscribe = addKeyListener(getScopedListeners(nonMemoryScope!), config.key, listener);
559
+ unsubscribe = addKeyListener(
560
+ getScopedListeners(nonMemoryScope!),
561
+ storageKey,
562
+ listener,
563
+ );
417
564
  };
418
565
 
419
566
  const readStoredRaw = (): unknown => {
420
567
  if (isMemory) {
421
568
  if (memoryExpiration) {
422
- const expiresAt = memoryExpiration.get(config.key);
569
+ const expiresAt = memoryExpiration.get(storageKey);
423
570
  if (expiresAt !== undefined && expiresAt <= Date.now()) {
424
- memoryExpiration.delete(config.key);
425
- memoryStore.delete(config.key);
426
- notifyKeyListeners(memoryListeners, config.key);
571
+ memoryExpiration.delete(storageKey);
572
+ memoryStore.delete(storageKey);
573
+ notifyKeyListeners(memoryListeners, storageKey);
574
+ onExpired?.(storageKey);
427
575
  return undefined;
428
576
  }
429
577
  }
430
- return memoryStore.get(config.key) as T | undefined;
578
+ return memoryStore.get(storageKey);
431
579
  }
432
580
 
433
- if (nonMemoryScope === StorageScope.Secure && hasPendingSecureWrite(config.key)) {
434
- return readPendingSecureWrite(config.key);
581
+ if (
582
+ nonMemoryScope === StorageScope.Secure &&
583
+ !isBiometric &&
584
+ hasPendingSecureWrite(storageKey)
585
+ ) {
586
+ return readPendingSecureWrite(storageKey);
435
587
  }
436
588
 
437
589
  if (readCache) {
438
- if (hasCachedRawValue(nonMemoryScope!, config.key)) {
439
- return readCachedRawValue(nonMemoryScope!, config.key);
590
+ if (hasCachedRawValue(nonMemoryScope!, storageKey)) {
591
+ return readCachedRawValue(nonMemoryScope!, storageKey);
440
592
  }
441
593
  }
442
594
 
443
- const raw = getStorageModule().get(config.key, config.scope);
444
- cacheRawValue(nonMemoryScope!, config.key, raw);
595
+ if (isBiometric) {
596
+ return getStorageModule().getSecureBiometric(storageKey);
597
+ }
598
+
599
+ const raw = getStorageModule().get(storageKey, config.scope);
600
+ cacheRawValue(nonMemoryScope!, storageKey, raw);
445
601
  return raw;
446
602
  };
447
603
 
448
604
  const writeStoredRaw = (rawValue: string): void => {
449
- cacheRawValue(nonMemoryScope!, config.key, rawValue);
605
+ if (isBiometric) {
606
+ getStorageModule().setSecureBiometric(storageKey, rawValue);
607
+ return;
608
+ }
609
+
610
+ cacheRawValue(nonMemoryScope!, storageKey, rawValue);
450
611
 
451
612
  if (coalesceSecureWrites) {
452
- scheduleSecureWrite(config.key, rawValue);
613
+ scheduleSecureWrite(storageKey, rawValue);
453
614
  return;
454
615
  }
455
616
 
456
617
  if (nonMemoryScope === StorageScope.Secure) {
457
- clearPendingSecureWrite(config.key);
618
+ clearPendingSecureWrite(storageKey);
619
+ getStorageModule().setSecureAccessControl(
620
+ secureAccessControl ?? secureDefaultAccessControl,
621
+ );
458
622
  }
459
623
 
460
- getStorageModule().set(config.key, rawValue, config.scope);
624
+ getStorageModule().set(storageKey, rawValue, config.scope);
461
625
  };
462
626
 
463
627
  const removeStoredRaw = (): void => {
464
- cacheRawValue(nonMemoryScope!, config.key, undefined);
628
+ if (isBiometric) {
629
+ getStorageModule().deleteSecureBiometric(storageKey);
630
+ return;
631
+ }
632
+
633
+ cacheRawValue(nonMemoryScope!, storageKey, undefined);
465
634
 
466
635
  if (coalesceSecureWrites) {
467
- scheduleSecureWrite(config.key, undefined);
636
+ scheduleSecureWrite(storageKey, undefined);
468
637
  return;
469
638
  }
470
639
 
471
640
  if (nonMemoryScope === StorageScope.Secure) {
472
- clearPendingSecureWrite(config.key);
641
+ clearPendingSecureWrite(storageKey);
473
642
  }
474
643
 
475
- getStorageModule().remove(config.key, config.scope);
644
+ getStorageModule().remove(storageKey, config.scope);
476
645
  };
477
646
 
478
647
  const writeValueWithoutValidation = (value: T): void => {
479
648
  if (isMemory) {
480
649
  if (memoryExpiration) {
481
- memoryExpiration.set(config.key, Date.now() + (expirationTtlMs ?? 0));
650
+ memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
482
651
  }
483
- memoryStore.set(config.key, value);
484
- notifyKeyListeners(memoryListeners, config.key);
652
+ memoryStore.set(storageKey, value);
653
+ notifyKeyListeners(memoryListeners, storageKey);
485
654
  return;
486
655
  }
487
656
 
@@ -504,12 +673,12 @@ export function createStorageItem<T = undefined>(
504
673
  return onValidationError(invalidValue);
505
674
  }
506
675
 
507
- return config.defaultValue as T;
676
+ return defaultValue;
508
677
  };
509
678
 
510
679
  const ensureValidatedValue = (
511
680
  candidate: unknown,
512
- hadStoredValue: boolean
681
+ hadStoredValue: boolean,
513
682
  ): T => {
514
683
  if (!validate || validate(candidate)) {
515
684
  return candidate as T;
@@ -517,7 +686,7 @@ export function createStorageItem<T = undefined>(
517
686
 
518
687
  const resolved = resolveInvalidValue(candidate);
519
688
  if (validate && !validate(resolved)) {
520
- return config.defaultValue as T;
689
+ return defaultValue;
521
690
  }
522
691
  if (hadStoredValue) {
523
692
  writeValueWithoutValidation(resolved);
@@ -528,35 +697,61 @@ export function createStorageItem<T = undefined>(
528
697
  const get = (): T => {
529
698
  const raw = readStoredRaw();
530
699
 
531
- const canUseCachedValue = !expiration && !memoryExpiration;
532
- if (canUseCachedValue && raw === lastRaw && hasLastValue) {
533
- return lastValue as T;
700
+ if (!memoryExpiration && raw === lastRaw && hasLastValue) {
701
+ if (!expiration || lastExpiresAt === null) {
702
+ return lastValue as T;
703
+ }
704
+
705
+ if (typeof lastExpiresAt === "number") {
706
+ if (lastExpiresAt > Date.now()) {
707
+ return lastValue as T;
708
+ }
709
+
710
+ removeStoredRaw();
711
+ invalidateParsedCache();
712
+ onExpired?.(storageKey);
713
+ lastValue = ensureValidatedValue(defaultValue, false);
714
+ hasLastValue = true;
715
+ return lastValue;
716
+ }
534
717
  }
535
718
 
536
719
  lastRaw = raw;
537
720
 
538
721
  if (raw === undefined) {
539
- lastValue = ensureValidatedValue(config.defaultValue, false);
722
+ lastExpiresAt = undefined;
723
+ lastValue = ensureValidatedValue(defaultValue, false);
540
724
  hasLastValue = true;
541
725
  return lastValue;
542
726
  }
543
727
 
544
728
  if (isMemory) {
729
+ lastExpiresAt = undefined;
545
730
  lastValue = ensureValidatedValue(raw, true);
546
731
  hasLastValue = true;
547
732
  return lastValue;
548
733
  }
549
734
 
550
- let deserializableRaw = raw as string;
735
+ if (typeof raw !== "string") {
736
+ lastExpiresAt = undefined;
737
+ lastValue = ensureValidatedValue(defaultValue, false);
738
+ hasLastValue = true;
739
+ return lastValue;
740
+ }
741
+
742
+ let deserializableRaw = raw;
551
743
 
552
744
  if (expiration) {
745
+ let envelopeExpiresAt: number | null = null;
553
746
  try {
554
- const parsed = JSON.parse(raw as string) as unknown;
747
+ const parsed = JSON.parse(raw) as unknown;
555
748
  if (isStoredEnvelope(parsed)) {
749
+ envelopeExpiresAt = parsed.expiresAt;
556
750
  if (parsed.expiresAt <= Date.now()) {
557
751
  removeStoredRaw();
558
752
  invalidateParsedCache();
559
- lastValue = ensureValidatedValue(config.defaultValue, false);
753
+ onExpired?.(storageKey);
754
+ lastValue = ensureValidatedValue(defaultValue, false);
560
755
  hasLastValue = true;
561
756
  return lastValue;
562
757
  }
@@ -566,6 +761,9 @@ export function createStorageItem<T = undefined>(
566
761
  } catch {
567
762
  // Keep backward compatibility with legacy raw values.
568
763
  }
764
+ lastExpiresAt = envelopeExpiresAt;
765
+ } else {
766
+ lastExpiresAt = undefined;
569
767
  }
570
768
 
571
769
  lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
@@ -574,17 +772,13 @@ export function createStorageItem<T = undefined>(
574
772
  };
575
773
 
576
774
  const set = (valueOrFn: T | ((prev: T) => T)): void => {
577
- const currentValue = get();
578
- const newValue =
579
- typeof valueOrFn === "function"
580
- ? (valueOrFn as (prev: T) => T)(currentValue)
581
- : valueOrFn;
775
+ const newValue = isUpdater(valueOrFn) ? valueOrFn(get()) : valueOrFn;
582
776
 
583
777
  invalidateParsedCache();
584
778
 
585
779
  if (validate && !validate(newValue)) {
586
780
  throw new Error(
587
- `Validation failed for key "${config.key}" in scope "${StorageScope[config.scope]}".`
781
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
588
782
  );
589
783
  }
590
784
 
@@ -596,16 +790,22 @@ export function createStorageItem<T = undefined>(
596
790
 
597
791
  if (isMemory) {
598
792
  if (memoryExpiration) {
599
- memoryExpiration.delete(config.key);
793
+ memoryExpiration.delete(storageKey);
600
794
  }
601
- memoryStore.delete(config.key);
602
- notifyKeyListeners(memoryListeners, config.key);
795
+ memoryStore.delete(storageKey);
796
+ notifyKeyListeners(memoryListeners, storageKey);
603
797
  return;
604
798
  }
605
799
 
606
800
  removeStoredRaw();
607
801
  };
608
802
 
803
+ const hasItem = (): boolean => {
804
+ if (isMemory) return memoryStore.has(storageKey);
805
+ if (isBiometric) return getStorageModule().hasSecureBiometric(storageKey);
806
+ return getStorageModule().has(storageKey, config.scope);
807
+ };
808
+
609
809
  const subscribe = (callback: () => void): (() => void) => {
610
810
  ensureSubscription();
611
811
  listeners.add(callback);
@@ -621,10 +821,11 @@ export function createStorageItem<T = undefined>(
621
821
  };
622
822
  };
623
823
 
624
- const storageItem: StorageItem<T> = {
824
+ const storageItem: StorageItemInternal<T> = {
625
825
  get,
626
826
  set,
627
827
  delete: deleteItem,
828
+ has: hasItem,
628
829
  subscribe,
629
830
  serialize,
630
831
  deserialize,
@@ -635,62 +836,29 @@ export function createStorageItem<T = undefined>(
635
836
  _hasValidation: validate !== undefined,
636
837
  _hasExpiration: expiration !== undefined,
637
838
  _readCacheEnabled: readCache,
839
+ _isBiometric: isBiometric,
840
+ ...(secureAccessControl !== undefined
841
+ ? { _secureAccessControl: secureAccessControl }
842
+ : {}),
638
843
  scope: config.scope,
639
- key: config.key,
844
+ key: storageKey,
640
845
  };
641
846
 
642
847
  return storageItem;
643
848
  }
644
849
 
645
- export function useStorage<T>(
646
- item: StorageItem<T>
647
- ): [T, (value: T | ((prev: T) => T)) => void] {
648
- const value = useSyncExternalStore(item.subscribe, item.get, item.get);
649
- return [value, item.set];
650
- }
651
-
652
- export function useStorageSelector<T, TSelected>(
653
- item: StorageItem<T>,
654
- selector: (value: T) => TSelected,
655
- isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is
656
- ): [TSelected, (value: T | ((prev: T) => T)) => void] {
657
- const selectedRef = useRef<{ hasValue: false } | { hasValue: true; value: TSelected }>({
658
- hasValue: false,
659
- });
660
-
661
- const getSelectedSnapshot = () => {
662
- const nextSelected = selector(item.get());
663
- const current = selectedRef.current;
664
- if (current.hasValue && isEqual(current.value, nextSelected)) {
665
- return current.value;
666
- }
667
-
668
- selectedRef.current = { hasValue: true, value: nextSelected };
669
- return nextSelected;
670
- };
671
-
672
- const selectedValue = useSyncExternalStore(
673
- item.subscribe,
674
- getSelectedSnapshot,
675
- getSelectedSnapshot
676
- );
677
- return [selectedValue, item.set];
678
- }
679
-
680
- export function useSetStorage<T>(item: StorageItem<T>) {
681
- return item.set;
682
- }
850
+ export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
683
851
 
684
852
  type BatchReadItem<T> = Pick<
685
853
  StorageItem<T>,
686
- | "key"
687
- | "scope"
688
- | "get"
689
- | "deserialize"
690
- | "_hasValidation"
691
- | "_hasExpiration"
692
- | "_readCacheEnabled"
693
- >;
854
+ "key" | "scope" | "get" | "deserialize"
855
+ > & {
856
+ _hasValidation?: boolean;
857
+ _hasExpiration?: boolean;
858
+ _readCacheEnabled?: boolean;
859
+ _isBiometric?: boolean;
860
+ _secureAccessControl?: AccessControl;
861
+ };
694
862
  type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
695
863
 
696
864
  export type StorageBatchSetItem<T> = {
@@ -700,7 +868,7 @@ export type StorageBatchSetItem<T> = {
700
868
 
701
869
  export function getBatch(
702
870
  items: readonly BatchReadItem<unknown>[],
703
- scope: StorageScope
871
+ scope: StorageScope,
704
872
  ): unknown[] {
705
873
  assertBatchScope(items, scope);
706
874
 
@@ -708,7 +876,11 @@ export function getBatch(
708
876
  return items.map((item) => item.get());
709
877
  }
710
878
 
711
- const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
879
+ const useRawBatchPath = items.every((item) =>
880
+ scope === StorageScope.Secure
881
+ ? canUseSecureRawBatchPath(item)
882
+ : canUseRawBatchPath(item),
883
+ );
712
884
  if (!useRawBatchPath) {
713
885
  return items.map((item) => item.get());
714
886
  }
@@ -745,6 +917,9 @@ export function getBatch(
745
917
  fetchedValues.forEach((value, index) => {
746
918
  const key = keysToFetch[index];
747
919
  const targetIndex = keyIndexes[index];
920
+ if (key === undefined || targetIndex === undefined) {
921
+ return;
922
+ }
748
923
  rawValues[targetIndex] = value;
749
924
  cacheRawValue(scope, key, value);
750
925
  });
@@ -761,11 +936,11 @@ export function getBatch(
761
936
 
762
937
  export function setBatch<T>(
763
938
  items: readonly StorageBatchSetItem<T>[],
764
- scope: StorageScope
939
+ scope: StorageScope,
765
940
  ): void {
766
941
  assertBatchScope(
767
942
  items.map((batchEntry) => batchEntry.item),
768
- scope
943
+ scope,
769
944
  );
770
945
 
771
946
  if (scope === StorageScope.Memory) {
@@ -773,7 +948,52 @@ export function setBatch<T>(
773
948
  return;
774
949
  }
775
950
 
776
- const useRawBatchPath = items.every(({ item }) => canUseRawBatchPath(item));
951
+ if (scope === StorageScope.Secure) {
952
+ const secureEntries = items.map(({ item, value }) => ({
953
+ item,
954
+ value,
955
+ internal: asInternal(item),
956
+ }));
957
+ const canUseSecureBatchPath = secureEntries.every(({ internal }) =>
958
+ canUseSecureRawBatchPath(internal),
959
+ );
960
+ if (!canUseSecureBatchPath) {
961
+ items.forEach(({ item, value }) => item.set(value));
962
+ return;
963
+ }
964
+
965
+ flushSecureWrites();
966
+ const storageModule = getStorageModule();
967
+ const groupedByAccessControl = new Map<
968
+ number,
969
+ { keys: string[]; values: string[] }
970
+ >();
971
+
972
+ secureEntries.forEach(({ item, value, internal }) => {
973
+ const accessControl =
974
+ internal._secureAccessControl ?? secureDefaultAccessControl;
975
+ const existingGroup = groupedByAccessControl.get(accessControl);
976
+ const group = existingGroup ?? { keys: [], values: [] };
977
+ group.keys.push(item.key);
978
+ group.values.push(item.serialize(value));
979
+ if (!existingGroup) {
980
+ groupedByAccessControl.set(accessControl, group);
981
+ }
982
+ });
983
+
984
+ groupedByAccessControl.forEach((group, accessControl) => {
985
+ storageModule.setSecureAccessControl(accessControl);
986
+ storageModule.setBatch(group.keys, group.values, scope);
987
+ group.keys.forEach((key, index) =>
988
+ cacheRawValue(scope, key, group.values[index]),
989
+ );
990
+ });
991
+ return;
992
+ }
993
+
994
+ const useRawBatchPath = items.every(({ item }) =>
995
+ canUseRawBatchPath(asInternal(item)),
996
+ );
777
997
  if (!useRawBatchPath) {
778
998
  items.forEach(({ item, value }) => item.set(value));
779
999
  return;
@@ -782,16 +1002,13 @@ export function setBatch<T>(
782
1002
  const keys = items.map((entry) => entry.item.key);
783
1003
  const values = items.map((entry) => entry.item.serialize(entry.value));
784
1004
 
785
- if (scope === StorageScope.Secure) {
786
- flushSecureWrites();
787
- }
788
1005
  getStorageModule().setBatch(keys, values, scope);
789
1006
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
790
1007
  }
791
1008
 
792
1009
  export function removeBatch(
793
1010
  items: readonly BatchRemoveItem[],
794
- scope: StorageScope
1011
+ scope: StorageScope,
795
1012
  ): void {
796
1013
  assertBatchScope(items, scope);
797
1014
 
@@ -820,7 +1037,9 @@ export function registerMigration(version: number, migration: Migration): void {
820
1037
  registeredMigrations.set(version, migration);
821
1038
  }
822
1039
 
823
- export function migrateToLatest(scope: StorageScope = StorageScope.Disk): number {
1040
+ export function migrateToLatest(
1041
+ scope: StorageScope = StorageScope.Disk,
1042
+ ): number {
824
1043
  assertValidScope(scope);
825
1044
  const currentVersion = readMigrationVersion(scope);
826
1045
  const versions = Array.from(registeredMigrations.keys())
@@ -850,7 +1069,7 @@ export function migrateToLatest(scope: StorageScope = StorageScope.Disk): number
850
1069
 
851
1070
  export function runTransaction<T>(
852
1071
  scope: StorageScope,
853
- transaction: (context: TransactionContext) => T
1072
+ transaction: (context: TransactionContext) => T,
854
1073
  ): T {
855
1074
  assertValidScope(scope);
856
1075
  if (scope === StorageScope.Secure) {
@@ -908,3 +1127,43 @@ export function runTransaction<T>(
908
1127
  throw error;
909
1128
  }
910
1129
  }
1130
+
1131
+ export type SecureAuthStorageConfig<K extends string = string> = Record<
1132
+ K,
1133
+ {
1134
+ ttlMs?: number;
1135
+ biometric?: boolean;
1136
+ accessControl?: AccessControl;
1137
+ }
1138
+ >;
1139
+
1140
+ export function createSecureAuthStorage<K extends string>(
1141
+ config: SecureAuthStorageConfig<K>,
1142
+ options?: { namespace?: string },
1143
+ ): Record<K, StorageItem<string>> {
1144
+ const ns = options?.namespace ?? "auth";
1145
+ const result: Partial<Record<K, StorageItem<string>>> = {};
1146
+
1147
+ for (const key of typedKeys(config)) {
1148
+ const itemConfig = config[key];
1149
+ const expirationConfig =
1150
+ itemConfig.ttlMs !== undefined ? { ttlMs: itemConfig.ttlMs } : undefined;
1151
+ result[key] = createStorageItem<string>({
1152
+ key,
1153
+ scope: StorageScope.Secure,
1154
+ defaultValue: "",
1155
+ namespace: ns,
1156
+ ...(itemConfig.biometric !== undefined
1157
+ ? { biometric: itemConfig.biometric }
1158
+ : {}),
1159
+ ...(itemConfig.accessControl !== undefined
1160
+ ? { accessControl: itemConfig.accessControl }
1161
+ : {}),
1162
+ ...(expirationConfig !== undefined
1163
+ ? { expiration: expirationConfig }
1164
+ : {}),
1165
+ });
1166
+ }
1167
+
1168
+ return result as Record<K, StorageItem<string>>;
1169
+ }