react-native-nitro-storage 0.5.3 → 0.5.5

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 (36) hide show
  1. package/.watchmanconfig +6 -0
  2. package/README.md +45 -5
  3. package/android/build.gradle +5 -5
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +12 -25
  5. package/app.plugin.js +114 -9
  6. package/docs/api-reference.md +39 -36
  7. package/docs/batch-transactions-migrations.md +1 -1
  8. package/docs/recipes.md +1 -1
  9. package/docs/secure-storage.md +15 -4
  10. package/docs/web-backends.md +5 -0
  11. package/lib/commonjs/index.js +129 -27
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +169 -32
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/indexeddb-backend.js +28 -0
  16. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  17. package/lib/commonjs/web-storage-backend.js.map +1 -1
  18. package/lib/module/index.js +129 -27
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/index.web.js +169 -32
  21. package/lib/module/index.web.js.map +1 -1
  22. package/lib/module/indexeddb-backend.js +28 -0
  23. package/lib/module/indexeddb-backend.js.map +1 -1
  24. package/lib/module/web-storage-backend.js.map +1 -1
  25. package/lib/typescript/index.d.ts +10 -3
  26. package/lib/typescript/index.d.ts.map +1 -1
  27. package/lib/typescript/index.web.d.ts +10 -3
  28. package/lib/typescript/index.web.d.ts.map +1 -1
  29. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  30. package/lib/typescript/web-storage-backend.d.ts +1 -0
  31. package/lib/typescript/web-storage-backend.d.ts.map +1 -1
  32. package/package.json +5 -3
  33. package/src/index.ts +197 -32
  34. package/src/index.web.ts +250 -37
  35. package/src/indexeddb-backend.ts +30 -0
  36. package/src/web-storage-backend.ts +1 -0
package/src/index.web.ts CHANGED
@@ -54,6 +54,8 @@ export type {
54
54
  StorageKeyChangeEvent,
55
55
  } from "./storage-events";
56
56
  export type {
57
+ WebDiskStorageBackend,
58
+ WebSecureStorageBackend,
57
59
  WebStorageBackend,
58
60
  WebStorageChangeEvent,
59
61
  WebStorageScope,
@@ -75,6 +77,12 @@ export type StorageMetricsEvent = {
75
77
  keysCount: number;
76
78
  };
77
79
  export type StorageMetricsObserver = (event: StorageMetricsEvent) => void;
80
+ export type StorageEventObserverOptions = {
81
+ redactSecureValues?: boolean;
82
+ };
83
+ export type StorageExportOptions = {
84
+ includeSecureValues?: boolean;
85
+ };
78
86
  export type StorageMetricSummary = {
79
87
  count: number;
80
88
  totalDurationMs: number;
@@ -118,8 +126,24 @@ type RawBatchPathItem = {
118
126
  _hasValidation?: boolean;
119
127
  _hasExpiration?: boolean;
120
128
  _isBiometric?: boolean;
129
+ _biometricLevel?: BiometricLevel;
121
130
  _secureAccessControl?: AccessControl;
122
131
  };
132
+ type RollbackRecord =
133
+ | {
134
+ kind: "memory";
135
+ value: unknown;
136
+ }
137
+ | {
138
+ kind: "raw";
139
+ value: string | undefined;
140
+ accessControl?: AccessControl;
141
+ }
142
+ | {
143
+ kind: "biometric";
144
+ value: string | undefined;
145
+ level: BiometricLevel;
146
+ };
123
147
 
124
148
  function asInternal<T>(item: StorageItem<T>): StorageItemInternal<T> {
125
149
  return item as StorageItemInternal<T>;
@@ -134,6 +158,27 @@ function isUpdater<T>(
134
158
  function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
135
159
  return Object.keys(record) as K[];
136
160
  }
161
+ function assertEnumInteger(
162
+ value: number,
163
+ min: number,
164
+ max: number,
165
+ label: string,
166
+ ): void {
167
+ if (!Number.isFinite(value) || value < min || value > max) {
168
+ throw new Error(`NitroStorage: Invalid ${label}`);
169
+ }
170
+ if (value !== Math.trunc(value)) {
171
+ throw new Error(`NitroStorage: Invalid ${label}`);
172
+ }
173
+ }
174
+
175
+ function assertAccessControlLevel(level: number): void {
176
+ assertEnumInteger(level, 0, 4, "access control level");
177
+ }
178
+
179
+ function assertBiometricLevel(level: number): void {
180
+ assertEnumInteger(level, 0, 2, "biometric level");
181
+ }
137
182
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
138
183
  type PendingDiskWrite = {
139
184
  key: string;
@@ -217,6 +262,7 @@ let hasWarnedAboutWebBiometricFallback = false;
217
262
  let hasWindowStorageEventSubscription = false;
218
263
  let metricsObserver: StorageMetricsObserver | undefined;
219
264
  let eventObserver: StorageEventListener | undefined;
265
+ let eventObserverRedactSecureValues = true;
220
266
  const metricsCounters = new Map<
221
267
  string,
222
268
  { count: number; totalDurationMs: number; maxDurationMs: number }
@@ -515,6 +561,21 @@ function resetBackendChangeSubscription(scope: NonMemoryScope): void {
515
561
  externalSyncUnsubscribers.delete(scope);
516
562
  }
517
563
 
564
+ function closeWebBackend(
565
+ scope: NonMemoryScope,
566
+ backend: WebStorageBackend | undefined,
567
+ ): void {
568
+ if (!backend?.close) {
569
+ return;
570
+ }
571
+
572
+ try {
573
+ backend.close();
574
+ } catch (error) {
575
+ throw createWebStorageError(scope, "close", error, backend);
576
+ }
577
+ }
578
+
518
579
  function ensureExternalSyncSubscriptions(): void {
519
580
  if (
520
581
  !hasWindowStorageEventSubscription &&
@@ -638,6 +699,49 @@ function hasStorageChangeObservers(scope: StorageScope): boolean {
638
699
  return storageEvents.hasListeners(scope) || eventObserver !== undefined;
639
700
  }
640
701
 
702
+ function shouldReadPreviousEventValues(scope: StorageScope): boolean {
703
+ if (storageEvents.hasListeners(scope)) {
704
+ return true;
705
+ }
706
+ if (!eventObserver) {
707
+ return false;
708
+ }
709
+ return scope !== StorageScope.Secure || !eventObserverRedactSecureValues;
710
+ }
711
+
712
+ const SECURE_EVENT_REDACTED_VALUE = "[secure]";
713
+
714
+ function redactSecureKeyChange(
715
+ event: StorageKeyChangeEvent,
716
+ ): StorageKeyChangeEvent {
717
+ if (event.scope !== StorageScope.Secure) {
718
+ return event;
719
+ }
720
+
721
+ return {
722
+ ...event,
723
+ oldValue:
724
+ event.oldValue === undefined ? undefined : SECURE_EVENT_REDACTED_VALUE,
725
+ newValue:
726
+ event.newValue === undefined ? undefined : SECURE_EVENT_REDACTED_VALUE,
727
+ };
728
+ }
729
+
730
+ function eventForGlobalObserver(event: StorageChangeEvent): StorageChangeEvent {
731
+ if (!eventObserverRedactSecureValues || event.scope !== StorageScope.Secure) {
732
+ return event;
733
+ }
734
+
735
+ if (event.type === "key") {
736
+ return redactSecureKeyChange(event);
737
+ }
738
+
739
+ return {
740
+ ...event,
741
+ changes: event.changes.map(redactSecureKeyChange),
742
+ };
743
+ }
744
+
641
745
  function emitKeyChange(
642
746
  scope: StorageScope,
643
747
  key: string,
@@ -655,7 +759,7 @@ function emitKeyChange(
655
759
  source,
656
760
  );
657
761
  storageEvents.emitKey(event);
658
- eventObserver?.(event);
762
+ eventObserver?.(eventForGlobalObserver(event));
659
763
  }
660
764
 
661
765
  function emitBatchChange(
@@ -676,7 +780,7 @@ function emitBatchChange(
676
780
  changes,
677
781
  };
678
782
  storageEvents.emitBatch(event);
679
- eventObserver?.(event);
783
+ eventObserver?.(eventForGlobalObserver(event));
680
784
  }
681
785
 
682
786
  function readPendingSecureWrite(key: string): string | undefined {
@@ -866,6 +970,11 @@ const WebStorage: Storage = {
866
970
  if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
867
971
  return;
868
972
  }
973
+ if (keys.length !== values.length) {
974
+ throw new Error(
975
+ "NitroStorage: Keys and values size mismatch in setBatch",
976
+ );
977
+ }
869
978
 
870
979
  const entries: (readonly [string, string])[] = [];
871
980
  keys.forEach((key, index) => {
@@ -888,7 +997,13 @@ const WebStorage: Storage = {
888
997
  });
889
998
  });
890
999
  const keyIndex = ensureWebScopeKeyIndex(scope);
891
- keys.forEach((key) => keyIndex.add(key));
1000
+ entries.forEach(([storageKey]) =>
1001
+ keyIndex.add(
1002
+ scope === StorageScope.Secure
1003
+ ? storageKey.slice(SECURE_WEB_PREFIX.length)
1004
+ : storageKey,
1005
+ ),
1006
+ );
892
1007
  const listeners = getScopedListeners(scope);
893
1008
  keys.forEach((key) => notifyKeyListeners(listeners, key));
894
1009
  },
@@ -988,7 +1103,9 @@ const WebStorage: Storage = {
988
1103
  }
989
1104
  return 0;
990
1105
  },
991
- setSecureAccessControl: () => {},
1106
+ setSecureAccessControl: (level: number) => {
1107
+ assertAccessControlLevel(level);
1108
+ },
992
1109
  setSecureWritesAsync: (_enabled: boolean) => {},
993
1110
  setKeychainAccessGroup: () => {},
994
1111
  setSecureBiometric: (key: string, value: string) => {
@@ -998,7 +1115,17 @@ const WebStorage: Storage = {
998
1115
  BiometricLevel.BiometryOnly,
999
1116
  );
1000
1117
  },
1001
- setSecureBiometricWithLevel: (key: string, value: string, _level: number) => {
1118
+ setSecureBiometricWithLevel: (key: string, value: string, level: number) => {
1119
+ assertBiometricLevel(level);
1120
+ if (level === BiometricLevel.None) {
1121
+ withWebBackendOperation(StorageScope.Secure, "setSecure", (backend) => {
1122
+ backend.removeItem(toBiometricStorageKey(key));
1123
+ backend.setItem(toSecureStorageKey(key), value);
1124
+ });
1125
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
1126
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
1127
+ return;
1128
+ }
1002
1129
  if (
1003
1130
  typeof __DEV__ !== "undefined" &&
1004
1131
  __DEV__ &&
@@ -1233,15 +1360,19 @@ export const storage = {
1233
1360
  ): (() => void) => {
1234
1361
  return storage.subscribePrefix(scope, prefixKey(namespace, ""), listener);
1235
1362
  },
1236
- setEventObserver: (observer?: StorageEventListener) => {
1363
+ setEventObserver: (
1364
+ observer?: StorageEventListener,
1365
+ options: StorageEventObserverOptions = {},
1366
+ ) => {
1237
1367
  eventObserver = observer;
1368
+ eventObserverRedactSecureValues = options.redactSecureValues !== false;
1238
1369
  if (observer) {
1239
1370
  ensureExternalSyncSubscriptions();
1240
1371
  }
1241
1372
  },
1242
1373
  clear: (scope: StorageScope) => {
1243
1374
  measureOperation("storage:clear", scope, () => {
1244
- const previousValues = hasStorageChangeObservers(scope)
1375
+ const previousValues = shouldReadPreviousEventValues(scope)
1245
1376
  ? storage.getAll(scope)
1246
1377
  : {};
1247
1378
  if (scope === StorageScope.Memory) {
@@ -1345,7 +1476,7 @@ export const storage = {
1345
1476
  }
1346
1477
 
1347
1478
  const keyPrefix = prefixKey(namespace, "");
1348
- const previousValues = hasStorageChangeObservers(scope)
1479
+ const previousValues = shouldReadPreviousEventValues(scope)
1349
1480
  ? storage.getByPrefix(keyPrefix, scope)
1350
1481
  : {};
1351
1482
  if (scope === StorageScope.Disk) {
@@ -1491,11 +1622,26 @@ export const storage = {
1491
1622
  return result;
1492
1623
  });
1493
1624
  },
1494
- export: (scope: StorageScope): Record<string, string> => {
1625
+ export: (
1626
+ scope: StorageScope,
1627
+ options: StorageExportOptions = {},
1628
+ ): Record<string, string> => {
1629
+ if (scope === StorageScope.Secure && options.includeSecureValues !== true) {
1630
+ throw new Error(
1631
+ "NitroStorage: exporting Secure scope exposes raw secret values. Pass { includeSecureValues: true } or use exportSecureUnsafe().",
1632
+ );
1633
+ }
1495
1634
  return measureOperation("storage:export", scope, () =>
1496
1635
  storage.getAll(scope),
1497
1636
  );
1498
1637
  },
1638
+ exportSecureUnsafe: (): Record<string, string> => {
1639
+ return measureOperation(
1640
+ "storage:exportSecureUnsafe",
1641
+ StorageScope.Secure,
1642
+ () => storage.getAll(StorageScope.Secure),
1643
+ );
1644
+ },
1499
1645
  size: (scope: StorageScope): number => {
1500
1646
  return measureOperation("storage:size", scope, () => {
1501
1647
  assertValidScope(scope);
@@ -1510,6 +1656,7 @@ export const storage = {
1510
1656
  });
1511
1657
  },
1512
1658
  setAccessControl: (level: AccessControl) => {
1659
+ assertAccessControlLevel(level);
1513
1660
  secureDefaultAccessControl = level;
1514
1661
  recordMetric("storage:setAccessControl", StorageScope.Secure, 0);
1515
1662
  },
@@ -1698,12 +1845,17 @@ export const storage = {
1698
1845
  export function setWebSecureStorageBackend(
1699
1846
  backend?: WebSecureStorageBackend,
1700
1847
  ): void {
1848
+ const previousBackend = webSecureStorageBackend;
1849
+ const nextBackend = backend ?? createDefaultSecureBackend();
1701
1850
  pendingSecureWrites.clear();
1702
- webSecureStorageBackend = backend ?? createDefaultSecureBackend();
1703
1851
  resetBackendChangeSubscription(StorageScope.Secure);
1852
+ webSecureStorageBackend = nextBackend;
1704
1853
  hydratedWebScopeKeyIndex.delete(StorageScope.Secure);
1705
1854
  clearScopeRawCache(StorageScope.Secure);
1706
1855
  ensureExternalSyncSubscriptions();
1856
+ if (previousBackend !== nextBackend) {
1857
+ closeWebBackend(StorageScope.Secure, previousBackend);
1858
+ }
1707
1859
  }
1708
1860
 
1709
1861
  export function getWebSecureStorageBackend():
@@ -1715,12 +1867,17 @@ export function getWebSecureStorageBackend():
1715
1867
  export function setWebDiskStorageBackend(
1716
1868
  backend?: WebDiskStorageBackend,
1717
1869
  ): void {
1870
+ const previousBackend = webDiskStorageBackend;
1871
+ const nextBackend = backend ?? createDefaultDiskBackend();
1718
1872
  pendingDiskWrites.clear();
1719
- webDiskStorageBackend = backend ?? createDefaultDiskBackend();
1720
1873
  resetBackendChangeSubscription(StorageScope.Disk);
1874
+ webDiskStorageBackend = nextBackend;
1721
1875
  hydratedWebScopeKeyIndex.delete(StorageScope.Disk);
1722
1876
  clearScopeRawCache(StorageScope.Disk);
1723
1877
  ensureExternalSyncSubscriptions();
1878
+ if (previousBackend !== nextBackend) {
1879
+ closeWebBackend(StorageScope.Disk, previousBackend);
1880
+ }
1724
1881
  }
1725
1882
 
1726
1883
  export function getWebDiskStorageBackend(): WebDiskStorageBackend | undefined {
@@ -1793,6 +1950,7 @@ type StorageItemInternal<T> = StorageItem<T> & {
1793
1950
  _hasExpiration: boolean;
1794
1951
  _readCacheEnabled: boolean;
1795
1952
  _isBiometric: boolean;
1953
+ _biometricLevel: BiometricLevel;
1796
1954
  _defaultValue: T;
1797
1955
  _secureAccessControl?: AccessControl;
1798
1956
  };
@@ -1863,6 +2021,12 @@ export function createStorageItem<T = undefined>(
1863
2021
  if (expiration && expiration.ttlMs <= 0) {
1864
2022
  throw new Error("expiration.ttlMs must be greater than 0.");
1865
2023
  }
2024
+ if (config.scope === StorageScope.Secure) {
2025
+ assertBiometricLevel(resolvedBiometricLevel);
2026
+ if (secureAccessControl !== undefined) {
2027
+ assertAccessControlLevel(secureAccessControl);
2028
+ }
2029
+ }
1866
2030
 
1867
2031
  const listeners = new Set<() => void>();
1868
2032
  let unsubscribe: (() => void) | null = null;
@@ -2350,6 +2514,7 @@ export function createStorageItem<T = undefined>(
2350
2514
  _hasExpiration: expiration !== undefined,
2351
2515
  _readCacheEnabled: readCache,
2352
2516
  _isBiometric: isBiometric,
2517
+ _biometricLevel: resolvedBiometricLevel,
2353
2518
  _defaultValue: defaultValue,
2354
2519
  ...(secureAccessControl !== undefined
2355
2520
  ? { _secureAccessControl: secureAccessControl }
@@ -2528,7 +2693,7 @@ export function setBatch<T>(
2528
2693
 
2529
2694
  flushSecureWrites();
2530
2695
  const keys = secureEntries.map(({ item }) => item.key);
2531
- const oldValues = hasStorageChangeObservers(scope)
2696
+ const oldValues = shouldReadPreviousEventValues(scope)
2532
2697
  ? WebStorage.getBatch(keys, scope)
2533
2698
  : [];
2534
2699
  const groupedByAccessControl = new Map<
@@ -2585,7 +2750,7 @@ export function setBatch<T>(
2585
2750
 
2586
2751
  const keys = items.map((entry) => entry.item.key);
2587
2752
  const values = items.map((entry) => entry.item.serialize(entry.value));
2588
- const oldValues = hasStorageChangeObservers(scope)
2753
+ const oldValues = shouldReadPreviousEventValues(scope)
2589
2754
  ? WebStorage.getBatch(keys, scope)
2590
2755
  : [];
2591
2756
  WebStorage.setBatch(keys, values, scope);
@@ -2643,7 +2808,7 @@ export function removeBatch(
2643
2808
  if (scope === StorageScope.Secure) {
2644
2809
  flushSecureWrites();
2645
2810
  }
2646
- const oldValues = hasStorageChangeObservers(scope)
2811
+ const oldValues = shouldReadPreviousEventValues(scope)
2647
2812
  ? WebStorage.getBatch(keys, scope)
2648
2813
  : [];
2649
2814
  WebStorage.removeBatch(keys, scope);
@@ -2729,19 +2894,40 @@ export function runTransaction<T>(
2729
2894
  }
2730
2895
 
2731
2896
  const NOT_SET = Symbol();
2732
- const rollback = new Map<string, unknown>();
2897
+ const rollback = new Map<string, RollbackRecord>();
2733
2898
 
2734
- const rememberRollback = (key: string) => {
2899
+ const rememberRollback = (
2900
+ key: string,
2901
+ item?: Pick<StorageItem<unknown>, "key" | "scope">,
2902
+ ) => {
2735
2903
  if (rollback.has(key)) {
2736
2904
  return;
2737
2905
  }
2738
2906
  if (scope === StorageScope.Memory) {
2739
- rollback.set(
2740
- key,
2741
- memoryStore.has(key) ? memoryStore.get(key) : NOT_SET,
2742
- );
2907
+ rollback.set(key, {
2908
+ kind: "memory",
2909
+ value: memoryStore.has(key) ? memoryStore.get(key) : NOT_SET,
2910
+ });
2743
2911
  } else {
2744
- rollback.set(key, getRawValue(key, scope));
2912
+ const internal = item
2913
+ ? (item as StorageItemInternal<unknown>)
2914
+ : undefined;
2915
+ if (scope === StorageScope.Secure && internal?._isBiometric === true) {
2916
+ rollback.set(key, {
2917
+ kind: "biometric",
2918
+ value: WebStorage.getSecureBiometric(key),
2919
+ level: internal._biometricLevel,
2920
+ });
2921
+ return;
2922
+ }
2923
+ rollback.set(key, {
2924
+ kind: "raw",
2925
+ value: getRawValue(key, scope),
2926
+ ...(scope === StorageScope.Secure &&
2927
+ internal?._secureAccessControl !== undefined
2928
+ ? { accessControl: internal._secureAccessControl }
2929
+ : {}),
2930
+ });
2745
2931
  }
2746
2932
  };
2747
2933
 
@@ -2762,12 +2948,12 @@ export function runTransaction<T>(
2762
2948
  },
2763
2949
  setItem: (item, value) => {
2764
2950
  assertBatchScope([item], scope);
2765
- rememberRollback(item.key);
2951
+ rememberRollback(item.key, item);
2766
2952
  item.set(value);
2767
2953
  },
2768
2954
  removeItem: (item) => {
2769
2955
  assertBatchScope([item], scope);
2770
- rememberRollback(item.key);
2956
+ rememberRollback(item.key, item);
2771
2957
  item.delete();
2772
2958
  },
2773
2959
  };
@@ -2777,25 +2963,49 @@ export function runTransaction<T>(
2777
2963
  } catch (error) {
2778
2964
  const rollbackEntries = Array.from(rollback.entries()).reverse();
2779
2965
  if (scope === StorageScope.Memory) {
2780
- rollbackEntries.forEach(([key, previousValue]) => {
2781
- if (previousValue === NOT_SET) {
2966
+ rollbackEntries.forEach(([key, record]) => {
2967
+ if (record.value === NOT_SET) {
2782
2968
  memoryStore.delete(key);
2783
2969
  } else {
2784
- memoryStore.set(key, previousValue);
2970
+ memoryStore.set(key, record.value);
2785
2971
  }
2786
2972
  notifyKeyListeners(memoryListeners, key);
2787
2973
  });
2788
2974
  } else {
2789
- const keysToSet: string[] = [];
2790
- const valuesToSet: string[] = [];
2975
+ const groupedKeysToSet = new Map<
2976
+ AccessControl,
2977
+ { keys: string[]; values: string[] }
2978
+ >();
2791
2979
  const keysToRemove: string[] = [];
2792
2980
 
2793
- rollbackEntries.forEach(([key, previousValue]) => {
2794
- if (previousValue === undefined) {
2981
+ rollbackEntries.forEach(([key, record]) => {
2982
+ if (record.kind === "biometric") {
2983
+ if (record.value === undefined) {
2984
+ WebStorage.deleteSecureBiometric(key);
2985
+ } else {
2986
+ WebStorage.setSecureBiometricWithLevel(
2987
+ key,
2988
+ record.value,
2989
+ record.level,
2990
+ );
2991
+ }
2992
+ return;
2993
+ }
2994
+ if (record.kind !== "raw") {
2995
+ return;
2996
+ }
2997
+ if (record.value === undefined) {
2795
2998
  keysToRemove.push(key);
2796
2999
  } else {
2797
- keysToSet.push(key);
2798
- valuesToSet.push(previousValue as string);
3000
+ const accessControl =
3001
+ record.accessControl ?? secureDefaultAccessControl;
3002
+ const existingGroup = groupedKeysToSet.get(accessControl);
3003
+ const group = existingGroup ?? { keys: [], values: [] };
3004
+ group.keys.push(key);
3005
+ group.values.push(record.value);
3006
+ if (!existingGroup) {
3007
+ groupedKeysToSet.set(accessControl, group);
3008
+ }
2799
3009
  }
2800
3010
  });
2801
3011
 
@@ -2805,12 +3015,15 @@ export function runTransaction<T>(
2805
3015
  if (scope === StorageScope.Secure) {
2806
3016
  flushSecureWrites();
2807
3017
  }
2808
- if (keysToSet.length > 0) {
2809
- WebStorage.setBatch(keysToSet, valuesToSet, scope);
2810
- keysToSet.forEach((key, index) =>
2811
- cacheRawValue(scope, key, valuesToSet[index]),
3018
+ groupedKeysToSet.forEach((group, accessControl) => {
3019
+ if (scope === StorageScope.Secure) {
3020
+ WebStorage.setSecureAccessControl(accessControl);
3021
+ }
3022
+ WebStorage.setBatch(group.keys, group.values, scope);
3023
+ group.keys.forEach((key, index) =>
3024
+ cacheRawValue(scope, key, group.values[index]),
2812
3025
  );
2813
- }
3026
+ });
2814
3027
  if (keysToRemove.length > 0) {
2815
3028
  WebStorage.removeBatch(keysToRemove, scope);
2816
3029
  keysToRemove.forEach((key) => cacheRawValue(scope, key, undefined));
@@ -74,6 +74,7 @@ export async function createIndexedDBBackend(
74
74
  const pendingWrites = new Set<Promise<void>>();
75
75
  const pendingErrors: Error[] = [];
76
76
  const subscribers = new Set<(event: WebStorageChangeEvent) => void>();
77
+ let closed = false;
77
78
  const sourceId = `nitro-storage-${Math.random().toString(36).slice(2)}`;
78
79
  const channelName =
79
80
  options.channelName ?? `nitro-storage:${dbName}:${storeName}`;
@@ -82,6 +83,12 @@ export async function createIndexedDBBackend(
82
83
  ? new BroadcastChannel(channelName)
83
84
  : null;
84
85
 
86
+ function assertOpen(): void {
87
+ if (closed) {
88
+ throw new Error(`IndexedDB backend "${dbName}/${storeName}" is closed.`);
89
+ }
90
+ }
91
+
85
92
  function emitExternal(event: WebStorageChangeEvent): void {
86
93
  subscribers.forEach((subscriber) => {
87
94
  subscriber(event);
@@ -98,6 +105,10 @@ export async function createIndexedDBBackend(
98
105
  }
99
106
 
100
107
  channel?.addEventListener("message", (event: MessageEvent) => {
108
+ if (closed) {
109
+ return;
110
+ }
111
+
101
112
  const data = event.data as
102
113
  | (WebStorageChangeEvent & { sourceId?: string })
103
114
  | undefined;
@@ -209,34 +220,41 @@ export async function createIndexedDBBackend(
209
220
  const backend: WebSecureStorageBackend = {
210
221
  name: `indexeddb:${dbName}/${storeName}`,
211
222
  getItem(key: string): string | null {
223
+ assertOpen();
212
224
  return cache.get(key) ?? null;
213
225
  },
214
226
 
215
227
  setItem(key: string, value: string): void {
228
+ assertOpen();
216
229
  cache.set(key, value);
217
230
  persistSet(key, value);
218
231
  publish({ key, newValue: value });
219
232
  },
220
233
 
221
234
  removeItem(key: string): void {
235
+ assertOpen();
222
236
  cache.delete(key);
223
237
  persistDelete(key);
224
238
  publish({ key, newValue: null });
225
239
  },
226
240
 
227
241
  clear(): void {
242
+ assertOpen();
228
243
  cache.clear();
229
244
  persistClear();
230
245
  publish({ key: null, newValue: null });
231
246
  },
232
247
 
233
248
  getAllKeys(): string[] {
249
+ assertOpen();
234
250
  return Array.from(cache.keys());
235
251
  },
236
252
  getMany(keys: string[]): (string | null)[] {
253
+ assertOpen();
237
254
  return keys.map((key) => cache.get(key) ?? null);
238
255
  },
239
256
  setMany(entries): void {
257
+ assertOpen();
240
258
  entries.forEach(([key, value]) => {
241
259
  cache.set(key, value);
242
260
  });
@@ -253,6 +271,7 @@ export async function createIndexedDBBackend(
253
271
  }
254
272
  },
255
273
  removeMany(keys: string[]): void {
274
+ assertOpen();
256
275
  keys.forEach((key) => {
257
276
  cache.delete(key);
258
277
  });
@@ -269,9 +288,11 @@ export async function createIndexedDBBackend(
269
288
  }
270
289
  },
271
290
  size(): number {
291
+ assertOpen();
272
292
  return cache.size;
273
293
  },
274
294
  subscribe(listener): () => void {
295
+ assertOpen();
275
296
  subscribers.add(listener);
276
297
  return () => {
277
298
  subscribers.delete(listener);
@@ -286,6 +307,15 @@ export async function createIndexedDBBackend(
286
307
  const [error] = pendingErrors.splice(0);
287
308
  throw error;
288
309
  },
310
+ close(): void {
311
+ if (closed) {
312
+ return;
313
+ }
314
+ closed = true;
315
+ subscribers.clear();
316
+ channel?.close();
317
+ db.close();
318
+ },
289
319
  };
290
320
 
291
321
  return backend;
@@ -21,6 +21,7 @@ export type WebStorageBackend = {
21
21
  size?: () => number;
22
22
  subscribe?: (listener: (event: WebStorageChangeEvent) => void) => () => void;
23
23
  flush?: () => Promise<void>;
24
+ close?: () => void;
24
25
  name?: string;
25
26
  };
26
27