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
@@ -4,6 +4,7 @@ import { NitroModules } from "react-native-nitro-modules";
4
4
  import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
5
5
  import { MIGRATION_VERSION_KEY, isStoredEnvelope, assertBatchScope, assertValidScope, decodeNativeBatchValue, serializeWithPrimitiveFastPath, deserializeWithPrimitiveFastPath, toVersionToken, prefixKey, isNamespaced } from "./internal";
6
6
  import { getStorageErrorCode, isLockedStorageErrorCode } from "./storage-runtime";
7
+ import { StorageEventRegistry } from "./storage-events";
7
8
  export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
8
9
  export { migrateFromMMKV } from "./migration";
9
10
  export { getStorageErrorCode } from "./storage-runtime";
@@ -39,8 +40,12 @@ let diskWritesAsync = false;
39
40
  const pendingSecureWrites = new Map();
40
41
  let secureFlushScheduled = false;
41
42
  let secureDefaultAccessControl = AccessControl.WhenUnlocked;
43
+ const suppressedNativeEvents = new Map([[StorageScope.Disk, new Map()], [StorageScope.Secure, new Map()]]);
42
44
  let metricsObserver;
45
+ let eventObserver;
43
46
  const metricsCounters = new Map();
47
+ const storageEvents = new StorageEventRegistry();
48
+ const nativeSecureBackend = "platform-secure-storage";
44
49
  function recordMetric(operation, scope, durationMs, keysCount = 1) {
45
50
  const existing = metricsCounters.get(operation);
46
51
  if (!existing) {
@@ -90,6 +95,23 @@ function hasCachedRawValue(scope, key) {
90
95
  function clearScopeRawCache(scope) {
91
96
  getScopeRawCache(scope).clear();
92
97
  }
98
+ function suppressNativeEvent(scope, key) {
99
+ const suppressedEvents = suppressedNativeEvents.get(scope);
100
+ suppressedEvents.set(key, (suppressedEvents.get(key) ?? 0) + 1);
101
+ }
102
+ function consumeSuppressedNativeEvent(scope, key) {
103
+ const suppressedEvents = suppressedNativeEvents.get(scope);
104
+ const count = suppressedEvents.get(key);
105
+ if (count === undefined) {
106
+ return false;
107
+ }
108
+ if (count <= 1) {
109
+ suppressedEvents.delete(key);
110
+ } else {
111
+ suppressedEvents.set(key, count - 1);
112
+ }
113
+ return true;
114
+ }
93
115
  function notifyKeyListeners(registry, key) {
94
116
  const listeners = registry.get(key);
95
117
  if (listeners) {
@@ -123,6 +145,52 @@ function addKeyListener(registry, key, listener) {
123
145
  }
124
146
  };
125
147
  }
148
+ function getEventRawValue(scope, key) {
149
+ if (scope === StorageScope.Memory) {
150
+ const value = memoryStore.get(key);
151
+ return typeof value === "string" ? value : undefined;
152
+ }
153
+ return getRawValue(key, scope);
154
+ }
155
+ function createKeyChange(scope, key, oldValue, newValue, operation, source) {
156
+ return {
157
+ type: "key",
158
+ scope,
159
+ key,
160
+ oldValue,
161
+ newValue,
162
+ operation,
163
+ source
164
+ };
165
+ }
166
+ function hasStorageChangeObservers(scope) {
167
+ return storageEvents.hasListeners(scope) || eventObserver !== undefined;
168
+ }
169
+ function emitKeyChange(scope, key, oldValue, newValue, operation, source) {
170
+ if (source === "native" && operation !== "external" && scope !== StorageScope.Memory && scopedUnsubscribers.has(scope)) {
171
+ suppressNativeEvent(scope, key);
172
+ }
173
+ const event = createKeyChange(scope, key, oldValue, newValue, operation, source);
174
+ storageEvents.emitKey(event);
175
+ eventObserver?.(event);
176
+ }
177
+ function emitBatchChange(scope, operation, source, changes) {
178
+ if (changes.length === 0) {
179
+ return;
180
+ }
181
+ if (source === "native" && operation !== "external" && scope !== StorageScope.Memory && scopedUnsubscribers.has(scope)) {
182
+ changes.forEach(change => suppressNativeEvent(scope, change.key));
183
+ }
184
+ const event = {
185
+ type: "batch",
186
+ scope,
187
+ operation,
188
+ source,
189
+ changes
190
+ };
191
+ storageEvents.emitBatch(event);
192
+ eventObserver?.(event);
193
+ }
126
194
  function readPendingSecureWrite(key) {
127
195
  return pendingSecureWrites.get(key)?.value;
128
196
  }
@@ -259,14 +327,19 @@ function ensureNativeScopeSubscription(scope) {
259
327
  notifyAllListeners(getScopedListeners(scope));
260
328
  return;
261
329
  }
330
+ const oldValue = readCachedRawValue(scope, key);
262
331
  cacheRawValue(scope, key, value);
263
332
  notifyKeyListeners(getScopedListeners(scope), key);
333
+ if (consumeSuppressedNativeEvent(scope, key)) {
334
+ return;
335
+ }
336
+ emitKeyChange(scope, key, oldValue, value, "external", "native");
264
337
  });
265
- scopedUnsubscribers.set(scope, unsubscribe);
338
+ scopedUnsubscribers.set(scope, typeof unsubscribe === "function" ? unsubscribe : () => {});
266
339
  }
267
340
  function maybeCleanupNativeScopeSubscription(scope) {
268
341
  const listeners = getScopedListeners(scope);
269
- if (listeners.size > 0) {
342
+ if (listeners.size > 0 || storageEvents.hasListeners(scope) || eventObserver !== undefined) {
270
343
  return;
271
344
  }
272
345
  const unsubscribe = scopedUnsubscribers.get(scope);
@@ -292,15 +365,18 @@ function getRawValue(key, scope) {
292
365
  }
293
366
  function setRawValue(key, value, scope) {
294
367
  assertValidScope(scope);
368
+ const oldValue = scope === StorageScope.Memory ? getEventRawValue(scope, key) : undefined;
295
369
  if (scope === StorageScope.Memory) {
296
370
  memoryStore.set(key, value);
297
371
  notifyKeyListeners(memoryListeners, key);
372
+ emitKeyChange(scope, key, oldValue, value, "set", "memory");
298
373
  return;
299
374
  }
300
375
  if (scope === StorageScope.Disk) {
301
376
  cacheRawValue(scope, key, value);
302
377
  if (diskWritesAsync) {
303
378
  scheduleDiskWrite(key, value);
379
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
304
380
  return;
305
381
  }
306
382
  flushDiskWrites();
@@ -313,18 +389,22 @@ function setRawValue(key, value, scope) {
313
389
  }
314
390
  getStorageModule().set(key, value, scope);
315
391
  cacheRawValue(scope, key, value);
392
+ emitKeyChange(scope, key, oldValue, value, "set", "native");
316
393
  }
317
394
  function removeRawValue(key, scope) {
318
395
  assertValidScope(scope);
396
+ const oldValue = getEventRawValue(scope, key);
319
397
  if (scope === StorageScope.Memory) {
320
398
  memoryStore.delete(key);
321
399
  notifyKeyListeners(memoryListeners, key);
400
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "memory");
322
401
  return;
323
402
  }
324
403
  if (scope === StorageScope.Disk) {
325
404
  cacheRawValue(scope, key, undefined);
326
405
  if (diskWritesAsync) {
327
406
  scheduleDiskWrite(key, undefined);
407
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
328
408
  return;
329
409
  }
330
410
  flushDiskWrites();
@@ -336,6 +416,7 @@ function removeRawValue(key, scope) {
336
416
  }
337
417
  getStorageModule().remove(key, scope);
338
418
  cacheRawValue(scope, key, undefined);
419
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "native");
339
420
  }
340
421
  function readMigrationVersion(scope) {
341
422
  const raw = getRawValue(MIGRATION_VERSION_KEY, scope);
@@ -349,11 +430,62 @@ function writeMigrationVersion(scope, version) {
349
430
  setRawValue(MIGRATION_VERSION_KEY, String(version), scope);
350
431
  }
351
432
  export const storage = {
433
+ subscribe: (scope, listener) => {
434
+ assertValidScope(scope);
435
+ if (scope !== StorageScope.Memory) {
436
+ ensureNativeScopeSubscription(scope);
437
+ const unsubscribe = storageEvents.subscribe(scope, listener);
438
+ return () => {
439
+ unsubscribe();
440
+ maybeCleanupNativeScopeSubscription(scope);
441
+ };
442
+ }
443
+ return storageEvents.subscribe(scope, listener);
444
+ },
445
+ subscribeKey: (scope, key, listener) => {
446
+ assertValidScope(scope);
447
+ if (scope !== StorageScope.Memory) {
448
+ ensureNativeScopeSubscription(scope);
449
+ const unsubscribe = storageEvents.subscribeKey(scope, key, listener);
450
+ return () => {
451
+ unsubscribe();
452
+ maybeCleanupNativeScopeSubscription(scope);
453
+ };
454
+ }
455
+ return storageEvents.subscribeKey(scope, key, listener);
456
+ },
457
+ subscribePrefix: (scope, prefix, listener) => {
458
+ assertValidScope(scope);
459
+ if (scope !== StorageScope.Memory) {
460
+ ensureNativeScopeSubscription(scope);
461
+ const unsubscribe = storageEvents.subscribePrefix(scope, prefix, listener);
462
+ return () => {
463
+ unsubscribe();
464
+ maybeCleanupNativeScopeSubscription(scope);
465
+ };
466
+ }
467
+ return storageEvents.subscribePrefix(scope, prefix, listener);
468
+ },
469
+ subscribeNamespace: (namespace, scope, listener) => {
470
+ return storage.subscribePrefix(scope, prefixKey(namespace, ""), listener);
471
+ },
472
+ setEventObserver: observer => {
473
+ eventObserver = observer;
474
+ if (observer) {
475
+ ensureNativeScopeSubscription(StorageScope.Disk);
476
+ ensureNativeScopeSubscription(StorageScope.Secure);
477
+ return;
478
+ }
479
+ maybeCleanupNativeScopeSubscription(StorageScope.Disk);
480
+ maybeCleanupNativeScopeSubscription(StorageScope.Secure);
481
+ },
352
482
  clear: scope => {
353
483
  measureOperation("storage:clear", scope, () => {
484
+ const previousValues = hasStorageChangeObservers(scope) ? storage.getAll(scope) : {};
354
485
  if (scope === StorageScope.Memory) {
355
486
  memoryStore.clear();
356
487
  notifyAllListeners(memoryListeners);
488
+ emitBatchChange(scope, "clear", "memory", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clear", "memory")));
357
489
  return;
358
490
  }
359
491
  if (scope === StorageScope.Disk) {
@@ -366,6 +498,7 @@ export const storage = {
366
498
  }
367
499
  clearScopeRawCache(scope);
368
500
  getStorageModule().clear(scope);
501
+ emitBatchChange(scope, "clear", "native", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clear", "native")));
369
502
  });
370
503
  },
371
504
  clearAll: () => {
@@ -379,15 +512,26 @@ export const storage = {
379
512
  measureOperation("storage:clearNamespace", scope, () => {
380
513
  assertValidScope(scope);
381
514
  if (scope === StorageScope.Memory) {
382
- for (const key of memoryStore.keys()) {
383
- if (isNamespaced(key, namespace)) {
384
- memoryStore.delete(key);
385
- }
515
+ const affectedKeys = Array.from(memoryStore.keys()).filter(key => isNamespaced(key, namespace));
516
+ const previousValues = affectedKeys.map(key => ({
517
+ key,
518
+ value: getEventRawValue(scope, key)
519
+ }));
520
+ if (affectedKeys.length === 0) {
521
+ return;
386
522
  }
387
- notifyAllListeners(memoryListeners);
523
+ affectedKeys.forEach(key => {
524
+ memoryStore.delete(key);
525
+ });
526
+ affectedKeys.forEach(key => notifyKeyListeners(memoryListeners, key));
527
+ emitBatchChange(scope, "clearNamespace", "memory", previousValues.map(({
528
+ key,
529
+ value
530
+ }) => createKeyChange(scope, key, value, undefined, "clearNamespace", "memory")));
388
531
  return;
389
532
  }
390
533
  const keyPrefix = prefixKey(namespace, "");
534
+ const previousValues = hasStorageChangeObservers(scope) ? storage.getByPrefix(keyPrefix, scope) : {};
391
535
  if (scope === StorageScope.Disk) {
392
536
  flushDiskWrites();
393
537
  }
@@ -401,6 +545,7 @@ export const storage = {
401
545
  }
402
546
  }
403
547
  getStorageModule().removeByPrefix(keyPrefix, scope);
548
+ emitBatchChange(scope, "clearNamespace", "native", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clearNamespace", "native")));
404
549
  });
405
550
  },
406
551
  clearBiometric: () => {
@@ -450,7 +595,7 @@ export const storage = {
450
595
  if (scope === StorageScope.Secure) {
451
596
  flushSecureWrites();
452
597
  }
453
- return getStorageModule().getKeysByPrefix(prefix, scope);
598
+ return getStorageModule().getKeysByPrefix(prefix, scope) ?? [];
454
599
  });
455
600
  },
456
601
  getByPrefix: (prefix, scope) => {
@@ -475,7 +620,7 @@ export const storage = {
475
620
  if (scope === StorageScope.Secure) {
476
621
  flushSecureWrites();
477
622
  }
478
- const values = getStorageModule().getBatch(keys, scope);
623
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
479
624
  keys.forEach((key, idx) => {
480
625
  const value = decodeNativeBatchValue(values[idx]);
481
626
  if (value !== undefined) {
@@ -490,9 +635,10 @@ export const storage = {
490
635
  assertValidScope(scope);
491
636
  const result = {};
492
637
  if (scope === StorageScope.Memory) {
493
- memoryStore.forEach((value, key) => {
638
+ for (const key of memoryStore.keys()) {
639
+ const value = memoryStore.get(key);
494
640
  if (typeof value === "string") result[key] = value;
495
- });
641
+ }
496
642
  return result;
497
643
  }
498
644
  if (scope === StorageScope.Disk) {
@@ -501,9 +647,9 @@ export const storage = {
501
647
  if (scope === StorageScope.Secure) {
502
648
  flushSecureWrites();
503
649
  }
504
- const keys = getStorageModule().getAllKeys(scope);
650
+ const keys = getStorageModule().getAllKeys(scope) ?? [];
505
651
  if (keys.length === 0) return result;
506
- const values = getStorageModule().getBatch(keys, scope);
652
+ const values = getStorageModule().getBatch(keys, scope) ?? [];
507
653
  keys.forEach((key, idx) => {
508
654
  const val = decodeNativeBatchValue(values[idx]);
509
655
  if (val !== undefined) result[key] = val;
@@ -511,6 +657,9 @@ export const storage = {
511
657
  return result;
512
658
  });
513
659
  },
660
+ export: scope => {
661
+ return measureOperation("storage:export", scope, () => storage.getAll(scope));
662
+ },
514
663
  size: scope => {
515
664
  return measureOperation("storage:size", scope, () => {
516
665
  assertValidScope(scope);
@@ -582,7 +731,7 @@ export const storage = {
582
731
  platform: "native",
583
732
  backend: {
584
733
  disk: "platform-preferences",
585
- secure: "platform-secure-storage"
734
+ secure: nativeSecureBackend
586
735
  },
587
736
  writeBuffering: {
588
737
  disk: true,
@@ -590,6 +739,55 @@ export const storage = {
590
739
  },
591
740
  errorClassification: true
592
741
  }),
742
+ getSecurityCapabilities: () => ({
743
+ platform: "native",
744
+ secureStorage: {
745
+ backend: nativeSecureBackend,
746
+ encrypted: "available",
747
+ accessControl: "unknown",
748
+ keychainAccessGroup: "unknown",
749
+ hardwareBacked: "unknown"
750
+ },
751
+ biometric: {
752
+ storage: "unknown",
753
+ prompt: "unknown",
754
+ biometryOnly: "unknown",
755
+ biometryOrPasscode: "unknown"
756
+ },
757
+ metadata: {
758
+ perKey: true,
759
+ listsWithoutValues: true,
760
+ persistsTimestamps: false
761
+ }
762
+ }),
763
+ getSecureMetadata: key => {
764
+ return measureOperation("storage:getSecureMetadata", StorageScope.Secure, () => {
765
+ flushSecureWrites();
766
+ const storageModule = getStorageModule();
767
+ const biometricProtected = storageModule.hasSecureBiometric(key);
768
+ const exists = biometricProtected || storageModule.has(key, StorageScope.Secure);
769
+ let kind = "missing";
770
+ if (exists) {
771
+ kind = biometricProtected ? "biometric" : "secure";
772
+ }
773
+ return {
774
+ key,
775
+ exists,
776
+ kind,
777
+ backend: nativeSecureBackend,
778
+ encrypted: "available",
779
+ hardwareBacked: "unknown",
780
+ biometricProtected,
781
+ valueExposed: false
782
+ };
783
+ });
784
+ },
785
+ getAllSecureMetadata: () => {
786
+ return measureOperation("storage:getAllSecureMetadata", StorageScope.Secure, () => {
787
+ flushSecureWrites();
788
+ return getStorageModule().getAllKeys(StorageScope.Secure).map(key => storage.getSecureMetadata(key));
789
+ });
790
+ },
593
791
  getString: (key, scope) => {
594
792
  return measureOperation("storage:getString", scope, () => {
595
793
  return getRawValue(key, scope);
@@ -611,11 +809,13 @@ export const storage = {
611
809
  assertValidScope(scope);
612
810
  if (keys.length === 0) return;
613
811
  const values = keys.map(k => data[k]);
812
+ const changes = keys.map((key, index) => createKeyChange(scope, key, getEventRawValue(scope, key), values[index], "import", scope === StorageScope.Memory ? "memory" : "native"));
614
813
  if (scope === StorageScope.Memory) {
615
814
  keys.forEach((key, index) => {
616
815
  memoryStore.set(key, values[index]);
617
816
  });
618
817
  keys.forEach(key => notifyKeyListeners(memoryListeners, key));
818
+ emitBatchChange(scope, "import", "memory", changes);
619
819
  return;
620
820
  }
621
821
  if (scope === StorageScope.Secure) {
@@ -624,6 +824,7 @@ export const storage = {
624
824
  }
625
825
  getStorageModule().setBatch(keys, values, scope);
626
826
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
827
+ emitBatchChange(scope, "import", "native", changes);
627
828
  }, keys.length);
628
829
  }
629
830
  };
@@ -744,20 +945,24 @@ export function createStorageItem(config) {
744
945
  return raw;
745
946
  };
746
947
  const writeStoredRaw = rawValue => {
948
+ const oldValue = undefined;
747
949
  if (isBiometric) {
748
950
  getStorageModule().setSecureBiometricWithLevel(storageKey, rawValue, resolvedBiometricLevel);
951
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "native");
749
952
  return;
750
953
  }
751
954
  cacheRawValue(nonMemoryScope, storageKey, rawValue);
752
955
  if (nonMemoryScope === StorageScope.Disk) {
753
956
  if (coalesceDiskWrites || diskWritesAsync) {
754
957
  scheduleDiskWrite(storageKey, rawValue);
958
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "native");
755
959
  return;
756
960
  }
757
961
  clearPendingDiskWrite(storageKey);
758
962
  }
759
963
  if (coalesceSecureWrites) {
760
964
  scheduleSecureWrite(storageKey, rawValue, secureAccessControl ?? secureDefaultAccessControl);
965
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "native");
761
966
  return;
762
967
  }
763
968
  if (nonMemoryScope === StorageScope.Secure) {
@@ -765,36 +970,44 @@ export function createStorageItem(config) {
765
970
  getStorageModule().setSecureAccessControl(secureAccessControl ?? secureDefaultAccessControl);
766
971
  }
767
972
  getStorageModule().set(storageKey, rawValue, config.scope);
973
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "native");
768
974
  };
769
975
  const removeStoredRaw = () => {
976
+ const oldValue = getEventRawValue(config.scope, storageKey);
770
977
  if (isBiometric) {
771
978
  getStorageModule().deleteSecureBiometric(storageKey);
979
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "native");
772
980
  return;
773
981
  }
774
982
  cacheRawValue(nonMemoryScope, storageKey, undefined);
775
983
  if (nonMemoryScope === StorageScope.Disk) {
776
984
  if (coalesceDiskWrites || diskWritesAsync) {
777
985
  scheduleDiskWrite(storageKey, undefined);
986
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "native");
778
987
  return;
779
988
  }
780
989
  clearPendingDiskWrite(storageKey);
781
990
  }
782
991
  if (coalesceSecureWrites) {
783
992
  scheduleSecureWrite(storageKey, undefined, secureAccessControl ?? secureDefaultAccessControl);
993
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "native");
784
994
  return;
785
995
  }
786
996
  if (nonMemoryScope === StorageScope.Secure) {
787
997
  clearPendingSecureWrite(storageKey);
788
998
  }
789
999
  getStorageModule().remove(storageKey, config.scope);
1000
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "native");
790
1001
  };
791
1002
  const writeValueWithoutValidation = value => {
792
1003
  if (isMemory) {
1004
+ const oldValue = getEventRawValue(config.scope, storageKey);
793
1005
  if (memoryExpiration) {
794
1006
  memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
795
1007
  }
796
1008
  memoryStore.set(storageKey, value);
797
1009
  notifyKeyListeners(memoryListeners, storageKey);
1010
+ emitKeyChange(config.scope, storageKey, oldValue, typeof value === "string" ? value : undefined, "set", "memory");
798
1011
  return;
799
1012
  }
800
1013
  const serialized = serialize(value);
@@ -926,11 +1139,13 @@ export function createStorageItem(config) {
926
1139
  measureOperation("item:delete", config.scope, () => {
927
1140
  invalidateParsedCache();
928
1141
  if (isMemory) {
1142
+ const oldValue = getEventRawValue(config.scope, storageKey);
929
1143
  if (memoryExpiration) {
930
1144
  memoryExpiration.delete(storageKey);
931
1145
  }
932
1146
  memoryStore.delete(storageKey);
933
1147
  notifyKeyListeners(memoryListeners, storageKey);
1148
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "memory");
934
1149
  return;
935
1150
  }
936
1151
  removeStoredRaw();
@@ -967,6 +1182,22 @@ export function createStorageItem(config) {
967
1182
  }
968
1183
  };
969
1184
  };
1185
+ const subscribeSelector = (selector, listener, options = {}) => {
1186
+ const isEqual = options.isEqual ?? Object.is;
1187
+ let currentValue = selector(getInternal());
1188
+ if (options.fireImmediately === true) {
1189
+ listener(currentValue, currentValue);
1190
+ }
1191
+ return subscribe(() => {
1192
+ const nextValue = selector(getInternal());
1193
+ if (isEqual(currentValue, nextValue)) {
1194
+ return;
1195
+ }
1196
+ const previousValue = currentValue;
1197
+ currentValue = nextValue;
1198
+ listener(nextValue, previousValue);
1199
+ });
1200
+ };
970
1201
  const storageItem = {
971
1202
  get,
972
1203
  getWithVersion,
@@ -975,6 +1206,7 @@ export function createStorageItem(config) {
975
1206
  delete: deleteItem,
976
1207
  has: hasItem,
977
1208
  subscribe,
1209
+ subscribeSelector,
978
1210
  serialize,
979
1211
  deserialize,
980
1212
  _triggerListeners: () => {
@@ -1078,6 +1310,10 @@ export function setBatch(items, scope) {
1078
1310
  }) => item.set(value));
1079
1311
  return;
1080
1312
  }
1313
+ const changes = items.map(({
1314
+ item,
1315
+ value
1316
+ }) => createKeyChange(scope, item.key, getEventRawValue(scope, item.key), typeof value === "string" ? value : undefined, "setBatch", "memory"));
1081
1317
 
1082
1318
  // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
1083
1319
  items.forEach(({
@@ -1090,6 +1326,7 @@ export function setBatch(items, scope) {
1090
1326
  items.forEach(({
1091
1327
  item
1092
1328
  }) => notifyKeyListeners(memoryListeners, item.key));
1329
+ emitBatchChange(scope, "setBatch", "memory", changes);
1093
1330
  return;
1094
1331
  }
1095
1332
  if (scope === StorageScope.Secure) {
@@ -1113,6 +1350,10 @@ export function setBatch(items, scope) {
1113
1350
  }
1114
1351
  flushSecureWrites();
1115
1352
  const storageModule = getStorageModule();
1353
+ const keys = secureEntries.map(({
1354
+ item
1355
+ }) => item.key);
1356
+ const oldValues = hasStorageChangeObservers(scope) ? storageModule.getBatch(keys, scope) ?? [] : [];
1116
1357
  const groupedByAccessControl = new Map();
1117
1358
  secureEntries.forEach(({
1118
1359
  item,
@@ -1136,6 +1377,10 @@ export function setBatch(items, scope) {
1136
1377
  storageModule.setBatch(group.keys, group.values, scope);
1137
1378
  group.keys.forEach((key, index) => cacheRawValue(scope, key, group.values[index]));
1138
1379
  });
1380
+ emitBatchChange(scope, "setBatch", "native", secureEntries.map(({
1381
+ item,
1382
+ value
1383
+ }, index) => createKeyChange(scope, item.key, oldValues[index], item.serialize(value), "setBatch", "native")));
1139
1384
  return;
1140
1385
  }
1141
1386
  flushDiskWrites();
@@ -1151,15 +1396,19 @@ export function setBatch(items, scope) {
1151
1396
  }
1152
1397
  const keys = items.map(entry => entry.item.key);
1153
1398
  const values = items.map(entry => entry.item.serialize(entry.value));
1399
+ const oldValues = hasStorageChangeObservers(scope) ? getStorageModule().getBatch(keys, scope) ?? [] : [];
1154
1400
  getStorageModule().setBatch(keys, values, scope);
1155
1401
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1402
+ emitBatchChange(scope, "setBatch", "native", keys.map((key, index) => createKeyChange(scope, key, oldValues[index], values[index], "setBatch", "native")));
1156
1403
  }, items.length);
1157
1404
  }
1158
1405
  export function removeBatch(items, scope) {
1159
1406
  measureOperation("batch:remove", scope, () => {
1160
1407
  assertBatchScope(items, scope);
1161
1408
  if (scope === StorageScope.Memory) {
1409
+ const changes = items.map(item => createKeyChange(scope, item.key, getEventRawValue(scope, item.key), undefined, "removeBatch", "memory"));
1162
1410
  items.forEach(item => item.delete());
1411
+ emitBatchChange(scope, "removeBatch", "memory", changes);
1163
1412
  return;
1164
1413
  }
1165
1414
  const keys = items.map(item => item.key);
@@ -1169,8 +1418,10 @@ export function removeBatch(items, scope) {
1169
1418
  if (scope === StorageScope.Secure) {
1170
1419
  flushSecureWrites();
1171
1420
  }
1421
+ const oldValues = hasStorageChangeObservers(scope) ? getStorageModule().getBatch(keys, scope) ?? [] : [];
1172
1422
  getStorageModule().removeBatch(keys, scope);
1173
1423
  keys.forEach(key => cacheRawValue(scope, key, undefined));
1424
+ emitBatchChange(scope, "removeBatch", "native", keys.map((key, index) => createKeyChange(scope, key, oldValues[index], undefined, "removeBatch", "native")));
1174
1425
  }, items.length);
1175
1426
  }
1176
1427
  export function registerMigration(version, migration) {