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.
- package/AI-INTEGRATION-GUIDE.md +41 -1
- package/README.md +1 -1
- package/dist/README.md +1 -1
- package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CacheAdapter.js +20 -5
- package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
- package/dist/adapters/web/CookieAdapter.js +27 -3
- package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
- package/dist/adapters/web/IndexedDBAdapter.js +5 -1
- package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/LocalStorageAdapter.js +15 -1
- package/dist/adapters/web/MemoryAdapter.d.ts +7 -0
- package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
- package/dist/adapters/web/MemoryAdapter.js +22 -5
- package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/web/SessionStorageAdapter.js +14 -1
- package/dist/core/Strata.d.ts +12 -0
- package/dist/core/Strata.d.ts.map +1 -1
- package/dist/core/Strata.js +119 -21
- package/dist/features/compression.d.ts +23 -8
- package/dist/features/compression.d.ts.map +1 -1
- package/dist/features/compression.js +164 -77
- package/dist/features/migration.d.ts.map +1 -1
- package/dist/features/migration.js +7 -2
- package/dist/features/query.d.ts +1 -0
- package/dist/features/query.d.ts.map +1 -1
- package/dist/features/query.js +29 -6
- package/dist/features/sync.d.ts.map +1 -1
- package/dist/features/sync.js +18 -3
- package/dist/features/ttl.d.ts.map +1 -1
- package/dist/features/ttl.js +4 -2
- package/dist/firebase.d.ts.map +1 -1
- package/dist/firebase.js +39 -2
- package/dist/index.d.ts +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
- package/scripts/build.js +30 -13
package/dist/core/Strata.js
CHANGED
|
@@ -63,7 +63,13 @@ export class Strata {
|
|
|
63
63
|
*/
|
|
64
64
|
async initialize() {
|
|
65
65
|
if (!this._readyPromise) {
|
|
66
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
907
|
+
if (ns && !physicalKey.startsWith(this.namespacePrefix(ns)))
|
|
851
908
|
continue;
|
|
852
|
-
const logicalKey = ns ? physicalKey.slice(ns.length
|
|
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(
|
|
1140
|
-
.map((item) => ({ ...item, key: item.key.slice(ns.length
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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;
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
+
const bytes = new TextEncoder().encode(uncompressed);
|
|
102
|
+
if (bytes.length === 0)
|
|
78
103
|
return '';
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let phrase =
|
|
83
|
-
let
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
phrase
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
code++;
|
|
98
|
-
phrase = currentChar;
|
|
121
|
+
phrase = current;
|
|
99
122
|
}
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
out.push(phrase);
|
|
106
|
-
}
|
|
107
|
-
return this.encodeToBase64(out.join(''));
|
|
131
|
+
return this.bytesToBase64(out);
|
|
108
132
|
}
|
|
109
133
|
/**
|
|
110
|
-
* LZ
|
|
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
|
|
138
|
+
if (!compressed)
|
|
114
139
|
return '';
|
|
115
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
let
|
|
123
|
-
|
|
124
|
-
for (let i = 1; i <
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
if (nextCode <= CompressionManager.LZW_MAX_CODE) {
|
|
165
|
+
dictionary.set(nextCode++, oldPhrase + phrase.charAt(0));
|
|
166
|
+
}
|
|
136
167
|
oldPhrase = phrase;
|
|
137
168
|
}
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
|
188
|
+
const chars = CompressionManager.B64_CHARS;
|
|
150
189
|
let result = '';
|
|
151
|
-
let i = 0;
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const
|
|
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
|
|
160
|
-
result += i
|
|
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
|
-
*
|
|
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
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
258
|
+
return out.join('');
|
|
259
|
+
}
|
|
260
|
+
legacyDecodeFromBase64(input) {
|
|
261
|
+
const chars = `${CompressionManager.B64_CHARS}=`;
|
|
174
262
|
let result = '';
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
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 (
|
|
271
|
+
if (e3 !== 64 && e3 !== -1)
|
|
185
272
|
result += String.fromCharCode((bitmap >> 8) & 255);
|
|
186
|
-
if (
|
|
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;
|
|
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
|
-
|
|
26
|
-
|
|
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
|
}
|
package/dist/features/query.d.ts
CHANGED