strata-storage 2.4.3 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AI-INTEGRATION-GUIDE.md +115 -261
  2. package/README.md +426 -182
  3. package/android/AGENTS.md +34 -0
  4. package/android/CLAUDE.md +51 -0
  5. package/android/src/main/java/com/strata/storage/SQLiteStorage.java +35 -0
  6. package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +191 -27
  7. package/dist/README.md +426 -182
  8. package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
  9. package/dist/adapters/capacitor/FilesystemAdapter.js +2 -1
  10. package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
  11. package/dist/adapters/capacitor/PreferencesAdapter.js +2 -1
  12. package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
  13. package/dist/adapters/capacitor/SecureAdapter.js +2 -1
  14. package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
  15. package/dist/adapters/capacitor/SqliteAdapter.js +2 -1
  16. package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
  17. package/dist/adapters/web/CacheAdapter.js +11 -3
  18. package/dist/adapters/web/CookieAdapter.d.ts +37 -1
  19. package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
  20. package/dist/adapters/web/CookieAdapter.js +89 -9
  21. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  22. package/dist/adapters/web/IndexedDBAdapter.js +10 -2
  23. package/dist/adapters/web/LocalStorageAdapter.d.ts +31 -0
  24. package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
  25. package/dist/adapters/web/LocalStorageAdapter.js +92 -19
  26. package/dist/adapters/web/MemoryAdapter.d.ts +24 -0
  27. package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
  28. package/dist/adapters/web/MemoryAdapter.js +69 -18
  29. package/dist/adapters/web/SessionStorageAdapter.d.ts +24 -0
  30. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  31. package/dist/adapters/web/SessionStorageAdapter.js +71 -9
  32. package/dist/adapters/web/URLAdapter.d.ts +59 -0
  33. package/dist/adapters/web/URLAdapter.d.ts.map +1 -0
  34. package/dist/adapters/web/URLAdapter.js +234 -0
  35. package/dist/adapters/web/index.d.ts +1 -0
  36. package/dist/adapters/web/index.d.ts.map +1 -1
  37. package/dist/adapters/web/index.js +1 -0
  38. package/dist/android/AGENTS.md +34 -0
  39. package/dist/android/CLAUDE.md +51 -0
  40. package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +35 -0
  41. package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +191 -27
  42. package/dist/capacitor.d.ts.map +1 -1
  43. package/dist/capacitor.js +2 -1
  44. package/dist/core/BaseAdapter.d.ts +8 -0
  45. package/dist/core/BaseAdapter.d.ts.map +1 -1
  46. package/dist/core/BaseAdapter.js +34 -14
  47. package/dist/core/Strata.d.ts +56 -2
  48. package/dist/core/Strata.d.ts.map +1 -1
  49. package/dist/core/Strata.js +501 -53
  50. package/dist/features/encryption.d.ts.map +1 -1
  51. package/dist/features/encryption.js +3 -2
  52. package/dist/features/integrity.d.ts +16 -0
  53. package/dist/features/integrity.d.ts.map +1 -0
  54. package/dist/features/integrity.js +28 -0
  55. package/dist/features/observer.d.ts.map +1 -1
  56. package/dist/features/observer.js +2 -1
  57. package/dist/features/query.d.ts +7 -1
  58. package/dist/features/query.d.ts.map +1 -1
  59. package/dist/features/query.js +9 -2
  60. package/dist/features/sync.d.ts.map +1 -1
  61. package/dist/features/sync.js +4 -3
  62. package/dist/index.d.ts +35 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +55 -30
  65. package/dist/integrations/angular/index.d.ts +158 -0
  66. package/dist/integrations/angular/index.d.ts.map +1 -0
  67. package/dist/integrations/angular/index.js +395 -0
  68. package/dist/integrations/index.d.ts +15 -0
  69. package/dist/integrations/index.d.ts.map +1 -0
  70. package/dist/integrations/index.js +18 -0
  71. package/dist/integrations/react/index.d.ts +75 -0
  72. package/dist/integrations/react/index.d.ts.map +1 -0
  73. package/dist/integrations/react/index.js +191 -0
  74. package/dist/integrations/vue/index.d.ts +103 -0
  75. package/dist/integrations/vue/index.d.ts.map +1 -0
  76. package/dist/integrations/vue/index.js +274 -0
  77. package/dist/ios/AGENTS.md +33 -0
  78. package/dist/ios/CLAUDE.md +49 -0
  79. package/dist/ios/Plugin/KeychainStorage.swift +139 -50
  80. package/dist/ios/Plugin/SQLiteStorage.swift +40 -0
  81. package/dist/ios/Plugin/StrataStoragePlugin.m +23 -0
  82. package/dist/ios/Plugin/StrataStoragePlugin.swift +201 -52
  83. package/dist/package.json +21 -5
  84. package/dist/plugin/index.d.ts.map +1 -1
  85. package/dist/plugin/index.js +2 -1
  86. package/dist/types/index.d.ts +58 -9
  87. package/dist/types/index.d.ts.map +1 -1
  88. package/dist/types/index.js +0 -13
  89. package/dist/utils/errors.d.ts +7 -0
  90. package/dist/utils/errors.d.ts.map +1 -1
  91. package/dist/utils/errors.js +15 -3
  92. package/dist/utils/index.d.ts +63 -5
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +109 -16
  95. package/dist/utils/logger.d.ts +31 -0
  96. package/dist/utils/logger.d.ts.map +1 -0
  97. package/dist/utils/logger.js +63 -0
  98. package/ios/AGENTS.md +33 -0
  99. package/ios/CLAUDE.md +49 -0
  100. package/ios/Plugin/KeychainStorage.swift +139 -50
  101. package/ios/Plugin/SQLiteStorage.swift +40 -0
  102. package/ios/Plugin/StrataStoragePlugin.m +23 -0
  103. package/ios/Plugin/StrataStoragePlugin.swift +201 -52
  104. package/package.json +31 -20
  105. package/scripts/build.js +16 -5
  106. package/scripts/configure.js +2 -6
  107. package/scripts/postinstall.js +2 -2
  108. package/Readme.md +0 -271
@@ -3,12 +3,19 @@
3
3
  * Zero-dependency universal storage solution
4
4
  */
5
5
  import { AdapterRegistry } from "./AdapterRegistry.js";
6
- import { isBrowser, isNode, deepMerge } from "../utils/index.js";
7
- import { StorageError, EncryptionError } from "../utils/errors.js";
6
+ import { isBrowser, isNode, deepMerge, isObject, isSafeKey } from "../utils/index.js";
7
+ import { logger, setLogLevel, exposeLogLevelControls } from "../utils/logger.js";
8
+ import { StorageError, EncryptionError, IntegrityError } from "../utils/errors.js";
9
+ import { computeChecksum, verifyChecksum } from "../features/integrity.js";
8
10
  import { EncryptionManager } from "../features/encryption.js";
9
11
  import { CompressionManager } from "../features/compression.js";
10
12
  import { SyncManager } from "../features/sync.js";
11
13
  import { TTLManager } from "../features/ttl.js";
14
+ const VERBOSITY_TO_LEVEL = {
15
+ minimal: 'warn',
16
+ normal: 'info',
17
+ verbose: 'debug',
18
+ };
12
19
  /**
13
20
  * Main Strata class - unified storage interface
14
21
  */
@@ -23,8 +30,14 @@ export class Strata {
23
30
  syncManager;
24
31
  ttlManager;
25
32
  _initialized = false;
33
+ _readyPromise;
34
+ _autoBackupTimer;
26
35
  constructor(config = {}) {
27
36
  this.config = this.normalizeConfig(config);
37
+ if (this.config.debug?.enabled) {
38
+ setLogLevel(VERBOSITY_TO_LEVEL[this.config.debug.verbosity ?? 'normal']);
39
+ exposeLogLevelControls();
40
+ }
28
41
  this._platform = this.detectPlatform();
29
42
  this.registry = new AdapterRegistry();
30
43
  }
@@ -41,20 +54,33 @@ export class Strata {
41
54
  return this._platform;
42
55
  }
43
56
  /**
44
- * Initialize Strata with available adapters
57
+ * Initialize Strata with available adapters. Idempotent — repeated calls
58
+ * return the same in-flight/completed promise, so it is safe to call from a
59
+ * framework Provider, from defineStorage(), or lazily on first operation.
45
60
  */
46
61
  async initialize() {
47
- // No automatic adapter registration - adapters should be registered before initialize()
48
- // This allows for zero-dependency operation and explicit opt-in for features
49
- // Find and set default adapter
50
- await this.selectDefaultAdapter();
51
- // Initialize configured adapters
62
+ if (!this._readyPromise) {
63
+ this._readyPromise = this._performInitialization();
64
+ }
65
+ return this._readyPromise;
66
+ }
67
+ async _performInitialization() {
68
+ // Initialize every registered adapter that is available on this platform, so
69
+ // multi-adapter operations (keys/clear/size/subscribe without an explicit
70
+ // `storage`) span everything the user registered — not just the default.
52
71
  await this.initializeAdapters();
72
+ // Pick the default adapter (first available in the configured preference order).
73
+ this.selectDefaultAdapter();
74
+ if (!this.defaultAdapter) {
75
+ throw new StorageError(`No available storage adapters. Configured preference: ` +
76
+ `${(this.config.defaultStorages ?? []).join(', ') || '(none)'}. ` +
77
+ `Registered: ${this.registry.getNames().join(', ') || '(none)'}.`);
78
+ }
53
79
  // Initialize encryption if enabled
54
80
  if (this.config.encryption?.enabled) {
55
81
  this.encryptionManager = new EncryptionManager(this.config.encryption);
56
82
  if (!this.encryptionManager.isAvailable()) {
57
- console.warn('Encryption enabled but Web Crypto API not available');
83
+ logger.warn('Encryption enabled but Web Crypto API not available');
58
84
  }
59
85
  }
60
86
  // Initialize compression if enabled
@@ -65,10 +91,11 @@ export class Strata {
65
91
  if (this.config.sync?.enabled) {
66
92
  this.syncManager = new SyncManager(this.config.sync);
67
93
  await this.syncManager.initialize();
68
- // Subscribe to sync events
69
- this.syncManager.subscribe((_change) => {
70
- // Forward sync events to subscribers
71
- // The adapters will handle their own change events
94
+ // Apply changes received from other tabs/devices to the matching local
95
+ // adapter so BroadcastChannel-only backends (memory/indexedDB/cache) stay
96
+ // in sync, then let that adapter notify local subscribers.
97
+ this.syncManager.subscribe((change) => {
98
+ void this.applyRemoteChange(change);
72
99
  });
73
100
  }
74
101
  // Initialize TTL manager
@@ -77,9 +104,30 @@ export class Strata {
77
104
  if (this.defaultAdapter && this.config.ttl?.autoCleanup !== false) {
78
105
  this.ttlManager.startAutoCleanup(() => this.defaultAdapter.keys(), (key) => this.defaultAdapter.get(key), (key) => this.defaultAdapter.remove(key));
79
106
  }
107
+ // Start periodic auto-backup if configured
108
+ if (this.config.autoBackup?.interval) {
109
+ this.startAutoBackup();
110
+ }
80
111
  // Mark as initialized
81
112
  this._initialized = true;
82
113
  }
114
+ /**
115
+ * Ensure the instance is initialized before an operation runs. This powers the
116
+ * "create an instance and use it anywhere" pattern — callers never have to
117
+ * await initialize() manually unless autoInitialize is explicitly disabled.
118
+ */
119
+ async ensureReady() {
120
+ if (this._initialized)
121
+ return;
122
+ if (this._readyPromise) {
123
+ await this._readyPromise;
124
+ return;
125
+ }
126
+ if (this.config.autoInitialize === false) {
127
+ throw new StorageError('Strata is not initialized. Call initialize() first (autoInitialize is disabled).');
128
+ }
129
+ await this.initialize();
130
+ }
83
131
  /**
84
132
  * Get a value from storage
85
133
  *
@@ -123,6 +171,19 @@ export class Strata {
123
171
  await adapter.set(key, updatedValue);
124
172
  }
125
173
  }
174
+ // Verify integrity; on corruption, try mirror read-repair, then honor the
175
+ // ignoreCorruption option, else surface a typed IntegrityError.
176
+ if (value.checksum && !verifyChecksum(value.value, value.checksum)) {
177
+ const repaired = await this.repairFromMirror(key, options);
178
+ if (repaired !== undefined)
179
+ return repaired;
180
+ if (options?.ignoreCorruption)
181
+ return null;
182
+ throw new IntegrityError(`Integrity check failed for key "${key}" (data may be corrupted)`, {
183
+ key,
184
+ storage: adapter.name,
185
+ });
186
+ }
126
187
  // Handle decryption if needed
127
188
  if (value.encrypted && this.encryptionManager) {
128
189
  try {
@@ -137,7 +198,7 @@ export class Strata {
137
198
  }
138
199
  catch (error) {
139
200
  if (options?.ignoreDecryptionErrors) {
140
- console.warn(`Failed to decrypt key ${key}:`, error);
201
+ logger.warn(`Failed to decrypt key ${key}:`, error);
141
202
  return null;
142
203
  }
143
204
  throw error;
@@ -150,7 +211,7 @@ export class Strata {
150
211
  return decompressed;
151
212
  }
152
213
  catch (error) {
153
- console.warn(`Failed to decompress key ${key}:`, error);
214
+ logger.warn(`Failed to decompress key ${key}:`, error);
154
215
  return value.value;
155
216
  }
156
217
  }
@@ -229,7 +290,17 @@ export class Strata {
229
290
  encrypted: encrypted,
230
291
  compressed: compressed,
231
292
  };
293
+ // Integrity checksum over the stored (processed) value, when enabled.
294
+ if (this.config.integrity || options?.verify) {
295
+ storageValue.checksum = computeChecksum(processedValue);
296
+ }
232
297
  await adapter.set(key, storageValue);
298
+ // Durable write: read back and verify, retrying on mismatch.
299
+ if (this.config.durableWrites || options?.durable) {
300
+ await this.verifyDurableWrite(adapter, key, storageValue);
301
+ }
302
+ // Mirror the write to any configured backup adapters.
303
+ await this.mirrorWrite(key, storageValue, adapter.name);
233
304
  // Broadcast change for sync
234
305
  if (this.syncManager) {
235
306
  this.syncManager.broadcast({
@@ -261,6 +332,8 @@ export class Strata {
261
332
  async remove(key, options) {
262
333
  const adapter = await this.selectAdapter(options?.storage);
263
334
  await adapter.remove(key);
335
+ // Mirror the removal to any configured backup adapters.
336
+ await this.mirrorRemove(key, adapter.name);
264
337
  // Broadcast removal for sync
265
338
  if (this.syncManager) {
266
339
  this.syncManager.broadcast({
@@ -320,6 +393,7 @@ export class Strata {
320
393
  * ```
321
394
  */
322
395
  async clear(options) {
396
+ await this.ensureReady();
323
397
  if (options?.storage) {
324
398
  const adapter = await this.selectAdapter(options.storage);
325
399
  await adapter.clear(options);
@@ -356,6 +430,7 @@ export class Strata {
356
430
  * ```
357
431
  */
358
432
  async keys(pattern, options) {
433
+ await this.ensureReady();
359
434
  if (options?.storage) {
360
435
  const adapter = await this.selectAdapter(options.storage);
361
436
  return adapter.keys(pattern);
@@ -368,10 +443,169 @@ export class Strata {
368
443
  }
369
444
  return Array.from(allKeys);
370
445
  }
446
+ // ===========================================================================
447
+ // Synchronous API
448
+ //
449
+ // Works only on sync-capable adapters (memory, localStorage, sessionStorage,
450
+ // cookies, url). Throws a clear error on async-only backends (indexedDB,
451
+ // cache, sqlite, filesystem, secure, preferences) and when encryption or
452
+ // compression is requested (those are inherently async — use the async API).
453
+ // No await is needed; the adapter lookup falls back to the registry so sync
454
+ // calls work even before async initialize() has completed.
455
+ // ===========================================================================
456
+ /** Synchronous get. Throws on async-only backends and on encrypted/compressed values. */
457
+ getSync(key, options) {
458
+ const adapter = this.requireSyncAdapter(options?.storage);
459
+ const value = adapter.getSync(key);
460
+ if (!value)
461
+ return null;
462
+ if (value.encrypted || value.compressed) {
463
+ throw new StorageError(`Cannot synchronously read encrypted/compressed key "${key}". Use the async get() instead.`);
464
+ }
465
+ return value.value;
466
+ }
467
+ /** Synchronous set. Cannot encrypt or compress (those operations are async). */
468
+ setSync(key, value, options) {
469
+ if (options?.encrypt || options?.compress) {
470
+ throw new StorageError('Synchronous set cannot encrypt or compress (those operations are async). Use the async set() instead.');
471
+ }
472
+ const adapter = this.requireSyncAdapter(options?.storage);
473
+ const now = Date.now();
474
+ const storageValue = {
475
+ value,
476
+ created: now,
477
+ updated: now,
478
+ expires: this.computeExpiration(options),
479
+ tags: options?.tags,
480
+ metadata: options?.metadata,
481
+ encrypted: false,
482
+ compressed: false,
483
+ };
484
+ adapter.setSync(key, storageValue);
485
+ if (this.syncManager) {
486
+ this.syncManager.broadcast({
487
+ type: 'set',
488
+ key,
489
+ value: storageValue,
490
+ storage: adapter.name,
491
+ timestamp: now,
492
+ });
493
+ }
494
+ }
495
+ /** Synchronous remove. */
496
+ removeSync(key, options) {
497
+ const adapter = this.requireSyncAdapter(options?.storage);
498
+ adapter.removeSync(key);
499
+ if (this.syncManager) {
500
+ this.syncManager.broadcast({
501
+ type: 'remove',
502
+ key,
503
+ storage: adapter.name,
504
+ timestamp: Date.now(),
505
+ });
506
+ }
507
+ }
508
+ /** Synchronous existence check. */
509
+ hasSync(key, options) {
510
+ return this.requireSyncAdapter(options?.storage).hasSync(key);
511
+ }
512
+ /** Synchronous keys. With no `storage`, aggregates across sync-capable adapters. */
513
+ keysSync(pattern, options) {
514
+ if (options?.storage) {
515
+ return this.requireSyncAdapter(options.storage).keysSync(pattern);
516
+ }
517
+ const all = new Set();
518
+ for (const adapter of this.syncCapableAdapters()) {
519
+ try {
520
+ for (const key of adapter.keysSync(pattern)) {
521
+ all.add(key);
522
+ }
523
+ }
524
+ catch (error) {
525
+ // An adapter may be registered but unusable in this environment (e.g.
526
+ // localStorage during SSR) — skipping it is expected, not a problem.
527
+ logger.debug(`Synchronous keys: skipped unavailable adapter "${adapter.name}"`, error);
528
+ }
529
+ }
530
+ return Array.from(all);
531
+ }
532
+ /** Synchronous clear. With no `storage`, clears all sync-capable adapters. */
533
+ clearSync(options) {
534
+ if (options?.storage) {
535
+ this.requireSyncAdapter(options.storage).clearSync(options);
536
+ return;
537
+ }
538
+ for (const adapter of this.syncCapableAdapters()) {
539
+ try {
540
+ adapter.clearSync(options);
541
+ }
542
+ catch (error) {
543
+ logger.debug(`Synchronous clear: skipped unavailable adapter "${adapter.name}"`, error);
544
+ }
545
+ }
546
+ }
547
+ // Resolve a single sync-capable adapter or throw a clear, actionable error.
548
+ requireSyncAdapter(storage) {
549
+ const adapter = this.selectAdapterSync(storage);
550
+ if (!adapter.capabilities.synchronous || !adapter.getSync) {
551
+ throw new StorageError(`Storage "${adapter.name}" does not support synchronous operations. Use the async API, ` +
552
+ `or target a sync-capable adapter (memory, localStorage, sessionStorage, cookies, url).`);
553
+ }
554
+ return adapter;
555
+ }
556
+ // Synchronous adapter lookup — falls back to the registry so sync operations
557
+ // work even before async initialize() has completed.
558
+ selectAdapterSync(storage) {
559
+ if (storage) {
560
+ const names = Array.isArray(storage) ? storage : [storage];
561
+ for (const name of names) {
562
+ const adapter = this.adapters.get(name) ?? this.registry.get(name);
563
+ if (adapter)
564
+ return adapter;
565
+ }
566
+ throw new StorageError(`No adapter registered for storage type(s): ${names.join(', ')}`);
567
+ }
568
+ if (this.defaultAdapter)
569
+ return this.defaultAdapter;
570
+ const preferred = this.config.defaultStorages ?? [];
571
+ for (const name of preferred) {
572
+ const adapter = this.adapters.get(name) ?? this.registry.get(name);
573
+ if (adapter)
574
+ return adapter;
575
+ }
576
+ const first = this.registry.getAll().values().next().value;
577
+ if (first)
578
+ return first;
579
+ throw new StorageError('No storage adapter registered for synchronous operation.');
580
+ }
581
+ // All sync-capable adapters (initialized set, or the registry before init).
582
+ syncCapableAdapters() {
583
+ const source = this.adapters.size > 0 ? this.adapters.values() : this.registry.getAll().values();
584
+ const result = [];
585
+ for (const adapter of source) {
586
+ if (adapter.capabilities.synchronous && adapter.keysSync && adapter.clearSync) {
587
+ result.push(adapter);
588
+ }
589
+ }
590
+ return result;
591
+ }
592
+ // Compute an expiration timestamp from options without requiring the TTL
593
+ // manager (which only exists after initialize()).
594
+ computeExpiration(options) {
595
+ if (this.ttlManager)
596
+ return this.ttlManager.calculateExpiration(options);
597
+ if (typeof options?.ttl === 'number')
598
+ return Date.now() + options.ttl;
599
+ if (options?.expireAt !== undefined) {
600
+ return typeof options.expireAt === 'number' ? options.expireAt : options.expireAt.getTime();
601
+ }
602
+ return undefined;
603
+ }
371
604
  /**
372
605
  * Get storage size information
373
606
  */
374
607
  async size(detailed) {
608
+ await this.ensureReady();
375
609
  let total = 0;
376
610
  let count = 0;
377
611
  const byStorage = {};
@@ -402,24 +636,38 @@ export class Strata {
402
636
  * Subscribe to storage changes
403
637
  */
404
638
  subscribe(callback, options) {
639
+ let cancelled = false;
405
640
  const unsubscribers = [];
406
- if (options?.storage) {
407
- const adapter = this.adapters.get(options.storage);
408
- if (adapter?.subscribe) {
409
- unsubscribers.push(adapter.subscribe(callback));
410
- }
411
- }
412
- else {
413
- // Subscribe to all adapters that support it
414
- for (const adapter of this.adapters.values()) {
415
- if (adapter.subscribe) {
641
+ const attach = () => {
642
+ if (cancelled)
643
+ return;
644
+ const targets = options?.storage !== undefined
645
+ ? [this.adapters.get(options.storage)]
646
+ : Array.from(this.adapters.values());
647
+ for (const adapter of targets) {
648
+ if (adapter?.subscribe) {
416
649
  unsubscribers.push(adapter.subscribe(callback));
417
650
  }
418
651
  }
652
+ };
653
+ // Attach now if ready; otherwise once initialization completes — so a
654
+ // subscription created before initialization still receives changes.
655
+ if (this._initialized) {
656
+ attach();
419
657
  }
420
- // Return function to unsubscribe from all
658
+ else {
659
+ void this.ensureReady()
660
+ .then(attach)
661
+ .catch(() => {
662
+ /* init errors surface through operations, not subscriptions */
663
+ });
664
+ }
665
+ // Return a function that unsubscribes from all (even if attach ran later).
421
666
  return () => {
422
- unsubscribers.forEach((unsub) => unsub());
667
+ cancelled = true;
668
+ while (unsubscribers.length > 0) {
669
+ unsubscribers.pop()?.();
670
+ }
423
671
  };
424
672
  }
425
673
  /**
@@ -465,8 +713,21 @@ export class Strata {
465
713
  if (format !== 'json') {
466
714
  throw new StorageError(`Import format ${format} not supported`);
467
715
  }
468
- const parsed = JSON.parse(data);
716
+ let parsed;
717
+ try {
718
+ parsed = JSON.parse(data);
719
+ }
720
+ catch {
721
+ throw new StorageError('Cannot import: data is not valid JSON');
722
+ }
723
+ if (!isObject(parsed)) {
724
+ throw new StorageError('Cannot import: expected a JSON object of key/value pairs');
725
+ }
469
726
  for (const [key, value] of Object.entries(parsed)) {
727
+ // Never write a prototype-pollution key as a storage key, and never let
728
+ // it reach deepMerge below.
729
+ if (!isSafeKey(key))
730
+ continue;
470
731
  const exists = await this.has(key);
471
732
  if (!exists || options?.overwrite) {
472
733
  await this.set(key, value);
@@ -474,7 +735,8 @@ export class Strata {
474
735
  else if (options?.merge) {
475
736
  const existing = await this.get(key);
476
737
  if (options.merge === 'deep' && typeof existing === 'object' && typeof value === 'object') {
477
- // Use deep merge utility for proper nested object merging
738
+ // Use deep merge utility for proper nested object merging.
739
+ // deepMerge itself strips prototype-pollution keys defensively.
478
740
  const merged = deepMerge(existing, value);
479
741
  await this.set(key, merged);
480
742
  }
@@ -484,6 +746,67 @@ export class Strata {
484
746
  }
485
747
  }
486
748
  }
749
+ /**
750
+ * Create a portable, integrity-verified snapshot of all stored data. The
751
+ * returned string embeds a manifest (version, timestamp, checksum) so
752
+ * restore() can detect a corrupted backup. Pair with config.autoBackup for
753
+ * scheduled snapshots.
754
+ */
755
+ async snapshot(options) {
756
+ const payload = await this.export({ includeMetadata: true, ...options });
757
+ const manifest = {
758
+ __strataSnapshot: 1,
759
+ createdAt: Date.now(),
760
+ checksum: computeChecksum(payload),
761
+ payload,
762
+ };
763
+ return JSON.stringify(manifest);
764
+ }
765
+ /**
766
+ * Restore data from a snapshot() string. Validates the manifest checksum and
767
+ * throws IntegrityError if the backup is corrupted. A raw export string (no
768
+ * manifest) is also accepted.
769
+ */
770
+ async restore(snapshot, options) {
771
+ let parsed;
772
+ try {
773
+ parsed = JSON.parse(snapshot);
774
+ }
775
+ catch {
776
+ throw new IntegrityError('Cannot restore: snapshot is not valid JSON');
777
+ }
778
+ const manifest = parsed;
779
+ // A plain export string (no manifest) — import directly.
780
+ if (!manifest || manifest.__strataSnapshot === undefined) {
781
+ await this.import(snapshot, { overwrite: true, ...options });
782
+ return;
783
+ }
784
+ if (typeof manifest.payload !== 'string' ||
785
+ typeof manifest.checksum !== 'string' ||
786
+ !verifyChecksum(manifest.payload, manifest.checksum)) {
787
+ throw new IntegrityError('Cannot restore: snapshot checksum mismatch (backup is corrupted)');
788
+ }
789
+ // Snapshots embed full value wrappers (metadata + checksum), so write them
790
+ // back directly — going through set() would re-wrap them and lose TTL, tags,
791
+ // encryption flags, and checksums.
792
+ let data;
793
+ try {
794
+ data = JSON.parse(manifest.payload);
795
+ }
796
+ catch {
797
+ throw new IntegrityError('Cannot restore: snapshot payload is not valid JSON');
798
+ }
799
+ if (!isObject(data)) {
800
+ throw new IntegrityError('Cannot restore: snapshot payload is not an object');
801
+ }
802
+ const adapter = await this.selectAdapter();
803
+ for (const [key, wrapper] of Object.entries(data)) {
804
+ // Skip prototype-pollution keys; never use them as a storage key.
805
+ if (!isSafeKey(key))
806
+ continue;
807
+ await adapter.set(key, wrapper);
808
+ }
809
+ }
487
810
  /**
488
811
  * Get available storage types
489
812
  */
@@ -608,6 +931,10 @@ export class Strata {
608
931
  * Close all adapters
609
932
  */
610
933
  async close() {
934
+ if (this._autoBackupTimer) {
935
+ clearInterval(this._autoBackupTimer);
936
+ this._autoBackupTimer = undefined;
937
+ }
611
938
  for (const adapter of this.adapters.values()) {
612
939
  if (adapter.close) {
613
940
  await adapter.close();
@@ -654,41 +981,162 @@ export class Strata {
654
981
  }
655
982
  return (available.length > 0 ? available : registered);
656
983
  }
657
- async selectDefaultAdapter() {
658
- const storages = this.config.defaultStorages || this.getDefaultStorages();
659
- if (storages.length === 0) {
660
- throw new StorageError('No storage adapters registered or configured');
661
- }
662
- for (const storage of storages) {
984
+ async initializeAdapters() {
985
+ for (const [name, adapter] of this.registry.getAll()) {
986
+ if (this.adapters.has(name))
987
+ continue;
988
+ // Honor an explicit `adapters: { <name>: false }` opt-out.
989
+ const rawConfig = this.config.adapters?.[name];
990
+ if (rawConfig === false)
991
+ continue;
663
992
  try {
664
- const adapter = this.registry.get(storage);
665
- if (!adapter) {
666
- continue;
667
- }
668
- const isAvailable = await adapter.isAvailable();
669
- if (!isAvailable) {
993
+ if (!(await adapter.isAvailable()))
670
994
  continue;
671
- }
672
- // Initialize adapter with config if provided
673
- const config = this.config.adapters?.[storage];
674
- await adapter.initialize(config);
995
+ const adapterConfig = typeof rawConfig === 'object' ? rawConfig : undefined;
996
+ await adapter.initialize(adapterConfig);
997
+ this.adapters.set(name, adapter);
998
+ }
999
+ catch (error) {
1000
+ logger.warn(`Failed to initialize ${name} adapter:`, error);
1001
+ }
1002
+ }
1003
+ }
1004
+ selectDefaultAdapter() {
1005
+ const preferred = this.config.defaultStorages ?? this.getDefaultStorages();
1006
+ for (const name of preferred) {
1007
+ const adapter = this.adapters.get(name);
1008
+ if (adapter) {
675
1009
  this.defaultAdapter = adapter;
676
- this.adapters.set(storage, adapter);
677
1010
  return;
678
1011
  }
1012
+ }
1013
+ // Fall back to any initialized adapter so a misconfigured preference list
1014
+ // still yields a usable default instead of leaving the instance unusable.
1015
+ const fallback = this.adapters.values().next().value;
1016
+ if (fallback) {
1017
+ this.defaultAdapter = fallback;
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Apply a change received from another tab/device (via the sync manager) to
1022
+ * the matching local adapter, then let the adapter notify local subscribers.
1023
+ * Never re-broadcasts — prevents sync loops. localStorage propagates cross-tab
1024
+ * natively via the `storage` event and sessionStorage is per-tab, so both are
1025
+ * skipped here to avoid redundant re-writes.
1026
+ */
1027
+ async applyRemoteChange(change) {
1028
+ if (change.source !== 'remote' || !change.key || change.key === '*')
1029
+ return;
1030
+ if (change.storage === 'localStorage' || change.storage === 'sessionStorage')
1031
+ return;
1032
+ const adapter = this.adapters.get(change.storage);
1033
+ if (!adapter)
1034
+ return;
1035
+ try {
1036
+ if (change.newValue === undefined || change.newValue === null) {
1037
+ await adapter.remove(change.key);
1038
+ }
1039
+ else {
1040
+ await adapter.set(change.key, change.newValue);
1041
+ }
1042
+ }
1043
+ catch (error) {
1044
+ logger.warn(`Failed to apply remote sync change for "${change.key}":`, error);
1045
+ }
1046
+ }
1047
+ // --- Recovery helpers ------------------------------------------------------
1048
+ // Resolve the configured mirror adapters, excluding the primary.
1049
+ mirrorAdapters(excludeName) {
1050
+ const mirrors = this.config.mirror;
1051
+ if (!mirrors?.length)
1052
+ return [];
1053
+ const result = [];
1054
+ for (const name of mirrors) {
1055
+ if (name === excludeName)
1056
+ continue;
1057
+ const adapter = this.adapters.get(name);
1058
+ if (adapter)
1059
+ result.push(adapter);
1060
+ }
1061
+ return result;
1062
+ }
1063
+ async mirrorWrite(key, value, primaryName) {
1064
+ for (const adapter of this.mirrorAdapters(primaryName)) {
1065
+ try {
1066
+ await adapter.set(key, value);
1067
+ }
679
1068
  catch (error) {
680
- console.warn(`Failed to initialize ${storage} adapter:`, error);
681
- // Continue to next adapter
1069
+ logger.warn(`Mirror write to "${adapter.name}" failed for key "${key}":`, error);
682
1070
  }
683
1071
  }
684
- throw new StorageError(`No available storage adapters found. Tried: ${storages.join(', ')}. ` +
685
- `Registered adapters: ${Array.from(this.registry.getAll().keys()).join(', ')}`);
686
1072
  }
687
- async initializeAdapters() {
688
- // Adapters are already initialized in selectDefaultAdapter
689
- // This method is kept for compatibility but doesn't re-initialize
1073
+ async mirrorRemove(key, primaryName) {
1074
+ for (const adapter of this.mirrorAdapters(primaryName)) {
1075
+ try {
1076
+ await adapter.remove(key);
1077
+ }
1078
+ catch (error) {
1079
+ logger.warn(`Mirror remove on "${adapter.name}" failed for key "${key}":`, error);
1080
+ }
1081
+ }
1082
+ }
1083
+ // Read the value back after writing and verify it; rewrite + retry on mismatch.
1084
+ async verifyDurableWrite(adapter, key, expected) {
1085
+ const expectedChecksum = expected.checksum ?? computeChecksum(expected.value);
1086
+ const maxAttempts = 3;
1087
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1088
+ const readback = await adapter.get(key);
1089
+ const actualChecksum = readback
1090
+ ? (readback.checksum ?? computeChecksum(readback.value))
1091
+ : undefined;
1092
+ if (actualChecksum === expectedChecksum)
1093
+ return;
1094
+ if (attempt < maxAttempts)
1095
+ await adapter.set(key, expected);
1096
+ }
1097
+ throw new StorageError(`Durable write verification failed for key "${key}" after ${maxAttempts} attempts`, { key, storage: adapter.name });
1098
+ }
1099
+ // Recover a corrupted primary value from a mirror that still verifies, writing
1100
+ // it back to the primary (read-repair). Returns the decoded value or undefined.
1101
+ async repairFromMirror(key, options) {
1102
+ const mirrors = this.config.mirror;
1103
+ if (!mirrors?.length)
1104
+ return undefined;
1105
+ const primary = await this.selectAdapter(options?.storage);
1106
+ for (const name of mirrors) {
1107
+ if (name === primary.name)
1108
+ continue;
1109
+ const mirror = this.adapters.get(name);
1110
+ if (!mirror)
1111
+ continue;
1112
+ try {
1113
+ const mirrorValue = await mirror.get(key);
1114
+ if (mirrorValue && verifyChecksum(mirrorValue.value, mirrorValue.checksum)) {
1115
+ await primary.set(key, mirrorValue); // read-repair the primary
1116
+ logger.warn(`Repaired corrupted key "${key}" from mirror "${name}"`);
1117
+ // Decode via the normal path now that the primary is valid again.
1118
+ return (await this.get(key, { ...options, ignoreCorruption: false })) ?? undefined;
1119
+ }
1120
+ }
1121
+ catch (error) {
1122
+ logger.debug(`Mirror read-repair from "${name}" failed for "${key}":`, error);
1123
+ }
1124
+ }
1125
+ return undefined;
1126
+ }
1127
+ startAutoBackup() {
1128
+ const cfg = this.config.autoBackup;
1129
+ if (!cfg?.interval)
1130
+ return;
1131
+ const backupKey = cfg.key ?? '__strata_backup__';
1132
+ this._autoBackupTimer = setInterval(() => {
1133
+ void this.snapshot()
1134
+ .then((snap) => this.set(backupKey, snap, { storage: cfg.storage }))
1135
+ .catch((error) => logger.warn('Auto-backup failed:', error));
1136
+ }, cfg.interval);
690
1137
  }
691
1138
  async selectAdapter(storage) {
1139
+ await this.ensureReady();
692
1140
  if (!storage) {
693
1141
  if (!this.defaultAdapter) {
694
1142
  throw new StorageError('No default adapter available');