strata-storage 2.7.0 → 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 (37) hide show
  1. package/AI-INTEGRATION-GUIDE.md +41 -1
  2. package/README.md +1 -1
  3. package/dist/README.md +1 -1
  4. package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
  5. package/dist/adapters/web/CacheAdapter.js +20 -5
  6. package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
  7. package/dist/adapters/web/CookieAdapter.js +27 -3
  8. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  9. package/dist/adapters/web/IndexedDBAdapter.js +5 -1
  10. package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
  11. package/dist/adapters/web/LocalStorageAdapter.js +15 -1
  12. package/dist/adapters/web/MemoryAdapter.d.ts +7 -0
  13. package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
  14. package/dist/adapters/web/MemoryAdapter.js +22 -5
  15. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  16. package/dist/adapters/web/SessionStorageAdapter.js +14 -1
  17. package/dist/core/Strata.d.ts +12 -0
  18. package/dist/core/Strata.d.ts.map +1 -1
  19. package/dist/core/Strata.js +119 -21
  20. package/dist/features/compression.d.ts +23 -8
  21. package/dist/features/compression.d.ts.map +1 -1
  22. package/dist/features/compression.js +164 -77
  23. package/dist/features/migration.d.ts.map +1 -1
  24. package/dist/features/migration.js +7 -2
  25. package/dist/features/query.d.ts +1 -0
  26. package/dist/features/query.d.ts.map +1 -1
  27. package/dist/features/query.js +29 -6
  28. package/dist/features/sync.d.ts.map +1 -1
  29. package/dist/features/sync.js +18 -3
  30. package/dist/features/ttl.d.ts.map +1 -1
  31. package/dist/features/ttl.js +4 -2
  32. package/dist/firebase.d.ts.map +1 -1
  33. package/dist/firebase.js +39 -2
  34. package/dist/index.d.ts +2 -2
  35. package/dist/package.json +1 -1
  36. package/package.json +1 -1
  37. package/scripts/build.js +30 -13
@@ -63,7 +63,13 @@ export class Strata {
63
63
  */
64
64
  async initialize() {
65
65
  if (!this._readyPromise) {
66
- 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
+ });
67
73
  }
68
74
  return this._readyPromise;
69
75
  }
@@ -441,12 +447,42 @@ export class Strata {
441
447
  // Fast path: an unfiltered, un-namespaced clear wipes the whole adapter.
442
448
  if (!needsInspection) {
443
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();
444
478
  continue;
445
479
  }
446
480
  // Filtered/namespaced clear: inspect keys and remove only the matches.
447
- const scopedPrefix = ns ? `${ns}:${options?.prefix ?? ''}` : options?.prefix;
481
+ const scopedPrefix = ns
482
+ ? `${this.namespacePrefix(ns)}${options?.prefix ?? ''}`
483
+ : options?.prefix;
448
484
  for (const physicalKey of await adapter.keys()) {
449
- if (ns && !physicalKey.startsWith(`${ns}:`))
485
+ if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
450
486
  continue;
451
487
  if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
452
488
  continue;
@@ -641,11 +677,22 @@ export class Strata {
641
677
  try {
642
678
  if (!needsInspection) {
643
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
+ }
644
689
  continue;
645
690
  }
646
- const scopedPrefix = ns ? `${ns}:${options?.prefix ?? ''}` : options?.prefix;
691
+ const scopedPrefix = ns
692
+ ? `${this.namespacePrefix(ns)}${options?.prefix ?? ''}`
693
+ : options?.prefix;
647
694
  for (const physicalKey of adapter.keysSync()) {
648
- if (ns && !physicalKey.startsWith(`${ns}:`))
695
+ if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
649
696
  continue;
650
697
  if (scopedPrefix && !physicalKey.startsWith(scopedPrefix))
651
698
  continue;
@@ -720,11 +767,21 @@ export class Strata {
720
767
  computeExpiration(options) {
721
768
  if (this.ttlManager)
722
769
  return this.ttlManager.calculateExpiration(options);
723
- if (typeof options?.ttl === 'number')
724
- 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.
725
773
  if (options?.expireAt !== undefined) {
726
- 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;
727
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;
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;
728
785
  return undefined;
729
786
  }
730
787
  /**
@@ -752,7 +809,7 @@ export class Strata {
752
809
  continue;
753
810
  }
754
811
  let storageTotal = 0;
755
- const prefix = `${ns}:`;
812
+ const prefix = this.namespacePrefix(ns);
756
813
  for (const physicalKey of await adapter.keys()) {
757
814
  if (!physicalKey.startsWith(prefix))
758
815
  continue;
@@ -797,7 +854,7 @@ export class Strata {
797
854
  callback(change);
798
855
  return;
799
856
  }
800
- const prefix = `${ns}:`;
857
+ const prefix = this.namespacePrefix(ns);
801
858
  if (!change.key.startsWith(prefix))
802
859
  return;
803
860
  callback({ ...change, key: change.key.slice(prefix.length) });
@@ -847,9 +904,9 @@ export class Strata {
847
904
  // integrity repair, decryption, and decompression.
848
905
  const decoded = [];
849
906
  for (const physicalKey of rawKeys) {
850
- if (ns && !physicalKey.startsWith(`${ns}:`))
907
+ if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
851
908
  continue;
852
- const logicalKey = ns ? physicalKey.slice(ns.length + 1) : physicalKey;
909
+ const logicalKey = ns ? physicalKey.slice(this.namespacePrefix(ns).length) : physicalKey;
853
910
  if (!isValidKey(logicalKey))
854
911
  continue;
855
912
  const value = await this.get(logicalKey, options);
@@ -1136,8 +1193,8 @@ export class Strata {
1136
1193
  const items = await this.ttlManager.getExpiring(timeWindow, () => adapter.keys(), (physicalKey) => adapter.get(physicalKey));
1137
1194
  return ns
1138
1195
  ? items
1139
- .filter((item) => item.key.startsWith(`${ns}:`))
1140
- .map((item) => ({ ...item, key: item.key.slice(ns.length + 1) }))
1196
+ .filter((item) => item.key.startsWith(this.namespacePrefix(ns)))
1197
+ .map((item) => ({ ...item, key: item.key.slice(this.namespacePrefix(ns).length) }))
1141
1198
  : items;
1142
1199
  }
1143
1200
  /**
@@ -1158,7 +1215,7 @@ export class Strata {
1158
1215
  const ns = this.effectiveNamespace(options);
1159
1216
  const expired = await this.ttlManager.cleanup(async () => {
1160
1217
  const keys = await adapter.keys();
1161
- return ns ? keys.filter((key) => key.startsWith(`${ns}:`)) : keys;
1218
+ return ns ? keys.filter((key) => key.startsWith(this.namespacePrefix(ns))) : keys;
1162
1219
  }, (physicalKey) => adapter.get(physicalKey), (physicalKey) => adapter.remove(physicalKey));
1163
1220
  return expired.length;
1164
1221
  }
@@ -1248,7 +1305,14 @@ export class Strata {
1248
1305
  : [config.defaultStorage, ...existing];
1249
1306
  }
1250
1307
  if (!normalized.defaultStorages || normalized.defaultStorages.length === 0) {
1251
- normalized.defaultStorages = ['memory'];
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'];
1252
1316
  }
1253
1317
  return normalized;
1254
1318
  }
@@ -1266,14 +1330,30 @@ export class Strata {
1266
1330
  const ns = options?.namespace ?? this.config.namespace;
1267
1331
  return ns && ns.length > 0 ? ns : undefined;
1268
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
+ }
1269
1349
  /** Map a logical key to the physical key actually stored (namespace-prefixed). */
1270
1350
  resolveKey(key, options) {
1271
1351
  const ns = this.effectiveNamespace(options);
1272
- return ns ? `${ns}:${key}` : key;
1352
+ return ns ? `${this.encodeNamespace(ns)}:${key}` : key;
1273
1353
  }
1274
1354
  /** Reduce physical keys to the logical keys owned by `ns`, then apply pattern. */
1275
1355
  scopeAndStripKeys(physicalKeys, ns, pattern) {
1276
- const prefix = `${ns}:`;
1356
+ const prefix = `${this.encodeNamespace(ns)}:`;
1277
1357
  const logical = physicalKeys
1278
1358
  .filter((key) => key.startsWith(prefix))
1279
1359
  .map((key) => key.slice(prefix.length));
@@ -1296,8 +1376,9 @@ export class Strata {
1296
1376
  getDefaultStorages() {
1297
1377
  // Only return adapters that are actually registered
1298
1378
  const registered = Array.from(this.registry.getAll().keys()).map((key) => String(key));
1299
- // Prefer these storages in order if available
1300
- 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'];
1301
1382
  const available = preferredOrder.filter((storage) => registered.includes(storage));
1302
1383
  // Always include memory as fallback if registered
1303
1384
  if (available.length === 0 && registered.includes('memory')) {
@@ -1362,8 +1443,25 @@ export class Strata {
1362
1443
  }
1363
1444
  }
1364
1445
  async applyRemoteChange(change) {
1365
- 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
+ }
1366
1463
  return;
1464
+ }
1367
1465
  if (change.storage === 'localStorage' || change.storage === 'sessionStorage')
1368
1466
  return;
1369
1467
  const adapter = this.adapters.get(change.storage);
@@ -41,22 +41,37 @@ export declare class CompressionManager {
41
41
  * Check if data is compressed
42
42
  */
43
43
  isCompressedData(data: unknown): data is CompressedData;
44
+ private static readonly FORMAT;
45
+ private static readonly LZW_MAX_CODE;
44
46
  /**
45
- * LZ-string compression implementation
47
+ * LZ compression over UTF-8 bytes.
48
+ *
49
+ * The alphabet is bytes (0–255), so the literal-vs-dictionary-code
50
+ * discriminator (`< 256`) on decompress is correct for ALL input — including
51
+ * CJK / emoji / symbols whose code points are ≥ 256. (Operating on raw UTF-16
52
+ * chars previously misclassified any such literal as a dictionary code and
53
+ * silently corrupted the stored value, or threw on read.)
46
54
  */
47
55
  private lzCompress;
48
56
  /**
49
- * LZ-string decompression implementation
57
+ * LZ decompression — inverse of {@link lzCompress}, reconstructing UTF-8 bytes
58
+ * then decoding them back to the original string.
50
59
  */
51
60
  private lzDecompress;
61
+ private static readonly B64_CHARS;
62
+ /** Base64-encode raw bytes (btoa when available, else a pure-JS fallback). */
63
+ private bytesToBase64;
64
+ /** Decode a base64 string back to raw bytes (atob when available, else pure-JS). */
65
+ private base64ToBytes;
52
66
  /**
53
- * Base64 encoding for compressed data
67
+ * Legacy decoder for payloads written by <= 2.7.0 (algorithm 'lz'): the
68
+ * original UTF-16-char LZW over the raw string with a `decodeURIComponent`
69
+ * base64 layer. Kept read-only so existing compressed-at-rest values still
70
+ * decode after upgrading to the current byte-LZW codec. (That codec corrupted
71
+ * non-Latin1 input, so legacy payloads are Latin1 in practice.)
54
72
  */
55
- private encodeToBase64;
56
- /**
57
- * Base64 decoding for compressed data
58
- */
59
- private decodeFromBase64;
73
+ private legacyLzDecompress;
74
+ private legacyDecodeFromBase64;
60
75
  /**
61
76
  * Get compression ratio
62
77
  */
@@ -1 +1 @@
1
- {"version":3,"file":"compression.d.ts","sourceRoot":"","sources":["../../src/features/compression.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAA8B;gBAEhC,MAAM,GAAE,iBAAsB;IAQ1C;;OAEG;IACG,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,GAAG,OAAO,CAAC;IA8BhE;;OAEG;IACG,UAAU,CAAC,CAAC,GAAG,OAAO,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAazE;;OAEG;IACH,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,cAAc;IAWvD;;OAEG;IACH,OAAO,CAAC,UAAU;IAmClB;;OAEG;IACH,OAAO,CAAC,YAAY;IA+BpB;;OAEG;IACH,OAAO,CAAC,cAAc;IA2BtB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IA6BxB;;OAEG;IACH,mBAAmB,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM;IAI3D;;OAEG;IACH,oBAAoB,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM;CAO7D"}
1
+ {"version":3,"file":"compression.d.ts","sourceRoot":"","sources":["../../src/features/compression.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAIH;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAA8B;gBAEhC,MAAM,GAAE,iBAAsB;IAQ1C;;OAEG;IACG,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,GAAG,OAAO,CAAC;IAkChE;;OAEG;IACG,UAAU,CAAC,CAAC,GAAG,OAAO,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAmBzE;;OAEG;IACH,gBAAgB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,cAAc;IAcvD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAW;IAMzC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAU;IAE9C;;;;;;;;OAQG;IACH,OAAO,CAAC,UAAU;IAmClB;;;OAGG;IACH,OAAO,CAAC,YAAY;IA4CpB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CACoC;IAErE,8EAA8E;IAC9E,OAAO,CAAC,aAAa;IAyBrB,oFAAoF;IACpF,OAAO,CAAC,aAAa;IA2BrB;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IA8B1B,OAAO,CAAC,sBAAsB;IAiB9B;;OAEG;IACH,mBAAmB,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM;IAI3D;;OAEG;IACH,oBAAoB,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM;CAO7D"}
@@ -34,8 +34,12 @@ export class CompressionManager {
34
34
  return data;
35
35
  }
36
36
  return {
37
+ // Tag the on-disk format so decompress() can pick the right decoder.
38
+ // Older releases wrote `algorithm: 'lz'` with a UTF-16-char codec that
39
+ // corrupted non-Latin1 data; the current byte-LZW format is tagged
40
+ // distinctly and old 'lz' payloads still read via the legacy path.
37
41
  data: compressed,
38
- algorithm: this.config.algorithm,
42
+ algorithm: CompressionManager.FORMAT,
39
43
  originalSize,
40
44
  compressedSize,
41
45
  };
@@ -52,7 +56,12 @@ export class CompressionManager {
52
56
  if (!this.isCompressedData(compressedData)) {
53
57
  return compressedData;
54
58
  }
55
- const decompressed = this.lzDecompress(compressedData.data);
59
+ // Route by stored format tag: the current byte-LZW format vs. the legacy
60
+ // UTF-16-char codec written by <= 2.7.0 (algorithm 'lz'), so existing
61
+ // compressed-at-rest values keep decoding after upgrade.
62
+ const decompressed = compressedData.algorithm === CompressionManager.FORMAT
63
+ ? this.lzDecompress(compressedData.data)
64
+ : this.legacyLzDecompress(compressedData.data);
56
65
  return JSON.parse(decompressed);
57
66
  }
58
67
  catch (error) {
@@ -70,120 +79,198 @@ export class CompressionManager {
70
79
  'originalSize' in data &&
71
80
  'compressedSize' in data);
72
81
  }
82
+ // Format tag stored in CompressedData.algorithm for the current byte-LZW
83
+ // codec. Payloads written by <= 2.7.0 are tagged 'lz' and decode via the
84
+ // legacy path; anything tagged FORMAT uses the current decoder.
85
+ static FORMAT = 'lz-u8';
86
+ // Freeze the LZW dictionary at 16 bits so every token serializes to exactly
87
+ // two bytes; both encoder and decoder stop growing the dictionary at the same
88
+ // point, staying in sync. 0xFFFF entries is ample for storage-sized values;
89
+ // beyond it, already-learned phrases still compress, just no new ones.
90
+ static LZW_MAX_CODE = 0xffff;
73
91
  /**
74
- * LZ-string compression implementation
92
+ * LZ compression over UTF-8 bytes.
93
+ *
94
+ * The alphabet is bytes (0–255), so the literal-vs-dictionary-code
95
+ * discriminator (`< 256`) on decompress is correct for ALL input — including
96
+ * CJK / emoji / symbols whose code points are ≥ 256. (Operating on raw UTF-16
97
+ * chars previously misclassified any such literal as a dictionary code and
98
+ * silently corrupted the stored value, or threw on read.)
75
99
  */
76
100
  lzCompress(uncompressed) {
77
- if (uncompressed == null)
101
+ const bytes = new TextEncoder().encode(uncompressed);
102
+ if (bytes.length === 0)
78
103
  return '';
79
- const context = new Map();
80
- const out = [];
81
- let currentChar;
82
- let phrase = uncompressed.charAt(0);
83
- let code = 256;
84
- for (let i = 1; i < uncompressed.length; i++) {
85
- currentChar = uncompressed.charAt(i);
86
- if (context.has(phrase + currentChar)) {
87
- phrase += currentChar;
104
+ const dictionary = new Map();
105
+ let nextCode = 256;
106
+ const tokens = [];
107
+ let phrase = String.fromCharCode(bytes[0]); // byte-string (each char 0–255)
108
+ for (let i = 1; i < bytes.length; i++) {
109
+ const current = String.fromCharCode(bytes[i]);
110
+ const combined = phrase + current;
111
+ if (dictionary.has(combined)) {
112
+ phrase = combined;
88
113
  }
89
114
  else {
90
- if (phrase.length > 1) {
91
- out.push(String.fromCharCode(context.get(phrase)));
92
- }
93
- else {
94
- out.push(phrase);
115
+ // `phrase` is always either a single byte (literal) or an existing
116
+ // dictionary entry, so the lookup below is always defined.
117
+ tokens.push(phrase.length === 1 ? phrase.charCodeAt(0) : dictionary.get(phrase));
118
+ if (nextCode <= CompressionManager.LZW_MAX_CODE) {
119
+ dictionary.set(combined, nextCode++);
95
120
  }
96
- context.set(phrase + currentChar, code);
97
- code++;
98
- phrase = currentChar;
121
+ phrase = current;
99
122
  }
100
123
  }
101
- if (phrase.length > 1) {
102
- out.push(String.fromCharCode(context.get(phrase)));
124
+ tokens.push(phrase.length === 1 ? phrase.charCodeAt(0) : dictionary.get(phrase));
125
+ // Serialize each token as a 16-bit big-endian value, then base64.
126
+ const out = new Uint8Array(tokens.length * 2);
127
+ for (let i = 0; i < tokens.length; i++) {
128
+ out[i * 2] = (tokens[i] >> 8) & 0xff;
129
+ out[i * 2 + 1] = tokens[i] & 0xff;
103
130
  }
104
- else {
105
- out.push(phrase);
106
- }
107
- return this.encodeToBase64(out.join(''));
131
+ return this.bytesToBase64(out);
108
132
  }
109
133
  /**
110
- * LZ-string decompression implementation
134
+ * LZ decompression — inverse of {@link lzCompress}, reconstructing UTF-8 bytes
135
+ * then decoding them back to the original string.
111
136
  */
112
137
  lzDecompress(compressed) {
113
- if (compressed == null)
138
+ if (!compressed)
114
139
  return '';
115
- if (compressed === '')
140
+ const inBytes = this.base64ToBytes(compressed);
141
+ const tokenCount = inBytes.length >> 1;
142
+ if (tokenCount === 0)
116
143
  return '';
117
- const decoded = this.decodeFromBase64(compressed);
118
144
  const dictionary = new Map();
119
- let currentChar = decoded.charAt(0);
120
- let oldPhrase = currentChar;
121
- const out = [currentChar];
122
- let code = 256;
123
- let phrase;
124
- for (let i = 1; i < decoded.length; i++) {
125
- const currentCode = decoded.charCodeAt(i);
126
- if (currentCode < 256) {
127
- phrase = decoded.charAt(i);
145
+ let nextCode = 256;
146
+ const readToken = (i) => (inBytes[i * 2] << 8) | inBytes[i * 2 + 1];
147
+ // The first token is always a literal byte (the dictionary starts empty).
148
+ let oldPhrase = String.fromCharCode(readToken(0));
149
+ const out = [oldPhrase];
150
+ for (let i = 1; i < tokenCount; i++) {
151
+ const code = readToken(i);
152
+ let phrase;
153
+ if (code < 256) {
154
+ phrase = String.fromCharCode(code);
155
+ }
156
+ else if (dictionary.has(code)) {
157
+ phrase = dictionary.get(code);
128
158
  }
129
159
  else {
130
- phrase = dictionary.get(currentCode) || oldPhrase + currentChar;
160
+ // Classic LZW edge: the code equals the entry we are about to define.
161
+ phrase = oldPhrase + oldPhrase.charAt(0);
131
162
  }
132
163
  out.push(phrase);
133
- currentChar = phrase.charAt(0);
134
- dictionary.set(code, oldPhrase + currentChar);
135
- code++;
164
+ if (nextCode <= CompressionManager.LZW_MAX_CODE) {
165
+ dictionary.set(nextCode++, oldPhrase + phrase.charAt(0));
166
+ }
136
167
  oldPhrase = phrase;
137
168
  }
138
- return out.join('');
169
+ // `out` holds byte-strings; rebuild the byte sequence and UTF-8 decode it.
170
+ const joined = out.join('');
171
+ const bytes = new Uint8Array(joined.length);
172
+ for (let i = 0; i < joined.length; i++) {
173
+ bytes[i] = joined.charCodeAt(i) & 0xff;
174
+ }
175
+ return new TextDecoder().decode(bytes);
139
176
  }
140
- /**
141
- * Base64 encoding for compressed data
142
- */
143
- encodeToBase64(input) {
177
+ static B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
178
+ /** Base64-encode raw bytes (btoa when available, else a pure-JS fallback). */
179
+ bytesToBase64(bytes) {
144
180
  if (typeof btoa !== 'undefined') {
145
- // Browser environment
146
- return btoa(unescape(encodeURIComponent(input)));
181
+ let binary = '';
182
+ const chunk = 0x8000;
183
+ for (let i = 0; i < bytes.length; i += chunk) {
184
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
185
+ }
186
+ return btoa(binary);
147
187
  }
148
- // Pure JS implementation for Node.js or other environments
149
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
188
+ const chars = CompressionManager.B64_CHARS;
150
189
  let result = '';
151
- let i = 0;
152
- while (i < input.length) {
153
- const a = input.charCodeAt(i++);
154
- const b = i < input.length ? input.charCodeAt(i++) : 0;
155
- const c = i < input.length ? input.charCodeAt(i++) : 0;
190
+ for (let i = 0; i < bytes.length; i += 3) {
191
+ const a = bytes[i];
192
+ const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
193
+ const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
156
194
  const bitmap = (a << 16) | (b << 8) | c;
157
195
  result += chars.charAt((bitmap >> 18) & 63);
158
196
  result += chars.charAt((bitmap >> 12) & 63);
159
- result += i - 2 < input.length ? chars.charAt((bitmap >> 6) & 63) : '=';
160
- result += i - 1 < input.length ? chars.charAt(bitmap & 63) : '=';
197
+ result += i + 1 < bytes.length ? chars.charAt((bitmap >> 6) & 63) : '=';
198
+ result += i + 2 < bytes.length ? chars.charAt(bitmap & 63) : '=';
161
199
  }
162
200
  return result;
163
201
  }
202
+ /** Decode a base64 string back to raw bytes (atob when available, else pure-JS). */
203
+ base64ToBytes(input) {
204
+ if (typeof atob !== 'undefined') {
205
+ const binary = atob(input);
206
+ const bytes = new Uint8Array(binary.length);
207
+ for (let i = 0; i < binary.length; i++) {
208
+ bytes[i] = binary.charCodeAt(i) & 0xff;
209
+ }
210
+ return bytes;
211
+ }
212
+ const chars = CompressionManager.B64_CHARS;
213
+ const clean = input.replace(/[^A-Za-z0-9+/]/g, '');
214
+ const bytes = [];
215
+ for (let i = 0; i < clean.length; i += 4) {
216
+ const e1 = chars.indexOf(clean.charAt(i));
217
+ const e2 = chars.indexOf(clean.charAt(i + 1));
218
+ const e3 = chars.indexOf(clean.charAt(i + 2));
219
+ const e4 = chars.indexOf(clean.charAt(i + 3));
220
+ const bitmap = (e1 << 18) | (Math.max(e2, 0) << 12) | (Math.max(e3, 0) << 6) | Math.max(e4, 0);
221
+ bytes.push((bitmap >> 16) & 255);
222
+ if (e3 !== -1)
223
+ bytes.push((bitmap >> 8) & 255);
224
+ if (e4 !== -1)
225
+ bytes.push(bitmap & 255);
226
+ }
227
+ return new Uint8Array(bytes);
228
+ }
164
229
  /**
165
- * Base64 decoding for compressed data
230
+ * Legacy decoder for payloads written by <= 2.7.0 (algorithm 'lz'): the
231
+ * original UTF-16-char LZW over the raw string with a `decodeURIComponent`
232
+ * base64 layer. Kept read-only so existing compressed-at-rest values still
233
+ * decode after upgrading to the current byte-LZW codec. (That codec corrupted
234
+ * non-Latin1 input, so legacy payloads are Latin1 in practice.)
166
235
  */
167
- decodeFromBase64(input) {
168
- if (typeof atob !== 'undefined') {
169
- // Browser environment
170
- return decodeURIComponent(escape(atob(input)));
236
+ legacyLzDecompress(compressed) {
237
+ if (!compressed)
238
+ return '';
239
+ const decoded = typeof atob !== 'undefined'
240
+ ? decodeURIComponent(escape(atob(compressed)))
241
+ : this.legacyDecodeFromBase64(compressed);
242
+ const dictionary = new Map();
243
+ let currentChar = decoded.charAt(0);
244
+ let oldPhrase = currentChar;
245
+ const out = [currentChar];
246
+ let code = 256;
247
+ for (let i = 1; i < decoded.length; i++) {
248
+ const currentCode = decoded.charCodeAt(i);
249
+ const phrase = currentCode < 256
250
+ ? decoded.charAt(i)
251
+ : dictionary.get(currentCode) || oldPhrase + currentChar;
252
+ out.push(phrase);
253
+ currentChar = phrase.charAt(0);
254
+ dictionary.set(code, oldPhrase + currentChar);
255
+ code++;
256
+ oldPhrase = phrase;
171
257
  }
172
- // Pure JS implementation
173
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
258
+ return out.join('');
259
+ }
260
+ legacyDecodeFromBase64(input) {
261
+ const chars = `${CompressionManager.B64_CHARS}=`;
174
262
  let result = '';
175
- let i = 0;
176
- input = input.replace(/[^A-Za-z0-9+/]/g, '');
177
- while (i < input.length) {
178
- const encoded1 = chars.indexOf(input.charAt(i++));
179
- const encoded2 = chars.indexOf(input.charAt(i++));
180
- const encoded3 = chars.indexOf(input.charAt(i++));
181
- const encoded4 = chars.indexOf(input.charAt(i++));
182
- const bitmap = (encoded1 << 18) | (encoded2 << 12) | (encoded3 << 6) | encoded4;
263
+ const clean = input.replace(/[^A-Za-z0-9+/]/g, '');
264
+ for (let i = 0; i < clean.length; i += 4) {
265
+ const e1 = chars.indexOf(clean.charAt(i));
266
+ const e2 = chars.indexOf(clean.charAt(i + 1));
267
+ const e3 = chars.indexOf(clean.charAt(i + 2));
268
+ const e4 = chars.indexOf(clean.charAt(i + 3));
269
+ const bitmap = (e1 << 18) | (e2 << 12) | (e3 << 6) | e4;
183
270
  result += String.fromCharCode((bitmap >> 16) & 255);
184
- if (encoded3 !== 64)
271
+ if (e3 !== 64 && e3 !== -1)
185
272
  result += String.fromCharCode((bitmap >> 8) & 255);
186
- if (encoded4 !== 64)
273
+ if (e4 !== 64 && e4 !== -1)
187
274
  result += String.fromCharCode(bitmap & 255);
188
275
  }
189
276
  return result;
@@ -1 +1 @@
1
- {"version":3,"file":"migration.d.ts","sourceRoot":"","sources":["../../src/features/migration.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,UAAU,CAAmB;IAErC,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAK9B,OAAO,CAAC,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YA0B9D,iBAAiB;YAKjB,UAAU;CAOzB"}
1
+ {"version":3,"file":"migration.d.ts","sourceRoot":"","sources":["../../src/features/migration.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAG9C,MAAM,WAAW,SAAS;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,UAAU,CAAmB;IAErC,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI;IAK9B,OAAO,CAAC,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAgC9D,iBAAiB;YAKjB,UAAU;CAOzB"}
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Migration utilities for storage upgrades
3
3
  */
4
+ import { MigrationError } from "../utils/errors.js";
4
5
  export class MigrationManager {
5
6
  migrations = [];
6
7
  register(migration) {
@@ -22,9 +23,13 @@ export class MigrationManager {
22
23
  }
23
24
  else {
24
25
  for (const migration of migrationsToRun.reverse()) {
25
- if (migration.down) {
26
- await migration.down(adapter);
26
+ // An irreversible migration must NOT silently lower the stored version
27
+ // (that would claim a rollback that never reverted the data). Fail
28
+ // before touching anything so the version stays consistent with data.
29
+ if (!migration.down) {
30
+ throw new MigrationError(`Cannot roll back migration ${migration.version}: it has no down() and is irreversible.`);
27
31
  }
32
+ await migration.down(adapter);
28
33
  await this.setVersion(adapter, migration.version - 1);
29
34
  }
30
35
  }
@@ -68,6 +68,7 @@ export declare class QueryEngine {
68
68
  /**
69
69
  * Set nested value in object using dot notation
70
70
  */
71
+ private static readonly FORBIDDEN_KEYS;
71
72
  private setNestedValue;
72
73
  /**
73
74
  * Delete nested value from object using dot notation