@esportsplus/web-storage 0.4.0 → 0.5.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/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { decrypt, encrypt } from '@esportsplus/utilities';
2
- import type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope } from './types';
1
+ import { encryption } from '@esportsplus/utilities';
3
2
  import { DriverType } from './constants';
4
3
  import { IndexedDBDriver } from '~/drivers/indexeddb';
5
4
  import { LocalStorageDriver } from '~/drivers/localstorage';
6
5
  import { MemoryDriver } from '~/drivers/memory';
7
6
  import { SessionStorageDriver } from '~/drivers/sessionstorage';
8
7
 
8
+ import type { Cipher } from '@esportsplus/utilities';
9
+ import type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope } from './types';
10
+
9
11
 
10
12
  const VERSION_KEY = '__version__';
11
13
 
@@ -17,15 +19,14 @@ function isEnvelope<V>(value: unknown): value is TTLEnvelope<V> {
17
19
  && '__v' in (value as Record<string, unknown>);
18
20
  }
19
21
 
20
- async function deserialize<V>(value: unknown, secret: string | null): Promise<V | undefined> {
22
+ async function deserialize<V>(value: unknown, cipher: Cipher | null): Promise<V | undefined> {
21
23
  if (value === undefined || value === null) {
22
24
  return undefined;
23
25
  }
24
26
 
25
- if (secret && typeof value === 'string') {
27
+ if (cipher && typeof value === 'string') {
26
28
  try {
27
- value = await decrypt(value, secret);
28
- value = JSON.parse(value as string);
29
+ value = await cipher.decrypt(value);
29
30
  }
30
31
  catch {
31
32
  return undefined;
@@ -35,13 +36,13 @@ async function deserialize<V>(value: unknown, secret: string | null): Promise<V
35
36
  return value as V;
36
37
  }
37
38
 
38
- async function serialize<V>(value: V, secret: string | null): Promise<string | V> {
39
+ async function serialize<V>(value: V, cipher: Cipher | null): Promise<string | V> {
39
40
  if (value === undefined || value === null) {
40
41
  return value;
41
42
  }
42
43
 
43
- if (secret) {
44
- return encrypt(JSON.stringify(value), secret);
44
+ if (cipher) {
45
+ return cipher.encrypt(value);
45
46
  }
46
47
 
47
48
  return value;
@@ -101,14 +102,14 @@ async function migrate<T>(driver: Driver<T>, migrations: Record<number, Migratio
101
102
 
102
103
  let transformed = await migrations[keys[i]]({ all: () => Promise.resolve(data) });
103
104
 
104
- await driver.clear();
105
-
106
105
  let entries: [keyof T, T[keyof T]][] = [];
107
106
 
108
107
  for (let key in transformed) {
109
108
  entries.push([key as keyof T, transformed[key] as T[keyof T]]);
110
109
  }
111
110
 
111
+ await driver.clear();
112
+
112
113
  if (entries.length > 0) {
113
114
  await driver.replace(entries);
114
115
  }
@@ -120,6 +121,8 @@ async function migrate<T>(driver: Driver<T>, migrations: Record<number, Migratio
120
121
 
121
122
  class Local<T> {
122
123
 
124
+ private cipher: Cipher | null;
125
+
123
126
  private driver: Driver<T>;
124
127
 
125
128
  private globals: Set<GlobalCallback<T>>;
@@ -128,15 +131,13 @@ class Local<T> {
128
131
 
129
132
  private ready: Promise<void>;
130
133
 
131
- private secret: string | null;
132
-
133
134
  private version: number;
134
135
 
135
136
 
136
137
  constructor(options: Options, secret?: string) {
138
+ this.cipher = null;
137
139
  this.globals = new Set();
138
140
  this.listeners = new Map();
139
- this.secret = secret || null;
140
141
 
141
142
  let { migrations, name, version = 1 } = options;
142
143
 
@@ -155,11 +156,15 @@ class Local<T> {
155
156
  this.driver = new IndexedDBDriver<T>(name, version);
156
157
  }
157
158
 
159
+ let init = secret
160
+ ? encryption(secret).then((c) => { this.cipher = c; })
161
+ : Promise.resolve();
162
+
158
163
  if (migrations) {
159
- this.ready = migrate(this.driver, migrations, version);
164
+ this.ready = init.then(() => migrate(this.driver, migrations, version));
160
165
  }
161
166
  else {
162
- this.ready = Promise.resolve();
167
+ this.ready = init;
163
168
  }
164
169
  }
165
170
 
@@ -176,7 +181,7 @@ class Local<T> {
176
181
  continue;
177
182
  }
178
183
 
179
- let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw[key], this.secret),
184
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw[key], this.cipher),
180
185
  unwrapped = unwrap<T[keyof T]>(deserialized);
181
186
 
182
187
  if (deserialized === undefined) {
@@ -192,7 +197,7 @@ class Local<T> {
192
197
  }
193
198
 
194
199
  if (expired.length > 0) {
195
- this.driver.delete(expired);
200
+ await this.driver.delete(expired);
196
201
  }
197
202
 
198
203
  return result;
@@ -223,7 +228,7 @@ class Local<T> {
223
228
  return;
224
229
  }
225
230
 
226
- let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
231
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
227
232
  unwrapped = unwrap<T[keyof T]>(deserialized);
228
233
 
229
234
  if (deserialized !== undefined && unwrapped.expired) {
@@ -244,23 +249,47 @@ class Local<T> {
244
249
  async count(): Promise<number> {
245
250
  await this.ready;
246
251
 
247
- let total = await this.driver.count(),
248
- raw = await this.driver.get(VERSION_KEY as keyof T);
252
+ let expired: (keyof T)[] = [],
253
+ total = 0;
254
+
255
+ await this.driver.map(async (raw, key) => {
256
+ if (key as string === VERSION_KEY) {
257
+ return;
258
+ }
249
259
 
250
- return raw !== undefined ? total - 1 : total;
260
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
261
+ unwrapped = unwrap<T[keyof T]>(deserialized);
262
+
263
+ if (deserialized === undefined) {
264
+ return;
265
+ }
266
+
267
+ if (unwrapped.expired) {
268
+ expired.push(key);
269
+ return;
270
+ }
271
+
272
+ total++;
273
+ });
274
+
275
+ if (expired.length > 0) {
276
+ await this.driver.delete(expired);
277
+ }
278
+
279
+ return total;
251
280
  }
252
281
 
253
282
  async delete(...keys: (keyof T)[]): Promise<void> {
254
283
  await this.ready;
255
284
 
256
- let oldValues = new Map<keyof T, T[keyof T] | undefined>();
285
+ let oldValues = new Map<keyof T, T[keyof T] | undefined>(),
286
+ raw = await this.driver.only(keys);
257
287
 
258
- for (let i = 0, n = keys.length; i < n; i++) {
259
- let raw = await this.driver.get(keys[i]),
260
- deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
288
+ for (let [key, value] of raw) {
289
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher),
261
290
  unwrapped = unwrap<T[keyof T]>(deserialized);
262
291
 
263
- oldValues.set(keys[i], deserialized === undefined ? undefined : unwrapped.value);
292
+ oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
264
293
  }
265
294
 
266
295
  await this.driver.delete(keys);
@@ -284,7 +313,7 @@ class Local<T> {
284
313
  return;
285
314
  }
286
315
 
287
- let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
316
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
288
317
  unwrapped = unwrap<T[keyof T]>(deserialized);
289
318
 
290
319
  if (deserialized === undefined) {
@@ -302,7 +331,7 @@ class Local<T> {
302
331
  });
303
332
 
304
333
  if (expired.length > 0) {
305
- this.driver.delete(expired);
334
+ await this.driver.delete(expired);
306
335
  }
307
336
 
308
337
  return result;
@@ -315,7 +344,7 @@ class Local<T> {
315
344
 
316
345
  let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(
317
346
  await this.driver.get(key),
318
- this.secret
347
+ this.cipher
319
348
  ),
320
349
  missing = false,
321
350
  unwrapped = unwrap<T[keyof T]>(deserialized);
@@ -324,7 +353,7 @@ class Local<T> {
324
353
  missing = true;
325
354
  }
326
355
  else if (unwrapped.expired) {
327
- this.driver.delete([key]);
356
+ await this.driver.delete([key]);
328
357
  missing = true;
329
358
  }
330
359
 
@@ -346,9 +375,34 @@ class Local<T> {
346
375
  async keys(): Promise<(keyof T)[]> {
347
376
  await this.ready;
348
377
 
349
- let all = await this.driver.keys();
378
+ let expired: (keyof T)[] = [],
379
+ result: (keyof T)[] = [];
380
+
381
+ await this.driver.map(async (raw, key) => {
382
+ if (key as string === VERSION_KEY) {
383
+ return;
384
+ }
385
+
386
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
387
+ unwrapped = unwrap<T[keyof T]>(deserialized);
388
+
389
+ if (deserialized === undefined) {
390
+ return;
391
+ }
392
+
393
+ if (unwrapped.expired) {
394
+ expired.push(key);
395
+ return;
396
+ }
397
+
398
+ result.push(key);
399
+ });
400
+
401
+ if (expired.length > 0) {
402
+ await this.driver.delete(expired);
403
+ }
350
404
 
351
- return all.filter((k) => k as string !== VERSION_KEY);
405
+ return result;
352
406
  }
353
407
 
354
408
  async length(): Promise<number> {
@@ -368,7 +422,7 @@ class Local<T> {
368
422
  return;
369
423
  }
370
424
 
371
- let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
425
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
372
426
  unwrapped = unwrap<T[keyof T]>(deserialized);
373
427
 
374
428
  if (deserialized === undefined) {
@@ -384,7 +438,7 @@ class Local<T> {
384
438
  });
385
439
 
386
440
  if (expired.length > 0) {
387
- this.driver.delete(expired);
441
+ await this.driver.delete(expired);
388
442
  }
389
443
  }
390
444
 
@@ -396,7 +450,7 @@ class Local<T> {
396
450
  result = {} as T;
397
451
 
398
452
  for (let [key, value] of raw) {
399
- let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.secret),
453
+ let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher),
400
454
  unwrapped = unwrap<T[keyof T]>(deserialized);
401
455
 
402
456
  if (deserialized === undefined) {
@@ -412,7 +466,7 @@ class Local<T> {
412
466
  }
413
467
 
414
468
  if (expired.length > 0) {
415
- this.driver.delete(expired);
469
+ await this.driver.delete(expired);
416
470
  }
417
471
 
418
472
  return result;
@@ -422,7 +476,7 @@ class Local<T> {
422
476
  await this.ready;
423
477
 
424
478
  let raw = await this.driver.get(key),
425
- deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret);
479
+ deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher);
426
480
 
427
481
  if (deserialized === undefined) {
428
482
  return false;
@@ -431,7 +485,7 @@ class Local<T> {
431
485
  let unwrapped = unwrap<T[keyof T]>(deserialized);
432
486
 
433
487
  if (unwrapped.expired) {
434
- this.driver.delete([key]);
488
+ await this.driver.delete([key]);
435
489
  return false;
436
490
  }
437
491
 
@@ -441,7 +495,7 @@ class Local<T> {
441
495
 
442
496
  return this.driver.set(
443
497
  key,
444
- await serialize(unwrapped.value, this.secret) as T[keyof T]
498
+ await serialize(unwrapped.value, this.cipher) as T[keyof T]
445
499
  );
446
500
  }
447
501
 
@@ -450,11 +504,15 @@ class Local<T> {
450
504
 
451
505
  let entries: [keyof T, unknown][] = [],
452
506
  failed: string[] = [],
453
- oldValues = new Map<keyof T, T[keyof T] | undefined>();
454
-
455
- for (let key in values) {
456
- let raw = await this.driver.get(key),
457
- deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
507
+ fetchKeys = Object.keys(values) as (keyof T)[],
508
+ oldValues = new Map<keyof T, T[keyof T] | undefined>(),
509
+ raw = await this.driver.only(fetchKeys);
510
+
511
+ for (let key of fetchKeys) {
512
+ let value = raw.get(key),
513
+ deserialized = value !== undefined
514
+ ? await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher)
515
+ : undefined,
458
516
  unwrapped = unwrap<T[keyof T]>(deserialized);
459
517
 
460
518
  oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
@@ -462,11 +520,11 @@ class Local<T> {
462
520
  try {
463
521
  entries.push([
464
522
  key,
465
- await serialize(values[key], this.secret)
523
+ await serialize(values[key], this.cipher)
466
524
  ]);
467
525
  }
468
526
  catch {
469
- failed.push(key);
527
+ failed.push(key as string);
470
528
  }
471
529
  }
472
530
 
@@ -488,7 +546,7 @@ class Local<T> {
488
546
 
489
547
  try {
490
548
  let oldRaw = await this.driver.get(key),
491
- oldDeserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(oldRaw, this.secret),
549
+ oldDeserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(oldRaw, this.cipher),
492
550
  oldUnwrapped = unwrap<T[keyof T]>(oldDeserialized),
493
551
  oldValue = oldDeserialized === undefined ? undefined : oldUnwrapped.value,
494
552
  stored: T[keyof T] | string;
@@ -499,10 +557,10 @@ class Local<T> {
499
557
  __v: value
500
558
  };
501
559
 
502
- stored = await serialize(envelope, this.secret) as T[keyof T];
560
+ stored = await serialize(envelope, this.cipher) as T[keyof T];
503
561
  }
504
562
  else {
505
- stored = await serialize(value, this.secret) as T[keyof T];
563
+ stored = await serialize(value, this.cipher) as T[keyof T];
506
564
  }
507
565
 
508
566
  let result = await this.driver.set(key, stored as T[keyof T]);
@@ -545,7 +603,7 @@ class Local<T> {
545
603
  await this.ready;
546
604
 
547
605
  let raw = await this.driver.get(key),
548
- deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret);
606
+ deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher);
549
607
 
550
608
  if (deserialized === undefined) {
551
609
  return -1;
@@ -558,7 +616,7 @@ class Local<T> {
558
616
  let remaining = deserialized.__e - Date.now();
559
617
 
560
618
  if (remaining <= 0) {
561
- this.driver.delete([key]);
619
+ await this.driver.delete([key]);
562
620
  return -1;
563
621
  }
564
622
 
package/src/lz.ts ADDED
@@ -0,0 +1,200 @@
1
+ type CompressCtx = { bitsInBuffer: number; buffer: number; numBits: number; output: number[] };
2
+ type DecompressCtx = { bitPos: number; compressed: string; currentValue: number; pos: number };
3
+
4
+
5
+ let MAX_DECOMPRESSED_SIZE = 10_485_760;
6
+
7
+
8
+ function emitLiteral(ctx: CompressCtx, ch: string) {
9
+ let code = ch.charCodeAt(0);
10
+
11
+ if (code < 256) {
12
+ writeBits(ctx, ctx.numBits, 0);
13
+ writeBits(ctx, 8, code);
14
+ }
15
+ else {
16
+ writeBits(ctx, ctx.numBits, 1);
17
+ writeBits(ctx, 16, code);
18
+ }
19
+ }
20
+
21
+ function readBits(ctx: DecompressCtx, n: number): number {
22
+ let result = 0;
23
+
24
+ for (let i = 0; i < n; i++) {
25
+ if (ctx.bitPos > 15) {
26
+ if (ctx.pos >= ctx.compressed.length) {
27
+ throw new Error('LZ: unexpected end of compressed data');
28
+ }
29
+
30
+ ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
31
+ ctx.bitPos = 0;
32
+ }
33
+
34
+ result = (result << 1) | ((ctx.currentValue >> (15 - ctx.bitPos)) & 1);
35
+ ctx.bitPos++;
36
+ }
37
+
38
+ return result;
39
+ }
40
+
41
+ function writeBits(ctx: CompressCtx, n: number, value: number) {
42
+ for (let i = n - 1; i >= 0; i--) {
43
+ ctx.buffer = (ctx.buffer << 1) | ((value >> i) & 1);
44
+ ctx.bitsInBuffer++;
45
+
46
+ if (ctx.bitsInBuffer === 16) {
47
+ ctx.output.push(ctx.buffer + 1);
48
+ ctx.buffer = 0;
49
+ ctx.bitsInBuffer = 0;
50
+ }
51
+ }
52
+ }
53
+
54
+
55
+ const compress = (input: string): string => {
56
+ if (!input) {
57
+ return '';
58
+ }
59
+
60
+ let ctx: CompressCtx = { bitsInBuffer: 0, buffer: 0, numBits: 2, output: [] },
61
+ dictSize = 3,
62
+ dictionary = new Map<string, number>(),
63
+ w = '';
64
+
65
+ for (let i = 0, n = input.length; i < n; i++) {
66
+ let c = input[i],
67
+ wc = w + c;
68
+
69
+ if (dictionary.has(wc)) {
70
+ w = wc;
71
+ continue;
72
+ }
73
+
74
+ if (w.length > 0) {
75
+ if (dictionary.has(w)) {
76
+ writeBits(ctx, ctx.numBits, dictionary.get(w)!);
77
+ }
78
+ else {
79
+ emitLiteral(ctx, w);
80
+ }
81
+
82
+ dictionary.set(wc, dictSize++);
83
+
84
+ if (dictSize > (1 << ctx.numBits)) {
85
+ ctx.numBits++;
86
+ }
87
+ }
88
+
89
+ w = c;
90
+ }
91
+
92
+ if (w.length > 0) {
93
+ if (dictionary.has(w)) {
94
+ writeBits(ctx, ctx.numBits, dictionary.get(w)!);
95
+ }
96
+ else {
97
+ emitLiteral(ctx, w);
98
+ }
99
+ }
100
+
101
+ // Trailing dict advance: ensures the decompressor's last placeholder growth
102
+ // matches (the decompressor will push a placeholder before reading EOF)
103
+ dictSize++;
104
+
105
+ if (dictSize > (1 << ctx.numBits)) {
106
+ ctx.numBits++;
107
+ }
108
+
109
+ writeBits(ctx, ctx.numBits, 2);
110
+
111
+ if (ctx.bitsInBuffer > 0) {
112
+ ctx.output.push(((ctx.buffer << (16 - ctx.bitsInBuffer)) & 0xFFFF) + 1);
113
+ }
114
+
115
+ ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
116
+
117
+ return String.fromCharCode(...ctx.output);
118
+ };
119
+
120
+ const decompress = (compressed: string): string => {
121
+ if (!compressed) {
122
+ return '';
123
+ }
124
+
125
+ let ctx: DecompressCtx = { bitPos: 16, compressed: '', currentValue: 0, pos: 0 },
126
+ dictSize = 3,
127
+ dictionary: string[] = [],
128
+ numBits = 2;
129
+
130
+ ctx.compressed = compressed.substring(0, compressed.length - 1);
131
+
132
+ let code = readBits(ctx, numBits),
133
+ entry: string;
134
+
135
+ if (code === 0) {
136
+ entry = String.fromCharCode(readBits(ctx, 8));
137
+ }
138
+ else if (code === 1) {
139
+ entry = String.fromCharCode(readBits(ctx, 16));
140
+ }
141
+ else {
142
+ return '';
143
+ }
144
+
145
+ let parts: string[] = [entry],
146
+ totalLength = entry.length,
147
+ w = entry;
148
+
149
+ while (true) {
150
+ // Reserve dict slot BEFORE reading (matches compressor's add-before-next-emit timing)
151
+ let slotIdx = dictionary.length;
152
+
153
+ dictionary.push('');
154
+ dictSize++;
155
+
156
+ if (dictSize > (1 << numBits)) {
157
+ numBits++;
158
+ }
159
+
160
+ code = readBits(ctx, numBits);
161
+
162
+ if (code === 2) {
163
+ dictionary.pop();
164
+ break;
165
+ }
166
+
167
+ let slotCode = slotIdx + 3;
168
+
169
+ if (code === 0) {
170
+ entry = String.fromCharCode(readBits(ctx, 8));
171
+ }
172
+ else if (code === 1) {
173
+ entry = String.fromCharCode(readBits(ctx, 16));
174
+ }
175
+ else if (code === slotCode) {
176
+ entry = w + w[0];
177
+ }
178
+ else if (code >= 3 && code < slotCode) {
179
+ entry = dictionary[code - 3];
180
+ }
181
+ else {
182
+ throw new Error('LZ: invalid decompression code');
183
+ }
184
+
185
+ dictionary[slotIdx] = w + entry[0];
186
+ parts.push(entry);
187
+ totalLength += entry.length;
188
+
189
+ if (totalLength > MAX_DECOMPRESSED_SIZE) {
190
+ throw new Error('LZ: decompressed output exceeds size limit');
191
+ }
192
+
193
+ w = entry;
194
+ }
195
+
196
+ return parts.join('');
197
+ };
198
+
199
+
200
+ export { compress, decompress };
package/src/types.ts CHANGED
@@ -44,4 +44,4 @@ type GlobalCallback<T> = (key: keyof T, newValue: T[keyof T] | undefined, oldVal
44
44
  type KeyCallback<T, K extends keyof T = keyof T> = (newValue: T[K] | undefined, oldValue: T[K] | undefined) => void;
45
45
 
46
46
 
47
- export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationContext, MigrationFn, Options, SetOptions, TTLEnvelope };
47
+ export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope };
@@ -232,6 +232,92 @@ describe('LocalStorageDriver', () => {
232
232
  });
233
233
 
234
234
 
235
+ describe('compression', () => {
236
+ type LargeData = { bio: string };
237
+
238
+ let largeDriver: LocalStorageDriver<LargeData>,
239
+ largeValue: string;
240
+
241
+ beforeEach(() => {
242
+ largeDriver = new LocalStorageDriver<LargeData>('lz', 1);
243
+ largeValue = 'a'.repeat(200);
244
+ });
245
+
246
+ it('stores small values without compression prefix', async () => {
247
+ await driver.set('name', 'alice');
248
+
249
+ let raw = localStorage.getItem('test:1:name')!;
250
+
251
+ expect(raw.charCodeAt(0)).not.toBe(1);
252
+ expect(raw).toBe('"alice"');
253
+ });
254
+
255
+ it('stores large values with \\x01 prefix', async () => {
256
+ await largeDriver.set('bio', largeValue);
257
+
258
+ let raw = localStorage.getItem('lz:1:bio')!;
259
+
260
+ expect(raw.charCodeAt(0)).toBe(1);
261
+ });
262
+
263
+ it('round-trips large values through set/get', async () => {
264
+ await largeDriver.set('bio', largeValue);
265
+
266
+ expect(await largeDriver.get('bio')).toBe(largeValue);
267
+ });
268
+
269
+ it('round-trips large values through replace/all', async () => {
270
+ await largeDriver.replace([['bio', largeValue]]);
271
+
272
+ let all = await largeDriver.all();
273
+
274
+ expect(all.bio).toBe(largeValue);
275
+ });
276
+
277
+ it('reads existing uncompressed values (backward compat)', async () => {
278
+ localStorage.setItem('lz:1:bio', JSON.stringify(largeValue));
279
+
280
+ expect(await largeDriver.get('bio')).toBe(largeValue);
281
+ });
282
+
283
+ it('compressed output is smaller than raw JSON', async () => {
284
+ await largeDriver.set('bio', largeValue);
285
+
286
+ let compressed = localStorage.getItem('lz:1:bio')!,
287
+ raw = JSON.stringify(largeValue);
288
+
289
+ expect(compressed.length).toBeLessThan(raw.length);
290
+ });
291
+
292
+ it('handles 100-byte boundary correctly', async () => {
293
+ type BoundaryData = { val: string };
294
+
295
+ let boundaryDriver = new LocalStorageDriver<BoundaryData>('bound', 1);
296
+
297
+ // JSON.stringify('"' + 'x'.repeat(97) + '"') = 97 chars + 2 quotes = "xxx...x" = 99 chars inside quotes, total 99+2=101? No.
298
+ // JSON.stringify('x'.repeat(96)) = '"' + 'x'*96 + '"' = 98 bytes < 100 => no compress
299
+ await boundaryDriver.set('val', 'x'.repeat(96));
300
+
301
+ let rawSmall = localStorage.getItem('bound:1:val')!;
302
+
303
+ expect(rawSmall.charCodeAt(0)).not.toBe(1);
304
+
305
+ // JSON.stringify('x'.repeat(98)) = '"' + 'x'*98 + '"' = 100 bytes >= 100 => compress
306
+ await boundaryDriver.set('val', 'x'.repeat(98));
307
+
308
+ let rawLarge = localStorage.getItem('bound:1:val')!;
309
+
310
+ expect(rawLarge.charCodeAt(0)).toBe(1);
311
+ });
312
+
313
+ it('parse returns undefined for corrupted compressed data', async () => {
314
+ localStorage.setItem('lz:1:bio', '\x01corrupted-data');
315
+
316
+ expect(await largeDriver.get('bio')).toBeUndefined();
317
+ });
318
+ });
319
+
320
+
235
321
  describe('set / get', () => {
236
322
  it('overwrites existing key', async () => {
237
323
  await driver.set('name', 'alice');