react-native-nitro-storage 0.3.1 → 0.4.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 (44) hide show
  1. package/README.md +334 -34
  2. package/android/CMakeLists.txt +2 -0
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +26 -2
  4. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +4 -0
  5. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +90 -18
  6. package/cpp/bindings/HybridStorage.cpp +214 -23
  7. package/cpp/bindings/HybridStorage.hpp +31 -3
  8. package/cpp/core/NativeStorageAdapter.hpp +4 -0
  9. package/ios/IOSStorageAdapterCpp.hpp +17 -0
  10. package/ios/IOSStorageAdapterCpp.mm +140 -10
  11. package/lib/commonjs/index.js +555 -288
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/index.web.js +750 -309
  14. package/lib/commonjs/index.web.js.map +1 -1
  15. package/lib/commonjs/internal.js +25 -0
  16. package/lib/commonjs/internal.js.map +1 -1
  17. package/lib/commonjs/storage-hooks.js +36 -0
  18. package/lib/commonjs/storage-hooks.js.map +1 -0
  19. package/lib/module/index.js +537 -287
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/index.web.js +732 -308
  22. package/lib/module/index.web.js.map +1 -1
  23. package/lib/module/internal.js +24 -0
  24. package/lib/module/internal.js.map +1 -1
  25. package/lib/module/storage-hooks.js +30 -0
  26. package/lib/module/storage-hooks.js.map +1 -0
  27. package/lib/typescript/Storage.nitro.d.ts +4 -0
  28. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  29. package/lib/typescript/index.d.ts +41 -4
  30. package/lib/typescript/index.d.ts.map +1 -1
  31. package/lib/typescript/index.web.d.ts +45 -4
  32. package/lib/typescript/index.web.d.ts.map +1 -1
  33. package/lib/typescript/internal.d.ts +1 -0
  34. package/lib/typescript/internal.d.ts.map +1 -1
  35. package/lib/typescript/storage-hooks.d.ts +10 -0
  36. package/lib/typescript/storage-hooks.d.ts.map +1 -0
  37. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +4 -0
  39. package/package.json +5 -3
  40. package/src/Storage.nitro.ts +4 -0
  41. package/src/index.ts +704 -324
  42. package/src/index.web.ts +929 -346
  43. package/src/internal.ts +28 -0
  44. package/src/storage-hooks.ts +48 -0
@@ -1,13 +1,18 @@
1
1
  "use strict";
2
2
 
3
- import { useRef, useSyncExternalStore } from "react";
4
- import { StorageScope } from "./Storage.types";
5
- import { MIGRATION_VERSION_KEY, isStoredEnvelope, assertBatchScope, assertValidScope, serializeWithPrimitiveFastPath, deserializeWithPrimitiveFastPath, prefixKey, isNamespaced } from "./internal";
3
+ import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
4
+ import { MIGRATION_VERSION_KEY, isStoredEnvelope, assertBatchScope, assertValidScope, serializeWithPrimitiveFastPath, deserializeWithPrimitiveFastPath, toVersionToken, prefixKey, isNamespaced } from "./internal";
6
5
  export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
7
6
  export { migrateFromMMKV } from "./migration";
8
7
  function asInternal(item) {
9
8
  return item;
10
9
  }
10
+ function isUpdater(valueOrFn) {
11
+ return typeof valueOrFn === "function";
12
+ }
13
+ function typedKeys(record) {
14
+ return Object.keys(record);
15
+ }
11
16
  const registeredMigrations = new Map();
12
17
  const runMicrotask = typeof queueMicrotask === "function" ? queueMicrotask : task => {
13
18
  Promise.resolve().then(task);
@@ -16,17 +21,83 @@ const memoryStore = new Map();
16
21
  const memoryListeners = new Map();
17
22
  const webScopeListeners = new Map([[StorageScope.Disk, new Map()], [StorageScope.Secure, new Map()]]);
18
23
  const scopedRawCache = new Map([[StorageScope.Disk, new Map()], [StorageScope.Secure, new Map()]]);
24
+ const webScopeKeyIndex = new Map([[StorageScope.Disk, new Set()], [StorageScope.Secure, new Set()]]);
25
+ const hydratedWebScopeKeyIndex = new Set();
19
26
  const pendingSecureWrites = new Map();
20
27
  let secureFlushScheduled = false;
21
28
  const SECURE_WEB_PREFIX = "__secure_";
22
29
  const BIOMETRIC_WEB_PREFIX = "__bio_";
23
30
  let hasWarnedAboutWebBiometricFallback = false;
31
+ let hasWebStorageEventSubscription = false;
32
+ let metricsObserver;
33
+ const metricsCounters = new Map();
34
+ function recordMetric(operation, scope, durationMs, keysCount = 1) {
35
+ const existing = metricsCounters.get(operation);
36
+ if (!existing) {
37
+ metricsCounters.set(operation, {
38
+ count: 1,
39
+ totalDurationMs: durationMs,
40
+ maxDurationMs: durationMs
41
+ });
42
+ } else {
43
+ existing.count += 1;
44
+ existing.totalDurationMs += durationMs;
45
+ existing.maxDurationMs = Math.max(existing.maxDurationMs, durationMs);
46
+ }
47
+ metricsObserver?.({
48
+ operation,
49
+ scope,
50
+ durationMs,
51
+ keysCount
52
+ });
53
+ }
54
+ function measureOperation(operation, scope, fn, keysCount = 1) {
55
+ const start = Date.now();
56
+ try {
57
+ return fn();
58
+ } finally {
59
+ recordMetric(operation, scope, Date.now() - start, keysCount);
60
+ }
61
+ }
62
+ function createLocalStorageWebSecureBackend() {
63
+ return {
64
+ getItem: key => globalThis.localStorage?.getItem(key) ?? null,
65
+ setItem: (key, value) => globalThis.localStorage?.setItem(key, value),
66
+ removeItem: key => globalThis.localStorage?.removeItem(key),
67
+ clear: () => globalThis.localStorage?.clear(),
68
+ getAllKeys: () => {
69
+ const storage = globalThis.localStorage;
70
+ if (!storage) return [];
71
+ const keys = [];
72
+ for (let index = 0; index < storage.length; index += 1) {
73
+ const key = storage.key(index);
74
+ if (key) {
75
+ keys.push(key);
76
+ }
77
+ }
78
+ return keys;
79
+ }
80
+ };
81
+ }
82
+ let webSecureStorageBackend = createLocalStorageWebSecureBackend();
24
83
  function getBrowserStorage(scope) {
25
84
  if (scope === StorageScope.Disk) {
26
85
  return globalThis.localStorage;
27
86
  }
28
87
  if (scope === StorageScope.Secure) {
29
- return globalThis.localStorage;
88
+ if (!webSecureStorageBackend) {
89
+ return undefined;
90
+ }
91
+ return {
92
+ setItem: (key, value) => webSecureStorageBackend?.setItem(key, value),
93
+ getItem: key => webSecureStorageBackend?.getItem(key) ?? null,
94
+ removeItem: key => webSecureStorageBackend?.removeItem(key),
95
+ clear: () => webSecureStorageBackend?.clear(),
96
+ key: index => webSecureStorageBackend?.getAllKeys()[index] ?? null,
97
+ get length() {
98
+ return webSecureStorageBackend?.getAllKeys().length ?? 0;
99
+ }
100
+ };
30
101
  }
31
102
  return undefined;
32
103
  }
@@ -42,6 +113,99 @@ function toBiometricStorageKey(key) {
42
113
  function fromBiometricStorageKey(key) {
43
114
  return key.slice(BIOMETRIC_WEB_PREFIX.length);
44
115
  }
116
+ function getWebScopeKeyIndex(scope) {
117
+ return webScopeKeyIndex.get(scope);
118
+ }
119
+ function hydrateWebScopeKeyIndex(scope) {
120
+ if (hydratedWebScopeKeyIndex.has(scope)) {
121
+ return;
122
+ }
123
+ const storage = getBrowserStorage(scope);
124
+ const keyIndex = getWebScopeKeyIndex(scope);
125
+ keyIndex.clear();
126
+ if (storage) {
127
+ for (let index = 0; index < storage.length; index += 1) {
128
+ const key = storage.key(index);
129
+ if (!key) {
130
+ continue;
131
+ }
132
+ if (scope === StorageScope.Disk) {
133
+ if (!key.startsWith(SECURE_WEB_PREFIX) && !key.startsWith(BIOMETRIC_WEB_PREFIX)) {
134
+ keyIndex.add(key);
135
+ }
136
+ continue;
137
+ }
138
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
139
+ keyIndex.add(fromSecureStorageKey(key));
140
+ continue;
141
+ }
142
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
143
+ keyIndex.add(fromBiometricStorageKey(key));
144
+ }
145
+ }
146
+ }
147
+ hydratedWebScopeKeyIndex.add(scope);
148
+ }
149
+ function ensureWebScopeKeyIndex(scope) {
150
+ hydrateWebScopeKeyIndex(scope);
151
+ return getWebScopeKeyIndex(scope);
152
+ }
153
+ function handleWebStorageEvent(event) {
154
+ const key = event.key;
155
+ if (key === null) {
156
+ clearScopeRawCache(StorageScope.Disk);
157
+ clearScopeRawCache(StorageScope.Secure);
158
+ ensureWebScopeKeyIndex(StorageScope.Disk).clear();
159
+ ensureWebScopeKeyIndex(StorageScope.Secure).clear();
160
+ notifyAllListeners(getScopedListeners(StorageScope.Disk));
161
+ notifyAllListeners(getScopedListeners(StorageScope.Secure));
162
+ return;
163
+ }
164
+ if (key.startsWith(SECURE_WEB_PREFIX)) {
165
+ const plainKey = fromSecureStorageKey(key);
166
+ if (event.newValue === null) {
167
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
168
+ cacheRawValue(StorageScope.Secure, plainKey, undefined);
169
+ } else {
170
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
171
+ cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
172
+ }
173
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
174
+ return;
175
+ }
176
+ if (key.startsWith(BIOMETRIC_WEB_PREFIX)) {
177
+ const plainKey = fromBiometricStorageKey(key);
178
+ if (event.newValue === null) {
179
+ if (getBrowserStorage(StorageScope.Secure)?.getItem(toSecureStorageKey(plainKey)) === null) {
180
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(plainKey);
181
+ }
182
+ cacheRawValue(StorageScope.Secure, plainKey, undefined);
183
+ } else {
184
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(plainKey);
185
+ cacheRawValue(StorageScope.Secure, plainKey, event.newValue);
186
+ }
187
+ notifyKeyListeners(getScopedListeners(StorageScope.Secure), plainKey);
188
+ return;
189
+ }
190
+ if (event.newValue === null) {
191
+ ensureWebScopeKeyIndex(StorageScope.Disk).delete(key);
192
+ cacheRawValue(StorageScope.Disk, key, undefined);
193
+ } else {
194
+ ensureWebScopeKeyIndex(StorageScope.Disk).add(key);
195
+ cacheRawValue(StorageScope.Disk, key, event.newValue);
196
+ }
197
+ notifyKeyListeners(getScopedListeners(StorageScope.Disk), key);
198
+ }
199
+ function ensureWebStorageEventSubscription() {
200
+ if (hasWebStorageEventSubscription) {
201
+ return;
202
+ }
203
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
204
+ return;
205
+ }
206
+ window.addEventListener("storage", handleWebStorageEvent);
207
+ hasWebStorageEventSubscription = true;
208
+ }
45
209
  function getScopedListeners(scope) {
46
210
  return webScopeListeners.get(scope);
47
211
  }
@@ -102,32 +266,46 @@ function flushSecureWrites() {
102
266
  }
103
267
  const writes = Array.from(pendingSecureWrites.values());
104
268
  pendingSecureWrites.clear();
105
- const keysToSet = [];
106
- const valuesToSet = [];
269
+ const groupedSetWrites = new Map();
107
270
  const keysToRemove = [];
108
271
  writes.forEach(({
109
272
  key,
110
- value
273
+ value,
274
+ accessControl
111
275
  }) => {
112
276
  if (value === undefined) {
113
277
  keysToRemove.push(key);
114
278
  } else {
115
- keysToSet.push(key);
116
- valuesToSet.push(value);
279
+ const resolvedAccessControl = accessControl ?? AccessControl.WhenUnlocked;
280
+ const existingGroup = groupedSetWrites.get(resolvedAccessControl);
281
+ const group = existingGroup ?? {
282
+ keys: [],
283
+ values: []
284
+ };
285
+ group.keys.push(key);
286
+ group.values.push(value);
287
+ if (!existingGroup) {
288
+ groupedSetWrites.set(resolvedAccessControl, group);
289
+ }
117
290
  }
118
291
  });
119
- if (keysToSet.length > 0) {
120
- WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
121
- }
292
+ groupedSetWrites.forEach((group, accessControl) => {
293
+ WebStorage.setSecureAccessControl(accessControl);
294
+ WebStorage.setBatch(group.keys, group.values, StorageScope.Secure);
295
+ });
122
296
  if (keysToRemove.length > 0) {
123
297
  WebStorage.removeBatch(keysToRemove, StorageScope.Secure);
124
298
  }
125
299
  }
126
- function scheduleSecureWrite(key, value) {
127
- pendingSecureWrites.set(key, {
300
+ function scheduleSecureWrite(key, value, accessControl) {
301
+ const pendingWrite = {
128
302
  key,
129
303
  value
130
- });
304
+ };
305
+ if (accessControl !== undefined) {
306
+ pendingWrite.accessControl = accessControl;
307
+ }
308
+ pendingSecureWrites.set(key, pendingWrite);
131
309
  if (secureFlushScheduled) {
132
310
  return;
133
311
  }
@@ -146,6 +324,7 @@ const WebStorage = {
146
324
  const storageKey = scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
147
325
  storage.setItem(storageKey, value);
148
326
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
327
+ ensureWebScopeKeyIndex(scope).add(key);
149
328
  notifyKeyListeners(getScopedListeners(scope), key);
150
329
  }
151
330
  },
@@ -166,6 +345,7 @@ const WebStorage = {
166
345
  storage.removeItem(key);
167
346
  }
168
347
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
348
+ ensureWebScopeKeyIndex(scope).delete(key);
169
349
  notifyKeyListeners(getScopedListeners(scope), key);
170
350
  }
171
351
  },
@@ -196,6 +376,7 @@ const WebStorage = {
196
376
  storage.clear();
197
377
  }
198
378
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
379
+ ensureWebScopeKeyIndex(scope).clear();
199
380
  notifyAllListeners(getScopedListeners(scope));
200
381
  }
201
382
  },
@@ -205,10 +386,16 @@ const WebStorage = {
205
386
  return;
206
387
  }
207
388
  keys.forEach((key, index) => {
389
+ const value = values[index];
390
+ if (value === undefined) {
391
+ return;
392
+ }
208
393
  const storageKey = scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
209
- storage.setItem(storageKey, values[index]);
394
+ storage.setItem(storageKey, value);
210
395
  });
211
396
  if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
397
+ const keyIndex = ensureWebScopeKeyIndex(scope);
398
+ keys.forEach(key => keyIndex.add(key));
212
399
  const listeners = getScopedListeners(scope);
213
400
  keys.forEach(key => notifyKeyListeners(listeners, key));
214
401
  }
@@ -221,9 +408,37 @@ const WebStorage = {
221
408
  });
222
409
  },
223
410
  removeBatch: (keys, scope) => {
224
- keys.forEach(key => {
225
- WebStorage.remove(key, scope);
226
- });
411
+ const storage = getBrowserStorage(scope);
412
+ if (!storage) {
413
+ return;
414
+ }
415
+ if (scope === StorageScope.Secure) {
416
+ keys.forEach(key => {
417
+ storage.removeItem(toSecureStorageKey(key));
418
+ storage.removeItem(toBiometricStorageKey(key));
419
+ });
420
+ } else {
421
+ keys.forEach(key => {
422
+ storage.removeItem(key);
423
+ });
424
+ }
425
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
426
+ const keyIndex = ensureWebScopeKeyIndex(scope);
427
+ keys.forEach(key => keyIndex.delete(key));
428
+ const listeners = getScopedListeners(scope);
429
+ keys.forEach(key => notifyKeyListeners(listeners, key));
430
+ }
431
+ },
432
+ removeByPrefix: (prefix, scope) => {
433
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
434
+ return;
435
+ }
436
+ const keyIndex = ensureWebScopeKeyIndex(scope);
437
+ const keys = Array.from(keyIndex).filter(key => key.startsWith(prefix));
438
+ if (keys.length === 0) {
439
+ return;
440
+ }
441
+ WebStorage.removeBatch(keys, scope);
227
442
  },
228
443
  addOnChange: (_scope, _callback) => {
229
444
  return () => {};
@@ -236,54 +451,54 @@ const WebStorage = {
236
451
  return storage?.getItem(key) !== null;
237
452
  },
238
453
  getAllKeys: scope => {
239
- const storage = getBrowserStorage(scope);
240
- if (!storage) return [];
241
- const keys = new Set();
242
- for (let i = 0; i < storage.length; i++) {
243
- const k = storage.key(i);
244
- if (!k) {
245
- continue;
246
- }
247
- if (scope === StorageScope.Secure) {
248
- if (k.startsWith(SECURE_WEB_PREFIX)) {
249
- keys.add(fromSecureStorageKey(k));
250
- } else if (k.startsWith(BIOMETRIC_WEB_PREFIX)) {
251
- keys.add(fromBiometricStorageKey(k));
252
- }
253
- continue;
254
- }
255
- if (k.startsWith(SECURE_WEB_PREFIX) || k.startsWith(BIOMETRIC_WEB_PREFIX)) {
256
- continue;
257
- }
258
- keys.add(k);
454
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
455
+ return [];
259
456
  }
260
- return Array.from(keys);
457
+ return Array.from(ensureWebScopeKeyIndex(scope));
458
+ },
459
+ getKeysByPrefix: (prefix, scope) => {
460
+ if (scope !== StorageScope.Disk && scope !== StorageScope.Secure) {
461
+ return [];
462
+ }
463
+ return Array.from(ensureWebScopeKeyIndex(scope)).filter(key => key.startsWith(prefix));
261
464
  },
262
465
  size: scope => {
263
- return WebStorage.getAllKeys(scope).length;
466
+ if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
467
+ return ensureWebScopeKeyIndex(scope).size;
468
+ }
469
+ return 0;
264
470
  },
265
471
  setSecureAccessControl: () => {},
472
+ setSecureWritesAsync: _enabled => {},
266
473
  setKeychainAccessGroup: () => {},
267
474
  setSecureBiometric: (key, value) => {
475
+ WebStorage.setSecureBiometricWithLevel(key, value, BiometricLevel.BiometryOnly);
476
+ },
477
+ setSecureBiometricWithLevel: (key, value, _level) => {
268
478
  if (typeof __DEV__ !== "undefined" && __DEV__ && !hasWarnedAboutWebBiometricFallback) {
269
479
  hasWarnedAboutWebBiometricFallback = true;
270
480
  console.warn("[NitroStorage] Biometric storage is not supported on web. Using localStorage.");
271
481
  }
272
- globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
482
+ getBrowserStorage(StorageScope.Secure)?.setItem(toBiometricStorageKey(key), value);
483
+ ensureWebScopeKeyIndex(StorageScope.Secure).add(key);
273
484
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
274
485
  },
275
486
  getSecureBiometric: key => {
276
- return globalThis.localStorage?.getItem(toBiometricStorageKey(key)) ?? undefined;
487
+ return getBrowserStorage(StorageScope.Secure)?.getItem(toBiometricStorageKey(key)) ?? undefined;
277
488
  },
278
489
  deleteSecureBiometric: key => {
279
- globalThis.localStorage?.removeItem(toBiometricStorageKey(key));
490
+ const storage = getBrowserStorage(StorageScope.Secure);
491
+ storage?.removeItem(toBiometricStorageKey(key));
492
+ if (storage?.getItem(toSecureStorageKey(key)) === null) {
493
+ ensureWebScopeKeyIndex(StorageScope.Secure).delete(key);
494
+ }
280
495
  notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
281
496
  },
282
497
  hasSecureBiometric: key => {
283
- return globalThis.localStorage?.getItem(toBiometricStorageKey(key)) !== null;
498
+ return getBrowserStorage(StorageScope.Secure)?.getItem(toBiometricStorageKey(key)) !== null;
284
499
  },
285
500
  clearSecureBiometric: () => {
286
- const storage = globalThis.localStorage;
501
+ const storage = getBrowserStorage(StorageScope.Secure);
287
502
  if (!storage) return;
288
503
  const keysToNotify = [];
289
504
  const toRemove = [];
@@ -295,6 +510,12 @@ const WebStorage = {
295
510
  }
296
511
  }
297
512
  toRemove.forEach(k => storage.removeItem(k));
513
+ const keyIndex = ensureWebScopeKeyIndex(StorageScope.Secure);
514
+ keysToNotify.forEach(key => {
515
+ if (storage.getItem(toSecureStorageKey(key)) === null) {
516
+ keyIndex.delete(key);
517
+ }
518
+ });
298
519
  const listeners = getScopedListeners(StorageScope.Secure);
299
520
  keysToNotify.forEach(key => notifyKeyListeners(listeners, key));
300
521
  }
@@ -351,90 +572,173 @@ function writeMigrationVersion(scope, version) {
351
572
  }
352
573
  export const storage = {
353
574
  clear: scope => {
354
- if (scope === StorageScope.Memory) {
355
- memoryStore.clear();
356
- notifyAllListeners(memoryListeners);
357
- return;
358
- }
359
- if (scope === StorageScope.Secure) {
360
- flushSecureWrites();
361
- pendingSecureWrites.clear();
362
- }
363
- clearScopeRawCache(scope);
364
- WebStorage.clear(scope);
365
- if (scope === StorageScope.Secure) {
366
- WebStorage.clearSecureBiometric();
367
- }
575
+ measureOperation("storage:clear", scope, () => {
576
+ if (scope === StorageScope.Memory) {
577
+ memoryStore.clear();
578
+ notifyAllListeners(memoryListeners);
579
+ return;
580
+ }
581
+ if (scope === StorageScope.Secure) {
582
+ flushSecureWrites();
583
+ pendingSecureWrites.clear();
584
+ }
585
+ clearScopeRawCache(scope);
586
+ WebStorage.clear(scope);
587
+ });
368
588
  },
369
589
  clearAll: () => {
370
- storage.clear(StorageScope.Memory);
371
- storage.clear(StorageScope.Disk);
372
- storage.clear(StorageScope.Secure);
590
+ measureOperation("storage:clearAll", StorageScope.Memory, () => {
591
+ storage.clear(StorageScope.Memory);
592
+ storage.clear(StorageScope.Disk);
593
+ storage.clear(StorageScope.Secure);
594
+ }, 3);
373
595
  },
374
596
  clearNamespace: (namespace, scope) => {
375
- assertValidScope(scope);
376
- if (scope === StorageScope.Memory) {
377
- for (const key of memoryStore.keys()) {
378
- if (isNamespaced(key, namespace)) {
379
- memoryStore.delete(key);
597
+ measureOperation("storage:clearNamespace", scope, () => {
598
+ assertValidScope(scope);
599
+ if (scope === StorageScope.Memory) {
600
+ for (const key of memoryStore.keys()) {
601
+ if (isNamespaced(key, namespace)) {
602
+ memoryStore.delete(key);
603
+ }
380
604
  }
605
+ notifyAllListeners(memoryListeners);
606
+ return;
381
607
  }
382
- notifyAllListeners(memoryListeners);
383
- return;
384
- }
385
- if (scope === StorageScope.Secure) {
386
- flushSecureWrites();
387
- }
388
- const keys = WebStorage.getAllKeys(scope);
389
- const namespacedKeys = keys.filter(k => isNamespaced(k, namespace));
390
- if (namespacedKeys.length > 0) {
391
- WebStorage.removeBatch(namespacedKeys, scope);
392
- namespacedKeys.forEach(k => cacheRawValue(scope, k, undefined));
608
+ const keyPrefix = prefixKey(namespace, "");
393
609
  if (scope === StorageScope.Secure) {
394
- namespacedKeys.forEach(k => clearPendingSecureWrite(k));
610
+ flushSecureWrites();
395
611
  }
396
- }
612
+ clearScopeRawCache(scope);
613
+ WebStorage.removeByPrefix(keyPrefix, scope);
614
+ });
397
615
  },
398
616
  clearBiometric: () => {
399
- WebStorage.clearSecureBiometric();
617
+ measureOperation("storage:clearBiometric", StorageScope.Secure, () => {
618
+ WebStorage.clearSecureBiometric();
619
+ });
400
620
  },
401
621
  has: (key, scope) => {
402
- assertValidScope(scope);
403
- if (scope === StorageScope.Memory) return memoryStore.has(key);
404
- return WebStorage.has(key, scope);
622
+ return measureOperation("storage:has", scope, () => {
623
+ assertValidScope(scope);
624
+ if (scope === StorageScope.Memory) return memoryStore.has(key);
625
+ return WebStorage.has(key, scope);
626
+ });
405
627
  },
406
628
  getAllKeys: scope => {
407
- assertValidScope(scope);
408
- if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
409
- return WebStorage.getAllKeys(scope);
629
+ return measureOperation("storage:getAllKeys", scope, () => {
630
+ assertValidScope(scope);
631
+ if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
632
+ return WebStorage.getAllKeys(scope);
633
+ });
634
+ },
635
+ getKeysByPrefix: (prefix, scope) => {
636
+ return measureOperation("storage:getKeysByPrefix", scope, () => {
637
+ assertValidScope(scope);
638
+ if (scope === StorageScope.Memory) {
639
+ return Array.from(memoryStore.keys()).filter(key => key.startsWith(prefix));
640
+ }
641
+ return WebStorage.getKeysByPrefix(prefix, scope);
642
+ });
643
+ },
644
+ getByPrefix: (prefix, scope) => {
645
+ return measureOperation("storage:getByPrefix", scope, () => {
646
+ const result = {};
647
+ const keys = storage.getKeysByPrefix(prefix, scope);
648
+ if (keys.length === 0) {
649
+ return result;
650
+ }
651
+ if (scope === StorageScope.Memory) {
652
+ keys.forEach(key => {
653
+ const value = memoryStore.get(key);
654
+ if (typeof value === "string") {
655
+ result[key] = value;
656
+ }
657
+ });
658
+ return result;
659
+ }
660
+ const values = WebStorage.getBatch(keys, scope);
661
+ keys.forEach((key, index) => {
662
+ const value = values[index];
663
+ if (value !== undefined) {
664
+ result[key] = value;
665
+ }
666
+ });
667
+ return result;
668
+ });
410
669
  },
411
670
  getAll: scope => {
412
- assertValidScope(scope);
413
- const result = {};
414
- if (scope === StorageScope.Memory) {
415
- memoryStore.forEach((value, key) => {
416
- if (typeof value === "string") result[key] = value;
671
+ return measureOperation("storage:getAll", scope, () => {
672
+ assertValidScope(scope);
673
+ const result = {};
674
+ if (scope === StorageScope.Memory) {
675
+ memoryStore.forEach((value, key) => {
676
+ if (typeof value === "string") result[key] = value;
677
+ });
678
+ return result;
679
+ }
680
+ const keys = WebStorage.getAllKeys(scope);
681
+ keys.forEach(key => {
682
+ const val = WebStorage.get(key, scope);
683
+ if (val !== undefined) result[key] = val;
417
684
  });
418
685
  return result;
419
- }
420
- const keys = WebStorage.getAllKeys(scope);
421
- keys.forEach(key => {
422
- const val = WebStorage.get(key, scope);
423
- if (val !== undefined) result[key] = val;
424
686
  });
425
- return result;
426
687
  },
427
688
  size: scope => {
428
- assertValidScope(scope);
429
- if (scope === StorageScope.Memory) return memoryStore.size;
430
- return WebStorage.size(scope);
689
+ return measureOperation("storage:size", scope, () => {
690
+ assertValidScope(scope);
691
+ if (scope === StorageScope.Memory) return memoryStore.size;
692
+ return WebStorage.size(scope);
693
+ });
694
+ },
695
+ setAccessControl: _level => {
696
+ recordMetric("storage:setAccessControl", StorageScope.Secure, 0);
697
+ },
698
+ setSecureWritesAsync: _enabled => {
699
+ recordMetric("storage:setSecureWritesAsync", StorageScope.Secure, 0);
700
+ },
701
+ flushSecureWrites: () => {
702
+ measureOperation("storage:flushSecureWrites", StorageScope.Secure, () => {
703
+ flushSecureWrites();
704
+ });
705
+ },
706
+ setKeychainAccessGroup: _group => {
707
+ recordMetric("storage:setKeychainAccessGroup", StorageScope.Secure, 0);
431
708
  },
432
- setAccessControl: _level => {},
433
- setKeychainAccessGroup: _group => {}
709
+ setMetricsObserver: observer => {
710
+ metricsObserver = observer;
711
+ },
712
+ getMetricsSnapshot: () => {
713
+ const snapshot = {};
714
+ metricsCounters.forEach((value, key) => {
715
+ snapshot[key] = {
716
+ count: value.count,
717
+ totalDurationMs: value.totalDurationMs,
718
+ avgDurationMs: value.count === 0 ? 0 : value.totalDurationMs / value.count,
719
+ maxDurationMs: value.maxDurationMs
720
+ };
721
+ });
722
+ return snapshot;
723
+ },
724
+ resetMetrics: () => {
725
+ metricsCounters.clear();
726
+ }
434
727
  };
728
+ export function setWebSecureStorageBackend(backend) {
729
+ webSecureStorageBackend = backend ?? createLocalStorageWebSecureBackend();
730
+ hydratedWebScopeKeyIndex.delete(StorageScope.Secure);
731
+ clearScopeRawCache(StorageScope.Secure);
732
+ }
733
+ export function getWebSecureStorageBackend() {
734
+ return webSecureStorageBackend;
735
+ }
435
736
  function canUseRawBatchPath(item) {
436
737
  return item._hasExpiration === false && item._hasValidation === false && item._isBiometric !== true && item._secureAccessControl === undefined;
437
738
  }
739
+ function canUseSecureRawBatchPath(item) {
740
+ return item._hasExpiration === false && item._hasValidation === false && item._isBiometric !== true;
741
+ }
438
742
  function defaultSerialize(value) {
439
743
  return serializeWithPrimitiveFastPath(value);
440
744
  }
@@ -446,7 +750,8 @@ export function createStorageItem(config) {
446
750
  const serialize = config.serialize ?? defaultSerialize;
447
751
  const deserialize = config.deserialize ?? defaultDeserialize;
448
752
  const isMemory = config.scope === StorageScope.Memory;
449
- const isBiometric = config.biometric === true && config.scope === StorageScope.Secure;
753
+ const resolvedBiometricLevel = config.scope === StorageScope.Secure ? config.biometricLevel ?? (config.biometric === true ? BiometricLevel.BiometryOnly : BiometricLevel.None) : BiometricLevel.None;
754
+ const isBiometric = resolvedBiometricLevel !== BiometricLevel.None;
450
755
  const secureAccessControl = config.accessControl;
451
756
  const validate = config.validate;
452
757
  const onValidationError = config.onValidationError;
@@ -455,7 +760,8 @@ export function createStorageItem(config) {
455
760
  const expirationTtlMs = expiration?.ttlMs;
456
761
  const memoryExpiration = expiration && isMemory ? new Map() : null;
457
762
  const readCache = !isMemory && config.readCache === true;
458
- const coalesceSecureWrites = config.scope === StorageScope.Secure && config.coalesceSecureWrites === true && !isBiometric && secureAccessControl === undefined;
763
+ const coalesceSecureWrites = config.scope === StorageScope.Secure && config.coalesceSecureWrites === true && !isBiometric;
764
+ const defaultValue = config.defaultValue;
459
765
  const nonMemoryScope = config.scope === StorageScope.Disk ? StorageScope.Disk : config.scope === StorageScope.Secure ? StorageScope.Secure : null;
460
766
  if (expiration && expiration.ttlMs <= 0) {
461
767
  throw new Error("expiration.ttlMs must be greater than 0.");
@@ -465,10 +771,12 @@ export function createStorageItem(config) {
465
771
  let lastRaw = undefined;
466
772
  let lastValue;
467
773
  let hasLastValue = false;
774
+ let lastExpiresAt = undefined;
468
775
  const invalidateParsedCache = () => {
469
776
  lastRaw = undefined;
470
777
  lastValue = undefined;
471
778
  hasLastValue = false;
779
+ lastExpiresAt = undefined;
472
780
  };
473
781
  const ensureSubscription = () => {
474
782
  if (unsubscribe) {
@@ -482,6 +790,7 @@ export function createStorageItem(config) {
482
790
  unsubscribe = addKeyListener(memoryListeners, storageKey, listener);
483
791
  return;
484
792
  }
793
+ ensureWebStorageEventSubscription();
485
794
  unsubscribe = addKeyListener(getScopedListeners(nonMemoryScope), storageKey, listener);
486
795
  };
487
796
  const readStoredRaw = () => {
@@ -515,12 +824,12 @@ export function createStorageItem(config) {
515
824
  };
516
825
  const writeStoredRaw = rawValue => {
517
826
  if (isBiometric) {
518
- WebStorage.setSecureBiometric(storageKey, rawValue);
827
+ WebStorage.setSecureBiometricWithLevel(storageKey, rawValue, resolvedBiometricLevel);
519
828
  return;
520
829
  }
521
830
  cacheRawValue(nonMemoryScope, storageKey, rawValue);
522
831
  if (coalesceSecureWrites) {
523
- scheduleSecureWrite(storageKey, rawValue);
832
+ scheduleSecureWrite(storageKey, rawValue, secureAccessControl ?? AccessControl.WhenUnlocked);
524
833
  return;
525
834
  }
526
835
  if (nonMemoryScope === StorageScope.Secure) {
@@ -535,7 +844,7 @@ export function createStorageItem(config) {
535
844
  }
536
845
  cacheRawValue(nonMemoryScope, storageKey, undefined);
537
846
  if (coalesceSecureWrites) {
538
- scheduleSecureWrite(storageKey, undefined);
847
+ scheduleSecureWrite(storageKey, undefined, secureAccessControl ?? AccessControl.WhenUnlocked);
539
848
  return;
540
849
  }
541
850
  if (nonMemoryScope === StorageScope.Secure) {
@@ -568,7 +877,7 @@ export function createStorageItem(config) {
568
877
  if (onValidationError) {
569
878
  return onValidationError(invalidValue);
570
879
  }
571
- return config.defaultValue;
880
+ return defaultValue;
572
881
  };
573
882
  const ensureValidatedValue = (candidate, hadStoredValue) => {
574
883
  if (!validate || validate(candidate)) {
@@ -576,40 +885,62 @@ export function createStorageItem(config) {
576
885
  }
577
886
  const resolved = resolveInvalidValue(candidate);
578
887
  if (validate && !validate(resolved)) {
579
- return config.defaultValue;
888
+ return defaultValue;
580
889
  }
581
890
  if (hadStoredValue) {
582
891
  writeValueWithoutValidation(resolved);
583
892
  }
584
893
  return resolved;
585
894
  };
586
- const get = () => {
895
+ const getInternal = () => {
587
896
  const raw = readStoredRaw();
588
- const canUseCachedValue = !expiration && !memoryExpiration;
589
- if (canUseCachedValue && raw === lastRaw && hasLastValue) {
590
- return lastValue;
897
+ if (!memoryExpiration && raw === lastRaw && hasLastValue) {
898
+ if (!expiration || lastExpiresAt === null) {
899
+ return lastValue;
900
+ }
901
+ if (typeof lastExpiresAt === "number") {
902
+ if (lastExpiresAt > Date.now()) {
903
+ return lastValue;
904
+ }
905
+ removeStoredRaw();
906
+ invalidateParsedCache();
907
+ onExpired?.(storageKey);
908
+ lastValue = ensureValidatedValue(defaultValue, false);
909
+ hasLastValue = true;
910
+ return lastValue;
911
+ }
591
912
  }
592
913
  lastRaw = raw;
593
914
  if (raw === undefined) {
594
- lastValue = ensureValidatedValue(config.defaultValue, false);
915
+ lastExpiresAt = undefined;
916
+ lastValue = ensureValidatedValue(defaultValue, false);
595
917
  hasLastValue = true;
596
918
  return lastValue;
597
919
  }
598
920
  if (isMemory) {
921
+ lastExpiresAt = undefined;
599
922
  lastValue = ensureValidatedValue(raw, true);
600
923
  hasLastValue = true;
601
924
  return lastValue;
602
925
  }
926
+ if (typeof raw !== "string") {
927
+ lastExpiresAt = undefined;
928
+ lastValue = ensureValidatedValue(defaultValue, false);
929
+ hasLastValue = true;
930
+ return lastValue;
931
+ }
603
932
  let deserializableRaw = raw;
604
933
  if (expiration) {
934
+ let envelopeExpiresAt = null;
605
935
  try {
606
936
  const parsed = JSON.parse(raw);
607
937
  if (isStoredEnvelope(parsed)) {
938
+ envelopeExpiresAt = parsed.expiresAt;
608
939
  if (parsed.expiresAt <= Date.now()) {
609
940
  removeStoredRaw();
610
941
  invalidateParsedCache();
611
942
  onExpired?.(storageKey);
612
- lastValue = ensureValidatedValue(config.defaultValue, false);
943
+ lastValue = ensureValidatedValue(defaultValue, false);
613
944
  hasLastValue = true;
614
945
  return lastValue;
615
946
  }
@@ -618,37 +949,60 @@ export function createStorageItem(config) {
618
949
  } catch {
619
950
  // Keep backward compatibility with legacy raw values.
620
951
  }
952
+ lastExpiresAt = envelopeExpiresAt;
953
+ } else {
954
+ lastExpiresAt = undefined;
621
955
  }
622
956
  lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
623
957
  hasLastValue = true;
624
958
  return lastValue;
625
959
  };
960
+ const getCurrentVersion = () => {
961
+ const raw = readStoredRaw();
962
+ return toVersionToken(raw);
963
+ };
964
+ const get = () => measureOperation("item:get", config.scope, () => getInternal());
965
+ const getWithVersion = () => measureOperation("item:getWithVersion", config.scope, () => ({
966
+ value: getInternal(),
967
+ version: getCurrentVersion()
968
+ }));
626
969
  const set = valueOrFn => {
627
- const currentValue = get();
628
- const newValue = typeof valueOrFn === "function" ? valueOrFn(currentValue) : valueOrFn;
629
- invalidateParsedCache();
630
- if (validate && !validate(newValue)) {
631
- throw new Error(`Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`);
632
- }
633
- writeValueWithoutValidation(newValue);
970
+ measureOperation("item:set", config.scope, () => {
971
+ const newValue = isUpdater(valueOrFn) ? valueOrFn(getInternal()) : valueOrFn;
972
+ invalidateParsedCache();
973
+ if (validate && !validate(newValue)) {
974
+ throw new Error(`Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`);
975
+ }
976
+ writeValueWithoutValidation(newValue);
977
+ });
634
978
  };
979
+ const setIfVersion = (version, valueOrFn) => measureOperation("item:setIfVersion", config.scope, () => {
980
+ const currentVersion = getCurrentVersion();
981
+ if (currentVersion !== version) {
982
+ return false;
983
+ }
984
+ set(valueOrFn);
985
+ return true;
986
+ });
635
987
  const deleteItem = () => {
636
- invalidateParsedCache();
637
- if (isMemory) {
638
- if (memoryExpiration) {
639
- memoryExpiration.delete(storageKey);
988
+ measureOperation("item:delete", config.scope, () => {
989
+ invalidateParsedCache();
990
+ if (isMemory) {
991
+ if (memoryExpiration) {
992
+ memoryExpiration.delete(storageKey);
993
+ }
994
+ memoryStore.delete(storageKey);
995
+ notifyKeyListeners(memoryListeners, storageKey);
996
+ return;
640
997
  }
641
- memoryStore.delete(storageKey);
642
- notifyKeyListeners(memoryListeners, storageKey);
643
- return;
644
- }
645
- removeStoredRaw();
998
+ removeStoredRaw();
999
+ });
646
1000
  };
647
- const hasItem = () => {
1001
+ const hasItem = () => measureOperation("item:has", config.scope, () => {
648
1002
  if (isMemory) return memoryStore.has(storageKey);
649
1003
  if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
650
1004
  return WebStorage.has(storageKey, config.scope);
651
- };
1005
+ });
652
1006
  const subscribe = callback => {
653
1007
  ensureSubscription();
654
1008
  listeners.add(callback);
@@ -662,7 +1016,9 @@ export function createStorageItem(config) {
662
1016
  };
663
1017
  const storageItem = {
664
1018
  get,
1019
+ getWithVersion,
665
1020
  set,
1021
+ setIfVersion,
666
1022
  delete: deleteItem,
667
1023
  has: hasItem,
668
1024
  subscribe,
@@ -676,123 +1032,151 @@ export function createStorageItem(config) {
676
1032
  _hasExpiration: expiration !== undefined,
677
1033
  _readCacheEnabled: readCache,
678
1034
  _isBiometric: isBiometric,
679
- _secureAccessControl: secureAccessControl,
1035
+ _defaultValue: defaultValue,
1036
+ ...(secureAccessControl !== undefined ? {
1037
+ _secureAccessControl: secureAccessControl
1038
+ } : {}),
680
1039
  scope: config.scope,
681
1040
  key: storageKey
682
1041
  };
683
1042
  return storageItem;
684
1043
  }
685
- export function useStorage(item) {
686
- const value = useSyncExternalStore(item.subscribe, item.get, item.get);
687
- return [value, item.set];
688
- }
689
- export function useStorageSelector(item, selector, isEqual = Object.is) {
690
- const selectedRef = useRef({
691
- hasValue: false
692
- });
693
- const getSelectedSnapshot = () => {
694
- const nextSelected = selector(item.get());
695
- const current = selectedRef.current;
696
- if (current.hasValue && isEqual(current.value, nextSelected)) {
697
- return current.value;
698
- }
699
- selectedRef.current = {
700
- hasValue: true,
701
- value: nextSelected
702
- };
703
- return nextSelected;
704
- };
705
- const selectedValue = useSyncExternalStore(item.subscribe, getSelectedSnapshot, getSelectedSnapshot);
706
- return [selectedValue, item.set];
707
- }
708
- export function useSetStorage(item) {
709
- return item.set;
710
- }
1044
+ export { useStorage, useStorageSelector, useSetStorage } from "./storage-hooks";
711
1045
  export function getBatch(items, scope) {
712
- assertBatchScope(items, scope);
713
- if (scope === StorageScope.Memory) {
714
- return items.map(item => item.get());
715
- }
716
- const useRawBatchPath = items.every(item => canUseRawBatchPath(item));
717
- if (!useRawBatchPath) {
718
- return items.map(item => item.get());
719
- }
720
- const useBatchCache = items.every(item => item._readCacheEnabled === true);
721
- const rawValues = new Array(items.length);
722
- const keysToFetch = [];
723
- const keyIndexes = [];
724
- items.forEach((item, index) => {
725
- if (scope === StorageScope.Secure) {
726
- if (hasPendingSecureWrite(item.key)) {
727
- rawValues[index] = readPendingSecureWrite(item.key);
728
- return;
729
- }
1046
+ return measureOperation("batch:get", scope, () => {
1047
+ assertBatchScope(items, scope);
1048
+ if (scope === StorageScope.Memory) {
1049
+ return items.map(item => item.get());
730
1050
  }
731
- if (useBatchCache) {
732
- if (hasCachedRawValue(scope, item.key)) {
733
- rawValues[index] = readCachedRawValue(scope, item.key);
734
- return;
735
- }
1051
+ const useRawBatchPath = items.every(item => scope === StorageScope.Secure ? canUseSecureRawBatchPath(item) : canUseRawBatchPath(item));
1052
+ if (!useRawBatchPath) {
1053
+ return items.map(item => item.get());
736
1054
  }
737
- keysToFetch.push(item.key);
738
- keyIndexes.push(index);
739
- });
740
- if (keysToFetch.length > 0) {
741
- const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
742
- fetchedValues.forEach((value, index) => {
743
- const key = keysToFetch[index];
744
- const targetIndex = keyIndexes[index];
745
- rawValues[targetIndex] = value;
746
- cacheRawValue(scope, key, value);
1055
+ const rawValues = new Array(items.length);
1056
+ const keysToFetch = [];
1057
+ const keyIndexes = [];
1058
+ items.forEach((item, index) => {
1059
+ if (scope === StorageScope.Secure) {
1060
+ if (hasPendingSecureWrite(item.key)) {
1061
+ rawValues[index] = readPendingSecureWrite(item.key);
1062
+ return;
1063
+ }
1064
+ }
1065
+ if (item._readCacheEnabled === true) {
1066
+ if (hasCachedRawValue(scope, item.key)) {
1067
+ rawValues[index] = readCachedRawValue(scope, item.key);
1068
+ return;
1069
+ }
1070
+ }
1071
+ keysToFetch.push(item.key);
1072
+ keyIndexes.push(index);
747
1073
  });
748
- }
749
- return items.map((item, index) => {
750
- const raw = rawValues[index];
751
- if (raw === undefined) {
752
- return item.get();
1074
+ if (keysToFetch.length > 0) {
1075
+ const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
1076
+ fetchedValues.forEach((value, index) => {
1077
+ const key = keysToFetch[index];
1078
+ const targetIndex = keyIndexes[index];
1079
+ if (key === undefined || targetIndex === undefined) {
1080
+ return;
1081
+ }
1082
+ rawValues[targetIndex] = value;
1083
+ cacheRawValue(scope, key, value);
1084
+ });
753
1085
  }
754
- return item.deserialize(raw);
755
- });
1086
+ return items.map((item, index) => {
1087
+ const raw = rawValues[index];
1088
+ if (raw === undefined) {
1089
+ return asInternal(item)._defaultValue;
1090
+ }
1091
+ return item.deserialize(raw);
1092
+ });
1093
+ }, items.length);
756
1094
  }
757
1095
  export function setBatch(items, scope) {
758
- assertBatchScope(items.map(batchEntry => batchEntry.item), scope);
759
- if (scope === StorageScope.Memory) {
760
- items.forEach(({
761
- item,
762
- value
763
- }) => item.set(value));
764
- return;
765
- }
766
- const useRawBatchPath = items.every(({
767
- item
768
- }) => canUseRawBatchPath(asInternal(item)));
769
- if (!useRawBatchPath) {
770
- items.forEach(({
771
- item,
772
- value
773
- }) => item.set(value));
774
- return;
775
- }
776
- const keys = items.map(entry => entry.item.key);
777
- const values = items.map(entry => entry.item.serialize(entry.value));
778
- if (scope === StorageScope.Secure) {
779
- flushSecureWrites();
780
- }
781
- WebStorage.setBatch(keys, values, scope);
782
- keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1096
+ measureOperation("batch:set", scope, () => {
1097
+ assertBatchScope(items.map(batchEntry => batchEntry.item), scope);
1098
+ if (scope === StorageScope.Memory) {
1099
+ items.forEach(({
1100
+ item,
1101
+ value
1102
+ }) => item.set(value));
1103
+ return;
1104
+ }
1105
+ if (scope === StorageScope.Secure) {
1106
+ const secureEntries = items.map(({
1107
+ item,
1108
+ value
1109
+ }) => ({
1110
+ item,
1111
+ value,
1112
+ internal: asInternal(item)
1113
+ }));
1114
+ const canUseSecureBatchPath = secureEntries.every(({
1115
+ internal
1116
+ }) => canUseSecureRawBatchPath(internal));
1117
+ if (!canUseSecureBatchPath) {
1118
+ items.forEach(({
1119
+ item,
1120
+ value
1121
+ }) => item.set(value));
1122
+ return;
1123
+ }
1124
+ flushSecureWrites();
1125
+ const groupedByAccessControl = new Map();
1126
+ secureEntries.forEach(({
1127
+ item,
1128
+ value,
1129
+ internal
1130
+ }) => {
1131
+ const accessControl = internal._secureAccessControl ?? AccessControl.WhenUnlocked;
1132
+ const existingGroup = groupedByAccessControl.get(accessControl);
1133
+ const group = existingGroup ?? {
1134
+ keys: [],
1135
+ values: []
1136
+ };
1137
+ group.keys.push(item.key);
1138
+ group.values.push(item.serialize(value));
1139
+ if (!existingGroup) {
1140
+ groupedByAccessControl.set(accessControl, group);
1141
+ }
1142
+ });
1143
+ groupedByAccessControl.forEach((group, accessControl) => {
1144
+ WebStorage.setSecureAccessControl(accessControl);
1145
+ WebStorage.setBatch(group.keys, group.values, scope);
1146
+ group.keys.forEach((key, index) => cacheRawValue(scope, key, group.values[index]));
1147
+ });
1148
+ return;
1149
+ }
1150
+ const useRawBatchPath = items.every(({
1151
+ item
1152
+ }) => canUseRawBatchPath(asInternal(item)));
1153
+ if (!useRawBatchPath) {
1154
+ items.forEach(({
1155
+ item,
1156
+ value
1157
+ }) => item.set(value));
1158
+ return;
1159
+ }
1160
+ const keys = items.map(entry => entry.item.key);
1161
+ const values = items.map(entry => entry.item.serialize(entry.value));
1162
+ WebStorage.setBatch(keys, values, scope);
1163
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
1164
+ }, items.length);
783
1165
  }
784
1166
  export function removeBatch(items, scope) {
785
- assertBatchScope(items, scope);
786
- if (scope === StorageScope.Memory) {
787
- items.forEach(item => item.delete());
788
- return;
789
- }
790
- const keys = items.map(item => item.key);
791
- if (scope === StorageScope.Secure) {
792
- flushSecureWrites();
793
- }
794
- WebStorage.removeBatch(keys, scope);
795
- keys.forEach(key => cacheRawValue(scope, key, undefined));
1167
+ measureOperation("batch:remove", scope, () => {
1168
+ assertBatchScope(items, scope);
1169
+ if (scope === StorageScope.Memory) {
1170
+ items.forEach(item => item.delete());
1171
+ return;
1172
+ }
1173
+ const keys = items.map(item => item.key);
1174
+ if (scope === StorageScope.Secure) {
1175
+ flushSecureWrites();
1176
+ }
1177
+ WebStorage.removeBatch(keys, scope);
1178
+ keys.forEach(key => cacheRawValue(scope, key, undefined));
1179
+ }, items.length);
796
1180
  }
797
1181
  export function registerMigration(version, migration) {
798
1182
  if (!Number.isInteger(version) || version <= 0) {
@@ -804,93 +1188,133 @@ export function registerMigration(version, migration) {
804
1188
  registeredMigrations.set(version, migration);
805
1189
  }
806
1190
  export function migrateToLatest(scope = StorageScope.Disk) {
807
- assertValidScope(scope);
808
- const currentVersion = readMigrationVersion(scope);
809
- const versions = Array.from(registeredMigrations.keys()).filter(version => version > currentVersion).sort((a, b) => a - b);
810
- let appliedVersion = currentVersion;
811
- const context = {
812
- scope,
813
- getRaw: key => getRawValue(key, scope),
814
- setRaw: (key, value) => setRawValue(key, value, scope),
815
- removeRaw: key => removeRawValue(key, scope)
816
- };
817
- versions.forEach(version => {
818
- const migration = registeredMigrations.get(version);
819
- if (!migration) {
820
- return;
821
- }
822
- migration(context);
823
- writeMigrationVersion(scope, version);
824
- appliedVersion = version;
1191
+ return measureOperation("migration:run", scope, () => {
1192
+ assertValidScope(scope);
1193
+ const currentVersion = readMigrationVersion(scope);
1194
+ const versions = Array.from(registeredMigrations.keys()).filter(version => version > currentVersion).sort((a, b) => a - b);
1195
+ let appliedVersion = currentVersion;
1196
+ const context = {
1197
+ scope,
1198
+ getRaw: key => getRawValue(key, scope),
1199
+ setRaw: (key, value) => setRawValue(key, value, scope),
1200
+ removeRaw: key => removeRawValue(key, scope)
1201
+ };
1202
+ versions.forEach(version => {
1203
+ const migration = registeredMigrations.get(version);
1204
+ if (!migration) {
1205
+ return;
1206
+ }
1207
+ migration(context);
1208
+ writeMigrationVersion(scope, version);
1209
+ appliedVersion = version;
1210
+ });
1211
+ return appliedVersion;
825
1212
  });
826
- return appliedVersion;
827
1213
  }
828
1214
  export function runTransaction(scope, transaction) {
829
- assertValidScope(scope);
830
- if (scope === StorageScope.Secure) {
831
- flushSecureWrites();
832
- }
833
- const rollback = new Map();
834
- const rememberRollback = key => {
835
- if (rollback.has(key)) {
836
- return;
837
- }
838
- rollback.set(key, getRawValue(key, scope));
839
- };
840
- const tx = {
841
- scope,
842
- getRaw: key => getRawValue(key, scope),
843
- setRaw: (key, value) => {
844
- rememberRollback(key);
845
- setRawValue(key, value, scope);
846
- },
847
- removeRaw: key => {
848
- rememberRollback(key);
849
- removeRawValue(key, scope);
850
- },
851
- getItem: item => {
852
- assertBatchScope([item], scope);
853
- return item.get();
854
- },
855
- setItem: (item, value) => {
856
- assertBatchScope([item], scope);
857
- rememberRollback(item.key);
858
- item.set(value);
859
- },
860
- removeItem: item => {
861
- assertBatchScope([item], scope);
862
- rememberRollback(item.key);
863
- item.delete();
1215
+ return measureOperation("transaction:run", scope, () => {
1216
+ assertValidScope(scope);
1217
+ if (scope === StorageScope.Secure) {
1218
+ flushSecureWrites();
864
1219
  }
865
- };
866
- try {
867
- return transaction(tx);
868
- } catch (error) {
869
- Array.from(rollback.entries()).reverse().forEach(([key, previousValue]) => {
870
- if (previousValue === undefined) {
1220
+ const rollback = new Map();
1221
+ const rememberRollback = key => {
1222
+ if (rollback.has(key)) {
1223
+ return;
1224
+ }
1225
+ rollback.set(key, getRawValue(key, scope));
1226
+ };
1227
+ const tx = {
1228
+ scope,
1229
+ getRaw: key => getRawValue(key, scope),
1230
+ setRaw: (key, value) => {
1231
+ rememberRollback(key);
1232
+ setRawValue(key, value, scope);
1233
+ },
1234
+ removeRaw: key => {
1235
+ rememberRollback(key);
871
1236
  removeRawValue(key, scope);
1237
+ },
1238
+ getItem: item => {
1239
+ assertBatchScope([item], scope);
1240
+ return item.get();
1241
+ },
1242
+ setItem: (item, value) => {
1243
+ assertBatchScope([item], scope);
1244
+ rememberRollback(item.key);
1245
+ item.set(value);
1246
+ },
1247
+ removeItem: item => {
1248
+ assertBatchScope([item], scope);
1249
+ rememberRollback(item.key);
1250
+ item.delete();
1251
+ }
1252
+ };
1253
+ try {
1254
+ return transaction(tx);
1255
+ } catch (error) {
1256
+ const rollbackEntries = Array.from(rollback.entries()).reverse();
1257
+ if (scope === StorageScope.Memory) {
1258
+ rollbackEntries.forEach(([key, previousValue]) => {
1259
+ if (previousValue === undefined) {
1260
+ removeRawValue(key, scope);
1261
+ } else {
1262
+ setRawValue(key, previousValue, scope);
1263
+ }
1264
+ });
872
1265
  } else {
873
- setRawValue(key, previousValue, scope);
1266
+ const keysToSet = [];
1267
+ const valuesToSet = [];
1268
+ const keysToRemove = [];
1269
+ rollbackEntries.forEach(([key, previousValue]) => {
1270
+ if (previousValue === undefined) {
1271
+ keysToRemove.push(key);
1272
+ } else {
1273
+ keysToSet.push(key);
1274
+ valuesToSet.push(previousValue);
1275
+ }
1276
+ });
1277
+ if (scope === StorageScope.Secure) {
1278
+ flushSecureWrites();
1279
+ }
1280
+ if (keysToSet.length > 0) {
1281
+ WebStorage.setBatch(keysToSet, valuesToSet, scope);
1282
+ keysToSet.forEach((key, index) => cacheRawValue(scope, key, valuesToSet[index]));
1283
+ }
1284
+ if (keysToRemove.length > 0) {
1285
+ WebStorage.removeBatch(keysToRemove, scope);
1286
+ keysToRemove.forEach(key => cacheRawValue(scope, key, undefined));
1287
+ }
874
1288
  }
875
- });
876
- throw error;
877
- }
1289
+ throw error;
1290
+ }
1291
+ });
878
1292
  }
879
1293
  export function createSecureAuthStorage(config, options) {
880
1294
  const ns = options?.namespace ?? "auth";
881
1295
  const result = {};
882
- for (const key of Object.keys(config)) {
1296
+ for (const key of typedKeys(config)) {
883
1297
  const itemConfig = config[key];
1298
+ const expirationConfig = itemConfig.ttlMs !== undefined ? {
1299
+ ttlMs: itemConfig.ttlMs
1300
+ } : undefined;
884
1301
  result[key] = createStorageItem({
885
1302
  key,
886
1303
  scope: StorageScope.Secure,
887
1304
  defaultValue: "",
888
1305
  namespace: ns,
889
- biometric: itemConfig.biometric,
890
- accessControl: itemConfig.accessControl,
891
- expiration: itemConfig.ttlMs ? {
892
- ttlMs: itemConfig.ttlMs
893
- } : undefined
1306
+ ...(itemConfig.biometric !== undefined ? {
1307
+ biometric: itemConfig.biometric
1308
+ } : {}),
1309
+ ...(itemConfig.biometricLevel !== undefined ? {
1310
+ biometricLevel: itemConfig.biometricLevel
1311
+ } : {}),
1312
+ ...(itemConfig.accessControl !== undefined ? {
1313
+ accessControl: itemConfig.accessControl
1314
+ } : {}),
1315
+ ...(expirationConfig !== undefined ? {
1316
+ expiration: expirationConfig
1317
+ } : {})
894
1318
  });
895
1319
  }
896
1320
  return result;