strata-storage 2.4.2 → 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.
- package/AI-INTEGRATION-GUIDE.md +208 -0
- package/README.md +427 -181
- package/android/AGENTS.md +34 -0
- package/android/CLAUDE.md +51 -0
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +35 -0
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +191 -27
- package/dist/README.md +427 -181
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/FilesystemAdapter.js +2 -1
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/PreferencesAdapter.js +2 -1
- package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SecureAdapter.js +2 -1
- package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SqliteAdapter.js +2 -1
- package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CacheAdapter.js +11 -3
- package/dist/adapters/web/CookieAdapter.d.ts +37 -1
- package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CookieAdapter.js +89 -9
- package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
- package/dist/adapters/web/IndexedDBAdapter.js +10 -2
- package/dist/adapters/web/LocalStorageAdapter.d.ts +31 -0
- package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/LocalStorageAdapter.js +92 -19
- package/dist/adapters/web/MemoryAdapter.d.ts +24 -0
- package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
- package/dist/adapters/web/MemoryAdapter.js +69 -18
- package/dist/adapters/web/SessionStorageAdapter.d.ts +24 -0
- package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/SessionStorageAdapter.js +71 -9
- package/dist/adapters/web/URLAdapter.d.ts +59 -0
- package/dist/adapters/web/URLAdapter.d.ts.map +1 -0
- package/dist/adapters/web/URLAdapter.js +234 -0
- package/dist/adapters/web/index.d.ts +1 -0
- package/dist/adapters/web/index.d.ts.map +1 -1
- package/dist/adapters/web/index.js +1 -0
- package/dist/android/AGENTS.md +34 -0
- package/dist/android/CLAUDE.md +51 -0
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +35 -0
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +191 -27
- package/dist/capacitor.d.ts.map +1 -1
- package/dist/capacitor.js +2 -1
- package/dist/config/support.d.ts +10 -0
- package/dist/config/support.d.ts.map +1 -0
- package/dist/config/support.js +9 -0
- package/dist/core/BaseAdapter.d.ts +8 -0
- package/dist/core/BaseAdapter.d.ts.map +1 -1
- package/dist/core/BaseAdapter.js +34 -14
- package/dist/core/Strata.d.ts +56 -2
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +501 -53
- package/dist/features/encryption.d.ts.map +1 -1
- package/dist/features/encryption.js +3 -2
- package/dist/features/integrity.d.ts +16 -0
- package/dist/features/integrity.d.ts.map +1 -0
- package/dist/features/integrity.js +28 -0
- package/dist/features/observer.d.ts.map +1 -1
- package/dist/features/observer.js +2 -1
- package/dist/features/query.d.ts +7 -1
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +9 -2
- package/dist/features/sync.d.ts.map +1 -1
- package/dist/features/sync.js +4 -3
- package/dist/index.d.ts +35 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55 -30
- package/dist/integrations/angular/index.d.ts +158 -0
- package/dist/integrations/angular/index.d.ts.map +1 -0
- package/dist/integrations/angular/index.js +395 -0
- package/dist/integrations/index.d.ts +15 -0
- package/dist/integrations/index.d.ts.map +1 -0
- package/dist/integrations/index.js +18 -0
- package/dist/integrations/react/index.d.ts +75 -0
- package/dist/integrations/react/index.d.ts.map +1 -0
- package/dist/integrations/react/index.js +191 -0
- package/dist/integrations/vue/index.d.ts +103 -0
- package/dist/integrations/vue/index.d.ts.map +1 -0
- package/dist/integrations/vue/index.js +274 -0
- package/dist/ios/AGENTS.md +33 -0
- package/dist/ios/CLAUDE.md +49 -0
- package/dist/ios/Plugin/KeychainStorage.swift +139 -50
- package/dist/ios/Plugin/SQLiteStorage.swift +40 -0
- package/dist/ios/Plugin/StrataStoragePlugin.m +23 -0
- package/dist/ios/Plugin/StrataStoragePlugin.swift +201 -52
- package/dist/package.json +21 -5
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -1
- package/dist/types/index.d.ts +58 -9
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -13
- package/dist/utils/errors.d.ts +7 -0
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +15 -3
- package/dist/utils/index.d.ts +63 -5
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +109 -16
- package/dist/utils/logger.d.ts +31 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +63 -0
- package/ios/AGENTS.md +33 -0
- package/ios/CLAUDE.md +49 -0
- package/ios/Plugin/KeychainStorage.swift +139 -50
- package/ios/Plugin/SQLiteStorage.swift +40 -0
- package/ios/Plugin/StrataStoragePlugin.m +23 -0
- package/ios/Plugin/StrataStoragePlugin.swift +201 -52
- package/package.json +35 -23
- package/scripts/build.js +16 -5
- package/scripts/configure.js +2 -6
- package/scripts/postinstall.js +2 -2
- package/Readme.md +0 -271
package/dist/core/Strata.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
|
688
|
-
|
|
689
|
-
|
|
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');
|