@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/README.md +248 -0
- package/build/drivers/localstorage.d.ts +1 -0
- package/build/drivers/localstorage.js +17 -3
- package/build/drivers/sessionstorage.d.ts +1 -0
- package/build/drivers/sessionstorage.js +17 -3
- package/build/index.d.ts +1 -1
- package/build/index.js +82 -44
- package/build/lz.d.ts +3 -0
- package/build/lz.js +138 -0
- package/build/types.d.ts +1 -1
- package/package.json +5 -5
- package/src/drivers/localstorage.ts +22 -3
- package/src/drivers/sessionstorage.ts +22 -3
- package/src/index.ts +110 -52
- package/src/lz.ts +200 -0
- package/src/types.ts +1 -1
- package/tests/drivers/localstorage.ts +86 -0
- package/tests/drivers/sessionstorage.ts +85 -0
- package/tests/index.ts +564 -10
- package/tests/lz.ts +371 -0
- package/storage/feature-research.md +0 -173
package/tests/lz.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { compress, decompress } from '~/lz';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('LZ Compression', () => {
|
|
7
|
+
|
|
8
|
+
describe('empty/null handling', () => {
|
|
9
|
+
it('empty string round-trips', () => {
|
|
10
|
+
expect(compress('')).toBe('');
|
|
11
|
+
expect(decompress('')).toBe('');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('single characters', () => {
|
|
16
|
+
it('lowercase a', () => {
|
|
17
|
+
let compressed = compress('a');
|
|
18
|
+
|
|
19
|
+
expect(decompress(compressed)).toBe('a');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uppercase Z', () => {
|
|
23
|
+
let compressed = compress('Z');
|
|
24
|
+
|
|
25
|
+
expect(decompress(compressed)).toBe('Z');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('null character (U+0000)', () => {
|
|
29
|
+
let compressed = compress('\0');
|
|
30
|
+
|
|
31
|
+
expect(decompress(compressed)).toBe('\0');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('short ASCII strings', () => {
|
|
36
|
+
it('hello world', () => {
|
|
37
|
+
let input = 'hello world',
|
|
38
|
+
compressed = compress(input);
|
|
39
|
+
|
|
40
|
+
expect(decompress(compressed)).toBe(input);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('the quick brown fox', () => {
|
|
44
|
+
let input = 'The quick brown fox jumps over the lazy dog',
|
|
45
|
+
compressed = compress(input);
|
|
46
|
+
|
|
47
|
+
expect(decompress(compressed)).toBe(input);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('numbers and symbols', () => {
|
|
51
|
+
let input = '0123456789!@#$%^&*()_+-=[]{}|;:,.<>?',
|
|
52
|
+
compressed = compress(input);
|
|
53
|
+
|
|
54
|
+
expect(decompress(compressed)).toBe(input);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('repetitive strings', () => {
|
|
59
|
+
it('abcabc repeated 500 times', () => {
|
|
60
|
+
let input = 'abc'.repeat(500),
|
|
61
|
+
compressed = compress(input);
|
|
62
|
+
|
|
63
|
+
expect(decompress(compressed)).toBe(input);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('single char repeated 1000 times', () => {
|
|
67
|
+
let input = 'x'.repeat(1000),
|
|
68
|
+
compressed = compress(input);
|
|
69
|
+
|
|
70
|
+
expect(decompress(compressed)).toBe(input);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('aaabbbccc pattern repeated', () => {
|
|
74
|
+
let input = 'aaabbbccc'.repeat(200),
|
|
75
|
+
compressed = compress(input);
|
|
76
|
+
|
|
77
|
+
expect(decompress(compressed)).toBe(input);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('JSON-like strings', () => {
|
|
82
|
+
it('1KB+ JSON string round-trips', () => {
|
|
83
|
+
let items: Record<string, unknown>[] = [];
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < 50; i++) {
|
|
86
|
+
items.push({
|
|
87
|
+
age: 20 + (i % 60),
|
|
88
|
+
id: i,
|
|
89
|
+
name: `user_${i}`,
|
|
90
|
+
tags: ['alpha', 'beta', 'gamma']
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let input = JSON.stringify({ data: items, total: 50, type: 'users' }),
|
|
95
|
+
compressed = compress(input);
|
|
96
|
+
|
|
97
|
+
expect(input.length).toBeGreaterThan(1024);
|
|
98
|
+
expect(decompress(compressed)).toBe(input);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('nested JSON structure', () => {
|
|
102
|
+
let input = JSON.stringify({
|
|
103
|
+
config: {
|
|
104
|
+
database: { host: 'localhost', port: 5432 },
|
|
105
|
+
features: { darkMode: true, notifications: false }
|
|
106
|
+
},
|
|
107
|
+
users: [
|
|
108
|
+
{ email: 'alice@test.com', name: 'alice' },
|
|
109
|
+
{ email: 'bob@test.com', name: 'bob' }
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let compressed = compress(input);
|
|
114
|
+
|
|
115
|
+
expect(decompress(compressed)).toBe(input);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('unicode', () => {
|
|
120
|
+
it('emoji characters', () => {
|
|
121
|
+
let input = '😀🎉🚀💯🔥✨',
|
|
122
|
+
compressed = compress(input);
|
|
123
|
+
|
|
124
|
+
expect(decompress(compressed)).toBe(input);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('CJK characters', () => {
|
|
128
|
+
let input = '日本語テスト',
|
|
129
|
+
compressed = compress(input);
|
|
130
|
+
|
|
131
|
+
expect(decompress(compressed)).toBe(input);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('mixed scripts', () => {
|
|
135
|
+
let input = 'Hello 世界! Привет мир! 🎉 café résumé naïve',
|
|
136
|
+
compressed = compress(input);
|
|
137
|
+
|
|
138
|
+
expect(decompress(compressed)).toBe(input);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('arabic and hebrew', () => {
|
|
142
|
+
let input = 'مرحبا שלום',
|
|
143
|
+
compressed = compress(input);
|
|
144
|
+
|
|
145
|
+
expect(decompress(compressed)).toBe(input);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('byte value coverage', () => {
|
|
150
|
+
it('all 256 byte values individually', () => {
|
|
151
|
+
for (let i = 0; i < 256; i++) {
|
|
152
|
+
let input = String.fromCharCode(i),
|
|
153
|
+
compressed = compress(input);
|
|
154
|
+
|
|
155
|
+
expect(decompress(compressed)).toBe(input);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('all 256 byte values concatenated', () => {
|
|
160
|
+
let chars: string[] = [];
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < 256; i++) {
|
|
163
|
+
chars.push(String.fromCharCode(i));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let input = chars.join(''),
|
|
167
|
+
compressed = compress(input);
|
|
168
|
+
|
|
169
|
+
expect(decompress(compressed)).toBe(input);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('compression ratio', () => {
|
|
174
|
+
it('1KB repetitive JSON compresses to < 50%', () => {
|
|
175
|
+
let items: Record<string, unknown>[] = [];
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < 50; i++) {
|
|
178
|
+
items.push({
|
|
179
|
+
active: true,
|
|
180
|
+
name: 'test_user',
|
|
181
|
+
role: 'admin',
|
|
182
|
+
score: 100
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let input = JSON.stringify(items),
|
|
187
|
+
compressed = compress(input);
|
|
188
|
+
|
|
189
|
+
expect(input.length).toBeGreaterThan(1024);
|
|
190
|
+
expect(compressed.length).toBeLessThan(input.length * 0.5);
|
|
191
|
+
expect(decompress(compressed)).toBe(input);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('edge cases', () => {
|
|
196
|
+
it('two character string', () => {
|
|
197
|
+
let compressed = compress('ab');
|
|
198
|
+
|
|
199
|
+
expect(decompress(compressed)).toBe('ab');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('three character string', () => {
|
|
203
|
+
let compressed = compress('abc');
|
|
204
|
+
|
|
205
|
+
expect(decompress(compressed)).toBe('abc');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('string with only whitespace', () => {
|
|
209
|
+
let input = ' \t\n\r ',
|
|
210
|
+
compressed = compress(input);
|
|
211
|
+
|
|
212
|
+
expect(decompress(compressed)).toBe(input);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('string with newlines', () => {
|
|
216
|
+
let input = 'line1\nline2\nline3\n',
|
|
217
|
+
compressed = compress(input);
|
|
218
|
+
|
|
219
|
+
expect(decompress(compressed)).toBe(input);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('compressed output contains no null bytes', () => {
|
|
223
|
+
let inputs = [
|
|
224
|
+
'hello world',
|
|
225
|
+
'abc'.repeat(500),
|
|
226
|
+
JSON.stringify({ key: 'value' }),
|
|
227
|
+
'日本語テスト'
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
for (let i = 0, n = inputs.length; i < n; i++) {
|
|
231
|
+
let compressed = compress(inputs[i]);
|
|
232
|
+
|
|
233
|
+
for (let j = 0, m = compressed.length; j < m; j++) {
|
|
234
|
+
expect(compressed.charCodeAt(j)).not.toBe(0);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('binary-like string with high char codes', () => {
|
|
240
|
+
let chars: string[] = [];
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < 100; i++) {
|
|
243
|
+
chars.push(String.fromCharCode(Math.floor(Math.random() * 65535) + 1));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let input = chars.join(''),
|
|
247
|
+
compressed = compress(input);
|
|
248
|
+
|
|
249
|
+
expect(decompress(compressed)).toBe(input);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('boundary cases', () => {
|
|
254
|
+
it('very large string (~100KB) round-trips', () => {
|
|
255
|
+
let items: Record<string, unknown>[] = [];
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < 2000; i++) {
|
|
258
|
+
items.push({
|
|
259
|
+
data: 'x'.repeat(10),
|
|
260
|
+
id: i,
|
|
261
|
+
name: `entry_${i}`,
|
|
262
|
+
value: i * 3.14
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let input = JSON.stringify(items);
|
|
267
|
+
|
|
268
|
+
expect(input.length).toBeGreaterThan(100_000);
|
|
269
|
+
|
|
270
|
+
let compressed = compress(input);
|
|
271
|
+
|
|
272
|
+
expect(decompress(compressed)).toBe(input);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('high-entropy string that does not compress well', () => {
|
|
276
|
+
let chars: string[] = [];
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < 500; i++) {
|
|
279
|
+
chars.push(String.fromCharCode(32 + (((i * 7) + (i * i * 3)) % 95)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let input = chars.join(''),
|
|
283
|
+
compressed = compress(input);
|
|
284
|
+
|
|
285
|
+
expect(decompress(compressed)).toBe(input);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('exact bit-width boundary (2-bit to 3-bit transition)', () => {
|
|
289
|
+
// dictSize starts at 3, numBits at 2. After 2 new dictionary entries
|
|
290
|
+
// dictSize=5 > (1<<2)=4, triggering numBits bump to 3.
|
|
291
|
+
// 'abcd' has 4 unique chars; pattern 'abcdabcd' creates entries:
|
|
292
|
+
// 'ab'->3 (dictSize=4), 'bc'->4 (dictSize=5, triggers 2->3 bit transition)
|
|
293
|
+
let input = 'abcdabcd',
|
|
294
|
+
compressed = compress(input);
|
|
295
|
+
|
|
296
|
+
expect(decompress(compressed)).toBe(input);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('single char repeated 2 times', () => {
|
|
300
|
+
let compressed = compress('aa');
|
|
301
|
+
|
|
302
|
+
expect(decompress(compressed)).toBe('aa');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('single char repeated 3 times', () => {
|
|
306
|
+
let compressed = compress('aaa');
|
|
307
|
+
|
|
308
|
+
expect(decompress(compressed)).toBe('aaa');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('single char repeated 4 times', () => {
|
|
312
|
+
let compressed = compress('aaaa');
|
|
313
|
+
|
|
314
|
+
expect(decompress(compressed)).toBe('aaaa');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('surrogate pairs (mathematical bold fraktur)', () => {
|
|
318
|
+
let input = '𝕳𝖊𝖑𝖑𝖔',
|
|
319
|
+
compressed = compress(input);
|
|
320
|
+
|
|
321
|
+
expect(decompress(compressed)).toBe(input);
|
|
322
|
+
});
|
|
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
|
+
});
|
|
371
|
+
});
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
# Feature Research: @esportsplus/web-storage
|
|
2
|
-
|
|
3
|
-
Research date: 2026-03-24
|
|
4
|
-
Sources: localForage, Dexie.js, idb-keyval, store2, RxDB, unstorage, lz-string, lscache, expired-storage, proxy-storage, Valtio, Podda
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## What We Already Have
|
|
8
|
-
|
|
9
|
-
- Two drivers: IndexedDB (default), localStorage
|
|
10
|
-
- Unified async API: get, set, delete, all, clear, count, keys, map, filter, only, replace
|
|
11
|
-
- AES-GCM encryption with optional secret
|
|
12
|
-
- TypeScript generics (`Local<T>` with per-key type inference)
|
|
13
|
-
- Namespace isolation via `name:version:` prefix (LS) / separate databases (IDB)
|
|
14
|
-
- Connection pooling for IndexedDB
|
|
15
|
-
- Factory pattern for independent instances
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Recommended Features (Priority Order)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
### 1. TTL / Expiration (HIGH)
|
|
22
|
-
|
|
23
|
-
The most universally requested missing feature across storage libraries.
|
|
24
|
-
|
|
25
|
-
**What**: Per-key time-to-live. `set(key, value, { ttl: 60000 })` stores an expiry timestamp alongside the value. On `get`, expired entries return `undefined` and are lazily deleted.
|
|
26
|
-
|
|
27
|
-
**Why**: Without TTL, consumers reimplement expiry logic on every project. Cache use cases are impossible without it. lscache, expired-storage, and dozens of wrappers exist solely for this.
|
|
28
|
-
|
|
29
|
-
**Approach**: Envelope wrapping — store `{ value, expiry }` instead of raw value. Lazy deletion on read (check expiry on `get`). Optional proactive sweep via `cleanup()` method.
|
|
30
|
-
|
|
31
|
-
**API surface**:
|
|
32
|
-
```typescript
|
|
33
|
-
await store.set('session', token, { ttl: 3600000 }); // 1 hour
|
|
34
|
-
await store.get('session'); // undefined after expiry
|
|
35
|
-
await store.ttl('session'); // remaining ms, or -1 if no TTL
|
|
36
|
-
await store.persist('session'); // remove TTL, make permanent
|
|
37
|
-
await store.cleanup(); // proactively sweep all expired entries
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**Implemented by**: lscache, expired-storage, ttl-db, localstorage-slim, Redis
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
### 2. Memory Driver (HIGH)
|
|
44
|
-
|
|
45
|
-
**What**: In-memory `Driver<T>` backed by a `Map`. Non-persistent — data lost on page reload.
|
|
46
|
-
|
|
47
|
-
**Why**: Essential for unit testing (no browser APIs needed), SSR environments (no `localStorage`/`indexedDB`), and as a fallback when persistent storage is unavailable (private browsing, quota exceeded). Every major library offers this.
|
|
48
|
-
|
|
49
|
-
**Approach**: Implement `Driver<T>` interface with a `Map<keyof T, T[keyof T]>`. Trivial — the interface already exists.
|
|
50
|
-
|
|
51
|
-
**API surface**:
|
|
52
|
-
```typescript
|
|
53
|
-
import storage, { DriverType } from '@esportsplus/web-storage';
|
|
54
|
-
|
|
55
|
-
let store = storage({ driver: DriverType.Memory, name: 'test', version: 1 });
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
**Implemented by**: localForage (plugin), unstorage (default), proxy-storage
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
### 4. `get(key, factory)` — Lazy Init (HIGH)
|
|
62
|
-
|
|
63
|
-
**What**: Optional second parameter on `get`. If the key is missing and a factory is provided, call the factory to produce the value, fire-and-forget the `set` (do not await it), and return the value immediately.
|
|
64
|
-
|
|
65
|
-
**Why**: Extremely common pattern that every consumer reimplements. Especially useful for expensive computations or API calls that should be cached. The fire-and-forget write means the caller is never blocked by storage I/O — the value is returned as soon as the factory resolves.
|
|
66
|
-
|
|
67
|
-
**Behavior**:
|
|
68
|
-
1. `get(key)` — existing behavior, returns `T[keyof T] | undefined`
|
|
69
|
-
2. `get(key, factory)` — if value exists, return it. If missing, call `factory()`, persist the result via `set` without awaiting, return the factory value. Factory can be sync or async.
|
|
70
|
-
|
|
71
|
-
**API surface**:
|
|
72
|
-
```typescript
|
|
73
|
-
// Without factory — unchanged, returns T[keyof T] | undefined
|
|
74
|
-
let user = await store.get('user');
|
|
75
|
-
|
|
76
|
-
// With factory — returns T[keyof T] (never undefined)
|
|
77
|
-
let user = await store.get('user', async () => {
|
|
78
|
-
return await fetchUser(id);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Sync factory works too
|
|
82
|
-
let count = await store.get('count', () => 0);
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**Implementation note**: The `set` call is intentionally not awaited. The returned value comes from the factory, not from a subsequent read. This means the caller gets the value immediately while the write happens in the background. If the write fails silently, the next `get` with a factory will simply re-invoke it.
|
|
86
|
-
|
|
87
|
-
**Implemented by**: Common pattern, no library does it particularly well
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
### 5. Compression (HIGH for localStorage)
|
|
91
|
-
|
|
92
|
-
**What**: Always-on LZ-based compression for the localStorage driver. Multiplies effective capacity by 2-10x.
|
|
93
|
-
|
|
94
|
-
**Why**: localStorage has a hard 5MB limit. Users consistently hit this wall. IndexedDB has no meaningful quota pressure (60% of disk), so compression is localStorage-only.
|
|
95
|
-
|
|
96
|
-
**Approach**: Built into the localStorage driver's serialize/deserialize pipeline. No configuration — always active, but with a 100-byte threshold: values under 100 bytes are stored as-is (LZ framing overhead would make them larger), values at or above 100 bytes are compressed via `lz-string` `compressToUTF16`. On read, detect whether the stored value is compressed and decompress accordingly. Compression runs before encryption when a secret is present (compress → encrypt on write, decrypt → decompress on read).
|
|
97
|
-
|
|
98
|
-
**No API surface** — this is an internal optimization. No user-facing option or configuration. The localStorage driver always compresses large values transparently.
|
|
99
|
-
|
|
100
|
-
**Implemented by**: lz-string, localstorage-slim, locally (locallyjs)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
### 6. Change Subscriptions (MEDIUM-HIGH)
|
|
104
|
-
|
|
105
|
-
**What**: `subscribe(key, callback)` fires when a value changes. Returns an unsubscribe function.
|
|
106
|
-
|
|
107
|
-
**Why**: Essential for UI framework integration. Dexie's `liveQuery` and RxDB's reactive queries are headline features. A minimal event emitter per key fills the gap between the storage library and the UI layer.
|
|
108
|
-
|
|
109
|
-
**Approach**: Internal `Map<keyof T, Set<Callback>>` in `Local<T>`. Fire callbacks after `set`, `delete`, `replace`, `clear` complete. No cross-tab sync (keep it simple).
|
|
110
|
-
|
|
111
|
-
**API surface**:
|
|
112
|
-
```typescript
|
|
113
|
-
let unsubscribe = store.subscribe('theme', (newValue, oldValue) => {
|
|
114
|
-
applyTheme(newValue);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// or subscribe to all changes
|
|
118
|
-
let unsubscribe = store.subscribe((key, newValue, oldValue) => {
|
|
119
|
-
console.log(`${key} changed`);
|
|
120
|
-
});
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
**Implemented by**: Podda, Valtio, unstorage, local-storage-proxy
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
### 7. Migration Callbacks (MEDIUM-HIGH)
|
|
127
|
-
|
|
128
|
-
**What**: Run transform functions when the version number changes. The `version` parameter already exists but has no migration path.
|
|
129
|
-
|
|
130
|
-
**Why**: Critical for production apps that evolve their schema. Without migrations, a version bump silently loses or corrupts data. Dexie and RxDB consider this a core feature.
|
|
131
|
-
|
|
132
|
-
**Approach**: Accept a `migrations` map in options. On construction, detect version change and run the appropriate migration function before the store becomes usable.
|
|
133
|
-
|
|
134
|
-
**API surface**:
|
|
135
|
-
```typescript
|
|
136
|
-
let store = storage<AppDataV2>({
|
|
137
|
-
name: 'app',
|
|
138
|
-
version: 2,
|
|
139
|
-
migrations: {
|
|
140
|
-
2: async (old) => {
|
|
141
|
-
// Transform v1 data to v2 shape
|
|
142
|
-
let all = await old.all();
|
|
143
|
-
return { ...all, newField: defaultValue };
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
**Implemented by**: Dexie (`upgrade()`), RxDB (`migrationStrategies`)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
### 9. sessionStorage Driver (MEDIUM)
|
|
153
|
-
|
|
154
|
-
**What**: Per-tab ephemeral storage via `sessionStorage`.
|
|
155
|
-
|
|
156
|
-
**Why**: Fills a real gap for per-tab state (form drafts, wizard progress, auth tokens). Trivial to implement — copy `LocalStorageDriver` and swap `localStorage` for `sessionStorage`.
|
|
157
|
-
|
|
158
|
-
**API surface**:
|
|
159
|
-
```typescript
|
|
160
|
-
let store = storage({ driver: DriverType.SessionStorage, name: 'tab', version: 1 });
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
**Implemented by**: store2 (`store.session`)
|
|
164
|
-
|
|
165
|
-
### 11. OPFS Driver (LOW — emerging)
|
|
166
|
-
|
|
167
|
-
**What**: Origin Private File System — 3-4x faster than IndexedDB for large binary data. Accessed via Web Worker using `FileSystemSyncAccessHandle`.
|
|
168
|
-
|
|
169
|
-
**Why**: Emerging as the fastest browser storage for performance-sensitive apps. RxDB reports significant speed gains. However, browser support is still maturing and the API is complex.
|
|
170
|
-
|
|
171
|
-
**Approach**: Defer until OPFS stabilizes further. Monitor adoption.
|
|
172
|
-
|
|
173
|
-
**Implemented by**: RxDB, opfs-tools
|