Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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<void> => 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 () => {
Expand Down Expand Up @@ -122,13 +164,23 @@ describe('browser local storage cache', () => {
});
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
);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { BrowserLocalStorageOptions, Cache, CacheEvents } from '../types';
import type {
BrowserLocalStorageCacheItem,
BrowserLocalStorageOptions,
Cache,
CacheEvents,
} from '../types';

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

function setNamespace(namespace: Record<string, any>): void {
getStorage().setItem(namespaceKey, JSON.stringify(namespace));
}

function removeOutdatedCacheItems(): void {
const timeToLive = options.timeToLive ? options.timeToLive * 1000 : null;
const namespace = getNamespace<BrowserLocalStorageCacheItem>();

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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens when timeToLive is null ? shouldn't you just early return instead of doing the addition here ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those are already filtered

I did not touched the actual implementation just in case we broke something non tested

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah it's just above I missed it


return !isExpired;
}
)
);

setNamespace(filteredNamespaceWithoutExpiredItems);
}

return {
get<TValue>(
key: Record<string, any> | string,
defaultValue: () => Promise<TValue>,
events: CacheEvents<TValue> = {
miss: (): Promise<void> => Promise.resolve(),
miss: () => Promise.resolve(),
}
): Promise<TValue> {
return Promise.resolve()
.then(() => {
const keyAsString = JSON.stringify(key);
const value = getNamespace<TValue>()[keyAsString];
removeOutdatedCacheItems();

return Promise.all([value || defaultValue(), value !== undefined]);
return getNamespace<Promise<BrowserLocalStorageCacheItem>>()[
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)]);
Expand All @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down