diff --git a/.eslintrc.js b/.eslintrc.js index c2c970e27d2e4bc56f229f95dae3bebd465ed1ec..8dfbd0dfde506cad674d9d7957e38ecab7f34bd2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -123,6 +123,8 @@ module.exports = { '@typescript-eslint/unbound-method': 0, 'jest/valid-title': [0, { ignoreTypeOfDescribeName: true }], + 'max-classes-per-file': 0, + 'class-methods-use-this': 0, }, }, { diff --git a/lib/util/cache/package/decorator.spec.ts b/lib/util/cache/package/decorator.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4ca78a928ee1414ead88cb8d3ab697d8a76ef2d --- /dev/null +++ b/lib/util/cache/package/decorator.spec.ts @@ -0,0 +1,59 @@ +import os from 'os'; +import { mock } from 'jest-mock-extended'; +import { getName } from '../../../../test/util'; +import type { GetReleasesConfig } from '../../../datasource'; +import * as memCache from '../memory'; +import { cache } from './decorator'; +import * as packageCache from '.'; + +jest.mock('./file'); + +describe(getName(), () => { + const spy = jest.fn(() => Promise.resolve()); + + beforeAll(() => { + memCache.init(); + packageCache.init({ cacheDir: os.tmpdir() }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should cache string', async () => { + class MyClass { + @cache({ namespace: 'namespace', key: 'key' }) + public async getNumber(): Promise<number> { + await spy(); + return Math.random(); + } + } + const myClass = new MyClass(); + expect(await myClass.getNumber()).toEqual(await myClass.getNumber()); + expect(await myClass.getNumber()).not.toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should cache function', async () => { + class MyClass { + @cache({ + namespace: (arg: GetReleasesConfig) => arg.registryUrl, + key: () => 'key', + }) + public async getNumber(_: GetReleasesConfig): Promise<number> { + await spy(); + return Math.random(); + } + } + const myClass = new MyClass(); + const getReleasesConfig: GetReleasesConfig = { + registryUrl: 'registry', + ...mock<GetReleasesConfig>(), + }; + expect(await myClass.getNumber(getReleasesConfig)).toEqual( + await myClass.getNumber(getReleasesConfig) + ); + expect(await myClass.getNumber(getReleasesConfig)).not.toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/util/cache/package/decorator.ts b/lib/util/cache/package/decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..60fc2590c15ab068cece1fddbaa8cf916a2aeb1f --- /dev/null +++ b/lib/util/cache/package/decorator.ts @@ -0,0 +1,126 @@ +import is from '@sindresorhus/is'; +import { logger } from '../../../logger'; +import * as packageCache from '.'; + +type Handler<T> = (parameters: DecoratorParameters<T>) => Promise<unknown>; +type Method<T> = (this: T, ...args: any[]) => Promise<any>; +type Decorator<T> = <U extends T>( + target: U, + key: keyof U, + descriptor: TypedPropertyDescriptor<Method<T>> +) => TypedPropertyDescriptor<Method<T>>; + +interface DecoratorParameters<T, U extends any[] = any[]> { + /** + * Current call arguments. + */ + args: U; + + /** + * A callback to call the decorated method with the current arguments. + */ + callback(): unknown; + + /** + * Current call context. + */ + instance: T; +} + +/** + * Applies decorating function to intercept decorated method calls. + * @param fn - The decorating function. + */ +function decorate<T>(fn: Handler<T>): Decorator<T> { + const result: Decorator<T> = ( + target, + key, + descriptor = Object.getOwnPropertyDescriptor(target, key) ?? { + enumerable: true, + configurable: true, + writable: true, + } + ) => { + const { value } = descriptor; + + return Object.assign(descriptor, { + value(this: T, ...args: any[]) { + return fn({ + args, + instance: this, + callback: () => value?.apply(this, args), + }); + }, + }); + }; + + return result; +} + +type HashFunction<T extends any[] = any[]> = (...args: T) => string; + +/** + * The cache decorator parameters. + */ +interface CacheParameters { + /** + * The cache namespace + * Either a string or a hash function that generates a string + */ + namespace: string | HashFunction; + + /** + * The cache key + * Either a string or a hash function that generates a string + */ + key: string | HashFunction; + + /** + * The TTL (or expiry) of the key in minutes + */ + ttlMinutes?: number; +} + +/** + * caches the result of a decorated method. + */ +export function cache<T>({ + namespace, + key, + ttlMinutes = 30, +}: CacheParameters): Decorator<T> { + return decorate(async ({ args, instance, callback }) => { + try { + let finalNamespace: string; + if (is.string(namespace)) { + finalNamespace = namespace; + } else if (is.function_(namespace)) { + finalNamespace = namespace.apply(instance, args); + } + + let finalKey: string; + if (is.string(key)) { + finalKey = key; + } else if (is.function_(key)) { + finalKey = key.apply(instance, args); + } + + const cachedResult = await packageCache.get<unknown>( + finalNamespace, + finalKey + ); + + if (cachedResult !== undefined) { + return cachedResult; + } + + const result = await callback(); + + await packageCache.set(finalNamespace, finalKey, result, ttlMinutes); + return result; + } catch (err) /* istanbul ignore next */ { + logger.error(err); + throw err; + } + }); +} diff --git a/tsconfig.json b/tsconfig.json index 0099ae0294bbe26c9fcc0d4bba5e69bbd1bfb070..f3cee72242a32ac3c27b08460c8d6e7e7a0641b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "resolveJsonModule": false, "noUnusedLocals": true, + "experimentalDecorators": true, "lib": ["es2018"], "types": ["node", "jest", "jest-extended"], "allowJs": true,