@esportsplus/web-storage 0.4.0 → 0.5.0

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/tests/lz.ts ADDED
@@ -0,0 +1,324 @@
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
+ });
@@ -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