react-native-nitro-storage 0.1.3 → 0.3.0

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