Repository: FormidableLabs/urql
Pull request #899 (2020-07-10 15:23:42)

(exchanges) - implement requestPolicyExchange
## Summary

Implements an exchange that monitors all incoming operations and checks them vs an internal map, if this operation is `cache-first` or `cache-only` and has exceeded the TTL it will upgrade this operation to a `cache-and-network` policy. This helps us refresh stale data.

The user can specify a custom TTL in ms

## Set of changes

- add new exchange


changeset-bot[bot]: ###  💥  No Changeset

Latest commit: bc28fa1d1978a0c703c103ec8214f9d0b83fa980

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/request-policy-exchange?filename=.changeset/nervous-suns-bow.md&value=---%0A%22%40urql%2Fexchange-request-policy%22%3A%20patch%0A---%0A%0A(exchanges)%20-%20implement%20requestPolicyExchange%0A)


stevehollaar: Thanks for doing this, I'd love to use this exchange 👍 
Can I ask for one change? I'd like to _only_ upgrade certain operations (in my case, I _don't_ want to upgrade `cache-only` queries, which I use for optimistic partial rendering)
Perhaps another option that we can pass to the exchange:
```
  upgradeOperation: (operation: Operation) => boolean
```
to be used like:
```
  upgradeOperation: operation => operation.context.requestPolicy === 'cache-first'
```
Happy to do this in user-land if it's just me that needs this, or make a separate PR after this one lands. Thanks again! 🙏 

Patch:
diff --git a/.changeset/config.json b/.changeset/config.json
index cc080c6f..b6fe0964 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -3,5 +3,5 @@
   "changelog": "../scripts/changesets/changelog.js",
   "commit": false,
   "access": "public",
-  "baseBranch": "master"
+  "baseBranch": "main"
 }
diff --git a/docs/concepts/exchanges.md b/docs/concepts/exchanges.md
index 88c6301c..a792eb9b 100644
--- a/docs/concepts/exchanges.md
+++ b/docs/concepts/exchanges.md
@@ -25,7 +25,8 @@ Other available exchanges:
 - `retryExchange`: Allows operations to be retried
 - `devtoolsExchange`: Provides the ability to use the [urql-devtools](https://github.com/FormidableLabs/urql-devtools)
 - `multipartFetchExchange`: Provides multipart file upload capability
-- `suspenseExchange` (experimental): Allows the use of React Suspense on the client-side with `urql`'s built-in suspense mode
+- `suspenseExchange` (experimental): Allows the use of React Suspense on the client-side with `urql`'s built-in suspense modeµ
+- `requestPolicyExchange`: Automatically upgrades `cache-only` and `cache-first` operations to `cache-and-network` after a given amount of time.
 
 It is also possible to apply custom exchanges to override the default logic.
 
diff --git a/exchanges/request-policy/CHANGELOG.md b/exchanges/request-policy/CHANGELOG.md
new file mode 100644
index 00000000..8c30f86a
--- /dev/null
+++ b/exchanges/request-policy/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## v0.1.0
+
+**Initial Release**
diff --git a/exchanges/request-policy/README.md b/exchanges/request-policy/README.md
new file mode 100644
index 00000000..020d438f
--- /dev/null
+++ b/exchanges/request-policy/README.md
@@ -0,0 +1,39 @@
+# @urql/exchange-request-policy (Exchange factory)
+
+`@urql/exchange-request-policy` is an exchange for the [`urql`](../../README.md) GraphQL client that will automatically upgrade operation request-policies
+on a time-to-live basis.
+
+## Quick Start Guide
+
+First install `@urql/exchange-request-policy` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-request-policy
+# or
+npm install --save @urql/exchange-request-policy
+```
+
+Then add it to your client.
+
+```js
+import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
+import { requestPolicyExchange } from '@urql/exchange-request-policy';
+
+const client = createClient({
+  url: 'http://localhost:1234/graphql',
+  exchanges: [
+    dedupExchange,
+    requestPolicyExchange({
+      // The amount of time in ms that has to go by before upgrading, default is 5 minutes.
+      ttl: 60 * 1000, // 1 minute.
+      // An optional function that allows you to specify whether an operation should be upgraded.
+      shouldUpgrade: operation => operation.context.requestPolicy !== 'cache-only',
+    }),
+    cacheExchange,
+    fetchExchange,
+  ],
+});
+```
+
+Now when the exchange sees a `cache-first` operation that hasn't been seen in ttl amount of time it will upgrade
+the `requestPolicy` to `cache-and-network`.
diff --git a/exchanges/request-policy/package.json b/exchanges/request-policy/package.json
new file mode 100644
index 00000000..b857bae3
--- /dev/null
+++ b/exchanges/request-policy/package.json
@@ -0,0 +1,66 @@
+{
+  "name": "@urql/exchange-request-policy",
+  "version": "0.1.0",
+  "description": "An exchange for operation request-policy upgrading in urql",
+  "sideEffects": false,
+  "homepage": "https://formidable.com/open-source/urql/docs/",
+  "bugs": "https://github.com/FormidableLabs/urql/issues",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/FormidableLabs/urql.git",
+    "directory": "exchanges/request-policy"
+  },
+  "keywords": [
+    "urql",
+    "graphql client",
+    "formidablelabs",
+    "exchanges",
+    "react",
+    "request-policy"
+  ],
+  "main": "dist/urql-exchange-request-policy",
+  "module": "dist/urql-exchange-request-policy.mjs",
+  "types": "dist/types/index.d.ts",
+  "source": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/urql-exchange-request-policy.mjs",
+      "require": "./dist/urql-exchange-request-policy.js",
+      "types": "./dist/types/index.d.ts",
+      "source": "./src/index.ts"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "LICENSE",
+    "CHANGELOG.md",
+    "README.md",
+    "dist/"
+  ],
+  "scripts": {
+    "test": "jest",
+    "clean": "rimraf dist",
+    "check": "tsc --noEmit",
+    "lint": "eslint --ext=js,jsx,ts,tsx .",
+    "build": "rollup -c ../../scripts/rollup/config.js",
+    "prepare": "node ../../scripts/prepare/index.js",
+    "prepublishOnly": "run-s clean build"
+  },
+  "jest": {
+    "preset": "../../scripts/jest/preset"
+  },
+  "devDependencies": {
+    "graphql": "^15.1.0"
+  },
+  "peerDependencies": {
+    "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0"
+  },
+  "dependencies": {
+    "@urql/core": ">=1.12.0",
+    "wonka": "^4.0.14"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/exchanges/request-policy/src/index.ts b/exchanges/request-policy/src/index.ts
new file mode 100644
index 00000000..45669158
--- /dev/null
+++ b/exchanges/request-policy/src/index.ts
@@ -0,0 +1,1 @@
+export { requestPolicyExchange } from './requestPolicyExchange';
diff --git a/exchanges/request-policy/src/requestPolicyExchange.ts b/exchanges/request-policy/src/requestPolicyExchange.ts
new file mode 100644
index 00000000..33bed890
--- /dev/null
+++ b/exchanges/request-policy/src/requestPolicyExchange.ts
@@ -0,0 +1,51 @@
+import { Operation, Exchange } from '@urql/core';
+import { pipe, map } from 'wonka';
+
+const defaultTTL = 5 * 60 * 1000;
+
+export interface Options {
+  shouldUpgrade?: (op: Operation) => boolean;
+  ttl?: number;
+}
+
+export const requestPolicyExchange = (options: Options): Exchange => ({
+  forward,
+}) => {
+  const operations = new Map();
+
+  const TTL = (options || {}).ttl || defaultTTL;
+
+  const processIncomingOperation = (operation: Operation): Operation => {
+    if (
+      operation.context.requestPolicy !== 'cache-first' &&
+      operation.context.requestPolicy !== 'cache-only'
+    )
+      return operation;
+    if (!operations.has(operation.key)) {
+      operations.set(operation.key, new Date());
+      return operation;
+    }
+
+    const lastOccurrence = operations.get(operation.key);
+    const currentTime = new Date().getTime();
+    if (
+      currentTime - lastOccurrence.getTime() > TTL &&
+      (options.shouldUpgrade ? options.shouldUpgrade(operation) : true)
+    ) {
+      operations.set(operation.key, new Date());
+      return {
+        ...operation,
+        context: {
+          ...operation.context,
+          requestPolicy: 'cache-and-network',
+        },
+      };
+    }
+
+    return operation;
+  };
+
+  return ops$ => {
+    return forward(pipe(ops$, map(processIncomingOperation)));
+  };
+};
diff --git a/exchanges/request-policy/tsconfig.json b/exchanges/request-policy/tsconfig.json
new file mode 100644
index 00000000..5797ce61
--- /dev/null
+++ b/exchanges/request-policy/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/site/package.json b/packages/site/package.json
index bdefe8f3..c91435d8 100644
--- a/packages/site/package.json
+++ b/packages/site/package.json
@@ -53,7 +53,7 @@
   "devDependencies": {
     "@babel/core": "^7.2.0",
     "@mdx-js/mdx": "^1.5.7",
-    "lodash": "^4.17.11",
+    "lodash": "^4.17.19",
     "react-hot-loader": "^4.12.20",
     "react-static-plugin-react-router": "^7.2.3",
     "react-static-plugin-sitemap": "^7.0.0",
diff --git a/yarn.lock b/yarn.lock
index f082e9bd..2db26971 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8851,10 +8851,10 @@ lodash.uniq@4.5.0, lodash.uniq@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.5:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.5:
+  version "4.17.19"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
+  integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
 
 log-symbols@^4.0.0:
   version "4.0.0"


Test patch:
diff --git a/exchanges/request-policy/src/requestPolicyExchange.test.ts b/exchanges/request-policy/src/requestPolicyExchange.test.ts
new file mode 100644
index 00000000..e0022e0d
--- /dev/null
+++ b/exchanges/request-policy/src/requestPolicyExchange.test.ts
@@ -0,0 +1,136 @@
+import gql from 'graphql-tag';
+
+import { pipe, map, makeSubject, publish, tap } from 'wonka';
+
+import {
+  createClient,
+  Operation,
+  OperationResult,
+  ExchangeIO,
+} from '@urql/core';
+import { requestPolicyExchange } from './requestPolicyExchange';
+
+const dispatchDebug = jest.fn();
+
+const mockOptions = {
+  ttl: 5,
+};
+
+const queryOne = gql`
+  {
+    author {
+      id
+      name
+    }
+  }
+`;
+
+const queryOneData = {
+  __typename: 'Query',
+  author: {
+    __typename: 'Author',
+    id: '123',
+    name: 'Author',
+  },
+};
+
+let client, op, ops$, next;
+beforeEach(() => {
+  client = createClient({ url: 'http://0.0.0.0' });
+  op = client.createRequestOperation('query', {
+    key: 1,
+    query: queryOne,
+  });
+
+  ({ source: ops$, next } = makeSubject<Operation>());
+});
+
+it(`upgrades to cache-and-network`, done => {
+  const response = jest.fn(
+    (forwardOp: Operation): OperationResult => {
+      return {
+        operation: forwardOp,
+        data: queryOneData,
+      };
+    }
+  );
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  pipe(
+    requestPolicyExchange(mockOptions)({
+      forward,
+      client,
+      dispatchDebug,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  next(op);
+
+  expect(response).toHaveBeenCalledTimes(1);
+  expect(response.mock.calls[0][0].context.requestPolicy).toEqual(
+    'cache-first'
+  );
+  expect(result).toHaveBeenCalledTimes(1);
+
+  setTimeout(() => {
+    next(op);
+    expect(response).toHaveBeenCalledTimes(2);
+    expect(response.mock.calls[1][0].context.requestPolicy).toEqual(
+      'cache-and-network'
+    );
+    expect(result).toHaveBeenCalledTimes(2);
+    done();
+  }, 10);
+});
+
+it(`doesn't upgrade when shouldUpgrade returns false`, done => {
+  const response = jest.fn(
+    (forwardOp: Operation): OperationResult => {
+      return {
+        operation: forwardOp,
+        data: queryOneData,
+      };
+    }
+  );
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  const shouldUpgrade = jest.fn(() => false);
+  pipe(
+    requestPolicyExchange({ ...mockOptions, shouldUpgrade })({
+      forward,
+      client,
+      dispatchDebug,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  next(op);
+
+  expect(response).toHaveBeenCalledTimes(1);
+  expect(response.mock.calls[0][0].context.requestPolicy).toEqual(
+    'cache-first'
+  );
+  expect(result).toHaveBeenCalledTimes(1);
+
+  setTimeout(() => {
+    next(op);
+    expect(response).toHaveBeenCalledTimes(2);
+    expect(response.mock.calls[1][0].context.requestPolicy).toEqual(
+      'cache-first'
+    );
+    expect(result).toHaveBeenCalledTimes(2);
+    expect(shouldUpgrade).toBeCalledTimes(1);
+    done();
+  }, 10);
+});