├── .gitignore ├── src ├── index.ts └── cache-control.decorator.ts ├── test └── cache-control.decorator.test.ts ├── .github └── workflows │ ├── size.yml │ └── main.yml ├── LICENSE ├── tsconfig.json ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache-control.decorator'; 2 | -------------------------------------------------------------------------------- /test/cache-control.decorator.test.ts: -------------------------------------------------------------------------------- 1 | import { CacheControl } from '../src'; 2 | 3 | describe('cache-control.decorator', () => { 4 | it('should be defined', () => { 5 | expect(CacheControl).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /src/cache-control.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@nestjs/graphql'; 2 | 3 | interface CacheControlOptions { 4 | maxAge?: number; 5 | scope?: 'PRIVATE' | 'PUBLIC'; 6 | inheritMaxAge?: boolean; 7 | } 8 | 9 | export const CacheControl = ({ 10 | maxAge, 11 | scope = 'PUBLIC', 12 | inheritMaxAge, 13 | }: CacheControlOptions) => 14 | Directive( 15 | `@cacheControl(scope: ${scope}${maxAge !== undefined ? `, maxAge: ${maxAge}` : ''}${ 16 | inheritMaxAge ? `, inheritMaxAge: ${inheritMaxAge}` : '' 17 | })` 18 | ); 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alireza Zamani 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. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "strictPropertyInitialization": false, 6 | "experimentalDecorators": true, 7 | "target": "ES6", 8 | "module": "esnext", 9 | "lib": ["dom", "esnext"], 10 | "importHelpers": true, 11 | // output .d.ts declaration files for consumers 12 | "declaration": true, 13 | // output .js.map sourcemap files for consumers 14 | "sourceMap": true, 15 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 16 | "rootDir": "./src", 17 | // stricter type-checking for stronger correctness. Recommended by TS 18 | "strict": true, 19 | // linter checks for common issues 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | // use Node's module resolution algorithm, instead of the legacy TS one 26 | "moduleResolution": "node", 27 | // transpile JSX to React.createElement 28 | "jsx": "react", 29 | // interop between ESM and CJS modules. Recommended by TS 30 | "esModuleInterop": true, 31 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 32 | "skipLibCheck": true, 33 | // error out if import and file system have a casing mismatch. Recommended by TS 34 | "forceConsistentCasingInFileNames": true, 35 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 36 | "noEmit": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.2", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "homepage": "https://github.com/overnested", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/overnested/nestjs-gql-cache-control" 17 | }, 18 | "scripts": { 19 | "start": "tsdx watch", 20 | "build": "tsdx build", 21 | "test": "tsdx test", 22 | "lint": "tsdx lint", 23 | "prepare": "tsdx build", 24 | "size": "size-limit", 25 | "analyze": "size-limit --why" 26 | }, 27 | "peerDependencies": {}, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "tsdx lint" 31 | } 32 | }, 33 | "prettier": { 34 | "printWidth": 80, 35 | "semi": true, 36 | "singleQuote": true, 37 | "trailingComma": "es5" 38 | }, 39 | "name": "nestjs-gql-cache-control", 40 | "author": { 41 | "email": "alirezazamani2922@gmail.com", 42 | "name": "Alireza Zamani" 43 | }, 44 | "module": "dist/graphql-cache-control.esm.js", 45 | "size-limit": [ 46 | { 47 | "path": "dist/graphql-cache-control.cjs.production.min.js", 48 | "limit": "10 KB" 49 | }, 50 | { 51 | "path": "dist/graphql-cache-control.esm.js", 52 | "limit": "10 KB" 53 | } 54 | ], 55 | "devDependencies": { 56 | "@size-limit/preset-small-lib": "^7.0.3", 57 | "husky": "^7.0.4", 58 | "size-limit": "^7.0.3", 59 | "tsdx": "^0.14.1", 60 | "tslib": "^2.3.1", 61 | "typescript": "^4.5.2" 62 | }, 63 | "dependencies": { 64 | "@nestjs/common": "^8.2.3", 65 | "@nestjs/core": "^8.2.3", 66 | "@nestjs/graphql": "^9.1.1", 67 | "apollo-server-core": "^3.5.0", 68 | "graphql": "15.7.0", 69 | "reflect-metadata": "^0.1.13" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Cache Control 2 | 3 | This package offers you a simple decorator to set cache control on your resolvers. 4 | 5 | ## Installation 6 | 7 | On Yarn: 8 | 9 | ```shell 10 | yarn add nestjs-gql-cache-control 11 | ``` 12 | 13 | On NPM: 14 | 15 | ```shell 16 | npm install nestjs-gql-cache-control 17 | ``` 18 | 19 | ## Usage 20 | 21 | To use caching, you are gonna need these packages too: 22 | 23 | ``` 24 | @nestjs/graphql 25 | apollo-server-core 26 | apollo-server-plugin-response-cache 27 | ``` 28 | 29 | First, register graphql module and cache plugins in your app module: 30 | 31 | ```ts 32 | import responseCachePlugin from 'apollo-server-plugin-response-cache'; 33 | import { ApolloServerPluginCacheControl } from 'apollo-server-core/dist/plugin/cacheControl'; 34 | 35 | GraphQLModule.forRoot({ 36 | // ... 37 | plugins: [ 38 | ApolloServerPluginCacheControl({ defaultMaxAge: 5 }), // optional 39 | responseCachePlugin(), 40 | ], 41 | }), 42 | ``` 43 | 44 | > To add Redis or other caching stores, check [Apollo's docs](https://www.apollographql.com/docs/apollo-server/performance/caching/#in-memory-cache-setup) 45 | 46 | Then, you can use the decorator on your queries and field resolvers: 47 | 48 | ```ts 49 | import { CacheControl } from 'nestjs-gql-cache-control'; 50 | 51 | @Resolver((type) => Post) 52 | export class PostResolver { 53 | @Query(() => [Post]) 54 | @CacheControl({ maxAge: 10 }) 55 | posts() { 56 | // database calls 57 | return posts; 58 | } 59 | 60 | @ResolveField(() => User) 61 | @CacheControl({ inheritMaxAge: true }) 62 | owner() { 63 | // database calls 64 | return owner; 65 | } 66 | 67 | @ResolveField(() => boolean) 68 | @CacheControl({ maxAge: 5, scope: 'PRIVATE' }) 69 | hasLiked() { 70 | // database calls 71 | return hasLiked; 72 | } 73 | } 74 | ``` 75 | 76 | ## How The Apollo Cache Works 77 | 78 | Please carefully read [Apollo's docs about caching](https://www.apollographql.com/docs/apollo-server/performance/caching/) to understand how caching works, since it has a set of rules for cache calculation. In a brief: 79 | 80 | > a response should only be considered cacheable if every part of that response opts in to being cacheable. At the same time, we don't think developers should have to specify cache hints for every single field in their schema. 81 | > So, we follow these heuristics: 82 | > Root field resolvers are extremely likely to fetch data (because these fields have no parent), so we set their default maxAge to 0 to avoid automatically caching data that shouldn't be cached. 83 | > Resolvers for other non-scalar fields (objects, interfaces, and unions) also commonly fetch data because they contain arbitrarily many fields. Consequently, we also set their default maxAge to 0. 84 | > Resolvers for scalar, non-root fields rarely fetch data and instead usually populate data via the parent argument. Consequently, these fields inherit their default maxAge from their parent to reduce schema clutter. 85 | 86 | ## Connections (Pagination) 87 | 88 | If you happen to use [nestjs-gql-connections](https://github.com/overnested/nestjs-gql-connections), `edges` and `node` will automatically inherit cache control from their parents. but otherwise you should set `inheritMaxAge` on your connection fields to prevent connections from cancelling your cache. 89 | 90 | Why you should do that? because you probably don't want your connections to cancel your cache control. ([learn more](https://www.apollographql.com/docs/apollo-server/performance/caching/#default-maxage)) 91 | --------------------------------------------------------------------------------