diff --git a/.eslintrc.json b/.eslintrc.json index 99550d0..a483f44 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -88,8 +88,7 @@ "string", "Boolean", "boolean", - "Undefined", - "undefined" + "Undefined" ], "id-match": "error", "max-classes-per-file": "off", diff --git a/README.md b/README.md index ebcf31f..53ff791 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ class MyCoolService extends IMQService { } ~~~ -With the setup, descibed above first call to `MyCoolService.listEntities()` will -save method return value to cache and each next call will return cached value, -stored in redis instead of making database query. +With the setup, described above first call to `MyCoolService.listEntities()` +will save method return value to cache and each next call will return cached +value, stored in redis instead of making database query. Each function call is signed by a call signature, so if your method accepts arguments which influence database query execution caching is made for each unique method call. Cached result will be automatically invalidated each time @@ -41,6 +41,74 @@ different TTL to be used, do it as: In this example TTL is set to 5 minutes. TTL should be provided in milliseconds. +### Using @cacheBy + +Since v2.0.0 @cacheBy decorator introduced and helps simplify caching +invalidation definition for those service methods which rely on a root sequelize +model output. In combination of passing the fields map to such service methods +it will also provide more efficient caching for the method calls. + +Take a look at such example: + +~~~typescript +import { IMQService, expose } from '@imqueue/rpc'; +import { PgCache, cacheBy } from '@imqueue/pg-cache'; +import { + FieldsInput, + FindOptions, + OrderByInput, + PaginationInput, + query, +} from '@imqueue/sequelize'; +import { cacheConfig } from '../config'; +import { MyDbEntity } from './orm/models'; +import { MyDbEntityFilter } from './types'; +import withRangeFilters = query.withRangeFilters; +import autoCountQuery = query.autoCountQuery; +import autoQuery = query.autoQuery; +import toWhereOptions = query.toWhereOptions; +import toLimitOptions = query.toLimitOptions; +import toOrderOptions = query.toOrderOptions; + +@PgCache(cacheConfig) +class MyCoolService extends IMQService { + @expose() + @cacheBy(MyDbEntity, { fieldsArg: 1 }) + public async listEntities( + filter?: MyDbEntityFilter, + fields?: FieldsInput, + pageOptions?: PaginationInput, + orderBy?: OrderByInput, + ): Promise<{ total: number, data: MyDbEntity[] }> { + const where = toWhereOptions( + withRangeFilters(filter), + MyDbEntityFilter, + ); + const countQuery = autoCountQuery(MyDbEntity, fields, where); + const findQuery = autoQuery( + MyDbEntity, fields, where, + toLimitOptions(pageOptions), + toOrderOptions(orderBy), + ); + const [total, data] = await Promise.all([ + MyDbEntity.count(countQuery), + MyDbEntity.findAll(findQuery), + ]); + + return { total, data } as { total: number, data: MyDbEntity[] }; + } +} +~~~ + +By doing so, `@cacheBy` will take care of invalidating only those queries +which has selection of fields matching db changes events, so if the actual +query was not selecting some joined data, but there was a change only on that +joined data - cache won't invalidate, as far as real selection was not +influenced by a data change. And yes, it will automatically monitor +all related to the root model entities for changes and match them with runtime +selections. This makes caching more efficient and more obvious for +developer. + ### Extended channels API ~~~typescript @@ -55,8 +123,7 @@ import { cacheWith, ChannelOperation, ChannelPayload } from '@imqueue/pg-cache'; @cacheWith({ channels: { [MyDbEnity.tableName]: ( payload: ChannelPayload, // payload caught from db event - args: any[], // method runtime arguments - ) => payload.record.id === args.id, + ) => payload.operation === ChannelOperation.DELETE, }}) ~~~ diff --git a/package-lock.json b/package-lock.json index 56790db..39c3434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -166,9 +166,9 @@ "dev": true }, "@babel/helpers": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.0.tgz", - "integrity": "sha512-/9GvfYTCG1NWCNwDj9e+XlnSCmWW/r9T794Xi58vPF9WCcnZCAZ0kWLSn54oqP40SUvh1T2G6VwKmFO5AOlW3A==", + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.9.2.tgz", + "integrity": "sha512-JwLvzlXVPjO8eU9c/wF9/zOIN7X6h8DYf7mG4CiFRZRvZNKEF5dQ3H3V+ASkHoIB3mWhatgl5ONhyqHRI6MppA==", "dev": true, "requires": { "@babel/template": "^7.8.3", @@ -188,9 +188,9 @@ } }, "@babel/parser": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.0.tgz", - "integrity": "sha512-Iwyp00CZsypoNJcpXCbq3G4tcDgphtlMwMVrMhhZ//XBkqjXF7LW6V511yk0+pBX3ZwwGnPea+pTKNJiqA7pUg==", + "version": "7.9.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.2.tgz", + "integrity": "sha512-2jyvKdoOS1aWAFL2rjJZmamyDDkPCx/AAz4/Wh1Dfxvw8qqnOvek/ZlHQ2noO/o8JpnXa/WiUUFOv48meBKkpA==", "dev": true }, "@babel/template": { @@ -407,6 +407,12 @@ "@types/estree": "*" } }, + "@types/bluebird": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.30.tgz", + "integrity": "sha512-8LhzvcjIoqoi1TghEkRMkbbmM+jhHnBokPGkJWjclMK+Ks0MxEBow3/p2/iFTZ+OIbJHQDSfpgdZEb+af3gfVw==", + "dev": true + }, "@types/chai": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.11.tgz", @@ -419,6 +425,15 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/continuation-local-storage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/continuation-local-storage/-/continuation-local-storage-3.2.2.tgz", + "integrity": "sha512-aItm+aYPJ4rT1cHmAxO+OdWjSviQ9iB5UKb5f0Uvgln0N4hS2mcDodHtPiqicYBXViUYhqyBjhA5uyOcT+S34Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -436,6 +451,12 @@ "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", "dev": true }, + "@types/lodash": { + "version": "4.14.149", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", + "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", + "dev": true + }, "@types/mocha": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-7.0.2.tgz", @@ -486,12 +507,30 @@ "@types/node": "*" } }, + "@types/sequelize": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/@types/sequelize/-/sequelize-4.28.8.tgz", + "integrity": "sha512-3n/iSpOtO5NynSBgEJ+738DK/ctxkqnVMvdPLZxrbZrf/rQgNm8wgDPDSarS1rmluvOe5ZMRDF8DXhts5MIbag==", + "dev": true, + "requires": { + "@types/bluebird": "*", + "@types/continuation-local-storage": "*", + "@types/lodash": "*", + "@types/validator": "*" + } + }, "@types/sinon": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.5.2.tgz", "integrity": "sha512-T+m89VdXj/eidZyejvmoP9jivXgBDdkOSBVQjU9kF349NEx10QdPNGxHeZUaj1IlJ32/ewdyXJjnJxyxJroYwg==", "dev": true }, + "@types/validator": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-12.0.1.tgz", + "integrity": "sha512-l57fIANZLMe8DArz+SDb+7ATXnDm15P7u2wHBw5mb0aSMd+UuvmvhouBF2hdLgQPDMJ39sh9g2MJO4GkZ0VAdQ==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.24.0.tgz", @@ -635,6 +674,11 @@ "color-convert": "^1.9.0" } }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, "anymatch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", @@ -763,6 +807,11 @@ "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==", "dev": true }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -958,6 +1007,15 @@ } } }, + "cls-bluebird": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cls-bluebird/-/cls-bluebird-2.1.0.tgz", + "integrity": "sha1-N+8eCAqP+1XC9BZPU28ZGeeWiu4=", + "requires": { + "is-bluebird": "^1.0.2", + "shimmer": "^1.1.0" + } + }, "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -1155,6 +1213,11 @@ "esutils": "^2.0.2" } }, + "dottie": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.2.tgz", + "integrity": "sha512-fmrwR04lsniq/uSr8yikThDTrM7epXHBAAjH9TbeH3rEA8tdCO7mRzB9hdmdGyJCxF8KERo9CITcm3kGuoyMhg==" + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -1674,9 +1737,9 @@ } }, "glob-parent": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", - "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -1797,9 +1860,9 @@ "dev": true }, "html-escaper": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.0.tgz", - "integrity": "sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.1.tgz", + "integrity": "sha512-hNX23TjWwD3q56HpWjUHOKj1+4KKlnjv9PcmBUYKVpga+2cnb9nDx/B1o0yO4n+RZXZdiNxzx6B24C9aNMTkkQ==", "dev": true }, "http-signature": { @@ -1857,6 +1920,11 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true }, + "inflection": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", + "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1996,6 +2064,11 @@ "binary-extensions": "^2.0.0" } }, + "is-bluebird": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-bluebird/-/is-bluebird-1.0.2.tgz", + "integrity": "sha1-CWQ5Bg9KpBGr7hkUOoTWpVNG1uI=" + }, "is-buffer": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", @@ -2414,8 +2487,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash.flattendeep": { "version": "4.4.0", @@ -2474,9 +2546,9 @@ "dev": true }, "marked": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.8.1.tgz", - "integrity": "sha512-tZfJS8uE0zpo7xpTffwFwYRfW9AzNcdo04Qcjs+C9+oCy8MSRD2reD5iDVtYx8mtLaqsGughw/YLlcwNxAHA1g==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.8.0.tgz", + "integrity": "sha512-MyUe+T/Pw4TZufHkzAfDj6HarCBWia2y27/bhuYkTaiUnfDYFnCP3KUN+9oM7Wi6JA2rymtVYbQu3spE0GCmxQ==", "dev": true }, "mime-db": { @@ -2634,6 +2706,19 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, + "moment-timezone": { + "version": "0.5.28", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.28.tgz", + "integrity": "sha512-TDJkZvAyKIVWg5EtVqRzU97w0Rb0YVbfpqyjgu6GwXCAohVRqwZjf4fOzDE6p1Ch98Sro/8hQQi65WDXW5STPw==", + "requires": { + "moment": ">= 2.9.0" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -3342,9 +3427,9 @@ } }, "picomatch": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.1.tgz", - "integrity": "sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", "dev": true }, "pkg-dir": { @@ -3630,6 +3715,14 @@ "signal-exit": "^3.0.2" } }, + "retry-as-promised": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-3.2.0.tgz", + "integrity": "sha512-CybGs60B7oYU/qSQ6kuaFmRd9sTZ6oXSc0toqePvV74Ac6/IFZSI1ReFQmtCN+uvW1Mtqdwpvt/LGOiCBAY2Mg==", + "requires": { + "any-promise": "^1.3.0" + } + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -3676,6 +3769,76 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" }, + "sequelize": { + "version": "5.21.5", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-5.21.5.tgz", + "integrity": "sha512-n9hR5K4uQGmBGK/Y/iqewCeSFmKVsd0TRnh0tfoLoAkmXbKC4tpeK96RhKs7d+TTMtrJlgt2TNLVBaAxEwC4iw==", + "requires": { + "bluebird": "^3.5.0", + "cls-bluebird": "^2.1.0", + "debug": "^4.1.1", + "dottie": "^2.0.0", + "inflection": "1.12.0", + "lodash": "^4.17.15", + "moment": "^2.24.0", + "moment-timezone": "^0.5.21", + "retry-as-promised": "^3.2.0", + "semver": "^6.3.0", + "sequelize-pool": "^2.3.0", + "toposort-class": "^1.0.1", + "uuid": "^3.3.3", + "validator": "^10.11.0", + "wkx": "^0.4.8" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, + "sequelize-pool": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-2.3.0.tgz", + "integrity": "sha512-Ibz08vnXvkZ8LJTiUOxRcj1Ckdn7qafNZ2t59jYHMX1VIebTAOYefWdRYFt6z6+hy52WGthAHAoLc9hvk3onqA==" + }, + "sequelize-typescript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-1.1.0.tgz", + "integrity": "sha512-FAPEQPeAhIaFQNLAcf9Q2IWcqWhNcvn5OZZ7BzGB0CJMtImIsGg4E/EAb7huMmPaPwDArxJUWGqk1KurphTNRA==", + "requires": { + "glob": "7.1.2" + }, + "dependencies": { + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -3707,6 +3870,11 @@ "rechoir": "^0.6.2" } }, + "shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", @@ -4036,6 +4204,11 @@ "is-number": "^7.0.0" } }, + "toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha1-f/0feMi+KMO6Rc1OGj9e4ZO9mYg=" + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -4047,9 +4220,9 @@ } }, "ts-node": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.7.0.tgz", - "integrity": "sha512-s659CsHrsxaRVDEleuOkGvbsA0rWHtszUNEt1r0CgAFN5ZZTQtDzpsluS7W5pOGJIa1xZE8R/zK4dEs+ldFezg==", + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.8.1.tgz", + "integrity": "sha512-10DE9ONho06QORKAaCBpPiFCdW+tZJuY/84tyypGtl6r+/C7Asq0dhqbRZURuUlLQtZxxDvT8eoj8cGW0ha6Bg==", "dev": true, "requires": { "arg": "^4.1.0", @@ -4128,16 +4301,16 @@ } }, "typedoc": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.17.1.tgz", - "integrity": "sha512-1AckBdQNvBm0jgR7eko9t3FMPfjoxiKEpQx8ePCsyfTQDPwLVpFIFzn5pXA+smDGTWf2BT7FQrKU6BDzSdgMng==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.17.2.tgz", + "integrity": "sha512-mdOMHmTBs5+wcdzolBJKCUh0wV8avTaupkz6FssWfz3n+4McJnhX2WQhb2hwVbLllqjfaDDzQFQP7CbH/gpoqg==", "dev": true, "requires": { "fs-extra": "^8.1.0", "handlebars": "^4.7.3", "highlight.js": "^9.18.1", "lodash": "^4.17.15", - "marked": "^0.8.0", + "marked": "0.8.0", "minimatch": "^3.0.0", "progress": "^2.0.3", "shelljs": "^0.8.3", @@ -4234,6 +4407,11 @@ "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", "dev": true }, + "validator": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-10.11.0.tgz", + "integrity": "sha512-X/p3UZerAIsbBfN/IwahhYaBbY68EN/UQBWHtsbXGT5bfrH/p4NQzUCG1kF/rtKaNpnJ7jAu6NGTdSNtyNIXMw==" + }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -4268,6 +4446,14 @@ "string-width": "^1.0.2 || 2" } }, + "wkx": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.4.8.tgz", + "integrity": "sha512-ikPXMM9IR/gy/LwiOSqWlSL3X/J5uk9EO2hHNRXS41eTLXaUFEVw9fn/593jW/tE5tedNg8YjT5HkCa4FqQZyQ==", + "requires": { + "@types/node": "*" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index ef5e1c3..a1646df 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "@imqueue/pg-pubsub": "^1.3.7", "@imqueue/rpc": "^1.10.8", "@imqueue/tag-cache": "^1.1.5", - "pg": "^7.18.2" + "pg": "^7.18.2", + "sequelize": "^5.21.5", + "sequelize-typescript": "^1.1.0" }, "devDependencies": { "@types/chai": "^4.2.11", @@ -51,6 +53,7 @@ "@types/mock-require": "^2.0.0", "@types/node": "^13.9.2", "@types/pg": "^7.14.3", + "@types/sequelize": "^4.28.8", "@types/sinon": "^7.5.2", "@typescript-eslint/eslint-plugin": "^2.24.0", "@typescript-eslint/parser": "^2.24.0", @@ -68,8 +71,8 @@ "open": "^7.0.3", "sinon": "^9.0.1", "source-map-support": "^0.5.16", - "ts-node": "^8.7.0", - "typedoc": "^0.17.1", + "ts-node": "^8.8.1", + "typedoc": "^0.17.2", "typedoc-plugin-as-member-of": "^1.0.2", "typedoc-plugin-markdown": "^2.2.17", "typescript": "^3.8.3" diff --git a/src/PgCache.ts b/src/PgCache.ts index e686c00..8a3d0ce 100644 --- a/src/PgCache.ts +++ b/src/PgCache.ts @@ -23,7 +23,7 @@ import { import { TagCache } from '@imqueue/tag-cache'; import { PgPubSub } from '@imqueue/pg-pubsub'; import { Client } from 'pg'; -import { PG_CACHE_DEBUG, PG_CACHE_TRIGGER } from './env'; +import { ClassDecorator, PG_CACHE_DEBUG, PG_CACHE_TRIGGER } from './env'; export interface PgCacheOptions { /** @@ -80,16 +80,18 @@ export interface PgCacheOptions { export interface PgCacheable { taggedCache: TagCache; pubSub: PgPubSub; - pgCacheChannels: PgChannels; + pgCacheChannels: PgCacheChannels; } -export interface PgChannels { - [name: string]: [string, ChannelFilter | undefined][]; -} +export type PgCacheChannel = [ + string, // called method name + ChannelFilter | undefined, // filter used to decide of invalidation +]; -export type PgCacheDecorator = {}>( - constructor: T, -) => T & PgCacheable; +export interface PgCacheChannels { + // key is actually a table name - which is pg notify channel + [name: string]: PgCacheChannel[]; +} const RX_TRIGGER = new RegExp( 'create\\s+(or\\s+replace)?function\\s+' + @@ -133,9 +135,9 @@ async function install( try { await pg.query(triggerDefinition); } catch (err) { - if (PG_CACHE_DEBUG) { - logger.info('PgCache: create trigger function errored:', err); - } + PG_CACHE_DEBUG && logger.info( + 'PgCache: create trigger function errored:', err, + ); } await Promise.all(channels.map(async channel => { @@ -148,15 +150,13 @@ async function install( EXECUTE PROCEDURE post_change_notify_trigger()`, ); - if (PG_CACHE_DEBUG) { - logger.info(`PgCache: trigger created on ${ channel }!`); - } + PG_CACHE_DEBUG && logger.info( + `PgCache: trigger created on ${ channel }!`, + ); } catch (err) { - if (PG_CACHE_DEBUG) { - logger.info(`PgCache: create trigger on ${ channel } errored:`, - err, - ); - } + PG_CACHE_DEBUG && logger.info( + `PgCache: create trigger on ${ channel } errored:`, err, + ); } })); } @@ -176,99 +176,71 @@ export interface ChannelPayload { record: JsonObject; } -export type ChannelPayloadFilter = ( - payload: ChannelPayload, - args: any[], -) => boolean; - +export type ChannelPayloadFilter = (payload: ChannelPayload) => boolean; export type ChannelFilter = ChannelOperation[] | ChannelPayloadFilter; +export interface FilteredChannels { [channel: string]: ChannelFilter; } -export interface FilteredChannels { - [channel: string]: ChannelFilter; -} - -function invalidate( - self: any & PgCacheable, - channel: string, - className: string, - method: string, +function needInvalidate( payload: ChannelPayload, - args: any[], filter?: ChannelFilter, - publish?: boolean, -): void { - let needInvalidate = true; - +): boolean { if (Array.isArray(filter)) { - if (!~filter.indexOf(payload.operation)) { - needInvalidate = false; - } + return !~filter.indexOf(payload.operation); } else if (typeof filter === 'function') { payload.timestamp = new Date(payload.timestamp); - needInvalidate = !!filter(payload, args); + + return !!filter(payload); } - if (!needInvalidate) { + return true; +} + +function publish( + self: any & PgCacheable, + channel: string, + payload: AnyJson, + tag: string, +): void { + if (typeof self.publish !== 'function') { + PG_CACHE_DEBUG && self.logger.info( + `PgCache: publish method does not exist on ${ + self.constructor.name + }`); + return ; } - self.taggedCache.invalidate(`${ className }:${ method }`) + (self as IMQService).publish({ channel, payload, tag }) .then((result: any) => { - if (!PG_CACHE_DEBUG) { - return result; - } - - self.logger.info( - `PgCache: key '${ className }:${ method }' invalidated!`, + PG_CACHE_DEBUG && self.logger.info( + `PgCache: tag '${ tag }' published to client with:`, + channel, ); return result; }) .catch((err: any) => self.logger.warn( - `PgCache: error invalidating '${ className }:${ method }':`, - err, + `PgCache: error publishing '${ tag }':`, err, )); +} - if (typeof self.publish === 'function') { - if (!publish) { - return ; - } - - (self as IMQService) - .publish({ - channel, - payload: payload as unknown as AnyJson, - tag: `${ className }:${ method }`, - }) - .then((result: any) => { - if (!PG_CACHE_DEBUG) { - return result; - } +function invalidate(self: any & PgCacheable, tag: string): void { + self.taggedCache.invalidate(tag) + .then((result: any) => { + PG_CACHE_DEBUG && self.logger.info( + `PgCache: key '${ tag }' invalidated!`, + ); - self.logger.info( - `PgCache: tag '${ - className }:${ method - }' published to client with:`, - channel, - payload, - ); - - return result; - }) - .catch((err: any) => self.logger.warn( - `PgCache: error publishing '${ - className }:${ method }':`, - err, - )); - } else if (PG_CACHE_DEBUG) { - self.logger.info(`PgCache: publish method does not exist on ${ - self.constructor.name - }`); - } + return result; + }) + .catch((err: any) => self.logger.warn( + `PgCache: error invalidating '${ tag }':`, + err, + )); } // noinspection JSUnusedGlobalSymbols -export function PgCache(options: PgCacheOptions): PgCacheDecorator { +export function PgCache(options: PgCacheOptions): ClassDecorator { return {}>( constructor: T, ): T & PgCacheable => { @@ -276,45 +248,38 @@ export function PgCache(options: PgCacheOptions): PgCacheDecorator { class CachedService { private taggedCache: TagCache; - private pgCacheChannels: PgChannels; + private pgCacheChannels: PgCacheChannels; private pubSub: PgPubSub = new PgPubSub({ connectionString: options.postgres, } as any); - // noinspection JSUnusedGlobalSymbols public async start(...args: any[]): Promise { if (init && typeof init === 'function') { await init.apply(this, args); } + const logger = ((this as any).logger || console); + const prefix = options.prefix || constructor.name; let cache: RedisCache; if (options.redisCache) { cache = options.redisCache; } else if (options.redis) { - cache = new RedisCache(); - - // noinspection TypeScriptUnresolvedFunction - await cache.init({ - ...options.redis, - prefix: options.prefix || constructor.name, - logger: ((this as any).logger || console), - } as any); + cache = await new RedisCache() + .init({ ...options.redis, prefix, logger }); } else if ((this as any).cache) { cache = (this as any).cache; } else { throw new TypeError( - 'Either one of redisCache or redisConnectionString ' + - 'option must be provided!', + 'PgCache: either one of redisCache or ' + + 'redisConnectionString option must be provided!', ); } - // noinspection TypeScriptUnresolvedVariable this.taggedCache = new TagCache(cache); const channels = Object.keys(this.pgCacheChannels); const className = constructor.name; - const logger = ((this as any).logger || console); const maxListeners = channels.length * 2; this.pubSub.channels.setMaxListeners(maxListeners); @@ -323,26 +288,23 @@ export function PgCache(options: PgCacheOptions): PgCacheDecorator { for (const channel of channels) { this.pubSub.channels.on(channel, payload => { - if (PG_CACHE_DEBUG) { - logger.info( - 'PgCache: database event caught:', - channel, payload, - ); - } + PG_CACHE_DEBUG && logger.info( + 'PgCache: database event caught:', + channel, payload, + ); const methods = this.pgCacheChannels[channel] || []; + const data = payload as unknown as ChannelPayload; for (const [method, filter] of methods) { - invalidate( - this, - channel, - className, - method, - payload as unknown as ChannelPayload, - args, - filter, - options.publish !== false, - ); + const useTag = `${ className }:${ method }`; + + if (needInvalidate(data, filter)) { + invalidate(this, useTag); + options.publish !== false && publish( + this, channel, payload, useTag, + ); + } } }); } @@ -355,21 +317,17 @@ export function PgCache(options: PgCacheOptions): PgCacheDecorator { logger, ); - if (PG_CACHE_DEBUG) { - logger.info(`PgCache: triggers installed for ${ - this.constructor.name - }`); - } + PG_CACHE_DEBUG && logger.info( + `PgCache: triggers installed for ${ className }`, + ); await Promise.all(channels.map(async channel => await this.pubSub.listen(channel)), ); - if (PG_CACHE_DEBUG) { - logger.info(`PgCache: listening channels ${ - channels.join(', ') - } on ${ this.constructor.name }`); - } + PG_CACHE_DEBUG && logger.info( + `PgCache: listening channels ${ + channels.join(', ') } on ${ className }`); }); await this.pubSub.connect(); diff --git a/src/cacheBy.ts b/src/cacheBy.ts new file mode 100644 index 0000000..beb35f6 --- /dev/null +++ b/src/cacheBy.ts @@ -0,0 +1,184 @@ +/*! + * Copyright (c) 2018, imqueue.com + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + */ +import { PgCacheable } from './PgCache'; +import { Model } from 'sequelize-typescript'; +import { + MethodDecorator, + DEFAULT_CACHE_TTL, + fetchError, + initError, + setError, + setInfo, +} from './env'; +import { signature } from '@imqueue/rpc'; +import { TagCache } from '@imqueue/tag-cache'; + +/** + * Options expected by @cacheBy() decorator factory + */ +export interface CacheByOptions { + /** + * Time to live for cached values. If not specified - default is used. + * Default is equivalent of 24 hours. Must be specified in milliseconds. + * + * @type {number | undefined} + */ + ttl?: number; + + /** + * Zero-index based position of fields argument in a method arguments, + * which are passed at runtime. Fields argument are usually passed from + * a client to specify a query map to be extracted and returned from + * a service method. For example, fields map can be built from an + * incoming GraphQL request using fieldsMap() function from + * graphql-fields-list package. + * + * Usually pg-based @imqueue services, which utilize @imqueue/sequelize + * package passing fields as a second argument to service methods, + * so if this option is omitted, it will try to check for the second + * passed argument. If you need to explicitly disable it, pass -1. + * + * @see https://github.com/Mikhus/graphql-fields-list + * + * @type {number} + */ + fieldsArg?: number; +} + +/** + * Retrieves table names as channels from the given model and filter them by + * a given fields map, if passed. Returns result as list of table names. + * + * @access private + * @param {typeof Model} model + * @param {any} [fields] + * @param {string[]} [tables] + * @return {string[]} + */ +export function channelsOf( + model: typeof Model, + fields?: any, + tables: string[] = [] +): string[] { + const modelRels = model.associations; + const relsMap = fields ? fields : model.associations; + const rels = Object.keys(relsMap); + const table = model.tableName; + + tables.push(table); + + for (const field of rels) { + if (!modelRels[field]) { + continue ; + } + + const relation = modelRels[field] as any; + const { target, options } = relation; + const through = options && options.through && options.through.model; + const subFields = (fields || {})[field]; + + if (through && !~tables.indexOf(through.tableName)) { + channelsOf(through, subFields, tables); + } + + if (target && !~tables.indexOf(target.tableName)) { + channelsOf(target, subFields, tables); + } + } + + return tables; +} + +/** + * Decorator factory @cacheBy(Model, CacheByOptions) + * This decorator should be used on a service methods, to set the caching + * rules for a method. Caching rules within this decorator are defined by a + * passed model, which is treated as a root model of the call and it analyzes + * cache invalidation based on passed runtime fields arguments, which + * prevents unnecessary cache invalidations. So it is more intellectual way + * to invalidate cache instead of any changes on described list of tables. + */ +export function cacheBy( + model: typeof Model, + options?: CacheByOptions, +): MethodDecorator { + const opts = options || {} as CacheByOptions; + + return ( + target: any & PgCacheable, + methodName: string | symbol, + descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, + ): void => { + const original: Function = descriptor.value as any; + const className = typeof target === 'function' + ? target.name + : target.constructor.name; + const ttl = opts.ttl || DEFAULT_CACHE_TTL; + const channels: string[] = channelsOf(model); + + target.pgCacheChannels = target.pgCacheChannels || {}; + + for (const channel of channels) { + const pgChannel = target.pgCacheChannels[channel] = + target.pgCacheChannels[channel] || []; + + pgChannel.push([methodName]); + } + + descriptor.value = async function(...args: any[]): Promise { + const self = this || target; + const cache: TagCache = self.taggedCache; + const logger = (self.logger || console); + + if (!cache) { + initError(logger, className, String(methodName), cacheBy); + + return original.apply(self, args); + } + + const fields = args[opts.fieldsArg as number]; + const key = signature(className, methodName, args); + + try { + let result: any = await cache.get(key); + + if (result === null || result === undefined) { + result = original.apply(self, args); + + if (result && result.then) { + result = await result; + } + + const tags = channelsOf(model, fields).map(table => + signature(className, methodName, [table]), + ); + + cache.set(key, result, tags, ttl) + .then(res => setInfo(logger, res, key, cacheBy)) + .catch(err => setError(logger, err, key, cacheBy)); + } + + return result; + } + + catch (err) { + fetchError(logger, err, key, cacheBy); + + return original.apply(self, args); + } + }; + }; +} diff --git a/src/cacheWith.ts b/src/cacheWith.ts index 0857ad6..c87c572 100644 --- a/src/cacheWith.ts +++ b/src/cacheWith.ts @@ -15,16 +15,15 @@ */ import { signature } from '@imqueue/rpc'; import { TagCache } from '@imqueue/tag-cache'; -import { FilteredChannels, PgCacheable } from './PgCache'; -import { PG_CACHE_DEBUG } from './env'; - -const defaultTtl = 86400000; // 24 hrs in milliseconds - -export type CachedWithDecorator = ( - target: any, - methodName: string | symbol, - descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, -) => void; +import { FilteredChannels, PgCacheable, PgCacheChannel } from './PgCache'; +import { + MethodDecorator, + DEFAULT_CACHE_TTL, + fetchError, + initError, + setError, + setInfo, +} from './env'; export interface CacheWithOptions { /** @@ -41,65 +40,84 @@ export interface CacheWithOptions { * @type {string[] | FilteredChannels} */ channels: string[] | FilteredChannels; + + /** + * Tag to use for this cache when set a value + * + * @type {string} + */ + tag?: string; } -// noinspection JSUnusedGlobalSymbols -export function cacheWith(options: CacheWithOptions): CachedWithDecorator { +/** + * Makes channel entry from a given channel name, class method name and options. + * + * @access private + * @param {string} name + * @param {string} method + * @param {CacheWithOptions} options + * @return {PgCacheChannel} + */ +export function makeChannel( + name: string, + method: string, + options: CacheWithOptions, +): PgCacheChannel { + return [method, !Array.isArray(options.channels) + ? (options.channels)[name] + : undefined, + ] as PgCacheChannel; +} + +/** + * Decorator factory @cacheWith(CacheWithOptions) + * This decorator should be used on a service methods, to set the caching + * rules for a method. + * + * @param {CacheWithOptions} options + * @return {MethodDecorator} + */ +export function cacheWith(options: CacheWithOptions): MethodDecorator { return ( target: any & PgCacheable, methodName: string | symbol, descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, ): void => { - const original: (...args: any[]) => any = descriptor.value as any; + const original: Function = descriptor.value as any; const className = typeof target === 'function' ? target.name : target.constructor.name; - const ttl = options.ttl || defaultTtl; - - if (!target.pgCacheChannels) { - target.pgCacheChannels = {}; - } - + const ttl = options.ttl || DEFAULT_CACHE_TTL; const isFiltered = !Array.isArray(options.channels); const channels: string[] = isFiltered ? Object.keys(options.channels) : options.channels as string[]; + target.pgCacheChannels = target.pgCacheChannels || {}; + for (const channel of channels) { - if (!target.pgCacheChannels[channel]) { - target.pgCacheChannels[channel] = []; - } + const pgChannel = target.pgCacheChannels[channel] = + target.pgCacheChannels[channel] || []; - target.pgCacheChannels[channel].push(isFiltered ? [ - String(methodName), - (options.channels as FilteredChannels)[channel] - ] : [String(methodName)]); + pgChannel.push(makeChannel(channel, String(methodName), options)); } - // tslint:disable-next-line:only-arrow-functions descriptor.value = async function(...args: any[]): Promise { const self = this || target; const cache: TagCache = self.taggedCache; const logger = (self.logger || console); if (!cache) { - logger.warn( - `PgCache:cacheWith: cache is not initialized on ${ - className - }, called in ${ - String(methodName) - }`, - ); + initError(logger, className, String(methodName), cacheWith); + return original.apply(self, args); } + const key = signature(className, methodName, args); + try { - const key = `${ className }:${ String(methodName) }:${ - signature(className, methodName, args) - }`; let result: any = await cache.get(key); - // eslint-disable-next-line id-blacklist if (result === null || result === undefined) { result = original.apply(self, args); @@ -107,44 +125,19 @@ export function cacheWith(options: CacheWithOptions): CachedWithDecorator { result = await result; } - cache.set( - key, - result, - [`${ className }:${ String(methodName) }`], - ttl, - ).then((res: any) => { - if (!PG_CACHE_DEBUG) { - return res; - } - - logger.info( - `PgCache:cacheWith: data saved to cache key ${ - key - }`, - ); - - return res; - }).catch(err => logger.warn( - `PgCache:cacheWith: saving cache key '${ - className }:${ String(methodName) - }' error:`, - err, - )); + const tags = [signature(className, methodName, [])]; + + cache.set(key, result, tags, ttl) + .then(res => setInfo(logger, res, key, cacheWith)) + .catch(err => setError(logger, err, key, cacheWith)); } return result; } catch (err) { - // istanbul ignore next - logger.warn( - `PgCache:cacheWith: fetching cache key '${ - className}:${ String(methodName) - }' error:`, - err, - ); - - // istanbul ignore next + fetchError(logger, err, key, cacheWith); + return original.apply(self, args); } }; diff --git a/src/env.ts b/src/env.ts index b63d674..2e34b23 100644 --- a/src/env.ts +++ b/src/env.ts @@ -13,6 +13,20 @@ * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR * PERFORMANCE OF THIS SOFTWARE. */ +import { ILogger } from '@imqueue/core'; +import { PgCacheable } from './PgCache'; + +export type ClassDecorator = {}>( + constructor: T, +) => T & PgCacheable; + +export type MethodDecorator = ( + target: any, + methodName: string | symbol, + descriptor: TypedPropertyDescriptor<(...args: any[]) => any>, +) => void; + +export const DEFAULT_CACHE_TTL = 86400000; // 24 hrs in milliseconds export const PG_CACHE_DEBUG = !!+(process.env.PG_CACHE_DEBUG || 0); export const PG_CACHE_TRIGGER = `CREATE FUNCTION post_change_notify_trigger() @@ -68,3 +82,51 @@ BEGIN END; $$; `; + +export function setInfo( + logger: ILogger, + res: any, + key: string, + decorator: Function, +): any { + PG_CACHE_DEBUG && logger.info( + `PgCache:${ decorator.name }: cache key '${ key }' saved!`, + ); + + return res; +} + +export function setError( + logger: ILogger, + err: any, + key: string, + decorator: Function, +): void { + logger.warn( + `PgCache:${ decorator.name }: saving cache key '${ key }' error:`, + err, + ); +} + +export function fetchError( + logger: ILogger, + err: any, + key: string, + decorator: Function, +): void { + logger.warn( + `PgCache:${ decorator.name }: fetching cache key '${ key }' error:`, + err, + ); +} + +export function initError( + logger: ILogger, + className: string, + methodName: string, + decorator: Function, +): void { + logger.warn(`PgCache:${ decorator.name }: cache is not initialized on ${ + className }, called in ${ methodName }`, + ); +}