strata-storage 2.6.0 → 2.7.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 +97 -10
- package/README.md +70 -70
- 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 +70 -70
- 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/CookieAdapter.d.ts +2 -2
- 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 +14 -3
- package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
- package/dist/adapters/web/MemoryAdapter.d.ts +2 -2
- 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 +3 -2
- 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 +41 -7
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +476 -118
- package/dist/features/compression/index.d.ts +1 -1
- package/dist/features/compression.d.ts +5 -1
- package/dist/features/compression.d.ts.map +1 -1
- 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/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 +1 -1
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +3 -1
- 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 +57 -9
- package/dist/features/ttl/index.d.ts +1 -1
- package/dist/features/ttl.d.ts +2 -2
- package/dist/firebase.d.ts +1 -1
- package/dist/firebase.d.ts.map +1 -1
- package/dist/firebase.js +34 -9
- 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 +29 -26
- package/scripts/build.js +16 -14
- 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) {
|
|
@@ -100,9 +103,13 @@ export class Strata {
|
|
|
100
103
|
}
|
|
101
104
|
// Initialize TTL manager
|
|
102
105
|
this.ttlManager = new TTLManager(this.config.ttl);
|
|
103
|
-
// Set up TTL cleanup
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
// Set up periodic TTL cleanup across ALL initialized adapters (not just the
|
|
107
|
+
// default), so expired items in every registered backend are reaped.
|
|
108
|
+
if (this.config.ttl?.autoCleanup !== false) {
|
|
109
|
+
const interval = this.config.ttl?.cleanupInterval ?? 60000;
|
|
110
|
+
this._ttlCleanupTimer = setInterval(() => {
|
|
111
|
+
void this.cleanupAllAdapters();
|
|
112
|
+
}, interval);
|
|
106
113
|
}
|
|
107
114
|
// Start periodic auto-backup if configured
|
|
108
115
|
if (this.config.autoBackup?.interval) {
|
|
@@ -135,7 +142,7 @@ export class Strata {
|
|
|
135
142
|
* @param options - Storage options
|
|
136
143
|
* @param options.storage - Specific storage type to use (e.g., 'localStorage', 'indexedDB')
|
|
137
144
|
* @param options.decrypt - Whether to decrypt the value (default: auto-detect)
|
|
138
|
-
* @param options.
|
|
145
|
+
* @param options.encryptionPassword - Password for decryption (uses config password if not provided)
|
|
139
146
|
* @returns The stored value or null if not found
|
|
140
147
|
* @throws {StorageError} If the storage adapter is not available
|
|
141
148
|
* @throws {EncryptionError} If decryption fails
|
|
@@ -150,25 +157,31 @@ export class Strata {
|
|
|
150
157
|
*
|
|
151
158
|
* // Get encrypted value
|
|
152
159
|
* const value = await storage.get('secure-key', {
|
|
153
|
-
*
|
|
160
|
+
* encryptionPassword: 'myPassword'
|
|
154
161
|
* });
|
|
155
162
|
* ```
|
|
156
163
|
*/
|
|
157
164
|
async get(key, options) {
|
|
165
|
+
if (!isValidKey(key)) {
|
|
166
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
167
|
+
}
|
|
158
168
|
const adapter = await this.selectAdapter(options?.storage);
|
|
159
|
-
const
|
|
169
|
+
const physicalKey = this.resolveKey(key, options);
|
|
170
|
+
const value = await adapter.get(physicalKey);
|
|
160
171
|
if (!value)
|
|
161
172
|
return null;
|
|
162
173
|
// Handle TTL
|
|
163
174
|
if (this.ttlManager && this.ttlManager.isExpired(value)) {
|
|
164
|
-
await adapter.remove(
|
|
175
|
+
await adapter.remove(physicalKey);
|
|
165
176
|
return null;
|
|
166
177
|
}
|
|
167
|
-
// Update sliding TTL
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
// Update sliding TTL — honored per-call (`options.sliding`) or via the
|
|
179
|
+
// instance default (`config.ttl.slidingTTL`).
|
|
180
|
+
const shouldSlide = options?.sliding ?? this.config.ttl?.slidingTTL;
|
|
181
|
+
if (shouldSlide && value.expires && this.ttlManager) {
|
|
182
|
+
const updatedValue = this.ttlManager.updateExpiration(value, { ...options, sliding: true });
|
|
170
183
|
if (updatedValue !== value) {
|
|
171
|
-
await adapter.set(
|
|
184
|
+
await adapter.set(physicalKey, updatedValue);
|
|
172
185
|
}
|
|
173
186
|
}
|
|
174
187
|
// Verify integrity; on corruption, try mirror read-repair, then honor the
|
|
@@ -184,17 +197,28 @@ export class Strata {
|
|
|
184
197
|
storage: adapter.name,
|
|
185
198
|
});
|
|
186
199
|
}
|
|
187
|
-
//
|
|
200
|
+
// Decode through the shared pipeline: decrypt first, then decompress (the
|
|
201
|
+
// write path is compress -> encrypt, so the read path must reverse it).
|
|
202
|
+
return this.decodeStoredValue(value, key, options);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Reverse the write-time transform pipeline on a stored wrapper: decrypt (if
|
|
206
|
+
* encrypted) and then decompress (if compressed). Shared by get() and query()
|
|
207
|
+
* so query results are decoded identically to direct reads. Returns the raw
|
|
208
|
+
* encrypted payload when `skipDecryption` is set.
|
|
209
|
+
*/
|
|
210
|
+
async decodeStoredValue(value, key, options) {
|
|
211
|
+
let payload = value.value;
|
|
188
212
|
if (value.encrypted && this.encryptionManager) {
|
|
213
|
+
if (options?.skipDecryption) {
|
|
214
|
+
return payload;
|
|
215
|
+
}
|
|
189
216
|
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;
|
|
217
|
+
const password = options?.encryptionPassword || this.config.encryption?.password;
|
|
218
|
+
if (!password) {
|
|
219
|
+
throw new EncryptionError('Encrypted value requires password for decryption');
|
|
197
220
|
}
|
|
221
|
+
payload = await this.encryptionManager.decrypt(payload, password);
|
|
198
222
|
}
|
|
199
223
|
catch (error) {
|
|
200
224
|
if (options?.ignoreDecryptionErrors) {
|
|
@@ -204,18 +228,10 @@ export class Strata {
|
|
|
204
228
|
throw error;
|
|
205
229
|
}
|
|
206
230
|
}
|
|
207
|
-
// Handle decompression if needed
|
|
208
231
|
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
|
-
}
|
|
232
|
+
return this.compressionManager.decompress(payload);
|
|
217
233
|
}
|
|
218
|
-
return
|
|
234
|
+
return payload;
|
|
219
235
|
}
|
|
220
236
|
/**
|
|
221
237
|
* Set a value in storage
|
|
@@ -256,20 +272,29 @@ export class Strata {
|
|
|
256
272
|
* ```
|
|
257
273
|
*/
|
|
258
274
|
async set(key, value, options) {
|
|
275
|
+
if (!isValidKey(key)) {
|
|
276
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
277
|
+
}
|
|
278
|
+
if (!isValidValue(value)) {
|
|
279
|
+
throw new ValidationError('Invalid storage value: undefined, functions, and symbols cannot be stored', { key });
|
|
280
|
+
}
|
|
259
281
|
const adapter = await this.selectAdapter(options?.storage);
|
|
282
|
+
const physicalKey = this.resolveKey(key, options);
|
|
260
283
|
const now = Date.now();
|
|
261
284
|
let processedValue = value;
|
|
262
285
|
let compressed = false;
|
|
263
286
|
// Handle compression if needed
|
|
264
287
|
const shouldCompress = options?.compress ?? this.config.compression?.enabled;
|
|
265
288
|
if (shouldCompress && this.compressionManager) {
|
|
266
|
-
const compressedResult = await this.compressionManager.compress(
|
|
289
|
+
const compressedResult = await this.compressionManager.compress(processedValue);
|
|
267
290
|
if (this.compressionManager.isCompressedData(compressedResult)) {
|
|
268
291
|
processedValue = compressedResult;
|
|
269
292
|
compressed = true;
|
|
270
293
|
}
|
|
271
294
|
}
|
|
272
|
-
// Handle encryption if needed
|
|
295
|
+
// Handle encryption if needed. Encrypt whatever `processedValue` currently
|
|
296
|
+
// holds — the compressed payload when compression ran — so the transforms
|
|
297
|
+
// compose as compress-then-encrypt and the `compressed` flag stays truthful.
|
|
273
298
|
const shouldEncrypt = options?.encrypt ?? this.config.encryption?.enabled;
|
|
274
299
|
let encrypted = false;
|
|
275
300
|
if (shouldEncrypt && this.encryptionManager) {
|
|
@@ -277,7 +302,7 @@ export class Strata {
|
|
|
277
302
|
if (!password) {
|
|
278
303
|
throw new EncryptionError('Encryption enabled but no password provided');
|
|
279
304
|
}
|
|
280
|
-
processedValue = await this.encryptionManager.encrypt(
|
|
305
|
+
processedValue = await this.encryptionManager.encrypt(processedValue, password);
|
|
281
306
|
encrypted = true;
|
|
282
307
|
}
|
|
283
308
|
const storageValue = {
|
|
@@ -294,18 +319,18 @@ export class Strata {
|
|
|
294
319
|
if (this.config.integrity || options?.verify) {
|
|
295
320
|
storageValue.checksum = computeChecksum(processedValue);
|
|
296
321
|
}
|
|
297
|
-
await adapter.set(
|
|
322
|
+
await adapter.set(physicalKey, storageValue);
|
|
298
323
|
// Durable write: read back and verify, retrying on mismatch.
|
|
299
324
|
if (this.config.durableWrites || options?.durable) {
|
|
300
|
-
await this.verifyDurableWrite(adapter,
|
|
325
|
+
await this.verifyDurableWrite(adapter, physicalKey, storageValue);
|
|
301
326
|
}
|
|
302
327
|
// Mirror the write to any configured backup adapters.
|
|
303
|
-
await this.mirrorWrite(
|
|
328
|
+
await this.mirrorWrite(physicalKey, storageValue, adapter.name);
|
|
304
329
|
// Broadcast change for sync
|
|
305
330
|
if (this.syncManager) {
|
|
306
331
|
this.syncManager.broadcast({
|
|
307
332
|
type: 'set',
|
|
308
|
-
key,
|
|
333
|
+
key: physicalKey,
|
|
309
334
|
value: storageValue,
|
|
310
335
|
storage: adapter.name,
|
|
311
336
|
timestamp: now,
|
|
@@ -330,15 +355,19 @@ export class Strata {
|
|
|
330
355
|
* ```
|
|
331
356
|
*/
|
|
332
357
|
async remove(key, options) {
|
|
358
|
+
if (!isValidKey(key)) {
|
|
359
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
360
|
+
}
|
|
333
361
|
const adapter = await this.selectAdapter(options?.storage);
|
|
334
|
-
|
|
362
|
+
const physicalKey = this.resolveKey(key, options);
|
|
363
|
+
await adapter.remove(physicalKey);
|
|
335
364
|
// Mirror the removal to any configured backup adapters.
|
|
336
|
-
await this.mirrorRemove(
|
|
365
|
+
await this.mirrorRemove(physicalKey, adapter.name);
|
|
337
366
|
// Broadcast removal for sync
|
|
338
367
|
if (this.syncManager) {
|
|
339
368
|
this.syncManager.broadcast({
|
|
340
369
|
type: 'remove',
|
|
341
|
-
key,
|
|
370
|
+
key: physicalKey,
|
|
342
371
|
storage: adapter.name,
|
|
343
372
|
timestamp: Date.now(),
|
|
344
373
|
});
|
|
@@ -363,8 +392,11 @@ export class Strata {
|
|
|
363
392
|
* ```
|
|
364
393
|
*/
|
|
365
394
|
async has(key, options) {
|
|
395
|
+
if (!isValidKey(key)) {
|
|
396
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
397
|
+
}
|
|
366
398
|
const adapter = await this.selectAdapter(options?.storage);
|
|
367
|
-
return adapter.has(key);
|
|
399
|
+
return adapter.has(this.resolveKey(key, options));
|
|
368
400
|
}
|
|
369
401
|
/**
|
|
370
402
|
* Clear storage
|
|
@@ -394,17 +426,53 @@ export class Strata {
|
|
|
394
426
|
*/
|
|
395
427
|
async clear(options) {
|
|
396
428
|
await this.ensureReady();
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
429
|
+
const adapters = options?.storage
|
|
430
|
+
? [await this.selectAdapter(options.storage)]
|
|
431
|
+
: Array.from(this.adapters.values());
|
|
432
|
+
const ns = this.effectiveNamespace(options);
|
|
433
|
+
const olderThanTs = options?.olderThan !== undefined ? Strata.normalizeTimestamp(options.olderThan) : undefined;
|
|
434
|
+
const needsInspection = !!(ns ||
|
|
435
|
+
options?.prefix ||
|
|
436
|
+
options?.pattern ||
|
|
437
|
+
options?.tags ||
|
|
438
|
+
options?.expiredOnly ||
|
|
439
|
+
olderThanTs !== undefined);
|
|
440
|
+
for (const adapter of adapters) {
|
|
441
|
+
// Fast path: an unfiltered, un-namespaced clear wipes the whole adapter.
|
|
442
|
+
if (!needsInspection) {
|
|
443
|
+
await adapter.clear();
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
// Filtered/namespaced clear: inspect keys and remove only the matches.
|
|
447
|
+
const scopedPrefix = ns ? `${ns}:${options?.prefix ?? ''}` : options?.prefix;
|
|
448
|
+
for (const physicalKey of await adapter.keys()) {
|
|
449
|
+
if (ns && !physicalKey.startsWith(`${ns}:`))
|
|
450
|
+
continue;
|
|
451
|
+
if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
|
|
452
|
+
continue;
|
|
453
|
+
if (options?.pattern &&
|
|
454
|
+
this.filterKeysByPattern([physicalKey], options.pattern).length === 0)
|
|
455
|
+
continue;
|
|
456
|
+
if (options?.tags || options?.expiredOnly || olderThanTs !== undefined) {
|
|
457
|
+
const value = await adapter.get(physicalKey);
|
|
458
|
+
if (options?.tags &&
|
|
459
|
+
(!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))))
|
|
460
|
+
continue;
|
|
461
|
+
if (options?.expiredOnly && (!value || !this.isItemExpired(value)))
|
|
462
|
+
continue;
|
|
463
|
+
if (olderThanTs !== undefined && (!value || value.created >= olderThanTs))
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
await adapter.remove(physicalKey);
|
|
405
467
|
}
|
|
406
468
|
}
|
|
407
469
|
}
|
|
470
|
+
/** Expiry check that works before the TTL manager exists (e.g. pre-init). */
|
|
471
|
+
isItemExpired(value) {
|
|
472
|
+
if (this.ttlManager)
|
|
473
|
+
return this.ttlManager.isExpired(value);
|
|
474
|
+
return value.expires !== undefined && Date.now() > value.expires;
|
|
475
|
+
}
|
|
408
476
|
/**
|
|
409
477
|
* Get all keys from storage
|
|
410
478
|
*
|
|
@@ -431,17 +499,23 @@ export class Strata {
|
|
|
431
499
|
*/
|
|
432
500
|
async keys(pattern, options) {
|
|
433
501
|
await this.ensureReady();
|
|
502
|
+
const ns = this.effectiveNamespace(options);
|
|
434
503
|
if (options?.storage) {
|
|
435
504
|
const adapter = await this.selectAdapter(options.storage);
|
|
436
|
-
|
|
505
|
+
// When namespaced, fetch all physical keys and strip/scope ourselves;
|
|
506
|
+
// otherwise let the adapter apply the pattern directly.
|
|
507
|
+
const raw = ns ? await adapter.keys() : await adapter.keys(pattern);
|
|
508
|
+
return ns ? this.scopeAndStripKeys(raw, ns, pattern) : raw;
|
|
437
509
|
}
|
|
438
510
|
// Get keys from all adapters and deduplicate
|
|
439
511
|
const allKeys = new Set();
|
|
440
512
|
for (const adapter of this.adapters.values()) {
|
|
441
|
-
const
|
|
442
|
-
|
|
513
|
+
const raw = ns ? await adapter.keys() : await adapter.keys(pattern);
|
|
514
|
+
for (const key of raw)
|
|
515
|
+
allKeys.add(key);
|
|
443
516
|
}
|
|
444
|
-
|
|
517
|
+
const collected = Array.from(allKeys);
|
|
518
|
+
return ns ? this.scopeAndStripKeys(collected, ns, pattern) : collected;
|
|
445
519
|
}
|
|
446
520
|
// ===========================================================================
|
|
447
521
|
// Synchronous API
|
|
@@ -455,8 +529,11 @@ export class Strata {
|
|
|
455
529
|
// ===========================================================================
|
|
456
530
|
/** Synchronous get. Throws on async-only backends and on encrypted/compressed values. */
|
|
457
531
|
getSync(key, options) {
|
|
532
|
+
if (!isValidKey(key)) {
|
|
533
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
534
|
+
}
|
|
458
535
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
459
|
-
const value = adapter.getSync(key);
|
|
536
|
+
const value = adapter.getSync(this.resolveKey(key, options));
|
|
460
537
|
if (!value)
|
|
461
538
|
return null;
|
|
462
539
|
if (value.encrypted || value.compressed) {
|
|
@@ -466,10 +543,17 @@ export class Strata {
|
|
|
466
543
|
}
|
|
467
544
|
/** Synchronous set. Cannot encrypt or compress (those operations are async). */
|
|
468
545
|
setSync(key, value, options) {
|
|
546
|
+
if (!isValidKey(key)) {
|
|
547
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
548
|
+
}
|
|
549
|
+
if (!isValidValue(value)) {
|
|
550
|
+
throw new ValidationError('Invalid storage value: undefined, functions, and symbols cannot be stored', { key });
|
|
551
|
+
}
|
|
469
552
|
if (options?.encrypt || options?.compress) {
|
|
470
553
|
throw new StorageError('Synchronous set cannot encrypt or compress (those operations are async). Use the async set() instead.');
|
|
471
554
|
}
|
|
472
555
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
556
|
+
const physicalKey = this.resolveKey(key, options);
|
|
473
557
|
const now = Date.now();
|
|
474
558
|
const storageValue = {
|
|
475
559
|
value,
|
|
@@ -481,11 +565,11 @@ export class Strata {
|
|
|
481
565
|
encrypted: false,
|
|
482
566
|
compressed: false,
|
|
483
567
|
};
|
|
484
|
-
adapter.setSync(
|
|
568
|
+
adapter.setSync(physicalKey, storageValue);
|
|
485
569
|
if (this.syncManager) {
|
|
486
570
|
this.syncManager.broadcast({
|
|
487
571
|
type: 'set',
|
|
488
|
-
key,
|
|
572
|
+
key: physicalKey,
|
|
489
573
|
value: storageValue,
|
|
490
574
|
storage: adapter.name,
|
|
491
575
|
timestamp: now,
|
|
@@ -494,12 +578,16 @@ export class Strata {
|
|
|
494
578
|
}
|
|
495
579
|
/** Synchronous remove. */
|
|
496
580
|
removeSync(key, options) {
|
|
581
|
+
if (!isValidKey(key)) {
|
|
582
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
583
|
+
}
|
|
497
584
|
const adapter = this.requireSyncAdapter(options?.storage);
|
|
498
|
-
|
|
585
|
+
const physicalKey = this.resolveKey(key, options);
|
|
586
|
+
adapter.removeSync(physicalKey);
|
|
499
587
|
if (this.syncManager) {
|
|
500
588
|
this.syncManager.broadcast({
|
|
501
589
|
type: 'remove',
|
|
502
|
-
key,
|
|
590
|
+
key: physicalKey,
|
|
503
591
|
storage: adapter.name,
|
|
504
592
|
timestamp: Date.now(),
|
|
505
593
|
});
|
|
@@ -507,17 +595,23 @@ export class Strata {
|
|
|
507
595
|
}
|
|
508
596
|
/** Synchronous existence check. */
|
|
509
597
|
hasSync(key, options) {
|
|
510
|
-
|
|
598
|
+
if (!isValidKey(key)) {
|
|
599
|
+
throw new ValidationError('Invalid storage key', { key });
|
|
600
|
+
}
|
|
601
|
+
return this.requireSyncAdapter(options?.storage).hasSync(this.resolveKey(key, options));
|
|
511
602
|
}
|
|
512
603
|
/** Synchronous keys. With no `storage`, aggregates across sync-capable adapters. */
|
|
513
604
|
keysSync(pattern, options) {
|
|
605
|
+
const ns = this.effectiveNamespace(options);
|
|
514
606
|
if (options?.storage) {
|
|
515
|
-
|
|
607
|
+
const adapter = this.requireSyncAdapter(options.storage);
|
|
608
|
+
const raw = ns ? adapter.keysSync() : adapter.keysSync(pattern);
|
|
609
|
+
return ns ? this.scopeAndStripKeys(raw, ns, pattern) : raw;
|
|
516
610
|
}
|
|
517
611
|
const all = new Set();
|
|
518
612
|
for (const adapter of this.syncCapableAdapters()) {
|
|
519
613
|
try {
|
|
520
|
-
for (const key of adapter.keysSync(pattern)) {
|
|
614
|
+
for (const key of ns ? adapter.keysSync() : adapter.keysSync(pattern)) {
|
|
521
615
|
all.add(key);
|
|
522
616
|
}
|
|
523
617
|
}
|
|
@@ -527,17 +621,49 @@ export class Strata {
|
|
|
527
621
|
logger.debug(`Synchronous keys: skipped unavailable adapter "${adapter.name}"`, error);
|
|
528
622
|
}
|
|
529
623
|
}
|
|
530
|
-
|
|
624
|
+
const collected = Array.from(all);
|
|
625
|
+
return ns ? this.scopeAndStripKeys(collected, ns, pattern) : collected;
|
|
531
626
|
}
|
|
532
627
|
/** Synchronous clear. With no `storage`, clears all sync-capable adapters. */
|
|
533
628
|
clearSync(options) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
629
|
+
const ns = this.effectiveNamespace(options);
|
|
630
|
+
const olderThanTs = options?.olderThan !== undefined ? Strata.normalizeTimestamp(options.olderThan) : undefined;
|
|
631
|
+
const needsInspection = !!(ns ||
|
|
632
|
+
options?.prefix ||
|
|
633
|
+
options?.pattern ||
|
|
634
|
+
options?.tags ||
|
|
635
|
+
options?.expiredOnly ||
|
|
636
|
+
olderThanTs !== undefined);
|
|
637
|
+
const adapters = options?.storage
|
|
638
|
+
? [this.requireSyncAdapter(options.storage)]
|
|
639
|
+
: this.syncCapableAdapters();
|
|
640
|
+
for (const adapter of adapters) {
|
|
539
641
|
try {
|
|
540
|
-
|
|
642
|
+
if (!needsInspection) {
|
|
643
|
+
adapter.clearSync();
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const scopedPrefix = ns ? `${ns}:${options?.prefix ?? ''}` : options?.prefix;
|
|
647
|
+
for (const physicalKey of adapter.keysSync()) {
|
|
648
|
+
if (ns && !physicalKey.startsWith(`${ns}:`))
|
|
649
|
+
continue;
|
|
650
|
+
if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
|
|
651
|
+
continue;
|
|
652
|
+
if (options?.pattern &&
|
|
653
|
+
this.filterKeysByPattern([physicalKey], options.pattern).length === 0)
|
|
654
|
+
continue;
|
|
655
|
+
if (options?.tags || options?.expiredOnly || olderThanTs !== undefined) {
|
|
656
|
+
const value = adapter.getSync(physicalKey);
|
|
657
|
+
if (options?.tags &&
|
|
658
|
+
(!value?.tags || !options.tags.some((tag) => value.tags?.includes(tag))))
|
|
659
|
+
continue;
|
|
660
|
+
if (options?.expiredOnly && (!value || !this.isItemExpired(value)))
|
|
661
|
+
continue;
|
|
662
|
+
if (olderThanTs !== undefined && (!value || value.created >= olderThanTs))
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
adapter.removeSync(physicalKey);
|
|
666
|
+
}
|
|
541
667
|
}
|
|
542
668
|
catch (error) {
|
|
543
669
|
logger.debug(`Synchronous clear: skipped unavailable adapter "${adapter.name}"`, error);
|
|
@@ -604,23 +730,47 @@ export class Strata {
|
|
|
604
730
|
/**
|
|
605
731
|
* Get storage size information
|
|
606
732
|
*/
|
|
607
|
-
async size(detailed) {
|
|
733
|
+
async size(detailed, options) {
|
|
608
734
|
await this.ensureReady();
|
|
735
|
+
const ns = this.effectiveNamespace(options);
|
|
609
736
|
let total = 0;
|
|
610
737
|
let count = 0;
|
|
611
738
|
const byStorage = {};
|
|
612
739
|
const allByKey = {};
|
|
613
740
|
for (const [type, adapter] of this.adapters.entries()) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
741
|
+
if (!ns) {
|
|
742
|
+
const sizeInfo = await adapter.size(detailed);
|
|
743
|
+
total += sizeInfo.total;
|
|
744
|
+
count += sizeInfo.count;
|
|
745
|
+
byStorage[type] = sizeInfo.total;
|
|
746
|
+
// Aggregate byKey data if detailed
|
|
747
|
+
if (detailed && sizeInfo.byKey) {
|
|
748
|
+
for (const [key, size] of Object.entries(sizeInfo.byKey)) {
|
|
749
|
+
allByKey[key] = (allByKey[key] || 0) + size;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
let storageTotal = 0;
|
|
755
|
+
const prefix = `${ns}:`;
|
|
756
|
+
for (const physicalKey of await adapter.keys()) {
|
|
757
|
+
if (!physicalKey.startsWith(prefix))
|
|
758
|
+
continue;
|
|
759
|
+
const value = await adapter.get(physicalKey);
|
|
760
|
+
if (!value)
|
|
761
|
+
continue;
|
|
762
|
+
const logicalKey = physicalKey.slice(prefix.length);
|
|
763
|
+
const keySize = logicalKey.length * 2;
|
|
764
|
+
const valueSize = getObjectSize(value);
|
|
765
|
+
const entrySize = keySize + valueSize;
|
|
766
|
+
total += entrySize;
|
|
767
|
+
storageTotal += entrySize;
|
|
768
|
+
count++;
|
|
769
|
+
if (detailed) {
|
|
770
|
+
allByKey[logicalKey] = (allByKey[logicalKey] || 0) + entrySize;
|
|
622
771
|
}
|
|
623
772
|
}
|
|
773
|
+
byStorage[type] = storageTotal;
|
|
624
774
|
}
|
|
625
775
|
const result = {
|
|
626
776
|
total,
|
|
@@ -638,6 +788,21 @@ export class Strata {
|
|
|
638
788
|
subscribe(callback, options) {
|
|
639
789
|
let cancelled = false;
|
|
640
790
|
const unsubscribers = [];
|
|
791
|
+
// When namespaced, only forward changes for keys this namespace owns and
|
|
792
|
+
// present the logical (un-prefixed) key to the subscriber.
|
|
793
|
+
const ns = this.effectiveNamespace(options);
|
|
794
|
+
const effectiveCallback = ns
|
|
795
|
+
? (change) => {
|
|
796
|
+
if (change.key === '*') {
|
|
797
|
+
callback(change);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const prefix = `${ns}:`;
|
|
801
|
+
if (!change.key.startsWith(prefix))
|
|
802
|
+
return;
|
|
803
|
+
callback({ ...change, key: change.key.slice(prefix.length) });
|
|
804
|
+
}
|
|
805
|
+
: callback;
|
|
641
806
|
const attach = () => {
|
|
642
807
|
if (cancelled)
|
|
643
808
|
return;
|
|
@@ -646,7 +811,7 @@ export class Strata {
|
|
|
646
811
|
: Array.from(this.adapters.values());
|
|
647
812
|
for (const adapter of targets) {
|
|
648
813
|
if (adapter?.subscribe) {
|
|
649
|
-
unsubscribers.push(adapter.subscribe(
|
|
814
|
+
unsubscribers.push(adapter.subscribe(effectiveCallback));
|
|
650
815
|
}
|
|
651
816
|
}
|
|
652
817
|
};
|
|
@@ -675,28 +840,77 @@ export class Strata {
|
|
|
675
840
|
*/
|
|
676
841
|
async query(condition, options) {
|
|
677
842
|
const adapter = await this.selectAdapter(options?.storage);
|
|
678
|
-
|
|
679
|
-
|
|
843
|
+
const ns = this.effectiveNamespace(options);
|
|
844
|
+
const rawKeys = await adapter.keys();
|
|
845
|
+
// Query decoded values, not adapter-level stored wrappers. This keeps
|
|
846
|
+
// query() behavior aligned with get(), including namespace scoping, TTL,
|
|
847
|
+
// integrity repair, decryption, and decompression.
|
|
848
|
+
const decoded = [];
|
|
849
|
+
for (const physicalKey of rawKeys) {
|
|
850
|
+
if (ns && !physicalKey.startsWith(`${ns}:`))
|
|
851
|
+
continue;
|
|
852
|
+
const logicalKey = ns ? physicalKey.slice(ns.length + 1) : physicalKey;
|
|
853
|
+
if (!isValidKey(logicalKey))
|
|
854
|
+
continue;
|
|
855
|
+
const value = await this.get(logicalKey, options);
|
|
856
|
+
if (value === null)
|
|
857
|
+
continue;
|
|
858
|
+
if (this.queryEngine.matches(value, condition)) {
|
|
859
|
+
decoded.push({ key: logicalKey, value });
|
|
860
|
+
}
|
|
680
861
|
}
|
|
681
|
-
return
|
|
862
|
+
return this.applyQueryOptions(decoded, options);
|
|
863
|
+
}
|
|
864
|
+
/** Apply QueryOptions (sort → skip → limit → select) to decoded query results. */
|
|
865
|
+
applyQueryOptions(items, options) {
|
|
866
|
+
let result = items;
|
|
867
|
+
if (options?.sort) {
|
|
868
|
+
const spec = typeof options.sort === 'string'
|
|
869
|
+
? { [options.sort]: 1 }
|
|
870
|
+
: options.sort;
|
|
871
|
+
// Sort by fields of the stored value (prefix paths so the engine reads
|
|
872
|
+
// `item.value.<field>`).
|
|
873
|
+
const sortBy = Object.fromEntries(Object.entries(spec).map(([field, dir]) => [`value.${field}`, dir]));
|
|
874
|
+
result = this.queryEngine.sort(result, sortBy);
|
|
875
|
+
}
|
|
876
|
+
if (typeof options?.skip === 'number' && options.skip > 0) {
|
|
877
|
+
result = result.slice(options.skip);
|
|
878
|
+
}
|
|
879
|
+
if (typeof options?.limit === 'number') {
|
|
880
|
+
result = result.slice(0, Math.max(0, options.limit));
|
|
881
|
+
}
|
|
882
|
+
if (options?.select && options.select.length > 0) {
|
|
883
|
+
const projection = {};
|
|
884
|
+
for (const field of options.select)
|
|
885
|
+
projection[field] = 1;
|
|
886
|
+
result = result.map((item) => ({
|
|
887
|
+
key: item.key,
|
|
888
|
+
value: this.queryEngine.project(item.value, projection),
|
|
889
|
+
}));
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
682
892
|
}
|
|
683
893
|
/**
|
|
684
894
|
* Export storage data
|
|
685
895
|
*/
|
|
686
896
|
async export(options) {
|
|
687
897
|
const data = {};
|
|
688
|
-
const
|
|
898
|
+
const opts = options?.storage
|
|
899
|
+
? { storage: options.storage }
|
|
900
|
+
: undefined;
|
|
901
|
+
const keys = options?.keys || (await this.keys(undefined, opts));
|
|
689
902
|
for (const key of keys) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
903
|
+
if (options?.includeMetadata) {
|
|
904
|
+
// Read the full wrapper from the targeted adapter (physical key).
|
|
905
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
906
|
+
const storageValue = await adapter.get(this.resolveKey(key, opts));
|
|
907
|
+
if (storageValue)
|
|
695
908
|
data[key] = storageValue;
|
|
696
|
-
|
|
697
|
-
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
const value = await this.get(key, opts);
|
|
912
|
+
if (value !== null)
|
|
698
913
|
data[key] = value;
|
|
699
|
-
}
|
|
700
914
|
}
|
|
701
915
|
}
|
|
702
916
|
const format = options?.format || 'json';
|
|
@@ -705,6 +919,12 @@ export class Strata {
|
|
|
705
919
|
}
|
|
706
920
|
throw new StorageError(`Export format ${format} not supported`);
|
|
707
921
|
}
|
|
922
|
+
/** Detect a full StorageValue wrapper (e.g. from export({ includeMetadata })). */
|
|
923
|
+
isStorageValueWrapper(value) {
|
|
924
|
+
if (!isObject(value))
|
|
925
|
+
return false;
|
|
926
|
+
return ('value' in value && typeof value.created === 'number' && typeof value.updated === 'number');
|
|
927
|
+
}
|
|
708
928
|
/**
|
|
709
929
|
* Import storage data
|
|
710
930
|
*/
|
|
@@ -723,25 +943,38 @@ export class Strata {
|
|
|
723
943
|
if (!isObject(parsed)) {
|
|
724
944
|
throw new StorageError('Cannot import: expected a JSON object of key/value pairs');
|
|
725
945
|
}
|
|
946
|
+
const opts = options?.storage
|
|
947
|
+
? { storage: options.storage }
|
|
948
|
+
: undefined;
|
|
726
949
|
for (const [key, value] of Object.entries(parsed)) {
|
|
727
|
-
// Never write a prototype-pollution
|
|
728
|
-
//
|
|
729
|
-
if (!
|
|
950
|
+
// Never write a prototype-pollution / invalid key, and never let it reach
|
|
951
|
+
// deepMerge below (isValidKey also rejects __proto__/constructor/prototype).
|
|
952
|
+
if (!isValidKey(key))
|
|
730
953
|
continue;
|
|
731
|
-
const exists = await this.has(key);
|
|
954
|
+
const exists = await this.has(key, opts);
|
|
955
|
+
// A full StorageValue wrapper (from export({ includeMetadata })) is written
|
|
956
|
+
// straight to the adapter so its TTL/tags/metadata/flags survive — routing
|
|
957
|
+
// it through set() would wrap the wrapper and corrupt the shape.
|
|
958
|
+
if (this.isStorageValueWrapper(value)) {
|
|
959
|
+
if (!exists || options?.overwrite) {
|
|
960
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
961
|
+
await adapter.set(this.resolveKey(key, opts), value);
|
|
962
|
+
}
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
732
965
|
if (!exists || options?.overwrite) {
|
|
733
|
-
await this.set(key, value);
|
|
966
|
+
await this.set(key, value, opts);
|
|
734
967
|
}
|
|
735
968
|
else if (options?.merge) {
|
|
736
|
-
const existing = await this.get(key);
|
|
969
|
+
const existing = await this.get(key, opts);
|
|
737
970
|
if (options.merge === 'deep' && typeof existing === 'object' && typeof value === 'object') {
|
|
738
971
|
// Use deep merge utility for proper nested object merging.
|
|
739
972
|
// deepMerge itself strips prototype-pollution keys defensively.
|
|
740
973
|
const merged = deepMerge(existing, value);
|
|
741
|
-
await this.set(key, merged);
|
|
974
|
+
await this.set(key, merged, opts);
|
|
742
975
|
}
|
|
743
976
|
else {
|
|
744
|
-
await this.set(key, value);
|
|
977
|
+
await this.set(key, value, opts);
|
|
745
978
|
}
|
|
746
979
|
}
|
|
747
980
|
}
|
|
@@ -799,12 +1032,14 @@ export class Strata {
|
|
|
799
1032
|
if (!isObject(data)) {
|
|
800
1033
|
throw new IntegrityError('Cannot restore: snapshot payload is not an object');
|
|
801
1034
|
}
|
|
802
|
-
const adapter = await this.selectAdapter();
|
|
1035
|
+
const adapter = await this.selectAdapter(options?.storage);
|
|
803
1036
|
for (const [key, wrapper] of Object.entries(data)) {
|
|
804
|
-
// Skip prototype-pollution keys; never use them as a storage key.
|
|
805
|
-
if (!
|
|
1037
|
+
// Skip prototype-pollution / invalid keys; never use them as a storage key.
|
|
1038
|
+
if (!isValidKey(key))
|
|
806
1039
|
continue;
|
|
807
|
-
|
|
1040
|
+
// Snapshots have no per-call namespace; the instance-level namespace (if
|
|
1041
|
+
// any) is applied via resolveKey().
|
|
1042
|
+
await adapter.set(this.resolveKey(key), wrapper);
|
|
808
1043
|
}
|
|
809
1044
|
}
|
|
810
1045
|
/**
|
|
@@ -853,7 +1088,7 @@ export class Strata {
|
|
|
853
1088
|
if (!this.ttlManager)
|
|
854
1089
|
return null;
|
|
855
1090
|
const adapter = await this.selectAdapter(options?.storage);
|
|
856
|
-
const value = await adapter.get(key);
|
|
1091
|
+
const value = await adapter.get(this.resolveKey(key, options));
|
|
857
1092
|
if (!value)
|
|
858
1093
|
return null;
|
|
859
1094
|
return this.ttlManager.getTimeToLive(value);
|
|
@@ -866,12 +1101,13 @@ export class Strata {
|
|
|
866
1101
|
throw new StorageError('TTL manager not initialized');
|
|
867
1102
|
}
|
|
868
1103
|
const adapter = await this.selectAdapter(options?.storage);
|
|
869
|
-
const
|
|
1104
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1105
|
+
const value = await adapter.get(physicalKey);
|
|
870
1106
|
if (!value) {
|
|
871
1107
|
throw new StorageError(`Key ${key} not found`);
|
|
872
1108
|
}
|
|
873
1109
|
const updated = this.ttlManager.extendTTL(value, extension);
|
|
874
|
-
await adapter.set(
|
|
1110
|
+
await adapter.set(physicalKey, updated);
|
|
875
1111
|
}
|
|
876
1112
|
/**
|
|
877
1113
|
* Make a key persistent (remove TTL)
|
|
@@ -881,12 +1117,13 @@ export class Strata {
|
|
|
881
1117
|
throw new StorageError('TTL manager not initialized');
|
|
882
1118
|
}
|
|
883
1119
|
const adapter = await this.selectAdapter(options?.storage);
|
|
884
|
-
const
|
|
1120
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1121
|
+
const value = await adapter.get(physicalKey);
|
|
885
1122
|
if (!value) {
|
|
886
1123
|
throw new StorageError(`Key ${key} not found`);
|
|
887
1124
|
}
|
|
888
1125
|
const persisted = this.ttlManager.persist(value);
|
|
889
|
-
await adapter.set(
|
|
1126
|
+
await adapter.set(physicalKey, persisted);
|
|
890
1127
|
}
|
|
891
1128
|
/**
|
|
892
1129
|
* Get items expiring within a time window
|
|
@@ -895,7 +1132,13 @@ export class Strata {
|
|
|
895
1132
|
if (!this.ttlManager)
|
|
896
1133
|
return [];
|
|
897
1134
|
const adapter = await this.selectAdapter(options?.storage);
|
|
898
|
-
|
|
1135
|
+
const ns = this.effectiveNamespace(options);
|
|
1136
|
+
const items = await this.ttlManager.getExpiring(timeWindow, () => adapter.keys(), (physicalKey) => adapter.get(physicalKey));
|
|
1137
|
+
return ns
|
|
1138
|
+
? items
|
|
1139
|
+
.filter((item) => item.key.startsWith(`${ns}:`))
|
|
1140
|
+
.map((item) => ({ ...item, key: item.key.slice(ns.length + 1) }))
|
|
1141
|
+
: items;
|
|
899
1142
|
}
|
|
900
1143
|
/**
|
|
901
1144
|
* Manually trigger TTL cleanup
|
|
@@ -904,7 +1147,19 @@ export class Strata {
|
|
|
904
1147
|
if (!this.ttlManager)
|
|
905
1148
|
return 0;
|
|
906
1149
|
const adapter = await this.selectAdapter(options?.storage);
|
|
907
|
-
|
|
1150
|
+
// SQLite filters expired rows in SQL, so its keys() no longer surfaces them
|
|
1151
|
+
// — the per-key sweep below would reclaim nothing. Delegate to the adapter's
|
|
1152
|
+
// own bulk reclamation (one DELETE), matching what the automatic TTL tick
|
|
1153
|
+
// does. Reclamation here is backend-wide (not namespace-scoped), consistent
|
|
1154
|
+
// with that tick.
|
|
1155
|
+
if (adapter.name === 'sqlite' && typeof adapter.cleanupExpired === 'function') {
|
|
1156
|
+
return adapter.cleanupExpired();
|
|
1157
|
+
}
|
|
1158
|
+
const ns = this.effectiveNamespace(options);
|
|
1159
|
+
const expired = await this.ttlManager.cleanup(async () => {
|
|
1160
|
+
const keys = await adapter.keys();
|
|
1161
|
+
return ns ? keys.filter((key) => key.startsWith(`${ns}:`)) : keys;
|
|
1162
|
+
}, (physicalKey) => adapter.get(physicalKey), (physicalKey) => adapter.remove(physicalKey));
|
|
908
1163
|
return expired.length;
|
|
909
1164
|
}
|
|
910
1165
|
/**
|
|
@@ -920,6 +1175,21 @@ export class Strata {
|
|
|
920
1175
|
registerAdapter(adapter) {
|
|
921
1176
|
this.registry.register(adapter);
|
|
922
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Initialize and attach any registered adapters that are not yet active, then
|
|
1180
|
+
* re-pick the default adapter. Call this after `registerAdapter()` on an
|
|
1181
|
+
* already-initialized instance (e.g. `registerCapacitorAdapters()`), so the
|
|
1182
|
+
* newly registered adapters become available to multi-adapter operations
|
|
1183
|
+
* (keys/clear/size/subscribe) — not just to explicit `{ storage }` calls.
|
|
1184
|
+
*/
|
|
1185
|
+
async refreshAdapters() {
|
|
1186
|
+
if (!this._initialized) {
|
|
1187
|
+
await this.initialize();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
await this.initializeAdapters();
|
|
1191
|
+
this.selectDefaultAdapter();
|
|
1192
|
+
}
|
|
923
1193
|
/**
|
|
924
1194
|
* Get the adapter registry (for advanced use cases)
|
|
925
1195
|
* @internal
|
|
@@ -935,6 +1205,10 @@ export class Strata {
|
|
|
935
1205
|
clearInterval(this._autoBackupTimer);
|
|
936
1206
|
this._autoBackupTimer = undefined;
|
|
937
1207
|
}
|
|
1208
|
+
if (this._ttlCleanupTimer) {
|
|
1209
|
+
clearInterval(this._ttlCleanupTimer);
|
|
1210
|
+
this._ttlCleanupTimer = undefined;
|
|
1211
|
+
}
|
|
938
1212
|
for (const adapter of this.adapters.values()) {
|
|
939
1213
|
if (adapter.close) {
|
|
940
1214
|
await adapter.close();
|
|
@@ -953,14 +1227,30 @@ export class Strata {
|
|
|
953
1227
|
if (this.ttlManager) {
|
|
954
1228
|
this.ttlManager.clear();
|
|
955
1229
|
}
|
|
1230
|
+
// Reset initialization state so the instance can be re-initialized after
|
|
1231
|
+
// close() (framework unmount/remount, test teardown, adapter swap).
|
|
1232
|
+
this.defaultAdapter = undefined;
|
|
1233
|
+
this._initialized = false;
|
|
1234
|
+
this._readyPromise = undefined;
|
|
956
1235
|
}
|
|
957
1236
|
// Private methods
|
|
958
1237
|
normalizeConfig(config) {
|
|
959
|
-
|
|
1238
|
+
const normalized = {
|
|
960
1239
|
platform: config.platform || this.detectPlatform(),
|
|
961
|
-
defaultStorages: config.defaultStorages || ['memory'], // Default to memory adapter
|
|
962
1240
|
...config,
|
|
963
1241
|
};
|
|
1242
|
+
// Normalize the singular `defaultStorage` into `defaultStorages` so it is no
|
|
1243
|
+
// longer a silent no-op: it is prepended to (or seeds) the preference list.
|
|
1244
|
+
if (config.defaultStorage) {
|
|
1245
|
+
const existing = config.defaultStorages ?? [];
|
|
1246
|
+
normalized.defaultStorages = existing.includes(config.defaultStorage)
|
|
1247
|
+
? existing
|
|
1248
|
+
: [config.defaultStorage, ...existing];
|
|
1249
|
+
}
|
|
1250
|
+
if (!normalized.defaultStorages || normalized.defaultStorages.length === 0) {
|
|
1251
|
+
normalized.defaultStorages = ['memory'];
|
|
1252
|
+
}
|
|
1253
|
+
return normalized;
|
|
964
1254
|
}
|
|
965
1255
|
detectPlatform() {
|
|
966
1256
|
if (isBrowser())
|
|
@@ -969,6 +1259,40 @@ export class Strata {
|
|
|
969
1259
|
return 'node';
|
|
970
1260
|
return 'web'; // Default to web
|
|
971
1261
|
}
|
|
1262
|
+
// --- Namespace + key helpers ----------------------------------------------
|
|
1263
|
+
/** Effective namespace for an operation: a per-call value wins over the
|
|
1264
|
+
* instance default (`config.namespace`). Empty strings are treated as none. */
|
|
1265
|
+
effectiveNamespace(options) {
|
|
1266
|
+
const ns = options?.namespace ?? this.config.namespace;
|
|
1267
|
+
return ns && ns.length > 0 ? ns : undefined;
|
|
1268
|
+
}
|
|
1269
|
+
/** Map a logical key to the physical key actually stored (namespace-prefixed). */
|
|
1270
|
+
resolveKey(key, options) {
|
|
1271
|
+
const ns = this.effectiveNamespace(options);
|
|
1272
|
+
return ns ? `${ns}:${key}` : key;
|
|
1273
|
+
}
|
|
1274
|
+
/** Reduce physical keys to the logical keys owned by `ns`, then apply pattern. */
|
|
1275
|
+
scopeAndStripKeys(physicalKeys, ns, pattern) {
|
|
1276
|
+
const prefix = `${ns}:`;
|
|
1277
|
+
const logical = physicalKeys
|
|
1278
|
+
.filter((key) => key.startsWith(prefix))
|
|
1279
|
+
.map((key) => key.slice(prefix.length));
|
|
1280
|
+
return this.filterKeysByPattern(logical, pattern);
|
|
1281
|
+
}
|
|
1282
|
+
/** Pattern matcher mirroring BaseAdapter.filterKeys (prefix / glob / RegExp). */
|
|
1283
|
+
filterKeysByPattern(keys, pattern) {
|
|
1284
|
+
if (!pattern)
|
|
1285
|
+
return keys;
|
|
1286
|
+
if (pattern instanceof RegExp)
|
|
1287
|
+
return keys.filter((key) => pattern.test(key));
|
|
1288
|
+
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
1289
|
+
return keys.filter((key) => key.startsWith(pattern));
|
|
1290
|
+
}
|
|
1291
|
+
return keys.filter((key) => matchGlob(pattern, key));
|
|
1292
|
+
}
|
|
1293
|
+
static normalizeTimestamp(value) {
|
|
1294
|
+
return value instanceof Date ? value.getTime() : value;
|
|
1295
|
+
}
|
|
972
1296
|
getDefaultStorages() {
|
|
973
1297
|
// Only return adapters that are actually registered
|
|
974
1298
|
const registered = Array.from(this.registry.getAll().keys()).map((key) => String(key));
|
|
@@ -1024,6 +1348,19 @@ export class Strata {
|
|
|
1024
1348
|
* natively via the `storage` event and sessionStorage is per-tab, so both are
|
|
1025
1349
|
* skipped here to avoid redundant re-writes.
|
|
1026
1350
|
*/
|
|
1351
|
+
// Run a TTL cleanup pass across every initialized adapter.
|
|
1352
|
+
async cleanupAllAdapters() {
|
|
1353
|
+
if (!this.ttlManager)
|
|
1354
|
+
return;
|
|
1355
|
+
for (const adapter of this.adapters.values()) {
|
|
1356
|
+
try {
|
|
1357
|
+
await this.ttlManager.cleanup(() => adapter.keys(), (key) => adapter.get(key), (key) => adapter.remove(key));
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
logger.debug(`TTL cleanup failed for adapter "${adapter.name}":`, error);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1027
1364
|
async applyRemoteChange(change) {
|
|
1028
1365
|
if (change.source !== 'remote' || !change.key || change.key === '*')
|
|
1029
1366
|
return;
|
|
@@ -1037,7 +1374,18 @@ export class Strata {
|
|
|
1037
1374
|
await adapter.remove(change.key);
|
|
1038
1375
|
}
|
|
1039
1376
|
else {
|
|
1040
|
-
|
|
1377
|
+
let incoming = change.newValue;
|
|
1378
|
+
// When a local value already exists for this key, apply the configured
|
|
1379
|
+
// conflict-resolution strategy (default 'latest' keeps the incoming
|
|
1380
|
+
// value, matching prior behavior; 'merge' / a custom resolver now take
|
|
1381
|
+
// effect instead of being silently ignored).
|
|
1382
|
+
if (this.syncManager) {
|
|
1383
|
+
const existing = await adapter.get(change.key);
|
|
1384
|
+
if (existing) {
|
|
1385
|
+
incoming = this.syncManager.resolveConflict([existing, incoming]);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
await adapter.set(change.key, incoming);
|
|
1041
1389
|
}
|
|
1042
1390
|
}
|
|
1043
1391
|
catch (error) {
|
|
@@ -1103,6 +1451,7 @@ export class Strata {
|
|
|
1103
1451
|
if (!mirrors?.length)
|
|
1104
1452
|
return undefined;
|
|
1105
1453
|
const primary = await this.selectAdapter(options?.storage);
|
|
1454
|
+
const physicalKey = this.resolveKey(key, options);
|
|
1106
1455
|
for (const name of mirrors) {
|
|
1107
1456
|
if (name === primary.name)
|
|
1108
1457
|
continue;
|
|
@@ -1110,9 +1459,9 @@ export class Strata {
|
|
|
1110
1459
|
if (!mirror)
|
|
1111
1460
|
continue;
|
|
1112
1461
|
try {
|
|
1113
|
-
const mirrorValue = await mirror.get(
|
|
1462
|
+
const mirrorValue = await mirror.get(physicalKey);
|
|
1114
1463
|
if (mirrorValue && verifyChecksum(mirrorValue.value, mirrorValue.checksum)) {
|
|
1115
|
-
await primary.set(
|
|
1464
|
+
await primary.set(physicalKey, mirrorValue); // read-repair the primary
|
|
1116
1465
|
logger.warn(`Repaired corrupted key "${key}" from mirror "${name}"`);
|
|
1117
1466
|
// Decode via the normal path now that the primary is valid again.
|
|
1118
1467
|
return (await this.get(key, { ...options, ignoreCorruption: false })) ?? undefined;
|
|
@@ -1130,9 +1479,18 @@ export class Strata {
|
|
|
1130
1479
|
return;
|
|
1131
1480
|
const backupKey = cfg.key ?? '__strata_backup__';
|
|
1132
1481
|
this._autoBackupTimer = setInterval(() => {
|
|
1133
|
-
void
|
|
1134
|
-
|
|
1135
|
-
|
|
1482
|
+
void (async () => {
|
|
1483
|
+
try {
|
|
1484
|
+
// Exclude the backup key itself so each snapshot doesn't nest the
|
|
1485
|
+
// previous backup (which would grow without bound).
|
|
1486
|
+
const keys = (await this.keys()).filter((key) => key !== backupKey);
|
|
1487
|
+
const snap = await this.snapshot({ keys });
|
|
1488
|
+
await this.set(backupKey, snap, { storage: cfg.storage });
|
|
1489
|
+
}
|
|
1490
|
+
catch (error) {
|
|
1491
|
+
logger.warn('Auto-backup failed:', error);
|
|
1492
|
+
}
|
|
1493
|
+
})();
|
|
1136
1494
|
}, cfg.interval);
|
|
1137
1495
|
}
|
|
1138
1496
|
async selectAdapter(storage) {
|