Skip to content

Commit

Permalink
cache-manager:wrap() method - support options parameter and raw retur…
Browse files Browse the repository at this point in the history
…n data (#998)

* cache-manager:wrap() method - support options parameter and raw return data

* cache-manager:wrap() method - support options parameter and raw return data - fix test complaints

* cache-manager:wrap() method - support options parameter and raw return data - fix failing test

* cache-manager:wrap() method - support options parameter and raw return data - fix coverage

---------

Co-authored-by: Jared Wray <me@jaredwray.com>
  • Loading branch information
vz-tl and jaredwray authored Jan 30, 2025
1 parent b4eb3e3 commit 18ae742
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 19 deletions.
10 changes: 10 additions & 0 deletions packages/cache-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,16 @@ See unit tests in [`test/clear.test.ts`](./test/clear.test.ts) for more informat
## wrap
`wrap(key, fn: async () => value, [ttl], [refreshThreshold]): Promise<value>`

Alternatively, with optional parameters as options object supporting a `raw` parameter:

`wrap(key, fn: async () => value, { ttl?: number, refreshThreshold?: number, raw?: true }): Promise<value>`

Wraps a function in cache. The first time the function is run, its results are stored in cache so subsequent calls retrieve from cache instead of calling the function.

If `refreshThreshold` is set and the remaining TTL is less than `refreshThreshold`, the system will update the value asynchronously. In the meantime, the system will return the old value until expiration. You can also provide a function that will return the refreshThreshold based on the value `(value:T) => number`.

If the object format for the optional parameters is used, an additional `raw` parameter can be applied, changing the function return type to raw data including expiration timestamp as `{ value: [data], expires: [timestamp] }`.

```typescript
await cache.wrap('key', () => 1, 5000, 3000)
// call function then save the result to cache
Expand All @@ -335,6 +341,10 @@ await cache.wrap('key', () => 2, 5000, 3000)
// return data from cache, function will not be called again
// => 1

await cache.wrap('key', () => 2, { ttl: 5000, refreshThreshold: 3000, raw: true })
// returns raw data including expiration timestamp
// => { value: 1, expires: [timestamp] }

// wait 3 seconds
await sleep(3000)

Expand Down
50 changes: 37 additions & 13 deletions packages/cache-manager/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/prefer-promise-reject-errors, unicorn/no-useless-promise-resolve-reject, no-await-in-loop, unicorn/prefer-event-target */
import EventEmitter from 'node:events';
import {Keyv} from 'keyv';
import {Keyv, type StoredDataRaw} from 'keyv';
import {coalesceAsync} from './coalesce-async.js';
import {isObject} from './is-object.js';
import {runIfFn} from './run-if-fn.js';
import {lt} from './lt.js';

Expand All @@ -14,6 +15,15 @@ export type CreateCacheOptions = {
cacheId?: string;
};

type WrapOptions<T> = {
ttl?: number | ((value: T) => number);
refreshThreshold?: number | ((value: T) => number);
};

type WrapOptionsRaw<T> = WrapOptions<T> & {
raw: true;
};

export type Cache = {
// eslint-disable-next-line @typescript-eslint/ban-types
get: <T>(key: string) => Promise<T | null>;
Expand All @@ -37,12 +47,6 @@ export type Cache = {
del: (key: string) => Promise<boolean>;
mdel: (keys: string[]) => Promise<boolean>;
clear: () => Promise<boolean>;
wrap: <T>(
key: string,
fnc: () => T | Promise<T>,
ttl?: number | ((value: T) => number),
refreshThreshold?: number | ((value: T) => number)
) => Promise<T>;
on: <E extends keyof Events>(
event: E,
listener: Events[E]
Expand All @@ -54,6 +58,22 @@ export type Cache = {
disconnect: () => Promise<undefined>;
cacheId: () => string;
stores: Keyv[];
wrap<T>(
key: string,
fnc: () => T | Promise<T>,
ttl?: number | ((value: T) => number),
refreshThreshold?: number | ((value: T) => number)
): Promise<T>;
wrap<T>(
key: string,
fnc: () => T | Promise<T>,
options: WrapOptions<T>
): Promise<T>;
wrap<T>(
key: string,
fnc: () => T | Promise<T>,
options: WrapOptionsRaw<T>
): Promise<StoredDataRaw<T>>;
};

export type Events = {
Expand Down Expand Up @@ -252,19 +272,22 @@ export const createCache = (options?: CreateCacheOptions): Cache => {
const wrap = async <T>(
key: string,
fnc: () => T | Promise<T>,
ttl?: number | ((value: T) => number),
refreshThreshold?: number | ((value: T) => number),
): Promise<T> => coalesceAsync(`${_cacheId}::${key}`, async () => {
ttlOrOptions?: number | ((value: T) => number) | Partial<WrapOptionsRaw<T>>,
refreshThresholdParameter?: number | ((value: T) => number),
): Promise<T | StoredDataRaw<T>> => coalesceAsync(`${_cacheId}::${key}`, async () => {
let value: T | undefined;
let rawData: StoredDataRaw<T> | undefined;
let i = 0;
let remainingTtl: number | undefined;
const {ttl, refreshThreshold, raw} = isObject(ttlOrOptions) ? ttlOrOptions : {ttl: ttlOrOptions, refreshThreshold: refreshThresholdParameter};
const resolveTtl = (result: T) => runIfFn(ttl, result) ?? options?.ttl;

for (; i < stores.length; i++) {
try {
const data = await stores[i].get<T>(key, {raw: true});
if (data !== undefined) {
value = data.value;
rawData = data;
if (typeof data.expires === 'number') {
remainingTtl = Math.max(0, data.expires - Date.now());
}
Expand All @@ -278,8 +301,9 @@ export const createCache = (options?: CreateCacheOptions): Cache => {

if (value === undefined) {
const result = await fnc();
await set(stores, key, result, resolveTtl(result));
return result;
const ttl = resolveTtl(result)!;
await set(stores, key, result, ttl);
return raw ? {value: result, expires: Date.now() + ttl} : result;
}

const shouldRefresh = lt(remainingTtl, runIfFn(refreshThreshold, value) ?? options?.refreshThreshold);
Expand All @@ -304,7 +328,7 @@ export const createCache = (options?: CreateCacheOptions): Cache => {
await set(stores.slice(0, i), key, value, resolveTtl(value));
}

return value;
return raw ? rawData : value;
});

const on = <E extends keyof Events>(event: E, listener: Events[E]) => eventEmitter.addListener(event, listener);
Expand Down
3 changes: 3 additions & 0 deletions packages/cache-manager/src/is-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isObject<T = Record<string, unknown>>(value: unknown): value is T {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
36 changes: 30 additions & 6 deletions packages/cache-manager/test/wrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ describe('wrap', () => {
expect(getTtlFunction).toHaveBeenCalledTimes(1);
});

it('ttl - options', async () => {
await cache.wrap(data.key, async () => data.value, {ttl});
await expect(cache.get(data.key)).resolves.toEqual(data.value);
await sleep(ttl + 100);
await expect(cache.get(data.key)).resolves.toBeNull();
});

it('returns single value or raw storage-data', async () => {
// Run pristine and expect single value
await expect(cache.wrap(data.key, () => data.value, ttl))
.resolves.toEqual(data.value);
// Expect cached response with raw data
await expect(cache.wrap(data.key, () => data.value, {ttl, raw: true}))
.resolves.toEqual({value: data.value, expires: expect.any(Number)});

// Run pristine with new key and expect raw data
await expect(cache.wrap(data.key + 'i', () => data.value, {ttl, raw: true}))
.resolves.toEqual({value: data.value, expires: expect.any(Number)});
// Expect cached response with single value
await expect(cache.wrap(data.key + 'i', () => data.value, ttl))
.resolves.toEqual(data.value);
});

it('calls fn once to fetch value on cache miss when invoked multiple times', async () => {
const getValue = vi.fn().mockResolvedValue(data.value);

Expand All @@ -65,16 +88,17 @@ describe('wrap', () => {
}
});

it('should allow dynamic refreshThreshold on wrap function', async () => {
const config = {ttl: 2000, refreshThreshold: 1000};

it.each([
[2000, 1000],
[{ttl: 2000, refreshThreshold: 1000}, undefined],
])('should allow dynamic refreshThreshold on wrap function with ttl/options param as %s', async (ttlOrOptions, refreshThreshold) => {
// 1st call should be cached
expect(await cache.wrap(data.key, async () => 0, config.ttl, config.refreshThreshold)).toEqual(0);
expect(await cache.wrap(data.key, async () => 0, ttlOrOptions as never, refreshThreshold)).toEqual(0);
await sleep(1001);
// Background refresh, but stale value returned
expect(await cache.wrap(data.key, async () => 1, config.ttl, config.refreshThreshold)).toEqual(0);
expect(await cache.wrap(data.key, async () => 1, ttlOrOptions as never, refreshThreshold)).toEqual(0);
// New value in cache
expect(await cache.wrap(data.key, async () => 2, config.ttl, config.refreshThreshold)).toEqual(1);
expect(await cache.wrap(data.key, async () => 2, ttlOrOptions as never, refreshThreshold)).toEqual(1);

await sleep(1001);
// No background refresh with the new override params
Expand Down

0 comments on commit 18ae742

Please sign in to comment.