@esportsplus/web-storage 0.3.3 → 0.4.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/.github/workflows/bump.yml +2 -2
- package/.github/workflows/dependabot.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/build/constants.d.ts +3 -1
- package/build/constants.js +2 -0
- package/build/drivers/memory.d.ts +16 -0
- package/build/drivers/memory.js +64 -0
- package/build/drivers/sessionstorage.d.ts +19 -0
- package/build/drivers/sessionstorage.js +94 -0
- package/build/index.d.ts +12 -2
- package/build/index.js +271 -31
- package/build/types.d.ts +16 -2
- package/package.json +6 -2
- package/src/constants.ts +3 -1
- package/src/drivers/memory.ts +92 -0
- package/src/drivers/sessionstorage.ts +131 -0
- package/src/index.ts +420 -39
- package/src/types.ts +23 -2
- package/storage/feature-research.md +173 -0
- package/tests/drivers/indexeddb.ts +297 -0
- package/tests/drivers/localstorage.ts +290 -0
- package/tests/drivers/memory.ts +257 -0
- package/tests/drivers/sessionstorage.ts +290 -0
- package/tests/index.ts +1462 -0
- package/vitest.config.ts +16 -0
package/tests/index.ts
ADDED
|
@@ -0,0 +1,1462 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
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
|
+
})
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { decrypt, encrypt } from '@esportsplus/utilities';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import createLocal, { DriverType } from '~/index';
|
|
18
|
+
import type { Local } from '~/index';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type TestData = { age: number; name: string; tags: string[] };
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
let idbId = 0;
|
|
25
|
+
|
|
26
|
+
function uid() {
|
|
27
|
+
return `test-local-db-${++idbId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
describe('Local (IndexedDB driver)', () => {
|
|
32
|
+
|
|
33
|
+
describe('with encryption', () => {
|
|
34
|
+
it('all — decrypts all values', async () => {
|
|
35
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 }, 'test-secret');
|
|
36
|
+
|
|
37
|
+
await store.set('age', 30);
|
|
38
|
+
await store.set('name', 'alice');
|
|
39
|
+
|
|
40
|
+
let result = await store.all();
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({ age: 30, name: 'alice' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('set / get — round-trip with secret', async () => {
|
|
46
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 }, 'test-secret');
|
|
47
|
+
|
|
48
|
+
await store.set('name', 'alice');
|
|
49
|
+
|
|
50
|
+
expect(await store.get('name')).toBe('alice');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('set / get — round-trip object with secret', async () => {
|
|
54
|
+
type ObjData = { meta: { nested: boolean; value: number } };
|
|
55
|
+
|
|
56
|
+
let store = createLocal<ObjData>({ name: uid(), version: 1 }, 'test-secret');
|
|
57
|
+
|
|
58
|
+
await store.set('meta', { nested: true, value: 42 });
|
|
59
|
+
|
|
60
|
+
expect(await store.get('meta')).toEqual({ nested: true, value: 42 });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
describe('without encryption', () => {
|
|
66
|
+
it('all — returns all entries', async () => {
|
|
67
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
68
|
+
|
|
69
|
+
await store.set('age', 30);
|
|
70
|
+
await store.set('name', 'alice');
|
|
71
|
+
await store.set('tags', ['a', 'b']);
|
|
72
|
+
|
|
73
|
+
let result = await store.all();
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual({ age: 30, name: 'alice', tags: ['a', 'b'] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('clear — removes all entries', async () => {
|
|
79
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
80
|
+
|
|
81
|
+
await store.set('age', 25);
|
|
82
|
+
await store.set('name', 'alice');
|
|
83
|
+
await store.clear();
|
|
84
|
+
|
|
85
|
+
expect(await store.count()).toBe(0);
|
|
86
|
+
expect(await store.all()).toEqual({});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('count — returns correct count', async () => {
|
|
90
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
91
|
+
|
|
92
|
+
await store.set('age', 25);
|
|
93
|
+
await store.set('name', 'alice');
|
|
94
|
+
|
|
95
|
+
expect(await store.count()).toBe(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('delete — removes specified keys', async () => {
|
|
99
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
100
|
+
|
|
101
|
+
await store.set('age', 30);
|
|
102
|
+
await store.set('name', 'alice');
|
|
103
|
+
await store.set('tags', ['a']);
|
|
104
|
+
await store.delete('name', 'tags');
|
|
105
|
+
|
|
106
|
+
expect(await store.get('name')).toBeUndefined();
|
|
107
|
+
expect(await store.get('tags')).toBeUndefined();
|
|
108
|
+
expect(await store.get('age')).toBe(30);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('filter — filters entries by predicate', async () => {
|
|
112
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
113
|
+
|
|
114
|
+
await store.set('age', 30);
|
|
115
|
+
await store.set('name', 'alice');
|
|
116
|
+
await store.set('tags', ['a', 'b']);
|
|
117
|
+
|
|
118
|
+
let result = await store.filter(({ key }) => key === 'name' || key === 'tags');
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual({ name: 'alice', tags: ['a', 'b'] });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('filter — stop mechanism halts iteration', async () => {
|
|
124
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
125
|
+
|
|
126
|
+
await store.set('age', 30);
|
|
127
|
+
await store.set('name', 'alice');
|
|
128
|
+
await store.set('tags', ['a']);
|
|
129
|
+
|
|
130
|
+
let visited = 0;
|
|
131
|
+
|
|
132
|
+
await store.filter(({ stop }) => {
|
|
133
|
+
visited++;
|
|
134
|
+
|
|
135
|
+
if (visited === 2) {
|
|
136
|
+
stop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(visited).toBe(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('keys — returns all keys', async () => {
|
|
146
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
147
|
+
|
|
148
|
+
await store.set('age', 25);
|
|
149
|
+
await store.set('name', 'alice');
|
|
150
|
+
|
|
151
|
+
let result = await store.keys();
|
|
152
|
+
|
|
153
|
+
expect(result.sort()).toEqual(['age', 'name']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('length — returns correct count', async () => {
|
|
157
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
158
|
+
|
|
159
|
+
await store.set('age', 25);
|
|
160
|
+
await store.set('name', 'alice');
|
|
161
|
+
|
|
162
|
+
expect(await store.length()).toBe(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('map — iterates all entries', async () => {
|
|
166
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
167
|
+
|
|
168
|
+
await store.set('age', 25);
|
|
169
|
+
await store.set('name', 'alice');
|
|
170
|
+
|
|
171
|
+
let entries: { i: number; key: keyof TestData; value: TestData[keyof TestData] }[] = [];
|
|
172
|
+
|
|
173
|
+
await store.map((value, key, i) => {
|
|
174
|
+
entries.push({ i, key, value });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(entries).toHaveLength(2);
|
|
178
|
+
|
|
179
|
+
entries.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
180
|
+
|
|
181
|
+
expect(entries[0]).toEqual({ i: expect.any(Number), key: 'age', value: 25 });
|
|
182
|
+
expect(entries[1]).toEqual({ i: expect.any(Number), key: 'name', value: 'alice' });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('only — returns subset of entries', async () => {
|
|
186
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
187
|
+
|
|
188
|
+
await store.set('age', 30);
|
|
189
|
+
await store.set('name', 'alice');
|
|
190
|
+
await store.set('tags', ['a']);
|
|
191
|
+
|
|
192
|
+
let result = await store.only('name', 'tags');
|
|
193
|
+
|
|
194
|
+
expect(result).toEqual({ name: 'alice', tags: ['a'] });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('replace — batch replace returns empty failed array', async () => {
|
|
198
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
199
|
+
|
|
200
|
+
let failed = await store.replace({ age: 25, name: 'bob', tags: ['x', 'y'] });
|
|
201
|
+
|
|
202
|
+
expect(failed).toEqual([]);
|
|
203
|
+
expect(await store.get('age')).toBe(25);
|
|
204
|
+
expect(await store.get('name')).toBe('bob');
|
|
205
|
+
expect(await store.get('tags')).toEqual(['x', 'y']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('set / get — basic round-trip', async () => {
|
|
209
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
210
|
+
|
|
211
|
+
await store.set('name', 'bob');
|
|
212
|
+
|
|
213
|
+
expect(await store.get('name')).toBe('bob');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
describe('Local (LocalStorage driver)', () => {
|
|
220
|
+
let store: Local<TestData>;
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
localStorage.clear();
|
|
224
|
+
store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'test', version: 1 });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
describe('with encryption', () => {
|
|
229
|
+
let encrypted: Local<TestData>;
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
encrypted = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'enc', version: 1 }, 'test-secret');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
it('all — decrypts all values', async () => {
|
|
237
|
+
await encrypted.set('age', 30);
|
|
238
|
+
await encrypted.set('name', 'alice');
|
|
239
|
+
|
|
240
|
+
let result = await encrypted.all();
|
|
241
|
+
|
|
242
|
+
expect(result).toEqual({ age: 30, name: 'alice' });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('replace — encrypts on write, decrypts on read', async () => {
|
|
246
|
+
let failed = await encrypted.replace({ age: 25, name: 'bob', tags: ['x'] });
|
|
247
|
+
|
|
248
|
+
expect(failed).toEqual([]);
|
|
249
|
+
expect(await encrypted.get('age')).toBe(25);
|
|
250
|
+
expect(await encrypted.get('name')).toBe('bob');
|
|
251
|
+
expect(await encrypted.get('tags')).toEqual(['x']);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('set / get — round-trip with secret', async () => {
|
|
255
|
+
await encrypted.set('name', 'alice');
|
|
256
|
+
|
|
257
|
+
expect(await encrypted.get('name')).toBe('alice');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('set / get — round-trip number with secret', async () => {
|
|
261
|
+
await encrypted.set('age', 42);
|
|
262
|
+
|
|
263
|
+
expect(await encrypted.get('age')).toBe(42);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('set / get — round-trip object with secret', async () => {
|
|
267
|
+
await encrypted.set('tags', ['a', 'b', 'c']);
|
|
268
|
+
|
|
269
|
+
expect(await encrypted.get('tags')).toEqual(['a', 'b', 'c']);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
describe('error branches', () => {
|
|
275
|
+
let encrypted: Local<TestData>;
|
|
276
|
+
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
encrypted = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'err', version: 1 }, 'test-secret');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
it('get — returns undefined when decrypt fails', async () => {
|
|
283
|
+
await encrypted.set('name', 'alice');
|
|
284
|
+
|
|
285
|
+
vi.mocked(decrypt).mockRejectedValueOnce(new Error('decrypt failed'));
|
|
286
|
+
|
|
287
|
+
expect(await encrypted.get('name')).toBeUndefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('replace — returns failed keys when encrypt throws', async () => {
|
|
291
|
+
vi.mocked(encrypt).mockRejectedValueOnce(new Error('encrypt failed'));
|
|
292
|
+
|
|
293
|
+
let failed = await encrypted.replace({ age: 25, name: 'bob' });
|
|
294
|
+
|
|
295
|
+
expect(failed).toContain('age');
|
|
296
|
+
expect(failed).toHaveLength(1);
|
|
297
|
+
expect(await encrypted.get('name')).toBe('bob');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('set — returns false when encrypt throws', async () => {
|
|
301
|
+
vi.mocked(encrypt).mockRejectedValueOnce(new Error('encrypt failed'));
|
|
302
|
+
|
|
303
|
+
expect(await encrypted.set('name', 'alice')).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
describe('without encryption', () => {
|
|
309
|
+
it('all — returns all entries', async () => {
|
|
310
|
+
await store.set('age', 30);
|
|
311
|
+
await store.set('name', 'alice');
|
|
312
|
+
await store.set('tags', ['a', 'b']);
|
|
313
|
+
|
|
314
|
+
let result = await store.all();
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({ age: 30, name: 'alice', tags: ['a', 'b'] });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('clear — removes all entries', async () => {
|
|
320
|
+
await store.set('age', 25);
|
|
321
|
+
await store.set('name', 'alice');
|
|
322
|
+
await store.clear();
|
|
323
|
+
|
|
324
|
+
expect(await store.count()).toBe(0);
|
|
325
|
+
expect(await store.all()).toEqual({});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('count — returns correct count', async () => {
|
|
329
|
+
await store.set('age', 25);
|
|
330
|
+
await store.set('name', 'alice');
|
|
331
|
+
|
|
332
|
+
expect(await store.count()).toBe(2);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('delete — removes specified keys', async () => {
|
|
336
|
+
await store.set('age', 30);
|
|
337
|
+
await store.set('name', 'alice');
|
|
338
|
+
await store.set('tags', ['a']);
|
|
339
|
+
await store.delete('name', 'tags');
|
|
340
|
+
|
|
341
|
+
expect(await store.get('name')).toBeUndefined();
|
|
342
|
+
expect(await store.get('tags')).toBeUndefined();
|
|
343
|
+
expect(await store.get('age')).toBe(30);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('filter — filters entries by predicate', async () => {
|
|
347
|
+
await store.set('age', 30);
|
|
348
|
+
await store.set('name', 'alice');
|
|
349
|
+
await store.set('tags', ['a', 'b']);
|
|
350
|
+
|
|
351
|
+
let result = await store.filter(({ key }) => key === 'name' || key === 'tags');
|
|
352
|
+
|
|
353
|
+
expect(result).toEqual({ name: 'alice', tags: ['a', 'b'] });
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('filter — stop mechanism halts iteration', async () => {
|
|
357
|
+
await store.set('age', 30);
|
|
358
|
+
await store.set('name', 'alice');
|
|
359
|
+
await store.set('tags', ['a']);
|
|
360
|
+
|
|
361
|
+
let visited = 0;
|
|
362
|
+
|
|
363
|
+
let result = await store.filter(({ stop, value }) => {
|
|
364
|
+
visited++;
|
|
365
|
+
|
|
366
|
+
if (visited === 2) {
|
|
367
|
+
stop();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return typeof value === 'string';
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
let keys = Object.keys(result);
|
|
374
|
+
|
|
375
|
+
expect(keys.length).toBeLessThanOrEqual(2);
|
|
376
|
+
expect(visited).toBe(2);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('get — returns undefined for non-existent key', async () => {
|
|
380
|
+
expect(await store.get('name')).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('keys — returns all keys', async () => {
|
|
384
|
+
await store.set('age', 25);
|
|
385
|
+
await store.set('name', 'alice');
|
|
386
|
+
|
|
387
|
+
let result = await store.keys();
|
|
388
|
+
|
|
389
|
+
expect(result.sort()).toEqual(['age', 'name']);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('length — returns correct count', async () => {
|
|
393
|
+
await store.set('age', 25);
|
|
394
|
+
await store.set('name', 'alice');
|
|
395
|
+
|
|
396
|
+
expect(await store.length()).toBe(2);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('map — iterates all entries', async () => {
|
|
400
|
+
await store.set('age', 25);
|
|
401
|
+
await store.set('name', 'alice');
|
|
402
|
+
|
|
403
|
+
let entries: { i: number; key: keyof TestData; value: TestData[keyof TestData] }[] = [];
|
|
404
|
+
|
|
405
|
+
await store.map((value, key, i) => {
|
|
406
|
+
entries.push({ i, key, value });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(entries).toHaveLength(2);
|
|
410
|
+
|
|
411
|
+
entries.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
412
|
+
|
|
413
|
+
expect(entries[0]).toEqual({ i: expect.any(Number), key: 'age', value: 25 });
|
|
414
|
+
expect(entries[1]).toEqual({ i: expect.any(Number), key: 'name', value: 'alice' });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('only — returns subset of entries', async () => {
|
|
418
|
+
await store.set('age', 30);
|
|
419
|
+
await store.set('name', 'alice');
|
|
420
|
+
await store.set('tags', ['a']);
|
|
421
|
+
|
|
422
|
+
let result = await store.only('name', 'tags');
|
|
423
|
+
|
|
424
|
+
expect(result).toEqual({ name: 'alice', tags: ['a'] });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('replace — batch replace returns empty failed array', async () => {
|
|
428
|
+
let failed = await store.replace({ age: 25, name: 'bob', tags: ['x', 'y'] });
|
|
429
|
+
|
|
430
|
+
expect(failed).toEqual([]);
|
|
431
|
+
expect(await store.get('age')).toBe(25);
|
|
432
|
+
expect(await store.get('name')).toBe('bob');
|
|
433
|
+
expect(await store.get('tags')).toEqual(['x', 'y']);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('set / get — array value', async () => {
|
|
437
|
+
await store.set('tags', ['a', 'b', 'c']);
|
|
438
|
+
|
|
439
|
+
expect(await store.get('tags')).toEqual(['a', 'b', 'c']);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('set / get — number value', async () => {
|
|
443
|
+
await store.set('age', 42);
|
|
444
|
+
|
|
445
|
+
expect(await store.get('age')).toBe(42);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('set / get — object value', async () => {
|
|
449
|
+
type ObjData = { meta: { nested: boolean; value: number } };
|
|
450
|
+
|
|
451
|
+
let objStore = createLocal<ObjData>({ driver: DriverType.LocalStorage, name: 'obj', version: 1 });
|
|
452
|
+
|
|
453
|
+
await objStore.set('meta', { nested: true, value: 99 });
|
|
454
|
+
|
|
455
|
+
expect(await objStore.get('meta')).toEqual({ nested: true, value: 99 });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('set / get — string value', async () => {
|
|
459
|
+
await store.set('name', 'alice');
|
|
460
|
+
|
|
461
|
+
expect(await store.get('name')).toBe('alice');
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
describe('Local (Memory driver)', () => {
|
|
468
|
+
let store: Local<TestData>;
|
|
469
|
+
|
|
470
|
+
beforeEach(async () => {
|
|
471
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name: 'test', version: 1 });
|
|
472
|
+
await store.clear();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
describe('without encryption', () => {
|
|
477
|
+
it('all — returns all entries', async () => {
|
|
478
|
+
await store.set('age', 30);
|
|
479
|
+
await store.set('name', 'alice');
|
|
480
|
+
await store.set('tags', ['a', 'b']);
|
|
481
|
+
|
|
482
|
+
let result = await store.all();
|
|
483
|
+
|
|
484
|
+
expect(result).toEqual({ age: 30, name: 'alice', tags: ['a', 'b'] });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('clear — removes all entries', async () => {
|
|
488
|
+
await store.set('age', 25);
|
|
489
|
+
await store.set('name', 'alice');
|
|
490
|
+
await store.clear();
|
|
491
|
+
|
|
492
|
+
expect(await store.count()).toBe(0);
|
|
493
|
+
expect(await store.all()).toEqual({});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('count — returns correct count', async () => {
|
|
497
|
+
await store.set('age', 25);
|
|
498
|
+
await store.set('name', 'alice');
|
|
499
|
+
|
|
500
|
+
expect(await store.count()).toBe(2);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('delete — removes specified keys', async () => {
|
|
504
|
+
await store.set('age', 30);
|
|
505
|
+
await store.set('name', 'alice');
|
|
506
|
+
await store.set('tags', ['a']);
|
|
507
|
+
await store.delete('name', 'tags');
|
|
508
|
+
|
|
509
|
+
expect(await store.get('name')).toBeUndefined();
|
|
510
|
+
expect(await store.get('tags')).toBeUndefined();
|
|
511
|
+
expect(await store.get('age')).toBe(30);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('get — returns undefined for non-existent key', async () => {
|
|
515
|
+
expect(await store.get('name')).toBeUndefined();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('keys — returns all keys', async () => {
|
|
519
|
+
await store.set('age', 25);
|
|
520
|
+
await store.set('name', 'alice');
|
|
521
|
+
|
|
522
|
+
let result = await store.keys();
|
|
523
|
+
|
|
524
|
+
expect(result.sort()).toEqual(['age', 'name']);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('set / get — basic round-trip', async () => {
|
|
528
|
+
await store.set('name', 'bob');
|
|
529
|
+
|
|
530
|
+
expect(await store.get('name')).toBe('bob');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
describe('factory function', () => {
|
|
537
|
+
|
|
538
|
+
it('accepts optional secret parameter', () => {
|
|
539
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 }, 'my-secret');
|
|
540
|
+
|
|
541
|
+
expect(store).toBeDefined();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('defaults to IndexedDB when no driver specified', async () => {
|
|
545
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
546
|
+
|
|
547
|
+
await store.set('name', 'test');
|
|
548
|
+
|
|
549
|
+
expect(await store.get('name')).toBe('test');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('uses Memory when DriverType.Memory specified', async () => {
|
|
553
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-mem', version: 1 });
|
|
554
|
+
|
|
555
|
+
await store.set('name', 'test');
|
|
556
|
+
|
|
557
|
+
expect(await store.get('name')).toBe('test');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('uses LocalStorage when DriverType.LocalStorage specified', async () => {
|
|
561
|
+
localStorage.clear();
|
|
562
|
+
|
|
563
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
564
|
+
|
|
565
|
+
await store.set('name', 'test');
|
|
566
|
+
|
|
567
|
+
expect(localStorage.getItem('factory-ls:1:name')).toBe('"test"');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('uses SessionStorage when DriverType.SessionStorage specified', async () => {
|
|
571
|
+
sessionStorage.clear();
|
|
572
|
+
|
|
573
|
+
let store = createLocal<TestData>({ driver: DriverType.SessionStorage, name: 'factory-ss', version: 1 });
|
|
574
|
+
|
|
575
|
+
await store.set('name', 'test');
|
|
576
|
+
|
|
577
|
+
expect(sessionStorage.getItem('factory-ss:1:name')).toBe('"test"');
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
describe('get(key, factory) — IndexedDB driver', () => {
|
|
583
|
+
let now: number;
|
|
584
|
+
|
|
585
|
+
beforeEach(() => {
|
|
586
|
+
now = Date.now();
|
|
587
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
afterEach(() => {
|
|
591
|
+
vi.restoreAllMocks();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
it('returns factory value when key is missing', async () => {
|
|
596
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
597
|
+
|
|
598
|
+
let result = await store.get('name', () => 'default');
|
|
599
|
+
|
|
600
|
+
expect(result).toBe('default');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('returns stored value when key exists — factory NOT called', async () => {
|
|
604
|
+
let called = false,
|
|
605
|
+
store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
606
|
+
|
|
607
|
+
await store.set('name', 'alice');
|
|
608
|
+
|
|
609
|
+
let result = await store.get('name', () => {
|
|
610
|
+
called = true;
|
|
611
|
+
return 'default';
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
expect(result).toBe('alice');
|
|
615
|
+
expect(called).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('works with async factory', async () => {
|
|
619
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
620
|
+
|
|
621
|
+
let result = await store.get('name', async () => {
|
|
622
|
+
return 'async-value';
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
expect(result).toBe('async-value');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('works with sync factory', async () => {
|
|
629
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
630
|
+
|
|
631
|
+
let result = await store.get('age', () => 42);
|
|
632
|
+
|
|
633
|
+
expect(result).toBe(42);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('fires-and-forgets the set — value persisted after await', async () => {
|
|
637
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
638
|
+
|
|
639
|
+
await store.get('name', () => 'lazy');
|
|
640
|
+
|
|
641
|
+
// Allow fire-and-forget set to complete
|
|
642
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
643
|
+
|
|
644
|
+
let persisted = await store.get('name');
|
|
645
|
+
|
|
646
|
+
expect(persisted).toBe('lazy');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('triggers factory on expired TTL entry', async () => {
|
|
650
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
651
|
+
|
|
652
|
+
await store.set('name', 'old', { ttl: 10000 });
|
|
653
|
+
|
|
654
|
+
now += 10001;
|
|
655
|
+
|
|
656
|
+
let result = await store.get('name', () => 'refreshed');
|
|
657
|
+
|
|
658
|
+
expect(result).toBe('refreshed');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('without factory — backward compatible, returns undefined', async () => {
|
|
662
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
663
|
+
|
|
664
|
+
let result = await store.get('name');
|
|
665
|
+
|
|
666
|
+
expect(result).toBeUndefined();
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
describe('get(key, factory) — LocalStorage driver', () => {
|
|
672
|
+
let now: number;
|
|
673
|
+
|
|
674
|
+
beforeEach(() => {
|
|
675
|
+
localStorage.clear();
|
|
676
|
+
now = Date.now();
|
|
677
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
afterEach(() => {
|
|
681
|
+
vi.restoreAllMocks();
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
it('returns factory value when key is missing', async () => {
|
|
686
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
687
|
+
|
|
688
|
+
let result = await store.get('name', () => 'default');
|
|
689
|
+
|
|
690
|
+
expect(result).toBe('default');
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('returns stored value when key exists — factory NOT called', async () => {
|
|
694
|
+
let called = false,
|
|
695
|
+
store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
696
|
+
|
|
697
|
+
await store.set('name', 'alice');
|
|
698
|
+
|
|
699
|
+
let result = await store.get('name', () => {
|
|
700
|
+
called = true;
|
|
701
|
+
return 'default';
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
expect(result).toBe('alice');
|
|
705
|
+
expect(called).toBe(false);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('works with async factory', async () => {
|
|
709
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
710
|
+
|
|
711
|
+
let result = await store.get('name', async () => {
|
|
712
|
+
return 'async-value';
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
expect(result).toBe('async-value');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('works with sync factory', async () => {
|
|
719
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
720
|
+
|
|
721
|
+
let result = await store.get('age', () => 42);
|
|
722
|
+
|
|
723
|
+
expect(result).toBe(42);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('fires-and-forgets the set — value persisted after await', async () => {
|
|
727
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
728
|
+
|
|
729
|
+
await store.get('name', () => 'lazy');
|
|
730
|
+
|
|
731
|
+
// Allow fire-and-forget set to complete
|
|
732
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
733
|
+
|
|
734
|
+
let persisted = await store.get('name');
|
|
735
|
+
|
|
736
|
+
expect(persisted).toBe('lazy');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('triggers factory on expired TTL entry', async () => {
|
|
740
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
741
|
+
|
|
742
|
+
await store.set('name', 'old', { ttl: 10000 });
|
|
743
|
+
|
|
744
|
+
now += 10001;
|
|
745
|
+
|
|
746
|
+
let result = await store.get('name', () => 'refreshed');
|
|
747
|
+
|
|
748
|
+
expect(result).toBe('refreshed');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('without factory — backward compatible, returns undefined', async () => {
|
|
752
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'factory-ls', version: 1 });
|
|
753
|
+
|
|
754
|
+
let result = await store.get('name');
|
|
755
|
+
|
|
756
|
+
expect(result).toBeUndefined();
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
describe('TTL / Expiration (IndexedDB driver)', () => {
|
|
762
|
+
let now: number;
|
|
763
|
+
|
|
764
|
+
beforeEach(() => {
|
|
765
|
+
now = Date.now();
|
|
766
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
afterEach(() => {
|
|
770
|
+
vi.restoreAllMocks();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
it('get — returns value before TTL expires', async () => {
|
|
775
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
776
|
+
|
|
777
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
778
|
+
|
|
779
|
+
expect(await store.get('name')).toBe('alice');
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('get — returns undefined after TTL expires', async () => {
|
|
783
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
784
|
+
|
|
785
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
786
|
+
|
|
787
|
+
now += 60001;
|
|
788
|
+
|
|
789
|
+
expect(await store.get('name')).toBeUndefined();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('ttl — returns remaining ms', async () => {
|
|
793
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
794
|
+
|
|
795
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
796
|
+
|
|
797
|
+
now += 10000;
|
|
798
|
+
|
|
799
|
+
let remaining = await store.ttl('name');
|
|
800
|
+
|
|
801
|
+
expect(remaining).toBe(50000);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('ttl — returns -1 for no-TTL key', async () => {
|
|
805
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
806
|
+
|
|
807
|
+
await store.set('name', 'alice');
|
|
808
|
+
|
|
809
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('ttl — returns -1 for non-existent key', async () => {
|
|
813
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
814
|
+
|
|
815
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('persist — removes TTL, value still accessible', async () => {
|
|
819
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
820
|
+
|
|
821
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
822
|
+
|
|
823
|
+
await store.persist('name');
|
|
824
|
+
|
|
825
|
+
now += 120000;
|
|
826
|
+
|
|
827
|
+
expect(await store.get('name')).toBe('alice');
|
|
828
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('cleanup — removes all expired entries', async () => {
|
|
832
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
833
|
+
|
|
834
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
835
|
+
await store.set('age', 30, { ttl: 10000 });
|
|
836
|
+
await store.set('tags', ['a']);
|
|
837
|
+
|
|
838
|
+
now += 10001;
|
|
839
|
+
|
|
840
|
+
await store.cleanup();
|
|
841
|
+
|
|
842
|
+
expect(await store.count()).toBe(1);
|
|
843
|
+
expect(await store.get('tags')).toEqual(['a']);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('set — without TTL works as before (backward compat)', async () => {
|
|
847
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
848
|
+
|
|
849
|
+
await store.set('name', 'alice');
|
|
850
|
+
|
|
851
|
+
now += 999999;
|
|
852
|
+
|
|
853
|
+
expect(await store.get('name')).toBe('alice');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('set — with TTL + encryption round-trips correctly', async () => {
|
|
857
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 }, 'test-secret');
|
|
858
|
+
|
|
859
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
860
|
+
|
|
861
|
+
expect(await store.get('name')).toBe('alice');
|
|
862
|
+
|
|
863
|
+
now += 60001;
|
|
864
|
+
|
|
865
|
+
expect(await store.get('name')).toBeUndefined();
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('all — skips expired entries', async () => {
|
|
869
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
870
|
+
|
|
871
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
872
|
+
await store.set('age', 30);
|
|
873
|
+
|
|
874
|
+
now += 10001;
|
|
875
|
+
|
|
876
|
+
let result = await store.all();
|
|
877
|
+
|
|
878
|
+
expect(result).toEqual({ age: 30 });
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('filter — skips expired entries', async () => {
|
|
882
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
883
|
+
|
|
884
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
885
|
+
await store.set('age', 30);
|
|
886
|
+
await store.set('tags', ['a'], { ttl: 10000 });
|
|
887
|
+
|
|
888
|
+
now += 10001;
|
|
889
|
+
|
|
890
|
+
let result = await store.filter(() => true);
|
|
891
|
+
|
|
892
|
+
expect(result).toEqual({ age: 30 });
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('only — skips expired entries', async () => {
|
|
896
|
+
let store = createLocal<TestData>({ name: uid(), version: 1 });
|
|
897
|
+
|
|
898
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
899
|
+
await store.set('age', 30);
|
|
900
|
+
|
|
901
|
+
now += 10001;
|
|
902
|
+
|
|
903
|
+
let result = await store.only('name', 'age');
|
|
904
|
+
|
|
905
|
+
expect(result).toEqual({ age: 30 });
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
describe('TTL / Expiration (LocalStorage driver)', () => {
|
|
911
|
+
let now: number;
|
|
912
|
+
|
|
913
|
+
beforeEach(() => {
|
|
914
|
+
localStorage.clear();
|
|
915
|
+
now = Date.now();
|
|
916
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
afterEach(() => {
|
|
920
|
+
vi.restoreAllMocks();
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
it('get — returns value before TTL expires', async () => {
|
|
925
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
926
|
+
|
|
927
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
928
|
+
|
|
929
|
+
expect(await store.get('name')).toBe('alice');
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('get — returns undefined after TTL expires', async () => {
|
|
933
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
934
|
+
|
|
935
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
936
|
+
|
|
937
|
+
now += 60001;
|
|
938
|
+
|
|
939
|
+
expect(await store.get('name')).toBeUndefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('ttl — returns remaining ms', async () => {
|
|
943
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
944
|
+
|
|
945
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
946
|
+
|
|
947
|
+
now += 10000;
|
|
948
|
+
|
|
949
|
+
let remaining = await store.ttl('name');
|
|
950
|
+
|
|
951
|
+
expect(remaining).toBe(50000);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('ttl — returns -1 for no-TTL key', async () => {
|
|
955
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
956
|
+
|
|
957
|
+
await store.set('name', 'alice');
|
|
958
|
+
|
|
959
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('ttl — returns -1 for non-existent key', async () => {
|
|
963
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
964
|
+
|
|
965
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('persist — removes TTL, value still accessible', async () => {
|
|
969
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
970
|
+
|
|
971
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
972
|
+
|
|
973
|
+
await store.persist('name');
|
|
974
|
+
|
|
975
|
+
now += 120000;
|
|
976
|
+
|
|
977
|
+
expect(await store.get('name')).toBe('alice');
|
|
978
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('cleanup — removes all expired entries', async () => {
|
|
982
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
983
|
+
|
|
984
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
985
|
+
await store.set('age', 30, { ttl: 10000 });
|
|
986
|
+
await store.set('tags', ['a']);
|
|
987
|
+
|
|
988
|
+
now += 10001;
|
|
989
|
+
|
|
990
|
+
await store.cleanup();
|
|
991
|
+
|
|
992
|
+
expect(await store.count()).toBe(1);
|
|
993
|
+
expect(await store.get('tags')).toEqual(['a']);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('set — without TTL works as before (backward compat)', async () => {
|
|
997
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
998
|
+
|
|
999
|
+
await store.set('name', 'alice');
|
|
1000
|
+
|
|
1001
|
+
now += 999999;
|
|
1002
|
+
|
|
1003
|
+
expect(await store.get('name')).toBe('alice');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('set — with TTL + encryption round-trips correctly', async () => {
|
|
1007
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-enc', version: 1 }, 'test-secret');
|
|
1008
|
+
|
|
1009
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
1010
|
+
|
|
1011
|
+
expect(await store.get('name')).toBe('alice');
|
|
1012
|
+
|
|
1013
|
+
now += 60001;
|
|
1014
|
+
|
|
1015
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('all — skips expired entries', async () => {
|
|
1019
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
1020
|
+
|
|
1021
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1022
|
+
await store.set('age', 30);
|
|
1023
|
+
|
|
1024
|
+
now += 10001;
|
|
1025
|
+
|
|
1026
|
+
let result = await store.all();
|
|
1027
|
+
|
|
1028
|
+
expect(result).toEqual({ age: 30 });
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('filter — skips expired entries', async () => {
|
|
1032
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
1033
|
+
|
|
1034
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1035
|
+
await store.set('age', 30);
|
|
1036
|
+
await store.set('tags', ['a'], { ttl: 10000 });
|
|
1037
|
+
|
|
1038
|
+
now += 10001;
|
|
1039
|
+
|
|
1040
|
+
let result = await store.filter(() => true);
|
|
1041
|
+
|
|
1042
|
+
expect(result).toEqual({ age: 30 });
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('only — skips expired entries', async () => {
|
|
1046
|
+
let store = createLocal<TestData>({ driver: DriverType.LocalStorage, name: 'ttl-ls', version: 1 });
|
|
1047
|
+
|
|
1048
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1049
|
+
await store.set('age', 30);
|
|
1050
|
+
|
|
1051
|
+
now += 10001;
|
|
1052
|
+
|
|
1053
|
+
let result = await store.only('name', 'age');
|
|
1054
|
+
|
|
1055
|
+
expect(result).toEqual({ age: 30 });
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
describe('Change Subscriptions', () => {
|
|
1061
|
+
let store: Local<TestData>;
|
|
1062
|
+
|
|
1063
|
+
beforeEach(async () => {
|
|
1064
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name: 'sub-test', version: 1 });
|
|
1065
|
+
await store.clear();
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
it('subscribe(key, cb) fires on set', async () => {
|
|
1070
|
+
let called = false;
|
|
1071
|
+
|
|
1072
|
+
store.subscribe('name', () => { called = true; });
|
|
1073
|
+
await store.set('name', 'alice');
|
|
1074
|
+
|
|
1075
|
+
expect(called).toBe(true);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('subscribe(key, cb) receives correct newValue and oldValue', async () => {
|
|
1079
|
+
let captured: { newValue: unknown; oldValue: unknown } | null = null;
|
|
1080
|
+
|
|
1081
|
+
await store.set('name', 'alice');
|
|
1082
|
+
|
|
1083
|
+
store.subscribe('name', (newValue, oldValue) => {
|
|
1084
|
+
captured = { newValue, oldValue };
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
await store.set('name', 'bob');
|
|
1088
|
+
|
|
1089
|
+
expect(captured).toEqual({ newValue: 'bob', oldValue: 'alice' });
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it('subscribe(key, cb) fires on delete with undefined newValue', async () => {
|
|
1093
|
+
let captured: { newValue: unknown; oldValue: unknown } | null = null;
|
|
1094
|
+
|
|
1095
|
+
await store.set('name', 'alice');
|
|
1096
|
+
|
|
1097
|
+
store.subscribe('name', (newValue, oldValue) => {
|
|
1098
|
+
captured = { newValue, oldValue };
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
await store.delete('name');
|
|
1102
|
+
|
|
1103
|
+
expect(captured).toEqual({ newValue: undefined, oldValue: 'alice' });
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it('subscribe(cb) fires for any key change', async () => {
|
|
1107
|
+
let calls: { key: unknown; newValue: unknown }[] = [];
|
|
1108
|
+
|
|
1109
|
+
store.subscribe((key, newValue) => {
|
|
1110
|
+
calls.push({ key, newValue });
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
await store.set('name', 'alice');
|
|
1114
|
+
await store.set('age', 30);
|
|
1115
|
+
|
|
1116
|
+
expect(calls).toHaveLength(2);
|
|
1117
|
+
expect(calls[0]).toEqual({ key: 'name', newValue: 'alice' });
|
|
1118
|
+
expect(calls[1]).toEqual({ key: 'age', newValue: 30 });
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('unsubscribe stops notifications', async () => {
|
|
1122
|
+
let count = 0,
|
|
1123
|
+
unsubscribe = store.subscribe('name', () => { count++; });
|
|
1124
|
+
|
|
1125
|
+
await store.set('name', 'alice');
|
|
1126
|
+
|
|
1127
|
+
expect(count).toBe(1);
|
|
1128
|
+
|
|
1129
|
+
unsubscribe();
|
|
1130
|
+
|
|
1131
|
+
await store.set('name', 'bob');
|
|
1132
|
+
|
|
1133
|
+
expect(count).toBe(1);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('subscribe fires on replace', async () => {
|
|
1137
|
+
let calls: { key: unknown; newValue: unknown; oldValue: unknown }[] = [];
|
|
1138
|
+
|
|
1139
|
+
await store.set('name', 'alice');
|
|
1140
|
+
await store.set('age', 25);
|
|
1141
|
+
|
|
1142
|
+
store.subscribe((key, newValue, oldValue) => {
|
|
1143
|
+
calls.push({ key, newValue, oldValue });
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
await store.replace({ age: 30, name: 'bob' });
|
|
1147
|
+
|
|
1148
|
+
calls.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
1149
|
+
|
|
1150
|
+
expect(calls).toHaveLength(2);
|
|
1151
|
+
expect(calls[0]).toEqual({ key: 'age', newValue: 30, oldValue: 25 });
|
|
1152
|
+
expect(calls[1]).toEqual({ key: 'name', newValue: 'bob', oldValue: 'alice' });
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('subscribe fires on clear for each key', async () => {
|
|
1156
|
+
let calls: { key: unknown; newValue: unknown; oldValue: unknown }[] = [];
|
|
1157
|
+
|
|
1158
|
+
await store.set('name', 'alice');
|
|
1159
|
+
await store.set('age', 30);
|
|
1160
|
+
|
|
1161
|
+
store.subscribe((key, newValue, oldValue) => {
|
|
1162
|
+
calls.push({ key, newValue, oldValue });
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
await store.clear();
|
|
1166
|
+
|
|
1167
|
+
calls.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
1168
|
+
|
|
1169
|
+
expect(calls).toHaveLength(2);
|
|
1170
|
+
expect(calls[0]).toEqual({ key: 'age', newValue: undefined, oldValue: 30 });
|
|
1171
|
+
expect(calls[1]).toEqual({ key: 'name', newValue: undefined, oldValue: 'alice' });
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('multiple subscribers on same key all fire', async () => {
|
|
1175
|
+
let count1 = 0,
|
|
1176
|
+
count2 = 0;
|
|
1177
|
+
|
|
1178
|
+
store.subscribe('name', () => { count1++; });
|
|
1179
|
+
store.subscribe('name', () => { count2++; });
|
|
1180
|
+
|
|
1181
|
+
await store.set('name', 'alice');
|
|
1182
|
+
|
|
1183
|
+
expect(count1).toBe(1);
|
|
1184
|
+
expect(count2).toBe(1);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('subscribe does NOT fire for unrelated keys', async () => {
|
|
1188
|
+
let called = false;
|
|
1189
|
+
|
|
1190
|
+
store.subscribe('name', () => { called = true; });
|
|
1191
|
+
|
|
1192
|
+
await store.set('age', 30);
|
|
1193
|
+
|
|
1194
|
+
expect(called).toBe(false);
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
describe('Migration Callbacks (Memory driver)', () => {
|
|
1200
|
+
|
|
1201
|
+
let migrationId = 0;
|
|
1202
|
+
|
|
1203
|
+
function muid() {
|
|
1204
|
+
return `migration-test-${++migrationId}`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
it('transforms data when version changes', async () => {
|
|
1209
|
+
type V1 = { name: string };
|
|
1210
|
+
type V2 = { name: string; role: string };
|
|
1211
|
+
|
|
1212
|
+
let name = muid();
|
|
1213
|
+
|
|
1214
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1215
|
+
|
|
1216
|
+
await v1.set('name', 'alice');
|
|
1217
|
+
|
|
1218
|
+
let v2 = createLocal<V2>({
|
|
1219
|
+
driver: DriverType.Memory,
|
|
1220
|
+
migrations: {
|
|
1221
|
+
2: async (old) => {
|
|
1222
|
+
let all = await old.all();
|
|
1223
|
+
|
|
1224
|
+
return { ...all, role: 'admin' };
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
name,
|
|
1228
|
+
version: 2
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
expect(await v2.get('name')).toBe('alice');
|
|
1232
|
+
expect(await v2.get('role')).toBe('admin');
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it('migration runs only once — second open skips migration', async () => {
|
|
1236
|
+
type V1 = { name: string };
|
|
1237
|
+
type V2 = { name: string; role: string };
|
|
1238
|
+
|
|
1239
|
+
let migrationCount = 0,
|
|
1240
|
+
name = muid();
|
|
1241
|
+
|
|
1242
|
+
let migration = async (old: { all(): Promise<Record<string, unknown>> }) => {
|
|
1243
|
+
migrationCount++;
|
|
1244
|
+
|
|
1245
|
+
let all = await old.all();
|
|
1246
|
+
|
|
1247
|
+
return { ...all, role: 'admin' };
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1251
|
+
|
|
1252
|
+
await v1.set('name', 'alice');
|
|
1253
|
+
|
|
1254
|
+
let v2a = createLocal<V2>({
|
|
1255
|
+
driver: DriverType.Memory,
|
|
1256
|
+
migrations: { 2: migration },
|
|
1257
|
+
name,
|
|
1258
|
+
version: 2
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
await v2a.get('name');
|
|
1262
|
+
|
|
1263
|
+
expect(migrationCount).toBe(1);
|
|
1264
|
+
|
|
1265
|
+
let v2b = createLocal<V2>({
|
|
1266
|
+
driver: DriverType.Memory,
|
|
1267
|
+
migrations: { 2: migration },
|
|
1268
|
+
name,
|
|
1269
|
+
version: 2
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
await v2b.get('name');
|
|
1273
|
+
|
|
1274
|
+
expect(migrationCount).toBe(1);
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('sequential migrations — v1 to v3 runs migration 2 then 3', async () => {
|
|
1278
|
+
type V1 = { name: string };
|
|
1279
|
+
type V3 = { name: string; role: string; verified: boolean };
|
|
1280
|
+
|
|
1281
|
+
let name = muid(),
|
|
1282
|
+
order: number[] = [];
|
|
1283
|
+
|
|
1284
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1285
|
+
|
|
1286
|
+
await v1.set('name', 'alice');
|
|
1287
|
+
|
|
1288
|
+
let v3 = createLocal<V3>({
|
|
1289
|
+
driver: DriverType.Memory,
|
|
1290
|
+
migrations: {
|
|
1291
|
+
2: async (old) => {
|
|
1292
|
+
order.push(2);
|
|
1293
|
+
|
|
1294
|
+
let all = await old.all();
|
|
1295
|
+
|
|
1296
|
+
return { ...all, role: 'user' };
|
|
1297
|
+
},
|
|
1298
|
+
3: async (old) => {
|
|
1299
|
+
order.push(3);
|
|
1300
|
+
|
|
1301
|
+
let all = await old.all();
|
|
1302
|
+
|
|
1303
|
+
return { ...all, verified: true };
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
name,
|
|
1307
|
+
version: 3
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
expect(await v3.get('name')).toBe('alice');
|
|
1311
|
+
expect(await v3.get('role')).toBe('user');
|
|
1312
|
+
expect(await v3.get('verified')).toBe(true);
|
|
1313
|
+
expect(order).toEqual([2, 3]);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it('store without migrations works — backward compatible', async () => {
|
|
1317
|
+
let name = muid(),
|
|
1318
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name, version: 1 });
|
|
1319
|
+
|
|
1320
|
+
await store.set('name', 'alice');
|
|
1321
|
+
await store.set('age', 30);
|
|
1322
|
+
|
|
1323
|
+
expect(await store.get('name')).toBe('alice');
|
|
1324
|
+
expect(await store.get('age')).toBe(30);
|
|
1325
|
+
expect(await store.count()).toBe(2);
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
it('version key is hidden from all()', async () => {
|
|
1329
|
+
type V1 = { name: string };
|
|
1330
|
+
type V2 = { name: string; role: string };
|
|
1331
|
+
|
|
1332
|
+
let name = muid();
|
|
1333
|
+
|
|
1334
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1335
|
+
|
|
1336
|
+
await v1.set('name', 'alice');
|
|
1337
|
+
|
|
1338
|
+
let v2 = createLocal<V2>({
|
|
1339
|
+
driver: DriverType.Memory,
|
|
1340
|
+
migrations: {
|
|
1341
|
+
2: async (old) => {
|
|
1342
|
+
let all = await old.all();
|
|
1343
|
+
|
|
1344
|
+
return { ...all, role: 'admin' };
|
|
1345
|
+
}
|
|
1346
|
+
},
|
|
1347
|
+
name,
|
|
1348
|
+
version: 2
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
let all = await v2.all();
|
|
1352
|
+
|
|
1353
|
+
expect(all).toEqual({ name: 'alice', role: 'admin' });
|
|
1354
|
+
expect('__version__' in all).toBe(false);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it('version key is hidden from keys()', async () => {
|
|
1358
|
+
type V1 = { name: string };
|
|
1359
|
+
type V2 = { name: string; role: string };
|
|
1360
|
+
|
|
1361
|
+
let name = muid();
|
|
1362
|
+
|
|
1363
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1364
|
+
|
|
1365
|
+
await v1.set('name', 'alice');
|
|
1366
|
+
|
|
1367
|
+
let v2 = createLocal<V2>({
|
|
1368
|
+
driver: DriverType.Memory,
|
|
1369
|
+
migrations: {
|
|
1370
|
+
2: async (old) => {
|
|
1371
|
+
let all = await old.all();
|
|
1372
|
+
|
|
1373
|
+
return { ...all, role: 'admin' };
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
name,
|
|
1377
|
+
version: 2
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
let keys = await v2.keys();
|
|
1381
|
+
|
|
1382
|
+
expect(keys.sort()).toEqual(['name', 'role']);
|
|
1383
|
+
expect(keys).not.toContain('__version__');
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
it('version key is hidden from count()', async () => {
|
|
1387
|
+
type V1 = { name: string };
|
|
1388
|
+
type V2 = { name: string; role: string };
|
|
1389
|
+
|
|
1390
|
+
let name = muid();
|
|
1391
|
+
|
|
1392
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1393
|
+
|
|
1394
|
+
await v1.set('name', 'alice');
|
|
1395
|
+
|
|
1396
|
+
let v2 = createLocal<V2>({
|
|
1397
|
+
driver: DriverType.Memory,
|
|
1398
|
+
migrations: {
|
|
1399
|
+
2: async (old) => {
|
|
1400
|
+
let all = await old.all();
|
|
1401
|
+
|
|
1402
|
+
return { ...all, role: 'admin' };
|
|
1403
|
+
}
|
|
1404
|
+
},
|
|
1405
|
+
name,
|
|
1406
|
+
version: 2
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
expect(await v2.count()).toBe(2);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
it('migration removes old keys not returned by transform', async () => {
|
|
1413
|
+
type V1 = { legacy: string; name: string };
|
|
1414
|
+
type V2 = { name: string };
|
|
1415
|
+
|
|
1416
|
+
let name = muid();
|
|
1417
|
+
|
|
1418
|
+
let v1 = createLocal<V1>({ driver: DriverType.Memory, name, version: 1 });
|
|
1419
|
+
|
|
1420
|
+
await v1.set('name', 'alice');
|
|
1421
|
+
await v1.set('legacy', 'old-value');
|
|
1422
|
+
|
|
1423
|
+
let v2 = createLocal<V2>({
|
|
1424
|
+
driver: DriverType.Memory,
|
|
1425
|
+
migrations: {
|
|
1426
|
+
2: async (old) => {
|
|
1427
|
+
let all = await old.all();
|
|
1428
|
+
|
|
1429
|
+
return { name: all['name'] as string };
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1432
|
+
name,
|
|
1433
|
+
version: 2
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
expect(await v2.get('name')).toBe('alice');
|
|
1437
|
+
|
|
1438
|
+
let keys = await v2.keys();
|
|
1439
|
+
|
|
1440
|
+
expect(keys).toEqual(['name']);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it('migration with empty store produces correct result', async () => {
|
|
1444
|
+
type V2 = { name: string; role: string };
|
|
1445
|
+
|
|
1446
|
+
let name = muid();
|
|
1447
|
+
|
|
1448
|
+
let v2 = createLocal<V2>({
|
|
1449
|
+
driver: DriverType.Memory,
|
|
1450
|
+
migrations: {
|
|
1451
|
+
2: async () => {
|
|
1452
|
+
return { name: 'default', role: 'guest' };
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
name,
|
|
1456
|
+
version: 2
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
expect(await v2.get('name')).toBe('default');
|
|
1460
|
+
expect(await v2.get('role')).toBe('guest');
|
|
1461
|
+
});
|
|
1462
|
+
});
|