react-native-nitro-storage 0.4.4 → 0.5.0

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 (48) hide show
  1. package/README.md +237 -862
  2. package/SECURITY.md +26 -0
  3. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +61 -10
  4. package/docs/api-reference.md +217 -0
  5. package/docs/batch-transactions-migrations.md +186 -0
  6. package/docs/benchmarks.md +37 -0
  7. package/docs/mmkv-migration.md +80 -0
  8. package/docs/react-hooks.md +113 -0
  9. package/docs/recipes.md +281 -0
  10. package/docs/secure-storage.md +171 -0
  11. package/docs/web-backends.md +141 -0
  12. package/ios/IOSStorageAdapterCpp.mm +44 -14
  13. package/lib/commonjs/index.js +271 -5
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/index.web.js +498 -202
  16. package/lib/commonjs/index.web.js.map +1 -1
  17. package/lib/commonjs/indexeddb-backend.js +129 -7
  18. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  19. package/lib/commonjs/storage-runtime.js +41 -0
  20. package/lib/commonjs/storage-runtime.js.map +1 -0
  21. package/lib/commonjs/web-storage-backend.js +90 -0
  22. package/lib/commonjs/web-storage-backend.js.map +1 -0
  23. package/lib/module/index.js +263 -5
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/index.web.js +490 -202
  26. package/lib/module/index.web.js.map +1 -1
  27. package/lib/module/indexeddb-backend.js +129 -7
  28. package/lib/module/indexeddb-backend.js.map +1 -1
  29. package/lib/module/storage-runtime.js +36 -0
  30. package/lib/module/storage-runtime.js.map +1 -0
  31. package/lib/module/web-storage-backend.js +86 -0
  32. package/lib/module/web-storage-backend.js.map +1 -0
  33. package/lib/typescript/index.d.ts +14 -7
  34. package/lib/typescript/index.d.ts.map +1 -1
  35. package/lib/typescript/index.web.d.ts +15 -8
  36. package/lib/typescript/index.web.d.ts.map +1 -1
  37. package/lib/typescript/indexeddb-backend.d.ts +6 -2
  38. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  39. package/lib/typescript/storage-runtime.d.ts +48 -0
  40. package/lib/typescript/storage-runtime.d.ts.map +1 -0
  41. package/lib/typescript/web-storage-backend.d.ts +30 -0
  42. package/lib/typescript/web-storage-backend.d.ts.map +1 -0
  43. package/package.json +21 -8
  44. package/src/index.ts +330 -20
  45. package/src/index.web.ts +673 -245
  46. package/src/indexeddb-backend.ts +147 -6
  47. package/src/storage-runtime.ts +129 -0
  48. package/src/web-storage-backend.ts +129 -0
package/src/index.web.ts CHANGED
@@ -11,9 +11,36 @@ import {
11
11
  prefixKey,
12
12
  isNamespaced,
13
13
  } from "./internal";
14
+ import {
15
+ createLocalStorageWebBackend,
16
+ type WebDiskStorageBackend,
17
+ type WebSecureStorageBackend,
18
+ type WebStorageBackend,
19
+ type WebStorageChangeEvent,
20
+ } from "./web-storage-backend";
21
+ import {
22
+ getStorageErrorCode,
23
+ isLockedStorageErrorCode,
24
+ type SecureStorageMetadata,
25
+ type SecurityCapabilities,
26
+ type StorageCapabilities,
27
+ type StorageErrorCode,
28
+ } from "./storage-runtime";
14
29
 
15
30
  export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
16
31
  export { migrateFromMMKV } from "./migration";
32
+ export {
33
+ getStorageErrorCode,
34
+ type SecureStorageMetadata,
35
+ type SecurityCapabilities,
36
+ type StorageCapabilities,
37
+ type StorageErrorCode,
38
+ } from "./storage-runtime";
39
+ export type {
40
+ WebStorageBackend,
41
+ WebStorageChangeEvent,
42
+ WebStorageScope,
43
+ } from "./web-storage-backend";
17
44
 
18
45
  export type Validator<T> = (value: unknown) => value is T;
19
46
  export type ExpirationConfig = {
@@ -37,14 +64,6 @@ export type StorageMetricSummary = {
37
64
  avgDurationMs: number;
38
65
  maxDurationMs: number;
39
66
  };
40
- export type WebSecureStorageBackend = {
41
- getItem: (key: string) => string | null;
42
- setItem: (key: string, value: string) => void;
43
- removeItem: (key: string) => void;
44
- clear: () => void;
45
- getAllKeys: () => string[];
46
- };
47
-
48
67
  export type MigrationContext = {
49
68
  scope: StorageScope;
50
69
  getRaw: (key: string) => string | undefined;
@@ -91,19 +110,15 @@ function typedKeys<K extends string, V>(record: Record<K, V>): K[] {
91
110
  return Object.keys(record) as K[];
92
111
  }
93
112
  type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
113
+ type PendingDiskWrite = {
114
+ key: string;
115
+ value: string | undefined;
116
+ };
94
117
  type PendingSecureWrite = {
95
118
  key: string;
96
119
  value: string | undefined;
97
120
  accessControl?: AccessControl;
98
121
  };
99
- type BrowserStorageLike = {
100
- setItem: (key: string, value: string) => void;
101
- getItem: (key: string) => string | null;
102
- removeItem: (key: string) => void;
103
- clear: () => void;
104
- key: (index: number) => string | null;
105
- readonly length: number;
106
- };
107
122
 
108
123
  const registeredMigrations = new Map<number, Migration>();
109
124
  const runMicrotask =
@@ -112,6 +127,10 @@ const runMicrotask =
112
127
  : (task: () => void) => {
113
128
  Promise.resolve().then(task);
114
129
  };
130
+ const now =
131
+ typeof performance !== "undefined" && typeof performance.now === "function"
132
+ ? () => performance.now()
133
+ : () => Date.now();
115
134
 
116
135
  export interface Storage {
117
136
  name: string;
@@ -161,14 +180,16 @@ const webScopeKeyIndex = new Map<NonMemoryScope, Set<string>>([
161
180
  [StorageScope.Secure, new Set()],
162
181
  ]);
163
182
  const hydratedWebScopeKeyIndex = new Set<NonMemoryScope>();
183
+ const pendingDiskWrites = new Map<string, PendingDiskWrite>();
184
+ let diskFlushScheduled = false;
185
+ let diskWritesAsync = false;
164
186
  const pendingSecureWrites = new Map<string, PendingSecureWrite>();
165
187
  let secureFlushScheduled = false;
166
188
  let secureDefaultAccessControl: AccessControl = AccessControl.WhenUnlocked;
167
189
  const SECURE_WEB_PREFIX = "__secure_";
168
190
  const BIOMETRIC_WEB_PREFIX = "__bio_";
169
191
  let hasWarnedAboutWebBiometricFallback = false;
170
- let hasWebStorageEventSubscription = false;
171
- let webStorageSubscriberCount = 0;
192
+ let hasWindowStorageEventSubscription = false;
172
193
  let metricsObserver: StorageMetricsObserver | undefined;
173
194
  const metricsCounters = new Map<
174
195
  string,
@@ -205,69 +226,92 @@ function measureOperation<T>(
205
226
  if (!metricsObserver) {
206
227
  return fn();
207
228
  }
208
- const start = Date.now();
229
+ const start = now();
209
230
  try {
210
231
  return fn();
211
232
  } finally {
212
- recordMetric(operation, scope, Date.now() - start, keysCount);
233
+ recordMetric(operation, scope, now() - start, keysCount);
213
234
  }
214
235
  }
215
236
 
216
- function createLocalStorageWebSecureBackend(): WebSecureStorageBackend {
217
- return {
218
- getItem: (key) => globalThis.localStorage?.getItem(key) ?? null,
219
- setItem: (key, value) => globalThis.localStorage?.setItem(key, value),
220
- removeItem: (key) => globalThis.localStorage?.removeItem(key),
221
- clear: () => globalThis.localStorage?.clear(),
222
- getAllKeys: () => {
223
- const storage = globalThis.localStorage;
224
- if (!storage) return [];
225
- const keys: string[] = [];
226
- for (let index = 0; index < storage.length; index += 1) {
227
- const key = storage.key(index);
228
- if (key) {
229
- keys.push(key);
230
- }
231
- }
232
- return keys;
233
- },
234
- };
237
+ function createDefaultDiskBackend(): WebDiskStorageBackend {
238
+ return createLocalStorageWebBackend({
239
+ name: "localStorage:disk",
240
+ includeKey: (key) =>
241
+ !key.startsWith(SECURE_WEB_PREFIX) &&
242
+ !key.startsWith(BIOMETRIC_WEB_PREFIX),
243
+ });
235
244
  }
236
245
 
246
+ function createDefaultSecureBackend(): WebSecureStorageBackend {
247
+ return createLocalStorageWebBackend({
248
+ name: "localStorage:secure",
249
+ includeKey: (key) =>
250
+ key.startsWith(SECURE_WEB_PREFIX) || key.startsWith(BIOMETRIC_WEB_PREFIX),
251
+ });
252
+ }
253
+
254
+ let webDiskStorageBackend: WebDiskStorageBackend | undefined =
255
+ createDefaultDiskBackend();
237
256
  let webSecureStorageBackend: WebSecureStorageBackend | undefined =
238
- createLocalStorageWebSecureBackend();
257
+ createDefaultSecureBackend();
258
+ const externalSyncUnsubscribers = new Map<NonMemoryScope, () => void>();
239
259
 
240
- let cachedSecureBrowserStorage: BrowserStorageLike | undefined;
241
- let cachedSecureBackendRef: WebSecureStorageBackend | undefined;
260
+ function getBackendName(
261
+ scope: NonMemoryScope,
262
+ backend: WebStorageBackend | undefined,
263
+ ): string {
264
+ const scopeName = scope === StorageScope.Disk ? "disk" : "secure";
265
+ return backend?.name ?? `web:${scopeName}`;
266
+ }
242
267
 
243
- function getBrowserStorage(scope: number): BrowserStorageLike | undefined {
244
- if (scope === StorageScope.Disk) {
245
- return globalThis.localStorage;
268
+ function getWebSecureEncryptionStatus(
269
+ backend: WebSecureStorageBackend | undefined,
270
+ ): "unavailable" | "unknown" {
271
+ return backend?.name === "localStorage:secure" ? "unavailable" : "unknown";
272
+ }
273
+
274
+ function createWebStorageError(
275
+ scope: NonMemoryScope,
276
+ operation: string,
277
+ error: unknown,
278
+ backend: WebStorageBackend | undefined,
279
+ ): Error {
280
+ const backendName = getBackendName(scope, backend);
281
+ const message =
282
+ error instanceof Error ? error.message : String(error ?? "Unknown error");
283
+ return new Error(
284
+ `NitroStorage(web): ${operation} failed for ${backendName}: ${message}`,
285
+ );
286
+ }
287
+
288
+ function withWebBackendOperation<T>(
289
+ scope: NonMemoryScope,
290
+ operation: string,
291
+ fn: (backend: WebStorageBackend) => T,
292
+ ): T {
293
+ const backend =
294
+ scope === StorageScope.Disk
295
+ ? webDiskStorageBackend
296
+ : webSecureStorageBackend;
297
+ if (!backend) {
298
+ throw new Error(
299
+ `NitroStorage(web): ${operation} failed because no ${scope === StorageScope.Disk ? "disk" : "secure"} backend is configured.`,
300
+ );
246
301
  }
247
- if (scope === StorageScope.Secure) {
248
- if (!webSecureStorageBackend) {
249
- return undefined;
250
- }
251
- if (
252
- cachedSecureBackendRef === webSecureStorageBackend &&
253
- cachedSecureBrowserStorage
254
- ) {
255
- return cachedSecureBrowserStorage;
256
- }
257
- cachedSecureBackendRef = webSecureStorageBackend;
258
- cachedSecureBrowserStorage = {
259
- setItem: (key, value) => webSecureStorageBackend!.setItem(key, value),
260
- getItem: (key) => webSecureStorageBackend!.getItem(key) ?? null,
261
- removeItem: (key) => webSecureStorageBackend!.removeItem(key),
262
- clear: () => webSecureStorageBackend!.clear(),
263
- key: (index) => webSecureStorageBackend!.getAllKeys()[index] ?? null,
264
- get length() {
265
- return webSecureStorageBackend!.getAllKeys().length;
266
- },
267
- };
268
- return cachedSecureBrowserStorage;
302
+
303
+ try {
304
+ ensureExternalSyncSubscriptions();
305
+ return fn(backend);
306
+ } catch (error) {
307
+ throw createWebStorageError(scope, operation, error, backend);
269
308
  }
270
- return undefined;
309
+ }
310
+
311
+ function getWebBackend(scope: NonMemoryScope): WebStorageBackend | undefined {
312
+ return scope === StorageScope.Disk
313
+ ? webDiskStorageBackend
314
+ : webSecureStorageBackend;
271
315
  }
272
316
 
273
317
  function toSecureStorageKey(key: string): string {
@@ -295,32 +339,22 @@ function hydrateWebScopeKeyIndex(scope: NonMemoryScope): void {
295
339
  return;
296
340
  }
297
341
 
298
- const storage = getBrowserStorage(scope);
342
+ const backend = getWebBackend(scope);
299
343
  const keyIndex = getWebScopeKeyIndex(scope);
300
344
  keyIndex.clear();
301
- if (storage) {
302
- for (let index = 0; index < storage.length; index += 1) {
303
- const key = storage.key(index);
304
- if (!key) {
305
- continue;
306
- }
307
- if (scope === StorageScope.Disk) {
308
- if (
309
- !key.startsWith(SECURE_WEB_PREFIX) &&
310
- !key.startsWith(BIOMETRIC_WEB_PREFIX)
311
- ) {
312
- keyIndex.add(key);
313
- }
314
- continue;
315
- }
345
+ const keys = backend?.getAllKeys() ?? [];
346
+ for (const key of keys) {
347
+ if (scope === StorageScope.Disk) {
348
+ keyIndex.add(key);
349
+ continue;
350
+ }
316
351
 
317
- if (key.startsWith(SECURE_WEB_PREFIX)) {
318
- keyIndex.add(fromSecureStorageKey(key));
319
- continue;
320
- }
321
- if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
322
- keyIndex.add(fromBiometricStorageKey(key));
323
- }
352
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
353
+ keyIndex.add(fromSecureStorageKey(key));
354
+ continue;
355
+ }
356
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
357
+ keyIndex.add(fromBiometricStorageKey(key));
324
358
  }
325
359
  }
326
360
  hydratedWebScopeKeyIndex.add(scope);
@@ -331,37 +365,39 @@ function ensureWebScopeKeyIndex(scope: NonMemoryScope): Set<string> {
331
365
  return getWebScopeKeyIndex(scope);
332
366
  }
333
367
 
334
- function handleWebStorageEvent(event: StorageEvent): void {
335
- const key = event.key;
368
+ function applyExternalChangeEvent(
369
+ scope: NonMemoryScope,
370
+ key: string | null,
371
+ newValue: string | null,
372
+ ): void {
336
373
  if (key === null) {
337
- clearScopeRawCache(StorageScope.Disk);
338
- clearScopeRawCache(StorageScope.Secure);
339
- ensureWebScopeKeyIndex(StorageScope.Disk).clear();
340
- ensureWebScopeKeyIndex(StorageScope.Secure).clear();
341
- notifyAllListeners(getScopedListeners(StorageScope.Disk));
342
- notifyAllListeners(getScopedListeners(StorageScope.Secure));
374
+ clearScopeRawCache(scope);
375
+ ensureWebScopeKeyIndex(scope).clear();
376
+ notifyAllListeners(getScopedListeners(scope));
343
377
  return;
344
378
  }
345
379
 
346
- if (key.startsWith(SECURE_WEB_PREFIX)) {
380
+ if (scope === StorageScope.Secure && key.startsWith(SECURE_WEB_PREFIX)) {
347
381
  const plainKey = fromSecureStorageKey(key);
348
- if (event.newValue === null) {
382
+ if (newValue === null) {
349
383
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
350
384
  cacheRawValue(StorageScope.Secure, plainKey, undefined);
351
385
  } else {
352
386
  ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
353
- cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
387
+ cacheRawValue(StorageScope.Secure, plainKey, newValue);
354
388
  }
355
389
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
356
390
  return;
357
391
  }
358
392
 
359
- if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
393
+ if (scope === StorageScope.Secure && key.startsWith(BIOMETRIC_WEB_PREFIX)) {
360
394
  const plainKey = fromBiometricStorageKey(key);
361
- if (event.newValue === null) {
395
+ if (newValue === null) {
362
396
  if (
363
- getBrowserStorage(StorageScope.Secure)?.getItem(
364
- toSecureStorageKey(plainKey),
397
+ withWebBackendOperation(
398
+ StorageScope.Secure,
399
+ "external-sync:getItem",
400
+ (backend) => backend.getItem(toSecureStorageKey(plainKey)),
365
401
  ) === null
366
402
  ) {
367
403
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
@@ -369,44 +405,74 @@ function handleWebStorageEvent(event: StorageEvent): void {
369
405
  cacheRawValue(StorageScope.Secure, plainKey, undefined);
370
406
  } else {
371
407
  ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
372
- cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
408
+ cacheRawValue(StorageScope.Secure, plainKey, newValue);
373
409
  }
374
410
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
375
411
  return;
376
412
  }
377
413
 
378
- if (event.newValue === null) {
379
- ensureWebScopeKeyIndex(StorageScope.Disk).delete(key);
380
- cacheRawValue(StorageScope.Disk, key, undefined);
414
+ if (newValue === null) {
415
+ ensureWebScopeKeyIndex(scope).delete(key);
416
+ cacheRawValue(scope, key, undefined);
381
417
  } else {
382
- ensureWebScopeKeyIndex(StorageScope.Disk).add(key);
383
- cacheRawValue(StorageScope.Disk, key, event.newValue);
418
+ ensureWebScopeKeyIndex(scope).add(key);
419
+ cacheRawValue(scope, key, newValue);
384
420
  }
385
- notifyKeyListeners(getScopedListeners(StorageScope.Disk), key);
421
+ notifyKeyListeners(getScopedListeners(scope), key);
386
422
  }
387
423
 
388
- function ensureWebStorageEventSubscription(): void {
389
- webStorageSubscriberCount += 1;
424
+ function handleWebStorageEvent(event: StorageEvent): void {
425
+ const key = event.key;
426
+ if (key === null) {
427
+ applyExternalChangeEvent(StorageScope.Disk, null, null);
428
+ applyExternalChangeEvent(StorageScope.Secure, null, null);
429
+ return;
430
+ }
431
+
390
432
  if (
391
- webStorageSubscriberCount === 1 &&
392
- typeof window !== "undefined" &&
393
- typeof window.addEventListener === "function"
433
+ key.startsWith(SECURE_WEB_PREFIX) ||
434
+ key.startsWith(BIOMETRIC_WEB_PREFIX)
394
435
  ) {
395
- window.addEventListener("storage", handleWebStorageEvent);
396
- hasWebStorageEventSubscription = true;
436
+ applyExternalChangeEvent(StorageScope.Secure, key, event.newValue);
437
+ return;
397
438
  }
439
+
440
+ applyExternalChangeEvent(StorageScope.Disk, key, event.newValue);
441
+ }
442
+
443
+ function subscribeToBackendChanges(scope: NonMemoryScope): void {
444
+ if (externalSyncUnsubscribers.has(scope)) {
445
+ return;
446
+ }
447
+
448
+ const backend = getWebBackend(scope);
449
+ if (!backend?.subscribe) {
450
+ return;
451
+ }
452
+
453
+ const unsubscribe = backend.subscribe((event: WebStorageChangeEvent) => {
454
+ applyExternalChangeEvent(scope, event.key, event.newValue);
455
+ });
456
+ externalSyncUnsubscribers.set(scope, unsubscribe);
457
+ }
458
+
459
+ function resetBackendChangeSubscription(scope: NonMemoryScope): void {
460
+ externalSyncUnsubscribers.get(scope)?.();
461
+ externalSyncUnsubscribers.delete(scope);
398
462
  }
399
463
 
400
- function maybeCleanupWebStorageSubscription(): void {
401
- webStorageSubscriberCount = Math.max(0, webStorageSubscriberCount - 1);
464
+ function ensureExternalSyncSubscriptions(): void {
402
465
  if (
403
- webStorageSubscriberCount === 0 &&
404
- hasWebStorageEventSubscription &&
405
- typeof window !== "undefined"
466
+ !hasWindowStorageEventSubscription &&
467
+ typeof window !== "undefined" &&
468
+ typeof window.addEventListener === "function"
406
469
  ) {
407
- window.removeEventListener("storage", handleWebStorageEvent);
408
- hasWebStorageEventSubscription = false;
470
+ window.addEventListener("storage", handleWebStorageEvent);
471
+ hasWindowStorageEventSubscription = true;
409
472
  }
473
+
474
+ subscribeToBackendChanges(StorageScope.Disk);
475
+ subscribeToBackendChanges(StorageScope.Secure);
410
476
  }
411
477
 
412
478
  function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
@@ -487,14 +553,58 @@ function readPendingSecureWrite(key: string): string | undefined {
487
553
  return pendingSecureWrites.get(key)?.value;
488
554
  }
489
555
 
556
+ function readPendingDiskWrite(key: string): string | undefined {
557
+ return pendingDiskWrites.get(key)?.value;
558
+ }
559
+
560
+ function hasPendingDiskWrite(key: string): boolean {
561
+ return pendingDiskWrites.has(key);
562
+ }
563
+
490
564
  function hasPendingSecureWrite(key: string): boolean {
491
565
  return pendingSecureWrites.has(key);
492
566
  }
493
567
 
568
+ function clearPendingDiskWrite(key: string): void {
569
+ pendingDiskWrites.delete(key);
570
+ }
571
+
494
572
  function clearPendingSecureWrite(key: string): void {
495
573
  pendingSecureWrites.delete(key);
496
574
  }
497
575
 
576
+ function flushDiskWrites(): void {
577
+ diskFlushScheduled = false;
578
+
579
+ if (pendingDiskWrites.size === 0) {
580
+ return;
581
+ }
582
+
583
+ const writes = Array.from(pendingDiskWrites.values());
584
+ pendingDiskWrites.clear();
585
+
586
+ const keysToSet: string[] = [];
587
+ const valuesToSet: string[] = [];
588
+ const keysToRemove: string[] = [];
589
+
590
+ writes.forEach(({ key, value }) => {
591
+ if (value === undefined) {
592
+ keysToRemove.push(key);
593
+ return;
594
+ }
595
+
596
+ keysToSet.push(key);
597
+ valuesToSet.push(value);
598
+ });
599
+
600
+ if (keysToSet.length > 0) {
601
+ WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Disk);
602
+ }
603
+ if (keysToRemove.length > 0) {
604
+ WebStorage.removeBatch(keysToRemove, StorageScope.Disk);
605
+ }
606
+ }
607
+
498
608
  function flushSecureWrites(): void {
499
609
  secureFlushScheduled = false;
500
610
 
@@ -535,6 +645,15 @@ function flushSecureWrites(): void {
535
645
  }
536
646
  }
537
647
 
648
+ function scheduleDiskWrite(key: string, value: string | undefined): void {
649
+ pendingDiskWrites.set(key, { key, value });
650
+ if (diskFlushScheduled) {
651
+ return;
652
+ }
653
+ diskFlushScheduled = true;
654
+ runMicrotask(flushDiskWrites);
655
+ }
656
+
538
657
  function scheduleSecureWrite(
539
658
  key: string,
540
659
  value: string | undefined,
@@ -557,131 +676,142 @@ const WebStorage: Storage = {
557
676
  equals: (other) => other === WebStorage,
558
677
  dispose: () => {},
559
678
  set: (key: string, value: string, scope: number) => {
560
- const storage = getBrowserStorage(scope);
561
- if (!storage) {
679
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
562
680
  return;
563
681
  }
564
682
  const storageKey =
565
683
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
566
- storage.setItem(storageKey, value);
567
- if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
568
- ensureWebScopeKeyIndex(scope).add(key);
569
- notifyKeyListeners(getScopedListeners(scope), key);
570
- }
684
+ withWebBackendOperation(scope, "set", (backend) => {
685
+ backend.setItem(storageKey, value);
686
+ });
687
+ ensureWebScopeKeyIndex(scope).add(key);
688
+ notifyKeyListeners(getScopedListeners(scope), key);
571
689
  },
572
690
  get: (key: string, scope: number) => {
573
- const storage = getBrowserStorage(scope);
691
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
692
+ return undefined;
693
+ }
574
694
  const storageKey =
575
695
  scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
576
- return storage?.getItem(storageKey) ?? undefined;
696
+ const value = withWebBackendOperation(scope, "get", (backend) =>
697
+ backend.getItem(storageKey),
698
+ );
699
+ return value ?? undefined;
577
700
  },
578
701
  remove: (key: string, scope: number) => {
579
- const storage = getBrowserStorage(scope);
580
- if (!storage) {
702
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
581
703
  return;
582
704
  }
583
705
  if (scope === StorageScope.Secure) {
584
- storage.removeItem(toSecureStorageKey(key));
585
- storage.removeItem(toBiometricStorageKey(key));
706
+ withWebBackendOperation(scope, "remove", (backend) => {
707
+ if (backend.removeMany) {
708
+ backend.removeMany([
709
+ toSecureStorageKey(key),
710
+ toBiometricStorageKey(key),
711
+ ]);
712
+ return;
713
+ }
714
+ backend.removeItem(toSecureStorageKey(key));
715
+ backend.removeItem(toBiometricStorageKey(key));
716
+ });
586
717
  } else {
587
- storage.removeItem(key);
588
- }
589
- if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
590
- ensureWebScopeKeyIndex(scope).delete(key);
591
- notifyKeyListeners(getScopedListeners(scope), key);
718
+ withWebBackendOperation(scope, "remove", (backend) => {
719
+ backend.removeItem(key);
720
+ });
592
721
  }
722
+ ensureWebScopeKeyIndex(scope).delete(key);
723
+ notifyKeyListeners(getScopedListeners(scope), key);
593
724
  },
594
725
  clear: (scope: number) => {
595
- const storage = getBrowserStorage(scope);
596
- if (!storage) {
726
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
597
727
  return;
598
728
  }
599
- if (scope === StorageScope.Secure) {
600
- const keysToRemove: string[] = [];
601
- for (let i = 0; i < storage.length; i++) {
602
- const key = storage.key(i);
603
- if (
604
- key?.startsWith(SECURE_WEB_PREFIX) ||
605
- key?.startsWith(BIOMETRIC_WEB_PREFIX)
606
- ) {
607
- keysToRemove.push(key);
608
- }
609
- }
610
- keysToRemove.forEach((key) => storage.removeItem(key));
611
- } else if (scope === StorageScope.Disk) {
612
- const keysToRemove: string[] = [];
613
- for (let i = 0; i < storage.length; i++) {
614
- const key = storage.key(i);
615
- if (
616
- key &&
617
- !key.startsWith(SECURE_WEB_PREFIX) &&
618
- !key.startsWith(BIOMETRIC_WEB_PREFIX)
619
- ) {
620
- keysToRemove.push(key);
621
- }
622
- }
623
- keysToRemove.forEach((key) => storage.removeItem(key));
624
- } else {
625
- storage.clear();
626
- }
627
- if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
628
- ensureWebScopeKeyIndex(scope).clear();
629
- notifyAllListeners(getScopedListeners(scope));
630
- }
729
+ withWebBackendOperation(scope, "clear", (backend) => {
730
+ backend.clear();
731
+ });
732
+ ensureWebScopeKeyIndex(scope).clear();
733
+ notifyAllListeners(getScopedListeners(scope));
631
734
  },
632
735
  setBatch: (keys: string[], values: string[], scope: number) => {
633
- const storage = getBrowserStorage(scope);
634
- if (!storage) {
736
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
635
737
  return;
636
738
  }
637
739
 
740
+ const entries: (readonly [string, string])[] = [];
638
741
  keys.forEach((key, index) => {
639
742
  const value = values[index];
640
743
  if (value === undefined) {
641
744
  return;
642
745
  }
643
- const storageKey =
644
- scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
645
- storage.setItem(storageKey, value);
746
+ entries.push([
747
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key,
748
+ value,
749
+ ]);
646
750
  });
647
- if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
648
- const keyIndex = ensureWebScopeKeyIndex(scope);
649
- keys.forEach((key) => keyIndex.add(key));
650
- const listeners = getScopedListeners(scope);
651
- keys.forEach((key) => notifyKeyListeners(listeners, key));
652
- }
751
+ withWebBackendOperation(scope, "setBatch", (backend) => {
752
+ if (backend.setMany) {
753
+ backend.setMany(entries);
754
+ return;
755
+ }
756
+ entries.forEach(([storageKey, value]) => {
757
+ backend.setItem(storageKey, value);
758
+ });
759
+ });
760
+ const keyIndex = ensureWebScopeKeyIndex(scope);
761
+ keys.forEach((key) => keyIndex.add(key));
762
+ const listeners = getScopedListeners(scope);
763
+ keys.forEach((key) => notifyKeyListeners(listeners, key));
653
764
  },
654
765
  getBatch: (keys: string[], scope: number) => {
655
- const storage = getBrowserStorage(scope);
656
- return keys.map((key) => {
657
- const storageKey =
658
- scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
659
- return storage?.getItem(storageKey) ?? undefined;
766
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
767
+ return keys.map(() => undefined);
768
+ }
769
+ const storageKeys = keys.map((key) =>
770
+ scope === StorageScope.Secure ? toSecureStorageKey(key) : key,
771
+ );
772
+ const values = withWebBackendOperation(scope, "getBatch", (backend) => {
773
+ if (backend.getMany) {
774
+ return backend.getMany(storageKeys);
775
+ }
776
+ return storageKeys.map((storageKey) => backend.getItem(storageKey));
660
777
  });
778
+ return values.map((value) => value ?? undefined);
661
779
  },
662
780
  removeBatch: (keys: string[], scope: number) => {
663
- const storage = getBrowserStorage(scope);
664
- if (!storage) {
781
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
665
782
  return;
666
783
  }
667
784
 
668
785
  if (scope === StorageScope.Secure) {
669
- keys.forEach((key) => {
670
- storage.removeItem(toSecureStorageKey(key));
671
- storage.removeItem(toBiometricStorageKey(key));
786
+ const storageKeys = keys.flatMap((key) => [
787
+ toSecureStorageKey(key),
788
+ toBiometricStorageKey(key),
789
+ ]);
790
+ withWebBackendOperation(scope, "removeBatch", (backend) => {
791
+ if (backend.removeMany) {
792
+ backend.removeMany(storageKeys);
793
+ return;
794
+ }
795
+ storageKeys.forEach((storageKey) => {
796
+ backend.removeItem(storageKey);
797
+ });
672
798
  });
673
799
  } else {
674
- keys.forEach((key) => {
675
- storage.removeItem(key);
800
+ withWebBackendOperation(scope, "removeBatch", (backend) => {
801
+ if (backend.removeMany) {
802
+ backend.removeMany(keys);
803
+ return;
804
+ }
805
+ keys.forEach((key) => {
806
+ backend.removeItem(key);
807
+ });
676
808
  });
677
809
  }
678
810
 
679
- if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
680
- const keyIndex = ensureWebScopeKeyIndex(scope);
681
- keys.forEach((key) => keyIndex.delete(key));
682
- const listeners = getScopedListeners(scope);
683
- keys.forEach((key) => notifyKeyListeners(listeners, key));
684
- }
811
+ const keyIndex = ensureWebScopeKeyIndex(scope);
812
+ keys.forEach((key) => keyIndex.delete(key));
813
+ const listeners = getScopedListeners(scope);
814
+ keys.forEach((key) => notifyKeyListeners(listeners, key));
685
815
  },
686
816
  removeByPrefix: (prefix: string, scope: number) => {
687
817
  if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
@@ -703,14 +833,24 @@ const WebStorage: Storage = {
703
833
  return () => {};
704
834
  },
705
835
  has: (key: string, scope: number) => {
706
- const storage = getBrowserStorage(scope);
707
836
  if (scope === StorageScope.Secure) {
708
837
  return (
709
- storage?.getItem(toSecureStorageKey(key)) !== null ||
710
- storage?.getItem(toBiometricStorageKey(key)) !== null
838
+ withWebBackendOperation(scope, "has", (backend) =>
839
+ backend.getItem(toSecureStorageKey(key)),
840
+ ) !== null ||
841
+ withWebBackendOperation(scope, "has", (backend) =>
842
+ backend.getItem(toBiometricStorageKey(key)),
843
+ ) !== null
711
844
  );
712
845
  }
713
- return storage?.getItem(key) !== null;
846
+ if (scope !== StorageScope.Disk) {
847
+ return false;
848
+ }
849
+ return (
850
+ withWebBackendOperation(scope, "has", (backend) =>
851
+ backend.getItem(key),
852
+ ) !== null
853
+ );
714
854
  },
715
855
  getAllKeys: (scope: number) => {
716
856
  if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
@@ -753,51 +893,85 @@ const WebStorage: Storage = {
753
893
  "[NitroStorage] Biometric storage is not supported on web. Using localStorage.",
754
894
  );
755
895
  }
756
- getBrowserStorage(StorageScope.Secure)?.setItem(
757
- toBiometricStorageKey(key),
758
- value,
896
+ withWebBackendOperation(
897
+ StorageScope.Secure,
898
+ "setSecureBiometric",
899
+ (backend) => backend.setItem(toBiometricStorageKey(key), value),
759
900
  );
760
901
  ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
761
902
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
762
903
  },
763
904
  getSecureBiometric: (key: string) => {
764
- return (
765
- getBrowserStorage(StorageScope.Secure)?.getItem(
766
- toBiometricStorageKey(key),
767
- ) ?? undefined
905
+ const value = withWebBackendOperation(
906
+ StorageScope.Secure,
907
+ "getSecureBiometric",
908
+ (backend) => backend.getItem(toBiometricStorageKey(key)),
768
909
  );
910
+ return value ?? undefined;
769
911
  },
770
912
  deleteSecureBiometric: (key: string) => {
771
- const storage = getBrowserStorage(StorageScope.Secure);
772
- storage?.removeItem(toBiometricStorageKey(key));
773
- if (storage?.getItem(toSecureStorageKey(key)) === null) {
913
+ withWebBackendOperation(
914
+ StorageScope.Secure,
915
+ "deleteSecureBiometric",
916
+ (backend) => backend.removeItem(toBiometricStorageKey(key)),
917
+ );
918
+ if (
919
+ withWebBackendOperation(
920
+ StorageScope.Secure,
921
+ "deleteSecureBiometric:getItem",
922
+ (backend) => backend.getItem(toSecureStorageKey(key)),
923
+ ) === null
924
+ ) {
774
925
  ensureWebScopeKeyIndex(StorageScope.Secure).delete(key);
775
926
  }
776
927
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
777
928
  },
778
929
  hasSecureBiometric: (key: string) => {
779
930
  return (
780
- getBrowserStorage(StorageScope.Secure)?.getItem(
781
- toBiometricStorageKey(key),
931
+ withWebBackendOperation(
932
+ StorageScope.Secure,
933
+ "hasSecureBiometric",
934
+ (backend) => backend.getItem(toBiometricStorageKey(key)),
782
935
  ) !== null
783
936
  );
784
937
  },
785
938
  clearSecureBiometric: () => {
786
- const storage = getBrowserStorage(StorageScope.Secure);
787
- if (!storage) return;
788
- const keysToNotify: string[] = [];
789
- const toRemove: string[] = [];
790
- for (let i = 0; i < storage.length; i++) {
791
- const k = storage.key(i);
792
- if (k?.startsWith(BIOMETRIC_WEB_PREFIX)) {
793
- toRemove.push(k);
794
- keysToNotify.push(fromBiometricStorageKey(k));
795
- }
939
+ const storageKeys = withWebBackendOperation(
940
+ StorageScope.Secure,
941
+ "clearSecureBiometric:getAllKeys",
942
+ (backend) => backend.getAllKeys(),
943
+ );
944
+ const keysToNotify = storageKeys
945
+ .filter((key) => key.startsWith(BIOMETRIC_WEB_PREFIX))
946
+ .map((key) => fromBiometricStorageKey(key));
947
+ if (keysToNotify.length === 0) {
948
+ return;
796
949
  }
797
- toRemove.forEach((k) => storage.removeItem(k));
950
+ withWebBackendOperation(
951
+ StorageScope.Secure,
952
+ "clearSecureBiometric",
953
+ (backend) => {
954
+ const biometricKeys = keysToNotify.map((key) =>
955
+ toBiometricStorageKey(key),
956
+ );
957
+ if (backend.removeMany) {
958
+ backend.removeMany(biometricKeys);
959
+ return;
960
+ }
961
+ biometricKeys.forEach((storageKey) => {
962
+ backend.removeItem(storageKey);
963
+ });
964
+ },
965
+ );
798
966
  const keyIndex = ensureWebScopeKeyIndex(StorageScope.Secure);
799
967
  keysToNotify.forEach((key) => {
800
- if (storage.getItem(toSecureStorageKey(key)) === null) {
968
+ if (
969
+ withWebBackendOperation(
970
+ StorageScope.Secure,
971
+ "clearSecureBiometric:getItem",
972
+ (backend) => backend.getItem(toSecureStorageKey(key)),
973
+ ) === null
974
+ ) {
801
975
  keyIndex.delete(key);
802
976
  }
803
977
  });
@@ -813,6 +987,10 @@ function getRawValue(key: string, scope: StorageScope): string | undefined {
813
987
  return typeof value === "string" ? value : undefined;
814
988
  }
815
989
 
990
+ if (scope === StorageScope.Disk && hasPendingDiskWrite(key)) {
991
+ return readPendingDiskWrite(key);
992
+ }
993
+
816
994
  if (scope === StorageScope.Secure && hasPendingSecureWrite(key)) {
817
995
  return readPendingSecureWrite(key);
818
996
  }
@@ -828,6 +1006,17 @@ function setRawValue(key: string, value: string, scope: StorageScope): void {
828
1006
  return;
829
1007
  }
830
1008
 
1009
+ if (scope === StorageScope.Disk) {
1010
+ cacheRawValue(scope, key, value);
1011
+ if (diskWritesAsync) {
1012
+ scheduleDiskWrite(key, value);
1013
+ return;
1014
+ }
1015
+
1016
+ flushDiskWrites();
1017
+ clearPendingDiskWrite(key);
1018
+ }
1019
+
831
1020
  if (scope === StorageScope.Secure) {
832
1021
  flushSecureWrites();
833
1022
  clearPendingSecureWrite(key);
@@ -845,6 +1034,17 @@ function removeRawValue(key: string, scope: StorageScope): void {
845
1034
  return;
846
1035
  }
847
1036
 
1037
+ if (scope === StorageScope.Disk) {
1038
+ cacheRawValue(scope, key, undefined);
1039
+ if (diskWritesAsync) {
1040
+ scheduleDiskWrite(key, undefined);
1041
+ return;
1042
+ }
1043
+
1044
+ flushDiskWrites();
1045
+ clearPendingDiskWrite(key);
1046
+ }
1047
+
848
1048
  if (scope === StorageScope.Secure) {
849
1049
  flushSecureWrites();
850
1050
  clearPendingSecureWrite(key);
@@ -877,6 +1077,11 @@ export const storage = {
877
1077
  return;
878
1078
  }
879
1079
 
1080
+ if (scope === StorageScope.Disk) {
1081
+ flushDiskWrites();
1082
+ pendingDiskWrites.clear();
1083
+ }
1084
+
880
1085
  if (scope === StorageScope.Secure) {
881
1086
  flushSecureWrites();
882
1087
  pendingSecureWrites.clear();
@@ -912,6 +1117,9 @@ export const storage = {
912
1117
  }
913
1118
 
914
1119
  const keyPrefix = prefixKey(namespace, "");
1120
+ if (scope === StorageScope.Disk) {
1121
+ flushDiskWrites();
1122
+ }
915
1123
  if (scope === StorageScope.Secure) {
916
1124
  flushSecureWrites();
917
1125
  }
@@ -933,6 +1141,12 @@ export const storage = {
933
1141
  return measureOperation("storage:has", scope, () => {
934
1142
  assertValidScope(scope);
935
1143
  if (scope === StorageScope.Memory) return memoryStore.has(key);
1144
+ if (scope === StorageScope.Disk) {
1145
+ flushDiskWrites();
1146
+ }
1147
+ if (scope === StorageScope.Secure) {
1148
+ flushSecureWrites();
1149
+ }
936
1150
  return WebStorage.has(key, scope);
937
1151
  });
938
1152
  },
@@ -940,6 +1154,12 @@ export const storage = {
940
1154
  return measureOperation("storage:getAllKeys", scope, () => {
941
1155
  assertValidScope(scope);
942
1156
  if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
1157
+ if (scope === StorageScope.Disk) {
1158
+ flushDiskWrites();
1159
+ }
1160
+ if (scope === StorageScope.Secure) {
1161
+ flushSecureWrites();
1162
+ }
943
1163
  return WebStorage.getAllKeys(scope);
944
1164
  });
945
1165
  },
@@ -951,6 +1171,12 @@ export const storage = {
951
1171
  key.startsWith(prefix),
952
1172
  );
953
1173
  }
1174
+ if (scope === StorageScope.Disk) {
1175
+ flushDiskWrites();
1176
+ }
1177
+ if (scope === StorageScope.Secure) {
1178
+ flushSecureWrites();
1179
+ }
954
1180
  return WebStorage.getKeysByPrefix(prefix, scope);
955
1181
  });
956
1182
  },
@@ -975,6 +1201,12 @@ export const storage = {
975
1201
  return result;
976
1202
  }
977
1203
 
1204
+ if (scope === StorageScope.Disk) {
1205
+ flushDiskWrites();
1206
+ }
1207
+ if (scope === StorageScope.Secure) {
1208
+ flushSecureWrites();
1209
+ }
978
1210
  const values = WebStorage.getBatch(keys, scope);
979
1211
  keys.forEach((key, index) => {
980
1212
  const value = values[index];
@@ -995,6 +1227,12 @@ export const storage = {
995
1227
  });
996
1228
  return result;
997
1229
  }
1230
+ if (scope === StorageScope.Disk) {
1231
+ flushDiskWrites();
1232
+ }
1233
+ if (scope === StorageScope.Secure) {
1234
+ flushSecureWrites();
1235
+ }
998
1236
  const keys = WebStorage.getAllKeys(scope);
999
1237
  if (keys.length === 0) return {};
1000
1238
  const values = WebStorage.getBatch(keys, scope);
@@ -1011,6 +1249,12 @@ export const storage = {
1011
1249
  return measureOperation("storage:size", scope, () => {
1012
1250
  assertValidScope(scope);
1013
1251
  if (scope === StorageScope.Memory) return memoryStore.size;
1252
+ if (scope === StorageScope.Disk) {
1253
+ flushDiskWrites();
1254
+ }
1255
+ if (scope === StorageScope.Secure) {
1256
+ flushSecureWrites();
1257
+ }
1014
1258
  return WebStorage.size(scope);
1015
1259
  });
1016
1260
  },
@@ -1021,6 +1265,19 @@ export const storage = {
1021
1265
  setSecureWritesAsync: (_enabled: boolean) => {
1022
1266
  recordMetric("storage:setSecureWritesAsync", StorageScope.Secure, 0);
1023
1267
  },
1268
+ setDiskWritesAsync: (enabled: boolean) => {
1269
+ measureOperation("storage:setDiskWritesAsync", StorageScope.Disk, () => {
1270
+ diskWritesAsync = enabled;
1271
+ if (!enabled) {
1272
+ flushDiskWrites();
1273
+ }
1274
+ });
1275
+ },
1276
+ flushDiskWrites: () => {
1277
+ measureOperation("storage:flushDiskWrites", StorageScope.Disk, () => {
1278
+ flushDiskWrites();
1279
+ });
1280
+ },
1024
1281
  flushSecureWrites: () => {
1025
1282
  measureOperation("storage:flushSecureWrites", StorageScope.Secure, () => {
1026
1283
  flushSecureWrites();
@@ -1048,6 +1305,84 @@ export const storage = {
1048
1305
  resetMetrics: () => {
1049
1306
  metricsCounters.clear();
1050
1307
  },
1308
+ getCapabilities: (): StorageCapabilities => ({
1309
+ platform: "web",
1310
+ backend: {
1311
+ disk: getBackendName(StorageScope.Disk, webDiskStorageBackend),
1312
+ secure: getBackendName(StorageScope.Secure, webSecureStorageBackend),
1313
+ },
1314
+ writeBuffering: {
1315
+ disk: true,
1316
+ secure: true,
1317
+ },
1318
+ errorClassification: true,
1319
+ }),
1320
+ getSecurityCapabilities: (): SecurityCapabilities => {
1321
+ const secureBackend = getBackendName(
1322
+ StorageScope.Secure,
1323
+ webSecureStorageBackend,
1324
+ );
1325
+ return {
1326
+ platform: "web",
1327
+ secureStorage: {
1328
+ backend: secureBackend,
1329
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1330
+ accessControl: "unavailable",
1331
+ keychainAccessGroup: "unavailable",
1332
+ hardwareBacked: "unavailable",
1333
+ },
1334
+ biometric: {
1335
+ storage: "unavailable",
1336
+ prompt: "unavailable",
1337
+ biometryOnly: "unavailable",
1338
+ biometryOrPasscode: "unavailable",
1339
+ },
1340
+ metadata: {
1341
+ perKey: true,
1342
+ listsWithoutValues: true,
1343
+ persistsTimestamps: false,
1344
+ },
1345
+ };
1346
+ },
1347
+ getSecureMetadata: (key: string): SecureStorageMetadata => {
1348
+ return measureOperation(
1349
+ "storage:getSecureMetadata",
1350
+ StorageScope.Secure,
1351
+ () => {
1352
+ flushSecureWrites();
1353
+ const biometricProtected = WebStorage.hasSecureBiometric(key);
1354
+ const exists =
1355
+ biometricProtected || WebStorage.has(key, StorageScope.Secure);
1356
+ let kind: SecureStorageMetadata["kind"] = "missing";
1357
+ if (exists) {
1358
+ kind = biometricProtected ? "biometric" : "secure";
1359
+ }
1360
+
1361
+ return {
1362
+ key,
1363
+ exists,
1364
+ kind,
1365
+ backend: getBackendName(StorageScope.Secure, webSecureStorageBackend),
1366
+ encrypted: getWebSecureEncryptionStatus(webSecureStorageBackend),
1367
+ hardwareBacked: "unavailable",
1368
+ biometricProtected,
1369
+ valueExposed: false,
1370
+ };
1371
+ },
1372
+ );
1373
+ },
1374
+ getAllSecureMetadata: (): SecureStorageMetadata[] => {
1375
+ return measureOperation(
1376
+ "storage:getAllSecureMetadata",
1377
+ StorageScope.Secure,
1378
+ () => {
1379
+ flushSecureWrites();
1380
+ return WebStorage.getAllKeys(StorageScope.Secure).map((key) =>
1381
+ storage.getSecureMetadata(key),
1382
+ );
1383
+ },
1384
+ );
1385
+ },
1051
1386
  getString: (key: string, scope: StorageScope): string | undefined => {
1052
1387
  return measureOperation("storage:getString", scope, () => {
1053
1388
  return getRawValue(key, scope);
@@ -1085,6 +1420,9 @@ export const storage = {
1085
1420
  flushSecureWrites();
1086
1421
  WebStorage.setSecureAccessControl(secureDefaultAccessControl);
1087
1422
  }
1423
+ if (scope === StorageScope.Disk) {
1424
+ flushDiskWrites();
1425
+ }
1088
1426
 
1089
1427
  WebStorage.setBatch(keys, values, scope);
1090
1428
  keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
@@ -1097,11 +1435,12 @@ export const storage = {
1097
1435
  export function setWebSecureStorageBackend(
1098
1436
  backend?: WebSecureStorageBackend,
1099
1437
  ): void {
1100
- webSecureStorageBackend = backend ?? createLocalStorageWebSecureBackend();
1101
- cachedSecureBrowserStorage = undefined;
1102
- cachedSecureBackendRef = undefined;
1438
+ pendingSecureWrites.clear();
1439
+ webSecureStorageBackend = backend ?? createDefaultSecureBackend();
1440
+ resetBackendChangeSubscription(StorageScope.Secure);
1103
1441
  hydratedWebScopeKeyIndex.delete(StorageScope.Secure);
1104
1442
  clearScopeRawCache(StorageScope.Secure);
1443
+ ensureExternalSyncSubscriptions();
1105
1444
  }
1106
1445
 
1107
1446
  export function getWebSecureStorageBackend():
@@ -1110,6 +1449,39 @@ export function getWebSecureStorageBackend():
1110
1449
  return webSecureStorageBackend;
1111
1450
  }
1112
1451
 
1452
+ export function setWebDiskStorageBackend(
1453
+ backend?: WebDiskStorageBackend,
1454
+ ): void {
1455
+ pendingDiskWrites.clear();
1456
+ webDiskStorageBackend = backend ?? createDefaultDiskBackend();
1457
+ resetBackendChangeSubscription(StorageScope.Disk);
1458
+ hydratedWebScopeKeyIndex.delete(StorageScope.Disk);
1459
+ clearScopeRawCache(StorageScope.Disk);
1460
+ ensureExternalSyncSubscriptions();
1461
+ }
1462
+
1463
+ export function getWebDiskStorageBackend(): WebDiskStorageBackend | undefined {
1464
+ return webDiskStorageBackend;
1465
+ }
1466
+
1467
+ export async function flushWebStorageBackends(): Promise<void> {
1468
+ flushDiskWrites();
1469
+ flushSecureWrites();
1470
+
1471
+ const flushes: Promise<void>[] = [];
1472
+ const diskFlush = webDiskStorageBackend?.flush;
1473
+ const secureFlush = webSecureStorageBackend?.flush;
1474
+
1475
+ if (diskFlush) {
1476
+ flushes.push(diskFlush());
1477
+ }
1478
+ if (secureFlush) {
1479
+ flushes.push(secureFlush());
1480
+ }
1481
+
1482
+ await Promise.all(flushes);
1483
+ }
1484
+
1113
1485
  export interface StorageItemConfig<T> {
1114
1486
  key: string;
1115
1487
  scope: StorageScope;
@@ -1121,6 +1493,7 @@ export interface StorageItemConfig<T> {
1121
1493
  expiration?: ExpirationConfig;
1122
1494
  onExpired?: (key: string) => void;
1123
1495
  readCache?: boolean;
1496
+ coalesceDiskWrites?: boolean;
1124
1497
  coalesceSecureWrites?: boolean;
1125
1498
  namespace?: string;
1126
1499
  biometric?: boolean;
@@ -1205,6 +1578,8 @@ export function createStorageItem<T = undefined>(
1205
1578
  const memoryExpiration =
1206
1579
  expiration && isMemory ? new Map<string, number>() : null;
1207
1580
  const readCache = !isMemory && config.readCache === true;
1581
+ const coalesceDiskWrites =
1582
+ config.scope === StorageScope.Disk && config.coalesceDiskWrites === true;
1208
1583
  const coalesceSecureWrites =
1209
1584
  config.scope === StorageScope.Secure &&
1210
1585
  config.coalesceSecureWrites === true &&
@@ -1250,7 +1625,7 @@ export function createStorageItem<T = undefined>(
1250
1625
  return;
1251
1626
  }
1252
1627
 
1253
- ensureWebStorageEventSubscription();
1628
+ ensureExternalSyncSubscriptions();
1254
1629
  unsubscribe = addKeyListener(
1255
1630
  getScopedListeners(nonMemoryScope!),
1256
1631
  storageKey,
@@ -1273,6 +1648,13 @@ export function createStorageItem<T = undefined>(
1273
1648
  return memoryStore.get(storageKey);
1274
1649
  }
1275
1650
 
1651
+ if (nonMemoryScope === StorageScope.Disk) {
1652
+ const pending = pendingDiskWrites.get(storageKey);
1653
+ if (pending !== undefined) {
1654
+ return pending.value;
1655
+ }
1656
+ }
1657
+
1276
1658
  if (nonMemoryScope === StorageScope.Secure && !isBiometric) {
1277
1659
  const pending = pendingSecureWrites.get(storageKey);
1278
1660
  if (pending !== undefined) {
@@ -1309,6 +1691,15 @@ export function createStorageItem<T = undefined>(
1309
1691
 
1310
1692
  cacheRawValue(nonMemoryScope!, storageKey, rawValue);
1311
1693
 
1694
+ if (nonMemoryScope === StorageScope.Disk) {
1695
+ if (coalesceDiskWrites || diskWritesAsync) {
1696
+ scheduleDiskWrite(storageKey, rawValue);
1697
+ return;
1698
+ }
1699
+
1700
+ clearPendingDiskWrite(storageKey);
1701
+ }
1702
+
1312
1703
  if (coalesceSecureWrites) {
1313
1704
  scheduleSecureWrite(
1314
1705
  storageKey,
@@ -1333,6 +1724,15 @@ export function createStorageItem<T = undefined>(
1333
1724
 
1334
1725
  cacheRawValue(nonMemoryScope!, storageKey, undefined);
1335
1726
 
1727
+ if (nonMemoryScope === StorageScope.Disk) {
1728
+ if (coalesceDiskWrites || diskWritesAsync) {
1729
+ scheduleDiskWrite(storageKey, undefined);
1730
+ return;
1731
+ }
1732
+
1733
+ clearPendingDiskWrite(storageKey);
1734
+ }
1735
+
1336
1736
  if (coalesceSecureWrites) {
1337
1737
  scheduleSecureWrite(
1338
1738
  storageKey,
@@ -1543,6 +1943,18 @@ export function createStorageItem<T = undefined>(
1543
1943
  measureOperation("item:has", config.scope, () => {
1544
1944
  if (isMemory) return memoryStore.has(storageKey);
1545
1945
  if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
1946
+ if (nonMemoryScope === StorageScope.Disk) {
1947
+ const pending = pendingDiskWrites.get(storageKey);
1948
+ if (pending !== undefined) {
1949
+ return pending.value !== undefined;
1950
+ }
1951
+ }
1952
+ if (nonMemoryScope === StorageScope.Secure) {
1953
+ const pending = pendingSecureWrites.get(storageKey);
1954
+ if (pending !== undefined) {
1955
+ return pending.value !== undefined;
1956
+ }
1957
+ }
1546
1958
  return WebStorage.has(storageKey, config.scope);
1547
1959
  });
1548
1960
 
@@ -1554,9 +1966,6 @@ export function createStorageItem<T = undefined>(
1554
1966
  if (listeners.size === 0 && unsubscribe) {
1555
1967
  unsubscribe();
1556
1968
  unsubscribe = null;
1557
- if (!isMemory) {
1558
- maybeCleanupWebStorageSubscription();
1559
- }
1560
1969
  }
1561
1970
  };
1562
1971
  };
@@ -1642,6 +2051,14 @@ export function getBatch(
1642
2051
  const keyIndexes: number[] = [];
1643
2052
 
1644
2053
  items.forEach((item, index) => {
2054
+ if (scope === StorageScope.Disk) {
2055
+ const pending = pendingDiskWrites.get(item.key);
2056
+ if (pending !== undefined) {
2057
+ rawValues[index] = pending.value;
2058
+ return;
2059
+ }
2060
+ }
2061
+
1645
2062
  if (scope === StorageScope.Secure) {
1646
2063
  const pending = pendingSecureWrites.get(item.key);
1647
2064
  if (pending !== undefined) {
@@ -1766,6 +2183,8 @@ export function setBatch<T>(
1766
2183
  return;
1767
2184
  }
1768
2185
 
2186
+ flushDiskWrites();
2187
+
1769
2188
  const useRawBatchPath = items.every(({ item }) =>
1770
2189
  canUseRawBatchPath(asInternal(item)),
1771
2190
  );
@@ -1799,6 +2218,9 @@ export function removeBatch(
1799
2218
  }
1800
2219
 
1801
2220
  const keys = items.map((item) => item.key);
2221
+ if (scope === StorageScope.Disk) {
2222
+ flushDiskWrites();
2223
+ }
1802
2224
  if (scope === StorageScope.Secure) {
1803
2225
  flushSecureWrites();
1804
2226
  }
@@ -1862,6 +2284,9 @@ export function runTransaction<T>(
1862
2284
  ): T {
1863
2285
  return measureOperation("transaction:run", scope, () => {
1864
2286
  assertValidScope(scope);
2287
+ if (scope === StorageScope.Disk) {
2288
+ flushDiskWrites();
2289
+ }
1865
2290
  if (scope === StorageScope.Secure) {
1866
2291
  flushSecureWrites();
1867
2292
  }
@@ -1937,6 +2362,9 @@ export function runTransaction<T>(
1937
2362
  }
1938
2363
  });
1939
2364
 
2365
+ if (scope === StorageScope.Disk) {
2366
+ flushDiskWrites();
2367
+ }
1940
2368
  if (scope === StorageScope.Secure) {
1941
2369
  flushSecureWrites();
1942
2370
  }
@@ -2000,6 +2428,6 @@ export function createSecureAuthStorage<K extends string>(
2000
2428
  return result as Record<K, StorageItem<string>>;
2001
2429
  }
2002
2430
 
2003
- export function isKeychainLockedError(_err: unknown): boolean {
2004
- return false;
2431
+ export function isKeychainLockedError(err: unknown): boolean {
2432
+ return isLockedStorageErrorCode(getStorageErrorCode(err));
2005
2433
  }