├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── bin └── generate-schema-config ├── codegen.ts ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── caching.md │ ├── client-errors.md │ ├── client.md │ ├── getting-started.md │ ├── pagination.md │ ├── use-deferred-query.md │ ├── use-mutation.md │ └── use-query.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.js │ │ │ └── styles.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── index.module.css ├── static │ └── img │ │ ├── favicon.png │ │ ├── logo.svg │ │ ├── social.png │ │ └── swan-opensource.svg └── yarn.lock ├── example ├── components │ ├── App.tsx │ ├── Film.tsx │ ├── FilmCharacterList.tsx │ ├── FilmDetails.tsx │ └── FilmList.tsx ├── gql-config.json ├── gql │ ├── fragment-masking.ts │ ├── gql.ts │ ├── graphql.ts │ └── index.ts ├── index.html └── index.tsx ├── package.json ├── src ├── cache │ ├── cache.ts │ ├── entry.ts │ ├── read.ts │ └── write.ts ├── client.ts ├── errors.ts ├── graphql │ ├── ast.ts │ └── print.ts ├── index.ts ├── json │ ├── cacheEntryKey.ts │ └── getTypename.ts ├── react │ ├── ClientContext.ts │ ├── useDeferredQuery.ts │ ├── useMutation.ts │ ├── usePagination.ts │ └── useQuery.ts ├── types.ts └── utils.ts ├── test ├── __snapshots__ │ └── cache.test.ts.snap ├── cache.test.ts └── data.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsup.config.ts ├── vite.config.mjs └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL="https://api.swan.io/sandbox-partner/graphql" 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | parser: "@typescript-eslint/parser", 5 | plugins: ["react", "react-hooks"], 6 | 7 | ignorePatterns: [ 8 | ".eslintrc.js", 9 | "codegen.ts", 10 | "tsup.config.ts", 11 | "vite.config.mjs", 12 | ], 13 | 14 | extends: [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | ], 19 | 20 | parserOptions: { 21 | sourceType: "module", 22 | project: path.resolve(__dirname + "/tsconfig.json"), 23 | }, 24 | 25 | env: { 26 | browser: true, 27 | es2022: true, 28 | }, 29 | 30 | overrides: [ 31 | { 32 | files: ["**/__{mocks,tests}__/**/*.{ts,tsx}"], 33 | rules: { 34 | "no-empty": ["error", { allowEmptyCatch: true }], 35 | }, 36 | }, 37 | { 38 | files: ["*.d.ts"], 39 | rules: { 40 | "@typescript-eslint/consistent-type-definitions": "off", 41 | "@typescript-eslint/no-unused-vars": "off", 42 | }, 43 | }, 44 | { 45 | files: ["clients/**/src/graphql/**/*.{ts,tsx}"], 46 | rules: { 47 | "@typescript-eslint/ban-types": "off", 48 | "@typescript-eslint/no-explicit-any": "off", 49 | }, 50 | }, 51 | ], 52 | 53 | rules: { 54 | "no-implicit-coercion": "error", 55 | "no-param-reassign": "error", 56 | "no-var": "error", 57 | "object-shorthand": "warn", 58 | "prefer-const": "error", 59 | 60 | "no-extra-boolean-cast": "off", 61 | 62 | "react/jsx-boolean-value": ["error", "always"], 63 | 64 | "react-hooks/rules-of-hooks": "error", 65 | "react-hooks/exhaustive-deps": "warn", 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: yarn 20 | 21 | - run: yarn install --pure-lockfile 22 | - run: yarn prepack 23 | 24 | - name: Build docs 25 | run: cd docs && yarn && yarn build 26 | 27 | - name: Deploy 28 | if: "contains('refs/heads/main', github.ref)" 29 | uses: peaceiris/actions-gh-pages@v3 30 | with: 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | publish_dir: ./docs/build 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Release 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: lts/* 21 | cache: yarn 22 | 23 | - run: yarn install --pure-lockfile 24 | - run: yarn typecheck 25 | - run: yarn test 26 | - run: yarn build 27 | 28 | - name: Publish 29 | run: | 30 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 31 | yarn publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /dist 6 | 7 | # misc 8 | .DS_Store 9 | .env 10 | 11 | yarn-debug.log* 12 | yarn-error.log* 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | example/graphql-env.d.ts 2 | dist/ 3 | docs/.docusaurus 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Swan 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 | @swan-io/graphql-client 2 | 3 | # @swan-io/graphql-client 4 | 5 | [![mit licence](https://img.shields.io/dub/l/vibe-d.svg?style=for-the-badge)](https://github.com/swan-io/graphql-client/blob/main/LICENSE) 6 | [![npm version](https://img.shields.io/npm/v/@swan-io/graphql-client?style=for-the-badge)](https://www.npmjs.org/package/@swan-io/graphql-client) 7 | [![bundlephobia](https://img.shields.io/bundlephobia/minzip/@swan-io/graphql-client?label=size&style=for-the-badge)](https://bundlephobia.com/result?p=@swan-io/graphql-client) 8 | 9 | > A simple, typesafe GraphQL client for React 10 | 11 | ## Installation 12 | 13 | ```bash 14 | $ yarn add @swan-io/graphql-client 15 | # --- or --- 16 | $ npm install --save @swan-io/graphql-client 17 | ``` 18 | 19 | ## Links 20 | 21 | - 📘 [**Documentation**](https://swan-io.github.io/graphql-client) 22 | - ⚖️ [**License**](./LICENSE) 23 | -------------------------------------------------------------------------------- /bin/generate-schema-config: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require("node:fs"); 3 | const path = require("node:path"); 4 | const { 5 | introspectionFromSchema, 6 | buildSchema, 7 | getIntrospectionQuery, 8 | } = require("graphql"); 9 | const cwd = process.cwd(); 10 | const args = process.argv; 11 | 12 | const schemaPath = args[2]; 13 | const distPath = args[3]; 14 | 15 | const introspection = schemaPath.startsWith("http") 16 | ? fetch(schemaPath + "?test", { 17 | method: "POST", 18 | headers: { "content-type": "application/json" }, 19 | body: JSON.stringify({ query: getIntrospectionQuery() }), 20 | }) 21 | .then((res) => res.json()) 22 | .then((res) => res.data) 23 | : Promise.resolve( 24 | introspectionFromSchema( 25 | buildSchema(fs.readFileSync(path.join(cwd, schemaPath), "utf-8")), 26 | ), 27 | ); 28 | 29 | introspection.then((introspection) => { 30 | const interfaceToTypes = new Map(); 31 | 32 | introspection.__schema.types.forEach((type) => { 33 | type.interfaces?.forEach((int) => { 34 | const set = interfaceToTypes.get(int.name) ?? new Set(); 35 | set.add(type.name); 36 | interfaceToTypes.set(int.name, set); 37 | }); 38 | }); 39 | 40 | const json = { 41 | interfaceToTypes: Object.fromEntries( 42 | [...interfaceToTypes.entries()].map(([key, value]) => [key, [...value]]), 43 | ), 44 | }; 45 | 46 | fs.writeFileSync( 47 | path.join(cwd, distPath), 48 | JSON.stringify(json, null, 2), 49 | "utf-8", 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import { type CodegenConfig } from "@graphql-codegen/cli"; 2 | 3 | const config: CodegenConfig = { 4 | schema: "https://swapi-graphql.netlify.app/.netlify/functions/index", 5 | documents: ["example/components/**/*.tsx"], 6 | generates: { 7 | "./example/gql/": { 8 | preset: "client", 9 | }, 10 | }, 11 | hooks: { afterAllFileWrite: ["prettier --write"] }, 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/caching.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Caching 3 | sidebar_label: Caching 4 | --- 5 | 6 | ## Cache rules 7 | 8 | ### `__typename` 9 | 10 | The client systematically adds `__typename` the any object that's queried. It helps the cache identifying the objects. 11 | 12 | ### Objects with `id` 13 | 14 | Any object with a `string` `id` property is cached under the `Typename` key. You should always query the `id` property of any object that has one. 15 | 16 | ### Field cache 17 | 18 | Fields are cached within their closest cached parent (objects with `id`) or their closest operation (`query`). Fields with arguments are cached under the `fieldName(serializedArguments)` key. 19 | 20 | ### Requested keys 21 | 22 | We do not handle partial resolving from cache, queries can be resolved from the cache only if all the requested fields have been cached at some point. 23 | -------------------------------------------------------------------------------- /docs/docs/client-errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Client Errors 3 | sidebar_label: Client Errors 4 | --- 5 | 6 | ## Errors 7 | 8 | The default error handling is that any error (including `{"errors": [...]}` in your GraphQL response) makes the whole query in error. If you rather want the query to be considered valid, you can update the parsing logic by providing a custom `makeRequest` to your `Client`. 9 | 10 | ## Error types 11 | 12 | - `NetworkError`: network isn't reachable 13 | - `TimeoutError`: request timeout 14 | - `BadStatusError`: request status is not in the valid range (`>= 200` && `< 300`) 15 | - `EmptyResponseError`: response was empty 16 | - `InvalidGraphQLResponseError`: error parsing the payload 17 | - `GraphQLError[]`: the GraphQL payload returned errors 18 | -------------------------------------------------------------------------------- /docs/docs/client.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Client 3 | sidebar_label: Client 4 | --- 5 | 6 | ## Configuration 7 | 8 | ```ts 9 | const client = new Client({ 10 | url: "/path/to/gql", 11 | }); 12 | ``` 13 | 14 | ### Params 15 | 16 | - `url` (mandatory): the URL of your GraphQL API 17 | - `schemaConfig` (mandatory): your [generated schema config](./getting-started/#2-generate-the-schema-config) 18 | - `headers` (optional): the default headers to send 19 | - `makeRequest` (optional): function that performs the request and returns a `Future>` (e.g. to add request IDs, custom parsing of the payload, logging & custom error handling) 20 | 21 | ## Perform a query 22 | 23 | ```ts 24 | client.query(query, variables).tap((result) => { 25 | console.log(result); 26 | }); 27 | ``` 28 | 29 | ## Perform a mutation 30 | 31 | ```ts 32 | client.commitMutation(mutation, variables).tap((result) => { 33 | console.log(result); 34 | }); 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | sidebar_label: Getting started 4 | --- 5 | 6 | # Getting started 7 | 8 | **GraphQL Client** is a simple GraphQL client for React applications. It's focused on giving a good, typesafe experience when working on your codebase. 9 | 10 | ## 1. Install 11 | 12 | ```console 13 | $ yarn add @swan-io/graphql-client 14 | ``` 15 | 16 | or 17 | 18 | ```console 19 | $ npm install @swan-io/graphql-client 20 | ``` 21 | 22 | ## 2. Generate the schema config 23 | 24 | The schema config is necessary for the cache to understand when your spread an interface type (e.g. `on ... Node { id }`). Don't worry, this ends up being really light and wont't affect your bundle size much. 25 | 26 | ```console 27 | $ generate-schema-config path/to/schema.gql dist/schema-config.json 28 | ``` 29 | 30 | ## 3. Create your client 31 | 32 | Configure your client with your `url`, desired default `headers` & the `schemaConfig` you just generateed. 33 | 34 | ```ts title="src/index.tsx" 35 | import { Client, ClientContext } from "@swan-io/graphql-client"; 36 | import { App } from "./App"; 37 | import { createRoot } from "react-dom/client"; 38 | import schemaConfig from "./dist/schema-config.json" 39 | 40 | // highlight-start 41 | const client = new Client({ 42 | url: "/api", 43 | headers: { 44 | "Content-Type": "application/json", 45 | }, 46 | schemaConfig, 47 | }); 48 | // highlight-end 49 | 50 | export const Root = () => { 51 | return ( 52 | // highlight-start 53 | 54 | // highlight-end 55 | 56 | // highlight-start 57 | 58 | // highlight-end 59 | ); 60 | }; 61 | 62 | const root = document.querySelector("#app"); 63 | 64 | if (root != null) { 65 | createRoot(root).render(); 66 | } 67 | ``` 68 | 69 | ## 4. Add linter (recommended) 70 | 71 | We recommend to install `@graphql-eslint` and activate the `@graphql-eslint/require-id-when-available` rule, as the cache heavily relies on `id` being queried. 72 | 73 | And you're ready to go! 74 | -------------------------------------------------------------------------------- /docs/docs/pagination.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pagination 3 | sidebar_label: Pagination 4 | --- 5 | 6 | As far as the client is concerned, a paginated query is a different query (as it has different variables). This is why we use React hooks to perform the pagination aggregate using some metadata added when the query is received. 7 | 8 | ## Setting the cursor 9 | 10 | The the `setVariables` function from `useQuery` to indicate that the update shouldn't be a full query reload. 11 | 12 | ```ts 13 | const [data, {setVariables}] = useQuery(..., {}) 14 | 15 | setVariables({after: cursor}) 16 | ``` 17 | 18 | ## useForwardPagination(connection) 19 | 20 | Aggregates the connection data (with `after`). 21 | 22 | ```ts 23 | const users = useForwardPagination(usersConnection); 24 | ``` 25 | 26 | ## useBackwardPagination(connection) 27 | 28 | Aggregates the connection data (with `before`). 29 | 30 | ```ts 31 | const users = useBackwardPagination(usersConnection); 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/docs/use-deferred-query.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: useDeferredQuery 3 | sidebar_label: useDeferredQuery 4 | --- 5 | 6 | ## useDeferredQuery(query, config?) 7 | 8 | Similar to [`useQuery`](./use-query), but requires a manual call to `query`. 9 | 10 | ### Params 11 | 12 | - `query`: your query document node 13 | - `config` (optional) 14 | - `optimize`: adapt query to only require data that's missing from the cache (default: `false`) 15 | 16 | ### Returns 17 | 18 | This hook returns a tuple you can extract like a `useState`: 19 | 20 | ```ts 21 | const [data, query] = useDeferredQuery(...) 22 | ``` 23 | 24 | - `data` (`AsyncData>`): the GraphQL response 25 | - `query(variables, ?config)`: runs the query 26 | - `config` (optional) 27 | - `overrides`: custom request configuration (`url`, `headers` and/or `withCredentials`) 28 | 29 | ## Example 30 | 31 | ```ts 32 | import { useDeferredQuery } from "@swan-io/graphql-client"; 33 | // ... 34 | 35 | const userPageQuery = graphql(` 36 | query UserPage($userId: ID!) { 37 | user(id: $userId) { 38 | id 39 | username 40 | avatar 41 | } 42 | } 43 | `); 44 | 45 | type Props = { 46 | userId: string; 47 | }; 48 | 49 | const UserPage = ({ userId }: Props) => { 50 | const [user, queryUser] = useDeferredQuery(userPageQuery); 51 | 52 | useEffect(() => { 53 | const request = queryUser({ userId }) 54 | return () => request.cancel() 55 | }, [userId, queryUser]) 56 | 57 | return user.match({ 58 | NotAsked: () => null, 59 | Loading: () => , 60 | Done: (result) => 61 | result.match({ 62 | Error: (error) => , 63 | Ok: (user) => , 64 | }), 65 | }); 66 | }; 67 | ``` 68 | -------------------------------------------------------------------------------- /docs/docs/use-mutation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: useMutation 3 | sidebar_label: useMutation 4 | --- 5 | 6 | ## useMutation(mutation, config?) 7 | 8 | ### Params 9 | 10 | - `mutation`: your mutation document node 11 | - `config`: 12 | - `connectionUpdates`: configuration to prepend/append/remove edges from connections on mutation 13 | 14 | ### Returns 15 | 16 | This hook returns a tuple you can extract like a `useState`: 17 | 18 | ```ts 19 | const [commitMutation, mutationData] = useMutation(...) 20 | ``` 21 | 22 | - `commitMutation(variables, ?config)`: function commit the mutation, returns a `Future>` 23 | 24 | - `config` (optional) 25 | - `overrides`: custom request configuration (`url`, `headers` and/or `withCredentials`) 26 | 27 | - `mutationData` (`AsyncData>`): the mutation data 28 | 29 | ## Example 30 | 31 | ```ts 32 | import { useMutation } from "@swan-io/graphql-client"; 33 | // ... 34 | 35 | const updateUsernameMutation = graphql(` 36 | mutation UpdateUsername($userId: ID!, $username: String!) { 37 | updateUsername(id: $userId, username: $username) { 38 | ... on UpdateUsernameSuccessPayload { 39 | user { 40 | id 41 | username 42 | avatar 43 | } 44 | } 45 | ... on InvalidUsernameRejection { 46 | message 47 | } 48 | } 49 | } 50 | `); 51 | 52 | type Props = { 53 | userId: string; 54 | }; 55 | 56 | const UserPage = ({ userId }: Props) => { 57 | const [updateUsername, usernameUpdate] = useMutation(updateUsernameMutation); 58 | const [username, setUsername] = useState(""); 59 | 60 | // ... 61 | const onSubmit = (event) => { 62 | event.preventDefault(); 63 | updateUsername({ userId, username }); 64 | }; 65 | 66 | const isLoading = usernameUpdate.isLoading(); 67 | 68 | return ( 69 |
70 | setUsername(event.target.value)} 74 | /> 75 | 76 | 79 |
80 | ); 81 | }; 82 | ``` 83 | 84 | ## Handling connections 85 | 86 | You can configure the update of [GraphQL Connections](https://relay.dev/graphql/connections.htm) in the `connectionUpdates` field. 87 | 88 | ```ts 89 | useMutation(BlockUser, { 90 | connectionUpdates: [ 91 | ({ data, append }) => 92 | Option.fromNullable(data.blockUser).map(({ user }) => 93 | append(blockedUsers, [user]), 94 | ), 95 | ({ data, prepend }) => 96 | Option.fromNullable(data.blockUser).map(({ user }) => 97 | prepend(lastBlockedUsers, [user]), 98 | ), 99 | ], 100 | }); 101 | 102 | useMutation(Unfriend, { 103 | connectionUpdates: [ 104 | ({ data, variables, remove }) => 105 | Option.fromNullable(data.unfriend).map(() => 106 | remove(friends, [variables.id]), 107 | ), 108 | ], 109 | }); 110 | ``` 111 | -------------------------------------------------------------------------------- /docs/docs/use-query.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: useQuery 3 | sidebar_label: useQuery 4 | --- 5 | 6 | ## useQuery(query, variables, config?) 7 | 8 | The `useQuery` hook will execute the query with the given `variables`. 9 | 10 | ```ts 11 | import { AsyncData, Result } from "@swan-io/boxed"; 12 | import { useQuery } from "@swan-io/graphql-client"; 13 | import { match, P } from "ts-pattern"; 14 | 15 | const query = gql(` 16 | query MyQuery { 17 | __typename 18 | } 19 | `) 20 | 21 | const MyComponent = () => { 22 | const [data] = useQuery(query, {}); 23 | 24 | return match(data) 25 | .with(AsyncData.P.NotAsked, AsyncData.P.Loading, () => ) 26 | .with(AsyncData.P.Done(Result.P.Error(P.select())), (error) => ) 27 | .with(AsyncData.P.Done(Result.P.Ok(P.select())), data => { 28 | // show your data 29 | }) 30 | .exhaustive(); 31 | } 32 | ``` 33 | 34 | `data` is exposed as an [`AsyncData`](https://swan-io.github.io/boxed/async-data) (to represent the loading date), that contains a [`Result`](https://swan-io.github.io/boxed/result) (to represent the success of the operation), which is either `Ok` or `Error`. 35 | 36 | This structure avoids any ambuguity as to what the current state of the data is. 37 | 38 | ### Params 39 | 40 | - `query`: your query document node 41 | - `variables`: your query variables 42 | - `config` (optional) 43 | - `suspense`: use React Suspense (default: `false`) 44 | - `overrides`: custom request configuration (`url`, `headers` and/or `withCredentials`) 45 | - `optimize`: (⚠️ experimental) adapt query to only require data that's missing from the cache (default: `false`) 46 | 47 | ### Returns 48 | 49 | This hook returns a tuple you can extract like a `useState`: 50 | 51 | ```ts 52 | const [data, {isLoading, refresh, reload, setVariables}] = useQuery(...) 53 | ``` 54 | 55 | - `data` (`AsyncData>`): the GraphQL response 56 | - `isLoading` (`boolean`): if the query is fetching 57 | - `refresh()`: refresh the query in the background, keeping current data on screen 58 | - `reload()`: reload the query (full reload, showing a full loading state and resets local variables) 59 | - `setVariables(variables)`: overwrites the variables locally, useful for `before` & `after` pagination 60 | 61 | ### Lifecycle 62 | 63 | Any time the provided `variables` structurally change (meaning they're not deeply equal to the previous ones), the query will fully reload. 64 | 65 | ### Suspense 66 | 67 | You can optionally provide a `suspense` flag to activate the feature, but the exposed `data` will still be an `AsyncData>` so that your component isn't tied to a particular rendering context: it'll always be capable of handling its own loading state if not suspended. 68 | 69 | ## Example 70 | 71 | ```ts 72 | import { useQuery } from "@swan-io/graphql-client"; 73 | // ... 74 | 75 | const userPageQuery = graphql(` 76 | query UserPage($userId: ID!) { 77 | user(id: $userId) { 78 | id 79 | username 80 | avatar 81 | } 82 | } 83 | `); 84 | 85 | type Props = { 86 | userId: string; 87 | }; 88 | 89 | const UserPage = ({ userId }: Props) => { 90 | const [user] = useQuery(userPageQuery, { userId }); 91 | 92 | return user.match({ 93 | NotAsked: () => null, 94 | Loading: () => , 95 | Done: (result) => 96 | result.match({ 97 | Error: (error) => , 98 | Ok: (user) => , 99 | }), 100 | }); 101 | }; 102 | ``` 103 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // `@type` JSDoc annotations allow editor autocompletion and type checking 3 | // (when paired with `@ts-check`). 4 | // There are various equivalent ways to declare your Docusaurus config. 5 | // See: https://docusaurus.io/docs/api/docusaurus-config 6 | 7 | import { themes as prismThemes } from "prism-react-renderer"; 8 | 9 | const url = "https://swan-io.github.io/graphql-client"; 10 | 11 | /** @type {import('@docusaurus/types').Config} */ 12 | const config = { 13 | title: "GraphQL Client", 14 | tagline: "A simple GraphQL client for React", 15 | favicon: "img/favicon.png", 16 | 17 | // Set the production url of your site here 18 | url: "https://swan-io.github.io", 19 | // Set the // pathname under which your site is served 20 | // For GitHub pages deployment, it is often '//' 21 | baseUrl: "/graphql-client/", 22 | 23 | // GitHub pages deployment config. 24 | // If you aren't using GitHub pages, you don't need these. 25 | organizationName: "swan-io", // Usually your GitHub org/user name. 26 | projectName: "graphql-client", // Usually your repo name. 27 | 28 | onBrokenLinks: "throw", 29 | onBrokenMarkdownLinks: "warn", 30 | 31 | // Even if you don't use internationalization, you can use this field to set 32 | // useful metadata like html lang. For example, if your site is Chinese, you 33 | // may want to replace "en" with "zh-Hans". 34 | i18n: { 35 | defaultLocale: "en", 36 | locales: ["en"], 37 | }, 38 | 39 | presets: [ 40 | [ 41 | "classic", 42 | /** @type {import('@docusaurus/preset-classic').Options} */ 43 | ({ 44 | docs: { 45 | routeBasePath: "/", 46 | sidebarPath: "./sidebars.js", 47 | // Please change this to your repo. 48 | // Remove this to remove the "edit this page" links. 49 | editUrl: "https://github.com/swan-io/graphql-client/edit/main/docs/", 50 | }, 51 | theme: { 52 | customCss: "./src/css/custom.css", 53 | }, 54 | }), 55 | ], 56 | ], 57 | 58 | themeConfig: 59 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 60 | ({ 61 | navbar: { 62 | title: "GraphQL Client", 63 | logo: { 64 | alt: "GraphQL Client", 65 | src: "img/logo.svg", 66 | }, 67 | items: [ 68 | { 69 | href: "/getting-started", 70 | label: "Getting started", 71 | position: "left", 72 | }, 73 | { 74 | href: "https://github.com/swan-io/graphql-client", 75 | label: "GitHub", 76 | position: "right", 77 | }, 78 | ], 79 | }, 80 | footer: { 81 | logo: { 82 | alt: "Swan Open Source", 83 | src: "img/swan-opensource.svg", 84 | href: "https://swan.io", 85 | width: 116, 86 | height: 43, 87 | }, 88 | style: "dark", 89 | copyright: `Copyright © ${new Date().getFullYear()} Swan`, 90 | }, 91 | metadata: [ 92 | { name: "twitter:card", content: "summary_large_image" }, 93 | { property: "og:image", content: `${url}/img/social.png` }, 94 | { property: "og:image:width", content: `1280` }, 95 | { property: "og:image:height", content: `640` }, 96 | { name: "twitter:image", content: `${url}/img/social.png` }, 97 | ], 98 | prism: { 99 | theme: prismThemes.github, 100 | darkTheme: prismThemes.dracula, 101 | }, 102 | }), 103 | }; 104 | 105 | export default config; 106 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "3.1.1", 18 | "@docusaurus/preset-classic": "3.1.1", 19 | "@mdx-js/react": "^3.0.1", 20 | "clsx": "^2.1.0", 21 | "prism-react-renderer": "^2.3.1", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0" 24 | }, 25 | "devDependencies": { 26 | "@docusaurus/module-type-aliases": "3.1.1", 27 | "@docusaurus/types": "3.1.1" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.5%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 3 chrome version", 37 | "last 3 firefox version", 38 | "last 5 safari version" 39 | ] 40 | }, 41 | "engines": { 42 | "node": ">=18.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | docs: [ 17 | { 18 | type: "doc", 19 | id: "getting-started", 20 | }, 21 | { 22 | type: "category", 23 | label: "API", 24 | collapsed: false, 25 | collapsible: false, 26 | items: [ 27 | { 28 | type: "doc", 29 | id: "use-query", 30 | }, 31 | { 32 | type: "doc", 33 | id: "use-deferred-query", 34 | }, 35 | { 36 | type: "doc", 37 | id: "use-mutation", 38 | }, 39 | { 40 | type: "doc", 41 | id: "client", 42 | }, 43 | { 44 | type: "doc", 45 | id: "pagination", 46 | }, 47 | { 48 | type: "doc", 49 | id: "client-errors", 50 | }, 51 | ], 52 | }, 53 | { 54 | type: "doc", 55 | id: "caching", 56 | }, 57 | ], 58 | }; 59 | 60 | export default sidebars; 61 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.js: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import styles from "./styles.module.css"; 3 | 4 | const FeatureList = [ 5 | { 6 | title: "Simple, safe API", 7 | svg: ( 8 | 16 | 20 | 21 | ), 22 | description: ( 23 | <> 24 | GraphQL client is super easy to use, with a{" "} 25 | small API surface and safe data-structures from our{" "} 26 | Boxed library ( 27 | AsyncData, Result, Future). 28 | 29 | ), 30 | }, 31 | { 32 | title: "Fast by default", 33 | svg: ( 34 | 42 | 46 | 47 | ), 48 | description: ( 49 | <> 50 | With a normalized cache out of the box, your 51 | application feels ⚡️ blazing fast ⚡️ and updates the 52 | UI automatically when recent data is fetched. 53 | 54 | ), 55 | }, 56 | { 57 | title: "Built with types in mind", 58 | svg: ( 59 | 67 | 71 | 72 | ), 73 | description: ( 74 | <> 75 | Compatible with typed documents, get your TypeScript 76 | compiler to understand the data that goes through your application. 77 | 78 | ), 79 | }, 80 | ]; 81 | 82 | function Feature({ svg, title, description }) { 83 | return ( 84 |
85 |
{svg}
86 |
87 |

{title}

88 |

{description}

89 |
90 |
91 | ); 92 | } 93 | 94 | export default function HomepageFeatures() { 95 | return ( 96 |
97 |
98 |
99 | {FeatureList.map((props, idx) => ( 100 | 101 | ))} 102 |
103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 5rem 0; 5 | width: 100%; 6 | } 7 | 8 | .feature { 9 | padding-bottom: 1rem; 10 | } 11 | 12 | .svgContainer { 13 | width: 64px; 14 | height: 64px; 15 | background-color: #f7f5fb; 16 | border-radius: 100%; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | margin: 0 auto 20px; 21 | } 22 | 23 | .svg { 24 | width: 48px; 25 | height: 48px; 26 | } 27 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"); 8 | 9 | /* You can override the default Infima variables here. */ 10 | :root { 11 | --ifm-color-primary: #6240b5; 12 | --ifm-color-primary-dark: #4e3391; 13 | --ifm-color-primary-darker: #3b266d; 14 | --ifm-color-primary-darkest: #271a48; 15 | --ifm-color-primary-light: #a18cd3; 16 | --ifm-color-primary-lighter: #c0b3e1; 17 | --ifm-color-primary-lightest: #e0d9f0; 18 | --ifm-code-font-size: 95%; 19 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.08); 20 | 21 | --ifm-navbar-link-hover-color: #8166c4; 22 | --ifm-font-family-base: Inter, system-ui, -apple-system, Segoe UI, Roboto, 23 | Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, "Segoe UI", 24 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 25 | "Segoe UI Symbol"; 26 | } 27 | 28 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 29 | [data-theme="dark"] { 30 | --ifm-color-primary: #af9ddc; 31 | --ifm-color-primary-dark: #a08ad6; 32 | --ifm-color-primary-darker: #9278d0; 33 | --ifm-color-primary-darkest: #8267c8; 34 | --ifm-color-primary-light: #d0c5e8; 35 | --ifm-color-primary-lighter: #dfd9ef; 36 | --ifm-color-primary-lightest: #efecf7; 37 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 38 | 39 | --ifm-navbar-link-hover-color: #c0b2e1; 40 | } 41 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import Link from "@docusaurus/Link"; 2 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 3 | import HomepageFeatures from "@site/src/components/HomepageFeatures"; 4 | import Layout from "@theme/Layout"; 5 | import clsx from "clsx"; 6 | 7 | import styles from "./index.module.css"; 8 | 9 | function HomepageHeader() { 10 | const { siteConfig } = useDocusaurusContext(); 11 | return ( 12 |
13 | GraphQL Client 18 |
19 |

{siteConfig.title}

20 |

{siteConfig.tagline}

21 |
22 | 26 | Get started 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | export default function Home() { 35 | const { siteConfig } = useDocusaurusContext(); 36 | return ( 37 | 41 | 42 |
43 | 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .heroBanner { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: hidden; 5 | position: relative; 6 | text-align: center; 7 | align-items: center; 8 | color: #fff; 9 | background: linear-gradient( 10 | to bottom right, 11 | #8266c4 0%, 12 | rgba(217, 97, 81, 0.5) 70.75% 13 | ); 14 | } 15 | 16 | .heroLogo { 17 | max-width: 150px; 18 | height: auto; 19 | margin-bottom: 24px; 20 | } 21 | 22 | .heroTitle { 23 | font-size: 64px; 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | .heroSubtitle { 29 | font-size: 20px; 30 | line-height: 1.2; 31 | } 32 | 33 | .heroButtons { 34 | text-align: center; 35 | } 36 | 37 | .heroButton { 38 | border: 1px solid white; 39 | color: #fff; 40 | margin-top: 10px; 41 | } 42 | 43 | .heroButton:hover { 44 | color: #fff; 45 | } 46 | 47 | @media screen and (max-width: 1200px) { 48 | .heroBanner { 49 | padding: 3rem; 50 | } 51 | 52 | .heroTitle { 53 | font-size: 40px; 54 | } 55 | 56 | .heroSubtitle { 57 | font-size: 16px; 58 | line-height: 1.2; 59 | } 60 | 61 | .heroLogo { 62 | max-width: 100px; 63 | margin-right: 0; 64 | margin-bottom: 16px; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/graphql-client/fda39f24afc5a9971ddeca2874c4e25406047b55/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | logo 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /docs/static/img/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swan-io/graphql-client/fda39f24afc5a9971ddeca2874c4e25406047b55/docs/static/img/social.png -------------------------------------------------------------------------------- /docs/static/img/swan-opensource.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Artboard 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Option } from "@swan-io/boxed"; 2 | import { useState } from "react"; 3 | import { useQuery } from "../../src"; 4 | import { graphql } from "../gql"; 5 | import { FilmDetails } from "./FilmDetails"; 6 | import { FilmList } from "./FilmList"; 7 | 8 | const AllFilmsQuery = graphql(` 9 | query allFilmsWithVariablesQuery($first: Int!, $after: String) { 10 | allFilms(first: $first, after: $after) { 11 | ...FilmsConnection 12 | } 13 | } 14 | `); 15 | 16 | export const App = () => { 17 | const [optimize, setOptimize] = useState(false); 18 | const [activeFilm, setActiveFilm] = useState>(Option.None()); 19 | 20 | const [data, { isLoading, setVariables }] = useQuery( 21 | AllFilmsQuery, 22 | { first: 3 }, 23 | { optimize }, 24 | ); 25 | 26 | return ( 27 |
28 | {data.match({ 29 | NotAsked: () => null, 30 | Loading: () =>
Loading ...
, 31 | Done: (result) => 32 | result.match({ 33 | Error: () =>
An error occured
, 34 | Ok: ({ allFilms }) => { 35 | if (allFilms == null) { 36 | return
No films
; 37 | } 38 | return ( 39 |
40 |
41 | 49 | setVariables({ after })} 52 | isLoadingMore={isLoading} 53 | activeFilm={activeFilm} 54 | onPressFilm={(filmId: string) => 55 | setActiveFilm(Option.Some(filmId)) 56 | } 57 | /> 58 |
59 |
60 | {activeFilm.match({ 61 | None: () =>
No film selected
, 62 | Some: (filmId) => ( 63 | 64 | ), 65 | })} 66 |
67 |
68 | ); 69 | }, 70 | }), 71 | })} 72 |
73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /example/components/Film.tsx: -------------------------------------------------------------------------------- 1 | import { FragmentType, graphql, useFragment } from "../gql"; 2 | 3 | const FilmFragment = graphql(` 4 | fragment FilmItem on Film { 5 | id 6 | title 7 | releaseDate 8 | producers 9 | } 10 | `); 11 | 12 | type Props = { 13 | film: FragmentType; 14 | isActive: boolean; 15 | onPress: (filmId: string) => void; 16 | }; 17 | 18 | export const Film = ({ film: data, isActive, onPress }: Props) => { 19 | const film = useFragment(FilmFragment, data); 20 | return ( 21 |
onPress(film.id)} 25 | > 26 |

{film.title}

27 |

{film.releaseDate}

28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /example/components/FilmCharacterList.tsx: -------------------------------------------------------------------------------- 1 | import { useForwardPagination } from "../../src"; 2 | import { FragmentType, graphql, useFragment } from "../gql"; 3 | 4 | const FilmCharactersConnectionFragment = graphql(` 5 | fragment FilmCharactersConnection on FilmCharactersConnection { 6 | edges { 7 | node { 8 | id 9 | name 10 | } 11 | } 12 | pageInfo { 13 | hasNextPage 14 | endCursor 15 | } 16 | } 17 | `); 18 | 19 | type Props = { 20 | characters: FragmentType; 21 | onNextPage: (cursor: string | null) => void; 22 | isLoadingMore: boolean; 23 | }; 24 | 25 | export const FilmCharacterList = ({ 26 | characters, 27 | onNextPage, 28 | isLoadingMore, 29 | }: Props) => { 30 | const connection = useForwardPagination( 31 | useFragment(FilmCharactersConnectionFragment, characters), 32 | ); 33 | 34 | if (connection.edges == null) { 35 | return null; 36 | } 37 | 38 | return ( 39 | <> 40 |
    41 | {connection.edges.map((edge) => { 42 | if (edge == null) { 43 | return null; 44 | } 45 | const node = edge.node; 46 | if (node == null) { 47 | return null; 48 | } 49 | return
  • {node.name}
  • ; 50 | })} 51 |
52 | 53 | {isLoadingMore ?
Loading more
: null} 54 | 55 | {connection.pageInfo.hasNextPage ? ( 56 | 62 | ) : null} 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /example/components/FilmDetails.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDeferredQuery, useQuery } from "../../src"; 3 | import { graphql } from "../gql"; 4 | import { FilmCharacterList } from "./FilmCharacterList"; 5 | 6 | const FilmDetailsQuery = graphql(` 7 | query FilmDetails($filmId: ID!, $first: Int!, $after: String) { 8 | film(id: $filmId) { 9 | id 10 | title 11 | director 12 | openingCrawl 13 | characterConnection(first: $first, after: $after) { 14 | ...FilmCharactersConnection 15 | } 16 | releaseDate 17 | } 18 | } 19 | `); 20 | 21 | const ProducersQuery = graphql(` 22 | query Producers($filmId: ID!) { 23 | film(id: $filmId) { 24 | id 25 | producers 26 | } 27 | } 28 | `); 29 | 30 | type Props = { 31 | filmId: string; 32 | optimize: boolean; 33 | }; 34 | 35 | export const FilmDetails = ({ filmId, optimize }: Props) => { 36 | const [data, { isLoading, reload, setVariables }] = useQuery( 37 | FilmDetailsQuery, 38 | { 39 | filmId, 40 | first: 5, 41 | }, 42 | { optimize }, 43 | ); 44 | 45 | const [producers, { query: queryProducers }] = useDeferredQuery( 46 | ProducersQuery, 47 | { 48 | debounce: 500, 49 | }, 50 | ); 51 | 52 | useEffect(() => { 53 | // try debounced 54 | const a = queryProducers({ filmId: "1" }); 55 | const b = queryProducers({ filmId: "2" }); 56 | const c = queryProducers({ filmId }); 57 | return () => { 58 | a.cancel(); 59 | b.cancel(); 60 | c.cancel(); 61 | }; 62 | }, [filmId, queryProducers]); 63 | 64 | return ( 65 |
66 | {data.match({ 67 | NotAsked: () => null, 68 | Loading: () =>
Loading ...
, 69 | Done: (result) => 70 | result.match({ 71 | Error: () =>
An error occured
, 72 | Ok: ({ film }) => { 73 | if (film == null) { 74 | return
No film
; 75 | } 76 | return ( 77 | <> 78 | 85 |

{film.title}

86 |
Director: {film.director}
87 |
Release date: {film.releaseDate}
88 |
89 | Producers:{" "} 90 | {producers.match({ 91 | NotAsked: () => null, 92 | Loading: () => Loading ..., 93 | Done: (result) => 94 | result.match({ 95 | Error: () => Error, 96 | Ok: ({ film }) => ( 97 | queryProducers({ filmId })} 100 | > 101 | {film?.producers?.join(", ")} 102 | 103 | ), 104 | }), 105 | })} 106 |
107 |
108 | Opening crawl: 109 |
{film.openingCrawl}
110 |
111 | {film.characterConnection != null ? ( 112 | <> 113 |

Characters

114 | setVariables({ after })} 117 | isLoadingMore={isLoading} 118 | /> 119 | 120 | ) : null} 121 | 122 | ); 123 | }, 124 | }), 125 | })} 126 |
127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /example/components/FilmList.tsx: -------------------------------------------------------------------------------- 1 | import { Option } from "@swan-io/boxed"; 2 | import { useForwardPagination } from "../../src"; 3 | import { FragmentType, graphql, useFragment } from "../gql"; 4 | import { Film } from "./Film"; 5 | 6 | const FilmsConnectionFragment = graphql(` 7 | fragment FilmsConnection on FilmsConnection { 8 | edges { 9 | node { 10 | id 11 | ...FilmItem 12 | } 13 | } 14 | pageInfo { 15 | hasNextPage 16 | endCursor 17 | } 18 | } 19 | `); 20 | 21 | type Props = { 22 | films: FragmentType; 23 | onNextPage: (cursor: string | null) => void; 24 | isLoadingMore: boolean; 25 | activeFilm: Option; 26 | onPressFilm: (filmId: string) => void; 27 | }; 28 | 29 | export const FilmList = ({ 30 | films, 31 | onNextPage, 32 | activeFilm, 33 | onPressFilm, 34 | isLoadingMore, 35 | }: Props) => { 36 | const connection = useForwardPagination( 37 | useFragment(FilmsConnectionFragment, films), 38 | ); 39 | 40 | if (connection.edges == null) { 41 | return null; 42 | } 43 | 44 | return ( 45 | <> 46 | {connection.edges.map((edge) => { 47 | if (edge == null) { 48 | return null; 49 | } 50 | const node = edge.node; 51 | if (node == null) { 52 | return null; 53 | } 54 | return ( 55 | node.id === id).getOr(false)} 59 | onPress={onPressFilm} 60 | /> 61 | ); 62 | })} 63 | 64 | {isLoadingMore ?
Loading more
: null} 65 | 66 | {connection.pageInfo.hasNextPage ? ( 67 | 73 | ) : null} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /example/gql-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "interfaceToTypes": { 3 | "Node": [ 4 | "Film", 5 | "Species", 6 | "Planet", 7 | "Person", 8 | "Starship", 9 | "Vehicle" 10 | ] 11 | } 12 | } -------------------------------------------------------------------------------- /example/gql/fragment-masking.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { 3 | DocumentTypeDecoration, 4 | ResultOf, 5 | TypedDocumentNode, 6 | } from "@graphql-typed-document-node/core"; 7 | import { FragmentDefinitionNode } from "graphql"; 8 | import { Incremental } from "./graphql"; 9 | 10 | export type FragmentType< 11 | TDocumentType extends DocumentTypeDecoration, 12 | > = 13 | TDocumentType extends DocumentTypeDecoration 14 | ? [TType] extends [{ " $fragmentName"?: infer TKey }] 15 | ? TKey extends string 16 | ? { " $fragmentRefs"?: { [key in TKey]: TType } } 17 | : never 18 | : never 19 | : never; 20 | 21 | // return non-nullable if `fragmentType` is non-nullable 22 | export function useFragment( 23 | _documentNode: DocumentTypeDecoration, 24 | fragmentType: FragmentType>, 25 | ): TType; 26 | // return nullable if `fragmentType` is nullable 27 | export function useFragment( 28 | _documentNode: DocumentTypeDecoration, 29 | fragmentType: 30 | | FragmentType> 31 | | null 32 | | undefined, 33 | ): TType | null | undefined; 34 | // return array of non-nullable if `fragmentType` is array of non-nullable 35 | export function useFragment( 36 | _documentNode: DocumentTypeDecoration, 37 | fragmentType: ReadonlyArray>>, 38 | ): ReadonlyArray; 39 | // return array of nullable if `fragmentType` is array of nullable 40 | export function useFragment( 41 | _documentNode: DocumentTypeDecoration, 42 | fragmentType: 43 | | ReadonlyArray>> 44 | | null 45 | | undefined, 46 | ): ReadonlyArray | null | undefined; 47 | export function useFragment( 48 | _documentNode: DocumentTypeDecoration, 49 | fragmentType: 50 | | FragmentType> 51 | | ReadonlyArray>> 52 | | null 53 | | undefined, 54 | ): TType | ReadonlyArray | null | undefined { 55 | return fragmentType as any; 56 | } 57 | 58 | export function makeFragmentData< 59 | F extends DocumentTypeDecoration, 60 | FT extends ResultOf, 61 | >(data: FT, _fragment: F): FragmentType { 62 | return data as FragmentType; 63 | } 64 | export function isFragmentReady( 65 | queryNode: DocumentTypeDecoration, 66 | fragmentNode: TypedDocumentNode, 67 | data: 68 | | FragmentType, any>> 69 | | null 70 | | undefined, 71 | ): data is FragmentType { 72 | const deferredFields = ( 73 | queryNode as { 74 | __meta__?: { deferredFields: Record }; 75 | } 76 | ).__meta__?.deferredFields; 77 | 78 | if (!deferredFields) return true; 79 | 80 | const fragDef = fragmentNode.definitions[0] as 81 | | FragmentDefinitionNode 82 | | undefined; 83 | const fragName = fragDef?.name?.value; 84 | 85 | const fields = (fragName && deferredFields[fragName]) || []; 86 | return fields.length > 0 && fields.every((field) => data && field in data); 87 | } 88 | -------------------------------------------------------------------------------- /example/gql/gql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core"; 3 | import * as types from "./graphql"; 4 | 5 | /** 6 | * Map of all GraphQL operations in the project. 7 | * 8 | * This map has several performance disadvantages: 9 | * 1. It is not tree-shakeable, so it will include all operations in the project. 10 | * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. 11 | * 3. It does not support dead code elimination, so it will add unused operations. 12 | * 13 | * Therefore it is highly recommended to use the babel or swc plugin for production. 14 | */ 15 | const documents = { 16 | "\n query allFilmsWithVariablesQuery($first: Int!, $after: String) {\n allFilms(first: $first, after: $after) {\n ...FilmsConnection\n }\n }\n": 17 | types.AllFilmsWithVariablesQueryDocument, 18 | "\n fragment FilmItem on Film {\n id\n title\n releaseDate\n producers\n }\n": 19 | types.FilmItemFragmentDoc, 20 | "\n fragment FilmCharactersConnection on FilmCharactersConnection {\n edges {\n node {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n": 21 | types.FilmCharactersConnectionFragmentDoc, 22 | "\n query FilmDetails($filmId: ID!, $first: Int!, $after: String) {\n film(id: $filmId) {\n id\n title\n director\n openingCrawl\n characterConnection(first: $first, after: $after) {\n ...FilmCharactersConnection\n }\n releaseDate\n }\n }\n": 23 | types.FilmDetailsDocument, 24 | "\n query Producers($filmId: ID!) {\n film(id: $filmId) {\n id\n producers\n }\n }\n": 25 | types.ProducersDocument, 26 | "\n fragment FilmsConnection on FilmsConnection {\n edges {\n node {\n id\n ...FilmItem\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n": 27 | types.FilmsConnectionFragmentDoc, 28 | }; 29 | 30 | /** 31 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 32 | * 33 | * 34 | * @example 35 | * ```ts 36 | * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); 37 | * ``` 38 | * 39 | * The query argument is unknown! 40 | * Please regenerate the types. 41 | */ 42 | export function graphql(source: string): unknown; 43 | 44 | /** 45 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 46 | */ 47 | export function graphql( 48 | source: "\n query allFilmsWithVariablesQuery($first: Int!, $after: String) {\n allFilms(first: $first, after: $after) {\n ...FilmsConnection\n }\n }\n", 49 | ): (typeof documents)["\n query allFilmsWithVariablesQuery($first: Int!, $after: String) {\n allFilms(first: $first, after: $after) {\n ...FilmsConnection\n }\n }\n"]; 50 | /** 51 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 52 | */ 53 | export function graphql( 54 | source: "\n fragment FilmItem on Film {\n id\n title\n releaseDate\n producers\n }\n", 55 | ): (typeof documents)["\n fragment FilmItem on Film {\n id\n title\n releaseDate\n producers\n }\n"]; 56 | /** 57 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 58 | */ 59 | export function graphql( 60 | source: "\n fragment FilmCharactersConnection on FilmCharactersConnection {\n edges {\n node {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n", 61 | ): (typeof documents)["\n fragment FilmCharactersConnection on FilmCharactersConnection {\n edges {\n node {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n"]; 62 | /** 63 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 64 | */ 65 | export function graphql( 66 | source: "\n query FilmDetails($filmId: ID!, $first: Int!, $after: String) {\n film(id: $filmId) {\n id\n title\n director\n openingCrawl\n characterConnection(first: $first, after: $after) {\n ...FilmCharactersConnection\n }\n releaseDate\n }\n }\n", 67 | ): (typeof documents)["\n query FilmDetails($filmId: ID!, $first: Int!, $after: String) {\n film(id: $filmId) {\n id\n title\n director\n openingCrawl\n characterConnection(first: $first, after: $after) {\n ...FilmCharactersConnection\n }\n releaseDate\n }\n }\n"]; 68 | /** 69 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 70 | */ 71 | export function graphql( 72 | source: "\n query Producers($filmId: ID!) {\n film(id: $filmId) {\n id\n producers\n }\n }\n", 73 | ): (typeof documents)["\n query Producers($filmId: ID!) {\n film(id: $filmId) {\n id\n producers\n }\n }\n"]; 74 | /** 75 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 76 | */ 77 | export function graphql( 78 | source: "\n fragment FilmsConnection on FilmsConnection {\n edges {\n node {\n id\n ...FilmItem\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n", 79 | ): (typeof documents)["\n fragment FilmsConnection on FilmsConnection {\n edges {\n node {\n id\n ...FilmItem\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n"]; 80 | 81 | export function graphql(source: string) { 82 | return (documents as any)[source] ?? {}; 83 | } 84 | 85 | export type DocumentType> = 86 | TDocumentNode extends DocumentNode ? TType : never; 87 | -------------------------------------------------------------------------------- /example/gql/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fragment-masking"; 2 | export * from "./gql"; 3 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Demo 4 | 82 |
83 | 84 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Client, ClientContext } from "../src"; 4 | import { App } from "./components/App"; 5 | import schemaConfig from "./gql-config.json"; 6 | 7 | const client = new Client({ 8 | url: "https://swapi-graphql.eskerda.vercel.app", 9 | headers: { 10 | "Content-Type": "application/json", 11 | }, 12 | schemaConfig, 13 | }); 14 | 15 | const Root = () => { 16 | return ( 17 | 18 | Suspense loading}> 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | const root = document.querySelector("#app"); 26 | 27 | if (root != null) { 28 | createRoot(root).render(); 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swan-io/graphql-client", 3 | "version": "0.6.0", 4 | "license": "MIT", 5 | "description": "A simple, typesafe GraphQL client for React", 6 | "author": "Matthias Le Brun ", 7 | "homepage": "https://swan-io.github.io/graphql-client", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/swan-io/graphql-client.git" 11 | }, 12 | "source": "src/index.ts", 13 | "main": "dist/index.js", 14 | "module": "dist/index.mjs", 15 | "types": "dist/index.d.ts", 16 | "keywords": [ 17 | "cache", 18 | "client", 19 | "gql", 20 | "graphql", 21 | "react" 22 | ], 23 | "publishConfig": { 24 | "access": "public", 25 | "registry": "https://registry.npmjs.org" 26 | }, 27 | "bin": { 28 | "generate-schema-config": "bin/generate-schema-config" 29 | }, 30 | "files": [ 31 | "bin", 32 | "LICENSE", 33 | "dist", 34 | "README.md" 35 | ], 36 | "scripts": { 37 | "lint": "eslint 'src/**/*.{ts,tsx}'", 38 | "example": "vite example --config vite.config.mjs", 39 | "format": "prettier '**/*' --ignore-unknown --write", 40 | "test": "vitest run", 41 | "typecheck": "tsc --noEmit", 42 | "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", 43 | "prepack": "yarn typecheck && yarn test && yarn build", 44 | "codegen": "graphql-codegen --config codegen.ts" 45 | }, 46 | "prettier": { 47 | "plugins": [ 48 | "prettier-plugin-organize-imports" 49 | ] 50 | }, 51 | "dependencies": { 52 | "@0no-co/graphql.web": "^1.0.6", 53 | "@swan-io/boxed": "^3.0.0", 54 | "@swan-io/request": "^2.0.0" 55 | }, 56 | "peerDependencies": { 57 | "react": ">=18.2.0" 58 | }, 59 | "devDependencies": { 60 | "@0no-co/graphqlsp": "^1.7.1", 61 | "@graphql-codegen/cli": "^5.0.2", 62 | "@types/node": "^20.12.3", 63 | "@types/react": "^18.2.74", 64 | "@types/react-dom": "^18.2.23", 65 | "@typescript-eslint/eslint-plugin": "^7.5.0", 66 | "@typescript-eslint/parser": "^7.5.0", 67 | "@vitejs/plugin-basic-ssl": "^1.1.0", 68 | "eslint": "^8.57.0", 69 | "eslint-plugin-react": "^7.34.1", 70 | "eslint-plugin-react-hooks": "^4.6.0", 71 | "gql.tada": "^1.4.1", 72 | "graphql": "^16.8.1", 73 | "jsdom": "^24.0.0", 74 | "prettier": "^3.2.5", 75 | "prettier-plugin-organize-imports": "^3.2.4", 76 | "react": "^18.2.0", 77 | "react-dom": "^18.2.0", 78 | "ts-pattern": "^5.1.0", 79 | "tsup": "^8.0.2", 80 | "tsx": "^4.7.1", 81 | "typescript": "^5.6.3", 82 | "vite": "^5.2.7", 83 | "vitest": "^1.4.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "@0no-co/graphql.web"; 2 | import { Array, Option, Result } from "@swan-io/boxed"; 3 | import { getCacheEntryKey } from "../json/cacheEntryKey"; 4 | import { Connection, Edge } from "../types"; 5 | import { 6 | CONNECTION_REF, 7 | EDGES_KEY, 8 | NODE_KEY, 9 | REQUESTED_KEYS, 10 | TYPENAME_KEY, 11 | containsAll, 12 | isRecord, 13 | serializeVariables, 14 | } from "../utils"; 15 | import type { CacheEntry } from "./entry"; 16 | 17 | export type SchemaConfig = { 18 | interfaceToTypes: Record; 19 | }; 20 | 21 | type ConnectionInfo = { 22 | // useful for connection updates 23 | cacheEntry: CacheEntry; 24 | // to re-read from cache 25 | document: DocumentNode; 26 | variables: Record; 27 | pathInQuery: PropertyKey[]; 28 | fieldVariables: Record; 29 | }; 30 | 31 | export class ClientCache { 32 | cache = new Map(); 33 | operationCache = new Map< 34 | DocumentNode, 35 | Map>> 36 | >(); 37 | 38 | interfaceToType: Record>; 39 | connectionCache: Map; 40 | connectionRefCount = -1; 41 | 42 | constructor(schemaConfig: SchemaConfig) { 43 | this.interfaceToType = Object.fromEntries( 44 | Object.entries(schemaConfig.interfaceToTypes).map(([key, value]) => [ 45 | key, 46 | new Set(value), 47 | ]), 48 | ); 49 | this.connectionCache = new Map(); 50 | } 51 | 52 | registerConnectionInfo(info: ConnectionInfo) { 53 | const id = ++this.connectionRefCount; 54 | this.connectionCache.set(id, info); 55 | return id; 56 | } 57 | 58 | isTypeCompatible(typename: string, typeCondition: string) { 59 | if (typename === typeCondition) { 60 | return true; 61 | } 62 | const compatibleTypes = this.interfaceToType[typeCondition]; 63 | if (compatibleTypes == undefined) { 64 | return false; 65 | } 66 | return compatibleTypes.has(typename); 67 | } 68 | 69 | dump() { 70 | return this.cache; 71 | } 72 | 73 | getOperationFromCache( 74 | documentNode: DocumentNode, 75 | variables: Record, 76 | ) { 77 | const serializedVariables = serializeVariables(variables); 78 | return Option.fromNullable(this.operationCache.get(documentNode)) 79 | .flatMap((cache) => Option.fromNullable(cache.get(serializedVariables))) 80 | .flatMap((value) => value); 81 | } 82 | 83 | setOperationInCache( 84 | documentNode: DocumentNode, 85 | variables: Record, 86 | data: Result, 87 | ) { 88 | const serializedVariables = serializeVariables(variables); 89 | const documentCache = Option.fromNullable( 90 | this.operationCache.get(documentNode), 91 | ).getOr(new Map()); 92 | documentCache.set(serializedVariables, Option.Some(data)); 93 | this.operationCache.set(documentNode, documentCache); 94 | } 95 | 96 | getFromCache(cacheKey: symbol, requestedKeys: Set) { 97 | return this.get(cacheKey).flatMap((entry) => { 98 | if (isRecord(entry)) { 99 | if (containsAll(entry[REQUESTED_KEYS] as Set, requestedKeys)) { 100 | return Option.Some(entry); 101 | } else { 102 | return Option.None(); 103 | } 104 | } else { 105 | return Option.Some(entry); 106 | } 107 | }); 108 | } 109 | 110 | getFromCacheWithoutKey(cacheKey: symbol) { 111 | return this.get(cacheKey).flatMap((entry) => { 112 | return Option.Some(entry); 113 | }); 114 | } 115 | 116 | get(cacheKey: symbol): Option { 117 | if (this.cache.has(cacheKey)) { 118 | return Option.Some(this.cache.get(cacheKey)); 119 | } else { 120 | return Option.None(); 121 | } 122 | } 123 | 124 | getOrCreateEntry(cacheKey: symbol, defaultValue: CacheEntry): unknown { 125 | if (this.cache.has(cacheKey)) { 126 | return this.cache.get(cacheKey) as unknown; 127 | } else { 128 | const entry = defaultValue; 129 | this.cache.set(cacheKey, entry); 130 | return entry; 131 | } 132 | } 133 | 134 | set(cacheKey: symbol, entry: CacheEntry) { 135 | this.cache.set(cacheKey, entry); 136 | } 137 | 138 | updateConnection( 139 | connection: Connection, 140 | config: 141 | | { prepend: Edge[] } 142 | | { append: Edge[] } 143 | | { remove: string[] }, 144 | ) { 145 | if (connection == null) { 146 | return; 147 | } 148 | if ( 149 | CONNECTION_REF in connection && 150 | typeof connection[CONNECTION_REF] === "number" 151 | ) { 152 | const connectionConfig = this.connectionCache.get( 153 | connection[CONNECTION_REF], 154 | ); 155 | if (connectionConfig == null) { 156 | return; 157 | } 158 | 159 | if ("prepend" in config) { 160 | const edges = config.prepend; 161 | connectionConfig.cacheEntry[EDGES_KEY] = [ 162 | ...Array.filterMap(edges, ({ node, __typename }) => 163 | getCacheEntryKey(node).flatMap((key) => 164 | // we can omit the requested fields here because the Connection contrains the fields 165 | this.getFromCacheWithoutKey(key).map(() => ({ 166 | [TYPENAME_KEY]: __typename, 167 | [NODE_KEY]: key, 168 | })), 169 | ), 170 | ), 171 | ...(connectionConfig.cacheEntry[EDGES_KEY] as unknown[]), 172 | ]; 173 | return; 174 | } 175 | 176 | if ("append" in config) { 177 | const edges = config.append; 178 | connectionConfig.cacheEntry[EDGES_KEY] = [ 179 | ...(connectionConfig.cacheEntry[EDGES_KEY] as unknown[]), 180 | ...Array.filterMap(edges, ({ node, __typename }) => 181 | getCacheEntryKey(node).flatMap((key) => 182 | // we can omit the requested fields here because the Connection contrains the fields 183 | this.getFromCacheWithoutKey(key).map(() => ({ 184 | [TYPENAME_KEY]: __typename, 185 | [NODE_KEY]: key, 186 | })), 187 | ), 188 | ), 189 | ]; 190 | return; 191 | } 192 | const nodeIds = config.remove; 193 | connectionConfig.cacheEntry[EDGES_KEY] = ( 194 | connectionConfig.cacheEntry[EDGES_KEY] as unknown[] 195 | ).filter((edge) => { 196 | // @ts-expect-error fine 197 | const node = edge[NODE_KEY] as symbol; 198 | return !nodeIds.some((nodeId) => { 199 | return node.description?.includes(`<${nodeId}>`); 200 | }); 201 | }); 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/cache/entry.ts: -------------------------------------------------------------------------------- 1 | import { CONNECTION_REF, REQUESTED_KEYS } from "../utils"; 2 | 3 | export type CacheEntry = Record & { 4 | [REQUESTED_KEYS]: Set; 5 | [CONNECTION_REF]?: number; 6 | }; 7 | 8 | export const createEmptyCacheEntry = (): CacheEntry => ({ 9 | [REQUESTED_KEYS]: new Set(), 10 | }); 11 | -------------------------------------------------------------------------------- /src/cache/read.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | InlineFragmentNode, 4 | Kind, 5 | OperationDefinitionNode, 6 | SelectionNode, 7 | SelectionSetNode, 8 | } from "@0no-co/graphql.web"; 9 | import { Array, Option, Result } from "@swan-io/boxed"; 10 | import { 11 | addIdIfPreviousSelected, 12 | getCacheKeyFromOperationNode, 13 | getFieldName, 14 | getFieldNameWithArguments, 15 | getSelectedKeys, 16 | isExcluded, 17 | } from "../graphql/ast"; 18 | import { getTypename } from "../json/getTypename"; 19 | import { 20 | REQUESTED_KEYS, 21 | containsAll, 22 | deepEqual, 23 | hasOwnProperty, 24 | isRecord, 25 | serializeVariables, 26 | } from "../utils"; 27 | import { ClientCache } from "./cache"; 28 | 29 | const getFromCacheOrReturnValue = ( 30 | cache: ClientCache, 31 | valueOrKey: unknown, 32 | selectedKeys: Set, 33 | ): Option => { 34 | if (typeof valueOrKey === "symbol") { 35 | return cache 36 | .getFromCache(valueOrKey, selectedKeys) 37 | .flatMap(Option.fromNullable); 38 | } 39 | if ( 40 | isRecord(valueOrKey) && 41 | REQUESTED_KEYS in valueOrKey && 42 | valueOrKey[REQUESTED_KEYS] instanceof Set 43 | ) { 44 | if (containsAll(valueOrKey[REQUESTED_KEYS], selectedKeys)) { 45 | return Option.Some(valueOrKey); 46 | } else { 47 | return Option.None(); 48 | } 49 | } 50 | return Option.Some(valueOrKey); 51 | }; 52 | 53 | const getFromCacheOrReturnValueWithoutKeyFilter = ( 54 | cache: ClientCache, 55 | valueOrKey: unknown, 56 | ): Option => { 57 | return typeof valueOrKey === "symbol" 58 | ? cache.getFromCacheWithoutKey(valueOrKey).flatMap(Option.fromNullable) 59 | : Option.Some(valueOrKey); 60 | }; 61 | 62 | const STABILITY_CACHE = new WeakMap>(); 63 | 64 | const EXCLUDED = Symbol.for("EXCLUDED"); 65 | 66 | export const readOperationFromCache = ( 67 | cache: ClientCache, 68 | document: DocumentNode, 69 | variables: Record, 70 | ) => { 71 | const traverse = ( 72 | selections: SelectionSetNode, 73 | data: Record, 74 | ): Option => { 75 | return selections.selections.reduce>((data, selection) => { 76 | return data.flatMap((data) => { 77 | if (selection.kind === Kind.FIELD) { 78 | const fieldNode = selection; 79 | const originalFieldName = getFieldName(fieldNode); 80 | const fieldNameWithArguments = getFieldNameWithArguments( 81 | fieldNode, 82 | variables, 83 | ); 84 | 85 | if (data == undefined) { 86 | return Option.None(); 87 | } 88 | 89 | const cacheHasKey = 90 | hasOwnProperty.call(data, originalFieldName) || 91 | hasOwnProperty.call(data, fieldNameWithArguments); 92 | 93 | if (!cacheHasKey) { 94 | if (isExcluded(fieldNode, variables)) { 95 | return Option.Some({ 96 | ...data, 97 | [originalFieldName]: EXCLUDED, 98 | }); 99 | } else { 100 | return Option.None(); 101 | } 102 | } 103 | 104 | // in case a the data is read across multiple selections, get the actual one if generated, 105 | // otherwise, read from cache (e.g. fragments) 106 | const valueOrKeyFromCache = 107 | // @ts-expect-error `data` is indexable at this point 108 | originalFieldName in data 109 | ? // @ts-expect-error `data` is indexable at this point 110 | data[originalFieldName] 111 | : // @ts-expect-error `data` is indexable at this point 112 | data[fieldNameWithArguments]; 113 | 114 | if (valueOrKeyFromCache == undefined) { 115 | return Option.Some({ 116 | ...data, 117 | [originalFieldName]: valueOrKeyFromCache, 118 | }); 119 | } 120 | 121 | if (Array.isArray(valueOrKeyFromCache)) { 122 | const selectedKeys = getSelectedKeys(fieldNode, variables); 123 | return Option.all( 124 | valueOrKeyFromCache.map((valueOrKey) => { 125 | const value = getFromCacheOrReturnValue( 126 | cache, 127 | valueOrKey, 128 | selectedKeys, 129 | ); 130 | 131 | return value.flatMap((value) => { 132 | if (isRecord(value) && fieldNode.selectionSet != undefined) { 133 | return traverse(fieldNode.selectionSet, value); 134 | } else { 135 | return Option.Some(value); 136 | } 137 | }); 138 | }), 139 | ).map((result) => ({ 140 | ...data, 141 | [originalFieldName]: result, 142 | })); 143 | } else { 144 | const selectedKeys = getSelectedKeys(fieldNode, variables); 145 | 146 | const value = getFromCacheOrReturnValue( 147 | cache, 148 | valueOrKeyFromCache, 149 | selectedKeys, 150 | ); 151 | 152 | return value.flatMap((value) => { 153 | if (isRecord(value) && fieldNode.selectionSet != undefined) { 154 | return traverse( 155 | fieldNode.selectionSet, 156 | value as Record, 157 | ).map((result) => ({ 158 | ...data, 159 | [originalFieldName]: result, 160 | })); 161 | } else { 162 | return Option.Some({ ...data, [originalFieldName]: value }); 163 | } 164 | }); 165 | } 166 | } 167 | if (selection.kind === Kind.INLINE_FRAGMENT) { 168 | const inlineFragmentNode = selection; 169 | const typeCondition = inlineFragmentNode.typeCondition?.name.value; 170 | const dataTypename = getTypename(data); 171 | 172 | if (typeCondition != null && dataTypename != null) { 173 | if (cache.isTypeCompatible(dataTypename, typeCondition)) { 174 | return traverse( 175 | inlineFragmentNode.selectionSet, 176 | data as Record, 177 | ); 178 | } else { 179 | if ( 180 | inlineFragmentNode.selectionSet.selections.some( 181 | (selection) => selection.kind === Kind.INLINE_FRAGMENT, 182 | ) 183 | ) { 184 | return traverse( 185 | { 186 | ...inlineFragmentNode.selectionSet, 187 | selections: 188 | inlineFragmentNode.selectionSet.selections.filter( 189 | (selection) => { 190 | if (selection.kind === Kind.INLINE_FRAGMENT) { 191 | const typeCondition = 192 | selection.typeCondition?.name.value; 193 | if (typeCondition == null) { 194 | return true; 195 | } else { 196 | return cache.isTypeCompatible( 197 | dataTypename, 198 | typeCondition, 199 | ); 200 | } 201 | } 202 | return true; 203 | }, 204 | ), 205 | }, 206 | data as Record, 207 | ); 208 | } else { 209 | return Option.Some(data); 210 | } 211 | } 212 | } 213 | return traverse( 214 | inlineFragmentNode.selectionSet, 215 | data as Record, 216 | ); 217 | } else { 218 | return Option.None(); 219 | } 220 | }); 221 | }, Option.Some(data)); 222 | }; 223 | 224 | return Array.findMap(document.definitions, (definition) => 225 | definition.kind === Kind.OPERATION_DEFINITION 226 | ? Option.Some(definition) 227 | : Option.None(), 228 | ) 229 | .flatMap((operation) => 230 | getCacheKeyFromOperationNode(operation).map((cacheKey) => ({ 231 | operation, 232 | cacheKey, 233 | })), 234 | ) 235 | .flatMap(({ operation, cacheKey }) => { 236 | return cache 237 | .getFromCache(cacheKey, getSelectedKeys(operation, variables)) 238 | .map((cache) => ({ cache, operation })); 239 | }) 240 | .flatMap(({ operation, cache }) => { 241 | return traverse( 242 | operation.selectionSet, 243 | cache as Record, 244 | ); 245 | }) 246 | .map((data) => JSON.parse(JSON.stringify(data))) 247 | .flatMap((value) => { 248 | // We use a trick to return stable values, the document holds a WeakMap 249 | // that for each key (serialized variables), stores the last returned result. 250 | // If the last value deeply equals the previous one, return the previous one 251 | const serializedVariables = serializeVariables(variables); 252 | const previous = Option.fromNullable(STABILITY_CACHE.get(document)) 253 | .flatMap((byVariable) => 254 | Option.fromNullable(byVariable.get(serializedVariables)), 255 | ) 256 | .flatMap((value) => value as Option>); 257 | 258 | if ( 259 | previous 260 | .flatMap((previous) => previous.toOption()) 261 | .map((previous) => deepEqual(value, previous)) 262 | .getOr(false) 263 | ) { 264 | return previous; 265 | } else { 266 | const valueToCache = Option.Some(Result.Ok(value)); 267 | const documentCache = STABILITY_CACHE.get(document) ?? new Map(); 268 | documentCache.set(serializedVariables, valueToCache); 269 | STABILITY_CACHE.set(document, documentCache); 270 | return valueToCache; 271 | } 272 | }); 273 | }; 274 | 275 | export const optimizeQuery = ( 276 | cache: ClientCache, 277 | document: DocumentNode, 278 | variables: Record, 279 | ): Option => { 280 | const traverse = ( 281 | selections: SelectionSetNode, 282 | data: Record, 283 | parentSelectedKeys: Set, 284 | ): Option => { 285 | const nextSelections = Array.filterMap( 286 | selections.selections, 287 | (selection) => { 288 | switch (selection.kind) { 289 | case Kind.FIELD: { 290 | const fieldNode = selection; 291 | const fieldNameWithArguments = getFieldNameWithArguments( 292 | fieldNode, 293 | variables, 294 | ); 295 | 296 | if (data == undefined) { 297 | return Option.Some(fieldNode); 298 | } 299 | 300 | const cacheHasKey = hasOwnProperty.call( 301 | data, 302 | fieldNameWithArguments, 303 | ); 304 | 305 | if (!cacheHasKey) { 306 | return Option.Some(fieldNode); 307 | } 308 | 309 | if (parentSelectedKeys.has(fieldNameWithArguments)) { 310 | const valueOrKeyFromCache = data[fieldNameWithArguments]; 311 | 312 | const subFieldSelectedKeys = getSelectedKeys( 313 | fieldNode, 314 | variables, 315 | ); 316 | if (Array.isArray(valueOrKeyFromCache)) { 317 | return valueOrKeyFromCache.reduce((acc, valueOrKey) => { 318 | const value = getFromCacheOrReturnValueWithoutKeyFilter( 319 | cache, 320 | valueOrKey, 321 | ); 322 | 323 | if (value.isNone()) { 324 | return Option.Some(fieldNode); 325 | } 326 | 327 | const originalSelectionSet = fieldNode.selectionSet; 328 | if (originalSelectionSet != null) { 329 | return traverse( 330 | originalSelectionSet, 331 | value.get() as Record, 332 | subFieldSelectedKeys, 333 | ).map((selectionSet) => ({ 334 | ...fieldNode, 335 | selectionSet: addIdIfPreviousSelected( 336 | originalSelectionSet, 337 | selectionSet, 338 | ), 339 | })); 340 | } else { 341 | return acc; 342 | } 343 | }, Option.None()); 344 | } else { 345 | const value = getFromCacheOrReturnValueWithoutKeyFilter( 346 | cache, 347 | valueOrKeyFromCache, 348 | ); 349 | 350 | if (value.isNone()) { 351 | return Option.Some(fieldNode); 352 | } 353 | 354 | const originalSelectionSet = fieldNode.selectionSet; 355 | if (originalSelectionSet != null) { 356 | return traverse( 357 | originalSelectionSet, 358 | value.get() as Record, 359 | subFieldSelectedKeys, 360 | ).map((selectionSet) => ({ 361 | ...fieldNode, 362 | selectionSet: addIdIfPreviousSelected( 363 | originalSelectionSet, 364 | selectionSet, 365 | ), 366 | })); 367 | } else { 368 | return Option.None(); 369 | } 370 | } 371 | } else { 372 | return Option.Some(fieldNode); 373 | } 374 | } 375 | case Kind.INLINE_FRAGMENT: { 376 | const inlineFragmentNode = selection; 377 | return traverse( 378 | inlineFragmentNode.selectionSet, 379 | data as Record, 380 | parentSelectedKeys, 381 | ).map( 382 | (selectionSet) => 383 | ({ ...inlineFragmentNode, selectionSet }) as InlineFragmentNode, 384 | ); 385 | } 386 | default: 387 | return Option.None(); 388 | } 389 | }, 390 | ); 391 | if (nextSelections.length > 0) { 392 | return Option.Some({ ...selections, selections: nextSelections }); 393 | } else { 394 | return Option.None(); 395 | } 396 | }; 397 | 398 | return Array.findMap(document.definitions, (definition) => 399 | definition.kind === Kind.OPERATION_DEFINITION 400 | ? Option.Some(definition) 401 | : Option.None(), 402 | ) 403 | .flatMap((operation) => 404 | getCacheKeyFromOperationNode(operation).map((cacheKey) => ({ 405 | operation, 406 | cacheKey, 407 | })), 408 | ) 409 | .flatMap(({ operation, cacheKey }) => { 410 | const selectedKeys = getSelectedKeys(operation, variables); 411 | return cache 412 | .getFromCache(cacheKey, selectedKeys) 413 | .map((cache) => ({ cache, operation, selectedKeys })); 414 | }) 415 | .flatMap(({ operation, cache, selectedKeys }) => { 416 | return traverse( 417 | operation.selectionSet, 418 | cache as Record, 419 | selectedKeys, 420 | ).map((selectionSet) => ({ 421 | ...document, 422 | definitions: [ 423 | { 424 | ...operation, 425 | selectionSet, 426 | } as OperationDefinitionNode, 427 | ], 428 | })); 429 | }); 430 | }; 431 | -------------------------------------------------------------------------------- /src/cache/write.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Kind, 3 | OperationTypeNode, 4 | type DocumentNode, 5 | type FieldNode, 6 | type SelectionSetNode, 7 | } from "@0no-co/graphql.web"; 8 | import { 9 | extractArguments, 10 | getFieldName, 11 | getFieldNameWithArguments, 12 | } from "../graphql/ast"; 13 | import { getCacheEntryKey } from "../json/cacheEntryKey"; 14 | import { CONNECTION_REF, isRecord, REQUESTED_KEYS } from "../utils"; 15 | import { type ClientCache } from "./cache"; 16 | import { createEmptyCacheEntry, type CacheEntry } from "./entry"; 17 | 18 | export const writeOperationToCache = ( 19 | cache: ClientCache, 20 | document: DocumentNode, 21 | response: unknown, 22 | variables: Record, 23 | ) => { 24 | const registerConnection = ( 25 | cacheEntry: CacheEntry, 26 | pathInQuery: PropertyKey[], 27 | fieldVariables: Record, 28 | ) => { 29 | if (cacheEntry[CONNECTION_REF]) { 30 | return; 31 | } 32 | const id = cache.registerConnectionInfo({ 33 | cacheEntry, 34 | variables, 35 | pathInQuery, 36 | fieldVariables, 37 | document, 38 | }); 39 | cacheEntry[CONNECTION_REF] = id; 40 | }; 41 | 42 | const cacheField = ( 43 | field: FieldNode, 44 | parentJson: Record, 45 | parentCache: CacheEntry, 46 | path: PropertyKey[], 47 | ) => { 48 | const originalFieldName = getFieldName(field); 49 | const fieldNameWithArguments = getFieldNameWithArguments(field, variables); 50 | const fieldValue = parentJson[originalFieldName]; 51 | 52 | if (parentCache[REQUESTED_KEYS] != undefined) { 53 | parentCache[REQUESTED_KEYS].add(fieldNameWithArguments); 54 | } else { 55 | console.error( 56 | `GraphQL Client cache error: ${path.join(".")} likely didn't query its \`id\` field`, 57 | ); 58 | } 59 | 60 | // either scalar type with no selection, or a null/undefined value 61 | const subSelectionSet = field.selectionSet; 62 | if (subSelectionSet === undefined || fieldValue == null) { 63 | parentCache[fieldNameWithArguments] = fieldValue; 64 | return; 65 | } 66 | // array with selection 67 | if (Array.isArray(fieldValue)) { 68 | const arrayCache = 69 | parentCache[fieldNameWithArguments] ?? Array(fieldValue.length); 70 | // @ts-expect-error it's an array 71 | arrayCache.length = fieldValue.length; 72 | if (parentCache[fieldNameWithArguments] == undefined) { 73 | parentCache[fieldNameWithArguments] = arrayCache; 74 | } 75 | fieldValue.forEach((item, index) => { 76 | if (item == null) { 77 | // @ts-expect-error It's fine 78 | arrayCache[index] = item; 79 | return; 80 | } 81 | const cacheKey = getCacheEntryKey(item); 82 | const cacheEntry = cacheKey.map((key) => 83 | cache.getOrCreateEntry(key, createEmptyCacheEntry()), 84 | ); 85 | const cacheObject = cacheEntry.getOr( 86 | // @ts-expect-error It's fine 87 | arrayCache[index] ?? createEmptyCacheEntry(), 88 | ) as CacheEntry; 89 | 90 | // @ts-expect-error It's fine 91 | const cacheValueInParent = cacheKey.getOr(cacheObject); 92 | // @ts-expect-error It's fine 93 | arrayCache[index] = cacheValueInParent; 94 | 95 | cacheSelectionSet(subSelectionSet, item, cacheObject, [ 96 | ...path, 97 | originalFieldName, 98 | index, 99 | ]); 100 | }); 101 | return; 102 | } 103 | // object with selection 104 | const record = fieldValue as Record; 105 | const cacheKey = getCacheEntryKey(record); 106 | const cacheEntry = cacheKey.map((key) => 107 | cache.getOrCreateEntry(key, createEmptyCacheEntry()), 108 | ); 109 | const cacheObject = cacheEntry.getOr( 110 | parentCache[fieldNameWithArguments] ?? createEmptyCacheEntry(), 111 | ) as CacheEntry; 112 | 113 | // @ts-expect-error It's fine 114 | const cacheValueInParent = cacheKey.getOr(cacheObject); 115 | parentCache[fieldNameWithArguments] = cacheValueInParent; 116 | 117 | if ( 118 | typeof record.__typename === "string" && 119 | record.__typename.endsWith("Connection") 120 | ) { 121 | registerConnection( 122 | cacheObject, 123 | [...path, originalFieldName], 124 | extractArguments(field, variables), 125 | ); 126 | } 127 | 128 | return cacheSelectionSet(subSelectionSet, record, cacheObject, [ 129 | ...path, 130 | originalFieldName, 131 | ]); 132 | }; 133 | 134 | const cacheSelectionSet = ( 135 | selectionSet: SelectionSetNode, 136 | json: Record, 137 | cached: CacheEntry, 138 | path: PropertyKey[], 139 | ) => { 140 | for (const selection of selectionSet.selections) { 141 | switch (selection.kind) { 142 | case Kind.INLINE_FRAGMENT: 143 | cacheSelectionSet(selection.selectionSet, json, cached, path); 144 | continue; 145 | case Kind.FIELD: 146 | cacheField(selection, json, cached, path); 147 | continue; 148 | default: 149 | continue; 150 | } 151 | } 152 | }; 153 | 154 | document.definitions.forEach((definition) => { 155 | if (definition.kind === Kind.OPERATION_DEFINITION) { 156 | // Root __typename can vary, but we can't guess it from the document alone 157 | const operationName = 158 | definition.operation === OperationTypeNode.QUERY 159 | ? "Query" 160 | : definition.operation === OperationTypeNode.SUBSCRIPTION 161 | ? "Subscription" 162 | : "Mutation"; 163 | 164 | if (!isRecord(response)) { 165 | return; 166 | } 167 | 168 | const cacheEntry = cache.getOrCreateEntry( 169 | Symbol.for(operationName), 170 | createEmptyCacheEntry(), 171 | ); 172 | return cacheSelectionSet( 173 | definition.selectionSet, 174 | response, 175 | cacheEntry as CacheEntry, 176 | [], 177 | ); 178 | } 179 | }); 180 | }; 181 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "@0no-co/graphql.web"; 2 | import { Future, Option, Result } from "@swan-io/boxed"; 3 | import { Request, badStatusToError, emptyToError } from "@swan-io/request"; 4 | import { ClientCache, SchemaConfig } from "./cache/cache"; 5 | import { optimizeQuery, readOperationFromCache } from "./cache/read"; 6 | import { writeOperationToCache } from "./cache/write"; 7 | import { 8 | ClientError, 9 | InvalidGraphQLResponseError, 10 | parseGraphQLError, 11 | } from "./errors"; 12 | import { 13 | addTypenames, 14 | getExecutableOperationName, 15 | inlineFragments, 16 | } from "./graphql/ast"; 17 | import { print } from "./graphql/print"; 18 | import { Connection, Edge, TypedDocumentNode } from "./types"; 19 | 20 | export type RequestConfig = { 21 | url: string; 22 | headers: Record; 23 | operationName: string; 24 | document: DocumentNode; 25 | variables: Record; 26 | credentials?: RequestCredentials; 27 | }; 28 | 29 | export type MakeRequest = ( 30 | config: RequestConfig, 31 | ) => Future>; 32 | 33 | export type ClientConfig = { 34 | url: string; 35 | headers?: Record; 36 | makeRequest?: MakeRequest; 37 | schemaConfig: SchemaConfig; 38 | }; 39 | 40 | const defaultMakeRequest: MakeRequest = ({ 41 | url, 42 | headers, 43 | operationName, 44 | credentials, 45 | document, 46 | variables, 47 | }: RequestConfig) => { 48 | return Request.make({ 49 | url, 50 | method: "POST", 51 | type: "json", 52 | headers, 53 | ...(credentials != undefined ? { credentials } : null), 54 | body: JSON.stringify({ 55 | operationName, 56 | query: print(document), 57 | variables, 58 | }), 59 | }) 60 | .mapOkToResult(badStatusToError) 61 | .mapOkToResult(emptyToError) 62 | .mapOkToResult((payload) => { 63 | if (payload != null && typeof payload === "object") { 64 | if ("errors" in payload && Array.isArray(payload.errors)) { 65 | return Result.Error(payload.errors.map(parseGraphQLError)); 66 | } 67 | if ("data" in payload && payload.data != null) { 68 | return Result.Ok(payload.data); 69 | } 70 | } 71 | return Result.Error(new InvalidGraphQLResponseError(payload)); 72 | }); 73 | }; 74 | 75 | type ConnectionUpdate = [ 76 | Connection, 77 | { prepend: Edge[] } | { append: Edge[] } | { remove: string[] }, 78 | ]; 79 | 80 | const prepend = ( 81 | connection: Connection, 82 | edges: Edge[], 83 | ): ConnectionUpdate => { 84 | return [connection, { prepend: edges }]; 85 | }; 86 | 87 | const append = ( 88 | connection: Connection, 89 | edges: Edge[], 90 | ): ConnectionUpdate => { 91 | return [connection, { append: edges }]; 92 | }; 93 | 94 | const remove = ( 95 | connection: Connection, 96 | ids: string[], 97 | ): ConnectionUpdate => { 98 | return [connection, { remove: ids }]; 99 | }; 100 | 101 | export type GetConnectionUpdate = (config: { 102 | data: Data; 103 | variables: Variables; 104 | prepend: ( 105 | connection: Connection, 106 | edges: Edge[], 107 | ) => ConnectionUpdate; 108 | append: ( 109 | connection: Connection, 110 | edges: Edge[], 111 | ) => ConnectionUpdate; 112 | remove: (connection: Connection, ids: string[]) => ConnectionUpdate; 113 | }) => Option>; 114 | 115 | export type RequestOverrides = Partial< 116 | Pick 117 | >; 118 | 119 | type RequestOptions = { 120 | optimize?: boolean; 121 | normalize?: boolean; 122 | connectionUpdates?: GetConnectionUpdate[] | undefined; 123 | overrides?: RequestOverrides | undefined; 124 | }; 125 | 126 | export class Client { 127 | url: string; 128 | headers: Record; 129 | cache: ClientCache; 130 | schemaConfig: SchemaConfig; 131 | makeRequest: MakeRequest; 132 | 133 | subscribers: Set<() => void>; 134 | 135 | transformedDocuments: Map; 136 | transformedDocumentsForRequest: Map; 137 | 138 | constructor(config: ClientConfig) { 139 | this.url = config.url; 140 | 141 | this.headers = { 142 | Accept: "application/json", 143 | "Content-Type": "application/json", 144 | ...config.headers, 145 | }; 146 | 147 | this.schemaConfig = config.schemaConfig; 148 | this.cache = new ClientCache(config.schemaConfig); 149 | this.makeRequest = config.makeRequest ?? defaultMakeRequest; 150 | this.subscribers = new Set(); 151 | this.transformedDocuments = new Map(); 152 | this.transformedDocumentsForRequest = new Map(); 153 | } 154 | 155 | getTransformedDocument(document: DocumentNode) { 156 | if (this.transformedDocuments.has(document)) { 157 | return this.transformedDocuments.get(document) as DocumentNode; 158 | } else { 159 | const transformedDocument = inlineFragments(addTypenames(document)); 160 | this.transformedDocuments.set(document, transformedDocument); 161 | return transformedDocument; 162 | } 163 | } 164 | 165 | getTransformedDocumentsForRequest(document: DocumentNode) { 166 | if (this.transformedDocumentsForRequest.has(document)) { 167 | return this.transformedDocumentsForRequest.get(document) as DocumentNode; 168 | } else { 169 | const transformedDocument = addTypenames(document); 170 | this.transformedDocumentsForRequest.set(document, transformedDocument); 171 | return transformedDocument; 172 | } 173 | } 174 | 175 | subscribe(func: () => void) { 176 | this.subscribers.add(func); 177 | return () => this.subscribers.delete(func); 178 | } 179 | 180 | request( 181 | document: TypedDocumentNode, 182 | variables: NoInfer, 183 | { 184 | optimize = false, 185 | normalize = true, 186 | connectionUpdates, 187 | overrides, 188 | }: RequestOptions = {}, 189 | ): Future> { 190 | const transformedDocument = this.getTransformedDocument(document); 191 | const transformedDocumentsForRequest = 192 | this.getTransformedDocumentsForRequest(document); 193 | 194 | const operationName = 195 | getExecutableOperationName(transformedDocument).getOr("Untitled"); 196 | 197 | const variablesAsRecord = variables as Record; 198 | 199 | const possiblyOptimizedQuery = optimize 200 | ? optimizeQuery(this.cache, transformedDocument, variablesAsRecord).map( 201 | addTypenames, 202 | ) 203 | : Option.Some(transformedDocumentsForRequest); 204 | 205 | if (possiblyOptimizedQuery.isNone()) { 206 | const operationResult = readOperationFromCache( 207 | this.cache, 208 | transformedDocument, 209 | variablesAsRecord, 210 | ); 211 | if (operationResult.isSome()) { 212 | return Future.value(operationResult.get() as Result); 213 | } 214 | } 215 | 216 | return this.makeRequest({ 217 | url: this.url, 218 | operationName, 219 | document: possiblyOptimizedQuery.getOr(transformedDocumentsForRequest), 220 | variables: variablesAsRecord, 221 | ...overrides, 222 | headers: { 223 | ...this.headers, 224 | ...(overrides != null ? overrides.headers : null), 225 | }, 226 | }) 227 | .mapOk((data) => data as Data) 228 | .tapOk((data) => { 229 | if (normalize) { 230 | writeOperationToCache( 231 | this.cache, 232 | transformedDocument, 233 | data, 234 | variablesAsRecord, 235 | ); 236 | } 237 | }) 238 | .tapOk((data) => { 239 | if (connectionUpdates !== undefined) { 240 | connectionUpdates.forEach((getUpdate) => { 241 | getUpdate({ data, variables, prepend, append, remove }).map( 242 | ([connection, update]) => { 243 | this.cache.updateConnection(connection, update); 244 | }, 245 | ); 246 | }); 247 | } 248 | }) 249 | .tap((result) => { 250 | this.cache.setOperationInCache( 251 | transformedDocument, 252 | variablesAsRecord, 253 | result, 254 | ); 255 | this.subscribers.forEach((func) => { 256 | func(); 257 | }); 258 | }); 259 | } 260 | 261 | readFromCache( 262 | document: TypedDocumentNode, 263 | variables: NoInfer, 264 | { normalize = true }: { normalize?: boolean }, 265 | ) { 266 | const variablesAsRecord = variables as Record; 267 | const transformedDocument = this.getTransformedDocument(document); 268 | const cached = this.cache.getOperationFromCache( 269 | transformedDocument, 270 | variablesAsRecord, 271 | ); 272 | 273 | if (cached.isSome() && cached.get().isError()) { 274 | return cached; 275 | } 276 | if (cached.isSome() && cached.get().isOk() && normalize === false) { 277 | return cached; 278 | } 279 | return readOperationFromCache( 280 | this.cache, 281 | transformedDocument, 282 | variablesAsRecord, 283 | ); 284 | } 285 | 286 | query( 287 | document: TypedDocumentNode, 288 | variables: NoInfer, 289 | requestOptions?: RequestOptions, 290 | ) { 291 | return this.request(document, variables, requestOptions); 292 | } 293 | 294 | commitMutation( 295 | document: TypedDocumentNode, 296 | variables: NoInfer, 297 | requestOptions?: RequestOptions, 298 | ) { 299 | return this.request(document, variables, requestOptions); 300 | } 301 | 302 | purge() { 303 | this.cache = new ClientCache(this.schemaConfig); 304 | this.subscribers.forEach((func) => { 305 | func(); 306 | }); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode, GraphQLError } from "@0no-co/graphql.web"; 2 | import { 3 | BadStatusError, 4 | EmptyResponseError, 5 | NetworkError, 6 | TimeoutError, 7 | } from "@swan-io/request"; 8 | 9 | export type ClientError = 10 | | NetworkError 11 | | TimeoutError 12 | | BadStatusError 13 | | EmptyResponseError 14 | | InvalidGraphQLResponseError 15 | | GraphQLError[]; 16 | 17 | export class InvalidGraphQLResponseError extends Error { 18 | response: unknown; 19 | constructor(response: unknown) { 20 | super("Received an invalid GraphQL response"); 21 | Object.setPrototypeOf(this, InvalidGraphQLResponseError.prototype); 22 | this.name = "InvalidGraphQLResponseError"; 23 | this.response = response; 24 | } 25 | } 26 | 27 | export const parseGraphQLError = (error: unknown): GraphQLError => { 28 | if ( 29 | typeof error === "object" && 30 | error != null && 31 | "message" in error && 32 | typeof error.message === "string" 33 | ) { 34 | const graphqlError = error as Record & { 35 | message: string; 36 | }; 37 | const originalError = 38 | "error" in error && 39 | typeof error.error === "object" && 40 | error.error != null && 41 | "message" in error.error && 42 | typeof error.error.message === "string" 43 | ? new Error(error.error.message) 44 | : undefined; 45 | return new GraphQLError( 46 | graphqlError.message, 47 | graphqlError.nodes as ReadonlyArray | ASTNode | null | undefined, 48 | graphqlError.source, 49 | graphqlError.positions as readonly number[] | null | undefined, 50 | graphqlError.path as readonly (string | number)[] | null | undefined, 51 | originalError, 52 | graphqlError.extensions as 53 | | { 54 | [extension: string]: unknown; 55 | } 56 | | null 57 | | undefined, 58 | ); 59 | } 60 | return new GraphQLError(JSON.stringify(error)); 61 | }; 62 | 63 | type Flat = T extends (infer X)[] ? X : T; 64 | 65 | export const ClientError = { 66 | toArray: (clientError: E): Flat[] => { 67 | return Array.isArray(clientError) 68 | ? (clientError as Flat[]) 69 | : ([clientError] as Flat[]); 70 | }, 71 | forEach: ( 72 | clientError: E, 73 | func: (error: Flat, index?: number) => void, 74 | ) => { 75 | ClientError.toArray(clientError).forEach(func); 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/graphql/ast.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ASTNode, 3 | DirectiveNode, 4 | DocumentNode, 5 | FieldNode, 6 | FragmentDefinitionNode, 7 | InlineFragmentNode, 8 | Kind, 9 | OperationDefinitionNode, 10 | OperationTypeNode, 11 | SelectionNode, 12 | SelectionSetNode, 13 | ValueNode, 14 | visit, 15 | } from "@0no-co/graphql.web"; 16 | import { Array, Option } from "@swan-io/boxed"; 17 | 18 | /** 19 | * Returns a Set with all keys selected within the direct selection sets 20 | * of a given `FieldNode` or `OperationDefinitionNode`. 21 | * 22 | * { user { id, firstName, lastName } } 23 | * => Set{"id", "firstName", "lastName"} 24 | * 25 | * @param fieldNode FieldNode | OperationDefinitionNode 26 | * @returns selectedKeys Set 27 | */ 28 | export const getSelectedKeys = ( 29 | fieldNode: FieldNode | OperationDefinitionNode, 30 | variables: Record, 31 | ): Set => { 32 | const selectedKeys = new Set(); 33 | 34 | const traverse = (selections: SelectionSetNode) => { 35 | // We only need to care about FieldNode & InlineFragment node 36 | // as we inline all fragments in the query 37 | selections.selections.forEach((selection) => { 38 | if (selection.kind === Kind.FIELD) { 39 | const fieldNameWithArguments = getFieldNameWithArguments( 40 | selection, 41 | variables, 42 | ); 43 | selectedKeys.add(fieldNameWithArguments); 44 | } else if (selection.kind === Kind.INLINE_FRAGMENT) { 45 | traverse(selection.selectionSet); 46 | } 47 | }); 48 | }; 49 | 50 | if (fieldNode.selectionSet) { 51 | traverse(fieldNode.selectionSet); 52 | } 53 | 54 | return selectedKeys; 55 | }; 56 | 57 | /** 58 | * Serializes the field name and arguments as a symbol. 59 | * 60 | * { user {id} } 61 | * => Symbol(`user`) 62 | * 63 | * { user(id: "1") {id} } 64 | * => Symbol(`user({"id":"1"})`) 65 | * 66 | * { user(id: $id) {id} } with variables `{"id": "2"}` 67 | * => Symbol(`user({"id":"2"})`) 68 | * 69 | * @param fieldNode 70 | * @param variables The variables of the GraphQL operation 71 | * @returns symbol 72 | */ 73 | export const getFieldNameWithArguments = ( 74 | fieldNode: FieldNode, 75 | variables: Record, 76 | ): symbol => { 77 | const fieldName = getFieldName(fieldNode); 78 | const args = extractArguments(fieldNode, variables); 79 | if (Object.keys(args).length === 0) { 80 | return Symbol.for(fieldName); 81 | } 82 | return Symbol.for(`${fieldName}(${JSON.stringify(args)})`); 83 | }; 84 | 85 | /** 86 | * Returns a record representation of the arguments passed to a given field 87 | * 88 | * @param fieldNode 89 | * @param variables 90 | * @returns Record 91 | */ 92 | export const extractArguments = ( 93 | fieldNode: FieldNode, 94 | variables: Record, 95 | ): Record => { 96 | const args = fieldNode.arguments ?? []; 97 | return Object.fromEntries( 98 | args.map(({ name: { value: name }, value }) => [ 99 | name, 100 | extractValue(value, variables), 101 | ]), 102 | ); 103 | }; 104 | 105 | /** 106 | * Resolves and serializes a GraphQL value 107 | * 108 | * @param valueNode: ValueNode 109 | * @param variables: Record 110 | * @returns Record 111 | */ 112 | const extractValue = ( 113 | valueNode: ValueNode, 114 | variables: Record, 115 | ): unknown => { 116 | switch (valueNode.kind) { 117 | case Kind.NULL: 118 | return null; 119 | case Kind.INT: 120 | case Kind.FLOAT: 121 | case Kind.STRING: 122 | case Kind.BOOLEAN: 123 | case Kind.ENUM: 124 | return valueNode.value; 125 | case Kind.LIST: 126 | return valueNode.values.map((value) => extractValue(value, variables)); 127 | case Kind.OBJECT: 128 | return Object.fromEntries( 129 | valueNode.fields.map(({ name: { value: name }, value }) => [ 130 | name, 131 | extractValue(value, variables), 132 | ]), 133 | ); 134 | case Kind.VARIABLE: 135 | return variables[valueNode.name.value]; 136 | default: 137 | return null; 138 | } 139 | }; 140 | 141 | /** 142 | * Gets the field name in the response payload from its AST definition 143 | * 144 | * @param fieldNode 145 | * @returns field name 146 | */ 147 | export const getFieldName = (fieldNode: FieldNode) => { 148 | return fieldNode.alias ? fieldNode.alias.value : fieldNode.name.value; 149 | }; 150 | 151 | /** 152 | * Simplifies the query for internal processing by inlining all fragments. 153 | * 154 | * @param documentNode 155 | * @returns documentNode 156 | */ 157 | export const inlineFragments = (documentNode: DocumentNode): DocumentNode => { 158 | const fragmentMap: { [fragmentName: string]: FragmentDefinitionNode } = {}; 159 | 160 | // Populate the fragment map 161 | visit(documentNode, { 162 | [Kind.FRAGMENT_DEFINITION](node: FragmentDefinitionNode) { 163 | fragmentMap[node.name.value] = node; 164 | }, 165 | }); 166 | 167 | const inline = (node: ASTNode): unknown => { 168 | if (node.kind === Kind.FRAGMENT_SPREAD) { 169 | const fragmentName = node.name.value; 170 | const fragmentNode = fragmentMap[fragmentName]; 171 | if (!fragmentNode) { 172 | throw new Error(`Fragment "${fragmentName}" is not defined.`); 173 | } 174 | const nextNode: InlineFragmentNode = { 175 | kind: Kind.INLINE_FRAGMENT, 176 | typeCondition: fragmentNode.typeCondition, 177 | selectionSet: fragmentNode.selectionSet, 178 | }; 179 | return nextNode; 180 | } 181 | 182 | if (node.kind === Kind.SELECTION_SET) { 183 | return { 184 | ...node, 185 | selections: node.selections.map((selection: SelectionNode) => 186 | inline(selection), 187 | ), 188 | }; 189 | } 190 | 191 | if ("selectionSet" in node && node.selectionSet != null) { 192 | return { 193 | ...node, 194 | selectionSet: inline(node.selectionSet), 195 | }; 196 | } 197 | 198 | return node; 199 | }; 200 | 201 | return visit(documentNode, { 202 | [Kind.FRAGMENT_DEFINITION]: () => null, 203 | enter: inline, 204 | }); 205 | }; 206 | 207 | const TYPENAME_NODE: FieldNode = { 208 | kind: Kind.FIELD, 209 | name: { 210 | kind: Kind.NAME, 211 | value: "__typename", 212 | }, 213 | }; 214 | 215 | /** 216 | * Adds `__typename` to all selection sets in the document 217 | * 218 | * @param documentNode 219 | * @returns documentNode 220 | */ 221 | export const addTypenames = (documentNode: DocumentNode): DocumentNode => { 222 | return visit(documentNode, { 223 | [Kind.SELECTION_SET]: (selectionSet): SelectionSetNode => { 224 | if ( 225 | selectionSet.selections.find( 226 | (selection) => 227 | selection.kind === Kind.FIELD && 228 | selection.name.value === "__typename", 229 | ) 230 | ) { 231 | return selectionSet; 232 | } else { 233 | return { 234 | ...selectionSet, 235 | selections: [TYPENAME_NODE, ...selectionSet.selections], 236 | }; 237 | } 238 | }, 239 | }); 240 | }; 241 | 242 | export const getExecutableOperationName = (document: DocumentNode) => { 243 | return Array.findMap(document.definitions, (definition) => { 244 | if (definition.kind === Kind.OPERATION_DEFINITION) { 245 | return Option.fromNullable(definition.name).map((name) => name.value); 246 | } else { 247 | return Option.None(); 248 | } 249 | }); 250 | }; 251 | 252 | const getIdFieldNode = (selection: SelectionNode): Option => { 253 | switch (selection.kind) { 254 | case Kind.FIELD: 255 | return selection.name.value === "id" 256 | ? Option.Some(selection) 257 | : Option.None(); 258 | case Kind.INLINE_FRAGMENT: 259 | return Array.findMap(selection.selectionSet.selections, getIdFieldNode); 260 | default: 261 | return Option.None(); 262 | } 263 | }; 264 | 265 | export const addIdIfPreviousSelected = ( 266 | oldSelectionSet: SelectionSetNode, 267 | newSelectionSet: SelectionSetNode, 268 | ): SelectionSetNode => { 269 | const idSelection = Array.findMap(oldSelectionSet.selections, getIdFieldNode); 270 | const idSelectionInNew = Array.findMap( 271 | newSelectionSet.selections, 272 | getIdFieldNode, 273 | ); 274 | 275 | if (idSelectionInNew.isSome()) { 276 | return newSelectionSet; 277 | } 278 | 279 | return idSelection 280 | .map((selection) => ({ 281 | ...newSelectionSet, 282 | selections: [ 283 | selection, 284 | ...newSelectionSet.selections, 285 | ] as readonly SelectionNode[], 286 | })) 287 | .getOr(newSelectionSet); 288 | }; 289 | 290 | export const isExcluded = ( 291 | fieldNode: FieldNode, 292 | variables: Record, 293 | ) => { 294 | if (!Array.isArray(fieldNode.directives)) { 295 | return false; 296 | } 297 | 298 | return fieldNode.directives.some( 299 | (directive: DirectiveNode) => 300 | directive.name.value === "include" && 301 | directive.arguments != null && 302 | directive.arguments.some((arg) => { 303 | return ( 304 | arg.name.value === "if" && 305 | extractValue(arg.value, variables) === false 306 | ); 307 | }), 308 | ); 309 | }; 310 | 311 | export const getCacheKeyFromOperationNode = ( 312 | operationNode: OperationDefinitionNode, 313 | ): Option => { 314 | switch (operationNode.operation) { 315 | case OperationTypeNode.QUERY: 316 | return Option.Some(Symbol.for("Query")); 317 | case OperationTypeNode.SUBSCRIPTION: 318 | return Option.Some(Symbol.for("Subscription")); 319 | default: 320 | return Option.None(); 321 | } 322 | }; 323 | -------------------------------------------------------------------------------- /src/graphql/print.ts: -------------------------------------------------------------------------------- 1 | import { ASTNode } from "@0no-co/graphql.web"; 2 | 3 | export const printString = (string: string) => { 4 | return JSON.stringify(string); 5 | }; 6 | 7 | export const printBlockString = (string: string) => { 8 | return '"""' + string.replace(/"""/g, '\\"""') + '"""'; 9 | }; 10 | 11 | const hasItems = ( 12 | array: ReadonlyArray | undefined | null, 13 | ): array is ReadonlyArray => Boolean(array && array.length); 14 | 15 | const MAX_LINE_LENGTH = 80; 16 | 17 | const nodes: { 18 | [NodeT in ASTNode as NodeT["kind"]]?: (node: NodeT) => string; 19 | } = { 20 | OperationDefinition(node) { 21 | if ( 22 | node.operation === "query" && 23 | !node.name && 24 | !hasItems(node.variableDefinitions) && 25 | !hasItems(node.directives) 26 | ) { 27 | return nodes.SelectionSet!(node.selectionSet); 28 | } 29 | let out: string = node.operation; 30 | if (node.name) out += " " + node.name.value; 31 | if (hasItems(node.variableDefinitions)) { 32 | if (!node.name) out += " "; 33 | out += 34 | "(" + 35 | node.variableDefinitions.map(nodes.VariableDefinition!).join(", ") + 36 | ")"; 37 | } 38 | if (hasItems(node.directives)) 39 | out += " " + node.directives.map(nodes.Directive!).join(" "); 40 | return out + " " + nodes.SelectionSet!(node.selectionSet); 41 | }, 42 | VariableDefinition(node) { 43 | let out = nodes.Variable!(node.variable) + ": " + print(node.type); 44 | if (node.defaultValue) out += " = " + print(node.defaultValue); 45 | if (hasItems(node.directives)) 46 | out += " " + node.directives.map(nodes.Directive!).join(" "); 47 | return out; 48 | }, 49 | Field(node) { 50 | let out = (node.alias ? node.alias.value + ": " : "") + node.name.value; 51 | if (hasItems(node.arguments)) { 52 | const args = node.arguments.map(nodes.Argument!); 53 | const argsLine = out + "(" + args.join(", ") + ")"; 54 | out = 55 | argsLine.length > MAX_LINE_LENGTH 56 | ? out + "(" + args.join(" ") + ")" 57 | : argsLine; 58 | } 59 | if (hasItems(node.directives)) 60 | out += " " + node.directives.map(nodes.Directive!).join(" "); 61 | return node.selectionSet 62 | ? out + " " + nodes.SelectionSet!(node.selectionSet) 63 | : out; 64 | }, 65 | StringValue(node) { 66 | return node.block ? printBlockString(node.value) : printString(node.value); 67 | }, 68 | BooleanValue(node) { 69 | return String(node.value); 70 | }, 71 | NullValue() { 72 | return "null"; 73 | }, 74 | IntValue(node) { 75 | return node.value; 76 | }, 77 | FloatValue(node) { 78 | return node.value; 79 | }, 80 | EnumValue(node) { 81 | return node.value; 82 | }, 83 | Name(node) { 84 | return node.value; 85 | }, 86 | Variable(node) { 87 | return "$" + node.name.value; 88 | }, 89 | ListValue(node) { 90 | return "[" + node.values.map(print).join(", ") + "]"; 91 | }, 92 | ObjectValue(node) { 93 | return "{" + node.fields.map(nodes.ObjectField!).join(", ") + "}"; 94 | }, 95 | ObjectField(node) { 96 | return node.name.value + ": " + print(node.value); 97 | }, 98 | Document(node) { 99 | return hasItems(node.definitions) 100 | ? node.definitions.map(print).join(" ") 101 | : ""; 102 | }, 103 | SelectionSet(node) { 104 | return "{" + node.selections.map(print).join(" ") + "}"; 105 | }, 106 | Argument(node) { 107 | return node.name.value + ": " + print(node.value); 108 | }, 109 | FragmentSpread(node) { 110 | let out = "..." + node.name.value; 111 | if (hasItems(node.directives)) 112 | out += " " + node.directives.map(nodes.Directive!).join(" "); 113 | return out; 114 | }, 115 | InlineFragment(node) { 116 | let out = "..."; 117 | if (node.typeCondition) out += " on " + node.typeCondition.name.value; 118 | if (hasItems(node.directives)) 119 | out += " " + node.directives.map(nodes.Directive!).join(" "); 120 | return out + " " + print(node.selectionSet); 121 | }, 122 | FragmentDefinition(node) { 123 | let out = "fragment " + node.name.value; 124 | out += " on " + node.typeCondition.name.value; 125 | if (hasItems(node.directives)) 126 | out += " " + node.directives.map(nodes.Directive!).join(" "); 127 | return out + " " + print(node.selectionSet); 128 | }, 129 | Directive(node) { 130 | let out = "@" + node.name.value; 131 | if (hasItems(node.arguments)) 132 | out += "(" + node.arguments.map(nodes.Argument!).join(", ") + ")"; 133 | return out; 134 | }, 135 | NamedType(node) { 136 | return node.name.value; 137 | }, 138 | ListType(node) { 139 | return "[" + print(node.type) + "]"; 140 | }, 141 | NonNullType(node) { 142 | return print(node.type) + "!"; 143 | }, 144 | }; 145 | 146 | export const print = (node: ASTNode): string => { 147 | // @ts-expect-error It's safe 148 | return typeof nodes[node.kind] == "function" ? nodes[node.kind](node) : ""; 149 | }; 150 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client"; 2 | export * from "./errors"; 3 | export { print } from "./graphql/print"; 4 | export * from "./react/ClientContext"; 5 | export * from "./react/useDeferredQuery"; 6 | export * from "./react/useMutation"; 7 | export * from "./react/usePagination"; 8 | export * from "./react/useQuery"; 9 | export * from "./types"; 10 | -------------------------------------------------------------------------------- /src/json/cacheEntryKey.ts: -------------------------------------------------------------------------------- 1 | import { Option } from "@swan-io/boxed"; 2 | 3 | const OPERATION_TYPES = new Set(["Query", "Mutation", "Subscription"]); 4 | 5 | export const getCacheEntryKey = (json: unknown): Option => { 6 | if (typeof json === "object" && json != null) { 7 | if ("__typename" in json && typeof json.__typename === "string") { 8 | const typename = json.__typename; 9 | if (OPERATION_TYPES.has(typename)) { 10 | return Option.Some(Symbol.for(typename)); 11 | } 12 | if ("id" in json && typeof json.id === "string") { 13 | return Option.Some(Symbol.for(`${typename}<${json.id}>`)); 14 | } 15 | } 16 | } 17 | return Option.None(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/json/getTypename.ts: -------------------------------------------------------------------------------- 1 | export const getTypename = (json: unknown): string | undefined => { 2 | if (typeof json === "object" && json != null) { 3 | if (Array.isArray(json)) { 4 | return getTypename(json[0]); 5 | } 6 | if ("__typename" in json && typeof json.__typename === "string") { 7 | return json.__typename; 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/react/ClientContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Client } from "../client"; 3 | 4 | export const ClientContext = createContext( 5 | new Client({ url: "/graphql", schemaConfig: { interfaceToTypes: {} } }), 6 | ); 7 | -------------------------------------------------------------------------------- /src/react/useDeferredQuery.ts: -------------------------------------------------------------------------------- 1 | import { AsyncData, Deferred, Future, Option, Result } from "@swan-io/boxed"; 2 | import { 3 | useCallback, 4 | useContext, 5 | useMemo, 6 | useRef, 7 | useState, 8 | useSyncExternalStore, 9 | } from "react"; 10 | import { RequestOverrides } from "../client"; 11 | import { ClientError } from "../errors"; 12 | import { TypedDocumentNode } from "../types"; 13 | import { deepEqual } from "../utils"; 14 | import { ClientContext } from "./ClientContext"; 15 | 16 | export type DeferredQueryConfig = { 17 | optimize?: boolean; 18 | normalize?: boolean; 19 | debounce?: number; 20 | }; 21 | 22 | export type DeferredQueryExtraConfig = { overrides?: RequestOverrides }; 23 | 24 | export type DeferredQuery = readonly [ 25 | AsyncData>, 26 | { 27 | query: ( 28 | variables: Variables, 29 | config?: DeferredQueryExtraConfig, 30 | ) => Future>; 31 | reset: () => void; 32 | }, 33 | ]; 34 | 35 | export const useDeferredQuery = ( 36 | query: TypedDocumentNode, 37 | { optimize = false, normalize = true, debounce }: DeferredQueryConfig = {}, 38 | ): DeferredQuery => { 39 | const client = useContext(ClientContext); 40 | 41 | // Query should never change 42 | const [stableQuery] = useState>(query); 43 | 44 | // Only break variables reference equality if not deeply equal 45 | const [stableVariables, setStableVariables] = useState>( 46 | Option.None(), 47 | ); 48 | 49 | const timeoutRef = useRef(undefined); 50 | 51 | // Get data from cache 52 | const getSnapshot = useCallback(() => { 53 | return stableVariables.flatMap((variables) => 54 | client.readFromCache(stableQuery, variables, { normalize }), 55 | ); 56 | }, [client, stableQuery, stableVariables, normalize]); 57 | 58 | const data = useSyncExternalStore( 59 | (func) => client.subscribe(func), 60 | getSnapshot, 61 | ); 62 | 63 | const asyncData = useMemo(() => { 64 | return data 65 | .map((value) => AsyncData.Done(value as Result)) 66 | .getOr(AsyncData.NotAsked()); 67 | }, [data]); 68 | 69 | const runQuery = useCallback( 70 | (variables: Variables, { overrides }: DeferredQueryExtraConfig = {}) => { 71 | setStableVariables((stableVariables) => 72 | stableVariables.match({ 73 | None: () => Option.Some(variables), 74 | Some: (prevVariables) => 75 | deepEqual(prevVariables, variables) 76 | ? stableVariables 77 | : Option.Some(variables), 78 | }), 79 | ); 80 | return client 81 | .request(stableQuery, variables, { optimize, overrides }) 82 | .tap(() => setIsQuerying(false)); 83 | }, 84 | [client, optimize, stableQuery], 85 | ); 86 | 87 | const [isQuerying, setIsQuerying] = useState(false); 88 | const exposedRunQuery = useCallback( 89 | (variables: Variables, config?: DeferredQueryExtraConfig) => { 90 | if (timeoutRef.current !== undefined) { 91 | clearTimeout(timeoutRef.current); 92 | } 93 | setIsQuerying(true); 94 | if (debounce === undefined) { 95 | return runQuery(variables, config); 96 | } else { 97 | const [future, resolve] = Deferred.make>(); 98 | timeoutRef.current = window.setTimeout( 99 | (variables: Variables) => { 100 | runQuery(variables, config).tap(resolve); 101 | }, 102 | debounce, 103 | variables, 104 | ); 105 | return future; 106 | } 107 | }, 108 | [runQuery, debounce], 109 | ); 110 | 111 | const reset = useCallback(() => { 112 | setIsQuerying(false); 113 | setStableVariables(Option.None()); 114 | }, []); 115 | 116 | const asyncDataToExpose = isQuerying ? AsyncData.Loading() : asyncData; 117 | 118 | return [asyncDataToExpose, { query: exposedRunQuery, reset }]; 119 | }; 120 | -------------------------------------------------------------------------------- /src/react/useMutation.ts: -------------------------------------------------------------------------------- 1 | import { AsyncData, Future, Result } from "@swan-io/boxed"; 2 | import { useCallback, useContext, useRef, useState } from "react"; 3 | import { GetConnectionUpdate, RequestOverrides } from "../client"; 4 | import { ClientError } from "../errors"; 5 | import { TypedDocumentNode } from "../types"; 6 | import { ClientContext } from "./ClientContext"; 7 | 8 | export type MutationExtraConfig = { overrides?: RequestOverrides }; 9 | 10 | export type Mutation = readonly [ 11 | ( 12 | variables: Variables, 13 | config?: MutationExtraConfig, 14 | ) => Future>, 15 | AsyncData>, 16 | { reset: () => void }, 17 | ]; 18 | 19 | export type MutationConfig = { 20 | connectionUpdates?: GetConnectionUpdate[] | undefined; 21 | }; 22 | 23 | export const useMutation = ( 24 | mutation: TypedDocumentNode, 25 | config: MutationConfig = {}, 26 | ): Mutation => { 27 | const client = useContext(ClientContext); 28 | 29 | const connectionUpdatesRef = useRef(config?.connectionUpdates); 30 | connectionUpdatesRef.current = config?.connectionUpdates; 31 | 32 | const [stableMutation] = 33 | useState>(mutation); 34 | 35 | const [data, setData] = useState>>( 36 | AsyncData.NotAsked(), 37 | ); 38 | 39 | const commitMutation = useCallback( 40 | (variables: Variables, { overrides }: MutationExtraConfig = {}) => { 41 | setData(AsyncData.Loading()); 42 | return client 43 | .commitMutation(stableMutation, variables, { 44 | connectionUpdates: connectionUpdatesRef.current, 45 | overrides, 46 | }) 47 | .tap((result) => setData(AsyncData.Done(result))); 48 | }, 49 | [client, stableMutation], 50 | ); 51 | 52 | const reset = useCallback(() => { 53 | setData(AsyncData.NotAsked()); 54 | }, []); 55 | 56 | return [commitMutation, data, { reset }]; 57 | }; 58 | -------------------------------------------------------------------------------- /src/react/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { Array, AsyncData, Option, Result } from "@swan-io/boxed"; 2 | import { useCallback, useContext, useRef, useSyncExternalStore } from "react"; 3 | import { Connection } from "../types"; 4 | import { CONNECTION_REF, deepEqual } from "../utils"; 5 | import { ClientContext } from "./ClientContext"; 6 | 7 | type mode = "before" | "after"; 8 | 9 | const mergeConnection = >( 10 | previous: T, 11 | next: T, 12 | mode: mode, 13 | ): T => { 14 | if (next == null) { 15 | return next; 16 | } 17 | if (previous == null) { 18 | return next; 19 | } 20 | 21 | if ( 22 | mode === "after" && 23 | next.pageInfo.endCursor === previous.pageInfo.endCursor 24 | ) { 25 | return previous; 26 | } 27 | if ( 28 | mode === "before" && 29 | next.pageInfo.startCursor === previous.pageInfo.startCursor 30 | ) { 31 | return previous; 32 | } 33 | 34 | return { 35 | ...next, 36 | edges: 37 | mode === "before" 38 | ? [...(next.edges ?? []), ...(previous.edges ?? [])] 39 | : [...(previous.edges ?? []), ...(next.edges ?? [])], 40 | pageInfo: 41 | mode === "before" 42 | ? { 43 | hasPreviousPage: next.pageInfo.hasPreviousPage, 44 | startCursor: next.pageInfo.startCursor, 45 | hasNextPage: previous.pageInfo.hasNextPage, 46 | endCursor: previous.pageInfo.endCursor, 47 | } 48 | : { 49 | hasPreviousPage: previous.pageInfo.hasPreviousPage, 50 | startCursor: previous.pageInfo.startCursor, 51 | hasNextPage: next.pageInfo.hasNextPage, 52 | endCursor: next.pageInfo.endCursor, 53 | }, 54 | }; 55 | }; 56 | 57 | const createPaginationHook = (direction: mode) => { 58 | return >(connection: T): T => { 59 | const client = useContext(ClientContext); 60 | const connectionRefs = useRef([]); 61 | const lastReturnedValueRef = useRef>(Option.None()); 62 | 63 | if (connection == null) { 64 | connectionRefs.current = []; 65 | } else { 66 | if ( 67 | CONNECTION_REF in connection && 68 | typeof connection[CONNECTION_REF] === "number" && 69 | !connectionRefs.current.includes(connection[CONNECTION_REF]) 70 | ) { 71 | connectionRefs.current.push(connection[CONNECTION_REF]); 72 | } 73 | } 74 | 75 | // Get fresh data from cache 76 | const getSnapshot = useCallback(() => { 77 | const value = Option.all( 78 | Array.filterMap(connectionRefs.current, (id) => 79 | Option.fromNullable(client.cache.connectionCache.get(id)), 80 | ).flatMap((info) => 81 | client 82 | .readFromCache(info.document, info.variables, {}) 83 | .map((query) => 84 | query.map((query) => ({ query, pathInQuery: info.pathInQuery })), 85 | ), 86 | ), 87 | ) 88 | .map(Result.all) 89 | .flatMap((x) => x.toOption()) 90 | .map((queries) => 91 | queries.map(({ query, pathInQuery }) => { 92 | return pathInQuery.reduce( 93 | (acc, key) => 94 | acc != null && typeof acc === "object" && key in acc 95 | ? // @ts-expect-error indexable 96 | acc[key] 97 | : null, 98 | query, 99 | ); 100 | }), 101 | ) as Option; 102 | if (!deepEqual(value, lastReturnedValueRef.current)) { 103 | lastReturnedValueRef.current = value; 104 | return value; 105 | } else { 106 | return lastReturnedValueRef.current; 107 | } 108 | }, [client]); 109 | 110 | const data = useSyncExternalStore( 111 | (func) => client.subscribe(func), 112 | getSnapshot, 113 | ) as Option; 114 | 115 | return data 116 | .map(([first, ...rest]) => 117 | rest.reduce((acc, item) => { 118 | return mergeConnection(acc, item, direction); 119 | }, first), 120 | ) 121 | .getOr(connection) as T; 122 | }; 123 | }; 124 | 125 | export const useForwardPagination = createPaginationHook("after"); 126 | 127 | export const useBackwardPagination = createPaginationHook("before"); 128 | 129 | export const useForwardAsyncDataPagination = < 130 | A, 131 | E, 132 | T extends AsyncData, E>>, 133 | >( 134 | connection: T, 135 | ): T => { 136 | const data = connection 137 | .toOption() 138 | .flatMap((result) => result.toOption()) 139 | .toNull(); 140 | const patchedData = useForwardPagination(data); 141 | return connection.mapOk(() => patchedData) as T; 142 | }; 143 | 144 | export const useBackwardAsyncDataPagination = < 145 | A, 146 | E, 147 | T extends AsyncData, E>>, 148 | >( 149 | connection: T, 150 | ): T => { 151 | const data = connection 152 | .toOption() 153 | .flatMap((result) => result.toOption()) 154 | .toNull(); 155 | const patchedData = useBackwardPagination(data); 156 | return connection.mapOk(() => patchedData) as T; 157 | }; 158 | -------------------------------------------------------------------------------- /src/react/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { AsyncData, Future, Result } from "@swan-io/boxed"; 2 | import { 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | useSyncExternalStore, 10 | } from "react"; 11 | import { RequestOverrides } from "../client"; 12 | import { ClientError } from "../errors"; 13 | import { TypedDocumentNode } from "../types"; 14 | import { deepEqual } from "../utils"; 15 | import { ClientContext } from "./ClientContext"; 16 | 17 | export type QueryConfig = { 18 | suspense?: boolean; 19 | optimize?: boolean; 20 | normalize?: boolean; 21 | overrides?: RequestOverrides; 22 | }; 23 | 24 | export type Query = readonly [ 25 | AsyncData>, 26 | { 27 | isLoading: boolean; 28 | reload: () => Future>; 29 | refresh: () => Future>; 30 | setVariables: (variables: Partial) => void; 31 | }, 32 | ]; 33 | 34 | const usePreviousValue = >(value: T): T => { 35 | const previousRef = useRef(value); 36 | 37 | useEffect(() => { 38 | if (value.isDone()) { 39 | previousRef.current = value; 40 | } 41 | if (value.isLoading() && previousRef.current.isNotAsked()) { 42 | previousRef.current = value; 43 | } 44 | }, [value]); 45 | 46 | return previousRef.current; 47 | }; 48 | 49 | export const useQuery = ( 50 | query: TypedDocumentNode, 51 | variables: NoInfer, 52 | { 53 | suspense = false, 54 | optimize = false, 55 | normalize = true, 56 | overrides, 57 | }: QueryConfig = {}, 58 | ): Query => { 59 | const client = useContext(ClientContext); 60 | 61 | // Query should never change 62 | const [stableQuery] = useState>(query); 63 | 64 | // Only break variables reference equality if not deeply equal 65 | const [stableVariables, setStableVariables] = useState< 66 | [Variables, Variables] 67 | >([variables, variables]); 68 | 69 | // Only break overrides reference equality if not deeply equal 70 | const [stableOverrides, setStableOverrides] = useState< 71 | RequestOverrides | undefined 72 | >(overrides); 73 | 74 | useEffect(() => { 75 | const [providedVariables] = stableVariables; 76 | if (!deepEqual(providedVariables, variables)) { 77 | setIsReloading(true); 78 | setStableVariables([variables, variables]); 79 | } 80 | }, [stableVariables, variables]); 81 | 82 | useEffect(() => { 83 | if (!deepEqual(stableOverrides, overrides)) { 84 | setIsReloading(true); 85 | setStableOverrides(overrides); 86 | } 87 | }, [stableOverrides, overrides]); 88 | 89 | // Get data from cache 90 | const getSnapshot = useCallback(() => { 91 | return client.readFromCache(stableQuery, stableVariables[1], { normalize }); 92 | }, [client, stableQuery, stableVariables, normalize]); 93 | 94 | const data = useSyncExternalStore( 95 | (func) => client.subscribe(func), 96 | getSnapshot, 97 | ); 98 | 99 | const asyncData = useMemo(() => { 100 | return data 101 | .map((value) => AsyncData.Done(value as Result)) 102 | .getOr(AsyncData.Loading()); 103 | }, [data]); 104 | 105 | const previousAsyncData = usePreviousValue(asyncData); 106 | 107 | const isSuspenseFirstFetch = useRef(true); 108 | 109 | useEffect(() => { 110 | if (suspense && isSuspenseFirstFetch.current) { 111 | isSuspenseFirstFetch.current = false; 112 | return; 113 | } 114 | const request = client 115 | .query(stableQuery, stableVariables[1], { 116 | optimize, 117 | overrides: stableOverrides, 118 | normalize, 119 | }) 120 | .tap(() => setIsReloading(false)); 121 | return () => request.cancel(); 122 | }, [ 123 | client, 124 | suspense, 125 | optimize, 126 | normalize, 127 | stableOverrides, 128 | stableQuery, 129 | stableVariables, 130 | ]); 131 | 132 | const [isRefreshing, setIsRefreshing] = useState(false); 133 | const refresh = useCallback(() => { 134 | setIsRefreshing(true); 135 | return client 136 | .query(stableQuery, stableVariables[1], { 137 | overrides: stableOverrides, 138 | normalize, 139 | }) 140 | .tap(() => setIsRefreshing(false)); 141 | }, [client, stableQuery, stableOverrides, stableVariables, normalize]); 142 | 143 | const [isReloading, setIsReloading] = useState(false); 144 | const reload = useCallback(() => { 145 | setIsReloading(true); 146 | setStableVariables(([stable]) => [stable, stable]); 147 | return client 148 | .query(stableQuery, stableVariables[0], { 149 | overrides: stableOverrides, 150 | normalize, 151 | }) 152 | .tap(() => setIsReloading(false)); 153 | }, [client, stableQuery, stableOverrides, stableVariables, normalize]); 154 | 155 | const isLoading = isRefreshing || isReloading || asyncData.isLoading(); 156 | const asyncDataToExpose = isReloading 157 | ? AsyncData.Loading() 158 | : isLoading 159 | ? previousAsyncData 160 | : asyncData; 161 | 162 | if ( 163 | suspense && 164 | isSuspenseFirstFetch.current && 165 | asyncDataToExpose.isLoading() 166 | ) { 167 | throw client 168 | .query(stableQuery, stableVariables[1], { optimize, normalize }) 169 | .toPromise(); 170 | } 171 | 172 | const setVariables = useCallback((variables: Partial) => { 173 | setStableVariables((prev) => { 174 | const [prevStable, prevFinal] = prev; 175 | const nextFinal = { ...prevFinal, ...variables }; 176 | if (!deepEqual(prevFinal, nextFinal)) { 177 | return [prevStable, nextFinal]; 178 | } else { 179 | return prev; 180 | } 181 | }); 182 | }, []); 183 | 184 | return [asyncDataToExpose, { isLoading, refresh, reload, setVariables }]; 185 | }; 186 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "@0no-co/graphql.web"; 2 | 3 | export interface DocumentTypeDecoration { 4 | /** 5 | * This type is used to ensure that the variables you pass in to the query are assignable to Variables 6 | * and that the Result is assignable to whatever you pass your result to. The method is never actually 7 | * implemented, but the type is valid because we list it as optional 8 | */ 9 | __apiType?: (variables: TVariables) => TResult; 10 | } 11 | 12 | export interface TypedDocumentNode< 13 | TResult = { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | [key: string]: any; 16 | }, 17 | TVariables = { 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | [key: string]: any; 20 | }, 21 | > extends DocumentNode, 22 | DocumentTypeDecoration {} 23 | 24 | export type Edge = { 25 | __typename?: string | null | undefined; 26 | cursor?: string | null | undefined; 27 | node?: T | null | undefined; 28 | }; 29 | 30 | export type Connection = 31 | | { 32 | edges?: (Edge | null | undefined)[] | null | undefined; 33 | pageInfo: { 34 | hasPreviousPage?: boolean | null | undefined; 35 | hasNextPage?: boolean | null | undefined; 36 | endCursor?: string | null | undefined; 37 | startCursor?: string | null | undefined; 38 | }; 39 | } 40 | | null 41 | | undefined; 42 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const REQUESTED_KEYS = Symbol.for("__requestedKeys"); 2 | 3 | export const CONNECTION_REF = "__connectionRef"; 4 | 5 | export const TYPENAME_KEY = Symbol.for("__typename"); 6 | export const EDGES_KEY = Symbol.for("edges"); 7 | export const NODE_KEY = Symbol.for("node"); 8 | 9 | export const containsAll = (a: Set, b: Set): boolean => { 10 | const keys = [...b.values()]; 11 | return keys.every((key) => a.has(key)); 12 | }; 13 | 14 | export const isRecord = ( 15 | value: unknown, 16 | ): value is Record => { 17 | return value != null && typeof value === "object"; 18 | }; 19 | 20 | export const hasOwnProperty = Object.prototype.hasOwnProperty; 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | export const deepEqual = (a: any, b: any): boolean => { 24 | if (Object.is(a, b)) { 25 | return true; 26 | } 27 | 28 | if ( 29 | typeof a !== "object" || 30 | a === null || 31 | typeof b !== "object" || 32 | b === null 33 | ) { 34 | return false; 35 | } 36 | 37 | const aKeys = Object.keys(a); 38 | const bKeys = Object.keys(b); 39 | 40 | if (aKeys.length !== bKeys.length) { 41 | return false; 42 | } 43 | 44 | for (const key of aKeys) { 45 | if (!hasOwnProperty.call(b, key) || !deepEqual(a[key], b[key])) { 46 | return false; 47 | } 48 | } 49 | 50 | return true; 51 | }; 52 | 53 | export const serializeVariables = (variables: Record) => { 54 | return JSON.stringify(variables); 55 | }; 56 | -------------------------------------------------------------------------------- /test/__snapshots__/cache.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Write & read in cache 1`] = ` 4 | Map { 5 | Symbol(Query) => { 6 | Symbol(__requestedKeys): Set { 7 | Symbol(__typename), 8 | Symbol(accountMembership({"id":"1"})), 9 | Symbol(accountMemberships({"first":"2"})), 10 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})), 11 | }, 12 | Symbol(__typename): "Query", 13 | Symbol(accountMembership({"id":"1"})): Symbol(AccountMembership), 14 | Symbol(accountMemberships({"first":"2"})): { 15 | "__connectionRef": 0, 16 | Symbol(__requestedKeys): Set { 17 | Symbol(__typename), 18 | Symbol(edges), 19 | }, 20 | Symbol(__typename): "AccountMembershipConnection", 21 | Symbol(edges): [ 22 | { 23 | Symbol(__requestedKeys): Set { 24 | Symbol(__typename), 25 | Symbol(node), 26 | }, 27 | Symbol(__typename): "AccountMembershipEdge", 28 | Symbol(node): Symbol(AccountMembership), 29 | }, 30 | { 31 | Symbol(__requestedKeys): Set { 32 | Symbol(__typename), 33 | Symbol(node), 34 | }, 35 | Symbol(__typename): "AccountMembershipEdge", 36 | Symbol(node): Symbol(AccountMembership), 37 | }, 38 | ], 39 | }, 40 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})): Symbol(SupportingDocumentCollection), 41 | }, 42 | Symbol(AccountMembership) => { 43 | Symbol(__requestedKeys): Set { 44 | Symbol(__typename), 45 | Symbol(id), 46 | Symbol(user), 47 | Symbol(account), 48 | Symbol(membershipUser), 49 | }, 50 | Symbol(__typename): "AccountMembership", 51 | Symbol(id): "account-membership-1", 52 | Symbol(user): Symbol(User), 53 | Symbol(account): { 54 | Symbol(__requestedKeys): Set { 55 | Symbol(__typename), 56 | Symbol(name), 57 | }, 58 | Symbol(__typename): "Account", 59 | Symbol(name): "First", 60 | }, 61 | Symbol(membershipUser): Symbol(User), 62 | }, 63 | Symbol(User) => { 64 | Symbol(__requestedKeys): Set { 65 | Symbol(__typename), 66 | Symbol(id), 67 | Symbol(firstName), 68 | Symbol(lastName), 69 | Symbol(identificationLevels), 70 | }, 71 | Symbol(__typename): "User", 72 | Symbol(id): "user-1", 73 | Symbol(firstName): "Matthias", 74 | Symbol(lastName): "Le Brun", 75 | Symbol(identificationLevels): null, 76 | }, 77 | Symbol(AccountMembership) => { 78 | Symbol(__requestedKeys): Set { 79 | Symbol(__typename), 80 | Symbol(id), 81 | Symbol(account), 82 | Symbol(membershipUser), 83 | }, 84 | Symbol(__typename): "AccountMembership", 85 | Symbol(id): "account-membership-2", 86 | Symbol(account): { 87 | Symbol(__requestedKeys): Set { 88 | Symbol(__typename), 89 | Symbol(name), 90 | }, 91 | Symbol(__typename): "Account", 92 | Symbol(name): "Second", 93 | }, 94 | Symbol(membershipUser): Symbol(User), 95 | }, 96 | Symbol(User) => { 97 | Symbol(__requestedKeys): Set { 98 | Symbol(__typename), 99 | Symbol(id), 100 | Symbol(lastName), 101 | }, 102 | Symbol(__typename): "User", 103 | Symbol(id): "user-2", 104 | Symbol(lastName): "Last", 105 | }, 106 | Symbol(SupportingDocumentCollection) => { 107 | Symbol(__requestedKeys): Set { 108 | Symbol(__typename), 109 | Symbol(supportingDocuments), 110 | Symbol(id), 111 | }, 112 | Symbol(__typename): "SupportingDocumentCollection", 113 | Symbol(supportingDocuments): [ 114 | Symbol(SupportingDocument), 115 | ], 116 | Symbol(id): "supporting-document-collection-1", 117 | }, 118 | Symbol(SupportingDocument) => { 119 | Symbol(__requestedKeys): Set { 120 | Symbol(__typename), 121 | Symbol(id), 122 | Symbol(createdAt), 123 | }, 124 | Symbol(__typename): "SupportingDocument", 125 | Symbol(id): "supporting-document-1", 126 | Symbol(createdAt): "2024-03-14T12:06:10.857Z", 127 | }, 128 | } 129 | `; 130 | 131 | exports[`Write & read in cache 2`] = ` 132 | Map { 133 | Symbol(Query) => { 134 | Symbol(__requestedKeys): Set { 135 | Symbol(__typename), 136 | Symbol(accountMembership({"id":"1"})), 137 | Symbol(accountMemberships({"first":"2"})), 138 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})), 139 | }, 140 | Symbol(__typename): "Query", 141 | Symbol(accountMembership({"id":"1"})): Symbol(AccountMembership), 142 | Symbol(accountMemberships({"first":"2"})): { 143 | "__connectionRef": 0, 144 | Symbol(__requestedKeys): Set { 145 | Symbol(__typename), 146 | Symbol(edges), 147 | }, 148 | Symbol(__typename): "AccountMembershipConnection", 149 | Symbol(edges): [ 150 | { 151 | Symbol(__requestedKeys): Set { 152 | Symbol(__typename), 153 | Symbol(node), 154 | }, 155 | Symbol(__typename): "AccountMembershipEdge", 156 | Symbol(node): Symbol(AccountMembership), 157 | }, 158 | { 159 | Symbol(__requestedKeys): Set { 160 | Symbol(__typename), 161 | Symbol(node), 162 | }, 163 | Symbol(__typename): "AccountMembershipEdge", 164 | Symbol(node): Symbol(AccountMembership), 165 | }, 166 | ], 167 | }, 168 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})): Symbol(SupportingDocumentCollection), 169 | }, 170 | Symbol(AccountMembership) => { 171 | Symbol(__requestedKeys): Set { 172 | Symbol(__typename), 173 | Symbol(id), 174 | Symbol(user), 175 | Symbol(account), 176 | Symbol(membershipUser), 177 | }, 178 | Symbol(__typename): "AccountMembership", 179 | Symbol(id): "account-membership-1", 180 | Symbol(user): Symbol(User), 181 | Symbol(account): { 182 | Symbol(__requestedKeys): Set { 183 | Symbol(__typename), 184 | Symbol(name), 185 | }, 186 | Symbol(__typename): "Account", 187 | Symbol(name): "First", 188 | }, 189 | Symbol(membershipUser): Symbol(User), 190 | }, 191 | Symbol(User) => { 192 | Symbol(__requestedKeys): Set { 193 | Symbol(__typename), 194 | Symbol(id), 195 | Symbol(firstName), 196 | Symbol(lastName), 197 | Symbol(identificationLevels), 198 | }, 199 | Symbol(__typename): "User", 200 | Symbol(id): "user-1", 201 | Symbol(firstName): "Matthias", 202 | Symbol(lastName): "Le Brun", 203 | Symbol(identificationLevels): null, 204 | }, 205 | Symbol(AccountMembership) => { 206 | Symbol(__requestedKeys): Set { 207 | Symbol(__typename), 208 | Symbol(id), 209 | Symbol(account), 210 | Symbol(membershipUser), 211 | Symbol(user), 212 | }, 213 | Symbol(__typename): "AccountMembership", 214 | Symbol(id): "account-membership-2", 215 | Symbol(account): { 216 | Symbol(__requestedKeys): Set { 217 | Symbol(__typename), 218 | Symbol(name), 219 | }, 220 | Symbol(__typename): "Account", 221 | Symbol(name): "Second", 222 | }, 223 | Symbol(membershipUser): Symbol(User), 224 | Symbol(user): Symbol(User), 225 | }, 226 | Symbol(User) => { 227 | Symbol(__requestedKeys): Set { 228 | Symbol(__typename), 229 | Symbol(id), 230 | Symbol(lastName), 231 | Symbol(firstName), 232 | Symbol(identificationLevels), 233 | }, 234 | Symbol(__typename): "User", 235 | Symbol(id): "user-2", 236 | Symbol(lastName): "Acthernoene", 237 | Symbol(firstName): "Mathieu", 238 | Symbol(identificationLevels): { 239 | Symbol(__requestedKeys): Set { 240 | Symbol(__typename), 241 | Symbol(expert), 242 | Symbol(PVID), 243 | Symbol(QES), 244 | }, 245 | Symbol(__typename): "IdentificationLevels", 246 | Symbol(expert): true, 247 | Symbol(PVID): true, 248 | Symbol(QES): false, 249 | }, 250 | }, 251 | Symbol(SupportingDocumentCollection) => { 252 | Symbol(__requestedKeys): Set { 253 | Symbol(__typename), 254 | Symbol(supportingDocuments), 255 | Symbol(id), 256 | }, 257 | Symbol(__typename): "SupportingDocumentCollection", 258 | Symbol(supportingDocuments): [ 259 | Symbol(SupportingDocument), 260 | ], 261 | Symbol(id): "supporting-document-collection-1", 262 | }, 263 | Symbol(SupportingDocument) => { 264 | Symbol(__requestedKeys): Set { 265 | Symbol(__typename), 266 | Symbol(id), 267 | Symbol(createdAt), 268 | }, 269 | Symbol(__typename): "SupportingDocument", 270 | Symbol(id): "supporting-document-1", 271 | Symbol(createdAt): "2024-03-14T12:06:10.857Z", 272 | }, 273 | Symbol(Mutation) => { 274 | Symbol(__requestedKeys): Set { 275 | Symbol(__typename), 276 | Symbol(bindAccountMembership({"input":{"accountMembershipId":"account-membership-2"}})), 277 | }, 278 | Symbol(__typename): "Mutation", 279 | Symbol(bindAccountMembership({"input":{"accountMembershipId":"account-membership-2"}})): { 280 | Symbol(__requestedKeys): Set { 281 | Symbol(__typename), 282 | Symbol(accountMembership), 283 | Symbol(message), 284 | }, 285 | Symbol(__typename): "BindAccountMembershipSuccessPayload", 286 | Symbol(accountMembership): Symbol(AccountMembership), 287 | Symbol(message): undefined, 288 | }, 289 | }, 290 | } 291 | `; 292 | 293 | exports[`Write & read in cache 3`] = ` 294 | Map { 295 | Symbol(Query) => { 296 | Symbol(__requestedKeys): Set { 297 | Symbol(__typename), 298 | Symbol(accountMembership({"id":"1"})), 299 | Symbol(accountMemberships({"first":"2"})), 300 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})), 301 | }, 302 | Symbol(__typename): "Query", 303 | Symbol(accountMembership({"id":"1"})): Symbol(AccountMembership), 304 | Symbol(accountMemberships({"first":"2"})): { 305 | "__connectionRef": 1, 306 | Symbol(__requestedKeys): Set { 307 | Symbol(__typename), 308 | Symbol(edges), 309 | }, 310 | Symbol(__typename): "AccountMembershipConnection", 311 | Symbol(edges): [ 312 | { 313 | Symbol(__requestedKeys): Set { 314 | Symbol(__typename), 315 | Symbol(node), 316 | }, 317 | Symbol(__typename): "AccountMembershipEdge", 318 | Symbol(node): Symbol(AccountMembership), 319 | }, 320 | { 321 | Symbol(__requestedKeys): Set { 322 | Symbol(__typename), 323 | Symbol(node), 324 | }, 325 | Symbol(__typename): "AccountMembershipEdge", 326 | Symbol(node): Symbol(AccountMembership), 327 | }, 328 | ], 329 | }, 330 | Symbol(supportingDocumentCollection({"id":"e8d38e87-9862-47ef-b749-212ed566b955"})): Symbol(SupportingDocumentCollection), 331 | }, 332 | Symbol(AccountMembership) => { 333 | Symbol(__requestedKeys): Set { 334 | Symbol(__typename), 335 | Symbol(id), 336 | Symbol(user), 337 | Symbol(account), 338 | Symbol(membershipUser), 339 | }, 340 | Symbol(__typename): "AccountMembership", 341 | Symbol(id): "account-membership-1", 342 | Symbol(user): Symbol(User), 343 | Symbol(account): { 344 | Symbol(__requestedKeys): Set { 345 | Symbol(__typename), 346 | Symbol(name), 347 | }, 348 | Symbol(__typename): "Account", 349 | Symbol(name): "First", 350 | }, 351 | Symbol(membershipUser): Symbol(User), 352 | }, 353 | Symbol(User) => { 354 | Symbol(__requestedKeys): Set { 355 | Symbol(__typename), 356 | Symbol(id), 357 | Symbol(firstName), 358 | Symbol(lastName), 359 | Symbol(identificationLevels), 360 | }, 361 | Symbol(__typename): "User", 362 | Symbol(id): "user-1", 363 | Symbol(firstName): "Matthias", 364 | Symbol(lastName): "Le Brun", 365 | Symbol(identificationLevels): { 366 | Symbol(__requestedKeys): Set { 367 | Symbol(__typename), 368 | Symbol(expert), 369 | Symbol(PVID), 370 | Symbol(QES), 371 | }, 372 | Symbol(__typename): "IdentificationLevels", 373 | Symbol(expert): true, 374 | Symbol(PVID): true, 375 | Symbol(QES): true, 376 | }, 377 | }, 378 | Symbol(AccountMembership) => { 379 | Symbol(__requestedKeys): Set { 380 | Symbol(__typename), 381 | Symbol(id), 382 | Symbol(account), 383 | Symbol(membershipUser), 384 | Symbol(user), 385 | }, 386 | Symbol(__typename): "AccountMembership", 387 | Symbol(id): "account-membership-2", 388 | Symbol(account): { 389 | Symbol(__requestedKeys): Set { 390 | Symbol(__typename), 391 | Symbol(name), 392 | }, 393 | Symbol(__typename): "Account", 394 | Symbol(name): "Second", 395 | }, 396 | Symbol(membershipUser): Symbol(User), 397 | Symbol(user): Symbol(User), 398 | }, 399 | Symbol(User) => { 400 | Symbol(__requestedKeys): Set { 401 | Symbol(__typename), 402 | Symbol(id), 403 | Symbol(lastName), 404 | Symbol(firstName), 405 | Symbol(identificationLevels), 406 | }, 407 | Symbol(__typename): "User", 408 | Symbol(id): "user-2", 409 | Symbol(lastName): "Acthernoene", 410 | Symbol(firstName): "Mathieu", 411 | Symbol(identificationLevels): { 412 | Symbol(__requestedKeys): Set { 413 | Symbol(__typename), 414 | Symbol(expert), 415 | Symbol(PVID), 416 | Symbol(QES), 417 | }, 418 | Symbol(__typename): "IdentificationLevels", 419 | Symbol(expert): true, 420 | Symbol(PVID): true, 421 | Symbol(QES): false, 422 | }, 423 | }, 424 | Symbol(SupportingDocumentCollection) => { 425 | Symbol(__requestedKeys): Set { 426 | Symbol(__typename), 427 | Symbol(supportingDocuments), 428 | Symbol(id), 429 | }, 430 | Symbol(__typename): "SupportingDocumentCollection", 431 | Symbol(supportingDocuments): [ 432 | Symbol(SupportingDocument), 433 | ], 434 | Symbol(id): "supporting-document-collection-1", 435 | }, 436 | Symbol(SupportingDocument) => { 437 | Symbol(__requestedKeys): Set { 438 | Symbol(__typename), 439 | Symbol(id), 440 | Symbol(createdAt), 441 | }, 442 | Symbol(__typename): "SupportingDocument", 443 | Symbol(id): "supporting-document-1", 444 | Symbol(createdAt): "2024-03-14T12:06:10.857Z", 445 | }, 446 | Symbol(Mutation) => { 447 | Symbol(__requestedKeys): Set { 448 | Symbol(__typename), 449 | Symbol(bindAccountMembership({"input":{"accountMembershipId":"account-membership-2"}})), 450 | }, 451 | Symbol(__typename): "Mutation", 452 | Symbol(bindAccountMembership({"input":{"accountMembershipId":"account-membership-2"}})): { 453 | Symbol(__requestedKeys): Set { 454 | Symbol(__typename), 455 | Symbol(accountMembership), 456 | Symbol(message), 457 | }, 458 | Symbol(__typename): "BindAccountMembershipSuccessPayload", 459 | Symbol(accountMembership): Symbol(AccountMembership), 460 | Symbol(message): undefined, 461 | }, 462 | }, 463 | } 464 | `; 465 | 466 | exports[`Write & read in cache 4`] = `"query App($id: ID!) {accountMembership(id: $id) {id user {id ... on User {birthDate mobilePhoneNumber}}}}"`; 467 | 468 | exports[`Write & read in cache 5`] = `"query App($id: ID!) {accountMembership(id: $id) {id user {id ... on User {birthDate mobilePhoneNumber}}} accountMemberships(first: 2) {edges {node {id createdAt account {bankDetails}}}}}"`; 469 | 470 | exports[`Write & read in cache 6`] = ` 471 | Map { 472 | Symbol(Query) => { 473 | Symbol(__requestedKeys): Set { 474 | Symbol(__typename), 475 | Symbol(onboardingInfo({"id":"d26ed1ed-5f70-4096-9d8e-27ef258e26fa"})), 476 | }, 477 | Symbol(__typename): "Query", 478 | Symbol(onboardingInfo({"id":"d26ed1ed-5f70-4096-9d8e-27ef258e26fa"})): Symbol(OnboardingInfo), 479 | }, 480 | Symbol(OnboardingInfo) => { 481 | Symbol(__requestedKeys): Set { 482 | Symbol(__typename), 483 | Symbol(id), 484 | Symbol(accountCountry), 485 | Symbol(email), 486 | Symbol(language), 487 | Symbol(redirectUrl), 488 | Symbol(tcuUrl), 489 | Symbol(legalRepresentativeRecommendedIdentificationLevel), 490 | Symbol(oAuthRedirectParameters), 491 | Symbol(onboardingState), 492 | Symbol(projectInfo), 493 | Symbol(supportingDocumentCollection), 494 | Symbol(info), 495 | Symbol(statusInfo), 496 | }, 497 | Symbol(__typename): "OnboardingInfo", 498 | Symbol(id): "d26ed1ed-5f70-4096-9d8e-27ef258e26fa", 499 | Symbol(accountCountry): "FRA", 500 | Symbol(email): null, 501 | Symbol(language): null, 502 | Symbol(redirectUrl): "", 503 | Symbol(tcuUrl): "https://document-factory.sandbox.master.oina.ws/swanTCU/7649fada-a1c8-4537-bd3c-d539664a841c.pdf?Expires=1712229767&Key-Pair-Id=KTRMJ5W6BT4MH&Signature=eRpFq3ChqRx7KUVM5bhzPoX7uIxaCyJycw~wTAPDKslc-oq4OwKCrB1mm8efx~wdwuauT0b80EoPidCsoMEdYKvT7LE-H12HKizLYaHXxVNevmWZMR2zqN1v9bi77oIhVEQZmV9uGCluDypvWQn9eu3ICOMJr8k0dn7f4K0jQyAMju1AqBg3~jeeAbOD1Y0hA0T2zeJ~OLDahJB54kFDt~UbdwEylgjh1V-tg5GvXs1w268aax98DpHrORttnaSTLvB7PlMIJJbgQIy712OO2~zg6dggMSlWHk0J3243xsd65eWhLPeLVt8jlnYBMvc0Iscd4k12iVWKWRozckglNQ__", 504 | Symbol(legalRepresentativeRecommendedIdentificationLevel): "PVID", 505 | Symbol(oAuthRedirectParameters): null, 506 | Symbol(onboardingState): "Ongoing", 507 | Symbol(projectInfo): Symbol(ProjectInfo<64060573-f0ec-4204-ad49-a3983497ada4>), 508 | Symbol(supportingDocumentCollection): Symbol(SupportingDocumentCollection<55561d8f-6a90-41f2-be48-e14c82e3cc34>), 509 | Symbol(info): { 510 | Symbol(__requestedKeys): Set { 511 | Symbol(__typename), 512 | Symbol(residencyAddress), 513 | Symbol(taxIdentificationNumber), 514 | Symbol(employmentStatus), 515 | Symbol(monthlyIncome), 516 | Symbol(legalRepresentativePersonalAddress), 517 | Symbol(businessActivity), 518 | Symbol(businessActivityDescription), 519 | Symbol(companyType), 520 | Symbol(isRegistered), 521 | Symbol(monthlyPaymentVolume), 522 | Symbol(name), 523 | Symbol(typeOfRepresentation), 524 | Symbol(registrationNumber), 525 | Symbol(vatNumber), 526 | Symbol(individualUltimateBeneficialOwners), 527 | }, 528 | Symbol(__typename): "OnboardingIndividualAccountHolderInfo", 529 | Symbol(residencyAddress): { 530 | Symbol(__requestedKeys): Set { 531 | Symbol(__typename), 532 | Symbol(addressLine1), 533 | Symbol(addressLine2), 534 | Symbol(city), 535 | Symbol(country), 536 | Symbol(postalCode), 537 | Symbol(state), 538 | }, 539 | Symbol(__typename): "AddressInfo", 540 | Symbol(addressLine1): null, 541 | Symbol(addressLine2): null, 542 | Symbol(city): null, 543 | Symbol(country): null, 544 | Symbol(postalCode): null, 545 | Symbol(state): null, 546 | }, 547 | Symbol(taxIdentificationNumber): null, 548 | Symbol(employmentStatus): null, 549 | Symbol(monthlyIncome): null, 550 | Symbol(legalRepresentativePersonalAddress): undefined, 551 | Symbol(businessActivity): undefined, 552 | Symbol(businessActivityDescription): undefined, 553 | Symbol(companyType): undefined, 554 | Symbol(isRegistered): undefined, 555 | Symbol(monthlyPaymentVolume): undefined, 556 | Symbol(name): undefined, 557 | Symbol(typeOfRepresentation): undefined, 558 | Symbol(registrationNumber): undefined, 559 | Symbol(vatNumber): undefined, 560 | Symbol(individualUltimateBeneficialOwners): undefined, 561 | }, 562 | Symbol(statusInfo): { 563 | Symbol(__requestedKeys): Set { 564 | Symbol(__typename), 565 | Symbol(errors), 566 | }, 567 | Symbol(__typename): "OnboardingInvalidStatusInfo", 568 | Symbol(errors): [ 569 | { 570 | Symbol(__requestedKeys): Set { 571 | Symbol(__typename), 572 | Symbol(field), 573 | Symbol(errors), 574 | }, 575 | Symbol(__typename): "ValidationError", 576 | Symbol(field): "email", 577 | Symbol(errors): [ 578 | "Missing", 579 | ], 580 | }, 581 | { 582 | Symbol(__requestedKeys): Set { 583 | Symbol(__typename), 584 | Symbol(field), 585 | Symbol(errors), 586 | }, 587 | Symbol(__typename): "ValidationError", 588 | Symbol(field): "employmentStatus", 589 | Symbol(errors): [ 590 | "Missing", 591 | ], 592 | }, 593 | { 594 | Symbol(__requestedKeys): Set { 595 | Symbol(__typename), 596 | Symbol(field), 597 | Symbol(errors), 598 | }, 599 | Symbol(__typename): "ValidationError", 600 | Symbol(field): "monthlyIncome", 601 | Symbol(errors): [ 602 | "Missing", 603 | ], 604 | }, 605 | { 606 | Symbol(__requestedKeys): Set { 607 | Symbol(__typename), 608 | Symbol(field), 609 | Symbol(errors), 610 | }, 611 | Symbol(__typename): "ValidationError", 612 | Symbol(field): "language", 613 | Symbol(errors): [ 614 | "Missing", 615 | ], 616 | }, 617 | { 618 | Symbol(__requestedKeys): Set { 619 | Symbol(__typename), 620 | Symbol(field), 621 | Symbol(errors), 622 | }, 623 | Symbol(__typename): "ValidationError", 624 | Symbol(field): "residencyAddress.addressLine1", 625 | Symbol(errors): [ 626 | "Missing", 627 | ], 628 | }, 629 | { 630 | Symbol(__requestedKeys): Set { 631 | Symbol(__typename), 632 | Symbol(field), 633 | Symbol(errors), 634 | }, 635 | Symbol(__typename): "ValidationError", 636 | Symbol(field): "residencyAddress.city", 637 | Symbol(errors): [ 638 | "Missing", 639 | ], 640 | }, 641 | { 642 | Symbol(__requestedKeys): Set { 643 | Symbol(__typename), 644 | Symbol(field), 645 | Symbol(errors), 646 | }, 647 | Symbol(__typename): "ValidationError", 648 | Symbol(field): "residencyAddress.country", 649 | Symbol(errors): [ 650 | "Missing", 651 | ], 652 | }, 653 | { 654 | Symbol(__requestedKeys): Set { 655 | Symbol(__typename), 656 | Symbol(field), 657 | Symbol(errors), 658 | }, 659 | Symbol(__typename): "ValidationError", 660 | Symbol(field): "residencyAddress.postalCode", 661 | Symbol(errors): [ 662 | "Missing", 663 | ], 664 | }, 665 | ], 666 | }, 667 | }, 668 | Symbol(ProjectInfo<64060573-f0ec-4204-ad49-a3983497ada4>) => { 669 | Symbol(__requestedKeys): Set { 670 | Symbol(__typename), 671 | Symbol(id), 672 | Symbol(accentColor), 673 | Symbol(name), 674 | Symbol(logoUri), 675 | Symbol(tcuDocumentUri({"language":"en"})), 676 | }, 677 | Symbol(__typename): "ProjectInfo", 678 | Symbol(id): "64060573-f0ec-4204-ad49-a3983497ada4", 679 | Symbol(accentColor): "#38945D", 680 | Symbol(name): "bloodyowl", 681 | Symbol(logoUri): "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/64060573-f0ec-4204-ad49-a3983497ada4/SANDBOX/logo-5733f69e-8223-4b7e-92c7-0fed9eaaca33.png", 682 | Symbol(tcuDocumentUri({"language":"en"})): "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/64060573-f0ec-4204-ad49-a3983497ada4/SANDBOX/tcu/bb87c4f2-de5f-4df7-b617-91f2c0eb03f4/en.pdf", 683 | }, 684 | Symbol(SupportingDocumentCollection<55561d8f-6a90-41f2-be48-e14c82e3cc34>) => { 685 | Symbol(__requestedKeys): Set { 686 | Symbol(__typename), 687 | Symbol(id), 688 | Symbol(requiredSupportingDocumentPurposes), 689 | Symbol(statusInfo), 690 | Symbol(supportingDocuments), 691 | }, 692 | Symbol(__typename): "SupportingDocumentCollection", 693 | Symbol(id): "55561d8f-6a90-41f2-be48-e14c82e3cc34", 694 | Symbol(requiredSupportingDocumentPurposes): [], 695 | Symbol(statusInfo): { 696 | Symbol(__requestedKeys): Set { 697 | Symbol(__typename), 698 | Symbol(status), 699 | }, 700 | Symbol(__typename): "SupportingDocumentCollectionWaitingForDocumentStatusInfo", 701 | Symbol(status): "WaitingForDocument", 702 | }, 703 | Symbol(supportingDocuments): [], 704 | }, 705 | } 706 | `; 707 | 708 | exports[`Write & read in cache 7`] = ` 709 | { 710 | "tag": "Some", 711 | "value": { 712 | "tag": "Ok", 713 | "value": { 714 | "__typename": "Query", 715 | "accountMembership": { 716 | "__typename": "AccountMembership", 717 | "id": "account-membership-1", 718 | "user": { 719 | "__typename": "User", 720 | "firstName": "Matthias", 721 | "id": "user-1", 722 | "identificationLevels": null, 723 | "lastName": "Le Brun", 724 | }, 725 | }, 726 | "accountMemberships": { 727 | "__connectionRef": 0, 728 | "__typename": "AccountMembershipConnection", 729 | "edges": [ 730 | { 731 | "__typename": "AccountMembershipEdge", 732 | "node": { 733 | "__typename": "AccountMembership", 734 | "account": { 735 | "__typename": "Account", 736 | "name": "First", 737 | }, 738 | "id": "account-membership-0", 739 | "membershipUser": { 740 | "__typename": "User", 741 | "id": "user-0", 742 | "lastName": "Le Brun", 743 | }, 744 | }, 745 | }, 746 | { 747 | "__typename": "AccountMembershipEdge", 748 | "node": { 749 | "__typename": "AccountMembership", 750 | "account": { 751 | "__typename": "Account", 752 | "name": "Second", 753 | }, 754 | "id": "account-membership-2", 755 | "membershipUser": { 756 | "__typename": "User", 757 | "id": "user-2", 758 | "lastName": "Last", 759 | }, 760 | }, 761 | }, 762 | { 763 | "__typename": "AccountMembershipEdge", 764 | "node": { 765 | "__typename": "AccountMembership", 766 | "account": { 767 | "__typename": "Account", 768 | "name": "First", 769 | }, 770 | "id": "account-membership-3", 771 | "membershipUser": { 772 | "__typename": "User", 773 | "id": "user-3", 774 | "lastName": "Le Brun", 775 | }, 776 | }, 777 | }, 778 | ], 779 | }, 780 | "supportingDocumentCollection": { 781 | "__typename": "SupportingDocumentCollection", 782 | "id": "supporting-document-collection-1", 783 | "supportingDocuments": [ 784 | { 785 | "__typename": "SupportingDocument", 786 | "createdAt": "2024-03-14T12:06:10.857Z", 787 | "id": "supporting-document-1", 788 | }, 789 | ], 790 | }, 791 | }, 792 | Symbol(__boxed_type__): "Result", 793 | }, 794 | Symbol(__boxed_type__): "Option", 795 | } 796 | `; 797 | -------------------------------------------------------------------------------- /test/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { Option, Result } from "@swan-io/boxed"; 2 | import { expect, test } from "vitest"; 3 | import { Connection } from "../src"; 4 | import { ClientCache } from "../src/cache/cache"; 5 | import { optimizeQuery, readOperationFromCache } from "../src/cache/read"; 6 | import { writeOperationToCache } from "../src/cache/write"; 7 | import { addTypenames, inlineFragments } from "../src/graphql/ast"; 8 | import { print } from "../src/graphql/print"; 9 | import { 10 | OnboardingInfo, 11 | addMembership, 12 | appQuery, 13 | appQueryWithExtraArrayInfo, 14 | appQueryWithMoreAccountInfo, 15 | appQueryWithoutMoreAccountInfo, 16 | bindAccountMembershipMutation, 17 | bindMembershipMutationRejectionResponse, 18 | bindMembershipMutationSuccessResponse, 19 | brandingQuery, 20 | brandingResponse, 21 | getAppQueryResponse, 22 | onboardingInfoResponse, 23 | otherAppQuery, 24 | } from "./data"; 25 | 26 | test("Write & read in cache", () => { 27 | const cache = new ClientCache({ interfaceToTypes: {} }); 28 | 29 | const preparedAppQuery = inlineFragments(addTypenames(appQuery)); 30 | 31 | writeOperationToCache( 32 | cache, 33 | preparedAppQuery, 34 | getAppQueryResponse({ 35 | user2LastName: "Last", 36 | user1IdentificationLevels: null, 37 | }), 38 | { 39 | id: "1", 40 | }, 41 | ); 42 | 43 | expect(cache.dump()).toMatchSnapshot(); 44 | 45 | expect( 46 | readOperationFromCache(cache, preparedAppQuery, { 47 | id: "1", 48 | }), 49 | ).toMatchObject( 50 | Option.Some( 51 | Result.Ok( 52 | getAppQueryResponse({ 53 | user2LastName: "Last", 54 | user1IdentificationLevels: null, 55 | }), 56 | ), 57 | ), 58 | ); 59 | 60 | const preparedOnboardingInfo = inlineFragments(addTypenames(OnboardingInfo)); 61 | 62 | const preparedBindAccountMembershipMutation = inlineFragments( 63 | addTypenames(bindAccountMembershipMutation), 64 | ); 65 | 66 | const preparedOtherAppQuery = inlineFragments(addTypenames(otherAppQuery)); 67 | const preparedAppQueryWithExtraArrayInfo = inlineFragments( 68 | addTypenames(appQueryWithExtraArrayInfo), 69 | ); 70 | 71 | writeOperationToCache( 72 | cache, 73 | preparedBindAccountMembershipMutation, 74 | bindMembershipMutationRejectionResponse, 75 | { 76 | id: "account-membership-2", 77 | }, 78 | ); 79 | 80 | expect( 81 | readOperationFromCache(cache, preparedAppQuery, { 82 | id: "1", 83 | }), 84 | ).toMatchObject( 85 | Option.Some( 86 | Result.Ok( 87 | getAppQueryResponse({ 88 | user2LastName: "Last", 89 | user1IdentificationLevels: null, 90 | }), 91 | ), 92 | ), 93 | ); 94 | 95 | writeOperationToCache( 96 | cache, 97 | preparedBindAccountMembershipMutation, 98 | bindMembershipMutationSuccessResponse, 99 | { 100 | id: "account-membership-2", 101 | }, 102 | ); 103 | 104 | expect(cache.dump()).toMatchSnapshot(); 105 | 106 | expect( 107 | readOperationFromCache(cache, preparedAppQuery, { 108 | id: "1", 109 | }), 110 | ).toMatchObject( 111 | Option.Some( 112 | Result.Ok( 113 | getAppQueryResponse({ 114 | user2LastName: "Acthernoene", 115 | user1IdentificationLevels: null, 116 | }), 117 | ), 118 | ), 119 | ); 120 | 121 | writeOperationToCache( 122 | cache, 123 | preparedAppQuery, 124 | getAppQueryResponse({ 125 | user2LastName: "Acthernoene", 126 | user1IdentificationLevels: { 127 | __typename: "IdentificationLevels", 128 | expert: true, 129 | PVID: true, 130 | QES: true, 131 | }, 132 | }), 133 | { 134 | id: "1", 135 | }, 136 | ); 137 | 138 | expect(cache.dump()).toMatchSnapshot(); 139 | 140 | expect( 141 | readOperationFromCache(cache, preparedAppQuery, { 142 | id: "1", 143 | }), 144 | ).toMatchObject( 145 | Option.Some( 146 | Result.Ok( 147 | getAppQueryResponse({ 148 | user2LastName: "Acthernoene", 149 | user1IdentificationLevels: { 150 | __typename: "IdentificationLevels", 151 | expert: true, 152 | PVID: true, 153 | QES: true, 154 | }, 155 | }), 156 | ), 157 | ), 158 | ); 159 | 160 | const values = Option.all([ 161 | readOperationFromCache(cache, preparedAppQuery, { 162 | id: "1", 163 | }), 164 | readOperationFromCache(cache, preparedAppQuery, { 165 | id: "1", 166 | }), 167 | ]); 168 | 169 | if (values.isSome()) { 170 | const [a, b] = values.get(); 171 | expect(a).toBe(b); 172 | } else { 173 | expect(true).toBe(false); 174 | } 175 | 176 | expect( 177 | optimizeQuery(cache, preparedOtherAppQuery, { id: "1" }) 178 | .map(print) 179 | .getOr("no delta"), 180 | ).toMatchSnapshot(); 181 | 182 | expect( 183 | optimizeQuery(cache, preparedAppQueryWithExtraArrayInfo, { id: "1" }) 184 | .map(print) 185 | .getOr("no delta"), 186 | ).toMatchSnapshot(); 187 | 188 | const cache2 = new ClientCache({ interfaceToTypes: {} }); 189 | 190 | writeOperationToCache( 191 | cache2, 192 | preparedOnboardingInfo, 193 | onboardingInfoResponse, 194 | { 195 | id: "d26ed1ed-5f70-4096-9d8e-27ef258e26fa", 196 | language: "en", 197 | }, 198 | ); 199 | 200 | expect(cache2.dump()).toMatchSnapshot(); 201 | 202 | expect( 203 | readOperationFromCache(cache2, preparedOnboardingInfo, { 204 | id: "d26ed1ed-5f70-4096-9d8e-27ef258e26fa", 205 | language: "en", 206 | }), 207 | ).toMatchObject(Option.Some(Result.Ok(onboardingInfoResponse))); 208 | 209 | const cache3 = new ClientCache({ interfaceToTypes: {} }); 210 | 211 | writeOperationToCache( 212 | cache3, 213 | preparedAppQuery, 214 | getAppQueryResponse({ 215 | user2LastName: "Last", 216 | user1IdentificationLevels: null, 217 | }), 218 | { 219 | id: "1", 220 | }, 221 | ); 222 | 223 | const read = readOperationFromCache(cache3, preparedAppQuery, { 224 | id: "1", 225 | }); 226 | 227 | expect( 228 | readOperationFromCache(cache3, appQueryWithoutMoreAccountInfo, {}).isSome(), 229 | ).toBe(true); 230 | 231 | expect( 232 | readOperationFromCache(cache3, appQueryWithMoreAccountInfo, {}).isNone(), 233 | ).toBe(true); 234 | 235 | if (read.isSome()) { 236 | const cacheResult = read.get(); 237 | if (cacheResult.isOk()) { 238 | const value = cacheResult.get() as ReturnType; 239 | const accountMemberships = 240 | value.accountMemberships as unknown as Connection<{ 241 | __typename: "AccountMembership"; 242 | id: string; 243 | account: { 244 | __typename: "Account"; 245 | name: string; 246 | }; 247 | membershipUser: { 248 | __typename: "User"; 249 | id: string; 250 | lastName: string; 251 | }; 252 | }>; 253 | cache3.updateConnection(accountMemberships, { 254 | remove: ["account-membership-1"], 255 | }); 256 | 257 | writeOperationToCache( 258 | cache3, 259 | inlineFragments(addTypenames(addMembership)), 260 | { 261 | __typename: "Mutation", 262 | addMembership: { 263 | __typename: "AddMembership", 264 | membership: { 265 | __typename: "AccountMembership", 266 | id: "account-membership-3", 267 | account: { 268 | __typename: "Account", 269 | name: "First", 270 | }, 271 | membershipUser: { 272 | __typename: "User", 273 | id: "user-3", 274 | lastName: "Le Brun", 275 | }, 276 | }, 277 | }, 278 | }, 279 | {}, 280 | ); 281 | 282 | writeOperationToCache( 283 | cache3, 284 | inlineFragments(addTypenames(addMembership)), 285 | { 286 | __typename: "Mutation", 287 | addMembership: { 288 | __typename: "AddMembership", 289 | membership: { 290 | __typename: "AccountMembership", 291 | id: "account-membership-0", 292 | account: { 293 | __typename: "Account", 294 | name: "First", 295 | }, 296 | membershipUser: { 297 | __typename: "User", 298 | id: "user-0", 299 | lastName: "Le Brun", 300 | }, 301 | }, 302 | }, 303 | }, 304 | {}, 305 | ); 306 | 307 | cache3.updateConnection(accountMemberships, { 308 | append: [ 309 | { 310 | __typename: "AccountMembershipEdge", 311 | node: { 312 | __typename: "AccountMembership", 313 | id: "account-membership-3", 314 | account: { 315 | __typename: "Account", 316 | name: "First", 317 | }, 318 | membershipUser: { 319 | __typename: "User", 320 | id: "user-3", 321 | lastName: "Le Brun", 322 | }, 323 | }, 324 | }, 325 | ], 326 | }); 327 | cache3.updateConnection(accountMemberships, { 328 | prepend: [ 329 | { 330 | __typename: "AccountMembershipEdge", 331 | node: { 332 | __typename: "AccountMembership", 333 | id: "account-membership-0", 334 | account: { 335 | __typename: "Account", 336 | name: "First", 337 | }, 338 | membershipUser: { 339 | __typename: "User", 340 | id: "user-0", 341 | lastName: "Le Brun", 342 | }, 343 | }, 344 | }, 345 | ], 346 | }); 347 | } 348 | 349 | expect( 350 | readOperationFromCache(cache3, preparedAppQuery, { 351 | id: "1", 352 | }), 353 | ).toMatchSnapshot(); 354 | } else { 355 | expect(true).toBe(false); 356 | } 357 | 358 | const preparedBrandingQuery = inlineFragments(addTypenames(brandingQuery)); 359 | 360 | const cache4 = new ClientCache({ 361 | interfaceToTypes: { 362 | ProjectSettings: ["LiveProjectSettings", "SandboxProjectSettings"], 363 | }, 364 | }); 365 | 366 | writeOperationToCache(cache4, preparedBrandingQuery, brandingResponse, { 367 | id: "64060573-f0ec-4204-ad49-a3983497ada4", 368 | }); 369 | 370 | expect( 371 | readOperationFromCache(cache4, preparedBrandingQuery, { 372 | id: "64060573-f0ec-4204-ad49-a3983497ada4", 373 | }), 374 | ).toEqual(Option.Some(Result.Ok(brandingResponse))); 375 | }); 376 | -------------------------------------------------------------------------------- /test/data.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from "gql.tada"; 2 | 3 | const IdentificationLevels = graphql(` 4 | fragment IdentificationLevels on IdentificationLevels { 5 | expert 6 | PVID 7 | QES 8 | } 9 | `); 10 | 11 | const UserInfo = graphql( 12 | ` 13 | fragment UserInfo on User { 14 | id 15 | firstName 16 | lastName 17 | identificationLevels { 18 | ...IdentificationLevels 19 | } 20 | } 21 | `, 22 | [IdentificationLevels], 23 | ); 24 | 25 | const CompleteUserInfo = graphql( 26 | ` 27 | fragment CompleteUserInfo on User { 28 | id 29 | firstName 30 | lastName 31 | birthDate 32 | mobilePhoneNumber 33 | } 34 | `, 35 | [IdentificationLevels], 36 | ); 37 | 38 | export const appQuery = graphql( 39 | ` 40 | query App($id: ID!) { 41 | accountMembership(id: $id) { 42 | id 43 | user { 44 | id 45 | ...UserInfo 46 | } 47 | } 48 | accountMemberships(first: 2) { 49 | edges { 50 | node { 51 | id 52 | account { 53 | name 54 | } 55 | membershipUser: user { 56 | id 57 | lastName 58 | } 59 | } 60 | } 61 | } 62 | supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") { 63 | __typename 64 | supportingDocuments { 65 | __typename 66 | id 67 | createdAt 68 | } 69 | id 70 | } 71 | } 72 | `, 73 | [UserInfo], 74 | ); 75 | 76 | export const otherAppQuery = graphql( 77 | ` 78 | query App($id: ID!) { 79 | accountMembership(id: $id) { 80 | id 81 | user { 82 | id 83 | ...CompleteUserInfo 84 | } 85 | } 86 | accountMemberships(first: 2) { 87 | edges { 88 | node { 89 | id 90 | account { 91 | name 92 | } 93 | membershipUser: user { 94 | id 95 | lastName 96 | } 97 | } 98 | } 99 | } 100 | supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") { 101 | __typename 102 | supportingDocuments { 103 | __typename 104 | id 105 | createdAt 106 | } 107 | id 108 | } 109 | } 110 | `, 111 | [CompleteUserInfo], 112 | ); 113 | 114 | export const appQueryWithoutMoreAccountInfo = graphql(` 115 | query App($id: ID!) { 116 | accountMemberships(first: 2) { 117 | edges { 118 | node { 119 | id 120 | account { 121 | name 122 | } 123 | } 124 | } 125 | } 126 | } 127 | `); 128 | 129 | export const appQueryWithMoreAccountInfo = graphql(` 130 | query App($id: ID!) { 131 | accountMemberships(first: 2) { 132 | edges { 133 | node { 134 | id 135 | account { 136 | name 137 | country 138 | } 139 | } 140 | } 141 | } 142 | } 143 | `); 144 | 145 | export const appQueryWithExtraArrayInfo = graphql( 146 | ` 147 | query App($id: ID!) { 148 | accountMembership(id: $id) { 149 | id 150 | user { 151 | id 152 | ...CompleteUserInfo 153 | } 154 | } 155 | accountMemberships(first: 2) { 156 | edges { 157 | node { 158 | id 159 | createdAt 160 | account { 161 | name 162 | bankDetails 163 | } 164 | membershipUser: user { 165 | id 166 | lastName 167 | firstName 168 | } 169 | } 170 | } 171 | } 172 | supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") { 173 | __typename 174 | supportingDocuments { 175 | __typename 176 | id 177 | createdAt 178 | } 179 | id 180 | } 181 | } 182 | `, 183 | [CompleteUserInfo], 184 | ); 185 | 186 | export const getAppQueryResponse = ({ 187 | user2LastName, 188 | user1IdentificationLevels, 189 | }: { 190 | user2LastName: string; 191 | user1IdentificationLevels: { 192 | __typename: "IdentificationLevels"; 193 | expert: boolean; 194 | PVID: boolean; 195 | QES: boolean; 196 | } | null; 197 | }) => ({ 198 | __typename: "Query", 199 | accountMembership: { 200 | __typename: "AccountMembership", 201 | id: "account-membership-1", 202 | user: { 203 | __typename: "User", 204 | id: "user-1", 205 | firstName: "Matthias", 206 | lastName: "Le Brun", 207 | identificationLevels: user1IdentificationLevels, 208 | }, 209 | }, 210 | accountMemberships: { 211 | __typename: "AccountMembershipConnection", 212 | edges: [ 213 | { 214 | __typename: "AccountMembershipEdge", 215 | node: { 216 | __typename: "AccountMembership", 217 | id: "account-membership-1", 218 | account: { 219 | __typename: "Account", 220 | name: "First", 221 | }, 222 | membershipUser: { 223 | __typename: "User", 224 | id: "user-1", 225 | lastName: "Le Brun", 226 | }, 227 | }, 228 | }, 229 | { 230 | __typename: "AccountMembershipEdge", 231 | node: { 232 | __typename: "AccountMembership", 233 | id: "account-membership-2", 234 | account: { 235 | __typename: "Account", 236 | name: "Second", 237 | }, 238 | membershipUser: { 239 | __typename: "User", 240 | id: "user-2", 241 | lastName: user2LastName, 242 | }, 243 | }, 244 | }, 245 | ], 246 | }, 247 | supportingDocumentCollection: { 248 | __typename: "SupportingDocumentCollection", 249 | supportingDocuments: [ 250 | { 251 | __typename: "SupportingDocument", 252 | id: "supporting-document-1", 253 | createdAt: "2024-03-14T12:06:10.857Z", 254 | }, 255 | ], 256 | id: "supporting-document-collection-1", 257 | }, 258 | }); 259 | 260 | export const bindAccountMembershipMutation = graphql( 261 | ` 262 | mutation BindAccountMembership($id: ID!) { 263 | bindAccountMembership(input: { accountMembershipId: $id }) { 264 | ... on BindAccountMembershipSuccessPayload { 265 | accountMembership { 266 | id 267 | user { 268 | ...UserInfo 269 | } 270 | } 271 | } 272 | ... on Rejection { 273 | message 274 | } 275 | } 276 | } 277 | `, 278 | [UserInfo], 279 | ); 280 | 281 | export const addMembership = graphql(` 282 | mutation AddMembership { 283 | addMembership { 284 | membership { 285 | id 286 | createdAt 287 | account { 288 | name 289 | bankDetails 290 | } 291 | membershipUser: user { 292 | id 293 | lastName 294 | firstName 295 | } 296 | } 297 | } 298 | } 299 | `); 300 | 301 | export const OnboardingInfo = graphql( 302 | ` 303 | query GetOnboarding($id: ID!, $language: String!) { 304 | __typename 305 | onboardingInfo(id: $id) { 306 | __typename 307 | ...OnboardingData 308 | } 309 | } 310 | fragment SupportingDocument on SupportingDocument { 311 | __typename 312 | id 313 | supportingDocumentPurpose 314 | supportingDocumentType 315 | updatedAt 316 | statusInfo { 317 | __typename 318 | status 319 | ... on SupportingDocumentUploadedStatusInfo { 320 | __typename 321 | downloadUrl 322 | filename 323 | } 324 | ... on SupportingDocumentValidatedStatusInfo { 325 | __typename 326 | downloadUrl 327 | filename 328 | } 329 | ... on SupportingDocumentRefusedStatusInfo { 330 | __typename 331 | downloadUrl 332 | reason 333 | filename 334 | } 335 | } 336 | } 337 | fragment IndividualAccountHolder on OnboardingIndividualAccountHolderInfo { 338 | __typename 339 | residencyAddress { 340 | __typename 341 | addressLine1 342 | addressLine2 343 | city 344 | country 345 | postalCode 346 | state 347 | } 348 | taxIdentificationNumber 349 | employmentStatus 350 | monthlyIncome 351 | } 352 | fragment UBO on IndividualUltimateBeneficialOwner { 353 | __typename 354 | firstName 355 | lastName 356 | birthDate 357 | birthCountryCode 358 | birthCity 359 | birthCityPostalCode 360 | info { 361 | __typename 362 | type 363 | ... on IndividualUltimateBeneficialOwnerTypeHasCapital { 364 | __typename 365 | indirect 366 | direct 367 | totalCapitalPercentage 368 | } 369 | } 370 | taxIdentificationNumber 371 | residencyAddress { 372 | __typename 373 | addressLine1 374 | addressLine2 375 | city 376 | country 377 | postalCode 378 | state 379 | } 380 | } 381 | fragment CompanyAccountHolder on OnboardingCompanyAccountHolderInfo { 382 | __typename 383 | taxIdentificationNumber 384 | residencyAddress { 385 | __typename 386 | addressLine1 387 | addressLine2 388 | city 389 | country 390 | postalCode 391 | state 392 | } 393 | legalRepresentativePersonalAddress { 394 | __typename 395 | addressLine1 396 | addressLine2 397 | city 398 | country 399 | postalCode 400 | state 401 | } 402 | businessActivity 403 | businessActivityDescription 404 | companyType 405 | isRegistered 406 | monthlyPaymentVolume 407 | name 408 | typeOfRepresentation 409 | registrationNumber 410 | vatNumber 411 | individualUltimateBeneficialOwners { 412 | __typename 413 | ...UBO 414 | } 415 | } 416 | fragment OnboardingInvalidInfo on OnboardingStatusInfo { 417 | __typename 418 | ... on OnboardingInvalidStatusInfo { 419 | __typename 420 | errors { 421 | __typename 422 | field 423 | errors 424 | } 425 | } 426 | ... on OnboardingFinalizedStatusInfo { 427 | __typename 428 | } 429 | ... on OnboardingValidStatusInfo { 430 | __typename 431 | } 432 | } 433 | fragment OnboardingData on OnboardingInfo { 434 | __typename 435 | id 436 | accountCountry 437 | email 438 | language 439 | redirectUrl 440 | tcuUrl 441 | legalRepresentativeRecommendedIdentificationLevel 442 | oAuthRedirectParameters { 443 | __typename 444 | redirectUrl 445 | } 446 | onboardingState 447 | projectInfo { 448 | __typename 449 | id 450 | accentColor 451 | name 452 | logoUri 453 | tcuDocumentUri(language: $language) 454 | } 455 | supportingDocumentCollection { 456 | __typename 457 | id 458 | requiredSupportingDocumentPurposes { 459 | __typename 460 | name 461 | } 462 | statusInfo { 463 | __typename 464 | status 465 | } 466 | supportingDocuments { 467 | __typename 468 | ...SupportingDocument 469 | } 470 | } 471 | info { 472 | __typename 473 | ... on OnboardingIndividualAccountHolderInfo { 474 | __typename 475 | ...IndividualAccountHolder 476 | } 477 | ... on OnboardingCompanyAccountHolderInfo { 478 | __typename 479 | ...CompanyAccountHolder 480 | } 481 | } 482 | statusInfo { 483 | __typename 484 | ...OnboardingInvalidInfo 485 | } 486 | } 487 | `, 488 | [IdentificationLevels], 489 | ); 490 | 491 | export const onboardingInfoResponse = { 492 | __typename: "Query", 493 | onboardingInfo: { 494 | __typename: "OnboardingInfo", 495 | id: "d26ed1ed-5f70-4096-9d8e-27ef258e26fa", 496 | accountCountry: "FRA", 497 | email: null, 498 | language: null, 499 | redirectUrl: "", 500 | tcuUrl: 501 | "https://document-factory.sandbox.master.oina.ws/swanTCU/7649fada-a1c8-4537-bd3c-d539664a841c.pdf?Expires=1712229767&Key-Pair-Id=KTRMJ5W6BT4MH&Signature=eRpFq3ChqRx7KUVM5bhzPoX7uIxaCyJycw~wTAPDKslc-oq4OwKCrB1mm8efx~wdwuauT0b80EoPidCsoMEdYKvT7LE-H12HKizLYaHXxVNevmWZMR2zqN1v9bi77oIhVEQZmV9uGCluDypvWQn9eu3ICOMJr8k0dn7f4K0jQyAMju1AqBg3~jeeAbOD1Y0hA0T2zeJ~OLDahJB54kFDt~UbdwEylgjh1V-tg5GvXs1w268aax98DpHrORttnaSTLvB7PlMIJJbgQIy712OO2~zg6dggMSlWHk0J3243xsd65eWhLPeLVt8jlnYBMvc0Iscd4k12iVWKWRozckglNQ__", 502 | legalRepresentativeRecommendedIdentificationLevel: "PVID", 503 | oAuthRedirectParameters: null, 504 | onboardingState: "Ongoing", 505 | projectInfo: { 506 | __typename: "ProjectInfo", 507 | id: "64060573-f0ec-4204-ad49-a3983497ada4", 508 | accentColor: "#38945D", 509 | name: "bloodyowl", 510 | logoUri: 511 | "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/64060573-f0ec-4204-ad49-a3983497ada4/SANDBOX/logo-5733f69e-8223-4b7e-92c7-0fed9eaaca33.png", 512 | tcuDocumentUri: 513 | "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/64060573-f0ec-4204-ad49-a3983497ada4/SANDBOX/tcu/bb87c4f2-de5f-4df7-b617-91f2c0eb03f4/en.pdf", 514 | }, 515 | supportingDocumentCollection: { 516 | __typename: "SupportingDocumentCollection", 517 | id: "55561d8f-6a90-41f2-be48-e14c82e3cc34", 518 | requiredSupportingDocumentPurposes: [], 519 | statusInfo: { 520 | __typename: "SupportingDocumentCollectionWaitingForDocumentStatusInfo", 521 | status: "WaitingForDocument", 522 | }, 523 | supportingDocuments: [], 524 | }, 525 | info: { 526 | __typename: "OnboardingIndividualAccountHolderInfo", 527 | residencyAddress: { 528 | __typename: "AddressInfo", 529 | addressLine1: null, 530 | addressLine2: null, 531 | city: null, 532 | country: null, 533 | postalCode: null, 534 | state: null, 535 | }, 536 | taxIdentificationNumber: null, 537 | employmentStatus: null, 538 | monthlyIncome: null, 539 | }, 540 | statusInfo: { 541 | __typename: "OnboardingInvalidStatusInfo", 542 | errors: [ 543 | { 544 | __typename: "ValidationError", 545 | field: "email", 546 | errors: ["Missing"], 547 | }, 548 | { 549 | __typename: "ValidationError", 550 | field: "employmentStatus", 551 | errors: ["Missing"], 552 | }, 553 | { 554 | __typename: "ValidationError", 555 | field: "monthlyIncome", 556 | errors: ["Missing"], 557 | }, 558 | { 559 | __typename: "ValidationError", 560 | field: "language", 561 | errors: ["Missing"], 562 | }, 563 | { 564 | __typename: "ValidationError", 565 | field: "residencyAddress.addressLine1", 566 | errors: ["Missing"], 567 | }, 568 | { 569 | __typename: "ValidationError", 570 | field: "residencyAddress.city", 571 | errors: ["Missing"], 572 | }, 573 | { 574 | __typename: "ValidationError", 575 | field: "residencyAddress.country", 576 | errors: ["Missing"], 577 | }, 578 | { 579 | __typename: "ValidationError", 580 | field: "residencyAddress.postalCode", 581 | errors: ["Missing"], 582 | }, 583 | ], 584 | }, 585 | }, 586 | }; 587 | 588 | export const bindMembershipMutationRejectionResponse = { 589 | __typename: "Mutation", 590 | bindAccountMembership: { 591 | __typename: "BadAccountStatusRejection", 592 | message: "Account is in invalid status", 593 | }, 594 | }; 595 | 596 | export const bindMembershipMutationSuccessResponse = { 597 | __typename: "Mutation", 598 | bindAccountMembership: { 599 | __typename: "BindAccountMembershipSuccessPayload", 600 | accountMembership: { 601 | __typename: "AccountMembership", 602 | id: "account-membership-2", 603 | user: { 604 | __typename: "User", 605 | id: "user-2", 606 | firstName: "Mathieu", 607 | lastName: "Acthernoene", 608 | identificationLevels: { 609 | __typename: "IdentificationLevels", 610 | expert: true, 611 | PVID: true, 612 | QES: false, 613 | }, 614 | }, 615 | }, 616 | }, 617 | }; 618 | 619 | export const brandingQuery = graphql(` 620 | query getBrandingPage($projectId: ID!) { 621 | project(id: $projectId) { 622 | id 623 | activatedInLive 624 | sandboxProjectSettings { 625 | ...BrandingInfo 626 | } 627 | liveProjectSettings { 628 | ...BrandingInfo 629 | } 630 | } 631 | } 632 | 633 | fragment BrandingInfo on ProjectSettings { 634 | __typename 635 | id 636 | name 637 | accentColor 638 | logoUri 639 | } 640 | `); 641 | 642 | export const brandingResponse = { 643 | __typename: "Query", 644 | project: { 645 | __typename: "Project", 646 | id: "64060573-f0ec-4204-ad49-a3983497ada4", 647 | activatedInLive: false, 648 | sandboxProjectSettings: { 649 | __typename: "SandboxProjectSettings", 650 | id: "e73d3a09-98ad-4abd-8c83-75d3ac86e4f7", 651 | name: "bloodyowl", 652 | accentColor: "#38945D", 653 | logoUri: 654 | "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/64060573-f0ec-4204-ad49-a3983497ada4/SANDBOX/logo-5733f69e-8223-4b7e-92c7-0fed9eaaca33.png", 655 | }, 656 | liveProjectSettings: { 657 | __typename: "LiveProjectSettings", 658 | id: "2c35f812-3763-44cc-b102-9d7fc9991407", 659 | name: "bloodyowl", 660 | accentColor: "#65E197", 661 | logoUri: 662 | "https://s3.eu-west-1.amazonaws.com/data.master.oina.ws/8c2bfcb0-d18a-4cf0-bb2a-3805930661be", 663 | }, 664 | }, 665 | }; 666 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["example", "test"], 5 | "compilerOptions": { "noEmit": false } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["example", "src", "test"], 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "target": "ES2019", 6 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Node", 9 | "outDir": "dist/", 10 | 11 | "allowJs": false, 12 | "declaration": true, 13 | "noEmit": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "stripInternal": true, 18 | 19 | // https://www.typescriptlang.org/tsconfig#Type_Checking_6248 20 | "allowUnreachableCode": false, 21 | "allowUnusedLabels": false, 22 | "exactOptionalPropertyTypes": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noImplicitOverride": true, 25 | "noImplicitReturns": false, 26 | "noUncheckedIndexedAccess": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "resolveJsonModule": true, 30 | "allowSyntheticDefaultImports": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "src/index.ts" }, 5 | format: ["cjs", "esm"], 6 | target: "es2019", 7 | tsconfig: "./tsconfig.build.json", 8 | clean: true, 9 | dts: false, 10 | sourcemap: true, 11 | treeshake: true, 12 | }); 13 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | test: { 3 | environment: "jsdom", 4 | include: ["**/*.{test,spec}.ts"], 5 | }, 6 | }; 7 | --------------------------------------------------------------------------------