strata-storage 2.6.1 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-INTEGRATION-GUIDE.md +129 -11
- package/README.md +42 -64
- package/android/AGENTS.md +18 -3
- package/android/CLAUDE.md +36 -3
- package/android/build.gradle +5 -1
- package/android/src/main/java/com/strata/storage/EncryptedStorage.java +31 -18
- package/android/src/main/java/com/strata/storage/FilesystemStorage.java +62 -10
- package/android/src/main/java/com/strata/storage/SQLiteStorage.java +65 -13
- package/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +63 -51
- package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +80 -11
- package/dist/README.md +42 -64
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts +2 -2
- package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/FilesystemAdapter.js +6 -2
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts +2 -2
- package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/PreferencesAdapter.js +6 -2
- package/dist/adapters/capacitor/SecureAdapter.d.ts +3 -3
- package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SecureAdapter.js +6 -2
- package/dist/adapters/capacitor/SqliteAdapter.d.ts +24 -3
- package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitor/SqliteAdapter.js +105 -23
- package/dist/adapters/capacitor/index.d.ts +4 -4
- package/dist/adapters/web/CacheAdapter.d.ts +2 -2
- package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CacheAdapter.js +20 -5
- package/dist/adapters/web/CookieAdapter.d.ts +2 -2
- package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CookieAdapter.js +27 -3
- package/dist/adapters/web/IndexedDBAdapter.d.ts +10 -3
- package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
- package/dist/adapters/web/IndexedDBAdapter.js +19 -4
- package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
- package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/LocalStorageAdapter.js +15 -1
- package/dist/adapters/web/MemoryAdapter.d.ts +9 -2
- package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
- package/dist/adapters/web/MemoryAdapter.js +22 -5
- package/dist/adapters/web/SessionStorageAdapter.d.ts +10 -10
- package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/SessionStorageAdapter.js +17 -3
- package/dist/adapters/web/URLAdapter.d.ts +2 -2
- package/dist/adapters/web/index.d.ts +7 -7
- package/dist/capacitor.d.ts +6 -6
- package/dist/capacitor.d.ts.map +1 -1
- package/dist/capacitor.js +5 -4
- package/dist/core/AdapterRegistry.d.ts +1 -1
- package/dist/core/BaseAdapter.d.ts +21 -5
- package/dist/core/BaseAdapter.d.ts.map +1 -1
- package/dist/core/BaseAdapter.js +29 -1
- package/dist/core/Strata.d.ts +53 -7
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +581 -125
- package/dist/features/compression/index.d.ts +1 -1
- package/dist/features/compression.d.ts +28 -9
- package/dist/features/compression.d.ts.map +1 -1
- package/dist/features/compression.js +164 -77
- package/dist/features/encryption/index.d.ts +1 -1
- package/dist/features/encryption.d.ts +55 -2
- package/dist/features/encryption.d.ts.map +1 -1
- package/dist/features/encryption.js +139 -26
- package/dist/features/migration.d.ts +1 -1
- package/dist/features/migration.d.ts.map +1 -1
- package/dist/features/migration.js +7 -2
- package/dist/features/observer/index.d.ts +1 -1
- package/dist/features/observer.d.ts +1 -1
- package/dist/features/query/index.d.ts +1 -1
- package/dist/features/query.d.ts +2 -1
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +32 -7
- package/dist/features/sync/index.d.ts +1 -1
- package/dist/features/sync.d.ts +19 -3
- package/dist/features/sync.d.ts.map +1 -1
- package/dist/features/sync.js +75 -12
- package/dist/features/ttl/index.d.ts +1 -1
- package/dist/features/ttl.d.ts +2 -2
- package/dist/features/ttl.d.ts.map +1 -1
- package/dist/features/ttl.js +4 -2
- package/dist/firebase.d.ts +1 -1
- package/dist/firebase.d.ts.map +1 -1
- package/dist/firebase.js +72 -10
- package/dist/index.d.ts +24 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/integrations/angular/index.d.ts +2 -2
- package/dist/integrations/angular/index.d.ts.map +1 -1
- package/dist/integrations/angular/index.js +7 -8
- package/dist/integrations/react/index.d.ts +3 -3
- package/dist/integrations/react/index.d.ts.map +1 -1
- package/dist/integrations/react/index.js +16 -4
- package/dist/integrations/vue/index.d.ts +2 -2
- package/dist/integrations/vue/index.d.ts.map +1 -1
- package/dist/integrations/vue/index.js +5 -2
- package/dist/package.json +11 -2
- package/dist/plugin/definitions.d.ts +29 -3
- package/dist/plugin/definitions.d.ts.map +1 -1
- package/dist/plugin/index.d.ts +2 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -0
- package/dist/plugin/web.d.ts +11 -2
- package/dist/plugin/web.d.ts.map +1 -1
- package/dist/plugin/web.js +23 -4
- package/dist/types/index.d.ts +50 -20
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -10
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +7 -0
- package/ios/AGENTS.md +14 -3
- package/ios/CLAUDE.md +25 -3
- package/ios/Plugin/FilesystemStorage.swift +19 -0
- package/ios/Plugin/SQLiteStorage.swift +59 -9
- package/ios/Plugin/StrataStoragePlugin.m +1 -0
- package/ios/Plugin/StrataStoragePlugin.swift +87 -34
- package/ios/Plugin/UserDefaultsStorage.swift +47 -46
- package/package.json +27 -24
- package/scripts/build.js +46 -27
- package/scripts/cli.js +1 -1
- package/scripts/configure.js +2 -2
- package/scripts/postinstall.js +3 -2
- package/dist/android/AGENTS.md +0 -51
- package/dist/android/CLAUDE.md +0 -89
- package/dist/android/build.gradle +0 -58
- package/dist/android/proguard-rules.pro +0 -11
- package/dist/android/settings.gradle +0 -2
- package/dist/android/src/main/AndroidManifest.xml +0 -2
- package/dist/android/src/main/java/com/strata/storage/EncryptedStorage.java +0 -218
- package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +0 -287
- package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +0 -371
- package/dist/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +0 -209
- package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +0 -616
- package/dist/core/StorageStrategy.d.ts +0 -55
- package/dist/core/StorageStrategy.d.ts.map +0 -1
- package/dist/core/StorageStrategy.js +0 -200
- package/dist/ios/AGENTS.md +0 -48
- package/dist/ios/CLAUDE.md +0 -84
- package/dist/ios/Plugin/FilesystemStorage.swift +0 -218
- package/dist/ios/Plugin/KeychainStorage.swift +0 -233
- package/dist/ios/Plugin/SQLiteStorage.swift +0 -426
- package/dist/ios/Plugin/StrataStoragePlugin.m +0 -23
- package/dist/ios/Plugin/StrataStoragePlugin.swift +0 -411
- package/dist/ios/Plugin/UserDefaultsStorage.swift +0 -87
package/dist/core/Strata.js
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
* Zero-dependency universal storage solution
|
|
4
4
|
*/
|
|
5
5
|
import { AdapterRegistry } from "./AdapterRegistry.js";
|
|
6
|
-
import { isBrowser, isNode, deepMerge, isObject,
|
|
6
|
+
import { isBrowser, isNode, deepMerge, isObject, isValidKey, isValidValue, matchGlob, getObjectSize, } from "../utils/index.js";
|
|
7
7
|
import { logger, setLogLevel, exposeLogLevelControls } from "../utils/logger.js";
|
|
8
|
-
import { StorageError, EncryptionError, IntegrityError } from "../utils/errors.js";
|
|
8
|
+
import { StorageError, EncryptionError, IntegrityError, ValidationError } from "../utils/errors.js";
|
|
9
9
|
import { computeChecksum, verifyChecksum } from "../features/integrity.js";
|
|
10
10
|
import { EncryptionManager } from "../features/encryption.js";
|
|
11
11
|
import { CompressionManager } from "../features/compression.js";
|
|
12
12
|
import { SyncManager } from "../features/sync.js";
|
|
13
13
|
import { TTLManager } from "../features/ttl.js";
|
|
14
|
+
import { QueryEngine } from "../features/query.js";
|
|
14
15
|
const VERBOSITY_TO_LEVEL = {
|
|
15
16
|
minimal: 'warn',
|
|
16
17
|
normal: 'info',
|
|
@@ -29,9 +30,11 @@ export class Strata {
|
|
|
29
30
|
compressionManager;
|
|
30
31
|
syncManager;
|
|
31
32
|
ttlManager;
|
|
33
|
+
queryEngine = new QueryEngine();
|
|
32
34
|
_initialized = false;
|
|
33
35
|
_readyPromise;
|
|
34
36
|
_autoBackupTimer;
|
|
37
|
+
_ttlCleanupTimer;
|
|
35
38
|
constructor(config = {}) {
|
|
36
39
|
this.config = this.normalizeConfig(config);
|
|
37
40
|
if (this.config.debug?.enabled) {
|
|
@@ -60,7 +63,13 @@ export class Strata {
|
|
|
60
63
|
*/
|
|
61
64
|
async initialize() {
|
|
62
65
|
if (!this._readyPromise) {
|
|
63
|
-
|
|
66
|
+
// On failure, clear the cached promise so a later call can retry instead
|
|
67
|
+
// of permanently re-awaiting a rejected promise (e.g. a transient adapter
|
|
68
|
+
// error during init should not brick the instance forever).
|
|
69
|
+
this._readyPromise = this._performInitialization().catch((error) => {
|
|
70
|
+
this._readyPromise = undefined;
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
64
73
|
}
|
|
65
74
|
return this._readyPromise;
|
|
66
75
|
}
|
|
@@ -100,9 +109,13 @@ export class Strata {
|
|
|
100
109
|
}
|
|
101
110
|
// Initialize TTL manager
|
|
102
111
|
this.ttlManager = new TTLManager(this.config.ttl);
|
|
103
|
-
// Set up TTL cleanup
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
// Set up periodic TTL cleanup across ALL initialized adapters (not just the
|
|
113
|
+
// default), so expired items in every registered backend are reaped.
|
|
114
|
+
if (this.config.ttl?.autoCleanup !== false) {
|
|
115
|
+
const interval = this.config.ttl?.cleanupInterval ?? 60000;
|
|
116
|
+
this._ttlCleanupTimer = setInterval(() => {
|
|
117
|
+
void this.cleanupAllAdapters();
|
|
118
|
+
}, interval);
|
|
106
119
|
}
|
|
107
120
|
// Start periodic auto-backup if configured
|
|
108
121
|
if (this.config.autoBackup?.interval) {
|
|
@@ -135,7 +148,7 @@ export class Strata {
|
|
|
135
148
|
* @param options - Storage options
|
|
136
149
|
* @param options.storage - Specific storage type to use (e.g., 'localStorage', 'indexedDB')
|
|
137
150
|
* @param options.decrypt - Whether to decrypt the value (default: auto-detect)
|
|
138
|
-
* @param options.
|
|
151
|
+
* @param options.encryptionPassword - Password for decryption (uses config password if not provided)
|
|
139
152
|
* @returns The stored value or null if not found
|
|
140
153
|
* @throws {StorageError} If the storage adapter is not available
|
|
141
154
|
* @throws {EncryptionError} If decryption fails
|
|
@@ -150,25 +163,31 @@ export class Strata {
|
|
|
150
163
|
*
|
|
151
164
|
* // Get encrypted value
|
|
152
165
|
* const value = await storage.get('secure-key', {
|
|
153
|
-
*
|
|
166
|
+
* encryptionPassword: 'myPassword'
|
|
154
167
|
* });
|
|
155
168
|
* ```
|
|
156
169
|
*/
|
|
157
170
|
async get(key, options) {
|
|
171
|
+
if (!isValidKey(key)) {
|
|
172
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
173
|
+
}
|
|
158
174
|
const adapter = await this.selectAdapter(options?.storage);
|
|
159
|
-
const
|
|
175
|
+
const physicalKey = this.resolveKey(key, options);
|
|
176
|
+
const value = await adapter.get(physicalKey);
|
|
160
177
|
if (!value)
|
|
161
178
|
return null;
|
|
162
179
|
// Handle TTL
|
|
163
180
|
if (this.ttlManager && this.ttlManager.isExpired(value)) {
|
|
164
|
-
await adapter.remove(
|
|
181
|
+
await adapter.remove(physicalKey);
|
|
165
182
|
return null;
|
|
166
183
|
}
|
|
167
|
-
// Update sliding TTL
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
// Update sliding TTL — honored per-call (`options.sliding`) or via the
|
|
185
|
+
// instance default (`config.ttl.slidingTTL`).
|
|
186
|
+
const shouldSlide = options?.sliding ?? this.config.ttl?.slidingTTL;
|
|
187
|
+
if (shouldSlide && value.expires && this.ttlManager) {
|
|
188
|
+
const updatedValue = this.ttlManager.updateExpiration(value, { ...options, sliding: true });
|
|
170
189
|
if (updatedValue !== value) {
|
|
171
|
-
await adapter.set(
|
|
190
|
+
await adapter.set(physicalKey, updatedValue);
|
|
172
191
|
}
|
|
173
192
|
}
|
|
174
193
|
// Verify integrity; on corruption, try mirror read-repair, then honor the
|
|
@@ -184,17 +203,28 @@ export class Strata {
|
|
|
184
203
|
storage: adapter.name,
|
|
185
204
|
});
|
|
186
205
|
}
|
|
187
|
-
//
|
|
206
|
+
// Decode through the shared pipeline: decrypt first, then decompress (the
|
|
207
|
+
// write path is compress -> encrypt, so the read path must reverse it).
|
|
208
|
+
return this.decodeStoredValue(value, key, options);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Reverse the write-time transform pipeline on a stored wrapper: decrypt (if
|
|
212
|
+
* encrypted) and then decompress (if compressed). Shared by get() and query()
|
|
213
|
+
* so query results are decoded identically to direct reads. Returns the raw
|
|
214
|
+
* encrypted payload when `skipDecryption` is set.
|
|
215
|
+
*/
|
|
216
|
+
async decodeStoredValue(value, key, options) {
|
|
217
|
+
let payload = value.value;
|
|
188
218
|
if (value.encrypted && this.encryptionManager) {
|
|
219
|
+
if (options?.skipDecryption) {
|
|
220
|
+
return payload;
|
|
221
|
+
}
|
|
189
222
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
throw new EncryptionError('Encrypted value requires password for decryption');
|
|
194
|
-
}
|
|
195
|
-
const decrypted = await this.encryptionManager.decrypt(value.value, password);
|
|
196
|
-
return decrypted;
|
|
223
|
+
const password = options?.encryptionPassword || this.config.encryption?.password;
|
|
224
|
+
if (!password) {
|
|
225
|
+
throw new EncryptionError('Encrypted value requires password for decryption');
|
|
197
226
|
}
|
|
227
|
+
payload = await this.encryptionManager.decrypt(payload, password);
|
|
198
228
|
}
|
|
199
229
|
catch (error) {
|
|
200
230
|
if (options?.ignoreDecryptionErrors) {
|
|
@@ -204,18 +234,10 @@ export class Strata {
|
|
|
204
234
|
throw error;
|
|
205
235
|
}
|
|
206
236
|
}
|
|
207
|
-
// Handle decompression if needed
|
|
208
237
|
if (value.compressed && this.compressionManager) {
|
|
209
|
-
|
|
210
|
-
const decompressed = await this.compressionManager.decompress(value.value);
|
|
211
|
-
return decompressed;
|
|
212
|
-
}
|
|
213
|
-
catch (error) {
|
|
214
|
-
logger.warn(`Failed to decompress key ${key}:`, error);
|
|
215
|
-
return value.value;
|
|
216
|
-
}
|
|
238
|
+
return this.compressionManager.decompress(payload);
|
|
217
239
|
}
|
|
218
|
-
return
|
|
240
|
+
return payload;
|
|
219
241
|
}
|
|
220
242
|
/**
|
|
221
243
|
* Set a value in storage
|
|
@@ -256,20 +278,29 @@ export class Strata {
|
|
|
256
278
|
* ```
|
|
257
279
|
*/
|
|
258
280
|
async set(key, value, options) {
|
|
281
|
+
if (!isValidKey(key)) {
|
|
282
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
283
|
+
}
|
|
284
|
+
if (!isValidValue(value)) {
|
|
285
|
+
throw new ValidationError('Invalid storage value: undefined, functions, and symbols cannot be stored', { key });
|
|
286
|
+
}
|
|
259
287
|
const adapter = await this.selectAdapter(options?.storage);
|
|
288
|
+
const physicalKey = this.resolveKey(key, options);
|
|
260
289
|
const now = Date.now();
|
|
261
290
|
let processedValue = value;
|
|
262
291
|
let compressed = false;
|
|
263
292
|
// Handle compression if needed
|
|
264
293
|
const shouldCompress = options?.compress ?? this.config.compression?.enabled;
|
|
265
294
|
if (shouldCompress && this.compressionManager) {
|
|
266
|
-
const compressedResult = await this.compressionManager.compress(
|
|
295
|
+
const compressedResult = await this.compressionManager.compress(processedValue);
|
|
267
296
|
if (this.compressionManager.isCompressedData(compressedResult)) {
|
|
268
297
|
processedValue = compressedResult;
|
|
269
298
|
compressed = true;
|
|
270
299
|
}
|
|
271
300
|
}
|
|
272
|
-
// Handle encryption if needed
|
|
301
|
+
// Handle encryption if needed. Encrypt whatever `processedValue` currently
|
|
302
|
+
// holds — the compressed payload when compression ran — so the transforms
|
|
303
|
+
// compose as compress-then-encrypt and the `compressed` flag stays truthful.
|
|
273
304
|
const shouldEncrypt = options?.encrypt ?? this.config.encryption?.enabled;
|
|
274
305
|
let encrypted = false;
|
|
275
306
|
if (shouldEncrypt && this.encryptionManager) {
|
|
@@ -277,7 +308,7 @@ export class Strata {
|
|
|
277
308
|
if (!password) {
|
|
278
309
|
throw new EncryptionError('Encryption enabled but no password provided');
|
|
279
310
|
}
|
|
280
|
-
processedValue = await this.encryptionManager.encrypt(
|
|
311
|
+
processedValue = await this.encryptionManager.encrypt(processedValue, password);
|
|
281
312
|
encrypted = true;
|
|
282
313
|
}
|
|
283
314
|
const storageValue = {
|
|
@@ -294,18 +325,18 @@ export class Strata {
|
|
|
294
325
|
if (this.config.integrity || options?.verify) {
|
|
295
326
|
storageValue.checksum = computeChecksum(processedValue);
|
|
296
327
|
}
|
|
297
|
-
await adapter.set(
|
|
328
|
+
await adapter.set(physicalKey, storageValue);
|
|
298
329
|
// Durable write: read back and verify, retrying on mismatch.
|
|
299
330
|
if (this.config.durableWrites || options?.durable) {
|
|
300
|
-
await this.verifyDurableWrite(adapter,
|
|
331
|
+
await this.verifyDurableWrite(adapter, physicalKey, storageValue);
|
|
301
332
|
}
|
|
302
333
|
// Mirror the write to any configured backup adapters.
|
|
303
|
-
await this.mirrorWrite(
|
|
334
|
+
await this.mirrorWrite(physicalKey, storageValue, adapter.name);
|
|
304
335
|
// Broadcast change for sync
|
|
305
336
|
if (this.syncManager) {
|
|
306
337
|
this.syncManager.broadcast({
|
|
307
338
|
type: 'set',
|
|
308
|
-
key,
|
|
339
|
+
key: physicalKey,
|
|
309
340
|
value: storageValue,
|
|
310
341
|
storage: adapter.name,
|
|
311
342
|
timestamp: now,
|
|
@@ -330,15 +361,19 @@ export class Strata {
|
|
|
330
361
|
* ```
|
|
331
362
|
*/
|
|
332
363
|
async remove(key, options) {
|
|
364
|
+
if (!isValidKey(key)) {
|
|
365
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
366
|
+
}
|
|
333
367
|
const adapter = await this.selectAdapter(options?.storage);
|
|
334
|
-
|
|
368
|
+
const physicalKey = this.resolveKey(key, options);
|
|
369
|
+
await adapter.remove(physicalKey);
|
|
335
370
|
// Mirror the removal to any configured backup adapters.
|
|
336
|
-
await this.mirrorRemove(
|
|
371
|
+
await this.mirrorRemove(physicalKey, adapter.name);
|
|
337
372
|
// Broadcast removal for sync
|
|
338
373
|
if (this.syncManager) {
|
|
339
374
|
this.syncManager.broadcast({
|
|
340
375
|
type: 'remove',
|
|
341
|
-
key,
|
|
376
|
+
key: physicalKey,
|
|
342
377
|
storage: adapter.name,
|
|
343
378
|
timestamp: Date.now(),
|
|
344
379
|
});
|
|
@@ -363,8 +398,11 @@ export class Strata {
|
|
|
363
398
|
* ```
|
|
364
399
|
*/
|
|
365
400
|
async has(key, options) {
|
|
401
|
+
if (!isValidKey(key)) {
|
|
402
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
403
|
+
}
|
|
366
404
|
const adapter = await this.selectAdapter(options?.storage);
|
|
367
|
-
return adapter.has(key);
|
|
405
|
+
return adapter.has(this.resolveKey(key, options));
|
|
368
406
|
}
|
|
369
407
|
/**
|
|
370
408
|
* Clear storage
|
|
@@ -394,17 +432,83 @@ export class Strata {
|
|
|
394
432
|
*/
|
|
395
433
|
async clear(options) {
|
|
396
434
|
await this.ensureReady();
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
435
|
+
const adapters = options?.storage
|
|
436
|
+
? [await this.selectAdapter(options.storage)]
|
|
437
|
+
: Array.from(this.adapters.values());
|
|
438
|
+
const ns = this.effectiveNamespace(options);
|
|
439
|
+
const olderThanTs = options?.olderThan !== undefined ? Strata.normalizeTimestamp(options.olderThan) : undefined;
|
|
440
|
+
const needsInspection = !!(ns ||
|
|
441
|
+
options?.prefix ||
|
|
442
|
+
options?.pattern ||
|
|
443
|
+
options?.tags ||
|
|
444
|
+
options?.expiredOnly ||
|
|
445
|
+
olderThanTs !== undefined);
|
|
446
|
+
for (const adapter of adapters) {
|
|
447
|
+
// Fast path: an unfiltered, un-namespaced clear wipes the whole adapter.
|
|
448
|
+
if (!needsInspection) {
|
|
449
|
+
await adapter.clear();
|
|
450
|
+
// Propagate the full clear cross-tab so other tabs converge, matching
|
|
451
|
+
// the set/remove broadcasts. ONLY full clears broadcast — a filtered or
|
|
452
|
+
// namespaced clear must not wipe the whole adapter elsewhere.
|
|
453
|
+
// localStorage/sessionStorage propagate natively and are skipped on the
|
|
454
|
+
// receiving side; applyRemoteChange clears the adapter directly (no
|
|
455
|
+
// re-broadcast), so there is no sync loop.
|
|
456
|
+
if (this.syncManager) {
|
|
457
|
+
this.syncManager.broadcast({
|
|
458
|
+
type: 'clear',
|
|
459
|
+
storage: adapter.name,
|
|
460
|
+
timestamp: Date.now(),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// `expiredOnly` with no other narrowing filter: delegate to the adapter's
|
|
466
|
+
// bulk expired-reclamation when it has one (SQLite reaps in a single
|
|
467
|
+
// DELETE). The key-inspection loop below cannot reach expired rows on
|
|
468
|
+
// backends whose keys() already filters them out (SQLite filters in SQL),
|
|
469
|
+
// so without this it would be a silent no-op there.
|
|
470
|
+
if (options?.expiredOnly &&
|
|
471
|
+
!ns &&
|
|
472
|
+
!options?.prefix &&
|
|
473
|
+
!options?.pattern &&
|
|
474
|
+
!options?.tags &&
|
|
475
|
+
olderThanTs === undefined &&
|
|
476
|
+
typeof adapter.cleanupExpired === 'function') {
|
|
477
|
+
await adapter.cleanupExpired();
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
// Filtered/namespaced clear: inspect keys and remove only the matches.
|
|
481
|
+
const scopedPrefix = ns
|
|
482
|
+
? `${this.namespacePrefix(ns)}${options?.prefix ?? ''}`
|
|
483
|
+
: options?.prefix;
|
|
484
|
+
for (const physicalKey of await adapter.keys()) {
|
|
485
|
+
if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
|
|
486
|
+
continue;
|
|
487
|
+
if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
|
|
488
|
+
continue;
|
|
489
|
+
if (options?.pattern &&
|
|
490
|
+
this.filterKeysByPattern([physicalKey], options.pattern).length === 0)
|
|
491
|
+
continue;
|
|
492
|
+
if (options?.tags || options?.expiredOnly || olderThanTs !== undefined) {
|
|
493
|
+
const value = await adapter.get(physicalKey);
|
|
494
|
+
if (options?.tags &&
|
|
495
|
+
(!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))))
|
|
496
|
+
continue;
|
|
497
|
+
if (options?.expiredOnly && (!value || !this.isItemExpired(value)))
|
|
498
|
+
continue;
|
|
499
|
+
if (olderThanTs !== undefined && (!value || value.created >= olderThanTs))
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
await adapter.remove(physicalKey);
|
|
405
503
|
}
|
|
406
504
|
}
|
|
407
505
|
}
|
|
506
|
+
/** Expiry check that works before the TTL manager exists (e.g. pre-init). */
|
|
507
|
+
isItemExpired(value) {
|
|
508
|
+
if (this.ttlManager)
|
|
509
|
+
return this.ttlManager.isExpired(value);
|
|
510
|
+
return value.expires !== undefined && Date.now() > value.expires;
|
|
511
|
+
}
|
|
408
512
|
/**
|
|
409
513
|
* Get all keys from storage
|
|
410
514
|
*
|
|
@@ -431,17 +535,23 @@ export class Strata {
|
|
|
431
535
|
*/
|
|
432
536
|
async keys(pattern, options) {
|
|
433
537
|
await this.ensureReady();
|
|
538
|
+
const ns = this.effectiveNamespace(options);
|
|
434
539
|
if (options?.storage) {
|
|
435
540
|
const adapter = await this.selectAdapter(options.storage);
|
|
436
|
-
|
|
541
|
+
// When namespaced, fetch all physical keys and strip/scope ourselves;
|
|
542
|
+
// otherwise let the adapter apply the pattern directly.
|
|
543
|
+
const raw = ns ? await adapter.keys() : await adapter.keys(pattern);
|
|
544
|
+
return ns ? this.scopeAndStripKeys(raw, ns, pattern) : raw;
|
|
437
545
|
}
|
|
438
546
|
// Get keys from all adapters and deduplicate
|
|
439
547
|
const allKeys = new Set();
|
|
440
548
|
for (const adapter of this.adapters.values()) {
|
|
441
|
-
const
|
|
442
|
-
|
|
549
|
+
const raw = ns ? await adapter.keys() : await adapter.keys(pattern);
|
|
550
|
+
for (const key of raw)
|
|
551
|
+
allKeys.add(key);
|
|
443
552
|
}
|
|
444
|
-
|
|
553
|
+
const collected = Array.from(allKeys);
|
|
554
|
+
return ns ? this.scopeAndStripKeys(collected, ns, pattern) : collected;
|
|
445
555
|
}
|
|
446
556
|
// ===========================================================================
|
|
447
557
|
// Synchronous API
|
|
@@ -455,8 +565,11 @@ export class Strata {
|
|
|
455
565
|
// ===========================================================================
|
|
456
566
|
/** Synchronous get. Throws on async-only backends and on encrypted/compressed values. */
|
|
457
567
|
getSync(key, options) {
|
|
568
|
+
if (!isValidKey(key)) {
|
|
569
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
570
|
+
}
|
|
458
571
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
459
|
-
const value = adapter.getSync(key);
|
|
572
|
+
const value = adapter.getSync(this.resolveKey(key, options));
|
|
460
573
|
if (!value)
|
|
461
574
|
return null;
|
|
462
575
|
if (value.encrypted || value.compressed) {
|
|
@@ -466,10 +579,17 @@ export class Strata {
|
|
|
466
579
|
}
|
|
467
580
|
/** Synchronous set. Cannot encrypt or compress (those operations are async). */
|
|
468
581
|
setSync(key, value, options) {
|
|
582
|
+
if (!isValidKey(key)) {
|
|
583
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
584
|
+
}
|
|
585
|
+
if (!isValidValue(value)) {
|
|
586
|
+
throw new ValidationError('Invalid storage value: undefined, functions, and symbols cannot be stored', { key });
|
|
587
|
+
}
|
|
469
588
|
if (options?.encrypt || options?.compress) {
|
|
470
589
|
throw new StorageError('Synchronous set cannot encrypt or compress (those operations are async). Use the async set() instead.');
|
|
471
590
|
}
|
|
472
591
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
592
|
+
const physicalKey = this.resolveKey(key, options);
|
|
473
593
|
const now = Date.now();
|
|
474
594
|
const storageValue = {
|
|
475
595
|
value,
|
|
@@ -481,11 +601,11 @@ export class Strata {
|
|
|
481
601
|
encrypted: false,
|
|
482
602
|
compressed: false,
|
|
483
603
|
};
|
|
484
|
-
adapter.setSync(
|
|
604
|
+
adapter.setSync(physicalKey, storageValue);
|
|
485
605
|
if (this.syncManager) {
|
|
486
606
|
this.syncManager.broadcast({
|
|
487
607
|
type: 'set',
|
|
488
|
-
key,
|
|
608
|
+
key: physicalKey,
|
|
489
609
|
value: storageValue,
|
|
490
610
|
storage: adapter.name,
|
|
491
611
|
timestamp: now,
|
|
@@ -494,12 +614,16 @@ export class Strata {
|
|
|
494
614
|
}
|
|
495
615
|
/** Synchronous remove. */
|
|
496
616
|
removeSync(key, options) {
|
|
617
|
+
if (!isValidKey(key)) {
|
|
618
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
619
|
+
}
|
|
497
620
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
498
|
-
|
|
621
|
+
const physicalKey = this.resolveKey(key, options);
|
|
622
|
+
adapter.removeSync(physicalKey);
|
|
499
623
|
if (this.syncManager) {
|
|
500
624
|
this.syncManager.broadcast({
|
|
501
625
|
type: 'remove',
|
|
502
|
-
key,
|
|
626
|
+
key: physicalKey,
|
|
503
627
|
storage: adapter.name,
|
|
504
628
|
timestamp: Date.now(),
|
|
505
629
|
});
|
|
@@ -507,17 +631,23 @@ export class Strata {
|
|
|
507
631
|
}
|
|
508
632
|
/** Synchronous existence check. */
|
|
509
633
|
hasSync(key, options) {
|
|
510
|
-
|
|
634
|
+
if (!isValidKey(key)) {
|
|
635
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
636
|
+
}
|
|
637
|
+
return this.requireSyncAdapter(options?.storage).hasSync(this.resolveKey(key, options));
|
|
511
638
|
}
|
|
512
639
|
/** Synchronous keys. With no `storage`, aggregates across sync-capable adapters. */
|
|
513
640
|
keysSync(pattern, options) {
|
|
641
|
+
const ns = this.effectiveNamespace(options);
|
|
514
642
|
if (options?.storage) {
|
|
515
|
-
|
|
643
|
+
const adapter = this.requireSyncAdapter(options.storage);
|
|
644
|
+
const raw = ns ? adapter.keysSync() : adapter.keysSync(pattern);
|
|
645
|
+
return ns ? this.scopeAndStripKeys(raw, ns, pattern) : raw;
|
|
516
646
|
}
|
|
517
647
|
const all = new Set();
|
|
518
648
|
for (const adapter of this.syncCapableAdapters()) {
|
|
519
649
|
try {
|
|
520
|
-
for (const key of adapter.keysSync(pattern)) {
|
|
650
|
+
for (const key of ns ? adapter.keysSync() : adapter.keysSync(pattern)) {
|
|
521
651
|
all.add(key);
|
|
522
652
|
}
|
|
523
653
|
}
|
|
@@ -527,17 +657,60 @@ export class Strata {
|
|
|
527
657
|
logger.debug(`Synchronous keys: skipped unavailable adapter "${adapter.name}"`, error);
|
|
528
658
|
}
|
|
529
659
|
}
|
|
530
|
-
|
|
660
|
+
const collected = Array.from(all);
|
|
661
|
+
return ns ? this.scopeAndStripKeys(collected, ns, pattern) : collected;
|
|
531
662
|
}
|
|
532
663
|
/** Synchronous clear. With no `storage`, clears all sync-capable adapters. */
|
|
533
664
|
clearSync(options) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
665
|
+
const ns = this.effectiveNamespace(options);
|
|
666
|
+
const olderThanTs = options?.olderThan !== undefined ? Strata.normalizeTimestamp(options.olderThan) : undefined;
|
|
667
|
+
const needsInspection = !!(ns ||
|
|
668
|
+
options?.prefix ||
|
|
669
|
+
options?.pattern ||
|
|
670
|
+
options?.tags ||
|
|
671
|
+
options?.expiredOnly ||
|
|
672
|
+
olderThanTs !== undefined);
|
|
673
|
+
const adapters = options?.storage
|
|
674
|
+
? [this.requireSyncAdapter(options.storage)]
|
|
675
|
+
: this.syncCapableAdapters();
|
|
676
|
+
for (const adapter of adapters) {
|
|
539
677
|
try {
|
|
540
|
-
|
|
678
|
+
if (!needsInspection) {
|
|
679
|
+
adapter.clearSync();
|
|
680
|
+
// Propagate the full clear cross-tab (see clear()). Only full clears
|
|
681
|
+
// broadcast; localStorage/sessionStorage are skipped on receive.
|
|
682
|
+
if (this.syncManager) {
|
|
683
|
+
this.syncManager.broadcast({
|
|
684
|
+
type: 'clear',
|
|
685
|
+
storage: adapter.name,
|
|
686
|
+
timestamp: Date.now(),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const scopedPrefix = ns
|
|
692
|
+
? `${this.namespacePrefix(ns)}${options?.prefix ?? ''}`
|
|
693
|
+
: options?.prefix;
|
|
694
|
+
for (const physicalKey of adapter.keysSync()) {
|
|
695
|
+
if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
|
|
696
|
+
continue;
|
|
697
|
+
if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
|
|
698
|
+
continue;
|
|
699
|
+
if (options?.pattern &&
|
|
700
|
+
this.filterKeysByPattern([physicalKey], options.pattern).length === 0)
|
|
701
|
+
continue;
|
|
702
|
+
if (options?.tags || options?.expiredOnly || olderThanTs !== undefined) {
|
|
703
|
+
const value = adapter.getSync(physicalKey);
|
|
704
|
+
if (options?.tags &&
|
|
705
|
+
(!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))))
|
|
706
|
+
continue;
|
|
707
|
+
if (options?.expiredOnly && (!value || !this.isItemExpired(value)))
|
|
708
|
+
continue;
|
|
709
|
+
if (olderThanTs !== undefined && (!value || value.created >= olderThanTs))
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
adapter.removeSync(physicalKey);
|
|
713
|
+
}
|
|
541
714
|
}
|
|
542
715
|
catch (error) {
|
|
543
716
|
logger.debug(`Synchronous clear: skipped unavailable adapter "${adapter.name}"`, error);
|
|
@@ -594,33 +767,67 @@ export class Strata {
|
|
|
594
767
|
computeExpiration(options) {
|
|
595
768
|
if (this.ttlManager)
|
|
596
769
|
return this.ttlManager.calculateExpiration(options);
|
|
597
|
-
|
|
598
|
-
|
|
770
|
+
// Pre-init fallback: mirror TTLManager.calculateExpiration's precedence
|
|
771
|
+
// (expireAt > expireAfter > ttl > defaultTTL) and past-guarding, so a write
|
|
772
|
+
// issued before initialize() expires identically to one issued after.
|
|
599
773
|
if (options?.expireAt !== undefined) {
|
|
600
|
-
|
|
774
|
+
const t = options.expireAt instanceof Date ? options.expireAt.getTime() : options.expireAt;
|
|
775
|
+
return t > Date.now() ? t : undefined;
|
|
776
|
+
}
|
|
777
|
+
if (options?.expireAfter !== undefined) {
|
|
778
|
+
const t = options.expireAfter instanceof Date ? options.expireAfter.getTime() : options.expireAfter;
|
|
779
|
+
return t > Date.now() ? t : undefined;
|
|
601
780
|
}
|
|
781
|
+
if (typeof options?.ttl === 'number')
|
|
782
|
+
return Date.now() + options.ttl;
|
|
783
|
+
if (this.config.ttl?.defaultTTL)
|
|
784
|
+
return Date.now() + this.config.ttl.defaultTTL;
|
|
602
785
|
return undefined;
|
|
603
786
|
}
|
|
604
787
|
/**
|
|
605
788
|
* Get storage size information
|
|
606
789
|
*/
|
|
607
|
-
async size(detailed) {
|
|
790
|
+
async size(detailed, options) {
|
|
608
791
|
await this.ensureReady();
|
|
792
|
+
const ns = this.effectiveNamespace(options);
|
|
609
793
|
let total = 0;
|
|
610
794
|
let count = 0;
|
|
611
795
|
const byStorage = {};
|
|
612
796
|
const allByKey = {};
|
|
613
797
|
for (const [type, adapter] of this.adapters.entries()) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
798
|
+
if (!ns) {
|
|
799
|
+
const sizeInfo = await adapter.size(detailed);
|
|
800
|
+
total += sizeInfo.total;
|
|
801
|
+
count += sizeInfo.count;
|
|
802
|
+
byStorage[type] = sizeInfo.total;
|
|
803
|
+
// Aggregate byKey data if detailed
|
|
804
|
+
if (detailed && sizeInfo.byKey) {
|
|
805
|
+
for (const [key, size] of Object.entries(sizeInfo.byKey)) {
|
|
806
|
+
allByKey[key] = (allByKey[key] || 0) + size;
|
|
807
|
+
}
|
|
622
808
|
}
|
|
809
|
+
continue;
|
|
623
810
|
}
|
|
811
|
+
let storageTotal = 0;
|
|
812
|
+
const prefix = this.namespacePrefix(ns);
|
|
813
|
+
for (const physicalKey of await adapter.keys()) {
|
|
814
|
+
if (!physicalKey.startsWith(prefix))
|
|
815
|
+
continue;
|
|
816
|
+
const value = await adapter.get(physicalKey);
|
|
817
|
+
if (!value)
|
|
818
|
+
continue;
|
|
819
|
+
const logicalKey = physicalKey.slice(prefix.length);
|
|
820
|
+
const keySize = logicalKey.length * 2;
|
|
821
|
+
const valueSize = getObjectSize(value);
|
|
822
|
+
const entrySize = keySize + valueSize;
|
|
823
|
+
total += entrySize;
|
|
824
|
+
storageTotal += entrySize;
|
|
825
|
+
count++;
|
|
826
|
+
if (detailed) {
|
|
827
|
+
allByKey[logicalKey] = (allByKey[logicalKey] || 0) + entrySize;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
byStorage[type] = storageTotal;
|
|
624
831
|
}
|
|
625
832
|
const result = {
|
|
626
833
|
total,
|
|
@@ -638,6 +845,21 @@ export class Strata {
|
|
|
638
845
|
subscribe(callback, options) {
|
|
639
846
|
let cancelled = false;
|
|
640
847
|
const unsubscribers = [];
|
|
848
|
+
// When namespaced, only forward changes for keys this namespace owns and
|
|
849
|
+
// present the logical (un-prefixed) key to the subscriber.
|
|
850
|
+
const ns = this.effectiveNamespace(options);
|
|
851
|
+
const effectiveCallback = ns
|
|
852
|
+
? (change) => {
|
|
853
|
+
if (change.key === '*') {
|
|
854
|
+
callback(change);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const prefix = this.namespacePrefix(ns);
|
|
858
|
+
if (!change.key.startsWith(prefix))
|
|
859
|
+
return;
|
|
860
|
+
callback({ ...change, key: change.key.slice(prefix.length) });
|
|
861
|
+
}
|
|
862
|
+
: callback;
|
|
641
863
|
const attach = () => {
|
|
642
864
|
if (cancelled)
|
|
643
865
|
return;
|
|
@@ -646,7 +868,7 @@ export class Strata {
|
|
|
646
868
|
: Array.from(this.adapters.values());
|
|
647
869
|
for (const adapter of targets) {
|
|
648
870
|
if (adapter?.subscribe) {
|
|
649
|
-
unsubscribers.push(adapter.subscribe(
|
|
871
|
+
unsubscribers.push(adapter.subscribe(effectiveCallback));
|
|
650
872
|
}
|
|
651
873
|
}
|
|
652
874
|
};
|
|
@@ -675,28 +897,77 @@ export class Strata {
|
|
|
675
897
|
*/
|
|
676
898
|
async query(condition, options) {
|
|
677
899
|
const adapter = await this.selectAdapter(options?.storage);
|
|
678
|
-
|
|
679
|
-
|
|
900
|
+
const ns = this.effectiveNamespace(options);
|
|
901
|
+
const rawKeys = await adapter.keys();
|
|
902
|
+
// Query decoded values, not adapter-level stored wrappers. This keeps
|
|
903
|
+
// query() behavior aligned with get(), including namespace scoping, TTL,
|
|
904
|
+
// integrity repair, decryption, and decompression.
|
|
905
|
+
const decoded = [];
|
|
906
|
+
for (const physicalKey of rawKeys) {
|
|
907
|
+
if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
|
|
908
|
+
continue;
|
|
909
|
+
const logicalKey = ns ? physicalKey.slice(this.namespacePrefix(ns).length) : physicalKey;
|
|
910
|
+
if (!isValidKey(logicalKey))
|
|
911
|
+
continue;
|
|
912
|
+
const value = await this.get(logicalKey, options);
|
|
913
|
+
if (value === null)
|
|
914
|
+
continue;
|
|
915
|
+
if (this.queryEngine.matches(value, condition)) {
|
|
916
|
+
decoded.push({ key: logicalKey, value });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return this.applyQueryOptions(decoded, options);
|
|
920
|
+
}
|
|
921
|
+
/** Apply QueryOptions (sort → skip → limit → select) to decoded query results. */
|
|
922
|
+
applyQueryOptions(items, options) {
|
|
923
|
+
let result = items;
|
|
924
|
+
if (options?.sort) {
|
|
925
|
+
const spec = typeof options.sort === 'string'
|
|
926
|
+
? { [options.sort]: 1 }
|
|
927
|
+
: options.sort;
|
|
928
|
+
// Sort by fields of the stored value (prefix paths so the engine reads
|
|
929
|
+
// `item.value.<field>`).
|
|
930
|
+
const sortBy = Object.fromEntries(Object.entries(spec).map(([field, dir]) => [`value.${field}`, dir]));
|
|
931
|
+
result = this.queryEngine.sort(result, sortBy);
|
|
932
|
+
}
|
|
933
|
+
if (typeof options?.skip === 'number' && options.skip > 0) {
|
|
934
|
+
result = result.slice(options.skip);
|
|
935
|
+
}
|
|
936
|
+
if (typeof options?.limit === 'number') {
|
|
937
|
+
result = result.slice(0, Math.max(0, options.limit));
|
|
938
|
+
}
|
|
939
|
+
if (options?.select && options.select.length > 0) {
|
|
940
|
+
const projection = {};
|
|
941
|
+
for (const field of options.select)
|
|
942
|
+
projection[field] = 1;
|
|
943
|
+
result = result.map((item) => ({
|
|
944
|
+
key: item.key,
|
|
945
|
+
value: this.queryEngine.project(item.value, projection),
|
|
946
|
+
}));
|
|
680
947
|
}
|
|
681
|
-
return
|
|
948
|
+
return result;
|
|
682
949
|
}
|
|
683
950
|
/**
|
|
684
951
|
* Export storage data
|
|
685
952
|
*/
|
|
686
953
|
async export(options) {
|
|
687
954
|
const data = {};
|
|
688
|
-
const
|
|
955
|
+
const opts = options?.storage
|
|
956
|
+
? { storage: options.storage }
|
|
957
|
+
: undefined;
|
|
958
|
+
const keys = options?.keys || (await this.keys(undefined, opts));
|
|
689
959
|
for (const key of keys) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
960
|
+
if (options?.includeMetadata) {
|
|
961
|
+
// Read the full wrapper from the targeted adapter (physical key).
|
|
962
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
963
|
+
const storageValue = await adapter.get(this.resolveKey(key, opts));
|
|
964
|
+
if (storageValue)
|
|
695
965
|
data[key] = storageValue;
|
|
696
|
-
|
|
697
|
-
|
|
966
|
+
}
|
|
967
|
+
else {
|
|
968
|
+
const value = await this.get(key, opts);
|
|
969
|
+
if (value !== null)
|
|
698
970
|
data[key] = value;
|
|
699
|
-
}
|
|
700
971
|
}
|
|
701
972
|
}
|
|
702
973
|
const format = options?.format || 'json';
|
|
@@ -705,6 +976,12 @@ export class Strata {
|
|
|
705
976
|
}
|
|
706
977
|
throw new StorageError(`Export format ${format} not supported`);
|
|
707
978
|
}
|
|
979
|
+
/** Detect a full StorageValue wrapper (e.g. from export({ includeMetadata })). */
|
|
980
|
+
isStorageValueWrapper(value) {
|
|
981
|
+
if (!isObject(value))
|
|
982
|
+
return false;
|
|
983
|
+
return ('value' in value && typeof value.created === 'number' && typeof value.updated === 'number');
|
|
984
|
+
}
|
|
708
985
|
/**
|
|
709
986
|
* Import storage data
|
|
710
987
|
*/
|
|
@@ -723,25 +1000,38 @@ export class Strata {
|
|
|
723
1000
|
if (!isObject(parsed)) {
|
|
724
1001
|
throw new StorageError('Cannot import: expected a JSON object of key/value pairs');
|
|
725
1002
|
}
|
|
1003
|
+
const opts = options?.storage
|
|
1004
|
+
? { storage: options.storage }
|
|
1005
|
+
: undefined;
|
|
726
1006
|
for (const [key, value] of Object.entries(parsed)) {
|
|
727
|
-
// Never write a prototype-pollution
|
|
728
|
-
//
|
|
729
|
-
if (!
|
|
1007
|
+
// Never write a prototype-pollution / invalid key, and never let it reach
|
|
1008
|
+
// deepMerge below (isValidKey also rejects __proto__/constructor/prototype).
|
|
1009
|
+
if (!isValidKey(key))
|
|
730
1010
|
continue;
|
|
731
|
-
const exists = await this.has(key);
|
|
1011
|
+
const exists = await this.has(key, opts);
|
|
1012
|
+
// A full StorageValue wrapper (from export({ includeMetadata })) is written
|
|
1013
|
+
// straight to the adapter so its TTL/tags/metadata/flags survive — routing
|
|
1014
|
+
// it through set() would wrap the wrapper and corrupt the shape.
|
|
1015
|
+
if (this.isStorageValueWrapper(value)) {
|
|
1016
|
+
if (!exists || options?.overwrite) {
|
|
1017
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
1018
|
+
await adapter.set(this.resolveKey(key, opts), value);
|
|
1019
|
+
}
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
732
1022
|
if (!exists || options?.overwrite) {
|
|
733
|
-
await this.set(key, value);
|
|
1023
|
+
await this.set(key, value, opts);
|
|
734
1024
|
}
|
|
735
1025
|
else if (options?.merge) {
|
|
736
|
-
const existing = await this.get(key);
|
|
1026
|
+
const existing = await this.get(key, opts);
|
|
737
1027
|
if (options.merge === 'deep' && typeof existing === 'object' && typeof value === 'object') {
|
|
738
1028
|
// Use deep merge utility for proper nested object merging.
|
|
739
1029
|
// deepMerge itself strips prototype-pollution keys defensively.
|
|
740
1030
|
const merged = deepMerge(existing, value);
|
|
741
|
-
await this.set(key, merged);
|
|
1031
|
+
await this.set(key, merged, opts);
|
|
742
1032
|
}
|
|
743
1033
|
else {
|
|
744
|
-
await this.set(key, value);
|
|
1034
|
+
await this.set(key, value, opts);
|
|
745
1035
|
}
|
|
746
1036
|
}
|
|
747
1037
|
}
|
|
@@ -799,12 +1089,14 @@ export class Strata {
|
|
|
799
1089
|
if (!isObject(data)) {
|
|
800
1090
|
throw new IntegrityError('Cannot restore: snapshot payload is not an object');
|
|
801
1091
|
}
|
|
802
|
-
const adapter = await this.selectAdapter();
|
|
1092
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
803
1093
|
for (const [key, wrapper] of Object.entries(data)) {
|
|
804
|
-
// Skip prototype-pollution keys; never use them as a storage key.
|
|
805
|
-
if (!
|
|
1094
|
+
// Skip prototype-pollution / invalid keys; never use them as a storage key.
|
|
1095
|
+
if (!isValidKey(key))
|
|
806
1096
|
continue;
|
|
807
|
-
|
|
1097
|
+
// Snapshots have no per-call namespace; the instance-level namespace (if
|
|
1098
|
+
// any) is applied via resolveKey().
|
|
1099
|
+
await adapter.set(this.resolveKey(key), wrapper);
|
|
808
1100
|
}
|
|
809
1101
|
}
|
|
810
1102
|
/**
|
|
@@ -853,7 +1145,7 @@ export class Strata {
|
|
|
853
1145
|
if (!this.ttlManager)
|
|
854
1146
|
return null;
|
|
855
1147
|
const adapter = await this.selectAdapter(options?.storage);
|
|
856
|
-
const value = await adapter.get(key);
|
|
1148
|
+
const value = await adapter.get(this.resolveKey(key, options));
|
|
857
1149
|
if (!value)
|
|
858
1150
|
return null;
|
|
859
1151
|
return this.ttlManager.getTimeToLive(value);
|
|
@@ -866,12 +1158,13 @@ export class Strata {
|
|
|
866
1158
|
throw new StorageError('TTL manager not initialized');
|
|
867
1159
|
}
|
|
868
1160
|
const adapter = await this.selectAdapter(options?.storage);
|
|
869
|
-
const
|
|
1161
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1162
|
+
const value = await adapter.get(physicalKey);
|
|
870
1163
|
if (!value) {
|
|
871
1164
|
throw new StorageError(`Key ${key} not found`);
|
|
872
1165
|
}
|
|
873
1166
|
const updated = this.ttlManager.extendTTL(value, extension);
|
|
874
|
-
await adapter.set(
|
|
1167
|
+
await adapter.set(physicalKey, updated);
|
|
875
1168
|
}
|
|
876
1169
|
/**
|
|
877
1170
|
* Make a key persistent (remove TTL)
|
|
@@ -881,12 +1174,13 @@ export class Strata {
|
|
|
881
1174
|
throw new StorageError('TTL manager not initialized');
|
|
882
1175
|
}
|
|
883
1176
|
const adapter = await this.selectAdapter(options?.storage);
|
|
884
|
-
const
|
|
1177
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1178
|
+
const value = await adapter.get(physicalKey);
|
|
885
1179
|
if (!value) {
|
|
886
1180
|
throw new StorageError(`Key ${key} not found`);
|
|
887
1181
|
}
|
|
888
1182
|
const persisted = this.ttlManager.persist(value);
|
|
889
|
-
await adapter.set(
|
|
1183
|
+
await adapter.set(physicalKey, persisted);
|
|
890
1184
|
}
|
|
891
1185
|
/**
|
|
892
1186
|
* Get items expiring within a time window
|
|
@@ -895,7 +1189,13 @@ export class Strata {
|
|
|
895
1189
|
if (!this.ttlManager)
|
|
896
1190
|
return [];
|
|
897
1191
|
const adapter = await this.selectAdapter(options?.storage);
|
|
898
|
-
|
|
1192
|
+
const ns = this.effectiveNamespace(options);
|
|
1193
|
+
const items = await this.ttlManager.getExpiring(timeWindow, () => adapter.keys(), (physicalKey) => adapter.get(physicalKey));
|
|
1194
|
+
return ns
|
|
1195
|
+
? items
|
|
1196
|
+
.filter((item) => item.key.startsWith(this.namespacePrefix(ns)))
|
|
1197
|
+
.map((item) => ({ ...item, key: item.key.slice(this.namespacePrefix(ns).length) }))
|
|
1198
|
+
: items;
|
|
899
1199
|
}
|
|
900
1200
|
/**
|
|
901
1201
|
* Manually trigger TTL cleanup
|
|
@@ -904,7 +1204,19 @@ export class Strata {
|
|
|
904
1204
|
if (!this.ttlManager)
|
|
905
1205
|
return 0;
|
|
906
1206
|
const adapter = await this.selectAdapter(options?.storage);
|
|
907
|
-
|
|
1207
|
+
// SQLite filters expired rows in SQL, so its keys() no longer surfaces them
|
|
1208
|
+
// — the per-key sweep below would reclaim nothing. Delegate to the adapter's
|
|
1209
|
+
// own bulk reclamation (one DELETE), matching what the automatic TTL tick
|
|
1210
|
+
// does. Reclamation here is backend-wide (not namespace-scoped), consistent
|
|
1211
|
+
// with that tick.
|
|
1212
|
+
if (adapter.name === 'sqlite' && typeof adapter.cleanupExpired === 'function') {
|
|
1213
|
+
return adapter.cleanupExpired();
|
|
1214
|
+
}
|
|
1215
|
+
const ns = this.effectiveNamespace(options);
|
|
1216
|
+
const expired = await this.ttlManager.cleanup(async () => {
|
|
1217
|
+
const keys = await adapter.keys();
|
|
1218
|
+
return ns ? keys.filter((key) => key.startsWith(this.namespacePrefix(ns))) : keys;
|
|
1219
|
+
}, (physicalKey) => adapter.get(physicalKey), (physicalKey) => adapter.remove(physicalKey));
|
|
908
1220
|
return expired.length;
|
|
909
1221
|
}
|
|
910
1222
|
/**
|
|
@@ -920,6 +1232,21 @@ export class Strata {
|
|
|
920
1232
|
registerAdapter(adapter) {
|
|
921
1233
|
this.registry.register(adapter);
|
|
922
1234
|
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Initialize and attach any registered adapters that are not yet active, then
|
|
1237
|
+
* re-pick the default adapter. Call this after `registerAdapter()` on an
|
|
1238
|
+
* already-initialized instance (e.g. `registerCapacitorAdapters()`), so the
|
|
1239
|
+
* newly registered adapters become available to multi-adapter operations
|
|
1240
|
+
* (keys/clear/size/subscribe) — not just to explicit `{ storage }` calls.
|
|
1241
|
+
*/
|
|
1242
|
+
async refreshAdapters() {
|
|
1243
|
+
if (!this._initialized) {
|
|
1244
|
+
await this.initialize();
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
await this.initializeAdapters();
|
|
1248
|
+
this.selectDefaultAdapter();
|
|
1249
|
+
}
|
|
923
1250
|
/**
|
|
924
1251
|
* Get the adapter registry (for advanced use cases)
|
|
925
1252
|
* @internal
|
|
@@ -935,6 +1262,10 @@ export class Strata {
|
|
|
935
1262
|
clearInterval(this._autoBackupTimer);
|
|
936
1263
|
this._autoBackupTimer = undefined;
|
|
937
1264
|
}
|
|
1265
|
+
if (this._ttlCleanupTimer) {
|
|
1266
|
+
clearInterval(this._ttlCleanupTimer);
|
|
1267
|
+
this._ttlCleanupTimer = undefined;
|
|
1268
|
+
}
|
|
938
1269
|
for (const adapter of this.adapters.values()) {
|
|
939
1270
|
if (adapter.close) {
|
|
940
1271
|
await adapter.close();
|
|
@@ -953,14 +1284,37 @@ export class Strata {
|
|
|
953
1284
|
if (this.ttlManager) {
|
|
954
1285
|
this.ttlManager.clear();
|
|
955
1286
|
}
|
|
1287
|
+
// Reset initialization state so the instance can be re-initialized after
|
|
1288
|
+
// close() (framework unmount/remount, test teardown, adapter swap).
|
|
1289
|
+
this.defaultAdapter = undefined;
|
|
1290
|
+
this._initialized = false;
|
|
1291
|
+
this._readyPromise = undefined;
|
|
956
1292
|
}
|
|
957
1293
|
// Private methods
|
|
958
1294
|
normalizeConfig(config) {
|
|
959
|
-
|
|
1295
|
+
const normalized = {
|
|
960
1296
|
platform: config.platform || this.detectPlatform(),
|
|
961
|
-
defaultStorages: config.defaultStorages || ['memory'], // Default to memory adapter
|
|
962
1297
|
...config,
|
|
963
1298
|
};
|
|
1299
|
+
// Normalize the singular `defaultStorage` into `defaultStorages` so it is no
|
|
1300
|
+
// longer a silent no-op: it is prepended to (or seeds) the preference list.
|
|
1301
|
+
if (config.defaultStorage) {
|
|
1302
|
+
const existing = config.defaultStorages ?? [];
|
|
1303
|
+
normalized.defaultStorages = existing.includes(config.defaultStorage)
|
|
1304
|
+
? existing
|
|
1305
|
+
: [config.defaultStorage, ...existing];
|
|
1306
|
+
}
|
|
1307
|
+
if (!normalized.defaultStorages || normalized.defaultStorages.length === 0) {
|
|
1308
|
+
// Persistent-first, matching the default `storage` singleton: prefer
|
|
1309
|
+
// durable backends so writes survive a reload, with sync-capable
|
|
1310
|
+
// localStorage ahead of async-only indexedDB, and volatile `memory` as the
|
|
1311
|
+
// last-resort fallback (SSR / no web storage). selectDefaultAdapter only
|
|
1312
|
+
// picks from adapters that are actually available, so unusable entries are
|
|
1313
|
+
// skipped. (A bare instance previously defaulted to memory-only, silently
|
|
1314
|
+
// contradicting the documented persistent-first behavior.)
|
|
1315
|
+
normalized.defaultStorages = ['localStorage', 'indexedDB', 'sessionStorage', 'memory'];
|
|
1316
|
+
}
|
|
1317
|
+
return normalized;
|
|
964
1318
|
}
|
|
965
1319
|
detectPlatform() {
|
|
966
1320
|
if (isBrowser())
|
|
@@ -969,11 +1323,62 @@ export class Strata {
|
|
|
969
1323
|
return 'node';
|
|
970
1324
|
return 'web'; // Default to web
|
|
971
1325
|
}
|
|
1326
|
+
// --- Namespace + key helpers ----------------------------------------------
|
|
1327
|
+
/** Effective namespace for an operation: a per-call value wins over the
|
|
1328
|
+
* instance default (`config.namespace`). Empty strings are treated as none. */
|
|
1329
|
+
effectiveNamespace(options) {
|
|
1330
|
+
const ns = options?.namespace ?? this.config.namespace;
|
|
1331
|
+
return ns && ns.length > 0 ? ns : undefined;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Encode a namespace into an unambiguous physical-key prefix component.
|
|
1335
|
+
* `:` is a legal character in user keys, so a bare `${ns}:${key}` join lets
|
|
1336
|
+
* namespace `a` + key `b:c` collide with namespace `a:b` + key `c` (both
|
|
1337
|
+
* `a:b:c`), leaking keys across namespaces. Percent-encoding `:` (and the `%`
|
|
1338
|
+
* escape itself) in the namespace component removes the ambiguity while
|
|
1339
|
+
* leaving the user key — including any `:` — untouched. Namespaces without a
|
|
1340
|
+
* `:` are unchanged, so existing namespaced data keeps the same physical key.
|
|
1341
|
+
*/
|
|
1342
|
+
encodeNamespace(ns) {
|
|
1343
|
+
return ns.replace(/%/g, '%25').replace(/:/g, '%3A');
|
|
1344
|
+
}
|
|
1345
|
+
/** The physical-key prefix (with trailing `:`) for a namespace. */
|
|
1346
|
+
namespacePrefix(ns) {
|
|
1347
|
+
return `${this.encodeNamespace(ns)}:`;
|
|
1348
|
+
}
|
|
1349
|
+
/** Map a logical key to the physical key actually stored (namespace-prefixed). */
|
|
1350
|
+
resolveKey(key, options) {
|
|
1351
|
+
const ns = this.effectiveNamespace(options);
|
|
1352
|
+
return ns ? `${this.encodeNamespace(ns)}:${key}` : key;
|
|
1353
|
+
}
|
|
1354
|
+
/** Reduce physical keys to the logical keys owned by `ns`, then apply pattern. */
|
|
1355
|
+
scopeAndStripKeys(physicalKeys, ns, pattern) {
|
|
1356
|
+
const prefix = `${this.encodeNamespace(ns)}:`;
|
|
1357
|
+
const logical = physicalKeys
|
|
1358
|
+
.filter((key) => key.startsWith(prefix))
|
|
1359
|
+
.map((key) => key.slice(prefix.length));
|
|
1360
|
+
return this.filterKeysByPattern(logical, pattern);
|
|
1361
|
+
}
|
|
1362
|
+
/** Pattern matcher mirroring BaseAdapter.filterKeys (prefix / glob / RegExp). */
|
|
1363
|
+
filterKeysByPattern(keys, pattern) {
|
|
1364
|
+
if (!pattern)
|
|
1365
|
+
return keys;
|
|
1366
|
+
if (pattern instanceof RegExp)
|
|
1367
|
+
return keys.filter((key) => pattern.test(key));
|
|
1368
|
+
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
1369
|
+
return keys.filter((key) => key.startsWith(pattern));
|
|
1370
|
+
}
|
|
1371
|
+
return keys.filter((key) => matchGlob(pattern, key));
|
|
1372
|
+
}
|
|
1373
|
+
static normalizeTimestamp(value) {
|
|
1374
|
+
return value instanceof Date ? value.getTime() : value;
|
|
1375
|
+
}
|
|
972
1376
|
getDefaultStorages() {
|
|
973
1377
|
// Only return adapters that are actually registered
|
|
974
1378
|
const registered = Array.from(this.registry.getAll().keys()).map((key) => String(key));
|
|
975
|
-
// Prefer these storages in order if available
|
|
976
|
-
|
|
1379
|
+
// Prefer these storages in order if available (sync-capable localStorage
|
|
1380
|
+
// ahead of async-only indexedDB, mirroring normalizeConfig's default).
|
|
1381
|
+
const preferredOrder = ['localStorage', 'indexedDB', 'sessionStorage', 'memory'];
|
|
977
1382
|
const available = preferredOrder.filter((storage) => registered.includes(storage));
|
|
978
1383
|
// Always include memory as fallback if registered
|
|
979
1384
|
if (available.length === 0 && registered.includes('memory')) {
|
|
@@ -1024,9 +1429,39 @@ export class Strata {
|
|
|
1024
1429
|
* natively via the `storage` event and sessionStorage is per-tab, so both are
|
|
1025
1430
|
* skipped here to avoid redundant re-writes.
|
|
1026
1431
|
*/
|
|
1432
|
+
// Run a TTL cleanup pass across every initialized adapter.
|
|
1433
|
+
async cleanupAllAdapters() {
|
|
1434
|
+
if (!this.ttlManager)
|
|
1435
|
+
return;
|
|
1436
|
+
for (const adapter of this.adapters.values()) {
|
|
1437
|
+
try {
|
|
1438
|
+
await this.ttlManager.cleanup(() => adapter.keys(), (key) => adapter.get(key), (key) => adapter.remove(key));
|
|
1439
|
+
}
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
logger.debug(`TTL cleanup failed for adapter "${adapter.name}":`, error);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1027
1445
|
async applyRemoteChange(change) {
|
|
1028
|
-
if (change.source !== 'remote' || !change.key
|
|
1446
|
+
if (change.source !== 'remote' || !change.key)
|
|
1447
|
+
return;
|
|
1448
|
+
// A full-clear broadcast arrives as a keyless ('*') change. Apply it to the
|
|
1449
|
+
// matching adapter so cleared state stays consistent cross-tab. Clearing the
|
|
1450
|
+
// adapter directly (not this.clear()) avoids re-broadcasting, so no loop.
|
|
1451
|
+
if (change.key === '*') {
|
|
1452
|
+
if (change.storage === 'localStorage' || change.storage === 'sessionStorage')
|
|
1453
|
+
return;
|
|
1454
|
+
const clearTarget = this.adapters.get(change.storage);
|
|
1455
|
+
if (clearTarget) {
|
|
1456
|
+
try {
|
|
1457
|
+
await clearTarget.clear();
|
|
1458
|
+
}
|
|
1459
|
+
catch (error) {
|
|
1460
|
+
logger.warn(`Failed to apply remote clear for "${change.storage}":`, error);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1029
1463
|
return;
|
|
1464
|
+
}
|
|
1030
1465
|
if (change.storage === 'localStorage' || change.storage === 'sessionStorage')
|
|
1031
1466
|
return;
|
|
1032
1467
|
const adapter = this.adapters.get(change.storage);
|
|
@@ -1037,7 +1472,18 @@ export class Strata {
|
|
|
1037
1472
|
await adapter.remove(change.key);
|
|
1038
1473
|
}
|
|
1039
1474
|
else {
|
|
1040
|
-
|
|
1475
|
+
let incoming = change.newValue;
|
|
1476
|
+
// When a local value already exists for this key, apply the configured
|
|
1477
|
+
// conflict-resolution strategy (default 'latest' keeps the incoming
|
|
1478
|
+
// value, matching prior behavior; 'merge' / a custom resolver now take
|
|
1479
|
+
// effect instead of being silently ignored).
|
|
1480
|
+
if (this.syncManager) {
|
|
1481
|
+
const existing = await adapter.get(change.key);
|
|
1482
|
+
if (existing) {
|
|
1483
|
+
incoming = this.syncManager.resolveConflict([existing, incoming]);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
await adapter.set(change.key, incoming);
|
|
1041
1487
|
}
|
|
1042
1488
|
}
|
|
1043
1489
|
catch (error) {
|
|
@@ -1103,6 +1549,7 @@ export class Strata {
|
|
|
1103
1549
|
if (!mirrors?.length)
|
|
1104
1550
|
return undefined;
|
|
1105
1551
|
const primary = await this.selectAdapter(options?.storage);
|
|
1552
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1106
1553
|
for (const name of mirrors) {
|
|
1107
1554
|
if (name === primary.name)
|
|
1108
1555
|
continue;
|
|
@@ -1110,9 +1557,9 @@ export class Strata {
|
|
|
1110
1557
|
if (!mirror)
|
|
1111
1558
|
continue;
|
|
1112
1559
|
try {
|
|
1113
|
-
const mirrorValue = await mirror.get(
|
|
1560
|
+
const mirrorValue = await mirror.get(physicalKey);
|
|
1114
1561
|
if (mirrorValue && verifyChecksum(mirrorValue.value, mirrorValue.checksum)) {
|
|
1115
|
-
await primary.set(
|
|
1562
|
+
await primary.set(physicalKey, mirrorValue); // read-repair the primary
|
|
1116
1563
|
logger.warn(`Repaired corrupted key "${key}" from mirror "${name}"`);
|
|
1117
1564
|
// Decode via the normal path now that the primary is valid again.
|
|
1118
1565
|
return (await this.get(key, { ...options, ignoreCorruption: false })) ?? undefined;
|
|
@@ -1130,9 +1577,18 @@ export class Strata {
|
|
|
1130
1577
|
return;
|
|
1131
1578
|
const backupKey = cfg.key ?? '__strata_backup__';
|
|
1132
1579
|
this._autoBackupTimer = setInterval(() => {
|
|
1133
|
-
void
|
|
1134
|
-
|
|
1135
|
-
|
|
1580
|
+
void (async () => {
|
|
1581
|
+
try {
|
|
1582
|
+
// Exclude the backup key itself so each snapshot doesn't nest the
|
|
1583
|
+
// previous backup (which would grow without bound).
|
|
1584
|
+
const keys = (await this.keys()).filter((key) => key !== backupKey);
|
|
1585
|
+
const snap = await this.snapshot({ keys });
|
|
1586
|
+
await this.set(backupKey, snap, { storage: cfg.storage });
|
|
1587
|
+
}
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
logger.warn('Auto-backup failed:', error);
|
|
1590
|
+
}
|
|
1591
|
+
})();
|
|
1136
1592
|
}, cfg.interval);
|
|
1137
1593
|
}
|
|
1138
1594
|
async selectAdapter(storage) {
|