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
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