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.web.ts CHANGED
@@ -21,17 +21,38 @@ import {
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 { migrateFromMMKV } from "./migration";
30
41
  export {
31
42
  getStorageErrorCode,
43
+ type SecureStorageMetadata,
44
+ type SecurityCapabilities,
32
45
  type StorageCapabilities,
33
46
  type StorageErrorCode,
34
47
  } from "./storage-runtime";
48
+ export type {
49
+ StorageBatchChangeEvent,
50
+ StorageChangeEvent,
51
+ StorageChangeOperation,
52
+ StorageChangeSource,
53
+ StorageEventListener,
54
+ StorageKeyChangeEvent,
55
+ } from "./storage-events";
35
56
  export type {
36
57
  WebStorageBackend,
37
58
  WebStorageChangeEvent,
@@ -60,6 +81,14 @@ export type StorageMetricSummary = {
60
81
  avgDurationMs: number;
61
82
  maxDurationMs: number;
62
83
  };
84
+ export type StorageSelectorListener<TSelected> = (
85
+ value: TSelected,
86
+ previousValue: TSelected,
87
+ ) => void;
88
+ export type StorageSelectorSubscribeOptions<TSelected> = {
89
+ isEqual?: (previousValue: TSelected, nextValue: TSelected) => boolean;
90
+ fireImmediately?: boolean;
91
+ };
63
92
  export type MigrationContext = {
64
93
  scope: StorageScope;
65
94
  getRaw: (key: string) => string | undefined;
@@ -187,10 +216,12 @@ const BIOMETRIC_WEB_PREFIX = "__bio_";
187
216
  let hasWarnedAboutWebBiometricFallback = false;
188
217
  let hasWindowStorageEventSubscription = false;
189
218
  let metricsObserver: StorageMetricsObserver | undefined;
219
+ let eventObserver: StorageEventListener | undefined;
190
220
  const metricsCounters = new Map<
191
221
  string,
192
222
  { count: number; totalDurationMs: number; maxDurationMs: number }
193
223
  >();
224
+ const storageEvents = new StorageEventRegistry();
194
225
 
195
226
  function recordMetric(
196
227
  operation: string,
@@ -261,6 +292,12 @@ function getBackendName(
261
292
  return backend?.name ?? `web:${scopeName}`;
262
293
  }
263
294
 
295
+ function getWebSecureEncryptionStatus(
296
+ backend: WebSecureStorageBackend | undefined,
297
+ ): "unavailable" | "unknown" {
298
+ return backend?.name === "localStorage:secure" ? "unavailable" : "unknown";
299
+ }
300
+
264
301
  function createWebStorageError(
265
302
  scope: NonMemoryScope,
266
303
  operation: string,
@@ -369,6 +406,7 @@ function applyExternalChangeEvent(
369
406
 
370
407
  if (scope === StorageScope.Secure && key.startsWith(SECURE_WEB_PREFIX)) {
371
408
  const plainKey = fromSecureStorageKey(key);
409
+ const oldValue = readCachedRawValue(StorageScope.Secure, plainKey);
372
410
  if (newValue === null) {
373
411
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
374
412
  cacheRawValue(StorageScope.Secure, plainKey, undefined);
@@ -377,11 +415,20 @@ function applyExternalChangeEvent(
377
415
  cacheRawValue(StorageScope.Secure, plainKey, newValue);
378
416
  }
379
417
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
418
+ emitKeyChange(
419
+ StorageScope.Secure,
420
+ plainKey,
421
+ oldValue,
422
+ newValue ?? undefined,
423
+ "external",
424
+ "external",
425
+ );
380
426
  return;
381
427
  }
382
428
 
383
429
  if (scope === StorageScope.Secure && key.startsWith(BIOMETRIC_WEB_PREFIX)) {
384
430
  const plainKey = fromBiometricStorageKey(key);
431
+ const oldValue = readCachedRawValue(StorageScope.Secure, plainKey);
385
432
  if (newValue === null) {
386
433
  if (
387
434
  withWebBackendOperation(
@@ -398,9 +445,18 @@ function applyExternalChangeEvent(
398
445
  cacheRawValue(StorageScope.Secure, plainKey, newValue);
399
446
  }
400
447
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
448
+ emitKeyChange(
449
+ StorageScope.Secure,
450
+ plainKey,
451
+ oldValue,
452
+ newValue ?? undefined,
453
+ "external",
454
+ "external",
455
+ );
401
456
  return;
402
457
  }
403
458
 
459
+ const oldValue = readCachedRawValue(scope, key);
404
460
  if (newValue === null) {
405
461
  ensureWebScopeKeyIndex(scope).delete(key);
406
462
  cacheRawValue(scope, key, undefined);
@@ -409,6 +465,14 @@ function applyExternalChangeEvent(
409
465
  cacheRawValue(scope, key, newValue);
410
466
  }
411
467
  notifyKeyListeners(getScopedListeners(scope), key);
468
+ emitKeyChange(
469
+ scope,
470
+ key,
471
+ oldValue,
472
+ newValue ?? undefined,
473
+ "external",
474
+ "external",
475
+ );
412
476
  }
413
477
 
414
478
  function handleWebStorageEvent(event: StorageEvent): void {
@@ -539,6 +603,82 @@ function addKeyListener(
539
603
  };
540
604
  }
541
605
 
606
+ function getEventRawValue(
607
+ scope: StorageScope,
608
+ key: string,
609
+ ): string | undefined {
610
+ if (scope === StorageScope.Memory) {
611
+ const value = memoryStore.get(key);
612
+ return typeof value === "string" ? value : undefined;
613
+ }
614
+
615
+ return getRawValue(key, scope);
616
+ }
617
+
618
+ function createKeyChange(
619
+ scope: StorageScope,
620
+ key: string,
621
+ oldValue: string | undefined,
622
+ newValue: string | undefined,
623
+ operation: StorageChangeOperation,
624
+ source: StorageChangeSource,
625
+ ): StorageKeyChangeEvent {
626
+ return {
627
+ type: "key",
628
+ scope,
629
+ key,
630
+ oldValue,
631
+ newValue,
632
+ operation,
633
+ source,
634
+ };
635
+ }
636
+
637
+ function hasStorageChangeObservers(scope: StorageScope): boolean {
638
+ return storageEvents.hasListeners(scope) || eventObserver !== undefined;
639
+ }
640
+
641
+ function emitKeyChange(
642
+ scope: StorageScope,
643
+ key: string,
644
+ oldValue: string | undefined,
645
+ newValue: string | undefined,
646
+ operation: StorageChangeOperation,
647
+ source: StorageChangeSource,
648
+ ): void {
649
+ const event = createKeyChange(
650
+ scope,
651
+ key,
652
+ oldValue,
653
+ newValue,
654
+ operation,
655
+ source,
656
+ );
657
+ storageEvents.emitKey(event);
658
+ eventObserver?.(event);
659
+ }
660
+
661
+ function emitBatchChange(
662
+ scope: StorageScope,
663
+ operation: StorageChangeOperation,
664
+ source: StorageChangeSource,
665
+ changes: StorageKeyChangeEvent[],
666
+ ): void {
667
+ if (changes.length === 0) {
668
+ return;
669
+ }
670
+
671
+ const event: StorageBatchChangeEvent = {
672
+ type: "batch",
673
+ scope,
674
+ operation,
675
+ source,
676
+ changes,
677
+ };
678
+ storageEvents.emitBatch(event);
679
+ eventObserver?.(event);
680
+ }
681
+
542
682
  function readPendingSecureWrite(key: string): string | undefined {
543
683
  return pendingSecureWrites.get(key)?.value;
544
684
  }
@@ -823,24 +963,10 @@ const WebStorage: Storage = {
823
963
  return () => {};
824
964
  },
825
965
  has: (key: string, scope: number) => {
826
- if (scope === StorageScope.Secure) {
827
- return (
828
- withWebBackendOperation(scope, "has", (backend) =>
829
- backend.getItem(toSecureStorageKey(key)),
830
- ) !== null ||
831
- withWebBackendOperation(scope, "has", (backend) =>
832
- backend.getItem(toBiometricStorageKey(key)),
833
- ) !== null
834
- );
835
- }
836
- if (scope !== StorageScope.Disk) {
837
- return false;
966
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
967
+ return ensureWebScopeKeyIndex(scope).has(key);
838
968
  }
839
- return (
840
- withWebBackendOperation(scope, "has", (backend) =>
841
- backend.getItem(key),
842
- ) !== null
843
- );
969
+ return false;
844
970
  },
845
971
  getAllKeys: (scope: number) => {
846
972
  if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
@@ -990,9 +1116,12 @@ function getRawValue(key: string, scope: StorageScope): string | undefined {
990
1116
 
991
1117
  function setRawValue(key: string, value: string, scope: StorageScope): void {
992
1118
  assertValidScope(scope);
1119
+ const oldValue =
1120
+ scope === StorageScope.Memory ? getEventRawValue(scope, key) : undefined;
993
1121
  if (scope === StorageScope.Memory) {
994
1122
  memoryStore.set(key, value);
995
1123
  notifyKeyListeners(memoryListeners, key);
1124
+ emitKeyChange(scope, key, oldValue, value, "set", "memory");
996
1125
  return;
997
1126
  }
998
1127
 
@@ -1000,6 +1129,7 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
1000
1129
  cacheRawValue(scope, key, value);
1001
1130
  if (diskWritesAsync) {
1002
1131
  scheduleDiskWrite(key, value);
1132
+ emitKeyChange(scope, key, oldValue, value, "set", "web");
1003
1133
  return;
1004
1134
  }
1005
1135
 
@@ -1014,13 +1144,16 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
1014
1144
 
1015
1145
  WebStorage.set(key, value, scope);
1016
1146
  cacheRawValue(scope, key, value);
1147
+ emitKeyChange(scope, key, oldValue, value, "set", "web");
1017
1148
  }
1018
1149
 
1019
1150
  function removeRawValue(key: string, scope: StorageScope): void {
1020
1151
  assertValidScope(scope);
1152
+ const oldValue = getEventRawValue(scope, key);
1021
1153
  if (scope === StorageScope.Memory) {
1022
1154
  memoryStore.delete(key);
1023
1155
  notifyKeyListeners(memoryListeners, key);
1156
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "memory");
1024
1157
  return;
1025
1158
  }
1026
1159
 
@@ -1028,6 +1161,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
1028
1161
  cacheRawValue(scope, key, undefined);
1029
1162
  if (diskWritesAsync) {
1030
1163
  scheduleDiskWrite(key, undefined);
1164
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "web");
1031
1165
  return;
1032
1166
  }
1033
1167
 
@@ -1042,6 +1176,7 @@ function removeRawValue(key: string, scope: StorageScope): void {
1042
1176
 
1043
1177
  WebStorage.remove(key, scope);
1044
1178
  cacheRawValue(scope, key, undefined);
1179
+ emitKeyChange(scope, key, oldValue, undefined, "remove", "web");
1045
1180
  }
1046
1181
 
1047
1182
  function readMigrationVersion(scope: StorageScope): number {
@@ -1059,11 +1194,74 @@ function writeMigrationVersion(scope: StorageScope, version: number): void {
1059
1194
  }
1060
1195
 
1061
1196
  export const storage = {
1197
+ subscribe: (
1198
+ scope: StorageScope,
1199
+ listener: StorageEventListener,
1200
+ ): (() => void) => {
1201
+ assertValidScope(scope);
1202
+ if (scope !== StorageScope.Memory) {
1203
+ ensureExternalSyncSubscriptions();
1204
+ }
1205
+ return storageEvents.subscribe(scope, listener);
1206
+ },
1207
+ subscribeKey: (
1208
+ scope: StorageScope,
1209
+ key: string,
1210
+ listener: StorageEventListener,
1211
+ ): (() => void) => {
1212
+ assertValidScope(scope);
1213
+ if (scope !== StorageScope.Memory) {
1214
+ ensureExternalSyncSubscriptions();
1215
+ }
1216
+ return storageEvents.subscribeKey(scope, key, listener);
1217
+ },
1218
+ subscribePrefix: (
1219
+ scope: StorageScope,
1220
+ prefix: string,
1221
+ listener: StorageEventListener,
1222
+ ): (() => void) => {
1223
+ assertValidScope(scope);
1224
+ if (scope !== StorageScope.Memory) {
1225
+ ensureExternalSyncSubscriptions();
1226
+ }
1227
+ return storageEvents.subscribePrefix(scope, prefix, listener);
1228
+ },
1229
+ subscribeNamespace: (
1230
+ namespace: string,
1231
+ scope: StorageScope,
1232
+ listener: StorageEventListener,
1233
+ ): (() => void) => {
1234
+ return storage.subscribePrefix(scope, prefixKey(namespace, ""), listener);
1235
+ },
1236
+ setEventObserver: (observer?: StorageEventListener) => {
1237
+ eventObserver = observer;
1238
+ if (observer) {
1239
+ ensureExternalSyncSubscriptions();
1240
+ }
1241
+ },
1062
1242
  clear: (scope: StorageScope) => {
1063
1243
  measureOperation("storage:clear", scope, () => {
1244
+ const previousValues = hasStorageChangeObservers(scope)
1245
+ ? storage.getAll(scope)
1246
+ : {};
1064
1247
  if (scope === StorageScope.Memory) {
1065
1248
  memoryStore.clear();
1066
1249
  notifyAllListeners(memoryListeners);
1250
+ emitBatchChange(
1251
+ scope,
1252
+ "clear",
1253
+ "memory",
1254
+ Object.keys(previousValues).map((key) =>
1255
+ createKeyChange(
1256
+ scope,
1257
+ key,
1258
+ previousValues[key],
1259
+ undefined,
1260
+ "clear",
1261
+ "memory",
1262
+ ),
1263
+ ),
1264
+ );
1067
1265
  return;
1068
1266
  }
1069
1267
 
@@ -1079,6 +1277,21 @@ export const storage = {
1079
1277
 
1080
1278
  clearScopeRawCache(scope);
1081
1279
  WebStorage.clear(scope);
1280
+ emitBatchChange(
1281
+ scope,
1282
+ "clear",
1283
+ "web",
1284
+ Object.keys(previousValues).map((key) =>
1285
+ createKeyChange(
1286
+ scope,
1287
+ key,
1288
+ previousValues[key],
1289
+ undefined,
1290
+ "clear",
1291
+ "web",
1292
+ ),
1293
+ ),
1294
+ );
1082
1295
  });
1083
1296
  },
1084
1297
  clearAll: () => {
@@ -1097,16 +1310,44 @@ export const storage = {
1097
1310
  measureOperation("storage:clearNamespace", scope, () => {
1098
1311
  assertValidScope(scope);
1099
1312
  if (scope === StorageScope.Memory) {
1100
- for (const key of memoryStore.keys()) {
1101
- if (isNamespaced(key, namespace)) {
1102
- memoryStore.delete(key);
1103
- }
1313
+ const affectedKeys = Array.from(memoryStore.keys()).filter((key) =>
1314
+ isNamespaced(key, namespace),
1315
+ );
1316
+ const previousValues = affectedKeys.map((key) => ({
1317
+ key,
1318
+ value: getEventRawValue(scope, key),
1319
+ }));
1320
+
1321
+ if (affectedKeys.length === 0) {
1322
+ return;
1104
1323
  }
1105
- notifyAllListeners(memoryListeners);
1324
+
1325
+ affectedKeys.forEach((key) => {
1326
+ memoryStore.delete(key);
1327
+ });
1328
+ affectedKeys.forEach((key) => notifyKeyListeners(memoryListeners, key));
1329
+ emitBatchChange(
1330
+ scope,
1331
+ "clearNamespace",
1332
+ "memory",
1333
+ previousValues.map(({ key, value }) =>
1334
+ createKeyChange(
1335
+ scope,
1336
+ key,
1337
+ value,
1338
+ undefined,
1339
+ "clearNamespace",
1340
+ "memory",
1341
+ ),
1342
+ ),
1343
+ );
1106
1344
  return;
1107
1345
  }
1108
1346
 
1109
1347
  const keyPrefix = prefixKey(namespace, "");
1348
+ const previousValues = hasStorageChangeObservers(scope)
1349
+ ? storage.getByPrefix(keyPrefix, scope)
1350
+ : {};
1110
1351
  if (scope === StorageScope.Disk) {
1111
1352
  flushDiskWrites();
1112
1353
  }
@@ -1120,6 +1361,21 @@ export const storage = {
1120
1361
  }
1121
1362
  }
1122
1363
  WebStorage.removeByPrefix(keyPrefix, scope);
1364
+ emitBatchChange(
1365
+ scope,
1366
+ "clearNamespace",
1367
+ "web",
1368
+ Object.keys(previousValues).map((key) =>
1369
+ createKeyChange(
1370
+ scope,
1371
+ key,
1372
+ previousValues[key],
1373
+ undefined,
1374
+ "clearNamespace",
1375
+ "web",
1376
+ ),
1377
+ ),
1378
+ );
1123
1379
  });
1124
1380
  },
1125
1381
  clearBiometric: () => {
@@ -1235,6 +1491,11 @@ export const storage = {
1235
1491
  return result;
1236
1492
  });
1237
1493
  },
1494
+ export: (scope: StorageScope): Record<string, string> => {
1495
+ return measureOperation("storage:export", scope, () =>
1496
+ storage.getAll(scope),
1497
+ );
1498
+ },
1238
1499
  size: (scope: StorageScope): number => {
1239
1500
  return measureOperation("storage:size", scope, () => {
1240
1501
  assertValidScope(scope);
@@ -1307,6 +1568,72 @@ export const storage = {
1307
1568
  },
1308
1569
  errorClassification: true,
1309
1570
  }),
1571
+ getSecurityCapabilities: (): SecurityCapabilities => {
1572
+ const secureBackend = getBackendName(
1573
+ StorageScope.Secure,
1574
+ webSecureStorageBackend,
1575
+ );
1576
+ return {
1577
+ platform: "web",
1578
+ secureStorage: {
1579
+ backend: secureBackend,
1580
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1581
+ accessControl: "unavailable",
1582
+ keychainAccessGroup: "unavailable",
1583
+ hardwareBacked: "unavailable",
1584
+ },
1585
+ biometric: {
1586
+ storage: "unavailable",
1587
+ prompt: "unavailable",
1588
+ biometryOnly: "unavailable",
1589
+ biometryOrPasscode: "unavailable",
1590
+ },
1591
+ metadata: {
1592
+ perKey: true,
1593
+ listsWithoutValues: true,
1594
+ persistsTimestamps: false,
1595
+ },
1596
+ };
1597
+ },
1598
+ getSecureMetadata: (key: string): SecureStorageMetadata => {
1599
+ return measureOperation(
1600
+ "storage:getSecureMetadata",
1601
+ StorageScope.Secure,
1602
+ () => {
1603
+ flushSecureWrites();
1604
+ const biometricProtected = WebStorage.hasSecureBiometric(key);
1605
+ const exists =
1606
+ biometricProtected || WebStorage.has(key, StorageScope.Secure);
1607
+ let kind: SecureStorageMetadata["kind"] = "missing";
1608
+ if (exists) {
1609
+ kind = biometricProtected ? "biometric" : "secure";
1610
+ }
1611
+
1612
+ return {
1613
+ key,
1614
+ exists,
1615
+ kind,
1616
+ backend: getBackendName(StorageScope.Secure, webSecureStorageBackend),
1617
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1618
+ hardwareBacked: "unavailable",
1619
+ biometricProtected,
1620
+ valueExposed: false,
1621
+ };
1622
+ },
1623
+ );
1624
+ },
1625
+ getAllSecureMetadata: (): SecureStorageMetadata[] => {
1626
+ return measureOperation(
1627
+ "storage:getAllSecureMetadata",
1628
+ StorageScope.Secure,
1629
+ () => {
1630
+ flushSecureWrites();
1631
+ return WebStorage.getAllKeys(StorageScope.Secure).map((key) =>
1632
+ storage.getSecureMetadata(key),
1633
+ );
1634
+ },
1635
+ );
1636
+ },
1310
1637
  getString: (key: string, scope: StorageScope): string | undefined => {
1311
1638
  return measureOperation("storage:getString", scope, () => {
1312
1639
  return getRawValue(key, scope);
@@ -1331,12 +1658,23 @@ export const storage = {
1331
1658
  assertValidScope(scope);
1332
1659
  if (keys.length === 0) return;
1333
1660
  const values = keys.map((k) => data[k]!);
1661
+ const changes = keys.map((key, index) =>
1662
+ createKeyChange(
1663
+ scope,
1664
+ key,
1665
+ getEventRawValue(scope, key),
1666
+ values[index],
1667
+ "import",
1668
+ scope === StorageScope.Memory ? "memory" : "web",
1669
+ ),
1670
+ );
1334
1671
 
1335
1672
  if (scope === StorageScope.Memory) {
1336
1673
  keys.forEach((key, index) => {
1337
1674
  memoryStore.set(key, values[index]);
1338
1675
  });
1339
1676
  keys.forEach((key) => notifyKeyListeners(memoryListeners, key));
1677
+ emitBatchChange(scope, "import", "memory", changes);
1340
1678
  return;
1341
1679
  }
1342
1680
 
@@ -1350,6 +1688,7 @@ export const storage = {
1350
1688
 
1351
1689
  WebStorage.setBatch(keys, values, scope);
1352
1690
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1691
+ emitBatchChange(scope, "import", "web", changes);
1353
1692
  },
1354
1693
  keys.length,
1355
1694
  );
@@ -1436,6 +1775,11 @@ export interface StorageItem<T> {
1436
1775
  delete: () => void;
1437
1776
  has: () => boolean;
1438
1777
  subscribe: (callback: () => void) => () => void;
1778
+ subscribeSelector: <TSelected>(
1779
+ selector: (value: T) => TSelected,
1780
+ listener: StorageSelectorListener<TSelected>,
1781
+ options?: StorageSelectorSubscribeOptions<TSelected>,
1782
+ ) => () => void;
1439
1783
  serialize: (value: T) => string;
1440
1784
  deserialize: (value: string) => T;
1441
1785
  scope: StorageScope;
@@ -1604,12 +1948,17 @@ export function createStorageItem<T = undefined>(
1604
1948
  };
1605
1949
 
1606
1950
  const writeStoredRaw = (rawValue: string): void => {
1951
+ const oldValue =
1952
+ config.scope === StorageScope.Memory
1953
+ ? getEventRawValue(config.scope, storageKey)
1954
+ : undefined;
1607
1955
  if (isBiometric) {
1608
1956
  WebStorage.setSecureBiometricWithLevel(
1609
1957
  storageKey,
1610
1958
  rawValue,
1611
1959
  resolvedBiometricLevel,
1612
1960
  );
1961
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1613
1962
  return;
1614
1963
  }
1615
1964
 
@@ -1618,6 +1967,14 @@ export function createStorageItem<T = undefined>(
1618
1967
  if (nonMemoryScope === StorageScope.Disk) {
1619
1968
  if (coalesceDiskWrites || diskWritesAsync) {
1620
1969
  scheduleDiskWrite(storageKey, rawValue);
1970
+ emitKeyChange(
1971
+ config.scope,
1972
+ storageKey,
1973
+ oldValue,
1974
+ rawValue,
1975
+ "set",
1976
+ "web",
1977
+ );
1621
1978
  return;
1622
1979
  }
1623
1980
 
@@ -1630,6 +1987,7 @@ export function createStorageItem<T = undefined>(
1630
1987
  rawValue,
1631
1988
  secureAccessControl ?? secureDefaultAccessControl,
1632
1989
  );
1990
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1633
1991
  return;
1634
1992
  }
1635
1993
 
@@ -1638,11 +1996,21 @@ export function createStorageItem<T = undefined>(
1638
1996
  }
1639
1997
 
1640
1998
  WebStorage.set(storageKey, rawValue, config.scope);
1999
+ emitKeyChange(config.scope, storageKey, oldValue, rawValue, "set", "web");
1641
2000
  };
1642
2001
 
1643
2002
  const removeStoredRaw = (): void => {
2003
+ const oldValue = getEventRawValue(config.scope, storageKey);
1644
2004
  if (isBiometric) {
1645
2005
  WebStorage.deleteSecureBiometric(storageKey);
2006
+ emitKeyChange(
2007
+ config.scope,
2008
+ storageKey,
2009
+ oldValue,
2010
+ undefined,
2011
+ "remove",
2012
+ "web",
2013
+ );
1646
2014
  return;
1647
2015
  }
1648
2016
 
@@ -1651,6 +2019,14 @@ export function createStorageItem<T = undefined>(
1651
2019
  if (nonMemoryScope === StorageScope.Disk) {
1652
2020
  if (coalesceDiskWrites || diskWritesAsync) {
1653
2021
  scheduleDiskWrite(storageKey, undefined);
2022
+ emitKeyChange(
2023
+ config.scope,
2024
+ storageKey,
2025
+ oldValue,
2026
+ undefined,
2027
+ "remove",
2028
+ "web",
2029
+ );
1654
2030
  return;
1655
2031
  }
1656
2032
 
@@ -1663,6 +2039,14 @@ export function createStorageItem<T = undefined>(
1663
2039
  undefined,
1664
2040
  secureAccessControl ?? secureDefaultAccessControl,
1665
2041
  );
2042
+ emitKeyChange(
2043
+ config.scope,
2044
+ storageKey,
2045
+ oldValue,
2046
+ undefined,
2047
+ "remove",
2048
+ "web",
2049
+ );
1666
2050
  return;
1667
2051
  }
1668
2052
 
@@ -1671,15 +2055,32 @@ export function createStorageItem<T = undefined>(
1671
2055
  }
1672
2056
 
1673
2057
  WebStorage.remove(storageKey, config.scope);
2058
+ emitKeyChange(
2059
+ config.scope,
2060
+ storageKey,
2061
+ oldValue,
2062
+ undefined,
2063
+ "remove",
2064
+ "web",
2065
+ );
1674
2066
  };
1675
2067
 
1676
2068
  const writeValueWithoutValidation = (value: T): void => {
1677
2069
  if (isMemory) {
2070
+ const oldValue = getEventRawValue(config.scope, storageKey);
1678
2071
  if (memoryExpiration) {
1679
2072
  memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
1680
2073
  }
1681
2074
  memoryStore.set(storageKey, value);
1682
2075
  notifyKeyListeners(memoryListeners, storageKey);
2076
+ emitKeyChange(
2077
+ config.scope,
2078
+ storageKey,
2079
+ oldValue,
2080
+ typeof value === "string" ? value : undefined,
2081
+ "set",
2082
+ "memory",
2083
+ );
1683
2084
  return;
1684
2085
  }
1685
2086
 
@@ -1851,11 +2252,20 @@ export function createStorageItem<T = undefined>(
1851
2252
  invalidateParsedCache();
1852
2253
 
1853
2254
  if (isMemory) {
2255
+ const oldValue = getEventRawValue(config.scope, storageKey);
1854
2256
  if (memoryExpiration) {
1855
2257
  memoryExpiration.delete(storageKey);
1856
2258
  }
1857
2259
  memoryStore.delete(storageKey);
1858
2260
  notifyKeyListeners(memoryListeners, storageKey);
2261
+ emitKeyChange(
2262
+ config.scope,
2263
+ storageKey,
2264
+ oldValue,
2265
+ undefined,
2266
+ "remove",
2267
+ "memory",
2268
+ );
1859
2269
  return;
1860
2270
  }
1861
2271
 
@@ -1894,6 +2304,30 @@ export function createStorageItem<T = undefined>(
1894
2304
  };
1895
2305
  };
1896
2306
 
2307
+ const subscribeSelector = <TSelected>(
2308
+ selector: (value: T) => TSelected,
2309
+ listener: StorageSelectorListener<TSelected>,
2310
+ options: StorageSelectorSubscribeOptions<TSelected> = {},
2311
+ ): (() => void) => {
2312
+ const isEqual = options.isEqual ?? Object.is;
2313
+ let currentValue = selector(getInternal());
2314
+
2315
+ if (options.fireImmediately === true) {
2316
+ listener(currentValue, currentValue);
2317
+ }
2318
+
2319
+ return subscribe(() => {
2320
+ const nextValue = selector(getInternal());
2321
+ if (isEqual(currentValue, nextValue)) {
2322
+ return;
2323
+ }
2324
+
2325
+ const previousValue = currentValue;
2326
+ currentValue = nextValue;
2327
+ listener(nextValue, previousValue);
2328
+ });
2329
+ };
2330
+
1897
2331
  const storageItem: StorageItemInternal<T> = {
1898
2332
  get,
1899
2333
  getWithVersion,
@@ -1902,6 +2336,7 @@ export function createStorageItem<T = undefined>(
1902
2336
  delete: deleteItem,
1903
2337
  has: hasItem,
1904
2338
  subscribe,
2339
+ subscribeSelector,
1905
2340
  serialize,
1906
2341
  deserialize,
1907
2342
  _triggerListeners: () => {
@@ -2054,6 +2489,17 @@ export function setBatch<T>(
2054
2489
  return;
2055
2490
  }
2056
2491
 
2492
+ const changes = items.map(({ item, value }) =>
2493
+ createKeyChange(
2494
+ scope,
2495
+ item.key,
2496
+ getEventRawValue(scope, item.key),
2497
+ typeof value === "string" ? value : undefined,
2498
+ "setBatch",
2499
+ "memory",
2500
+ ),
2501
+ );
2502
+
2057
2503
  // Atomic write: update all values in memoryStore, invalidate caches, then batch-notify
2058
2504
  items.forEach(({ item, value }) => {
2059
2505
  memoryStore.set(item.key, value);
@@ -2062,6 +2508,7 @@ export function setBatch<T>(
2062
2508
  items.forEach(({ item }) =>
2063
2509
  notifyKeyListeners(memoryListeners, item.key),
2064
2510
  );
2511
+ emitBatchChange(scope, "setBatch", "memory", changes);
2065
2512
  return;
2066
2513
  }
2067
2514
 
@@ -2080,6 +2527,10 @@ export function setBatch<T>(
2080
2527
  }
2081
2528
 
2082
2529
  flushSecureWrites();
2530
+ const keys = secureEntries.map(({ item }) => item.key);
2531
+ const oldValues = hasStorageChangeObservers(scope)
2532
+ ? WebStorage.getBatch(keys, scope)
2533
+ : [];
2083
2534
  const groupedByAccessControl = new Map<
2084
2535
  number,
2085
2536
  { keys: string[]; values: string[] }
@@ -2104,6 +2555,21 @@ export function setBatch<T>(
2104
2555
  cacheRawValue(scope, key, group.values[index]),
2105
2556
  );
2106
2557
  });
2558
+ emitBatchChange(
2559
+ scope,
2560
+ "setBatch",
2561
+ "web",
2562
+ secureEntries.map(({ item, value }, index) =>
2563
+ createKeyChange(
2564
+ scope,
2565
+ item.key,
2566
+ oldValues[index],
2567
+ item.serialize(value),
2568
+ "setBatch",
2569
+ "web",
2570
+ ),
2571
+ ),
2572
+ );
2107
2573
  return;
2108
2574
  }
2109
2575
 
@@ -2119,8 +2585,26 @@ export function setBatch<T>(
2119
2585
 
2120
2586
  const keys = items.map((entry) => entry.item.key);
2121
2587
  const values = items.map((entry) => entry.item.serialize(entry.value));
2588
+ const oldValues = hasStorageChangeObservers(scope)
2589
+ ? WebStorage.getBatch(keys, scope)
2590
+ : [];
2122
2591
  WebStorage.setBatch(keys, values, scope);
2123
2592
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
2593
+ emitBatchChange(
2594
+ scope,
2595
+ "setBatch",
2596
+ "web",
2597
+ keys.map((key, index) =>
2598
+ createKeyChange(
2599
+ scope,
2600
+ key,
2601
+ oldValues[index],
2602
+ values[index],
2603
+ "setBatch",
2604
+ "web",
2605
+ ),
2606
+ ),
2607
+ );
2124
2608
  },
2125
2609
  items.length,
2126
2610
  );
@@ -2137,7 +2621,18 @@ export function removeBatch(
2137
2621
  assertBatchScope(items, scope);
2138
2622
 
2139
2623
  if (scope === StorageScope.Memory) {
2624
+ const changes = items.map((item) =>
2625
+ createKeyChange(
2626
+ scope,
2627
+ item.key,
2628
+ getEventRawValue(scope, item.key),
2629
+ undefined,
2630
+ "removeBatch",
2631
+ "memory",
2632
+ ),
2633
+ );
2140
2634
  items.forEach((item) => item.delete());
2635
+ emitBatchChange(scope, "removeBatch", "memory", changes);
2141
2636
  return;
2142
2637
  }
2143
2638
 
@@ -2148,8 +2643,26 @@ export function removeBatch(
2148
2643
  if (scope === StorageScope.Secure) {
2149
2644
  flushSecureWrites();
2150
2645
  }
2646
+ const oldValues = hasStorageChangeObservers(scope)
2647
+ ? WebStorage.getBatch(keys, scope)
2648
+ : [];
2151
2649
  WebStorage.removeBatch(keys, scope);
2152
2650
  keys.forEach((key) => cacheRawValue(scope, key, undefined));
2651
+ emitBatchChange(
2652
+ scope,
2653
+ "removeBatch",
2654
+ "web",
2655
+ keys.map((key, index) =>
2656
+ createKeyChange(
2657
+ scope,
2658
+ key,
2659
+ oldValues[index],
2660
+ undefined,
2661
+ "removeBatch",
2662
+ "web",
2663
+ ),
2664
+ ),
2665
+ );
2153
2666
  },
2154
2667
  items.length,
2155
2668
  );