strata-storage 2.6.1 → 2.7.1

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