@esportsplus/web-storage 0.5.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.
@@ -52,7 +52,13 @@ class LocalStorageDriver {
52
52
  }
53
53
  }
54
54
  async count() {
55
- return this.getKeys().length;
55
+ let count = 0;
56
+ for (let i = 0, n = localStorage.length; i < n; i++) {
57
+ if (localStorage.key(i)?.startsWith(this.prefix)) {
58
+ count++;
59
+ }
60
+ }
61
+ return count;
56
62
  }
57
63
  async delete(keys) {
58
64
  for (let i = 0, n = keys.length; i < n; i++) {
@@ -52,7 +52,13 @@ class SessionStorageDriver {
52
52
  }
53
53
  }
54
54
  async count() {
55
- return this.getKeys().length;
55
+ let count = 0;
56
+ for (let i = 0, n = sessionStorage.length; i < n; i++) {
57
+ if (sessionStorage.key(i)?.startsWith(this.prefix)) {
58
+ count++;
59
+ }
60
+ }
61
+ return count;
56
62
  }
57
63
  async delete(keys) {
58
64
  for (let i = 0, n = keys.length; i < n; i++) {
package/build/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { Filter, GlobalCallback, KeyCallback, Options, SetOptions } from './types.js';
2
2
  declare class Local<T> {
3
+ private cipher;
3
4
  private driver;
4
5
  private globals;
5
6
  private listeners;
6
7
  private ready;
7
- private secret;
8
8
  private version;
9
9
  constructor(options: Options, secret?: string);
10
10
  all(): Promise<T>;
package/build/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decrypt, encrypt } from '@esportsplus/utilities';
1
+ import { encryption } from '@esportsplus/utilities';
2
2
  import { DriverType } from './constants.js';
3
3
  import { IndexedDBDriver } from './drivers/indexeddb.js';
4
4
  import { LocalStorageDriver } from './drivers/localstorage.js';
@@ -11,14 +11,13 @@ function isEnvelope(value) {
11
11
  && '__e' in value
12
12
  && '__v' in value;
13
13
  }
14
- async function deserialize(value, secret) {
14
+ async function deserialize(value, cipher) {
15
15
  if (value === undefined || value === null) {
16
16
  return undefined;
17
17
  }
18
- if (secret && typeof value === 'string') {
18
+ if (cipher && typeof value === 'string') {
19
19
  try {
20
- value = await decrypt(value, secret);
21
- value = JSON.parse(value);
20
+ value = await cipher.decrypt(value);
22
21
  }
23
22
  catch {
24
23
  return undefined;
@@ -26,12 +25,12 @@ async function deserialize(value, secret) {
26
25
  }
27
26
  return value;
28
27
  }
29
- async function serialize(value, secret) {
28
+ async function serialize(value, cipher) {
30
29
  if (value === undefined || value === null) {
31
30
  return value;
32
31
  }
33
- if (secret) {
34
- return encrypt(JSON.stringify(value), secret);
32
+ if (cipher) {
33
+ return cipher.encrypt(value);
35
34
  }
36
35
  return value;
37
36
  }
@@ -70,11 +69,11 @@ async function migrate(driver, migrations, version) {
70
69
  }
71
70
  }
72
71
  let transformed = await migrations[keys[i]]({ all: () => Promise.resolve(data) });
73
- await driver.clear();
74
72
  let entries = [];
75
73
  for (let key in transformed) {
76
74
  entries.push([key, transformed[key]]);
77
75
  }
76
+ await driver.clear();
78
77
  if (entries.length > 0) {
79
78
  await driver.replace(entries);
80
79
  }
@@ -82,16 +81,16 @@ async function migrate(driver, migrations, version) {
82
81
  await driver.set(VERSION_KEY, version);
83
82
  }
84
83
  class Local {
84
+ cipher;
85
85
  driver;
86
86
  globals;
87
87
  listeners;
88
88
  ready;
89
- secret;
90
89
  version;
91
90
  constructor(options, secret) {
91
+ this.cipher = null;
92
92
  this.globals = new Set();
93
93
  this.listeners = new Map();
94
- this.secret = secret || null;
95
94
  let { migrations, name, version = 1 } = options;
96
95
  this.version = version;
97
96
  if (options.driver === DriverType.LocalStorage) {
@@ -106,11 +105,14 @@ class Local {
106
105
  else {
107
106
  this.driver = new IndexedDBDriver(name, version);
108
107
  }
108
+ let init = secret
109
+ ? encryption(secret).then((c) => { this.cipher = c; })
110
+ : Promise.resolve();
109
111
  if (migrations) {
110
- this.ready = migrate(this.driver, migrations, version);
112
+ this.ready = init.then(() => migrate(this.driver, migrations, version));
111
113
  }
112
114
  else {
113
- this.ready = Promise.resolve();
115
+ this.ready = init;
114
116
  }
115
117
  }
116
118
  async all() {
@@ -120,7 +122,7 @@ class Local {
120
122
  if (key === VERSION_KEY) {
121
123
  continue;
122
124
  }
123
- let deserialized = await deserialize(raw[key], this.secret), unwrapped = unwrap(deserialized);
125
+ let deserialized = await deserialize(raw[key], this.cipher), unwrapped = unwrap(deserialized);
124
126
  if (deserialized === undefined) {
125
127
  continue;
126
128
  }
@@ -131,7 +133,7 @@ class Local {
131
133
  result[key] = unwrapped.value;
132
134
  }
133
135
  if (expired.length > 0) {
134
- this.driver.delete(expired);
136
+ await this.driver.delete(expired);
135
137
  }
136
138
  return result;
137
139
  }
@@ -151,7 +153,7 @@ class Local {
151
153
  if (key === VERSION_KEY) {
152
154
  return;
153
155
  }
154
- let deserialized = await deserialize(raw, this.secret), unwrapped = unwrap(deserialized);
156
+ let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
155
157
  if (deserialized !== undefined && unwrapped.expired) {
156
158
  expired.push(key);
157
159
  oldValues.set(key, unwrapped.value);
@@ -166,15 +168,32 @@ class Local {
166
168
  }
167
169
  async count() {
168
170
  await this.ready;
169
- let total = await this.driver.count(), raw = await this.driver.get(VERSION_KEY);
170
- return raw !== undefined ? total - 1 : total;
171
+ let expired = [], total = 0;
172
+ await this.driver.map(async (raw, key) => {
173
+ if (key === VERSION_KEY) {
174
+ return;
175
+ }
176
+ let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
177
+ if (deserialized === undefined) {
178
+ return;
179
+ }
180
+ if (unwrapped.expired) {
181
+ expired.push(key);
182
+ return;
183
+ }
184
+ total++;
185
+ });
186
+ if (expired.length > 0) {
187
+ await this.driver.delete(expired);
188
+ }
189
+ return total;
171
190
  }
172
191
  async delete(...keys) {
173
192
  await this.ready;
174
- let oldValues = new Map();
175
- for (let i = 0, n = keys.length; i < n; i++) {
176
- let raw = await this.driver.get(keys[i]), deserialized = await deserialize(raw, this.secret), unwrapped = unwrap(deserialized);
177
- oldValues.set(keys[i], deserialized === undefined ? undefined : unwrapped.value);
193
+ let oldValues = new Map(), raw = await this.driver.only(keys);
194
+ for (let [key, value] of raw) {
195
+ let deserialized = await deserialize(value, this.cipher), unwrapped = unwrap(deserialized);
196
+ oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
178
197
  }
179
198
  await this.driver.delete(keys);
180
199
  for (let i = 0, n = keys.length; i < n; i++) {
@@ -188,7 +207,7 @@ class Local {
188
207
  if (stopped || key === VERSION_KEY) {
189
208
  return;
190
209
  }
191
- let deserialized = await deserialize(raw, this.secret), unwrapped = unwrap(deserialized);
210
+ let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
192
211
  if (deserialized === undefined) {
193
212
  return;
194
213
  }
@@ -201,18 +220,18 @@ class Local {
201
220
  }
202
221
  });
203
222
  if (expired.length > 0) {
204
- this.driver.delete(expired);
223
+ await this.driver.delete(expired);
205
224
  }
206
225
  return result;
207
226
  }
208
227
  async get(key, factory) {
209
228
  await this.ready;
210
- let deserialized = await deserialize(await this.driver.get(key), this.secret), missing = false, unwrapped = unwrap(deserialized);
229
+ let deserialized = await deserialize(await this.driver.get(key), this.cipher), missing = false, unwrapped = unwrap(deserialized);
211
230
  if (deserialized === undefined) {
212
231
  missing = true;
213
232
  }
214
233
  else if (unwrapped.expired) {
215
- this.driver.delete([key]);
234
+ await this.driver.delete([key]);
216
235
  missing = true;
217
236
  }
218
237
  if (missing) {
@@ -227,8 +246,25 @@ class Local {
227
246
  }
228
247
  async keys() {
229
248
  await this.ready;
230
- let all = await this.driver.keys();
231
- return all.filter((k) => k !== VERSION_KEY);
249
+ let expired = [], result = [];
250
+ await this.driver.map(async (raw, key) => {
251
+ if (key === VERSION_KEY) {
252
+ return;
253
+ }
254
+ let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
255
+ if (deserialized === undefined) {
256
+ return;
257
+ }
258
+ if (unwrapped.expired) {
259
+ expired.push(key);
260
+ return;
261
+ }
262
+ result.push(key);
263
+ });
264
+ if (expired.length > 0) {
265
+ await this.driver.delete(expired);
266
+ }
267
+ return result;
232
268
  }
233
269
  async length() {
234
270
  await this.ready;
@@ -241,7 +277,7 @@ class Local {
241
277
  if (key === VERSION_KEY) {
242
278
  return;
243
279
  }
244
- let deserialized = await deserialize(raw, this.secret), unwrapped = unwrap(deserialized);
280
+ let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
245
281
  if (deserialized === undefined) {
246
282
  return;
247
283
  }
@@ -252,14 +288,14 @@ class Local {
252
288
  await fn(unwrapped.value, key, j++);
253
289
  });
254
290
  if (expired.length > 0) {
255
- this.driver.delete(expired);
291
+ await this.driver.delete(expired);
256
292
  }
257
293
  }
258
294
  async only(...keys) {
259
295
  await this.ready;
260
296
  let expired = [], raw = await this.driver.only(keys), result = {};
261
297
  for (let [key, value] of raw) {
262
- let deserialized = await deserialize(value, this.secret), unwrapped = unwrap(deserialized);
298
+ let deserialized = await deserialize(value, this.cipher), unwrapped = unwrap(deserialized);
263
299
  if (deserialized === undefined) {
264
300
  continue;
265
301
  }
@@ -270,36 +306,38 @@ class Local {
270
306
  result[key] = unwrapped.value;
271
307
  }
272
308
  if (expired.length > 0) {
273
- this.driver.delete(expired);
309
+ await this.driver.delete(expired);
274
310
  }
275
311
  return result;
276
312
  }
277
313
  async persist(key) {
278
314
  await this.ready;
279
- let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.secret);
315
+ let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.cipher);
280
316
  if (deserialized === undefined) {
281
317
  return false;
282
318
  }
283
319
  let unwrapped = unwrap(deserialized);
284
320
  if (unwrapped.expired) {
285
- this.driver.delete([key]);
321
+ await this.driver.delete([key]);
286
322
  return false;
287
323
  }
288
324
  if (!unwrapped.hasTTL) {
289
325
  return true;
290
326
  }
291
- return this.driver.set(key, await serialize(unwrapped.value, this.secret));
327
+ return this.driver.set(key, await serialize(unwrapped.value, this.cipher));
292
328
  }
293
329
  async replace(values) {
294
330
  await this.ready;
295
- let entries = [], failed = [], oldValues = new Map();
296
- for (let key in values) {
297
- let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.secret), unwrapped = unwrap(deserialized);
331
+ let entries = [], failed = [], fetchKeys = Object.keys(values), oldValues = new Map(), raw = await this.driver.only(fetchKeys);
332
+ for (let key of fetchKeys) {
333
+ let value = raw.get(key), deserialized = value !== undefined
334
+ ? await deserialize(value, this.cipher)
335
+ : undefined, unwrapped = unwrap(deserialized);
298
336
  oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
299
337
  try {
300
338
  entries.push([
301
339
  key,
302
- await serialize(values[key], this.secret)
340
+ await serialize(values[key], this.cipher)
303
341
  ]);
304
342
  }
305
343
  catch {
@@ -318,16 +356,16 @@ class Local {
318
356
  async set(key, value, options) {
319
357
  await this.ready;
320
358
  try {
321
- let oldRaw = await this.driver.get(key), oldDeserialized = await deserialize(oldRaw, this.secret), oldUnwrapped = unwrap(oldDeserialized), oldValue = oldDeserialized === undefined ? undefined : oldUnwrapped.value, stored;
359
+ let oldRaw = await this.driver.get(key), oldDeserialized = await deserialize(oldRaw, this.cipher), oldUnwrapped = unwrap(oldDeserialized), oldValue = oldDeserialized === undefined ? undefined : oldUnwrapped.value, stored;
322
360
  if (options?.ttl != null && options.ttl > 0) {
323
361
  let envelope = {
324
362
  __e: Date.now() + options.ttl,
325
363
  __v: value
326
364
  };
327
- stored = await serialize(envelope, this.secret);
365
+ stored = await serialize(envelope, this.cipher);
328
366
  }
329
367
  else {
330
- stored = await serialize(value, this.secret);
368
+ stored = await serialize(value, this.cipher);
331
369
  }
332
370
  let result = await this.driver.set(key, stored);
333
371
  notify(this.globals, this.listeners, key, value, oldValue);
@@ -353,7 +391,7 @@ class Local {
353
391
  }
354
392
  async ttl(key) {
355
393
  await this.ready;
356
- let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.secret);
394
+ let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.cipher);
357
395
  if (deserialized === undefined) {
358
396
  return -1;
359
397
  }
@@ -362,7 +400,7 @@ class Local {
362
400
  }
363
401
  let remaining = deserialized.__e - Date.now();
364
402
  if (remaining <= 0) {
365
- this.driver.delete([key]);
403
+ await this.driver.delete([key]);
366
404
  return -1;
367
405
  }
368
406
  return remaining;
package/build/lz.js CHANGED
@@ -1,3 +1,4 @@
1
+ let MAX_DECOMPRESSED_SIZE = 10_485_760;
1
2
  function emitLiteral(ctx, ch) {
2
3
  let code = ch.charCodeAt(0);
3
4
  if (code < 256) {
@@ -13,6 +14,9 @@ function readBits(ctx, n) {
13
14
  let result = 0;
14
15
  for (let i = 0; i < n; i++) {
15
16
  if (ctx.bitPos > 15) {
17
+ if (ctx.pos >= ctx.compressed.length) {
18
+ throw new Error('LZ: unexpected end of compressed data');
19
+ }
16
20
  ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
17
21
  ctx.bitPos = 0;
18
22
  }
@@ -74,11 +78,7 @@ const compress = (input) => {
74
78
  ctx.output.push(((ctx.buffer << (16 - ctx.bitsInBuffer)) & 0xFFFF) + 1);
75
79
  }
76
80
  ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
77
- let chars = [];
78
- for (let i = 0, n = ctx.output.length; i < n; i++) {
79
- chars.push(String.fromCharCode(ctx.output[i]));
80
- }
81
- return chars.join('');
81
+ return String.fromCharCode(...ctx.output);
82
82
  };
83
83
  const decompress = (compressed) => {
84
84
  if (!compressed) {
@@ -96,7 +96,7 @@ const decompress = (compressed) => {
96
96
  else {
97
97
  return '';
98
98
  }
99
- let result = [entry], w = entry;
99
+ let parts = [entry], totalLength = entry.length, w = entry;
100
100
  while (true) {
101
101
  let slotIdx = dictionary.length;
102
102
  dictionary.push('');
@@ -126,9 +126,13 @@ const decompress = (compressed) => {
126
126
  throw new Error('LZ: invalid decompression code');
127
127
  }
128
128
  dictionary[slotIdx] = w + entry[0];
129
- result.push(entry);
129
+ parts.push(entry);
130
+ totalLength += entry.length;
131
+ if (totalLength > MAX_DECOMPRESSED_SIZE) {
132
+ throw new Error('LZ: decompressed output exceeds size limit');
133
+ }
130
134
  w = entry;
131
135
  }
132
- return result.join('');
136
+ return parts.join('');
133
137
  };
134
138
  export { compress, decompress };
package/build/types.d.ts CHANGED
@@ -36,4 +36,4 @@ type TTLEnvelope<V> = {
36
36
  };
37
37
  type GlobalCallback<T> = (key: keyof T, newValue: T[keyof T] | undefined, oldValue: T[keyof T] | undefined) => void;
38
38
  type KeyCallback<T, K extends keyof T = keyof T> = (newValue: T[K] | undefined, oldValue: T[K] | undefined) => void;
39
- export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationContext, MigrationFn, Options, SetOptions, TTLEnvelope };
39
+ export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "author": "ICJR",
3
3
  "dependencies": {
4
- "@esportsplus/utilities": "^0.26.0"
4
+ "@esportsplus/utilities": "^0.28.0"
5
5
  },
6
6
  "description": "Web storage utility",
7
7
  "devDependencies": {
8
- "@esportsplus/typescript": "^0.9.2",
8
+ "@esportsplus/typescript": "^0.29.1",
9
9
  "fake-indexeddb": "^6.2.5",
10
- "happy-dom": "^20.8.8",
11
- "vitest": "^4.1.1"
10
+ "happy-dom": "^20.8.9",
11
+ "vitest": "^4.1.4"
12
12
  },
13
13
  "main": "build/index.js",
14
14
  "name": "@esportsplus/web-storage",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "type": "module",
21
21
  "types": "build/index.d.ts",
22
- "version": "0.5.0",
22
+ "version": "0.5.1",
23
23
  "scripts": {
24
24
  "build": "tsc && tsc-alias",
25
25
  "test": "vitest run",
@@ -78,7 +78,15 @@ class LocalStorageDriver<T> implements Driver<T> {
78
78
  }
79
79
 
80
80
  async count(): Promise<number> {
81
- return this.getKeys().length;
81
+ let count = 0;
82
+
83
+ for (let i = 0, n = localStorage.length; i < n; i++) {
84
+ if (localStorage.key(i)?.startsWith(this.prefix)) {
85
+ count++;
86
+ }
87
+ }
88
+
89
+ return count;
82
90
  }
83
91
 
84
92
  async delete(keys: (keyof T)[]): Promise<void> {
@@ -78,7 +78,15 @@ class SessionStorageDriver<T> implements Driver<T> {
78
78
  }
79
79
 
80
80
  async count(): Promise<number> {
81
- return this.getKeys().length;
81
+ let count = 0;
82
+
83
+ for (let i = 0, n = sessionStorage.length; i < n; i++) {
84
+ if (sessionStorage.key(i)?.startsWith(this.prefix)) {
85
+ count++;
86
+ }
87
+ }
88
+
89
+ return count;
82
90
  }
83
91
 
84
92
  async delete(keys: (keyof T)[]): Promise<void> {
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 CHANGED
@@ -2,6 +2,9 @@ type CompressCtx = { bitsInBuffer: number; buffer: number; numBits: number; outp
2
2
  type DecompressCtx = { bitPos: number; compressed: string; currentValue: number; pos: number };
3
3
 
4
4
 
5
+ let MAX_DECOMPRESSED_SIZE = 10_485_760;
6
+
7
+
5
8
  function emitLiteral(ctx: CompressCtx, ch: string) {
6
9
  let code = ch.charCodeAt(0);
7
10
 
@@ -20,6 +23,10 @@ function readBits(ctx: DecompressCtx, n: number): number {
20
23
 
21
24
  for (let i = 0; i < n; i++) {
22
25
  if (ctx.bitPos > 15) {
26
+ if (ctx.pos >= ctx.compressed.length) {
27
+ throw new Error('LZ: unexpected end of compressed data');
28
+ }
29
+
23
30
  ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
24
31
  ctx.bitPos = 0;
25
32
  }
@@ -107,13 +114,7 @@ const compress = (input: string): string => {
107
114
 
108
115
  ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
109
116
 
110
- let chars: string[] = [];
111
-
112
- for (let i = 0, n = ctx.output.length; i < n; i++) {
113
- chars.push(String.fromCharCode(ctx.output[i]));
114
- }
115
-
116
- return chars.join('');
117
+ return String.fromCharCode(...ctx.output);
117
118
  };
118
119
 
119
120
  const decompress = (compressed: string): string => {
@@ -141,7 +142,8 @@ const decompress = (compressed: string): string => {
141
142
  return '';
142
143
  }
143
144
 
144
- let result: string[] = [entry],
145
+ let parts: string[] = [entry],
146
+ totalLength = entry.length,
145
147
  w = entry;
146
148
 
147
149
  while (true) {
@@ -181,11 +183,17 @@ const decompress = (compressed: string): string => {
181
183
  }
182
184
 
183
185
  dictionary[slotIdx] = w + entry[0];
184
- result.push(entry);
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
+
185
193
  w = entry;
186
194
  }
187
195
 
188
- return result.join('');
196
+ return parts.join('');
189
197
  };
190
198
 
191
199
 
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 };
package/tests/index.ts CHANGED
@@ -2,16 +2,22 @@ import 'fake-indexeddb/auto';
2
2
 
3
3
  import { vi } from 'vitest';
4
4
 
5
+ let mockDecrypt = vi.fn(async (content: string) => {
6
+ return JSON.parse(atob(content));
7
+ });
8
+
9
+ let mockEncrypt = vi.fn(async (content: unknown) => {
10
+ return btoa(JSON.stringify(content));
11
+ });
12
+
5
13
  vi.mock('@esportsplus/utilities', () => ({
6
- decrypt: vi.fn(async (content: string, _password: string) => {
7
- return atob(content);
8
- }),
9
- encrypt: vi.fn(async (content: unknown, _password: string) => {
10
- return btoa(content as string);
11
- })
14
+ encryption: vi.fn(async (_password: string) => ({
15
+ decrypt: (...args: unknown[]) => mockDecrypt(...args as [string]),
16
+ encrypt: (...args: unknown[]) => mockEncrypt(...args as [unknown])
17
+ }))
12
18
  }));
13
19
 
14
- import { decrypt, encrypt } from '@esportsplus/utilities';
20
+ import { encryption } from '@esportsplus/utilities';
15
21
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
22
 
17
23
  import createLocal, { DriverType } from '~/index';
@@ -282,13 +288,13 @@ describe('Local (LocalStorage driver)', () => {
282
288
  it('get — returns undefined when decrypt fails', async () => {
283
289
  await encrypted.set('name', 'alice');
284
290
 
285
- vi.mocked(decrypt).mockRejectedValueOnce(new Error('decrypt failed'));
291
+ mockDecrypt.mockRejectedValueOnce(new Error('decrypt failed'));
286
292
 
287
293
  expect(await encrypted.get('name')).toBeUndefined();
288
294
  });
289
295
 
290
296
  it('replace — returns failed keys when encrypt throws', async () => {
291
- vi.mocked(encrypt).mockRejectedValueOnce(new Error('encrypt failed'));
297
+ mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
292
298
 
293
299
  let failed = await encrypted.replace({ age: 25, name: 'bob' });
294
300
 
@@ -298,7 +304,7 @@ describe('Local (LocalStorage driver)', () => {
298
304
  });
299
305
 
300
306
  it('set — returns false when encrypt throws', async () => {
301
- vi.mocked(encrypt).mockRejectedValueOnce(new Error('encrypt failed'));
307
+ mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
302
308
 
303
309
  expect(await encrypted.set('name', 'alice')).toBe(false);
304
310
  });
@@ -1869,3 +1875,142 @@ describe('Compression + Encryption (LocalStorage)', () => {
1869
1875
  expect(await store.get('payload')).toBe(largeValue);
1870
1876
  });
1871
1877
  });
1878
+
1879
+
1880
+ describe('get(key, factory) — error handling', () => {
1881
+
1882
+ it('rejects when factory throws sync error', async () => {
1883
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-1', version: 1 });
1884
+
1885
+ await expect(store.get('name', () => { throw new Error('factory boom'); }))
1886
+ .rejects.toThrow('factory boom');
1887
+ });
1888
+
1889
+ it('rejects when factory returns rejected promise', async () => {
1890
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-2', version: 1 });
1891
+
1892
+ await expect(store.get('name', async () => { throw new Error('async boom'); }))
1893
+ .rejects.toThrow('async boom');
1894
+ });
1895
+ });
1896
+
1897
+
1898
+ describe('set() TTL boundary values', () => {
1899
+ let now: number;
1900
+
1901
+ beforeEach(() => {
1902
+ now = Date.now();
1903
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
1904
+ });
1905
+
1906
+ afterEach(() => {
1907
+ vi.restoreAllMocks();
1908
+ });
1909
+
1910
+
1911
+ it('ttl: 0 stores value without TTL envelope (treated as permanent)', async () => {
1912
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-zero', version: 1 });
1913
+
1914
+ await store.set('name', 'alice', { ttl: 0 });
1915
+
1916
+ now += 999999;
1917
+
1918
+ expect(await store.get('name')).toBe('alice');
1919
+ expect(await store.ttl('name')).toBe(-1);
1920
+ });
1921
+
1922
+ it('ttl: -1 stores value without TTL envelope (treated as permanent)', async () => {
1923
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-neg', version: 1 });
1924
+
1925
+ await store.set('name', 'alice', { ttl: -1 });
1926
+
1927
+ now += 999999;
1928
+
1929
+ expect(await store.get('name')).toBe('alice');
1930
+ expect(await store.ttl('name')).toBe(-1);
1931
+ });
1932
+ });
1933
+
1934
+
1935
+ describe('persist() on expired key', () => {
1936
+ let now: number;
1937
+
1938
+ beforeEach(() => {
1939
+ now = Date.now();
1940
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
1941
+ });
1942
+
1943
+ afterEach(() => {
1944
+ vi.restoreAllMocks();
1945
+ });
1946
+
1947
+
1948
+ it('returns false and deletes the expired entry', async () => {
1949
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'persist-expired', version: 1 });
1950
+
1951
+ await store.set('name', 'alice', { ttl: 10000 });
1952
+
1953
+ now += 10001;
1954
+
1955
+ expect(await store.persist('name')).toBe(false);
1956
+ expect(await store.get('name')).toBeUndefined();
1957
+ });
1958
+ });
1959
+
1960
+
1961
+ describe('ttl() on expired key', () => {
1962
+ let now: number;
1963
+
1964
+ beforeEach(() => {
1965
+ now = Date.now();
1966
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
1967
+ });
1968
+
1969
+ afterEach(() => {
1970
+ vi.restoreAllMocks();
1971
+ });
1972
+
1973
+
1974
+ it('returns -1 and deletes the expired entry', async () => {
1975
+ let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-expired', version: 1 });
1976
+
1977
+ await store.set('name', 'alice', { ttl: 10000 });
1978
+
1979
+ now += 10001;
1980
+
1981
+ expect(await store.ttl('name')).toBe(-1);
1982
+ expect(await store.get('name')).toBeUndefined();
1983
+ });
1984
+ });
1985
+
1986
+
1987
+ describe('Migration error handling', () => {
1988
+
1989
+ let migrationErrId = 0;
1990
+
1991
+ function meuid() {
1992
+ return `migration-err-${++migrationErrId}`;
1993
+ }
1994
+
1995
+
1996
+ it('throwing migration makes store methods reject', async () => {
1997
+ type V2 = { name: string };
1998
+
1999
+ let name = meuid();
2000
+
2001
+ let v1 = createLocal<{ name: string }>({ driver: DriverType.Memory, name, version: 1 });
2002
+
2003
+ await v1.set('name', 'alice');
2004
+
2005
+ let v2 = createLocal<V2>({
2006
+ driver: DriverType.Memory,
2007
+ migrations: {
2008
+ 2: async () => { throw new Error('migration failed'); }
2009
+ },
2010
+ name,
2011
+ version: 2
2012
+ });
2013
+
2014
+ await expect(v2.get('name')).rejects.toThrow('migration failed');
2015
+ });
2016
+ });
package/tests/lz.ts CHANGED
@@ -321,4 +321,51 @@ describe('LZ Compression', () => {
321
321
  expect(decompress(compressed)).toBe(input);
322
322
  });
323
323
  });
324
+
325
+ describe('error handling', () => {
326
+ it('throws on truncated compressed input', () => {
327
+ let compressed = compress('hello world this is a test string'),
328
+ truncated = compressed.substring(0, Math.floor(compressed.length / 2));
329
+
330
+ expect(() => decompress(truncated)).toThrow('LZ: unexpected end of compressed data');
331
+ });
332
+
333
+ it('throws on invalid decompression code', () => {
334
+ // Craft a compressed string with a valid header followed by an invalid code:
335
+ // Start with a valid literal (code=0, 8-bit char 'a'=97), then inject a code
336
+ // value that exceeds the current dictionary size.
337
+ // At that point: dictSize=4, numBits=2, valid codes: 0,1,2,3
338
+ // We need a code >= 4 which is impossible in 2 bits, so we need to grow
339
+ // the dictionary first. Instead, use a real compressed stream and corrupt it.
340
+ let compressed = compress('abcdefghijklmnop'),
341
+ chars = [...compressed.slice(0, -1)];
342
+
343
+ // Corrupt a middle byte to inject invalid codes
344
+ if (chars.length > 3) {
345
+ chars[2] = String.fromCharCode(chars[2].charCodeAt(0) ^ 0x7F);
346
+ }
347
+
348
+ let corrupted = chars.join('') + compressed[compressed.length - 1];
349
+
350
+ expect(() => decompress(corrupted)).toThrow();
351
+ });
352
+
353
+ it('throws when decompressed output exceeds size limit', () => {
354
+ // Use a moderately sized repetitive input that compresses quickly
355
+ // but decompresses to >10MB by building a string just over the limit
356
+ let chunk = 'abcdef'.repeat(100),
357
+ input = chunk.repeat(Math.ceil(10_485_761 / chunk.length) + 1);
358
+
359
+ let compressed = compress(input);
360
+
361
+ expect(() => decompress(compressed)).toThrow('LZ: decompressed output exceeds size limit');
362
+ }, 60000);
363
+
364
+ it('100KB repeated data round-trips without triggering size limit', () => {
365
+ let input = 'a'.repeat(100_000),
366
+ compressed = compress(input);
367
+
368
+ expect(decompress(compressed)).toBe(input);
369
+ });
370
+ });
324
371
  });
@@ -1,74 +0,0 @@
1
- # Test Audit: @esportsplus/web-storage
2
-
3
- ## Summary
4
- - Source modules: 8
5
- - Tested modules: 8 (100%)
6
- - Benchmarked modules: 0 (0%)
7
- - Total tests: 253
8
- - Total gaps found: 23
9
-
10
- ## Missing Tests (Priority Order)
11
-
12
- | Module | Export / Path | Type | Risk |
13
- |--------|-------------|------|------|
14
- | Local<T> + SessionStorage | All Local<T> methods with SS driver | integration | HIGH — only 1 factory test exists for SS; zero method coverage at Local<T> layer |
15
- | Local<T> + Memory | TTL methods (ttl, persist, cleanup) | integration | HIGH — TTL logic is driver-agnostic but untested against Memory driver |
16
- | Local<T> + Memory | get(key, factory) | integration | MED — factory tested for IDB/LS but not Memory |
17
- | Local<T> + Memory | encryption (with secret) | integration | MED — no encryption round-trip tests for Memory driver |
18
- | Local<T> + Memory | filter, only, map, length | integration | MED — bulk read operations untested at Local<T> layer for Memory |
19
- | Local<T> | persist() on non-existent key | edge case | MED — returns false by code inspection, no test |
20
- | Local<T> | persist() on already-permanent key | edge case | LOW — returns true, no test |
21
- | Local<T> | cleanup() subscription notifications | integration | MED — validator flagged: cleanup fires notify but no test covers it |
22
- | Local<T> | get() TTL expiry + subscription | edge case | MED — expired get does fire-and-forget delete but bypasses subscription-aware delete path; behavior unclear |
23
- | Local<T> | count() with VERSION_KEY present | edge case | MED — should return count-1 when migrations active; no test |
24
- | Local<T> | keys() with VERSION_KEY present | edge case | MED — should exclude __version__; no test |
25
- | Local<T> + encryption | get(key, factory) + encryption | integration | LOW — factory + encrypt combo untested |
26
- | Local<T> + LS | compression + encryption combined | integration | MED — encrypted ciphertext stored via driver which may attempt compression; round-trip untested |
27
-
28
- ## Shallow Tests
29
-
30
- | Module | Export | Covered | Missing |
31
- |--------|--------|---------|---------|
32
- | Local<T>.persist() | IDB, LS | happy path (has TTL, removes it) | non-existent key, already-permanent key, expired key |
33
- | Local<T>.cleanup() | IDB, LS | removes expired entries | empty store, no expired entries, subscription notifications |
34
- | Local<T>.clear() | IDB, LS, Memory | clears all + notifies | VERSION_KEY preserved after clear (migration stores re-init) |
35
- | Local<T>.subscribe() | Memory | set, delete, replace, clear, unsubscribe | cleanup notifications, factory-triggered notifications, TTL expiry notifications |
36
- | Local<T>.map() | IDB, LS, Memory | iterates entries | TTL filtering + VERSION_KEY filtering combined |
37
- | LZ compress/decompress | round-trip | all string types | very large strings (>100KB), strings that produce larger output than input (random/high-entropy) |
38
- | LocalStorageDriver.parse() | error handling | corrupted compressed data | corrupted non-compressed JSON, null byte in stored data |
39
-
40
- ## Missing Benchmarks
41
-
42
- No benchmark infrastructure exists. For a storage library, benchmarks would be useful for:
43
-
44
- | Module | Export | Reason |
45
- |--------|--------|--------|
46
- | LZ compress/decompress | compress() | Called on every localStorage write ≥100 bytes |
47
- | LZ compress/decompress | decompress() | Called on every localStorage read of compressed data |
48
- | LocalStorageDriver | set/get | Hot path for localStorage operations |
49
- | IndexedDBDriver | set/get/all | Async I/O operations, would reveal contention |
50
- | Local<T> | set with encryption | Encryption + serialization overhead |
51
-
52
- ## Stale Tests
53
-
54
- None found. All test references match current exports.
55
-
56
- ## Recommendations
57
-
58
- ### Priority 1: SessionStorage Local<T> integration (HIGH)
59
- The sessionStorage driver has full driver-level tests (36) but almost zero Local<T> integration tests (just 1 factory test). Add at minimum: set/get, all, delete, clear, count, keys — mirroring the existing Memory driver block. Encryption and TTL should also be tested since the driver shares serialization logic with localStorage but includes compression.
60
-
61
- ### Priority 2: Memory driver feature coverage (HIGH)
62
- TTL, persist, cleanup, get(key, factory), encryption, and bulk read operations are untested at the Local<T> layer for the Memory driver. Since Memory is the recommended driver for unit testing, these gaps are ironic — users testing their own code with Memory may hit untested paths.
63
-
64
- ### Priority 3: Cross-feature edge cases (MED)
65
- - cleanup() + subscription notifications
66
- - get() TTL expiry + subscription side-effects
67
- - count()/keys() with VERSION_KEY present (migrations active)
68
- - Compression + encryption combined round-trip
69
- - persist() on non-existent and already-permanent keys
70
-
71
- ### Priority 4: LZ compression boundaries (LOW)
72
- - Very large strings (100KB+)
73
- - High-entropy strings that don't compress
74
- - Explicit test that compression never increases size by more than a bounded amount