react-native-nitro-storage 0.5.0 → 0.5.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.
package/src/index.ts CHANGED
@@ -26,6 +26,15 @@ import {
26
26
  type StorageCapabilities,
27
27
  type StorageErrorCode,
28
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";
29
38
 
30
39
  export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
31
40
  export type { Storage } from "./Storage.nitro";
@@ -37,6 +46,14 @@ export {
37
46
  type StorageCapabilities,
38
47
  type StorageErrorCode,
39
48
  } from "./storage-runtime";
49
+ export type {
50
+ StorageBatchChangeEvent,
51
+ StorageChangeEvent,
52
+ StorageChangeOperation,
53
+ StorageChangeSource,
54
+ StorageEventListener,
55
+ StorageKeyChangeEvent,
56
+ } from "./storage-events";
40
57
  export type {
41
58
  WebStorageBackend,
42
59
  WebStorageChangeEvent,
@@ -65,6 +82,14 @@ export type StorageMetricSummary = {
65
82
  avgDurationMs: number;
66
83
  maxDurationMs: number;
67
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
+ };
68
93
  export type MigrationContext = {
69
94
  scope: StorageScope;
70
95
  getRaw: (key: string) => string | undefined;
@@ -161,11 +186,17 @@ let diskWritesAsync = false;
161
186
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
162
187
  let secureFlushScheduled = false;
163
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
+ ]);
164
193
  let metricsObserver: StorageMetricsObserver | undefined;
194
+ let eventObserver: StorageEventListener | undefined;
165
195
  const metricsCounters = new Map<
166
196
  string,
167
197
  { count: number; totalDurationMs: number; maxDurationMs: number }
168
198
  >();
199
+ const storageEvents = new StorageEventRegistry();
169
200
  const nativeSecureBackend = "platform-secure-storage";
170
201
 
171
202
  function recordMetric(
@@ -245,6 +276,28 @@ function clearScopeRawCache(scope: NonMemoryScope): void {
245
276
  getScopeRawCache(scope).clear();
246
277
  }
247
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
+
248
301
  function notifyKeyListeners(registry: KeyListenerRegistry, key: string): void {
249
302
  const listeners = registry.get(key);
250
303
  if (listeners) {
@@ -286,6 +339,99 @@ function addKeyListener(
286
339
  };
287
340
  }
288
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
+
289
435
  function readPendingSecureWrite(key: string): string | undefined {
290
436
  return pendingSecureWrites.get(key)?.value;
291
437
  }
@@ -438,15 +584,27 @@ function ensureNativeScopeSubscription(scope: NonMemoryScope): void {
438
584
  return;
439
585
  }
440
586
 
587
+ const oldValue = readCachedRawValue(scope, key);
441
588
  cacheRawValue(scope, key, value);
442
589
  notifyKeyListeners(getScopedListeners(scope), key);
590
+ if (consumeSuppressedNativeEvent(scope, key)) {
591
+ return;
592
+ }
593
+ emitKeyChange(scope, key, oldValue, value, "external", "native");
443
594
  });
444
- scopedUnsubscribers.set(scope, unsubscribe);
595
+ scopedUnsubscribers.set(
596
+ scope,
597
+ typeof unsubscribe === "function" ? unsubscribe : () => {},
598
+ );
445
599
  }
446
600
 
447
601
  function maybeCleanupNativeScopeSubscription(scope: NonMemoryScope): void {
448
602
  const listeners = getScopedListeners(scope);
449
- if (listeners.size > 0) {
603
+ if (
604
+ listeners.size > 0 ||
605
+ storageEvents.hasListeners(scope) ||
606
+ eventObserver !== undefined
607
+ ) {
450
608
  return;
451
609
  }
452
610
 
@@ -479,9 +637,12 @@ function getRawValue(key: string, scope: StorageScope): string | undefined {
479
637
 
480
638
  function setRawValue(key: string, value: string, scope: StorageScope): void {
481
639
  assertValidScope(scope);
640
+ const oldValue =
641
+ scope === StorageScope.Memory ? getEventRawValue(scope, key) : undefined;
482
642
  if (scope === StorageScope.Memory) {
483
643
  memoryStore.set(key, value);
484
644
  notifyKeyListeners(memoryListeners, key);
645
+ emitKeyChange(scope, key, oldValue, value, "set", "memory");
485
646
  return;
486
647
  }
487
648
 
@@ -489,6 +650,7 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
489
650
  cacheRawValue(scope, key, value);
490
651
  if (diskWritesAsync) {
491
652
  scheduleDiskWrite(key, value);
653
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
492
654
  return;
493
655
  }
494
656
 
@@ -504,13 +666,16 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
504
666
 
505
667
  getStorageModule().set(key, value, scope);
506
668
  cacheRawValue(scope, key, value);
669
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
507
670
  }
508
671
 
509
672
  function removeRawValue(key: string, scope: StorageScope): void {
510
673
  assertValidScope(scope);
674
+ const oldValue = getEventRawValue(scope, key);
511
675
  if (scope === StorageScope.Memory) {
512
676
  memoryStore.delete(key);
513
677
  notifyKeyListeners(memoryListeners, key);
678
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "memory");
514
679
  return;
515
680
  }
516
681
 
@@ -518,6 +683,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
518
683
  cacheRawValue(scope, key, undefined);
519
684
  if (diskWritesAsync) {
520
685
  scheduleDiskWrite(key, undefined);
686
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
521
687
  return;
522
688
  }
523
689
 
@@ -532,6 +698,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
532
698
 
533
699
  getStorageModule().remove(key, scope);
534
700
  cacheRawValue(scope, key, undefined);
701
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
535
702
  }
536
703
 
537
704
  function readMigrationVersion(scope: StorageScope): number {
@@ -549,11 +716,97 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
549
716
  }
550
717
 
551
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
+ },
552
787
  clear: (scope: StorageScope) => {
553
788
  measureOperation("storage:clear", scope, () => {
789
+ const previousValues = hasStorageChangeObservers(scope)
790
+ ? storage.getAll(scope)
791
+ : {};
554
792
  if (scope === StorageScope.Memory) {
555
793
  memoryStore.clear();
556
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
+ );
557
810
  return;
558
811
  }
559
812
 
@@ -569,6 +822,21 @@ export const storage = {
569
822
 
570
823
  clearScopeRawCache(scope);
571
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
+ );
572
840
  });
573
841
  },
574
842
  clearAll: () => {
@@ -587,16 +855,44 @@ export const storage = {
587
855
  measureOperation("storage:clearNamespace", scope, () => {
588
856
  assertValidScope(scope);
589
857
  if (scope === StorageScope.Memory) {
590
- for (const key of memoryStore.keys()) {
591
- if (isNamespaced(key, namespace)) {
592
- memoryStore.delete(key);
593
- }
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;
594
868
  }
595
- 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
+ );
596
889
  return;
597
890
  }
598
891
 
599
892
  const keyPrefix = prefixKey(namespace, "");
893
+ const previousValues = hasStorageChangeObservers(scope)
894
+ ? storage.getByPrefix(keyPrefix, scope)
895
+ : {};
600
896
  if (scope === StorageScope.Disk) {
601
897
  flushDiskWrites();
602
898
  }
@@ -611,6 +907,21 @@ export const storage = {
611
907
  }
612
908
  }
613
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
+ );
614
925
  });
615
926
  },
616
927
  clearBiometric: () => {
@@ -662,7 +973,7 @@ export const storage = {
662
973
  if (scope === StorageScope.Secure) {
663
974
  flushSecureWrites();
664
975
  }
665
- return getStorageModule().getKeysByPrefix(prefix, scope);
976
+ return getStorageModule().getKeysByPrefix(prefix, scope) ?? [];
666
977
  });
667
978
  },
668
979
  getByPrefix: (
@@ -692,7 +1003,7 @@ export const storage = {
692
1003
  if (scope === StorageScope.Secure) {
693
1004
  flushSecureWrites();
694
1005
  }
695
- const values = getStorageModule().getBatch(keys, scope);
1006
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
696
1007
  keys.forEach((key, idx) => {
697
1008
  const value = decodeNativeBatchValue(values[idx]);
698
1009
  if (value !== undefined) {
@@ -707,9 +1018,10 @@ export const storage = {
707
1018
  assertValidScope(scope);
708
1019
  const result: Record<string, string> = {};
709
1020
  if (scope === StorageScope.Memory) {
710
- memoryStore.forEach((value, key) => {
1021
+ for (const key of memoryStore.keys()) {
1022
+ const value = memoryStore.get(key);
711
1023
  if (typeof value === "string") result[key] = value;
712
- });
1024
+ }
713
1025
  return result;
714
1026
  }
715
1027
  if (scope === StorageScope.Disk) {
@@ -718,9 +1030,9 @@ export const storage = {
718
1030
  if (scope === StorageScope.Secure) {
719
1031
  flushSecureWrites();
720
1032
  }
721
- const keys = getStorageModule().getAllKeys(scope);
1033
+ const keys = getStorageModule().getAllKeys(scope) ?? [];
722
1034
  if (keys.length === 0) return result;
723
- const values = getStorageModule().getBatch(keys, scope);
1035
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
724
1036
  keys.forEach((key, idx) => {
725
1037
  const val = decodeNativeBatchValue(values[idx]);
726
1038
  if (val !== undefined) result[key] = val;
@@ -728,6 +1040,11 @@ export const storage = {
728
1040
  return result;
729
1041
  });
730
1042
  },
1043
+ export: (scope: StorageScope): Record<string, string> => {
1044
+ return measureOperation("storage:export", scope, () =>
1045
+ storage.getAll(scope),
1046
+ );
1047
+ },
731
1048
  size: (scope: StorageScope): number => {
732
1049
  return measureOperation("storage:size", scope, () => {
733
1050
  assertValidScope(scope);
@@ -901,12 +1218,23 @@ export const storage = {
901
1218
  assertValidScope(scope);
902
1219
  if (keys.length === 0) return;
903
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
+ );
904
1231
 
905
1232
  if (scope === StorageScope.Memory) {
906
1233
  keys.forEach((key, index) => {
907
1234
  memoryStore.set(key, values[index]);
908
1235
  });
909
1236
  keys.forEach((key) => notifyKeyListeners(memoryListeners, key));
1237
+ emitBatchChange(scope, "import", "memory", changes);
910
1238
  return;
911
1239
  }
912
1240
 
@@ -917,6 +1245,7 @@ export const storage = {
917
1245
 
918
1246
  getStorageModule().setBatch(keys, values, scope);
919
1247
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1248
+ emitBatchChange(scope, "import", "native", changes);
920
1249
  },
921
1250
  keys.length,
922
1251
  );
@@ -979,6 +1308,11 @@ export interface StorageItem<T> {
979
1308
  delete: () => void;
980
1309
  has: () => boolean;
981
1310
  subscribe: (callback: () => void) => () => void;
1311
+ subscribeSelector: <TSelected>(
1312
+ selector: (value: T) => TSelected,
1313
+ listener: StorageSelectorListener<TSelected>,
1314
+ options?: StorageSelectorSubscribeOptions<TSelected>,
1315
+ ) => () => void;
982
1316
  serialize: (value: T) => string;
983
1317
  deserialize: (value: string) => T;
984
1318
  scope: StorageScope;
@@ -1147,12 +1481,21 @@ export function createStorageItem<T = undefined>(
1147
1481
  };
1148
1482
 
1149
1483
  const writeStoredRaw = (rawValue: string): void => {
1484
+ const oldValue = undefined;
1150
1485
  if (isBiometric) {
1151
1486
  getStorageModule().setSecureBiometricWithLevel(
1152
1487
  storageKey,
1153
1488
  rawValue,
1154
1489
  resolvedBiometricLevel,
1155
1490
  );
1491
+ emitKeyChange(
1492
+ config.scope,
1493
+ storageKey,
1494
+ oldValue,
1495
+ rawValue,
1496
+ "set",
1497
+ "native",
1498
+ );
1156
1499
  return;
1157
1500
  }
1158
1501
 
@@ -1161,6 +1504,14 @@ export function createStorageItem<T = undefined>(
1161
1504
  if (nonMemoryScope === StorageScope.Disk) {
1162
1505
  if (coalesceDiskWrites || diskWritesAsync) {
1163
1506
  scheduleDiskWrite(storageKey, rawValue);
1507
+ emitKeyChange(
1508
+ config.scope,
1509
+ storageKey,
1510
+ oldValue,
1511
+ rawValue,
1512
+ "set",
1513
+ "native",
1514
+ );
1164
1515
  return;
1165
1516
  }
1166
1517
 
@@ -1173,6 +1524,14 @@ export function createStorageItem<T = undefined>(
1173
1524
  rawValue,
1174
1525
  secureAccessControl ?? secureDefaultAccessControl,
1175
1526
  );
1527
+ emitKeyChange(
1528
+ config.scope,
1529
+ storageKey,
1530
+ oldValue,
1531
+ rawValue,
1532
+ "set",
1533
+ "native",
1534
+ );
1176
1535
  return;
1177
1536
  }
1178
1537
 
@@ -1184,11 +1543,28 @@ export function createStorageItem<T = undefined>(
1184
1543
  }
1185
1544
 
1186
1545
  getStorageModule().set(storageKey, rawValue, config.scope);
1546
+ emitKeyChange(
1547
+ config.scope,
1548
+ storageKey,
1549
+ oldValue,
1550
+ rawValue,
1551
+ "set",
1552
+ "native",
1553
+ );
1187
1554
  };
1188
1555
 
1189
1556
  const removeStoredRaw = (): void => {
1557
+ const oldValue = getEventRawValue(config.scope, storageKey);
1190
1558
  if (isBiometric) {
1191
1559
  getStorageModule().deleteSecureBiometric(storageKey);
1560
+ emitKeyChange(
1561
+ config.scope,
1562
+ storageKey,
1563
+ oldValue,
1564
+ undefined,
1565
+ "remove",
1566
+ "native",
1567
+ );
1192
1568
  return;
1193
1569
  }
1194
1570
 
@@ -1197,6 +1573,14 @@ export function createStorageItem<T = undefined>(
1197
1573
  if (nonMemoryScope === StorageScope.Disk) {
1198
1574
  if (coalesceDiskWrites || diskWritesAsync) {
1199
1575
  scheduleDiskWrite(storageKey, undefined);
1576
+ emitKeyChange(
1577
+ config.scope,
1578
+ storageKey,
1579
+ oldValue,
1580
+ undefined,
1581
+ "remove",
1582
+ "native",
1583
+ );
1200
1584
  return;
1201
1585
  }
1202
1586
 
@@ -1209,6 +1593,14 @@ export function createStorageItem<T = undefined>(
1209
1593
  undefined,
1210
1594
  secureAccessControl ?? secureDefaultAccessControl,
1211
1595
  );
1596
+ emitKeyChange(
1597
+ config.scope,
1598
+ storageKey,
1599
+ oldValue,
1600
+ undefined,
1601
+ "remove",
1602
+ "native",
1603
+ );
1212
1604
  return;
1213
1605
  }
1214
1606
 
@@ -1217,15 +1609,32 @@ export function createStorageItem<T = undefined>(
1217
1609
  }
1218
1610
 
1219
1611
  getStorageModule().remove(storageKey, config.scope);
1612
+ emitKeyChange(
1613
+ config.scope,
1614
+ storageKey,
1615
+ oldValue,
1616
+ undefined,
1617
+ "remove",
1618
+ "native",
1619
+ );
1220
1620
  };
1221
1621
 
1222
1622
  const writeValueWithoutValidation = (value: T): void => {
1223
1623
  if (isMemory) {
1624
+ const oldValue = getEventRawValue(config.scope, storageKey);
1224
1625
  if (memoryExpiration) {
1225
1626
  memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
1226
1627
  }
1227
1628
  memoryStore.set(storageKey, value);
1228
1629
  notifyKeyListeners(memoryListeners, storageKey);
1630
+ emitKeyChange(
1631
+ config.scope,
1632
+ storageKey,
1633
+ oldValue,
1634
+ typeof value === "string" ? value : undefined,
1635
+ "set",
1636
+ "memory",
1637
+ );
1229
1638
  return;
1230
1639
  }
1231
1640
 
@@ -1397,11 +1806,20 @@ export function createStorageItem<T = undefined>(
1397
1806
  invalidateParsedCache();
1398
1807
 
1399
1808
  if (isMemory) {
1809
+ const oldValue = getEventRawValue(config.scope, storageKey);
1400
1810
  if (memoryExpiration) {
1401
1811
  memoryExpiration.delete(storageKey);
1402
1812
  }
1403
1813
  memoryStore.delete(storageKey);
1404
1814
  notifyKeyListeners(memoryListeners, storageKey);
1815
+ emitKeyChange(
1816
+ config.scope,
1817
+ storageKey,
1818
+ oldValue,
1819
+ undefined,
1820
+ "remove",
1821
+ "memory",
1822
+ );
1405
1823
  return;
1406
1824
  }
1407
1825
 
@@ -1443,6 +1861,30 @@ export function createStorageItem<T = undefined>(
1443
1861
  };
1444
1862
  };
1445
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
+
1446
1888
  const storageItem: StorageItemInternal<T> = {
1447
1889
  get,
1448
1890
  getWithVersion,
@@ -1451,6 +1893,7 @@ export function createStorageItem<T = undefined>(
1451
1893
  delete: deleteItem,
1452
1894
  has: hasItem,
1453
1895
  subscribe,
1896
+ subscribeSelector,
1454
1897
  serialize,
1455
1898
  deserialize,
1456
1899
  _triggerListeners: () => {
@@ -1607,6 +2050,17 @@ export function setBatch<T>(
1607
2050
  return;
1608
2051
  }
1609
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
+
1610
2064
  // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
1611
2065
  items.forEach(({ item, value }) => {
1612
2066
  memoryStore.set(item.key, value);
@@ -1615,6 +2069,7 @@ export function setBatch<T>(
1615
2069
  items.forEach(({ item }) =>
1616
2070
  notifyKeyListeners(memoryListeners, item.key),
1617
2071
  );
2072
+ emitBatchChange(scope, "setBatch", "memory", changes);
1618
2073
  return;
1619
2074
  }
1620
2075
 
@@ -1634,6 +2089,10 @@ export function setBatch<T>(
1634
2089
 
1635
2090
  flushSecureWrites();
1636
2091
  const storageModule = getStorageModule();
2092
+ const keys = secureEntries.map(({ item }) => item.key);
2093
+ const oldValues = hasStorageChangeObservers(scope)
2094
+ ? (storageModule.getBatch(keys, scope) ?? [])
2095
+ : [];
1637
2096
  const groupedByAccessControl = new Map<
1638
2097
  number,
1639
2098
  { keys: string[]; values: string[] }
@@ -1658,6 +2117,21 @@ export function setBatch<T>(
1658
2117
  cacheRawValue(scope, key, group.values[index]),
1659
2118
  );
1660
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
+ );
1661
2135
  return;
1662
2136
  }
1663
2137
 
@@ -1673,9 +2147,27 @@ export function setBatch<T>(
1673
2147
 
1674
2148
  const keys = items.map((entry) => entry.item.key);
1675
2149
  const values = items.map((entry) => entry.item.serialize(entry.value));
2150
+ const oldValues = hasStorageChangeObservers(scope)
2151
+ ? (getStorageModule().getBatch(keys, scope) ?? [])
2152
+ : [];
1676
2153
 
1677
2154
  getStorageModule().setBatch(keys, values, scope);
1678
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
+ );
1679
2171
  },
1680
2172
  items.length,
1681
2173
  );
@@ -1692,7 +2184,18 @@ export function removeBatch(
1692
2184
  assertBatchScope(items, scope);
1693
2185
 
1694
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
+ );
1695
2197
  items.forEach((item) => item.delete());
2198
+ emitBatchChange(scope, "removeBatch", "memory", changes);
1696
2199
  return;
1697
2200
  }
1698
2201
 
@@ -1703,8 +2206,26 @@ export function removeBatch(
1703
2206
  if (scope === StorageScope.Secure) {
1704
2207
  flushSecureWrites();
1705
2208
  }
2209
+ const oldValues = hasStorageChangeObservers(scope)
2210
+ ? (getStorageModule().getBatch(keys, scope) ?? [])
2211
+ : [];
1706
2212
  getStorageModule().removeBatch(keys, scope);
1707
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
+ );
1708
2229
  },
1709
2230
  items.length,
1710
2231
  );