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