react-native-nitro-storage 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +254 -945
  2. package/SECURITY.md +26 -0
  3. package/docs/api-reference.md +281 -0
  4. package/docs/batch-transactions-migrations.md +200 -0
  5. package/docs/benchmarks.md +37 -0
  6. package/docs/mmkv-migration.md +80 -0
  7. package/docs/react-hooks.md +113 -0
  8. package/docs/recipes.md +302 -0
  9. package/docs/secure-storage.md +190 -0
  10. package/docs/web-backends.md +141 -0
  11. package/lib/commonjs/index.js +265 -14
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +220 -11
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/storage-events.js +117 -0
  16. package/lib/commonjs/storage-events.js.map +1 -0
  17. package/lib/commonjs/storage-runtime.js.map +1 -1
  18. package/lib/module/index.js +265 -14
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/index.web.js +220 -11
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/module/storage-events.js +112 -0
  23. package/lib/module/storage-events.js.map +1 -0
  24. package/lib/module/storage-runtime.js.map +1 -1
  25. package/lib/typescript/index.d.ts +19 -2
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/index.web.d.ts +19 -2
  28. package/lib/typescript/index.web.d.ts.map +1 -1
  29. package/lib/typescript/storage-events.d.ts +37 -0
  30. package/lib/typescript/storage-events.d.ts.map +1 -0
  31. package/lib/typescript/storage-runtime.d.ts +32 -0
  32. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  33. package/package.json +25 -11
  34. package/src/index.ts +601 -14
  35. package/src/index.web.ts +535 -22
  36. package/src/storage-events.ts +184 -0
  37. package/src/storage-runtime.ts +35 -0
package/src/index.ts CHANGED
@@ -21,18 +21,39 @@ import type {
21
21
  import {
22
22
  getStorageErrorCode,
23
23
  isLockedStorageErrorCode,
24
+ type SecureStorageMetadata,
25
+ type SecurityCapabilities,
24
26
  type StorageCapabilities,
25
27
  type StorageErrorCode,
26
28
  } from "./storage-runtime";
29
+ import {
30
+ StorageEventRegistry,
31
+ type StorageBatchChangeEvent,
32
+ type StorageChangeEvent,
33
+ type StorageChangeOperation,
34
+ type StorageChangeSource,
35
+ type StorageEventListener,
36
+ type StorageKeyChangeEvent,
37
+ } from "./storage-events";
27
38
 
28
39
  export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
29
40
  export type { Storage } from "./Storage.nitro";
30
41
  export { migrateFromMMKV } from "./migration";
31
42
  export {
32
43
  getStorageErrorCode,
44
+ type SecureStorageMetadata,
45
+ type SecurityCapabilities,
33
46
  type StorageCapabilities,
34
47
  type StorageErrorCode,
35
48
  } from "./storage-runtime";
49
+ export type {
50
+ StorageBatchChangeEvent,
51
+ StorageChangeEvent,
52
+ StorageChangeOperation,
53
+ StorageChangeSource,
54
+ StorageEventListener,
55
+ StorageKeyChangeEvent,
56
+ } from "./storage-events";
36
57
  export type {
37
58
  WebStorageBackend,
38
59
  WebStorageChangeEvent,
@@ -61,6 +82,14 @@ export type StorageMetricSummary = {
61
82
  avgDurationMs: number;
62
83
  maxDurationMs: number;
63
84
  };
85
+ export type StorageSelectorListener<TSelected> = (
86
+ value: TSelected,
87
+ previousValue: TSelected,
88
+ ) => void;
89
+ export type StorageSelectorSubscribeOptions<TSelected> = {
90
+ isEqual?: (previousValue: TSelected, nextValue: TSelected) => boolean;
91
+ fireImmediately?: boolean;
92
+ };
64
93
  export type MigrationContext = {
65
94
  scope: StorageScope;
66
95
  getRaw: (key: string) => string | undefined;
@@ -157,11 +186,18 @@ let diskWritesAsync = false;
157
186
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
158
187
  let secureFlushScheduled = false;
159
188
  let secureDefaultAccessControl: AccessControl = AccessControl.WhenUnlocked;
189
+ const suppressedNativeEvents = new Map<NonMemoryScope, Map<string, number>>([
190
+ [StorageScope.Disk, new Map()],
191
+ [StorageScope.Secure, new Map()],
192
+ ]);
160
193
  let metricsObserver: StorageMetricsObserver | undefined;
194
+ let eventObserver: StorageEventListener | undefined;
161
195
  const metricsCounters = new Map<
162
196
  string,
163
197
  { count: number; totalDurationMs: number; maxDurationMs: number }
164
198
  >();
199
+ const storageEvents = new StorageEventRegistry();
200
+ const nativeSecureBackend = "platform-secure-storage";
165
201
 
166
202
  function recordMetric(
167
203
  operation: string,
@@ -240,6 +276,28 @@ function clearScopeRawCache(scope: NonMemoryScope): void {
240
276
  getScopeRawCache(scope).clear();
241
277
  }
242
278
 
279
+ function suppressNativeEvent(scope: NonMemoryScope, key: string): void {
280
+ const suppressedEvents = suppressedNativeEvents.get(scope)!;
281
+ suppressedEvents.set(key, (suppressedEvents.get(key) ?? 0) + 1);
282
+ }
283
+
284
+ function consumeSuppressedNativeEvent(
285
+ scope: NonMemoryScope,
286
+ key: string,
287
+ ): boolean {
288
+ const suppressedEvents = suppressedNativeEvents.get(scope)!;
289
+ const count = suppressedEvents.get(key);
290
+ if (count === undefined) {
291
+ return false;
292
+ }
293
+ if (count <= 1) {
294
+ suppressedEvents.delete(key);
295
+ } else {
296
+ suppressedEvents.set(key, count - 1);
297
+ }
298
+ return true;
299
+ }
300
+
243
301
  function notifyKeyListeners(registry: KeyListenerRegistry, key: string): void {
244
302
  const listeners = registry.get(key);
245
303
  if (listeners) {
@@ -281,6 +339,99 @@ function addKeyListener(
281
339
  };
282
340
  }
283
341
 
342
+ function getEventRawValue(
343
+ scope: StorageScope,
344
+ key: string,
345
+ ): string | undefined {
346
+ if (scope === StorageScope.Memory) {
347
+ const value = memoryStore.get(key);
348
+ return typeof value === "string" ? value : undefined;
349
+ }
350
+
351
+ return getRawValue(key, scope);
352
+ }
353
+
354
+ function createKeyChange(
355
+ scope: StorageScope,
356
+ key: string,
357
+ oldValue: string | undefined,
358
+ newValue: string | undefined,
359
+ operation: StorageChangeOperation,
360
+ source: StorageChangeSource,
361
+ ): StorageKeyChangeEvent {
362
+ return {
363
+ type: "key",
364
+ scope,
365
+ key,
366
+ oldValue,
367
+ newValue,
368
+ operation,
369
+ source,
370
+ };
371
+ }
372
+
373
+ function hasStorageChangeObservers(scope: StorageScope): boolean {
374
+ return storageEvents.hasListeners(scope) || eventObserver !== undefined;
375
+ }
376
+
377
+ function emitKeyChange(
378
+ scope: StorageScope,
379
+ key: string,
380
+ oldValue: string | undefined,
381
+ newValue: string | undefined,
382
+ operation: StorageChangeOperation,
383
+ source: StorageChangeSource,
384
+ ): void {
385
+ if (
386
+ source === "native" &&
387
+ operation !== "external" &&
388
+ scope !== StorageScope.Memory &&
389
+ scopedUnsubscribers.has(scope)
390
+ ) {
391
+ suppressNativeEvent(scope, key);
392
+ }
393
+ const event = createKeyChange(
394
+ scope,
395
+ key,
396
+ oldValue,
397
+ newValue,
398
+ operation,
399
+ source,
400
+ );
401
+ storageEvents.emitKey(event);
402
+ eventObserver?.(event);
403
+ }
404
+
405
+ function emitBatchChange(
406
+ scope: StorageScope,
407
+ operation: StorageChangeOperation,
408
+ source: StorageChangeSource,
409
+ changes: StorageKeyChangeEvent[],
410
+ ): void {
411
+ if (changes.length === 0) {
412
+ return;
413
+ }
414
+
415
+ if (
416
+ source === "native" &&
417
+ operation !== "external" &&
418
+ scope !== StorageScope.Memory &&
419
+ scopedUnsubscribers.has(scope)
420
+ ) {
421
+ changes.forEach((change) => suppressNativeEvent(scope, change.key));
422
+ }
423
+
424
+ const event: StorageBatchChangeEvent = {
425
+ type: "batch",
426
+ scope,
427
+ operation,
428
+ source,
429
+ changes,
430
+ };
431
+ storageEvents.emitBatch(event);
432
+ eventObserver?.(event);
433
+ }
434
+
284
435
  function readPendingSecureWrite(key: string): string | undefined {
285
436
  return pendingSecureWrites.get(key)?.value;
286
437
  }
@@ -433,15 +584,27 @@ function ensureNativeScopeSubscription(scope: NonMemoryScope): void {
433
584
  return;
434
585
  }
435
586
 
587
+ const oldValue = readCachedRawValue(scope, key);
436
588
  cacheRawValue(scope, key, value);
437
589
  notifyKeyListeners(getScopedListeners(scope), key);
590
+ if (consumeSuppressedNativeEvent(scope, key)) {
591
+ return;
592
+ }
593
+ emitKeyChange(scope, key, oldValue, value, "external", "native");
438
594
  });
439
- scopedUnsubscribers.set(scope, unsubscribe);
595
+ scopedUnsubscribers.set(
596
+ scope,
597
+ typeof unsubscribe === "function" ? unsubscribe : () => {},
598
+ );
440
599
  }
441
600
 
442
601
  function maybeCleanupNativeScopeSubscription(scope: NonMemoryScope): void {
443
602
  const listeners = getScopedListeners(scope);
444
- if (listeners.size > 0) {
603
+ if (
604
+ listeners.size > 0 ||
605
+ storageEvents.hasListeners(scope) ||
606
+ eventObserver !== undefined
607
+ ) {
445
608
  return;
446
609
  }
447
610
 
@@ -474,9 +637,12 @@ function getRawValue(key: string, scope: StorageScope): string | undefined {
474
637
 
475
638
  function setRawValue(key: string, value: string, scope: StorageScope): void {
476
639
  assertValidScope(scope);
640
+ const oldValue =
641
+ scope === StorageScope.Memory ? getEventRawValue(scope, key) : undefined;
477
642
  if (scope === StorageScope.Memory) {
478
643
  memoryStore.set(key, value);
479
644
  notifyKeyListeners(memoryListeners, key);
645
+ emitKeyChange(scope, key, oldValue, value, "set", "memory");
480
646
  return;
481
647
  }
482
648
 
@@ -484,6 +650,7 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
484
650
  cacheRawValue(scope, key, value);
485
651
  if (diskWritesAsync) {
486
652
  scheduleDiskWrite(key, value);
653
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
487
654
  return;
488
655
  }
489
656
 
@@ -499,13 +666,16 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
499
666
 
500
667
  getStorageModule().set(key, value, scope);
501
668
  cacheRawValue(scope, key, value);
669
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
502
670
  }
503
671
 
504
672
  function removeRawValue(key: string, scope: StorageScope): void {
505
673
  assertValidScope(scope);
674
+ const oldValue = getEventRawValue(scope, key);
506
675
  if (scope === StorageScope.Memory) {
507
676
  memoryStore.delete(key);
508
677
  notifyKeyListeners(memoryListeners, key);
678
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "memory");
509
679
  return;
510
680
  }
511
681
 
@@ -513,6 +683,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
513
683
  cacheRawValue(scope, key, undefined);
514
684
  if (diskWritesAsync) {
515
685
  scheduleDiskWrite(key, undefined);
686
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
516
687
  return;
517
688
  }
518
689
 
@@ -527,6 +698,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
527
698
 
528
699
  getStorageModule().remove(key, scope);
529
700
  cacheRawValue(scope, key, undefined);
701
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
530
702
  }
531
703
 
532
704
  function readMigrationVersion(scope: StorageScope): number {
@@ -544,11 +716,97 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
544
716
  }
545
717
 
546
718
  export const storage = {
719
+ subscribe: (
720
+ scope: StorageScope,
721
+ listener: StorageEventListener,
722
+ ): (() => void) => {
723
+ assertValidScope(scope);
724
+ if (scope !== StorageScope.Memory) {
725
+ ensureNativeScopeSubscription(scope);
726
+ const unsubscribe = storageEvents.subscribe(scope, listener);
727
+ return () => {
728
+ unsubscribe();
729
+ maybeCleanupNativeScopeSubscription(scope);
730
+ };
731
+ }
732
+ return storageEvents.subscribe(scope, listener);
733
+ },
734
+ subscribeKey: (
735
+ scope: StorageScope,
736
+ key: string,
737
+ listener: StorageEventListener,
738
+ ): (() => void) => {
739
+ assertValidScope(scope);
740
+ if (scope !== StorageScope.Memory) {
741
+ ensureNativeScopeSubscription(scope);
742
+ const unsubscribe = storageEvents.subscribeKey(scope, key, listener);
743
+ return () => {
744
+ unsubscribe();
745
+ maybeCleanupNativeScopeSubscription(scope);
746
+ };
747
+ }
748
+ return storageEvents.subscribeKey(scope, key, listener);
749
+ },
750
+ subscribePrefix: (
751
+ scope: StorageScope,
752
+ prefix: string,
753
+ listener: StorageEventListener,
754
+ ): (() => void) => {
755
+ assertValidScope(scope);
756
+ if (scope !== StorageScope.Memory) {
757
+ ensureNativeScopeSubscription(scope);
758
+ const unsubscribe = storageEvents.subscribePrefix(
759
+ scope,
760
+ prefix,
761
+ listener,
762
+ );
763
+ return () => {
764
+ unsubscribe();
765
+ maybeCleanupNativeScopeSubscription(scope);
766
+ };
767
+ }
768
+ return storageEvents.subscribePrefix(scope, prefix, listener);
769
+ },
770
+ subscribeNamespace: (
771
+ namespace: string,
772
+ scope: StorageScope,
773
+ listener: StorageEventListener,
774
+ ): (() => void) => {
775
+ return storage.subscribePrefix(scope, prefixKey(namespace, ""), listener);
776
+ },
777
+ setEventObserver: (observer?: StorageEventListener) => {
778
+ eventObserver = observer;
779
+ if (observer) {
780
+ ensureNativeScopeSubscription(StorageScope.Disk);
781
+ ensureNativeScopeSubscription(StorageScope.Secure);
782
+ return;
783
+ }
784
+ maybeCleanupNativeScopeSubscription(StorageScope.Disk);
785
+ maybeCleanupNativeScopeSubscription(StorageScope.Secure);
786
+ },
547
787
  clear: (scope: StorageScope) => {
548
788
  measureOperation("storage:clear", scope, () => {
789
+ const previousValues = hasStorageChangeObservers(scope)
790
+ ? storage.getAll(scope)
791
+ : {};
549
792
  if (scope === StorageScope.Memory) {
550
793
  memoryStore.clear();
551
794
  notifyAllListeners(memoryListeners);
795
+ emitBatchChange(
796
+ scope,
797
+ "clear",
798
+ "memory",
799
+ Object.keys(previousValues).map((key) =>
800
+ createKeyChange(
801
+ scope,
802
+ key,
803
+ previousValues[key],
804
+ undefined,
805
+ "clear",
806
+ "memory",
807
+ ),
808
+ ),
809
+ );
552
810
  return;
553
811
  }
554
812
 
@@ -564,6 +822,21 @@ export const storage = {
564
822
 
565
823
  clearScopeRawCache(scope);
566
824
  getStorageModule().clear(scope);
825
+ emitBatchChange(
826
+ scope,
827
+ "clear",
828
+ "native",
829
+ Object.keys(previousValues).map((key) =>
830
+ createKeyChange(
831
+ scope,
832
+ key,
833
+ previousValues[key],
834
+ undefined,
835
+ "clear",
836
+ "native",
837
+ ),
838
+ ),
839
+ );
567
840
  });
568
841
  },
569
842
  clearAll: () => {
@@ -582,16 +855,44 @@ export const storage = {
582
855
  measureOperation("storage:clearNamespace", scope, () => {
583
856
  assertValidScope(scope);
584
857
  if (scope === StorageScope.Memory) {
585
- for (const key of memoryStore.keys()) {
586
- if (isNamespaced(key, namespace)) {
587
- memoryStore.delete(key);
588
- }
858
+ const affectedKeys = Array.from(memoryStore.keys()).filter((key) =>
859
+ isNamespaced(key, namespace),
860
+ );
861
+ const previousValues = affectedKeys.map((key) => ({
862
+ key,
863
+ value: getEventRawValue(scope, key),
864
+ }));
865
+
866
+ if (affectedKeys.length === 0) {
867
+ return;
589
868
  }
590
- notifyAllListeners(memoryListeners);
869
+
870
+ affectedKeys.forEach((key) => {
871
+ memoryStore.delete(key);
872
+ });
873
+ affectedKeys.forEach((key) => notifyKeyListeners(memoryListeners, key));
874
+ emitBatchChange(
875
+ scope,
876
+ "clearNamespace",
877
+ "memory",
878
+ previousValues.map(({ key, value }) =>
879
+ createKeyChange(
880
+ scope,
881
+ key,
882
+ value,
883
+ undefined,
884
+ "clearNamespace",
885
+ "memory",
886
+ ),
887
+ ),
888
+ );
591
889
  return;
592
890
  }
593
891
 
594
892
  const keyPrefix = prefixKey(namespace, "");
893
+ const previousValues = hasStorageChangeObservers(scope)
894
+ ? storage.getByPrefix(keyPrefix, scope)
895
+ : {};
595
896
  if (scope === StorageScope.Disk) {
596
897
  flushDiskWrites();
597
898
  }
@@ -606,6 +907,21 @@ export const storage = {
606
907
  }
607
908
  }
608
909
  getStorageModule().removeByPrefix(keyPrefix, scope);
910
+ emitBatchChange(
911
+ scope,
912
+ "clearNamespace",
913
+ "native",
914
+ Object.keys(previousValues).map((key) =>
915
+ createKeyChange(
916
+ scope,
917
+ key,
918
+ previousValues[key],
919
+ undefined,
920
+ "clearNamespace",
921
+ "native",
922
+ ),
923
+ ),
924
+ );
609
925
  });
610
926
  },
611
927
  clearBiometric: () => {
@@ -657,7 +973,7 @@ export const storage = {
657
973
  if (scope === StorageScope.Secure) {
658
974
  flushSecureWrites();
659
975
  }
660
- return getStorageModule().getKeysByPrefix(prefix, scope);
976
+ return getStorageModule().getKeysByPrefix(prefix, scope) ?? [];
661
977
  });
662
978
  },
663
979
  getByPrefix: (
@@ -687,7 +1003,7 @@ export const storage = {
687
1003
  if (scope === StorageScope.Secure) {
688
1004
  flushSecureWrites();
689
1005
  }
690
- const values = getStorageModule().getBatch(keys, scope);
1006
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
691
1007
  keys.forEach((key, idx) => {
692
1008
  const value = decodeNativeBatchValue(values[idx]);
693
1009
  if (value !== undefined) {
@@ -702,9 +1018,10 @@ export const storage = {
702
1018
  assertValidScope(scope);
703
1019
  const result: Record<string, string> = {};
704
1020
  if (scope === StorageScope.Memory) {
705
- memoryStore.forEach((value, key) => {
1021
+ for (const key of memoryStore.keys()) {
1022
+ const value = memoryStore.get(key);
706
1023
  if (typeof value === "string") result[key] = value;
707
- });
1024
+ }
708
1025
  return result;
709
1026
  }
710
1027
  if (scope === StorageScope.Disk) {
@@ -713,9 +1030,9 @@ export const storage = {
713
1030
  if (scope === StorageScope.Secure) {
714
1031
  flushSecureWrites();
715
1032
  }
716
- const keys = getStorageModule().getAllKeys(scope);
1033
+ const keys = getStorageModule().getAllKeys(scope) ?? [];
717
1034
  if (keys.length === 0) return result;
718
- const values = getStorageModule().getBatch(keys, scope);
1035
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
719
1036
  keys.forEach((key, idx) => {
720
1037
  const val = decodeNativeBatchValue(values[idx]);
721
1038
  if (val !== undefined) result[key] = val;
@@ -723,6 +1040,11 @@ export const storage = {
723
1040
  return result;
724
1041
  });
725
1042
  },
1043
+ export: (scope: StorageScope): Record<string, string> => {
1044
+ return measureOperation("storage:export", scope, () =>
1045
+ storage.getAll(scope),
1046
+ );
1047
+ },
726
1048
  size: (scope: StorageScope): number => {
727
1049
  return measureOperation("storage:size", scope, () => {
728
1050
  assertValidScope(scope);
@@ -803,7 +1125,7 @@ export const storage = {
803
1125
  platform: "native",
804
1126
  backend: {
805
1127
  disk: "platform-preferences",
806
- secure: "platform-secure-storage",
1128
+ secure: nativeSecureBackend,
807
1129
  },
808
1130
  writeBuffering: {
809
1131
  disk: true,
@@ -811,6 +1133,67 @@ export const storage = {
811
1133
  },
812
1134
  errorClassification: true,
813
1135
  }),
1136
+ getSecurityCapabilities: (): SecurityCapabilities => ({
1137
+ platform: "native",
1138
+ secureStorage: {
1139
+ backend: nativeSecureBackend,
1140
+ encrypted: "available",
1141
+ accessControl: "unknown",
1142
+ keychainAccessGroup: "unknown",
1143
+ hardwareBacked: "unknown",
1144
+ },
1145
+ biometric: {
1146
+ storage: "unknown",
1147
+ prompt: "unknown",
1148
+ biometryOnly: "unknown",
1149
+ biometryOrPasscode: "unknown",
1150
+ },
1151
+ metadata: {
1152
+ perKey: true,
1153
+ listsWithoutValues: true,
1154
+ persistsTimestamps: false,
1155
+ },
1156
+ }),
1157
+ getSecureMetadata: (key: string): SecureStorageMetadata => {
1158
+ return measureOperation(
1159
+ "storage:getSecureMetadata",
1160
+ StorageScope.Secure,
1161
+ () => {
1162
+ flushSecureWrites();
1163
+ const storageModule = getStorageModule();
1164
+ const biometricProtected = storageModule.hasSecureBiometric(key);
1165
+ const exists =
1166
+ biometricProtected || storageModule.has(key, StorageScope.Secure);
1167
+ let kind: SecureStorageMetadata["kind"] = "missing";
1168
+ if (exists) {
1169
+ kind = biometricProtected ? "biometric" : "secure";
1170
+ }
1171
+
1172
+ return {
1173
+ key,
1174
+ exists,
1175
+ kind,
1176
+ backend: nativeSecureBackend,
1177
+ encrypted: "available",
1178
+ hardwareBacked: "unknown",
1179
+ biometricProtected,
1180
+ valueExposed: false,
1181
+ };
1182
+ },
1183
+ );
1184
+ },
1185
+ getAllSecureMetadata: (): SecureStorageMetadata[] => {
1186
+ return measureOperation(
1187
+ "storage:getAllSecureMetadata",
1188
+ StorageScope.Secure,
1189
+ () => {
1190
+ flushSecureWrites();
1191
+ return getStorageModule()
1192
+ .getAllKeys(StorageScope.Secure)
1193
+ .map((key) => storage.getSecureMetadata(key));
1194
+ },
1195
+ );
1196
+ },
814
1197
  getString: (key: string, scope: StorageScope): string | undefined => {
815
1198
  return measureOperation("storage:getString", scope, () => {
816
1199
  return getRawValue(key, scope);
@@ -835,12 +1218,23 @@ export const storage = {
835
1218
  assertValidScope(scope);
836
1219
  if (keys.length === 0) return;
837
1220
  const values = keys.map((k) => data[k]!);
1221
+ const changes = keys.map((key, index) =>
1222
+ createKeyChange(
1223
+ scope,
1224
+ key,
1225
+ getEventRawValue(scope, key),
1226
+ values[index],
1227
+ "import",
1228
+ scope === StorageScope.Memory ? "memory" : "native",
1229
+ ),
1230
+ );
838
1231
 
839
1232
  if (scope === StorageScope.Memory) {
840
1233
  keys.forEach((key, index) => {
841
1234
  memoryStore.set(key, values[index]);
842
1235
  });
843
1236
  keys.forEach((key) => notifyKeyListeners(memoryListeners, key));
1237
+ emitBatchChange(scope, "import", "memory", changes);
844
1238
  return;
845
1239
  }
846
1240
 
@@ -851,6 +1245,7 @@ export const storage = {
851
1245
 
852
1246
  getStorageModule().setBatch(keys, values, scope);
853
1247
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1248
+ emitBatchChange(scope, "import", "native", changes);
854
1249
  },
855
1250
  keys.length,
856
1251
  );
@@ -913,6 +1308,11 @@ export interface StorageItem<T> {
913
1308
  delete: () => void;
914
1309
  has: () => boolean;
915
1310
  subscribe: (callback: () => void) => () => void;
1311
+ subscribeSelector: <TSelected>(
1312
+ selector: (value: T) => TSelected,
1313
+ listener: StorageSelectorListener<TSelected>,
1314
+ options?: StorageSelectorSubscribeOptions<TSelected>,
1315
+ ) => () => void;
916
1316
  serialize: (value: T) => string;
917
1317
  deserialize: (value: string) => T;
918
1318
  scope: StorageScope;
@@ -1081,12 +1481,21 @@ export function createStorageItem<T = undefined>(
1081
1481
  };
1082
1482
 
1083
1483
  const writeStoredRaw = (rawValue: string): void => {
1484
+ const oldValue = undefined;
1084
1485
  if (isBiometric) {
1085
1486
  getStorageModule().setSecureBiometricWithLevel(
1086
1487
  storageKey,
1087
1488
  rawValue,
1088
1489
  resolvedBiometricLevel,
1089
1490
  );
1491
+ emitKeyChange(
1492
+ config.scope,
1493
+ storageKey,
1494
+ oldValue,
1495
+ rawValue,
1496
+ "set",
1497
+ "native",
1498
+ );
1090
1499
  return;
1091
1500
  }
1092
1501
 
@@ -1095,6 +1504,14 @@ export function createStorageItem<T = undefined>(
1095
1504
  if (nonMemoryScope === StorageScope.Disk) {
1096
1505
  if (coalesceDiskWrites || diskWritesAsync) {
1097
1506
  scheduleDiskWrite(storageKey, rawValue);
1507
+ emitKeyChange(
1508
+ config.scope,
1509
+ storageKey,
1510
+ oldValue,
1511
+ rawValue,
1512
+ "set",
1513
+ "native",
1514
+ );
1098
1515
  return;
1099
1516
  }
1100
1517
 
@@ -1107,6 +1524,14 @@ export function createStorageItem<T = undefined>(
1107
1524
  rawValue,
1108
1525
  secureAccessControl ?? secureDefaultAccessControl,
1109
1526
  );
1527
+ emitKeyChange(
1528
+ config.scope,
1529
+ storageKey,
1530
+ oldValue,
1531
+ rawValue,
1532
+ "set",
1533
+ "native",
1534
+ );
1110
1535
  return;
1111
1536
  }
1112
1537
 
@@ -1118,11 +1543,28 @@ export function createStorageItem<T = undefined>(
1118
1543
  }
1119
1544
 
1120
1545
  getStorageModule().set(storageKey, rawValue, config.scope);
1546
+ emitKeyChange(
1547
+ config.scope,
1548
+ storageKey,
1549
+ oldValue,
1550
+ rawValue,
1551
+ "set",
1552
+ "native",
1553
+ );
1121
1554
  };
1122
1555
 
1123
1556
  const removeStoredRaw = (): void => {
1557
+ const oldValue = getEventRawValue(config.scope, storageKey);
1124
1558
  if (isBiometric) {
1125
1559
  getStorageModule().deleteSecureBiometric(storageKey);
1560
+ emitKeyChange(
1561
+ config.scope,
1562
+ storageKey,
1563
+ oldValue,
1564
+ undefined,
1565
+ "remove",
1566
+ "native",
1567
+ );
1126
1568
  return;
1127
1569
  }
1128
1570
 
@@ -1131,6 +1573,14 @@ export function createStorageItem<T = undefined>(
1131
1573
  if (nonMemoryScope === StorageScope.Disk) {
1132
1574
  if (coalesceDiskWrites || diskWritesAsync) {
1133
1575
  scheduleDiskWrite(storageKey, undefined);
1576
+ emitKeyChange(
1577
+ config.scope,
1578
+ storageKey,
1579
+ oldValue,
1580
+ undefined,
1581
+ "remove",
1582
+ "native",
1583
+ );
1134
1584
  return;
1135
1585
  }
1136
1586
 
@@ -1143,6 +1593,14 @@ export function createStorageItem<T = undefined>(
1143
1593
  undefined,
1144
1594
  secureAccessControl ?? secureDefaultAccessControl,
1145
1595
  );
1596
+ emitKeyChange(
1597
+ config.scope,
1598
+ storageKey,
1599
+ oldValue,
1600
+ undefined,
1601
+ "remove",
1602
+ "native",
1603
+ );
1146
1604
  return;
1147
1605
  }
1148
1606
 
@@ -1151,15 +1609,32 @@ export function createStorageItem<T = undefined>(
1151
1609
  }
1152
1610
 
1153
1611
  getStorageModule().remove(storageKey, config.scope);
1612
+ emitKeyChange(
1613
+ config.scope,
1614
+ storageKey,
1615
+ oldValue,
1616
+ undefined,
1617
+ "remove",
1618
+ "native",
1619
+ );
1154
1620
  };
1155
1621
 
1156
1622
  const writeValueWithoutValidation = (value: T): void => {
1157
1623
  if (isMemory) {
1624
+ const oldValue = getEventRawValue(config.scope, storageKey);
1158
1625
  if (memoryExpiration) {
1159
1626
  memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
1160
1627
  }
1161
1628
  memoryStore.set(storageKey, value);
1162
1629
  notifyKeyListeners(memoryListeners, storageKey);
1630
+ emitKeyChange(
1631
+ config.scope,
1632
+ storageKey,
1633
+ oldValue,
1634
+ typeof value === "string" ? value : undefined,
1635
+ "set",
1636
+ "memory",
1637
+ );
1163
1638
  return;
1164
1639
  }
1165
1640
 
@@ -1331,11 +1806,20 @@ export function createStorageItem<T = undefined>(
1331
1806
  invalidateParsedCache();
1332
1807
 
1333
1808
  if (isMemory) {
1809
+ const oldValue = getEventRawValue(config.scope, storageKey);
1334
1810
  if (memoryExpiration) {
1335
1811
  memoryExpiration.delete(storageKey);
1336
1812
  }
1337
1813
  memoryStore.delete(storageKey);
1338
1814
  notifyKeyListeners(memoryListeners, storageKey);
1815
+ emitKeyChange(
1816
+ config.scope,
1817
+ storageKey,
1818
+ oldValue,
1819
+ undefined,
1820
+ "remove",
1821
+ "memory",
1822
+ );
1339
1823
  return;
1340
1824
  }
1341
1825
 
@@ -1377,6 +1861,30 @@ export function createStorageItem<T = undefined>(
1377
1861
  };
1378
1862
  };
1379
1863
 
1864
+ const subscribeSelector = <TSelected>(
1865
+ selector: (value: T) => TSelected,
1866
+ listener: StorageSelectorListener<TSelected>,
1867
+ options: StorageSelectorSubscribeOptions<TSelected> = {},
1868
+ ): (() => void) => {
1869
+ const isEqual = options.isEqual ?? Object.is;
1870
+ let currentValue = selector(getInternal());
1871
+
1872
+ if (options.fireImmediately === true) {
1873
+ listener(currentValue, currentValue);
1874
+ }
1875
+
1876
+ return subscribe(() => {
1877
+ const nextValue = selector(getInternal());
1878
+ if (isEqual(currentValue, nextValue)) {
1879
+ return;
1880
+ }
1881
+
1882
+ const previousValue = currentValue;
1883
+ currentValue = nextValue;
1884
+ listener(nextValue, previousValue);
1885
+ });
1886
+ };
1887
+
1380
1888
  const storageItem: StorageItemInternal<T> = {
1381
1889
  get,
1382
1890
  getWithVersion,
@@ -1385,6 +1893,7 @@ export function createStorageItem<T = undefined>(
1385
1893
  delete: deleteItem,
1386
1894
  has: hasItem,
1387
1895
  subscribe,
1896
+ subscribeSelector,
1388
1897
  serialize,
1389
1898
  deserialize,
1390
1899
  _triggerListeners: () => {
@@ -1541,6 +2050,17 @@ export function setBatch<T>(
1541
2050
  return;
1542
2051
  }
1543
2052
 
2053
+ const changes = items.map(({ item, value }) =>
2054
+ createKeyChange(
2055
+ scope,
2056
+ item.key,
2057
+ getEventRawValue(scope, item.key),
2058
+ typeof value === "string" ? value : undefined,
2059
+ "setBatch",
2060
+ "memory",
2061
+ ),
2062
+ );
2063
+
1544
2064
  // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
1545
2065
  items.forEach(({ item, value }) => {
1546
2066
  memoryStore.set(item.key, value);
@@ -1549,6 +2069,7 @@ export function setBatch<T>(
1549
2069
  items.forEach(({ item }) =>
1550
2070
  notifyKeyListeners(memoryListeners, item.key),
1551
2071
  );
2072
+ emitBatchChange(scope, "setBatch", "memory", changes);
1552
2073
  return;
1553
2074
  }
1554
2075
 
@@ -1568,6 +2089,10 @@ export function setBatch<T>(
1568
2089
 
1569
2090
  flushSecureWrites();
1570
2091
  const storageModule = getStorageModule();
2092
+ const keys = secureEntries.map(({ item }) => item.key);
2093
+ const oldValues = hasStorageChangeObservers(scope)
2094
+ ? (storageModule.getBatch(keys, scope) ?? [])
2095
+ : [];
1571
2096
  const groupedByAccessControl = new Map<
1572
2097
  number,
1573
2098
  { keys: string[]; values: string[] }
@@ -1592,6 +2117,21 @@ export function setBatch<T>(
1592
2117
  cacheRawValue(scope, key, group.values[index]),
1593
2118
  );
1594
2119
  });
2120
+ emitBatchChange(
2121
+ scope,
2122
+ "setBatch",
2123
+ "native",
2124
+ secureEntries.map(({ item, value }, index) =>
2125
+ createKeyChange(
2126
+ scope,
2127
+ item.key,
2128
+ oldValues[index],
2129
+ item.serialize(value),
2130
+ "setBatch",
2131
+ "native",
2132
+ ),
2133
+ ),
2134
+ );
1595
2135
  return;
1596
2136
  }
1597
2137
 
@@ -1607,9 +2147,27 @@ export function setBatch<T>(
1607
2147
 
1608
2148
  const keys = items.map((entry) => entry.item.key);
1609
2149
  const values = items.map((entry) => entry.item.serialize(entry.value));
2150
+ const oldValues = hasStorageChangeObservers(scope)
2151
+ ? (getStorageModule().getBatch(keys, scope) ?? [])
2152
+ : [];
1610
2153
 
1611
2154
  getStorageModule().setBatch(keys, values, scope);
1612
2155
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
2156
+ emitBatchChange(
2157
+ scope,
2158
+ "setBatch",
2159
+ "native",
2160
+ keys.map((key, index) =>
2161
+ createKeyChange(
2162
+ scope,
2163
+ key,
2164
+ oldValues[index],
2165
+ values[index],
2166
+ "setBatch",
2167
+ "native",
2168
+ ),
2169
+ ),
2170
+ );
1613
2171
  },
1614
2172
  items.length,
1615
2173
  );
@@ -1626,7 +2184,18 @@ export function removeBatch(
1626
2184
  assertBatchScope(items, scope);
1627
2185
 
1628
2186
  if (scope === StorageScope.Memory) {
2187
+ const changes = items.map((item) =>
2188
+ createKeyChange(
2189
+ scope,
2190
+ item.key,
2191
+ getEventRawValue(scope, item.key),
2192
+ undefined,
2193
+ "removeBatch",
2194
+ "memory",
2195
+ ),
2196
+ );
1629
2197
  items.forEach((item) => item.delete());
2198
+ emitBatchChange(scope, "removeBatch", "memory", changes);
1630
2199
  return;
1631
2200
  }
1632
2201
 
@@ -1637,8 +2206,26 @@ export function removeBatch(
1637
2206
  if (scope === StorageScope.Secure) {
1638
2207
  flushSecureWrites();
1639
2208
  }
2209
+ const oldValues = hasStorageChangeObservers(scope)
2210
+ ? (getStorageModule().getBatch(keys, scope) ?? [])
2211
+ : [];
1640
2212
  getStorageModule().removeBatch(keys, scope);
1641
2213
  keys.forEach((key) => cacheRawValue(scope, key, undefined));
2214
+ emitBatchChange(
2215
+ scope,
2216
+ "removeBatch",
2217
+ "native",
2218
+ keys.map((key, index) =>
2219
+ createKeyChange(
2220
+ scope,
2221
+ key,
2222
+ oldValues[index],
2223
+ undefined,
2224
+ "removeBatch",
2225
+ "native",
2226
+ ),
2227
+ ),
2228
+ );
1642
2229
  },
1643
2230
  items.length,
1644
2231
  );