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 { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
4
4
  import { MIGRATION_VERSION_KEY, isStoredEnvelope, assertBatchScope, assertValidScope, serializeWithPrimitiveFastPath, deserializeWithPrimitiveFastPath, toVersionToken, prefixKey, isNamespaced } from "./internal";
5
5
  import { createLocalStorageWebBackend } from "./web-storage-backend";
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";
@@ -38,7 +39,9 @@ const BIOMETRIC_WEB_PREFIX = "__bio_";
38
39
  let hasWarnedAboutWebBiometricFallback = false;
39
40
  let hasWindowStorageEventSubscription = false;
40
41
  let metricsObserver;
42
+ let eventObserver;
41
43
  const metricsCounters = new Map();
44
+ const storageEvents = new StorageEventRegistry();
42
45
  function recordMetric(operation, scope, durationMs, keysCount = 1) {
43
46
  const existing = metricsCounters.get(operation);
44
47
  if (!existing) {
@@ -89,6 +92,9 @@ function getBackendName(scope, backend) {
89
92
  const scopeName = scope === StorageScope.Disk ? "disk" : "secure";
90
93
  return backend?.name ?? `web:${scopeName}`;
91
94
  }
95
+ function getWebSecureEncryptionStatus(backend) {
96
+ return backend?.name === "localStorage:secure" ? "unavailable" : "unknown";
97
+ }
92
98
  function createWebStorageError(scope, operation, error, backend) {
93
99
  const backendName = getBackendName(scope, backend);
94
100
  const message = error instanceof Error ? error.message : String(error ?? "Unknown error");
@@ -160,6 +166,7 @@ function applyExternalChangeEvent(scope, key, newValue) {
160
166
  }
161
167
  if (scope === StorageScope.Secure && key.startsWith(SECURE_WEB_PREFIX)) {
162
168
  const plainKey = fromSecureStorageKey(key);
169
+ const oldValue = readCachedRawValue(StorageScope.Secure, plainKey);
163
170
  if (newValue === null) {
164
171
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
165
172
  cacheRawValue(StorageScope.Secure, plainKey, undefined);
@@ -168,10 +175,12 @@ function applyExternalChangeEvent(scope, key, newValue) {
168
175
  cacheRawValue(StorageScope.Secure, plainKey, newValue);
169
176
  }
170
177
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
178
+ emitKeyChange(StorageScope.Secure, plainKey, oldValue, newValue ?? undefined, "external", "external");
171
179
  return;
172
180
  }
173
181
  if (scope === StorageScope.Secure && key.startsWith(BIOMETRIC_WEB_PREFIX)) {
174
182
  const plainKey = fromBiometricStorageKey(key);
183
+ const oldValue = readCachedRawValue(StorageScope.Secure, plainKey);
175
184
  if (newValue === null) {
176
185
  if (withWebBackendOperation(StorageScope.Secure, "external-sync:getItem", backend => backend.getItem(toSecureStorageKey(plainKey))) === null) {
177
186
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
@@ -182,8 +191,10 @@ function applyExternalChangeEvent(scope, key, newValue) {
182
191
  cacheRawValue(StorageScope.Secure, plainKey, newValue);
183
192
  }
184
193
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
194
+ emitKeyChange(StorageScope.Secure, plainKey, oldValue, newValue ?? undefined, "external", "external");
185
195
  return;
186
196
  }
197
+ const oldValue = readCachedRawValue(scope, key);
187
198
  if (newValue === null) {
188
199
  ensureWebScopeKeyIndex(scope).delete(key);
189
200
  cacheRawValue(scope, key, undefined);
@@ -192,6 +203,7 @@ function applyExternalChangeEvent(scope, key, newValue) {
192
203
  cacheRawValue(scope, key, newValue);
193
204
  }
194
205
  notifyKeyListeners(getScopedListeners(scope), key);
206
+ emitKeyChange(scope, key, oldValue, newValue ?? undefined, "external", "external");
195
207
  }
196
208
  function handleWebStorageEvent(event) {
197
209
  const key = event.key;
@@ -282,6 +294,46 @@ function addKeyListener(registry, key, listener) {
282
294
  }
283
295
  };
284
296
  }
297
+ function getEventRawValue(scope, key) {
298
+ if (scope === StorageScope.Memory) {
299
+ const value = memoryStore.get(key);
300
+ return typeof value === "string" ? value : undefined;
301
+ }
302
+ return getRawValue(key, scope);
303
+ }
304
+ function createKeyChange(scope, key, oldValue, newValue, operation, source) {
305
+ return {
306
+ type: "key",
307
+ scope,
308
+ key,
309
+ oldValue,
310
+ newValue,
311
+ operation,
312
+ source
313
+ };
314
+ }
315
+ function hasStorageChangeObservers(scope) {
316
+ return storageEvents.hasListeners(scope) || eventObserver !== undefined;
317
+ }
318
+ function emitKeyChange(scope, key, oldValue, newValue, operation, source) {
319
+ const event = createKeyChange(scope, key, oldValue, newValue, operation, source);
320
+ storageEvents.emitKey(event);
321
+ eventObserver?.(event);
322
+ }
323
+ function emitBatchChange(scope, operation, source, changes) {
324
+ if (changes.length === 0) {
325
+ return;
326
+ }
327
+ const event = {
328
+ type: "batch",
329
+ scope,
330
+ operation,
331
+ source,
332
+ changes
333
+ };
334
+ storageEvents.emitBatch(event);
335
+ eventObserver?.(event);
336
+ }
285
337
  function readPendingSecureWrite(key) {
286
338
  return pendingSecureWrites.get(key)?.value;
287
339
  }
@@ -531,13 +583,10 @@ const WebStorage = {
531
583
  return () => {};
532
584
  },
533
585
  has: (key, scope) => {
534
- if (scope === StorageScope.Secure) {
535
- return withWebBackendOperation(scope, "has", backend => backend.getItem(toSecureStorageKey(key))) !== null || withWebBackendOperation(scope, "has", backend => backend.getItem(toBiometricStorageKey(key))) !== null;
536
- }
537
- if (scope !== StorageScope.Disk) {
538
- return false;
586
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
587
+ return ensureWebScopeKeyIndex(scope).has(key);
539
588
  }
540
- return withWebBackendOperation(scope, "has", backend => backend.getItem(key)) !== null;
589
+ return false;
541
590
  },
542
591
  getAllKeys: scope => {
543
592
  if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
@@ -628,15 +677,18 @@ function getRawValue(key, scope) {
628
677
  }
629
678
  function setRawValue(key, value, scope) {
630
679
  assertValidScope(scope);
680
+ const oldValue = scope === StorageScope.Memory ? getEventRawValue(scope, key) : undefined;
631
681
  if (scope === StorageScope.Memory) {
632
682
  memoryStore.set(key, value);
633
683
  notifyKeyListeners(memoryListeners, key);
684
+ emitKeyChange(scope, key, oldValue, value, "set", "memory");
634
685
  return;
635
686
  }
636
687
  if (scope === StorageScope.Disk) {
637
688
  cacheRawValue(scope, key, value);
638
689
  if (diskWritesAsync) {
639
690
  scheduleDiskWrite(key, value);
691
+ emitKeyChange(scope, key, oldValue, value, "set", "web");
640
692
  return;
641
693
  }
642
694
  flushDiskWrites();
@@ -648,18 +700,22 @@ function setRawValue(key, value, scope) {
648
700
  }
649
701
  WebStorage.set(key, value, scope);
650
702
  cacheRawValue(scope, key, value);
703
+ emitKeyChange(scope, key, oldValue, value, "set", "web");
651
704
  }
652
705
  function removeRawValue(key, scope) {
653
706
  assertValidScope(scope);
707
+ const oldValue = getEventRawValue(scope, key);
654
708
  if (scope === StorageScope.Memory) {
655
709
  memoryStore.delete(key);
656
710
  notifyKeyListeners(memoryListeners, key);
711
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "memory");
657
712
  return;
658
713
  }
659
714
  if (scope === StorageScope.Disk) {
660
715
  cacheRawValue(scope, key, undefined);
661
716
  if (diskWritesAsync) {
662
717
  scheduleDiskWrite(key, undefined);
718
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "web");
663
719
  return;
664
720
  }
665
721
  flushDiskWrites();
@@ -671,6 +727,7 @@ function removeRawValue(key, scope) {
671
727
  }
672
728
  WebStorage.remove(key, scope);
673
729
  cacheRawValue(scope, key, undefined);
730
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "web");
674
731
  }
675
732
  function readMigrationVersion(scope) {
676
733
  const raw = getRawValue(MIGRATION_VERSION_KEY, scope);
@@ -684,11 +741,43 @@ function writeMigrationVersion(scope, version) {
684
741
  setRawValue(MIGRATION_VERSION_KEY, String(version), scope);
685
742
  }
686
743
  export const storage = {
744
+ subscribe: (scope, listener) => {
745
+ assertValidScope(scope);
746
+ if (scope !== StorageScope.Memory) {
747
+ ensureExternalSyncSubscriptions();
748
+ }
749
+ return storageEvents.subscribe(scope, listener);
750
+ },
751
+ subscribeKey: (scope, key, listener) => {
752
+ assertValidScope(scope);
753
+ if (scope !== StorageScope.Memory) {
754
+ ensureExternalSyncSubscriptions();
755
+ }
756
+ return storageEvents.subscribeKey(scope, key, listener);
757
+ },
758
+ subscribePrefix: (scope, prefix, listener) => {
759
+ assertValidScope(scope);
760
+ if (scope !== StorageScope.Memory) {
761
+ ensureExternalSyncSubscriptions();
762
+ }
763
+ return storageEvents.subscribePrefix(scope, prefix, listener);
764
+ },
765
+ subscribeNamespace: (namespace, scope, listener) => {
766
+ return storage.subscribePrefix(scope, prefixKey(namespace, ""), listener);
767
+ },
768
+ setEventObserver: observer => {
769
+ eventObserver = observer;
770
+ if (observer) {
771
+ ensureExternalSyncSubscriptions();
772
+ }
773
+ },
687
774
  clear: scope => {
688
775
  measureOperation("storage:clear", scope, () => {
776
+ const previousValues = hasStorageChangeObservers(scope) ? storage.getAll(scope) : {};
689
777
  if (scope === StorageScope.Memory) {
690
778
  memoryStore.clear();
691
779
  notifyAllListeners(memoryListeners);
780
+ emitBatchChange(scope, "clear", "memory", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clear", "memory")));
692
781
  return;
693
782
  }
694
783
  if (scope === StorageScope.Disk) {
@@ -701,6 +790,7 @@ export const storage = {
701
790
  }
702
791
  clearScopeRawCache(scope);
703
792
  WebStorage.clear(scope);
793
+ emitBatchChange(scope, "clear", "web", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clear", "web")));
704
794
  });
705
795
  },
706
796
  clearAll: () => {
@@ -714,15 +804,26 @@ export const storage = {
714
804
  measureOperation("storage:clearNamespace", scope, () => {
715
805
  assertValidScope(scope);
716
806
  if (scope === StorageScope.Memory) {
717
- for (const key of memoryStore.keys()) {
718
- if (isNamespaced(key, namespace)) {
719
- memoryStore.delete(key);
720
- }
807
+ const affectedKeys = Array.from(memoryStore.keys()).filter(key => isNamespaced(key, namespace));
808
+ const previousValues = affectedKeys.map(key => ({
809
+ key,
810
+ value: getEventRawValue(scope, key)
811
+ }));
812
+ if (affectedKeys.length === 0) {
813
+ return;
721
814
  }
722
- notifyAllListeners(memoryListeners);
815
+ affectedKeys.forEach(key => {
816
+ memoryStore.delete(key);
817
+ });
818
+ affectedKeys.forEach(key => notifyKeyListeners(memoryListeners, key));
819
+ emitBatchChange(scope, "clearNamespace", "memory", previousValues.map(({
820
+ key,
821
+ value
822
+ }) => createKeyChange(scope, key, value, undefined, "clearNamespace", "memory")));
723
823
  return;
724
824
  }
725
825
  const keyPrefix = prefixKey(namespace, "");
826
+ const previousValues = hasStorageChangeObservers(scope) ? storage.getByPrefix(keyPrefix, scope) : {};
726
827
  if (scope === StorageScope.Disk) {
727
828
  flushDiskWrites();
728
829
  }
@@ -736,6 +837,7 @@ export const storage = {
736
837
  }
737
838
  }
738
839
  WebStorage.removeByPrefix(keyPrefix, scope);
840
+ emitBatchChange(scope, "clearNamespace", "web", Object.keys(previousValues).map(key => createKeyChange(scope, key, previousValues[key], undefined, "clearNamespace", "web")));
739
841
  });
740
842
  },
741
843
  clearBiometric: () => {
@@ -844,6 +946,9 @@ export const storage = {
844
946
  return result;
845
947
  });
846
948
  },
949
+ export: scope => {
950
+ return measureOperation("storage:export", scope, () => storage.getAll(scope));
951
+ },
847
952
  size: scope => {
848
953
  return measureOperation("storage:size", scope, () => {
849
954
  assertValidScope(scope);
@@ -915,6 +1020,57 @@ export const storage = {
915
1020
  },
916
1021
  errorClassification: true
917
1022
  }),
1023
+ getSecurityCapabilities: () => {
1024
+ const secureBackend = getBackendName(StorageScope.Secure, webSecureStorageBackend);
1025
+ return {
1026
+ platform: "web",
1027
+ secureStorage: {
1028
+ backend: secureBackend,
1029
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1030
+ accessControl: "unavailable",
1031
+ keychainAccessGroup: "unavailable",
1032
+ hardwareBacked: "unavailable"
1033
+ },
1034
+ biometric: {
1035
+ storage: "unavailable",
1036
+ prompt: "unavailable",
1037
+ biometryOnly: "unavailable",
1038
+ biometryOrPasscode: "unavailable"
1039
+ },
1040
+ metadata: {
1041
+ perKey: true,
1042
+ listsWithoutValues: true,
1043
+ persistsTimestamps: false
1044
+ }
1045
+ };
1046
+ },
1047
+ getSecureMetadata: key => {
1048
+ return measureOperation("storage:getSecureMetadata", StorageScope.Secure, () => {
1049
+ flushSecureWrites();
1050
+ const biometricProtected = WebStorage.hasSecureBiometric(key);
1051
+ const exists = biometricProtected || WebStorage.has(key, StorageScope.Secure);
1052
+ let kind = "missing";
1053
+ if (exists) {
1054
+ kind = biometricProtected ? "biometric" : "secure";
1055
+ }
1056
+ return {
1057
+ key,
1058
+ exists,
1059
+ kind,
1060
+ backend: getBackendName(StorageScope.Secure, webSecureStorageBackend),
1061
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1062
+ hardwareBacked: "unavailable",
1063
+ biometricProtected,
1064
+ valueExposed: false
1065
+ };
1066
+ });
1067
+ },
1068
+ getAllSecureMetadata: () => {
1069
+ return measureOperation("storage:getAllSecureMetadata", StorageScope.Secure, () => {
1070
+ flushSecureWrites();
1071
+ return WebStorage.getAllKeys(StorageScope.Secure).map(key => storage.getSecureMetadata(key));
1072
+ });
1073
+ },
918
1074
  getString: (key, scope) => {
919
1075
  return measureOperation("storage:getString", scope, () => {
920
1076
  return getRawValue(key, scope);
@@ -936,11 +1092,13 @@ export const storage = {
936
1092
  assertValidScope(scope);
937
1093
  if (keys.length === 0) return;
938
1094
  const values = keys.map(k => data[k]);
1095
+ const changes = keys.map((key, index) => createKeyChange(scope, key, getEventRawValue(scope, key), values[index], "import", scope === StorageScope.Memory ? "memory" : "web"));
939
1096
  if (scope === StorageScope.Memory) {
940
1097
  keys.forEach((key, index) => {
941
1098
  memoryStore.set(key, values[index]);
942
1099
  });
943
1100
  keys.forEach(key => notifyKeyListeners(memoryListeners, key));
1101
+ emitBatchChange(scope, "import", "memory", changes);
944
1102
  return;
945
1103
  }
946
1104
  if (scope === StorageScope.Secure) {
@@ -952,6 +1110,7 @@ export const storage = {
952
1110
  }
953
1111
  WebStorage.setBatch(keys, values, scope);
954
1112
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1113
+ emitBatchChange(scope, "import", "web", changes);
955
1114
  }, keys.length);
956
1115
  }
957
1116
  };
@@ -1093,56 +1252,68 @@ export function createStorageItem(config) {
1093
1252
  return raw;
1094
1253
  };
1095
1254
  const writeStoredRaw = rawValue => {
1255
+ const oldValue = config.scope === StorageScope.Memory ? getEventRawValue(config.scope, storageKey) : undefined;
1096
1256
  if (isBiometric) {
1097
1257
  WebStorage.setSecureBiometricWithLevel(storageKey, rawValue, resolvedBiometricLevel);
1258
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1098
1259
  return;
1099
1260
  }
1100
1261
  cacheRawValue(nonMemoryScope, storageKey, rawValue);
1101
1262
  if (nonMemoryScope === StorageScope.Disk) {
1102
1263
  if (coalesceDiskWrites || diskWritesAsync) {
1103
1264
  scheduleDiskWrite(storageKey, rawValue);
1265
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1104
1266
  return;
1105
1267
  }
1106
1268
  clearPendingDiskWrite(storageKey);
1107
1269
  }
1108
1270
  if (coalesceSecureWrites) {
1109
1271
  scheduleSecureWrite(storageKey, rawValue, secureAccessControl ?? secureDefaultAccessControl);
1272
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1110
1273
  return;
1111
1274
  }
1112
1275
  if (nonMemoryScope === StorageScope.Secure) {
1113
1276
  clearPendingSecureWrite(storageKey);
1114
1277
  }
1115
1278
  WebStorage.set(storageKey, rawValue, config.scope);
1279
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1116
1280
  };
1117
1281
  const removeStoredRaw = () => {
1282
+ const oldValue = getEventRawValue(config.scope, storageKey);
1118
1283
  if (isBiometric) {
1119
1284
  WebStorage.deleteSecureBiometric(storageKey);
1285
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "web");
1120
1286
  return;
1121
1287
  }
1122
1288
  cacheRawValue(nonMemoryScope, storageKey, undefined);
1123
1289
  if (nonMemoryScope === StorageScope.Disk) {
1124
1290
  if (coalesceDiskWrites || diskWritesAsync) {
1125
1291
  scheduleDiskWrite(storageKey, undefined);
1292
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "web");
1126
1293
  return;
1127
1294
  }
1128
1295
  clearPendingDiskWrite(storageKey);
1129
1296
  }
1130
1297
  if (coalesceSecureWrites) {
1131
1298
  scheduleSecureWrite(storageKey, undefined, secureAccessControl ?? secureDefaultAccessControl);
1299
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "web");
1132
1300
  return;
1133
1301
  }
1134
1302
  if (nonMemoryScope === StorageScope.Secure) {
1135
1303
  clearPendingSecureWrite(storageKey);
1136
1304
  }
1137
1305
  WebStorage.remove(storageKey, config.scope);
1306
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "web");
1138
1307
  };
1139
1308
  const writeValueWithoutValidation = value => {
1140
1309
  if (isMemory) {
1310
+ const oldValue = getEventRawValue(config.scope, storageKey);
1141
1311
  if (memoryExpiration) {
1142
1312
  memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
1143
1313
  }
1144
1314
  memoryStore.set(storageKey, value);
1145
1315
  notifyKeyListeners(memoryListeners, storageKey);
1316
+ emitKeyChange(config.scope, storageKey, oldValue, typeof value === "string" ? value : undefined, "set", "memory");
1146
1317
  return;
1147
1318
  }
1148
1319
  const serialized = serialize(value);
@@ -1274,11 +1445,13 @@ export function createStorageItem(config) {
1274
1445
  measureOperation("item:delete", config.scope, () => {
1275
1446
  invalidateParsedCache();
1276
1447
  if (isMemory) {
1448
+ const oldValue = getEventRawValue(config.scope, storageKey);
1277
1449
  if (memoryExpiration) {
1278
1450
  memoryExpiration.delete(storageKey);
1279
1451
  }
1280
1452
  memoryStore.delete(storageKey);
1281
1453
  notifyKeyListeners(memoryListeners, storageKey);
1454
+ emitKeyChange(config.scope, storageKey, oldValue, undefined, "remove", "memory");
1282
1455
  return;
1283
1456
  }
1284
1457
  removeStoredRaw();
@@ -1312,6 +1485,22 @@ export function createStorageItem(config) {
1312
1485
  }
1313
1486
  };
1314
1487
  };
1488
+ const subscribeSelector = (selector, listener, options = {}) => {
1489
+ const isEqual = options.isEqual ?? Object.is;
1490
+ let currentValue = selector(getInternal());
1491
+ if (options.fireImmediately === true) {
1492
+ listener(currentValue, currentValue);
1493
+ }
1494
+ return subscribe(() => {
1495
+ const nextValue = selector(getInternal());
1496
+ if (isEqual(currentValue, nextValue)) {
1497
+ return;
1498
+ }
1499
+ const previousValue = currentValue;
1500
+ currentValue = nextValue;
1501
+ listener(nextValue, previousValue);
1502
+ });
1503
+ };
1315
1504
  const storageItem = {
1316
1505
  get,
1317
1506
  getWithVersion,
@@ -1320,6 +1509,7 @@ export function createStorageItem(config) {
1320
1509
  delete: deleteItem,
1321
1510
  has: hasItem,
1322
1511
  subscribe,
1512
+ subscribeSelector,
1323
1513
  serialize,
1324
1514
  deserialize,
1325
1515
  _triggerListeners: () => {
@@ -1422,6 +1612,10 @@ export function setBatch(items, scope) {
1422
1612
  }) => item.set(value));
1423
1613
  return;
1424
1614
  }
1615
+ const changes = items.map(({
1616
+ item,
1617
+ value
1618
+ }) => createKeyChange(scope, item.key, getEventRawValue(scope, item.key), typeof value === "string" ? value : undefined, "setBatch", "memory"));
1425
1619
 
1426
1620
  // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
1427
1621
  items.forEach(({
@@ -1434,6 +1628,7 @@ export function setBatch(items, scope) {
1434
1628
  items.forEach(({
1435
1629
  item
1436
1630
  }) => notifyKeyListeners(memoryListeners, item.key));
1631
+ emitBatchChange(scope, "setBatch", "memory", changes);
1437
1632
  return;
1438
1633
  }
1439
1634
  if (scope === StorageScope.Secure) {
@@ -1456,6 +1651,10 @@ export function setBatch(items, scope) {
1456
1651
  return;
1457
1652
  }
1458
1653
  flushSecureWrites();
1654
+ const keys = secureEntries.map(({
1655
+ item
1656
+ }) => item.key);
1657
+ const oldValues = hasStorageChangeObservers(scope) ? WebStorage.getBatch(keys, scope) : [];
1459
1658
  const groupedByAccessControl = new Map();
1460
1659
  secureEntries.forEach(({
1461
1660
  item,
@@ -1479,6 +1678,10 @@ export function setBatch(items, scope) {
1479
1678
  WebStorage.setBatch(group.keys, group.values, scope);
1480
1679
  group.keys.forEach((key, index) => cacheRawValue(scope, key, group.values[index]));
1481
1680
  });
1681
+ emitBatchChange(scope, "setBatch", "web", secureEntries.map(({
1682
+ item,
1683
+ value
1684
+ }, index) => createKeyChange(scope, item.key, oldValues[index], item.serialize(value), "setBatch", "web")));
1482
1685
  return;
1483
1686
  }
1484
1687
  flushDiskWrites();
@@ -1494,15 +1697,19 @@ export function setBatch(items, scope) {
1494
1697
  }
1495
1698
  const keys = items.map(entry => entry.item.key);
1496
1699
  const values = items.map(entry => entry.item.serialize(entry.value));
1700
+ const oldValues = hasStorageChangeObservers(scope) ? WebStorage.getBatch(keys, scope) : [];
1497
1701
  WebStorage.setBatch(keys, values, scope);
1498
1702
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1703
+ emitBatchChange(scope, "setBatch", "web", keys.map((key, index) => createKeyChange(scope, key, oldValues[index], values[index], "setBatch", "web")));
1499
1704
  }, items.length);
1500
1705
  }
1501
1706
  export function removeBatch(items, scope) {
1502
1707
  measureOperation("batch:remove", scope, () => {
1503
1708
  assertBatchScope(items, scope);
1504
1709
  if (scope === StorageScope.Memory) {
1710
+ const changes = items.map(item => createKeyChange(scope, item.key, getEventRawValue(scope, item.key), undefined, "removeBatch", "memory"));
1505
1711
  items.forEach(item => item.delete());
1712
+ emitBatchChange(scope, "removeBatch", "memory", changes);
1506
1713
  return;
1507
1714
  }
1508
1715
  const keys = items.map(item => item.key);
@@ -1512,8 +1719,10 @@ export function removeBatch(items, scope) {
1512
1719
  if (scope === StorageScope.Secure) {
1513
1720
  flushSecureWrites();
1514
1721
  }
1722
+ const oldValues = hasStorageChangeObservers(scope) ? WebStorage.getBatch(keys, scope) : [];
1515
1723
  WebStorage.removeBatch(keys, scope);
1516
1724
  keys.forEach(key => cacheRawValue(scope, key, undefined));
1725
+ emitBatchChange(scope, "removeBatch", "web", keys.map((key, index) => createKeyChange(scope, key, oldValues[index], undefined, "removeBatch", "web")));
1517
1726
  }, items.length);
1518
1727
  }
1519
1728
  export function registerMigration(version, migration) {