├── .github ├── dependabot.yml ├── release-drafter.yml ├── tests_checker.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bench.sh ├── bench ├── custom-key-bench.js ├── custom-key.js ├── gateway-bench.js ├── gateway-invalidations.js ├── gateway-service-1.js ├── gateway-service-2.js └── gateway.js ├── examples ├── basic.js ├── cache-per-user.js ├── gateway-federation.js ├── invalidation-with-subscription.js ├── invalidation.js ├── policy-options.js ├── redis-gc └── redis.js ├── index.d.ts ├── index.js ├── lib ├── report.js └── validation.js ├── package.json ├── test ├── base.test.js ├── federation.test.js ├── gateway.test.js ├── helper.js ├── lib-validation.test.js ├── policy-options.test.js ├── refresh.test.js ├── report.test.js ├── scalar-types.test.js ├── types │ └── index.test-d.ts └── with-redis.test.js └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.github/tests_checker.yml: -------------------------------------------------------------------------------- 1 | comment: 'Could you please add tests to make sure this change works as expected?', 2 | fileExtensions: ['.php', '.ts', '.js', '.c', '.cs', '.cpp', '.rb', '.java'] 3 | testDir: 'test' 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [20.x, 22.x] 9 | redis-tag: [6, 7] 10 | services: 11 | redis: 12 | image: redis:${{ matrix.redis-tag }} 13 | options: >- 14 | --health-cmd "redis-cli ping" 15 | --health-interval 10s 16 | --health-timeout 5s 17 | --health-retries 5 18 | ports: 19 | - 6379:6379 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Install Dependencies 27 | run: npm install --ignore-scripts 28 | - name: Test 29 | run: npm test 30 | automerge: 31 | needs: test 32 | runs-on: ubuntu-latest 33 | permissions: 34 | pull-requests: write 35 | contents: write 36 | steps: 37 | - uses: fastify/github-action-merge-dependabot@v3 38 | with: 39 | github-token: ${{secrets.GITHUB_TOKEN}} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | package-lock.json 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | .clinic 107 | 108 | .vscode 109 | docker-compose.yml 110 | dump.rdb 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mercurius-js 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mercurius-cache 2 | 3 | Adds an in-process caching layer to Mercurius. 4 | Federation is fully supported. 5 | 6 | Based on preliminary testing, it is possible to achieve a significant 7 | throughput improvement at the expense of the freshness of the data. 8 | Setting the ttl accordingly and/or a good invalidation strategy is of critical importance. 9 | 10 | Under the covers, it uses [`async-cache-dedupe`](https://github.com/mcollina/async-cache-dedupe) 11 | which will also deduplicate the calls. 12 | 13 | ## Install 14 | 15 | ```bash 16 | npm i fastify mercurius mercurius-cache graphql 17 | ``` 18 | 19 | ## Quickstart 20 | 21 | ```js 22 | 'use strict' 23 | 24 | const fastify = require('fastify') 25 | const mercurius = require('mercurius') 26 | const cache = require('mercurius-cache') 27 | 28 | const app = fastify({ logger: true }) 29 | 30 | const schema = ` 31 | type Query { 32 | add(x: Int, y: Int): Int 33 | hello: String 34 | } 35 | ` 36 | 37 | const resolvers = { 38 | Query: { 39 | async add (_, { x, y }, { reply }) { 40 | reply.log.info('add called') 41 | for (let i = 0; i < 10000000; i++) {} // something that takes time 42 | return x + y 43 | } 44 | } 45 | } 46 | 47 | app.register(mercurius, { 48 | schema, 49 | resolvers 50 | }) 51 | 52 | 53 | // cache query "add" responses for 10 seconds 54 | app.register(cache, { 55 | ttl: 10, 56 | policy: { 57 | Query: { 58 | add: true 59 | // note: it cache "add" but it doesn't cache "hello" 60 | } 61 | } 62 | }) 63 | 64 | app.listen(3000) 65 | 66 | // Use the following to test 67 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql 68 | ``` 69 | 70 | ## Options 71 | 72 | - **ttl** 73 | 74 | a number or a function that returns a number of the maximum time a cache entry can live in seconds; default is `0`, which means that the cache is disabled. The ttl function reveives the result of the original function as the first argument. 75 | 76 | Example(s) 77 | 78 | ```js 79 | ttl: 10 80 | ``` 81 | 82 | ```js 83 | ttl: (result) => !!result.importantProp ? 10 : 0 84 | ``` 85 | 86 | - **stale** 87 | 88 | the time in seconds after the ttl to serve stale data while the cache values are re-validated. Has no effect if `ttl` is not configured. 89 | 90 | Example 91 | 92 | ```js 93 | stale: 5 94 | ``` 95 | 96 | - **all** 97 | 98 | use the cache in all resolvers; default is false. Use either `policy` or `all` but not both. 99 | Example 100 | 101 | ```js 102 | all: true 103 | ``` 104 | 105 | - **storage** 106 | 107 | default cache is in `memory`, but a `redis` storage can be used for a larger and shared cache. 108 | Storage options are: 109 | 110 | - **type**: `memory` (default) or `redis` 111 | - **options**: by storage type 112 | - for `memory` 113 | - **size**: maximum number of items to store in the cache _per resolver_. Default is `1024`. 114 | - **invalidation**: enable invalidation, see [documentation](#invalidation). Default is disabled. 115 | - **log**: logger instance `pino` compatible, default is the `app.log` instance. 116 | 117 | Example 118 | 119 | ```js 120 | storage: { 121 | type: 'memory', 122 | options: { 123 | size: 2048 124 | } 125 | } 126 | ``` 127 | 128 | - for `redis` 129 | - **client**: a redis client instance, mandatory. Should be an `ioredis` client or compatible. 130 | - **invalidation**: enable invalidation, see [documentation](#invalidation). Default is disabled. 131 | - **invalidation.referencesTTL**: references TTL in seconds. Default is the max static `ttl` between the main one and policies. If all ttls specified are functions then `referencesTTL` will need to be specified explictly. 132 | - **log**: logger instance `pino` compatible, default is the `app.log` instance. 133 | 134 | Example 135 | 136 | ```js 137 | storage: { 138 | type: 'redis', 139 | options: { 140 | client: new Redis(), 141 | invalidation: { 142 | referencesTTL: 60 143 | } 144 | } 145 | } 146 | ``` 147 | 148 | See [https://github.com/mercurius-js/mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete complex use case. 149 | 150 | - **policy** 151 | 152 | specify queries to cache; default is empty. 153 | Set it to `true` to cache using main `ttl` and `stale` if configured. 154 | Example 155 | 156 | ```js 157 | policy: { 158 | Query: { 159 | add: true 160 | } 161 | } 162 | ``` 163 | 164 | - **policy~ttl** 165 | 166 | use a specific `ttl` for the policy, instead of the main one. 167 | Example 168 | 169 | ```js 170 | ttl: 10, 171 | policy: { 172 | Query: { 173 | welcome: { 174 | ttl: 5 // Query "welcome" will be cached for 5 seconds 175 | }, 176 | bye: true, // Query "bye" will be cached for 10 seconds 177 | hello: (result) => result.shouldCache ? 15 : 0 // function that determines the ttl for how long the item should be cached 178 | } 179 | } 180 | ``` 181 | 182 | - **policy~stale** 183 | 184 | use a specific `stale` value for the policy, instead of the main one. 185 | Example 186 | 187 | ```js 188 | ttl: 10, 189 | stale: 10, 190 | policy: { 191 | Query: { 192 | welcome: { 193 | ttl: 5 // Query "welcome" will be cached for 5 seconds 194 | stale: 5 // Query "welcome" will available for 5 seconds after the ttl has expired 195 | }, 196 | bye: true // Query "bye" will be cached for 10 seconds and available for 10 seconds after the ttl is expired 197 | } 198 | } 199 | ``` 200 | 201 | - **policy~storage** 202 | 203 | use specific storage for the policy, instead of the main one. 204 | Can be useful to have, for example, in-memory storage for small data set along with the redis storage. 205 | See [https://github.com/mercurius-js/mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete complex use case. 206 | Example 207 | 208 | ```js 209 | storage: { 210 | type: 'redis', 211 | options: { client: new Redis() } 212 | }, 213 | policy: { 214 | Query: { 215 | countries: { 216 | ttl: 86400, // Query "countries" will be cached for 1 day 217 | storage: { type: 'memory' } 218 | } 219 | } 220 | } 221 | ``` 222 | 223 | - **policy~skip** 224 | 225 | skip cache use for a specific condition, `onSkip` will be triggered. 226 | Example 227 | 228 | ```js 229 | skip (self, arg, ctx, info) { 230 | if (ctx.reply.request.headers.authorization) { 231 | return true 232 | } 233 | return false 234 | } 235 | ``` 236 | 237 | - **policy~key** 238 | 239 | To improve performance, we can define a custom key serializer. 240 | Example 241 | 242 | ```js 243 | const schema = ` 244 | type Query { 245 | getUser (id: ID!): User 246 | }` 247 | 248 | // ... 249 | 250 | policy: { 251 | Query: { 252 | getUser: { key ({ self, arg, info, ctx, fields }) { return `${arg.id}` } } 253 | } 254 | } 255 | ``` 256 | 257 | Please note that the `key` function must return a string, otherwise the result will be stringified, losing the performance advantage of custom serialization. 258 | 259 | - **policy~extendKey** 260 | 261 | extend the key to cache responses by different requests, for example, to enable custom cache per user. 262 | See [examples/cache-per-user.js](examples/cache-per-user.js). 263 | Example 264 | 265 | ```js 266 | policy: { 267 | Query: { 268 | welcome: { 269 | extendKey: function (source, args, context, info) { 270 | return context.userId ? `user:${context.userId}` : undefined 271 | } 272 | } 273 | } 274 | } 275 | ``` 276 | 277 | - **policy~references** 278 | 279 | function to set the `references` for the query, see [invalidation](#invalidation) to know how to use references, and [https://github.com/mercurius-js/mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete use case. 280 | Example 281 | 282 | ```js 283 | policy: { 284 | Query: { 285 | user: { 286 | references: ({source, args, context, info}, key, result) => { 287 | if(!result) { return } 288 | return [`user:${result.id}`] 289 | } 290 | }, 291 | users: { 292 | references: ({source, args, context, info}, key, result) => { 293 | if(!result) { return } 294 | const references = result.map(user => (`user:${user.id}`)) 295 | references.push('users') 296 | return references 297 | } 298 | } 299 | } 300 | } 301 | ``` 302 | 303 | - **policy~invalidate** 304 | 305 | function to `invalidate` for the query by references, see [invalidation](#invalidation) to know how to use references, and [https://github.com/mercurius-js/mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete use case. 306 | `invalidate` function can be sync or async. 307 | Example 308 | 309 | ```js 310 | policy: { 311 | Mutation: { 312 | addUser: { 313 | invalidate: (self, arg, ctx, info, result) => ['users'] 314 | } 315 | } 316 | } 317 | ``` 318 | 319 | - **policy~__options** 320 | 321 | should be used in case of conflicts with nested fields with the same name as policy fields (ttl, skip, storage....). 322 | Example 323 | 324 | ```js 325 | policy: { 326 | Query: { 327 | welcome: { 328 | // no __options key present, so policy options are considered as it is 329 | ttl: 6 330 | }, 331 | hello: { 332 | // since "hello" query has a ttl property 333 | __options: { 334 | ttl: 6 335 | }, 336 | ttl: { 337 | // here we can use both __options or list policy options 338 | skip: () { /* .. */ } 339 | } 340 | } 341 | } 342 | } 343 | ``` 344 | 345 | - **skip** 346 | 347 | skip cache use for a specific condition, `onSkip` will be triggered. 348 | Example 349 | 350 | ```js 351 | skip (self, arg, ctx, info) { 352 | if (ctx.reply.request.headers.authorization) { 353 | return true 354 | } 355 | return false 356 | } 357 | ``` 358 | 359 | - **onDedupe** 360 | 361 | called when a request is deduped. 362 | When multiple requests arrive at the same time, the dedupe system calls the resolver only once and serve all the request with the result of the first request - and after the result is cached. 363 | Example 364 | 365 | ```js 366 | onDedupe (type, fieldName) { 367 | console.log(`dedupe ${type} ${fieldName}`) 368 | } 369 | ``` 370 | 371 | - **onHit** 372 | 373 | called when a cached value is returned. 374 | Example 375 | 376 | ```js 377 | onHit (type, fieldName) { 378 | console.log(`hit ${type} ${fieldName}`) 379 | } 380 | ``` 381 | 382 | - **onMiss** 383 | 384 | called when there is no value in the cache; it is not called if a resolver is skipped. 385 | Example 386 | 387 | ```js 388 | onMiss (type, fieldName) { 389 | console.log(`miss ${type} ${fieldName}`) 390 | } 391 | ``` 392 | 393 | - **onSkip** 394 | 395 | called when the resolver is skipped, both by `skip` or `policy.skip`. 396 | Example 397 | 398 | ```js 399 | onSkip (type, fieldName) { 400 | console.log(`skip ${type} ${fieldName}`) 401 | } 402 | ``` 403 | 404 | - **onError** 405 | 406 | called when an error occurred on the caching operation. 407 | Example 408 | 409 | ```js 410 | onError (type, fieldName, error) { 411 | console.error(`error on ${type} ${fieldName}`, error) 412 | } 413 | ``` 414 | 415 | - **logInterval** 416 | 417 | This option enables cache report with hit/miss/dedupes/skips count for all queries specified in the policy; default is disabled. 418 | The value of the interval is in *seconds*. 419 | 420 | Example 421 | 422 | ```js 423 | logInterval: 3 424 | ``` 425 | 426 | - **logReport** 427 | 428 | custom function for logging cache hits/misses. called every `logInterval` seconds when the cache report is logged. 429 | 430 | Example 431 | 432 | ```js 433 | logReport (report) { 434 | console.log('Periodic cache report') 435 | console.table(report) 436 | } 437 | 438 | // console table output 439 | 440 | ┌───────────────┬─────────┬──────┬────────┬───────┐ 441 | │ (index) │ dedupes │ hits │ misses │ skips │ 442 | ├───────────────┼─────────┼──────┼────────┼───────┤ 443 | │ Query.add │ 0 │ 8 │ 1 │ 0 │ 444 | │ Query.sub │ 0 │ 2 │ 6 │ 0 │ 445 | └───────────────┴─────────┴──────┴────────┴───────┘ 446 | 447 | // report format 448 | { 449 | "Query.add": { 450 | "dedupes": 0, 451 | "hits": 8, 452 | "misses": 1, 453 | "skips": 0 454 | }, 455 | "Query.sub": { 456 | "dedupes": 0, 457 | "hits": 2, 458 | "misses": 6, 459 | "skips": 0 460 | }, 461 | } 462 | ``` 463 | 464 | ## Methods 465 | 466 | - **invalidate** 467 | 468 | ### `cache.invalidate(references, [storage])` 469 | 470 | `cache.invalidate` perform invalidation over the whole storage. 471 | To specify the `storage` to operate invalidation, it needs to be the name of a policy, for example `Query.getUser`. 472 | Note that `invalidation` must be enabled on `storage`. 473 | 474 | `references` can be: 475 | 476 | - a single reference 477 | - an array of references (without wildcard) 478 | - a matching reference with wildcard, same logic for `memory` and `redis` 479 | 480 | Example 481 | 482 | ```js 483 | const app = fastify() 484 | 485 | await app.register(cache, { 486 | ttl: 60, 487 | storage: { 488 | type: 'redis', 489 | options: { client: redisClient, invalidation: true } 490 | }, 491 | policy: { 492 | Query: { 493 | getUser: { 494 | references: (args, key, result) => result ? [`user:${result.id}`] : null 495 | } 496 | } 497 | } 498 | }) 499 | 500 | // ... 501 | 502 | // invalidate all users 503 | await app.graphql.cache.invalidate('user:*') 504 | 505 | // invalidate user 1 506 | await app.graphql.cache.invalidate('user:1') 507 | 508 | // invalidate user 1 and user 2 509 | await app.graphql.cache.invalidate(['user:1', 'user:2']) 510 | ``` 511 | 512 | See [example](/examples/invalidation.js) for a complete example. 513 | 514 | - **clear** 515 | 516 | `clear` method allows to pragmatically clear the cache entries, for example 517 | 518 | ```js 519 | const app = fastify() 520 | 521 | await app.register(cache, { 522 | ttl: 60, 523 | policy: { 524 | // ... 525 | } 526 | }) 527 | 528 | // ... 529 | 530 | await app.graphql.cache.clear() 531 | ``` 532 | 533 | ## Invalidation 534 | 535 | Along with `time to live` invalidation of the cache entries, we can use invalidation by keys. 536 | The concept behind invalidation by keys is that entries have an auxiliary key set that explicitly links requests along with their result. These auxiliary keys are called here `references`. 537 | The use case is common. Let's say we have an entry _user_ `{id: 1, name: "Alice"}`, it may change often or rarely, the `ttl` system is not accurate: 538 | 539 | - it can be updated before `ttl` expiration, in this case the old value is shown until expiration by `ttl`. 540 | It may also be in more queries, for example, `getUser` and `findUsers`, so we need to keep their responses consistent 541 | - it's not been updated during `ttl` expiration, so in this case, we don't need to reload the value, because it's not changed 542 | 543 | To solve this common problem, we can use `references`. 544 | We can say that the result of query `getUser(id: 1)` has reference `user~1`, and the result of query `findUsers`, containing `{id: 1, name: "Alice"},{id: 2, name: "Bob"}` has references `[user~1,user~2]`. 545 | So we can find the results in the cache by their `references`, independently of the request that generated them, and we can invalidate by `references`. 546 | 547 | When the mutation `updateUser` involves `user {id: 1}` we can remove all the entries in the cache that have references to `user~1`, so the result of `getUser(id: 1)` and `findUsers`, and they will be reloaded at the next request with the new data - but not the result of `getUser(id: 2)`. 548 | 549 | However, the operations required to do that could be expensive and not worthing it, for example, is not recommendable to cache frequently updating data by queries of `find` that have pagination/filtering/sorting. 550 | 551 | Explicit invalidation is `disabled` by default, you have to enable in `storage` settings. 552 | 553 | See [mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) for a complete example. 554 | 555 | ### Redis 556 | 557 | Using a `redis` storage is the best choice for a shared cache for a cluster of a service instance. 558 | However, using the invalidation system need to keep `references` updated, and remove the expired ones: while expired references do not compromise the cache integrity, they slow down the I/O operations. 559 | 560 | So, redis storage has the `gc` function, to perform garbage collection. 561 | 562 | See this example in [mercurius-cache-example/plugins/cache.js](https://github.com/mercurius-js/mercurius-cache-example/blob/master/plugins/cache.js) about how to run gc on a single instance service. 563 | 564 | Another example: 565 | 566 | ```js 567 | const { createStorage } = require('async-cache-dedupe') 568 | const client = new Redis(connection) 569 | 570 | const storage = createStorage('redis', { log, client, invalidation: true }) 571 | 572 | // run in lazy mode, doing a full db iteration / but not a full clean up 573 | let cursor = 0 574 | do { 575 | const report = await storage.gc('lazy', { lazy: { chunk: 200, cursor } }) 576 | cursor = report.cursor 577 | } while (cursor !== 0) 578 | 579 | // run in strict mode 580 | const report = await storage.gc('strict', { chunk: 250 }) 581 | 582 | ``` 583 | 584 | In lazy mode, only `options.max` references are scanned every time, picking keys to check randomly; this operation is lighter while does not ensure references full clean up 585 | 586 | In strict mode, all references and keys are checked and cleaned; this operation scans the whole db and is slow, while it ensures full references clean up. 587 | 588 | `gc` options are: 589 | 590 | - **chunk** the chunk size of references analyzed per loops, default `64` 591 | - **lazy~chunk** the chunk size of references analyzed per loops in `lazy` mode, default `64`; if both `chunk` and `lazy.chunk` is set, the maximum one is taken 592 | - **lazy~cursor** the cursor offset, default zero; cursor should be set at `report.cursor` to continue scanning from the previous operation 593 | 594 | `storage.gc` function returns the `report` of the job, like 595 | 596 | ```json 597 | "report":{ 598 | "references":{ 599 | "scanned":["r:user:8", "r:group:11", "r:group:16"], 600 | "removed":["r:user:8", "r:group:16"] 601 | }, 602 | "keys":{ 603 | "scanned":["users~1"], 604 | "removed":["users~1"] 605 | }, 606 | "loops":4, 607 | "cursor":0, 608 | "error":null 609 | } 610 | ``` 611 | 612 | An effective strategy is to run often `lazy` cleans and a `strict` clean sometimes. 613 | The report contains useful information about the gc cycle, use them to adjust params of the gc utility, settings depending on the size, and the mutability of cached data. 614 | 615 | A way is to run it programmatically, as in [https://github.com/mercurius-js/mercurius-cache-example](https://github.com/mercurius-js/mercurius-cache-example) or set up cronjobs as described in [examples/redis-gc](examples/redis-gc) - this one is useful when there are many instances of the mercurius server. 616 | See [async-cache-dedupe#redis-garbage-collector](https://github.com/mcollina/async-cache-dedupe#redis-garbage-collector) for details. 617 | 618 | ## Breaking Changes 619 | 620 | - version `0.11.0` -> `0.12.0` 621 | - `options.cacheSize` is dropped in favor of `storage` 622 | - `storage.get` and `storage.set` are removed in favor of `storage` options 623 | 624 | ## Benchmarks 625 | 626 | We have experienced up to 10x performance improvements in real-world scenarios. 627 | This repository also includes a benchmark of a gateway and two federated services that shows 628 | that adding a cache with 10ms TTL can improve the performance by 4x: 629 | 630 | ``` 631 | $ sh bench.sh 632 | =============================== 633 | = Gateway Mode (not cache) = 634 | =============================== 635 | Running 10s test @ http://localhost:3000/graphql 636 | 100 connections 637 | 638 | ┌─────────┬───────┬───────┬───────┬───────┬──────────┬─────────┬────────┐ 639 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 640 | ├─────────┼───────┼───────┼───────┼───────┼──────────┼─────────┼────────┤ 641 | │ Latency │ 28 ms │ 31 ms │ 57 ms │ 86 ms │ 33.47 ms │ 12.2 ms │ 238 ms │ 642 | └─────────┴───────┴───────┴───────┴───────┴──────────┴─────────┴────────┘ 643 | ┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬────────┬────────┐ 644 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 645 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤ 646 | │ Req/Sec │ 1291 │ 1291 │ 3201 │ 3347 │ 2942.1 │ 559.51 │ 1291 │ 647 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼────────┼────────┤ 648 | │ Bytes/Sec │ 452 kB │ 452 kB │ 1.12 MB │ 1.17 MB │ 1.03 MB │ 196 kB │ 452 kB │ 649 | └───────────┴────────┴────────┴─────────┴─────────┴─────────┴────────┴────────┘ 650 | 651 | Req/Bytes counts sampled once per second. 652 | 653 | 32k requests in 11.03s, 11.3 MB read 654 | 655 | =============================== 656 | = Gateway Mode (0s TTL) = 657 | =============================== 658 | Running 10s test @ http://localhost:3000/graphql 659 | 100 connections 660 | 661 | ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐ 662 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 663 | ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤ 664 | │ Latency │ 6 ms │ 7 ms │ 12 ms │ 17 ms │ 7.29 ms │ 3.32 ms │ 125 ms │ 665 | └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘ 666 | ┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ 667 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 668 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 669 | │ Req/Sec │ 7403 │ 7403 │ 13359 │ 13751 │ 12759 │ 1831.94 │ 7400 │ 670 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 671 | │ Bytes/Sec │ 2.59 MB │ 2.59 MB │ 4.68 MB │ 4.81 MB │ 4.47 MB │ 642 kB │ 2.59 MB │ 672 | └───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 673 | 674 | Req/Bytes counts sampled once per second. 675 | 676 | 128k requests in 10.03s, 44.7 MB read 677 | 678 | =============================== 679 | = Gateway Mode (1s TTL) = 680 | =============================== 681 | Running 10s test @ http://localhost:3000/graphql 682 | 100 connections 683 | 684 | ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐ 685 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 686 | ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤ 687 | │ Latency │ 7 ms │ 7 ms │ 13 ms │ 19 ms │ 7.68 ms │ 4.01 ms │ 149 ms │ 688 | └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘ 689 | ┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ 690 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 691 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 692 | │ Req/Sec │ 6735 │ 6735 │ 12879 │ 12951 │ 12173 │ 1828.86 │ 6735 │ 693 | ├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 694 | │ Bytes/Sec │ 2.36 MB │ 2.36 MB │ 4.51 MB │ 4.53 MB │ 4.26 MB │ 640 kB │ 2.36 MB │ 695 | └───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 696 | 697 | Req/Bytes counts sampled once per second. 698 | 699 | 122k requests in 10.03s, 42.6 MB read 700 | 701 | =============================== 702 | = Gateway Mode (10s TTL) = 703 | =============================== 704 | Running 10s test @ http://localhost:3000/graphql 705 | 100 connections 706 | 707 | ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬────────┐ 708 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 709 | ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼────────┤ 710 | │ Latency │ 7 ms │ 7 ms │ 13 ms │ 18 ms │ 7.51 ms │ 3.22 ms │ 121 ms │ 711 | └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴────────┘ 712 | ┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬─────────┬────────┐ 713 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 714 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤ 715 | │ Req/Sec │ 7147 │ 7147 │ 13231 │ 13303 │ 12498.2 │ 1807.01 │ 7144 │ 716 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼────────┤ 717 | │ Bytes/Sec │ 2.5 MB │ 2.5 MB │ 4.63 MB │ 4.66 MB │ 4.37 MB │ 633 kB │ 2.5 MB │ 718 | └───────────┴────────┴────────┴─────────┴─────────┴─────────┴─────────┴────────┘ 719 | 720 | Req/Bytes counts sampled once per second. 721 | 722 | 125k requests in 10.03s, 43.7 MB read 723 | ``` 724 | 725 | ## More info about how this plugin works 726 | This plugin caches the result of the resolver, but if the resolver returns a type incompatible with the schema return type, the plugin will cache the invalid return value. When you call the resolver again, the plugin will return the cached value, thereby caching the validation error. 727 | 728 | This issue may be exacerbated in a federation setup when you don't have full control over the implementation of federated schema and resolvers. 729 | 730 | Here you can find an example of the problem. 731 | ```js 732 | 'use strict' 733 | 734 | const fastify = require('fastify') 735 | const mercurius = require('mercurius') 736 | const cache = require('mercurius-cache') 737 | 738 | const app = fastify({ logger: true }) 739 | 740 | const schema = ` 741 | type Query { 742 | getNumber: Int 743 | } 744 | ` 745 | 746 | const resolvers = { 747 | Query: { 748 | async getNumber(_, __, { reply }) { 749 | return "hello"; 750 | } 751 | } 752 | } 753 | 754 | app.register(mercurius, { 755 | schema, 756 | resolvers 757 | }) 758 | 759 | app.register(cache, { 760 | ttl: 10, 761 | policy: { 762 | Query: { 763 | getNumber: true 764 | } 765 | } 766 | }) 767 | ``` 768 | 769 | If you come across this problem, you will first need to fix your code. Then you have two options: 770 | 771 | 1. If you are you using an **in-memory** cache, it will be cleared at the next start of the application, so the impact of this issue will be limited 772 | 2. If you are you using the **Redis** cache, you will need to manually invalidate the cache in Redis or wait for the TTL to expire 773 | 774 | ## License 775 | 776 | MIT 777 | -------------------------------------------------------------------------------- /bench.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo '===============================' 4 | echo '= Gateway Mode (not cache) =' 5 | echo '===============================' 6 | npx concurrently --raw -k \ 7 | "node ./bench/gateway-service-1.js" \ 8 | "node ./bench/gateway-service-2.js" \ 9 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js" \ 10 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 11 | 12 | echo 13 | 14 | echo '===============================' 15 | echo '= Gateway Mode (0s TTL) =' 16 | echo '===============================' 17 | npx concurrently --raw -k \ 18 | "node ./bench/gateway-service-1.js" \ 19 | "node ./bench/gateway-service-2.js" \ 20 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js 0" \ 21 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 22 | 23 | echo 24 | 25 | echo '===============================' 26 | echo '= Gateway Mode (1s TTL) =' 27 | echo '===============================' 28 | npx concurrently --raw -k \ 29 | "node ./bench/gateway-service-1.js" \ 30 | "node ./bench/gateway-service-2.js" \ 31 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js 1" \ 32 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 33 | 34 | echo 35 | 36 | echo '===============================' 37 | echo '= Gateway Mode (10s TTL) =' 38 | echo '===============================' 39 | npx concurrently --raw -k \ 40 | "node ./bench/gateway-service-1.js" \ 41 | "node ./bench/gateway-service-2.js" \ 42 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway.js 10" \ 43 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 44 | 45 | echo 46 | echo '*******************************' 47 | echo 48 | 49 | echo 50 | 51 | echo '===============================' 52 | echo '= Default Key Serialization =' 53 | echo '===============================' 54 | npx concurrently --raw -k \ 55 | "node ./bench/custom-key.js" \ 56 | "npx wait-on tcp:3000 && QUERY=0 node ./bench/custom-key-bench.js" 57 | 58 | echo 59 | 60 | echo '===============================' 61 | echo '= Custom Key Serialization =' 62 | echo '===============================' 63 | npx concurrently --raw -k \ 64 | "node ./bench/custom-key.js" \ 65 | "npx wait-on tcp:3000 && QUERY=1 node ./bench/custom-key-bench.js" 66 | -------------------------------------------------------------------------------- /bench/custom-key-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const queries = [ 6 | // '{ getUser(id: "a1") { name, lastName} }', 7 | // '{ getUserCustom(id: "a1") { name, lastName} }', 8 | 9 | '{ getUsers(name: "Brian") { id, name, lastName} }', 10 | '{ getUsersCustom(name: "Brian") { id, name, lastName} }' 11 | ] 12 | 13 | const query = queries[process.env.QUERY] 14 | 15 | const instance = autocannon( 16 | { 17 | url: 'http://localhost:3000/graphql', 18 | connections: 100, 19 | title: '', 20 | method: 'POST', 21 | headers: { 22 | 'content-type': 'application/json' 23 | }, 24 | body: JSON.stringify({ query }) 25 | }, 26 | (err) => { 27 | if (err) { 28 | console.error(err) 29 | } 30 | } 31 | ) 32 | 33 | process.once('SIGINT', () => { 34 | instance.stop() 35 | }) 36 | 37 | autocannon.track(instance, { renderProgressBar: true }) 38 | -------------------------------------------------------------------------------- /bench/custom-key.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('..') 6 | 7 | const users = { 8 | a1: { name: 'Angus', lastName: 'Young' }, 9 | b2: { name: 'Phil', lastName: 'Rudd' }, 10 | c3: { name: 'Cliff', lastName: 'Williams' }, 11 | d4: { name: 'Brian', lastName: 'Johnson' }, 12 | e5: { name: 'Stevie', lastName: 'Young' } 13 | } 14 | 15 | const app = fastify() 16 | 17 | const schema = ` 18 | type Query { 19 | getUser (id: ID!): User 20 | getUsers (name: String, lastName: String): [User] 21 | 22 | getUserCustom (id: ID!): User 23 | getUsersCustom (name: String, lastName: String): [User] 24 | } 25 | 26 | type User { 27 | id: ID! 28 | name: String 29 | lastName: String 30 | } 31 | ` 32 | 33 | async function getUser (_, { id }) { return users[id] ? { id, ...users[id] } : null } 34 | async function getUsers (_, { name, lastName }) { 35 | const id = Object.keys(users).find(key => { 36 | const user = users[key] 37 | if (name && user.name !== name) return false 38 | if (lastName && user.lastName !== lastName) return false 39 | return true 40 | }) 41 | return id ? [{ id, ...users[id] }] : [] 42 | } 43 | 44 | const resolvers = { 45 | Query: { 46 | getUser, 47 | getUsers, 48 | getUserCustom: getUser, 49 | getUsersCustom: getUsers 50 | } 51 | } 52 | 53 | app.register(mercurius, { schema, resolvers }) 54 | 55 | app.register(cache, { 56 | ttl: 60, 57 | policy: { 58 | Query: { 59 | getUser: true, 60 | getUsers: true, 61 | 62 | getUserCustom: { key ({ self, arg, info, ctx, fields }) { return `${arg.id}` } }, 63 | getUsersCustom: { key ({ self, arg, info, ctx, fields }) { return `${arg.name || '*'},${arg.lastName || '*'}` } } 64 | } 65 | } 66 | }) 67 | 68 | app.listen(3000) 69 | -------------------------------------------------------------------------------- /bench/gateway-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const query = `query { 6 | me { 7 | id 8 | name 9 | nickname: name 10 | topPosts(count: 2) { 11 | pid 12 | author { 13 | id 14 | } 15 | } 16 | } 17 | topPosts(count: 2) { 18 | pid 19 | } 20 | }` 21 | 22 | const instance = autocannon( 23 | { 24 | url: 'http://localhost:3000/graphql', 25 | connections: 100, 26 | title: '', 27 | method: 'POST', 28 | headers: { 29 | 'content-type': 'application/json' 30 | }, 31 | body: JSON.stringify({ query }) 32 | }, 33 | (err) => { 34 | if (err) { 35 | console.error(err) 36 | } 37 | } 38 | ) 39 | 40 | process.once('SIGINT', () => { 41 | instance.stop() 42 | }) 43 | 44 | autocannon.track(instance, { renderProgressBar: true }) 45 | -------------------------------------------------------------------------------- /bench/gateway-invalidations.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('..') 6 | 7 | const app = Fastify() 8 | 9 | app.register(mercurius, { 10 | gateway: { 11 | services: [{ 12 | name: 'user', 13 | url: 'http://localhost:3001/graphql' 14 | }, { 15 | name: 'post', 16 | url: 'http://localhost:3002/graphql' 17 | }] 18 | }, 19 | graphiql: true, 20 | jit: 1 21 | }) 22 | 23 | app.register(cache, { 24 | ttl: 120, // 3 minutes 25 | options: { 26 | invalidation: true 27 | }, 28 | onError: (err) => { 29 | console.log('Error', err) 30 | }, 31 | policy: { 32 | Query: { 33 | topPosts: { 34 | references: () => { 35 | return ['topPosts'] 36 | } 37 | }, 38 | getPost: { 39 | references: (_, arg) => { 40 | return [`getPost:${arg.pid}`] 41 | } 42 | } 43 | }, 44 | Mutation: { 45 | updatePostTitle: { 46 | invalidate (_, arg) { 47 | return [`getPost:${arg.pid}`, 'posts'] 48 | } 49 | } 50 | } 51 | } 52 | }) 53 | 54 | app.listen(3000) 55 | -------------------------------------------------------------------------------- /bench/gateway-service-1.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const app = Fastify() 7 | 8 | const users = { 9 | u1: { 10 | id: 'u1', 11 | name: 'John' 12 | }, 13 | u2: { 14 | id: 'u2', 15 | name: 'Jane' 16 | } 17 | } 18 | 19 | const schema = ` 20 | type Query @extends { 21 | me: User 22 | } 23 | 24 | type User @key(fields: "id") { 25 | id: ID! 26 | name: String! 27 | } 28 | 29 | type Mutation @extends { 30 | createUser(name: String!): User 31 | updateUser(id: ID!, name: String!): User 32 | } 33 | ` 34 | 35 | const resolvers = { 36 | Query: { 37 | me: (root, args, context, info) => { 38 | return users.u1 39 | } 40 | }, 41 | User: { 42 | __resolveReference: (user, args, context, info) => { 43 | return users[user.id] 44 | } 45 | }, 46 | Mutation: { 47 | createUser: (root, args, context, info) => { 48 | const user = { 49 | id: `u${Object.keys(users).length + 1}`, 50 | name: args.name 51 | } 52 | users[user.id] = user 53 | return user 54 | }, 55 | 56 | updateUser: (root, args, context, info) => { 57 | if (!users[args.id]) { 58 | throw new Error('User not found') 59 | } 60 | users[args.id] = args 61 | return args 62 | } 63 | } 64 | } 65 | 66 | app.register(mercurius, { 67 | schema, 68 | resolvers, 69 | federationMetadata: true, 70 | graphiql: false, 71 | jit: 1 72 | }) 73 | 74 | app.listen(3001) 75 | -------------------------------------------------------------------------------- /bench/gateway-service-2.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const app = Fastify() 7 | 8 | const posts = { 9 | p1: { 10 | pid: 'p1', 11 | title: 'Post 1', 12 | content: 'Content 1', 13 | authorId: 'u1' 14 | }, 15 | p2: { 16 | pid: 'p2', 17 | title: 'Post 2', 18 | content: 'Content 2', 19 | authorId: 'u2' 20 | }, 21 | p3: { 22 | pid: 'p3', 23 | title: 'Post 3', 24 | content: 'Content 3', 25 | authorId: 'u1' 26 | }, 27 | p4: { 28 | pid: 'p4', 29 | title: 'Post 4', 30 | content: 'Content 4', 31 | authorId: 'u1' 32 | } 33 | } 34 | 35 | const schema = ` 36 | type Post @key(fields: "pid") { 37 | pid: ID! 38 | title: String 39 | content: String 40 | author: User 41 | } 42 | 43 | extend type Query { 44 | topPosts(count: Int): [Post] 45 | getPost(pid: ID!): Post 46 | } 47 | 48 | extend type Mutation { 49 | updatePostTitle(pid: ID!, title: String!): Post 50 | } 51 | 52 | type User @key(fields: "id") @extends { 53 | id: ID! @external 54 | topPosts(count: Int!): [Post] 55 | }` 56 | 57 | const resolvers = { 58 | Post: { 59 | __resolveReference: (post, args, context, info) => { 60 | return posts[post.pid] 61 | }, 62 | author: (post, args, context, info) => { 63 | return { 64 | __typename: 'User', 65 | id: post.authorId 66 | } 67 | } 68 | }, 69 | User: { 70 | topPosts: (user, { count }, context, info) => { 71 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 72 | } 73 | }, 74 | Query: { 75 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count), 76 | getPost: (root, { pid }) => { 77 | console.log(pid, posts[pid]) 78 | return posts[pid] 79 | } 80 | }, 81 | Mutation: { 82 | updatePostTitle: (root, args, context, info) => { 83 | if (!posts[args.pid]) { 84 | throw new Error('Post not found') 85 | } 86 | posts[args.pid].title = args.title 87 | return posts[args.pid] 88 | } 89 | } 90 | } 91 | 92 | app.register(mercurius, { 93 | schema, 94 | resolvers, 95 | federationMetadata: true, 96 | graphiql: false, 97 | jit: 1 98 | }) 99 | 100 | app.listen(3002) 101 | -------------------------------------------------------------------------------- /bench/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('..') 6 | 7 | const app = Fastify() 8 | 9 | app.register(mercurius, { 10 | gateway: { 11 | services: [{ 12 | name: 'user', 13 | url: 'http://localhost:3001/graphql' 14 | }, { 15 | name: 'post', 16 | url: 'http://localhost:3002/graphql' 17 | }] 18 | }, 19 | graphiql: true, 20 | jit: 1 21 | }) 22 | 23 | if (process.argv[2]) { 24 | app.register(cache, { 25 | all: true, 26 | ttl: Number(process.argv[2]) 27 | }) 28 | } 29 | 30 | app.listen(3000) 31 | -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('mercurius-cache') 6 | 7 | const app = fastify({ logger: true }) 8 | 9 | const schema = ` 10 | type Query { 11 | add(x: Int, y: Int): Int 12 | hello: String 13 | } 14 | ` 15 | 16 | const resolvers = { 17 | Query: { 18 | async add (_, { x, y }, { reply }) { 19 | reply.log.info('add called') 20 | for (let i = 0; i < 10000000; i++) { 21 | // empty for a reason 22 | } 23 | return x + y 24 | } 25 | } 26 | } 27 | 28 | app.register(mercurius, { 29 | schema, 30 | resolvers 31 | }) 32 | 33 | app.register(cache, { 34 | ttl: 10, 35 | // all: true 36 | policy: { 37 | Query: { 38 | add: true 39 | } 40 | } 41 | }) 42 | 43 | app.listen(3000) 44 | 45 | // Use the following to test 46 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql 47 | -------------------------------------------------------------------------------- /examples/cache-per-user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('mercurius-cache') 6 | 7 | const app = fastify({ logger: true }) 8 | 9 | const schema = ` 10 | type Query { 11 | welcome: String 12 | } 13 | ` 14 | 15 | const resolvers = { 16 | Query: { 17 | async welcome (source, args, { reply, user }) { 18 | reply.log.info(`welcome for ${user}`) 19 | for (let i = 0; i < 10000000; i++) { 20 | // empty for a reason 21 | } 22 | return user ? `Welcome ${user}` : 'Hello' 23 | } 24 | } 25 | } 26 | 27 | app.register(mercurius, { 28 | schema, 29 | resolvers, 30 | context: async (req) => { 31 | return { 32 | user: req.query.user 33 | } 34 | } 35 | }) 36 | 37 | app.register(cache, { 38 | ttl: 10, 39 | // all: true 40 | policy: { 41 | Query: { 42 | welcome: { 43 | extendKey: function (source, args, context, info) { 44 | return context.user ? `user:${context.user}` : undefined 45 | } 46 | } 47 | } 48 | }, 49 | onHit: function (type, fieldName) { 50 | app.log.info({ msg: 'hit from cache', type, fieldName }) 51 | }, 52 | onMiss: function (type, fieldName) { 53 | app.log.info({ msg: 'miss from cache', type, fieldName }) 54 | } 55 | }) 56 | 57 | app.listen(3000) 58 | 59 | // Use the following to test 60 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ welcome }" }' localhost:3000/graphql?user=alice 61 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ welcome }" }' localhost:3000/graphql?user=bob 62 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ welcome }" }' localhost:3000/graphql 63 | -------------------------------------------------------------------------------- /examples/gateway-federation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercuriusGateway = require('@mercuriusjs/gateway') 5 | const mercuriusFederation = require('@mercuriusjs/federation') 6 | const redis = require('@fastify/redis') 7 | const cache = require('mercurius-cache') 8 | 9 | async function createPostService () { 10 | const service = Fastify({ logger: true }) 11 | 12 | const posts = { 13 | p1: { 14 | pid: 'p1', 15 | categoryId: 'c1' 16 | }, 17 | p2: { 18 | pid: 'p2', 19 | categoryId: 'c2' 20 | }, 21 | p3: { 22 | pid: 'p3', 23 | categoryId: 'c1' 24 | }, 25 | p4: { 26 | pid: 'p4', 27 | categoryId: 'c1' 28 | } 29 | } 30 | 31 | const schema = ` 32 | type Post @key(fields: "pid") { 33 | pid: ID! 34 | category: Category 35 | } 36 | 37 | type Query @extends { 38 | posts: [Post] 39 | topPosts(count: Int): [Post] 40 | } 41 | 42 | type Category @key(fields: "id") @extends { 43 | id: ID! @external 44 | topPosts(count: Int!): [Post] 45 | } 46 | 47 | input PostInput { 48 | categoryId: ID! 49 | } 50 | 51 | type Mutation { 52 | createPost(post: PostInput!): Post 53 | } 54 | ` 55 | 56 | const resolvers = { 57 | Post: { 58 | __resolveReference: (post, args, context, info) => { 59 | return posts[post.pid] 60 | }, 61 | category: (post, args, context, info) => { 62 | return { 63 | __typename: 'Category', 64 | id: post.categoryId 65 | } 66 | } 67 | }, 68 | Category: { 69 | topPosts: (category, { count }, context, info) => { 70 | return Object.values(posts) 71 | .filter((p) => p.categoryId === category.id) 72 | .slice(0, count) 73 | } 74 | }, 75 | Query: { 76 | posts: (root, args, context, info) => { 77 | return Object.values(posts) 78 | }, 79 | topPosts: (root, { count = 2 }, context, info) => { 80 | return Object.values(posts).slice(0, count) 81 | } 82 | }, 83 | Mutation: { 84 | createPost: (root, { post }) => { 85 | const pid = `p${Object.values(posts).length + 1}` 86 | 87 | const result = { 88 | pid, 89 | ...post 90 | } 91 | 92 | posts[pid] = result 93 | return result 94 | } 95 | } 96 | } 97 | 98 | await service.register(mercuriusFederation, { 99 | schema, 100 | resolvers, 101 | graphiql: true, 102 | jit: 1 103 | }) 104 | 105 | await service.register(redis) 106 | 107 | await service.register( 108 | cache, 109 | { 110 | ttl: 10, 111 | storage: { 112 | type: 'redis', 113 | options: { client: service.redis, invalidation: true } 114 | }, 115 | onHit: function (type, fieldName) { 116 | service.log.info({ msg: 'Hit from cache', type, fieldName }) 117 | }, 118 | onMiss: function (type, fieldName) { 119 | service.log.info({ msg: 'Miss from cache', type, fieldName }) 120 | }, 121 | onError (type, fieldName, error) { 122 | service.log.error(`Error on ${type} ${fieldName}`, error) 123 | }, 124 | policy: { 125 | Post: { 126 | category: true, 127 | __resolveReference: true 128 | }, 129 | Query: { 130 | posts: { 131 | references: (_, __, result) => ['posts'] 132 | } 133 | }, 134 | Mutation: { 135 | createPost: { 136 | invalidate: (self, arg, ctx, info, result) => ['posts'] 137 | } 138 | } 139 | } 140 | }, 141 | { dependencies: ['@fastify/redis'] } 142 | ) 143 | 144 | await service.listen({ port: 4001 }) 145 | } 146 | 147 | async function createCategoriesService () { 148 | const service = Fastify({ logger: true }) 149 | 150 | const categories = { 151 | c1: { 152 | id: 'c1', 153 | name: 'Food' 154 | }, 155 | c2: { 156 | id: 'c2', 157 | name: 'Places' 158 | } 159 | } 160 | 161 | const schema = ` 162 | type Query @extends { 163 | categories: [Category] 164 | } 165 | 166 | type Category @key(fields: "id") { 167 | id: ID! 168 | name: String 169 | } 170 | ` 171 | 172 | const resolvers = { 173 | Query: { 174 | categories: (root, args, context, info) => { 175 | return Object.values(categories) 176 | } 177 | }, 178 | Category: { 179 | __resolveReference: (category, args, context, info) => { 180 | return categories[category.id] 181 | } 182 | } 183 | } 184 | 185 | await service.register(mercuriusFederation, { 186 | schema, 187 | resolvers, 188 | graphiql: true, 189 | jit: 1 190 | }) 191 | 192 | await service.register(redis) 193 | 194 | await service.register(cache, { 195 | ttl: 10, 196 | storage: { 197 | type: 'redis', 198 | options: { client: service.redis, invalidation: true } 199 | }, 200 | onHit: function (type, fieldName) { 201 | service.log.info({ msg: 'Hit from cache', type, fieldName }) 202 | }, 203 | onMiss: function (type, fieldName) { 204 | service.log.info({ msg: 'Miss from cache', type, fieldName }) 205 | }, 206 | onError (type, fieldName, error) { 207 | service.log.error(`Error on ${type} ${fieldName}`, error) 208 | }, 209 | policy: { 210 | Category: { __resolveReference: true } 211 | } 212 | }, 213 | { dependencies: ['@fastify/redis'] } 214 | ) 215 | 216 | await service.listen({ port: 4002 }) 217 | } 218 | 219 | async function main () { 220 | const gateway = Fastify({ logger: true }) 221 | 222 | await createPostService() 223 | await createCategoriesService() 224 | 225 | await gateway.register(mercuriusGateway, { 226 | graphiql: true, 227 | jit: 1, 228 | gateway: { 229 | services: [ 230 | { 231 | name: 'post', 232 | url: 'http://localhost:4001/graphql' 233 | }, 234 | { 235 | name: 'category', 236 | url: 'http://localhost:4002/graphql' 237 | } 238 | ] 239 | } 240 | }) 241 | 242 | await gateway.register(redis) 243 | 244 | await gateway.register( 245 | cache, 246 | { 247 | ttl: 120, 248 | storage: { 249 | type: 'redis', 250 | options: { client: gateway.redis, invalidation: true } 251 | }, 252 | onHit: function (type, fieldName) { 253 | gateway.log.info({ msg: 'Hit from cache', type, fieldName }) 254 | }, 255 | onMiss: function (type, fieldName) { 256 | gateway.log.info({ msg: 'Miss from cache', type, fieldName }) 257 | }, 258 | onError (type, fieldName, error) { 259 | gateway.log.error(`Error on ${type} ${fieldName}`, error) 260 | }, 261 | policy: { 262 | Query: { 263 | categories: true, 264 | topPosts: { 265 | references: (_, __, result) => ['posts'] 266 | } 267 | }, 268 | Post: { 269 | category: true 270 | }, 271 | Category: { 272 | topPosts: { 273 | references: (_, __, result) => ['posts'] 274 | } 275 | } 276 | } 277 | }, 278 | { dependencies: ['@fastify/redis'] } 279 | ) 280 | 281 | await gateway.listen({ port: 4000 }) 282 | } 283 | 284 | main() 285 | -------------------------------------------------------------------------------- /examples/invalidation-with-subscription.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('mercurius-cache') 6 | 7 | async function main () { 8 | const app = fastify({ logger: true }) 9 | 10 | const schema = ` 11 | type Notification { 12 | id: ID! 13 | message: String 14 | } 15 | 16 | type Query { 17 | notifications: [Notification] 18 | } 19 | 20 | type Mutation { 21 | addNotification(message: String): Notification 22 | } 23 | 24 | type Subscription { 25 | notificationAdded: Notification 26 | } 27 | ` 28 | 29 | const notifications = [ 30 | { 31 | id: 1, 32 | message: 'Notification message' 33 | } 34 | ] 35 | 36 | const resolvers = { 37 | Query: { 38 | notifications: (_, __, { app }) => { 39 | app.log.info('Requesting notifications') 40 | return notifications 41 | } 42 | }, 43 | Mutation: { 44 | addNotification: async (_, { message }, { pubsub }) => { 45 | app.log.info('Adding a notification') 46 | 47 | const notification = { 48 | id: notifications.length + 1, 49 | message 50 | } 51 | 52 | notifications.push(notification) 53 | await pubsub.publish({ 54 | topic: 'NOTIFICATION_ADDED', 55 | payload: { 56 | notificationAdded: notification 57 | } 58 | }) 59 | 60 | return notification 61 | } 62 | }, 63 | Subscription: { 64 | notificationAdded: { 65 | subscribe: async (root, args, { pubsub }) => 66 | await pubsub.subscribe('NOTIFICATION_ADDED') 67 | } 68 | } 69 | } 70 | 71 | app.register(mercurius, { 72 | schema, 73 | resolvers, 74 | graphiql: true, 75 | subscription: true 76 | }) 77 | 78 | app.register( 79 | cache, 80 | { 81 | ttl: 10, 82 | storage: { 83 | type: 'memory', 84 | options: { invalidation: true } 85 | }, 86 | onHit: function (type, fieldName) { 87 | app.log.info({ msg: 'Hit from cache', type, fieldName }) 88 | }, 89 | onMiss: function (type, fieldName) { 90 | app.log.info({ msg: 'Miss from cache', type, fieldName }) 91 | }, 92 | policy: { 93 | Query: { 94 | notifications: { 95 | references: (_, __, result) => { 96 | if (!result) { return } 97 | return [...result.map(notification => (`notification:${notification.id}`)), 'notifications'] 98 | } 99 | } 100 | }, 101 | Mutation: { 102 | addNotification: { 103 | // invalidate the notifications, because it may includes now the new notification 104 | invalidate: (self, arg, ctx, info, result) => ['notifications'] 105 | } 106 | } 107 | } 108 | } 109 | ) 110 | 111 | await app.listen({ port: 3000 }) 112 | } 113 | 114 | main() 115 | -------------------------------------------------------------------------------- /examples/invalidation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const redis = require('fastify-redis') 6 | const fp = require('fastify-plugin') 7 | const cache = require('mercurius-cache') 8 | 9 | async function main () { 10 | const app = fastify({ logger: true }) 11 | 12 | const schema = ` 13 | type Country { 14 | name: String 15 | } 16 | 17 | type User { 18 | id: ID! 19 | name: String! 20 | } 21 | 22 | type Query { 23 | user(id: ID!): User 24 | countries: [Country] 25 | } 26 | 27 | input UserInput { 28 | name: String! 29 | } 30 | 31 | type Mutation { 32 | updateUser (id: ID!, user: UserInput!): User! 33 | } 34 | ` 35 | 36 | const resolvers = { 37 | Query: { 38 | user (_, { id }, { app }) { 39 | app.log.info(`requesting user with an id ${id}`) 40 | return { id, name: `User ${id}` } 41 | }, 42 | countries (_, __, { app }) { 43 | app.log.info('requesting countries') 44 | return [{ name: 'Ireland' }, { name: 'Italy' }] 45 | } 46 | }, 47 | Mutation: { 48 | updateUser (_, { id, user }, { app }) { 49 | app.log.info(`updating a user with an id ${id}`) 50 | return { id, name: user.name } 51 | } 52 | } 53 | } 54 | 55 | app.register(mercurius, { 56 | schema, 57 | resolvers 58 | }) 59 | 60 | app.register(redis) 61 | 62 | app.register(fp(async app => { 63 | app.register(cache, { 64 | ttl: 10, 65 | // default storage is redis 66 | storage: { 67 | type: 'redis', 68 | options: { client: app.redis, invalidation: true } 69 | }, 70 | onHit (type, fieldName) { 71 | console.log(`hit ${type} ${fieldName}`) 72 | }, 73 | onMiss (type, fieldName) { 74 | console.log(`miss ${type} ${fieldName}`) 75 | }, 76 | policy: { 77 | Query: { 78 | user: { 79 | // references: the user by id 80 | references: (_request, _key, result) => { 81 | if (!result) { 82 | return 83 | } 84 | return [`user:${result.id}`] 85 | } 86 | }, 87 | // since countries rarely change, we can cache them for a very long time 88 | // and since their size is small, it's convenient to cache them in memory 89 | countries: { 90 | ttl: 86400, // 1 day 91 | // don't really need invalidation, just as example 92 | storage: { type: 'memory', options: { invalidation: true } }, 93 | references: () => ['countries'] 94 | } 95 | }, 96 | Mutation: { 97 | updateUser: { 98 | // invalidate the user 99 | invalidate: (self, arg, _ctx, _info, _result) => [`user:${arg.id}`] 100 | } 101 | } 102 | } 103 | }, { dependencies: ['fastify-redis'] }) 104 | })) 105 | 106 | await app.listen(3000) 107 | 108 | // manual invalidation 109 | 110 | // wildcard 111 | app.graphql.cache.invalidate('user:*') 112 | 113 | // with a reference 114 | app.graphql.cache.invalidate('user:1') 115 | 116 | // with an array of references 117 | app.graphql.cache.invalidate(['user:1', 'user:2']) 118 | 119 | // using a specific storage 120 | // note "countries" uses a different storage from the default one 121 | // so need to specify it, otherwise it will invalidate on the default storage 122 | app.graphql.cache.invalidate('countries', 'Query.countries') 123 | } 124 | 125 | main() 126 | -------------------------------------------------------------------------------- /examples/policy-options.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const cache = require('mercurius-cache') 6 | 7 | const app = fastify({ logger: true }) 8 | 9 | function slowdown () { 10 | for (let i = 0; i < 10000000; i++) { 11 | // empty for a reason 12 | } 13 | } 14 | 15 | const schema = ` 16 | type Query { 17 | add(x: Int, y: Int): Int 18 | sub(x: Int, y: Int): Int 19 | multiply(x: Int, y: Int): Int 20 | divide(x: Int, y: Int): Int 21 | } 22 | ` 23 | 24 | const resolvers = { 25 | Query: { 26 | async add (_, { x, y }, { reply }) { 27 | reply.log.info('add called') 28 | slowdown() 29 | return x + y 30 | }, 31 | async sub (_, { x, y }, { reply }) { 32 | reply.log.info('sub called') 33 | slowdown() 34 | return x - y 35 | }, 36 | async multiply (_, { x, y }, { reply }) { 37 | reply.log.info('multiply called') 38 | slowdown() 39 | return x * y 40 | }, 41 | async divide (_, { x, y }, { reply }) { 42 | reply.log.info('divide called') 43 | slowdown() 44 | return Math.floor(x / y) 45 | } 46 | } 47 | } 48 | 49 | app.register(mercurius, { 50 | schema, 51 | resolvers 52 | }) 53 | 54 | app.register(cache, { 55 | ttl: 10, 56 | storage: { type: 'memory', options: { size: 10 } }, 57 | policy: { 58 | Query: { 59 | add: { ttl: 1, storage: { type: 'memory', options: { size: 1 } } }, 60 | sub: { ttl: 2, storage: { type: 'memory', options: { size: 2 } } }, 61 | multiply: { ttl: 3, storage: { type: 'memory', options: { size: 3 } } }, 62 | divide: { ttl: 4, storage: { type: 'memory', options: { size: 4 } } } 63 | } 64 | } 65 | }) 66 | 67 | app.listen(3000) 68 | 69 | // Use the following to test 70 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql 71 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ sub(x: 2, y: 2) }" }' localhost:3000/graphql 72 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ multiply(x: 2, y: 2) }" }' localhost:3000/graphql 73 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ divide(x: 2, y: 2) }" }' localhost:3000/graphql 74 | -------------------------------------------------------------------------------- /examples/redis-gc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /// ///////////////////////////////////////////////////////////////////////////// 4 | // 5 | // run this scrips as 6 | // 7 | // REDIS_GC_MODE=lazy \ 8 | // REDIS_GC_CONNECTION=localhost:6379 \ 9 | // REDIS_GC_CHUNK=1000 \ 10 | // ./redis-gc 11 | // 12 | // or use an .env file to set the environment variables 13 | // 14 | // scheduled as: every 3 minutes lazy, every 15 minutes strict 15 | // 16 | // */3 * * * * export REDIS_GC_MODE=lazy REDIS_GC_LAZY_CHUNK=100; /redis-gc 17 | // */15 * * * * export REDIS_GC_MODE=strict REDIS_GC_CHUNK=100; /redis-gc 18 | // 19 | /// ///////////////////////////////////////////////////////////////////////////// 20 | 21 | // TODO test 22 | 23 | if (process.argv[2]) { 24 | require('dotenv').config({ path: process.argv[2] }) 25 | } 26 | 27 | const fs = require('fs').promises 28 | const path = require('path') 29 | const os = require('os') 30 | const Redis = require('ioredis') 31 | const { createStorage } = require('async-cache-dedupe') 32 | 33 | const log = require('pino')() 34 | 35 | const mode = process.env.REDIS_GC_MODE || 'lazy' 36 | const connection = process.env.REDIS_GC_CONNECTION || 'redis://localhost:6379' 37 | const chunk = process.env.REDIS_GC_CHUNK ? Number(process.env.REDIS_GC_CHUNK) : 1000 38 | const lazyChunk = process.env.REDIS_GC_LAZY_CHUNK ? Number(process.env.REDIS_GC_LAZY_CHUNK) : 1000 39 | 40 | async function main () { 41 | const start = Date.now() 42 | // retrieve last cursor 43 | let cursor = process.env.REDIS_GC_LAZY_CURSOR ? Number(process.env.REDIS_GC_LAZY_CURSOR) : undefined 44 | if (!cursor && mode === 'lazy') { 45 | try { 46 | cursor = await fs.readFile(path.join(os.tmpdir(), 'mercurius-cache-gc'), 'utf8') 47 | cursor = Number(cursor) 48 | } catch (e) { } 49 | } 50 | 51 | const client = new Redis(connection) 52 | const storage = createStorage('redis', { log, client, invalidation: true }) 53 | const report = await storage.gc(mode, { chunk, lazy: { chunk: lazyChunk, cursor } }) 54 | 55 | // need to save cursor to complete the iteration in lazy mode 56 | if (mode === 'lazy') { 57 | await fs.writeFile(path.join(os.tmpdir(), 'mercurius-cache-gc'), String(report.cursor ?? 0), 'utf8') 58 | } 59 | 60 | const d = Date.now() - start 61 | log.info({ msg: `gc done in ${d} ms`, mode, report }) 62 | 63 | await client.end() 64 | } 65 | 66 | main() 67 | -------------------------------------------------------------------------------- /examples/redis.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const redis = require('fastify-redis') 6 | const fp = require('fastify-plugin') 7 | const cache = require('mercurius-cache') 8 | 9 | const app = fastify({ logger: true }) 10 | 11 | const schema = ` 12 | type Query { 13 | add(x: Int, y: Int): Int 14 | hello: String 15 | } 16 | ` 17 | 18 | const resolvers = { 19 | Query: { 20 | async add (_, { x, y }, { reply }) { 21 | reply.log.info('add called') 22 | for (let i = 0; i < 10000000; i++) { 23 | // empty for a reason 24 | } 25 | return x + y 26 | }, 27 | async hello () { 28 | return 'world' 29 | } 30 | } 31 | } 32 | 33 | app.register(mercurius, { 34 | schema, 35 | resolvers 36 | }) 37 | 38 | app.register(redis) 39 | 40 | app.register(fp(async app => { 41 | app.register(cache, { 42 | ttl: 60, 43 | policy: { 44 | Query: { 45 | add: true, 46 | hello: true 47 | } 48 | }, 49 | storage: { 50 | type: 'redis', 51 | options: { 52 | client: app.redis 53 | } 54 | }, 55 | onHit: function (type, fieldName) { 56 | app.log.info({ msg: 'hit from cache', type, fieldName }) 57 | }, 58 | onMiss: function (type, fieldName) { 59 | app.log.info({ msg: 'miss from cache', type, fieldName }) 60 | } 61 | }, { dependencies: ['fastify-redis'] }) 62 | })) 63 | 64 | app.listen(3000) 65 | 66 | // Use the following to test 67 | // curl -X POST -H 'content-type: application/json' -d '{ "query": "{ add(x: 2, y: 2) }" }' localhost:3000/graphql 68 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import { MercuriusPlugin } from "mercurius"; 3 | 4 | export type TtlFunction = (...args: any[]) => number; 5 | export interface PolicyFieldOptions { 6 | ttl?: number | TtlFunction; 7 | stale?: number; 8 | storage?: MercuriusCacheStorageMemory | MercuriusCacheStorageRedis; 9 | extendKey?: Function; 10 | skip?: Function; 11 | invalidate?: Function; 12 | references?: Function; 13 | } 14 | 15 | export type PolicyFieldName = string; 16 | export type PolicyField = Record; 17 | export type PolicyName = string; 18 | export type MercuriusCachePolicy = Record; 19 | 20 | export interface MercuriusCacheStorageMemoryOptions { 21 | size: number; 22 | log?: object; 23 | invalidation?: boolean; 24 | } 25 | 26 | export interface MercuriusCacheStorageRedisOptions { 27 | client: object; 28 | log?: object; 29 | invalidation?: boolean | { invalidate: boolean; referencesTTL?: number }; 30 | } 31 | 32 | export enum MercuriusCacheStorageType { 33 | MEMORY = "memory", 34 | REDIS = "redis", 35 | } 36 | export interface MercuriusCacheStorage { 37 | type: "memory" | "redis"; 38 | } 39 | export interface MercuriusCacheStorageMemory extends MercuriusCacheStorage { 40 | options?: MercuriusCacheStorageMemoryOptions; 41 | } 42 | 43 | export interface MercuriusCacheStorageRedis extends MercuriusCacheStorage { 44 | options?: MercuriusCacheStorageRedisOptions; 45 | } 46 | 47 | export interface MercuriusCacheOptions { 48 | all?: boolean; 49 | policy?: MercuriusCachePolicy; 50 | ttl?: number | TtlFunction; 51 | stale?: number; 52 | skip?: Function; 53 | storage?: MercuriusCacheStorageMemory | MercuriusCacheStorageRedis; 54 | onDedupe?: Function; 55 | onHit?: Function; 56 | onMiss?: Function; 57 | onSkip?: Function; 58 | onError?: Function; 59 | logInterval?: number; 60 | logReport?: Function; 61 | } 62 | export interface QueryFieldData { 63 | dedupes: number; 64 | hits: number; 65 | misses: number; 66 | skips: number; 67 | } 68 | 69 | export type QueryFieldName = string; 70 | export type ReportData = Record; 71 | 72 | export declare class Report { 73 | constructor( 74 | app: object, 75 | all?: boolean, 76 | policy?: any, 77 | logInterval?: number, 78 | logReport?: Function 79 | ); 80 | 81 | log: object; 82 | logReport: Function; 83 | logInterval: number; 84 | logTimer: Function; 85 | data: ReportData; 86 | 87 | init(options: MercuriusCacheOptions): void; 88 | clear(): void; 89 | defaultLog(): void; 90 | logReportAndClear(): void; 91 | refresh(): void; 92 | close(): void; 93 | wrap( 94 | name: string, 95 | onDedupe: Function, 96 | onHit: Function, 97 | onMiss: Function, 98 | onSkip: Function 99 | ): void; 100 | } 101 | 102 | /** Mercurius Cache is a plugin that adds an in-process caching layer to Mercurius. */ 103 | declare const mercuriusCache: FastifyPluginAsync; 104 | 105 | export interface MercuriusCacheContext { 106 | refresh(): void; 107 | clear(): void; 108 | invalidate(references: string | string[], storage?: string): void 109 | } 110 | 111 | declare module "mercurius" { 112 | interface MercuriusPlugin { 113 | cache?: MercuriusCacheContext; 114 | } 115 | } 116 | 117 | export default mercuriusCache; 118 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const { createCache } = require('async-cache-dedupe') 5 | const { validateOpts } = require('./lib/validation') 6 | const createReport = require('./lib/report') 7 | 8 | module.exports = fp(async function (app, opts) { 9 | const { all, policy, ttl, stale, skip, storage, onDedupe, onHit, onMiss, onSkip, onError, logInterval, logReport } = validateOpts(app, opts) 10 | 11 | let cache = null 12 | let report = null 13 | 14 | app.graphql.cache = { 15 | refresh () { 16 | buildCache() 17 | setupSchema(app.graphql.schema, policy, all, cache, skip, onDedupe, onHit, onMiss, onSkip, onError, report) 18 | }, 19 | 20 | invalidate (references, storage) { 21 | return cache.invalidateAll(references, storage) 22 | }, 23 | 24 | clear () { 25 | cache.clear() 26 | report.clear() 27 | } 28 | } 29 | 30 | app.addHook('onReady', async () => { 31 | app.graphql.cache.refresh() 32 | report.refresh() 33 | }) 34 | 35 | app.addHook('onClose', () => { 36 | report && report.close() 37 | }) 38 | 39 | if (app.graphqlGateway) { 40 | // Add hook to regenerate the resolvers when the schema is refreshed 41 | app.graphqlGateway.addHook('onGatewayReplaceSchema', async (instance, schema) => { 42 | buildCache() 43 | setupSchema(schema, policy, all, cache, skip, onDedupe, onHit, onMiss, onSkip, onError, report) 44 | }) 45 | } 46 | 47 | function buildCache () { 48 | // Default the first two parameters of onError(prefix, fieldName, err) 49 | cache = createCache({ ttl, stale, storage, onError: onError.bind(null, 'Internal Error', 'async-cache-dedupe') }) 50 | report = createReport({ app, all, policy, logInterval, logReport }) 51 | } 52 | }, { 53 | fastify: '5.x', 54 | dependencies: ['mercurius'] 55 | }) 56 | 57 | function setupSchema (schema, policy, all, cache, skip, onDedupe, onHit, onMiss, onSkip, onError, report) { 58 | const schemaTypeMap = schema.getTypeMap() 59 | // validate policies vs schema 60 | const policies = !all && policy 61 | ? Object.keys(policy).reduce((o, key) => { 62 | o.push(...Object.keys(policy[key]).map(fieldName => `${key}.${fieldName}`)) 63 | return o 64 | }, []) 65 | : [] 66 | 67 | for (const schemaType of Object.values(schemaTypeMap)) { 68 | const fieldPolicy = all || policy[schemaType.name] 69 | if (!fieldPolicy) { 70 | continue 71 | } 72 | 73 | // Handle fields on schema type 74 | if (typeof schemaType.getFields === 'function') { 75 | for (const [fieldName, field] of Object.entries(schemaType.getFields())) { 76 | const policy = getPolicyOptions(fieldPolicy, fieldName) 77 | if (all || policy) { 78 | // Override resolvers for caching purposes 79 | if (typeof field.resolve === 'function') { 80 | const originalFieldResolver = field.resolve 81 | if (!all) { 82 | policies.splice(policies.indexOf(`${schemaType.name}.${fieldName}`), 1) 83 | } 84 | field.resolve = makeCachedResolver(schemaType.name, fieldName, cache, originalFieldResolver, policy, skip, onDedupe, onHit, onMiss, onSkip, onError, report) 85 | } 86 | } 87 | } 88 | } 89 | 90 | if (typeof schemaType.resolveReference === 'function') { 91 | const resolver = '__resolveReference' 92 | const policy = getPolicyOptions(fieldPolicy, '__resolveReference') 93 | if (policy) { 94 | // Override reference resolver for caching purposes 95 | const originalResolver = schemaType.resolveReference 96 | policies.splice(policies.indexOf(`${schemaType.name}.${resolver}`), 1) 97 | schemaType.resolveReference = makeCachedResolver(schemaType.name, resolver, cache, originalResolver, policy, skip, onDedupe, onHit, onMiss, onSkip, onError, report) 98 | } 99 | } 100 | } 101 | 102 | if (!all && policies.length) { 103 | throw new Error(`policies does not match schema: ${policies.join(', ')}, it must be a resolver or a loader`) 104 | } 105 | } 106 | 107 | function getPolicyOptions (fieldPolicy, fieldName) { 108 | if (!fieldPolicy[fieldName]) { 109 | return 110 | } 111 | 112 | if (fieldPolicy[fieldName].__options) { 113 | return fieldPolicy[fieldName].__options 114 | } 115 | 116 | return fieldPolicy[fieldName] 117 | } 118 | 119 | function makeCachedResolver (prefix, fieldName, cache, originalFieldResolver, policy, skip, onDedupe, onHit, onMiss, onSkip, onError, report) { 120 | const name = prefix + '.' + fieldName 121 | 122 | onDedupe = onDedupe.bind(null, prefix, fieldName) 123 | onHit = onHit.bind(null, prefix, fieldName) 124 | onMiss = onMiss.bind(null, prefix, fieldName) 125 | onSkip = onSkip.bind(null, prefix, fieldName) 126 | onError = onError.bind(null, prefix, fieldName) 127 | 128 | report.wrap({ name, onDedupe, onHit, onMiss, onSkip }) 129 | 130 | let ttl, stale, storage, references, invalidate 131 | if (policy) { 132 | ttl = policy.ttl 133 | stale = policy.stale 134 | storage = policy.storage 135 | references = policy.references 136 | invalidate = policy.invalidate 137 | } 138 | 139 | cache.define(name, { 140 | onDedupe: report[name].onDedupe, 141 | onHit: report[name].onHit, 142 | onMiss: report[name].onMiss, 143 | onError, 144 | ttl, 145 | stale, 146 | storage, 147 | references, 148 | 149 | serialize ({ self, arg, info, ctx }) { 150 | // We need to cache only for the selected fields to support Federation 151 | // TODO detect if we really need to do this in most cases 152 | const fields = [] 153 | for (let i = 0; i < info.fieldNodes.length; i++) { 154 | const node = info.fieldNodes[i] 155 | if (!node.selectionSet) { 156 | continue 157 | } 158 | for (let j = 0; j < node.selectionSet.selections.length; j++) { 159 | if (node.selectionSet.selections[j].kind === 'InlineFragment') { 160 | fields.push(...node.selectionSet.selections[j].selectionSet.selections.map(s => s.name.value)) 161 | } else { // kind = 'Field' 162 | fields.push(node.selectionSet.selections[j].name.value) 163 | } 164 | } 165 | } 166 | fields.sort() 167 | 168 | // We must skip ctx and info as they are not easy to serialize 169 | const id = { self, arg, fields } 170 | 171 | if (!policy) { 172 | return id 173 | } 174 | 175 | // use a custom policy serializer if any 176 | if (policy.key) { 177 | return policy.key({ self, arg, info, ctx, fields }) 178 | } else if (policy.extendKey) { 179 | const append = policy.extendKey(self, arg, ctx, info) 180 | if (append) { 181 | id.extendKey = '~' + append 182 | } 183 | } 184 | 185 | return id 186 | } 187 | }, async function ({ self, arg, ctx, info }) { 188 | return originalFieldResolver(self, arg, ctx, info) 189 | }) 190 | 191 | return async function (self, arg, ctx, info) { 192 | let result 193 | let resolved 194 | 195 | // dont use cache on mutation and subscriptions 196 | [result, resolved] = await getResultForMutationSubscription({ self, arg, ctx, info, originalFieldResolver }) 197 | 198 | // dont use cache on skip by policy or by general skip 199 | if (!resolved) { 200 | [result, resolved] = await getResultIfSkipDefined({ self, arg, ctx, info, skip, policy, name, report, originalFieldResolver, onError }) 201 | } 202 | 203 | // use cache to get the result 204 | if (!resolved) { 205 | // in case of che original resolver will throw, the error will be forwarded 206 | // onError is already been called by cache events binding 207 | result = await cache[name]({ self, arg, ctx, info }) 208 | } 209 | 210 | if (invalidate) { 211 | // Invalidates references and calls onError if fails 212 | await invalidation(invalidate, cache, name, self, arg, ctx, info, result, onError) 213 | } 214 | 215 | return result 216 | } 217 | } 218 | 219 | async function invalidation (invalidate, cache, name, self, arg, ctx, info, result, onError) { 220 | try { 221 | const references = await invalidate(self, arg, ctx, info, result) 222 | await cache.invalidate(name, references) 223 | } catch (err) { 224 | onError(err) 225 | } 226 | } 227 | 228 | async function getResultForMutationSubscription ({ self, arg, ctx, info, originalFieldResolver }) { 229 | const resolved = false 230 | let result = null 231 | if (info.operation && (info.operation.operation === 'mutation' || info.operation.operation === 'subscription')) { 232 | result = await originalFieldResolver(self, arg, ctx, info) 233 | return [result, true] 234 | } 235 | return [result, resolved] 236 | } 237 | 238 | async function getResultIfSkipDefined ({ self, arg, ctx, info, skip, policy, name, report, originalFieldResolver, onError }) { 239 | const resolved = false 240 | let result = null 241 | let isSkipped = false 242 | try { 243 | isSkipped = ((skip && (await skip(self, arg, ctx, info))) || 244 | (policy && policy.skip && (await policy.skip(self, arg, ctx, info)))) 245 | } catch (error) { 246 | onError(error) 247 | result = await originalFieldResolver(self, arg, ctx, info) 248 | return [result, true] 249 | } 250 | 251 | if (isSkipped) { 252 | report[name].onSkip() 253 | result = await originalFieldResolver(self, arg, ctx, info) 254 | return [result, true] 255 | } 256 | return [result, resolved] 257 | } 258 | -------------------------------------------------------------------------------- /lib/report.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class Report { 4 | constructor (opts) { 5 | this.logReport = opts.logReport || this.defaultLog 6 | this.logInterval = opts.logInterval 7 | this.logTimer = null 8 | this.data = {} 9 | 10 | this.init(opts) 11 | } 12 | 13 | init (opts) { 14 | this.log = opts.app.log 15 | const schema = opts.app.graphql.schema 16 | const fields = opts.all 17 | ? Object.keys(schema.getQueryType().getFields()).map(field => `${schema.getQueryType()}.${field}`) 18 | : this.getPolicies(opts.policy) 19 | 20 | for (const field of fields) { 21 | this.data[field] = {} 22 | this.data[field].dedupes = 0 23 | this.data[field].hits = 0 24 | this.data[field].misses = 0 25 | this.data[field].skips = 0 26 | } 27 | } 28 | 29 | getPolicies (policies) { 30 | const fields = [] 31 | for (const policy of Object.keys(policies)) { 32 | if (policy === 'Mutation' || policy === 'Subscription') { 33 | continue 34 | } 35 | 36 | for (const field of Object.keys(policies[policy])) { 37 | fields.push(`${policy}.${field}`) 38 | } 39 | } 40 | return fields 41 | } 42 | 43 | clear () { 44 | for (const item of Object.keys(this.data)) { 45 | this.data[item].dedupes = 0 46 | this.data[item].hits = 0 47 | this.data[item].misses = 0 48 | this.data[item].skips = 0 49 | } 50 | } 51 | 52 | defaultLog () { 53 | this.log && this.log.info({ data: this.data }, 'mercurius-cache report') 54 | } 55 | 56 | logReportAndClear () { 57 | this.logReport(this.data) 58 | this.clear() 59 | } 60 | 61 | refresh () { 62 | this.logTimer = setInterval(() => this.logReportAndClear(), this.logInterval * 1000).unref() 63 | } 64 | 65 | close () { 66 | // istanbul ignore next 67 | if (!this.logTimer) { return } 68 | clearInterval(this.logTimer) 69 | } 70 | 71 | wrap ({ name, onDedupe, onHit, onMiss, onSkip }) { 72 | this[name] = { 73 | onDedupe: () => { 74 | this.data[name].dedupes++ 75 | onDedupe() 76 | }, 77 | onHit: () => { 78 | this.data[name].hits++ 79 | onHit() 80 | }, 81 | onMiss: () => { 82 | this.data[name].misses++ 83 | onMiss() 84 | }, 85 | onSkip: () => { 86 | this.data[name].skips++ 87 | onSkip() 88 | } 89 | } 90 | } 91 | } 92 | 93 | function createReport ({ app, all, policy, logInterval, logReport }) { 94 | if (!logInterval || !((policy && policy[app.graphql.schema.getQueryType()]) || all)) { 95 | const disabled = { 96 | clear: noop, 97 | refresh: noop, 98 | close: noop, 99 | wrap: ({ name, onDedupe, onHit, onMiss, onSkip }) => { 100 | disabled[name] = { onDedupe, onHit, onMiss, onSkip } 101 | } 102 | } 103 | return disabled 104 | } 105 | 106 | return new Report({ app, all, policy, logInterval, logReport }) 107 | } 108 | 109 | function noop () { } 110 | 111 | module.exports = createReport 112 | -------------------------------------------------------------------------------- /lib/validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function validateOpts (app, opts = {}) { 4 | let { all, policy, ttl, stale, skip, storage, onDedupe, onHit, onMiss, onSkip, onError, logInterval, logReport } = opts 5 | 6 | if (all && typeof all !== 'boolean') { 7 | throw new Error('all must be a boolean') 8 | } 9 | 10 | if (all && policy) { 11 | throw new Error('policy and all options are exclusive') 12 | } 13 | 14 | if (typeof ttl !== 'undefined' && typeof ttl !== 'function' && (typeof ttl !== 'number' || ttl < 0 || isNaN(ttl))) { 15 | throw new Error('ttl must be a function or a number greater than 0') 16 | } 17 | 18 | if (typeof stale !== 'undefined' && (typeof stale !== 'number' || stale < 0 || isNaN(stale))) { 19 | throw new Error('stale must be a number greater than 0') 20 | } 21 | 22 | if (skip && typeof skip !== 'function') { 23 | throw new Error('skip must be a function') 24 | } 25 | 26 | if (onDedupe) { 27 | if (typeof onDedupe !== 'function') { 28 | throw new Error('onDedupe must be a function') 29 | } 30 | } else { 31 | onDedupe = noop 32 | } 33 | 34 | // TODO move report options validation to report lib 35 | if (logInterval && (typeof logInterval !== 'number' || logInterval < 1)) { 36 | throw new Error('logInterval must be a number greater than 1') 37 | } 38 | 39 | if (logReport && typeof logReport !== 'function') { 40 | throw new Error('logReport must be a function') 41 | } 42 | 43 | if (onHit) { 44 | if (typeof onHit !== 'function') { 45 | throw new Error('onHit must be a function') 46 | } 47 | } else { 48 | onHit = noop 49 | } 50 | 51 | if (onMiss) { 52 | if (typeof onMiss !== 'function') { 53 | throw new Error('onMiss must be a function') 54 | } 55 | } else { 56 | onMiss = noop 57 | } 58 | 59 | if (onSkip) { 60 | if (typeof onSkip !== 'function') { 61 | throw new Error('onSkip must be a function') 62 | } 63 | } else { 64 | onSkip = noop 65 | } 66 | 67 | if (onError) { 68 | if (typeof onError !== 'function') { 69 | throw new Error('onError must be a function') 70 | } 71 | } else if (app.log && app.log.debug && typeof app.log.debug === 'function') { 72 | onError = defaultDebug(app) 73 | } else { 74 | onError = noop 75 | } 76 | 77 | if (!ttl) { 78 | ttl = 0 79 | } 80 | let maxTTL = typeof ttl === 'number' ? ttl : 0 81 | if (policy) { 82 | if (typeof policy !== 'object' && !all) { 83 | throw new Error('policy must be an object') 84 | } 85 | 86 | for (const type of Object.keys(policy)) { 87 | const policyType = policy[type] 88 | for (const name of Object.keys(policyType)) { 89 | const policyField = validateNestedPolicy(policyType, name) 90 | 91 | if ( 92 | typeof policyField.ttl !== 'undefined' && 93 | typeof policyField.ttl !== 'function' && 94 | (typeof policyField.ttl !== 'number' || policyField.ttl < 0 || isNaN(policyField.ttl)) 95 | ) { 96 | throw new Error(`policy '${type}.${name}' ttl must be a function or a number greater than 0`) 97 | } 98 | if ( 99 | typeof policyField.stale !== 'undefined' && 100 | (typeof policyField.stale !== 'number' || policyField.stale < 0 || isNaN(policyField.stale)) 101 | ) { 102 | throw new Error(`policy '${type}.${name}' stale must be a number greater than 0`) 103 | } 104 | if (policyField.storage) { 105 | const validation = validateStorage(app, policyField.storage) 106 | if (validation.errorMessage) { 107 | throw new Error(`policy '${type}.${name}' storage ${validation.errorMessage}`) 108 | } 109 | policyField.storage = validation.storage 110 | } 111 | if (policyField.key && policyField.extendKey) { 112 | throw new Error(`policy '${type}.${name}' key and extendKey are exclusive`) 113 | } 114 | if (policyField.key && typeof policyField.key !== 'function') { 115 | throw new Error(`policy '${type}.${name}' key must be a function`) 116 | } 117 | if (policyField.extendKey && typeof policyField.extendKey !== 'function') { 118 | throw new Error(`policy '${type}.${name}' extendKey must be a function`) 119 | } 120 | if (policyField.skip && typeof policyField.skip !== 'function') { 121 | throw new Error(`policy '${type}.${name}' skip must be a function`) 122 | } 123 | if (policyField.invalidate && typeof policyField.invalidate !== 'function') { 124 | throw new Error(`policy '${type}.${name}' invalidate must be a function`) 125 | } 126 | if (policyField.references && typeof policyField.references !== 'function') { 127 | throw new Error(`policy '${type}.${name}' references must be a function`) 128 | } 129 | if (typeof policyField.ttl === 'number') { 130 | maxTTL = Math.max(maxTTL, policyField.ttl) 131 | } 132 | } 133 | } 134 | } 135 | 136 | if (storage) { 137 | if (!ttl && maxTTL < 1) { 138 | throw new Error('storage is set but no ttl or policy ttl is set') 139 | } 140 | const validation = validateStorage(app, storage, maxTTL) 141 | if (validation.errorMessage) { 142 | throw new Error(`storage ${validation.errorMessage}`) 143 | } 144 | storage = validation.storage 145 | } else { 146 | storage = { type: 'memory' } 147 | } 148 | 149 | return { all, policy, ttl, stale, skip, storage, onDedupe, onHit, onMiss, onSkip, onError, logInterval, logReport } 150 | } 151 | 152 | // TODO need to validate also nested 153 | function validateNestedPolicy (policyType, fieldName) { 154 | if (policyType[fieldName].__options) { 155 | return policyType[fieldName].__options 156 | } 157 | 158 | return policyType[fieldName] 159 | } 160 | 161 | function validateStorage (app, storage, maxTTL) { 162 | if (typeof storage !== 'object') { 163 | return { errorMessage: 'must be an object' } 164 | } 165 | if (storage.type !== 'memory' && storage.type !== 'redis') { 166 | return { errorMessage: 'type must be memory or redis' } 167 | } 168 | if (storage.options && typeof storage.options !== 'object') { 169 | return { errorMessage: 'options must be an object' } 170 | } 171 | 172 | const _storage = { ...storage } 173 | if (!_storage.options) { 174 | _storage.options = {} 175 | } 176 | 177 | if (_storage.type === 'redis' && _storage.options.invalidation) { 178 | if (typeof _storage.options.invalidation === 'boolean' || !_storage.options.invalidation.referencesTTL) { 179 | if (typeof maxTTL !== 'number' || isNaN(maxTTL) || maxTTL < 1) { 180 | throw new Error('at least one ttl must be a positive integer greater than 1 to use for storage.invalidation.referencesTTL, please specify the option explictly.') 181 | } 182 | _storage.options = { 183 | ..._storage.options, 184 | invalidation: { 185 | ..._storage.options.invalidation, 186 | referencesTTL: maxTTL + 1 187 | } 188 | } 189 | } 190 | } 191 | 192 | if (!_storage.options.log) { 193 | _storage.options = { 194 | ..._storage.options, 195 | log: app.log 196 | } 197 | } 198 | return { storage: _storage } 199 | } 200 | 201 | function noop () { } 202 | 203 | function defaultDebug (app) { 204 | return function (prefix, fieldName, err) { 205 | app.log.debug({ err, msg: 'Mercurius cache error', prefix, fieldName }) 206 | } 207 | } 208 | 209 | module.exports.validateOpts = validateOpts 210 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mercurius-cache", 3 | "version": "8.0.0", 4 | "description": "Cache the results of your GraphQL resolvers, for Mercurius", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test": "standard | snazzy && tap test/*test.js && tsd", 9 | "redis": "docker run -p 6379:6379 --rm redis:7", 10 | "lint:fix": "standard --fix" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/mercurius-js/cache.git" 15 | }, 16 | "keywords": [ 17 | "mercurius", 18 | "graphql", 19 | "cache" 20 | ], 21 | "author": "Matteo Collina ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/mercurius-js/cache/issues" 25 | }, 26 | "homepage": "https://github.com/mercurius-js/cache#readme", 27 | "devDependencies": { 28 | "@fastify/pre-commit": "^2.0.2", 29 | "@sinonjs/fake-timers": "^11.1.0", 30 | "autocannon": "^7.12.0", 31 | "concurrently": "^9.0.0", 32 | "fastify": "^5.0.0", 33 | "graphql": "^16.8.1", 34 | "graphql-type-json": "^0.3.2", 35 | "ioredis": "^5.3.2", 36 | "mercurius": "^16.0.0", 37 | "snazzy": "^9.0.0", 38 | "split2": "^4.2.0", 39 | "standard": "^17.1.0", 40 | "tap": "^16.3.4", 41 | "tsd": "^0.31.0", 42 | "typescript": "^5.2.2", 43 | "wait-on": "^8.0.0", 44 | "ws": "^8.14.2", 45 | "@mercuriusjs/federation": "^5.0.0", 46 | "@mercuriusjs/gateway": "^5.0.0" 47 | }, 48 | "tsd": { 49 | "directory": "./test/types" 50 | }, 51 | "dependencies": { 52 | "async-cache-dedupe": "^2.0.0", 53 | "fastify-plugin": "^5.0.0" 54 | }, 55 | "precommit": "test" 56 | } 57 | -------------------------------------------------------------------------------- /test/federation.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const fastify = require('fastify') 5 | const { mercuriusFederationPlugin } = require('@mercuriusjs/federation') 6 | const cache = require('..') 7 | 8 | const { request } = require('./helper') 9 | 10 | test('cache __resolveReference on federated service', async ({ equal, same, teardown }) => { 11 | const app = fastify() 12 | teardown(app.close.bind(app)) 13 | 14 | const schema = ` 15 | type User @key(fields: "id") { 16 | id: ID! 17 | name: String 18 | } 19 | 20 | type Dog { 21 | name: String! 22 | } 23 | 24 | type Query { 25 | getDog: Dog 26 | } 27 | ` 28 | 29 | const resolvers = { 30 | User: { 31 | __resolveReference: async (source, args, context, info) => { 32 | return { id: source.id, name: `user #${source.id}` } 33 | } 34 | }, 35 | Dog: { 36 | __resolveReference: async (source, args, context, info) => { 37 | return { name: 'Rocky' } 38 | }, 39 | name: async (source, args, context, info) => { 40 | return 'Rocky' 41 | } 42 | }, 43 | Query: { 44 | getDog: async (source, args, context, info) => { 45 | return { name: 'Lillo' } 46 | } 47 | } 48 | } 49 | 50 | app.register(mercuriusFederationPlugin, { 51 | schema, 52 | resolvers 53 | }) 54 | 55 | let hits = 0 56 | let misses = 0 57 | 58 | app.register(cache, { 59 | ttl: 4242, 60 | onHit (type, name) { hits++ }, 61 | onMiss (type, name) { misses++ }, 62 | // it should use the cache for User.__resolveReference but not for Dog 63 | policy: { 64 | User: { __resolveReference: true }, 65 | Dog: { name: true } 66 | } 67 | }) 68 | 69 | let query = `query ($representations: [_Any!]!) { 70 | _entities(representations: $representations) { 71 | ... on User { id, name } 72 | } 73 | }` 74 | 75 | const variables = { 76 | representations: [ 77 | { 78 | __typename: 'User', 79 | id: 123 80 | } 81 | ] 82 | } 83 | 84 | same(await request({ app, query, variables }), 85 | { data: { _entities: [{ id: '123', name: 'user #123' }] } }) 86 | 87 | same(await request({ app, query, variables }), 88 | { data: { _entities: [{ id: '123', name: 'user #123' }] } }) 89 | 90 | query = '{ getDog { name } }' 91 | 92 | same(await request({ app, query }), 93 | { data: { getDog: { name: 'Rocky' } } }) 94 | 95 | equal(misses, 2) 96 | equal(hits, 1) 97 | }) 98 | -------------------------------------------------------------------------------- /test/gateway.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const Fastify = require('fastify') 5 | const mercuriusGateway = require('@mercuriusjs/gateway') 6 | const { mercuriusFederationPlugin } = require('@mercuriusjs/federation') 7 | const mercuriusCache = require('..') 8 | 9 | async function createTestService (t, schema, resolvers = {}) { 10 | const service = Fastify({ logger: { level: 'error' } }) 11 | service.register(mercuriusFederationPlugin, { 12 | schema, 13 | resolvers 14 | }) 15 | await service.listen({ port: 0 }) 16 | return [service, service.server.address().port] 17 | } 18 | 19 | const categories = { 20 | c1: { 21 | id: 'c1', 22 | name: 'Food' 23 | }, 24 | c2: { 25 | id: 'c2', 26 | name: 'Places' 27 | } 28 | } 29 | 30 | const posts = { 31 | p1: { 32 | pid: 'p1', 33 | title: 'Post 1', 34 | content: 'Content 1', 35 | categoryId: 'c1' 36 | }, 37 | p2: { 38 | pid: 'p2', 39 | title: 'Post 2', 40 | content: 'Content 2', 41 | categoryId: 'c2' 42 | }, 43 | p3: { 44 | pid: 'p3', 45 | title: 'Post 3', 46 | content: 'Content 3', 47 | categoryId: 'c1' 48 | }, 49 | p4: { 50 | pid: 'p4', 51 | title: 'Post 4', 52 | content: 'Content 4', 53 | categoryId: 'c1' 54 | } 55 | } 56 | 57 | // Post service 58 | const postServiceSchema = ` 59 | type Post @key(fields: "pid") { 60 | pid: ID! 61 | category: Category 62 | } 63 | 64 | type Query @extends { 65 | topPosts(count: Int): [Post] 66 | } 67 | 68 | type Category @key(fields: "id") @extends { 69 | id: ID! @external 70 | topPosts(count: Int!): [Post] 71 | }` 72 | 73 | const categoryServiceSchema = ` 74 | type Query @extends { 75 | categories: [Category] 76 | } 77 | 78 | type Category @key(fields: "id") { 79 | id: ID! 80 | name: String 81 | }` 82 | 83 | async function createTestGatewayServer (t, cacheOpts) { 84 | // User service 85 | 86 | const categoryServiceResolvers = { 87 | Query: { 88 | categories: (root, args, context, info) => { 89 | t.pass('Query.categories resolved') 90 | return Object.values(categories) 91 | } 92 | }, 93 | Category: { 94 | __resolveReference: (category, args, context, info) => { 95 | t.pass('Category.__resolveReference') 96 | return categories[category.id] 97 | } 98 | } 99 | } 100 | const [categoryService, categoryServicePort] = await createTestService( 101 | t, 102 | categoryServiceSchema, 103 | categoryServiceResolvers 104 | ) 105 | 106 | const postServiceResolvers = { 107 | Post: { 108 | __resolveReference: (post, args, context, info) => { 109 | t.pass('Post.__resolveReference') 110 | return posts[post.pid] 111 | }, 112 | category: (post, args, context, info) => { 113 | t.pass('Post.category') 114 | return { 115 | __typename: 'Category', 116 | id: post.categoryId 117 | } 118 | } 119 | }, 120 | Category: { 121 | topPosts: (category, { count }, context, info) => { 122 | t.pass('Category.topPosts') 123 | return Object.values(posts) 124 | .filter((p) => p.categoryId === category.id) 125 | .slice(0, count) 126 | } 127 | }, 128 | Query: { 129 | topPosts: (root, { count = 2 }) => { 130 | t.pass('Query.topPosts') 131 | return Object.values(posts).slice(0, count) 132 | } 133 | } 134 | } 135 | const [postService, postServicePort] = await createTestService( 136 | t, 137 | postServiceSchema, 138 | postServiceResolvers 139 | ) 140 | 141 | const gateway = Fastify() 142 | t.teardown(async () => { 143 | await gateway.close() 144 | await categoryService.close() 145 | await postService.close() 146 | }) 147 | gateway.register(mercuriusGateway, { 148 | gateway: { 149 | services: [ 150 | { 151 | name: 'category', 152 | url: `http://localhost:${categoryServicePort}/graphql` 153 | }, 154 | { 155 | name: 'post', 156 | url: `http://localhost:${postServicePort}/graphql` 157 | } 158 | ] 159 | } 160 | }) 161 | 162 | if (cacheOpts) { 163 | gateway.register(mercuriusCache, cacheOpts) 164 | } 165 | 166 | return { gateway, postService, categoryService } 167 | } 168 | 169 | test('gateway - should cache it all', async (t) => { 170 | // The number of the tests are the number of resolvers 171 | // in the federeted services called for 1 request plus 172 | // two assertions. 173 | t.plan(14) 174 | 175 | const { gateway: app } = await createTestGatewayServer(t, { 176 | ttl: 4242, 177 | // cache it all 178 | policy: { 179 | Query: { 180 | categories: true, 181 | topPosts: true 182 | }, 183 | Post: { 184 | category: true 185 | }, 186 | Category: { 187 | topPosts: true 188 | } 189 | } 190 | }) 191 | 192 | const query = `query { 193 | categories { 194 | id 195 | name 196 | topPosts(count: 2) { 197 | pid 198 | category { 199 | id 200 | name 201 | } 202 | } 203 | } 204 | topPosts(count: 2) { 205 | pid, 206 | category { 207 | id 208 | name 209 | } 210 | } 211 | }` 212 | 213 | const expected = { 214 | data: { 215 | categories: [ 216 | { 217 | id: 'c1', 218 | name: 'Food', 219 | topPosts: [ 220 | { 221 | pid: 'p1', 222 | category: { 223 | id: 'c1', 224 | name: 'Food' 225 | } 226 | }, 227 | { 228 | pid: 'p3', 229 | category: { 230 | id: 'c1', 231 | name: 'Food' 232 | } 233 | } 234 | ] 235 | }, 236 | { 237 | id: 'c2', 238 | name: 'Places', 239 | topPosts: [ 240 | { 241 | pid: 'p2', 242 | category: { 243 | id: 'c2', 244 | name: 'Places' 245 | } 246 | } 247 | ] 248 | } 249 | ], 250 | topPosts: [ 251 | { 252 | pid: 'p1', 253 | category: { 254 | id: 'c1', 255 | name: 'Food' 256 | } 257 | }, 258 | { 259 | pid: 'p2', 260 | category: { 261 | id: 'c2', 262 | name: 'Places' 263 | } 264 | } 265 | ] 266 | } 267 | } 268 | 269 | t.comment('first request') 270 | 271 | { 272 | const res = await app.inject({ 273 | method: 'POST', 274 | url: '/graphql', 275 | body: { query } 276 | }) 277 | 278 | t.same(res.json(), expected) 279 | } 280 | 281 | t.comment('second request') 282 | 283 | { 284 | const res = await app.inject({ 285 | method: 'POST', 286 | url: '/graphql', 287 | body: { query } 288 | }) 289 | 290 | t.same(res.json(), expected) 291 | } 292 | }) 293 | 294 | test('gateway - should let different fields in the query ignore the cache', async (t) => { 295 | // The number of the tests are the number of resolvers 296 | // in the federeted services called for 1 request plus 297 | // two assertions. 298 | t.plan(14) 299 | 300 | const { gateway: app } = await createTestGatewayServer(t, { 301 | ttl: 4242, 302 | // cache it all 303 | policy: { 304 | Query: { 305 | categories: true, 306 | topPosts: true 307 | }, 308 | Post: { 309 | category: true 310 | }, 311 | Category: { 312 | topPosts: true 313 | } 314 | } 315 | }) 316 | 317 | const query1 = `query { 318 | categories { 319 | id 320 | name 321 | topPosts(count: 2) { 322 | pid 323 | category { 324 | id 325 | } 326 | } 327 | } 328 | }` 329 | 330 | const expected1 = { 331 | data: { 332 | categories: [ 333 | { 334 | id: 'c1', 335 | name: 'Food', 336 | topPosts: [ 337 | { 338 | pid: 'p1', 339 | category: { 340 | id: 'c1' 341 | } 342 | }, 343 | { 344 | pid: 'p3', 345 | category: { 346 | id: 'c1' 347 | } 348 | } 349 | ] 350 | }, 351 | { 352 | id: 'c2', 353 | name: 'Places', 354 | topPosts: [ 355 | { 356 | pid: 'p2', 357 | category: { 358 | id: 'c2' 359 | } 360 | } 361 | ] 362 | } 363 | ] 364 | } 365 | } 366 | 367 | const query2 = `query { 368 | categories { 369 | id 370 | name 371 | topPosts(count: 2) { 372 | pid 373 | category { 374 | id 375 | name 376 | } 377 | } 378 | } 379 | }` 380 | 381 | const expected2 = { 382 | data: { 383 | categories: [ 384 | { 385 | id: 'c1', 386 | name: 'Food', 387 | topPosts: [ 388 | { 389 | pid: 'p1', 390 | category: { 391 | id: 'c1', 392 | name: 'Food' 393 | } 394 | }, 395 | { 396 | pid: 'p3', 397 | category: { 398 | id: 'c1', 399 | name: 'Food' 400 | } 401 | } 402 | ] 403 | }, 404 | { 405 | id: 'c2', 406 | name: 'Places', 407 | topPosts: [ 408 | { 409 | pid: 'p2', 410 | category: { 411 | id: 'c2', 412 | name: 'Places' 413 | } 414 | } 415 | ] 416 | } 417 | ] 418 | } 419 | } 420 | 421 | t.comment('first request') 422 | 423 | { 424 | const res = await app.inject({ 425 | method: 'POST', 426 | url: '/graphql', 427 | body: { query: query1 } 428 | }) 429 | 430 | t.same(res.json(), expected1) 431 | } 432 | 433 | t.comment('second request') 434 | 435 | { 436 | const res = await app.inject({ 437 | method: 'POST', 438 | url: '/graphql', 439 | body: { query: query2 } 440 | }) 441 | 442 | t.same(res.json(), expected2) 443 | } 444 | }) 445 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { equal } = require('assert') 4 | 5 | module.exports = { 6 | request: async function ({ app, query, variables }) { 7 | const res = await app.inject({ 8 | method: 'POST', 9 | url: '/graphql', 10 | body: { query, variables } 11 | }) 12 | equal(res.statusCode, 200) 13 | return res.json() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/lib-validation.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const { validateOpts } = require('../lib/validation') 5 | 6 | test('should get default options', async (t) => { 7 | const app = { log: 'the-logger' } 8 | const options = validateOpts(app) 9 | t.same(options.storage, { type: 'memory' }) 10 | t.equal(options.ttl, 0) 11 | t.equal(options.all, undefined) 12 | t.equal(options.policy, undefined) 13 | t.equal(options.skip, undefined) 14 | t.equal(options.logInterval, undefined) 15 | t.equal(options.logReport, undefined) 16 | t.equal(typeof options.onDedupe, 'function') 17 | t.equal(typeof options.onHit, 'function') 18 | t.equal(typeof options.onMiss, 'function') 19 | t.equal(typeof options.onSkip, 'function') 20 | t.equal(typeof options.onError, 'function') 21 | }) 22 | 23 | test('should get default options with log object', async (t) => { 24 | t.plan(13) 25 | const app = { 26 | log: { debug } 27 | } 28 | const options = validateOpts(app) 29 | t.same(options.storage, { type: 'memory' }) 30 | t.equal(options.ttl, 0) 31 | t.equal(options.all, undefined) 32 | t.equal(options.policy, undefined) 33 | t.equal(options.skip, undefined) 34 | t.equal(options.logInterval, undefined) 35 | t.equal(options.logReport, undefined) 36 | t.equal(typeof options.onDedupe, 'function') 37 | t.equal(typeof options.onHit, 'function') 38 | t.equal(typeof options.onMiss, 'function') 39 | t.equal(typeof options.onSkip, 'function') 40 | t.equal(typeof options.onError, 'function') 41 | // Trigger options.onError to be tested on callback at top 42 | const except = { 43 | prefix: 'Query', 44 | fieldName: 'add', 45 | err: 'Error', 46 | msg: 'Mercurius cache error' 47 | } 48 | options.onError(except.prefix, except.fieldName, except.err) 49 | function debug (params) { 50 | t.same(params, except) 51 | } 52 | }) 53 | 54 | test('should get default storage.options', async (t) => { 55 | const options = { 56 | ttl: 1, 57 | storage: { type: 'memory' }, 58 | all: true 59 | } 60 | const app = { log: 'the-logger' } 61 | const { storage } = validateOpts(app, options) 62 | t.same(storage.options, { log: 'the-logger' }) 63 | }) 64 | 65 | test('should get default storage.options, with logger', async (t) => { 66 | const options = { 67 | ttl: 1, 68 | storage: { type: 'memory', options: { log: 'the-logger' } }, 69 | all: true 70 | } 71 | const app = { log: 'another-logger' } 72 | const { storage } = validateOpts(app, options) 73 | t.same(storage.options, { log: 'the-logger' }) 74 | }) 75 | 76 | test('should get default storage.options.invalidation.referencesTTL as max of policies and main ttl / invalidation as boolean true', async (t) => { 77 | const options = { 78 | ttl: 1, 79 | storage: { type: 'redis', options: { client: {}, invalidation: true } }, 80 | policy: { 81 | Query: { 82 | a: { ttl: 2, storage: { type: 'redis', options: { client: {} } } }, 83 | b: { ttl: 3, storage: { type: 'redis', options: { client: {} } } }, 84 | c: { ttl: 4, storage: { type: 'redis', options: { client: {} } } }, 85 | d: { ttl: 5, storage: { type: 'redis', options: { client: {} } } }, 86 | e: { storage: { type: 'redis', options: { client: {} } } } 87 | } 88 | } 89 | } 90 | const app = { log: 'the-logger' } 91 | const { storage } = validateOpts(app, options) 92 | 93 | t.equal(storage.options.invalidation.referencesTTL, 6) 94 | }) 95 | 96 | test('should get default storage.options.invalidation.referencesTTL as max of policies and main ttl / invalidation as empty object', async (t) => { 97 | const options = { 98 | ttl: 1, 99 | storage: { type: 'redis', options: { client: {}, invalidation: true } }, 100 | policy: { 101 | Query: { 102 | a: { ttl: 2, storage: { type: 'redis', options: { client: {} } } }, 103 | b: { ttl: 3, storage: { type: 'redis', options: { client: {} } } }, 104 | c: { ttl: 4, storage: { type: 'redis', options: { client: {} } } }, 105 | d: { ttl: 5, storage: { type: 'redis', options: { client: {} } } } 106 | } 107 | } 108 | } 109 | const app = { log: 'the-logger' } 110 | const { storage } = validateOpts(app, options) 111 | 112 | t.equal(storage.options.invalidation.referencesTTL, 6) 113 | }) 114 | 115 | test('should not default storage.options.invalidation.referencesTTL when all ttls are functions / invalidation as boolean true', async (t) => { 116 | const options = { 117 | ttl: () => 1, 118 | storage: { type: 'redis', options: { client: {}, invalidation: true } }, 119 | policy: { 120 | Query: { 121 | a: { ttl: () => 2, storage: { type: 'redis', options: { client: {} } } }, 122 | b: { ttl: () => 3, storage: { type: 'redis', options: { client: {} } } }, 123 | c: { ttl: () => 4, storage: { type: 'redis', options: { client: {} } } }, 124 | d: { ttl: () => 5, storage: { type: 'redis', options: { client: {} } } } 125 | } 126 | } 127 | } 128 | const app = { log: 'the-logger' } 129 | t.throws(() => { 130 | validateOpts(app, options) 131 | }, '') 132 | }) 133 | 134 | test('should not default storage.options.invalidation.referencesTTL when all ttls are functions / invalidation as empty object', async (t) => { 135 | const options = { 136 | ttl: () => 1, 137 | storage: { type: 'redis', options: { client: {}, invalidation: {} } }, 138 | policy: { 139 | Query: { 140 | a: { ttl: () => 2, storage: { type: 'redis', options: { client: {} } } }, 141 | b: { ttl: () => 3, storage: { type: 'redis', options: { client: {} } } }, 142 | c: { ttl: () => 4, storage: { type: 'redis', options: { client: {} } } }, 143 | d: { ttl: () => 5, storage: { type: 'redis', options: { client: {} } } } 144 | } 145 | } 146 | } 147 | const app = { log: 'the-logger' } 148 | t.throws(() => { 149 | validateOpts(app, options) 150 | }, '') 151 | }) 152 | 153 | test('should default storage.options.invalidation.referencesTTL to max ttl when a mix of static and dynamic ttls are configured / invalidation as boolean true', async (t) => { 154 | const options = { 155 | ttl: () => 1, 156 | storage: { type: 'redis', options: { client: {}, invalidation: true } }, 157 | policy: { 158 | Query: { 159 | a: { ttl: () => 2, storage: { type: 'redis', options: { client: {} } } }, 160 | b: { ttl: 3, storage: { type: 'redis', options: { client: {} } } }, 161 | c: { ttl: () => 4, storage: { type: 'redis', options: { client: {} } } }, 162 | d: { ttl: 1, storage: { type: 'redis', options: { client: {} } } } 163 | } 164 | } 165 | } 166 | const app = { log: 'the-logger' } 167 | const { storage } = validateOpts(app, options) 168 | t.equal(storage.options.invalidation.referencesTTL, 4) 169 | }) 170 | 171 | test('should use explicitly defined referencesTTL', async (t) => { 172 | const options = { 173 | ttl: () => 1, 174 | storage: { type: 'redis', options: { client: {}, invalidation: { referencesTTL: 6 } } }, 175 | policy: { 176 | Query: { 177 | a: { ttl: () => 2, storage: { type: 'redis', options: { client: {} } } }, 178 | b: { ttl: 3, storage: { type: 'redis', options: { client: {} } } }, 179 | c: { ttl: () => 4, storage: { type: 'redis', options: { client: {} } } }, 180 | d: { ttl: 1, storage: { type: 'redis', options: { client: {} } } } 181 | } 182 | } 183 | } 184 | const app = { log: 'the-logger' } 185 | const { storage } = validateOpts(app, options) 186 | t.equal(storage.options.invalidation.referencesTTL, 6) 187 | }) 188 | 189 | test('should get default storage.options.log as app.log', async (t) => { 190 | const options = { 191 | ttl: 1, 192 | storage: { type: 'redis', options: { client: {}, invalidation: true } }, 193 | all: true 194 | } 195 | const app = { log: 'the-logger' } 196 | const { storage } = validateOpts(app, options) 197 | t.equal(storage.options.log, 'the-logger') 198 | }) 199 | 200 | test('should not throw error when "__options" is used with valid parameters', async (t) => { 201 | const options = { 202 | policy: { 203 | Query: { 204 | a: { 205 | __options: { 206 | ttl: 2, 207 | stale: 10, 208 | storage: { type: 'redis', options: { client: {} } }, 209 | extendKey: () => {}, 210 | skip: () => {}, 211 | invalidate: () => {}, 212 | references: () => {} 213 | } 214 | }, 215 | b: { 216 | __options: { 217 | ttl: () => 10 218 | } 219 | } 220 | } 221 | } 222 | } 223 | 224 | const app = { log: 'the-logger' } 225 | t.doesNotThrow(() => validateOpts(app, options)) 226 | }) 227 | 228 | const cases = [ 229 | { 230 | title: 'should get error using all as string', 231 | options: { all: 'true' }, 232 | expect: /all must be a boolean/ 233 | }, 234 | { 235 | title: 'should get error using all and policy', 236 | options: { all: true, policy: {} }, 237 | expect: /policy and all options are exclusive/ 238 | }, 239 | { 240 | title: 'should get error using ttl as string', 241 | options: { ttl: '10' }, 242 | expect: /ttl must be a function or a number greater than 0/ 243 | }, 244 | { 245 | title: 'should get error using ttl negative', 246 | options: { ttl: -1 }, 247 | expect: /ttl must be a function or a number greater than 0/ 248 | }, 249 | { 250 | title: 'should get error using ttl NaN', 251 | options: { ttl: NaN }, 252 | expect: /ttl must be a function or a number greater than 0/ 253 | }, 254 | { 255 | title: 'should get error using stale as string', 256 | options: { stale: '10' }, 257 | expect: /stale must be a number greater than 0/ 258 | }, 259 | { 260 | title: 'should get error using stale negative', 261 | options: { stale: -1 }, 262 | expect: /stale must be a number greater than 0/ 263 | }, 264 | { 265 | title: 'should get error using stale NaN', 266 | options: { stale: NaN }, 267 | expect: /stale must be a number greater than 0/ 268 | }, 269 | { 270 | title: 'should get error using onDedupe as string', 271 | options: { onDedupe: 'not a function' }, 272 | expect: /onDedupe must be a function/ 273 | }, 274 | { 275 | title: 'should get error using onHit as string', 276 | options: { onHit: 'not a function' }, 277 | expect: /onHit must be a function/ 278 | }, 279 | { 280 | title: 'should get error using onMiss as string', 281 | options: { onMiss: 'not a function' }, 282 | expect: /onMiss must be a function/ 283 | }, 284 | { 285 | title: 'should get error using onSkip as string', 286 | options: { onSkip: 'not a function' }, 287 | expect: /onSkip must be a function/ 288 | }, 289 | { 290 | title: 'should get error using onError as string', 291 | options: { onError: 'not a function' }, 292 | expect: /onError must be a function/ 293 | }, 294 | { 295 | title: 'should get error using policy as string', 296 | options: { policy: 'not an object' }, 297 | expect: /policy must be an object/ 298 | }, 299 | { 300 | title: 'should get error using logInterval as string', 301 | options: { logInterval: 'not-a-number' }, 302 | expect: /logInterval must be a number greater than 1/ 303 | }, 304 | { 305 | title: 'should get error using logReport not a function', 306 | options: { logReport: 'not a function' }, 307 | expect: /logReport must be a function/ 308 | }, 309 | { 310 | title: 'should get error using skip not a function', 311 | options: { skip: 'not a function' }, 312 | expect: /skip must be a function/ 313 | }, 314 | { 315 | title: 'should get error using storage without any ttl', 316 | options: { storage: { type: 'memory', options: { ttl: 0 } } }, 317 | expect: /storage is set but no ttl or policy ttl is set/ 318 | }, 319 | { 320 | title: 'should get error using storage type must be memory or redis', 321 | options: { ttl: 1, storage: { type: 'zzz' } }, 322 | expect: /storage type must be memory or redis/ 323 | }, 324 | 325 | // policy options 326 | { 327 | title: 'should get error using policy.ttl as string', 328 | options: { policy: { Query: { add: { ttl: '10' } } } }, 329 | expect: /ttl must be a function or a number greater than 0/ 330 | }, 331 | { 332 | title: 'should get error using policy.ttl negative', 333 | options: { policy: { Query: { add: { ttl: -1 } } } }, 334 | expect: /ttl must be a function or a number greater than 0/ 335 | }, 336 | { 337 | title: 'should get error using policy.ttl NaN', 338 | options: { policy: { Query: { add: { ttl: NaN } } } }, 339 | expect: /ttl must be a function or a number greater than 0/ 340 | }, 341 | { 342 | title: 'should get error using policy.stale as string', 343 | options: { policy: { Query: { add: { stale: '10' } } } }, 344 | expect: /stale must be a number greater than 0/ 345 | }, 346 | { 347 | title: 'should get error using policy.stale negative', 348 | options: { policy: { Query: { add: { stale: -1 } } } }, 349 | expect: /stale must be a number greater than 0/ 350 | }, 351 | { 352 | title: 'should get error using policy.stale NaN', 353 | options: { policy: { Query: { add: { stale: NaN } } } }, 354 | expect: /stale must be a number greater than 0/ 355 | }, 356 | { 357 | title: 'should get error using policy.extendKey not a function', 358 | options: { policy: { Query: { add: { extendKey: 'not a function' } } } }, 359 | expect: /policy 'Query.add' extendKey must be a function/ 360 | }, 361 | { 362 | title: 'should get error using policy.skip not a function', 363 | options: { policy: { Query: { add: { skip: 'not a function' } } } }, 364 | expect: /policy 'Query.add' skip must be a function/ 365 | }, 366 | { 367 | title: 'should get error using policy.invalidate not a function', 368 | options: { policy: { Query: { add: { invalidate: 'not a function' } } } }, 369 | expect: /policy 'Query.add' invalidate must be a function/ 370 | }, 371 | { 372 | title: 'should get error using policy.references not a function', 373 | options: { policy: { Query: { add: { references: 'not a function' } } } }, 374 | expect: /policy 'Query.add' references must be a function/ 375 | }, 376 | { 377 | title: 'should get error using policy.storage not an object', 378 | options: { policy: { Query: { add: { storage: 'not an object' } } } }, 379 | expect: /policy 'Query.add' storage must be an object/ 380 | }, 381 | { 382 | title: 'should get error using policy.storage.type allowed', 383 | options: { policy: { Query: { add: { storage: { type: 'zzz' } } } } }, 384 | expect: /policy 'Query.add' storage type must be memory or redis/ 385 | }, 386 | { 387 | title: 'should get error using policy.storage.options not an object', 388 | options: { policy: { Query: { add: { storage: { type: 'memory', options: 'not an object' } } } } }, 389 | expect: /policy 'Query.add' storage options must be an object/ 390 | }, 391 | { 392 | title: 'should get error using policy.key not a function', 393 | options: { policy: { Query: { add: { key: 'not a function' } } } }, 394 | expect: /policy 'Query.add' key must be a function/ 395 | }, 396 | { 397 | title: 'should get error using policy.key along with policy.extendKey', 398 | options: { policy: { Query: { add: { key: () => {}, extendKey: () => {} } } } }, 399 | expect: /policy 'Query.add' key and extendKey are exclusive/ 400 | } 401 | ] 402 | 403 | for (const useCase of cases) { 404 | test(useCase.title, async (t) => { 405 | t.throws(() => validateOpts({}, useCase.options), useCase.expect) 406 | }) 407 | } 408 | -------------------------------------------------------------------------------- /test/policy-options.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, before, teardown, afterEach } = require('tap') 4 | const fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const FakeTimers = require('@sinonjs/fake-timers') 7 | const cache = require('..') 8 | 9 | const { request } = require('./helper') 10 | 11 | let clock 12 | before(() => { 13 | clock = FakeTimers.install({ 14 | shouldAdvanceTime: true, 15 | advanceTimeDelta: 0 16 | }) 17 | }) 18 | 19 | afterEach(() => { 20 | clock.runAll() 21 | }) 22 | 23 | teardown(() => { 24 | clock.uninstall() 25 | }) 26 | 27 | test('different cache while revalidate options for policies', async ({ equal, teardown, same }) => { 28 | const app = fastify() 29 | teardown(app.close.bind(app)) 30 | 31 | const schema = ` 32 | type Query { 33 | add(x: Int, y: Int): Int 34 | sub(x: Int, y: Int): Int 35 | } 36 | ` 37 | 38 | let addCounter = 0 39 | let subCounter = 0 40 | 41 | const resolvers = { 42 | Query: { 43 | async add () { return ++addCounter }, 44 | async sub () { return ++subCounter } 45 | } 46 | } 47 | 48 | app.register(mercurius, { schema, resolvers }) 49 | 50 | const hits = { add: 0, sub: 0 } 51 | const misses = { add: 0, sub: 0 } 52 | 53 | app.register(cache, { 54 | ttl: 2, 55 | stale: 2, 56 | onHit (type, name) { 57 | hits[name] = hits[name] ? hits[name] + 1 : 1 58 | }, 59 | onMiss (type, name) { 60 | misses[name] = misses[name] ? misses[name] + 1 : 1 61 | }, 62 | policy: { 63 | Query: { 64 | add: { ttl: 1, stale: 1 }, 65 | sub: true 66 | } 67 | } 68 | }) 69 | 70 | let addData = await request({ app, query: '{ add(x: 1, y: 1) }' }) 71 | let subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 72 | 73 | equal(hits.add, 0) 74 | equal(misses.add, 1) 75 | equal(addCounter, 1) 76 | same(addData, { 77 | data: { 78 | add: 1 79 | } 80 | }) 81 | 82 | equal(hits.sub, 0) 83 | equal(misses.sub, 1) 84 | equal(subCounter, 1) 85 | same(subData, { 86 | data: { 87 | sub: 1 88 | } 89 | }) 90 | 91 | clock.tick(500) 92 | 93 | addData = await request({ app, query: '{ add(x: 1, y: 1) }' }) 94 | subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 95 | 96 | equal(hits.add, 1) 97 | equal(misses.add, 1) 98 | equal(addCounter, 1) 99 | same(addData, { 100 | data: { 101 | add: 1 102 | } 103 | }) 104 | 105 | equal(hits.sub, 1) 106 | equal(misses.sub, 1) 107 | equal(subCounter, 1) 108 | same(subData, { 109 | data: { 110 | sub: 1 111 | } 112 | }) 113 | 114 | clock.tick(1000) 115 | 116 | addData = await request({ app, query: '{ add(x: 1, y: 1) }' }) 117 | subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 118 | 119 | equal(hits.add, 2) 120 | equal(misses.add, 1) 121 | equal(addCounter, 2) 122 | same(addData, { 123 | data: { 124 | add: 1 125 | } 126 | }) 127 | 128 | equal(hits.sub, 2) 129 | equal(misses.sub, 1) 130 | equal(subCounter, 1) 131 | same(subData, { 132 | data: { 133 | sub: 1 134 | } 135 | }) 136 | 137 | addData = await request({ app, query: '{ add(x: 1, y: 1) }' }) 138 | subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 139 | 140 | equal(hits.add, 3) 141 | equal(misses.add, 1) 142 | equal(addCounter, 2) 143 | same(addData, { 144 | data: { 145 | add: 2 146 | } 147 | }) 148 | 149 | equal(hits.sub, 3) 150 | equal(misses.sub, 1) 151 | equal(subCounter, 1) 152 | same(subData, { 153 | data: { 154 | sub: 1 155 | } 156 | }) 157 | 158 | clock.tick(1000) 159 | 160 | subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 161 | 162 | equal(hits.sub, 4) 163 | equal(misses.sub, 1) 164 | equal(subCounter, 2) 165 | same(subData, { 166 | data: { 167 | sub: 1 168 | } 169 | }) 170 | 171 | subData = await request({ app, query: '{ sub(x: 2, y: 2) }' }) 172 | 173 | equal(hits.sub, 5) 174 | equal(misses.sub, 1) 175 | equal(subCounter, 2) 176 | same(subData, { 177 | data: { 178 | sub: 2 179 | } 180 | }) 181 | }) 182 | 183 | test('cache different policies with different options / dynamic ttl', async ({ equal, teardown }) => { 184 | const app = fastify() 185 | teardown(app.close.bind(app)) 186 | 187 | const schema = ` 188 | type Query { 189 | add(x: Int, y: Int): Int 190 | sub(x: Int, y: Int): Int 191 | } 192 | ` 193 | const resolvers = { 194 | Query: { 195 | async add (_, { x, y }) { return x + y }, 196 | async sub (_, { x, y }) { return x - y } 197 | } 198 | } 199 | 200 | app.register(mercurius, { schema, resolvers }) 201 | 202 | const hits = { add: 0, sub: 0 } 203 | const misses = { add: 0, sub: 0 } 204 | 205 | app.register(cache, { 206 | ttl: 100, 207 | onHit (type, name) { 208 | hits[name] = hits[name] ? hits[name] + 1 : 1 209 | }, 210 | onMiss (type, name) { 211 | misses[name] = misses[name] ? misses[name] + 1 : 1 212 | }, 213 | policy: { 214 | Query: { 215 | add: { ttl: () => 1 }, 216 | sub: { ttl: () => 2 } 217 | } 218 | } 219 | }) 220 | 221 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 222 | await request({ app, query: '{ sub(x: 2, y: 2) }' }) 223 | 224 | await clock.tick(500) 225 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 226 | 227 | await clock.tick(2000) 228 | await request({ app, query: '{ sub(x: 2, y: 2) }' }) 229 | 230 | equal(hits.add, 1) 231 | equal(misses.add, 1) 232 | 233 | equal(hits.sub, 0) 234 | equal(misses.sub, 2) 235 | }) 236 | 237 | test('cache different policies with different options / ttl', async ({ equal, teardown }) => { 238 | const app = fastify() 239 | teardown(app.close.bind(app)) 240 | 241 | const schema = ` 242 | type Query { 243 | add(x: Int, y: Int): Int 244 | sub(x: Int, y: Int): Int 245 | } 246 | ` 247 | const resolvers = { 248 | Query: { 249 | async add (_, { x, y }) { return x + y }, 250 | async sub (_, { x, y }) { return x - y } 251 | } 252 | } 253 | 254 | app.register(mercurius, { schema, resolvers }) 255 | 256 | const hits = { add: 0, sub: 0 } 257 | const misses = { add: 0, sub: 0 } 258 | 259 | app.register(cache, { 260 | ttl: 100, 261 | onHit (type, name) { 262 | hits[name] = hits[name] ? hits[name] + 1 : 1 263 | }, 264 | onMiss (type, name) { 265 | misses[name] = misses[name] ? misses[name] + 1 : 1 266 | }, 267 | policy: { 268 | Query: { 269 | add: { ttl: 1 }, 270 | sub: { ttl: 2 } 271 | } 272 | } 273 | }) 274 | 275 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 276 | await request({ app, query: '{ sub(x: 2, y: 2) }' }) 277 | 278 | await clock.tick(500) 279 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 280 | 281 | await clock.tick(2000) 282 | await request({ app, query: '{ sub(x: 2, y: 2) }' }) 283 | 284 | equal(hits.add, 1) 285 | equal(misses.add, 1) 286 | 287 | equal(hits.sub, 0) 288 | equal(misses.sub, 2) 289 | }) 290 | 291 | test('cache different policies with different options / storage', async ({ equal, teardown }) => { 292 | const app = fastify() 293 | teardown(app.close.bind(app)) 294 | 295 | const schema = ` 296 | type Query { 297 | add(x: Int, y: Int): Int 298 | sub(x: Int, y: Int): Int 299 | } 300 | ` 301 | 302 | const resolvers = { 303 | Query: { 304 | async add (_, { x, y }) { return x + y }, 305 | async sub (_, { x, y }) { return x - y } 306 | } 307 | } 308 | 309 | app.register(mercurius, { schema, resolvers }) 310 | 311 | const hits = { add: 0, sub: 0 }; const misses = { add: 0, sub: 0 } 312 | 313 | app.register(cache, { 314 | onHit (type, name) { 315 | hits[name]++ 316 | }, 317 | onMiss (type, name) { 318 | misses[name]++ 319 | }, 320 | policy: { 321 | Query: { 322 | add: { storage: { type: 'memory', options: { size: 1 } } }, 323 | sub: { storage: { type: 'memory', options: { size: 2 } } } 324 | } 325 | } 326 | }) 327 | 328 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 329 | await request({ app, query: '{ add(x: 2, y: 1) }' }) 330 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 331 | 332 | await request({ app, query: '{ sub(x: 1, y: 1) }' }) 333 | await request({ app, query: '{ sub(x: 2, y: 1) }' }) 334 | await request({ app, query: '{ sub(x: 3, y: 1) }' }) 335 | await request({ app, query: '{ sub(x: 1, y: 1) }' }) 336 | await request({ app, query: '{ sub(x: 2, y: 1) }' }) 337 | await request({ app, query: '{ sub(x: 3, y: 1) }' }) 338 | 339 | equal(hits.add, 0, 'never hits the cache') 340 | equal(misses.add, 0, 'never use the cache') 341 | 342 | equal(hits.sub, 0, 'never hits the cache') 343 | equal(misses.sub, 0, 'never use the cache') 344 | }) 345 | 346 | test('cache different policies with different options / skip', async ({ equal, teardown }) => { 347 | const app = fastify() 348 | teardown(app.close.bind(app)) 349 | 350 | const schema = ` 351 | type Query { 352 | add(x: Int, y: Int): Int 353 | sub(x: Int, y: Int): Int 354 | mul(x: Int, y: Int): Int 355 | } 356 | ` 357 | 358 | const resolvers = { 359 | Query: { 360 | async add (_, { x, y }) { return x + y }, 361 | async sub (_, { x, y }) { 362 | return x - y 363 | }, 364 | async mul (_, { x, y }) { return x * y } 365 | } 366 | } 367 | 368 | app.register(mercurius, { schema, resolvers }) 369 | 370 | const hits = { add: 0, sub: 0, mul: 0 } 371 | const misses = { add: 0, sub: 0, mul: 0 } 372 | const skips = { add: 0, sub: 0, mul: 0 } 373 | 374 | app.register(cache, { 375 | ttl: 10, 376 | onHit (type, name) { 377 | hits[name]++ 378 | }, 379 | onMiss (type, name) { 380 | misses[name]++ 381 | }, 382 | onSkip (type, name) { 383 | skips[name]++ 384 | }, 385 | policy: { 386 | Query: { 387 | add: { skip: () => true }, 388 | sub: true, 389 | mul: { skip: (self, arg, ctx, info) => arg.x > 9 } 390 | } 391 | } 392 | }) 393 | 394 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 395 | await request({ app, query: '{ add(x: 2, y: 1) }' }) 396 | await request({ app, query: '{ add(x: 3, y: 1) }' }) 397 | 398 | await request({ app, query: '{ sub(x: 1, y: 1) }' }) 399 | await request({ app, query: '{ sub(x: 1, y: 1) }' }) 400 | await request({ app, query: '{ sub(x: 2, y: 1) }' }) 401 | await request({ app, query: '{ sub(x: 2, y: 1) }' }) 402 | 403 | await request({ app, query: '{ mul(x: 1, y: 1) }' }) 404 | await request({ app, query: '{ mul(x: 10, y: 1) }' }) 405 | 406 | equal(hits.add, 0) 407 | equal(misses.add, 0) 408 | equal(skips.add, 3, 'always skipped') 409 | 410 | equal(hits.sub, 2, 'regular from cache') 411 | equal(misses.sub, 2) 412 | equal(skips.sub, 0) 413 | 414 | equal(hits.mul, 0) 415 | equal(misses.mul, 1) 416 | equal(skips.mul, 1, 'skipped if first arg > 9') 417 | }) 418 | 419 | test('cache per user using extendKey option', async ({ equal, same, teardown }) => { 420 | const app = fastify() 421 | teardown(app.close.bind(app)) 422 | 423 | const schema = ` 424 | type Query { 425 | hello: String 426 | } 427 | ` 428 | 429 | const resolvers = { 430 | Query: { 431 | async hello (source, args, { reply, user }) { 432 | return user ? `Hello ${user}` : '?' 433 | } 434 | } 435 | } 436 | 437 | app.register(mercurius, { 438 | schema, 439 | resolvers, 440 | context: async (req) => { 441 | return { 442 | user: req.query.user 443 | } 444 | } 445 | }) 446 | 447 | let hits = 0 448 | let misses = 0 449 | app.register(cache, { 450 | ttl: 10, 451 | policy: { 452 | Query: { 453 | hello: { 454 | extendKey: function (source, args, context, info) { 455 | return context.user ? `user:${context.user}` : undefined 456 | } 457 | } 458 | } 459 | }, 460 | onHit () { hits++ }, 461 | onMiss () { misses++ } 462 | }) 463 | 464 | for (let i = 0; i < 3; i++) { 465 | const query = '{ hello }' 466 | { 467 | const res = await app.inject({ 468 | method: 'POST', 469 | url: '/graphql', 470 | body: { query } 471 | }) 472 | 473 | equal(res.statusCode, 200) 474 | same(res.json(), { 475 | data: { 476 | hello: '?' 477 | } 478 | }) 479 | } 480 | 481 | { 482 | const res = await app.inject({ 483 | method: 'POST', 484 | url: '/graphql?user=alice', 485 | body: { query } 486 | }) 487 | 488 | equal(res.statusCode, 200) 489 | same(res.json(), { 490 | data: { 491 | hello: 'Hello alice' 492 | } 493 | }) 494 | } 495 | 496 | { 497 | const res = await app.inject({ 498 | method: 'POST', 499 | url: '/graphql?user=bob', 500 | body: { query } 501 | }) 502 | 503 | equal(res.statusCode, 200) 504 | same(res.json(), { 505 | data: { 506 | hello: 'Hello bob' 507 | } 508 | }) 509 | } 510 | } 511 | 512 | equal(misses, 3) 513 | equal(hits, 6) 514 | }) 515 | -------------------------------------------------------------------------------- /test/refresh.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const FakeTimers = require('@sinonjs/fake-timers') 5 | const { promisify } = require('util') 6 | const Fastify = require('fastify') 7 | const mercurius = require('mercurius') 8 | const { mercuriusFederationPlugin, buildFederationSchema } = require('@mercuriusjs/federation') 9 | const mercuriusGateway = require('@mercuriusjs/gateway') 10 | const mercuriusCache = require('..') 11 | const { buildSchema } = require('graphql') 12 | 13 | const immediate = promisify(setImmediate) 14 | 15 | test('polling interval with a new schema should trigger refresh of schema policy build', async (t) => { 16 | t.plan(6) 17 | 18 | const clock = FakeTimers.install({ 19 | shouldAdvanceTime: true, 20 | advanceTimeDelta: 40 21 | }) 22 | t.teardown(() => clock.uninstall()) 23 | 24 | const user = { 25 | id: 'u1', 26 | name: 'John', 27 | lastName: 'Doe' 28 | } 29 | 30 | const resolvers = { 31 | Query: { 32 | me: (root, args, context, info) => { 33 | t.pass('resolver called') 34 | return user 35 | } 36 | } 37 | } 38 | 39 | const userService = Fastify() 40 | const gateway = Fastify({ logger: { level: 'error' } }) 41 | t.teardown(async () => { 42 | await gateway.close() 43 | await userService.close() 44 | }) 45 | 46 | userService.register(mercuriusFederationPlugin, { 47 | schema: ` 48 | extend type Query { 49 | me: User 50 | } 51 | 52 | type User @key(fields: "id") { 53 | id: ID! 54 | name: String 55 | } 56 | `, 57 | resolvers 58 | }) 59 | 60 | await userService.listen({ port: 0 }) 61 | 62 | const userServicePort = userService.server.address().port 63 | 64 | await gateway.register(mercuriusGateway, { 65 | gateway: { 66 | services: [ 67 | { 68 | name: 'user', 69 | url: `http://localhost:${userServicePort}/graphql` 70 | } 71 | ], 72 | pollingInterval: 2000 73 | } 74 | }) 75 | 76 | await gateway.register(mercuriusCache, { 77 | ttl: 4242, 78 | policy: { 79 | Query: { 80 | me: true 81 | } 82 | } 83 | }) 84 | 85 | async function getMe () { 86 | const query = ` 87 | query { 88 | me { 89 | id 90 | name 91 | } 92 | } 93 | ` 94 | 95 | const res = await gateway.inject({ 96 | method: 'POST', 97 | url: '/graphql', 98 | body: { query } 99 | }) 100 | 101 | t.same(res.json(), { 102 | data: { 103 | me: { 104 | id: 'u1', 105 | name: 'John' 106 | } 107 | } 108 | }) 109 | } 110 | 111 | await getMe() 112 | await getMe() 113 | 114 | t.comment('userService.graphql.replaceSchema') 115 | 116 | userService.graphql.replaceSchema( 117 | buildFederationSchema(` 118 | extend type Query { 119 | me: User 120 | } 121 | 122 | type User @key(fields: "id") { 123 | id: ID! 124 | name: String 125 | lastName: String 126 | } 127 | `) 128 | ) 129 | userService.graphql.defineResolvers(resolvers) 130 | 131 | await clock.tickAsync(2000) 132 | 133 | // We need the event loop to actually spin twice to 134 | // be able to propagate the change 135 | await immediate() 136 | await immediate() 137 | 138 | async function getMeWithLastName () { 139 | const query = ` 140 | query { 141 | me { 142 | id 143 | name 144 | lastName 145 | } 146 | } 147 | ` 148 | 149 | const res = await gateway.inject({ 150 | method: 'POST', 151 | url: '/graphql', 152 | body: { query } 153 | }) 154 | 155 | t.same(res.json(), { 156 | data: { 157 | me: { 158 | id: 'u1', 159 | name: 'John', 160 | lastName: 'Doe' 161 | } 162 | } 163 | }) 164 | } 165 | 166 | t.comment('refreshed service calls') 167 | await getMeWithLastName() 168 | await getMeWithLastName() 169 | }) 170 | 171 | test('adds a mercuriusCache.refresh() method', async (t) => { 172 | t.plan(6) 173 | 174 | const user = { 175 | id: 'u1', 176 | name: 'John', 177 | lastName: 'Doe' 178 | } 179 | 180 | const resolvers = { 181 | Query: { 182 | me: (root, args, context, info) => { 183 | t.pass('resolver called') 184 | return user 185 | } 186 | } 187 | } 188 | 189 | const userService = Fastify() 190 | t.teardown(async () => { 191 | await userService.close() 192 | }) 193 | 194 | userService.register(mercurius, { 195 | schema: ` 196 | type Query { 197 | me: User 198 | } 199 | 200 | type User { 201 | id: ID! 202 | name: String 203 | } 204 | `, 205 | resolvers 206 | }) 207 | 208 | userService.register(mercuriusCache, { 209 | ttl: 4242, 210 | policy: { 211 | Query: { 212 | me: true 213 | } 214 | } 215 | }) 216 | 217 | async function getMe () { 218 | const query = ` 219 | query { 220 | me { 221 | id 222 | name 223 | } 224 | } 225 | ` 226 | 227 | const res = await userService.inject({ 228 | method: 'POST', 229 | url: '/graphql', 230 | body: { query } 231 | }) 232 | 233 | t.same(res.json(), { 234 | data: { 235 | me: { 236 | id: 'u1', 237 | name: 'John' 238 | } 239 | } 240 | }) 241 | } 242 | 243 | await getMe() 244 | await getMe() 245 | 246 | t.comment('userService.graphql.cache.refresh()') 247 | 248 | userService.graphql.replaceSchema(buildSchema(` 249 | type Query { 250 | me: User 251 | } 252 | 253 | type User { 254 | id: ID! 255 | name: String 256 | lastName: String 257 | } 258 | `) 259 | ) 260 | userService.graphql.defineResolvers(resolvers) 261 | 262 | // This is the new method added by this module 263 | userService.graphql.cache.refresh() 264 | 265 | // We need the event loop to actually spin twice to 266 | // be able to propagate the change 267 | await immediate() 268 | await immediate() 269 | 270 | async function getMeWithLastName () { 271 | const query = ` 272 | query { 273 | me { 274 | id 275 | name 276 | lastName 277 | } 278 | } 279 | ` 280 | 281 | const res = await userService.inject({ 282 | method: 'POST', 283 | url: '/graphql', 284 | body: { query } 285 | }) 286 | 287 | t.same(res.json(), { 288 | data: { 289 | me: { 290 | id: 'u1', 291 | name: 'John', 292 | lastName: 'Doe' 293 | } 294 | } 295 | }) 296 | } 297 | 298 | t.comment('refreshed service calls') 299 | await getMeWithLastName() 300 | await getMeWithLastName() 301 | }) 302 | -------------------------------------------------------------------------------- /test/report.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { promisify } = require('util') 4 | const { test } = require('tap') 5 | const fastify = require('fastify') 6 | const mercurius = require('mercurius') 7 | const cache = require('..') 8 | const split = require('split2') 9 | const FakeTimers = require('@sinonjs/fake-timers') 10 | const { request } = require('./helper') 11 | 12 | const sleep = promisify(setTimeout) 13 | 14 | test('Report with non-default query type name', async ({ strictSame, plan, fail, teardown }) => { 15 | plan(2) 16 | 17 | let app = null 18 | const stream = split(JSON.parse) 19 | try { 20 | app = fastify({ 21 | logger: { 22 | stream 23 | } 24 | }) 25 | } catch (e) { 26 | fail() 27 | } 28 | 29 | teardown(app.close.bind(app)) 30 | 31 | const clock = FakeTimers.install({ 32 | shouldAdvanceTime: true, 33 | advanceTimeDelta: 100 34 | }) 35 | teardown(() => clock.uninstall()) 36 | 37 | const schema = ` 38 | schema { 39 | query: XQuery 40 | } 41 | 42 | type XQuery { 43 | add(x: Int, y: Int): Int 44 | } 45 | ` 46 | 47 | const resolvers = { 48 | XQuery: { 49 | async add (_, { x, y }) { 50 | return x + y 51 | } 52 | } 53 | } 54 | 55 | app.register(mercurius, { 56 | schema, 57 | resolvers 58 | }) 59 | 60 | app.register(cache, { 61 | ttl: 1, 62 | policy: { 63 | XQuery: { 64 | add: true 65 | } 66 | }, 67 | logInterval: 3 68 | }) 69 | 70 | const query = '{ add(x: 2, y: 2) }' 71 | 72 | let data 73 | await request({ app, query }) 74 | await request({ app, query }) 75 | 76 | clock.tick(1000) 77 | await once(stream, 'data') 78 | 79 | data = await once(stream, 'data') 80 | 81 | strictSame(data.data, { 'XQuery.add': { dedupes: 0, hits: 1, misses: 1, skips: 0 } }) 82 | 83 | await clock.tick(3000) 84 | 85 | data = await once(stream, 'data') 86 | 87 | strictSame(data.data, { 'XQuery.add': { dedupes: 0, hits: 0, misses: 0, skips: 0 } }) 88 | }) 89 | 90 | test('Report with policy specified', async ({ strictSame, plan, fail, teardown }) => { 91 | plan(2) 92 | 93 | let app = null 94 | const stream = split(JSON.parse) 95 | try { 96 | app = fastify({ 97 | logger: { 98 | stream 99 | } 100 | }) 101 | } catch (e) { 102 | fail() 103 | } 104 | 105 | teardown(app.close.bind(app)) 106 | 107 | const clock = FakeTimers.install({ 108 | shouldAdvanceTime: true, 109 | advanceTimeDelta: 100 110 | }) 111 | teardown(() => clock.uninstall()) 112 | 113 | const schema = ` 114 | type Query { 115 | add(x: Int, y: Int): Int 116 | } 117 | ` 118 | 119 | const resolvers = { 120 | Query: { 121 | async add (_, { x, y }) { 122 | return x + y 123 | } 124 | } 125 | } 126 | 127 | app.register(mercurius, { 128 | schema, 129 | resolvers 130 | }) 131 | 132 | app.register(cache, { 133 | ttl: 1, 134 | policy: { 135 | Query: { 136 | add: true 137 | } 138 | }, 139 | logInterval: 3 140 | }) 141 | 142 | const query = '{ add(x: 2, y: 2) }' 143 | 144 | let data 145 | await request({ app, query }) 146 | await request({ app, query }) 147 | 148 | clock.tick(1000) 149 | await once(stream, 'data') 150 | 151 | data = await once(stream, 'data') 152 | 153 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 1, misses: 1, skips: 0 } }) 154 | 155 | await clock.tick(3000) 156 | 157 | data = await once(stream, 'data') 158 | 159 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 0, misses: 0, skips: 0 } }) 160 | }) 161 | 162 | test('Report with all specified', async ({ strictSame, plan, fail, teardown }) => { 163 | plan(2) 164 | 165 | let app = null 166 | const stream = split(JSON.parse) 167 | try { 168 | app = fastify({ 169 | logger: { 170 | stream 171 | } 172 | }) 173 | } catch (e) { 174 | fail() 175 | } 176 | 177 | teardown(app.close.bind(app)) 178 | 179 | const clock = FakeTimers.install({ 180 | shouldAdvanceTime: true, 181 | advanceTimeDelta: 100 182 | }) 183 | teardown(() => clock.uninstall()) 184 | 185 | const schema = ` 186 | type Query { 187 | add(x: Int, y: Int): Int 188 | } 189 | ` 190 | 191 | const resolvers = { 192 | Query: { 193 | async add (_, { x, y }) { 194 | return x + y 195 | } 196 | } 197 | } 198 | 199 | app.register(mercurius, { 200 | schema, 201 | resolvers 202 | }) 203 | 204 | app.register(cache, { 205 | ttl: 1, 206 | all: true, 207 | logInterval: 3 208 | }) 209 | 210 | const query = '{ add(x: 2, y: 2) }' 211 | 212 | let data 213 | await request({ app, query }) 214 | await request({ app, query }) 215 | 216 | clock.tick(1000) 217 | await once(stream, 'data') 218 | 219 | data = await once(stream, 'data') 220 | 221 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 1, misses: 1, skips: 0 } }) 222 | 223 | clock.tick(3000) 224 | data = await once(stream, 'data') 225 | 226 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 0, misses: 0, skips: 0 } }) 227 | }) 228 | 229 | test('Log skips correctly', async ({ strictSame, plan, fail, teardown }) => { 230 | plan(2) 231 | 232 | let app = null 233 | const stream = split(JSON.parse) 234 | try { 235 | app = fastify({ 236 | logger: { 237 | stream 238 | } 239 | }) 240 | } catch (e) { 241 | fail() 242 | } 243 | 244 | teardown(app.close.bind(app)) 245 | 246 | const clock = FakeTimers.install({ 247 | shouldAdvanceTime: true, 248 | advanceTimeDelta: 100 249 | }) 250 | teardown(() => clock.uninstall()) 251 | 252 | const schema = ` 253 | type Query { 254 | add(x: Int, y: Int): Int 255 | } 256 | ` 257 | 258 | const resolvers = { 259 | Query: { 260 | async add (_, { x, y }) { 261 | return x + y 262 | } 263 | } 264 | } 265 | 266 | app.register(mercurius, { 267 | schema, 268 | resolvers 269 | }) 270 | 271 | app.register(cache, { 272 | ttl: 1, 273 | all: true, 274 | logInterval: 3, 275 | skip: (self, arg, ctx, info) => { 276 | return true 277 | } 278 | }) 279 | 280 | const query = '{ add(x: 2, y: 2) }' 281 | 282 | let data 283 | await request({ app, query }) 284 | await request({ app, query }) 285 | 286 | clock.tick(1000) 287 | await once(stream, 'data') 288 | 289 | data = await once(stream, 'data') 290 | 291 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 0, misses: 0, skips: 2 } }) 292 | 293 | clock.tick(3000) 294 | data = await once(stream, 'data') 295 | 296 | strictSame(data.data, { 'Query.add': { dedupes: 0, hits: 0, misses: 0, skips: 0 } }) 297 | }) 298 | 299 | test('Report using custom logReport function', async ({ type, plan, endAll, fail, teardown }) => { 300 | plan(1) 301 | 302 | let app = null 303 | const stream = split(JSON.parse) 304 | try { 305 | app = fastify({ 306 | logger: { 307 | stream 308 | } 309 | }) 310 | } catch (e) { 311 | fail() 312 | } 313 | 314 | teardown(app.close.bind(app)) 315 | 316 | const clock = FakeTimers.install({ 317 | shouldAdvanceTime: true, 318 | advanceTimeDelta: 100 319 | }) 320 | teardown(() => clock.uninstall()) 321 | 322 | const schema = ` 323 | type Query { 324 | add(x: Int, y: Int): Int 325 | } 326 | ` 327 | 328 | const resolvers = { 329 | Query: { 330 | async add (_, { x, y }) { 331 | return x + y 332 | } 333 | } 334 | } 335 | 336 | app.register(mercurius, { 337 | schema, 338 | resolvers 339 | }) 340 | 341 | app.register(cache, { 342 | ttl: 1, 343 | all: true, 344 | logInterval: 1, 345 | logReport: (report) => { 346 | type(report['Query.add'], 'object') 347 | endAll() 348 | } 349 | }) 350 | 351 | const query = '{ add(x: 2, y: 2) }' 352 | 353 | await request({ app, query }) 354 | clock.tick(1000) 355 | await clock.nextAsync() 356 | 357 | await once(stream, 'data') 358 | await once(stream, 'data') 359 | }) 360 | 361 | test('Report using a custom type', async ({ plan, ok, teardown }) => { 362 | plan(4) 363 | const app = fastify() 364 | teardown(app.close.bind(app)) 365 | const clock = FakeTimers.install({ 366 | shouldAdvanceTime: true, 367 | advanceTimeDelta: 100 368 | }) 369 | teardown(() => clock.uninstall()) 370 | 371 | const dogs = [{ name: 'Max' }, { name: 'Charlie' }, { name: 'Buddy' }, { name: 'Max' }] 372 | const owners = { Max: { name: 'Jennifer' }, Charlie: { name: 'Sarah' }, Buddy: { name: 'Tracy' } } 373 | 374 | const schema = ` 375 | type Human { 376 | name: String! 377 | } 378 | 379 | type Dog { 380 | name: String! 381 | owner: Human 382 | } 383 | 384 | type Query { 385 | dogs: [Dog] 386 | } 387 | 388 | type Mutation { 389 | addDog(name: String!): Dog 390 | } 391 | ` 392 | 393 | const resolvers = { 394 | Query: { 395 | dogs (_, params, { reply }) { return dogs } 396 | }, 397 | Mutation: { 398 | async addDog (_, { name }, { reply }) { 399 | const dog = { name } 400 | dogs.push(dog) 401 | return dog 402 | } 403 | } 404 | } 405 | 406 | const loaders = { 407 | Dog: { 408 | async owner (queries) { return queries.map(({ obj }) => owners[obj.name]) } 409 | } 410 | } 411 | 412 | app.register(mercurius, { 413 | schema, 414 | resolvers, 415 | loaders 416 | }) 417 | 418 | app.register(cache, { 419 | ttl: 10, 420 | logInterval: 1, 421 | logReport: (report) => { 422 | ok(report['Dog.owner']) 423 | ok(report['Query.dogs']) 424 | }, 425 | policy: { 426 | Dog: { 427 | owner: true 428 | }, 429 | Query: { 430 | dogs: true 431 | }, 432 | Mutation: { 433 | addDog: { 434 | invalidate: () => '*' 435 | } 436 | } 437 | } 438 | }) 439 | 440 | await request({ app, query: '{ dogs { owner { name } } }' }) 441 | await request({ app, query: '{ dogs { owner { name } } }' }) 442 | 443 | clock.tick(1000) 444 | await clock.nextAsync() 445 | }) 446 | 447 | test('Report dedupes', async ({ strictSame, plan, fail, teardown, equal }) => { 448 | plan(4) 449 | 450 | let app = null 451 | const stream = split(JSON.parse) 452 | try { 453 | app = fastify({ logger: { stream } }) 454 | } catch (e) { 455 | fail() 456 | } 457 | 458 | teardown(app.close.bind(app)) 459 | 460 | const schema = ` 461 | type Query { 462 | add(x: Int, y: Int): Int 463 | } 464 | ` 465 | 466 | const resolvers = { 467 | Query: { 468 | async add (_, { x, y }) { 469 | await sleep(500) 470 | return x + y 471 | } 472 | } 473 | } 474 | 475 | app.register(mercurius, { schema, resolvers }) 476 | 477 | app.register(cache, { 478 | ttl: 1, 479 | policy: { 480 | Query: { 481 | add: true 482 | } 483 | }, 484 | onDedupe: (type, name) => { 485 | equal(type, 'Query') 486 | equal(name, 'add') 487 | }, 488 | logInterval: 1, 489 | logReport: (data) => { 490 | strictSame(data, { 'Query.add': { dedupes: 2, hits: 0, misses: 1, skips: 0 } }) 491 | } 492 | }) 493 | 494 | const query = '{ add(x: 2, y: 2) }' 495 | 496 | await Promise.all([ 497 | request({ app, query }), 498 | request({ app, query }), 499 | request({ app, query }) 500 | ]) 501 | }) 502 | 503 | function once (emitter, name) { 504 | return new Promise((resolve, reject) => { 505 | if (name !== 'error') emitter.once('error', reject) 506 | emitter.once(name, (...args) => { 507 | emitter.removeListener('error', reject) 508 | resolve(...args) 509 | }) 510 | }) 511 | } 512 | -------------------------------------------------------------------------------- /test/scalar-types.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const { GraphQLScalarType, Kind } = require('graphql') 7 | const GraphQLJSON = require('graphql-type-json') 8 | const Redis = require('ioredis') 9 | const cache = require('..') 10 | 11 | const { request } = require('./helper') 12 | 13 | const redisClient = new Redis() 14 | 15 | t.teardown(async () => { 16 | await redisClient.quit() 17 | }) 18 | 19 | const storages = [ 20 | { type: 'memory' }, 21 | { type: 'redis', options: { client: redisClient, invalidation: true } } 22 | ] 23 | 24 | t.test('works with custom scalar type', async t => { 25 | t.beforeEach(async () => { 26 | await redisClient.flushall() 27 | }) 28 | 29 | for (const storage of storages) { 30 | t.test(`with ${storage.type} storage`, async t => { 31 | const app = fastify() 32 | t.teardown(app.close.bind(app)) 33 | 34 | const dateScalar = new GraphQLScalarType({ 35 | name: 'Date', 36 | description: 'Date custom scalar type', 37 | parseValue: value => value instanceof Date ? value : new Date(value), 38 | serialize: value => value instanceof Date ? value : new Date(value), 39 | parseLiteral: ast => ast.kind === Kind.INT ? new Date(+ast.value) : null 40 | }) 41 | 42 | const schema = ` 43 | scalar Date 44 | 45 | type Event { 46 | id: ID! 47 | date: Date! 48 | } 49 | 50 | type Query { 51 | events: [Event!] 52 | } 53 | ` 54 | 55 | const date = '2023-01-19T08:25:38.258Z' 56 | const id = 'abc123' 57 | const events = [ 58 | { id, date: new Date(date) } 59 | ] 60 | 61 | const resolvers = { 62 | Date: dateScalar, 63 | Query: { async events () { return events } } 64 | } 65 | 66 | app.register(mercurius, { schema, resolvers }) 67 | 68 | let hits = 0 69 | let misses = 0 70 | 71 | app.register(cache, { 72 | ttl: 999, 73 | all: true, 74 | storage, 75 | onHit () { hits++ }, 76 | onMiss () { misses++ } 77 | }) 78 | 79 | const query = '{ events { id, date } }' 80 | 81 | { 82 | const result = await request({ app, query }) 83 | t.same(result, { data: { events: [{ id, date }] } }) 84 | } 85 | 86 | { 87 | const result = await request({ app, query }) 88 | t.same(result, { data: { events: [{ id, date }] } }) 89 | } 90 | 91 | t.equal(hits, 1) 92 | t.equal(misses, 1) 93 | }) 94 | } 95 | }) 96 | 97 | t.test('works with 3rd party scalar type', async t => { 98 | t.beforeEach(async () => { 99 | await redisClient.flushall() 100 | }) 101 | 102 | for (const storage of storages) { 103 | t.test(`with ${storage.type} storage`, async t => { 104 | const app = fastify() 105 | t.teardown(app.close.bind(app)) 106 | 107 | const schema = ` 108 | scalar JSON 109 | 110 | type Event { 111 | address: JSON 112 | } 113 | 114 | type Query { 115 | events: [Event] 116 | } 117 | ` 118 | 119 | const events = [ 120 | { address: { } }, 121 | { address: { street: '15th Avenue', zip: '987' } } 122 | ] 123 | 124 | const resolvers = { 125 | JSON: GraphQLJSON, 126 | Query: { async events () { return events } } 127 | } 128 | 129 | app.register(mercurius, { schema, resolvers }) 130 | 131 | let hits = 0 132 | let misses = 0 133 | 134 | app.register(cache, { 135 | ttl: 999, 136 | all: true, 137 | storage, 138 | onHit () { hits++ }, 139 | onMiss () { misses++ } 140 | }) 141 | 142 | const query = '{ events { address } }' 143 | 144 | { 145 | const result = await request({ app, query }) 146 | t.same(result, { data: { events } }) 147 | } 148 | 149 | { 150 | const result = await request({ app, query }) 151 | t.same(result, { data: { events } }) 152 | } 153 | 154 | t.equal(hits, 1) 155 | t.equal(misses, 1) 156 | }) 157 | } 158 | }) 159 | -------------------------------------------------------------------------------- /test/types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import { 3 | MercuriusCacheOptions, 4 | MercuriusCachePolicy, 5 | PolicyFieldOptions, 6 | MercuriusCacheStorageMemory, 7 | MercuriusCacheStorageRedis, 8 | MercuriusCacheContext, 9 | MercuriusCacheStorageType, 10 | } from "../../index"; 11 | import { expectAssignable, expectNotAssignable } from "tsd"; 12 | import mercuriusCache from "../../index"; 13 | 14 | const app = fastify(); 15 | 16 | const emptyCacheOptions = {}; 17 | expectAssignable(emptyCacheOptions); 18 | app.register(mercuriusCache, emptyCacheOptions); 19 | 20 | expectAssignable(app.graphql.cache) 21 | 22 | const queryFieldPolicy = { 23 | ttl: (result: { shouldCache: boolean }) => result.shouldCache ? 10 : 0, 24 | stale: 10, 25 | storage: { type: MercuriusCacheStorageType.MEMORY, options: { size: 1 } }, 26 | }; 27 | 28 | expectAssignable(queryFieldPolicy); 29 | 30 | const queryPolicy = { 31 | Query: { 32 | add: queryFieldPolicy, 33 | }, 34 | }; 35 | 36 | expectAssignable(queryPolicy); 37 | 38 | const wrongStorageType = { 39 | type: "wrong type" 40 | } 41 | 42 | expectNotAssignable(wrongStorageType); 43 | 44 | const cacheRedisStorage = { 45 | type: MercuriusCacheStorageType.REDIS, 46 | options: { 47 | client: {}, 48 | invalidate: true, 49 | log: {log: "storage log"}, 50 | }, 51 | }; 52 | 53 | expectAssignable(cacheRedisStorage); 54 | expectNotAssignable(cacheRedisStorage); 55 | 56 | const cacheMemoryStorage = { 57 | type: MercuriusCacheStorageType.MEMORY, 58 | options: { 59 | invalidate: true, 60 | size: 10000, 61 | log: {log: "storage log"}, 62 | }, 63 | }; 64 | 65 | expectAssignable(cacheMemoryStorage); 66 | expectNotAssignable(cacheMemoryStorage); 67 | 68 | 69 | const allValidCacheOptions = { 70 | all: false, 71 | policy: queryPolicy, 72 | ttl: () => 1000, 73 | skip: () => { 74 | console.log("skip called!"); 75 | }, 76 | storage: cacheMemoryStorage, 77 | onDedupe: () => { 78 | console.log("onDedupe called!"); 79 | }, 80 | onHit: () => { 81 | console.log("onHit called!"); 82 | }, 83 | onMiss: () => { 84 | console.log("onMiss called!"); 85 | }, 86 | onSkip: () => { 87 | console.log("onSkip called!"); 88 | }, 89 | onError: () => { 90 | console.log("onError called!"); 91 | }, 92 | logInterval: 500, 93 | logReport: () => { 94 | console.log("log report"); 95 | }, 96 | }; 97 | expectAssignable(allValidCacheOptions); 98 | app.register(mercuriusCache, allValidCacheOptions); 99 | 100 | expectAssignable(app.graphql.cache) 101 | -------------------------------------------------------------------------------- /test/with-redis.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, teardown, beforeEach } = require('tap') 4 | const fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const Redis = require('ioredis') 7 | const cache = require('..') 8 | const { request } = require('./helper') 9 | 10 | const redisClient = new Redis() 11 | 12 | teardown(async () => { 13 | await redisClient.quit() 14 | }) 15 | 16 | beforeEach(async () => { 17 | await redisClient.flushall() 18 | }) 19 | 20 | test('redis invalidation', async () => { 21 | const setupServer = ({ onMiss, onHit, invalidate, onError, t }) => { 22 | const schema = ` 23 | type Query { 24 | get (id: Int): String 25 | search (id: Int): String 26 | } 27 | type Mutation { 28 | set (id: Int): String 29 | } 30 | ` 31 | const resolvers = { 32 | Query: { 33 | async get (_, { id }) { 34 | return 'get ' + id 35 | }, 36 | async search (_, { id }) { 37 | return 'search ' + id 38 | } 39 | }, 40 | Mutation: { 41 | async set (_, { id }) { 42 | return 'set ' + id 43 | } 44 | } 45 | } 46 | const app = fastify() 47 | t.teardown(app.close.bind(app)) 48 | app.register(mercurius, { schema, resolvers }) 49 | // Setup Cache 50 | app.register(cache, { 51 | ttl: 100, 52 | storage: { 53 | type: 'redis', 54 | options: { client: redisClient, invalidation: true } 55 | }, 56 | onMiss, 57 | onHit, 58 | onError, 59 | policy: { 60 | Query: { 61 | get: { 62 | references: ({ arg }) => [`get:${arg.id}`, 'gets'] 63 | }, 64 | search: { 65 | references: ({ arg }) => [`search:${arg.id}`] 66 | } 67 | }, 68 | Mutation: { 69 | set: { 70 | invalidate: invalidate || ((_, arg) => [`get:${arg.id}`, 'gets']) 71 | } 72 | } 73 | } 74 | }) 75 | return app 76 | } 77 | 78 | test('should remove storage keys by references', async t => { 79 | // Setup Fastify and Mercurius 80 | let miss = 0 81 | const app = setupServer({ 82 | onMiss: () => ++miss, 83 | invalidate: (_, arg) => [`get:${arg.id}`], 84 | t 85 | }) 86 | // Cache the follwoing 87 | await request({ app, query: '{ get(id: 11) }' }) 88 | t.equal(miss, 1) 89 | await request({ app, query: '{ get(id: 12) }' }) 90 | t.equal(miss, 2) 91 | await request({ app, query: '{ search(id: 11) }' }) 92 | t.equal(miss, 3) 93 | // Request a mutation 94 | await request({ app, query: 'mutation { set(id: 11) }' }) 95 | t.equal(miss, 3) 96 | // 'get:11' should not be present in cache anymore 97 | await request({ app, query: '{ get(id: 11) }' }) 98 | t.equal(miss, 4) 99 | await request({ app, query: '{ search(id: 11) }' }) 100 | t.equal(miss, 4) 101 | await request({ app, query: '{ get(id: 12) }' }) 102 | t.equal(miss, 4) 103 | }) 104 | 105 | test('should not remove storage key by not existing reference', async t => { 106 | // Setup Fastify and Mercurius 107 | let miss = 0 108 | const app = setupServer({ 109 | onMiss: () => ++miss, 110 | invalidate: () => ['foo'], 111 | t 112 | }) 113 | // Cache the follwoing 114 | await request({ app, query: '{ get(id: 11) }' }) 115 | t.equal(miss, 1) 116 | await request({ app, query: '{ get(id: 12) }' }) 117 | t.equal(miss, 2) 118 | await request({ app, query: '{ search(id: 11) }' }) 119 | t.equal(miss, 3) 120 | // Request a mutation 121 | await request({ app, query: 'mutation { set(id: 11) }' }) 122 | t.equal(miss, 3) 123 | // 'get:11' should be still in cache 124 | await request({ app, query: '{ get(id: 11) }' }) 125 | t.equal(miss, 3) 126 | await request({ app, query: '{ search(id: 11) }' }) 127 | t.equal(miss, 3) 128 | await request({ app, query: '{ get(id: 12) }' }) 129 | t.equal(miss, 3) 130 | }) 131 | 132 | test('should invalidate more than one reference at once', async t => { 133 | // Setup Fastify and Mercurius 134 | let miss = 0 135 | const app = setupServer({ 136 | onMiss: () => ++miss, 137 | t 138 | }) 139 | // Cache the follwoing 140 | await request({ app, query: '{ get(id: 11) }' }) 141 | t.equal(miss, 1) 142 | await request({ app, query: '{ get(id: 12) }' }) 143 | t.equal(miss, 2) 144 | await request({ app, query: '{ search(id: 11) }' }) 145 | t.equal(miss, 3) 146 | // Request a mutation 147 | await request({ app, query: 'mutation { set(id: 11) }' }) 148 | t.equal(miss, 3) 149 | // All 'get' should not be present in cache anymore 150 | await request({ app, query: '{ get(id: 11) }' }) 151 | t.equal(miss, 4) 152 | await request({ app, query: '{ search(id: 11) }' }) 153 | t.equal(miss, 4) 154 | await request({ app, query: '{ get(id: 12) }' }) 155 | t.equal(miss, 5) 156 | }) 157 | 158 | test('should remove storage keys by references, but not the ones still alive', async t => { 159 | // Setup Fastify and Mercurius 160 | let failHit = false 161 | const app = setupServer({ 162 | onHit () { 163 | if (failHit) t.fail() 164 | }, 165 | t 166 | }) 167 | // Run the request and cache it 168 | await request({ app, query: '{ get(id: 11) }' }) 169 | t.equal( 170 | await redisClient.get((await redisClient.smembers('r:get:11'))[0]), 171 | '"get 11"' 172 | ) 173 | await request({ app, query: '{ get(id: 12) }' }) 174 | t.equal( 175 | await redisClient.get((await redisClient.smembers('r:get:12'))[0]), 176 | '"get 12"' 177 | ) 178 | await request({ app, query: '{ search(id: 11) }' }) 179 | t.equal( 180 | await redisClient.get((await redisClient.smembers('r:search:11'))[0]), 181 | '"search 11"' 182 | ) 183 | // Request a mutation, invalidate 'gets' 184 | await request({ app, query: 'mutation { set(id: 11) }' }) 185 | // Check the references of 'searchs', should still be there 186 | t.equal( 187 | await redisClient.get((await redisClient.smembers('r:search:11'))[0]), 188 | '"search 11"' 189 | ) 190 | // 'get:11' should not be present in cache anymore, 191 | failHit = true 192 | // should trigger onMiss and not onHit 193 | await request({ app, query: '{ get(id: 11) }' }) 194 | }) 195 | 196 | test('should not throw on invalidation error', async t => { 197 | t.plan(3) 198 | // Setup Fastify and Mercurius 199 | const app = setupServer({ 200 | invalidate () { 201 | throw new Error('Kaboom') 202 | }, 203 | onError (type, fieldName, error) { 204 | t.equal(type, 'Mutation') 205 | t.equal(fieldName, 'set') 206 | t.equal(error.message, 'Kaboom') 207 | }, 208 | t 209 | }) 210 | // Run the request and cache it 211 | await request({ app, query: '{ get(id: 11) }' }) 212 | await request({ app, query: 'mutation { set(id: 11) }' }) 213 | }) 214 | }) 215 | 216 | test('policy options', async t => { 217 | test('custom key', async t => { 218 | t.beforeEach(async () => { 219 | await redisClient.flushall() 220 | }) 221 | 222 | t.test('should be able to use a custom key function, without fields', async t => { 223 | const app = fastify() 224 | t.teardown(app.close.bind(app)) 225 | 226 | const schema = ` 227 | type Query { 228 | add(x: Int, y: Int): Int 229 | sub(x: Int, y: Int): Int 230 | mul(x: Int, y: Int): Int 231 | } 232 | ` 233 | 234 | const resolvers = { 235 | Query: { 236 | async add (_, { x, y }) { return x + y }, 237 | async sub (_, { x, y }) { return x - y }, 238 | async mul (_, { x, y }) { return x * y } 239 | } 240 | } 241 | 242 | app.register(mercurius, { schema, resolvers }) 243 | 244 | app.register(cache, { 245 | ttl: 999, 246 | storage: { type: 'redis', options: { client: redisClient } }, 247 | policy: { 248 | Query: { 249 | add: { key ({ self, arg, info, ctx, fields }) { return `${arg.x}+${arg.y}` } }, 250 | sub: { key ({ self, arg, info, ctx, fields }) { return `${arg.x}-${arg.y}` } }, 251 | mul: { key ({ self, arg, info, ctx, fields }) { return `${arg.x}*${arg.y}` } } 252 | } 253 | } 254 | }) 255 | 256 | await request({ app, query: '{ add(x: 1, y: 1) }' }) 257 | t.equal(await redisClient.get('Query.add~1+1'), '2') 258 | 259 | await request({ app, query: '{ sub(x: 2, y: 2) }' }) 260 | t.equal(await redisClient.get('Query.sub~2-2'), '0') 261 | 262 | await request({ app, query: '{ mul(x: 3, y: 3) }' }) 263 | t.equal(await redisClient.get('Query.mul~3*3'), '9') 264 | }) 265 | 266 | t.test('should be able to use a custom key function, with fields without selection', async t => { 267 | const app = fastify() 268 | t.teardown(app.close.bind(app)) 269 | 270 | const schema = ` 271 | type Query { 272 | getUser (id: ID!): User 273 | getUsers (name: String, lastName: String): [User] 274 | } 275 | 276 | type User { 277 | id: ID! 278 | name: String 279 | lastName: String 280 | } 281 | ` 282 | 283 | const users = { 284 | a1: { name: 'Angus', lastName: 'Young' }, 285 | b2: { name: 'Phil', lastName: 'Rudd' }, 286 | c3: { name: 'Cliff', lastName: 'Williams' }, 287 | d4: { name: 'Brian', lastName: 'Johnson' }, 288 | e5: { name: 'Stevie', lastName: 'Young' } 289 | } 290 | 291 | const resolvers = { 292 | Query: { 293 | async getUser (_, { id }) { return users[id] ? { id, ...users[id] } : null }, 294 | async getUsers (_, { name, lastName }) { 295 | const id = Object.keys(users).find(key => { 296 | const user = users[key] 297 | if (name && user.name !== name) return false 298 | if (lastName && user.lastName !== lastName) return false 299 | return true 300 | }) 301 | return id ? [{ id, ...users[id] }] : [] 302 | } 303 | } 304 | } 305 | 306 | app.register(mercurius, { schema, resolvers }) 307 | 308 | const hits = { getUser: 0, getUsers: 0 } 309 | app.register(cache, { 310 | ttl: 999, 311 | storage: { 312 | type: 'redis', 313 | options: { client: redisClient } 314 | }, 315 | onHit (type, name) { hits[name]++ }, 316 | policy: { 317 | Query: { 318 | getUser: { key ({ self, arg, info, ctx, fields }) { return `${arg.id}` } }, 319 | getUsers: { key ({ self, arg, info, ctx, fields }) { return `${arg.name || '*'},${arg.lastName || '*'}` } } 320 | } 321 | } 322 | }) 323 | 324 | // use key and store in cache the user 325 | t.same(await request({ app, query: '{ getUser(id: "a1") { name, lastName} }' }), { 326 | data: { getUser: { name: 'Angus', lastName: 'Young' } } 327 | }) 328 | t.equal(await redisClient.get('Query.getUser~a1'), JSON.stringify({ id: 'a1', lastName: 'Young', name: 'Angus' })) 329 | 330 | // use key and get the user from cache 331 | t.same(await request({ app, query: '{ getUser(id: "a1") { id } }' }), { 332 | data: { getUser: { id: 'a1' } } 333 | }) 334 | t.equal(hits.getUser, 1) 335 | 336 | // query users 337 | t.same(await request({ app, query: '{ getUsers(name: "Brian") { id, name, lastName} }' }), { 338 | data: { getUsers: [{ id: 'd4', name: 'Brian', lastName: 'Johnson' }] } 339 | }) 340 | t.equal(await redisClient.get('Query.getUsers~Brian,*'), JSON.stringify([{ id: 'd4', lastName: 'Johnson', name: 'Brian' }])) 341 | 342 | t.same(await request({ app, query: '{ getUsers(name: "Brian") { name } }' }), { 343 | data: { getUsers: [{ name: 'Brian' }] } 344 | }) 345 | t.equal(hits.getUsers, 1) 346 | }) 347 | 348 | test('should be able to use a custom key function, with fields selection', async t => { 349 | function selectedFields (info) { 350 | const fields = [] 351 | for (let i = 0; i < info.fieldNodes.length; i++) { 352 | const node = info.fieldNodes[i] 353 | if (!node.selectionSet) { 354 | continue 355 | } 356 | for (let j = 0; j < node.selectionSet.selections.length; j++) { 357 | fields.push(node.selectionSet.selections[j].name.value) 358 | } 359 | } 360 | fields.sort() 361 | return fields 362 | } 363 | const app = fastify() 364 | t.teardown(app.close.bind(app)) 365 | 366 | const schema = ` 367 | type Query { 368 | getUser (id: ID!): User 369 | getUsers (name: String, lastName: String): [User] 370 | } 371 | 372 | type User { 373 | id: ID! 374 | name: String 375 | lastName: String 376 | } 377 | ` 378 | 379 | const users = { 380 | a1: { name: 'Angus', lastName: 'Young' }, 381 | b2: { name: 'Phil', lastName: 'Rudd' }, 382 | c3: { name: 'Cliff', lastName: 'Williams' }, 383 | d4: { name: 'Brian', lastName: 'Johnson' }, 384 | e5: { name: 'Stevie', lastName: 'Young' } 385 | } 386 | 387 | const resolvers = { 388 | Query: { 389 | async getUser (_, { id }, context, info) { 390 | if (!users[id]) { return null } 391 | const fields = selectedFields(info) 392 | const user = fields.reduce((user, field) => ({ ...user, [field]: users[id][field] }), {}) 393 | if (fields.includes('id')) { user.id = id } 394 | return user 395 | }, 396 | async getUsers (_, { name, lastName }, context, info) { 397 | const ids = Object.keys(users).filter(key => { 398 | const user = users[key] 399 | if (name && user.name !== name) return false 400 | if (lastName && user.lastName !== lastName) return false 401 | return true 402 | }) 403 | const fields = selectedFields(info) 404 | const withId = fields.includes('id') 405 | return ids.map(id => { 406 | const user = fields.reduce((user, field) => ({ ...user, [field]: users[id][field] }), {}) 407 | if (withId) { user.id = id } 408 | return user 409 | }) 410 | } 411 | } 412 | } 413 | 414 | app.register(mercurius, { schema, resolvers }) 415 | 416 | let hits = 0 417 | app.register(cache, { 418 | ttl: 999, 419 | storage: { 420 | type: 'redis', 421 | options: { client: redisClient } 422 | }, 423 | onHit (type, name) { hits++ }, 424 | policy: { 425 | Query: { 426 | getUser: { key ({ self, arg, info, ctx, fields }) { return `${arg.id}:${fields.join()}` } }, 427 | getUsers: { key ({ self, arg, info, ctx, fields }) { return `${arg.name || '*'},${arg.lastName || '*'}:${fields.join()}` } } 428 | } 429 | } 430 | }) 431 | 432 | // use key and store in cache the user 433 | t.same(await request({ app, query: '{ getUser(id: "a1") { name, lastName} }' }), { 434 | data: { getUser: { name: 'Angus', lastName: 'Young' } } 435 | }) 436 | t.equal(await redisClient.get('Query.getUser~a1:lastName,name'), JSON.stringify({ lastName: 'Young', name: 'Angus' })) 437 | 438 | // use key and get the user from cache 439 | t.same(await request({ app, query: '{ getUser(id: "a1") { id } }' }), { 440 | data: { getUser: { id: 'a1' } } 441 | }) 442 | t.equal(await redisClient.get('Query.getUser~a1:id'), JSON.stringify({ id: 'a1' })) 443 | 444 | // query users 445 | t.same(await request({ app, query: '{ getUsers(lastName: "Young") { id, name, lastName} }' }), { 446 | data: { getUsers: [{ id: 'a1', name: 'Angus', lastName: 'Young' }, { id: 'e5', name: 'Stevie', lastName: 'Young' }] } 447 | }) 448 | t.equal(await redisClient.get('Query.getUsers~*,Young:id,lastName,name'), JSON.stringify([{ id: 'a1', lastName: 'Young', name: 'Angus' }, { id: 'e5', lastName: 'Young', name: 'Stevie' }])) 449 | 450 | // query users different fields 451 | t.same(await request({ app, query: '{ getUsers(lastName: "Young") { name } }' }), { 452 | data: { getUsers: [{ name: 'Angus' }, { name: 'Stevie' }] } 453 | }) 454 | t.equal(await redisClient.get('Query.getUsers~*,Young:name'), JSON.stringify([{ name: 'Angus' }, { name: 'Stevie' }])) 455 | 456 | // never used the cache 457 | t.equal(hits, 0) 458 | }) 459 | }) 460 | }) 461 | 462 | test('manual invalidation', async t => { 463 | beforeEach(async () => { 464 | await redisClient.flushall() 465 | }) 466 | 467 | const createApp = ({ schema, resolvers, t, cacheOptions }) => { 468 | const app = fastify() 469 | t.teardown(app.close.bind(app)) 470 | app.register(mercurius, { schema, resolvers }) 471 | app.register(cache, cacheOptions) 472 | return app 473 | } 474 | 475 | t.test('should be able to call invalidation with a reference', async t => { 476 | let hits 477 | const app = createApp({ 478 | t, 479 | schema: ` 480 | type Country { 481 | name: String 482 | } 483 | 484 | type User { 485 | id: ID! 486 | name: String! 487 | } 488 | 489 | type Query { 490 | user(id: ID!): User 491 | countries: [Country] 492 | } 493 | `, 494 | resolvers: { 495 | Query: { 496 | user (_, { id }) { return { id, name: `User ${id}` } }, 497 | countries () { return [{ name: 'Ireland' }, { name: 'Italy' }] } 498 | } 499 | }, 500 | cacheOptions: { 501 | ttl: 99, 502 | storage: { 503 | type: 'redis', 504 | options: { client: redisClient, invalidation: true } 505 | }, 506 | onHit (type, fieldName) { 507 | hits++ 508 | }, 509 | policy: { 510 | Query: { 511 | user: { 512 | references: (_request, _key, result) => { 513 | if (!result) { return } 514 | return [`user:${result.id}`] 515 | } 516 | } 517 | } 518 | } 519 | } 520 | }) 521 | 522 | const query = '{ user(id: "1") { id, name } }' 523 | hits = 0 524 | await request({ app, query }) 525 | await app.graphql.cache.invalidate('user:1') 526 | await request({ app, query }) 527 | t.equal(hits, 0) 528 | }) 529 | 530 | t.test('should be able to call invalidation with wildcard', async t => { 531 | let hits 532 | const app = createApp({ 533 | t, 534 | schema: ` 535 | type Country { 536 | name: String 537 | } 538 | 539 | type User { 540 | id: ID! 541 | name: String! 542 | } 543 | 544 | type Query { 545 | user(id: ID!): User 546 | countries: [Country] 547 | } 548 | `, 549 | resolvers: { 550 | Query: { 551 | user (_, { id }) { return { id, name: `User ${id}` } }, 552 | countries () { return [{ name: 'Ireland' }, { name: 'Italy' }] } 553 | } 554 | }, 555 | cacheOptions: { 556 | ttl: 99, 557 | storage: { 558 | type: 'redis', 559 | options: { client: redisClient, invalidation: true } 560 | }, 561 | onHit (type, fieldName) { 562 | hits++ 563 | }, 564 | policy: { 565 | Query: { 566 | user: { 567 | references: (_request, _key, result) => { 568 | if (!result) { return } 569 | return [`user:${result.id}`] 570 | } 571 | } 572 | } 573 | } 574 | } 575 | }) 576 | 577 | hits = 0 578 | await request({ app, query: '{ user(id: "1") { name } }' }) 579 | await request({ app, query: '{ user(id: "2") { name } }' }) 580 | await request({ app, query: '{ user(id: "3") { name } }' }) 581 | await app.graphql.cache.invalidate('user:*') 582 | await request({ app, query: '{ user(id: "1") { name } }' }) 583 | await request({ app, query: '{ user(id: "2") { name } }' }) 584 | await request({ app, query: '{ user(id: "3") { name } }' }) 585 | t.equal(hits, 0) 586 | }) 587 | 588 | t.test('should be able to call invalidation with an array of references', async t => { 589 | let hits 590 | const app = createApp({ 591 | t, 592 | schema: ` 593 | type Country { 594 | name: String 595 | } 596 | 597 | type User { 598 | id: ID! 599 | name: String! 600 | } 601 | 602 | type Query { 603 | user(id: ID!): User 604 | countries: [Country] 605 | } 606 | `, 607 | resolvers: { 608 | Query: { 609 | user (_, { id }) { return { id, name: `User ${id}` } }, 610 | countries () { return [{ name: 'Ireland' }, { name: 'Italy' }] } 611 | } 612 | }, 613 | cacheOptions: { 614 | ttl: 99, 615 | storage: { 616 | type: 'redis', 617 | options: { client: redisClient, invalidation: true } 618 | }, 619 | onHit (type, fieldName) { 620 | hits++ 621 | }, 622 | policy: { 623 | Query: { 624 | user: { 625 | references: (_request, _key, result) => { 626 | if (!result) { return } 627 | return [`user:${result.id}`] 628 | } 629 | } 630 | } 631 | } 632 | } 633 | }) 634 | 635 | hits = 0 636 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 637 | await request({ app, query: '{ user(id: "2") { id, name } }' }) 638 | await request({ app, query: '{ user(id: "3") { id, name } }' }) 639 | await app.graphql.cache.invalidate(['user:1', 'user:2']) 640 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 641 | await request({ app, query: '{ user(id: "2") { id, name } }' }) 642 | t.equal(hits, 0) 643 | }) 644 | 645 | t.test('should be able to call invalidation on a specific storage', async t => { 646 | const app = createApp({ 647 | t, 648 | schema: ` 649 | type Country { 650 | name: String 651 | } 652 | 653 | type User { 654 | id: ID! 655 | name: String! 656 | } 657 | 658 | type Query { 659 | user(id: ID!): User 660 | countries: [Country] 661 | } 662 | `, 663 | resolvers: { 664 | Query: { 665 | user (_, { id }) { return { id, name: `User ${id}` } }, 666 | countries () { return [{ name: 'Ireland' }, { name: 'Italy' }] } 667 | } 668 | }, 669 | cacheOptions: { 670 | ttl: 99, 671 | storage: { 672 | type: 'redis', 673 | options: { client: redisClient, invalidation: true } 674 | }, 675 | onHit (type, fieldName) { 676 | hits[`${type}.${fieldName}`]++ 677 | }, 678 | policy: { 679 | Query: { 680 | user: { 681 | references: (_request, _key, result) => { 682 | if (!result) { return } 683 | return [`user:${result.id}`] 684 | } 685 | }, 686 | countries: { 687 | ttl: 86400, // 1 day 688 | storage: { type: 'memory', options: { invalidation: true } }, 689 | references: () => ['countries'] 690 | } 691 | } 692 | } 693 | } 694 | }) 695 | 696 | const hits = { 'Query.user': 0, 'Query.countries': 0 } 697 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 698 | await request({ app, query: '{ countries { name } }' }) 699 | 700 | await app.graphql.cache.invalidate('countries', 'Query.countries') 701 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 702 | await request({ app, query: '{ countries { name } }' }) 703 | t.same(hits, { 'Query.user': 1, 'Query.countries': 0 }) 704 | }) 705 | 706 | t.test('should get a warning calling invalidation when it is disabled', async t => { 707 | t.plan(1) 708 | 709 | const app = createApp({ 710 | t, 711 | schema: ` 712 | type User { 713 | id: ID! 714 | name: String! 715 | } 716 | 717 | type Query { 718 | user(id: ID!): User 719 | } 720 | `, 721 | resolvers: { 722 | Query: { 723 | user (_, { id }) { return { id, name: `User ${id}` } } 724 | } 725 | }, 726 | cacheOptions: { 727 | ttl: 99, 728 | storage: { 729 | type: 'redis', 730 | options: { 731 | client: redisClient, 732 | invalidation: false, 733 | log: { 734 | warn: (args) => { 735 | t.equal(args.msg, 'acd/storage/redis.invalidate, exit due invalidation is disabled') 736 | } 737 | } 738 | } 739 | }, 740 | policy: { 741 | Query: { user: true } 742 | } 743 | } 744 | }) 745 | 746 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 747 | app.graphql.cache.invalidate('user:1') 748 | }) 749 | 750 | t.test('should reject calling invalidation on a non-existing storage', async t => { 751 | const app = createApp({ 752 | t, 753 | schema: ` 754 | type Country { 755 | name: String 756 | } 757 | 758 | type User { 759 | id: ID! 760 | name: String! 761 | } 762 | 763 | type Query { 764 | user(id: ID!): User 765 | countries: [Country] 766 | } 767 | `, 768 | resolvers: { 769 | Query: { 770 | user (_, { id }) { return { id, name: `User ${id}` } }, 771 | countries () { return [{ name: 'Ireland' }, { name: 'Italy' }] } 772 | } 773 | }, 774 | cacheOptions: { 775 | ttl: 99, 776 | storage: { 777 | type: 'redis', 778 | options: { client: redisClient, invalidation: true } 779 | }, 780 | policy: { 781 | Query: { 782 | user: { 783 | references: (_request, _key, result) => { 784 | if (!result) { return } 785 | return [`user:${result.id}`] 786 | } 787 | }, 788 | countries: { 789 | ttl: 86400, // 1 day 790 | storage: { type: 'memory', options: { invalidation: true } }, 791 | references: () => ['countries'] 792 | } 793 | } 794 | } 795 | } 796 | }) 797 | 798 | await request({ app, query: '{ user(id: "1") { id, name } }' }) 799 | await request({ app, query: '{ countries { name } }' }) 800 | 801 | await t.rejects(app.graphql.cache.invalidate('countries', 'non-existing-storage')) 802 | }) 803 | }) 804 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "jsx": "preserve", 7 | "outDir": "lib", 8 | "declarationDir": "types", 9 | "allowJs": false, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "strictNullChecks": true, 17 | "declaration": true 18 | }, 19 | "files": ["./test/types/index.test-d.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------