Skip to content

Commit 4546090

Browse files
authored
feat(javascript): add cache TTL and fix support message (#2474)
1 parent 78daef2 commit 4546090

File tree

4 files changed

+134
-16
lines changed

4 files changed

+134
-16
lines changed

clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ describe('browser local storage cache', () => {
3939
expect(missMock.mock.calls.length).toBe(1);
4040
});
4141

42+
it('reads unexpired timeToLive keys', async () => {
43+
const cache = createBrowserLocalStorageCache({
44+
key: version,
45+
timeToLive: 5,
46+
});
47+
await cache.set({ key: 'foo' }, { bar: 1 });
48+
49+
const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 });
50+
51+
expect(
52+
await cache.get({ key: 'foo' }, defaultValue, {
53+
miss: () => Promise.resolve(missMock()),
54+
})
55+
).toMatchObject({ bar: 1 });
56+
57+
expect(missMock.mock.calls.length).toBe(0);
58+
});
59+
4260
it('deletes keys', async () => {
4361
const cache = createBrowserLocalStorageCache({ key: version });
4462

@@ -53,19 +71,43 @@ describe('browser local storage cache', () => {
5371
expect(missMock.mock.calls.length).toBe(1);
5472
});
5573

74+
it('deletes expired keys', async () => {
75+
const cache = createBrowserLocalStorageCache({
76+
key: version,
77+
timeToLive: -1,
78+
});
79+
await cache.set({ key: 'foo' }, { bar: 1 });
80+
81+
const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 });
82+
83+
expect(
84+
await cache.get({ key: 'foo' }, defaultValue, {
85+
miss: () => Promise.resolve(missMock()),
86+
})
87+
).toMatchObject({ bar: 2 });
88+
89+
expect(missMock.mock.calls.length).toBe(1);
90+
});
91+
5692
it('can be cleared', async () => {
5793
const cache = createBrowserLocalStorageCache({ key: version });
58-
5994
await cache.set({ key: 'foo' }, { bar: 1 });
95+
6096
await cache.clear();
6197

62-
const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 });
98+
const defaultValue = (): Promise<void> => Promise.resolve({ bar: 2 });
6399

64-
expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject(
65-
{ bar: 2 }
66-
);
67-
expect(missMock.mock.calls.length).toBe(1);
68100
expect(localStorage.length).toBe(0);
101+
102+
expect(
103+
await cache.get({ key: 'foo' }, defaultValue, {
104+
miss: () => Promise.resolve(missMock()),
105+
})
106+
).toMatchObject({ bar: 2 });
107+
108+
expect(missMock.mock.calls.length).toBe(1);
109+
110+
expect(localStorage.getItem(`algolia-client-js-${version}`)).toEqual('{}');
69111
});
70112

71113
it('do throws localstorage exceptions on access', async () => {
@@ -122,13 +164,23 @@ describe('browser local storage cache', () => {
122164
});
123165
const key = { foo: 'bar' };
124166
const value = 'foo';
125-
126167
expect(localStorage.getItem(`algolia-client-js-${version}`)).toBeNull();
127168

128169
await cache.set(key, value);
129170

130-
expect(localStorage.getItem(`algolia-client-js-${version}`)).toBe(
131-
'{"{\\"foo\\":\\"bar\\"}":"foo"}'
171+
const expectedValue = expect.objectContaining({
172+
[JSON.stringify(key)]: {
173+
timestamp: expect.any(Number),
174+
value,
175+
},
176+
});
177+
178+
const localStorageValue = localStorage.getItem(
179+
`algolia-client-js-${version}`
180+
);
181+
182+
expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual(
183+
expectedValue
132184
);
133185
});
134186
});

clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { BrowserLocalStorageOptions, Cache, CacheEvents } from '../types';
1+
import type {
2+
BrowserLocalStorageCacheItem,
3+
BrowserLocalStorageOptions,
4+
Cache,
5+
CacheEvents,
6+
} from '../types';
27

38
export function createBrowserLocalStorageCache(
49
options: BrowserLocalStorageOptions
@@ -19,20 +24,61 @@ export function createBrowserLocalStorageCache(
1924
return JSON.parse(getStorage().getItem(namespaceKey) || '{}');
2025
}
2126

27+
function setNamespace(namespace: Record<string, any>): void {
28+
getStorage().setItem(namespaceKey, JSON.stringify(namespace));
29+
}
30+
31+
function removeOutdatedCacheItems(): void {
32+
const timeToLive = options.timeToLive ? options.timeToLive * 1000 : null;
33+
const namespace = getNamespace<BrowserLocalStorageCacheItem>();
34+
35+
const filteredNamespaceWithoutOldFormattedCacheItems = Object.fromEntries(
36+
Object.entries(namespace).filter(([, cacheItem]) => {
37+
return cacheItem.timestamp !== undefined;
38+
})
39+
);
40+
41+
setNamespace(filteredNamespaceWithoutOldFormattedCacheItems);
42+
43+
if (!timeToLive) {
44+
return;
45+
}
46+
47+
const filteredNamespaceWithoutExpiredItems = Object.fromEntries(
48+
Object.entries(filteredNamespaceWithoutOldFormattedCacheItems).filter(
49+
([, cacheItem]) => {
50+
const currentTimestamp = new Date().getTime();
51+
const isExpired = cacheItem.timestamp + timeToLive < currentTimestamp;
52+
53+
return !isExpired;
54+
}
55+
)
56+
);
57+
58+
setNamespace(filteredNamespaceWithoutExpiredItems);
59+
}
60+
2261
return {
2362
get<TValue>(
2463
key: Record<string, any> | string,
2564
defaultValue: () => Promise<TValue>,
2665
events: CacheEvents<TValue> = {
27-
miss: (): Promise<void> => Promise.resolve(),
66+
miss: () => Promise.resolve(),
2867
}
2968
): Promise<TValue> {
3069
return Promise.resolve()
3170
.then(() => {
32-
const keyAsString = JSON.stringify(key);
33-
const value = getNamespace<TValue>()[keyAsString];
71+
removeOutdatedCacheItems();
3472

35-
return Promise.all([value || defaultValue(), value !== undefined]);
73+
return getNamespace<Promise<BrowserLocalStorageCacheItem>>()[
74+
JSON.stringify(key)
75+
];
76+
})
77+
.then((value) => {
78+
return Promise.all([
79+
value ? value.value : defaultValue(),
80+
value !== undefined,
81+
]);
3682
})
3783
.then(([value, exists]) => {
3884
return Promise.all([value, exists || events.miss(value)]);
@@ -47,7 +93,10 @@ export function createBrowserLocalStorageCache(
4793
return Promise.resolve().then(() => {
4894
const namespace = getNamespace();
4995

50-
namespace[JSON.stringify(key)] = value;
96+
namespace[JSON.stringify(key)] = {
97+
timestamp: new Date().getTime(),
98+
value,
99+
};
51100

52101
getStorage().setItem(namespaceKey, JSON.stringify(namespace));
53102

clients/algoliasearch-client-javascript/packages/client-common/src/transporter/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class ErrorWithStackTrace extends AlgoliaError {
2525
export class RetryError extends ErrorWithStackTrace {
2626
constructor(stackTrace: StackFrame[]) {
2727
super(
28-
'Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.',
28+
'Unreachable hosts - your application id may be incorrect. If the error persists, please create a ticket at https://support.algolia.com/ sharing steps we can use to reproduce the issue.',
2929
stackTrace,
3030
'RetryError'
3131
);

clients/algoliasearch-client-javascript/packages/client-common/src/types/cache.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,29 @@ export type BrowserLocalStorageOptions = {
4747
*/
4848
key: string;
4949

50+
/**
51+
* The time to live for each cached item in seconds.
52+
*/
53+
timeToLive?: number;
54+
5055
/**
5156
* The native local storage implementation.
5257
*/
5358
localStorage?: Storage;
5459
};
5560

61+
export type BrowserLocalStorageCacheItem = {
62+
/**
63+
* The cache item creation timestamp.
64+
*/
65+
timestamp: number;
66+
67+
/**
68+
* The cache item value.
69+
*/
70+
value: any;
71+
};
72+
5673
export type FallbackableCacheOptions = {
5774
/**
5875
* List of caches order by priority.

0 commit comments

Comments
 (0)