react-native-nitro-storage 0.1.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +432 -345
  2. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +191 -3
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +21 -41
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +181 -29
  5. package/android/src/main/java/com/nitrostorage/NitroStoragePackage.kt +2 -2
  6. package/app.plugin.js +9 -7
  7. package/cpp/bindings/HybridStorage.cpp +239 -10
  8. package/cpp/bindings/HybridStorage.hpp +10 -0
  9. package/cpp/core/NativeStorageAdapter.hpp +22 -0
  10. package/ios/IOSStorageAdapterCpp.hpp +25 -0
  11. package/ios/IOSStorageAdapterCpp.mm +315 -33
  12. package/lib/commonjs/Storage.types.js +23 -1
  13. package/lib/commonjs/Storage.types.js.map +1 -1
  14. package/lib/commonjs/index.js +680 -68
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/index.web.js +801 -133
  17. package/lib/commonjs/index.web.js.map +1 -1
  18. package/lib/commonjs/internal.js +112 -0
  19. package/lib/commonjs/internal.js.map +1 -0
  20. package/lib/module/Storage.types.js +22 -0
  21. package/lib/module/Storage.types.js.map +1 -1
  22. package/lib/module/index.js +660 -71
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/index.web.js +766 -125
  25. package/lib/module/index.web.js.map +1 -1
  26. package/lib/module/internal.js +100 -0
  27. package/lib/module/internal.js.map +1 -0
  28. package/lib/typescript/Storage.nitro.d.ts +10 -0
  29. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  30. package/lib/typescript/Storage.types.d.ts +20 -0
  31. package/lib/typescript/Storage.types.d.ts.map +1 -1
  32. package/lib/typescript/index.d.ts +68 -9
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/index.web.d.ts +79 -13
  35. package/lib/typescript/index.web.d.ts.map +1 -1
  36. package/lib/typescript/internal.d.ts +21 -0
  37. package/lib/typescript/internal.d.ts.map +1 -0
  38. package/lib/typescript/migration.d.ts +2 -3
  39. package/lib/typescript/migration.d.ts.map +1 -1
  40. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +10 -0
  41. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +10 -0
  42. package/package.json +22 -8
  43. package/src/Storage.nitro.ts +11 -2
  44. package/src/Storage.types.ts +22 -0
  45. package/src/index.ts +943 -84
  46. package/src/index.web.ts +1082 -137
  47. package/src/internal.ts +144 -0
  48. package/src/migration.ts +3 -3
package/src/index.ts CHANGED
@@ -1,10 +1,74 @@
1
- import { useSyncExternalStore } from "react";
1
+ import { useRef, useSyncExternalStore } from "react";
2
2
  import { NitroModules } from "react-native-nitro-modules";
3
3
  import type { Storage } from "./Storage.nitro";
4
- import { StorageScope } from "./Storage.types";
4
+ import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
5
+ import {
6
+ MIGRATION_VERSION_KEY,
7
+ type StoredEnvelope,
8
+ isStoredEnvelope,
9
+ assertBatchScope,
10
+ assertValidScope,
11
+ decodeNativeBatchValue,
12
+ serializeWithPrimitiveFastPath,
13
+ deserializeWithPrimitiveFastPath,
14
+ prefixKey,
15
+ isNamespaced,
16
+ } from "./internal";
5
17
 
6
- export { StorageScope } from "./Storage.types";
18
+ export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
7
19
  export type { Storage } from "./Storage.nitro";
20
+ export { migrateFromMMKV } from "./migration";
21
+
22
+ export type Validator<T> = (value: unknown) => value is T;
23
+ export type ExpirationConfig = {
24
+ ttlMs: number;
25
+ };
26
+
27
+ export type MigrationContext = {
28
+ scope: StorageScope;
29
+ getRaw: (key: string) => string | undefined;
30
+ setRaw: (key: string, value: string) => void;
31
+ removeRaw: (key: string) => void;
32
+ };
33
+
34
+ export type Migration = (context: MigrationContext) => void;
35
+
36
+ export type TransactionContext = {
37
+ scope: StorageScope;
38
+ getRaw: (key: string) => string | undefined;
39
+ setRaw: (key: string, value: string) => void;
40
+ removeRaw: (key: string) => void;
41
+ getItem: <T>(item: Pick<StorageItem<T>, "scope" | "key" | "get">) => T;
42
+ setItem: <T>(
43
+ item: Pick<StorageItem<T>, "scope" | "key" | "set">,
44
+ value: T,
45
+ ) => void;
46
+ removeItem: (
47
+ item: Pick<StorageItem<unknown>, "scope" | "key" | "delete">,
48
+ ) => void;
49
+ };
50
+
51
+ type KeyListenerRegistry = Map<string, Set<() => void>>;
52
+ type RawBatchPathItem = {
53
+ _hasValidation?: boolean;
54
+ _hasExpiration?: boolean;
55
+ _isBiometric?: boolean;
56
+ _secureAccessControl?: AccessControl;
57
+ };
58
+
59
+ function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
60
+ return item as unknown as StorageItemInternal<any>;
61
+ }
62
+ type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
63
+ type PendingSecureWrite = { key: string; value: string | undefined };
64
+
65
+ const registeredMigrations = new Map<number, Migration>();
66
+ const runMicrotask =
67
+ typeof queueMicrotask === "function"
68
+ ? queueMicrotask
69
+ : (task: () => void) => {
70
+ Promise.resolve().then(task);
71
+ };
8
72
 
9
73
  let _storageModule: Storage | null = null;
10
74
 
@@ -12,23 +76,268 @@ function getStorageModule(): Storage {
12
76
  if (!_storageModule) {
13
77
  _storageModule = NitroModules.createHybridObject<Storage>("Storage");
14
78
  }
15
- return _storageModule!;
79
+ return _storageModule;
80
+ }
81
+
82
+ const memoryStore = new Map<string, unknown>();
83
+ const memoryListeners: KeyListenerRegistry = new Map();
84
+ const scopedListeners = new Map<NonMemoryScope, KeyListenerRegistry>([
85
+ [StorageScope.Disk, new Map()],
86
+ [StorageScope.Secure, new Map()],
87
+ ]);
88
+ const scopedUnsubscribers = new Map<NonMemoryScope, () => void>();
89
+ const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
90
+ [
91
+ [StorageScope.Disk, new Map()],
92
+ [StorageScope.Secure, new Map()],
93
+ ],
94
+ );
95
+ const pendingSecureWrites = new Map<string, PendingSecureWrite>();
96
+ let secureFlushScheduled = false;
97
+ let secureDefaultAccessControl: AccessControl = AccessControl.WhenUnlocked;
98
+
99
+ function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
100
+ return scopedListeners.get(scope)!;
16
101
  }
17
102
 
18
- const memoryStore = new Map<string, any>();
19
- const memoryListeners = new Set<(key: string, value: any) => void>();
103
+ function getScopeRawCache(
104
+ scope: NonMemoryScope,
105
+ ): Map<string, string | undefined> {
106
+ return scopedRawCache.get(scope)!;
107
+ }
108
+
109
+ function cacheRawValue(
110
+ scope: NonMemoryScope,
111
+ key: string,
112
+ value: string | undefined,
113
+ ): void {
114
+ getScopeRawCache(scope).set(key, value);
115
+ }
116
+
117
+ function readCachedRawValue(
118
+ scope: NonMemoryScope,
119
+ key: string,
120
+ ): string | undefined {
121
+ return getScopeRawCache(scope).get(key);
122
+ }
123
+
124
+ function hasCachedRawValue(scope: NonMemoryScope, key: string): boolean {
125
+ return getScopeRawCache(scope).has(key);
126
+ }
127
+
128
+ function clearScopeRawCache(scope: NonMemoryScope): void {
129
+ getScopeRawCache(scope).clear();
130
+ }
131
+
132
+ function notifyKeyListeners(registry: KeyListenerRegistry, key: string): void {
133
+ registry.get(key)?.forEach((listener) => listener());
134
+ }
135
+
136
+ function notifyAllListeners(registry: KeyListenerRegistry): void {
137
+ registry.forEach((listeners) => {
138
+ listeners.forEach((listener) => listener());
139
+ });
140
+ }
141
+
142
+ function addKeyListener(
143
+ registry: KeyListenerRegistry,
144
+ key: string,
145
+ listener: () => void,
146
+ ): () => void {
147
+ let listeners = registry.get(key);
148
+ if (!listeners) {
149
+ listeners = new Set();
150
+ registry.set(key, listeners);
151
+ }
152
+ listeners.add(listener);
153
+
154
+ return () => {
155
+ const scopedListeners = registry.get(key);
156
+ if (!scopedListeners) {
157
+ return;
158
+ }
159
+ scopedListeners.delete(listener);
160
+ if (scopedListeners.size === 0) {
161
+ registry.delete(key);
162
+ }
163
+ };
164
+ }
20
165
 
21
- function notifyMemoryListeners(key: string, value: any) {
22
- memoryListeners.forEach((listener) => listener(key, value));
166
+ function readPendingSecureWrite(key: string): string | undefined {
167
+ return pendingSecureWrites.get(key)?.value;
168
+ }
169
+
170
+ function hasPendingSecureWrite(key: string): boolean {
171
+ return pendingSecureWrites.has(key);
172
+ }
173
+
174
+ function clearPendingSecureWrite(key: string): void {
175
+ pendingSecureWrites.delete(key);
176
+ }
177
+
178
+ function flushSecureWrites(): void {
179
+ secureFlushScheduled = false;
180
+
181
+ if (pendingSecureWrites.size === 0) {
182
+ return;
183
+ }
184
+
185
+ const writes = Array.from(pendingSecureWrites.values());
186
+ pendingSecureWrites.clear();
187
+
188
+ const keysToSet: string[] = [];
189
+ const valuesToSet: string[] = [];
190
+ const keysToRemove: string[] = [];
191
+
192
+ writes.forEach(({ key, value }) => {
193
+ if (value === undefined) {
194
+ keysToRemove.push(key);
195
+ } else {
196
+ keysToSet.push(key);
197
+ valuesToSet.push(value);
198
+ }
199
+ });
200
+
201
+ const storageModule = getStorageModule();
202
+ storageModule.setSecureAccessControl(secureDefaultAccessControl);
203
+ if (keysToSet.length > 0) {
204
+ storageModule.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
205
+ }
206
+ if (keysToRemove.length > 0) {
207
+ storageModule.removeBatch(keysToRemove, StorageScope.Secure);
208
+ }
209
+ }
210
+
211
+ function scheduleSecureWrite(key: string, value: string | undefined): void {
212
+ pendingSecureWrites.set(key, { key, value });
213
+ if (secureFlushScheduled) {
214
+ return;
215
+ }
216
+ secureFlushScheduled = true;
217
+ runMicrotask(flushSecureWrites);
218
+ }
219
+
220
+ function ensureNativeScopeSubscription(scope: NonMemoryScope): void {
221
+ if (scopedUnsubscribers.has(scope)) {
222
+ return;
223
+ }
224
+
225
+ const unsubscribe = getStorageModule().addOnChange(scope, (key, value) => {
226
+ if (scope === StorageScope.Secure) {
227
+ if (key === "") {
228
+ pendingSecureWrites.clear();
229
+ } else {
230
+ clearPendingSecureWrite(key);
231
+ }
232
+ }
233
+
234
+ if (key === "") {
235
+ clearScopeRawCache(scope);
236
+ notifyAllListeners(getScopedListeners(scope));
237
+ return;
238
+ }
239
+
240
+ cacheRawValue(scope, key, value);
241
+ notifyKeyListeners(getScopedListeners(scope), key);
242
+ });
243
+ scopedUnsubscribers.set(scope, unsubscribe);
244
+ }
245
+
246
+ function maybeCleanupNativeScopeSubscription(scope: NonMemoryScope): void {
247
+ const listeners = getScopedListeners(scope);
248
+ if (listeners.size > 0) {
249
+ return;
250
+ }
251
+
252
+ const unsubscribe = scopedUnsubscribers.get(scope);
253
+ if (!unsubscribe) {
254
+ return;
255
+ }
256
+
257
+ unsubscribe();
258
+ scopedUnsubscribers.delete(scope);
259
+ }
260
+
261
+ function getRawValue(key: string, scope: StorageScope): string | undefined {
262
+ assertValidScope(scope);
263
+ if (scope === StorageScope.Memory) {
264
+ const value = memoryStore.get(key);
265
+ return typeof value === "string" ? value : undefined;
266
+ }
267
+
268
+ if (scope === StorageScope.Secure && hasPendingSecureWrite(key)) {
269
+ return readPendingSecureWrite(key);
270
+ }
271
+
272
+ return getStorageModule().get(key, scope);
273
+ }
274
+
275
+ function setRawValue(key: string, value: string, scope: StorageScope): void {
276
+ assertValidScope(scope);
277
+ if (scope === StorageScope.Memory) {
278
+ memoryStore.set(key, value);
279
+ notifyKeyListeners(memoryListeners, key);
280
+ return;
281
+ }
282
+
283
+ if (scope === StorageScope.Secure) {
284
+ flushSecureWrites();
285
+ clearPendingSecureWrite(key);
286
+ getStorageModule().setSecureAccessControl(secureDefaultAccessControl);
287
+ }
288
+
289
+ getStorageModule().set(key, value, scope);
290
+ cacheRawValue(scope, key, value);
291
+ }
292
+
293
+ function removeRawValue(key: string, scope: StorageScope): void {
294
+ assertValidScope(scope);
295
+ if (scope === StorageScope.Memory) {
296
+ memoryStore.delete(key);
297
+ notifyKeyListeners(memoryListeners, key);
298
+ return;
299
+ }
300
+
301
+ if (scope === StorageScope.Secure) {
302
+ flushSecureWrites();
303
+ clearPendingSecureWrite(key);
304
+ }
305
+
306
+ getStorageModule().remove(key, scope);
307
+ cacheRawValue(scope, key, undefined);
308
+ }
309
+
310
+ function readMigrationVersion(scope: StorageScope): number {
311
+ const raw = getRawValue(MIGRATION_VERSION_KEY, scope);
312
+ if (raw === undefined) {
313
+ return 0;
314
+ }
315
+
316
+ const parsed = Number.parseInt(raw, 10);
317
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
318
+ }
319
+
320
+ function writeMigrationVersion(scope: StorageScope, version: number): void {
321
+ setRawValue(MIGRATION_VERSION_KEY, String(version), scope);
23
322
  }
24
323
 
25
324
  export const storage = {
26
325
  clear: (scope: StorageScope) => {
27
326
  if (scope === StorageScope.Memory) {
28
327
  memoryStore.clear();
29
- notifyMemoryListeners("", undefined);
30
- } else {
31
- getStorageModule().clear(scope);
328
+ notifyAllListeners(memoryListeners);
329
+ return;
330
+ }
331
+
332
+ if (scope === StorageScope.Secure) {
333
+ flushSecureWrites();
334
+ pendingSecureWrites.clear();
335
+ }
336
+
337
+ clearScopeRawCache(scope);
338
+ getStorageModule().clear(scope);
339
+ if (scope === StorageScope.Secure) {
340
+ getStorageModule().clearSecureBiometric();
32
341
  }
33
342
  },
34
343
  clearAll: () => {
@@ -36,6 +345,79 @@ export const storage = {
36
345
  storage.clear(StorageScope.Disk);
37
346
  storage.clear(StorageScope.Secure);
38
347
  },
348
+ clearNamespace: (namespace: string, scope: StorageScope) => {
349
+ assertValidScope(scope);
350
+ if (scope === StorageScope.Memory) {
351
+ for (const key of memoryStore.keys()) {
352
+ if (isNamespaced(key, namespace)) {
353
+ memoryStore.delete(key);
354
+ }
355
+ }
356
+ notifyAllListeners(memoryListeners);
357
+ return;
358
+ }
359
+ if (scope === StorageScope.Secure) {
360
+ flushSecureWrites();
361
+ }
362
+ const keys = getStorageModule().getAllKeys(scope);
363
+ const namespacedKeys = keys.filter((k) => isNamespaced(k, namespace));
364
+ if (namespacedKeys.length > 0) {
365
+ getStorageModule().removeBatch(namespacedKeys, scope);
366
+ namespacedKeys.forEach((k) => cacheRawValue(scope, k, undefined));
367
+ if (scope === StorageScope.Secure) {
368
+ namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
369
+ }
370
+ }
371
+ },
372
+ clearBiometric: () => {
373
+ getStorageModule().clearSecureBiometric();
374
+ },
375
+ has: (key: string, scope: StorageScope): boolean => {
376
+ assertValidScope(scope);
377
+ if (scope === StorageScope.Memory) {
378
+ return memoryStore.has(key);
379
+ }
380
+ return getStorageModule().has(key, scope);
381
+ },
382
+ getAllKeys: (scope: StorageScope): string[] => {
383
+ assertValidScope(scope);
384
+ if (scope === StorageScope.Memory) {
385
+ return Array.from(memoryStore.keys());
386
+ }
387
+ return getStorageModule().getAllKeys(scope);
388
+ },
389
+ getAll: (scope: StorageScope): Record<string, string> => {
390
+ assertValidScope(scope);
391
+ const result: Record<string, string> = {};
392
+ if (scope === StorageScope.Memory) {
393
+ memoryStore.forEach((value, key) => {
394
+ if (typeof value === "string") result[key] = value;
395
+ });
396
+ return result;
397
+ }
398
+ const keys = getStorageModule().getAllKeys(scope);
399
+ if (keys.length === 0) return result;
400
+ const values = getStorageModule().getBatch(keys, scope);
401
+ keys.forEach((key, idx) => {
402
+ const val = decodeNativeBatchValue(values[idx]);
403
+ if (val !== undefined) result[key] = val;
404
+ });
405
+ return result;
406
+ },
407
+ size: (scope: StorageScope): number => {
408
+ assertValidScope(scope);
409
+ if (scope === StorageScope.Memory) {
410
+ return memoryStore.size;
411
+ }
412
+ return getStorageModule().size(scope);
413
+ },
414
+ setAccessControl: (level: AccessControl) => {
415
+ secureDefaultAccessControl = level;
416
+ getStorageModule().setSecureAccessControl(level);
417
+ },
418
+ setKeychainAccessGroup: (group: string) => {
419
+ getStorageModule().setKeychainAccessGroup(group);
420
+ },
39
421
  };
40
422
 
41
423
  export interface StorageItemConfig<T> {
@@ -44,90 +426,301 @@ export interface StorageItemConfig<T> {
44
426
  defaultValue?: T;
45
427
  serialize?: (value: T) => string;
46
428
  deserialize?: (value: string) => T;
429
+ validate?: Validator<T>;
430
+ onValidationError?: (invalidValue: unknown) => T;
431
+ expiration?: ExpirationConfig;
432
+ onExpired?: (key: string) => void;
433
+ readCache?: boolean;
434
+ coalesceSecureWrites?: boolean;
435
+ namespace?: string;
436
+ biometric?: boolean;
437
+ accessControl?: AccessControl;
47
438
  }
48
439
 
49
440
  export interface StorageItem<T> {
50
441
  get: () => T;
51
442
  set: (value: T | ((prev: T) => T)) => void;
52
443
  delete: () => void;
444
+ has: () => boolean;
53
445
  subscribe: (callback: () => void) => () => void;
54
446
  serialize: (value: T) => string;
55
447
  deserialize: (value: string) => T;
56
- _triggerListeners: () => void;
57
448
  scope: StorageScope;
58
449
  key: string;
59
450
  }
60
451
 
452
+ type StorageItemInternal<T> = StorageItem<T> & {
453
+ _triggerListeners: () => void;
454
+ _hasValidation: boolean;
455
+ _hasExpiration: boolean;
456
+ _readCacheEnabled: boolean;
457
+ _isBiometric: boolean;
458
+ _secureAccessControl?: AccessControl;
459
+ };
460
+
461
+ function canUseRawBatchPath(item: RawBatchPathItem): boolean {
462
+ return (
463
+ item._hasExpiration === false &&
464
+ item._hasValidation === false &&
465
+ item._isBiometric !== true &&
466
+ item._secureAccessControl === undefined
467
+ );
468
+ }
469
+
61
470
  function defaultSerialize<T>(value: T): string {
62
- return JSON.stringify(value);
471
+ return serializeWithPrimitiveFastPath(value);
63
472
  }
64
473
 
65
474
  function defaultDeserialize<T>(value: string): T {
66
- return JSON.parse(value) as T;
475
+ return deserializeWithPrimitiveFastPath(value);
67
476
  }
68
477
 
69
478
  export function createStorageItem<T = undefined>(
70
- config: StorageItemConfig<T>
479
+ config: StorageItemConfig<T>,
71
480
  ): StorageItem<T> {
481
+ const storageKey = prefixKey(config.namespace, config.key);
72
482
  const serialize = config.serialize ?? defaultSerialize;
73
483
  const deserialize = config.deserialize ?? defaultDeserialize;
74
484
  const isMemory = config.scope === StorageScope.Memory;
485
+ const isBiometric =
486
+ config.biometric === true && config.scope === StorageScope.Secure;
487
+ const secureAccessControl = config.accessControl;
488
+ const validate = config.validate;
489
+ const onValidationError = config.onValidationError;
490
+ const expiration = config.expiration;
491
+ const onExpired = config.onExpired;
492
+ const expirationTtlMs = expiration?.ttlMs;
493
+ const memoryExpiration =
494
+ expiration && isMemory ? new Map<string, number>() : null;
495
+ const readCache = !isMemory && config.readCache === true;
496
+ const coalesceSecureWrites =
497
+ config.scope === StorageScope.Secure &&
498
+ config.coalesceSecureWrites === true &&
499
+ !isBiometric &&
500
+ secureAccessControl === undefined;
501
+ const nonMemoryScope: NonMemoryScope | null =
502
+ config.scope === StorageScope.Disk
503
+ ? StorageScope.Disk
504
+ : config.scope === StorageScope.Secure
505
+ ? StorageScope.Secure
506
+ : null;
507
+
508
+ if (expiration && expiration.ttlMs <= 0) {
509
+ throw new Error("expiration.ttlMs must be greater than 0.");
510
+ }
75
511
 
76
512
  const listeners = new Set<() => void>();
77
513
  let unsubscribe: (() => void) | null = null;
514
+ let lastRaw: unknown = undefined;
515
+ let lastValue: T | undefined;
516
+ let hasLastValue = false;
517
+
518
+ const invalidateParsedCache = () => {
519
+ lastRaw = undefined;
520
+ lastValue = undefined;
521
+ hasLastValue = false;
522
+ };
78
523
 
79
524
  const ensureSubscription = () => {
80
- if (!unsubscribe) {
81
- if (isMemory) {
82
- const listener = (key: string) => {
83
- if (key === "" || key === config.key) {
84
- lastRaw = undefined;
85
- lastValue = undefined;
86
- listeners.forEach((l) => l());
87
- }
88
- };
89
- memoryListeners.add(listener);
90
- unsubscribe = () => memoryListeners.delete(listener);
91
- } else {
92
- unsubscribe = getStorageModule().addOnChange(config.scope, (key) => {
93
- if (key === "" || key === config.key) {
94
- lastRaw = undefined;
95
- lastValue = undefined;
96
- listeners.forEach((listener) => listener());
97
- }
98
- });
525
+ if (unsubscribe) {
526
+ return;
527
+ }
528
+
529
+ const listener = () => {
530
+ invalidateParsedCache();
531
+ listeners.forEach((callback) => callback());
532
+ };
533
+
534
+ if (isMemory) {
535
+ unsubscribe = addKeyListener(memoryListeners, storageKey, listener);
536
+ return;
537
+ }
538
+
539
+ ensureNativeScopeSubscription(nonMemoryScope!);
540
+ unsubscribe = addKeyListener(
541
+ getScopedListeners(nonMemoryScope!),
542
+ storageKey,
543
+ listener,
544
+ );
545
+ };
546
+
547
+ const readStoredRaw = (): unknown => {
548
+ if (isMemory) {
549
+ if (memoryExpiration) {
550
+ const expiresAt = memoryExpiration.get(storageKey);
551
+ if (expiresAt !== undefined && expiresAt <= Date.now()) {
552
+ memoryExpiration.delete(storageKey);
553
+ memoryStore.delete(storageKey);
554
+ notifyKeyListeners(memoryListeners, storageKey);
555
+ onExpired?.(storageKey);
556
+ return undefined;
557
+ }
99
558
  }
559
+ return memoryStore.get(storageKey) as T | undefined;
560
+ }
561
+
562
+ if (
563
+ nonMemoryScope === StorageScope.Secure &&
564
+ !isBiometric &&
565
+ hasPendingSecureWrite(storageKey)
566
+ ) {
567
+ return readPendingSecureWrite(storageKey);
100
568
  }
569
+
570
+ if (readCache) {
571
+ if (hasCachedRawValue(nonMemoryScope!, storageKey)) {
572
+ return readCachedRawValue(nonMemoryScope!, storageKey);
573
+ }
574
+ }
575
+
576
+ if (isBiometric) {
577
+ return getStorageModule().getSecureBiometric(storageKey);
578
+ }
579
+
580
+ const raw = getStorageModule().get(storageKey, config.scope);
581
+ cacheRawValue(nonMemoryScope!, storageKey, raw);
582
+ return raw;
101
583
  };
102
584
 
103
- let lastRaw: string | undefined;
104
- let lastValue: T | undefined;
585
+ const writeStoredRaw = (rawValue: string): void => {
586
+ if (isBiometric) {
587
+ getStorageModule().setSecureBiometric(storageKey, rawValue);
588
+ return;
589
+ }
105
590
 
106
- const get = (): T => {
107
- let raw: string | undefined;
591
+ cacheRawValue(nonMemoryScope!, storageKey, rawValue);
108
592
 
593
+ if (coalesceSecureWrites) {
594
+ scheduleSecureWrite(storageKey, rawValue);
595
+ return;
596
+ }
597
+
598
+ if (nonMemoryScope === StorageScope.Secure) {
599
+ clearPendingSecureWrite(storageKey);
600
+ getStorageModule().setSecureAccessControl(
601
+ secureAccessControl ?? secureDefaultAccessControl,
602
+ );
603
+ }
604
+
605
+ getStorageModule().set(storageKey, rawValue, config.scope);
606
+ };
607
+
608
+ const removeStoredRaw = (): void => {
609
+ if (isBiometric) {
610
+ getStorageModule().deleteSecureBiometric(storageKey);
611
+ return;
612
+ }
613
+
614
+ cacheRawValue(nonMemoryScope!, storageKey, undefined);
615
+
616
+ if (coalesceSecureWrites) {
617
+ scheduleSecureWrite(storageKey, undefined);
618
+ return;
619
+ }
620
+
621
+ if (nonMemoryScope === StorageScope.Secure) {
622
+ clearPendingSecureWrite(storageKey);
623
+ }
624
+
625
+ getStorageModule().remove(storageKey, config.scope);
626
+ };
627
+
628
+ const writeValueWithoutValidation = (value: T): void => {
109
629
  if (isMemory) {
110
- raw = memoryStore.get(config.key);
111
- } else {
112
- raw = getStorageModule().get(config.key, config.scope);
630
+ if (memoryExpiration) {
631
+ memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
632
+ }
633
+ memoryStore.set(storageKey, value);
634
+ notifyKeyListeners(memoryListeners, storageKey);
635
+ return;
113
636
  }
114
637
 
115
- if (raw === lastRaw && lastValue !== undefined) {
116
- return lastValue;
638
+ const serialized = serialize(value);
639
+ if (expiration) {
640
+ const envelope: StoredEnvelope = {
641
+ __nitroStorageEnvelope: true,
642
+ expiresAt: Date.now() + expiration.ttlMs,
643
+ payload: serialized,
644
+ };
645
+ writeStoredRaw(JSON.stringify(envelope));
646
+ return;
647
+ }
648
+
649
+ writeStoredRaw(serialized);
650
+ };
651
+
652
+ const resolveInvalidValue = (invalidValue: unknown): T => {
653
+ if (onValidationError) {
654
+ return onValidationError(invalidValue);
655
+ }
656
+
657
+ return config.defaultValue as T;
658
+ };
659
+
660
+ const ensureValidatedValue = (
661
+ candidate: unknown,
662
+ hadStoredValue: boolean,
663
+ ): T => {
664
+ if (!validate || validate(candidate)) {
665
+ return candidate as T;
666
+ }
667
+
668
+ const resolved = resolveInvalidValue(candidate);
669
+ if (validate && !validate(resolved)) {
670
+ return config.defaultValue as T;
671
+ }
672
+ if (hadStoredValue) {
673
+ writeValueWithoutValidation(resolved);
674
+ }
675
+ return resolved;
676
+ };
677
+
678
+ const get = (): T => {
679
+ const raw = readStoredRaw();
680
+
681
+ const canUseCachedValue = !expiration && !memoryExpiration;
682
+ if (canUseCachedValue && raw === lastRaw && hasLastValue) {
683
+ return lastValue as T;
117
684
  }
118
685
 
119
686
  lastRaw = raw;
120
687
 
121
688
  if (raw === undefined) {
122
- lastValue = config.defaultValue as T;
123
- } else {
124
- if (isMemory) {
125
- lastValue = raw as T;
126
- } else {
127
- lastValue = deserialize(raw);
689
+ lastValue = ensureValidatedValue(config.defaultValue, false);
690
+ hasLastValue = true;
691
+ return lastValue;
692
+ }
693
+
694
+ if (isMemory) {
695
+ lastValue = ensureValidatedValue(raw, true);
696
+ hasLastValue = true;
697
+ return lastValue;
698
+ }
699
+
700
+ let deserializableRaw = raw as string;
701
+
702
+ if (expiration) {
703
+ try {
704
+ const parsed = JSON.parse(raw as string) as unknown;
705
+ if (isStoredEnvelope(parsed)) {
706
+ if (parsed.expiresAt <= Date.now()) {
707
+ removeStoredRaw();
708
+ invalidateParsedCache();
709
+ onExpired?.(storageKey);
710
+ lastValue = ensureValidatedValue(config.defaultValue, false);
711
+ hasLastValue = true;
712
+ return lastValue;
713
+ }
714
+
715
+ deserializableRaw = parsed.payload;
716
+ }
717
+ } catch {
718
+ // Keep backward compatibility with legacy raw values.
128
719
  }
129
720
  }
130
721
 
722
+ lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
723
+ hasLastValue = true;
131
724
  return lastValue;
132
725
  };
133
726
 
@@ -138,22 +731,36 @@ export function createStorageItem<T = undefined>(
138
731
  ? (valueOrFn as (prev: T) => T)(currentValue)
139
732
  : valueOrFn;
140
733
 
141
- if (isMemory) {
142
- memoryStore.set(config.key, newValue);
143
- notifyMemoryListeners(config.key, newValue);
144
- } else {
145
- const serialized = serialize(newValue);
146
- getStorageModule().set(config.key, serialized, config.scope);
734
+ invalidateParsedCache();
735
+
736
+ if (validate && !validate(newValue)) {
737
+ throw new Error(
738
+ `Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
739
+ );
147
740
  }
741
+
742
+ writeValueWithoutValidation(newValue);
148
743
  };
149
744
 
150
745
  const deleteItem = (): void => {
746
+ invalidateParsedCache();
747
+
151
748
  if (isMemory) {
152
- memoryStore.delete(config.key);
153
- notifyMemoryListeners(config.key, undefined);
154
- } else {
155
- getStorageModule().remove(config.key, config.scope);
749
+ if (memoryExpiration) {
750
+ memoryExpiration.delete(storageKey);
751
+ }
752
+ memoryStore.delete(storageKey);
753
+ notifyKeyListeners(memoryListeners, storageKey);
754
+ return;
156
755
  }
756
+
757
+ removeStoredRaw();
758
+ };
759
+
760
+ const hasItem = (): boolean => {
761
+ if (isMemory) return memoryStore.has(storageKey);
762
+ if (isBiometric) return getStorageModule().hasSecureBiometric(storageKey);
763
+ return getStorageModule().has(storageKey, config.scope);
157
764
  };
158
765
 
159
766
  const subscribe = (callback: () => void): (() => void) => {
@@ -163,52 +770,150 @@ export function createStorageItem<T = undefined>(
163
770
  listeners.delete(callback);
164
771
  if (listeners.size === 0 && unsubscribe) {
165
772
  unsubscribe();
773
+ if (!isMemory) {
774
+ maybeCleanupNativeScopeSubscription(nonMemoryScope!);
775
+ }
166
776
  unsubscribe = null;
167
777
  }
168
778
  };
169
779
  };
170
780
 
171
- return {
781
+ const storageItem: StorageItemInternal<T> = {
172
782
  get,
173
783
  set,
174
784
  delete: deleteItem,
785
+ has: hasItem,
175
786
  subscribe,
176
787
  serialize,
177
788
  deserialize,
178
789
  _triggerListeners: () => {
179
- lastRaw = undefined;
180
- lastValue = undefined;
181
- listeners.forEach((l) => l());
790
+ invalidateParsedCache();
791
+ listeners.forEach((listener) => listener());
182
792
  },
793
+ _hasValidation: validate !== undefined,
794
+ _hasExpiration: expiration !== undefined,
795
+ _readCacheEnabled: readCache,
796
+ _isBiometric: isBiometric,
797
+ _secureAccessControl: secureAccessControl,
183
798
  scope: config.scope,
184
- key: config.key,
799
+ key: storageKey,
185
800
  };
801
+
802
+ return storageItem as StorageItem<T>;
186
803
  }
187
804
 
188
805
  export function useStorage<T>(
189
- item: StorageItem<T>
806
+ item: StorageItem<T>,
190
807
  ): [T, (value: T | ((prev: T) => T)) => void] {
191
808
  const value = useSyncExternalStore(item.subscribe, item.get, item.get);
192
809
  return [value, item.set];
193
810
  }
194
811
 
812
+ export function useStorageSelector<T, TSelected>(
813
+ item: StorageItem<T>,
814
+ selector: (value: T) => TSelected,
815
+ isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
816
+ ): [TSelected, (value: T | ((prev: T) => T)) => void] {
817
+ const selectedRef = useRef<
818
+ { hasValue: false } | { hasValue: true; value: TSelected }
819
+ >({
820
+ hasValue: false,
821
+ });
822
+
823
+ const getSelectedSnapshot = () => {
824
+ const nextSelected = selector(item.get());
825
+ const current = selectedRef.current;
826
+ if (current.hasValue && isEqual(current.value, nextSelected)) {
827
+ return current.value;
828
+ }
829
+
830
+ selectedRef.current = { hasValue: true, value: nextSelected };
831
+ return nextSelected;
832
+ };
833
+
834
+ const selectedValue = useSyncExternalStore(
835
+ item.subscribe,
836
+ getSelectedSnapshot,
837
+ getSelectedSnapshot,
838
+ );
839
+ return [selectedValue, item.set];
840
+ }
841
+
195
842
  export function useSetStorage<T>(item: StorageItem<T>) {
196
843
  return item.set;
197
844
  }
198
845
 
846
+ type BatchReadItem<T> = Pick<
847
+ StorageItem<T>,
848
+ "key" | "scope" | "get" | "deserialize"
849
+ > & {
850
+ _hasValidation?: boolean;
851
+ _hasExpiration?: boolean;
852
+ _readCacheEnabled?: boolean;
853
+ _isBiometric?: boolean;
854
+ _secureAccessControl?: AccessControl;
855
+ };
856
+ type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
857
+
858
+ export type StorageBatchSetItem<T> = {
859
+ item: StorageItem<T>;
860
+ value: T;
861
+ };
862
+
199
863
  export function getBatch(
200
- items: StorageItem<any>[],
201
- scope: StorageScope
202
- ): any[] {
864
+ items: readonly BatchReadItem<unknown>[],
865
+ scope: StorageScope,
866
+ ): unknown[] {
867
+ assertBatchScope(items, scope);
868
+
203
869
  if (scope === StorageScope.Memory) {
204
870
  return items.map((item) => item.get());
205
871
  }
206
872
 
207
- const keys = items.map((item) => item.key);
208
- const rawValues = getStorageModule().getBatch(keys, scope);
873
+ const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
874
+ if (!useRawBatchPath) {
875
+ return items.map((item) => item.get());
876
+ }
877
+ const useBatchCache = items.every((item) => item._readCacheEnabled === true);
878
+
879
+ const rawValues = new Array<string | undefined>(items.length);
880
+ const keysToFetch: string[] = [];
881
+ const keyIndexes: number[] = [];
882
+
883
+ items.forEach((item, index) => {
884
+ if (scope === StorageScope.Secure) {
885
+ if (hasPendingSecureWrite(item.key)) {
886
+ rawValues[index] = readPendingSecureWrite(item.key);
887
+ return;
888
+ }
889
+ }
890
+
891
+ if (useBatchCache) {
892
+ if (hasCachedRawValue(scope, item.key)) {
893
+ rawValues[index] = readCachedRawValue(scope, item.key);
894
+ return;
895
+ }
896
+ }
897
+
898
+ keysToFetch.push(item.key);
899
+ keyIndexes.push(index);
900
+ });
901
+
902
+ if (keysToFetch.length > 0) {
903
+ const fetchedValues = getStorageModule()
904
+ .getBatch(keysToFetch, scope)
905
+ .map((value) => decodeNativeBatchValue(value));
209
906
 
210
- return items.map((item, idx) => {
211
- const raw = rawValues[idx];
907
+ fetchedValues.forEach((value, index) => {
908
+ const key = keysToFetch[index];
909
+ const targetIndex = keyIndexes[index];
910
+ rawValues[targetIndex] = value;
911
+ cacheRawValue(scope, key, value);
912
+ });
913
+ }
914
+
915
+ return items.map((item, index) => {
916
+ const raw = rawValues[index];
212
917
  if (raw === undefined) {
213
918
  return item.get();
214
919
  }
@@ -216,35 +921,189 @@ export function getBatch(
216
921
  });
217
922
  }
218
923
 
219
- export function setBatch(
220
- items: { item: StorageItem<any>; value: any }[],
221
- scope: StorageScope
924
+ export function setBatch<T>(
925
+ items: readonly StorageBatchSetItem<T>[],
926
+ scope: StorageScope,
222
927
  ): void {
928
+ assertBatchScope(
929
+ items.map((batchEntry) => batchEntry.item),
930
+ scope,
931
+ );
932
+
223
933
  if (scope === StorageScope.Memory) {
224
934
  items.forEach(({ item, value }) => item.set(value));
225
935
  return;
226
936
  }
227
937
 
228
- const keys = items.map((i) => i.item.key);
229
- const values = items.map((i) => i.item.serialize(i.value));
938
+ const useRawBatchPath = items.every(({ item }) =>
939
+ canUseRawBatchPath(asInternal(item)),
940
+ );
941
+ if (!useRawBatchPath) {
942
+ items.forEach(({ item, value }) => item.set(value));
943
+ return;
944
+ }
230
945
 
231
- getStorageModule().setBatch(keys, values, scope);
946
+ const keys = items.map((entry) => entry.item.key);
947
+ const values = items.map((entry) => entry.item.serialize(entry.value));
232
948
 
233
- items.forEach(({ item }) => {
234
- item._triggerListeners();
235
- });
949
+ if (scope === StorageScope.Secure) {
950
+ flushSecureWrites();
951
+ getStorageModule().setSecureAccessControl(secureDefaultAccessControl);
952
+ }
953
+ getStorageModule().setBatch(keys, values, scope);
954
+ keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
236
955
  }
237
956
 
238
957
  export function removeBatch(
239
- items: StorageItem<any>[],
240
- scope: StorageScope
958
+ items: readonly BatchRemoveItem[],
959
+ scope: StorageScope,
241
960
  ): void {
961
+ assertBatchScope(items, scope);
962
+
242
963
  if (scope === StorageScope.Memory) {
243
964
  items.forEach((item) => item.delete());
244
965
  return;
245
966
  }
246
967
 
247
968
  const keys = items.map((item) => item.key);
969
+ if (scope === StorageScope.Secure) {
970
+ flushSecureWrites();
971
+ }
248
972
  getStorageModule().removeBatch(keys, scope);
249
- items.forEach((item) => item.delete());
973
+ keys.forEach((key) => cacheRawValue(scope, key, undefined));
974
+ }
975
+
976
+ export function registerMigration(version: number, migration: Migration): void {
977
+ if (!Number.isInteger(version) || version <= 0) {
978
+ throw new Error("Migration version must be a positive integer.");
979
+ }
980
+
981
+ if (registeredMigrations.has(version)) {
982
+ throw new Error(`Migration version ${version} is already registered.`);
983
+ }
984
+
985
+ registeredMigrations.set(version, migration);
986
+ }
987
+
988
+ export function migrateToLatest(
989
+ scope: StorageScope = StorageScope.Disk,
990
+ ): number {
991
+ assertValidScope(scope);
992
+ const currentVersion = readMigrationVersion(scope);
993
+ const versions = Array.from(registeredMigrations.keys())
994
+ .filter((version) => version > currentVersion)
995
+ .sort((a, b) => a - b);
996
+
997
+ let appliedVersion = currentVersion;
998
+ const context: MigrationContext = {
999
+ scope,
1000
+ getRaw: (key) => getRawValue(key, scope),
1001
+ setRaw: (key, value) => setRawValue(key, value, scope),
1002
+ removeRaw: (key) => removeRawValue(key, scope),
1003
+ };
1004
+
1005
+ versions.forEach((version) => {
1006
+ const migration = registeredMigrations.get(version);
1007
+ if (!migration) {
1008
+ return;
1009
+ }
1010
+ migration(context);
1011
+ writeMigrationVersion(scope, version);
1012
+ appliedVersion = version;
1013
+ });
1014
+
1015
+ return appliedVersion;
1016
+ }
1017
+
1018
+ export function runTransaction<T>(
1019
+ scope: StorageScope,
1020
+ transaction: (context: TransactionContext) => T,
1021
+ ): T {
1022
+ assertValidScope(scope);
1023
+ if (scope === StorageScope.Secure) {
1024
+ flushSecureWrites();
1025
+ }
1026
+
1027
+ const rollback = new Map<string, string | undefined>();
1028
+
1029
+ const rememberRollback = (key: string) => {
1030
+ if (rollback.has(key)) {
1031
+ return;
1032
+ }
1033
+ rollback.set(key, getRawValue(key, scope));
1034
+ };
1035
+
1036
+ const tx: TransactionContext = {
1037
+ scope,
1038
+ getRaw: (key) => getRawValue(key, scope),
1039
+ setRaw: (key, value) => {
1040
+ rememberRollback(key);
1041
+ setRawValue(key, value, scope);
1042
+ },
1043
+ removeRaw: (key) => {
1044
+ rememberRollback(key);
1045
+ removeRawValue(key, scope);
1046
+ },
1047
+ getItem: (item) => {
1048
+ assertBatchScope([item], scope);
1049
+ return item.get();
1050
+ },
1051
+ setItem: (item, value) => {
1052
+ assertBatchScope([item], scope);
1053
+ rememberRollback(item.key);
1054
+ item.set(value);
1055
+ },
1056
+ removeItem: (item) => {
1057
+ assertBatchScope([item], scope);
1058
+ rememberRollback(item.key);
1059
+ item.delete();
1060
+ },
1061
+ };
1062
+
1063
+ try {
1064
+ return transaction(tx);
1065
+ } catch (error) {
1066
+ Array.from(rollback.entries())
1067
+ .reverse()
1068
+ .forEach(([key, previousValue]) => {
1069
+ if (previousValue === undefined) {
1070
+ removeRawValue(key, scope);
1071
+ } else {
1072
+ setRawValue(key, previousValue, scope);
1073
+ }
1074
+ });
1075
+ throw error;
1076
+ }
1077
+ }
1078
+
1079
+ export type SecureAuthStorageConfig<K extends string = string> = Record<
1080
+ K,
1081
+ {
1082
+ ttlMs?: number;
1083
+ biometric?: boolean;
1084
+ accessControl?: AccessControl;
1085
+ }
1086
+ >;
1087
+
1088
+ export function createSecureAuthStorage<K extends string>(
1089
+ config: SecureAuthStorageConfig<K>,
1090
+ options?: { namespace?: string },
1091
+ ): Record<K, StorageItem<string>> {
1092
+ const ns = options?.namespace ?? "auth";
1093
+ const result = {} as Record<K, StorageItem<string>>;
1094
+
1095
+ for (const key of Object.keys(config) as K[]) {
1096
+ const itemConfig = config[key];
1097
+ result[key] = createStorageItem<string>({
1098
+ key,
1099
+ scope: StorageScope.Secure,
1100
+ defaultValue: "",
1101
+ namespace: ns,
1102
+ biometric: itemConfig.biometric,
1103
+ accessControl: itemConfig.accessControl,
1104
+ expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
1105
+ });
1106
+ }
1107
+
1108
+ return result;
250
1109
  }