├── .babelrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── component-test-backup ├── ComponentA.svelte └── test1_test.js ├── demo ├── App.svelte ├── index.d.ts ├── index.html ├── index.js ├── jsconfig.json ├── main-demo │ ├── BasicCodeSandbox.svelte │ ├── DemoRoot.svelte │ ├── util │ │ └── history-utils.ts │ └── view-data │ │ ├── CacheMutation.svelte │ │ ├── EditBook.svelte │ │ ├── EditSubject.svelte │ │ ├── HardReset.svelte │ │ ├── ShowBooks.svelte │ │ ├── ShowData.svelte │ │ ├── ShowSubjects.svelte │ │ ├── SoftReset.svelte │ │ ├── cacheHelpers.js │ │ ├── hardResetHelpers.js │ │ └── softResetHelpers.js ├── package-lock.json ├── package.json ├── public │ └── favicon.ico ├── savedQueries.js ├── tsconfig.json └── vite.config.js ├── docs ├── README.md ├── compress │ ├── README.md │ └── index.html ├── docup.min.css ├── docup.min.js ├── index.html ├── overrides.css └── svelte-logo.png ├── jest.config.js ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── src ├── cache.ts ├── client.ts ├── compress.ts ├── index.ts ├── mutation.ts ├── mutationManager.ts ├── mutationTypes.ts ├── query.ts ├── queryManager.ts └── queryTypes.ts ├── test ├── GraphQLTypes.ts ├── cacheFeedsQueryCorrectly.test.ts ├── cacheQueriesAreCorrect.test.ts ├── cacheSize.test.ts ├── cachedResultSynchronouslyAvailable.test.ts ├── cachedResultsAppropriatelyAvailable.test.ts ├── clientCreation.test.ts ├── clientMock.ts ├── clientPreload.test.ts ├── compress.test.ts ├── initialState.test.ts ├── mutation.test.ts ├── mutationEvents.test.ts ├── mutationEventsMatch.test.ts ├── mutationEventsRespectActiveStatus.test.ts ├── postQueryProcess.test.ts ├── query.test-data-store.test.ts ├── query.test.ts ├── queryActive.test.ts ├── queryInitialSearch.test.ts ├── refresh.test.ts ├── softResetImperativeEmptyCall.test.ts ├── testUtil.ts ├── unmount.test.ts └── unmountTestComponents │ ├── component1.svelte │ ├── component2.svelte │ └── component3.svelte ├── tsconfig.json ├── tsconfig.release.json └── vite.config.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | umd/ 4 | lib/ 5 | lib-es5/ 6 | coverage/ 7 | index-es5.min.js 8 | index.min.js 9 | .DS_Store 10 | test/.DS_Store 11 | index-es5.js 12 | index.js 13 | .env 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .git 3 | .travis.yml 4 | .vscode 5 | component-test-backup 6 | coverage 7 | demo 8 | docs 9 | jest.config.js 10 | node_modules 11 | rollup.config.js 12 | test -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "arrowParens": "avoid", 4 | "trailingComma": "none" 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14.16.0" 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}\\runServer.js" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "files.exclude": { 4 | "index*.js": false 5 | } 6 | } -------------------------------------------------------------------------------- /component-test-backup/ComponentA.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 | {val} 8 | 9 |
10 | -------------------------------------------------------------------------------- /component-test-backup/test1_test.js: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/svelte"; 2 | 3 | import Comp from "./ComponentA"; 4 | 5 | test("Test A", async () => { 6 | const { getByTestId, getByText, container } = render(Comp, { val: 5 }); 7 | 8 | const span = getByTestId("sp"); 9 | const span2 = container.querySelector("span"); 10 | const inc = getByText("Inc"); 11 | const dec = getByText("Dec"); 12 | 13 | expect(typeof span).toBe("object"); 14 | expect(span.textContent).toBe("5"); 15 | 16 | await fireEvent.click(inc); 17 | expect(span.textContent).toBe("6"); 18 | 19 | await fireEvent.click(dec); 20 | await fireEvent.click(dec); 21 | await fireEvent.click(dec); 22 | 23 | expect(span.textContent).toBe("3"); 24 | expect(span2.textContent).toBe("3"); 25 | }); 26 | 27 | 28 | test("Test B", async () => { 29 | expect(11).toBe(11); 30 | }); 31 | 32 | 33 | -------------------------------------------------------------------------------- /demo/App.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demo/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const value: any; 3 | export default value; 4 | } 5 | 6 | declare module "*.scss" { 7 | const value: any; 8 | export default value; 9 | } 10 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Svelte + Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import App from "./App.svelte"; 2 | 3 | import { Client, setDefaultClient } from "../src/index"; 4 | 5 | const client = new Client({ 6 | endpoint: "https://mylibrary.io/graphql-public", 7 | fetchOptions: { mode: "cors" } 8 | }); 9 | 10 | setDefaultClient(client); 11 | 12 | const app = new App({ 13 | target: document.getElementById("home"), 14 | props: {} 15 | }); 16 | -------------------------------------------------------------------------------- /demo/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "esnext", 6 | /** 7 | * svelte-preprocess cannot figure out whether you have 8 | * a value or a type, so tell TypeScript to enforce using 9 | * `import type` instead of `import` for Types. 10 | */ 11 | "importsNotUsedAsValues": "error", 12 | "isolatedModules": true, 13 | "resolveJsonModule": true, 14 | /** 15 | * To have warnings / errors of the Svelte compiler at the 16 | * correct position, enable source maps by default. 17 | */ 18 | "sourceMap": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "baseUrl": ".", 23 | /** 24 | * Typecheck JS in `.svelte` and `.js` files by default. 25 | * Disable this if you'd like to use dynamic types. 26 | */ 27 | "checkJs": true 28 | }, 29 | /** 30 | * Use global.d.ts instead of compilerOptions.types 31 | * to avoid limiting type declarations. 32 | */ 33 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] 34 | } 35 | -------------------------------------------------------------------------------- /demo/main-demo/BasicCodeSandbox.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {$booksState.currentQuery} 13 | 14 | {#if $booksState.loading}LOADING{/if} 15 | 16 | {#if $booksState.loaded} 17 | 22 | {/if} 23 | -------------------------------------------------------------------------------- /demo/main-demo/DemoRoot.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 | 30 | 31 | 32 | 33 |
{JSON.stringify({ page, search })}
34 |
{JSON.stringify($searchStateStore)}
35 | 36 |
37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 | -------------------------------------------------------------------------------- /demo/main-demo/util/history-utils.ts: -------------------------------------------------------------------------------- 1 | import createHistory from "history/createBrowserHistory"; 2 | import queryString from "query-string"; 3 | 4 | export const history = createHistory(); 5 | 6 | export function getCurrentUrlState() { 7 | let location = history.location; 8 | let parsed = queryString.parse(location.search) as any; 9 | 10 | if ("userId" in parsed && !parsed.userId) { 11 | parsed.userId = "-1"; //make it truthy so we know it's there 12 | } 13 | 14 | return { 15 | pathname: location.pathname, 16 | searchState: parsed 17 | }; 18 | } 19 | 20 | export function getSearchState() { 21 | const { searchState } = getCurrentUrlState(); 22 | 23 | return { 24 | page: +searchState.page || 1 as any, 25 | search: searchState.search || void 0 26 | }; 27 | } 28 | 29 | export function setSearchValues(state) { 30 | let { pathname, searchState: existingSearchState } = getCurrentUrlState(); 31 | let newState = { ...existingSearchState, ...state }; 32 | newState = Object.keys(newState) 33 | .filter(k => newState[k]) 34 | .reduce((hash, prop) => ((hash[prop] = newState[prop]), hash), {}); 35 | 36 | history.push({ 37 | pathname: history.location.pathname, 38 | search: queryString.stringify(newState) 39 | }); 40 | } -------------------------------------------------------------------------------- /demo/main-demo/view-data/CacheMutation.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/EditBook.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | 38 |
Edit {book.title}
39 | 40 | {#if missingTitle}Enter a title please{/if} 41 | 42 | 43 | {#if $mutationState.running}Saving ...{/if} 44 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/EditSubject.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | 38 |
Edit {subject.name}
39 | 40 | {#if missingName}Enter a name please{/if} 41 | 42 | 43 | {#if $mutationState.running}Saving ...{/if} 44 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/HardReset.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/ShowBooks.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if editingBook} 9 | (editingBook = null)} /> 10 | {/if} 11 | 12 | {#if data?.loading} 13 |

Loading...

14 | {/if} 15 | 16 | 17 | 18 | 19 | 21 | 22 | 24 | 25 | 26 | {#each data?.data?.allBooks?.Books ?? [] as book} 27 | 28 | 29 | 30 | 31 | 32 | 33 | {/each} 34 | 35 |
20 | TitleAuthors 23 |
{book.title}{book.authors.join(", ")}
36 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/ShowData.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 | 29 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/ShowSubjects.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if editingSubject} 9 | (editingSubject = null)} /> 10 | {/if} 11 | 12 | {#if data?.loading} 13 |

Loading...

14 | {/if} 15 | 16 |
    17 | {#each data?.data?.allSubjects?.Subjects ?? [] as subject} 18 |
  • {subject.name}
  • 19 | {/each} 20 |
21 | 22 | 28 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/SoftReset.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/cacheHelpers.js: -------------------------------------------------------------------------------- 1 | import { getDefaultClient } from "../../../src/index"; 2 | 3 | export const syncCollection = (current, newResultsLookup) => { 4 | return current.map(item => { 5 | const updatedItem = newResultsLookup.get(item._id); 6 | return updatedItem ? Object.assign({}, item, updatedItem) : item; 7 | }); 8 | }; 9 | 10 | export const syncQueryToCache = (query, type) => { 11 | const graphQLClient = getDefaultClient(); 12 | graphQLClient.subscribeMutation([ 13 | { 14 | when: new RegExp(`update${type}s?`), 15 | run: ({ refreshActiveQueries }, resp, variables) => { 16 | const cache = graphQLClient.getCache(query); 17 | const newResults = resp[`update${type}`] 18 | ? [resp[`update${type}`][type]] 19 | : resp[`update${type}s`][`${type}s`]; 20 | const newResultsLookup = new Map(newResults.map(item => [item._id, item])); 21 | 22 | for (let [uri, { data }] of cache.entries) { 23 | data[`all${type}s`][`${type}s`] = syncCollection( 24 | data[`all${type}s`][`${type}s`], 25 | newResultsLookup 26 | ); 27 | } 28 | 29 | refreshActiveQueries(query); 30 | } 31 | } 32 | ]); 33 | }; 34 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/hardResetHelpers.js: -------------------------------------------------------------------------------- 1 | import { query } from "../../../src/index"; 2 | 3 | export const hardResetQuery = (type, queryToRun, options = {}) => 4 | query(queryToRun, { 5 | ...options, 6 | onMutation: { 7 | when: new RegExp(`(update|create|delete)${type}s?`), 8 | run: ({ hardReset }) => hardReset() 9 | } 10 | }); 11 | 12 | export const bookHardResetQuery = (...args) => hardResetQuery("Book", ...args); 13 | export const subjectHardResetQuery = (...args) => hardResetQuery("Subject", ...args); 14 | -------------------------------------------------------------------------------- /demo/main-demo/view-data/softResetHelpers.js: -------------------------------------------------------------------------------- 1 | import { query } from "../../../src/index"; 2 | 3 | export const softResetQuery = (type, queryToUse, options = {}) => 4 | query(queryToUse, { 5 | ...options, 6 | onMutation: { 7 | when: new RegExp(`update${type}s?`), 8 | run: ({ softReset, currentResults }, resp) => { 9 | const updatedItems = resp[`update${type}s`]?.[`${type}s`] ?? [resp[`update${type}`][type]]; 10 | updatedItems.forEach(updatedItem => { 11 | let CachedItem = currentResults[`all${type}s`][`${type}s`].find( 12 | item => item._id == updatedItem._id 13 | ); 14 | CachedItem && Object.assign(CachedItem, updatedItem); 15 | }); 16 | softReset(currentResults); 17 | } 18 | } 19 | }); 20 | 21 | export const bookSoftResetQuery = (...args) => softResetQuery("Book", ...args); 22 | export const subjectSoftResetQuery = (...args) => softResetQuery("Subject", ...args); 23 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-project", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@sveltejs/vite-plugin-svelte": "^1.0.0-next.30", 13 | "history": "^4.6.1", 14 | "query-string": "^6.2.0", 15 | "svelte": "^3.44.0", 16 | "vite": "^2.9.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arackaf/micro-graphql-svelte/0c4b89f9cb026cd15c1a141501fa7a0c04980ec4/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/savedQueries.js: -------------------------------------------------------------------------------- 1 | export const BOOKS_QUERY = ` 2 | query ALL_BOOKS($page: Int, $search: String) { 3 | allBooks(SORT: { title: 1 }, PAGE: $page, PAGE_SIZE: 10, title_contains: $search) { 4 | Books { _id title pages, subjects, authors, publisher, publicationDate, isbn, dateAdded, smallImage } 5 | Meta { count } 6 | } 7 | }`; 8 | 9 | export const BOOKS_MUTATION = `mutation modifyBook($_id: String, $title: String, $pages: Int) { 10 | updateBook(_id: $_id, Updates: { title: $title, pages: $pages }) { 11 | Book { _id title pages } 12 | } 13 | }`; 14 | 15 | export const MODIFY_BOOK_TITLE = `mutation modifyBook($_id: String, $title: String) { 16 | updateBook(_id: $_id, Updates: { title: $title }) { 17 | success 18 | Book { _id title pages } 19 | } 20 | }`; 21 | 22 | export const MODIFY_BOOK_PAGE = `mutation modifyBook($_id: String, $pages: Int) { 23 | updateBook(_id: $_id, Updates: { pages: $pages }) { 24 | success 25 | Book { _id title pages } 26 | } 27 | }`; 28 | 29 | export const BOOKS_MUTATION_MULTI = `mutation modifyBooks($_ids: [String], $title: String, $pages: Int) { 30 | updateBooks(_ids: $_ids, Updates: { title: $title, pages: $pages }) { 31 | Books { _id title pages } 32 | } 33 | }`; 34 | 35 | export const BOOK_DELETE = `mutation deleteBook($_id: String) { 36 | deleteBook(_id: $_id) 37 | }`; 38 | 39 | export const BOOK_CREATE = `mutation createBook($Book: BookInput) { 40 | createBook(Book: $Book) { Book { _id title } } 41 | }`; 42 | 43 | export const ALL_SUBJECTS_QUERY = ` 44 | query ALL_SUBJECTS { 45 | allSubjects(SORT: { name: 1 }) { 46 | Subjects { _id, name, textColor, backgroundColor } 47 | } 48 | }`; 49 | 50 | export const SUBJECTS_QUERY = ` 51 | query ALL_SUBJECTS($page: Int) { 52 | allSubjects(SORT: { name: 1 }, PAGE: $page, PAGE_SIZE: 10) { 53 | Subjects { _id name } 54 | } 55 | }`; 56 | 57 | export const SUBJECT_MUTATION = `mutation modifySubject($_id: String, $name: String) { 58 | updateSubject(_id: $_id, Updates: { name: $name }) { 59 | Subject { _id name } 60 | } 61 | }`; 62 | 63 | export const SUBJECTS_MUTATION_MULTI = `mutation modifySubjects($_ids: [String], $name: String) { 64 | updateSubjects(_ids: $_ids, Updates: { name: $name }) { 65 | Subjects { _id name } 66 | } 67 | }`; 68 | 69 | export const SUBJECT_DELETE = `mutation deleteSubject($_id: String) { 70 | deleteSubject(_id: $_id) 71 | }`; 72 | 73 | export const SUBJECT_CREATE = `mutation createSubject($Subject: SubjectInput) { 74 | createSubject(Subject: $Subject) { Subject { _id name } } 75 | }`; 76 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": true, 6 | "baseUrl": "./", 7 | "experimentalDecorators": true, 8 | "jsx": "react", 9 | "lib": ["es2015", "es2017", "dom"], 10 | "module": "esNext", 11 | "moduleResolution": "node", 12 | "noEmit": true, 13 | "paths": { 14 | "@reach/dialog": ["./typings/modules/@reach/dialog"] 15 | }, 16 | "target": "esnext" 17 | }, 18 | "include": ["./**/*.ts", "./**/*.tsx"], 19 | "exclude": ["./node_modules", "./dist", "./service-worker"] 20 | } 21 | -------------------------------------------------------------------------------- /demo/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [svelte()] 7 | }) 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```javascript 4 | npm i micro-graphql-svelte --save 5 | ``` 6 | 7 | **Note** - this project ships standard, modern JavaScript (ES6, object spread, etc) that works in all evergreen browsers. If you need to support ES5 environments like IE11, just add an alias pointing to the ES5 build in your webpack config like so 8 | 9 | ```javascript 10 | alias: { 11 | "micro-graphql-svelte": "node_modules/micro-graphql-svelte/index-es5.js" 12 | }, 13 | ``` 14 | 15 | (`alias` goes under the `resolve` section in webpack.config.js) 16 | 17 | ## Creating a client 18 | 19 | Before you do anything, you'll need to create a client. 20 | 21 | ```javascript 22 | import { Client, setDefaultClient } from "micro-graphql-svelte"; 23 | 24 | const client = new Client({ 25 | endpoint: "/graphql", 26 | fetchOptions: { credentials: "include" } 27 | }); 28 | 29 | setDefaultClient(client); 30 | ``` 31 | 32 | Now that client will be used by default, everywhere, unless you manually pass in a different client to a query's options, as discussed below. 33 | 34 | ### Accessing the client 35 | 36 | To access the default client anywhere in your codebase, use the `getDefaultClient` method. 37 | 38 | ```javascript 39 | import { getDefaultClient } from "micro-graphql-svelte"; 40 | 41 | const client = getDefaultClient(); 42 | ``` 43 | 44 | ### Client options 45 | 46 | 47 | | Option | Description | 48 | | -------| ----------- | 49 | | `endpoint` | URL for your GraphQL endpoint | 50 | | `fetchOptions` | Options to send along with all fetches| 51 | | `cacheSize` | Default cache size to use for all caches created by this client, as needed, for all queries it processes| 52 | 53 | ### Client api 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
Option 59 | Description 60 |
runQuery(query, variables)Manually run this GraphQL query
runMutation(mutation, variables)Manually run this GraphQL mutation
forceUpdate(query)Manually update any components rendering that query. This is useful if you (dangerously) update a query's cache, as discussed in the caching section, below
subscribeMutation({ when, run })Manually subscribe to a mutation, in order to manually update the cache. See below for more info
81 | 82 | ## Running queries and mutations 83 | 84 | ### Preloading queries 85 | 86 | It's always a good idea to preload a query as soon as you know it'll be requested downstream by a (possibly lazy loaded) component. To preload a query, call the `preload` method on the client, and pass a query, and any args you might have. 87 | 88 | ```javascript 89 | import { getDefaultClient } from "micro-graphql-svelte"; 90 | 91 | const client = getDefaultClient(); 92 | client.preload(YourQuery, variables); 93 | ``` 94 | 95 | ### Queries 96 | 97 | ```js 98 | import { query } from "micro-graphql-svelte"; 99 | 100 | let { queryState, resultsState, sync } = query(YOUR_QUERY); 101 | $: booksSync($searchState); 102 | ``` 103 | 104 | `query` takes the following arguments 105 | 106 | 107 | | Arg | Description | 108 | | -------| ----------- | 109 | | `query: string` | The query text | 110 | | `options: object` | The query's options (optional) | 111 | 112 | The options argument, if supplied, can contain these properties 113 | 114 | 115 | | Option | Description | 116 | | -------| ----------- | 117 | | `onMutation` | A map of mutations, along with handlers. This is how you update your cached results after mutations, and is explained more fully below | 118 | | `client` | Manually pass in a client to be used for this query, which will override the default client| 119 | | `cache` | Manually pass in a cache object to be used for this query, which will override the default cache| 120 | | `initialSearch` | If you'd like to run a query immediately, without calling the returned sync method, provide the arguments object here.| 121 | | `activate` | Optional function that will run whenever the query becomes active, in other words the query store is subscribed by at least one component, or manual call to `.subscribe`.| 122 | | `deactivate` | Optional function that will run whenever the query becomes in-active, in other words the query store is not subscribed by any components, or manual calls to `.subscribe`.| 123 | | `postProcess` | An optional function to run on new search results. You can perform side effects in here, ie preloading images, and optionally return new results, which will then become the results for this query. If you return nothing, the original results will be used. | 124 | 125 | Be sure to use the `compress` tag to remove un-needed whitespace from your query text, since it will be sent via HTTP GET—for more information, see [here](./compress). An even better option would be to use my [persisted queries helper](https://github.com/arackaf/generic-persistgraphql). This not only removes the entire query text from your network requests altogether, but also from your bundled code. 126 | 127 | `query` returns an object with the following properties: `queryState`, a store with your query's current results; a `sync` function, which you can call anytime to update the query's current variables; and `resultsState`, a store with just the current query's actual data results—in other words a straight projection of `$queryState.data`. Your query will not actually run until you've called sync. If your query does not need any variables, just call it immediately with an empty object, or supply an `initialSearch` value (see above). 128 | 129 | If you need to run code when the current query's results (ie `data`), and **only** the current query's results changes, use the `resultsState` store in your component, since reactive blocks referencing `$resultsState` will frequently fire even when `data` has not changed, for example when `loading` has been set to true. 130 | 131 | ### Query results 132 | 133 | The `queryState` store has the following properties 134 | 135 | 136 | | Props | Description | 137 | | ----- | ----------- | 138 | |`loading`|Fetch is executing for your query| 139 | |`loaded`|Fetch has finished executing for your query| 140 | |`data`|If the last fetch finished successfully, this will contain the data returned, else null| 141 | |`currentQuery`|The query that was run, which produced the current results. This updates synchronously with updates to `data`, so you can use changes here as an easy way to subscribe to query result changes. This will not have a value until there are results passed to `data`. In other words, changes to `loading` do not affect this value| 142 | |`error`|If the last fetch did not finish successfully, this will contain the errors that were returned, else `null`| 143 | |`reload`|`function`: Manually re-fetches the current query| 144 | |`clearCache`|`function`: Clear the cache for this query| 145 | | `softReset` |`function`: Clears the cache, but does **not** re-issue any queries. It can optionally take an argument of new, updated results, which will replace the current `data` props | 146 | | `hardReset` |`function`: Clears the cache, and re-load the current query from the network| 147 | 148 | ### Mutations 149 | 150 | ```js 151 | import { mutation } from "micro-graphql-svelte"; 152 | 153 | const { mutationState } = mutation(YOUR_MUTATION); 154 | ``` 155 | 156 | The mutation function takes the following arguments. 157 | 158 | 159 | | Arg | Description | 160 | | ------------- | --------- | 161 | | `mutation: string` | Mutation text | 162 | | `options: object` | Mutation options (optional) | 163 | 164 | The options argument, if supplied, can contain this property 165 | 166 | 167 | | Option | Description | 168 | | ------------- | --------- | 169 | | `client` | Override the client used | 170 | 171 | ### Mutation results 172 | 173 | `mutation` returns a store with the following properties. 174 | 175 | 176 | | Option | Description | 177 | | ------------- | --------- | 178 | | `running` | Mutation is executing | 179 | | `finished` | Mutation has finished executing| 180 | | `runMutation` | A function you can call when you want to run your mutation. Pass it your variables | 181 | 182 | ## Caching 183 | 184 | The client object maintains a cache of each query it comes across when processing your components. The cache is LRU with a default size of 10 and, again, stored at the level of each specific query, not the GraphQL type. As your instances mount and unmount, and update, the cache will be checked for existing results to matching queries. 185 | 186 | ### Cache object 187 | 188 | You can import the `Cache` class like this 189 | 190 | ```javascript 191 | import { Cache } from "micro-graphql-svelte"; 192 | ``` 193 | 194 | When instantiating a new cache object, you can optionally pass in a cache size. 195 | 196 | ```javascript 197 | let cache = new Cache(15); 198 | ``` 199 | 200 | #### Cache api 201 | 202 | The cache object has the following properties and methods 203 | 204 | 205 | | Member | Description | 206 | | ----- | --------- | 207 | | `get entries()` | An array of the current entries. Each entry is an array of length 2, of the form `[key, value]`. The cache entry key is the actual GraphQL url query that was run. If you'd like to inspect it, see the variables that were sent, etc, just use your favorite url parsing utility, like `url-parse`. And of course the cache value itself is whatever the server sent back for that query. If the query is still pending, then the entry will be a promise for that request. | 208 | | `get(key)` | Gets the cache entry for a particular key | 209 | | `set(key, value)` | Sets the cache entry for a particular key | 210 | | `delete(key)` | Deletes the cache entry for a particular key | 211 | | `clearCache()` | Clears all entries from the cache | 212 | 213 | ### Cache invalidation 214 | 215 | The onMutation option that query options take is an object, or array of objects of the form `{ when: string|regularExpression, run: function }` 216 | 217 | `when` is a string or regular expression that's tested against each result of any mutations that finish. If the mutation has any result set names that match, `run` will be called with three arguments: an object with these properties, described below, `{ softReset, currentResults, hardReset, cache, refresh }`; the entire mutation result; and the mutation's `variables` object. 218 | 219 | 220 | | Arg | Description | 221 | | ---| -------- | 222 | | `softReset` | Clears the cache, but does **not** re-issue any queries. It can optionally take an argument of new, updated results, which will replace the current `data` props | 223 | | `currentResults` | The current results that are passed as your `data` prop | 224 | | `hardReset` | Clears the cache, and re-load the current query from the network| 225 | | `cache` | The actual cache object. You can enumerate its entries, and update whatever you need| 226 | | `refresh` | Refreshes the current query, from cache if present. You'll likely want to call this after modifying the cache | 227 | 228 | Many use cases follow. They're based on a hypothetical book tracking website. 229 | 230 | The code below uses a publicly available GraphQL endpoint created by my [mongo-graphql-starter project](https://github.com/arackaf/mongo-graphql-starter). You can run these examples from the demo folder of this repository. Just run `npm i` then run the `npm run demo` and `npm starte` scripts in separate terminals, and open `http://localhost:8082/` 231 | 232 | #### Hard Reset: Reload the query after any relevant mutation 233 | 234 | Let's say whenever a mutation happens, we want to immediately invalidate any related queries' caches, and reload the current data from the network. We understand this may cause a book we just edited to immediately disappear from our current search results, since it no longer matches our search criteria. 235 | 236 | The `hardReset` method that's passed makes this easy. Let's see how to use this in a (contrived) component that queries, and displays some books and subjects. 237 | 238 | ```svelte 239 | 257 | 258 | 259 | ``` 260 | 261 | Here we specify a regex matching every kind of book, or subject mutation, and upon completion, we just clear the cache, and reload by calling `hardReset()`. It's hard not to be a littler dissatisfied with this solution; the boilerplate is non-trivial. 262 | 263 | Assuming our GraphQL operations have a consistent naming structure—and they should, and in this case do—then some pretty obvious patterns emerge. We can write some basic helpers to remove some of this boilerplate. 264 | 265 | ```javascript 266 | //hardResetHelpers.js 267 | import { query } from "micro-graphql-svelte"; 268 | 269 | export const hardResetQuery = (type, queryToRun, options = {}) => 270 | query(queryToRun, { 271 | ...options, 272 | onMutation: { 273 | when: new RegExp(`(update|create|delete)${type}s?`), 274 | run: ({ hardReset }) => hardReset() 275 | } 276 | }); 277 | ``` 278 | 279 | which we _could_ use like this 280 | 281 | ```svelte 282 | 296 | 297 | 298 | ``` 299 | 300 | but really, why not go the extra mile and make wrappers for our various types, like so 301 | 302 | ```javascript 303 | //hardResetHelpers.js 304 | import { query } from "micro-graphql-svelte"; 305 | 306 | export const hardResetQuery = (type, queryToRun, options = {}) => 307 | query(queryToRun, { 308 | ...options, 309 | onMutation: { 310 | when: new RegExp(`(update|create|delete)${type}s?`), 311 | run: ({ hardReset }) => hardReset() 312 | } 313 | }); 314 | 315 | export const bookHardResetQuery = (...args) => hardResetQuery("Book", ...args); 316 | export const subjectHardResetQuery = (...args) => hardResetQuery("Subject", ...args); 317 | ``` 318 | 319 | which simplifies the code to just this 320 | 321 | ```svelte 322 | 336 | 337 | 338 | ``` 339 | 340 | #### Soft Reset: Update current results, but clear the cache 341 | 342 | Assume that after a mutation you want to update your current results based on what was changed, clear all cache entries, including the existing one, but **not** run any network requests. So if you're currently searching for an author of Dumas Malone, but one of the current results was written by Shelby Foote, and you click the book's edit button and fix it, you want that book to now show the updated value, but stay in the current results, since re-loading the current query and having the book just vanish is bad UX in your opinion. 343 | 344 | Here's the same component from above, but with our new cache strategy 345 | 346 | ```svelte 347 | 385 | 386 | 387 | 388 | ``` 389 | 390 | Whenever a mutation comes back with `updateBook` or `updateBooks` results, we manually update our current results, then call `softReset`, which clears our cache, including the current cache result; so if you page up, then come back down to where you were, a **new** network request will be run, and your edited books may no longer be there, if they no longer match the search results. And likewise for subjects. 391 | 392 | Obviously this is more boilerplate than we'd ever want to write in practice, so let's tuck it behind a helper, like we did before. 393 | 394 | ```javascript 395 | //softResetHelpers.js 396 | import { query } from "micro-graphql-svelte"; 397 | 398 | export const softResetQuery = (type, queryToUse, options = {}) => 399 | query(queryToUse, { 400 | ...options, 401 | onMutation: { 402 | when: new RegExp(`update${type}s?`), 403 | run: ({ softReset, currentResults }, resp) => { 404 | const updatedItems = resp[`update${type}s`]?.[`${type}s`] ?? [resp[`update${type}`][type]]; 405 | updatedItems.forEach(updatedItem => { 406 | let CachedItem = currentResults[`all${type}s`][`${type}s`].find(item => item._id == updatedItem._id); 407 | CachedItem && Object.assign(CachedItem, updatedItem); 408 | }); 409 | softReset(currentResults); 410 | } 411 | } 412 | }); 413 | 414 | export const bookSoftResetQuery = (...args) => softResetQuery("Book", ...args); 415 | export const subjectSoftResetQuery = (...args) => softResetQuery("Subject", ...args); 416 | ``` 417 | 418 | which we can use like this 419 | 420 | ```svelte 421 | 435 | 436 | 437 | ``` 438 | 439 | #### Manually update all affected cache entries 440 | 441 | Let's say you want to intercept mutation results, and manually update your cache. This is difficult to get right, so be careful. You'll likely only want to do this with data that are not searched or filtered, but even then, softReset will likely be good enough. 442 | 443 | For this, we can call the `subscribeMutation` method on the client object, and pass in the same `when` test, and `run` callback as before. Except now the `run` callback will receive a `refreshActiveQueries` callback, which we can use to force any queries showing data from a particular query to update itself from the now-updated cache. This function returns a cleanup function which you can call to remove the subscription. 444 | 445 | The manual solution might look something like this 446 | 447 | ```javascript 448 | //cacheHelpers.js 449 | export const syncCollection = (current, newResultsLookup) => { 450 | return current.map(item => { 451 | const updatedItem = newResultsLookup.get(item._id); 452 | return updatedItem ? Object.assign({}, item, updatedItem) : item; 453 | }); 454 | }; 455 | ``` 456 | 457 | and 458 | 459 | ```svelte 460 | 516 | 517 | 518 | ``` 519 | 520 | It's a lot of code, but as always the idea is that you'd wrap it all into some re-usable helpers, like this 521 | 522 | ```javascript 523 | //cacheHelpers.js 524 | import { getDefaultClient } from "micro-graphql-svelte"; 525 | 526 | export const syncCollection = (current, newResultsLookup) => { 527 | return current.map(item => { 528 | const updatedItem = newResultsLookup.get(item._id); 529 | return updatedItem ? Object.assign({}, item, updatedItem) : item; 530 | }); 531 | }; 532 | 533 | export const syncQueryToCache = (query, type) => { 534 | const graphQLClient = getDefaultClient(); 535 | graphQLClient.subscribeMutation([ 536 | { 537 | when: new RegExp(`update${type}s?`), 538 | run: ({ refreshActiveQueries }, resp, variables) => { 539 | const cache = graphQLClient.getCache(query); 540 | const newResults = resp[`update${type}`] ? [resp[`update${type}`][type]] : resp[`update${type}s`][`${type}s`]; 541 | const newResultsLookup = new Map(newResults.map(item => [item._id, item])); 542 | 543 | for (let [uri, { data }] of cache.entries) { 544 | data[`all${type}s`][`${type}s`] = syncCollection(data[`all${type}s`][`${type}s`], newResultsLookup); 545 | } 546 | 547 | refreshActiveQueries(query); 548 | } 549 | } 550 | ]); 551 | }; 552 | ``` 553 | 554 | which cuts the usage code to just this 555 | 556 | ```svelte 557 | 575 | 576 | 577 | ``` 578 | 579 | The above code assumes this component will only ever render once. If that's not the case, put these calls to `subscribeMutation` somewhere else, that will only ever run one. A svelte ` 18 | 19 | 23 | 24 | -------------------------------------------------------------------------------- /docs/docup.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:transparent;background-image:none;padding:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset,ol,ul{margin:0;padding:0}ol,ul{list-style:none}html{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #e2e8f0}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::placeholder,textarea::placeholder{color:#a0aec0}[role=button],button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}.bg-gray-200{--bg-opacity:1;background-color:#edf2f7;background-color:rgba(237,242,247,var(--bg-opacity))}.bg-opacity-50{--bg-opacity:0.5}.flex{display:flex}.hidden{display:none}.items-center{align-items:center}.justify-between{justify-content:space-between}.h-6{height:1.5rem}.h-12{height:3rem}.text-lg{font-size:1.125rem}.text-2xl{font-size:1.5rem}.my-5{margin-top:1.25rem;margin-bottom:1.25rem}.mr-5{margin-right:1.25rem}.mr-8{margin-right:2rem}.mt-12{margin-top:3rem}.max-w-2xl{max-width:42rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.pb-3{padding-bottom:.75rem}.fixed{position:fixed}.top-0{top:0}.bottom-0{bottom:0}.left-0{left:0}.w-6{width:1.5rem}.w-full{width:100%}@media (min-width:768px){.md\:flex{display:flex}.md\:hidden{display:none}.md\:pt-12{padding-top:3rem}}@media (min-width:1280px){.xl\:max-w-4xl{max-width:56rem}}pre{color:var(--code-block-fg);background:none;font-size:1rem;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none;border-radius:.5rem;padding:1.25rem;margin:.5em 0;overflow:auto}:not(pre)>code,pre{font-family:var(--code-font)}:not(pre)>code{background:var(--code-span-bg);color:var(--code-span-fg);padding:2px 4px;border-radius:.25rem;font-size:.875rem}pre{background:var(--code-block-bg)}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}:root{--navbar-bg:#3e2723;--navbar-fg:#fff;--navlink-fg:hsla(0,0%,100%,0.5);--navlink-hover-fg:hsla(0,0%,100%,0.75);--navlink-hover-bg:#4e342e;--sidebar-bg:#efebe9;--sidebar-width:300px;--sidebar-text-fg:#6b6867;--sidebar-menu-item-active-fg:#3e2723;--code-block-bg:#2f2625;--code-span-bg:#f8f3f0;--code-block-fg:#ccc;--code-span-fg:inherit;--code-font:Consolas,Monaco,"Andale Mono","Ubuntu Mono",monospace;--content-link-fg:#147914;--content-link-hover-decoration:underline}body{font-family:Lato}.navbar{background:var(--navbar-bg);color:var(--navbar-fg);z-index:999}.navlink{color:var(--navlink-fg);display:flex;align-items:center;padding-left:1.25rem;padding-right:1.25rem;height:2rem}@media (min-width:768px){.navlink{padding:.5rem;border-radius:.25rem;height:2rem}}.navlink:hover{color:var(--navlink-hover-fg);background:var(--navlink-hover-bg)}.sidebar{background:var(--sidebar-bg);z-index:9999;width:60%;min-width:300px;color:var(--sidebar-text-fg);overflow:auto;transform:translateX(-100%);transition:transform .15s ease-in-out}.sidebar_overlay{z-index:9990}.sidebar_navbar{background:var(--navbar-bg);color:var(--navbar-fg)}@media (min-width:768px){.sidebar{z-index:990;width:var(--sidebar-width);transform:none}}.sidebar.sidebar_show{display:block;transform:translateX(0)}.main{padding:1.25rem}@media (min-width:768px){.main{padding-left:2rem;padding-right:2rem;margin-left:var(--sidebar-width)}}.menu_item__active{color:var(--sidebar-menu-item-active-fg);font-weight:700}.menu_item[data-depth="3"],.menu_item[data-depth="4"]{padding-left:2rem}.menu_item[data-depth="4"]:before{content:"-";opacity:.25;padding-right:.5rem}.content{font-size:1.175rem}.content pre{overflow:auto}.content img{max-width:100%}.content>*,.content p{margin-top:1.25rem;margin-bottom:1.25rem}.content>:first-child,.content>:first-child>:first-child{margin-top:0}.content>:last-child,.content>:last-child>:last-child{margin-bottom:0}.content a{color:var(--content-link-fg)}.content a:hover{text-decoration:var(--content-link-hover-decoration)}.content h2{font-size:2.5rem;font-weight:700;margin-top:2rem;margin-bottom:2rem}.content h3{font-weight:400;font-size:2rem}.content h4{font-size:1.5rem}.content h5{font-size:1.1rem}.content h6{font-size:1rem;font-weight:700}.content ul{list-style-type:disc;list-style-position:inside}.content blockquote{padding:10px 15px;border-left:3px solid #000;margin:20px 0}.content blockquote p,.message p{margin:0}.message p:not(:first-child),blockquote p:not(:first-child){margin-top:20px}.message{background:#f5f5f5;border:1px solid #dbdbdb;border-radius:3px;color:#4a4a4a;padding:1em 1.25em;margin:25px 0}.message.message_type__alert{background:#fff5f7;border-color:#ff3860;color:#cd0930}.message.message_type__info{background:#f6fbfe;border-color:#209cee;color:#12537e}.message.message_type__warning{background:#fffdf5;border-color:#ffdd57;color:#3b3108}.message.message_type__success{background:#f6fef9;border-color:#23d160;color:#0e301a}.anchor{position:relative;margin-left:-14px;opacity:.5;display:inline-block;width:14px;height:14px;visibility:hidden}.anchor:hover{opacity:1}.anchor svg{position:absolute;right:5px;top:0}.content h2:hover .anchor,.content h3:hover .anchor,.content h4:hover .anchor{visibility:visible}.task_list__item{list-style-type:none}.task_list__item input[type=checkbox]:first-child{margin-right:.5rem} -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | My Awesome Doc 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 31 | 32 | -------------------------------------------------------------------------------- /docs/overrides.css: -------------------------------------------------------------------------------- 1 | table { 2 | font-size: .95rem; 3 | width: 100%; 4 | } 5 | 6 | table.client-api tbody tr td:first-child { 7 | white-space: nowrap; 8 | padding-right: 5px; 9 | } 10 | 11 | table code { 12 | font-size: .85rem; 13 | } 14 | 15 | table > thead > tr > th { 16 | border-bottom: 2px solid var(--header-bg); 17 | } 18 | 19 | table > tbody > tr > td { 20 | border-bottom: 1px solid var(--header-bg); 21 | } 22 | table > tbody > tr > td:not(:last-child) { 23 | border-right: 1px solid var(--header-bg); 24 | } 25 | 26 | table > tbody > tr > td:first-child { 27 | padding-right: 5px; 28 | } 29 | table > tbody > tr > td:not(:first-child) { 30 | padding-left: 5px; 31 | } 32 | 33 | table code { 34 | background: unset; 35 | padding: unset; 36 | } 37 | 38 | 39 | :root { 40 | --sidebar-bg: hsl(360, 100%, 98%); 41 | --code-block-bg: black; 42 | --header-bg: hsl(0, 100%, 7%); 43 | } 44 | 45 | header.navbar { 46 | background-color: var(--header-bg); 47 | } -------------------------------------------------------------------------------- /docs/svelte-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arackaf/micro-graphql-svelte/0c4b89f9cb026cd15c1a141501fa7a0c04980ec4/docs/svelte-logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const tsConfig = require("./tsconfig.json").compilerOptions; 3 | 4 | tsConfig.module = "commonjs"; 5 | tsConfig.target = "es2015"; 6 | tsConfig.types.push("jest"); 7 | 8 | module.exports = { 9 | globals: { 10 | "ts-jest": { 11 | tsconfig: tsConfig 12 | } 13 | }, 14 | transform: { 15 | "^.+\\.ts$": "ts-jest", 16 | "^.+\\.js$": "babel-jest", 17 | "^.+\\.svelte$": "svelte-jester" 18 | }, 19 | testMatch: ["**/*.test.(ts|js)"], 20 | moduleFileExtensions: ["ts", "js", "svelte"], 21 | coverageDirectory: "./coverage/", 22 | collectCoverage: true, 23 | collectCoverageFrom: ["src/**/*.ts"] 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micro-graphql-svelte", 3 | "version": "0.2.3", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/arackaf/micro-graphql-react.git" 9 | }, 10 | "author": "Adam Rackis", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/arackaf/micro-graphql-react/issues" 14 | }, 15 | "homepage": "https://github.com/arackaf/micro-graphql-react", 16 | "devDependencies": { 17 | "@babel/preset-env": "^7.3.4", 18 | "@std/esm": "0.19.7", 19 | "@testing-library/svelte": "^3.0.0", 20 | "@types/jest": "^26.0.20", 21 | "@types/node": "^14.14.28", 22 | "babel-jest": "^26.6.1", 23 | "classnames": "^2.2.6", 24 | "codecov": "^3.8.1", 25 | "del": "3.0.0", 26 | "history": "^4.6.1", 27 | "jest": "^26.6.3", 28 | "node-sass": "^7.0.1", 29 | "query-string": "^6.2.0", 30 | "sass": "^1.50.0", 31 | "svelte": "^3.29.4", 32 | "svelte-jester": "^1.1.5", 33 | "ts-jest": "^26.5.0", 34 | "typescript": "^4.1.3", 35 | "url-parse": "^1.4.0", 36 | "vite": "^2.9.0" 37 | }, 38 | "scripts": { 39 | "server": "node runServer", 40 | "build-all": "vite build && NO_MINIFY=true vite build", 41 | "prepublishOnly": "npm run compile-ts && npm run build-all", 42 | "test": "jest --runInBand --bail --detectOpenHandles && codecov", 43 | "test-local": "jest --runInBand", 44 | "test-local-codecov": "jest --runInBand && dotenv codecov", 45 | "testw": "jest --runInBand --watchAll", 46 | "start": "node ./demo/server.js", 47 | "size-check": "npm run build && gzip dist/index.min.js && stat dist/index.min.js.gz && rm -f dist/index.min.js.gz", 48 | "tsc": "tsc --noEmit", 49 | "tscw": "tsc -w --noEmit", 50 | "build": "vite build", 51 | "compile-ts": "tsc -d --project tsconfig.release.json --outDir ./lib" 52 | }, 53 | "sideEffects": false, 54 | "typings": "lib/index.d.ts" 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/micro-graphql-svelte.svg?style=flat)](https://www.npmjs.com/package/micro-graphql-svelte) 2 | [![codecov](https://codecov.io/gh/arackaf/micro-graphql-svelte/branch/master/graph/badge.svg?token=S472K5LJE1)](https://codecov.io/gh/arackaf/micro-graphql-svelte) 3 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 4 | 5 | # micro-graphql-svelte 6 | 7 | --- 8 | 9 | A light (2.8K min+gzip) and simple solution for painlessly connecting your Svelte components to a GraphQL endpoint. 10 | 11 | This project has a query and mutation function to produce a store with your query's data, or mutation info. Where this project differs from most others is how it approaches cache invalidation. Rather than adding metadata to queries and forming a normalized, automatically-managed cache, it instead provides simple, low-level building blocks to handle cache management yourself. The reason for this (ostensibly poor!) tradeoff is because of my experience with other GraphQL clients which attempted the normalized cache route. I consistently had difficulty getting the cache to behave exactly as I wanted, so decided to build a GraphQL client that gave me the low-level control I always wound up wanting. This project is the result. 12 | 13 | Full docs are [here](https://arackaf.github.io/micro-graphql-svelte/) 14 | 15 | The rest of this README describes in better detail the kind of cache management problems this project attempts to avoid. 16 | 17 | ## Common cache difficulties other GraphQL clients contend with 18 | 19 | ### Coordinating mutations with filtered result sets 20 | 21 | A common problem with GraphQL clients is configuring when a certain mutation should not just update existing data results, but also, more importantly, clear all other cache results, since the completed mutations might affect other queries' filters. For example, let's say you run 22 | 23 | ```graphql 24 | tasks(assignedTo: "Adam") { 25 | Tasks { 26 | id, description, assignedTo 27 | } 28 | } 29 | ``` 30 | 31 | and get back 32 | 33 | ```javascript 34 | [ 35 | { id: 1, description: "Adam's Task 1", assignedTo: "Adam" }, 36 | { id: 2, description: "Adam's Task 2", , assignedTo: "Adam" } 37 | ]; 38 | ``` 39 | 40 | Now, if you subsequently run something like 41 | 42 | ```graphql 43 | mutation { 44 | updateTask(id: 1, assignedTo: "Bob", description: "Bob's Task") 45 | } 46 | ``` 47 | 48 | the original query from above will update and now display 49 | 50 | ```json 51 | [ 52 | { "id": 1, "description": "Bob's Task", "assignedTo": "Bob" }, 53 | { "id": 2, "description": "Adam's Task 2", "assignedTo": "Adam" } 54 | ]; 55 | ``` 56 | 57 | which may or may not be what you want, but worse, if you browse to some other filter, say, `tasks(assignedTo: "Rich")`, and then return to `tasks(assignedTo: "Adam")`, those data above will still be returned, which is wrong, since task number 1 should no longer be in this result set at all. The `assignedTo` value has changed, and so no longer matches the filters of this query. 58 | 59 | --- 60 | 61 | This library solves this problem by allowing you to easily declare that a given mutation should clear all cache entries for a given query, and reload them from the network (hard reset), or just update the on-screen results, but otherwise clear the cache for a given query (soft reset). See the [docs](https://arackaf.github.io/micro-graphql-svelte/) for more info. 62 | 63 | ### Properly processing empty result sets 64 | 65 | An interesting approach that the first version of Urql took was to, after any mutation, invalidate any and all queries which dealt with the data type you just mutated. It did this by modifying queries to add `__typename` metadata, so it would know which types were in which queries, and therefore needed to be refreshed after relevant mutations. This is a lot closer in terms of correctness, but even here there are edge cases which GraphQL's limited type introspection make difficult. For example, let's say you run this query 66 | 67 | ```graphql 68 | tasks(assignedTo: "Adam") { 69 | Tasks { 70 | id, description, assignedTo 71 | } 72 | } 73 | ``` 74 | 75 | and get back 76 | 77 | ```json 78 | { 79 | "data": { 80 | "tasks": { 81 | "__typename": "TaskQueryResults", 82 | "Tasks": [] 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | It's more or less impossible to know what the underlying type of the empty `Tasks` array is, without a build step to introspect the entire endpoint's metadata. 89 | 90 | ### Are these actual problems you're facing? 91 | 92 | These are actual problems I ran into when evaluating GraphQL clients, which left me wanting a low-level, configurable caching solution. That's the value proposition of this project. If you're not facing these problems, for whatever reasons, you'll likely be better off with a more automated solution like Urql or Apollo. 93 | 94 | To be crystal clear, nothing in this readme should be misconstrued as claiming this project to be "better" than any other. The point is to articulate common problems with client-side GraphQL caching, and show how this project solves them. Keep these problems in mind when evaluating GraphQL clients, and pick the best solution for **your** app. 95 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const babel = require("rollup-plugin-babel"); 2 | const commonjs = require("rollup-plugin-commonjs"); 3 | const resolve = require("rollup-plugin-node-resolve"); 4 | const { terser } = require("rollup-plugin-terser"); 5 | const path = require("path"); 6 | 7 | const getConfig = ({ file, minify = false, presets = [], plugins = [] }) => ({ 8 | input: "./lib/index.js", 9 | output: { 10 | format: "esm", 11 | file 12 | }, 13 | external: ["svelte", "svelte/store"], 14 | plugins: [ 15 | babel({ 16 | babelrc: false, 17 | exclude: "node_modules/**", 18 | presets: [...presets], 19 | plugins: ["@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-optional-chaining", ...plugins] 20 | }), 21 | minify && terser({}), 22 | resolve(), 23 | commonjs({ include: ["node_modules/**"] }) 24 | ] 25 | }); 26 | 27 | let es5config = ["@babel/preset-env", { targets: { ie: "11" } }]; 28 | 29 | module.exports = [ 30 | getConfig({ file: "index.js" }), 31 | getConfig({ file: "index.min.js", minify: true }), 32 | getConfig({ file: "index-es5.js", presets: [es5config], plugins: ["@babel/plugin-proposal-object-rest-spread"] }), 33 | getConfig({ file: "index-es5.min.js", minify: true, presets: [es5config], plugins: ["@babel/plugin-proposal-object-rest-spread"] }) 34 | ]; 35 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResponse, CachedEntry } from "./queryTypes"; 2 | 3 | export default class Cache { 4 | constructor(private cacheSize = DEFAULT_CACHE_SIZE) { 5 | this.cacheSize = cacheSize; 6 | } 7 | _cache = new Map> | CachedEntry>([]); 8 | get noCaching() { 9 | return !this.cacheSize; 10 | } 11 | 12 | get keys() { 13 | return [...this._cache.keys()]; 14 | } 15 | 16 | get entries() { 17 | return [...this._cache]; 18 | } 19 | 20 | get(key: string) { 21 | return this._cache.get(key); 22 | } 23 | 24 | set(key: string, results: CachedEntry) { 25 | this._cache.set(key, results); 26 | } 27 | 28 | clearCache() { 29 | this._cache.clear(); 30 | } 31 | 32 | setPendingResult(graphqlQuery: string, promise: Promise>) { 33 | let cache = this._cache; 34 | //front of the line now, to support LRU ejection 35 | if (!this.noCaching) { 36 | cache.delete(graphqlQuery); 37 | if (cache.size === this.cacheSize) { 38 | //maps iterate entries and keys in insertion order - zero'th key should be oldest 39 | cache.delete([...cache.keys()][0]); 40 | } 41 | cache.set(graphqlQuery, promise); 42 | } 43 | } 44 | 45 | setResults(promise: Promise, cacheKey: string, resp?: GraphQLResponse, err: Object | null = null) { 46 | let cache = this._cache; 47 | if (this.noCaching) { 48 | return; 49 | } 50 | 51 | //cache may have been cleared while we were running. If so, we'll respect that, and not touch the cache, but 52 | //we'll still use the results locally 53 | if (cache.get(cacheKey) !== promise) return; 54 | 55 | if (err != null) { 56 | cache.set(cacheKey, { data: null, error: err }); 57 | } else if (resp != null) { 58 | if (resp.errors) { 59 | cache.set(cacheKey, { data: null, error: resp.errors }); 60 | } else { 61 | cache.set(cacheKey, { data: resp.data, error: null }); 62 | } 63 | } 64 | } 65 | 66 | getFromCache(key: string, ifPending: (p: Promise) => void, ifResults: (entry: CachedEntry) => void, ifNotFound: () => void) { 67 | let cache = this._cache; 68 | if (this.noCaching) { 69 | ifNotFound(); 70 | } else { 71 | let cachedEntry = cache.get(key); 72 | if (cachedEntry != null) { 73 | if (cachedEntry instanceof Promise) { 74 | ifPending(cachedEntry); 75 | } else { 76 | //re-insert to put it at the fornt of the line 77 | cache.delete(key); 78 | this.set(key, cachedEntry); 79 | ifResults(cachedEntry); 80 | } 81 | } else { 82 | ifNotFound(); 83 | } 84 | } 85 | } 86 | } 87 | 88 | export const DEFAULT_CACHE_SIZE = 10; 89 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import Cache, { DEFAULT_CACHE_SIZE } from "./cache"; 2 | import { 3 | BasicSubscriptionEntry, 4 | FullSubscriptionEntry, 5 | MinimalOnMutationPayload, 6 | OnMutationPayload, 7 | SubscriptionEntry, 8 | SubscriptionItem 9 | } from "./queryTypes"; 10 | 11 | type QueryPacket = { 12 | query: string; 13 | variables: unknown; 14 | }; 15 | 16 | type ClientOptions = { 17 | endpoint: string; 18 | cacheSize?: number; 19 | noCaching?: boolean; 20 | }; 21 | 22 | type OnMutationQuerySetup = { 23 | cache: Cache; 24 | softReset: (newResults: Object) => void; 25 | hardReset: () => void; 26 | refresh: () => void; 27 | currentResults: () => unknown; 28 | isActive: () => boolean; 29 | }; 30 | 31 | export default class Client { 32 | private caches = new Map(); 33 | private mutationListeners = new Set<{ subscription: (BasicSubscriptionEntry | FullSubscriptionEntry)[]; options?: OnMutationQuerySetup }>(); 34 | private forceListeners = new Map void>>(); 35 | private cacheSize?: number; 36 | private fetchOptions?: RequestInit; 37 | endpoint: string; 38 | 39 | constructor(props: ClientOptions) { 40 | if (props.noCaching != null && props.cacheSize != null) { 41 | throw "Both noCaching, and cacheSize are specified. At most one of these options can be included"; 42 | } 43 | 44 | if (props.noCaching) { 45 | props.cacheSize = 0; 46 | } 47 | 48 | this.endpoint = props.endpoint; 49 | Object.assign(this, { cacheSize: DEFAULT_CACHE_SIZE }, props); 50 | } 51 | get cacheSizeToUse() { 52 | if (this.cacheSize != null) { 53 | return this.cacheSize; 54 | } 55 | return DEFAULT_CACHE_SIZE; 56 | } 57 | getCache(query: string): Cache { 58 | return this.caches.get(query) as Cache; 59 | } 60 | preload(query: string, variables: unknown) { 61 | let cache = this.getCache(query); 62 | if (cache == null) { 63 | cache = this.newCacheForQuery(query); 64 | } 65 | 66 | let graphqlQuery = this.getGraphqlQuery({ query, variables }); 67 | 68 | let promiseResult; 69 | cache.getFromCache( 70 | graphqlQuery, 71 | promise => { 72 | promiseResult = promise; 73 | /* already preloading - cool */ 74 | }, 75 | cachedEntry => { 76 | promiseResult = cachedEntry; 77 | /* already loaded - cool */ 78 | }, 79 | () => { 80 | let promise = this.runUri(graphqlQuery); 81 | cache.setPendingResult(graphqlQuery, promise); 82 | promiseResult = promise; 83 | promise.then(resp => { 84 | cache.setResults(promise, graphqlQuery, resp); 85 | }); 86 | } 87 | ); 88 | return promiseResult; 89 | } 90 | newCacheForQuery(query: string) { 91 | let newCache = new Cache(this.cacheSizeToUse); 92 | this.setCache(query, newCache); 93 | return newCache; 94 | } 95 | setCache(query: string, cache: Cache) { 96 | this.caches.set(query, cache); 97 | } 98 | runQuery(query: string, variables: unknown) { 99 | return this.runUri(this.getGraphqlQuery({ query, variables })); 100 | } 101 | runUri(uri: string) { 102 | return fetch(uri, this.fetchOptions || void 0).then(resp => resp.json()); 103 | } 104 | getGraphqlQuery({ query, variables }: QueryPacket) { 105 | return `${this.endpoint}?query=${encodeURIComponent(query)}${ 106 | typeof variables === "object" ? `&variables=${encodeURIComponent(JSON.stringify(variables))}` : "" 107 | }`; 108 | } 109 | subscribeMutation(subscription: SubscriptionItem | SubscriptionItem[]) { 110 | if (!Array.isArray(subscription)) { 111 | subscription = [subscription]; 112 | } 113 | 114 | const entries: BasicSubscriptionEntry[] = subscription.map(entry => ({ ...entry, type: "Basic" })); 115 | const packet = { subscription: entries }; 116 | this.mutationListeners.add(packet); 117 | 118 | return () => this.mutationListeners.delete(packet); 119 | } 120 | subscribeQuery(subscription: FullSubscriptionEntry[], options: OnMutationQuerySetup) { 121 | if (!Array.isArray(subscription)) { 122 | subscription = [subscription]; 123 | } 124 | const packet = { subscription, options }; 125 | 126 | this.mutationListeners.add(packet); 127 | 128 | return () => this.mutationListeners.delete(packet); 129 | } 130 | forceUpdate(query: string) { 131 | let updateListeners = this.forceListeners.get(query); 132 | if (updateListeners) { 133 | for (let refresh of updateListeners) { 134 | refresh(); 135 | } 136 | } 137 | } 138 | registerQuery(query: string, refresh: () => void) { 139 | if (!this.forceListeners.has(query)) { 140 | this.forceListeners.set(query, new Set([])); 141 | } 142 | this.forceListeners.get(query)?.add(refresh); 143 | 144 | return () => this.forceListeners.get(query)?.delete(refresh); 145 | } 146 | processMutation(mutation: string, variables: unknown) { 147 | return Promise.resolve(this.runMutation(mutation, variables)).then(resp => { 148 | let mutationKeys = Object.keys(resp); 149 | let mutationKeysLookup = new Set(mutationKeys); 150 | [...this.mutationListeners].forEach(({ subscription, options }) => { 151 | subscription.forEach(sub => { 152 | if (options && typeof options.isActive === "function") { 153 | if (!options.isActive()) { 154 | return; 155 | } 156 | } 157 | 158 | if (typeof sub.when === "string") { 159 | if (mutationKeysLookup.has(sub.when)) { 160 | this.executeMutationSubscription(sub, options, resp, variables); 161 | } 162 | } else if (sub.when instanceof RegExp) { 163 | if ([...mutationKeysLookup].some(k => (sub.when as RegExp).test(k))) { 164 | this.executeMutationSubscription(sub, options, resp, variables); 165 | } 166 | } 167 | }); 168 | }); 169 | return resp; 170 | }); 171 | } 172 | private executeMutationSubscription(sub: SubscriptionEntry, options: OnMutationQuerySetup | undefined, resp: unknown, variables: unknown) { 173 | const refreshActiveQueries = (query: string) => this.forceUpdate(query); 174 | 175 | if (sub.type === "Basic") { 176 | let basicArgs: MinimalOnMutationPayload = { 177 | refreshActiveQueries 178 | }; 179 | sub.run(basicArgs, resp, variables); 180 | } else { 181 | let fullArgs: OnMutationPayload = { 182 | ...(options as OnMutationQuerySetup), 183 | currentResults: options!.currentResults(), 184 | refreshActiveQueries 185 | }; 186 | sub.run(fullArgs, resp, variables); 187 | } 188 | } 189 | runMutation(mutation: string, variables: unknown) { 190 | let { headers = {}, ...otherOptions } = this.fetchOptions || {}; 191 | return fetch(this.endpoint, { 192 | method: "post", 193 | headers: { 194 | Accept: "application/json", 195 | "Content-Type": "application/json", 196 | ...headers 197 | }, 198 | ...otherOptions, 199 | body: JSON.stringify({ 200 | query: mutation, 201 | variables 202 | }) 203 | }) 204 | .then(resp => resp.json()) 205 | .then(resp => resp.data); 206 | } 207 | } 208 | 209 | class DefaultClientManager { 210 | defaultClient: Client | null = null; 211 | setDefaultClient = (client: Client) => (this.defaultClient = client); 212 | getDefaultClient = () => this.defaultClient; 213 | } 214 | 215 | export const defaultClientManager = new DefaultClientManager(); 216 | -------------------------------------------------------------------------------- /src/compress.ts: -------------------------------------------------------------------------------- 1 | export default function compress(strings: TemplateStringsArray, ...expressions: string[]): string { 2 | return strings 3 | .map((string, i) => { 4 | const expression = i < expressions.length ? expressions[i] : ""; 5 | return ( 6 | string 7 | .replace(/\s+/g, " ") 8 | .replace(/ ,/g, ",") 9 | .replace(/, /g, ",") 10 | .replace(/ :/g, ":") 11 | .replace(/: /g, ":") 12 | .replace(/{ /g, "{") 13 | .replace(/} /g, "}") 14 | .replace(/ {/g, "{") 15 | .replace(/ }/g, "}") 16 | .replace(/ \(/g, "(") 17 | .replace(/ \)/g, ")") 18 | .replace(/\( /g, "(") 19 | .replace(/\) /g, ")") + expression 20 | ); 21 | }) 22 | .join("") 23 | .trim(); 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import query from "./query"; 2 | import mutation from "./mutation"; 3 | import Client, { defaultClientManager } from "./client"; 4 | import compress from "./compress"; 5 | import Cache from "./cache"; 6 | 7 | const { setDefaultClient, getDefaultClient } = defaultClientManager; 8 | 9 | export { Client, compress, setDefaultClient, getDefaultClient, Cache, query, mutation }; 10 | -------------------------------------------------------------------------------- /src/mutation.ts: -------------------------------------------------------------------------------- 1 | import { derived, Readable } from "svelte/store"; 2 | 3 | import Client, { defaultClientManager } from "./client"; 4 | import MutationManager from "./mutationManager"; 5 | import { MutationState } from "./mutationTypes"; 6 | 7 | type MutationOptions = { 8 | client?: Client; 9 | }; 10 | 11 | export default function mutation( 12 | mutation: string, 13 | options: MutationOptions = {} 14 | ): { mutationState: Readable> } { 15 | const client = options.client || defaultClientManager.getDefaultClient(); 16 | 17 | if (client == null) { 18 | throw "Default client not configured"; 19 | } 20 | 21 | const mutationManager = new MutationManager({ client }, mutation); 22 | 23 | return { mutationState: derived(mutationManager.mutationStore, $state => $state) }; 24 | } 25 | -------------------------------------------------------------------------------- /src/mutationManager.ts: -------------------------------------------------------------------------------- 1 | import { Writable, writable } from "svelte/store"; 2 | import Client from "./client"; 3 | 4 | import { MutationState } from "./mutationTypes"; 5 | 6 | type MutationOptions = { 7 | client: Client; 8 | }; 9 | 10 | export default class MutationManager { 11 | client: Client; 12 | mutation: string; 13 | setState: (newState: MutationState) => void; 14 | mutationStore: Writable>; 15 | 16 | runMutation = (variables: unknown) => { 17 | this.setState({ 18 | running: true, 19 | finished: false, 20 | runMutation: this.runMutation 21 | }); 22 | 23 | return this.client.processMutation(this.mutation, variables).then(resp => { 24 | this.setState({ 25 | running: false, 26 | finished: true, 27 | runMutation: this.runMutation 28 | }); 29 | return resp; 30 | }); 31 | }; 32 | static initialState = { 33 | running: false, 34 | finished: false 35 | }; 36 | currentState = { 37 | ...MutationManager.initialState, 38 | runMutation: this.runMutation 39 | }; 40 | updateState = (newState = {}) => { 41 | Object.assign(this.currentState, newState); 42 | this.setState(this.currentState); 43 | }; 44 | constructor({ client }: MutationOptions, mutation: string) { 45 | this.client = client; 46 | this.mutation = mutation; 47 | 48 | this.mutationStore = writable(this.currentState); 49 | this.setState = this.mutationStore.set; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/mutationTypes.ts: -------------------------------------------------------------------------------- 1 | export type MutationState = { 2 | running: boolean; 3 | finished: boolean; 4 | runMutation: (variables: unknown) => Promise; 5 | }; 6 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { writable, derived, Writable } from "svelte/store"; 2 | import Client, { defaultClientManager } from "./client"; 3 | 4 | import QueryManager, { QueryLoadOptions, QueryOptions, QueryState } from "./queryManager"; 5 | 6 | export default function query(query: string, options: Partial> = {}) { 7 | let queryManager: QueryManager; 8 | const queryStore = writable>(QueryManager.initialState, () => { 9 | options.activate && options.activate(queryStore); 10 | queryManager.activate(); 11 | return () => { 12 | options.deactivate && options.deactivate(queryStore); 13 | queryManager.dispose(); 14 | }; 15 | }); 16 | const resultsStore = writable(null); 17 | 18 | const client: Client | null = options.client || defaultClientManager.getDefaultClient(); 19 | if (client == null) { 20 | throw "Default Client not configured"; 21 | } 22 | 23 | const setState = (newState: any) => { 24 | const existingState = queryManager.currentState; 25 | queryStore.set(Object.assign({}, existingState, newState)); 26 | if ("data" in newState) { 27 | resultsStore.set(newState.data); 28 | } 29 | }; 30 | 31 | queryManager = new QueryManager({ query, client, cache: options?.cache, setState }, options); 32 | const sync = (variables: unknown, options?: QueryLoadOptions) => queryManager.load([query, variables], options); 33 | 34 | if (options.initialSearch) { 35 | sync(options.initialSearch); 36 | } 37 | 38 | return { 39 | queryState: derived(queryStore, $state => ({ ...$state, softReset: queryManager.softReset })), 40 | resultsState: derived(resultsStore, $state => $state), 41 | sync 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/queryManager.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from "svelte/store"; 2 | import Client from "./client"; 3 | import Cache from "./cache"; 4 | import { FullSubscriptionEntry, FullSubscriptionItem, GraphQLResponse } from "./queryTypes"; 5 | 6 | export type QueryOptions = { 7 | client: Client; 8 | cache?: Cache; 9 | initialSearch?: TArgs; 10 | activate?: (store: Writable>) => void; 11 | deactivate?: (store: Writable>) => void; 12 | postProcess?: (resp: unknown) => unknown; 13 | onMutation?: FullSubscriptionItem | FullSubscriptionItem[]; 14 | }; 15 | 16 | export type QueryState = { 17 | loading: boolean; 18 | loaded: boolean; 19 | data: TData | null; 20 | error: unknown; 21 | reload: () => void; 22 | clearCache: () => void; 23 | clearCacheAndReload: () => void; 24 | currentQuery: string; 25 | }; 26 | 27 | export type QueryLoadOptions = Partial<{ 28 | force: boolean; 29 | active: boolean; 30 | }>; 31 | 32 | type QueryManagerOptions = { 33 | query: string; 34 | client: Client; 35 | setState: (newState: Partial>) => void; 36 | cache?: Cache; 37 | }; 38 | 39 | export default class QueryManager { 40 | query: string; 41 | client: Client; 42 | active = true; 43 | setState: (newState: Partial>) => void; 44 | options: any; 45 | cache: Cache; 46 | postProcess?: (resp: unknown) => unknown; 47 | currentUri = ""; 48 | 49 | unregisterQuery?: () => void; 50 | 51 | currentPromise?: Promise; 52 | 53 | mutationSubscription?: () => void; 54 | static initialState = { 55 | loading: false, 56 | loaded: false, 57 | data: null, 58 | error: null, 59 | currentQuery: "", 60 | reload: () => {}, 61 | clearCache: () => {}, 62 | clearCacheAndReload: () => {} 63 | }; 64 | currentState: QueryState; 65 | 66 | onMutation: FullSubscriptionEntry[] = []; 67 | 68 | constructor({ query, client, setState, cache }: QueryManagerOptions, options: Partial>) { 69 | this.query = query; 70 | this.client = client; 71 | this.setState = setState; 72 | this.options = options; 73 | this.cache = cache || client.getCache(query) || client.newCacheForQuery(query); 74 | this.postProcess = options?.postProcess; 75 | 76 | if (typeof options?.onMutation === "object") { 77 | if (!Array.isArray(options.onMutation)) { 78 | options.onMutation = [options.onMutation]; 79 | } 80 | 81 | this.onMutation = options.onMutation.map(entry => ({ ...entry, type: "Full" })); 82 | } 83 | this.currentState = { 84 | ...QueryManager.initialState, 85 | reload: this.reload, 86 | clearCache: () => this.cache.clearCache(), 87 | clearCacheAndReload: this.clearCacheAndReload 88 | }; 89 | } 90 | isActive = () => this.active; 91 | updateState = (newState: Partial>) => { 92 | Object.assign(this.currentState, newState); 93 | this.setState(newState); 94 | }; 95 | refresh = () => { 96 | this.load(); 97 | }; 98 | softReset = (newResults?: TData) => { 99 | if (newResults != null) { 100 | this.updateState({ data: newResults }); 101 | } 102 | this.cache.clearCache(); 103 | }; 104 | hardReset = () => { 105 | this.cache.clearCache(); 106 | this.reload(); 107 | }; 108 | clearCacheAndReload = () => { 109 | this.cache.clearCache(); 110 | this.reload(); 111 | }; 112 | reload = () => { 113 | this.execute(); 114 | }; 115 | load(packet?: [string, unknown], options?: QueryLoadOptions) { 116 | let { force, active } = options || {}; 117 | 118 | if (typeof active !== "undefined") { 119 | this.active = active; 120 | } 121 | if (!this.isActive()) { 122 | return; 123 | } 124 | 125 | if (packet) { 126 | const [query, variables] = packet; 127 | let graphqlQuery = this.client.getGraphqlQuery({ query, variables }); 128 | if (force || graphqlQuery != this.currentUri) { 129 | this.currentUri = graphqlQuery; 130 | } else { 131 | return; 132 | } 133 | } 134 | 135 | let graphqlQuery = this.currentUri; 136 | this.cache.getFromCache( 137 | graphqlQuery, 138 | promise => { 139 | Promise.resolve(promise) 140 | .then(() => { 141 | //cache should now be updated, unless it was cleared. Either way, re-run this method 142 | this.load(); 143 | }) 144 | .catch(err => { 145 | this.load(); 146 | }); 147 | }, 148 | cachedEntry => { 149 | this.updateState({ data: cachedEntry.data, error: cachedEntry.error || null, loading: false, loaded: true, currentQuery: graphqlQuery }); 150 | }, 151 | () => this.execute() 152 | ); 153 | } 154 | execute() { 155 | let graphqlQuery = this.currentUri; 156 | this.updateState({ loading: true }); 157 | let promise = this.client.runUri(this.currentUri); 158 | 159 | if (this.postProcess != null) { 160 | promise = promise.then(resp => { 161 | return Promise.resolve(this.postProcess!(resp)).then(newRespMaybe => newRespMaybe || resp); 162 | }); 163 | } 164 | 165 | this.cache.setPendingResult(graphqlQuery, promise); 166 | this.handleExecution(promise, graphqlQuery); 167 | } 168 | handleExecution = (promise: Promise>, cacheKey: string) => { 169 | this.currentPromise = promise; 170 | Promise.resolve(promise) 171 | .then(resp => { 172 | if (this.currentPromise !== promise) { 173 | return; 174 | } 175 | this.cache.setResults(promise, cacheKey, resp); 176 | 177 | if (resp.errors) { 178 | this.updateState({ loaded: true, loading: false, data: null, error: resp.errors || null, currentQuery: cacheKey }); 179 | } else { 180 | this.updateState({ loaded: true, loading: false, data: resp.data, error: null, currentQuery: cacheKey }); 181 | } 182 | }) 183 | .catch(err => { 184 | this.cache.setResults(promise, cacheKey, void 0, err); 185 | this.updateState({ loaded: true, loading: false, data: null, error: err, currentQuery: cacheKey }); 186 | }); 187 | }; 188 | activate() { 189 | if (typeof this.options.onMutation === "object") { 190 | this.mutationSubscription = this.client.subscribeQuery(this.onMutation, { 191 | cache: this.cache, 192 | softReset: this.softReset as (data: Object) => void, 193 | hardReset: this.hardReset, 194 | refresh: this.refresh, 195 | currentResults: () => this.currentState.data, 196 | isActive: this.isActive 197 | }); 198 | } 199 | this.unregisterQuery = this.client.registerQuery(this.query, this.refresh); 200 | } 201 | dispose() { 202 | this.mutationSubscription && this.mutationSubscription(); 203 | this.unregisterQuery && this.unregisterQuery(); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/queryTypes.ts: -------------------------------------------------------------------------------- 1 | import Cache from "./cache"; 2 | 3 | export type GraphQLResponse = { 4 | errors: Object[] | null; 5 | data: TData | null; 6 | }; 7 | 8 | export type CachedEntry = { 9 | error: Object | Object[] | null; 10 | data: TData | null; 11 | }; 12 | 13 | export type MinimalOnMutationPayload = { 14 | refreshActiveQueries: (query: string) => void; 15 | }; 16 | 17 | export type OnMutationPayload = { 18 | cache: Cache; 19 | softReset: (newResults: Object) => void; 20 | hardReset: () => void; 21 | refresh: () => void; 22 | currentResults: TResults; 23 | isActive: () => boolean; 24 | refreshActiveQueries: (query: string) => void; 25 | }; 26 | export type SubscriptionTrigger = string | RegExp; 27 | 28 | export type SubscriptionItem = { 29 | when: SubscriptionTrigger; 30 | run(onChangeOptions: MinimalOnMutationPayload, resp?: any, variables?: any): void; 31 | }; 32 | export type BasicSubscriptionEntry = SubscriptionItem & { 33 | type: "Basic"; 34 | }; 35 | 36 | export type FullSubscriptionItem = { 37 | when: SubscriptionTrigger; 38 | run(onChangeOptions: OnMutationPayload, resp?: unknown, variables?: unknown): void; 39 | }; 40 | 41 | export type FullSubscriptionEntry = FullSubscriptionItem & { 42 | type: "Full"; 43 | }; 44 | 45 | export type SubscriptionEntry = BasicSubscriptionEntry | FullSubscriptionEntry; 46 | -------------------------------------------------------------------------------- /test/GraphQLTypes.ts: -------------------------------------------------------------------------------- 1 | export interface Book { 2 | id: number; 3 | } 4 | 5 | export interface UpdateBookResult { 6 | updateBook: { 7 | Book: Book; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /test/cacheFeedsQueryCorrectly.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | const basicQuery = "A"; 10 | 11 | beforeEach(() => { 12 | client1 = new ClientMock("endpoint1"); 13 | client2 = new ClientMock("endpoint2"); 14 | setDefaultClient(client1); 15 | }); 16 | 17 | test("Default cache size of 10", async () => { 18 | const { queryState, sync } = query(basicQuery); 19 | 20 | Array.from({ length: 10 }).forEach((x, i) => sync({ page: i + 1 })); 21 | 22 | expect(client1.queriesRun).toBe(10); 23 | 24 | Array.from({ length: 9 }).forEach((x, i) => sync({ page: 10 - i - 1 })); 25 | expect(client1.queriesRun).toBe(10); 26 | }); 27 | 28 | test("Reload query", async () => { 29 | const { queryState, sync } = query(basicQuery); 30 | sync({ page: 1 }); 31 | 32 | expect(client1.queriesRun).toBe(1); 33 | 34 | await pause(); 35 | 36 | get(queryState).reload(); 37 | expect(client1.queriesRun).toBe(2); 38 | }); 39 | 40 | test("Default cache works with reloading", async () => { 41 | expect(client1.queriesRun).toBe(0); 42 | 43 | const { queryState, sync } = query(basicQuery); 44 | sync({ page: 1 }); 45 | 46 | expect(client1.queriesRun).toBe(1); 47 | 48 | get(queryState).reload(); 49 | 50 | Array.from({ length: 9 }).forEach((x, i) => sync({ page: i + 2 })); 51 | expect(client1.queriesRun).toBe(11); 52 | 53 | get(queryState).reload(); 54 | 55 | Array.from({ length: 9 }).forEach((x, i) => sync({ page: 10 - i - 1 })); 56 | expect(client1.queriesRun).toBe(12); 57 | }); 58 | 59 | test("Clear cache", async () => { 60 | const { queryState, sync } = query(basicQuery); 61 | sync({ page: 1 }); 62 | 63 | let cache = client1.getCache(basicQuery); 64 | expect(cache.entries.length).toBe(1); 65 | 66 | get(queryState).clearCache(); 67 | expect(cache.entries.length).toBe(0); 68 | }); 69 | 70 | test("Clear cache and reload", async () => { 71 | const { queryState, sync } = query(basicQuery); 72 | sync({ page: 1 }); 73 | 74 | let cache = client1.getCache(basicQuery); 75 | expect(cache.entries.length).toBe(1); 76 | 77 | get(queryState).clearCacheAndReload(); 78 | 79 | expect(cache.entries.length).toBe(1); 80 | expect(client1.queriesRun).toBe(2); 81 | }); 82 | 83 | test("Pick up in-progress query", async () => { 84 | let p: any = (client1.nextResult = deferred()); 85 | 86 | const { queryState: store1, sync: sync1 } = query(basicQuery); 87 | sync1({ page: 1 }); 88 | const { queryState: store2, sync: sync2 } = query(basicQuery); 89 | sync2({ page: 1 }); 90 | 91 | await p.resolve({ data: { tasks: [{ id: 9 }] } }); 92 | 93 | expect(get(store1)).toMatchObject(dataPacket({ tasks: [{ id: 9 }] })); 94 | expect(get(store1)).toMatchObject(dataPacket({ tasks: [{ id: 9 }] })); 95 | 96 | expect(client1.queriesRun).toBe(1); 97 | }); 98 | 99 | test("Cache accessible by query in client", async () => { 100 | const { queryState, sync } = query(basicQuery); 101 | sync({ page: 1 }); 102 | 103 | let cache = client1.getCache(basicQuery); 104 | expect(typeof cache).toBe("object"); 105 | }); 106 | 107 | test("Default cache size - verify on cache object retrieved", async () => { 108 | const { queryState, sync } = query(basicQuery); 109 | sync({ page: 1 }); 110 | 111 | let cache = client1.getCache(basicQuery); 112 | 113 | Array.from({ length: 9 }).forEach((x, i) => { 114 | sync({ page: i + 2 }); 115 | expect(cache.entries.length).toBe(i + 2); 116 | }); 117 | expect(cache.entries.length).toBe(10); 118 | 119 | Array.from({ length: 9 }).forEach((x, i) => { 120 | sync({ page: 10 - i - 1 }); 121 | expect(cache.entries.length).toBe(10); 122 | }); 123 | expect(cache.entries.length).toBe(10); 124 | }); 125 | 126 | test("Second component shares the same cache", async () => { 127 | const { queryState: store1, sync: sync1 } = query(basicQuery); 128 | sync1({ page: 1 }); 129 | 130 | Array.from({ length: 9 }).forEach((x, i) => sync1({ page: i + 2 })); 131 | expect(client1.queriesRun).toBe(10); 132 | 133 | Array.from({ length: 9 }).forEach((x, i) => sync1({ page: 10 - i - 1 })); 134 | expect(client1.queriesRun).toBe(10); 135 | 136 | const { queryState: store2, sync: sync2 } = query(basicQuery); 137 | sync2({ page: 1 }); 138 | 139 | Array.from({ length: 9 }).forEach((x, i) => sync2({ page: i + 2 })); 140 | expect(client1.queriesRun).toBe(10); 141 | 142 | Array.from({ length: 9 }).forEach((x, i) => sync1({ page: i + 2 })); 143 | expect(client1.queriesRun).toBe(10); 144 | }); 145 | 146 | test("Default cache size with overridden client", async () => { 147 | const { queryState, sync } = query(basicQuery, { client: client2 }); 148 | sync({ page: 1 }); 149 | 150 | Array.from({ length: 9 }).forEach((x, i) => sync({ page: i + 2 })); 151 | expect(client2.queriesRun).toBe(10); 152 | 153 | Array.from({ length: 9 }).forEach((x, i) => sync({ page: 10 - i - 1 })); 154 | expect(client2.queriesRun).toBe(10); 155 | }); 156 | -------------------------------------------------------------------------------- /test/cacheQueriesAreCorrect.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | const queryA = "A"; 8 | const queryB = "B"; 9 | 10 | let client1: any; 11 | 12 | beforeEach(() => { 13 | client1 = new ClientMock("endpoint1"); 14 | setDefaultClient(client1); 15 | //[a, ComponentA] = getComponentA(); 16 | //[b, c, ComponentB] = getComponentB(); 17 | }); 18 | 19 | //const getComponentA = hookComponentFactory([queryA, (props) => ({ a: props.a })]); 20 | //const getComponentB = hookComponentFactory([queryA, (props) => ({ a: props.a })], [queryB, (props) => ({ b: props.b })]); 21 | 22 | test("Basic query fires on mount", () => { 23 | let { sync } = query(queryA); 24 | sync({ a: 1 }); 25 | 26 | expect(client1.queriesRun).toBe(1); 27 | 28 | expect(client1.queriesRun).toBe(1); 29 | expect(client1.queryCalls).toEqual([[queryA, { a: 1 }]]); 30 | }); 31 | 32 | test("Basic query does not re-fire for unrelated prop change", () => { 33 | let { sync } = query(queryA); 34 | sync({ a: 1 }); 35 | 36 | expect(client1.queriesRun).toBe(1); 37 | 38 | sync({ a: 1 }); 39 | expect(client1.queriesRun).toBe(1); 40 | expect(client1.queryCalls).toEqual([[queryA, { a: 1 }]]); 41 | }); 42 | 43 | test("Basic query re-fires for prop change", () => { 44 | let { sync } = query(queryA); 45 | sync({ a: 1 }); 46 | 47 | expect(client1.queriesRun).toBe(1); 48 | 49 | sync({ a: 2 }); 50 | 51 | expect(client1.queriesRun).toBe(2); 52 | expect(client1.queryCalls).toEqual([ 53 | [queryA, { a: 1 }], 54 | [queryA, { a: 2 }] 55 | ]); 56 | }); 57 | 58 | test("Basic query hits cache", () => { 59 | let { sync } = query(queryA); 60 | sync({ a: 1 }); 61 | 62 | expect(client1.queriesRun).toBe(1); 63 | 64 | sync({ a: 2 }); 65 | sync({ a: 1 }); 66 | 67 | expect(client1.queriesRun).toBe(2); 68 | expect(client1.queryCalls).toEqual([ 69 | [queryA, { a: 1 }], 70 | [queryA, { a: 2 }] 71 | ]); 72 | }); 73 | 74 | test("Run two queries", () => { 75 | let { sync: syncA } = query(queryA); 76 | syncA({ a: 1 }); 77 | 78 | let { sync: syncB } = query(queryB); 79 | syncB({ b: 2 }); 80 | 81 | expect(client1.queriesRun).toBe(2); 82 | 83 | expect(client1.queriesRun).toBe(2); 84 | expect(client1.queryCalls).toEqual([ 85 | [queryA, { a: 1 }], 86 | [queryB, { b: 2 }] 87 | ]); 88 | }); 89 | 90 | test("Run two queries second updates", () => { 91 | let { sync: syncA } = query(queryA); 92 | syncA({ a: 1 }); 93 | 94 | let { sync: syncB } = query(queryB); 95 | syncB({ b: 2 }); 96 | 97 | expect(client1.queriesRun).toBe(2); 98 | expect(client1.queryCalls).toEqual([ 99 | [queryA, { a: 1 }], 100 | [queryB, { b: 2 }] 101 | ]); 102 | 103 | syncA({ a: 1 }); 104 | syncB({ b: "2a" }); 105 | 106 | expect(client1.queriesRun).toBe(3); 107 | expect(client1.queryCalls).toEqual([ 108 | [queryA, { a: 1 }], 109 | [queryB, { b: 2 }], 110 | [queryB, { b: "2a" }] 111 | ]); 112 | }); 113 | 114 | test("Run two queries second updates, then hits cache", () => { 115 | let { sync: syncA } = query(queryA); 116 | syncA({ a: 1 }); 117 | 118 | let { sync: syncB } = query(queryB); 119 | syncB({ b: 2 }); 120 | 121 | expect(client1.queriesRun).toBe(2); 122 | expect(client1.queryCalls).toEqual([ 123 | [queryA, { a: 1 }], 124 | [queryB, { b: 2 }] 125 | ]); 126 | 127 | syncA({ a: 1 }); 128 | syncB({ b: "2a" }); 129 | 130 | expect(client1.queriesRun).toBe(3); 131 | expect(client1.queryCalls).toEqual([ 132 | [queryA, { a: 1 }], 133 | [queryB, { b: 2 }], 134 | [queryB, { b: "2a" }] 135 | ]); 136 | 137 | syncA({ a: 1 }); 138 | syncB({ b: 2 }); 139 | 140 | expect(client1.queriesRun).toBe(3); 141 | expect(client1.queryCalls).toEqual([ 142 | [queryA, { a: 1 }], 143 | [queryB, { b: 2 }], 144 | [queryB, { b: "2a" }] 145 | ]); 146 | }); 147 | 148 | test("Run two queries with identical prop 'changes'", () => { 149 | let { sync: syncA } = query(queryA); 150 | syncA({ a: 1 }); 151 | 152 | let { sync: syncB } = query(queryB); 153 | syncB({ b: 2 }); 154 | 155 | expect(client1.queriesRun).toBe(2); 156 | expect(client1.queryCalls).toEqual([ 157 | [queryA, { a: 1 }], 158 | [queryB, { b: 2 }] 159 | ]); 160 | 161 | syncA({ a: 1 }); 162 | syncB({ b: 2 }); 163 | 164 | expect(client1.queriesRun).toBe(2); 165 | expect(client1.queryCalls).toEqual([ 166 | [queryA, { a: 1 }], 167 | [queryB, { b: 2 }] 168 | ]); 169 | }); 170 | -------------------------------------------------------------------------------- /test/cacheSize.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | const basicQuery = "A"; 10 | 11 | beforeEach(() => { 12 | client1 = new ClientMock("endpoint1"); 13 | client2 = new ClientMock("endpoint2"); 14 | setDefaultClient(client1); 15 | }); 16 | 17 | describe("Disable caching", () => { 18 | test("Explicit cache with size zero", async () => { 19 | let noCache = new Cache(0); 20 | let p = (client1.nextResult = deferred()); 21 | 22 | const { queryState: store1, sync: sync1 } = query(basicQuery, { cache: noCache }); 23 | const { queryState: store2, sync: sync2 } = query(basicQuery, { cache: noCache }); 24 | 25 | sync1({ page: 1 }); 26 | 27 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 28 | 29 | sync2({ page: 1 }); 30 | 31 | expect(client1.queriesRun).toBe(2); 32 | }); 33 | test("Explicit cache with size one", async () => { 34 | let noCache = new Cache(1); 35 | let p = (client1.nextResult = deferred()); 36 | 37 | const { queryState: store1, sync: sync1 } = query(basicQuery, { cache: noCache }); 38 | const { queryState: store2, sync: sync2 } = query(basicQuery, { cache: noCache }); 39 | 40 | sync1({ page: 1 }); 41 | 42 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 43 | 44 | sync2({ page: 1 }); 45 | 46 | expect(client1.queriesRun).toBe(1); 47 | }); 48 | 49 | test("Client with cacheSize zero", async () => { 50 | let noCacheClient = new ClientMock({ cacheSize: 0 }); 51 | 52 | const { queryState: store1, sync: sync1 } = query(basicQuery, { client: noCacheClient }); 53 | const { queryState: store2, sync: sync2 } = query(basicQuery, { client: noCacheClient }); 54 | 55 | let p = (client1.nextResult = deferred()); 56 | 57 | sync1({ page: 1 }); 58 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 59 | sync2({ page: 1 }); 60 | 61 | expect(noCacheClient.queriesRun).toBe(2); 62 | }); 63 | test("Client with cacheSize one", async () => { 64 | let noCacheClient = new ClientMock({ cacheSize: 1 }); 65 | 66 | const { queryState: store1, sync: sync1 } = query(basicQuery, { client: noCacheClient }); 67 | const { queryState: store2, sync: sync2 } = query(basicQuery, { client: noCacheClient }); 68 | 69 | let p = (client1.nextResult = deferred()); 70 | 71 | sync1({ page: 1 }); 72 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 73 | sync2({ page: 1 }); 74 | 75 | expect(noCacheClient.queriesRun).toBe(1); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/cachedResultSynchronouslyAvailable.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { pause } from "./testUtil"; 6 | 7 | const LOAD_TASKS = "A"; 8 | 9 | let client1: any; 10 | 11 | beforeEach(() => { 12 | client1 = new ClientMock("endpoint1"); 13 | setDefaultClient(client1); 14 | }); 15 | 16 | test("Initial results synchronously available if available", async () => { 17 | const { queryState: store1, sync: sync1 } = query(LOAD_TASKS); 18 | 19 | client1.nextResult = { data: { x: 1 } }; 20 | 21 | sync1({ a: 12 }); 22 | await pause(); // library reads from "network" and awaits result, even though it's mocked here 23 | 24 | expect(get(store1).data).toEqual({ x: 1 }); 25 | expect(client1.queriesRun).toBe(1); 26 | 27 | const { queryState: store2, sync: sync2 } = query(LOAD_TASKS); 28 | sync2({ a: 12 }); 29 | 30 | expect(get(store2).data).toEqual({ x: 1 }); 31 | expect(client1.queriesRun).toBe(1); 32 | }); 33 | -------------------------------------------------------------------------------- /test/cachedResultsAppropriatelyAvailable.test.ts: -------------------------------------------------------------------------------- 1 | import { setDefaultClient, query, Client } from "../src/index"; 2 | import ClientMock from "./clientMock"; 3 | import { pause } from "./testUtil"; 4 | 5 | const LOAD_TASKS = "A"; 6 | const LOAD_USERS = "B"; 7 | const UPDATE_USER = "M"; 8 | 9 | let client1: any; 10 | let ComponentToUse: any; 11 | let renders = 0; 12 | 13 | beforeEach(() => { 14 | client1 = new ClientMock("endpoint1"); 15 | setDefaultClient(client1); 16 | renders = 0; 17 | }); 18 | 19 | test("Cache object behaves correctly", async () => { 20 | const { queryState, sync } = query(LOAD_TASKS); 21 | sync({ a: 12 }); 22 | 23 | client1.nextResult = { data: {} }; 24 | 25 | expect(typeof client1.getCache(LOAD_TASKS)).toBe("object"); 26 | expect(typeof client1.getCache(LOAD_USERS)).toBe("undefined"); 27 | 28 | expect(typeof client1.getCache(LOAD_TASKS).get(client1.getGraphqlQuery({ query: LOAD_TASKS, variables: { a: 12 } }))).toBe("object"); 29 | expect(client1.getCache(LOAD_TASKS).keys.length).toBe(1); 30 | }); 31 | -------------------------------------------------------------------------------- /test/clientCreation.test.ts: -------------------------------------------------------------------------------- 1 | import Client from "../src/client"; 2 | import { DEFAULT_CACHE_SIZE } from "../src/cache"; 3 | 4 | test("Default cache size", async () => { 5 | let c = new Client({ endpoint: "" }); 6 | expect(c.cacheSizeToUse).toBe(DEFAULT_CACHE_SIZE); 7 | }); 8 | -------------------------------------------------------------------------------- /test/clientMock.ts: -------------------------------------------------------------------------------- 1 | import ClientBase from "../src/client"; 2 | const queryString = require("query-string"); 3 | 4 | export default class Client extends ClientBase { 5 | queriesRun = 0; 6 | queryCalls: any[] = []; 7 | mutationsRun = 0; 8 | mutationCalls: any[] = []; 9 | nextResult: Promise | unknown; 10 | justWait: boolean = false; 11 | nextMutationResult: unknown; 12 | generateResponse?: (query: string, variables: unknown) => Promise; 13 | 14 | constructor(props: any) { 15 | super(props); 16 | this.reset(); 17 | this.endpoint = ""; 18 | } 19 | reset = () => { 20 | this.queriesRun = 0; 21 | this.queryCalls = []; 22 | 23 | this.mutationsRun = 0; 24 | this.mutationCalls = []; 25 | }; 26 | runUri = (uri: any): any => { 27 | let parsed = queryString.parse(uri); 28 | let query = parsed.query; 29 | let variables = eval("(" + parsed.variables + ")"); 30 | return this.runQuery(query, variables); 31 | }; 32 | runQuery = (query: any, variables: any): any => { 33 | if (this.generateResponse) { 34 | this.nextResult = this.generateResponse(query, variables); 35 | } else if (this.justWait) { 36 | return new Promise(() => null); 37 | } 38 | this.queriesRun++; 39 | this.queryCalls.push([query, variables]); 40 | return this.nextResult || {}; 41 | }; 42 | runMutation = (mutation: any, variables: any): any => { 43 | this.mutationsRun++; 44 | this.mutationCalls.push([mutation, variables]); 45 | return this.nextMutationResult || {}; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /test/clientPreload.test.ts: -------------------------------------------------------------------------------- 1 | import ClientMock from "./clientMock"; 2 | import { deferred, resolveDeferred } from "./testUtil"; 3 | 4 | let client1: any; 5 | const basicQuery = "A"; 6 | 7 | beforeEach(() => { 8 | client1 = new ClientMock("endpoint1"); 9 | }); 10 | 11 | describe("Preload", () => { 12 | test("Preload test 1", async () => { 13 | client1.nextResult = Promise.resolve({}); 14 | 15 | expect(client1.queriesRun).toBe(0); 16 | client1.preload(basicQuery, {}); 17 | expect(client1.queriesRun).toBe(1); 18 | client1.preload(basicQuery, {}); 19 | expect(client1.queriesRun).toBe(1); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/compress.test.ts: -------------------------------------------------------------------------------- 1 | import compress from "../src/compress"; 2 | 3 | const title = `This is a not compressed`; 4 | 5 | const query1 = compress` 6 | query ReadBooks { 7 | allBooks ( title : "${title}" ) { 8 | Books { 9 | title 10 | publisher 11 | } 12 | } 13 | }`; 14 | 15 | const compressed1 = `query ReadBooks{allBooks(title:"${title}"){Books{title publisher}}}`; 16 | 17 | test("Compress 1", async () => { 18 | expect(query1).toEqual(compressed1); 19 | }); 20 | 21 | const query2 = compress` 22 | 23 | allBooks ( title : "${title}" ) { 24 | Books { 25 | title 26 | publisher 27 | } 28 | } 29 | `; 30 | 31 | const compressed2 = `allBooks(title:"${title}"){Books{title publisher}}`; 32 | 33 | test("Compress 2", async () => { 34 | expect(query2).toEqual(compressed2); 35 | }); 36 | 37 | const query3 = compress` 38 | query twoQueries { 39 | ab1: allBooks ( title : "A" ) { 40 | Books { 41 | title 42 | publisher 43 | } 44 | } 45 | ab2: allBooks ( title : "B" ) { 46 | Books { 47 | title 48 | publisher 49 | } 50 | } 51 | }`; 52 | 53 | const compressed3 = `query twoQueries{ab1:allBooks(title:"A"){Books{title publisher}}ab2:allBooks(title:"B"){Books{title publisher}}}`; 54 | 55 | test("Compress 3", async () => { 56 | expect(query3).toEqual(compressed3); 57 | }); 58 | 59 | const query4 = compress` 60 | query twoQueries ( $title1: String , $title2 : String ) { 61 | ab1: allBooks ( title : $title1 ) { 62 | Books { 63 | title 64 | publisher 65 | } 66 | } 67 | ab2: allBooks ( title : $title2 ) { 68 | Books { 69 | title 70 | publisher 71 | } 72 | } 73 | }`; 74 | 75 | const compressed4 = `query twoQueries($title1:String,$title2:String){ab1:allBooks(title:$title1){Books{title publisher}}ab2:allBooks(title:$title2){Books{title publisher}}}`; 76 | 77 | test("Compress 4", async () => { 78 | expect(query4).toEqual(compressed4); 79 | }); 80 | -------------------------------------------------------------------------------- /test/initialState.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query, Cache } from "../src/index"; 4 | import mutation from "../src/mutation"; 5 | import ClientMock from "./clientMock"; 6 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 7 | 8 | let client1; 9 | let ComponentToUse; 10 | 11 | const LOAD_TASKS = "A"; 12 | const UPDATE_USER = "M"; 13 | 14 | beforeEach(() => { 15 | client1 = new ClientMock("endpoint1"); 16 | setDefaultClient(client1); 17 | }); 18 | 19 | test("initial mutation state", async () => { 20 | const mutationState = get(mutation(UPDATE_USER).mutationState); 21 | expect(typeof mutationState.running).toBe("boolean"); 22 | expect(typeof mutationState.finished).toBe("boolean"); 23 | expect(typeof mutationState.runMutation).toBe("function"); 24 | }); 25 | 26 | test("initial mutation state", async () => { 27 | const queryState = get(query(LOAD_TASKS).queryState); 28 | 29 | expect(typeof queryState.loading).toEqual("boolean"); 30 | expect(typeof queryState.loaded).toEqual("boolean"); 31 | expect(typeof queryState.data).toEqual("object"); 32 | expect(typeof queryState.error).toEqual("object"); 33 | }); 34 | -------------------------------------------------------------------------------- /test/mutation.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | let ComponentA: any; 10 | let getProps: any; 11 | 12 | beforeEach(() => { 13 | client1 = new ClientMock("endpoint1"); 14 | client2 = new ClientMock("endpoint2"); 15 | setDefaultClient(client1); 16 | }); 17 | 18 | test("Mutation function exists", () => { 19 | const mutationState = get(mutation("A").mutationState); 20 | 21 | expect(typeof mutationState.runMutation).toBe("function"); 22 | expect(mutationState.running).toBe(false); 23 | expect(mutationState.finished).toBe(false); 24 | }); 25 | 26 | test("Mutation function calls", () => { 27 | const mutationState = get(mutation("A").mutationState); 28 | mutationState.runMutation(null); 29 | 30 | expect(client1.mutationsRun).toBe(1); 31 | }); 32 | 33 | test("Mutation function calls client override", () => { 34 | const mutationState = get(mutation("A", { client: client2 }).mutationState); 35 | mutationState.runMutation(null); 36 | 37 | expect(client1.mutationsRun).toBe(0); 38 | expect(client2.mutationsRun).toBe(1); 39 | }); 40 | 41 | test("Mutation function calls twice", () => { 42 | const mutationState = get(mutation("A").mutationState); 43 | mutationState.runMutation(null); 44 | mutationState.runMutation(null); 45 | 46 | expect(client1.mutationsRun).toBe(2); 47 | }); 48 | 49 | test("Mutation function calls twice - client override", () => { 50 | const mutationState = get(mutation("A", { client: client2 }).mutationState); 51 | mutationState.runMutation(null); 52 | mutationState.runMutation(null); 53 | 54 | expect(client1.mutationsRun).toBe(0); 55 | expect(client2.mutationsRun).toBe(2); 56 | }); 57 | -------------------------------------------------------------------------------- /test/mutationEvents.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { UpdateBookResult } from "./GraphQLTypes"; 6 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 7 | 8 | let client1: any; 9 | let client2: any; 10 | let sub: any; 11 | 12 | type BookResults = { 13 | Books: { id: number }[]; 14 | }; 15 | 16 | beforeEach(() => { 17 | client1 = new ClientMock("endpoint1"); 18 | client2 = new ClientMock("endpoint1"); 19 | setDefaultClient(client1); 20 | }); 21 | afterEach(() => { 22 | sub && sub(); 23 | sub = null; 24 | }); 25 | 26 | generateTests(() => client1); 27 | generateTests( 28 | () => client2, 29 | () => ({ client: client2 }), 30 | () => ({ client: client2 }) 31 | ); 32 | 33 | function generateTests(getClient: any, queryProps = () => ({}), mutationProps = () => ({})) { 34 | test("Mutation listener updates cache X", async () => { 35 | const client = getClient(); 36 | const { queryState, sync } = query("A", { 37 | onMutation: { 38 | when: "updateBook", 39 | run: ({ cache }, { updateBook: { Book } }: UpdateBookResult, x: any) => { 40 | cache.entries.forEach(([key, results]) => { 41 | if (!(results instanceof Promise) && results.data != null) { 42 | let CachedBook: any = results.data.Books.find(b => b.id == Book.id); 43 | CachedBook && Object.assign(CachedBook, Book); 44 | } 45 | }); 46 | } 47 | }, 48 | ...queryProps() 49 | }); 50 | const { mutationState } = mutation("someMutation{}", mutationProps()); 51 | sub = queryState.subscribe(() => {}); 52 | 53 | client.nextResult = { 54 | data: { 55 | Books: [ 56 | { id: 1, title: "Book 1", author: "Adam" }, 57 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 58 | ] 59 | } 60 | }; 61 | await sync({ query: "a" }); 62 | 63 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 64 | 65 | await sync({ query: "b" }); 66 | 67 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 68 | await get(mutationState).runMutation(null); 69 | 70 | expect(client.queriesRun).toBe(2); //run for new query args 71 | 72 | sync({ query: "a" }); 73 | 74 | expect(client.queriesRun).toBe(2); //still loads from cache 75 | expect(get(queryState).data).toEqual({ 76 | Books: [ 77 | { id: 1, title: "Book 1", author: "Adam" }, 78 | { id: 2, title: "Book 2", author: "Eve" } 79 | ] 80 | }); //loads updated data 81 | }); 82 | 83 | test("Mutation listener updates cache with mutation args - string", async () => { 84 | const client = getClient(); 85 | const { queryState, sync } = query("A", { 86 | onMutation: { 87 | when: "deleteBook", 88 | run: ({ cache, refresh }, resp, args: any) => { 89 | cache.entries.forEach(([key, results]) => { 90 | if (!(results instanceof Promise) && results.data != null) { 91 | results.data.Books = results.data.Books.filter(b => b.id != args.id); 92 | refresh(); 93 | } 94 | }); 95 | } 96 | }, 97 | ...queryProps() 98 | }); 99 | const { mutationState } = mutation("someMutation{}", mutationProps()); 100 | sub = queryState.subscribe(() => {}); 101 | 102 | client.nextResult = { 103 | data: { 104 | Books: [ 105 | { id: 1, title: "Book 1", author: "Adam" }, 106 | { id: 2, title: "Book 2", author: "Eve" } 107 | ] 108 | } 109 | }; 110 | await sync({ query: "a" }); 111 | 112 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 113 | await sync({ query: "b" }); 114 | 115 | client.nextMutationResult = { deleteBook: { success: true } }; 116 | await get(mutationState).runMutation({ id: 1 }); 117 | 118 | expect(client.queriesRun).toBe(2); //run for new query args 119 | expect(get(queryState).data).toEqual({ Books: [] }); //loads updated data 120 | 121 | await sync({ query: "a" }); 122 | 123 | expect(client.queriesRun).toBe(2); //still loads from cache 124 | expect(get(queryState).data).toEqual({ Books: [{ id: 2, title: "Book 2", author: "Eve" }] }); //loads updated data 125 | }); 126 | 127 | test("Mutation listener updates cache with mutation args - string - component gets new data", async () => { 128 | const client = getClient(); 129 | const { queryState, sync } = query("A", { 130 | onMutation: { 131 | when: "deleteBook", 132 | run: ({ cache, refresh }, resp, args: any) => { 133 | cache.entries.forEach(([key, results]) => { 134 | if (!(results instanceof Promise) && results.data != null) { 135 | results.data.Books = results.data.Books.filter(b => b.id != args.id); 136 | refresh(); 137 | } 138 | }); 139 | } 140 | }, 141 | ...queryProps() 142 | }); 143 | const { mutationState } = mutation("someMutation{}", mutationProps()); 144 | sub = queryState.subscribe(() => {}); 145 | 146 | client.nextResult = { 147 | data: { 148 | Books: [ 149 | { id: 1, title: "Book 1", author: "Adam" }, 150 | { id: 2, title: "Book 2", author: "Eve" } 151 | ] 152 | } 153 | }; 154 | await sync({ query: "a" }); 155 | 156 | expect(get(queryState).data).toEqual({ 157 | Books: [ 158 | { id: 1, title: "Book 1", author: "Adam" }, 159 | { id: 2, title: "Book 2", author: "Eve" } 160 | ] 161 | }); //loads updated data 162 | 163 | client.nextMutationResult = { deleteBook: { success: true } }; 164 | await get(mutationState).runMutation({ id: 1 }); 165 | 166 | expect(get(queryState).data).toEqual({ 167 | Books: [{ id: 2, title: "Book 2", author: "Eve" }] 168 | }); //loads updated data 169 | }); 170 | 171 | test("Mutation listener updates cache with mutation args - regex", async () => { 172 | const client = getClient(); 173 | const { queryState, sync } = query("A", { 174 | onMutation: { 175 | when: /deleteBook/, 176 | run: ({ cache, refresh }, resp, args: any) => { 177 | cache.entries.forEach(([key, results]) => { 178 | if (!(results instanceof Promise) && results.data != null) { 179 | results.data.Books = results.data.Books.filter(b => b.id != args.id); 180 | refresh(); 181 | } 182 | }); 183 | } 184 | }, 185 | ...queryProps() 186 | }); 187 | const { mutationState } = mutation("someMutation{}", mutationProps()); 188 | sub = queryState.subscribe(() => {}); 189 | 190 | client.nextResult = { 191 | data: { 192 | Books: [ 193 | { id: 1, title: "Book 1", author: "Adam" }, 194 | { id: 2, title: "Book 2", author: "Eve" } 195 | ] 196 | } 197 | }; 198 | await sync({ query: "a" }); 199 | 200 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 201 | await sync({ query: "b" }); 202 | 203 | client.nextMutationResult = { deleteBook: { success: true } }; 204 | await get(mutationState).runMutation({ id: 1 }); 205 | 206 | expect(client.queriesRun).toBe(2); //run for new query args 207 | expect(get(queryState).data).toEqual({ Books: [] }); //loads updated data 208 | 209 | await sync({ query: "a" }); 210 | 211 | expect(client.queriesRun).toBe(2); //still loads from cache 212 | expect(get(queryState).data).toEqual({ Books: [{ id: 2, title: "Book 2", author: "Eve" }] }); //loads updated data 213 | }); 214 | 215 | test("Mutation listener updates cache then refreshes from cache", async () => { 216 | const client = getClient(); 217 | const { queryState, sync } = query("A", { 218 | onMutation: { 219 | when: "updateBook", 220 | run: ({ cache, refresh }, { updateBook: { Book } }) => { 221 | cache.entries.forEach(([key, results]) => { 222 | if (!(results instanceof Promise) && results.data != null) { 223 | let newBooks = results.data.Books.map(b => { 224 | if (b.id == Book.id) { 225 | return Object.assign({}, b, Book); 226 | } 227 | return b; 228 | }); 229 | //do this immutable crap just to make sure tests don't accidentally pass because of object references to current props being updated - in real life the component would not be re-rendered, but here's we're verifying the props directly 230 | let newResults: any = { ...results }; 231 | newResults.data = { ...newResults.data }; 232 | newResults.data.Books = newBooks; 233 | cache.set(key, newResults); 234 | refresh(); 235 | } 236 | }); 237 | } 238 | }, 239 | ...queryProps() 240 | }); 241 | const { mutationState } = mutation("someMutation{}", mutationProps()); 242 | sub = queryState.subscribe(() => {}); 243 | 244 | client.nextResult = { 245 | data: { 246 | Books: [ 247 | { id: 1, title: "Book 1", author: "Adam" }, 248 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 249 | ] 250 | } 251 | }; 252 | await sync({ query: "a" }); 253 | 254 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 255 | await get(mutationState).runMutation(null); 256 | 257 | expect(client.queriesRun).toBe(1); //refreshed from cache 258 | expect(get(queryState).data).toEqual({ 259 | Books: [ 260 | { id: 1, title: "Book 1", author: "Adam" }, 261 | { id: 2, title: "Book 2", author: "Eve" } 262 | ] 263 | }); //refreshed with updated data 264 | }); 265 | 266 | test("Mutation listener - soft reset - props right, cache cleared", async () => { 267 | let componentsCache: any; 268 | 269 | const client = getClient(); 270 | const { queryState, sync } = query("A", { 271 | onMutation: { 272 | when: "updateBook", 273 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }) => { 274 | componentsCache = cache; 275 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 276 | CachedBook && Object.assign(CachedBook, Book); 277 | softReset(currentResults); 278 | } 279 | }, 280 | ...queryProps() 281 | }); 282 | const { mutationState } = mutation("someMutation{}", mutationProps()); 283 | sub = queryState.subscribe(() => {}); 284 | 285 | client.nextResult = { 286 | data: { 287 | Books: [ 288 | { id: 1, title: "Book 1", author: "Adam" }, 289 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 290 | ] 291 | } 292 | }; 293 | await sync({ query: "a" }); 294 | 295 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 296 | await get(mutationState).runMutation(null); 297 | 298 | expect(componentsCache.entries.length).toBe(0); //cache is cleared 299 | expect(get(queryState).data).toEqual({ 300 | Books: [ 301 | { id: 1, title: "Book 1", author: "Adam" }, 302 | { id: 2, title: "Book 2", author: "Eve" } 303 | ] 304 | }); //updated data is now there 305 | }); 306 | 307 | test("Mutation listener - soft reset - re-render does not re-fetch", async () => { 308 | let componentsCache; 309 | const client = getClient(); 310 | const { queryState, sync } = query("A", { 311 | onMutation: { 312 | when: "updateBook", 313 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }) => { 314 | componentsCache = cache; 315 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 316 | CachedBook && Object.assign(CachedBook, Book); 317 | softReset(currentResults); 318 | } 319 | }, 320 | ...queryProps() 321 | }); 322 | const { mutationState } = mutation("someMutation{}", mutationProps()); 323 | sub = queryState.subscribe(() => {}); 324 | 325 | client.nextResult = { 326 | data: { 327 | Books: [ 328 | { id: 1, title: "Book 1", author: "Adam" }, 329 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 330 | ] 331 | } 332 | }; 333 | 334 | await sync({ query: "a" }); 335 | 336 | client.nextResult = { 337 | data: { 338 | Books: [ 339 | { id: 1, title: "Book 1", author: "Adam" }, 340 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 341 | ] 342 | } 343 | }; 344 | 345 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 346 | await get(mutationState).runMutation(null); 347 | 348 | expect(get(queryState).data).toEqual({ 349 | Books: [ 350 | { id: 1, title: "Book 1", author: "Adam" }, 351 | { id: 2, title: "Book 2", author: "Eve" } 352 | ] 353 | }); //updated data is now there 354 | 355 | sync({ query: "a" }); 356 | await pause(); 357 | 358 | expect(get(queryState).data).toEqual({ 359 | Books: [ 360 | { id: 1, title: "Book 1", author: "Adam" }, 361 | { id: 2, title: "Book 2", author: "Eve" } 362 | ] 363 | }); //updated data is now there 364 | }); 365 | 366 | test("Mutation listener - soft reset - re-render when you come back", async () => { 367 | let componentsCache; 368 | const client1 = getClient(); 369 | const { queryState, sync } = query("A", { 370 | onMutation: { 371 | when: "updateBook", 372 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }) => { 373 | componentsCache = cache; 374 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 375 | CachedBook && Object.assign(CachedBook, Book); 376 | softReset(currentResults); 377 | } 378 | }, 379 | ...queryProps() 380 | }); 381 | const { mutationState } = mutation("someMutation{}", mutationProps()); 382 | sub = queryState.subscribe(() => {}); 383 | 384 | client1.nextResult = { 385 | data: { 386 | Books: [ 387 | { id: 1, title: "Book 1", author: "Adam" }, 388 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 389 | ] 390 | } 391 | }; 392 | await sync({ query: "a" }); 393 | 394 | client1.nextResult = { 395 | data: { 396 | Books: [ 397 | { id: 1, title: "Book 1", author: "Adam" }, 398 | { id: 2, title: "Book 2", author: "XXXXXX" } 399 | ] 400 | } 401 | }; 402 | 403 | client1.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve New" } } }; 404 | await get(mutationState).runMutation(null); 405 | 406 | expect(get(queryState).data).toEqual({ 407 | Books: [ 408 | { id: 1, title: "Book 1", author: "Adam" }, 409 | { id: 2, title: "Book 2", author: "Eve New" } 410 | ] 411 | }); //updated data is now there 412 | 413 | client1.nextResult = { 414 | data: { 415 | Books: [ 416 | { id: 1, title: "Book 1", author: "Adam" }, 417 | { id: 2, title: "Book 2", author: "Eve 2" } 418 | ] 419 | } 420 | }; 421 | 422 | await sync({ query: "b" }); 423 | 424 | expect(get(queryState).data).toEqual({ 425 | Books: [ 426 | { id: 1, title: "Book 1", author: "Adam" }, 427 | { id: 2, title: "Book 2", author: "Eve 2" } 428 | ] 429 | }); 430 | 431 | client1.nextResult = { 432 | data: { 433 | Books: [ 434 | { id: 1, title: "Book 1", author: "Adam" }, 435 | { id: 2, title: "Book 2", author: "Eve 3" } 436 | ] 437 | } 438 | }; 439 | 440 | await sync({ query: "a" }); 441 | 442 | expect(get(queryState).data).toEqual({ 443 | Books: [ 444 | { id: 1, title: "Book 1", author: "Adam" }, 445 | { id: 2, title: "Book 2", author: "Eve 3" } 446 | ] 447 | }); 448 | }); 449 | 450 | test("Mutation listener - hard reset - props right, cache cleared, client qeried", async () => { 451 | let componentsCache: any; 452 | const client = getClient(); 453 | const { queryState, sync } = query("A", { 454 | onMutation: { 455 | when: "updateBook", 456 | run: ({ cache, hardReset }) => { 457 | componentsCache = cache; 458 | hardReset(); 459 | } 460 | }, 461 | ...queryProps() 462 | }); 463 | const { mutationState } = mutation("someMutation{}", mutationProps()); 464 | sub = queryState.subscribe(() => {}); 465 | 466 | client.nextResult = { 467 | data: { 468 | Books: [ 469 | { id: 1, title: "Book 1", author: "Adam" }, 470 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 471 | ] 472 | } 473 | }; 474 | sync({ query: "a" }); 475 | 476 | expect(client.queriesRun).toBe(1); //just the one 477 | client.nextResult = { 478 | data: { 479 | Books: [ 480 | { id: 1, title: "Book 1", author: "Adam" }, 481 | { id: 2, title: "Book 2", author: "Eve" } 482 | ] 483 | } 484 | }; 485 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 486 | await get(mutationState).runMutation(null); 487 | 488 | expect(componentsCache.entries.length).toBe(1); //just the most recent entry 489 | expect(get(queryState).data).toEqual({ 490 | Books: [ 491 | { id: 1, title: "Book 1", author: "Adam" }, 492 | { id: 2, title: "Book 2", author: "Eve" } 493 | ] 494 | }); //updated data is now there 495 | expect(client.queriesRun).toBe(2); //run from the hard reset 496 | }); 497 | 498 | test("Mutation listener - new component, re-queries", async () => { 499 | let componentsCache: any; 500 | const client = getClient(); 501 | const { queryState, sync } = query("A", { 502 | onMutation: { 503 | when: "updateBook", 504 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }: UpdateBookResult) => { 505 | componentsCache = cache; 506 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 507 | CachedBook && Object.assign(CachedBook, Book); 508 | softReset(currentResults); 509 | } 510 | }, 511 | ...queryProps() 512 | }); 513 | const { mutationState } = mutation("someMutation{}", mutationProps()); 514 | sub = queryState.subscribe(() => {}); 515 | 516 | client.nextResult = { 517 | data: { 518 | Books: [ 519 | { id: 1, title: "Book 1", author: "Adam" }, 520 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 521 | ] 522 | } 523 | }; 524 | await sync({ query: "a" }); 525 | 526 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 527 | await get(mutationState).runMutation(null); 528 | 529 | expect(componentsCache.entries.length).toBe(0); //cache is cleared 530 | 531 | expect(client.queriesRun).toBe(1); 532 | 533 | const { sync: sync2 } = query("A", { ...queryProps() }); 534 | await sync2({ query: "a" }); 535 | expect(client.queriesRun).toBe(2); 536 | }); 537 | } 538 | -------------------------------------------------------------------------------- /test/mutationEventsMatch.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | let sub: any; 10 | 11 | beforeEach(() => { 12 | client1 = new ClientMock("endpoint1"); 13 | client2 = new ClientMock("endpoint1"); 14 | setDefaultClient(client1); 15 | }); 16 | afterEach(() => { 17 | sub && sub(); 18 | sub = null; 19 | }); 20 | 21 | generateTests(() => client1); 22 | generateTests( 23 | () => client2, 24 | () => ({ client: client2 }), 25 | () => ({ client: client2 }) 26 | ); 27 | 28 | function generateTests(getClient: any, queryProps = () => ({}), mutationProps = () => ({})) { 29 | test("Mutation listener runs with exact match", async () => { 30 | const client = getClient(); 31 | let runCount = 0; 32 | const { queryState, sync } = query("A", { 33 | onMutation: { when: "updateBook", run: () => runCount++ }, 34 | ...queryProps() 35 | }); 36 | const { mutationState } = mutation("someMutation{}", mutationProps()); 37 | sub = queryState.subscribe(() => {}); 38 | 39 | sync({ page: 1 }); 40 | 41 | client.nextMutationResult = { updateBook: { Book: { title: "New Title" } } }; 42 | await get(mutationState).runMutation(null); 43 | 44 | expect(runCount).toBe(1); 45 | }); 46 | 47 | test("Mutation listener runs with exact match twice", async () => { 48 | const client = getClient(); 49 | let runCount = 0; 50 | let runCount2 = 0; 51 | const { queryState, sync } = query("A", { 52 | onMutation: [ 53 | { when: "updateBook", run: () => runCount++ }, 54 | { when: "updateBook", run: () => runCount2++ } 55 | ], 56 | ...queryProps() 57 | }); 58 | const { mutationState } = mutation("someMutation{}", mutationProps()); 59 | sub = queryState.subscribe(() => {}); 60 | 61 | client.nextMutationResult = { updateBook: { Book: { title: "New Name" } } }; 62 | await get(mutationState).runMutation(null); 63 | 64 | expect(runCount).toBe(1); 65 | expect(runCount2).toBe(1); 66 | }); 67 | 68 | test("Mutation listener runs with regex match", async () => { 69 | const client = getClient(); 70 | let runCount = 0; 71 | const { queryState, sync } = query("A", { onMutation: { when: /update/, run: () => runCount++ }, ...queryProps() }); 72 | const { mutationState } = mutation("someMutation{}", mutationProps()); 73 | sub = queryState.subscribe(() => {}); 74 | 75 | client.nextMutationResult = { updateBook: { Book: { title: "New Title" } } }; 76 | await get(mutationState).runMutation(null); 77 | 78 | expect(runCount).toBe(1); 79 | }); 80 | 81 | test("Mutation listener runs with regex match twice", async () => { 82 | const client = getClient(); 83 | let runCount = 0; 84 | let runCount2 = 0; 85 | const { queryState, sync } = query("A", { 86 | onMutation: [ 87 | { when: /book/i, run: () => runCount++ }, 88 | { when: /update/, run: () => runCount2++ } 89 | ], 90 | ...queryProps() 91 | }); 92 | const { mutationState } = mutation("someMutation{}", mutationProps()); 93 | sub = queryState.subscribe(() => {}); 94 | 95 | client.nextMutationResult = { updateBook: { Book: { title: "New Name" } } }; 96 | await get(mutationState).runMutation(null); 97 | 98 | expect(runCount).toBe(1); 99 | expect(runCount2).toBe(1); 100 | }); 101 | 102 | test("Mutation listener runs either test match", async () => { 103 | const client = getClient(); 104 | let runCount = 0; 105 | let runCount2 = 0; 106 | const { queryState, sync } = query("A", { 107 | onMutation: [ 108 | { when: "updateBook", run: () => runCount++ }, 109 | { when: /update/, run: () => runCount2++ } 110 | ], 111 | ...queryProps() 112 | }); 113 | const { mutationState } = mutation("someMutation{}", mutationProps()); 114 | sub = queryState.subscribe(() => {}); 115 | 116 | client.nextMutationResult = { updateBook: { Book: { title: "New Name" } } }; 117 | await get(mutationState).runMutation(null); 118 | 119 | expect(runCount).toBe(1); 120 | expect(runCount2).toBe(1); 121 | }); 122 | 123 | test("Mutation listener misses without match", async () => { 124 | const client = getClient(); 125 | let runCount = 0; 126 | const { queryState, sync } = query("A", { onMutation: { when: "updateBook", run: () => runCount++ }, ...queryProps() }); 127 | const { mutationState } = mutation("someMutation{}", mutationProps()); 128 | sub = queryState.subscribe(() => {}); 129 | 130 | client.nextMutationResult = { updateAuthor: { Author: { name: "New Name" } } }; 131 | await get(mutationState).runMutation(null); 132 | 133 | expect(runCount).toBe(0); 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /test/mutationEventsRespectActiveStatus.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | let sub: any; 10 | 11 | type BookResultsType = { Books: { id: number }[] }; 12 | type BookArgs = { id: number }; 13 | 14 | beforeEach(() => { 15 | client1 = new ClientMock("endpoint1"); 16 | client2 = new ClientMock("endpoint1"); 17 | setDefaultClient(client1); 18 | }); 19 | afterEach(() => { 20 | sub && sub(); 21 | sub = null; 22 | }); 23 | 24 | generateTests(() => client1); 25 | generateTests( 26 | () => client2, 27 | () => ({ client: client2 }), 28 | () => ({ client: client2 }) 29 | ); 30 | 31 | function generateTests(getClient: any, queryProps = () => ({}), mutationProps = () => ({})) { 32 | test("Mutation listener updates cache X", async () => { 33 | const client = getClient(); 34 | const { queryState, sync } = query("A", { 35 | onMutation: { 36 | when: "updateBook", 37 | run: ({ cache }, { updateBook: { Book } }) => { 38 | cache.entries.forEach(([key, results]) => { 39 | if (!(results instanceof Promise) && results.data != null) { 40 | let CachedBook = results.data.Books.find(b => b.id == Book.id); 41 | CachedBook && Object.assign(CachedBook, Book); 42 | } 43 | }); 44 | } 45 | }, 46 | ...queryProps() 47 | }); 48 | const { mutationState } = mutation("someMutation{}", mutationProps()); 49 | sub = queryState.subscribe(() => {}); 50 | 51 | client.nextResult = { 52 | data: { 53 | Books: [ 54 | { id: 1, title: "Book 1", author: "Adam" }, 55 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 56 | ] 57 | } 58 | }; 59 | await sync({ query: "a" }, { active: true }); 60 | 61 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 62 | 63 | await sync({ query: "b" }, { active: false }); 64 | 65 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 66 | await get(mutationState).runMutation(null); 67 | 68 | expect(client.queriesRun).toBe(1); //nothing loaded 69 | 70 | await sync({ query: "a" }, { active: false }); 71 | 72 | expect(client.queriesRun).toBe(1); //nothing's changed 73 | expect(get(queryState).data).toEqual({ 74 | //nothing's changed 75 | Books: [ 76 | { id: 1, title: "Book 1", author: "Adam" }, 77 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 78 | ] 79 | }); //loads updated data 80 | }); 81 | 82 | test("Mutation listener updates cache with mutation args - string", async () => { 83 | const client = getClient(); 84 | const { queryState, sync } = query("A", { 85 | onMutation: { 86 | when: "deleteBook", 87 | run: ({ cache, refresh }, resp, args: BookArgs) => { 88 | cache.entries.forEach(([key, results]) => { 89 | if (!(results instanceof Promise) && results.data != null) { 90 | results.data.Books = results.data.Books.filter(b => b.id != args.id); 91 | refresh(); 92 | } 93 | }); 94 | } 95 | }, 96 | ...queryProps() 97 | }); 98 | const { mutationState } = mutation("someMutation{}", mutationProps()); 99 | sub = queryState.subscribe(() => {}); 100 | 101 | let initialBooks = [ 102 | { id: 1, title: "Book 1", author: "Adam" }, 103 | { id: 2, title: "Book 2", author: "Eve" } 104 | ]; 105 | client.nextResult = { 106 | data: { 107 | Books: initialBooks 108 | } 109 | }; 110 | await sync({ query: "a" }, { active: true }); 111 | 112 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 113 | await sync({ query: "b" }, { active: false }); 114 | 115 | client.nextMutationResult = { deleteBook: { success: true } }; 116 | await get(mutationState).runMutation({ id: 1 }); 117 | 118 | expect(client.queriesRun).toBe(1); //nothing changed 119 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //loads updated data 120 | 121 | await sync({ query: "a" }, { active: false }); 122 | 123 | expect(client.queriesRun).toBe(1); //nothing changed 124 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //loads updated data 125 | }); 126 | 127 | test("Mutation listener updates cache with mutation args - regex", async () => { 128 | const client = getClient(); 129 | const { queryState, sync } = query("A", { 130 | onMutation: { 131 | when: /deleteBook/, 132 | run: ({ cache, refresh }, resp, args: BookArgs) => { 133 | cache.entries.forEach(([key, results]) => { 134 | if (!(results instanceof Promise) && results.data != null) { 135 | results.data.Books = results.data.Books.filter(b => b.id != args.id); 136 | refresh(); 137 | } 138 | }); 139 | } 140 | }, 141 | ...queryProps() 142 | }); 143 | const { mutationState } = mutation("someMutation{}", mutationProps()); 144 | sub = queryState.subscribe(() => {}); 145 | 146 | let initialBooks = [ 147 | { id: 1, title: "Book 1", author: "Adam" }, 148 | { id: 2, title: "Book 2", author: "Eve" } 149 | ]; 150 | client.nextResult = { 151 | data: { 152 | Books: initialBooks 153 | } 154 | }; 155 | await sync({ query: "a" }, { active: true }); 156 | 157 | client.nextResult = { data: { Books: [{ id: 1, title: "Book 1", author: "Adam" }] } }; 158 | await sync({ query: "b" }, { active: false }); 159 | 160 | client.nextMutationResult = { deleteBook: { success: true } }; 161 | await get(mutationState).runMutation({ id: 1 }); 162 | 163 | expect(client.queriesRun).toBe(1); // no change 164 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //no change 165 | 166 | await sync({ query: "a" }, { active: false }); 167 | 168 | expect(client.queriesRun).toBe(1); // no change 169 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //no change 170 | }); 171 | 172 | test("Mutation listener updates cache then refreshes from cache", async () => { 173 | const client = getClient(); 174 | const { queryState, sync } = query("A", { 175 | onMutation: { 176 | when: "updateBook", 177 | run: ({ cache, refresh }, { updateBook: { Book } }) => { 178 | cache.entries.forEach(([key, results]) => { 179 | if (!(results instanceof Promise) && results.data != null) { 180 | let newBooks = results.data.Books.map(b => { 181 | if (b.id == Book.id) { 182 | return Object.assign({}, b, Book); 183 | } 184 | return b; 185 | }); 186 | //do this immutable crap just to make sure tests don't accidentally pass because of object references to current props being updated - in real life the component would not be re-rendered, but here's we're verifying the props directly 187 | let newResults: any = { ...results }; 188 | newResults.data = { ...newResults.data }; 189 | newResults.data.Books = newBooks; 190 | cache.set(key, newResults); 191 | refresh(); 192 | } 193 | }); 194 | } 195 | }, 196 | ...queryProps() 197 | }); 198 | const { mutationState } = mutation("someMutation{}", mutationProps()); 199 | sub = queryState.subscribe(() => {}); 200 | 201 | let initialBooks = [ 202 | { id: 1, title: "Book 1", author: "Adam" }, 203 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 204 | ]; 205 | client.nextResult = { 206 | data: { 207 | Books: initialBooks 208 | } 209 | }; 210 | await sync({ query: "a" }, { active: false }); 211 | 212 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 213 | await get(mutationState).runMutation(null); 214 | await pause(); 215 | 216 | expect(client.queriesRun).toBe(0); //never run 217 | expect(get(queryState).data).toEqual(null); //refreshed with updated data 218 | }); 219 | 220 | test("Mutation listener updates cache then refreshes from cache 2", async () => { 221 | const client = getClient(); 222 | const { queryState, sync } = query("A", { 223 | onMutation: { 224 | when: "updateBook", 225 | run: ({ cache, refresh }, { updateBook: { Book } }) => { 226 | cache.entries.forEach(([key, results]) => { 227 | if (!(results instanceof Promise) && results.data != null) { 228 | let newBooks = results.data.Books.map(b => { 229 | if (b.id == Book.id) { 230 | return Object.assign({}, b, Book); 231 | } 232 | return b; 233 | }); 234 | //do this immutable crap just to make sure tests don't accidentally pass because of object references to current props being updated - in real life the component would not be re-rendered, but here's we're verifying the props directly 235 | let newResults: any = { ...results }; 236 | newResults.data = { ...newResults.data }; 237 | newResults.data.Books = newBooks; 238 | cache.set(key, newResults); 239 | refresh(); 240 | } 241 | }); 242 | } 243 | }, 244 | ...queryProps() 245 | }); 246 | const { mutationState } = mutation("someMutation{}", mutationProps()); 247 | sub = queryState.subscribe(() => {}); 248 | 249 | let initialBooks = [ 250 | { id: 1, title: "Book 1", author: "Adam" }, 251 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 252 | ]; 253 | client.nextResult = { 254 | data: { 255 | Books: initialBooks 256 | } 257 | }; 258 | await sync({ query: "a" }, { active: true }); 259 | 260 | await sync({ query: "a" }, { active: false }); 261 | 262 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 263 | await get(mutationState).runMutation(null); 264 | 265 | expect(client.queriesRun).toBe(1); //run once 266 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //refreshed with updated data 267 | }); 268 | 269 | test("Mutation listener - soft reset - props right, cache cleared", async () => { 270 | const client = getClient(); 271 | let componentsCache = null; 272 | const { queryState, sync } = query("A", { 273 | onMutation: { 274 | when: "updateBook", 275 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }) => { 276 | componentsCache = cache; 277 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 278 | CachedBook && Object.assign(CachedBook, Book); 279 | softReset(currentResults); 280 | } 281 | }, 282 | ...queryProps() 283 | }); 284 | const { mutationState } = mutation("someMutation{}", mutationProps()); 285 | sub = queryState.subscribe(() => {}); 286 | 287 | let initialBooks = [ 288 | { id: 1, title: "Book 1", author: "Adam" }, 289 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 290 | ]; 291 | client.nextResult = { 292 | data: { 293 | Books: initialBooks 294 | } 295 | }; 296 | await sync({ query: "a" }, { active: false }); 297 | 298 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 299 | await get(mutationState).runMutation(null); 300 | 301 | expect(componentsCache).toBe(null); //no change 302 | expect(get(queryState).data).toEqual(null); //nothing 303 | }); 304 | 305 | test("Mutation listener - soft reset - props right, cache cleared 2", async () => { 306 | const client1 = getClient(); 307 | let componentsCache = null; 308 | const { queryState, sync } = query("A", { 309 | onMutation: { 310 | when: "updateBook", 311 | run: ({ cache, softReset, currentResults }, { updateBook: { Book } }) => { 312 | componentsCache = cache; 313 | let CachedBook = currentResults.Books.find(b => b.id == Book.id); 314 | CachedBook && Object.assign(CachedBook, Book); 315 | softReset(currentResults); 316 | } 317 | }, 318 | ...queryProps() 319 | }); 320 | const { mutationState } = mutation("someMutation{}", mutationProps()); 321 | sub = queryState.subscribe(() => {}); 322 | 323 | let initialBooks = [ 324 | { id: 1, title: "Book 1", author: "Adam" }, 325 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 326 | ]; 327 | client1.nextResult = { 328 | data: { 329 | Books: initialBooks 330 | } 331 | }; 332 | await sync({ query: "a" }, { active: true }); 333 | 334 | await sync({ query: "a" }, { active: false }); 335 | 336 | client1.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 337 | await get(mutationState).runMutation(null); 338 | 339 | expect(componentsCache).toBe(null); //no change 340 | expect(get(queryState).data).toEqual({ Books: initialBooks }); //nothing 341 | }); 342 | 343 | test("Mutation listener - hard reset - props right, cache cleared, client qeried", async () => { 344 | const client = getClient(); 345 | let componentsCache = null; 346 | const { queryState, sync } = query("A", { 347 | onMutation: { 348 | when: "updateBook", 349 | run: ({ cache, hardReset, currentResults }) => { 350 | componentsCache = cache; 351 | hardReset(); 352 | } 353 | }, 354 | ...queryProps() 355 | }); 356 | const { mutationState } = mutation("someMutation{}", mutationProps()); 357 | sub = queryState.subscribe(() => {}); 358 | 359 | let initialBooks = [ 360 | { id: 1, title: "Book 1", author: "Adam" }, 361 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 362 | ]; 363 | client.nextResult = { 364 | data: { 365 | Books: initialBooks 366 | } 367 | }; 368 | 369 | await sync({ query: "a" }, { active: false }); 370 | 371 | expect(client.queriesRun).toBe(0); //nothing 372 | client.nextResult = { 373 | data: { 374 | Books: [ 375 | { id: 1, title: "Book 1", author: "Adam" }, 376 | { id: 2, title: "Book 2", author: "Eve" } 377 | ] 378 | } 379 | }; 380 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 381 | await get(mutationState).runMutation(null); 382 | 383 | expect(componentsCache).toBe(null); //just the initial entry 384 | expect(get(queryState).data).toEqual(null); //nothing there 385 | expect(client.queriesRun).toBe(0); //no run from the hard reset 386 | }); 387 | 388 | test("Mutation listener - hard reset - props right, cache cleared, client qeried 2", async () => { 389 | const client = getClient(); 390 | let componentsCache = null; 391 | const { queryState, sync } = query("A", { 392 | onMutation: { 393 | when: "updateBook", 394 | run: ({ cache, hardReset, currentResults }) => { 395 | componentsCache = cache; 396 | hardReset(); 397 | } 398 | }, 399 | ...queryProps() 400 | }); 401 | const { mutationState } = mutation("someMutation{}", mutationProps()); 402 | sub = queryState.subscribe(() => {}); 403 | 404 | let initialBooks = [ 405 | { id: 1, title: "Book 1", author: "Adam" }, 406 | { id: 2, title: "Book 2", author: "__WRONG__Eve" } 407 | ]; 408 | client.nextResult = { 409 | data: { 410 | Books: initialBooks 411 | } 412 | }; 413 | 414 | await sync({ query: "a" }, { active: true }); 415 | 416 | await sync({ query: "a" }, { active: false }); 417 | 418 | expect(client.queriesRun).toBe(1); //just the one 419 | client.nextResult = { 420 | data: { 421 | Books: [ 422 | { id: 1, title: "Book 1", author: "Adam" }, 423 | { id: 2, title: "Book 2", author: "Eve" } 424 | ] 425 | } 426 | }; 427 | client.nextMutationResult = { updateBook: { Book: { id: 2, author: "Eve" } } }; 428 | await get(mutationState).runMutation(null); 429 | 430 | expect(componentsCache).toBe(null); //just the initial entry 431 | expect(get(queryState).data).toEqual({ 432 | Books: initialBooks 433 | }); //updated data is not there 434 | expect(client.queriesRun).toBe(1); //no run from the hard reset 435 | }); 436 | } 437 | -------------------------------------------------------------------------------- /test/postQueryProcess.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let queryState: any; 9 | let sync: any; 10 | let sub: any; 11 | let p: any; 12 | 13 | beforeEach(() => { 14 | client1 = new ClientMock("endpoint1"); 15 | setDefaultClient(client1); 16 | p = client1.nextResult = deferred(); 17 | }); 18 | 19 | afterEach(() => { 20 | sub(); 21 | }); 22 | 23 | test("Side effect post-process", async () => { 24 | let resultAfter = (client1.nextResult = deferred()); 25 | 26 | ({ queryState, sync } = query("A", { 27 | postProcess: (resp: any) => { 28 | resp.data.tasks[0].id = 2; 29 | } 30 | })); 31 | sub = queryState.subscribe(() => {}); 32 | 33 | sync({ a: 1 }); 34 | 35 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 36 | await pause(); 37 | 38 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 39 | }); 40 | 41 | test("Non-side effect post-process", async () => { 42 | let resultAfter = (client1.nextResult = deferred()); 43 | 44 | ({ queryState, sync } = query("A", { 45 | postProcess: resp => { 46 | return { data: { tasks: [{ id: 2 }] } }; 47 | } 48 | })); 49 | sub = queryState.subscribe(() => {}); 50 | 51 | sync({ a: 1 }); 52 | 53 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 54 | await pause(); 55 | 56 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 57 | }); 58 | 59 | test("Non-side effect promise post-process", async () => { 60 | let resultAfter = (client1.nextResult = deferred()); 61 | 62 | ({ queryState, sync } = query("A", { 63 | postProcess: resp => { 64 | return Promise.resolve({ data: { tasks: [{ id: 2 }] } }); 65 | } 66 | })); 67 | sub = queryState.subscribe(() => {}); 68 | 69 | sync({ a: 1 }); 70 | 71 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 72 | await pause(); 73 | 74 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 75 | }); 76 | -------------------------------------------------------------------------------- /test/query.test-data-store.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, errorPacket, loadingPacket, pause, rejectDeferred, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let resultsState: any; 9 | let queryState: any; 10 | let sync: any; 11 | let sub: any; 12 | let p: any; 13 | 14 | beforeEach(() => { 15 | client1 = new ClientMock("endpoint1"); 16 | setDefaultClient(client1); 17 | p = client1.nextResult = deferred(); 18 | 19 | ({ resultsState, queryState, sync } = query("A")); 20 | sub = resultsState.subscribe(() => {}); 21 | }); 22 | 23 | afterEach(() => { 24 | sub(); 25 | }); 26 | 27 | test("Query resolves and data updated", async () => { 28 | sync({ a: 1 }); 29 | expect(get(resultsState)).toBe(null); 30 | 31 | await resolveDeferred(p, { data: { tasks: [] } }); 32 | expect(get(resultsState)).toMatchObject({ tasks: [] }); 33 | }); 34 | 35 | test("Query resolves and errors updated", async () => { 36 | sync({ a: 1 }); 37 | expect(get(resultsState)).toBe(null); 38 | 39 | await resolveDeferred(p, { errors: [{ msg: "a" }] }); 40 | expect(get(resultsState)).toBe(null); 41 | }); 42 | 43 | test("Error in promise", async () => { 44 | sync({ a: 1 }); 45 | expect(get(resultsState)).toBe(null); 46 | 47 | await rejectDeferred(p, { message: "Hello" }); 48 | expect(get(resultsState)).toBe(null); 49 | }); 50 | 51 | test("Out of order promise handled", async () => { 52 | sync({ a: 1 }); 53 | 54 | let resultAfter = (client1.nextResult = deferred()); 55 | sync({ a: 2 }); 56 | 57 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 58 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 59 | 60 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 61 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 62 | }); 63 | 64 | test("Out of order promise handled 2", async () => { 65 | sync({ a: 1 }); 66 | 67 | let resultAfter = (client1.nextResult = deferred()); 68 | sync({ a: 2 }); 69 | 70 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 71 | expect(get(resultsState)).toBe(null); 72 | 73 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 74 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 75 | }); 76 | 77 | test("Cached data handled", async () => { 78 | sync({ a: 1 }); 79 | 80 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 81 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 82 | 83 | p = client1.nextResult = deferred(); 84 | sync({ a: 2 }); 85 | 86 | await resolveDeferred(p, { data: { tasks: [{ id: 2 }] } }); 87 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 2 }] }); 88 | 89 | sync({ a: 1 }); 90 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 91 | 92 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 93 | expect(client1.queriesRun).toBe(2); 94 | }); 95 | 96 | test("Cached data while loading handled", async () => { 97 | sync({ a: 1 }); 98 | 99 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 100 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 101 | 102 | p = client1.nextResult = deferred(); 103 | sync({ a: 2 }); 104 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 105 | 106 | sync({ a: 1 }); 107 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 108 | }); 109 | 110 | test("Promise in flight picked up - resolved - handled", async () => { 111 | sync({ a: 1 }); 112 | 113 | await pause(); 114 | 115 | let { resultsState: resultsState2, sync: sync2 } = query("A"); 116 | sync2({ a: 1 }); 117 | 118 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 119 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 120 | expect(get(resultsState2)).toMatchObject({ tasks: [{ id: 1 }] }); 121 | }); 122 | 123 | test("Promise in flight picked up - rejected - and handled", async () => { 124 | sync({ a: 1 }); 125 | await pause(); 126 | 127 | let { resultsState: resultsState2, sync: sync2 } = query("A"); 128 | sync2({ a: 1 }); 129 | 130 | await rejectDeferred(p, { message: "Hello" }); 131 | await pause(); 132 | expect(get(resultsState)).toBe(null); 133 | expect(get(resultsState2)).toBe(null); 134 | }); 135 | 136 | test("Reload query - see new data", async () => { 137 | sync({ a: 1 }); 138 | 139 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 140 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 1 }] }); 141 | 142 | let resultAfter = (client1.nextResult = deferred()); 143 | get(queryState).reload(); 144 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 2 }] } }); 145 | expect(get(resultsState)).toMatchObject({ tasks: [{ id: 2 }] }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/query.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, errorPacket, loadingPacket, pause, rejectDeferred, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let queryState: any; 9 | let sync: any; 10 | let sub: any; 11 | let p: any; 12 | 13 | beforeEach(() => { 14 | client1 = new ClientMock("endpoint1"); 15 | setDefaultClient(client1); 16 | p = client1.nextResult = deferred(); 17 | 18 | ({ queryState, sync } = query("A")); 19 | sub = queryState.subscribe(() => {}); 20 | }); 21 | 22 | afterEach(() => { 23 | sub(); 24 | }); 25 | 26 | test("loading props passed", async () => { 27 | sync({}); 28 | expect(get(queryState)).toMatchObject(loadingPacket); 29 | }); 30 | 31 | test("Query resolves and data updated", async () => { 32 | sync({ a: 1 }); 33 | expect(get(queryState)).toMatchObject(loadingPacket); 34 | 35 | await resolveDeferred(p, { data: { tasks: [] } }); 36 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [] })); 37 | }); 38 | 39 | test("Query resolves and errors updated", async () => { 40 | sync({ a: 1 }); 41 | expect(get(queryState)).toMatchObject(loadingPacket); 42 | 43 | await resolveDeferred(p, { errors: [{ msg: "a" }] }); 44 | expect(get(queryState)).toMatchObject(errorPacket([{ msg: "a" }])); 45 | }); 46 | 47 | test("Error in promise", async () => { 48 | sync({ a: 1 }); 49 | expect(get(queryState)).toMatchObject(loadingPacket); 50 | 51 | await rejectDeferred(p, { message: "Hello" }); 52 | expect(get(queryState)).toMatchObject(errorPacket({ message: "Hello" })); 53 | }); 54 | 55 | test("Out of order promise handled", async () => { 56 | sync({ a: 1 }); 57 | 58 | let resultAfter = (client1.nextResult = deferred()); 59 | sync({ a: 2 }); 60 | 61 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 62 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 63 | 64 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 65 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 66 | }); 67 | 68 | test("Out of order promise handled 2", async () => { 69 | sync({ a: 1 }); 70 | 71 | let resultAfter = (client1.nextResult = deferred()); 72 | sync({ a: 2 }); 73 | 74 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 75 | expect(get(queryState)).toMatchObject(loadingPacket); 76 | 77 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 78 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 79 | }); 80 | 81 | test("Cached data handled", async () => { 82 | sync({ a: 1 }); 83 | 84 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 85 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 86 | 87 | p = client1.nextResult = deferred(); 88 | sync({ a: 2 }); 89 | 90 | await resolveDeferred(p, { data: { tasks: [{ id: 2 }] } }); 91 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 92 | 93 | sync({ a: 1 }); 94 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 95 | 96 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 97 | expect(client1.queriesRun).toBe(2); 98 | }); 99 | 100 | test("Cached data while loading handled", async () => { 101 | sync({ a: 1 }); 102 | 103 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 104 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 105 | 106 | p = client1.nextResult = deferred(); 107 | sync({ a: 2 }); 108 | expect(get(queryState)).toMatchObject({ ...dataPacket({ tasks: [{ id: 1 }] }), loading: true }); 109 | 110 | sync({ a: 1 }); 111 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 112 | }); 113 | 114 | test("Promise in flight picked up - resolved - handled", async () => { 115 | sync({ a: 1 }); 116 | 117 | await pause(); 118 | 119 | let { queryState: queryState2, sync: sync2 } = query("A"); 120 | sync2({ a: 1 }); 121 | 122 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 123 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 124 | expect(get(queryState2)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 125 | }); 126 | 127 | test("Promise in flight picked up - rejected - and handled", async () => { 128 | sync({ a: 1 }); 129 | await pause(); 130 | 131 | let { queryState: queryState2, sync: sync2 } = query("A"); 132 | sync2({ a: 1 }); 133 | 134 | await rejectDeferred(p, { message: "Hello" }); 135 | await pause(); 136 | expect(get(queryState)).toMatchObject(errorPacket({ message: "Hello" })); 137 | expect(get(queryState2)).toMatchObject(errorPacket({ message: "Hello" })); 138 | }); 139 | 140 | test("Reload query - see new data", async () => { 141 | sync({ a: 1 }); 142 | 143 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 144 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 145 | 146 | let resultAfter = (client1.nextResult = deferred()); 147 | get(queryState).reload(); 148 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 2 }] } }); 149 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 150 | }); 151 | -------------------------------------------------------------------------------- /test/queryActive.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, defaultPacket, deferred, errorPacket, loadingPacket, pause, rejectDeferred, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let queryState: any; 9 | let sync: any; 10 | let sub: any; 11 | 12 | beforeEach(() => { 13 | client1 = new ClientMock("endpoint1"); 14 | setDefaultClient(client1); 15 | 16 | ({ queryState, sync } = query("A")); 17 | sub = queryState.subscribe(() => {}); 18 | }); 19 | 20 | afterEach(() => { 21 | sub(); 22 | }); 23 | 24 | test("loading props passed", async () => { 25 | sync({ a: 1 }, { active: false }); 26 | 27 | expect(get(queryState)).toMatchObject(defaultPacket); 28 | }); 29 | 30 | test("Query resolves and data updated", async () => { 31 | sync({ a: 1 }, { active: null }); 32 | expect(get(queryState)).toMatchObject(defaultPacket); 33 | 34 | let nextResult = (client1.nextResult = deferred()); 35 | await sync({ a: 1 }, { active: true }); 36 | 37 | expect(get(queryState)).toMatchObject(loadingPacket); 38 | 39 | await resolveDeferred(nextResult, { data: { tasks: [] } }); 40 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [] })); 41 | }); 42 | 43 | test("Query resolves and errors updated", async () => { 44 | let nextResult = (client1.nextResult = deferred()); 45 | sync({ a: 1 }, { active: false }); 46 | expect(get(queryState)).toMatchObject(defaultPacket); 47 | 48 | await sync({ a: 1 }, { active: true }); 49 | expect(get(queryState)).toMatchObject(loadingPacket); 50 | 51 | await resolveDeferred(nextResult, { errors: [{ msg: "a" }] }); 52 | expect(get(queryState)).toMatchObject(errorPacket([{ msg: "a" }])); 53 | }); 54 | 55 | test("Cached data handled", async () => { 56 | let nextResult = (client1.nextResult = deferred()); 57 | sync({ a: 1 }, { active: true }); 58 | 59 | await resolveDeferred(nextResult, { data: { tasks: [{ id: 1 }] } }); 60 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 61 | 62 | nextResult = client1.nextResult = deferred(); 63 | await sync({ a: 1 }, { active: false }); 64 | await pause(); 65 | 66 | await resolveDeferred(nextResult, { data: { tasks: [{ id: 2 }] } }); 67 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 68 | 69 | await sync({ a: 1 }, { active: true }); 70 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 71 | 72 | expect(client1.queriesRun).toBe(1); 73 | }); 74 | 75 | test("Cached data while loading handled", async () => { 76 | let nextResult = (client1.nextResult = deferred()); 77 | sync({ a: 1 }, { active: true }); 78 | 79 | await resolveDeferred(nextResult, { data: { tasks: [{ id: 1 }] } }); 80 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 81 | 82 | nextResult = client1.nextResult = deferred(); 83 | 84 | await sync({ a: 2 }, { active: false }); 85 | 86 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 87 | 88 | await sync({ a: 1 }, { active: false }); 89 | await pause(); 90 | 91 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 92 | }); 93 | -------------------------------------------------------------------------------- /test/queryInitialSearch.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, errorPacket, loadingPacket, pause, rejectDeferred, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let queryState: any; 9 | let sync: any; 10 | let sub: any; 11 | let p: any; 12 | 13 | beforeEach(() => { 14 | client1 = new ClientMock("endpoint1"); 15 | setDefaultClient(client1); 16 | p = client1.nextResult = deferred(); 17 | }); 18 | 19 | afterEach(() => { 20 | sub(); 21 | }); 22 | 23 | function initialState(initialSearch: any) { 24 | ({ queryState, sync } = query("A", { initialSearch })); 25 | sub = queryState.subscribe(() => {}); 26 | } 27 | 28 | test("Query resolves and data updated", async () => { 29 | initialState({ a: 1 }); 30 | expect(get(queryState)).toMatchObject(loadingPacket); 31 | 32 | await resolveDeferred(p, { data: { tasks: [] } }); 33 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [] })); 34 | }); 35 | 36 | test("Query resolves and errors updated", async () => { 37 | initialState({ a: 1 }); 38 | expect(get(queryState)).toMatchObject(loadingPacket); 39 | 40 | await resolveDeferred(p, { errors: [{ msg: "a" }] }); 41 | expect(get(queryState)).toMatchObject(errorPacket([{ msg: "a" }])); 42 | }); 43 | 44 | test("Error in promise", async () => { 45 | initialState({ a: 1 }); 46 | expect(get(queryState)).toMatchObject(loadingPacket); 47 | 48 | await rejectDeferred(p, { message: "Hello" }); 49 | expect(get(queryState)).toMatchObject(errorPacket({ message: "Hello" })); 50 | }); 51 | 52 | test("Out of order promise handled", async () => { 53 | initialState({ a: 1 }); 54 | 55 | let resultAfter = (client1.nextResult = deferred()); 56 | sync({ a: 2 }); 57 | 58 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 59 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 60 | 61 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 62 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 63 | }); 64 | 65 | test("Out of order promise handled 2", async () => { 66 | initialState({ a: 1 }); 67 | 68 | let resultAfter = (client1.nextResult = deferred()); 69 | sync({ a: 2 }); 70 | 71 | await resolveDeferred(p, { data: { tasks: [{ id: -999 }] } }); 72 | expect(get(queryState)).toMatchObject(loadingPacket); 73 | 74 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 1 }] } }); 75 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 76 | }); 77 | 78 | test("Cached data handled", async () => { 79 | initialState({ a: 1 }); 80 | 81 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 82 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 83 | 84 | p = client1.nextResult = deferred(); 85 | sync({ a: 2 }); 86 | 87 | await resolveDeferred(p, { data: { tasks: [{ id: 2 }] } }); 88 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 89 | 90 | sync({ a: 1 }); 91 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 92 | 93 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 94 | expect(client1.queriesRun).toBe(2); 95 | }); 96 | 97 | test("Cached data while loading handled", async () => { 98 | initialState({ a: 1 }); 99 | 100 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 101 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 102 | 103 | p = client1.nextResult = deferred(); 104 | sync({ a: 2 }); 105 | expect(get(queryState)).toMatchObject({ ...dataPacket({ tasks: [{ id: 1 }] }), loading: true }); 106 | 107 | sync({ a: 1 }); 108 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 109 | }); 110 | 111 | test("Promise in flight picked up - resolved - handled", async () => { 112 | initialState({ a: 1 }); 113 | 114 | await pause(); 115 | 116 | let { queryState: queryState2, sync: sync2 } = query("A"); 117 | sync2({ a: 1 }); 118 | 119 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 120 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 121 | expect(get(queryState2)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 122 | }); 123 | 124 | test("Promise in flight picked up - rejected - and handled", async () => { 125 | initialState({ a: 1 }); 126 | await pause(); 127 | 128 | let { queryState: queryState2, sync: sync2 } = query("A"); 129 | sync2({ a: 1 }); 130 | 131 | await rejectDeferred(p, { message: "Hello" }); 132 | await pause(); 133 | expect(get(queryState)).toMatchObject(errorPacket({ message: "Hello" })); 134 | expect(get(queryState2)).toMatchObject(errorPacket({ message: "Hello" })); 135 | }); 136 | 137 | test("Reload query - see new data", async () => { 138 | initialState({ a: 1 }); 139 | 140 | await resolveDeferred(p, { data: { tasks: [{ id: 1 }] } }); 141 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 1 }] })); 142 | 143 | let resultAfter = (client1.nextResult = deferred()); 144 | get(queryState).reload(); 145 | await resolveDeferred(resultAfter, { data: { tasks: [{ id: 2 }] } }); 146 | expect(get(queryState)).toMatchObject(dataPacket({ tasks: [{ id: 2 }] })); 147 | }); 148 | -------------------------------------------------------------------------------- /test/refresh.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache, Client } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | let sub: any; 10 | 11 | const LOAD_TASKS = "A"; 12 | const LOAD_USERS = "B"; 13 | const UPDATE_USER = "M"; 14 | 15 | beforeEach(() => { 16 | client1 = new ClientMock("endpoint1"); 17 | client2 = new ClientMock("endpoint1"); 18 | setDefaultClient(client1); 19 | }); 20 | afterEach(() => { 21 | sub && sub(); 22 | sub = null; 23 | }); 24 | 25 | generateTests("client 1", () => client1); 26 | generateTests( 27 | "client 2", 28 | () => client2, 29 | () => ({ client: client2 }), 30 | () => ({ client: client2 }) 31 | ); 32 | 33 | function generateTests(name: any, getClient: any, queryProps = () => ({}), mutationProps = () => ({})) { 34 | test("client.forceUpdate works - " + name, async () => { 35 | const client = getClient(); 36 | const { sync, queryState } = query(LOAD_TASKS, { ...queryProps() }); 37 | sub = queryState.subscribe(x => {}); 38 | 39 | client.nextResult = { data: { x: 1 } }; 40 | await sync({ assignedTo: 1 }); 41 | 42 | expect(get(queryState).data).toEqual({ x: 1 }); 43 | 44 | client.nextResult = { data: { x: 2 } }; 45 | const cache = client.getCache(LOAD_TASKS); 46 | cache.clearCache(); 47 | await client.forceUpdate(LOAD_TASKS); 48 | 49 | expect(get(queryState).data).toEqual({ x: 2 }); 50 | }); 51 | 52 | test("force update from client mutation subscription -- string - " + name, async () => { 53 | var lastResults = null; 54 | const client: Client = getClient(); 55 | const { queryState, sync } = query(LOAD_TASKS, { ...queryProps() }); 56 | 57 | client.subscribeMutation({ 58 | when: "a", 59 | run: ({ refreshActiveQueries }) => { 60 | let cache: Cache<{ a: number }> = client.getCache(LOAD_TASKS); 61 | 62 | [...cache._cache.keys()].forEach(k => { 63 | cache._cache.set(k, { data: { a: 99 }, error: null }); 64 | }); 65 | 66 | refreshActiveQueries(LOAD_TASKS); 67 | } 68 | }); 69 | const { mutationState } = mutation("X", mutationProps()); 70 | sub = queryState.subscribe(() => {}); 71 | 72 | (client as any).nextMutationResult = { a: 2 }; 73 | (client as any).nextResult = { data: { a: 1 } }; 74 | 75 | await sync({ assignedTo: null }); 76 | expect(get(queryState).data).toEqual({ a: 1 }); 77 | 78 | await get(mutationState).runMutation(null); 79 | expect(get(queryState).data).toEqual({ a: 99 }); 80 | }); 81 | 82 | test("force update from client mutation subscription -- regex - " + name, async () => { 83 | const client: Client = getClient(); 84 | var lastResults = null; 85 | const { sync, queryState } = query(LOAD_TASKS, { ...queryProps() }); 86 | sub = queryState.subscribe(x => {}); 87 | 88 | const { mutationState } = mutation("X", mutationProps()); 89 | 90 | client.subscribeMutation({ 91 | when: /a/, 92 | run: ({ refreshActiveQueries }) => { 93 | let cache = client.getCache(LOAD_TASKS); 94 | 95 | [...cache._cache.keys()].forEach(k => { 96 | cache._cache.set(k, { data: { a: 99 }, error: null }); 97 | }); 98 | 99 | refreshActiveQueries(LOAD_TASKS); 100 | } 101 | }); 102 | (client as any).nextMutationResult = { a: 2 }; 103 | (client as any).nextResult = { data: { a: 1 } }; 104 | 105 | await sync({ assignedTo: 1 }); 106 | expect(get(queryState).data).toEqual({ a: 1 }); 107 | 108 | await get(mutationState).runMutation(null); 109 | expect(get(queryState).data).toEqual({ a: 99 }); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /test/softResetImperativeEmptyCall.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | let client1: any; 8 | let client2: any; 9 | const basicQuery = "A"; 10 | 11 | beforeEach(() => { 12 | client1 = new ClientMock("endpoint1"); 13 | client2 = new ClientMock("endpoint2"); 14 | setDefaultClient(client1); 15 | }); 16 | 17 | test("Default cache size of 10", async () => { 18 | const { queryState, sync } = query(basicQuery); 19 | 20 | sync({ page: 1 }); 21 | sync({ page: 2 }); 22 | client1.nextResult = { data: { a: 1 } }; 23 | await sync({ page: 3 }); 24 | 25 | expect(get(queryState).data).toEqual({ a: 1 }); 26 | expect(client1.queriesRun).toBe(3); 27 | 28 | get(queryState).softReset(null); 29 | expect(get(queryState).data).toEqual({ a: 1 }); 30 | 31 | sync({ page: 2 }); 32 | sync({ page: 1 }); 33 | expect(client1.queriesRun).toBe(5); 34 | }); 35 | -------------------------------------------------------------------------------- /test/testUtil.ts: -------------------------------------------------------------------------------- 1 | export const deferred = () => { 2 | let resolve, reject; 3 | let p: any = new Promise((res, rej) => { 4 | resolve = res; 5 | reject = rej; 6 | }); 7 | p.resolve = resolve; 8 | p.reject = reject; 9 | return p; 10 | }; 11 | 12 | export const resolveDeferred = async (p: any, val: any, wrapper?: any) => { 13 | p.resolve(val); 14 | await p; 15 | wrapper && wrapper.update(); 16 | }; 17 | 18 | export const rejectDeferred = async (p: any, val: any, wrapper?: any) => { 19 | try { 20 | p.reject(val); 21 | } catch (er) {} 22 | try { 23 | await p; 24 | } catch (er) {} 25 | wrapper && wrapper.update(); 26 | }; 27 | 28 | export const defaultPacket = { 29 | loading: false, 30 | loaded: false, 31 | data: null, 32 | error: null 33 | }; 34 | 35 | export const loadingPacket = { 36 | loading: true, 37 | loaded: false, 38 | data: null, 39 | error: null 40 | }; 41 | 42 | export const dataPacket = (data: any) => ({ 43 | loading: false, 44 | loaded: true, 45 | error: null, 46 | data 47 | }); 48 | 49 | export const errorPacket = (error: any) => ({ 50 | loading: false, 51 | loaded: true, 52 | error, 53 | data: null 54 | }); 55 | 56 | export const pause = (wrapper?: any) => 57 | new Promise((res: any) => 58 | setTimeout(() => { 59 | wrapper && wrapper.update(); 60 | res(); 61 | }, 10) 62 | ); 63 | -------------------------------------------------------------------------------- /test/unmount.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | 3 | import { setDefaultClient, mutation, query, Cache } from "../src/index"; 4 | import ClientMock from "./clientMock"; 5 | import { dataPacket, deferred, pause, resolveDeferred } from "./testUtil"; 6 | 7 | import { render, fireEvent } from "@testing-library/svelte"; 8 | import Comp1 from "./unmountTestComponents/component1.svelte"; 9 | import Comp2 from "./unmountTestComponents/component2.svelte"; 10 | import Comp3 from "./unmountTestComponents/component3.svelte"; 11 | 12 | test("Mutation listener destroys at unmount", async () => { 13 | let client1: any = new ClientMock("endpoint1"); 14 | setDefaultClient(client1); 15 | 16 | const { unmount, container } = render(Comp1, {}); 17 | 18 | client1.nextMutationResult = { updateBook: { Book: { title: "New Title" } } }; 19 | const { mutationState } = mutation("someMutation{}"); 20 | await get(mutationState).runMutation(null); 21 | 22 | await unmount(); 23 | 24 | await client1.processMutation(); 25 | await client1.processMutation(); 26 | await client1.processMutation(); 27 | }); 28 | 29 | test("Refresh reference removes at unmount", async () => { 30 | let client1: any = new ClientMock("endpoint1"); 31 | setDefaultClient(client1); 32 | 33 | let runCount = 0; 34 | 35 | const { unmount } = render(Comp1, {}); 36 | await pause(); 37 | 38 | expect(client1.forceListeners.get("A").size).toBe(1); 39 | 40 | unmount(); 41 | 42 | expect(client1.forceListeners.get("A").size).toBe(0); 43 | }); 44 | 45 | test("Activation works unmount", async () => { 46 | let client1 = new ClientMock("endpoint1"); 47 | setDefaultClient(client1); 48 | 49 | let activateCount = 0; 50 | let deActivateCount = 0; 51 | 52 | const activate = () => activateCount++; 53 | const deactivate = () => deActivateCount++; 54 | 55 | expect(activateCount).toBe(0); 56 | expect(deActivateCount).toBe(0); 57 | 58 | const { unmount } = render(Comp2, { activate, deactivate }); 59 | 60 | expect(activateCount).toBe(1); 61 | expect(deActivateCount).toBe(0); 62 | 63 | await unmount(); 64 | 65 | expect(activateCount).toBe(1); 66 | expect(deActivateCount).toBe(1); 67 | 68 | const { unmount: unmount2 } = render(Comp2, { activate, deactivate }); 69 | 70 | expect(activateCount).toBe(2); 71 | expect(deActivateCount).toBe(1); 72 | 73 | await unmount2(); 74 | 75 | expect(activateCount).toBe(2); 76 | expect(deActivateCount).toBe(2); 77 | }); 78 | 79 | test("Activation works unmount - 2", async () => { 80 | let client1 = new ClientMock("endpoint1"); 81 | setDefaultClient(client1); 82 | 83 | let activateCount = 0; 84 | let deActivateCount = 0; 85 | 86 | const activate = () => activateCount++; 87 | const deactivate = () => deActivateCount++; 88 | 89 | expect(activateCount).toBe(0); 90 | expect(deActivateCount).toBe(0); 91 | 92 | let { sync, queryState } = query("A", { 93 | activate, 94 | deactivate 95 | }); 96 | 97 | const { unmount } = render(Comp3, { queryState }); 98 | 99 | expect(activateCount).toBe(1); 100 | expect(deActivateCount).toBe(0); 101 | 102 | await unmount(); 103 | 104 | expect(activateCount).toBe(1); 105 | expect(deActivateCount).toBe(1); 106 | 107 | const { unmount: unmount2 } = render(Comp3, { queryState }); 108 | 109 | expect(activateCount).toBe(2); 110 | expect(deActivateCount).toBe(1); 111 | 112 | await unmount2(); 113 | 114 | expect(activateCount).toBe(2); 115 | expect(deActivateCount).toBe(2); 116 | }); 117 | -------------------------------------------------------------------------------- /test/unmountTestComponents/component1.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 | {$queryState} 23 |
24 | -------------------------------------------------------------------------------- /test/unmountTestComponents/component2.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 | {$queryState} 27 |
28 | -------------------------------------------------------------------------------- /test/unmountTestComponents/component3.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | {$queryState} 7 |
8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "module": "esNext", 5 | "target": "esnext", 6 | "strict": true, 7 | "types": ["svelte", "jest", "node"], 8 | "moduleResolution": "node" 9 | }, 10 | "include": ["./src/index.ts", "./test/**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "types": ["svelte"], 6 | }, 7 | "include": ["./src/index.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | const noMinify = process.env.NO_MINIFY; 4 | 5 | export default defineConfig({ 6 | build: { 7 | emptyOutDir: false, 8 | minify: noMinify ? false : "esbuild", 9 | target: "esnext", 10 | outDir: "./dist", 11 | lib: { 12 | entry: "./src/index.ts", 13 | formats: ["cjs"], 14 | fileName: () => (noMinify ? "index.js" : "index.min.js") 15 | }, 16 | rollupOptions: { 17 | external: ["svelte", "svelte/store"], 18 | output: {} 19 | } 20 | } 21 | }); 22 | --------------------------------------------------------------------------------