@esportsplus/web-storage 0.3.5 → 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/README.md +248 -0
- package/build/constants.d.ts +3 -1
- package/build/constants.js +2 -0
- package/build/drivers/localstorage.d.ts +1 -0
- package/build/drivers/localstorage.js +10 -2
- package/build/drivers/memory.d.ts +16 -0
- package/build/drivers/memory.js +64 -0
- package/build/drivers/sessionstorage.d.ts +20 -0
- package/build/drivers/sessionstorage.js +102 -0
- package/build/index.d.ts +12 -2
- package/build/index.js +271 -31
- package/build/lz.d.ts +3 -0
- package/build/lz.js +134 -0
- package/build/types.d.ts +16 -2
- package/package.json +6 -2
- package/src/constants.ts +3 -1
- package/src/drivers/localstorage.ts +13 -2
- package/src/drivers/memory.ts +92 -0
- package/src/drivers/sessionstorage.ts +142 -0
- package/src/index.ts +420 -39
- package/src/lz.ts +192 -0
- package/src/types.ts +23 -2
- package/storage/test-audit-web-storage.md +74 -0
- package/tests/drivers/indexeddb.ts +297 -0
- package/tests/drivers/localstorage.ts +376 -0
- package/tests/drivers/memory.ts +257 -0
- package/tests/drivers/sessionstorage.ts +375 -0
- package/tests/index.ts +1871 -0
- package/tests/lz.ts +324 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { SessionStorageDriver } from '~/drivers/sessionstorage';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
type TestData = { age: number; name: string; tags: string[] };
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
describe('SessionStorageDriver', () => {
|
|
10
|
+
let driver: SessionStorageDriver<TestData>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
sessionStorage.clear();
|
|
14
|
+
driver = new SessionStorageDriver<TestData>('test', 1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
describe('all', () => {
|
|
19
|
+
it('returns all stored key-value pairs', async () => {
|
|
20
|
+
await driver.set('name', 'alice');
|
|
21
|
+
await driver.set('age', 30);
|
|
22
|
+
await driver.set('tags', ['a', 'b']);
|
|
23
|
+
|
|
24
|
+
let result = await driver.all();
|
|
25
|
+
|
|
26
|
+
expect(result).toEqual({ age: 30, name: 'alice', tags: ['a', 'b'] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns empty object when storage is empty', async () => {
|
|
30
|
+
let result = await driver.all();
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('skips entries with unparseable JSON', async () => {
|
|
36
|
+
await driver.set('name', 'alice');
|
|
37
|
+
sessionStorage.setItem('test:1:age', '{invalid json');
|
|
38
|
+
|
|
39
|
+
let result = await driver.all();
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual({ name: 'alice' });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
describe('clear', () => {
|
|
47
|
+
it('does NOT remove keys from other drivers', async () => {
|
|
48
|
+
let other = new SessionStorageDriver<TestData>('other', 1);
|
|
49
|
+
|
|
50
|
+
await driver.set('name', 'alice');
|
|
51
|
+
await other.set('name', 'bob');
|
|
52
|
+
await driver.clear();
|
|
53
|
+
|
|
54
|
+
expect(await other.get('name')).toBe('bob');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('removes all prefixed keys', async () => {
|
|
58
|
+
await driver.set('name', 'alice');
|
|
59
|
+
await driver.set('age', 30);
|
|
60
|
+
await driver.clear();
|
|
61
|
+
|
|
62
|
+
expect(await driver.count()).toBe(0);
|
|
63
|
+
expect(await driver.all()).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
describe('constructor', () => {
|
|
69
|
+
it('creates with correct prefix format name:version:', async () => {
|
|
70
|
+
let d = new SessionStorageDriver<TestData>('myapp', 2);
|
|
71
|
+
|
|
72
|
+
await d.set('name', 'test');
|
|
73
|
+
|
|
74
|
+
expect(sessionStorage.getItem('myapp:2:name')).toBe('"test"');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
describe('count', () => {
|
|
80
|
+
it('returns 0 when empty', async () => {
|
|
81
|
+
expect(await driver.count()).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns correct count of stored items', async () => {
|
|
85
|
+
await driver.set('name', 'alice');
|
|
86
|
+
await driver.set('age', 30);
|
|
87
|
+
|
|
88
|
+
expect(await driver.count()).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
describe('delete', () => {
|
|
94
|
+
it('handles deleting non-existent keys without error', async () => {
|
|
95
|
+
await expect(driver.delete(['name', 'age'])).resolves.toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('removes specified keys', async () => {
|
|
99
|
+
await driver.set('name', 'alice');
|
|
100
|
+
await driver.set('age', 30);
|
|
101
|
+
await driver.set('tags', ['a']);
|
|
102
|
+
await driver.delete(['name', 'tags']);
|
|
103
|
+
|
|
104
|
+
expect(await driver.get('name')).toBeUndefined();
|
|
105
|
+
expect(await driver.get('tags')).toBeUndefined();
|
|
106
|
+
expect(await driver.get('age')).toBe(30);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
describe('keys', () => {
|
|
112
|
+
it('returns all keys without prefix', async () => {
|
|
113
|
+
await driver.set('name', 'alice');
|
|
114
|
+
await driver.set('age', 30);
|
|
115
|
+
|
|
116
|
+
let result = await driver.keys();
|
|
117
|
+
|
|
118
|
+
expect(result.sort()).toEqual(['age', 'name']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns empty array when empty', async () => {
|
|
122
|
+
expect(await driver.keys()).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
describe('map', () => {
|
|
128
|
+
it('iterates over all entries with correct value, key, and index', async () => {
|
|
129
|
+
await driver.set('age', 25);
|
|
130
|
+
await driver.set('name', 'alice');
|
|
131
|
+
|
|
132
|
+
let entries: { i: number; key: keyof TestData; value: TestData[keyof TestData] }[] = [];
|
|
133
|
+
|
|
134
|
+
await driver.map((value, key, i) => {
|
|
135
|
+
entries.push({ i, key, value });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(entries).toHaveLength(2);
|
|
139
|
+
|
|
140
|
+
entries.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
141
|
+
|
|
142
|
+
expect(entries[0]).toEqual({ i: expect.any(Number), key: 'age', value: 25 });
|
|
143
|
+
expect(entries[1]).toEqual({ i: expect.any(Number), key: 'name', value: 'alice' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not invoke callback on empty store', async () => {
|
|
147
|
+
let called = false;
|
|
148
|
+
|
|
149
|
+
await driver.map(() => {
|
|
150
|
+
called = true;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(called).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('skips entries with unparseable values', async () => {
|
|
157
|
+
await driver.set('name', 'alice');
|
|
158
|
+
sessionStorage.setItem('test:1:age', 'not-json');
|
|
159
|
+
|
|
160
|
+
let keys: (keyof TestData)[] = [];
|
|
161
|
+
|
|
162
|
+
await driver.map((_value, key) => {
|
|
163
|
+
keys.push(key);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(keys).toEqual(['name']);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
describe('only', () => {
|
|
172
|
+
it('returns empty Map when no keys match', async () => {
|
|
173
|
+
await driver.set('name', 'alice');
|
|
174
|
+
|
|
175
|
+
let result = await driver.only(['age', 'tags']);
|
|
176
|
+
|
|
177
|
+
expect(result.size).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns Map with only requested keys', async () => {
|
|
181
|
+
await driver.set('name', 'alice');
|
|
182
|
+
await driver.set('age', 30);
|
|
183
|
+
await driver.set('tags', ['a']);
|
|
184
|
+
|
|
185
|
+
let result = await driver.only(['name', 'tags']);
|
|
186
|
+
|
|
187
|
+
expect(result.size).toBe(2);
|
|
188
|
+
expect(result.get('name')).toBe('alice');
|
|
189
|
+
expect(result.get('tags')).toEqual(['a']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('skips keys that do not exist', async () => {
|
|
193
|
+
await driver.set('name', 'alice');
|
|
194
|
+
|
|
195
|
+
let result = await driver.only(['name', 'age']);
|
|
196
|
+
|
|
197
|
+
expect(result.size).toBe(1);
|
|
198
|
+
expect(result.get('name')).toBe('alice');
|
|
199
|
+
expect(result.has('age')).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
describe('prefix isolation', () => {
|
|
205
|
+
it('two drivers with different name/version do not see each other\'s data', async () => {
|
|
206
|
+
let driverA = new SessionStorageDriver<TestData>('app', 1),
|
|
207
|
+
driverB = new SessionStorageDriver<TestData>('app', 2);
|
|
208
|
+
|
|
209
|
+
await driverA.set('name', 'alice');
|
|
210
|
+
await driverB.set('name', 'bob');
|
|
211
|
+
|
|
212
|
+
expect(await driverA.get('name')).toBe('alice');
|
|
213
|
+
expect(await driverB.get('name')).toBe('bob');
|
|
214
|
+
expect(await driverA.count()).toBe(1);
|
|
215
|
+
expect(await driverB.count()).toBe(1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
describe('replace', () => {
|
|
221
|
+
it('replaces multiple entries at once', async () => {
|
|
222
|
+
await driver.replace([
|
|
223
|
+
['name', 'alice'],
|
|
224
|
+
['age', 30],
|
|
225
|
+
['tags', ['x', 'y']]
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
expect(await driver.get('name')).toBe('alice');
|
|
229
|
+
expect(await driver.get('age')).toBe(30);
|
|
230
|
+
expect(await driver.get('tags')).toEqual(['x', 'y']);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
describe('compression', () => {
|
|
236
|
+
type LargeData = { bio: string };
|
|
237
|
+
|
|
238
|
+
let largeDriver: SessionStorageDriver<LargeData>,
|
|
239
|
+
largeValue: string;
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
largeDriver = new SessionStorageDriver<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 = sessionStorage.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 = sessionStorage.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
|
+
sessionStorage.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 = sessionStorage.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 SessionStorageDriver<BoundaryData>('bound', 1);
|
|
296
|
+
|
|
297
|
+
// JSON.stringify('x'.repeat(96)) = '"' + 'x'*96 + '"' = 98 bytes < 100 => no compress
|
|
298
|
+
await boundaryDriver.set('val', 'x'.repeat(96));
|
|
299
|
+
|
|
300
|
+
let rawSmall = sessionStorage.getItem('bound:1:val')!;
|
|
301
|
+
|
|
302
|
+
expect(rawSmall.charCodeAt(0)).not.toBe(1);
|
|
303
|
+
|
|
304
|
+
// JSON.stringify('x'.repeat(98)) = '"' + 'x'*98 + '"' = 100 bytes >= 100 => compress
|
|
305
|
+
await boundaryDriver.set('val', 'x'.repeat(98));
|
|
306
|
+
|
|
307
|
+
let rawLarge = sessionStorage.getItem('bound:1:val')!;
|
|
308
|
+
|
|
309
|
+
expect(rawLarge.charCodeAt(0)).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('parse returns undefined for corrupted compressed data', async () => {
|
|
313
|
+
sessionStorage.setItem('lz:1:bio', '\x01corrupted-data');
|
|
314
|
+
|
|
315
|
+
expect(await largeDriver.get('bio')).toBeUndefined();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
describe('set / get', () => {
|
|
321
|
+
it('overwrites existing key', async () => {
|
|
322
|
+
await driver.set('name', 'alice');
|
|
323
|
+
await driver.set('name', 'bob');
|
|
324
|
+
|
|
325
|
+
expect(await driver.get('name')).toBe('bob');
|
|
326
|
+
expect(await driver.count()).toBe(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns false when setItem throws', async () => {
|
|
330
|
+
let spy = vi.spyOn(sessionStorage, 'setItem').mockImplementationOnce(() => {
|
|
331
|
+
throw new Error('QuotaExceededError');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(await driver.set('name', 'alice')).toBe(false);
|
|
335
|
+
|
|
336
|
+
spy.mockRestore();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('returns true on successful set', async () => {
|
|
340
|
+
expect(await driver.set('name', 'alice')).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('returns undefined for non-existent key', async () => {
|
|
344
|
+
expect(await driver.get('name')).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('sets and retrieves a number value', async () => {
|
|
348
|
+
await driver.set('age', 42);
|
|
349
|
+
|
|
350
|
+
expect(await driver.get('age')).toBe(42);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('sets and retrieves a string value', async () => {
|
|
354
|
+
await driver.set('name', 'alice');
|
|
355
|
+
|
|
356
|
+
expect(await driver.get('name')).toBe('alice');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('sets and retrieves an array value', async () => {
|
|
360
|
+
await driver.set('tags', ['a', 'b', 'c']);
|
|
361
|
+
|
|
362
|
+
expect(await driver.get('tags')).toEqual(['a', 'b', 'c']);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('sets and retrieves an object value', async () => {
|
|
366
|
+
type ObjData = { meta: { nested: boolean; value: number } };
|
|
367
|
+
|
|
368
|
+
let objDriver = new SessionStorageDriver<ObjData>('obj', 1);
|
|
369
|
+
|
|
370
|
+
await objDriver.set('meta', { nested: true, value: 99 });
|
|
371
|
+
|
|
372
|
+
expect(await objDriver.get('meta')).toEqual({ nested: true, value: 99 });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|