@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
|
@@ -232,6 +232,91 @@ describe('SessionStorageDriver', () => {
|
|
|
232
232
|
});
|
|
233
233
|
|
|
234
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
|
+
|
|
235
320
|
describe('set / get', () => {
|
|
236
321
|
it('overwrites existing key', async () => {
|
|
237
322
|
await driver.set('name', 'alice');
|
package/tests/index.ts
CHANGED
|
@@ -2,16 +2,22 @@ import 'fake-indexeddb/auto';
|
|
|
2
2
|
|
|
3
3
|
import { vi } from 'vitest';
|
|
4
4
|
|
|
5
|
+
let mockDecrypt = vi.fn(async (content: string) => {
|
|
6
|
+
return JSON.parse(atob(content));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
let mockEncrypt = vi.fn(async (content: unknown) => {
|
|
10
|
+
return btoa(JSON.stringify(content));
|
|
11
|
+
});
|
|
12
|
+
|
|
5
13
|
vi.mock('@esportsplus/utilities', () => ({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return btoa(content as string);
|
|
11
|
-
})
|
|
14
|
+
encryption: vi.fn(async (_password: string) => ({
|
|
15
|
+
decrypt: (...args: unknown[]) => mockDecrypt(...args as [string]),
|
|
16
|
+
encrypt: (...args: unknown[]) => mockEncrypt(...args as [unknown])
|
|
17
|
+
}))
|
|
12
18
|
}));
|
|
13
19
|
|
|
14
|
-
import {
|
|
20
|
+
import { encryption } from '@esportsplus/utilities';
|
|
15
21
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
22
|
|
|
17
23
|
import createLocal, { DriverType } from '~/index';
|
|
@@ -282,13 +288,13 @@ describe('Local (LocalStorage driver)', () => {
|
|
|
282
288
|
it('get — returns undefined when decrypt fails', async () => {
|
|
283
289
|
await encrypted.set('name', 'alice');
|
|
284
290
|
|
|
285
|
-
|
|
291
|
+
mockDecrypt.mockRejectedValueOnce(new Error('decrypt failed'));
|
|
286
292
|
|
|
287
293
|
expect(await encrypted.get('name')).toBeUndefined();
|
|
288
294
|
});
|
|
289
295
|
|
|
290
296
|
it('replace — returns failed keys when encrypt throws', async () => {
|
|
291
|
-
|
|
297
|
+
mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
|
|
292
298
|
|
|
293
299
|
let failed = await encrypted.replace({ age: 25, name: 'bob' });
|
|
294
300
|
|
|
@@ -298,7 +304,7 @@ describe('Local (LocalStorage driver)', () => {
|
|
|
298
304
|
});
|
|
299
305
|
|
|
300
306
|
it('set — returns false when encrypt throws', async () => {
|
|
301
|
-
|
|
307
|
+
mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
|
|
302
308
|
|
|
303
309
|
expect(await encrypted.set('name', 'alice')).toBe(false);
|
|
304
310
|
});
|
|
@@ -1460,3 +1466,551 @@ describe('Migration Callbacks (Memory driver)', () => {
|
|
|
1460
1466
|
expect(await v2.get('role')).toBe('guest');
|
|
1461
1467
|
});
|
|
1462
1468
|
});
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
describe('Local (SessionStorage driver)', () => {
|
|
1472
|
+
let store: Local<TestData>;
|
|
1473
|
+
|
|
1474
|
+
beforeEach(() => {
|
|
1475
|
+
sessionStorage.clear();
|
|
1476
|
+
store = createLocal<TestData>({ driver: DriverType.SessionStorage, name: 'ss-test', version: 1 });
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
describe('with encryption', () => {
|
|
1481
|
+
let encrypted: Local<TestData>;
|
|
1482
|
+
|
|
1483
|
+
beforeEach(() => {
|
|
1484
|
+
encrypted = createLocal<TestData>({ driver: DriverType.SessionStorage, name: 'ss-enc', version: 1 }, 'test-secret');
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
it('set / get — round-trip with secret', async () => {
|
|
1489
|
+
await encrypted.set('name', 'alice');
|
|
1490
|
+
|
|
1491
|
+
expect(await encrypted.get('name')).toBe('alice');
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
describe('without encryption', () => {
|
|
1497
|
+
it('all — returns all entries', async () => {
|
|
1498
|
+
await store.set('age', 30);
|
|
1499
|
+
await store.set('name', 'alice');
|
|
1500
|
+
await store.set('tags', ['a', 'b']);
|
|
1501
|
+
|
|
1502
|
+
let result = await store.all();
|
|
1503
|
+
|
|
1504
|
+
expect(result).toEqual({ age: 30, name: 'alice', tags: ['a', 'b'] });
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it('clear — removes all entries', async () => {
|
|
1508
|
+
await store.set('age', 25);
|
|
1509
|
+
await store.set('name', 'alice');
|
|
1510
|
+
await store.clear();
|
|
1511
|
+
|
|
1512
|
+
expect(await store.count()).toBe(0);
|
|
1513
|
+
expect(await store.all()).toEqual({});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it('count — returns correct count', async () => {
|
|
1517
|
+
await store.set('age', 25);
|
|
1518
|
+
await store.set('name', 'alice');
|
|
1519
|
+
|
|
1520
|
+
expect(await store.count()).toBe(2);
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it('delete — removes specified keys', async () => {
|
|
1524
|
+
await store.set('age', 30);
|
|
1525
|
+
await store.set('name', 'alice');
|
|
1526
|
+
await store.set('tags', ['a']);
|
|
1527
|
+
await store.delete('name', 'tags');
|
|
1528
|
+
|
|
1529
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1530
|
+
expect(await store.get('tags')).toBeUndefined();
|
|
1531
|
+
expect(await store.get('age')).toBe(30);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
it('filter — filters entries by predicate', async () => {
|
|
1535
|
+
await store.set('age', 30);
|
|
1536
|
+
await store.set('name', 'alice');
|
|
1537
|
+
await store.set('tags', ['a', 'b']);
|
|
1538
|
+
|
|
1539
|
+
let result = await store.filter(({ key }) => key === 'name' || key === 'tags');
|
|
1540
|
+
|
|
1541
|
+
expect(result).toEqual({ name: 'alice', tags: ['a', 'b'] });
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it('get — returns undefined for non-existent key', async () => {
|
|
1545
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
it('keys — returns all keys', async () => {
|
|
1549
|
+
await store.set('age', 25);
|
|
1550
|
+
await store.set('name', 'alice');
|
|
1551
|
+
|
|
1552
|
+
let result = await store.keys();
|
|
1553
|
+
|
|
1554
|
+
expect(result.sort()).toEqual(['age', 'name']);
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('length — returns correct count', async () => {
|
|
1558
|
+
await store.set('age', 25);
|
|
1559
|
+
await store.set('name', 'alice');
|
|
1560
|
+
|
|
1561
|
+
expect(await store.length()).toBe(2);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
it('map — iterates all entries', async () => {
|
|
1565
|
+
await store.set('age', 25);
|
|
1566
|
+
await store.set('name', 'alice');
|
|
1567
|
+
|
|
1568
|
+
let entries: { i: number; key: keyof TestData; value: TestData[keyof TestData] }[] = [];
|
|
1569
|
+
|
|
1570
|
+
await store.map((value, key, i) => {
|
|
1571
|
+
entries.push({ i, key, value });
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
expect(entries).toHaveLength(2);
|
|
1575
|
+
|
|
1576
|
+
entries.sort((a, b) => (a.key as string).localeCompare(b.key as string));
|
|
1577
|
+
|
|
1578
|
+
expect(entries[0]).toEqual({ i: expect.any(Number), key: 'age', value: 25 });
|
|
1579
|
+
expect(entries[1]).toEqual({ i: expect.any(Number), key: 'name', value: 'alice' });
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
it('only — returns subset of entries', async () => {
|
|
1583
|
+
await store.set('age', 30);
|
|
1584
|
+
await store.set('name', 'alice');
|
|
1585
|
+
await store.set('tags', ['a']);
|
|
1586
|
+
|
|
1587
|
+
let result = await store.only('name', 'tags');
|
|
1588
|
+
|
|
1589
|
+
expect(result).toEqual({ name: 'alice', tags: ['a'] });
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it('replace — batch replace returns empty failed array', async () => {
|
|
1593
|
+
let failed = await store.replace({ age: 25, name: 'bob', tags: ['x', 'y'] });
|
|
1594
|
+
|
|
1595
|
+
expect(failed).toEqual([]);
|
|
1596
|
+
expect(await store.get('age')).toBe(25);
|
|
1597
|
+
expect(await store.get('name')).toBe('bob');
|
|
1598
|
+
expect(await store.get('tags')).toEqual(['x', 'y']);
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it('set / get — basic round-trip', async () => {
|
|
1602
|
+
await store.set('name', 'bob');
|
|
1603
|
+
|
|
1604
|
+
expect(await store.get('name')).toBe('bob');
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
describe('Memory driver — encryption', () => {
|
|
1611
|
+
let encrypted: Local<TestData>;
|
|
1612
|
+
|
|
1613
|
+
beforeEach(async () => {
|
|
1614
|
+
encrypted = createLocal<TestData>({ driver: DriverType.Memory, name: 'mem-enc', version: 1 }, 'test-secret');
|
|
1615
|
+
await encrypted.clear();
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
it('set / get — round-trip with secret', async () => {
|
|
1620
|
+
await encrypted.set('name', 'alice');
|
|
1621
|
+
|
|
1622
|
+
expect(await encrypted.get('name')).toBe('alice');
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it('all — decrypts all values', async () => {
|
|
1626
|
+
await encrypted.set('age', 30);
|
|
1627
|
+
await encrypted.set('name', 'alice');
|
|
1628
|
+
|
|
1629
|
+
let result = await encrypted.all();
|
|
1630
|
+
|
|
1631
|
+
expect(result).toEqual({ age: 30, name: 'alice' });
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
describe('TTL / Expiration (Memory driver)', () => {
|
|
1637
|
+
let now: number;
|
|
1638
|
+
|
|
1639
|
+
beforeEach(() => {
|
|
1640
|
+
now = Date.now();
|
|
1641
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
afterEach(() => {
|
|
1645
|
+
vi.restoreAllMocks();
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
it('get — returns value before TTL expires', async () => {
|
|
1650
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-mem-1', version: 1 });
|
|
1651
|
+
|
|
1652
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
1653
|
+
|
|
1654
|
+
expect(await store.get('name')).toBe('alice');
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it('get — returns undefined after TTL expires', async () => {
|
|
1658
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-mem-2', version: 1 });
|
|
1659
|
+
|
|
1660
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
1661
|
+
|
|
1662
|
+
now += 60001;
|
|
1663
|
+
|
|
1664
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it('ttl — returns remaining ms', async () => {
|
|
1668
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-mem-3', version: 1 });
|
|
1669
|
+
|
|
1670
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
1671
|
+
|
|
1672
|
+
now += 10000;
|
|
1673
|
+
|
|
1674
|
+
let remaining = await store.ttl('name');
|
|
1675
|
+
|
|
1676
|
+
expect(remaining).toBe(50000);
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
it('persist — removes TTL, value still accessible', async () => {
|
|
1680
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-mem-4', version: 1 });
|
|
1681
|
+
|
|
1682
|
+
await store.set('name', 'alice', { ttl: 60000 });
|
|
1683
|
+
|
|
1684
|
+
await store.persist('name');
|
|
1685
|
+
|
|
1686
|
+
now += 120000;
|
|
1687
|
+
|
|
1688
|
+
expect(await store.get('name')).toBe('alice');
|
|
1689
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
it('cleanup — removes all expired entries', async () => {
|
|
1693
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-mem-5', version: 1 });
|
|
1694
|
+
|
|
1695
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1696
|
+
await store.set('age', 30, { ttl: 10000 });
|
|
1697
|
+
await store.set('tags', ['a']);
|
|
1698
|
+
|
|
1699
|
+
now += 10001;
|
|
1700
|
+
|
|
1701
|
+
await store.cleanup();
|
|
1702
|
+
|
|
1703
|
+
expect(await store.count()).toBe(1);
|
|
1704
|
+
expect(await store.get('tags')).toEqual(['a']);
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
describe('get(key, factory) — Memory driver', () => {
|
|
1710
|
+
let now: number;
|
|
1711
|
+
|
|
1712
|
+
beforeEach(() => {
|
|
1713
|
+
now = Date.now();
|
|
1714
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
afterEach(() => {
|
|
1718
|
+
vi.restoreAllMocks();
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
it('returns factory value when key is missing', async () => {
|
|
1723
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-mem-1', version: 1 });
|
|
1724
|
+
|
|
1725
|
+
let result = await store.get('name', () => 'default');
|
|
1726
|
+
|
|
1727
|
+
expect(result).toBe('default');
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it('returns stored value when key exists — factory NOT called', async () => {
|
|
1731
|
+
let called = false,
|
|
1732
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-mem-2', version: 1 });
|
|
1733
|
+
|
|
1734
|
+
await store.set('name', 'alice');
|
|
1735
|
+
|
|
1736
|
+
let result = await store.get('name', () => {
|
|
1737
|
+
called = true;
|
|
1738
|
+
return 'default';
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
expect(result).toBe('alice');
|
|
1742
|
+
expect(called).toBe(false);
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
describe('Cross-feature edge cases', () => {
|
|
1748
|
+
let now: number;
|
|
1749
|
+
|
|
1750
|
+
beforeEach(() => {
|
|
1751
|
+
now = Date.now();
|
|
1752
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
afterEach(() => {
|
|
1756
|
+
vi.restoreAllMocks();
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
it('persist — returns false for non-existent key', async () => {
|
|
1761
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'edge-persist-1', version: 1 });
|
|
1762
|
+
|
|
1763
|
+
expect(await store.persist('name')).toBe(false);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
it('persist — returns true for already-permanent key', async () => {
|
|
1767
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'edge-persist-2', version: 1 });
|
|
1768
|
+
|
|
1769
|
+
await store.set('name', 'alice');
|
|
1770
|
+
|
|
1771
|
+
expect(await store.persist('name')).toBe(true);
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
it('cleanup — fires subscription notifications for expired keys', async () => {
|
|
1775
|
+
let calls: { key: unknown; newValue: unknown; oldValue: unknown }[] = [],
|
|
1776
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name: 'edge-cleanup-sub', version: 1 });
|
|
1777
|
+
|
|
1778
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1779
|
+
await store.set('age', 30);
|
|
1780
|
+
|
|
1781
|
+
store.subscribe((key, newValue, oldValue) => {
|
|
1782
|
+
calls.push({ key, newValue, oldValue });
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
now += 10001;
|
|
1786
|
+
|
|
1787
|
+
await store.cleanup();
|
|
1788
|
+
|
|
1789
|
+
expect(calls).toHaveLength(1);
|
|
1790
|
+
expect(calls[0]).toEqual({ key: 'name', newValue: undefined, oldValue: 'alice' });
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it('count — excludes __version__ key when migrations active', async () => {
|
|
1794
|
+
type V1 = { name: string };
|
|
1795
|
+
type V2 = { name: string; role: string };
|
|
1796
|
+
|
|
1797
|
+
let store1 = createLocal<V1>({ driver: DriverType.Memory, name: 'edge-count-ver', version: 1 });
|
|
1798
|
+
|
|
1799
|
+
await store1.set('name', 'alice');
|
|
1800
|
+
|
|
1801
|
+
let store2 = createLocal<V2>({
|
|
1802
|
+
driver: DriverType.Memory,
|
|
1803
|
+
migrations: {
|
|
1804
|
+
2: async (old) => {
|
|
1805
|
+
let all = await old.all();
|
|
1806
|
+
|
|
1807
|
+
return { ...all, role: 'admin' };
|
|
1808
|
+
}
|
|
1809
|
+
},
|
|
1810
|
+
name: 'edge-count-ver',
|
|
1811
|
+
version: 2
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
expect(await store2.count()).toBe(2);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it('keys — excludes __version__ key when migrations active', async () => {
|
|
1818
|
+
type V1 = { name: string };
|
|
1819
|
+
type V2 = { name: string; role: string };
|
|
1820
|
+
|
|
1821
|
+
let store1 = createLocal<V1>({ driver: DriverType.Memory, name: 'edge-keys-ver', version: 1 });
|
|
1822
|
+
|
|
1823
|
+
await store1.set('name', 'alice');
|
|
1824
|
+
|
|
1825
|
+
let store2 = createLocal<V2>({
|
|
1826
|
+
driver: DriverType.Memory,
|
|
1827
|
+
migrations: {
|
|
1828
|
+
2: async (old) => {
|
|
1829
|
+
let all = await old.all();
|
|
1830
|
+
|
|
1831
|
+
return { ...all, role: 'admin' };
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1834
|
+
name: 'edge-keys-ver',
|
|
1835
|
+
version: 2
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
let keys = await store2.keys();
|
|
1839
|
+
|
|
1840
|
+
expect(keys.sort()).toEqual(['name', 'role']);
|
|
1841
|
+
expect(keys).not.toContain('__version__');
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
it('get with factory — fires subscription notification', async () => {
|
|
1845
|
+
let called = false,
|
|
1846
|
+
store = createLocal<TestData>({ driver: DriverType.Memory, name: 'edge-factory-sub', version: 1 });
|
|
1847
|
+
|
|
1848
|
+
store.subscribe('name', () => { called = true; });
|
|
1849
|
+
|
|
1850
|
+
await store.get('name', () => 'lazy');
|
|
1851
|
+
|
|
1852
|
+
// Allow fire-and-forget set to complete
|
|
1853
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1854
|
+
|
|
1855
|
+
expect(called).toBe(true);
|
|
1856
|
+
});
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
describe('Compression + Encryption (LocalStorage)', () => {
|
|
1861
|
+
|
|
1862
|
+
beforeEach(() => {
|
|
1863
|
+
localStorage.clear();
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
it('large value round-trips with encryption enabled', async () => {
|
|
1868
|
+
type LargeData = { payload: string };
|
|
1869
|
+
|
|
1870
|
+
let store = createLocal<LargeData>({ driver: DriverType.LocalStorage, name: 'comp-enc', version: 1 }, 'test-secret'),
|
|
1871
|
+
largeValue = 'a'.repeat(200);
|
|
1872
|
+
|
|
1873
|
+
await store.set('payload', largeValue);
|
|
1874
|
+
|
|
1875
|
+
expect(await store.get('payload')).toBe(largeValue);
|
|
1876
|
+
});
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
describe('get(key, factory) — error handling', () => {
|
|
1881
|
+
|
|
1882
|
+
it('rejects when factory throws sync error', async () => {
|
|
1883
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-1', version: 1 });
|
|
1884
|
+
|
|
1885
|
+
await expect(store.get('name', () => { throw new Error('factory boom'); }))
|
|
1886
|
+
.rejects.toThrow('factory boom');
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it('rejects when factory returns rejected promise', async () => {
|
|
1890
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-2', version: 1 });
|
|
1891
|
+
|
|
1892
|
+
await expect(store.get('name', async () => { throw new Error('async boom'); }))
|
|
1893
|
+
.rejects.toThrow('async boom');
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
describe('set() TTL boundary values', () => {
|
|
1899
|
+
let now: number;
|
|
1900
|
+
|
|
1901
|
+
beforeEach(() => {
|
|
1902
|
+
now = Date.now();
|
|
1903
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
afterEach(() => {
|
|
1907
|
+
vi.restoreAllMocks();
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
it('ttl: 0 stores value without TTL envelope (treated as permanent)', async () => {
|
|
1912
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-zero', version: 1 });
|
|
1913
|
+
|
|
1914
|
+
await store.set('name', 'alice', { ttl: 0 });
|
|
1915
|
+
|
|
1916
|
+
now += 999999;
|
|
1917
|
+
|
|
1918
|
+
expect(await store.get('name')).toBe('alice');
|
|
1919
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
it('ttl: -1 stores value without TTL envelope (treated as permanent)', async () => {
|
|
1923
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-neg', version: 1 });
|
|
1924
|
+
|
|
1925
|
+
await store.set('name', 'alice', { ttl: -1 });
|
|
1926
|
+
|
|
1927
|
+
now += 999999;
|
|
1928
|
+
|
|
1929
|
+
expect(await store.get('name')).toBe('alice');
|
|
1930
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1931
|
+
});
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
describe('persist() on expired key', () => {
|
|
1936
|
+
let now: number;
|
|
1937
|
+
|
|
1938
|
+
beforeEach(() => {
|
|
1939
|
+
now = Date.now();
|
|
1940
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
afterEach(() => {
|
|
1944
|
+
vi.restoreAllMocks();
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
it('returns false and deletes the expired entry', async () => {
|
|
1949
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'persist-expired', version: 1 });
|
|
1950
|
+
|
|
1951
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1952
|
+
|
|
1953
|
+
now += 10001;
|
|
1954
|
+
|
|
1955
|
+
expect(await store.persist('name')).toBe(false);
|
|
1956
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
describe('ttl() on expired key', () => {
|
|
1962
|
+
let now: number;
|
|
1963
|
+
|
|
1964
|
+
beforeEach(() => {
|
|
1965
|
+
now = Date.now();
|
|
1966
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
afterEach(() => {
|
|
1970
|
+
vi.restoreAllMocks();
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
it('returns -1 and deletes the expired entry', async () => {
|
|
1975
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-expired', version: 1 });
|
|
1976
|
+
|
|
1977
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1978
|
+
|
|
1979
|
+
now += 10001;
|
|
1980
|
+
|
|
1981
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1982
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1983
|
+
});
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
describe('Migration error handling', () => {
|
|
1988
|
+
|
|
1989
|
+
let migrationErrId = 0;
|
|
1990
|
+
|
|
1991
|
+
function meuid() {
|
|
1992
|
+
return `migration-err-${++migrationErrId}`;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
it('throwing migration makes store methods reject', async () => {
|
|
1997
|
+
type V2 = { name: string };
|
|
1998
|
+
|
|
1999
|
+
let name = meuid();
|
|
2000
|
+
|
|
2001
|
+
let v1 = createLocal<{ name: string }>({ driver: DriverType.Memory, name, version: 1 });
|
|
2002
|
+
|
|
2003
|
+
await v1.set('name', 'alice');
|
|
2004
|
+
|
|
2005
|
+
let v2 = createLocal<V2>({
|
|
2006
|
+
driver: DriverType.Memory,
|
|
2007
|
+
migrations: {
|
|
2008
|
+
2: async () => { throw new Error('migration failed'); }
|
|
2009
|
+
},
|
|
2010
|
+
name,
|
|
2011
|
+
version: 2
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
await expect(v2.get('name')).rejects.toThrow('migration failed');
|
|
2015
|
+
});
|
|
2016
|
+
});
|