From 56b24b7ff77ff05453e0e875c949907c0f7ca212 Mon Sep 17 00:00:00 2001 From: shortcuts Date: Wed, 3 Jan 2024 16:22:01 +0100 Subject: [PATCH 1/2] feat(javascript): add cache TTL and fix support message --- .../cache/browser-local-storage-cache.test.ts | 70 ++++++++++++++++--- .../cache/createBrowserLocalStorageCache.ts | 61 ++++++++++++++-- .../client-common/src/transporter/errors.ts | 2 +- .../packages/client-common/src/types/cache.ts | 17 +++++ 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts index 458039d40e5..b9f9a90eca8 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts @@ -39,6 +39,24 @@ describe('browser local storage cache', () => { expect(missMock.mock.calls.length).toBe(1); }); + it('reads unexpired timeToLive keys', async () => { + const cache = createBrowserLocalStorageCache({ + key: version, + timeToLive: 5, + }); + await cache.set({ key: 'foo' }, { bar: 1 }); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect( + await cache.get({ key: 'foo' }, defaultValue, { + miss: () => Promise.resolve(missMock()), + }) + ).toMatchObject({ bar: 1 }); + + expect(missMock.mock.calls.length).toBe(0); + }); + it('deletes keys', async () => { const cache = createBrowserLocalStorageCache({ key: version }); @@ -53,19 +71,43 @@ describe('browser local storage cache', () => { expect(missMock.mock.calls.length).toBe(1); }); + it('deletes expired keys', async () => { + const cache = createBrowserLocalStorageCache({ + key: version, + timeToLive: -1, + }); + await cache.set({ key: 'foo' }, { bar: 1 }); + + const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + + expect( + await cache.get({ key: 'foo' }, defaultValue, { + miss: () => Promise.resolve(missMock()), + }) + ).toMatchObject({ bar: 2 }); + + expect(missMock.mock.calls.length).toBe(1); + }); + it('can be cleared', async () => { const cache = createBrowserLocalStorageCache({ key: version }); - await cache.set({ key: 'foo' }, { bar: 1 }); + await cache.clear(); - const defaultValue = (): DefaultValue => Promise.resolve({ bar: 2 }); + const defaultValue = () => Promise.resolve({ bar: 2 }); - expect(await cache.get({ key: 'foo' }, defaultValue, events)).toMatchObject( - { bar: 2 } - ); - expect(missMock.mock.calls.length).toBe(1); expect(localStorage.length).toBe(0); + + expect( + await cache.get({ key: 'foo' }, defaultValue, { + miss: () => Promise.resolve(missMock()), + }) + ).toMatchObject({ bar: 2 }); + + expect(missMock.mock.calls.length).toBe(1); + + expect(localStorage.getItem(`algolia-client-js-${version}`)).toEqual('{}'); }); it('do throws localstorage exceptions on access', async () => { @@ -117,18 +159,24 @@ describe('browser local storage cache', () => { }); it('creates a namespace within local storage', async () => { - const cache = createBrowserLocalStorageCache({ + const cache = createBrowserLocalStorageCache({ key: version, }); const key = { foo: 'bar' }; const value = 'foo'; - expect(localStorage.getItem(`algolia-client-js-${version}`)).toBeNull(); await cache.set(key, value); - expect(localStorage.getItem(`algolia-client-js-${version}`)).toBe( - '{"{\\"foo\\":\\"bar\\"}":"foo"}' - ); + const expectedValue = expect.objectContaining({ + [JSON.stringify(key)]: { + timestamp: expect.any(Number), + value, + }, + }); + + const localStorageValue = localStorage.getItem(`algolia-client-js-${version}`); + + expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual(expectedValue); }); }); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts index 40e4865e8ec..414c5c0fa98 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/cache/createBrowserLocalStorageCache.ts @@ -1,4 +1,9 @@ -import type { BrowserLocalStorageOptions, Cache, CacheEvents } from '../types'; +import type { + BrowserLocalStorageCacheItem, + BrowserLocalStorageOptions, + Cache, + CacheEvents, +} from '../types'; export function createBrowserLocalStorageCache( options: BrowserLocalStorageOptions @@ -19,20 +24,61 @@ export function createBrowserLocalStorageCache( return JSON.parse(getStorage().getItem(namespaceKey) || '{}'); } + function setNamespace(namespace: Record): void { + getStorage().setItem(namespaceKey, JSON.stringify(namespace)); + } + + function removeOutdatedCacheItems(): void { + const timeToLive = options.timeToLive ? options.timeToLive * 1000 : null; + const namespace = getNamespace(); + + const filteredNamespaceWithoutOldFormattedCacheItems = Object.fromEntries( + Object.entries(namespace).filter(([, cacheItem]) => { + return cacheItem.timestamp !== undefined; + }) + ); + + setNamespace(filteredNamespaceWithoutOldFormattedCacheItems); + + if (!timeToLive) { + return; + } + + const filteredNamespaceWithoutExpiredItems = Object.fromEntries( + Object.entries(filteredNamespaceWithoutOldFormattedCacheItems).filter( + ([, cacheItem]) => { + const currentTimestamp = new Date().getTime(); + const isExpired = cacheItem.timestamp + timeToLive < currentTimestamp; + + return !isExpired; + } + ) + ); + + setNamespace(filteredNamespaceWithoutExpiredItems); + } + return { get( key: Record | string, defaultValue: () => Promise, events: CacheEvents = { - miss: (): Promise => Promise.resolve(), + miss: () => Promise.resolve(), } ): Promise { return Promise.resolve() .then(() => { - const keyAsString = JSON.stringify(key); - const value = getNamespace()[keyAsString]; + removeOutdatedCacheItems(); - return Promise.all([value || defaultValue(), value !== undefined]); + return getNamespace>()[ + JSON.stringify(key) + ]; + }) + .then((value) => { + return Promise.all([ + value ? value.value : defaultValue(), + value !== undefined, + ]); }) .then(([value, exists]) => { return Promise.all([value, exists || events.miss(value)]); @@ -47,7 +93,10 @@ export function createBrowserLocalStorageCache( return Promise.resolve().then(() => { const namespace = getNamespace(); - namespace[JSON.stringify(key)] = value; + namespace[JSON.stringify(key)] = { + timestamp: new Date().getTime(), + value, + }; getStorage().setItem(namespaceKey, JSON.stringify(namespace)); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/errors.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/errors.ts index c228c116b37..80c55e8d798 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/errors.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/transporter/errors.ts @@ -25,7 +25,7 @@ export class ErrorWithStackTrace extends AlgoliaError { export class RetryError extends ErrorWithStackTrace { constructor(stackTrace: StackFrame[]) { super( - 'Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.', + '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.', stackTrace, 'RetryError' ); diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/types/cache.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/types/cache.ts index 7616c0238d3..3432a6e1595 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/types/cache.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/types/cache.ts @@ -47,12 +47,29 @@ export type BrowserLocalStorageOptions = { */ key: string; + /** + * The time to live for each cached item in seconds. + */ + timeToLive?: number; + /** * The native local storage implementation. */ localStorage?: Storage; }; +export type BrowserLocalStorageCacheItem = { + /** + * The cache item creation timestamp. + */ + timestamp: number; + + /** + * The cache item value. + */ + value: any; +}; + export type FallbackableCacheOptions = { /** * List of caches order by priority. From 28580f073105aa0af2d96cb4c19a958f849abfa4 Mon Sep 17 00:00:00 2001 From: shortcuts Date: Wed, 3 Jan 2024 16:28:15 +0100 Subject: [PATCH 2/2] fix: types in test --- .../cache/browser-local-storage-cache.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts index b9f9a90eca8..4c39edd6723 100644 --- a/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts +++ b/clients/algoliasearch-client-javascript/packages/client-common/src/__tests__/cache/browser-local-storage-cache.test.ts @@ -95,7 +95,7 @@ describe('browser local storage cache', () => { await cache.clear(); - const defaultValue = () => Promise.resolve({ bar: 2 }); + const defaultValue = (): Promise => Promise.resolve({ bar: 2 }); expect(localStorage.length).toBe(0); @@ -159,7 +159,7 @@ describe('browser local storage cache', () => { }); it('creates a namespace within local storage', async () => { - const cache = createBrowserLocalStorageCache({ + const cache = createBrowserLocalStorageCache({ key: version, }); const key = { foo: 'bar' }; @@ -175,8 +175,12 @@ describe('browser local storage cache', () => { }, }); - const localStorageValue = localStorage.getItem(`algolia-client-js-${version}`); + const localStorageValue = localStorage.getItem( + `algolia-client-js-${version}` + ); - expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual(expectedValue); + expect(JSON.parse(localStorageValue ? localStorageValue : '{}')).toEqual( + expectedValue + ); }); });