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.
Files changed (130) hide show
  1. package/AI-INTEGRATION-GUIDE.md +97 -10
  2. package/README.md +70 -70
  3. package/android/AGENTS.md +18 -3
  4. package/android/CLAUDE.md +36 -3
  5. package/android/build.gradle +5 -1
  6. package/android/src/main/java/com/strata/storage/EncryptedStorage.java +31 -18
  7. package/android/src/main/java/com/strata/storage/FilesystemStorage.java +62 -10
  8. package/android/src/main/java/com/strata/storage/SQLiteStorage.java +65 -13
  9. package/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +63 -51
  10. package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +80 -11
  11. package/dist/README.md +70 -70
  12. package/dist/adapters/capacitor/FilesystemAdapter.d.ts +2 -2
  13. package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
  14. package/dist/adapters/capacitor/FilesystemAdapter.js +6 -2
  15. package/dist/adapters/capacitor/PreferencesAdapter.d.ts +2 -2
  16. package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
  17. package/dist/adapters/capacitor/PreferencesAdapter.js +6 -2
  18. package/dist/adapters/capacitor/SecureAdapter.d.ts +3 -3
  19. package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
  20. package/dist/adapters/capacitor/SecureAdapter.js +6 -2
  21. package/dist/adapters/capacitor/SqliteAdapter.d.ts +24 -3
  22. package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
  23. package/dist/adapters/capacitor/SqliteAdapter.js +105 -23
  24. package/dist/adapters/capacitor/index.d.ts +4 -4
  25. package/dist/adapters/web/CacheAdapter.d.ts +2 -2
  26. package/dist/adapters/web/CookieAdapter.d.ts +2 -2
  27. package/dist/adapters/web/IndexedDBAdapter.d.ts +10 -3
  28. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  29. package/dist/adapters/web/IndexedDBAdapter.js +14 -3
  30. package/dist/adapters/web/LocalStorageAdapter.d.ts +2 -2
  31. package/dist/adapters/web/MemoryAdapter.d.ts +2 -2
  32. package/dist/adapters/web/SessionStorageAdapter.d.ts +10 -10
  33. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  34. package/dist/adapters/web/SessionStorageAdapter.js +3 -2
  35. package/dist/adapters/web/URLAdapter.d.ts +2 -2
  36. package/dist/adapters/web/index.d.ts +7 -7
  37. package/dist/capacitor.d.ts +6 -6
  38. package/dist/capacitor.d.ts.map +1 -1
  39. package/dist/capacitor.js +5 -4
  40. package/dist/core/AdapterRegistry.d.ts +1 -1
  41. package/dist/core/BaseAdapter.d.ts +21 -5
  42. package/dist/core/BaseAdapter.d.ts.map +1 -1
  43. package/dist/core/BaseAdapter.js +29 -1
  44. package/dist/core/Strata.d.ts +41 -7
  45. package/dist/core/Strata.d.ts.map +1 -1
  46. package/dist/core/Strata.js +476 -118
  47. package/dist/features/compression/index.d.ts +1 -1
  48. package/dist/features/compression.d.ts +5 -1
  49. package/dist/features/compression.d.ts.map +1 -1
  50. package/dist/features/encryption/index.d.ts +1 -1
  51. package/dist/features/encryption.d.ts +55 -2
  52. package/dist/features/encryption.d.ts.map +1 -1
  53. package/dist/features/encryption.js +139 -26
  54. package/dist/features/migration.d.ts +1 -1
  55. package/dist/features/observer/index.d.ts +1 -1
  56. package/dist/features/observer.d.ts +1 -1
  57. package/dist/features/query/index.d.ts +1 -1
  58. package/dist/features/query.d.ts +1 -1
  59. package/dist/features/query.d.ts.map +1 -1
  60. package/dist/features/query.js +3 -1
  61. package/dist/features/sync/index.d.ts +1 -1
  62. package/dist/features/sync.d.ts +19 -3
  63. package/dist/features/sync.d.ts.map +1 -1
  64. package/dist/features/sync.js +57 -9
  65. package/dist/features/ttl/index.d.ts +1 -1
  66. package/dist/features/ttl.d.ts +2 -2
  67. package/dist/firebase.d.ts +1 -1
  68. package/dist/firebase.d.ts.map +1 -1
  69. package/dist/firebase.js +34 -9
  70. package/dist/index.d.ts +24 -22
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +6 -1
  73. package/dist/integrations/angular/index.d.ts +2 -2
  74. package/dist/integrations/angular/index.d.ts.map +1 -1
  75. package/dist/integrations/angular/index.js +7 -8
  76. package/dist/integrations/react/index.d.ts +3 -3
  77. package/dist/integrations/react/index.d.ts.map +1 -1
  78. package/dist/integrations/react/index.js +16 -4
  79. package/dist/integrations/vue/index.d.ts +2 -2
  80. package/dist/integrations/vue/index.d.ts.map +1 -1
  81. package/dist/integrations/vue/index.js +5 -2
  82. package/dist/package.json +11 -2
  83. package/dist/plugin/definitions.d.ts +29 -3
  84. package/dist/plugin/definitions.d.ts.map +1 -1
  85. package/dist/plugin/index.d.ts +2 -2
  86. package/dist/plugin/index.d.ts.map +1 -1
  87. package/dist/plugin/index.js +3 -0
  88. package/dist/plugin/web.d.ts +11 -2
  89. package/dist/plugin/web.d.ts.map +1 -1
  90. package/dist/plugin/web.js +23 -4
  91. package/dist/types/index.d.ts +50 -20
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/index.js +1 -10
  94. package/dist/utils/index.d.ts +5 -0
  95. package/dist/utils/index.d.ts.map +1 -1
  96. package/dist/utils/index.js +7 -0
  97. package/ios/AGENTS.md +14 -3
  98. package/ios/CLAUDE.md +25 -3
  99. package/ios/Plugin/FilesystemStorage.swift +19 -0
  100. package/ios/Plugin/SQLiteStorage.swift +59 -9
  101. package/ios/Plugin/StrataStoragePlugin.m +1 -0
  102. package/ios/Plugin/StrataStoragePlugin.swift +87 -34
  103. package/ios/Plugin/UserDefaultsStorage.swift +47 -46
  104. package/package.json +29 -26
  105. package/scripts/build.js +16 -14
  106. package/scripts/cli.js +1 -1
  107. package/scripts/configure.js +2 -2
  108. package/scripts/postinstall.js +3 -2
  109. package/dist/android/AGENTS.md +0 -51
  110. package/dist/android/CLAUDE.md +0 -89
  111. package/dist/android/build.gradle +0 -58
  112. package/dist/android/proguard-rules.pro +0 -11
  113. package/dist/android/settings.gradle +0 -2
  114. package/dist/android/src/main/AndroidManifest.xml +0 -2
  115. package/dist/android/src/main/java/com/strata/storage/EncryptedStorage.java +0 -218
  116. package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +0 -287
  117. package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +0 -371
  118. package/dist/android/src/main/java/com/strata/storage/SharedPreferencesStorage.java +0 -209
  119. package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +0 -616
  120. package/dist/core/StorageStrategy.d.ts +0 -55
  121. package/dist/core/StorageStrategy.d.ts.map +0 -1
  122. package/dist/core/StorageStrategy.js +0 -200
  123. package/dist/ios/AGENTS.md +0 -48
  124. package/dist/ios/CLAUDE.md +0 -84
  125. package/dist/ios/Plugin/FilesystemStorage.swift +0 -218
  126. package/dist/ios/Plugin/KeychainStorage.swift +0 -233
  127. package/dist/ios/Plugin/SQLiteStorage.swift +0 -426
  128. package/dist/ios/Plugin/StrataStoragePlugin.m +0 -23
  129. package/dist/ios/Plugin/StrataStoragePlugin.swift +0 -411
  130. package/dist/ios/Plugin/UserDefaultsStorage.swift +0 -87
@@ -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, isSafeKey } from "../utils/index.js";
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 for default adapter
104
- if (this.defaultAdapter && this.config.ttl?.autoCleanup !== false) {
105
- this.ttlManager.startAutoCleanup(() => this.defaultAdapter.keys(), (key) => this.defaultAdapter.get(key), (key) => this.defaultAdapter.remove(key));
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.decryptionPassword - Password for decryption (uses config password if not provided)
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
- * decryptionPassword: 'myPassword'
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 value = await adapter.get(key);
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(key);
175
+ await adapter.remove(physicalKey);
165
176
  return null;
166
177
  }
167
- // Update sliding TTL if configured
168
- if (options?.sliding && value.expires && this.ttlManager) {
169
- const updatedValue = this.ttlManager.updateExpiration(value, options);
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(key, updatedValue);
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
- // Handle decryption if needed
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
- if (!options?.skipDecryption) {
191
- const password = options?.encryptionPassword || this.config.encryption?.password;
192
- if (!password) {
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
- try {
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 value.value;
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(value);
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(value, password);
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(key, storageValue);
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, key, storageValue);
325
+ await this.verifyDurableWrite(adapter, physicalKey, storageValue);
301
326
  }
302
327
  // Mirror the write to any configured backup adapters.
303
- await this.mirrorWrite(key, storageValue, adapter.name);
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
- await adapter.remove(key);
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(key, adapter.name);
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
- if (options?.storage) {
398
- const adapter = await this.selectAdapter(options.storage);
399
- await adapter.clear(options);
400
- }
401
- else {
402
- // Clear all adapters
403
- for (const adapter of this.adapters.values()) {
404
- await adapter.clear(options);
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
- return adapter.keys(pattern);
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 keys = await adapter.keys(pattern);
442
- keys.forEach((key) => allKeys.add(key));
513
+ const raw = ns ? await adapter.keys() : await adapter.keys(pattern);
514
+ for (const key of raw)
515
+ allKeys.add(key);
443
516
  }
444
- return Array.from(allKeys);
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(key, storageValue);
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
- adapter.removeSync(key);
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
- return this.requireSyncAdapter(options?.storage).hasSync(key);
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
- return this.requireSyncAdapter(options.storage).keysSync(pattern);
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
- return Array.from(all);
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
- if (options?.storage) {
535
- this.requireSyncAdapter(options.storage).clearSync(options);
536
- return;
537
- }
538
- for (const adapter of this.syncCapableAdapters()) {
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
- adapter.clearSync(options);
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
- const sizeInfo = await adapter.size(detailed);
615
- total += sizeInfo.total;
616
- count += sizeInfo.count;
617
- byStorage[type] = sizeInfo.total;
618
- // Aggregate byKey data if detailed
619
- if (detailed && sizeInfo.byKey) {
620
- for (const [key, size] of Object.entries(sizeInfo.byKey)) {
621
- allByKey[key] = (allByKey[key] || 0) + size;
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(callback));
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
- if (!adapter.query) {
679
- throw new StorageError(`Adapter ${adapter.name} does not support queries`);
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 adapter.query(condition);
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 keys = options?.keys || (await this.keys());
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
- const value = await this.get(key);
691
- if (value !== null) {
692
- if (options?.includeMetadata) {
693
- const adapter = await this.selectAdapter();
694
- const storageValue = await adapter.get(key);
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
- else {
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 key as a storage key, and never let
728
- // it reach deepMerge below.
729
- if (!isSafeKey(key))
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 (!isSafeKey(key))
1037
+ // Skip prototype-pollution / invalid keys; never use them as a storage key.
1038
+ if (!isValidKey(key))
806
1039
  continue;
807
- await adapter.set(key, wrapper);
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 value = await adapter.get(key);
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(key, updated);
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 value = await adapter.get(key);
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(key, persisted);
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
- return this.ttlManager.getExpiring(timeWindow, () => adapter.keys(), (key) => adapter.get(key));
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
- const expired = await this.ttlManager.cleanup(() => adapter.keys(), (key) => adapter.get(key), (key) => adapter.remove(key));
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
- return {
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
- await adapter.set(change.key, change.newValue);
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(key);
1462
+ const mirrorValue = await mirror.get(physicalKey);
1114
1463
  if (mirrorValue && verifyChecksum(mirrorValue.value, mirrorValue.checksum)) {
1115
- await primary.set(key, mirrorValue); // read-repair the primary
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 this.snapshot()
1134
- .then((snap) => this.set(backupKey, snap, { storage: cfg.storage }))
1135
- .catch((error) => logger.warn('Auto-backup failed:', error));
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) {