├── src ├── version.ts ├── util │ ├── errorHandling.ts │ ├── environment.ts │ ├── maybeDeepFreeze.ts │ ├── cloneDeep.ts │ ├── assign.ts │ ├── isEqual.ts │ └── Observable.ts ├── transport │ ├── afterware.ts │ ├── middleware.ts │ ├── Deduplicator.ts │ ├── batching.ts │ └── batchedNetworkInterface.ts ├── data │ ├── debug.ts │ ├── mutationResults.ts │ ├── replaceQueryResults.ts │ ├── resultReducers.ts │ ├── storeUtils.ts │ └── store.ts ├── queries │ ├── queryTransform.ts │ ├── networkStatus.ts │ ├── directives.ts │ ├── getFromAST.ts │ └── store.ts ├── core │ ├── types.ts │ └── watchQueryOptions.ts ├── mutations │ └── store.ts ├── index.ts ├── errors │ └── ApolloError.ts ├── optimistic-data │ └── store.ts ├── actions.ts ├── store.ts └── scheduler │ └── scheduler.ts ├── analyze ├── src │ └── index.js └── webpack.config.js ├── tsconfig.test.json ├── .babelrc ├── scripts ├── dev.sh ├── test_and_lint.sh ├── deploy.sh └── filesize.js ├── .npmignore ├── test ├── fixtures │ └── redux-todomvc │ │ ├── README.md │ │ ├── index.ts │ │ ├── types.ts │ │ ├── actions.ts │ │ └── reducers.ts ├── mocks │ ├── mockWatchQuery.ts │ ├── mockQueryManager.ts │ ├── mockFetch.ts │ └── mockNetworkInterface.ts ├── util │ ├── wrap.ts │ ├── subscribeAndCount.ts │ └── observableToPromise.ts ├── assign.ts ├── tests.ts ├── customResolvers.ts ├── cloneDeep.ts ├── environment.ts ├── isEqual.ts ├── errors.ts ├── deduplicator.ts ├── directives.ts ├── subscribeToMore.ts ├── batching.ts ├── queryTransform.ts ├── mockNetworkInterface.ts ├── store.ts └── getFromAST.ts ├── .travis.yml ├── typings.d.ts ├── rollup.config.js ├── .vscode ├── launch.json └── settings.json ├── Gruntfile.js ├── appveyor.yml ├── tsconfig.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .gitignore ├── DESIGNS.md ├── LICENSE ├── designs ├── arguments.md └── errors.md ├── AUTHORS ├── tslint.json ├── package.json ├── ROADMAP.md ├── README.md ├── DESIGN.md └── benchmark └── util.ts /src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = 'local'; 2 | -------------------------------------------------------------------------------- /analyze/src/index.js: -------------------------------------------------------------------------------- 1 | import ApolloClient from '../../lib/src'; -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-es2015-modules-commonjs" 4 | ], 5 | "ignore": [ 6 | "lib/**/*.d.ts", 7 | "lib/bundles/**", 8 | "node_modules/**" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | test_and_lint_command="./scripts/test_and_lint.sh " 2 | test_and_lint_command+="$@" 3 | 4 | echo $test_and_lint_command 5 | 6 | $(npm bin)/concurrently -r -k "npm run watch:test" "$test_and_lint_command" 7 | -------------------------------------------------------------------------------- /src/util/errorHandling.ts: -------------------------------------------------------------------------------- 1 | export function tryFunctionOrLogError (f: Function) { 2 | try { 3 | return f(); 4 | } catch (e) { 5 | if (console.error) { 6 | console.error(e); 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | scripts 2 | test 3 | typings 4 | .gitignore 5 | .travis.yml 6 | ambient.d.ts 7 | appveyor.yml 8 | CHANGELOG.md 9 | design.md 10 | Gruntfile.js 11 | rollup.config.js 12 | tsconfig.json 13 | tslint.json 14 | typings.json 15 | -------------------------------------------------------------------------------- /src/transport/afterware.ts: -------------------------------------------------------------------------------- 1 | export interface AfterwareResponse { 2 | response: IResponse; 3 | options: RequestInit; 4 | } 5 | 6 | export interface AfterwareInterface { 7 | applyAfterware(response: AfterwareResponse, next: Function): any; 8 | } 9 | -------------------------------------------------------------------------------- /src/transport/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request } from './networkInterface'; 2 | 3 | export interface MiddlewareRequest { 4 | request: Request; 5 | options: RequestInit; 6 | } 7 | 8 | export interface MiddlewareInterface { 9 | applyMiddleware(request: MiddlewareRequest, next: Function): void; 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/redux-todomvc/README.md: -------------------------------------------------------------------------------- 1 | ## Redux TODOMVC 2 | 3 | 4 | The simple redux is directly pulled from the gist on redux's README.md 5 | This is the common explanation app also used in Dan's egghead videos 6 | on redux so it should be a familiar use case for many redux users 7 | 8 | Taken from: https://github.com/reactjs/redux/tree/master/examples/todomvc 9 | -------------------------------------------------------------------------------- /scripts/test_and_lint.sh: -------------------------------------------------------------------------------- 1 | sleep 5 2 | 3 | run_command="npm run testonly" 4 | 5 | if [[ $# -gt 0 ]]; then 6 | run_command+=' -- --grep "' 7 | run_command+=$@ 8 | run_command+='"' 9 | fi 10 | 11 | lint_command="npm run lint" 12 | 13 | command="$run_command" 14 | command+=" && " 15 | command+="$lint_command" 16 | 17 | nodemon --watch lib --exec "$command" --delay 0.5 18 | -------------------------------------------------------------------------------- /analyze/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 3 | 4 | module.exports = { 5 | context: __dirname, 6 | entry: './src/index.js', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'bundle.js' 10 | }, 11 | plugins: [ 12 | new BundleAnalyzerPlugin() 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "4" 5 | 6 | install: 7 | - npm install -g coveralls 8 | - npm prune 9 | - npm install 10 | 11 | script: 12 | - npm test 13 | - npm run coverage 14 | - coveralls < ./coverage/lcov.info || true # ignore coveralls error 15 | - npm run filesize 16 | 17 | # Allow Travis tests to run in containers. 18 | sudo: false 19 | 20 | notifications: 21 | email: false 22 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | GRAPHQL 4 | 5 | */ 6 | declare module 'graphql-tag/parser' { 7 | import { Source, ParseOptions, DocumentNode } from 'graphql'; 8 | // XXX figure out how to directly export this method 9 | function parse( 10 | source: Source | string, 11 | options?: ParseOptions 12 | ): DocumentNode; 13 | } 14 | 15 | declare module 'graphql-tag/printer' { 16 | function print(ast: any): string; 17 | } 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | entry: 'lib/src/index.js', 3 | dest: 'lib/apollo.umd.js', 4 | format: 'umd', 5 | sourceMap: true, 6 | moduleName: 'apollo', 7 | onwarn 8 | }; 9 | 10 | function onwarn(message) { 11 | const suppressed = [ 12 | 'UNRESOLVED_IMPORT', 13 | 'THIS_IS_UNDEFINED' 14 | ]; 15 | 16 | if (!suppressed.find(code => message.code === code)) { 17 | return console.warn(message.message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/fixtures/redux-todomvc/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | rootReducer, 3 | } from './reducers'; 4 | 5 | import { 6 | addTodo, 7 | deleteTodo, 8 | editTodo, 9 | completeTodo, 10 | completeAll, 11 | clearCompleted, 12 | } from './actions' 13 | 14 | import * as types from './types'; 15 | 16 | export { 17 | rootReducer, 18 | addTodo, 19 | deleteTodo, 20 | editTodo, 21 | completeTodo, 22 | completeAll, 23 | clearCompleted, 24 | types, 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/redux-todomvc/types.ts: -------------------------------------------------------------------------------- 1 | // action types 2 | export const ADD_TODO = 'ADD_TODO'; 3 | export const DELETE_TODO = 'DELETE_TODO'; 4 | export const EDIT_TODO = 'EDIT_TODO'; 5 | export const COMPLETE_TODO = 'COMPLETE_TODO'; 6 | export const COMPLETE_ALL = 'COMPLETE_ALL'; 7 | export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'; 8 | 9 | // todo types 10 | export const SHOW_ALL = 'show_all' 11 | export const SHOW_COMPLETED = 'show_completed' 12 | export const SHOW_ACTIVE = 'show_active' 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Test", 6 | "type": "node", 7 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 8 | "stopOnEntry": false, 9 | "args": [ 10 | "--no-timeouts", 11 | "lib/test/tests.js" 12 | ], 13 | "cwd": "${workspaceRoot}", 14 | "runtimeExecutable": null, 15 | "sourceMaps": true, 16 | "outDir": "${workspaceRoot}/lib" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "editor.tabSize": 2, 4 | "editor.rulers": [140], 5 | "files.trimTrailingWhitespace": true, 6 | "files.insertFinalNewline": true, 7 | "files.exclude": { 8 | "**/.git": true, 9 | "**/.DS_Store": true, 10 | "node_modules": true, 11 | "test-lib": true, 12 | "lib": true, 13 | "dist": true, 14 | "coverage": true, 15 | "npm": true 16 | }, 17 | "typescript.tsdk": "node_modules/typescript/lib" 18 | } 19 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (grunt) { 4 | grunt.initConfig({ 5 | tslint: { 6 | options: { 7 | // can be a configuration object or a filepath to tslint.json 8 | configuration: grunt.file.readJSON('tslint.json') 9 | }, 10 | files: { 11 | src: [ 12 | 'src/**/*.ts', 13 | 'test/**/*.ts', 14 | '!test/fixtures/**/*.ts', 15 | 'benchmark/**/*.ts', 16 | ] 17 | } 18 | } 19 | }) 20 | 21 | grunt.loadNpmTasks('grunt-tslint'); 22 | } 23 | -------------------------------------------------------------------------------- /test/mocks/mockWatchQuery.ts: -------------------------------------------------------------------------------- 1 | import { MockedResponse } from './mockNetworkInterface'; 2 | 3 | import mockQueryManager from './mockQueryManager'; 4 | 5 | import { ObservableQuery } from '../../src/core/ObservableQuery'; // tslint:disable-line 6 | 7 | export default (...mockedResponses: MockedResponse[]) => { 8 | const queryManager = mockQueryManager(...mockedResponses); 9 | const firstRequest = mockedResponses[0].request; 10 | return queryManager.watchQuery({ 11 | query: firstRequest.query!, 12 | variables: firstRequest.variables, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /src/data/debug.ts: -------------------------------------------------------------------------------- 1 | // For development only! 2 | 3 | export function stripLoc(obj: Object) { 4 | if (Array.isArray(obj)) { 5 | return obj.map(stripLoc); 6 | } 7 | 8 | if (obj === null || typeof obj !== 'object') { 9 | return obj; 10 | } 11 | 12 | const nextObj = {}; 13 | 14 | Object.keys(obj).forEach(key => { 15 | if (key !== 'loc') { 16 | nextObj[key] = stripLoc(obj[key]); 17 | } 18 | }); 19 | 20 | return nextObj; 21 | } 22 | 23 | export function printAST(fragAst: Object) { 24 | /* tslint:disable */ 25 | console.log(JSON.stringify(stripLoc(fragAst), null, 2)); 26 | } 27 | -------------------------------------------------------------------------------- /src/util/environment.ts: -------------------------------------------------------------------------------- 1 | export function getEnv(): string { 2 | if (typeof process !== 'undefined' && process.env.NODE_ENV) { 3 | return process.env.NODE_ENV; 4 | } 5 | 6 | // default environment 7 | return 'development'; 8 | } 9 | 10 | export function isEnv(env: string): boolean { 11 | return getEnv() === env; 12 | } 13 | 14 | export function isProduction(): boolean { 15 | return isEnv('production') === true; 16 | } 17 | 18 | export function isDevelopment(): boolean { 19 | return isEnv('development') === true; 20 | } 21 | 22 | export function isTest(): boolean { 23 | return isEnv('test') === true; 24 | } 25 | -------------------------------------------------------------------------------- /src/data/mutationResults.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloAction, 3 | } from '../actions'; 4 | 5 | // This is part of the public API, people write these functions in `updateQueries`. 6 | export type MutationQueryReducer = (previousResult: Object, options: { 7 | mutationResult: Object, 8 | queryName: Object, 9 | queryVariables: Object, 10 | }) => Object; 11 | 12 | export type MutationQueryReducersMap = { 13 | [queryName: string]: MutationQueryReducer; 14 | }; 15 | 16 | export type OperationResultReducer = (previousResult: Object, action: ApolloAction, variables: Object) => Object; 17 | 18 | export type OperationResultReducerMap = { 19 | [queryId: string]: OperationResultReducer; 20 | }; 21 | -------------------------------------------------------------------------------- /test/fixtures/redux-todomvc/actions.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types'; 2 | 3 | export function addTodo(text: any): any { 4 | return { type: types.ADD_TODO, text }; 5 | } 6 | 7 | export function deleteTodo(id: any): any { 8 | return { type: types.DELETE_TODO, id }; 9 | } 10 | 11 | export function editTodo(id: any, text: any): any { 12 | return { type: types.EDIT_TODO, id, text }; 13 | } 14 | 15 | export function completeTodo(id: any): any { 16 | return { type: types.COMPLETE_TODO, id }; 17 | } 18 | 19 | export function completeAll(): any { 20 | return { type: types.COMPLETE_ALL }; 21 | } 22 | 23 | export function clearCompleted(): any { 24 | return { type: types.CLEAR_COMPLETED }; 25 | } 26 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Test against this version of Node.js 2 | environment: 3 | matrix: 4 | # node.js 5 | - nodejs_version: "5" 6 | - nodejs_version: "4" 7 | 8 | # Install scripts. (runs after repo cloning) 9 | install: 10 | # Get the latest stable version of Node.js or io.js 11 | - ps: Install-Product node $env:nodejs_version 12 | # remove unused modules from node_modules directory 13 | - npm prune 14 | # install modules 15 | - npm install 16 | 17 | # Post-install test scripts. 18 | test_script: 19 | # run tests 20 | - npm test 21 | 22 | # artifacts: 23 | # - path: ./junit/xunit.xml 24 | # - path: ./xunit.xml 25 | 26 | # nothing to compile in this project 27 | build: off 28 | deploy: off 29 | -------------------------------------------------------------------------------- /test/util/wrap.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | const { assert } = chai; 3 | 4 | // I'm not sure why mocha doesn't provide something like this, you can't 5 | // always use promises 6 | export default (done: MochaDone, cb: (...args: any[]) => any) => (...args: any[]) => { 7 | try { 8 | return cb(...args); 9 | } catch (e) { 10 | done(e); 11 | } 12 | }; 13 | 14 | export function withWarning(func: Function, regex: RegExp) { 15 | let message: string = null as never; 16 | const oldWarn = console.warn; 17 | 18 | console.warn = (m: string) => message = m; 19 | 20 | try { 21 | const result = func(); 22 | assert.match(message, regex); 23 | return result; 24 | 25 | } finally { 26 | console.warn = oldWarn; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/util/maybeDeepFreeze.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isDevelopment, 3 | isTest, 4 | } from './environment'; 5 | 6 | // taken straight from https://github.com/substack/deep-freeze to avoid import hassles with rollup 7 | function deepFreeze (o: any) { 8 | Object.freeze(o); 9 | 10 | Object.getOwnPropertyNames(o).forEach(function (prop) { 11 | if (o.hasOwnProperty(prop) 12 | && o[prop] !== null 13 | && (typeof o[prop] === 'object' || typeof o[prop] === 'function') 14 | && !Object.isFrozen(o[prop])) { 15 | deepFreeze(o[prop]); 16 | } 17 | }); 18 | 19 | return o; 20 | }; 21 | 22 | export default function maybeDeepFreeze(obj: any) { 23 | if (isDevelopment() || isTest()) { 24 | return deepFreeze(obj); 25 | } 26 | return obj; 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "rootDir": ".", 9 | "outDir": "lib", 10 | "noLib": true, 11 | "allowSyntheticDefaultImports": true, 12 | "removeComments": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "strictNullChecks": true 17 | }, 18 | "files": [ 19 | "node_modules/typescript/lib/lib.es2015.d.ts", 20 | "node_modules/typescript/lib/lib.dom.d.ts", 21 | "typings.d.ts", 22 | "fetch-mock.typings.d.ts", 23 | "src/index.ts", 24 | "test/tests.ts", 25 | "benchmark/index.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /test/mocks/mockQueryManager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryManager, 3 | } from '../../src/core/QueryManager'; 4 | 5 | import mockNetworkInterface, { 6 | MockedResponse, 7 | } from './mockNetworkInterface'; 8 | 9 | import { 10 | createApolloStore, 11 | } from '../../src/store'; 12 | 13 | const defaultReduxRootSelector = (state: any) => state.apollo; 14 | 15 | // Helper method for the tests that construct a query manager out of a 16 | // a list of mocked responses for a mocked network interface. 17 | export default (...mockedResponses: MockedResponse[]) => { 18 | return new QueryManager({ 19 | networkInterface: mockNetworkInterface(...mockedResponses), 20 | store: createApolloStore(), 21 | reduxRootSelector: defaultReduxRootSelector, 22 | addTypename: false, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /test/util/subscribeAndCount.ts: -------------------------------------------------------------------------------- 1 | import { ObservableQuery } from '../../src/core/ObservableQuery'; 2 | import { ApolloQueryResult } from '../../src/core/types'; 3 | import { Subscription } from '../../src/util/Observable'; 4 | 5 | import wrap from './wrap'; 6 | 7 | export default function(done: MochaDone, observable: ObservableQuery, 8 | cb: (handleCount: number, result: ApolloQueryResult) => any): Subscription { 9 | let handleCount = 0; 10 | const subscription = observable.subscribe({ 11 | next: result => { 12 | try { 13 | handleCount++; 14 | cb(handleCount, result); 15 | } catch (e) { 16 | subscription.unsubscribe(); 17 | done(e); 18 | } 19 | }, 20 | error: done, 21 | }); 22 | return subscription; 23 | }; 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | TODO: 9 | 10 | - [ ] If this PR is a new feature, reference an issue where a consensus about the design was reached (not necessary for small changes) 11 | - [ ] Make sure all of the significant new logic is covered by tests 12 | - [ ] Rebase your changes on master so that they can be merged easily 13 | - [ ] Make sure all tests and linter rules pass 14 | - [ ] Update CHANGELOG.md with your change 15 | - [ ] Add your name and email to the AUTHORS file (optional) 16 | - [ ] If this was a change that affects the external API, update the docs and post a link to the PR in the discussion 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # don't commit compiled files 30 | lib 31 | test-lib 32 | dist 33 | npm 34 | 35 | # webstorm 36 | .idea/ 37 | 38 | # alm editor 39 | .alm 40 | -------------------------------------------------------------------------------- /src/data/replaceQueryResults.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NormalizedCache, 3 | } from './storeUtils'; 4 | 5 | import { 6 | writeResultToStore, 7 | } from './writeToStore'; 8 | 9 | import { 10 | ApolloReducerConfig, 11 | } from '../store'; 12 | 13 | import { 14 | DocumentNode, 15 | } from 'graphql'; 16 | 17 | export function replaceQueryResults(state: NormalizedCache, { 18 | variables, 19 | document, 20 | newResult, 21 | }: { 22 | variables: any; 23 | document: DocumentNode; 24 | newResult: Object; 25 | }, config: ApolloReducerConfig) { 26 | const clonedState = { ...state } as NormalizedCache; 27 | 28 | return writeResultToStore({ 29 | result: newResult, 30 | dataId: 'ROOT_QUERY', 31 | variables, 32 | document, 33 | store: clonedState, 34 | dataIdFromObject: config.dataIdFromObject, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/util/cloneDeep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deeply clones a value to create a new instance. 3 | */ 4 | export function cloneDeep (value: T): T { 5 | // If the value is an array, create a new array where every item has been cloned. 6 | if (Array.isArray(value)) { 7 | return value.map(item => cloneDeep(item)) as any; 8 | } 9 | // If the value is an object, go through all of the object’s properties and add them to a new 10 | // object. 11 | if (value !== null && typeof value === 'object') { 12 | const nextValue: any = {}; 13 | for (const key in value) { 14 | if (value.hasOwnProperty(key)) { 15 | nextValue[key] = cloneDeep(value[key]); 16 | } 17 | } 18 | return nextValue; 19 | } 20 | // Otherwise this is some primitive value and it is therefore immutable so we can just return it 21 | // directly. 22 | return value; 23 | } 24 | -------------------------------------------------------------------------------- /src/util/assign.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds the properties of one or more source objects to a target object. Works exactly like 3 | * `Object.assign`, but as a utility to maintain support for IE 11. 4 | * 5 | * @see https://github.com/apollostack/apollo-client/pull/1009 6 | */ 7 | export function assign (a: A, b: B): A & B; 8 | export function assign (a: A, b: B, c: C): A & B & C; 9 | export function assign (a: A, b: B, c: C, d: D): A & B & C & D; 10 | export function assign (a: A, b: B, c: C, d: D, e: E): A & B & C & D & E; 11 | export function assign (target: any, ...sources: Array): any; 12 | export function assign ( 13 | target: { [key: string]: any }, 14 | ...sources: Array<{ [key: string]: any }>, 15 | ): { [key: string]: any } { 16 | sources.forEach(source => Object.keys(source).forEach(key => { 17 | target[key] = source[key]; 18 | })); 19 | return target; 20 | } 21 | -------------------------------------------------------------------------------- /DESIGNS.md: -------------------------------------------------------------------------------- 1 | # Apollo Design Documents 2 | 3 | A design document is a way to communicate to others what the intent behind a certain feature/solution is. In the Apollo community we use them to facilitate discussions and reach agreement about important design decisions. 4 | 5 | Design docs are written with the purpose of helping community members and contributors understand design proposals and judge them on their merits. 6 | 7 | A good design document should: 8 | - state the problem that is being solved as clearly as possible 9 | - explain why the problem should be solved in this package, and not a different one (or possibly a new one) 10 | - show that the proposed solution fits in with the stated vision for Apollo Client 11 | - incrementally adoptable 12 | - universally compatible 13 | - easy to understand and use 14 | - compare the proposed solution with obvious alternatives and show that 15 | - the proposed solution is better than its alternatives with respect to the Apollo vision. 16 | 17 | For an example of a design doc, see [Apollo Errors](./designs/errors.md). 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2016 Meteor Development Group, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/util/isEqual.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performs a deep equality check on two JavaScript values. 3 | */ 4 | export function isEqual (a: any, b: any): boolean { 5 | // If the two values are strictly equal, we are good. 6 | if (a === b) { 7 | return true; 8 | } 9 | // If a and b are both objects, we will compare their properties. This will compare arrays as 10 | // well. 11 | if (a != null && typeof a === 'object' && b != null && typeof b === 'object') { 12 | // Compare all of the keys in `a`. If one of the keys has a different value, or that key does 13 | // not exist in `b` return false immediately. 14 | for (const key in a) { 15 | if (a.hasOwnProperty(key)) { 16 | if (!b.hasOwnProperty(key)) { 17 | return false; 18 | } 19 | if (!isEqual(a[key], b[key])) { 20 | return false; 21 | } 22 | } 23 | } 24 | // Look through all the keys in `b`. If `b` has a key that `a` does not, return false. 25 | for (const key in b) { 26 | if (!a.hasOwnProperty(key)) { 27 | return false; 28 | } 29 | } 30 | // If we made it this far the objects are equal! 31 | return true; 32 | } 33 | // Otherwise the values are not equal. 34 | return false; 35 | } 36 | -------------------------------------------------------------------------------- /test/assign.ts: -------------------------------------------------------------------------------- 1 | import { assign } from '../src/util/assign'; 2 | import { assert } from 'chai'; 3 | 4 | describe('assign', () => { 5 | it('will merge many objects together', () => { 6 | assert.deepEqual(assign({ a: 1 }, { b: 2 }), { a: 1, b: 2 }); 7 | assert.deepEqual(assign({ a: 1 }, { b: 2 }, { c: 3 }), { a: 1, b: 2, c: 3 }); 8 | assert.deepEqual(assign({ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }), { a: 1, b: 2, c: 3, d: 4 }); 9 | }); 10 | 11 | it('will merge many objects together shallowly', () => { 12 | assert.deepEqual(assign({ x: { a: 1 } }, { x: { b: 2 } }), { x: { b: 2 } }); 13 | assert.deepEqual(assign({ x: { a: 1 } }, { x: { b: 2 } }, { x: { c: 3 } }), { x: { c: 3 } }); 14 | assert.deepEqual(assign({ x: { a: 1 } }, { x: { b: 2 } }, { x: { c: 3 } }, { x: { d: 4 } }), { x: { d: 4 } }); 15 | }); 16 | 17 | it('will mutate and return the source objects', () => { 18 | const source1 = { a: 1 }; 19 | const source2 = { a: 1 }; 20 | const source3 = { a: 1 }; 21 | 22 | assert.strictEqual(assign(source1, { b: 2 }), source1); 23 | assert.strictEqual(assign(source2, { b: 2 }, { c: 3 }), source2); 24 | assert.strictEqual(assign(source3, { b: 2 }, { c: 3 }, { d: 4 }), source3); 25 | 26 | assert.deepEqual(source1, { a: 1, b: 2 }); 27 | assert.deepEqual(source2, { a: 1, b: 2, c: 3 }); 28 | assert.deepEqual(source3, { a: 1, b: 2, c: 3, d: 4 }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/transport/Deduplicator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NetworkInterface, 3 | Request, 4 | } from '../transport/networkInterface'; 5 | 6 | import { 7 | print, 8 | } from 'graphql-tag/printer'; 9 | 10 | export class Deduplicator { 11 | 12 | private inFlightRequestPromises: { [key: string]: Promise}; 13 | private networkInterface: NetworkInterface; 14 | 15 | constructor(networkInterface: NetworkInterface) { 16 | this.networkInterface = networkInterface; 17 | this.inFlightRequestPromises = {}; 18 | } 19 | 20 | public query(request: Request, deduplicate = true) { 21 | 22 | // sometimes we might not want to deduplicate a request, for example when we want to force fetch it. 23 | if (!deduplicate) { 24 | return this.networkInterface.query(request); 25 | } 26 | 27 | const key = this.getKey(request); 28 | if (!this.inFlightRequestPromises[key]) { 29 | this.inFlightRequestPromises[key] = this.networkInterface.query(request); 30 | } 31 | return this.inFlightRequestPromises[key] 32 | .then( res => { 33 | delete this.inFlightRequestPromises[key]; 34 | return res; 35 | }); 36 | } 37 | 38 | private getKey(request: Request) { 39 | // XXX we're assuming here that variables will be serialized in the same order. 40 | // that might not always be true 41 | return `${print(request.query)}|${JSON.stringify(request.variables)}|${request.operationName}`; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/Observable.ts: -------------------------------------------------------------------------------- 1 | // This simplified polyfill attempts to follow the ECMAScript Observable proposal. 2 | // See https://github.com/zenparsing/es-observable 3 | 4 | import $$observable from 'symbol-observable'; 5 | 6 | export type CleanupFunction = () => void; 7 | export type SubscriberFunction = (observer: Observer) => (Subscription | CleanupFunction); 8 | 9 | function isSubscription(subscription: Function | Subscription): subscription is Subscription { 10 | return (subscription).unsubscribe !== undefined; 11 | } 12 | 13 | export class Observable { 14 | private subscriberFunction: SubscriberFunction; 15 | 16 | constructor(subscriberFunction: SubscriberFunction) { 17 | this.subscriberFunction = subscriberFunction; 18 | } 19 | 20 | public [$$observable]() { 21 | return this; 22 | } 23 | 24 | public subscribe(observer: Observer): Subscription { 25 | let subscriptionOrCleanupFunction = this.subscriberFunction(observer); 26 | 27 | if (isSubscription(subscriptionOrCleanupFunction)) { 28 | return subscriptionOrCleanupFunction; 29 | } else { 30 | return { 31 | unsubscribe: subscriptionOrCleanupFunction, 32 | }; 33 | } 34 | } 35 | } 36 | 37 | export interface Observer { 38 | next?: (value: T) => void; 39 | error?: (error: Error) => void; 40 | complete?: () => void; 41 | } 42 | 43 | export interface Subscription { 44 | unsubscribe: CleanupFunction; 45 | } 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | **Intended outcome:** 12 | 13 | 14 | **Actual outcome:** 15 | 16 | 17 | **How to reproduce the issue:** 18 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | 4 | # When we publish to npm, the published files are available in the root 5 | # directory, which allows for a clean include or require of sub-modules. 6 | # 7 | # var language = require('apollo-client/parser'); 8 | # 9 | 10 | # Clear the built output 11 | rm -rf ./lib 12 | 13 | # Compile new files 14 | npm run compile 15 | 16 | # Make sure the ./npm directory is empty 17 | rm -rf ./npm 18 | mkdir ./npm 19 | 20 | # Copy all files from ./lib/src to /npm 21 | cd ./lib/src && cp -r ./ ../../npm/ 22 | # Copy also the umd bundle with the source map file 23 | cd ../ 24 | cp apollo.umd.js ../npm/ && cp apollo.umd.js.map ../npm/ 25 | 26 | # Back to the root directory 27 | cd ../ 28 | 29 | # Ensure a vanilla package.json before deploying so other tools do not interpret 30 | # The built output as requiring any further transformation. 31 | node -e "var package = require('./package.json'); \ 32 | delete package.babel; \ 33 | delete package.scripts; \ 34 | delete package.options; \ 35 | package.main = 'apollo.umd.js'; \ 36 | package.module = 'index.js'; \ 37 | package['jsnext:main'] = 'index.js'; \ 38 | package.typings = 'index.d.ts'; \ 39 | var fs = require('fs'); \ 40 | fs.writeFileSync('./npm/version.js', 'exports.version = \"' + package.version + '\"'); \ 41 | fs.writeFileSync('./npm/package.json', JSON.stringify(package, null, 2));" 42 | 43 | 44 | # Copy few more files to ./npm 45 | cp README.md npm/ 46 | cp LICENSE npm/ 47 | 48 | echo 'deploying to npm...' 49 | cd npm && npm publish 50 | -------------------------------------------------------------------------------- /designs/arguments.md: -------------------------------------------------------------------------------- 1 | # Variables and arguments handling 2 | 3 | ## Motivation 4 | ApolloClient currently requires every variable that is defined to be provided in a query/mutation, and it does not support default values. 5 | 6 | ## Proposed feature / solution 7 | We should change ApolloClient to enforce only the presence of required variables, and set default values where they are provided. In order to still be 8 | able to check required arguments, ApolloClient should throw an error if a variable is provided without having been declared. 9 | 10 | 11 | ## Implementation steps / changes needed 12 | 1. `storeUtils` should be used via graphql-anyhwere 13 | 2. In graphql-anywhere, storeUtils should not throw any more when a variable doesn't exist but assume it to be `undefined` instead. 14 | 3. The `graphql` function in graphql-anywhere should check the query to make sure all _required_ nonNull arguments are present or have a default. 15 | 4. The `graphql` function in graphql-anywhere should set variables to their specified default values if they haven't been provided. 16 | 5. The `graphql` function in graphql-anywhere should throw an error if a provided variable has not been declared. 17 | 6. We should consider updating `watchQuery`, `query`, `mutate` and `subscribe` in Apollo Client to do the same variable sanity-checking and setting of default variables. 18 | 19 | ## Changes to the external API 20 | * ApolloClient will support not declaring optional arguments 21 | * ApolloClient will support providing default values 22 | * ApolloClient will throw an error if a variable is used without having been declared 23 | -------------------------------------------------------------------------------- /test/tests.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | // These should (and generally do) get picked up automatically as they're installed 4 | // at @types/es6-shim, but it doesn't work in typedoc (or Atom it seems), 5 | // so we include them here manually 6 | /// 7 | /// 8 | 9 | // ensure support for fetch and promise 10 | import 'es6-promise'; 11 | import 'isomorphic-fetch'; 12 | 13 | process.env.NODE_ENV = 'test'; 14 | 15 | declare function require(name: string): any; 16 | require('source-map-support').install(); 17 | 18 | console.warn = console.error = (...messages: string[]) => { 19 | console.log(`==> Error in test: Tried to log warning or error with message: 20 | `, ...messages); 21 | if (!process.env.CI) { 22 | process.exit(1); 23 | } 24 | }; 25 | 26 | process.on('unhandledRejection', () => {}); 27 | 28 | import './writeToStore'; 29 | import './readFromStore'; 30 | import './roundtrip'; 31 | import './diffAgainstStore'; 32 | import './networkInterface'; 33 | import './deduplicator'; 34 | import './QueryManager'; 35 | import './client'; 36 | import './store'; 37 | import './queryTransform'; 38 | import './getFromAST'; 39 | import './directives'; 40 | import './batching'; 41 | import './scheduler'; 42 | import './mutationResults'; 43 | import './optimistic'; 44 | import './fetchMore'; 45 | import './errors'; 46 | import './mockNetworkInterface'; 47 | import './graphqlSubscriptions'; 48 | import './batchedNetworkInterface'; 49 | import './ObservableQuery'; 50 | import './subscribeToMore'; 51 | import './customResolvers'; 52 | import './isEqual'; 53 | import './cloneDeep'; 54 | import './assign'; 55 | import './environment' 56 | -------------------------------------------------------------------------------- /src/queries/queryTransform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | SelectionSetNode, 4 | DefinitionNode, 5 | OperationDefinitionNode, 6 | FieldNode, 7 | InlineFragmentNode, 8 | } from 'graphql'; 9 | 10 | import { 11 | checkDocument, 12 | } from './getFromAST'; 13 | 14 | import { cloneDeep } from '../util/cloneDeep'; 15 | 16 | const TYPENAME_FIELD: FieldNode = { 17 | kind: 'Field', 18 | name: { 19 | kind: 'Name', 20 | value: '__typename', 21 | }, 22 | }; 23 | 24 | function addTypenameToSelectionSet( 25 | selectionSet: SelectionSetNode, 26 | isRoot = false, 27 | ) { 28 | if (selectionSet.selections) { 29 | if (! isRoot) { 30 | const alreadyHasThisField = selectionSet.selections.some((selection) => { 31 | return selection.kind === 'Field' && (selection as FieldNode).name.value === '__typename'; 32 | }); 33 | 34 | if (! alreadyHasThisField) { 35 | selectionSet.selections.push(TYPENAME_FIELD); 36 | } 37 | } 38 | 39 | selectionSet.selections.forEach((selection) => { 40 | if (selection.kind === 'Field' || selection.kind === 'InlineFragment') { 41 | if (selection.selectionSet) { 42 | addTypenameToSelectionSet(selection.selectionSet); 43 | } 44 | } 45 | }); 46 | } 47 | } 48 | 49 | export function addTypenameToDocument(doc: DocumentNode) { 50 | checkDocument(doc); 51 | const docClone = cloneDeep(doc); 52 | 53 | docClone.definitions.forEach((definition: DefinitionNode) => { 54 | const isRoot = definition.kind === 'OperationDefinition'; 55 | addTypenameToSelectionSet((definition as OperationDefinitionNode).selectionSet, isRoot); 56 | }); 57 | 58 | return docClone; 59 | } 60 | -------------------------------------------------------------------------------- /scripts/filesize.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const Flags = require('minimist')(process.argv.slice(2)), 5 | Fs = require('fs'), 6 | Path = require('path'), 7 | GzipSize = require('gzip-size'), 8 | PrettyBytes = require('pretty-bytes'), 9 | Colors = require('colors'); 10 | 11 | if (!Flags.file) { 12 | process.exit(1); 13 | } 14 | 15 | let totalSize = 0, 16 | totalGzippedSize = 0, 17 | filePath = Path.resolve(process.cwd(), Flags.file); 18 | 19 | const rawSize = Fs.statSync(filePath).size; 20 | totalSize = PrettyBytes(rawSize); 21 | const rawGzippedSize = GzipSize.sync(Fs.readFileSync(filePath, 'utf8')); 22 | totalGzippedSize = PrettyBytes(rawGzippedSize); 23 | 24 | console.log('\n'); 25 | console.log('=============================== FileSize summary ==============================='); 26 | console.log(`The total size of ${Path.basename(filePath)} is ${Colors.green(totalSize)}.`); 27 | console.log(`The total gzipped size of ${Path.basename(filePath)} is ${Colors.green(totalGzippedSize)}.`); 28 | console.log('================================================================================'); 29 | console.log('\n'); 30 | 31 | if (Flags.max) { 32 | const max = Number(Flags.max) * 1000; // kb to bytes 33 | if (max > totalSize) { 34 | process.exitCode = 1; 35 | console.log(Colors.red(`The total size of ${Path.basename(filePath)} exceeds ${PrettyBytes(max)}.`)); 36 | } 37 | } 38 | 39 | if (Flags.maxGzip) { 40 | const maxGzip = Number(Flags.maxGzip) * 1000; // kb to bytes 41 | if (rawGzippedSize > maxGzip) { 42 | process.exitCode = 1; 43 | console.log(Colors.red(`The total gzipped size of ${Path.basename(filePath)} exceeds ${PrettyBytes(maxGzip)}.`)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/fixtures/redux-todomvc/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { assign } from 'lodash'; 3 | 4 | import { 5 | ADD_TODO, 6 | DELETE_TODO, 7 | EDIT_TODO, 8 | COMPLETE_TODO, 9 | COMPLETE_ALL, 10 | CLEAR_COMPLETED 11 | } from './types'; 12 | 13 | const initialState = [ 14 | { 15 | text: 'Use Redux', 16 | completed: false, 17 | id: 0, 18 | }, 19 | ]; 20 | 21 | function todos(state = initialState, action: any): any { 22 | switch (action.type) { 23 | case ADD_TODO: 24 | return [ 25 | { 26 | id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1, 27 | completed: false, 28 | text: action.text 29 | }, 30 | ...state 31 | ] 32 | 33 | case DELETE_TODO: 34 | return state.filter(todo => 35 | todo.id !== action.id 36 | ) 37 | 38 | case EDIT_TODO: 39 | return state.map(todo => 40 | todo.id === action.id ? 41 | assign({}, todo, { text: action.text }) : 42 | todo 43 | ) 44 | 45 | case COMPLETE_TODO: 46 | return state.map(todo => 47 | todo.id === action.id ? 48 | assign({}, todo, { completed: !todo.completed }) : 49 | todo 50 | ) 51 | 52 | case COMPLETE_ALL: 53 | const areAllMarked = state.every(todo => todo.completed) 54 | return state.map(todo => assign({}, todo, { 55 | completed: !areAllMarked 56 | })) 57 | 58 | case CLEAR_COMPLETED: 59 | return state.filter(todo => todo.completed === false) 60 | 61 | default: 62 | return state 63 | } 64 | } 65 | 66 | const rootReducer = combineReducers({ 67 | todos 68 | }) as any; // XXX see why this type fails 69 | 70 | export { 71 | rootReducer 72 | } 73 | -------------------------------------------------------------------------------- /src/data/resultReducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | } from 'graphql'; 4 | 5 | import { 6 | readQueryFromStore, 7 | } from './readFromStore'; 8 | 9 | import { 10 | writeResultToStore, 11 | } from './writeToStore'; 12 | 13 | import { 14 | NormalizedCache, 15 | } from './storeUtils'; 16 | 17 | import { 18 | ApolloReducer, 19 | ApolloReducerConfig, 20 | } from '../store'; 21 | 22 | import { 23 | ApolloAction, 24 | } from '../actions'; 25 | 26 | import { 27 | OperationResultReducer, 28 | } from './mutationResults'; 29 | 30 | /** 31 | * This function takes a result reducer and all other necessary information to obtain a proper 32 | * redux store reducer. 33 | * note: we're just passing the config to access dataIdFromObject, which writeToStore needs. 34 | */ 35 | export function createStoreReducer( 36 | resultReducer: OperationResultReducer, 37 | document: DocumentNode, 38 | variables: Object, 39 | config: ApolloReducerConfig, 40 | // TODO: maybe turn the arguments into a single object argument 41 | ): ApolloReducer { 42 | 43 | return (store: NormalizedCache, action: ApolloAction) => { 44 | const currentResult = readQueryFromStore({ 45 | store, 46 | query: document, 47 | variables, 48 | returnPartialData: true, 49 | config, 50 | }); 51 | // TODO add info about networkStatus 52 | 53 | const nextResult = resultReducer(currentResult, action, variables); // action should include operation name 54 | 55 | if (currentResult !== nextResult) { 56 | return writeResultToStore({ 57 | dataId: 'ROOT_QUERY', 58 | result: nextResult, 59 | store, 60 | document, 61 | variables, 62 | dataIdFromObject: config.dataIdFromObject, 63 | }); 64 | } 65 | return store; 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /test/customResolvers.ts: -------------------------------------------------------------------------------- 1 | import mockNetworkInterface from './mocks/mockNetworkInterface'; 2 | import gql from 'graphql-tag'; 3 | import { assert } from 'chai'; 4 | import ApolloClient, { toIdValue } from '../src'; 5 | 6 | import { NetworkStatus } from '../src/queries/networkStatus'; 7 | 8 | describe('custom resolvers', () => { 9 | it(`works for cache redirection`, () => { 10 | const dataIdFromObject = (obj: any) => { 11 | return obj.id; 12 | }; 13 | 14 | const listQuery = gql`{ people { id name } }`; 15 | 16 | const listData = { 17 | people: [ 18 | { 19 | id: '4', 20 | name: 'Luke Skywalker', 21 | __typename: 'Person', 22 | }, 23 | ], 24 | }; 25 | 26 | const netListQuery = gql`{ people { id name __typename } }`; 27 | 28 | const itemQuery = gql`{ person(id: 4) { id name } }`; 29 | 30 | // We don't expect the item query to go to the server at all 31 | const networkInterface = mockNetworkInterface({ 32 | request: { query: netListQuery }, 33 | result: { data: listData }, 34 | }); 35 | 36 | const client = new ApolloClient({ 37 | networkInterface, 38 | customResolvers: { 39 | Query: { 40 | person: (_, args) => toIdValue(args['id']), 41 | }, 42 | }, 43 | dataIdFromObject, 44 | }); 45 | 46 | return client.query({ query: listQuery }).then(() => { 47 | return client.query({ query: itemQuery }); 48 | }).then((itemResult) => { 49 | assert.deepEqual(itemResult, { 50 | loading: false, 51 | networkStatus: NetworkStatus.ready, 52 | data: { 53 | person: { 54 | __typename: 'Person', 55 | id: '4', 56 | name: 'Luke Skywalker', 57 | }, 58 | }, 59 | }); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/queries/networkStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The current status of a query’s execution in our system. 3 | */ 4 | export enum NetworkStatus { 5 | /** 6 | * The query has never been run before and the query is now currently running. A query will still 7 | * have this network status even if a partial data result was returned from the cache, but a 8 | * query was dispatched anyway. 9 | */ 10 | loading = 1, 11 | 12 | /** 13 | * If `setVariables` was called and a query was fired because of that then the network status 14 | * will be `setVariables` until the result of that query comes back. 15 | */ 16 | setVariables = 2, 17 | 18 | /** 19 | * Indicates that `fetchMore` was called on this query and that the query created is currently in 20 | * flight. As of commit `389d87a` this network status is not in use. 21 | */ 22 | fetchMore = 3, 23 | 24 | /** 25 | * Similar to the `setVariables` network status. It means that `refetch` was called on a query 26 | * and the refetch request is currently in flight. 27 | */ 28 | refetch = 4, 29 | 30 | /** 31 | * Indicates that a polling query is currently in flight. So for example if you are polling a 32 | * query every 10 seconds then the network status will switch to `poll` every 10 seconds whenever 33 | * a poll request has been sent but not resolved. 34 | */ 35 | poll = 6, 36 | 37 | /** 38 | * No request is in flight for this query, and no errors happened. Everything is OK. 39 | */ 40 | ready = 7, 41 | 42 | /** 43 | * No request is in flight for this query, but one or more errors were detected. 44 | */ 45 | error = 8, 46 | } 47 | 48 | /** 49 | * Returns true if there is currently a network request in flight according to a given network 50 | * status. 51 | */ 52 | export function isNetworkRequestInFlight (networkStatus: NetworkStatus): boolean { 53 | return networkStatus < 7; 54 | } 55 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from 'graphql'; 2 | import { QueryStoreValue } from '../queries/store'; 3 | import { NetworkStatus } from '../queries/networkStatus'; 4 | 5 | export interface SubscriptionOptions { 6 | document: DocumentNode; 7 | variables?: { [key: string]: any }; 8 | }; 9 | 10 | export type QueryListener = (queryStoreValue: QueryStoreValue) => void; 11 | 12 | export type PureQueryOptions = { 13 | query: DocumentNode, 14 | variables?: { [key: string]: any}; 15 | }; 16 | 17 | export type ApolloQueryResult = { 18 | data: T; 19 | loading: boolean; 20 | networkStatus: NetworkStatus; 21 | 22 | // This type is different from the GraphQLResult type because it doesn't include errors. 23 | // Those are thrown via the standard promise/observer catch mechanism. 24 | }; 25 | 26 | // A result transformer is given the data that is to be returned from the store from a query or 27 | // mutation, and can modify or observe it before the value is provided to your application. 28 | // 29 | // For watched queries, the transformer is only called when the data retrieved from the server is 30 | // different from previous. 31 | // 32 | // If the transformer wants to mutate results (say, by setting the prototype of result data), it 33 | // will likely need to be paired with a custom resultComparator. By default, Apollo performs a 34 | // deep equality comparsion on results, and skips those that are considered equal - reducing 35 | // re-renders. 36 | export type ResultTransformer = (resultData: ApolloQueryResult) => ApolloQueryResult; 37 | 38 | // Controls how Apollo compares two query results and considers their equality. Two equal results 39 | // will not trigger re-renders. 40 | export type ResultComparator = (result1: ApolloQueryResult, result2: ApolloQueryResult) => boolean; 41 | 42 | export enum FetchType { 43 | normal = 1, 44 | refetch = 2, 45 | poll = 3, 46 | } 47 | 48 | export type IdGetter = (value: Object) => string | null | undefined; 49 | -------------------------------------------------------------------------------- /src/mutations/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloAction, 3 | isMutationInitAction, 4 | isMutationResultAction, 5 | isMutationErrorAction, 6 | isStoreResetAction, 7 | } from '../actions'; 8 | 9 | import { 10 | SelectionSetNode, 11 | } from 'graphql'; 12 | 13 | export interface MutationStore { 14 | [mutationId: string]: MutationStoreValue; 15 | } 16 | 17 | export interface MutationStoreValue { 18 | mutationString: string; 19 | variables: Object; 20 | loading: boolean; 21 | error: Error | null; 22 | } 23 | 24 | export interface SelectionSetWithRoot { 25 | id: string; 26 | typeName: string; 27 | selectionSet: SelectionSetNode; 28 | } 29 | 30 | export function mutations( 31 | previousState: MutationStore = {}, 32 | action: ApolloAction, 33 | ): MutationStore { 34 | if (isMutationInitAction(action)) { 35 | const newState = { ...previousState } as MutationStore; 36 | 37 | newState[action.mutationId] = { 38 | mutationString: action.mutationString, 39 | variables: action.variables, 40 | loading: true, 41 | error: null, 42 | }; 43 | 44 | return newState; 45 | } else if (isMutationResultAction(action)) { 46 | const newState = { ...previousState } as MutationStore; 47 | 48 | newState[action.mutationId] = { 49 | ...previousState[action.mutationId], 50 | loading: false, 51 | error: null, 52 | } as MutationStoreValue; 53 | 54 | return newState; 55 | } else if (isMutationErrorAction(action)) { 56 | const newState = { ...previousState } as MutationStore; 57 | 58 | newState[action.mutationId] = { 59 | ...previousState[action.mutationId], 60 | loading: false, 61 | error: action.error, 62 | } as MutationStoreValue; 63 | } else if (isStoreResetAction(action)) { 64 | // if we are resetting the store, we no longer need information about the mutations 65 | // that are currently in the store so we can just throw them all away. 66 | return {}; 67 | } 68 | 69 | return previousState; 70 | } 71 | -------------------------------------------------------------------------------- /test/cloneDeep.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from '../src/util/cloneDeep'; 2 | import { assert } from 'chai'; 3 | 4 | describe('cloneDeep', () => { 5 | it('will clone primitive values', () => { 6 | assert.equal(cloneDeep(undefined), undefined); 7 | assert.equal(cloneDeep(null), null); 8 | assert.equal(cloneDeep(true), true); 9 | assert.equal(cloneDeep(false), false); 10 | assert.equal(cloneDeep(-1), -1); 11 | assert.equal(cloneDeep(+1), +1); 12 | assert.equal(cloneDeep(0.5), 0.5); 13 | assert.equal(cloneDeep('hello'), 'hello'); 14 | assert.equal(cloneDeep('world'), 'world'); 15 | }); 16 | 17 | it('will clone objects', () => { 18 | const value1 = {}; 19 | const value2 = { a: 1, b: 2, c: 3 }; 20 | const value3 = { x: { a: 1, b: 2, c: 3 }, y: { a: 1, b: 2, c: 3 } }; 21 | 22 | const clonedValue1 = cloneDeep(value1); 23 | const clonedValue2 = cloneDeep(value2); 24 | const clonedValue3 = cloneDeep(value3); 25 | 26 | assert.deepEqual(clonedValue1, value1); 27 | assert.deepEqual(clonedValue2, value2); 28 | assert.deepEqual(clonedValue3, value3); 29 | 30 | assert.notStrictEqual(clonedValue1, value1); 31 | assert.notStrictEqual(clonedValue2, value2); 32 | assert.notStrictEqual(clonedValue3, value3); 33 | assert.notStrictEqual(clonedValue3.x, value3.x); 34 | assert.notStrictEqual(clonedValue3.y, value3.y); 35 | }); 36 | 37 | it('will clone arrays', () => { 38 | const value1: Array = []; 39 | const value2 = [1, 2, 3]; 40 | const value3 = [[1, 2, 3], [1, 2, 3]]; 41 | 42 | const clonedValue1 = cloneDeep(value1); 43 | const clonedValue2 = cloneDeep(value2); 44 | const clonedValue3 = cloneDeep(value3); 45 | 46 | assert.deepEqual(clonedValue1, value1); 47 | assert.deepEqual(clonedValue2, value2); 48 | assert.deepEqual(clonedValue3, value3); 49 | 50 | assert.notStrictEqual(clonedValue1, value1); 51 | assert.notStrictEqual(clonedValue2, value2); 52 | assert.notStrictEqual(clonedValue3, value3); 53 | assert.notStrictEqual(clonedValue3[0], value3[0]); 54 | assert.notStrictEqual(clonedValue3[1], value3[1]); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/environment.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | 3 | import { isEnv, isProduction, isDevelopment, isTest } from '../src/util/environment'; 4 | 5 | describe('environment', () => { 6 | let keepEnv: string; 7 | 8 | beforeEach(() => { 9 | // save the NODE_ENV 10 | keepEnv = process.env.NODE_ENV; 11 | }); 12 | 13 | afterEach(() => { 14 | // restore the NODE_ENV 15 | process.env.NODE_ENV = keepEnv; 16 | }); 17 | 18 | describe('isEnv', () => { 19 | it(`should match when there's a value`, () => { 20 | [ 21 | 'production', 22 | 'development', 23 | 'test', 24 | ] 25 | .forEach(env => { 26 | process.env.NODE_ENV = env; 27 | assert.isTrue(isEnv(env)); 28 | }); 29 | }); 30 | 31 | it(`should treat no proces.env.NODE_ENV as it'd be in development`, () => { 32 | delete process.env.NODE_ENV; 33 | assert.isTrue(isEnv('development')); 34 | }); 35 | }); 36 | 37 | describe('isProduction', () => { 38 | it('should return true if in production', () => { 39 | process.env.NODE_ENV = 'production'; 40 | assert.isTrue(isProduction()); 41 | }); 42 | 43 | it('should return false if not in production', () => { 44 | process.env.NODE_ENV = 'test'; 45 | assert.isTrue(!isProduction()); 46 | }); 47 | }); 48 | 49 | describe('isTest', () => { 50 | it('should return true if in test', () => { 51 | process.env.NODE_ENV = 'test'; 52 | assert.isTrue(isTest()); 53 | }); 54 | 55 | it('should return true if not in test', () => { 56 | process.env.NODE_ENV = 'development'; 57 | assert.isTrue(!isTest()); 58 | }); 59 | }); 60 | 61 | describe('isDevelopment', () => { 62 | it('should return true if in development', () => { 63 | process.env.NODE_ENV = 'development'; 64 | assert.isTrue(isDevelopment()); 65 | }); 66 | 67 | it('should return true if not in development and environment is defined', () => { 68 | process.env.NODE_ENV = 'test'; 69 | assert.isTrue(!isDevelopment()); 70 | }); 71 | 72 | it('should make development as the default environment', () => { 73 | delete process.env.NODE_ENV; 74 | assert.isTrue(isDevelopment()); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/isEqual.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from '../src/util/isEqual'; 2 | import { assert } from 'chai'; 3 | 4 | describe('isEqual', () => { 5 | it('should return true for equal primitive values', () => { 6 | assert(isEqual(undefined, undefined)); 7 | assert(isEqual(null, null)); 8 | assert(isEqual(true, true)); 9 | assert(isEqual(false, false)); 10 | assert(isEqual(-1, -1)); 11 | assert(isEqual(+1, +1)); 12 | assert(isEqual(42, 42)); 13 | assert(isEqual(0, 0)); 14 | assert(isEqual(0.5, 0.5)); 15 | assert(isEqual('hello', 'hello')); 16 | assert(isEqual('world', 'world')); 17 | }); 18 | 19 | it('should return false for not equal primitive values', () => { 20 | assert(!isEqual(undefined, null)); 21 | assert(!isEqual(null, undefined)); 22 | assert(!isEqual(true, false)); 23 | assert(!isEqual(false, true)); 24 | assert(!isEqual(-1, +1)); 25 | assert(!isEqual(+1, -1)); 26 | assert(!isEqual(42, 42.00000000000001)); 27 | assert(!isEqual(0, 0.5)); 28 | assert(!isEqual('hello', 'world')); 29 | assert(!isEqual('world', 'hello')); 30 | }); 31 | 32 | it('should return false when comparing primitives with objects', () => { 33 | assert(!isEqual({}, null)); 34 | assert(!isEqual(null, {})); 35 | assert(!isEqual({}, true)); 36 | assert(!isEqual(true, {})); 37 | assert(!isEqual({}, 42)); 38 | assert(!isEqual(42, {})); 39 | assert(!isEqual({}, 'hello')); 40 | assert(!isEqual('hello', {})); 41 | }); 42 | 43 | it('should correctly compare shallow objects', () => { 44 | assert(isEqual({}, {})); 45 | assert(isEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 })); 46 | assert(!isEqual({ a: 1, b: 2, c: 3 }, { a: 3, b: 2, c: 1 })); 47 | assert(!isEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })); 48 | assert(!isEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })); 49 | }); 50 | 51 | it('should correctly compare deep objects', () => { 52 | assert(isEqual({ x: {} }, { x: {} })); 53 | assert(isEqual({ x: { a: 1, b: 2, c: 3 } }, { x: { a: 1, b: 2, c: 3 } })); 54 | assert(!isEqual({ x: { a: 1, b: 2, c: 3 } }, { x: { a: 3, b: 2, c: 1 } })); 55 | assert(!isEqual({ x: { a: 1, b: 2, c: 3 } }, { x: { a: 1, b: 2 } })); 56 | assert(!isEqual({ x: { a: 1, b: 2 } }, { x: { a: 1, b: 2, c: 3 } })); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/queries/directives.ts: -------------------------------------------------------------------------------- 1 | // Provides the methods that allow QueryManager to handle 2 | // the `skip` and `include` directives within GraphQL. 3 | import { 4 | SelectionNode, 5 | VariableNode, 6 | BooleanValueNode, 7 | } from 'graphql'; 8 | 9 | 10 | export function shouldInclude(selection: SelectionNode, variables: { [name: string]: any } = {}): boolean { 11 | if (!selection.directives) { 12 | return true; 13 | } 14 | 15 | let res: boolean = true; 16 | selection.directives.forEach((directive) => { 17 | // TODO should move this validation to GraphQL validation once that's implemented. 18 | if (directive.name.value !== 'skip' && directive.name.value !== 'include') { 19 | // Just don't worry about directives we don't understand 20 | return; 21 | } 22 | 23 | //evaluate the "if" argument and skip (i.e. return undefined) if it evaluates to true. 24 | const directiveArguments = directive.arguments || []; 25 | const directiveName = directive.name.value; 26 | if (directiveArguments.length !== 1) { 27 | throw new Error(`Incorrect number of arguments for the @${directiveName} directive.`); 28 | } 29 | 30 | 31 | const ifArgument = directiveArguments[0]; 32 | if (!ifArgument.name || ifArgument.name.value !== 'if') { 33 | throw new Error(`Invalid argument for the @${directiveName} directive.`); 34 | } 35 | 36 | const ifValue = directiveArguments[0].value; 37 | let evaledValue: boolean = false; 38 | if (!ifValue || ifValue.kind !== 'BooleanValue') { 39 | // means it has to be a variable value if this is a valid @skip or @include directive 40 | if (ifValue.kind !== 'Variable') { 41 | throw new Error(`Argument for the @${directiveName} directive must be a variable or a bool ean value.`); 42 | } else { 43 | evaledValue = variables[(ifValue as VariableNode).name.value]; 44 | if (evaledValue === undefined) { 45 | throw new Error(`Invalid variable referenced in @${directiveName} directive.`); 46 | } 47 | } 48 | } else { 49 | evaledValue = (ifValue as BooleanValueNode).value; 50 | } 51 | 52 | if (directiveName === 'skip') { 53 | evaledValue = !evaledValue; 54 | } 55 | 56 | if (!evaledValue) { 57 | res = false; 58 | } 59 | }); 60 | 61 | return res; 62 | } 63 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | 3 | Abhi Aiyer 4 | Brady Whitten 5 | Brett Jurgens 6 | Bruce Williams 7 | Caleb Meredith 8 | Carl Wolsey 9 | Clément Prévost 10 | David Alan Hjelle 11 | David Glasser 12 | Dominic Watson 13 | Dhaivat Pandya 14 | Dhaivat Pandya 15 | Doug Swain 16 | Google Inc. 17 | Ian Grayson 18 | Ian MacLeod 19 | James Baxley 20 | Jayden Seric 21 | Jesper Håkansson 22 | John Pinkerton 23 | Jonas Helfer 24 | Jonas Helfer 25 | Kamil Kisiela 26 | Louis DeScioli 27 | Marc-Andre Giroux 28 | Martijn Walraven 29 | Matt Jeanes 30 | Maxime Quandalle 31 | Michiel ter Reehorst 32 | Oleksandr Stubailo 33 | Olivier Ricordeau 34 | Pavol Fulop 35 | Pavol Fulop 36 | Rasmus Eneman 37 | Robin Ricard 38 | Sashko Stubailo 39 | Sashko Stubailo 40 | Slava Kim 41 | Slava Kim 42 | Tim Mikeladze 43 | Tom Coleman 44 | Tom Coleman 45 | Weipeng Kuang 46 | abhiaiyer91 47 | amandajliu 48 | davidwoody 49 | delianides 50 | greenkeeperio-bot 51 | hammadj 52 | matt debergalis 53 | Vladimir Guguiev 54 | Edvin Eriksson 55 | Agustin Polo 56 | Chris Metz 57 | jon wong 58 | jon wong 59 | Zlatko Fedor 60 | Hagai Cohen 61 | Matthieu Achard 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request, 3 | createNetworkInterface, 4 | NetworkInterface, 5 | HTTPFetchNetworkInterface, 6 | } from './transport/networkInterface'; 7 | 8 | import { 9 | createBatchingNetworkInterface, 10 | } from './transport/batchedNetworkInterface'; 11 | 12 | import { 13 | print, 14 | } from 'graphql-tag/printer'; 15 | 16 | import { 17 | createApolloStore, 18 | ApolloStore, 19 | createApolloReducer, 20 | } from './store'; 21 | 22 | import { 23 | ObservableQuery, 24 | } from './core/ObservableQuery'; 25 | 26 | import { 27 | Subscription, 28 | } from './util/Observable'; 29 | 30 | import { 31 | WatchQueryOptions, 32 | MutationOptions, 33 | SubscriptionOptions, 34 | } from './core/watchQueryOptions'; 35 | 36 | import { 37 | readQueryFromStore, 38 | } from './data/readFromStore'; 39 | 40 | import { 41 | writeQueryToStore, 42 | } from './data/writeToStore'; 43 | 44 | import { 45 | MutationQueryReducersMap, 46 | } from './data/mutationResults'; 47 | 48 | import { 49 | getQueryDefinition, 50 | getFragmentDefinitions, 51 | FragmentMap, 52 | createFragmentMap, 53 | } from './queries/getFromAST'; 54 | 55 | import { 56 | NetworkStatus, 57 | } from './queries/networkStatus'; 58 | 59 | import { 60 | ApolloError, 61 | } from './errors/ApolloError'; 62 | 63 | import ApolloClient from './ApolloClient'; 64 | 65 | import { 66 | ApolloQueryResult, 67 | } from './core/types'; 68 | 69 | import { 70 | toIdValue, 71 | } from './data/storeUtils'; 72 | 73 | // We expose the print method from GraphQL so that people that implement 74 | // custom network interfaces can turn query ASTs into query strings as needed. 75 | export { 76 | createNetworkInterface, 77 | createBatchingNetworkInterface, 78 | createApolloStore, 79 | createApolloReducer, 80 | readQueryFromStore, 81 | writeQueryToStore, 82 | print as printAST, 83 | createFragmentMap, 84 | NetworkStatus, 85 | ApolloError, 86 | 87 | getQueryDefinition, 88 | getFragmentDefinitions, 89 | FragmentMap, 90 | 91 | Request, 92 | 93 | ApolloQueryResult, 94 | 95 | toIdValue, 96 | 97 | // internal type definitions for export 98 | NetworkInterface, 99 | HTTPFetchNetworkInterface, 100 | WatchQueryOptions, 101 | MutationOptions, 102 | ObservableQuery, 103 | MutationQueryReducersMap, 104 | Subscription, 105 | SubscriptionOptions, 106 | ApolloStore, 107 | ApolloClient 108 | }; 109 | 110 | export default ApolloClient; 111 | -------------------------------------------------------------------------------- /src/errors/ApolloError.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | // XXX some duck typing here because for some reason new ApolloError is not instanceof ApolloError 4 | export function isApolloError(err: Error): err is ApolloError { 5 | return err.hasOwnProperty('graphQLErrors'); 6 | } 7 | 8 | // Sets the error message on this error according to the 9 | // the GraphQL and network errors that are present. 10 | // If the error message has already been set through the 11 | // constructor or otherwise, this function is a nop. 12 | const generateErrorMessage = (err: ApolloError) => { 13 | 14 | 15 | let message = ''; 16 | // If we have GraphQL errors present, add that to the error message. 17 | if (Array.isArray(err.graphQLErrors) && err.graphQLErrors.length !== 0) { 18 | err.graphQLErrors.forEach((graphQLError: GraphQLError) => { 19 | const errorMessage = graphQLError ? graphQLError.message : 'Error message not found.'; 20 | message += `GraphQL error: ${errorMessage}\n`; 21 | }); 22 | } 23 | 24 | if (err.networkError) { 25 | message += 'Network error: ' + err.networkError.message + '\n'; 26 | } 27 | 28 | // strip newline from the end of the message 29 | message = message.replace(/\n$/, ''); 30 | return message; 31 | }; 32 | 33 | export class ApolloError extends Error { 34 | public message: string; 35 | public graphQLErrors: GraphQLError[]; 36 | public networkError: Error | null; 37 | 38 | // An object that can be used to provide some additional information 39 | // about an error, e.g. specifying the type of error this is. Used 40 | // internally within Apollo Client. 41 | public extraInfo: any; 42 | 43 | // Constructs an instance of ApolloError given a GraphQLError 44 | // or a network error. Note that one of these has to be a valid 45 | // value or the constructed error will be meaningless. 46 | constructor({ 47 | graphQLErrors, 48 | networkError, 49 | errorMessage, 50 | extraInfo, 51 | }: { 52 | graphQLErrors?: GraphQLError[], 53 | networkError?: Error | null, 54 | errorMessage?: string, 55 | extraInfo?: any, 56 | }) { 57 | super(errorMessage); 58 | this.graphQLErrors = graphQLErrors || []; 59 | this.networkError = networkError || null; 60 | 61 | // set up the stack trace 62 | this.stack = new Error().stack; 63 | 64 | if (!errorMessage) { 65 | this.message = generateErrorMessage(this); 66 | } else { 67 | this.message = errorMessage; 68 | } 69 | 70 | this.extraInfo = extraInfo; 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /designs/errors.md: -------------------------------------------------------------------------------- 1 | # Error handling - design proposal 2 | 3 | ## Motivation 4 | ApolloClient currently does not deal with errors very gracefully. When the server returns any error, Apollo Client will discard the result and call `observer.error`. With the recent addition of error paths in the standard `formatError` function of [graphql-js](https://github.com/graphql/graphql-js/pull/561) and the [proposal to add it to the spec](https://github.com/facebook/graphql/pull/230), we have an opportunity to improve error handling and write partial results to the store when the server provides one. 5 | 6 | ## Proposed feature / solution 7 | As far as Apollo Client is concerned, errors can be roughly divided into two categories: 8 | 9 | 1. Errors that make the entire result unusable 10 | 2. Errors that make only part of the result unusable 11 | 12 | An example for the first kind of error is when the server cannot be reached, or the server does not return valid JSON. An example for the second kind of error is when the server returned a partial result and a GraphQL error because it could not reach a backend service. 13 | 14 | For errors of the first category, the server does not return any data that can be displayed to the user, only errors, so Apollo Client should call `observer.error` and not write data to the store. 15 | 16 | For errors of the second category, the server returns data *and* errors, so there is data that can be displayed to the user and Apollo Client should call `observer.error` with `{ data, errors }`, and write as much of the result to the store as possible. Any `null` fields in `result.data` should not be written to the store if there is an error with a path corresponding to that field. 17 | 18 | Note: We call `observer.error` with the partial result instead of `observer.next` in order to make error handling stay as close as possible to the current behavior. This is definitely debatable because calling `observer.error` also means the observable will stop at this point and needs to be restarted by the user if more responses are expected (as would be the case with a polling query). If we called `observer.next` instead, then the user could deal with transient GraphQL errors in a more "voluntary" way. 19 | 20 | Because initially not all servers will have support for error paths, the current behavior (discarding all data) will be used when errors without path are encountered in the result. 21 | 22 | ## Implementation steps / changes needed 23 | 1. Call `observer.error` and discard the result if not all errors have an error path 24 | 2. Write result to store if at least partial data is available. Ignore fields if there's an error with a path to that field. 25 | 3. Call `observer.error` with data and errors if there is at least a partial result. 26 | 27 | ## Changes to the external API 28 | * ApolloClient will have a new configuration option to keep using the current error behavior. Initially this could be on by default to provide a non-breaking change & smooth transition. 29 | * `observer.error` will now receive data as well if is a partial result 30 | * Partial results are now written to the store in the presence of GraphQL errors. 31 | 32 | -------------------------------------------------------------------------------- /test/mocks/mockFetch.ts: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | 3 | // This is an implementation of a mocked window.fetch implementation similar in 4 | // structure to the MockedNetworkInterface. 5 | 6 | export interface MockedIResponse { 7 | ok: boolean; 8 | status: number; 9 | statusText?: string; 10 | json(): Promise; 11 | } 12 | 13 | export interface MockedFetchResponse { 14 | url: string; 15 | opts: RequestInit; 16 | result: MockedIResponse; 17 | delay?: number; 18 | } 19 | 20 | export function createMockedIResponse(result: Object, options?: any): MockedIResponse { 21 | const status = options && options.status || 200; 22 | const statusText = options && options.statusText || undefined; 23 | 24 | return { 25 | ok: status === 200, 26 | status, 27 | statusText, 28 | json() { 29 | return Promise.resolve(result); 30 | }, 31 | }; 32 | } 33 | 34 | export class MockFetch { 35 | private mockedResponsesByKey: { [key: string]: MockedFetchResponse[] }; 36 | 37 | constructor(...mockedResponses: MockedFetchResponse[]) { 38 | this.mockedResponsesByKey = {}; 39 | 40 | mockedResponses.forEach((mockedResponse) => { 41 | this.addMockedResponse(mockedResponse); 42 | }); 43 | } 44 | 45 | public addMockedResponse(mockedResponse: MockedFetchResponse) { 46 | const key = this.fetchParamsToKey(mockedResponse.url, mockedResponse.opts); 47 | let mockedResponses = this.mockedResponsesByKey[key]; 48 | 49 | if (!mockedResponses) { 50 | mockedResponses = []; 51 | this.mockedResponsesByKey[key] = mockedResponses; 52 | } 53 | 54 | mockedResponses.push(mockedResponse); 55 | } 56 | 57 | public fetch(url: string, opts: RequestInit) { 58 | const key = this.fetchParamsToKey(url, opts); 59 | const responses = this.mockedResponsesByKey[key]; 60 | if (!responses || responses.length === 0) { 61 | throw new Error(`No more mocked fetch responses for the params ${url} and ${opts}`); 62 | } 63 | 64 | const { result, delay } = responses.shift()!; 65 | 66 | if (!result) { 67 | throw new Error(`Mocked fetch response should contain a result.`); 68 | } 69 | 70 | return new Promise((resolve, reject) => { 71 | setTimeout(() => { 72 | resolve(result); 73 | }, delay ? delay : 0); 74 | }); 75 | } 76 | 77 | public fetchParamsToKey(url: string, opts: RequestInit): string { 78 | return JSON.stringify({ 79 | url, 80 | opts: sortByKey(opts), 81 | }); 82 | } 83 | 84 | // Returns a "fetch" function equivalent that mocks the given responses. 85 | // The function by returned by this should be tacked onto the global scope 86 | // inorder to test functions that use "fetch". 87 | public getFetch() { 88 | return this.fetch.bind(this); 89 | } 90 | } 91 | 92 | function sortByKey(obj: any): Object { 93 | return Object.keys(obj).sort().reduce( 94 | (ret: any, key: string): Object => ( 95 | Object.assign({ 96 | [key]: Object.prototype.toString.call(obj[key]).slice(8, -1) === 'Object' 97 | ? sortByKey(obj[key]) 98 | : obj[key], 99 | }, ret) 100 | ), 101 | {}, 102 | ); 103 | } 104 | 105 | export function createMockFetch(...mockedResponses: MockedFetchResponse[]) { 106 | return new MockFetch(...mockedResponses).getFetch(); 107 | } 108 | -------------------------------------------------------------------------------- /test/util/observableToPromise.ts: -------------------------------------------------------------------------------- 1 | import { ObservableQuery } from '../../src/core/ObservableQuery'; 2 | import { ApolloQueryResult } from '../../src/core/types'; 3 | import { Subscription } from '../../src/util/Observable'; 4 | 5 | /** 6 | * 7 | * @param observable the observable query to subscribe to 8 | * @param shouldResolve should we resolve after seeing all our callbacks [default: true] 9 | * (use this if you are racing the promise against another) 10 | * @param wait how long to wait after seeing desired callbacks before resolving 11 | * [default: -1 => don't wait] 12 | * @param errorCallbacks an expected set of errors 13 | */ 14 | export type Options = { 15 | observable: ObservableQuery, 16 | shouldResolve?: boolean, 17 | wait?: number, 18 | errorCallbacks?: ((error: Error) => any)[], 19 | }; 20 | 21 | export type ResultCallback = ((result: ApolloQueryResult) => any); 22 | 23 | // Take an observable and N callbacks, and observe the observable, 24 | // ensuring it is called exactly N times, resolving once it has done so. 25 | // Optionally takes a timeout, which it will wait X ms after the Nth callback 26 | // to ensure it is not called again. 27 | export function observableToPromiseAndSubscription({ 28 | observable, 29 | shouldResolve = true, 30 | wait = -1, 31 | errorCallbacks = [], 32 | }: Options, 33 | ...cbs: ResultCallback[], 34 | ): { promise: Promise, subscription: Subscription } { 35 | 36 | let subscription: Subscription = null as never; 37 | const promise = new Promise((resolve, reject) => { 38 | let errorIndex = 0; 39 | let cbIndex = 0; 40 | const results: any[] = []; 41 | 42 | const tryToResolve = () => { 43 | if (!shouldResolve) { 44 | return; 45 | } 46 | 47 | const done = () => { 48 | subscription.unsubscribe(); 49 | // XXX: we could pass a few other things out here? 50 | resolve(results); 51 | }; 52 | 53 | if (cbIndex === cbs.length && errorIndex === errorCallbacks.length) { 54 | if (wait === -1) { 55 | done(); 56 | } else { 57 | setTimeout(done, wait); 58 | } 59 | } 60 | }; 61 | 62 | subscription = observable.subscribe({ 63 | next(result) { 64 | const cb = cbs[cbIndex++]; 65 | if (cb) { 66 | try { 67 | results.push(cb(result)); 68 | } catch (e) { 69 | return reject(e); 70 | } 71 | tryToResolve(); 72 | } else { 73 | reject(new Error(`Observable called more than ${cbs.length} times`)); 74 | } 75 | }, 76 | error(error) { 77 | const errorCb = errorCallbacks[errorIndex++]; 78 | if (errorCb) { 79 | try { 80 | // XXX: should we collect these results too? 81 | errorCb(error); 82 | } catch (e) { 83 | return reject(e); 84 | } 85 | tryToResolve(); 86 | } else { 87 | reject(error); 88 | } 89 | }, 90 | }); 91 | }); 92 | 93 | return { 94 | promise, 95 | subscription, 96 | }; 97 | }; 98 | 99 | export default function( 100 | options: Options, 101 | ...cbs: ResultCallback[], 102 | ): Promise { 103 | return observableToPromiseAndSubscription(options, ...cbs).promise; 104 | } 105 | -------------------------------------------------------------------------------- /test/errors.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { ApolloError } from '../src/errors/ApolloError'; 3 | 4 | import { createMockedIResponse } from './mocks/mockFetch'; 5 | 6 | describe('ApolloError', () => { 7 | it('should construct itself correctly', () => { 8 | const graphQLErrors = [ 9 | new Error('Something went wrong with GraphQL'), 10 | new Error('Something else went wrong with GraphQL'), 11 | ]; 12 | const networkError = new Error('Network error'); 13 | const errorMessage = 'this is an error message'; 14 | const apolloError = new ApolloError({ 15 | graphQLErrors: graphQLErrors, 16 | networkError: networkError, 17 | errorMessage: errorMessage, 18 | }); 19 | assert.equal(apolloError.graphQLErrors, graphQLErrors); 20 | assert.equal(apolloError.networkError, networkError); 21 | assert.equal(apolloError.message, errorMessage); 22 | }); 23 | 24 | it('should add a network error to the message', () => { 25 | const networkError = new Error('this is an error message'); 26 | const apolloError = new ApolloError({ 27 | networkError, 28 | }); 29 | assert.include(apolloError.message, 'Network error: '); 30 | assert.include(apolloError.message, 'this is an error message'); 31 | assert.equal(apolloError.message.split('\n').length, 1); 32 | }); 33 | 34 | it('should add a graphql error to the message', () => { 35 | const graphQLErrors = [ new Error('this is an error message') ]; 36 | const apolloError = new ApolloError({ 37 | graphQLErrors, 38 | }); 39 | assert.include(apolloError.message, 'GraphQL error: '); 40 | assert.include(apolloError.message, 'this is an error message'); 41 | assert.equal(apolloError.message.split('\n').length, 1); 42 | }); 43 | 44 | it('should add multiple graphql errors to the message', () => { 45 | const graphQLErrors = [ new Error('this is new'), 46 | new Error('this is old'), 47 | ]; 48 | const apolloError = new ApolloError({ 49 | graphQLErrors, 50 | }); 51 | const messages = apolloError.message.split('\n'); 52 | assert.equal(messages.length, 2); 53 | assert.include(messages[0], 'GraphQL error'); 54 | assert.include(messages[0], 'this is new'); 55 | assert.include(messages[1], 'GraphQL error'); 56 | assert.include(messages[1], 'this is old'); 57 | }); 58 | 59 | it('should add both network and graphql errors to the message', () => { 60 | const graphQLErrors = [ new Error('graphql error message') ]; 61 | const networkError = new Error('network error message'); 62 | const apolloError = new ApolloError({ 63 | graphQLErrors, 64 | networkError, 65 | }); 66 | const messages = apolloError.message.split('\n'); 67 | assert.equal(messages.length, 2); 68 | assert.include(messages[0], 'GraphQL error'); 69 | assert.include(messages[0], 'graphql error message'); 70 | assert.include(messages[1], 'Network error'); 71 | assert.include(messages[1], 'network error message'); 72 | }); 73 | 74 | it('should contain a stack trace', () => { 75 | const graphQLErrors = [ new Error('graphql error message') ]; 76 | const networkError = new Error('network error message'); 77 | const apolloError = new ApolloError({ 78 | graphQLErrors, 79 | networkError, 80 | }); 81 | assert(apolloError.stack, 'Does not contain a stack trace.'); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": [ 4 | false, 5 | "parameters", 6 | "arguments", 7 | "statements" 8 | ], 9 | "ban": false, 10 | "class-name": true, 11 | "curly": true, 12 | "eofline": true, 13 | "forin": true, 14 | "indent": [ 15 | true, 16 | "spaces" 17 | ], 18 | "interface-name": false, 19 | "jsdoc-format": true, 20 | "label-position": true, 21 | "max-line-length": [ 22 | true, 23 | 140 24 | ], 25 | "member-access": true, 26 | "member-ordering": [ 27 | true, 28 | "public-before-private", 29 | "static-before-instance", 30 | "variables-before-functions" 31 | ], 32 | "no-any": false, 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-conditional-assignment": true, 36 | "no-consecutive-blank-lines": false, 37 | "no-console": [ 38 | true, 39 | "log", 40 | "debug", 41 | "info", 42 | "time", 43 | "timeEnd", 44 | "trace" 45 | ], 46 | "no-construct": true, 47 | "no-debugger": true, 48 | "no-duplicate-variable": true, 49 | "no-empty": true, 50 | "no-eval": true, 51 | "no-inferrable-types": false, 52 | "no-internal-module": true, 53 | "no-null-keyword": false, 54 | "no-require-imports": false, 55 | "no-shadowed-variable": true, 56 | "no-switch-case-fall-through": true, 57 | "no-trailing-whitespace": true, 58 | "no-unused-expression": true, 59 | "no-use-before-declare": true, 60 | "no-var-keyword": true, 61 | "no-var-requires": true, 62 | "object-literal-sort-keys": false, 63 | "one-line": [ 64 | true, 65 | "check-open-brace", 66 | "check-catch", 67 | "check-else", 68 | "check-finally", 69 | "check-whitespace" 70 | ], 71 | "quotemark": [ 72 | true, 73 | "single", 74 | "avoid-escape" 75 | ], 76 | "radix": true, 77 | "semicolon": [ 78 | true, 79 | "always" 80 | ], 81 | "switch-default": true, 82 | "trailing-comma": [ 83 | true, 84 | { 85 | "multiline": "always", 86 | "singleline": "never" 87 | } 88 | ], 89 | "triple-equals": [ 90 | true, 91 | "allow-null-check" 92 | ], 93 | "typedef": [ 94 | false, 95 | "call-signature", 96 | "parameter", 97 | "arrow-parameter", 98 | "property-declaration", 99 | "variable-declaration", 100 | "member-variable-declaration" 101 | ], 102 | "typedef-whitespace": [ 103 | true, 104 | { 105 | "call-signature": "nospace", 106 | "index-signature": "nospace", 107 | "parameter": "nospace", 108 | "property-declaration": "nospace", 109 | "variable-declaration": "nospace" 110 | }, 111 | { 112 | "call-signature": "space", 113 | "index-signature": "space", 114 | "parameter": "space", 115 | "property-declaration": "space", 116 | "variable-declaration": "space" 117 | } 118 | ], 119 | "variable-name": [ 120 | true, 121 | "check-format", 122 | "allow-leading-underscore", 123 | "ban-keywords" 124 | ], 125 | "whitespace": [ 126 | true, 127 | "check-branch", 128 | "check-decl", 129 | "check-operator", 130 | "check-separator", 131 | "check-type" 132 | ] 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/core/watchQueryOptions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | FragmentDefinitionNode, 4 | } from 'graphql'; 5 | 6 | import { 7 | OperationResultReducer, 8 | MutationQueryReducersMap, 9 | } from '../data/mutationResults'; 10 | 11 | /** 12 | * We can change these options to an ObservableQuery 13 | */ 14 | export interface ModifiableWatchQueryOptions { 15 | /** 16 | * A map going from variable name to variable value, where the variables are used 17 | * within the GraphQL query. 18 | */ 19 | variables?: { [key: string]: any }; 20 | /** 21 | * Specifies whether the client should diff the query against the cache and only 22 | * fetch the portions of it that aren't already available (it does this when forceFetch is 23 | * false) or it should just fetch the entire query from the server and update the cache 24 | * accordingly (it does this when forceFetch is true). 25 | */ 26 | forceFetch?: boolean; 27 | /** 28 | * This specifies whether {@link Observer} instances for this query 29 | * should be updated with partial results. For example, when a portion of a query can be resolved 30 | * entirely from the cache, that result will be delivered to the Observer first and the 31 | * rest of the result (as provided by the server) will be returned later. 32 | */ 33 | returnPartialData?: boolean; 34 | /** 35 | * If this is set to true, the query is resolved *only* within information 36 | * available in the cache (i.e. we never hit the server). If a particular field is not available 37 | * in the cache, it will not be available in the result. 38 | */ 39 | noFetch?: boolean; 40 | /** 41 | * The time interval (in milliseconds) on which this query should be 42 | * refetched from the server. 43 | */ 44 | pollInterval?: number; 45 | 46 | /** 47 | * Whether or not updates to the network status should trigger next on the observer of this query 48 | */ 49 | notifyOnNetworkStatusChange?: boolean; 50 | 51 | /** 52 | * A redux reducer that lets you update the result of this query in the store based on any action (including mutation and query results) 53 | */ 54 | reducer?: OperationResultReducer; 55 | } 56 | 57 | /** 58 | * The argument to a query 59 | */ 60 | export interface WatchQueryOptions extends ModifiableWatchQueryOptions { 61 | /** 62 | * A GraphQL document that consists of a single query to be sent down to the 63 | * server. 64 | */ 65 | // TODO REFACTOR: rename this to document. Didn't do it yet because it's in a lot of tests. 66 | query: DocumentNode; 67 | 68 | /** 69 | * Arbitrary metadata stored in Redux with this query. Designed for debugging, 70 | * developer tools, etc. 71 | */ 72 | metadata?: any; 73 | } 74 | 75 | export interface FetchMoreQueryOptions { 76 | query?: DocumentNode; 77 | variables?: { [key: string]: any }; 78 | } 79 | 80 | export type SubscribeToMoreOptions = { 81 | document: DocumentNode; 82 | variables?: { [key: string]: any }; 83 | updateQuery: (previousQueryResult: Object, options: { 84 | subscriptionData: { data: any }, 85 | variables: { [key: string]: any }, 86 | }) => Object; 87 | onError?: (error: Error) => void; 88 | }; 89 | 90 | export interface SubscriptionOptions { 91 | query: DocumentNode; 92 | variables?: { [key: string]: any }; 93 | }; 94 | 95 | export interface MutationOptions { 96 | mutation: DocumentNode; 97 | variables?: Object; 98 | optimisticResponse?: Object; 99 | updateQueries?: MutationQueryReducersMap; 100 | refetchQueries?: string[]; 101 | } 102 | -------------------------------------------------------------------------------- /test/deduplicator.ts: -------------------------------------------------------------------------------- 1 | import mockNetworkInterface from './mocks/mockNetworkInterface'; 2 | import gql from 'graphql-tag'; 3 | import { assert } from 'chai'; 4 | import ApolloClient, { toIdValue } from '../src'; 5 | import { Request, NetworkInterface } from '../src/transport/networkInterface'; 6 | import { Deduplicator } from '../src/transport/Deduplicator'; 7 | import { getOperationName } from '../src/queries/getFromAST'; 8 | import { DocumentNode } from 'graphql'; 9 | import { NetworkStatus } from '../src/queries/networkStatus'; 10 | 11 | describe('query deduplication', () => { 12 | it(`does not affect different queries`, () => { 13 | 14 | const document: DocumentNode = gql`query test1($x: String){ 15 | test(x: $x) 16 | }`; 17 | const variables1 = { x: 'Hello World' }; 18 | const variables2 = { x: 'Goodbye World' }; 19 | 20 | const request1: Request = { 21 | query: document, 22 | variables: variables1, 23 | operationName: getOperationName(document), 24 | }; 25 | 26 | const request2: Request = { 27 | query: document, 28 | variables: variables2, 29 | operationName: getOperationName(document), 30 | }; 31 | 32 | let called = 0; 33 | const deduper = new Deduplicator({ 34 | query: () => { 35 | called += 1; 36 | return new Promise((resolve, reject) => { 37 | setTimeout(resolve, 5); 38 | }); 39 | }, 40 | } as any ); 41 | 42 | deduper.query(request1); 43 | deduper.query(request2); 44 | assert.equal(called, 2); 45 | 46 | }); 47 | 48 | it(`deduplicates identical queries`, () => { 49 | 50 | const document: DocumentNode = gql`query test1($x: String){ 51 | test(x: $x) 52 | }`; 53 | const variables1 = { x: 'Hello World' }; 54 | const variables2 = { x: 'Hello World' }; 55 | 56 | const request1: Request = { 57 | query: document, 58 | variables: variables1, 59 | operationName: getOperationName(document), 60 | }; 61 | 62 | const request2: Request = { 63 | query: document, 64 | variables: variables2, 65 | operationName: getOperationName(document), 66 | }; 67 | 68 | let called = 0; 69 | const deduper = new Deduplicator({ 70 | query: () => { 71 | called += 1; 72 | return new Promise((resolve, reject) => { 73 | setTimeout(resolve, 5); 74 | }); 75 | }, 76 | } as any ); 77 | 78 | deduper.query(request1); 79 | deduper.query(request2); 80 | assert.equal(called, 1); 81 | 82 | }); 83 | 84 | it(`can bypass deduplication if desired`, () => { 85 | 86 | const document: DocumentNode = gql`query test1($x: String){ 87 | test(x: $x) 88 | }`; 89 | const variables1 = { x: 'Hello World' }; 90 | const variables2 = { x: 'Hello World' }; 91 | 92 | const request1: Request = { 93 | query: document, 94 | variables: variables1, 95 | operationName: getOperationName(document), 96 | }; 97 | 98 | const request2: Request = { 99 | query: document, 100 | variables: variables2, 101 | operationName: getOperationName(document), 102 | }; 103 | 104 | let called = 0; 105 | const deduper = new Deduplicator({ 106 | query: () => { 107 | called += 1; 108 | return new Promise((resolve, reject) => { 109 | setTimeout(resolve, 5); 110 | }); 111 | }, 112 | } as any ); 113 | 114 | deduper.query(request1, false); 115 | deduper.query(request2, false); 116 | assert.equal(called, 2); 117 | 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-client", 3 | "version": "0.8.3", 4 | "description": "A simple yet functional GraphQL client.", 5 | "main": "./lib/apollo.umd.js", 6 | "module": "./lib/src/index.js", 7 | "jsnext:main": "./lib/src/index.js", 8 | "typings": "./lib/src/index.d.ts", 9 | "scripts": { 10 | "dev": "./scripts/dev.sh", 11 | "deploy": "./scripts/deploy.sh", 12 | "pretest": "npm run compile:test", 13 | "test": "npm run testonly --", 14 | "benchmark": "npm run compile:benchmark && node --stack-size=20000 lib/benchmark/index.js", 15 | "posttest": "npm run lint", 16 | "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=27", 17 | "compile": "tsc", 18 | "compile:benchmark": "tsc -p tsconfig.test.json", 19 | "compile:test": "tsc -p tsconfig.test.json", 20 | "compile:browser": "rm -rf ./dist && mkdir ./dist && browserify ./lib/apollo.umd.js -o=./dist/index.js && npm run minify:browser", 21 | "minify:browser": "uglifyjs --compress --mangle --screw-ie8 -o=./dist/index.min.js -- ./dist/index.js", 22 | "watch": "tsc -w", 23 | "watch:test": "tsc -p tsconfig.test.json -w", 24 | "bundle": "rollup -c", 25 | "postcompile": "npm run bundle", 26 | "prepublish": "npm run compile", 27 | "lint": "grunt tslint", 28 | "coverage": "istanbul cover ./node_modules/mocha/bin/_mocha -- --reporter dot --full-trace lib/test/tests.js", 29 | "postcoverage": "remap-istanbul --input coverage/coverage.json --type lcovonly --output coverage/lcov.info", 30 | "testonly": "mocha --reporter spec --full-trace lib/test/tests.js", 31 | "preanalyze": "npm run compile", 32 | "analyze": "webpack -p --config analyze/webpack.config.js" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "apollostack/apollo-client" 37 | }, 38 | "keywords": [ 39 | "ecmascript", 40 | "es2015", 41 | "jsnext", 42 | "javascript", 43 | "relay", 44 | "npm", 45 | "react" 46 | ], 47 | "author": "Sashko Stubailo ", 48 | "license": "MIT", 49 | "dependencies": { 50 | "graphql-anywhere": "^2.1.0", 51 | "graphql-tag": "^1.1.1", 52 | "redux": "^3.4.0", 53 | "symbol-observable": "^1.0.2", 54 | "whatwg-fetch": "^2.0.0" 55 | }, 56 | "devDependencies": { 57 | "@types/benchmark": "^1.0.30", 58 | "@types/chai": "^3.4.32", 59 | "@types/chai-as-promised": "0.0.28", 60 | "@types/lodash": "4.14.42", 61 | "@types/mocha": "^2.2.31", 62 | "@types/node": "^6.0.38", 63 | "@types/promises-a-plus": "0.0.26", 64 | "@types/sinon": "^1.16.29", 65 | "browserify": "^14.0.0", 66 | "benchmark": "^2.1.3", 67 | "chai": "^3.5.0", 68 | "chai-as-promised": "^6.0.0", 69 | "colors": "^1.1.2", 70 | "concurrently": "^3.1.0", 71 | "es6-promise": "^4.0.4", 72 | "fetch-mock": "^5.5.0", 73 | "grunt": "1.0.1", 74 | "grunt-tslint": "4.0.0", 75 | "gzip-size": "^3.0.0", 76 | "isomorphic-fetch": "^2.2.1", 77 | "istanbul": "^0.4.5", 78 | "lodash": "^4.17.1", 79 | "minimist": "^1.2.0", 80 | "mocha": "^3.0.0", 81 | "pretty-bytes": "^4.0.0", 82 | "remap-istanbul": "0.8.0", 83 | "request": "^2.75.0", 84 | "rollup": "^0.41.3", 85 | "rxjs": "^5.0.0-beta.11", 86 | "sinon": "^1.17.4", 87 | "source-map-support": "^0.4.0", 88 | "tslint": "4.4.2", 89 | "typescript": "2.1.5", 90 | "uglify-js": "^2.6.2", 91 | "webpack": "^2.1.0-beta.28", 92 | "webpack-bundle-analyzer": "^2.1.1" 93 | }, 94 | "optionalDependencies": { 95 | "@types/async": "^2.0.31", 96 | "@types/isomorphic-fetch": "0.0.30", 97 | "@types/graphql": "^0.8.0" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/transport/batching.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request, 3 | } from './networkInterface'; 4 | 5 | import { 6 | ExecutionResult, 7 | } from 'graphql'; 8 | 9 | export interface QueryFetchRequest { 10 | request: Request; 11 | 12 | // promise is created when the query fetch request is 13 | // added to the queue and is resolved once the result is back 14 | // from the server. 15 | promise?: Promise; 16 | resolve?: (result: ExecutionResult) => void; 17 | reject?: (error: Error) => void; 18 | }; 19 | 20 | // QueryBatcher operates on a queue of QueryFetchRequests. It polls and checks this queue 21 | // for new fetch requests. If there are multiple requests in the queue at a time, it will batch 22 | // them together into one query. 23 | export class QueryBatcher { 24 | // Queue on which the QueryBatcher will operate on a per-tick basis. 25 | public queuedRequests: QueryFetchRequest[] = []; 26 | 27 | private pollInterval: Number; 28 | private pollTimer: any; 29 | 30 | //This function is called to the queries in the queue to the server. 31 | private batchFetchFunction: (request: Request[]) => Promise; 32 | 33 | constructor({ 34 | batchFetchFunction, 35 | }: { 36 | batchFetchFunction: (request: Request[]) => Promise, 37 | }) { 38 | this.queuedRequests = []; 39 | this.batchFetchFunction = batchFetchFunction; 40 | } 41 | 42 | public enqueueRequest(request: Request): Promise { 43 | const fetchRequest: QueryFetchRequest = { 44 | request, 45 | }; 46 | this.queuedRequests.push(fetchRequest); 47 | fetchRequest.promise = new Promise((resolve, reject) => { 48 | fetchRequest.resolve = resolve; 49 | fetchRequest.reject = reject; 50 | }); 51 | 52 | return fetchRequest.promise; 53 | } 54 | 55 | // Consumes the queue. Called on a polling interval. 56 | // Returns a list of promises (one for each query). 57 | public consumeQueue(): (Promise | undefined)[] | undefined { 58 | if (this.queuedRequests.length < 1) { 59 | return undefined; 60 | } 61 | 62 | const requests: Request[] = this.queuedRequests.map((queuedRequest) => { 63 | return { 64 | query: queuedRequest.request.query, 65 | variables: queuedRequest.request.variables, 66 | operationName: queuedRequest.request.operationName, 67 | }; 68 | }); 69 | 70 | const promises: (Promise | undefined)[] = []; 71 | const resolvers: any[] = []; 72 | const rejecters: any[] = []; 73 | this.queuedRequests.forEach((fetchRequest, index) => { 74 | promises.push(fetchRequest.promise); 75 | resolvers.push(fetchRequest.resolve); 76 | rejecters.push(fetchRequest.reject); 77 | }); 78 | 79 | this.queuedRequests = []; 80 | const batchedPromise = this.batchFetchFunction(requests); 81 | 82 | batchedPromise.then((results) => { 83 | results.forEach((result, index) => { 84 | resolvers[index](result); 85 | }); 86 | }).catch((error) => { 87 | rejecters.forEach((rejecter, index) => { 88 | rejecters[index](error); 89 | }); 90 | }); 91 | return promises; 92 | } 93 | 94 | // TODO instead of start and stop, just set a timeout when a request comes in, 95 | // and batch up everything in that interval. If no requests come in, don't batch. 96 | public start(pollInterval: Number) { 97 | if (this.pollTimer) { 98 | clearInterval(this.pollTimer); 99 | } 100 | this.pollInterval = pollInterval; 101 | this.pollTimer = setInterval(() => { 102 | this.consumeQueue(); 103 | }, this.pollInterval); 104 | } 105 | 106 | public stop() { 107 | if (this.pollTimer) { 108 | clearInterval(this.pollTimer); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Apollo Client Roadmap to version 1.0 2 | expected by January 2017 3 | 4 | This roadmap serves as a rough guide of features and changes we hope to accomplish until Apollo Client 1.0. There will almost certainly be things in version 1.0 that are not in this list, and there may be the odd thing on this list that doesn't make it into version 1.0. 5 | 6 | Since version 0.5 Apollo Client is already being used in production by many people, including Meteor Development Group. Version 1.0 will mark the point where we think we've reached a stable external API that will not see any breaking changes until version 2.0. 7 | 8 | As a reminder, here are the goals of Apollo Client as stated in the readme file: 9 | 10 | 1. **Incrementally adoptable**, so that you can drop it into an existing JavaScript app and start using GraphQL for just part of your UI. 11 | 2. **Universally compatible**, so that Apollo works with any build setup, any GraphQL server, and any GraphQL schema. 12 | 3. **Simple to get started with**, you can start loading data right away and learn about advanced features later. 13 | 4. **Inspectable and understandable**, so that you can have great developer tools to understand exactly what is happening in your app. 14 | 5. **Built for interactive apps**, so your users can make changes and see them reflected in the UI immediately. 15 | 6. **Small and flexible**, so you don't get stuff you don't need. The core is under 40kb compressed. 16 | 7. **Community driven**, Apollo is driven by the community and serves a variety of use cases. Everything is planned and developed in the open. 17 | 18 | By and large Apollo Client already does a very good job in all these dimensions. For version 1.0 we want to put special focus to deliver top of the class developer ergonomics. That means further improvements to **ease of adoption/use**, **simplicity** and **understandability**. 19 | 20 | As stated before, the list below is not exhaustive. **Apollo Client is a community effort, so if there are features you would like to see in 1.0 that are not listed below, or would like to contribute to one of the items below, please say so by posting on the appropriate issue or opening a new one for discussion!** 21 | 22 | ## Features planned for 1.0 23 | 24 | ### Error handling: 25 | - [ ] More nuanced ways of dealing with GraphQL errors, eg. the ability to deliver partial results with errors 26 | - [ ] Useful error messages and stack traces for every error thrown by Apollo Client 27 | - [ ] Sanity checks (and useful error messages) for all input arguments to Apollo Client 28 | 29 | ### Client-side data store integration 30 | - [ ] Computed fields + custom resolvers to seamlessly integrate server and client-only data 31 | - [ ] Result reducers that can work with any action dispatched to the store 32 | - [ ] Convenience methods for interacting directly with the store (eg. get object by id) 33 | 34 | ### UI integration ergonomics 35 | - [ ] Immutable results 36 | - [ ] Deep-freezing of results in development mode 37 | - [ ] `fetchMore` network status 38 | 39 | ### Performance 40 | - [x] Query deduplication 41 | 42 | ### GraphQL features 43 | * support for custom scalars 44 | * fragment matching for unions + interface types 45 | * detect cache collisions and provide warning / fix 46 | 47 | 48 | ## Refactors planned for 1.0 49 | - [x] Simplify how polling queries work 50 | - [x] Remove fragment handling from Apollo Client (and put it in graphql-tag) 51 | - [ ] Streamline network interface and API for middlewares and afterwares 52 | - [ ] Simplify core and push view-layer integration logic to the edge 53 | - [x] Remove stopped queries from the store without breaking storeReset (#902) 54 | - [ ] Remove custom build step to move files around before publishing to npm 55 | - [x] Find low-hanging fruit to reduce bundle size (#684) 56 | 57 | 58 | ## Version 0.6 59 | - [x] Completely remove fragment logic (it's in graphql-tag now) 60 | - [ ] Refactoring of error handling 61 | -------------------------------------------------------------------------------- /src/optimistic-data/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MutationResultAction, 3 | isMutationInitAction, 4 | isMutationResultAction, 5 | isMutationErrorAction, 6 | } from '../actions'; 7 | 8 | import { 9 | data, 10 | } from '../data/store'; 11 | 12 | import { 13 | NormalizedCache, 14 | } from '../data/storeUtils'; 15 | 16 | import { 17 | QueryStore, 18 | } from '../queries/store'; 19 | 20 | import { 21 | MutationStore, 22 | } from '../mutations/store'; 23 | 24 | import { 25 | Store, 26 | ApolloReducerConfig, 27 | } from '../store'; 28 | 29 | import { assign } from '../util/assign'; 30 | 31 | // a stack of patches of new or changed documents 32 | export type OptimisticStore = { 33 | mutationId: string, 34 | data: NormalizedCache, 35 | }[]; 36 | 37 | const optimisticDefaultState: any[] = []; 38 | 39 | export function getDataWithOptimisticResults(store: Store): NormalizedCache { 40 | if (store.optimistic.length === 0) { 41 | return store.data; 42 | } 43 | const patches = store.optimistic.map(opt => opt.data); 44 | return assign({}, store.data, ...patches) as NormalizedCache; 45 | } 46 | 47 | export function optimistic( 48 | previousState = optimisticDefaultState, 49 | action: any, 50 | store: any, 51 | config: any, 52 | ): OptimisticStore { 53 | if (isMutationInitAction(action) && action.optimisticResponse) { 54 | const fakeMutationResultAction: MutationResultAction = { 55 | type: 'APOLLO_MUTATION_RESULT', 56 | result: { data: action.optimisticResponse }, 57 | document: action.mutation, 58 | operationName: action.operationName, 59 | variables: action.variables, 60 | mutationId: action.mutationId, 61 | extraReducers: action.extraReducers, 62 | updateQueries: action.updateQueries, 63 | }; 64 | 65 | const fakeStore = { 66 | ...store, 67 | optimistic: previousState, 68 | }; 69 | const optimisticData = getDataWithOptimisticResults(fakeStore); 70 | 71 | const patch = getOptimisticDataPatch( 72 | optimisticData, 73 | fakeMutationResultAction, 74 | store.queries, 75 | store.mutations, 76 | config, 77 | ); 78 | 79 | const optimisticState = { 80 | action: fakeMutationResultAction, 81 | data: patch, 82 | mutationId: action.mutationId, 83 | }; 84 | 85 | const newState = [...previousState, optimisticState]; 86 | 87 | return newState; 88 | } else if ((isMutationErrorAction(action) || isMutationResultAction(action)) 89 | && previousState.some(change => change.mutationId === action.mutationId)) { 90 | // Create a shallow copy of the data in the store. 91 | const optimisticData = assign({}, store.data); 92 | 93 | const newState = previousState 94 | // Throw away optimistic changes of that particular mutation 95 | .filter(change => change.mutationId !== action.mutationId) 96 | // Re-run all of our optimistic data actions on top of one another. 97 | .map(change => { 98 | const patch = getOptimisticDataPatch( 99 | optimisticData, 100 | change.action, 101 | store.queries, 102 | store.mutations, 103 | config, 104 | ); 105 | assign(optimisticData, patch); 106 | return { 107 | ...change, 108 | data: patch, 109 | }; 110 | }); 111 | 112 | return newState; 113 | } 114 | 115 | return previousState; 116 | } 117 | 118 | function getOptimisticDataPatch ( 119 | previousData: NormalizedCache, 120 | optimisticAction: MutationResultAction, 121 | queries: QueryStore, 122 | mutations: MutationStore, 123 | config: ApolloReducerConfig, 124 | ): any { 125 | const optimisticData = data( 126 | previousData, 127 | optimisticAction, 128 | queries, 129 | mutations, 130 | config, 131 | ); 132 | 133 | const patch: any = {}; 134 | 135 | Object.keys(optimisticData).forEach(key => { 136 | if (optimisticData[key] !== previousData[key]) { 137 | patch[key] = optimisticData[key]; 138 | } 139 | }); 140 | 141 | return patch; 142 | } 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apollo client 2 | 3 | [![npm version](https://badge.fury.io/js/apollo-client.svg)](https://badge.fury.io/js/apollo-client) 4 | [![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://www.apollostack.com/#slack) 5 | 6 | Apollo Client can be used in any JavaScript frontend where you want to use data from a GraphQL server. It's: 7 | 8 | 1. **Incrementally adoptable**, so that you can drop it into an existing JavaScript app and start using GraphQL for just part of your UI. 9 | 2. **Universally compatible**, so that Apollo works with any build setup, any GraphQL server, and any GraphQL schema. 10 | 2. **Simple to get started with**, you can start loading data right away and learn about advanced features later. 11 | 3. **Inspectable and understandable**, so that you can have great developer tools to understand exactly what is happening in your app. 12 | 4. **Built for interactive apps**, so your users can make changes and see them reflected in the UI immediately. 13 | 4. **Small and flexible**, so you don't get stuff you don't need. The core is under 25kb compressed. 14 | 5. **Community driven**, Apollo is driven by the community and serves a variety of use cases. Everything is planned and developed in the open. 15 | 16 | Get started on the [home page](http://dev.apollodata.com/), which has great examples for a variety of frameworks. 17 | 18 | ## Installation 19 | 20 | ```txt 21 | npm install apollo-client 22 | ``` 23 | 24 | To use this client in a web browser or mobile app, you'll need a build system capable of loading NPM packages on the client. Some common choices include Browserify, Webpack, and Meteor 1.3. 25 | 26 | **NEW:** Install the [Apollo Client Developer tools for Chrome](https://chrome.google.com/webstore/detail/apollo-client-developer-t/jdkknkkbebbapilgoeccciglkfbmbnfm) for a great GraphQL developer experience! 27 | 28 | ## Learn how to use Apollo Client with your favorite framework 29 | 30 | - [React](http://dev.apollodata.com/react/) 31 | - [Angular 2](http://dev.apollodata.com/angular2/) 32 | - [Vue](https://github.com/Akryum/vue-apollo) 33 | - [Ember](https://github.com/bgentry/ember-apollo-client) 34 | - [Polymer](https://github.com/aruntk/polymer-apollo) 35 | - [Meteor](http://dev.apollodata.com/core/meteor.html) 36 | - [Vanilla JS](http://dev.apollodata.com/core/) 37 | 38 | --- 39 | 40 | ## Contributing 41 | 42 | [![Build status](https://travis-ci.org/apollographql/apollo-client.svg?branch=master)](https://travis-ci.org/apollographql/apollo-client) 43 | [![Build status](https://ci.appveyor.com/api/projects/status/ajdf70delshw2ire/branch/master?svg=true)](https://ci.appveyor.com/project/stubailo/apollo-client/branch/master) 44 | [![Coverage Status](https://coveralls.io/repos/github/apollographql/apollo-client/badge.svg?branch=master)](https://coveralls.io/github/apollographql/apollo-client?branch=master) 45 | 46 | [Read the Apollo Contributor Guidelines.](CONTRIBUTING.md) 47 | 48 | Running tests locally: 49 | 50 | ``` 51 | # nvm use node 52 | npm install 53 | npm test 54 | ``` 55 | 56 | This project uses TypeScript for static typing and TSLint for linting. You can get both of these built into your editor with no configuration by opening this project in [Visual Studio Code](https://code.visualstudio.com/), an open source IDE which is available for free on all platforms. 57 | 58 | #### Useful tools 59 | 60 | Should be moved into some kind of CONTRIBUTING.md soon... 61 | 62 | - [AST explorer](https://astexplorer.net/): you can use this to see what the GraphQL query AST looks like for different queries 63 | 64 | #### Important discussions 65 | 66 | If you're getting booted up as a contributor, here are some discussions you should take a look at: 67 | 68 | 1. [Static typing and why we went with TypeScript](https://github.com/apollostack/apollo-client/issues/6) also covered in [the Medium post](https://medium.com/apollo-stack/javascript-code-quality-with-free-tools-9a6d80e29f2d#.k32z401au) 69 | 1. [Idea for pagination handling](https://github.com/apollostack/apollo-client/issues/26) 70 | 1. [Discussion about interaction with Redux and domain vs. client state](https://github.com/apollostack/apollo-client/issues/98) 71 | 1. [Long conversation about different client options, before this repo existed](https://github.com/apollostack/apollo/issues/1) 72 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | ExecutionResult, 4 | } from 'graphql'; 5 | 6 | import { 7 | MutationQueryReducer, 8 | } from './data/mutationResults'; 9 | 10 | import { 11 | ApolloReducer, 12 | } from './store'; 13 | 14 | export type QueryResultAction = { 15 | type: 'APOLLO_QUERY_RESULT'; 16 | result: ExecutionResult; 17 | queryId: string; 18 | document: DocumentNode; 19 | operationName: string; 20 | requestId: number; 21 | extraReducers?: ApolloReducer[]; 22 | }; 23 | 24 | export function isQueryResultAction(action: ApolloAction): action is QueryResultAction { 25 | return action.type === 'APOLLO_QUERY_RESULT'; 26 | } 27 | 28 | export interface QueryErrorAction { 29 | type: 'APOLLO_QUERY_ERROR'; 30 | error: Error; 31 | queryId: string; 32 | requestId: number; 33 | } 34 | 35 | export function isQueryErrorAction(action: ApolloAction): action is QueryErrorAction { 36 | return action.type === 'APOLLO_QUERY_ERROR'; 37 | } 38 | 39 | export interface QueryInitAction { 40 | type: 'APOLLO_QUERY_INIT'; 41 | queryString: string; 42 | document: DocumentNode; 43 | variables: Object; 44 | forceFetch: boolean; 45 | returnPartialData: boolean; 46 | queryId: string; 47 | requestId: number; 48 | storePreviousVariables: boolean; 49 | isRefetch: boolean; 50 | isPoll: boolean; 51 | metadata: any; 52 | } 53 | 54 | export function isQueryInitAction(action: ApolloAction): action is QueryInitAction { 55 | return action.type === 'APOLLO_QUERY_INIT'; 56 | } 57 | 58 | export interface QueryResultClientAction { 59 | type: 'APOLLO_QUERY_RESULT_CLIENT'; 60 | result: ExecutionResult; 61 | complete: boolean; 62 | queryId: string; 63 | requestId: number; 64 | } 65 | 66 | export function isQueryResultClientAction(action: ApolloAction): action is QueryResultClientAction { 67 | return action.type === 'APOLLO_QUERY_RESULT_CLIENT'; 68 | } 69 | 70 | export interface QueryStopAction { 71 | type: 'APOLLO_QUERY_STOP'; 72 | queryId: string; 73 | } 74 | 75 | export function isQueryStopAction(action: ApolloAction): action is QueryStopAction { 76 | return action.type === 'APOLLO_QUERY_STOP'; 77 | } 78 | 79 | export interface MutationInitAction { 80 | type: 'APOLLO_MUTATION_INIT'; 81 | mutationString: string; 82 | mutation: DocumentNode; 83 | variables: Object; 84 | operationName: string; 85 | mutationId: string; 86 | optimisticResponse: Object | undefined; 87 | extraReducers?: ApolloReducer[]; 88 | updateQueries?: { [queryId: string]: MutationQueryReducer }; 89 | } 90 | 91 | export function isMutationInitAction(action: ApolloAction): action is MutationInitAction { 92 | return action.type === 'APOLLO_MUTATION_INIT'; 93 | } 94 | 95 | // TODO REFACOTR: simplify all these actions by providing a generic options field to all actions. 96 | export interface MutationResultAction { 97 | type: 'APOLLO_MUTATION_RESULT'; 98 | result: ExecutionResult; 99 | document: DocumentNode; 100 | operationName: string; 101 | variables: Object; 102 | mutationId: string; 103 | extraReducers?: ApolloReducer[]; 104 | updateQueries?: { [queryId: string]: MutationQueryReducer }; 105 | } 106 | 107 | export function isMutationResultAction(action: ApolloAction): action is MutationResultAction { 108 | return action.type === 'APOLLO_MUTATION_RESULT'; 109 | } 110 | 111 | export interface MutationErrorAction { 112 | type: 'APOLLO_MUTATION_ERROR'; 113 | error: Error; 114 | mutationId: string; 115 | }; 116 | 117 | export function isMutationErrorAction(action: ApolloAction): action is MutationErrorAction { 118 | return action.type === 'APOLLO_MUTATION_ERROR'; 119 | } 120 | 121 | export interface UpdateQueryResultAction { 122 | type: 'APOLLO_UPDATE_QUERY_RESULT'; 123 | variables: any; 124 | document: DocumentNode; 125 | newResult: Object; 126 | } 127 | 128 | export function isUpdateQueryResultAction(action: ApolloAction): action is UpdateQueryResultAction { 129 | return action.type === 'APOLLO_UPDATE_QUERY_RESULT'; 130 | } 131 | 132 | export interface StoreResetAction { 133 | type: 'APOLLO_STORE_RESET'; 134 | observableQueryIds: string[]; 135 | } 136 | 137 | export function isStoreResetAction(action: ApolloAction): action is StoreResetAction { 138 | return action.type === 'APOLLO_STORE_RESET'; 139 | } 140 | 141 | export type SubscriptionResultAction = { 142 | type: 'APOLLO_SUBSCRIPTION_RESULT'; 143 | result: ExecutionResult; 144 | subscriptionId: number; 145 | variables: Object; 146 | document: DocumentNode; 147 | operationName: string; 148 | extraReducers?: ApolloReducer[]; 149 | }; 150 | 151 | export function isSubscriptionResultAction(action: ApolloAction): action is SubscriptionResultAction { 152 | return action.type === 'APOLLO_SUBSCRIPTION_RESULT'; 153 | } 154 | 155 | export type ApolloAction = 156 | QueryResultAction | 157 | QueryErrorAction | 158 | QueryInitAction | 159 | QueryResultClientAction | 160 | QueryStopAction | 161 | MutationInitAction | 162 | MutationResultAction | 163 | MutationErrorAction | 164 | UpdateQueryResultAction | 165 | StoreResetAction | 166 | SubscriptionResultAction; 167 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design principles of the Apollo Client 2 | 3 | If we are building a client-side GraphQL client and cache, we should have some goals that carve out our part of that space. These are the competitive advantages we believe this library will have over others that implement a similar set of functionality. 4 | 5 | ## Principles 6 | 7 | The Apollo Client should be: 8 | 9 | 1. Functional - this library should bring benefits to an application's developers and end users to achieve performance, usability, and simplicity. It should have more features than [Lokka](https://github.com/kadirahq/lokka) but less than [Relay](https://github.com/facebook/relay). 10 | 1. Transparent - a developer should be able to keep everything the Apollo Client is doing in their mind at once. They don't necessarily need to understand every part of the implementation, but nothing it's doing should be a surprise. This principle should take precedence over fine-grained performance optimizations. 11 | 1. Standalone - the published library should not impose any specific build or runtime environment, view framework, router, or development philosophies other than JavaScript, NPM, or GraphQL. When you install it via NPM in any NPM-compatible development environment, the batteries are included. Anything that isn't included, like certain polyfills, is clearly documented. 12 | 1. Compatible - the Apollo Client core should be compatible with as many GraphQL schemas, transports, and execution models as possible. There might be optimizations that rely on specific server-side features, but it should "just work" even in the absence of those features. 13 | 1. Usable - given the above, developer experience should be a priority. The API for the application developer should have a simple mental model, a minimal surface area, and be clearly documented. 14 | 15 | ## Implementation 16 | 17 | I think the principles above naturally lead to the following constraints on the implementation: 18 | 19 | ### Necessary features 20 | 21 | I think there is a "minimum viable" set of features for a good GraphQL client. Almost all GraphQL clients that aren't Relay don't have some of these features, and the necessity to have them, even when they don't have many other requirements, is what drives people to use Relay which often brings more functionality and complexity than they need for their application. Bringing us to [this graph from React Conf](https://www.dropbox.com/s/kppd4kdz40h96kj/Screenshot%202016-03-19%2016.40.19.png?dl=0) ([full slides here](https://github.com/jaredly/reactconf)). 22 | 23 | Based on talking to some developers, I believe that list includes, in no particular order: 24 | 25 | - Optimistic UI for mutations 26 | - A cache so that you don't refetch data you already have 27 | - The ability to manually refetch data when you know it has changed 28 | - The ability to preload data you might need later 29 | - Minimal server roundtrips to render the initial UI 30 | - Query aggregation from your UI tree 31 | - Basic handling of pagination, most critically being able to fetch a new page of items when you already have some 32 | 33 | The implementation process will determine the order in which these are completed. 34 | 35 | ### Stateless, well-documented store format 36 | 37 | All state of the GraphQL cache should be kept in a single immutable state object (referred to as the "store"), and every operation on the store should be implemented as a function from the previous store object to a new one. The store format should be easily understood and inspected by the application developer, rather than an implementation detail of the library. 38 | 39 | This will have many benefits compared to other approaches: 40 | 41 | 1. Simple debugging/testing both of the Apollo client itself and apps built with it, by making it possible to analyze the store contents directly and step through the different states 42 | 2. Trivial optimistic UI using time-traveling and reordering of actions taken on the store 43 | 3. Easy integration of extensions/middlewares by sharing a common data interchange format 44 | 45 | To enable this, we need to have clear documentation about the format of this store object, so that people can write extensions around it and be sure that they will remain compatible. 46 | 47 | ### Lowest-common-denominator APIs between modules 48 | 49 | APIs between the different parts of the library should be in simple, standard, easy-to-understand formats. We should avoid creating Apollo-specific representations of queries and data, and stick to the tools available - GraphQL strings, the standard GraphQL AST, selection sets, etc. 50 | 51 | If we do invent new data interchange APIs, they need to be clearly documented, have a good and documented reason for existing, and be stable so that plugins and extensions can use them. 52 | 53 | ### Simple modules, each with a clear purpose 54 | 55 | There are many utilities that any smart GraphQL cache will need around query diffing, reading a cache, etc. These should be written in a way that it would make sense to use them in any GraphQL client. In short, this is a set of libraries, not a framework. 56 | 57 | Each module should have minimal dependencies on the runtime environment. For example, the network layer can assume HTTP, but not any other part. 58 | -------------------------------------------------------------------------------- /benchmark/util.ts: -------------------------------------------------------------------------------- 1 | import * as Benchmark from 'benchmark'; 2 | 3 | import { 4 | times, 5 | cloneDeep, 6 | merge, 7 | } from 'lodash'; 8 | 9 | // This file implements utilities around benchmark.js that make it 10 | // easier to use for our benchmarking needs. 11 | 12 | // Specifically, it provides `group` and `benchmark`, examples of which 13 | // can be seen within the benchmarks.The functions allow you to manage scope and async 14 | // code more easily than benchmark.js typically allows. 15 | // 16 | // `group` is meant to provide a way to execute code that sets up the scope variables for your 17 | // benchmark. It is only run once before the benchmark, not on every call of the code to 18 | // be benchmarked. The `benchmark` function is similar to the `it` function within mocha; 19 | // it allows you to define a particular block of code to be benchmarked. 20 | 21 | const bsuite = new Benchmark.Suite(); 22 | export type DoneFunction = () => void; 23 | 24 | export interface DescriptionObject { 25 | name: string; 26 | [other: string]: any; 27 | }; 28 | 29 | export type Nullable = T | undefined; 30 | export type Description = DescriptionObject | string; 31 | export type CycleFunction = (doneFn: DoneFunction) => void; 32 | export type BenchmarkFunction = (description: Description, cycleFn: CycleFunction) => void; 33 | export type GroupFunction = (done: DoneFunction) => void; 34 | export type AfterEachCallbackFunction = (descr: Description, event: any) => void; 35 | export type AfterEachFunction = (afterEachFnArg: AfterEachCallbackFunction) => void; 36 | export type AfterAllCallbackFunction = () => void; 37 | export type AfterAllFunction = (afterAllFn: AfterAllCallbackFunction) => void; 38 | 39 | export let benchmark: BenchmarkFunction; 40 | export let afterEach: AfterEachFunction; 41 | export let afterAll: AfterAllFunction; 42 | 43 | // Used to log stuff within benchmarks without pissing off tslint. 44 | export function log(logString: string, ...args: any[]) { 45 | // tslint:disable-next-line 46 | console.log(logString, ...args); 47 | } 48 | 49 | interface Scope { 50 | benchmark?: BenchmarkFunction; 51 | afterEach?: AfterEachFunction; 52 | afterAll?: AfterAllFunction; 53 | }; 54 | 55 | // Internal function that returns the current exposed functions 56 | // benchmark, setup, etc. 57 | function currentScope() { 58 | return { 59 | benchmark, 60 | afterEach, 61 | afterAll, 62 | }; 63 | } 64 | 65 | // Internal function that lets us set benchmark, setup, afterEach, etc. 66 | // in a reasonable fashion. 67 | function setScope(scope: Scope) { 68 | benchmark = scope.benchmark as BenchmarkFunction; 69 | afterEach = scope.afterEach as AfterEachFunction; 70 | afterAll = scope.afterAll as AfterAllFunction; 71 | } 72 | 73 | export const groupPromises: Promise[] = []; 74 | 75 | export const group = (groupFn: GroupFunction) => { 76 | const oldScope = currentScope(); 77 | const scope: { 78 | benchmark?: BenchmarkFunction, 79 | afterEach?: AfterEachFunction, 80 | afterAll?: AfterAllFunction, 81 | } = {}; 82 | 83 | let afterEachFn: Nullable = undefined; 84 | scope.afterEach = (afterEachFnArg: AfterAllCallbackFunction) => { 85 | afterEachFn = afterEachFnArg; 86 | }; 87 | 88 | let afterAllFn: Nullable = undefined; 89 | scope.afterAll = (afterAllFnArg: AfterAllCallbackFunction) => { 90 | afterAllFn = afterAllFnArg; 91 | }; 92 | 93 | const benchmarkPromises: Promise[] = []; 94 | 95 | scope.benchmark = (description: string | Description, benchmarkFn: CycleFunction) => { 96 | const name = (description as DescriptionObject).name || (description as string); 97 | log('Adding benchmark: ', name); 98 | 99 | const scopes: Object[] = []; 100 | let cycleCount = 0; 101 | benchmarkPromises.push(new Promise((resolve, reject) => { 102 | bsuite.add(name, { 103 | defer: true, 104 | fn: (deferred: any) => { 105 | const done = () => { 106 | cycleCount++; 107 | deferred.resolve(); 108 | }; 109 | 110 | benchmarkFn(done); 111 | }, 112 | 113 | onComplete: (event: any) => { 114 | if (afterEachFn) { 115 | afterEachFn(description, event); 116 | } 117 | resolve(); 118 | }, 119 | }); 120 | })); 121 | }; 122 | 123 | 124 | groupPromises.push(new Promise((resolve, reject) => { 125 | const groupDone = () => { 126 | Promise.all(benchmarkPromises).then(() => { 127 | if (afterAllFn) { 128 | afterAllFn(); 129 | } 130 | }); 131 | resolve(); 132 | }; 133 | 134 | setScope(scope); 135 | groupFn(groupDone); 136 | setScope(oldScope); 137 | })); 138 | }; 139 | 140 | export function runBenchmarks() { 141 | Promise.all(groupPromises).then(() => { 142 | log('Running benchmarks.'); 143 | bsuite 144 | .on('error', (error: any) => { 145 | log('Error: ', error); 146 | }) 147 | .on('cycle', (event: any) => { 148 | log('Mean time in ms: ', event.target.stats.mean * 1000); 149 | log(String(event.target)); 150 | }) 151 | .run({'async': false}); 152 | }); 153 | } 154 | -------------------------------------------------------------------------------- /src/queries/getFromAST.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentNode, 3 | OperationDefinitionNode, 4 | FragmentDefinitionNode, 5 | } from 'graphql'; 6 | 7 | 8 | export function getMutationDefinition(doc: DocumentNode): OperationDefinitionNode { 9 | checkDocument(doc); 10 | 11 | let mutationDef: OperationDefinitionNode | null = null; 12 | doc.definitions.forEach((definition) => { 13 | if (definition.kind === 'OperationDefinition' 14 | && (definition as OperationDefinitionNode).operation === 'mutation') { 15 | mutationDef = definition as OperationDefinitionNode; 16 | } 17 | }); 18 | 19 | if (!mutationDef) { 20 | throw new Error('Must contain a mutation definition.'); 21 | } 22 | 23 | return mutationDef; 24 | } 25 | 26 | // Checks the document for errors and throws an exception if there is an error. 27 | export function checkDocument(doc: DocumentNode) { 28 | if (doc.kind !== 'Document') { 29 | throw new Error(`Expecting a parsed GraphQL document. Perhaps you need to wrap the query \ 30 | string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql`); 31 | } 32 | 33 | let foundOperation = false; 34 | 35 | doc.definitions.forEach((definition) => { 36 | switch (definition.kind) { 37 | // If this is a fragment that’s fine. 38 | case 'FragmentDefinition': 39 | break; 40 | // We can only find one operation, so the first time nothing happens. The second time we 41 | // encounter an operation definition we throw an error. 42 | case 'OperationDefinition': 43 | if (foundOperation) { 44 | throw new Error('Queries must have exactly one operation definition.'); 45 | } 46 | foundOperation = true; 47 | break; 48 | // If this is any other operation kind, throw an error. 49 | default: 50 | throw new Error(`Schema type definitions not allowed in queries. Found: "${definition.kind}"`); 51 | } 52 | }); 53 | } 54 | 55 | export function getOperationName(doc: DocumentNode): string { 56 | let res: string = ''; 57 | doc.definitions.forEach((definition) => { 58 | if (definition.kind === 'OperationDefinition' && definition.name) { 59 | res = definition.name.value; 60 | } 61 | }); 62 | return res; 63 | } 64 | 65 | // Returns the FragmentDefinitions from a particular document as an array 66 | export function getFragmentDefinitions(doc: DocumentNode): FragmentDefinitionNode[] { 67 | let fragmentDefinitions: FragmentDefinitionNode[] = doc.definitions.filter((definition) => { 68 | if (definition.kind === 'FragmentDefinition') { 69 | return true; 70 | } else { 71 | return false; 72 | } 73 | }) as FragmentDefinitionNode[]; 74 | 75 | return fragmentDefinitions; 76 | } 77 | 78 | export function getQueryDefinition(doc: DocumentNode): OperationDefinitionNode { 79 | checkDocument(doc); 80 | 81 | let queryDef: OperationDefinitionNode | null = null; 82 | doc.definitions.map((definition) => { 83 | if (definition.kind === 'OperationDefinition' 84 | && (definition as OperationDefinitionNode).operation === 'query') { 85 | queryDef = definition as OperationDefinitionNode; 86 | } 87 | }); 88 | 89 | if (!queryDef) { 90 | throw new Error('Must contain a query definition.'); 91 | } 92 | 93 | return queryDef; 94 | } 95 | 96 | // TODO REFACTOR: fix this and query/mutation definition to not use map, please. 97 | export function getOperationDefinition(doc: DocumentNode): OperationDefinitionNode { 98 | checkDocument(doc); 99 | 100 | let opDef: OperationDefinitionNode | null = null; 101 | doc.definitions.map((definition) => { 102 | if (definition.kind === 'OperationDefinition') { 103 | opDef = definition as OperationDefinitionNode; 104 | } 105 | }); 106 | 107 | if (!opDef) { 108 | throw new Error('Must contain a query definition.'); 109 | } 110 | 111 | return opDef; 112 | } 113 | 114 | export function getFragmentDefinition(doc: DocumentNode): FragmentDefinitionNode { 115 | if (doc.kind !== 'Document') { 116 | throw new Error(`Expecting a parsed GraphQL document. Perhaps you need to wrap the query \ 117 | string in a "gql" tag? http://docs.apollostack.com/apollo-client/core.html#gql`); 118 | } 119 | 120 | if (doc.definitions.length > 1) { 121 | throw new Error('Fragment must have exactly one definition.'); 122 | } 123 | 124 | const fragmentDef = doc.definitions[0] as FragmentDefinitionNode; 125 | 126 | if (fragmentDef.kind !== 'FragmentDefinition') { 127 | throw new Error('Must be a fragment definition.'); 128 | } 129 | 130 | return fragmentDef as FragmentDefinitionNode; 131 | } 132 | 133 | /** 134 | * This is an interface that describes a map from fragment names to fragment definitions. 135 | */ 136 | export interface FragmentMap { 137 | [fragmentName: string]: FragmentDefinitionNode; 138 | } 139 | 140 | // Utility function that takes a list of fragment definitions and makes a hash out of them 141 | // that maps the name of the fragment to the fragment definition. 142 | export function createFragmentMap(fragments: FragmentDefinitionNode[] = []): FragmentMap { 143 | const symTable: FragmentMap = {}; 144 | fragments.forEach((fragment) => { 145 | symTable[fragment.name.value] = fragment; 146 | }); 147 | 148 | return symTable; 149 | } 150 | -------------------------------------------------------------------------------- /src/data/storeUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldNode, 3 | IntValueNode, 4 | FloatValueNode, 5 | StringValueNode, 6 | BooleanValueNode, 7 | ObjectValueNode, 8 | ListValueNode, 9 | EnumValueNode, 10 | VariableNode, 11 | InlineFragmentNode, 12 | ValueNode, 13 | SelectionNode, 14 | ExecutionResult, 15 | NameNode, 16 | } from 'graphql'; 17 | 18 | function isStringValue(value: ValueNode): value is StringValueNode { 19 | return value.kind === 'StringValue'; 20 | } 21 | 22 | function isBooleanValue(value: ValueNode): value is BooleanValueNode { 23 | return value.kind === 'BooleanValue'; 24 | } 25 | 26 | function isIntValue(value: ValueNode): value is IntValueNode { 27 | return value.kind === 'IntValue'; 28 | } 29 | 30 | function isFloatValue(value: ValueNode): value is FloatValueNode { 31 | return value.kind === 'FloatValue'; 32 | } 33 | 34 | function isVariable(value: ValueNode): value is VariableNode { 35 | return value.kind === 'Variable'; 36 | } 37 | 38 | function isObjectValue(value: ValueNode): value is ObjectValueNode { 39 | return value.kind === 'ObjectValue'; 40 | } 41 | 42 | function isListValue(value: ValueNode): value is ListValueNode { 43 | return value.kind === 'ListValue'; 44 | } 45 | 46 | function isEnumValue(value: ValueNode): value is EnumValueNode { 47 | return value.kind === 'EnumValue'; 48 | } 49 | 50 | function valueToObjectRepresentation(argObj: any, name: NameNode, value: ValueNode, variables?: Object) { 51 | if (isIntValue(value) || isFloatValue(value)) { 52 | argObj[name.value] = Number(value.value); 53 | } else if (isBooleanValue(value) || isStringValue(value)) { 54 | argObj[name.value] = value.value; 55 | } else if (isObjectValue(value)) { 56 | const nestedArgObj = {}; 57 | value.fields.map((obj) => valueToObjectRepresentation(nestedArgObj, obj.name, obj.value, variables)); 58 | argObj[name.value] = nestedArgObj; 59 | } else if (isVariable(value)) { 60 | const variableValue = (variables || {} as any)[value.name.value]; 61 | argObj[name.value] = variableValue; 62 | } else if (isListValue(value)) { 63 | argObj[name.value] = value.values.map((listValue) => { 64 | const nestedArgArrayObj = {}; 65 | valueToObjectRepresentation(nestedArgArrayObj, name, listValue, variables); 66 | return (nestedArgArrayObj as any)[name.value]; 67 | }); 68 | } else if (isEnumValue(value)) { 69 | argObj[name.value] = (value as EnumValueNode).value; 70 | } else { 71 | throw new Error(`The inline argument "${name.value}" of kind "${(value as any).kind}" is not supported. 72 | Use variables instead of inline arguments to overcome this limitation.`); 73 | } 74 | } 75 | 76 | export function storeKeyNameFromField(field: FieldNode, variables?: Object): string { 77 | if (field.arguments && field.arguments.length) { 78 | const argObj: Object = {}; 79 | 80 | field.arguments.forEach(({name, value}) => valueToObjectRepresentation( 81 | argObj, name, value, variables)); 82 | 83 | return storeKeyNameFromFieldNameAndArgs(field.name.value, argObj); 84 | } 85 | 86 | return field.name.value; 87 | } 88 | 89 | export function storeKeyNameFromFieldNameAndArgs(fieldName: string, args?: Object): string { 90 | if (args) { 91 | const stringifiedArgs: string = JSON.stringify(args); 92 | 93 | return `${fieldName}(${stringifiedArgs})`; 94 | } 95 | 96 | return fieldName; 97 | } 98 | 99 | export function resultKeyNameFromField(field: FieldNode): string { 100 | return field.alias ? 101 | field.alias.value : 102 | field.name.value; 103 | } 104 | 105 | export function isField(selection: SelectionNode): selection is FieldNode { 106 | return selection.kind === 'Field'; 107 | } 108 | 109 | export function isInlineFragment(selection: SelectionNode): selection is InlineFragmentNode { 110 | return selection.kind === 'InlineFragment'; 111 | } 112 | 113 | export function graphQLResultHasError(result: ExecutionResult) { 114 | return result.errors && result.errors.length; 115 | } 116 | 117 | /** 118 | * This is a normalized representation of the Apollo query result cache. Briefly, it consists of 119 | * a flatten representation of query result trees. 120 | */ 121 | export interface NormalizedCache { 122 | [dataId: string]: StoreObject; 123 | } 124 | 125 | export interface StoreObject { 126 | __typename?: string; 127 | [storeFieldKey: string]: StoreValue; 128 | } 129 | 130 | export interface IdValue { 131 | type: 'id'; 132 | id: string; 133 | generated: boolean; 134 | } 135 | 136 | export interface JsonValue { 137 | type: 'json'; 138 | json: any; 139 | } 140 | 141 | export type StoreValue = number | string | string[] | IdValue | JsonValue | null | undefined | void; 142 | 143 | export function isIdValue(idObject: StoreValue): idObject is IdValue { 144 | return ( 145 | idObject != null && 146 | typeof idObject === 'object' && 147 | (idObject as (IdValue | JsonValue)).type === 'id' 148 | ); 149 | } 150 | 151 | export function toIdValue(id: string, generated = false): IdValue { 152 | return { 153 | type: 'id', 154 | id, 155 | generated, 156 | }; 157 | } 158 | 159 | export function isJsonValue(jsonObject: StoreValue): jsonObject is JsonValue { 160 | return ( 161 | jsonObject != null && 162 | typeof jsonObject === 'object' && 163 | (jsonObject as (IdValue | JsonValue)).type === 'json' 164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /src/transport/batchedNetworkInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionResult, 3 | } from 'graphql'; 4 | 5 | import 'whatwg-fetch'; 6 | 7 | import { 8 | HTTPFetchNetworkInterface, 9 | HTTPNetworkInterface, 10 | RequestAndOptions, 11 | Request, 12 | printRequest, 13 | } from './networkInterface'; 14 | 15 | import { 16 | QueryBatcher, 17 | } from './batching'; 18 | 19 | import { assign } from '../util/assign'; 20 | 21 | // An implementation of the network interface that operates over HTTP and batches 22 | // together requests over the HTTP transport. Note that this implementation will only work correctly 23 | // for GraphQL server implementations that support batching. If such a server is not available, you 24 | // should see `addQueryMerging` instead. 25 | export class HTTPBatchedNetworkInterface extends HTTPFetchNetworkInterface { 26 | 27 | private pollInterval: number; 28 | private batcher: QueryBatcher; 29 | 30 | constructor(uri: string, pollInterval: number, fetchOpts: RequestInit) { 31 | super(uri, fetchOpts); 32 | 33 | if (typeof pollInterval !== 'number') { 34 | throw new Error(`pollInterval must be a number, got ${pollInterval}`); 35 | } 36 | 37 | this.pollInterval = pollInterval; 38 | this.batcher = new QueryBatcher({ 39 | batchFetchFunction: this.batchQuery.bind(this), 40 | }); 41 | this.batcher.start(this.pollInterval); 42 | // XXX possible leak: when do we stop polling the queue? 43 | }; 44 | 45 | public query(request: Request): Promise { 46 | // we just pass it through to the batcher. 47 | return this.batcher.enqueueRequest(request); 48 | } 49 | 50 | // made public for testing only 51 | public batchQuery(requests: Request[]): Promise { 52 | const options = { ...this._opts }; 53 | 54 | // Apply the middlewares to each of the requests 55 | const middlewarePromises: Promise[] = []; 56 | requests.forEach((request) => { 57 | middlewarePromises.push(this.applyMiddlewares({ 58 | request, 59 | options, 60 | })); 61 | }); 62 | 63 | return new Promise((resolve, reject) => { 64 | Promise.all(middlewarePromises).then((requestsAndOptions: RequestAndOptions[]) => { 65 | return this.batchedFetchFromRemoteEndpoint(requestsAndOptions) 66 | .then(result => { 67 | const httpResponse = result as IResponse; 68 | 69 | if (!httpResponse.ok) { 70 | const httpError = new Error(`Network request failed with status ${httpResponse.status} - "${httpResponse.statusText}"`); 71 | (httpError as any).response = httpResponse; 72 | 73 | throw httpError; 74 | } 75 | 76 | // XXX can we be stricter with the type here? 77 | return result.json() as any; 78 | }) 79 | .then(responses => { 80 | if (typeof responses.map !== 'function') { 81 | throw new Error('BatchingNetworkInterface: server response is not an array'); 82 | } 83 | 84 | type ResponseAndOptions = { 85 | response: IResponse; 86 | options: RequestInit; 87 | }; 88 | 89 | const afterwaresPromises: ResponseAndOptions[] = responses.map((response: IResponse, index: number) => { 90 | return this.applyAfterwares({ 91 | response, 92 | options: requestsAndOptions[index].options, 93 | }); 94 | }); 95 | 96 | Promise.all(afterwaresPromises).then((responsesAndOptions: ResponseAndOptions[]) => { 97 | const results: Array = []; 98 | responsesAndOptions.forEach((result) => { 99 | results.push(result.response); 100 | }); 101 | resolve(results); 102 | }).catch((error: Error) => { 103 | reject(error); 104 | }); 105 | }); 106 | }).catch((error) => { 107 | reject(error); 108 | }); 109 | }); 110 | } 111 | 112 | private batchedFetchFromRemoteEndpoint( 113 | requestsAndOptions: RequestAndOptions[], 114 | ): Promise { 115 | const options: RequestInit = {}; 116 | 117 | // Combine all of the options given by the middleware into one object. 118 | requestsAndOptions.forEach((requestAndOptions) => { 119 | assign(options, requestAndOptions.options); 120 | }); 121 | 122 | // Serialize the requests to strings of JSON 123 | const printedRequests = requestsAndOptions.map(({ request }) => { 124 | return printRequest(request); 125 | }); 126 | 127 | return fetch(this._uri, { 128 | ...this._opts, 129 | body: JSON.stringify(printedRequests), 130 | method: 'POST', 131 | ...options, 132 | headers: { 133 | Accept: '*/*', 134 | 'Content-Type': 'application/json', 135 | ...(options.headers as { [headerName: string]: string }), 136 | }, 137 | }); 138 | }; 139 | } 140 | 141 | export interface BatchingNetworkInterfaceOptions { 142 | uri: string; 143 | batchInterval: number; 144 | opts?: RequestInit; 145 | } 146 | 147 | export function createBatchingNetworkInterface(options: BatchingNetworkInterfaceOptions): HTTPNetworkInterface { 148 | if (! options) { 149 | throw new Error('You must pass an options argument to createNetworkInterface.'); 150 | } 151 | return new HTTPBatchedNetworkInterface(options.uri, options.batchInterval, options.opts || {}); 152 | } 153 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | compose as reduxCompose, 4 | applyMiddleware, 5 | combineReducers, 6 | Middleware, 7 | } from 'redux'; 8 | 9 | import { 10 | data, 11 | } from './data/store'; 12 | 13 | import { 14 | NormalizedCache, 15 | } from './data/storeUtils'; 16 | 17 | import { 18 | queries, 19 | QueryStore, 20 | } from './queries/store'; 21 | 22 | import { 23 | mutations, 24 | MutationStore, 25 | } from './mutations/store'; 26 | 27 | import { 28 | optimistic, 29 | OptimisticStore, 30 | getDataWithOptimisticResults, 31 | } from './optimistic-data/store'; 32 | export { getDataWithOptimisticResults }; 33 | 34 | import { 35 | ApolloAction, 36 | } from './actions'; 37 | 38 | import { 39 | IdGetter, 40 | } from './core/types'; 41 | 42 | import { 43 | CustomResolverMap, 44 | } from './data/readFromStore'; 45 | 46 | import { assign } from './util/assign'; 47 | 48 | export interface Store { 49 | data: NormalizedCache; 50 | queries: QueryStore; 51 | mutations: MutationStore; 52 | optimistic: OptimisticStore; 53 | reducerError: Error | null; 54 | } 55 | 56 | /** 57 | * This is an interface that describes the behavior of a Apollo store, which is currently 58 | * implemented through redux. 59 | */ 60 | export interface ApolloStore { 61 | dispatch: (action: ApolloAction) => void; 62 | 63 | // We don't know what this will return because it could have any number of custom keys when 64 | // integrating with an existing store 65 | getState: () => any; 66 | } 67 | 68 | const crashReporter = (store: any) => (next: any) => (action: any) => { 69 | try { 70 | return next(action); 71 | } catch (err) { 72 | console.error('Caught an exception!', err); 73 | console.error(err.stack); 74 | throw err; 75 | } 76 | }; 77 | 78 | export type ApolloReducer = (store: NormalizedCache, action: ApolloAction) => NormalizedCache; 79 | 80 | export function createApolloReducer(config: ApolloReducerConfig): Function { 81 | return function apolloReducer(state = {} as Store, action: ApolloAction) { 82 | try { 83 | const newState: Store = { 84 | queries: queries(state.queries, action), 85 | mutations: mutations(state.mutations, action), 86 | 87 | // Note that we are passing the queries into this, because it reads them to associate 88 | // the query ID in the result with the actual query 89 | data: data(state.data, action, state.queries, state.mutations, config), 90 | optimistic: [] as any, 91 | 92 | reducerError: null, 93 | }; 94 | 95 | // use the two lines below to debug tests :) 96 | // console.log('ACTION', action.type, action); 97 | // console.log('new state', newState); 98 | 99 | // Note, we need to have the results of the 100 | // APOLLO_MUTATION_INIT action to simulate 101 | // the APOLLO_MUTATION_RESULT action. That's 102 | // why we pass in newState 103 | newState.optimistic = optimistic( 104 | state.optimistic, 105 | action, 106 | newState, 107 | config, 108 | ); 109 | 110 | if (state.data === newState.data && 111 | state.mutations === newState.mutations && 112 | state.queries === newState.queries && 113 | state.optimistic === newState.optimistic && 114 | state.reducerError === newState.reducerError) { 115 | return state; 116 | } 117 | 118 | return newState; 119 | } catch (reducerError) { 120 | return { 121 | ...state, 122 | reducerError, 123 | }; 124 | } 125 | }; 126 | } 127 | 128 | export function createApolloStore({ 129 | reduxRootKey = 'apollo', 130 | initialState, 131 | config = {}, 132 | reportCrashes = true, 133 | logger, 134 | }: { 135 | reduxRootKey?: string, 136 | initialState?: any, 137 | config?: ApolloReducerConfig, 138 | reportCrashes?: boolean, 139 | logger?: Middleware, 140 | } = {}): ApolloStore { 141 | const enhancers: any[] = []; 142 | const middlewares: Middleware[] = []; 143 | 144 | if (reportCrashes) { 145 | middlewares.push(crashReporter); 146 | } 147 | 148 | if (logger) { 149 | middlewares.push(logger); 150 | } 151 | 152 | if (middlewares.length > 0) { 153 | enhancers.push(applyMiddleware(...middlewares)); 154 | } 155 | 156 | // Dev tools enhancer should be last 157 | if (typeof window !== 'undefined') { 158 | const anyWindow = window as any; 159 | if (anyWindow.devToolsExtension) { 160 | enhancers.push(anyWindow.devToolsExtension()); 161 | } 162 | } 163 | 164 | // XXX to avoid type fail 165 | const compose: (...args: any[]) => () => any = reduxCompose; 166 | 167 | // Note: The below checks are what make it OK for QueryManager to start from 0 when generating 168 | // new query IDs. If we let people rehydrate query state for some reason, we would need to make 169 | // sure newly generated IDs don't overlap with old queries. 170 | if ( initialState && initialState[reduxRootKey] && initialState[reduxRootKey]['queries']) { 171 | throw new Error('Apollo initial state may not contain queries, only data'); 172 | } 173 | 174 | if ( initialState && initialState[reduxRootKey] && initialState[reduxRootKey]['mutations']) { 175 | throw new Error('Apollo initial state may not contain mutations, only data'); 176 | } 177 | 178 | return createStore( 179 | combineReducers({ [reduxRootKey]: createApolloReducer(config) as any }), // XXX see why this type fails 180 | initialState, 181 | compose(...enhancers), 182 | ); 183 | } 184 | 185 | export type ApolloReducerConfig = { 186 | dataIdFromObject?: IdGetter; 187 | customResolvers?: CustomResolverMap; 188 | }; 189 | -------------------------------------------------------------------------------- /test/directives.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | const { assert } = chai; 3 | 4 | import { 5 | shouldInclude, 6 | } from '../src/queries/directives'; 7 | 8 | import { 9 | getQueryDefinition, 10 | } from '../src/queries/getFromAST'; 11 | 12 | import gql from 'graphql-tag'; 13 | 14 | import { cloneDeep } from 'lodash'; 15 | 16 | describe('query directives', () => { 17 | it('should should not include a skipped field', () => { 18 | const query = gql` 19 | query { 20 | fortuneCookie @skip(if: true) 21 | }`; 22 | const field = getQueryDefinition(query).selectionSet.selections[0]; 23 | assert(!shouldInclude(field, {})); 24 | }); 25 | 26 | it('should include an included field', () => { 27 | const query = gql` 28 | query { 29 | fortuneCookie @include(if: true) 30 | }`; 31 | const field = getQueryDefinition(query).selectionSet.selections[0]; 32 | assert(shouldInclude(field, {})); 33 | }); 34 | 35 | it('should not include a not include: false field', () => { 36 | const query = gql` 37 | query { 38 | fortuneCookie @include(if: false) 39 | }`; 40 | const field = getQueryDefinition(query).selectionSet.selections[0]; 41 | assert(!shouldInclude(field, {})); 42 | }); 43 | 44 | it('should include a skip: false field', () => { 45 | const query = gql` 46 | query { 47 | fortuneCookie @skip(if: false) 48 | }`; 49 | const field = getQueryDefinition(query).selectionSet.selections[0]; 50 | assert(shouldInclude(field, {})); 51 | }); 52 | 53 | it('should not include a field if skip: true and include: true', () => { 54 | const query = gql` 55 | query { 56 | fortuneCookie @skip(if: true) @include(if: true) 57 | }`; 58 | const field = getQueryDefinition(query).selectionSet.selections[0]; 59 | assert(!shouldInclude(field, {})); 60 | }); 61 | 62 | it('should not include a field if skip: true and include: false', () => { 63 | const query = gql` 64 | query { 65 | fortuneCookie @skip(if: true) @include(if: false) 66 | }`; 67 | const field = getQueryDefinition(query).selectionSet.selections[0]; 68 | assert(!shouldInclude(field, {})); 69 | }); 70 | 71 | it('should include a field if skip: false and include: true', () => { 72 | const query = gql` 73 | query { 74 | fortuneCookie @skip(if:false) @include(if: true) 75 | }`; 76 | const field = getQueryDefinition(query).selectionSet.selections[0]; 77 | assert(shouldInclude(field, {})); 78 | }); 79 | 80 | it('should not include a field if skip: false and include: false', () => { 81 | const query = gql` 82 | query { 83 | fortuneCookie @skip(if: false) @include(if: false) 84 | }`; 85 | const field = getQueryDefinition(query).selectionSet.selections[0]; 86 | assert(!shouldInclude(field, {})); 87 | }); 88 | 89 | it('should leave the original query unmodified', () => { 90 | const query = gql` 91 | query { 92 | fortuneCookie @skip(if: false) @include(if: false) 93 | }`; 94 | const queryClone = cloneDeep(query); 95 | const field = getQueryDefinition(query).selectionSet.selections[0]; 96 | shouldInclude(field, {}); 97 | assert.deepEqual(query, queryClone); 98 | }); 99 | 100 | it('does not throw an error on an unsupported directive', () => { 101 | const query = gql` 102 | query { 103 | fortuneCookie @dosomething(if: true) 104 | }`; 105 | const field = getQueryDefinition(query).selectionSet.selections[0]; 106 | 107 | assert.doesNotThrow(() => { 108 | shouldInclude(field, {}); 109 | }); 110 | }); 111 | 112 | it('throws an error on an invalid argument for the skip directive', () => { 113 | const query = gql` 114 | query { 115 | fortuneCookie @skip(nothing: true) 116 | }`; 117 | const field = getQueryDefinition(query).selectionSet.selections[0]; 118 | 119 | assert.throws(() => { 120 | shouldInclude(field, {}); 121 | }); 122 | }); 123 | 124 | it('throws an error on an invalid argument for the include directive', () => { 125 | const query = gql` 126 | query { 127 | fortuneCookie @include(nothing: true) 128 | }`; 129 | const field = getQueryDefinition(query).selectionSet.selections[0]; 130 | 131 | assert.throws(() => { 132 | shouldInclude(field, {}); 133 | }); 134 | }); 135 | 136 | it('throws an error on an invalid variable name within a directive argument', () => { 137 | const query = gql` 138 | query { 139 | fortuneCookie @include(if: $neverDefined) 140 | }`; 141 | const field = getQueryDefinition(query).selectionSet.selections[0]; 142 | assert.throws(() => { 143 | shouldInclude(field, {}); 144 | }); 145 | }); 146 | 147 | it('evaluates variables on skip fields', () => { 148 | const query = gql` 149 | query($shouldSkip: Boolean) { 150 | fortuneCookie @skip(if: $shouldSkip) 151 | }`; 152 | const variables = { 153 | shouldSkip: true, 154 | }; 155 | const field = getQueryDefinition(query).selectionSet.selections[0]; 156 | assert(!shouldInclude(field, variables)); 157 | }); 158 | 159 | it('evaluates variables on include fields', () => { 160 | const query = gql` 161 | query($shouldSkip: Boolean) { 162 | fortuneCookie @include(if: $shouldInclude) 163 | }`; 164 | const variables = { 165 | shouldInclude: false, 166 | }; 167 | const field = getQueryDefinition(query).selectionSet.selections[0]; 168 | assert(!shouldInclude(field, variables)); 169 | }); 170 | 171 | it('throws an error if the value of the argument is not a variable or boolean', () => { 172 | const query = gql` 173 | query { 174 | fortuneCookie @include(if: "string") 175 | }`; 176 | const field = getQueryDefinition(query).selectionSet.selections[0]; 177 | assert.throws(() => { 178 | shouldInclude(field, {}); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /test/subscribeToMore.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | const { assert } = chai; 3 | 4 | import { 5 | mockSubscriptionNetworkInterface, 6 | } from './mocks/mockNetworkInterface'; 7 | 8 | import ApolloClient from '../src'; 9 | 10 | import gql from 'graphql-tag'; 11 | 12 | describe('subscribeToMore', () => { 13 | const query = gql` 14 | query aQuery { 15 | entry { 16 | value 17 | } 18 | } 19 | `; 20 | const result = { 21 | data: { 22 | entry: { 23 | value: 1, 24 | }, 25 | }, 26 | }; 27 | 28 | const req1 = { request: { query }, result }; 29 | 30 | const results = ['Dahivat Pandya', 'Amanda Liu'].map( 31 | name => ({ result: { name: name }, delay: 10 }), 32 | ); 33 | 34 | const sub1 = { 35 | request: { 36 | query: gql` 37 | subscription newValues { 38 | name 39 | } 40 | `, 41 | }, 42 | id: 0, 43 | results: [...results], 44 | }; 45 | 46 | const results2 = [ 47 | { error: new Error('You cant touch this'), delay: 10 }, 48 | { result: { name: 'Amanda Liu' }, delay: 10 }, 49 | ]; 50 | 51 | const sub2 = { 52 | request: { 53 | query: gql` 54 | subscription newValues { 55 | notAnActualField 56 | } 57 | `, 58 | }, 59 | id: 0, 60 | results: [...results2], 61 | }; 62 | 63 | const results3 = [ 64 | { error: new Error('You cant touch this'), delay: 10 }, 65 | { result: { name: 'Amanda Liu' }, delay: 10 }, 66 | ]; 67 | 68 | const sub3 = { 69 | request: { 70 | query: gql` 71 | subscription newValues { 72 | notAnActualField 73 | } 74 | `, 75 | }, 76 | id: 0, 77 | results: [...results3], 78 | }; 79 | 80 | it('triggers new result from subscription data', (done) => { 81 | let latestResult: any = null; 82 | const networkInterface = mockSubscriptionNetworkInterface([sub1], req1); 83 | let counter = 0; 84 | 85 | const client = new ApolloClient({ 86 | networkInterface, 87 | addTypename: false, 88 | }); 89 | 90 | const obsHandle = client.watchQuery({ 91 | query, 92 | }); 93 | const sub = obsHandle.subscribe({ 94 | next(queryResult) { 95 | latestResult = queryResult; 96 | counter++; 97 | }, 98 | }); 99 | 100 | obsHandle.subscribeToMore({ 101 | document: gql` 102 | subscription newValues { 103 | name 104 | } 105 | `, 106 | updateQuery: (prev, { subscriptionData }) => { 107 | return { entry: { value: subscriptionData.data.name } }; 108 | }, 109 | }); 110 | 111 | setTimeout(() => { 112 | sub.unsubscribe(); 113 | assert.equal(counter, 3); 114 | assert.deepEqual( 115 | latestResult, 116 | { data: { entry: { value: 'Amanda Liu' } }, loading: false, networkStatus: 7 }, 117 | ); 118 | done(); 119 | }, 50); 120 | 121 | for (let i = 0; i < 2; i++) { 122 | networkInterface.fireResult(0); // 0 is the id of the subscription for the NI 123 | } 124 | }); 125 | 126 | 127 | it('calls error callback on error', (done) => { 128 | let latestResult: any = null; 129 | const networkInterface = mockSubscriptionNetworkInterface([sub2], req1); 130 | let counter = 0; 131 | 132 | const client = new ApolloClient({ 133 | networkInterface, 134 | addTypename: false, 135 | }); 136 | 137 | const obsHandle = client.watchQuery({ 138 | query, 139 | }); 140 | const sub = obsHandle.subscribe({ 141 | next(queryResult) { 142 | latestResult = queryResult; 143 | counter++; 144 | }, 145 | }); 146 | 147 | let errorCount = 0; 148 | 149 | obsHandle.subscribeToMore({ 150 | document: gql` 151 | subscription newValues { 152 | notAnActualField 153 | } 154 | `, 155 | updateQuery: (prev, { subscriptionData }) => { 156 | return { entry: { value: subscriptionData.data.name } }; 157 | }, 158 | onError: (err) => { errorCount += 1; }, 159 | }); 160 | 161 | setTimeout(() => { 162 | sub.unsubscribe(); 163 | assert.equal(counter, 2); 164 | assert.deepEqual( 165 | latestResult, 166 | { data: { entry: { value: 'Amanda Liu' } }, loading: false, networkStatus: 7 }, 167 | ); 168 | assert.equal(errorCount, 1); 169 | done(); 170 | }, 50); 171 | 172 | for (let i = 0; i < 2; i++) { 173 | networkInterface.fireResult(0); // 0 is the id of the subscription for the NI 174 | } 175 | }); 176 | 177 | it('prints unhandled subscription errors to the console', (done) => { 178 | let latestResult: any = null; 179 | const networkInterface = mockSubscriptionNetworkInterface([sub3], req1); 180 | let counter = 0; 181 | 182 | const client = new ApolloClient({ 183 | networkInterface, 184 | addTypename: false, 185 | }); 186 | 187 | const obsHandle = client.watchQuery({ 188 | query, 189 | }); 190 | const sub = obsHandle.subscribe({ 191 | next(queryResult) { 192 | latestResult = queryResult; 193 | counter++; 194 | }, 195 | }); 196 | 197 | let errorCount = 0; 198 | const consoleErr = console.error; 199 | console.error = (err: Error) => { errorCount += 1; }; 200 | 201 | obsHandle.subscribeToMore({ 202 | document: gql` 203 | subscription newValues { 204 | notAnActualField 205 | } 206 | `, 207 | updateQuery: (prev, { subscriptionData }) => { 208 | return { entry: { value: subscriptionData.data.name } }; 209 | }, 210 | }); 211 | 212 | setTimeout(() => { 213 | sub.unsubscribe(); 214 | assert.equal(counter, 2); 215 | assert.deepEqual( 216 | latestResult, 217 | { data: { entry: { value: 'Amanda Liu' } }, loading: false, networkStatus: 7 }, 218 | ); 219 | assert.equal(errorCount, 1); 220 | console.error = consoleErr; 221 | done(); 222 | }, 50); 223 | 224 | for (let i = 0; i < 2; i++) { 225 | networkInterface.fireResult(0); // 0 is the id of the subscription for the NI 226 | } 227 | }); 228 | 229 | // TODO add a test that checks that subscriptions are cancelled when obs is unsubscribed from. 230 | }); 231 | -------------------------------------------------------------------------------- /test/batching.ts: -------------------------------------------------------------------------------- 1 | import { QueryBatcher, 2 | QueryFetchRequest, 3 | } from '../src/transport/batching'; 4 | import { assert } from 'chai'; 5 | import { Request } from '../src/transport/networkInterface'; 6 | import { 7 | mockBatchedNetworkInterface, 8 | } from './mocks/mockNetworkInterface'; 9 | import gql from 'graphql-tag'; 10 | import { ExecutionResult } from 'graphql'; 11 | 12 | const networkInterface = mockBatchedNetworkInterface(); 13 | 14 | describe('QueryBatcher', () => { 15 | it('should construct', () => { 16 | assert.doesNotThrow(() => { 17 | const querySched = new QueryBatcher({ 18 | batchFetchFunction: networkInterface.batchQuery.bind(networkInterface), 19 | }); 20 | querySched.consumeQueue(); 21 | }); 22 | }); 23 | 24 | it('should not do anything when faced with an empty queue', () => { 25 | const batcher = new QueryBatcher({ 26 | batchFetchFunction: networkInterface.batchQuery.bind(networkInterface), 27 | }); 28 | 29 | assert.equal(batcher.queuedRequests.length, 0); 30 | batcher.consumeQueue(); 31 | assert.equal(batcher.queuedRequests.length, 0); 32 | }); 33 | 34 | it('should be able to add to the queue', () => { 35 | const batcher = new QueryBatcher({ 36 | batchFetchFunction: networkInterface.batchQuery.bind(networkInterface), 37 | }); 38 | 39 | const query = gql` 40 | query { 41 | author { 42 | firstName 43 | lastName 44 | } 45 | }`; 46 | 47 | const request: QueryFetchRequest = { 48 | request: { query }, 49 | }; 50 | 51 | assert.equal(batcher.queuedRequests.length, 0); 52 | batcher.enqueueRequest(request); 53 | assert.equal(batcher.queuedRequests.length, 1); 54 | batcher.enqueueRequest(request); 55 | assert.equal(batcher.queuedRequests.length, 2); 56 | }); 57 | 58 | describe('request queue', () => { 59 | const query = gql` 60 | query { 61 | author { 62 | firstName 63 | lastName 64 | } 65 | }`; 66 | const data = { 67 | 'author' : { 68 | 'firstName': 'John', 69 | 'lastName': 'Smith', 70 | }, 71 | }; 72 | const myNetworkInterface = mockBatchedNetworkInterface( 73 | { 74 | request: { query }, 75 | result: { data }, 76 | }, 77 | { 78 | request: { query }, 79 | result: { data }, 80 | }, 81 | ); 82 | const batcher = new QueryBatcher({ 83 | batchFetchFunction: myNetworkInterface.batchQuery.bind(myNetworkInterface), 84 | }); 85 | const request: Request = { 86 | query, 87 | }; 88 | 89 | it('should be able to consume from a queue containing a single query', (done) => { 90 | const myBatcher = new QueryBatcher({ 91 | batchFetchFunction: myNetworkInterface.batchQuery.bind(myNetworkInterface), 92 | }); 93 | 94 | myBatcher.enqueueRequest(request); 95 | const promises: (Promise | undefined)[] = myBatcher.consumeQueue()!; 96 | assert.equal(promises.length, 1); 97 | promises[0]!.then((resultObj) => { 98 | assert.equal(myBatcher.queuedRequests.length, 0); 99 | assert.deepEqual(resultObj, { data } ); 100 | done(); 101 | }); 102 | }); 103 | 104 | it('should be able to consume from a queue containing multiple queries', (done) => { 105 | const request2: Request = { 106 | query, 107 | }; 108 | const NI = mockBatchedNetworkInterface( 109 | { 110 | request: { query }, 111 | result: {data }, 112 | }, 113 | { 114 | request: { query }, 115 | result: { data }, 116 | }, 117 | ); 118 | 119 | const myBatcher = new QueryBatcher({ 120 | batchFetchFunction: NI.batchQuery.bind(NI), 121 | }); 122 | myBatcher.enqueueRequest(request); 123 | myBatcher.enqueueRequest(request2); 124 | const promises: (Promise | undefined)[] = myBatcher.consumeQueue()!; 125 | assert.equal(batcher.queuedRequests.length, 0); 126 | assert.equal(promises.length, 2); 127 | promises[0]!.then((resultObj1) => { 128 | assert.deepEqual(resultObj1, { data }); 129 | promises[1]!.then((resultObj2) => { 130 | assert.deepEqual(resultObj2, { data }); 131 | done(); 132 | }); 133 | }); 134 | }); 135 | 136 | it('should return a promise when we enqueue a request and resolve it with a result', (done) => { 137 | const NI = mockBatchedNetworkInterface( 138 | { 139 | request: { query }, 140 | result: { data }, 141 | }, 142 | ); 143 | const myBatcher = new QueryBatcher({ 144 | batchFetchFunction: NI.batchQuery.bind(NI), 145 | }); 146 | const promise = myBatcher.enqueueRequest(request); 147 | myBatcher.consumeQueue(); 148 | promise.then((result) => { 149 | assert.deepEqual(result, { data }); 150 | done(); 151 | }); 152 | }); 153 | }); 154 | 155 | it('should be able to stop polling', () => { 156 | const batcher = new QueryBatcher({ 157 | batchFetchFunction: networkInterface.batchQuery.bind(networkInterface), 158 | }); 159 | const query = gql` 160 | query { 161 | author { 162 | firstName 163 | lastName 164 | } 165 | }`; 166 | const request: Request = { 167 | query, 168 | }; 169 | 170 | batcher.enqueueRequest(request); 171 | batcher.enqueueRequest(request); 172 | 173 | //poll with a big interval so that the queue 174 | //won't actually be consumed by the time we stop. 175 | batcher.start(1000); 176 | batcher.stop(); 177 | assert.equal(batcher.queuedRequests.length, 2); 178 | }); 179 | 180 | it('should reject the promise if there is a network error', (done) => { 181 | const query = gql` 182 | query { 183 | author { 184 | firstName 185 | lastName 186 | } 187 | }`; 188 | const request: Request = { 189 | query: query, 190 | }; 191 | const error = new Error('Network error'); 192 | const myNetworkInterface = mockBatchedNetworkInterface( 193 | { 194 | request: { query }, 195 | error, 196 | }, 197 | ); 198 | const batcher = new QueryBatcher({ 199 | batchFetchFunction: myNetworkInterface.batchQuery.bind(myNetworkInterface), 200 | }); 201 | const promise = batcher.enqueueRequest(request); 202 | batcher.consumeQueue(); 203 | promise.catch((resError: Error) => { 204 | assert.equal(resError.message, 'Network error'); 205 | done(); 206 | }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /test/queryTransform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addTypenameToDocument, 3 | } from '../src/queries/queryTransform'; 4 | 5 | import { 6 | getQueryDefinition, 7 | } from '../src/queries/getFromAST'; 8 | 9 | import { print } from 'graphql-tag/printer'; 10 | import gql from 'graphql-tag'; 11 | import { assert } from 'chai'; 12 | 13 | describe('query transforms', () => { 14 | it('should correctly add typenames', () => { 15 | let testQuery = gql` 16 | query { 17 | author { 18 | name { 19 | firstName 20 | lastName 21 | } 22 | } 23 | } 24 | `; 25 | const newQueryDoc = addTypenameToDocument(testQuery); 26 | 27 | const expectedQuery = gql` 28 | query { 29 | author { 30 | name { 31 | firstName 32 | lastName 33 | __typename 34 | } 35 | __typename 36 | } 37 | } 38 | `; 39 | const expectedQueryStr = print(expectedQuery); 40 | 41 | assert.equal(expectedQueryStr, print(newQueryDoc)); 42 | }); 43 | 44 | it('should not add duplicates', () => { 45 | let testQuery = gql` 46 | query { 47 | author { 48 | name { 49 | firstName 50 | lastName 51 | __typename 52 | } 53 | } 54 | } 55 | `; 56 | const newQueryDoc = addTypenameToDocument(testQuery); 57 | 58 | const expectedQuery = gql` 59 | query { 60 | author { 61 | name { 62 | firstName 63 | lastName 64 | __typename 65 | } 66 | __typename 67 | } 68 | } 69 | `; 70 | const expectedQueryStr = print(expectedQuery); 71 | 72 | assert.equal(expectedQueryStr, print(newQueryDoc)); 73 | }); 74 | 75 | it('should not screw up on a FragmentSpread within the query AST', () => { 76 | const testQuery = gql` 77 | query withFragments { 78 | user(id: 4) { 79 | friends(first: 10) { 80 | ...friendFields 81 | } 82 | } 83 | }`; 84 | const expectedQuery = getQueryDefinition(gql` 85 | query withFragments { 86 | user(id: 4) { 87 | friends(first: 10) { 88 | ...friendFields 89 | __typename 90 | } 91 | __typename 92 | } 93 | } 94 | `); 95 | const modifiedQuery = addTypenameToDocument(testQuery); 96 | assert.equal(print(expectedQuery), print(getQueryDefinition(modifiedQuery))); 97 | }); 98 | 99 | it('should modify all definitions in a document', () => { 100 | const testQuery = gql` 101 | query withFragments { 102 | user(id: 4) { 103 | friends(first: 10) { 104 | ...friendFields 105 | } 106 | } 107 | } 108 | fragment friendFields on User { 109 | firstName 110 | lastName 111 | }`; 112 | 113 | const newQueryDoc = addTypenameToDocument(testQuery); 114 | 115 | const expectedQuery = gql` 116 | query withFragments { 117 | user(id: 4) { 118 | friends(first: 10) { 119 | ...friendFields 120 | __typename 121 | } 122 | __typename 123 | } 124 | } 125 | fragment friendFields on User { 126 | firstName 127 | lastName 128 | __typename 129 | }`; 130 | 131 | assert.equal(print(expectedQuery), print(newQueryDoc)); 132 | }); 133 | 134 | it('should be able to apply a QueryTransformer correctly', () => { 135 | const testQuery = gql` 136 | query { 137 | author { 138 | firstName 139 | lastName 140 | } 141 | }`; 142 | 143 | const expectedQuery = getQueryDefinition(gql` 144 | query { 145 | author { 146 | firstName 147 | lastName 148 | __typename 149 | } 150 | } 151 | `); 152 | 153 | const modifiedQuery = addTypenameToDocument(testQuery); 154 | assert.equal(print(expectedQuery), print(getQueryDefinition(modifiedQuery))); 155 | }); 156 | 157 | it('should be able to apply a MutationTransformer correctly', () => { 158 | const testQuery = gql` 159 | mutation { 160 | createAuthor(firstName: "John", lastName: "Smith") { 161 | firstName 162 | lastName 163 | } 164 | }`; 165 | const expectedQuery = gql` 166 | mutation { 167 | createAuthor(firstName: "John", lastName: "Smith") { 168 | firstName 169 | lastName 170 | __typename 171 | } 172 | }`; 173 | 174 | const modifiedQuery = addTypenameToDocument(testQuery); 175 | assert.equal(print(expectedQuery), print(modifiedQuery)); 176 | 177 | }); 178 | 179 | it('should add typename fields correctly on this one query' , () => { 180 | const testQuery = gql` 181 | query Feed($type: FeedType!) { 182 | # Eventually move this into a no fetch query right on the entry 183 | # since we literally just need this info to determine whether to 184 | # show upvote/downvote buttons 185 | currentUser { 186 | login 187 | } 188 | feed(type: $type) { 189 | createdAt 190 | score 191 | commentCount 192 | id 193 | postedBy { 194 | login 195 | html_url 196 | } 197 | 198 | repository { 199 | name 200 | full_name 201 | description 202 | html_url 203 | stargazers_count 204 | open_issues_count 205 | created_at 206 | owner { 207 | avatar_url 208 | } 209 | } 210 | } 211 | }`; 212 | const expectedQuery = getQueryDefinition(gql` 213 | query Feed($type: FeedType!) { 214 | currentUser { 215 | login 216 | __typename 217 | } 218 | feed(type: $type) { 219 | createdAt 220 | score 221 | commentCount 222 | id 223 | postedBy { 224 | login 225 | html_url 226 | __typename 227 | } 228 | 229 | repository { 230 | name 231 | full_name 232 | description 233 | html_url 234 | stargazers_count 235 | open_issues_count 236 | created_at 237 | owner { 238 | avatar_url 239 | __typename 240 | } 241 | __typename 242 | } 243 | __typename 244 | } 245 | }`); 246 | const modifiedQuery = addTypenameToDocument(testQuery); 247 | assert.equal(print(expectedQuery), print(getQueryDefinition(modifiedQuery))); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /test/mockNetworkInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | } from 'chai'; 4 | 5 | import { 6 | mockSubscriptionNetworkInterface, 7 | MockedSubscription, 8 | } from './mocks/mockNetworkInterface'; 9 | 10 | import { omit } from 'lodash'; 11 | 12 | import gql from 'graphql-tag'; 13 | 14 | describe('MockSubscriptionNetworkInterface', () => { 15 | 16 | const result1 = { 17 | result: { 18 | data: {user: {name: 'Dhaivat Pandya'}}, 19 | }, 20 | delay: 50, 21 | }; 22 | 23 | const result2 = { 24 | result: { 25 | data: {user: {name: 'Vyacheslav Kim'}}, 26 | }, 27 | delay: 50, 28 | }; 29 | 30 | const result3 = { 31 | result: { 32 | data: {user: {name: 'Changping Chen'}}, 33 | }, 34 | delay: 50, 35 | }; 36 | 37 | const result4 = { 38 | result: { 39 | data: {user: {name: 'Amanda Liu'}}, 40 | }, 41 | delay: 50, 42 | }; 43 | let sub1: any; 44 | 45 | beforeEach(() => { 46 | 47 | sub1 = { 48 | request: { 49 | query: gql` 50 | query UserInfo($name: String) { 51 | user(name: $name) { 52 | name 53 | } 54 | } 55 | `, 56 | variables: { 57 | name: 'Changping Chen', 58 | }, 59 | }, 60 | id: 0, 61 | results: [result1, result2, result3, result4], 62 | }; 63 | }); 64 | 65 | it('correctly adds mocked subscriptions', () => { 66 | const networkInterface = mockSubscriptionNetworkInterface([sub1]); 67 | const mockedSubscriptionsByKey = networkInterface.mockedSubscriptionsByKey; 68 | assert.equal(Object.keys(mockedSubscriptionsByKey).length, 1); 69 | const key = Object.keys(mockedSubscriptionsByKey)[0]; 70 | assert.deepEqual(mockedSubscriptionsByKey[key], [sub1]); 71 | }); 72 | 73 | it('correctly adds multiple mocked subscriptions', () => { 74 | const networkInterface = mockSubscriptionNetworkInterface([sub1, sub1]); 75 | const mockedSubscriptionsByKey = networkInterface.mockedSubscriptionsByKey; 76 | assert.equal(Object.keys(mockedSubscriptionsByKey).length, 1); 77 | 78 | const key = Object.keys(mockedSubscriptionsByKey)[0]; 79 | assert.deepEqual(mockedSubscriptionsByKey[key], [sub1, sub1]); 80 | }); 81 | 82 | it('throws an error when firing a result array is empty', () => { 83 | const noResultSub = omit(sub1, 'results') as MockedSubscription; 84 | 85 | assert.throw(() => { 86 | const networkInterface = mockSubscriptionNetworkInterface([noResultSub]); 87 | networkInterface.subscribe( 88 | { 89 | query: gql` 90 | query UserInfo($name: String) { 91 | user(name: $name) { 92 | name 93 | } 94 | } 95 | `, 96 | variables: { 97 | name: 'Changping Chen', 98 | }, 99 | }, 100 | (error, result) => { 101 | assert.deepEqual(result, result1.result); 102 | }, 103 | ); 104 | networkInterface.fireResult(0); 105 | }); 106 | }); 107 | 108 | 109 | it('throws an error when firing a subscription id that does not exist', () => { 110 | const noResultSub = omit(sub1, 'results') as MockedSubscription; 111 | 112 | assert.throw(() => { 113 | const networkInterface = mockSubscriptionNetworkInterface([noResultSub]); 114 | networkInterface.subscribe( 115 | { 116 | query: gql` 117 | query UserInfo($name: String) { 118 | user(name: $name) { 119 | name 120 | } 121 | } 122 | `, 123 | variables: { 124 | name: 'Changping Chen', 125 | }, 126 | }, 127 | (error, result) => { 128 | assert.deepEqual(result, result1.result); 129 | }, 130 | ); 131 | networkInterface.fireResult(4); 132 | }); 133 | }); 134 | it('correctly subscribes', (done) => { 135 | const networkInterface = mockSubscriptionNetworkInterface([sub1]); 136 | const id = networkInterface.subscribe( 137 | { 138 | query: gql` 139 | query UserInfo($name: String) { 140 | user(name: $name) { 141 | name 142 | } 143 | } 144 | `, 145 | variables: { 146 | name: 'Changping Chen', 147 | }, 148 | }, 149 | (error, result) => { 150 | assert.deepEqual(result, result1.result); 151 | done(); 152 | }, 153 | ); 154 | networkInterface.fireResult(0); 155 | assert.equal(id, 0); 156 | assert.deepEqual(networkInterface.mockedSubscriptionsById[0], sub1); 157 | }); 158 | 159 | it('correctly fires results', (done) => { 160 | const networkInterface = mockSubscriptionNetworkInterface([sub1]); 161 | networkInterface.subscribe( 162 | { 163 | query: gql` 164 | query UserInfo($name: String) { 165 | user(name: $name) { 166 | name 167 | } 168 | } 169 | `, 170 | variables: { 171 | name: 'Changping Chen', 172 | }, 173 | }, 174 | (error, result) => { 175 | assert.deepEqual(result, result1.result); 176 | done(); 177 | }, 178 | ); 179 | networkInterface.fireResult(0); 180 | }); 181 | 182 | it('correctly fires multiple results', (done) => { 183 | let allResults: any[] = []; 184 | const networkInterface = mockSubscriptionNetworkInterface([sub1]); 185 | networkInterface.subscribe( 186 | { 187 | query: gql` 188 | query UserInfo($name: String) { 189 | user(name: $name) { 190 | name 191 | } 192 | } 193 | `, 194 | variables: { 195 | name: 'Changping Chen', 196 | }, 197 | }, 198 | (error, result) => { 199 | allResults.push(result); 200 | }, 201 | ); 202 | 203 | for (let i = 0; i < 4; i++) { 204 | networkInterface.fireResult(0); 205 | } 206 | setTimeout(() => { 207 | assert.deepEqual( 208 | allResults, 209 | [result1.result, result2.result, result3.result, result4.result], 210 | ); 211 | done(); 212 | }, 50); 213 | 214 | 215 | }); 216 | 217 | it('correctly unsubscribes', () => { 218 | const networkInterface = mockSubscriptionNetworkInterface([sub1]); 219 | networkInterface.subscribe( 220 | { 221 | query: gql` 222 | query UserInfo($name: String) { 223 | user(name: $name) { 224 | name 225 | } 226 | } 227 | `, 228 | variables: { 229 | name: 'Changping Chen', 230 | }, 231 | }, 232 | (error, result) => { 233 | assert(false); 234 | }, 235 | ); 236 | networkInterface.unsubscribe(0); 237 | assert.throw(() => { 238 | networkInterface.fireResult(0); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /src/scheduler/scheduler.ts: -------------------------------------------------------------------------------- 1 | // The QueryScheduler is supposed to be a mechanism that schedules polling queries such that 2 | // they are clustered into the time slots of the QueryBatcher and are batched together. It 3 | // also makes sure that for a given polling query, if one instance of the query is inflight, 4 | // another instance will not be fired until the query returns or times out. We do this because 5 | // another query fires while one is already in flight, the data will stay in the "loading" state 6 | // even after the first query has returned. 7 | 8 | // At the moment, the QueryScheduler implements the one-polling-instance-at-a-time logic and 9 | // adds queries to the QueryBatcher queue. 10 | 11 | import { 12 | QueryManager, 13 | } from '../core/QueryManager'; 14 | 15 | import { 16 | FetchType, 17 | QueryListener, 18 | } from '../core/types'; 19 | 20 | import { ObservableQuery } from '../core/ObservableQuery'; 21 | 22 | import { WatchQueryOptions } from '../core/watchQueryOptions'; 23 | 24 | import { NetworkStatus } from '../queries/networkStatus'; 25 | 26 | export class QueryScheduler { 27 | // Map going from queryIds to query options that are in flight. 28 | public inFlightQueries: { [queryId: string]: WatchQueryOptions }; 29 | 30 | // Map going from query ids to the query options associated with those queries. Contains all of 31 | // the queries, both in flight and not in flight. 32 | public registeredQueries: { [queryId: string]: WatchQueryOptions }; 33 | 34 | // Map going from polling interval with to the query ids that fire on that interval. 35 | // These query ids are associated with a set of options in the this.registeredQueries. 36 | public intervalQueries: { [interval: number]: string[] }; 37 | 38 | // We use this instance to actually fire queries (i.e. send them to the batching 39 | // mechanism). 40 | public queryManager: QueryManager; 41 | 42 | // Map going from polling interval widths to polling timers. 43 | private pollingTimers: { [interval: number]: any }; 44 | 45 | constructor({ 46 | queryManager, 47 | }: { 48 | queryManager: QueryManager; 49 | }) { 50 | this.queryManager = queryManager; 51 | this.pollingTimers = {}; 52 | this.inFlightQueries = {}; 53 | this.registeredQueries = {}; 54 | this.intervalQueries = {}; 55 | } 56 | 57 | public checkInFlight(queryId: string) { 58 | const queries = this.queryManager.getApolloState().queries; 59 | 60 | // XXX we do this because some legacy tests use a fake queryId. We should rewrite those tests 61 | return queries[queryId] && queries[queryId].networkStatus !== NetworkStatus.ready; 62 | } 63 | 64 | public fetchQuery(queryId: string, options: WatchQueryOptions, fetchType: FetchType) { 65 | return new Promise((resolve, reject) => { 66 | this.queryManager.fetchQuery(queryId, options, fetchType).then((result) => { 67 | resolve(result); 68 | }).catch((error) => { 69 | reject(error); 70 | }); 71 | }); 72 | } 73 | 74 | public startPollingQuery( 75 | options: WatchQueryOptions, 76 | queryId: string, 77 | listener?: QueryListener, 78 | ): string { 79 | if (!options.pollInterval) { 80 | throw new Error('Attempted to start a polling query without a polling interval.'); 81 | } 82 | 83 | this.registeredQueries[queryId] = options; 84 | 85 | if (listener) { 86 | this.queryManager.addQueryListener(queryId, listener); 87 | } 88 | this.addQueryOnInterval(queryId, options); 89 | 90 | return queryId; 91 | } 92 | 93 | public stopPollingQuery(queryId: string) { 94 | // Remove the query options from one of the registered queries. 95 | // The polling function will then take care of not firing it anymore. 96 | delete this.registeredQueries[queryId]; 97 | } 98 | 99 | // Fires the all of the queries on a particular interval. Called on a setInterval. 100 | public fetchQueriesOnInterval(interval: number) { 101 | // XXX this "filter" here is nasty, because it does two things at the same time. 102 | // 1. remove queries that have stopped polling 103 | // 2. call fetchQueries for queries that are polling and not in flight. 104 | // TODO: refactor this to make it cleaner 105 | this.intervalQueries[interval] = this.intervalQueries[interval].filter((queryId) => { 106 | // If queryOptions can't be found from registeredQueries, it means that this queryId 107 | // is no longer registered and should be removed from the list of queries firing on this 108 | // interval. 109 | if (!this.registeredQueries.hasOwnProperty(queryId)) { 110 | return false; 111 | } 112 | 113 | // Don't fire this instance of the polling query is one of the instances is already in 114 | // flight. 115 | if (this.checkInFlight(queryId)) { 116 | return true; 117 | } 118 | 119 | const queryOptions = this.registeredQueries[queryId]; 120 | const pollingOptions = { ...queryOptions } as WatchQueryOptions; 121 | pollingOptions.forceFetch = true; 122 | this.fetchQuery(queryId, pollingOptions, FetchType.poll); 123 | return true; 124 | }); 125 | 126 | if (this.intervalQueries[interval].length === 0) { 127 | clearInterval(this.pollingTimers[interval]); 128 | delete this.intervalQueries[interval]; 129 | } 130 | } 131 | 132 | // Adds a query on a particular interval to this.intervalQueries and then fires 133 | // that query with all the other queries executing on that interval. Note that the query id 134 | // and query options must have been added to this.registeredQueries before this function is called. 135 | public addQueryOnInterval(queryId: string, queryOptions: WatchQueryOptions) { 136 | const interval = queryOptions.pollInterval; 137 | 138 | if (!interval) { 139 | throw new Error(`A poll interval is required to start polling query with id '${queryId}'.`); 140 | } 141 | 142 | // If there are other queries on this interval, this query will just fire with those 143 | // and we don't need to create a new timer. 144 | if (this.intervalQueries.hasOwnProperty(interval.toString()) && this.intervalQueries[interval].length > 0) { 145 | this.intervalQueries[interval].push(queryId); 146 | } else { 147 | this.intervalQueries[interval] = [queryId]; 148 | // set up the timer for the function that will handle this interval 149 | this.pollingTimers[interval] = setInterval(() => { 150 | this.fetchQueriesOnInterval(interval); 151 | }, interval); 152 | } 153 | } 154 | 155 | // Used only for unit testing. 156 | public registerPollingQuery(queryOptions: WatchQueryOptions): ObservableQuery { 157 | if (!queryOptions.pollInterval) { 158 | throw new Error('Attempted to register a non-polling query with the scheduler.'); 159 | } 160 | return new ObservableQuery({ 161 | scheduler: this, 162 | options: queryOptions, 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /test/store.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | const { assert } = chai; 3 | import gql from 'graphql-tag'; 4 | 5 | import { 6 | Store, 7 | createApolloStore, 8 | } from '../src/store'; 9 | 10 | describe('createApolloStore', () => { 11 | it('does not require any arguments', () => { 12 | const store = createApolloStore(); 13 | assert.isDefined(store); 14 | }); 15 | 16 | it('has a default root key', () => { 17 | const store = createApolloStore(); 18 | assert.deepEqual( 19 | store.getState()['apollo'], 20 | { 21 | queries: {}, 22 | mutations: {}, 23 | data: {}, 24 | optimistic: [], 25 | reducerError: null, 26 | }, 27 | ); 28 | }); 29 | 30 | it('can take a custom root key', () => { 31 | const store = createApolloStore({ 32 | reduxRootKey: 'test', 33 | }); 34 | 35 | assert.deepEqual( 36 | store.getState()['test'], 37 | { 38 | queries: {}, 39 | mutations: {}, 40 | data: {}, 41 | optimistic: [], 42 | reducerError: null, 43 | }, 44 | ); 45 | }); 46 | 47 | it('can be rehydrated from the server', () => { 48 | const initialState = { 49 | apollo: { 50 | data: { 51 | 'test.0': true, 52 | }, 53 | optimistic: ([] as any[]), 54 | }, 55 | }; 56 | 57 | const store = createApolloStore({ 58 | initialState, 59 | }); 60 | 61 | assert.deepEqual(store.getState(), { 62 | apollo: { 63 | queries: {}, 64 | mutations: {}, 65 | data: initialState.apollo.data, 66 | optimistic: initialState.apollo.optimistic, 67 | reducerError: null, 68 | }, 69 | }); 70 | }); 71 | 72 | it('throws an error if state contains a non-empty queries field', () => { 73 | const initialState = { 74 | apollo: { 75 | queries: { 76 | 'test.0': true, 77 | }, 78 | mutations: {}, 79 | data: { 80 | 'test.0': true, 81 | }, 82 | optimistic: ([] as any[]), 83 | }, 84 | }; 85 | 86 | assert.throws(() => createApolloStore({ 87 | initialState, 88 | })); 89 | }); 90 | 91 | it('throws an error if state contains a non-empty mutations field', () => { 92 | const initialState = { 93 | apollo: { 94 | queries: {}, 95 | mutations: { 0: true }, 96 | data: { 97 | 'test.0': true, 98 | }, 99 | optimistic: ([] as any[]), 100 | }, 101 | }; 102 | 103 | assert.throws(() => createApolloStore({ 104 | initialState, 105 | })); 106 | }); 107 | 108 | it('reset itself', () => { 109 | const initialState = { 110 | apollo: { 111 | data: { 112 | 'test.0': true, 113 | }, 114 | }, 115 | }; 116 | 117 | const emptyState: Store = { 118 | queries: { }, 119 | mutations: { }, 120 | data: { }, 121 | optimistic: ([] as any[]), 122 | reducerError: null, 123 | }; 124 | 125 | const store = createApolloStore({ 126 | initialState, 127 | }); 128 | 129 | store.dispatch({ 130 | type: 'APOLLO_STORE_RESET', 131 | observableQueryIds: [], 132 | }); 133 | 134 | assert.deepEqual(store.getState().apollo, emptyState); 135 | }); 136 | 137 | it('can reset itself and keep the observable query ids', () => { 138 | const queryDocument = gql` query { abc }`; 139 | 140 | const initialState = { 141 | apollo: { 142 | data: { 143 | 'test.0': true, 144 | 'test.1': true, 145 | }, 146 | optimistic: ([] as any[]), 147 | }, 148 | }; 149 | 150 | const emptyState: Store = { 151 | queries: { 152 | 'test.0': { 153 | 'forceFetch': false, 154 | 'graphQLErrors': [], 155 | 'lastRequestId': 1, 156 | 'networkStatus': 1, 157 | 'networkError': null, 158 | 'previousVariables': null, 159 | 'queryString': '', 160 | 'document': queryDocument, 161 | 'returnPartialData': false, 162 | 'variables': {}, 163 | 'metadata': null, 164 | }, 165 | }, 166 | mutations: {}, 167 | data: {}, 168 | optimistic: ([] as any[]), 169 | reducerError: null, 170 | }; 171 | 172 | const store = createApolloStore({ 173 | initialState, 174 | }); 175 | 176 | store.dispatch({ 177 | type: 'APOLLO_QUERY_INIT', 178 | queryId: 'test.0', 179 | queryString: '', 180 | document: queryDocument, 181 | variables: {}, 182 | forceFetch: false, 183 | returnPartialData: false, 184 | requestId: 1, 185 | storePreviousVariables: false, 186 | isPoll: false, 187 | isRefetch: false, 188 | metadata: null, 189 | }); 190 | 191 | store.dispatch({ 192 | type: 'APOLLO_STORE_RESET', 193 | observableQueryIds: ['test.0'], 194 | }); 195 | 196 | assert.deepEqual(store.getState().apollo, emptyState); 197 | }); 198 | 199 | it('can\'t crash the reducer', () => { 200 | const initialState = { 201 | apollo: { 202 | data: {}, 203 | optimistic: ([] as any[]), 204 | reducerError: (null as Error | null), 205 | }, 206 | }; 207 | 208 | const store = createApolloStore({ 209 | initialState, 210 | }); 211 | 212 | // Try to crash the store with a bad behavior update 213 | const mutationString = `mutation Increment { incrementer { counter } }`; 214 | const mutation = gql`${mutationString}`; 215 | const variables = {}; 216 | 217 | store.dispatch({ 218 | type: 'APOLLO_MUTATION_INIT', 219 | mutationString, 220 | mutation, 221 | variables, 222 | operationName: 'Increment', 223 | mutationId: '1', 224 | optimisticResponse: {data: {incrementer: {counter: 1}}}, 225 | }); 226 | 227 | store.dispatch({ 228 | type: 'APOLLO_MUTATION_RESULT', 229 | result: {data: {incrementer: {counter: 1}}}, 230 | document: mutation, 231 | operationName: 'Increment', 232 | variables, 233 | mutationId: '1', 234 | extraReducers: [() => { throw new Error('test!!!'); }], 235 | }); 236 | 237 | assert(/test!!!/.test(store.getState().apollo.reducerError)); 238 | 239 | const resetState = { 240 | queries: {}, 241 | mutations: {}, 242 | data: {}, 243 | optimistic: [ 244 | { 245 | data: {}, 246 | mutationId: '1', 247 | action: { 248 | type: 'APOLLO_MUTATION_RESULT', 249 | result: {data: {data: {incrementer: {counter: 1}}}}, 250 | document: mutation, 251 | operationName: 'Increment', 252 | variables: {}, 253 | mutationId: '1', 254 | extraReducers: undefined as undefined, 255 | updateQueries: undefined as undefined, 256 | }, 257 | }, 258 | ], 259 | reducerError: (null as Error | null), 260 | }; 261 | 262 | store.dispatch({ 263 | type: 'APOLLO_STORE_RESET', 264 | observableQueryIds: ['test.0'], 265 | }); 266 | 267 | assert.deepEqual(store.getState().apollo, resetState); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /test/mocks/mockNetworkInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NetworkInterface, 3 | BatchedNetworkInterface, 4 | Request, 5 | SubscriptionNetworkInterface, 6 | } from '../../src/transport/networkInterface'; 7 | 8 | import { 9 | ExecutionResult, 10 | DocumentNode, 11 | } from 'graphql'; 12 | 13 | import { 14 | print, 15 | } from 'graphql-tag/printer'; 16 | 17 | // Pass in multiple mocked responses, so that you can test flows that end up 18 | // making multiple queries to the server 19 | export default function mockNetworkInterface( 20 | ...mockedResponses: MockedResponse[], 21 | ): NetworkInterface { 22 | return new MockNetworkInterface(mockedResponses); 23 | } 24 | 25 | export function mockSubscriptionNetworkInterface( 26 | mockedSubscriptions: MockedSubscription[], ...mockedResponses: MockedResponse[], 27 | ): MockSubscriptionNetworkInterface { 28 | return new MockSubscriptionNetworkInterface(mockedSubscriptions, mockedResponses); 29 | } 30 | 31 | export function mockBatchedNetworkInterface( 32 | ...mockedResponses: MockedResponse[], 33 | ): BatchedNetworkInterface { 34 | return new MockBatchedNetworkInterface(mockedResponses); 35 | } 36 | 37 | export interface ParsedRequest { 38 | variables?: Object; 39 | query?: DocumentNode; 40 | debugName?: string; 41 | } 42 | 43 | export interface MockedResponse { 44 | request: ParsedRequest; 45 | result?: ExecutionResult; 46 | error?: Error; 47 | delay?: number; 48 | } 49 | 50 | export interface MockedSubscriptionResult { 51 | result?: ExecutionResult; 52 | error?: Error; 53 | delay?: number; 54 | } 55 | 56 | export interface MockedSubscription { 57 | request: ParsedRequest; 58 | results?: MockedSubscriptionResult[]; 59 | id?: number; 60 | } 61 | 62 | export class MockNetworkInterface implements NetworkInterface { 63 | private mockedResponsesByKey: { [key: string]: MockedResponse[] } = {}; 64 | 65 | constructor(mockedResponses: MockedResponse[]) { 66 | mockedResponses.forEach((mockedResponse) => { 67 | this.addMockedResponse(mockedResponse); 68 | }); 69 | } 70 | 71 | public addMockedResponse(mockedResponse: MockedResponse) { 72 | const key = requestToKey(mockedResponse.request); 73 | let mockedResponses = this.mockedResponsesByKey[key]; 74 | if (!mockedResponses) { 75 | mockedResponses = []; 76 | this.mockedResponsesByKey[key] = mockedResponses; 77 | } 78 | mockedResponses.push(mockedResponse); 79 | } 80 | 81 | public query(request: Request) { 82 | return new Promise((resolve, reject) => { 83 | const parsedRequest: ParsedRequest = { 84 | query: request.query, 85 | variables: request.variables, 86 | debugName: request.debugName, 87 | }; 88 | 89 | const key = requestToKey(parsedRequest); 90 | const responses = this.mockedResponsesByKey[key]; 91 | if (!responses || responses.length === 0) { 92 | throw new Error(`No more mocked responses for the query: ${print(request.query)}, variables: ${JSON.stringify(request.variables)}`); 93 | } 94 | 95 | const { result, error, delay } = responses.shift()!; 96 | 97 | if (!result && !error) { 98 | throw new Error(`Mocked response should contain either result or error: ${key}`); 99 | } 100 | 101 | setTimeout(() => { 102 | if (error) { 103 | reject(error); 104 | } else { 105 | resolve(result); 106 | } 107 | }, delay ? delay : 0); 108 | }); 109 | } 110 | } 111 | 112 | export class MockSubscriptionNetworkInterface extends MockNetworkInterface implements SubscriptionNetworkInterface { 113 | public mockedSubscriptionsByKey: { [key: string ]: MockedSubscription[] } = {}; 114 | public mockedSubscriptionsById: { [id: number]: MockedSubscription} = {}; 115 | public handlersById: {[id: number]: (error: any, result: any) => void} = {}; 116 | public subId: number; 117 | 118 | constructor(mockedSubscriptions: MockedSubscription[], mockedResponses: MockedResponse[]) { 119 | super(mockedResponses); 120 | this.subId = 0; 121 | mockedSubscriptions.forEach((sub) => { 122 | this.addMockedSubscription(sub); 123 | }); 124 | } 125 | public generateSubscriptionId() { 126 | const requestId = this.subId; 127 | this.subId++; 128 | return requestId; 129 | } 130 | 131 | public addMockedSubscription(mockedSubscription: MockedSubscription) { 132 | const key = requestToKey(mockedSubscription.request); 133 | if (mockedSubscription.id === undefined) { 134 | mockedSubscription.id = this.generateSubscriptionId(); 135 | } 136 | 137 | let mockedSubs = this.mockedSubscriptionsByKey[key]; 138 | if (!mockedSubs) { 139 | mockedSubs = []; 140 | this.mockedSubscriptionsByKey[key] = mockedSubs; 141 | } 142 | mockedSubs.push(mockedSubscription); 143 | } 144 | 145 | public subscribe(request: Request, handler: (error: any, result: any) => void): number { 146 | const parsedRequest: ParsedRequest = { 147 | query: request.query, 148 | variables: request.variables, 149 | debugName: request.debugName, 150 | }; 151 | const key = requestToKey(parsedRequest); 152 | if (this.mockedSubscriptionsByKey.hasOwnProperty(key)) { 153 | const subscription = this.mockedSubscriptionsByKey[key].shift()!; 154 | const id = subscription.id!; 155 | this.handlersById[id] = handler; 156 | this.mockedSubscriptionsById[id] = subscription; 157 | return id; 158 | } else { 159 | throw new Error('Network interface does not have subscription associated with this request.'); 160 | } 161 | 162 | }; 163 | 164 | public fireResult(id: number) { 165 | const handler = this.handlersById[id]; 166 | if (this.mockedSubscriptionsById.hasOwnProperty(id.toString())) { 167 | const subscription = this.mockedSubscriptionsById[id]; 168 | if (subscription.results!.length === 0) { 169 | throw new Error(`No more mocked subscription responses for the query: ` + 170 | `${print(subscription.request.query)}, variables: ${JSON.stringify(subscription.request.variables)}`); 171 | } 172 | const response = subscription.results!.shift()!; 173 | setTimeout(() => { 174 | handler(response.error, response.result); 175 | }, response.delay ? response.delay : 0); 176 | } else { 177 | throw new Error('Network interface does not have subscription associated with this id.'); 178 | } 179 | } 180 | 181 | public unsubscribe(id: number) { 182 | delete this.mockedSubscriptionsById[id]; 183 | } 184 | } 185 | 186 | export class MockBatchedNetworkInterface 187 | extends MockNetworkInterface implements BatchedNetworkInterface { 188 | 189 | public batchQuery(requests: Request[]): Promise { 190 | const resultPromises: Promise[] = []; 191 | requests.forEach((request) => { 192 | resultPromises.push(this.query(request)); 193 | }); 194 | 195 | return Promise.all(resultPromises); 196 | } 197 | } 198 | 199 | 200 | function requestToKey(request: ParsedRequest): string { 201 | const queryString = request.query && print(request.query); 202 | 203 | return JSON.stringify({ 204 | variables: request.variables || {}, 205 | debugName: request.debugName, 206 | query: queryString, 207 | }); 208 | } 209 | -------------------------------------------------------------------------------- /src/queries/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloAction, 3 | isQueryInitAction, 4 | isQueryResultAction, 5 | isQueryErrorAction, 6 | isQueryResultClientAction, 7 | isQueryStopAction, 8 | isStoreResetAction, 9 | StoreResetAction, 10 | } from '../actions'; 11 | 12 | import { 13 | graphQLResultHasError, 14 | } from '../data/storeUtils'; 15 | 16 | import { 17 | DocumentNode, 18 | SelectionSetNode, 19 | GraphQLError, 20 | } from 'graphql'; 21 | 22 | import { isEqual } from '../util/isEqual'; 23 | 24 | import { NetworkStatus } from './networkStatus'; 25 | 26 | export interface QueryStore { 27 | [queryId: string]: QueryStoreValue; 28 | } 29 | 30 | export type QueryStoreValue = { 31 | queryString: string; 32 | document: DocumentNode; 33 | variables: Object; 34 | previousVariables: Object | null; 35 | networkStatus: NetworkStatus; 36 | networkError: Error | null; 37 | graphQLErrors: GraphQLError[]; 38 | forceFetch: boolean; 39 | returnPartialData: boolean; 40 | lastRequestId: number; 41 | metadata: any; 42 | }; 43 | 44 | export interface SelectionSetWithRoot { 45 | id: string; 46 | typeName: string; 47 | selectionSet: SelectionSetNode; 48 | } 49 | 50 | export function queries( 51 | previousState: QueryStore = {}, 52 | action: ApolloAction, 53 | ): QueryStore { 54 | if (isQueryInitAction(action)) { 55 | const newState = { ...previousState } as QueryStore; 56 | 57 | const previousQuery = previousState[action.queryId]; 58 | 59 | if (previousQuery && previousQuery.queryString !== action.queryString) { 60 | // XXX we're throwing an error here to catch bugs where a query gets overwritten by a new one. 61 | // we should implement a separate action for refetching so that QUERY_INIT may never overwrite 62 | // an existing query (see also: https://github.com/apollostack/apollo-client/issues/732) 63 | throw new Error('Internal Error: may not update existing query string in store'); 64 | } 65 | 66 | let isSetVariables = false; 67 | 68 | let previousVariables: Object | null = null; 69 | if ( 70 | action.storePreviousVariables && 71 | previousQuery && 72 | previousQuery.networkStatus !== NetworkStatus.loading 73 | // if the previous query was still loading, we don't want to remember it at all. 74 | ) { 75 | if (!isEqual(previousQuery.variables, action.variables)) { 76 | isSetVariables = true; 77 | previousVariables = previousQuery.variables; 78 | } 79 | } 80 | 81 | // TODO break this out into a separate function 82 | let newNetworkStatus = NetworkStatus.loading; 83 | 84 | if (isSetVariables) { 85 | newNetworkStatus = NetworkStatus.setVariables; 86 | } else if (action.isPoll) { 87 | newNetworkStatus = NetworkStatus.poll; 88 | } else if (action.isRefetch) { 89 | newNetworkStatus = NetworkStatus.refetch; 90 | // TODO: can we determine setVariables here if it's a refetch and the variables have changed? 91 | } else if (action.isPoll) { 92 | newNetworkStatus = NetworkStatus.poll; 93 | } 94 | 95 | // XXX right now if QUERY_INIT is fired twice, like in a refetch situation, we just overwrite 96 | // the store. We probably want a refetch action instead, because I suspect that if you refetch 97 | // before the initial fetch is done, you'll get an error. 98 | newState[action.queryId] = { 99 | queryString: action.queryString, 100 | document: action.document, 101 | variables: action.variables, 102 | previousVariables, 103 | networkError: null, 104 | graphQLErrors: [], 105 | networkStatus: newNetworkStatus, 106 | forceFetch: action.forceFetch, 107 | returnPartialData: action.returnPartialData, 108 | lastRequestId: action.requestId, 109 | metadata: action.metadata, 110 | }; 111 | 112 | return newState; 113 | } else if (isQueryResultAction(action)) { 114 | if (!previousState[action.queryId]) { 115 | return previousState; 116 | } 117 | 118 | // Ignore results from old requests 119 | if (action.requestId < previousState[action.queryId].lastRequestId) { 120 | return previousState; 121 | } 122 | 123 | const newState = { ...previousState } as QueryStore; 124 | const resultHasGraphQLErrors = graphQLResultHasError(action.result); 125 | 126 | newState[action.queryId] = { 127 | ...previousState[action.queryId], 128 | networkError: null, 129 | graphQLErrors: resultHasGraphQLErrors ? action.result.errors : [], 130 | previousVariables: null, 131 | networkStatus: NetworkStatus.ready, 132 | }; 133 | 134 | return newState; 135 | } else if (isQueryErrorAction(action)) { 136 | if (!previousState[action.queryId]) { 137 | return previousState; 138 | } 139 | 140 | // Ignore results from old requests 141 | if (action.requestId < previousState[action.queryId].lastRequestId) { 142 | return previousState; 143 | } 144 | 145 | const newState = { ...previousState } as QueryStore; 146 | 147 | newState[action.queryId] = { 148 | ...previousState[action.queryId], 149 | networkError: action.error, 150 | networkStatus: NetworkStatus.error, 151 | }; 152 | 153 | return newState; 154 | } else if (isQueryResultClientAction(action)) { 155 | if (!previousState[action.queryId]) { 156 | return previousState; 157 | } 158 | 159 | const newState = { ...previousState } as QueryStore; 160 | 161 | newState[action.queryId] = { 162 | ...previousState[action.queryId], 163 | networkError: null, 164 | previousVariables: null, 165 | // XXX I'm not sure what exactly action.complete really means. I assume it means we have the complete result 166 | // and do not need to hit the server. Not sure when we'd fire this action if the result is not complete, so that bears explanation. 167 | // We should write that down somewhere. 168 | networkStatus: action.complete ? NetworkStatus.ready : NetworkStatus.loading, 169 | }; 170 | 171 | return newState; 172 | } else if (isQueryStopAction(action)) { 173 | const newState = { ...previousState } as QueryStore; 174 | 175 | delete newState[action.queryId]; 176 | return newState; 177 | } else if (isStoreResetAction(action)) { 178 | return resetQueryState(previousState, action); 179 | } 180 | 181 | return previousState; 182 | } 183 | 184 | // Returns the new query state after we receive a store reset action. 185 | // Note that we don't remove the query state for the query IDs that are associated with watchQuery() 186 | // observables. This is because these observables are simply refetched and not 187 | // errored in the event of a store reset. 188 | function resetQueryState(state: QueryStore, action: StoreResetAction): QueryStore { 189 | const observableQueryIds = action.observableQueryIds; 190 | 191 | // keep only the queries with query ids that are associated with observables 192 | const newQueries = Object.keys(state).filter((queryId) => { 193 | return (observableQueryIds.indexOf(queryId) > -1); 194 | }).reduce((res, key) => { 195 | // XXX set loading to true so listeners don't trigger unless they want results with partial data 196 | res[key] = { 197 | ...state[key], 198 | networkStatus: NetworkStatus.loading, 199 | }; 200 | 201 | return res; 202 | }, {} as QueryStore); 203 | 204 | return newQueries; 205 | } 206 | -------------------------------------------------------------------------------- /test/getFromAST.ts: -------------------------------------------------------------------------------- 1 | import { 2 | checkDocument, 3 | getFragmentDefinitions, 4 | getQueryDefinition, 5 | getMutationDefinition, 6 | createFragmentMap, 7 | FragmentMap, 8 | getOperationName, 9 | } from '../src/queries/getFromAST'; 10 | 11 | import { 12 | FragmentDefinitionNode, 13 | OperationDefinitionNode, 14 | } from 'graphql'; 15 | 16 | import { print } from 'graphql-tag/printer'; 17 | import gql from 'graphql-tag'; 18 | import { assert } from 'chai'; 19 | 20 | describe('AST utility functions', () => { 21 | it('should correctly check a document for correctness', () => { 22 | const multipleQueries = gql` 23 | query { 24 | author { 25 | firstName 26 | lastName 27 | } 28 | } 29 | query { 30 | author { 31 | address 32 | } 33 | }`; 34 | assert.throws(() => { 35 | checkDocument(multipleQueries); 36 | }); 37 | 38 | const namedFragment = gql` 39 | query { 40 | author { 41 | ...authorDetails 42 | } 43 | } 44 | fragment authorDetails on Author { 45 | firstName 46 | lastName 47 | }`; 48 | assert.doesNotThrow(() => { 49 | checkDocument(namedFragment); 50 | }); 51 | }); 52 | 53 | it('should get fragment definitions from a document containing a single fragment', () => { 54 | const singleFragmentDefinition = gql` 55 | query { 56 | author { 57 | ...authorDetails 58 | } 59 | } 60 | fragment authorDetails on Author { 61 | firstName 62 | lastName 63 | }`; 64 | const expectedDoc = gql` 65 | fragment authorDetails on Author { 66 | firstName 67 | lastName 68 | }`; 69 | const expectedResult: FragmentDefinitionNode[] = [expectedDoc.definitions[0] as FragmentDefinitionNode]; 70 | const actualResult = getFragmentDefinitions(singleFragmentDefinition); 71 | assert.equal(actualResult.length, expectedResult.length); 72 | assert.equal(print(actualResult[0]), print(expectedResult[0])); 73 | }); 74 | 75 | it('should get fragment definitions from a document containing a multiple fragments', () => { 76 | const multipleFragmentDefinitions = gql` 77 | query { 78 | author { 79 | ...authorDetails 80 | ...moreAuthorDetails 81 | } 82 | } 83 | fragment authorDetails on Author { 84 | firstName 85 | lastName 86 | } 87 | fragment moreAuthorDetails on Author { 88 | address 89 | }`; 90 | const expectedDoc = gql` 91 | fragment authorDetails on Author { 92 | firstName 93 | lastName 94 | } 95 | fragment moreAuthorDetails on Author { 96 | address 97 | }`; 98 | const expectedResult: FragmentDefinitionNode[] = [ 99 | expectedDoc.definitions[0] as FragmentDefinitionNode, 100 | expectedDoc.definitions[1] as FragmentDefinitionNode, 101 | ]; 102 | const actualResult = getFragmentDefinitions(multipleFragmentDefinitions); 103 | assert.deepEqual(actualResult.map(print), expectedResult.map(print)); 104 | }); 105 | 106 | it('should get the correct query definition out of a query containing multiple fragments', () => { 107 | const queryWithFragments = gql` 108 | fragment authorDetails on Author { 109 | firstName 110 | lastName 111 | } 112 | fragment moreAuthorDetails on Author { 113 | address 114 | } 115 | query { 116 | author { 117 | ...authorDetails 118 | ...moreAuthorDetails 119 | } 120 | }`; 121 | const expectedDoc = gql` 122 | query { 123 | author { 124 | ...authorDetails 125 | ...moreAuthorDetails 126 | } 127 | }`; 128 | const expectedResult: OperationDefinitionNode = expectedDoc.definitions[0] as OperationDefinitionNode; 129 | const actualResult = getQueryDefinition(queryWithFragments); 130 | 131 | assert.equal(print(actualResult), print(expectedResult)); 132 | }); 133 | 134 | it('should throw if we try to get the query definition of a document with no query', () => { 135 | const mutationWithFragments = gql` 136 | fragment authorDetails on Author { 137 | firstName 138 | lastName 139 | } 140 | 141 | mutation { 142 | createAuthor(firstName: "John", lastName: "Smith") { 143 | ...authorDetails 144 | } 145 | }`; 146 | assert.throws(() => { 147 | getQueryDefinition(mutationWithFragments); 148 | }); 149 | }); 150 | 151 | it('should get the correct mutation definition out of a mutation with multiple fragments', () => { 152 | const mutationWithFragments = gql` 153 | mutation { 154 | createAuthor(firstName: "John", lastName: "Smith") { 155 | ...authorDetails 156 | } 157 | } 158 | fragment authorDetails on Author { 159 | firstName 160 | lastName 161 | }`; 162 | const expectedDoc = gql` 163 | mutation { 164 | createAuthor(firstName: "John", lastName: "Smith") { 165 | ...authorDetails 166 | } 167 | }`; 168 | const expectedResult: OperationDefinitionNode = expectedDoc.definitions[0] as OperationDefinitionNode; 169 | const actualResult = getMutationDefinition(mutationWithFragments); 170 | assert.equal(print(actualResult), print(expectedResult)); 171 | }); 172 | 173 | it('should create the fragment map correctly', () => { 174 | const fragments = getFragmentDefinitions(gql` 175 | fragment authorDetails on Author { 176 | firstName 177 | lastName 178 | } 179 | fragment moreAuthorDetails on Author { 180 | address 181 | }`); 182 | const fragmentMap = createFragmentMap(fragments); 183 | const expectedTable: FragmentMap = { 184 | 'authorDetails': fragments[0], 185 | 'moreAuthorDetails': fragments[1], 186 | }; 187 | assert.deepEqual(fragmentMap, expectedTable); 188 | }); 189 | 190 | it('should return an empty fragment map if passed undefined argument', () => { 191 | assert.deepEqual(createFragmentMap(undefined), {}); 192 | }); 193 | 194 | it('should get the operation name out of a query', () => { 195 | const query = gql` 196 | query nameOfQuery { 197 | fortuneCookie 198 | }`; 199 | const operationName = getOperationName(query); 200 | assert.equal(operationName, 'nameOfQuery'); 201 | }); 202 | 203 | it('should get the operation name out of a mutation', () => { 204 | const query = gql` 205 | mutation nameOfMutation { 206 | fortuneCookie 207 | }`; 208 | const operationName = getOperationName(query); 209 | assert.equal(operationName, 'nameOfMutation'); 210 | }); 211 | 212 | it('should throw if type definitions found in document', () => { 213 | const queryWithTypeDefination = gql` 214 | fragment authorDetails on Author { 215 | firstName 216 | lastName 217 | } 218 | 219 | query($search: AuthorSearchInputType) { 220 | author(search: $search) { 221 | ...authorDetails 222 | } 223 | } 224 | 225 | input AuthorSearchInputType { 226 | firstName: String 227 | }`; 228 | assert.throws(() => { 229 | getQueryDefinition(queryWithTypeDefination); 230 | }, 'Schema type definitions not allowed in queries. Found: "InputObjectTypeDefinition"'); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /src/data/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloAction, 3 | isQueryResultAction, 4 | isMutationResultAction, 5 | isUpdateQueryResultAction, 6 | isStoreResetAction, 7 | isSubscriptionResultAction, 8 | } from '../actions'; 9 | 10 | import { 11 | writeResultToStore, 12 | } from './writeToStore'; 13 | 14 | import { 15 | QueryStore, 16 | } from '../queries/store'; 17 | 18 | import { 19 | getOperationName, 20 | } from '../queries/getFromAST'; 21 | 22 | import { 23 | MutationStore, 24 | } from '../mutations/store'; 25 | 26 | import { 27 | ApolloReducerConfig, 28 | } from '../store'; 29 | 30 | import { 31 | graphQLResultHasError, 32 | NormalizedCache, 33 | } from './storeUtils'; 34 | 35 | import { 36 | replaceQueryResults, 37 | } from './replaceQueryResults'; 38 | 39 | import { 40 | readQueryFromStore, 41 | } from './readFromStore'; 42 | 43 | import { 44 | tryFunctionOrLogError, 45 | } from '../util/errorHandling'; 46 | 47 | export function data( 48 | previousState: NormalizedCache = {}, 49 | action: ApolloAction, 50 | queries: QueryStore, 51 | mutations: MutationStore, 52 | config: ApolloReducerConfig, 53 | ): NormalizedCache { 54 | // XXX This is hopefully a temporary binding to get around 55 | // https://github.com/Microsoft/TypeScript/issues/7719 56 | const constAction = action; 57 | 58 | if (isQueryResultAction(action)) { 59 | if (!queries[action.queryId]) { 60 | return previousState; 61 | } 62 | 63 | // Ignore results from old requests 64 | // XXX this means that if you have a refetch interval which is shorter than your roundtrip time, 65 | // your query will be in the loading state forever! 66 | if (action.requestId < queries[action.queryId].lastRequestId) { 67 | return previousState; 68 | } 69 | 70 | // XXX handle partial result due to errors 71 | if (! graphQLResultHasError(action.result)) { 72 | const queryStoreValue = queries[action.queryId]; 73 | 74 | // XXX use immutablejs instead of cloning 75 | const clonedState = { ...previousState } as NormalizedCache; 76 | 77 | // TODO REFACTOR: is writeResultToStore a good name for something that doesn't actually 78 | // write to "the" store? 79 | let newState = writeResultToStore({ 80 | result: action.result.data, 81 | dataId: 'ROOT_QUERY', // TODO: is this correct? what am I doing here? What is dataId for?? 82 | document: action.document, 83 | variables: queryStoreValue.variables, 84 | store: clonedState, 85 | dataIdFromObject: config.dataIdFromObject, 86 | }); 87 | 88 | // XXX each reducer gets the state from the previous reducer. 89 | // Maybe they should all get a clone instead and then compare at the end to make sure it's consistent. 90 | if (action.extraReducers) { 91 | action.extraReducers.forEach( reducer => { 92 | newState = reducer(newState, constAction); 93 | }); 94 | } 95 | 96 | return newState; 97 | } 98 | } else if (isSubscriptionResultAction(action)) { 99 | // the subscription interface should handle not sending us results we no longer subscribe to. 100 | // XXX I don't think we ever send in an object with errors, but we might in the future... 101 | if (! graphQLResultHasError(action.result)) { 102 | 103 | // XXX use immutablejs instead of cloning 104 | const clonedState = { ...previousState } as NormalizedCache; 105 | 106 | // TODO REFACTOR: is writeResultToStore a good name for something that doesn't actually 107 | // write to "the" store? 108 | let newState = writeResultToStore({ 109 | result: action.result.data, 110 | dataId: 'ROOT_SUBSCRIPTION', 111 | document: action.document, 112 | variables: action.variables, 113 | store: clonedState, 114 | dataIdFromObject: config.dataIdFromObject, 115 | }); 116 | 117 | // XXX each reducer gets the state from the previous reducer. 118 | // Maybe they should all get a clone instead and then compare at the end to make sure it's consistent. 119 | if (action.extraReducers) { 120 | action.extraReducers.forEach( reducer => { 121 | newState = reducer(newState, constAction); 122 | }); 123 | } 124 | 125 | return newState; 126 | } 127 | } else if (isMutationResultAction(constAction)) { 128 | // Incorporate the result from this mutation into the store 129 | if (!constAction.result.errors) { 130 | const queryStoreValue = mutations[constAction.mutationId]; 131 | 132 | // XXX use immutablejs instead of cloning 133 | const clonedState = { ...previousState } as NormalizedCache; 134 | 135 | let newState = writeResultToStore({ 136 | result: constAction.result.data, 137 | dataId: 'ROOT_MUTATION', 138 | document: constAction.document, 139 | variables: queryStoreValue.variables, 140 | store: clonedState, 141 | dataIdFromObject: config.dataIdFromObject, 142 | }); 143 | 144 | // If this action wants us to update certain queries. Let’s do it! 145 | const { updateQueries } = constAction; 146 | if (updateQueries) { 147 | Object.keys(updateQueries).forEach(queryId => { 148 | const query = queries[queryId]; 149 | if (!query) { 150 | return; 151 | } 152 | 153 | // Read the current query result from the store. 154 | const currentQueryResult = readQueryFromStore({ 155 | store: previousState, 156 | query: query.document, 157 | variables: query.variables, 158 | returnPartialData: true, 159 | config, 160 | }); 161 | 162 | const reducer = updateQueries[queryId]; 163 | 164 | // Run our reducer using the current query result and the mutation result. 165 | const nextQueryResult = tryFunctionOrLogError(() => reducer(currentQueryResult, { 166 | mutationResult: constAction.result, 167 | queryName: getOperationName(query.document), 168 | queryVariables: query.variables, 169 | })); 170 | 171 | // Write the modified result back into the store if we got a new result. 172 | if (nextQueryResult) { 173 | newState = writeResultToStore({ 174 | result: nextQueryResult, 175 | dataId: 'ROOT_QUERY', 176 | document: query.document, 177 | variables: query.variables, 178 | store: newState, 179 | dataIdFromObject: config.dataIdFromObject, 180 | }); 181 | } 182 | }); 183 | } 184 | 185 | // XXX each reducer gets the state from the previous reducer. 186 | // Maybe they should all get a clone instead and then compare at the end to make sure it's consistent. 187 | if (constAction.extraReducers) { 188 | constAction.extraReducers.forEach( reducer => { 189 | newState = reducer(newState, constAction); 190 | }); 191 | } 192 | 193 | return newState; 194 | } 195 | } else if (isUpdateQueryResultAction(constAction)) { 196 | return replaceQueryResults(previousState, constAction, config) as NormalizedCache; 197 | } else if (isStoreResetAction(action)) { 198 | // If we are resetting the store, we no longer need any of the data that is currently in 199 | // the store so we can just throw it all away. 200 | return {}; 201 | } 202 | 203 | return previousState; 204 | } 205 | --------------------------------------------------------------------------------