Skip to content

Commit a433d98

Browse files
committed
feat(client): improve cache support cache hit & eviction
1 parent 1513e31 commit a433d98

File tree

3 files changed

+128
-32
lines changed

3 files changed

+128
-32
lines changed

src/services/common/base-client.test.ts

+76-10
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,32 @@ const api = {
8585
cache: true,
8686
},
8787
}),
88+
endpointWitEvictOnError: new ClientEndpoint({
89+
method: HttpMethod.GET,
90+
url: '/endpoint-with-cache-object',
91+
opts: {
92+
cache: {
93+
evictOnError: true,
94+
},
95+
},
96+
}),
8897
endpointWitCacheRetention: new ClientEndpoint({
8998
method: HttpMethod.GET,
9099
url: '/endpoint-with-cache-retention',
91100
opts: {
92101
cache: 20,
93102
},
94103
}),
104+
endpointWitCacheObject: new ClientEndpoint({
105+
method: HttpMethod.GET,
106+
url: '/endpoint-with-cache-object',
107+
opts: {
108+
cache: {
109+
retention: 20,
110+
evictOnError: true,
111+
},
112+
},
113+
}),
95114
endpointWithoutCache: new ClientEndpoint({
96115
method: HttpMethod.GET,
97116
url: '/endpoint-without-cache',
@@ -225,60 +244,90 @@ describe('base-client.ts', () => {
225244
});
226245

227246
it('should not cache calls', async () => {
228-
expect.assertions(2);
247+
expect.assertions(3);
229248

230249
await client.endpointWithCache();
231250
await client.endpointWithCache();
232-
await client.endpointWithCache();
251+
const result = await client.endpointWithCache();
233252

253+
expect(result.cache).toBeUndefined();
234254
expect(fetch).toHaveBeenCalledTimes(3);
235255
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache', mockEndpoint).toString(), payload);
236256
});
237257

238258
it('should cache subsequent calls', async () => {
239-
expect.assertions(2);
259+
expect.assertions(6);
240260

241261
await client.endpointWithCache.cached();
242262
await client.endpointWithCache.cached();
243-
await client.endpointWithCache.cached();
263+
const result = await client.endpointWithCache.cached();
244264

265+
expect(result.cache).toBeDefined();
266+
expect(result.cache?.isCache).toBeTruthy();
267+
expect(result.cache?.previous).toBeDefined();
268+
expect(result.cache?.current).toBeDefined();
245269
expect(fetch).toHaveBeenCalledTimes(1);
246270
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache', mockEndpoint).toString(), payload);
247271
});
248272

249273
it('should ignore cache if cache cleared', async () => {
250-
expect.assertions(2);
274+
expect.assertions(4);
251275

252276
await client.endpointWithCache.cached();
253277
await client.endpointWithCache.cached();
254278
await client.clearCache();
255-
await client.endpointWithCache.cached();
279+
const result = await client.endpointWithCache.cached();
256280

281+
expect(result.cache?.previous).toBeUndefined();
282+
expect(result.cache?.current).toBeDefined();
257283
expect(fetch).toHaveBeenCalledTimes(2);
258284
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache', mockEndpoint).toString(), payload);
259285
});
260286

261-
it('should clear cache after error', async () => {
262-
expect.assertions(3);
287+
it('should not clear cache after error', async () => {
288+
expect.assertions(4);
289+
await client.endpointWithCache.cached();
263290

264291
const error = new Error('Error');
265292
fetch.mockRejectedValueOnce(error);
266293

267294
let err: unknown;
268295
try {
269-
await client.endpointWithCache.cached();
296+
await client.endpointWithCache.cached(undefined, undefined, { force: true });
270297
} catch (e) {
271298
err = e;
272299
} finally {
273300
expect(err).toBe(error);
274301
}
275302
await client.endpointWithCache.cached();
276-
await client.endpointWithCache.cached();
277303

304+
expect(spyCacheStore.delete).not.toHaveBeenCalled();
278305
expect(fetch).toHaveBeenCalledTimes(2);
279306
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache', mockEndpoint).toString(), payload);
280307
});
281308

309+
it('should clear cache after error', async () => {
310+
expect.assertions(4);
311+
await client.endpointWitEvictOnError.cached();
312+
313+
const error = new Error('Error');
314+
fetch.mockRejectedValueOnce(error);
315+
316+
let err: unknown;
317+
try {
318+
await client.endpointWitEvictOnError.cached(undefined, undefined, { force: true });
319+
} catch (e) {
320+
err = e;
321+
} finally {
322+
expect(err).toBe(error);
323+
}
324+
await client.endpointWitEvictOnError.cached();
325+
326+
expect(spyCacheStore.delete).toHaveBeenCalledTimes(1);
327+
expect(fetch).toHaveBeenCalledTimes(3);
328+
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache-object', mockEndpoint).toString(), payload);
329+
});
330+
282331
it('should ignore cache if cache expired using endpoint retention', async () => {
283332
expect.assertions(2);
284333

@@ -296,6 +345,23 @@ describe('base-client.ts', () => {
296345
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache-retention', mockEndpoint).toString(), payload);
297346
});
298347

348+
it('should ignore cache if cache expired using endpoint retention with object', async () => {
349+
expect.assertions(2);
350+
351+
await client.endpointWitCacheRetention.cached();
352+
await client.endpointWitCacheRetention.cached();
353+
354+
// Wait for cache to expire
355+
await new Promise(resolve => {
356+
setTimeout(resolve, 20);
357+
});
358+
359+
await client.endpointWitCacheRetention.cached();
360+
361+
expect(fetch).toHaveBeenCalledTimes(2);
362+
expect(fetch).toHaveBeenCalledWith(new URL('/endpoint-with-cache-retention', mockEndpoint).toString(), payload);
363+
});
364+
299365
it('should ignore cache if cache expired using param force', async () => {
300366
expect.assertions(2);
301367

src/services/common/base-client.ts

+50-22
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CacheStore } from '~/utils/cache.utils';
1+
import type { CacheStore, CacheStoreEntity } from '~/utils/cache.utils';
22

33
import type { RecursiveRecord } from '~/utils/typescript.utils';
44

@@ -56,7 +56,7 @@ export type BaseTemplateOptions<
5656
* Enables caching of requests (defaults to true).
5757
* If a number is provided, it will be used as the retention time in milliseconds.
5858
*/
59-
cache?: boolean | number;
59+
cache?: boolean | number | { retention?: number; evictOnError?: boolean };
6060
/** Boolean record or required (truthy) or optional parameters (falsy) */
6161
parameters?: {
6262
/** Boolean record or required (truthy) or optional path parameters (falsy) */
@@ -87,7 +87,14 @@ export type BaseTemplate<P extends RecursiveRecord = RecursiveRecord, O extends
8787
transform?: (param: P) => P;
8888
};
8989

90-
export type TypedResponse<T> = Omit<Response, 'json'> & { json(): Promise<T> };
90+
export type TypedResponse<T> = Omit<Response, 'json'> & {
91+
json(): Promise<T>;
92+
cache?: {
93+
previous?: CacheStoreEntity<TypedResponse<T>>;
94+
current?: CacheStoreEntity<TypedResponse<T>>;
95+
isCache?: boolean;
96+
};
97+
};
9198

9299
export type ResponseOrTypedResponse<T = unknown> = T extends never ? Response : TypedResponse<T>;
93100

@@ -99,7 +106,7 @@ type ClientEndpointCall<Parameter extends Record<string, never> = Record<string,
99106
export interface ClientEndpoint<Parameter extends RecursiveRecord = Record<string, never>, Response = unknown> {
100107
(param?: Parameter, init?: BaseInit): Promise<ResponseOrTypedResponse<Response>>;
101108
}
102-
type BaseCacheOption = { force?: boolean; retention?: number };
109+
type BaseCacheOption = { force?: boolean; retention?: number; evictOnError?: boolean };
103110

104111
type ClientEndpointCache<Parameter extends RecursiveRecord = Record<string, never>, Response = unknown> = (
105112
param?: Parameter,
@@ -125,6 +132,16 @@ export class ClientEndpoint<
125132
cached: Cache extends true ? Omit<this, 'cached'> & ClientEndpointCache<Parameter, Response> : never;
126133
resolve: (param?: Parameter) => URL;
127134

135+
get config() {
136+
return {
137+
method: this.method,
138+
url: this.url,
139+
opts: this.opts,
140+
init: this.init,
141+
body: this.body,
142+
};
143+
}
144+
128145
constructor(template: BaseTemplate<Parameter, Option>) {
129146
this.method = template.method;
130147
this.url = template.url;
@@ -155,12 +172,14 @@ const isApiTemplate = <T extends RecursiveRecord = RecursiveRecord>(template: Cl
155172
/**
156173
* Clones a response object
157174
* @param response - The response to clone
175+
* @param cache - Optional cache data to attach to the clone
158176
*/
159-
const cloneResponse = <T>(response: TypedResponse<T>): TypedResponse<T> => {
160-
const clone: Record<keyof TypedResponse<T>, unknown> = response.clone();
177+
const cloneResponse = <T>(response: TypedResponse<T>, cache?: TypedResponse<T>['cache']): TypedResponse<T> => {
178+
const clone: { -readonly [K in keyof TypedResponse<T>]: unknown } = response.clone();
161179
Object.entries(response).forEach(([key, value]) => {
162180
if (typeof value !== 'function') clone[key as keyof TypedResponse<T>] = value;
163181
});
182+
clone.cache = cache;
164183
return clone as TypedResponse<T>;
165184
};
166185

@@ -262,30 +281,39 @@ export abstract class BaseClient<
262281
protected bindToEndpoint(api: IApi) {
263282
const client = { ...api };
264283
Object.entries(client).forEach(([endpoint, template]) => {
265-
if (isApiTemplate(template) && isApiTemplate(client[endpoint])) {
284+
if (isApiTemplate(template)) {
266285
const fn: ClientEndpointCall = (param, init) => this._call(template, param, init);
267286

268287
const cachedFn: ClientEndpointCache = async (param, init, cacheOptions) => {
269-
const key = JSON.stringify({ endpoint, param, init });
270-
if (!cacheOptions?.force) {
271-
const cached = await this._cache.get(key);
272-
if (cached) {
273-
const templateRetention = typeof template.opts?.cache === 'number' ? template.opts.cache : undefined;
274-
const retention = cacheOptions?.retention ?? templateRetention ?? this._cache.retention;
275-
if (!retention) return cloneResponse(cached.value);
276-
const expires = cached.cachedAt + retention;
277-
if (expires > Date.now()) return cloneResponse(cached.value);
278-
}
288+
const key = JSON.stringify({ template: template.config, param, init });
289+
290+
const cached = await this._cache.get(key);
291+
if (cached && !cacheOptions?.force) {
292+
let templateRetention = typeof template.opts?.cache === 'number' ? template.opts.cache : undefined;
293+
if (typeof template.opts?.cache === 'object') templateRetention = template.opts.cache.retention;
294+
const retention = cacheOptions?.retention ?? templateRetention ?? this._cache.retention;
295+
if (!retention) return cloneResponse(cached.value, { previous: cached, current: cached, isCache: true });
296+
const expires = cached.cachedAt + retention;
297+
if (expires > Date.now()) return cloneResponse(cached.value, { previous: cached, current: cached, isCache: true });
279298
}
299+
280300
try {
281301
const result = await fn(param, init);
282-
await this._cache.set(key, {
302+
const cacheEntry = {
283303
cachedAt: Date.now(),
284304
value: cloneResponse(result) as ResponseType,
285-
});
305+
};
306+
await this._cache.set(key, cacheEntry);
307+
result.cache = { previous: cached, current: cacheEntry, isCache: false };
286308
return result;
287309
} catch (error) {
288-
this._cache.delete(key);
310+
if (
311+
cacheOptions?.evictOnError ??
312+
(typeof template.opts?.cache === 'object' ? template.opts?.cache?.evictOnError : undefined) ??
313+
this._cache.evictOnError
314+
) {
315+
this._cache.delete(key);
316+
}
289317
throw error;
290318
}
291319
};
@@ -296,7 +324,7 @@ export abstract class BaseClient<
296324
return this._parseUrl(template, _params);
297325
};
298326

299-
Object.entries(client[endpoint]).forEach(([key, value]) => {
327+
Object.entries(template).forEach(([key, value]) => {
300328
if (key === 'cached') {
301329
if (template.opts?.cache) Object.defineProperty(fn, 'cached', { value: cachedFn });
302330
} else if (key === 'resolve') {
@@ -310,7 +338,7 @@ export abstract class BaseClient<
310338

311339
client[endpoint] = fn as (typeof client)[typeof endpoint];
312340
} else {
313-
client[endpoint] = this.bindToEndpoint(client[endpoint] as IApi);
341+
client[endpoint] = this.bindToEndpoint(template as IApi);
314342
}
315343
});
316344
return client;

src/utils/cache.utils.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type CacheStore<T = unknown> = {
1616
clear(): void;
1717
/** the duration in milliseconds after which the cache will be cleared */
1818
retention?: number;
19+
/** if true, the cache will be deleted if an error occurs */
20+
evictOnError?: boolean;
1921
};
2022

2123
type FlatResponse<T extends Response = ResponseOrTypedResponse> = Record<keyof T, unknown>;

0 commit comments

Comments
 (0)