├── .babelrc ├── .gitignore ├── .npmrc ├── .prettierrc ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── README.md ├── docs ├── client.md ├── getting-started-js.md ├── getting-started-ts.md ├── getting-started.md ├── js-schema.md ├── js-server.md ├── memory.md └── performance.md ├── example ├── client │ ├── constants.js │ ├── index.js │ ├── todo-footer.js │ ├── todo-item.js │ ├── todo-model.js │ └── utils.js ├── data.js ├── index.html ├── index.js ├── schema │ ├── objects │ │ ├── root.js │ │ └── todo.js │ └── scalars │ │ ├── id.js │ │ └── json-object.js └── server.js ├── logo ├── bicycle.sketch ├── bicycle.svg └── illustrations │ ├── 1_2.ai │ └── 3_4.ai ├── package.json ├── src ├── BaseObject.ts ├── CacheUpdateType.ts ├── Ctx.ts ├── __tests__ │ └── integration.test.ts ├── build.ts ├── client │ ├── OptimisticValue.src.ts │ ├── OptimisticValue.ts │ ├── OptimisticValueStore.ts │ ├── index.ts │ ├── mutation.ts │ ├── optimistic.ts │ └── request-batcher.ts ├── error-reporting.ts ├── handleMessage.ts ├── load-schema │ ├── TypeAssertions.ts │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.ts.snap │ │ ├── get-type.test.ts │ │ ├── index.test.ts │ │ ├── normalize-object.test.ts │ │ └── normalize-scalar.test.ts │ ├── get-type.ts │ ├── index.ts │ ├── normalize-field.ts │ ├── normalize-fields.ts │ ├── normalize-mutation.ts │ ├── normalize-mutations.ts │ ├── normalize-object.ts │ └── normalize-scalar.ts ├── middleware.ts ├── network-layer.ts ├── runner │ ├── AccessDenied.src.ts │ ├── AccessDenied.ts │ ├── __tests__ │ │ └── args-parser.test.ts │ ├── index.ts │ ├── legacyArgParser.ts │ ├── matchesType.ts │ ├── resolveField.ts │ ├── resolveFieldResult.ts │ ├── runMutation.ts │ ├── runQuery.ts │ └── validate.ts ├── scripts │ ├── helpers │ │ ├── MarkdownEmitter.ts │ │ ├── getApiItem.ts │ │ ├── printApiItem.ts │ │ ├── processFile.ts │ │ └── utils.ts │ └── prepare-docs.ts ├── server-core.ts ├── server-rendering.ts ├── server.ts ├── sessions │ ├── HashLRU.ts │ ├── MemorySessionStore.ts │ └── SessionStore.ts ├── test-schema │ ├── objects │ │ ├── root.ts │ │ └── todo.ts │ └── scalars │ │ └── id.ts ├── typed-helpers │ ├── client.ts │ └── query.ts ├── types │ ├── Cache.ts │ ├── ErrorResult.ts │ ├── Logging.ts │ ├── MutationContext.ts │ ├── MutationID.src.ts │ ├── MutationID.ts │ ├── MutationResult.ts │ ├── NetworkLayerInterface.ts │ ├── NodeID.ts │ ├── Query.ts │ ├── QueryContext.ts │ ├── Request.ts │ ├── Schema.ts │ ├── SchemaKind.ts │ ├── ServerPreparation.ts │ ├── ServerResponse.ts │ ├── SessionID.src.ts │ ├── SessionID.ts │ ├── SessionVersion.src.ts │ ├── SessionVersion.ts │ └── ValueType.ts └── utils │ ├── __tests__ │ ├── diff-cache.test.ts │ ├── diff-queries.test.ts │ ├── get-session-id.test.ts │ ├── merge-cache.test.ts │ ├── merge-queries.test.ts │ ├── not-equal.test.ts │ ├── run-query-against-cache.test.ts │ ├── suggest-match.test.ts │ ├── type-name-from-definition.test.ts │ └── type-name-from-value.test.ts │ ├── create-error.ts │ ├── diff-cache.ts │ ├── diff-queries.ts │ ├── freeze.ts │ ├── get-session-id.ts │ ├── is-cached.ts │ ├── merge-cache.ts │ ├── merge-queries.ts │ ├── not-equal.ts │ ├── run-query-against-cache.ts │ ├── suggest-match.ts │ ├── type-name-from-definition.ts │ └── type-name-from-value.ts ├── tsconfig.json ├── website ├── README.md ├── blog │ ├── 2019-02-22-new-website.md │ └── assets │ │ ├── any-db.svg │ │ └── logo.svg ├── core │ └── Footer.js ├── i18n │ └── en.json ├── package.json ├── pages │ └── en │ │ ├── help.js │ │ ├── index.js │ │ └── users.js ├── sidebars.json ├── siteConfig.js ├── static │ ├── css │ │ └── custom.css │ └── img │ │ ├── docusaurus.svg │ │ ├── favicon.png │ │ ├── illustrations │ │ ├── EasyToUse.svg │ │ ├── RequestWhatYouNeed.svg │ │ ├── Secure.svg │ │ └── UseAnyDatabase.svg │ │ ├── logo │ │ ├── logo-color.svg │ │ ├── logo-white.svg │ │ ├── logo-word-color.svg │ │ └── logo-word-white.svg │ │ ├── oss_logo.png │ │ ├── typescript.svg │ │ └── users │ │ ├── jepso.svg │ │ └── savewillpower.svg └── yarn.lock └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-app"], 3 | "plugins": ["transform-es2015-modules-commonjs"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | pids 10 | logs 11 | results 12 | npm-debug.log 13 | node_modules 14 | /lib 15 | /dev 16 | .DS_Store 17 | /coverage 18 | /dist -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | node_js: 6 | - "6" 7 | 8 | notifications: 9 | email: 10 | on_success: never 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Forbes Lindesay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bicycle 2 | 3 | A data synchronisation library for JavaScript 4 | 5 | [![Build Status](https://img.shields.io/travis/bicyclejs/bicycle/master.svg)](https://travis-ci.org/bicyclejs/bicycle) 6 | [![Dependency Status](https://img.shields.io/david/bicyclejs/bicycle.svg)](https://david-dm.org/bicyclejs/bicycle) 7 | [![NPM version](https://img.shields.io/npm/v/bicycle.svg)](https://www.npmjs.org/package/bicycle) 8 | 9 | ## Installation 10 | 11 | npm install bicycle 12 | 13 | ## Usage 14 | 15 | ### Client 16 | 17 | ```js 18 | import BicycleClient from 'bicycle/lib/client'; 19 | 20 | const client = new BicycleClient(); 21 | 22 | const subscription = client.subscribe( 23 | {todos: {id: true, title: true, completed: true}}, 24 | (result, loaded) => { 25 | // note that if `loaded` is `false`, `result` is a partial result 26 | console.dir(result.todos); 27 | }, 28 | ); 29 | 30 | // to dispose of the subscription: 31 | subscription.unsubscribe(); 32 | 33 | // Use `update` to trigger mutations on the server. Any relevant subscriptions are updated automatically 34 | client.update('Todo.toggle', {id: todoToToggle.id, checked: !todoToToggle.completed}).done( 35 | () => console.log('updated!'), 36 | ); 37 | ``` 38 | 39 | Queries can also take parameters and have aliases, e.g. 40 | 41 | ```js 42 | const subscription = client.subscribe( 43 | {'todosById(id: "whatever") as todo': {id: true, title: true, completed: true}}, 44 | (result, loaded) => { 45 | console.dir(result.todo); 46 | }, 47 | ); 48 | ``` 49 | 50 | ### Server 51 | 52 | ```js 53 | import express from 'express'; 54 | import BicycleServer from 'bicycle/server'; 55 | 56 | const app = express(); 57 | 58 | // other routes etc. here 59 | 60 | // define the schema. 61 | // in a real app you'd want to split schema definition across multiple files 62 | const schema = { 63 | objects: [ 64 | { 65 | name: 'Root', 66 | fields: { 67 | todoById: { 68 | type: 'Todo', 69 | args: {id: 'string'}, 70 | resolve(root, {id}, {user}) { 71 | return getTodo(id); 72 | }, 73 | }, 74 | todos: { 75 | type: 'Todo[]', 76 | resolve(root, args, {user}) { 77 | return getTodos(); 78 | }, 79 | }, 80 | }, 81 | }, 82 | 83 | { 84 | name: 'Todo', 85 | fields: { 86 | id: 'id', 87 | title: 'string', 88 | completed: 'boolean', 89 | }, 90 | mutations: { 91 | addTodo: { 92 | args: {id: 'id', title: 'string', completed: 'boolean'}, 93 | resolve({id, title, completed}, {user}) { 94 | return addTodo({id, title, completed}); 95 | }, 96 | }, 97 | toggleAll: { 98 | args: {checked: 'boolean'}, 99 | resolve({checked}) { 100 | return toggleAll(checked); 101 | }, 102 | }, 103 | toggle: { 104 | args: {id: 'id', checked: 'boolean'}, 105 | resolve({id, checked}, {user}) { 106 | return toggle(id, checked); 107 | }, 108 | }, 109 | destroy: { 110 | args: {id: 'id'}, 111 | resolve({id}, {user}) { 112 | return destroy(id); 113 | }, 114 | }, 115 | save: { 116 | args: {id: 'id', title: 'string'}, 117 | resolve({id, title}, {user}) { 118 | return setTitle(id, title); 119 | }, 120 | }, 121 | clearCompleted: { 122 | resolve(args, {user}) { 123 | return clearCompleted(); 124 | }, 125 | }, 126 | }, 127 | }, 128 | ]; 129 | }; 130 | 131 | const bicycle = new BicycleServer(schema); 132 | 133 | // createMiddleware takes a function that returns the context given a request 134 | // this allows you to only expose information the user is allowed to see 135 | app.use('/bicycle', bicycle.createMiddleware(req => ({user: req.user}))); 136 | 137 | app.listen(3000); 138 | ``` 139 | 140 | #### schema 141 | 142 | Your schema consists of a collection of type definitions. Type definitions can be: 143 | 144 | - objects (a collection of fields, with an ID) 145 | - scalars (there are built in values for `'string'`, `'number'` and `'boolean'`, but you may wish to add your own) 146 | - enums (these take a value from a predetermined set) 147 | 148 | ##### Root Object 149 | 150 | You must always define an ObjectType called `'Root'`. This type is a singleton and is the entry point for all queries. 151 | 152 | e.g. 153 | 154 | ```js 155 | export default { 156 | name: 'Root', 157 | fields: { 158 | todoById: { 159 | type: 'Todo', 160 | args: {id: 'string'}, 161 | resolve(root, {id}, {user}) { 162 | return getTodo(id); 163 | }, 164 | }, 165 | todos: { 166 | type: 'Todo[]', 167 | resolve(root, args, {user}) { 168 | return getTodos(); 169 | }, 170 | }, 171 | }, 172 | }; 173 | ``` 174 | 175 | ##### Object types 176 | 177 | Object types have the following properties: 178 | 179 | - id (`Function`) - A function that takes an object of this type and returns a globally unique id, defaults to `obj => TypeName + obj.id` 180 | - name (`string`, required) - The name of your Object Type 181 | - description (`string`) - An optional string that may be useful for generating automated documentation 182 | - fields (`Map`) - An object mapping field names onto field definitions. 183 | - mutations (`Map`) - An object mapping field names onto mutation definitions. 184 | 185 | Fields can have: 186 | 187 | - type (`typeString`, required) - The type of the field 188 | - args (`Map`) - The type of any arguments the field takes 189 | - description (`string`) - An optional string that may be useful for generating automated documentation 190 | - resolve (`Function`) - A function that takes the object, the args (that have been type checked) and the context and returns the value of the field. Defaults to `obj => obj.fieldName` 191 | 192 | ## License 193 | 194 | MIT 195 | -------------------------------------------------------------------------------- /docs/client.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: server-api 3 | title: BicycleClient 4 | sidebar_label: Client API 5 | --- 6 | 7 | If you don't need to customise any details about the networking, server side 8 | rendering etc. you can just do: 9 | 10 | ```js 11 | import BicycleClient from 'bicycle/client'; 12 | 13 | const client = new BicycleClient(); 14 | 15 | const unsubscribe = client.subscribe({{todos: {id: true, title: true, completed: true}}}, (result, loaded, errors) => { 16 | if (errors.length) { 17 | console.log('Something went wrong loading data'); 18 | } else if (loaded) { 19 | console.dir(result); 20 | } 21 | }); 22 | // call unsubscribe to stop listening 23 | ``` 24 | 25 | You can customize the path (which defaults to `/bicycle`) that requests are sent 26 | to or add headers by passing them to the `NetworkLayer`: 27 | 28 | ```js 29 | import BicycleClient, {NetworkLayer} from 'bicycle/client'; 30 | 31 | const client = new BicycleClient({ 32 | networkLayer: new NetworkLayer('/custom-bicycle-path', { 33 | headers: {'x-csrf-token': CSRF_TOKEN}, 34 | }), 35 | }); 36 | ``` 37 | 38 | If you are doing server sider rendering, you should pass the `serverPreparation` 39 | value you got from bicycle as the second argument to the constructor: 40 | 41 | ```js 42 | import BicycleClient, {NetworkLayer} from 'bicycle/client'; 43 | 44 | const client = new BicycleClient({ 45 | networkLayer: new NetworkLayer('/custom-bicycle-path', { 46 | headers: {'x-csrf-token': CSRF_TOKEN}, 47 | }), 48 | serverPreparation, 49 | }); 50 | ``` 51 | 52 | Alternatively, you can just declare a global variable called 53 | `BICYCLE_SERVER_PREPARATION` which will be detected automatically. 54 | 55 | ## NetworkLayer 56 | 57 | In addition to passing in the bicycle `NetworkLayer` with custom options, you 58 | can also just provide your own implementation. Any object with a `.send(message) 59 | => Promise` method will work fine. On the server side you can then call 60 | `handleMessage` to process the message. 61 | 62 | It should be noted that the built in NetworkLayer also works well on the server 63 | side, providing you override the `'path'` to be a full absolute URI. 64 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getting-started 3 | title: Getting Started 4 | sidebar_label: Intro 5 | --- 6 | 7 | You can use Bicycle with plain JavaScript or with TypeScript. 8 | 9 | * If you're using it with plain JavaScript, we recommend you follow the [JavaScript Guide](getting-started-js.md) 10 | * If you're using it with TypeScript, we recommend you follow the [TypeScript Guide](getting-started-ts.md) -------------------------------------------------------------------------------- /docs/memory.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: sessions-memory 3 | title: MemorySession 4 | sidebar_label: MemorySession 5 | --- 6 | 7 | This module implements a bicycle session store in memory. It's perfect for small scale applications and for testing/development. For larger deployments you should consider storing the session in something like redis or mongo. 8 | 9 | ## Usage 10 | 11 | ```js 12 | import MemorySession from 'bicycle/sessions/memory'; 13 | 14 | // by default, sessions expire after 30 minutes of inactivity. 15 | const session = new MemorySession(); 16 | 17 | // you can save memory on the server, at the expense of clients being more 18 | // likely to need to re-fetch all the data by making the sessions shorter 19 | const ONE_MINUTE = 60 * 1000; 20 | const session = new MemorySession(ONE_MINUTE); 21 | 22 | // you can reduce the likelihood of clients needing to re-fetch all the data 23 | // by increasing the timeout, at the expense of more memory 24 | const ONE_DAY = 24 * 60 * 60 * 1000; 25 | const session = new MemorySession(ONE_DAY); 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/performance.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: performance 3 | title: Performance 4 | sidebar_label: Performance 5 | --- 6 | 7 | If you are attempting to diagnose a slow bicycle query, you can run node with a `--monitor-bicycle-performance` argument, and bicycle will log the total time spent resolving each field. 8 | 9 | If you enable this flag, bicycle forces fields to be resolved one at a time, rather than in parallel, so you should be aware that this will make queries **much** slower. 10 | -------------------------------------------------------------------------------- /example/client/constants.js: -------------------------------------------------------------------------------- 1 | export const ALL_TODOS = 'all'; 2 | export const ACTIVE_TODOS = 'active'; 3 | export const COMPLETED_TODOS = 'completed'; 4 | -------------------------------------------------------------------------------- /example/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TodoModel from './todo-model'; 4 | import TodoFooter from './todo-footer'; 5 | import TodoItem from './todo-item'; 6 | import {ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS} from './constants.js'; 7 | 8 | const ENTER_KEY = 13; 9 | 10 | const TodoApp = React.createClass({ 11 | getInitialState() { 12 | return { 13 | editing: null, 14 | newTodo: '', 15 | }; 16 | }, 17 | 18 | handleChange(event) { 19 | this.setState({newTodo: event.target.value}); 20 | }, 21 | 22 | handleNewTodoKeyDown(event) { 23 | if (event.keyCode !== ENTER_KEY) { 24 | return; 25 | } 26 | 27 | event.preventDefault(); 28 | 29 | const val = this.state.newTodo.trim(); 30 | 31 | if (val) { 32 | this.props.model.addTodo(val); 33 | this.setState({newTodo: ''}); 34 | } 35 | }, 36 | 37 | toggleAll(event) { 38 | const checked = event.target.checked; 39 | this.props.model.toggleAll(checked); 40 | }, 41 | 42 | toggle(todoToToggle) { 43 | this.props.model.toggle(todoToToggle); 44 | }, 45 | 46 | destroy(todo) { 47 | this.props.model.destroy(todo); 48 | }, 49 | 50 | edit(todo) { 51 | this.setState({editing: todo.id}); 52 | }, 53 | 54 | save(todoToSave, text) { 55 | this.props.model.save(todoToSave, text); 56 | this.setState({editing: null}); 57 | }, 58 | 59 | cancel() { 60 | this.setState({editing: null}); 61 | }, 62 | 63 | clearCompleted() { 64 | this.props.model.clearCompleted(); 65 | }, 66 | 67 | componentDidMount() { 68 | 69 | }, 70 | 71 | render() { 72 | let content = null; 73 | try { 74 | content = this._renderContent(); 75 | } catch (ex) { 76 | content =
Could not render todo items: {ex.message}
; 77 | } 78 | 79 | return ( 80 |
81 |
82 |

todos

83 | {this.props.model.errors.map((error, i) => { 84 | return
{error}
; 85 | })} 86 | 94 |
95 | {content} 96 |
97 | ); 98 | }, 99 | _renderContent() { 100 | let nowShowing; 101 | switch (location.hash) { 102 | case '#/completed': 103 | nowShowing = COMPLETED_TODOS; 104 | break; 105 | case '#/active': 106 | nowShowing = ACTIVE_TODOS; 107 | break; 108 | default: 109 | nowShowing = ALL_TODOS; 110 | break; 111 | } 112 | let footer; 113 | let main; 114 | const todos = this.props.model.todos; 115 | 116 | const shownTodos = todos.filter((todo) => { 117 | switch (nowShowing) { 118 | case ACTIVE_TODOS: 119 | return !todo.completed; 120 | case COMPLETED_TODOS: 121 | return todo.completed; 122 | default: 123 | return true; 124 | } 125 | }, this); 126 | 127 | const todoItems = shownTodos.map(function (todo) { 128 | return ( 129 | 139 | ); 140 | }, this); 141 | 142 | const activeTodoCount = todos.reduce((accum, todo) => { 143 | return todo.completed ? accum : accum + 1; 144 | }, 0); 145 | 146 | const completedCount = todos.length - activeTodoCount; 147 | 148 | if (activeTodoCount || completedCount) { 149 | footer = 150 | ; 156 | } 157 | 158 | if (todos.length) { 159 | main = ( 160 |
161 | 167 |
    168 | {todoItems} 169 |
170 |
171 | ); 172 | } 173 | return ( 174 |
175 | {main} 176 | {footer} 177 |
178 | ); 179 | }, 180 | }); 181 | 182 | const model = new TodoModel(); 183 | function render() { 184 | ReactDOM.render( 185 | , 186 | document.getElementsByClassName('todoapp')[0] 187 | ); 188 | } 189 | 190 | model.subscribe(render); 191 | window.addEventListener('hashchange', render, false); 192 | render(); 193 | -------------------------------------------------------------------------------- /example/client/todo-footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {pluralize, classNames} from './utils.js'; 3 | import {ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS} from './constants.js'; 4 | 5 | export default React.createClass({ 6 | render() { 7 | const activeTodoWord = pluralize(this.props.count, 'item'); 8 | let clearButton = null; 9 | 10 | if (this.props.completedCount > 0) { 11 | clearButton = ( 12 | 17 | ); 18 | } 19 | 20 | const nowShowing = this.props.nowShowing; 21 | return ( 22 | 53 | ); 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /example/client/todo-item.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {classNames} from './utils.js'; 4 | 5 | const ESCAPE_KEY = 27; 6 | const ENTER_KEY = 13; 7 | 8 | export default React.createClass({ 9 | handleSubmit(event) { 10 | const val = this.state.editText.trim(); 11 | if (val) { 12 | this.props.onSave(val); 13 | this.setState({editText: val}); 14 | } else { 15 | this.props.onDestroy(); 16 | } 17 | }, 18 | 19 | handleEdit() { 20 | this.props.onEdit(); 21 | this.setState({editText: this.props.todo.title}); 22 | }, 23 | 24 | handleKeyDown(event) { 25 | if (event.which === ESCAPE_KEY) { 26 | this.setState({editText: this.props.todo.title}); 27 | this.props.onCancel(event); 28 | } else if (event.which === ENTER_KEY) { 29 | this.handleSubmit(event); 30 | } 31 | }, 32 | 33 | handleChange(event) { 34 | if (this.props.editing) { 35 | this.setState({editText: event.target.value}); 36 | } 37 | }, 38 | 39 | getInitialState() { 40 | return {editText: this.props.todo.title}; 41 | }, 42 | 43 | /** 44 | * This is a completely optional performance enhancement that you can 45 | * implement on any React component. If you were to delete this method 46 | * the app would still work correctly (and still be very performant!), we 47 | * just use it as an example of how little code it takes to get an order 48 | * of magnitude performance improvement. 49 | */ 50 | shouldComponentUpdate(nextProps, nextState) { 51 | return ( 52 | nextProps.todo !== this.props.todo || 53 | nextProps.editing !== this.props.editing || 54 | nextState.editText !== this.state.editText 55 | ); 56 | }, 57 | 58 | /** 59 | * Safely manipulate the DOM after updating the state when invoking 60 | * `this.props.onEdit()` in the `handleEdit` method above. 61 | * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate 62 | * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate 63 | */ 64 | componentDidUpdate(prevProps) { 65 | if (!prevProps.editing && this.props.editing) { 66 | const node = ReactDOM.findDOMNode(this.refs.editField); 67 | node.focus(); 68 | node.setSelectionRange(node.value.length, node.value.length); 69 | } 70 | }, 71 | 72 | render() { 73 | return ( 74 |
  • 78 |
    79 | 85 | 88 |
    90 | 98 |
  • 99 | ); 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /example/client/todo-model.js: -------------------------------------------------------------------------------- 1 | import BicycleClient, {createNodeID} from '../../lib/client'; // in a real app, the path should be 'bicycle/client' 2 | 3 | export default function TodoModel() { 4 | this.errors = []; 5 | this.todos = []; 6 | this.onChanges = []; 7 | 8 | this._client = new BicycleClient(); 9 | this._subscription = this._client.subscribe({ 10 | todos: {id: true, title: true, completed: true}, 11 | obj: true, 12 | // intentional typo 13 | // todoos: true, 14 | }, (result, loaded, errors) => { 15 | if (loaded) { // ignore partial results 16 | console.dir(result); 17 | if (Array.isArray(result.todos)) { 18 | this.todos = result.todos; 19 | } 20 | this.errors = errors; 21 | this.inform(); 22 | } 23 | }); 24 | } 25 | 26 | TodoModel.prototype.subscribe = function (onChange) { 27 | this.onChanges.push(onChange); 28 | }; 29 | 30 | TodoModel.prototype.inform = function () { 31 | this.onChanges.forEach(cb => cb()); 32 | }; 33 | 34 | TodoModel.prototype.addTodo = function (title) { 35 | this._client.update('Todo.addTodo', {title, completed: false}, (mutation, cache, optimistic) => { 36 | const id = optimistic('id'); 37 | const todo = cache.getObject('Todo', id); 38 | todo.set('id', id); 39 | todo.set('title', mutation.args.title); 40 | todo.set('completed', mutation.args.completed); 41 | 42 | const todos = cache.get('todos') || []; 43 | cache.set('todos', [todo].concat(todos)); 44 | }); 45 | }; 46 | 47 | TodoModel.prototype.toggleAll = function (checked) { 48 | this._client.update('Todo.toggleAll', {checked}, (mutation, cache) => { 49 | const todos = cache.get('todos') || []; 50 | todos.forEach(todo => { 51 | todo.set('completed', checked); 52 | }); 53 | }); 54 | }; 55 | 56 | TodoModel.prototype.toggle = function (todoToToggle) { 57 | this._client.update('Todo.toggle', {id: todoToToggle.id, checked: !todoToToggle.completed}, (mutation, cache) => { 58 | cache.getObject('Todo', mutation.args.id).set('completed', mutation.args.checked); 59 | }); 60 | }; 61 | 62 | TodoModel.prototype.destroy = function (todo) { 63 | this._client.update('Todo.destroy', {id: todo.id}, (mutation, cache) => { 64 | const todos = cache.get('todos') || []; 65 | cache.set('todos', todos.filter(t => t.get('id') !== mutation.args.id)); 66 | }); 67 | }; 68 | 69 | TodoModel.prototype.save = function (todoToSave, text) { 70 | this._client.update('Todo.save', {id: todoToSave.id, title: text}, (mutation, cache) => { 71 | cache.getObject('Todo', mutation.args.id).set('title', mutation.args.title); 72 | }); 73 | }; 74 | 75 | TodoModel.prototype.clearCompleted = function () { 76 | this._client.update('Todo.clearCompleted', undefined, (mutation, cache) => { 77 | const todos = cache.get('todos') || []; 78 | cache.set('todos', todos.filter(t => !t.get('completed'))); 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /example/client/utils.js: -------------------------------------------------------------------------------- 1 | export function pluralize(count, word) { 2 | return count === 1 ? word : word + 's'; 3 | } 4 | 5 | export function classNames(...args) { 6 | // based on https://github.com/JedWatson/classnames 7 | let classes = ''; 8 | 9 | args.forEach(arg => { 10 | if (arg) { 11 | const argType = typeof arg; 12 | 13 | if (argType === 'string' || argType === 'number') { 14 | classes += ' ' + arg; 15 | } else if (Array.isArray(arg)) { 16 | classes += ' ' + classNames(...arg); 17 | } else if (argType === 'object') { 18 | Object.keys(arg).forEach(key => { 19 | if (arg[key]) { 20 | classes += ' ' + key; 21 | } 22 | }); 23 | } 24 | } 25 | }); 26 | 27 | return classes.substr(1); 28 | } 29 | 30 | export function uuid() { 31 | let i, random; 32 | let uuid = ''; 33 | 34 | for (i = 0; i < 32; i++) { 35 | random = Math.random() * 16 | 0; 36 | if (i === 8 || i === 12 || i === 16 || i === 20) { 37 | uuid += '-'; 38 | } 39 | uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) 40 | .toString(16); 41 | } 42 | 43 | return uuid; 44 | } 45 | -------------------------------------------------------------------------------- /example/data.js: -------------------------------------------------------------------------------- 1 | // this file is to simulate a database, its content probably isn't very interesting. 2 | 3 | import Promise from 'promise'; 4 | import {uuid} from './client/utils.js'; 5 | 6 | const LATENCY = 1000; 7 | 8 | let todos = [ 9 | {id: uuid(), title: 'Build Bicycle', completed: false}, 10 | {id: uuid(), title: 'Create an example', completed: false}, 11 | ]; 12 | 13 | export function addTodo(todo) { 14 | todo = {id: uuid(), title: todo.title, completed: todo.completed}; 15 | todos.unshift(todo); 16 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }).then(() => todo.id); 17 | } 18 | 19 | export function toggleAll(checked) { 20 | todos.forEach(todo => { 21 | todo.completed = checked; 22 | }); 23 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }); 24 | } 25 | 26 | export function toggle(id, checked) { 27 | todos.filter(t => t.id === id).forEach(todo => todo.completed = checked); 28 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }); 29 | } 30 | 31 | export function destroy(id) { 32 | for (let i = 0; i < todos.length; i++) { 33 | if (todos[i].id === id) { 34 | todos.splice(i, 1); 35 | } 36 | } 37 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }); 38 | } 39 | 40 | export function setTitle(id, title) { 41 | for (let i = 0; i < todos.length; i++) { 42 | if (todos[i].id === id) { 43 | todos[i].title = title; 44 | } 45 | } 46 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }); 47 | } 48 | 49 | export function clearCompleted() { 50 | todos = todos.filter(t => !t.completed); 51 | return new Promise((resolve) => { setTimeout(resolve, LATENCY); }); 52 | } 53 | 54 | export function getTodos() { 55 | return new Promise((resolve) => { setTimeout(() => resolve(JSON.parse(JSON.stringify(todos))), LATENCY); }); 56 | } 57 | 58 | export function getTodo(id) { 59 | return new Promise((resolve) => { setTimeout(() => resolve(JSON.parse(JSON.stringify(todos.filter(t => t.id === id)[0] || null))), LATENCY); }); 60 | } 61 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React • TodoMVC 6 | 7 | 8 | 9 |
    10 |
    11 |

    Double-click to edit a todo

    12 |

    Created by petehunt

    13 |

    Part of TodoMVC

    14 |
    15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | require("babel-register"); 2 | require('./server.js'); 3 | -------------------------------------------------------------------------------- /example/schema/objects/root.js: -------------------------------------------------------------------------------- 1 | import {getTodo, getTodos} from '../../data'; 2 | 3 | export default { 4 | name: 'Root', 5 | fields: { 6 | todoById: { 7 | type: 'Todo', 8 | args: {id: 'string'}, 9 | resolve(root, {id}, {user}) { 10 | return getTodo(id); 11 | }, 12 | }, 13 | todos: { 14 | type: 'Todo[]', 15 | resolve(root, args, {user}) { 16 | return getTodos(); 17 | }, 18 | }, 19 | obj: { 20 | type: 'JsonObject', 21 | resolve(root) { 22 | return { 23 | some: {complex: 'object'}, 24 | }; 25 | } 26 | } 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /example/schema/objects/todo.js: -------------------------------------------------------------------------------- 1 | import {addTodo, toggleAll, toggle, destroy, setTitle, clearCompleted} from '../../data'; 2 | 3 | export default { 4 | name: 'Todo', 5 | fields: { 6 | id: 'id', 7 | title: 'string', 8 | completed: 'boolean', 9 | }, 10 | mutations: { 11 | addTodo: { 12 | type: {id: 'id'}, 13 | args: {title: 'string', completed: 'boolean'}, 14 | resolve({title, completed}, {user}) { 15 | return addTodo({title, completed}).then(id => ({id})); 16 | }, 17 | }, 18 | toggleAll: { 19 | args: {checked: 'boolean'}, 20 | resolve({checked}) { 21 | return toggleAll(checked); 22 | }, 23 | }, 24 | toggle: { 25 | args: {id: 'id', checked: 'boolean'}, 26 | resolve({id, checked}, {user}) { 27 | return toggle(id, checked); 28 | }, 29 | }, 30 | destroy: { 31 | args: {id: 'id'}, 32 | resolve({id}, {user}) { 33 | return destroy(id); 34 | }, 35 | }, 36 | save: { 37 | args: {id: 'id', title: 'string'}, 38 | resolve({id, title}, {user}) { 39 | return setTitle(id, title); 40 | }, 41 | }, 42 | clearCompleted: { 43 | resolve(args, {user}) { 44 | return clearCompleted(); 45 | }, 46 | }, 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /example/schema/scalars/id.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'id', 3 | baseType: 'string', 4 | validate(value) { 5 | // validate that it matches the format of the values returned by uuid() 6 | return /^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$/.test(value); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /example/schema/scalars/json-object.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'JsonObject', 3 | baseType: 'any', 4 | validate(value) { 5 | return value && typeof value === 'object'; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import express from 'express'; 3 | import browserify from 'browserify-middleware'; 4 | import babelify from 'babelify'; 5 | import BicycleServer from '../lib/server'; 6 | 7 | const bicycle = new BicycleServer(__dirname + '/schema'); 8 | 9 | const app = express(); 10 | 11 | app.get('/', (req, res, next) => { 12 | res.sendFile(__dirname + '/index.html'); 13 | }); 14 | 15 | const baseCss = fs.readFileSync(require.resolve('todomvc-common/base.css')); 16 | const appCss = fs.readFileSync(require.resolve('todomvc-app-css/index.css')); 17 | app.get('/style.css', (req, res, next) => { 18 | res.type('css'); 19 | res.send(baseCss + '\n' + appCss); 20 | }); 21 | 22 | app.get('/client.js', browserify(__dirname + '/client/index.js', {transform: [babelify]})); 23 | 24 | app.use('/bicycle', bicycle.createMiddleware(req => ({user: req.user}))); 25 | 26 | // TODO: use this capability to actually do server side rendering 27 | const serverRenderer = bicycle.createServerRenderer(req => ({user: 'my user'}), (client, req, ...args) => { 28 | return client.queryCache({todos: {id: true, title: true, completed: true}}).result; 29 | }); 30 | const fakeRequest = {}; 31 | serverRenderer(fakeRequest).then(result => { 32 | console.log('server renderer result'); 33 | console.dir(result, {depth: 10, colors: true}); 34 | }); 35 | 36 | app.listen(3000); 37 | -------------------------------------------------------------------------------- /logo/bicycle.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bicyclejs/bicycle/b822e7ea2cd6f6b6cdbe02f4121e061090d81aef/logo/bicycle.sketch -------------------------------------------------------------------------------- /logo/illustrations/1_2.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bicyclejs/bicycle/b822e7ea2cd6f6b6cdbe02f4121e061090d81aef/logo/illustrations/1_2.ai -------------------------------------------------------------------------------- /logo/illustrations/3_4.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bicyclejs/bicycle/b822e7ea2cd6f6b6cdbe02f4121e061090d81aef/logo/illustrations/3_4.ai -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bicycle", 3 | "private": true, 4 | "version": "9.1.2", 5 | "description": "A data synchronisation library for JavaScript", 6 | "keywords": [], 7 | "dependencies": { 8 | "@authentication/lock-by-id": "^0.0.1", 9 | "@types/body-parser": "^1.16.4", 10 | "@types/cuid": "^1.3.0", 11 | "@types/deep-freeze": "^0.1.1", 12 | "@types/express": "^4.0.36", 13 | "@types/leven": "^2.1.0", 14 | "@types/ms": "^0.7.29", 15 | "@types/node": "^8.0.16", 16 | "body-parser": "^1.15.2", 17 | "character-parser": "^3.0.0", 18 | "cuid": "^2.0.2", 19 | "deep-freeze": "0.0.1", 20 | "leven": "^2.0.0", 21 | "ms": "^1.0.0", 22 | "promise": "^8.0.1", 23 | "stable-stringify": "^1.0.0", 24 | "then-request": "^4.1.0", 25 | "throat": "^3.0.0" 26 | }, 27 | "devDependencies": { 28 | "@microsoft/api-extractor": "7", 29 | "@microsoft/tsdoc": "^0.12.5", 30 | "@types/get-port": "0.0.4", 31 | "@types/jest": "*", 32 | "@types/rimraf": "^2.0.2", 33 | "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", 34 | "babel-preset-react-app": "^3.0.1", 35 | "babel-register": "^6.24.1", 36 | "babelify": "*", 37 | "browserify-middleware": "*", 38 | "express": "*", 39 | "get-port": "*", 40 | "husky": "*", 41 | "jest": "*", 42 | "lint-staged": "*", 43 | "lsr": "*", 44 | "opaque-types": "^1.1.2", 45 | "prettier": "*", 46 | "react": "^15.1.0", 47 | "react-dom": "^15.1.0", 48 | "todomvc-app-css": "^2.0.3", 49 | "todomvc-common": "^1.0.2", 50 | "ts-jest": "*", 51 | "ts-node": "^8.0.2", 52 | "typescript": "^3.3.1" 53 | }, 54 | "scripts": { 55 | "precommit": "lint-staged", 56 | "prettier": "prettier --write \"src/**/*.{ts,tsx}\"", 57 | "prettier:check": "prettier --list-different \"src/**/*.{ts,tsx}\"", 58 | "prebuild": "rimraf lib && opaque-types", 59 | "build": "tsc", 60 | "postbuild": "node lib/scripts/prepare-docs && rimraf lib/scripts && node lib/build", 61 | "pretest": "yarn build", 62 | "test": "jest ./src --coverage", 63 | "watch": "jest ./src --coverage --watch", 64 | "prerelease": "yarn prettier && yarn build", 65 | "release": "cd lib && yarn publish", 66 | "prestart": "yarn build", 67 | "start": "node example" 68 | }, 69 | "lint-staged": { 70 | "*.{ts,tsx}": [ 71 | "prettier --write", 72 | "git add" 73 | ] 74 | }, 75 | "jest": { 76 | "testEnvironment": "node", 77 | "moduleFileExtensions": [ 78 | "ts", 79 | "tsx", 80 | "js" 81 | ], 82 | "transform": { 83 | "\\.(ts|tsx)$": "/node_modules/ts-jest/preprocessor.js" 84 | }, 85 | "testMatch": [ 86 | "**/*.test.(ts|tsx|js)" 87 | ] 88 | }, 89 | "repository": { 90 | "type": "git", 91 | "url": "https://github.com/bicyclejs/bicycle.git" 92 | }, 93 | "author": "ForbesLindesay", 94 | "license": "MIT" 95 | } 96 | -------------------------------------------------------------------------------- /src/BaseObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for typed Bicycle Node Objects 3 | */ 4 | export default abstract class BaseObject { 5 | readonly [Symbol.toStringTag]: 'BicycleSchemaObject'; 6 | static readonly [Symbol.toStringTag]: 'BicycleSchemaObject'; 7 | 8 | /** 9 | * If you do not have an id property, you can set this to any field. 10 | * You can also just define a calculated field called `id`. 11 | */ 12 | $id: string = 'id'; 13 | $auth: {[key: string]: string[]} = {}; 14 | static $auth: {[key: string]: string[]} = {}; 15 | 16 | public readonly data: TData; 17 | constructor(data: TData) { 18 | this.data = data; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/CacheUpdateType.ts: -------------------------------------------------------------------------------- 1 | const enum CacheUpdateType { 2 | // we no longer delete fields, this leads to a smoother experience 3 | // if the user navigates to a page they have recently visited 4 | // DELETE_FIELD = 0, 5 | ERROR = 1, 6 | NODE_ID = 2, 7 | } 8 | export default CacheUpdateType; 9 | -------------------------------------------------------------------------------- /src/Ctx.ts: -------------------------------------------------------------------------------- 1 | export type ContextFunction = (( 2 | fn: (ctx: Context) => Promise, 3 | ) => PromiseLike); 4 | export type Ctx = 5 | | Context 6 | | ContextFunction 7 | | PromiseLike>; 8 | 9 | function isPromiseLike(v: PromiseLike | T): v is PromiseLike { 10 | return ( 11 | v && 12 | (typeof v === 'object' || typeof v === 'function') && 13 | typeof (v as any).then === 'function' 14 | ); 15 | } 16 | function isContextFunction( 17 | v: Context | ContextFunction, 18 | ): v is ContextFunction { 19 | return typeof v === 'function'; 20 | } 21 | export default function withContext( 22 | ctx: Ctx, 23 | fn: (context: Context) => Promise, 24 | ): Promise { 25 | return isPromiseLike(ctx) 26 | ? Promise.resolve(ctx).then(ctx => withContext(ctx, fn)) 27 | : isContextFunction(ctx) 28 | ? Promise.resolve(ctx(fn)) 29 | : fn(ctx); 30 | } 31 | -------------------------------------------------------------------------------- /src/build.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs'; 2 | import {lsrSync} from 'lsr'; 3 | const {sync: rimraf} = require('rimraf'); 4 | 5 | lsrSync(__dirname).forEach(entry => { 6 | if ( 7 | entry.name === '__tests__' || 8 | entry.name === 'test-schema' || 9 | /^build\./.test(entry.name) 10 | ) { 11 | rimraf(entry.fullPath); 12 | } 13 | }); 14 | writeFileSync(__dirname + '/LICENSE', readFileSync(__dirname + '/../LICENSE')); 15 | writeFileSync( 16 | __dirname + '/README.md', 17 | readFileSync(__dirname + '/../README.md'), 18 | ); 19 | writeFileSync( 20 | __dirname + '/HISTORY.md', 21 | readFileSync(__dirname + '/../HISTORY.md'), 22 | ); 23 | const pkg = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf8')); 24 | 25 | writeFileSync( 26 | __dirname + '/package.json', 27 | JSON.stringify( 28 | { 29 | name: pkg.name, 30 | version: pkg.version, 31 | description: pkg.description, 32 | keywords: pkg.keywords, 33 | dependencies: pkg.dependencies, 34 | repository: pkg.repository, 35 | author: pkg.author, 36 | license: pkg.license, 37 | }, 38 | null, 39 | ' ', 40 | ) + '\n', 41 | ); 42 | 43 | const history = readFileSync(__dirname + '/HISTORY.md', 'utf8'); 44 | if (history.indexOf('## v' + pkg.version + ':') === -1) { 45 | throw new Error('Missing history entry for ' + pkg.version); 46 | } 47 | -------------------------------------------------------------------------------- /src/client/OptimisticValue.src.ts: -------------------------------------------------------------------------------- 1 | // @opaque 2 | // @expose 3 | export type PendingOptimisticValue = string; 4 | // @opaque 5 | // @expose 6 | export type FulfilledOptimisticValue = string; 7 | // @opaque 8 | // @expose 9 | export type RejectedOptimisticValue = string; 10 | 11 | export type OptimisticValue = 12 | | PendingOptimisticValue 13 | | FulfilledOptimisticValue 14 | | RejectedOptimisticValue; 15 | export default OptimisticValue; 16 | -------------------------------------------------------------------------------- /src/client/OptimisticValue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated opaque-types 3 | */ 4 | 5 | export type PendingOptimisticValue__Base = string; 6 | declare const PendingOptimisticValue__Symbol: unique symbol; 7 | 8 | declare class PendingOptimisticValue__Class { 9 | private __kind: typeof PendingOptimisticValue__Symbol; 10 | } 11 | 12 | /** 13 | * @expose 14 | * @opaque 15 | * @base PendingOptimisticValue__Base 16 | */ 17 | type PendingOptimisticValue = PendingOptimisticValue__Base & 18 | PendingOptimisticValue__Class; 19 | const PendingOptimisticValue = { 20 | extract(value: PendingOptimisticValue): PendingOptimisticValue__Base { 21 | return value; 22 | }, 23 | 24 | unsafeCast(value: PendingOptimisticValue__Base): PendingOptimisticValue { 25 | return value as any; 26 | }, 27 | }; 28 | export {PendingOptimisticValue}; 29 | export type FulfilledOptimisticValue__Base = string; 30 | declare const FulfilledOptimisticValue__Symbol: unique symbol; 31 | 32 | declare class FulfilledOptimisticValue__Class { 33 | private __kind: typeof FulfilledOptimisticValue__Symbol; 34 | } 35 | 36 | /** 37 | * @expose 38 | * @opaque 39 | * @base FulfilledOptimisticValue__Base 40 | */ 41 | type FulfilledOptimisticValue = FulfilledOptimisticValue__Base & 42 | FulfilledOptimisticValue__Class; 43 | const FulfilledOptimisticValue = { 44 | extract(value: FulfilledOptimisticValue): FulfilledOptimisticValue__Base { 45 | return value; 46 | }, 47 | 48 | unsafeCast(value: FulfilledOptimisticValue__Base): FulfilledOptimisticValue { 49 | return value as any; 50 | }, 51 | }; 52 | export {FulfilledOptimisticValue}; 53 | export type RejectedOptimisticValue__Base = string; 54 | declare const RejectedOptimisticValue__Symbol: unique symbol; 55 | 56 | declare class RejectedOptimisticValue__Class { 57 | private __kind: typeof RejectedOptimisticValue__Symbol; 58 | } 59 | 60 | /** 61 | * @expose 62 | * @opaque 63 | * @base RejectedOptimisticValue__Base 64 | */ 65 | type RejectedOptimisticValue = RejectedOptimisticValue__Base & 66 | RejectedOptimisticValue__Class; 67 | const RejectedOptimisticValue = { 68 | extract(value: RejectedOptimisticValue): RejectedOptimisticValue__Base { 69 | return value; 70 | }, 71 | 72 | unsafeCast(value: RejectedOptimisticValue__Base): RejectedOptimisticValue { 73 | return value as any; 74 | }, 75 | }; 76 | export {RejectedOptimisticValue}; 77 | export type OptimisticValue = 78 | | PendingOptimisticValue 79 | | FulfilledOptimisticValue 80 | | RejectedOptimisticValue; 81 | export default OptimisticValue; 82 | -------------------------------------------------------------------------------- /src/client/OptimisticValueStore.ts: -------------------------------------------------------------------------------- 1 | import OptimisticValue, { 2 | PendingOptimisticValue, 3 | FulfilledOptimisticValue, 4 | RejectedOptimisticValue, 5 | } from './OptimisticValue'; 6 | 7 | export { 8 | OptimisticValue, 9 | PendingOptimisticValue, 10 | FulfilledOptimisticValue, 11 | RejectedOptimisticValue, 12 | }; 13 | 14 | function extractID(value: OptimisticValue): number { 15 | const match = /^__bicycle_optimistic_value_[0-9a-f]+_([0-9]+)__$/.exec(value); 16 | return parseInt(match![1], 10); 17 | } 18 | 19 | export default class OptimisticValueStore { 20 | private readonly _key: string; 21 | private _nextID: number; 22 | private readonly _fulfilled: Map = new Map(); 23 | private readonly _rejected: Map = new Map(); 24 | constructor(key?: string, nextID?: number) { 25 | // make it difficult to intentionally forge the optimistic values 26 | this._key = 27 | key || 28 | Math.random() 29 | .toString(16) 30 | .substr(2); 31 | this._nextID = nextID || 0; 32 | } 33 | isOptimisticValue(value: any): value is OptimisticValue { 34 | if (typeof value !== 'string') { 35 | return false; 36 | } 37 | const match = /^__bicycle_optimistic_value_([0-9a-f]+)_[0-9]+__$/.exec( 38 | value, 39 | ); 40 | if (!match) return false; 41 | return match[1] === this._key; 42 | } 43 | createValue(): PendingOptimisticValue { 44 | const id = this._nextID++; 45 | return PendingOptimisticValue.unsafeCast( 46 | `__bicycle_optimistic_value_${this._key}_${id}__`, 47 | ); 48 | } 49 | resolve(optimisticValue: PendingOptimisticValue, value: string) { 50 | const id = extractID(optimisticValue); 51 | if (!this._fulfilled.has(id) && !this._rejected.has(id)) { 52 | this._fulfilled.set(id, value); 53 | } 54 | } 55 | reject(optimisticValue: PendingOptimisticValue, err: Error) { 56 | const id = extractID(optimisticValue); 57 | if (!this._fulfilled.has(id) && !this._rejected.has(id)) { 58 | this._rejected.set(id, err); 59 | } 60 | } 61 | isFulfilled(value: OptimisticValue): value is FulfilledOptimisticValue { 62 | return this._fulfilled.has(extractID(value)); 63 | } 64 | isRejected(value: OptimisticValue): value is RejectedOptimisticValue { 65 | return this._rejected.has(extractID(value)); 66 | } 67 | getValue(value: FulfilledOptimisticValue): string { 68 | return this._fulfilled.get(extractID(value))!; 69 | } 70 | getError(value: RejectedOptimisticValue): Error { 71 | return this._rejected.get(extractID(value))!; 72 | } 73 | normalizeValue( 74 | value: any, 75 | ): { 76 | value: any; 77 | pendingKeys: PendingOptimisticValue[]; 78 | rejection: null | Error; 79 | } { 80 | const store = this; 81 | const pendingKeys: PendingOptimisticValue[] = []; 82 | let rejection: null | Error = null; 83 | function recurse(value: any): any { 84 | if (store.isOptimisticValue(value)) { 85 | if (store.isFulfilled(value)) { 86 | return store.getValue(value); 87 | } else if (store.isRejected(value)) { 88 | rejection = store.getError(value); 89 | } else { 90 | pendingKeys.push(value as PendingOptimisticValue); 91 | return value; 92 | } 93 | } 94 | if (value && typeof value === 'object') { 95 | if (Array.isArray(value)) { 96 | return value.map(recurse); 97 | } else { 98 | const result = {}; 99 | Object.keys(value).forEach(key => { 100 | result[key] = recurse(value[key]); 101 | }); 102 | return result; 103 | } 104 | } 105 | return value; 106 | } 107 | return {value: recurse(value), pendingKeys, rejection}; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/client/mutation.ts: -------------------------------------------------------------------------------- 1 | import cuid = require('cuid'); 2 | import OptimisticValueStore, { 3 | PendingOptimisticValue, 4 | } from './OptimisticValueStore'; 5 | import {BaseCache, OptimisticUpdateHandler} from './optimistic'; 6 | import MutationID from '../types/MutationID'; 7 | 8 | class Mutation { 9 | public readonly mutation: {id: MutationID; method: string; args: any}; 10 | private readonly _structuredMutation: { 11 | objectName: string; 12 | methodName: string; 13 | args: any; 14 | }; 15 | private _optimisticUpdateHandler: void | OptimisticUpdateHandler; 16 | private _optimisticValuesRequired: PendingOptimisticValue[]; 17 | private _optimisticValuesSuplied: { 18 | [key: string]: PendingOptimisticValue; 19 | } = {}; 20 | private _optimisticValueStore: OptimisticValueStore; 21 | private _pending: boolean = true; 22 | private _rejected: boolean = false; 23 | private readonly _result: Promise; 24 | private _resolve!: ((value: any) => void); 25 | private _reject!: ((err: any) => void); 26 | constructor( 27 | method: string, 28 | args: any, 29 | optimisticUpdate: void | OptimisticUpdateHandler, 30 | optimisticValueStore: OptimisticValueStore, 31 | ) { 32 | this._optimisticValueStore = optimisticValueStore; 33 | const result = optimisticValueStore.normalizeValue(args); 34 | this._optimisticValuesRequired = result.pendingKeys; 35 | this.mutation = { 36 | id: MutationID.unsafeCast(cuid()), 37 | method, 38 | args: result.value, 39 | }; 40 | this._structuredMutation = { 41 | objectName: method.split('.')[0], 42 | methodName: method.split('.')[1], 43 | args, 44 | }; 45 | this._optimisticUpdateHandler = optimisticUpdate; 46 | 47 | this._result = new Promise((resolve, reject) => { 48 | this._resolve = resolve; 49 | this._reject = reject; 50 | }); 51 | this.updateStatus(); 52 | } 53 | _getOptimisticValue = (name: string): PendingOptimisticValue => { 54 | return ( 55 | this._optimisticValuesSuplied[name] || 56 | (this._optimisticValuesSuplied[ 57 | name 58 | ] = this._optimisticValueStore.createValue()) 59 | ); 60 | }; 61 | updateStatus() { 62 | if (!this._pending) return; 63 | if (this._optimisticValuesRequired.length === 0) return; 64 | const result = this._optimisticValueStore.normalizeValue( 65 | this.mutation.args, 66 | ); 67 | this.mutation.args = result.value; 68 | this._structuredMutation.args = result.value; 69 | if (result.rejection) { 70 | this.reject(result.rejection); 71 | } 72 | this._optimisticValuesRequired = result.pendingKeys; 73 | } 74 | isPending(): boolean { 75 | return this._pending; 76 | } 77 | isRejected() { 78 | return this._rejected; 79 | } 80 | isBlocked(): boolean { 81 | return !this._rejected && this._optimisticValuesRequired.length !== 0; 82 | } 83 | applyOptimistic(cache: BaseCache): void { 84 | const handler = this._optimisticUpdateHandler; 85 | if (!handler) return; 86 | handler(this._structuredMutation, cache, this._getOptimisticValue); 87 | } 88 | getResult(): Promise { 89 | return this._result; 90 | } 91 | resolve(result: any) { 92 | this._pending = false; 93 | Object.keys(this._optimisticValuesSuplied).forEach(name => { 94 | const v = this._optimisticValuesSuplied[name]; 95 | if (v) { 96 | if (result && typeof result === 'object' && name in result) { 97 | this._optimisticValueStore.resolve(v, result[name]); 98 | } else { 99 | this._optimisticValueStore.reject( 100 | v, 101 | new Error( 102 | 'Could not resolve missing optimistic value "' + name + '"', 103 | ), 104 | ); 105 | } 106 | } 107 | }); 108 | this._resolve(result); 109 | } 110 | reject(err: Error) { 111 | this._pending = false; 112 | this._rejected = true; 113 | Object.keys(this._optimisticValuesSuplied).forEach(name => { 114 | const v = this._optimisticValuesSuplied[name]; 115 | this._optimisticValueStore.reject(v, err); 116 | }); 117 | this._reject(err); 118 | } 119 | } 120 | 121 | export default Mutation; 122 | -------------------------------------------------------------------------------- /src/client/optimistic.ts: -------------------------------------------------------------------------------- 1 | import Cache, {CacheData, CacheObject} from '../types/Cache'; 2 | import NodeID, {isID, getNode, createNodeID} from '../types/NodeID'; 3 | import {isErrorResult} from '../types/ErrorResult'; 4 | import {PendingOptimisticValue} from './OptimisticValueStore'; 5 | 6 | const stringify: (value: any) => string = require('stable-stringify'); 7 | 8 | function extractCacheData( 9 | data: CacheData, 10 | cache: Cache, 11 | resultCache: Cache, 12 | ): any { 13 | if (isID(data)) { 14 | return new BaseCache(data, cache, resultCache); 15 | } 16 | if (isErrorResult(data)) { 17 | return undefined; 18 | } 19 | if (Array.isArray(data)) { 20 | return data.map(v => extractCacheData(v, cache, resultCache)); 21 | } 22 | if (data && typeof data === 'object') { 23 | const result = {}; 24 | Object.keys(data).forEach( 25 | key => (result[key] = extractCacheData(data[key], cache, resultCache)), 26 | ); 27 | return result; 28 | } 29 | return data; 30 | } 31 | function packageCacheData(data: any): CacheData { 32 | if (Array.isArray(data)) { 33 | return data.map(v => packageCacheData(v)); 34 | } 35 | if (data && typeof data === 'object') { 36 | if (data instanceof BaseCache && isID(data.id)) { 37 | return data.id; 38 | } 39 | const result = {}; 40 | Object.keys(data).forEach( 41 | key => (result[key] = packageCacheData(data[key])), 42 | ); 43 | return result; 44 | } 45 | return data; 46 | } 47 | 48 | export type GetOptimisticValue = (name: string) => PendingOptimisticValue; 49 | 50 | export class BaseCache { 51 | public readonly id: NodeID; 52 | private readonly _data: CacheObject; 53 | private readonly _cache: Cache; 54 | private _resultData: void | CacheObject = undefined; 55 | private readonly _resultCache: Cache; 56 | constructor(id: NodeID, cache: Cache, resultCache: Cache) { 57 | this.id = id; 58 | this._data = (cache[id.n] && cache[id.n][id.i]) || {}; 59 | this._resultData = 60 | (resultCache[id.n] && resultCache[id.n][id.i]) || undefined; 61 | this._cache = cache; 62 | this._resultCache = resultCache; 63 | } 64 | get(name: string, args?: any): void | any { 65 | const key = args === undefined ? name : name + '(' + stringify(args) + ')'; 66 | const result = this._resultData && this._resultData[key]; 67 | return extractCacheData( 68 | result === undefined ? this._data[key] : result, 69 | this._cache, 70 | this._resultCache, 71 | ); 72 | } 73 | set(name: string, args?: any, value?: any): this { 74 | const v = value === undefined ? args : value; 75 | const a = value === undefined ? undefined : args; 76 | this._resultData = getNode(this._resultCache, this.id); 77 | this._resultData[ 78 | a === undefined ? name : name + '(' + stringify(a) + ')' 79 | ] = packageCacheData(v); 80 | return this; 81 | } 82 | getObject(typeName: string, id: string): BaseCache { 83 | const i = createNodeID(typeName, id); 84 | return new BaseCache(i, this._cache, this._resultCache); 85 | } 86 | } 87 | 88 | export type OptimisticUpdateHandler = ( 89 | mutation: {objectName: string; methodName: string; args: any}, 90 | cache: BaseCache, 91 | getOptimisticValue: GetOptimisticValue, 92 | ) => any; 93 | -------------------------------------------------------------------------------- /src/error-reporting.ts: -------------------------------------------------------------------------------- 1 | import Logging from './types/Logging'; 2 | 3 | export default function reportError(err: Error, logging: Logging) { 4 | if (!(logging && logging.disableDefaultLogging)) { 5 | console.error(err.stack); 6 | } 7 | if (logging) { 8 | logging.onError({error: err}); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/load-schema/TypeAssertions.ts: -------------------------------------------------------------------------------- 1 | import suggestMatch from '../utils/suggest-match'; 2 | import typeNameFromValue from '../utils/type-name-from-value'; 3 | 4 | export class Type { 5 | typeName: string; 6 | _validate: (value: unknown, context: string) => value is T; 7 | constructor( 8 | typeName: string, 9 | validate: (value: unknown, context: string) => value is T, 10 | ) { 11 | this.typeName = typeName; 12 | this._validate = validate; 13 | } 14 | validate(value: unknown, name: string = 'value'): T { 15 | if (!this._validate(value, name)) { 16 | throw new Error( 17 | 'Expected ' + 18 | name + 19 | ' to be a ' + 20 | this.typeName + 21 | ' but got ' + 22 | typeNameFromValue(value), 23 | ); 24 | } 25 | return value; 26 | } 27 | or(t: Type): Type { 28 | return new Type( 29 | this.typeName + ' | ' + t.typeName, 30 | (value, context): value is T | S => 31 | this._validate(value, context) || t._validate(value, context), 32 | ); 33 | } 34 | } 35 | 36 | export const Fn = new Type<(...args: any[]) => any>( 37 | 'function', 38 | (v): v is (...args: any[]) => any => typeof v === 'function', 39 | ); 40 | export const String = new Type( 41 | 'string', 42 | (v): v is string => typeof v === 'string', 43 | ); 44 | export const Void = new Type( 45 | 'undefined', 46 | (v): v is void => v === undefined, 47 | ); 48 | export const Literal = (value: T) => 49 | new Type(JSON.stringify(value), (v): v is T => v === value); 50 | 51 | export const ArrayOf = (elementType: Type) => 52 | new Type( 53 | (elementType.typeName.indexOf(' ') !== -1 54 | ? '(' + elementType.typeName + ')' 55 | : elementType.typeName) + '[]', 56 | (v, context): v is Array => 57 | Array.isArray(v) && v.every(v => elementType._validate(v, context)), 58 | ); 59 | 60 | export const ObjectKeys = (keys: T[]) => 61 | new Type>( 62 | '{' + keys.join(', ') + '}', 63 | (v, context): v is Record => { 64 | if (typeof v !== 'object' || !v) { 65 | return false; 66 | } 67 | Object.keys(v).forEach(key => { 68 | if (keys.indexOf(key as T) === -1) { 69 | const suggestion = suggestMatch(keys, key); 70 | throw new Error(`Invalid key "${key}" in ${context}${suggestion}`); 71 | } 72 | }); 73 | return true; 74 | }, 75 | ); 76 | export const AnyObject = new Type>( 77 | 'Object', 78 | (v): v is Record => { 79 | return v && typeof v === 'object' && !Array.isArray(v); 80 | }, 81 | ); 82 | 83 | export default {Fn, String, Void, Literal, ArrayOf, ObjectKeys, AnyObject}; 84 | -------------------------------------------------------------------------------- /src/load-schema/__tests__/get-type.test.ts: -------------------------------------------------------------------------------- 1 | import SchemaKind from '../../types/SchemaKind'; 2 | import getType from '../../load-schema/get-type'; 3 | 4 | test('NamedTypeReference', () => { 5 | expect(getType('String?', 'context', ['String'])).toEqual({ 6 | kind: SchemaKind.Union, 7 | elements: [ 8 | { 9 | kind: SchemaKind.Named, 10 | name: 'String', 11 | }, 12 | {kind: SchemaKind.Null}, 13 | {kind: SchemaKind.Void}, 14 | ], 15 | }); 16 | expect(getType('String', 'context', ['String'])).toEqual({ 17 | kind: SchemaKind.Named, 18 | name: 'String', 19 | }); 20 | }); 21 | test('List', () => { 22 | expect(getType('String?[]?', 'context', ['String'])).toEqual({ 23 | kind: SchemaKind.Union, 24 | elements: [ 25 | { 26 | kind: SchemaKind.List, 27 | element: { 28 | kind: SchemaKind.Union, 29 | elements: [ 30 | { 31 | kind: SchemaKind.Named, 32 | name: 'String', 33 | }, 34 | {kind: SchemaKind.Null}, 35 | {kind: SchemaKind.Void}, 36 | ], 37 | }, 38 | }, 39 | {kind: SchemaKind.Null}, 40 | {kind: SchemaKind.Void}, 41 | ], 42 | }); 43 | expect(getType('String[]', 'context', ['String'])).toEqual({ 44 | kind: SchemaKind.List, 45 | element: { 46 | kind: SchemaKind.Named, 47 | name: 'String', 48 | }, 49 | }); 50 | }); 51 | test('Throw on unexpected characters', () => { 52 | expect(() => getType('my_fake_type', 'context', [])).toThrowError( 53 | /Expected type name to match \[A-Za-z0-9\]\+ but got 'my_fake_type'/, 54 | ); 55 | }); 56 | test('A missing name', () => { 57 | expect(() => getType('MyType', 'context', [])).toThrowError( 58 | /context refers to MyType, but there is no type by that name/, 59 | ); 60 | expect(() => getType('MyTipe', 'context', ['MyType'])).toThrowError( 61 | /context refers to MyTipe, but there is no type by that name maybe you meant to use "MyType"/, 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /src/load-schema/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import {loadSchemaFromFiles} from '../../load-schema'; 4 | 5 | test('it can load a schema from a file', () => { 6 | expect( 7 | loadSchemaFromFiles(__dirname + '/../../test-schema'), 8 | ).toMatchSnapshot(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/load-schema/__tests__/normalize-object.test.ts: -------------------------------------------------------------------------------- 1 | import freeze from '../../utils/freeze'; 2 | import normalizeObject from '../../load-schema/normalize-object'; 3 | 4 | test('no name throws', () => { 5 | expect(() => normalizeObject(freeze({}), [])).toThrowError( 6 | /Expected ObjectType\.name to be a string but got void/, 7 | ); 8 | }); 9 | test('name must be alphabetic characters', () => { 10 | expect(() => 11 | normalizeObject(freeze({name: 'something_else'}), []), 12 | ).toThrowError( 13 | /Expected ObjectType\.name to match \[A-Za-z\]\+ but got 'something_else'/, 14 | ); 15 | }); 16 | test('description not a string throws', () => { 17 | expect(() => 18 | normalizeObject(freeze({name: 'Something', description: null}), []), 19 | ).toThrowError( 20 | /Expected Something\.description to be a undefined \| string but got null/, 21 | ); 22 | }); 23 | test('fields must be an object', () => { 24 | expect(() => normalizeObject(freeze({name: 'Something'}), [])).toThrowError( 25 | /Expected Something.fields to be a Object but got void/, 26 | ); 27 | expect(() => 28 | normalizeObject(freeze({name: 'Something', fields: 'not an object'}), []), 29 | ).toThrowError(/Expected Something.fields to be a Object but got string/); 30 | }); 31 | -------------------------------------------------------------------------------- /src/load-schema/__tests__/normalize-scalar.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../../utils/freeze'; 4 | import normalizeScalar from '../../load-schema/normalize-scalar'; 5 | 6 | test('no name throws', () => { 7 | expect(() => normalizeScalar(freeze({}), [])).toThrowError( 8 | /Expected Scalar\.name to be a string but got void/, 9 | ); 10 | }); 11 | test('name must be alphabetic characters', () => { 12 | expect(() => 13 | normalizeScalar(freeze({name: 'something_else'}), []), 14 | ).toThrowError( 15 | /Expected Scalar\.name to match \[A-Za-z\]\+ but got 'something_else'/, 16 | ); 17 | }); 18 | test('description not a string throws', () => { 19 | expect(() => 20 | normalizeScalar(freeze({name: 'Something', description: null}), []), 21 | ).toThrowError( 22 | /Expected Something\.description to be a undefined \| string but got null/, 23 | ); 24 | }); 25 | test('no base type throws', () => { 26 | expect(() => normalizeScalar(freeze({name: 'Something'}), [])).toThrowError( 27 | /Something\.baseType has an invalid type\. Types must be strings or objects\./, 28 | ); 29 | }); 30 | test('parse/serialize throws', () => { 31 | expect(() => 32 | normalizeScalar( 33 | freeze({name: 'Something', validate() {}, serialize() {}}), 34 | [], 35 | ), 36 | ).toThrowError(/Invalid key "serialize" in Scalar/); 37 | expect(() => 38 | normalizeScalar(freeze({name: 'Something', validate() {}, parse() {}}), []), 39 | ).toThrowError(/Invalid key "parse" in Scalar/); 40 | }); 41 | test('.validate -> non-function validate throws', () => { 42 | expect(() => 43 | normalizeScalar( 44 | freeze({ 45 | name: 'Something', 46 | baseType: 'string', 47 | validate: 'not a function', 48 | }), 49 | [], 50 | ), 51 | ).toThrowError( 52 | /Expected Something\.validate to be a undefined \| function but got string/, 53 | ); 54 | }); 55 | test('.validate -> validates input', () => { 56 | const result = normalizeScalar( 57 | freeze({ 58 | name: 'Something', 59 | baseType: 'string', 60 | validate: (val: any) => { 61 | expect(val).toBe('Something'); 62 | return true; 63 | }, 64 | }), 65 | [], 66 | ); 67 | expect(result.kind).toBe('Scalar'); 68 | expect(result.name).toBe('Something'); 69 | expect(result.validate('Something')).toBe(true); 70 | }); 71 | -------------------------------------------------------------------------------- /src/load-schema/get-type.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import SchemaKind from '../types/SchemaKind'; 3 | import ValueType from '../types/ValueType'; 4 | import suggestMatch from '../utils/suggest-match'; 5 | 6 | function getTypeFromString( 7 | strType: string, 8 | context: string, 9 | typeNames: Array, 10 | ): ValueType { 11 | if (strType[strType.length - 1] === '?') { 12 | return { 13 | kind: SchemaKind.Union, 14 | elements: [ 15 | getType(strType.substr(0, strType.length - 1), context, typeNames), 16 | {kind: SchemaKind.Null}, 17 | {kind: SchemaKind.Void}, 18 | ], 19 | }; 20 | } 21 | if ( 22 | strType[strType.length - 2] === '[' && 23 | strType[strType.length - 1] === ']' 24 | ) { 25 | return { 26 | kind: SchemaKind.List, 27 | element: getType( 28 | strType.substr(0, strType.length - 2), 29 | context, 30 | typeNames, 31 | ), 32 | }; 33 | } 34 | const contextStr = context ? ' for ' + context : ''; 35 | assert( 36 | /^[A-Za-z0-9]+$/.test(strType), 37 | `Expected type name to match [A-Za-z0-9]+ but got '${strType}'${contextStr}`, 38 | ); 39 | switch (strType) { 40 | case 'boolean': 41 | return {kind: SchemaKind.Boolean}; 42 | case 'string': 43 | return {kind: SchemaKind.String}; 44 | case 'number': 45 | return {kind: SchemaKind.Number}; 46 | case 'void': 47 | return {kind: SchemaKind.Void}; 48 | case 'null': 49 | return {kind: SchemaKind.Null}; 50 | case 'any': 51 | return {kind: SchemaKind.Any}; 52 | } 53 | if (typeNames.indexOf(strType) === -1) { 54 | const suggestion = suggestMatch(typeNames, strType); 55 | throw new Error( 56 | `${context} refers to ${strType}, but there is no type by that name${suggestion}`, 57 | ); 58 | } 59 | return {kind: SchemaKind.Named, name: strType}; 60 | } 61 | function getTypeFromObject( 62 | objType: {[key: string]: unknown}, 63 | context: string, 64 | typeNames: Array, 65 | ): ValueType { 66 | const properties = {}; 67 | Object.keys(objType).forEach(key => { 68 | properties[key] = getType(objType[key], context + '.' + key, typeNames); 69 | }); 70 | return {kind: SchemaKind.Object, properties}; 71 | } 72 | function getType( 73 | type: unknown, 74 | context: string, 75 | typeNames: Array, 76 | ): ValueType { 77 | if (typeof type === 'string') { 78 | return getTypeFromString(type, context, typeNames); 79 | } else if (typeof type === 'object' && type && !Array.isArray(type)) { 80 | return getTypeFromObject(type, context, typeNames); 81 | } else { 82 | throw new Error( 83 | `${context} has an invalid type. Types must be strings or objects.`, 84 | ); 85 | } 86 | } 87 | 88 | export default getType; 89 | -------------------------------------------------------------------------------- /src/load-schema/index.ts: -------------------------------------------------------------------------------- 1 | import {readdirSync} from 'fs'; 2 | import normalizeObject from './normalize-object'; 3 | import normalizeScalar from './normalize-scalar'; 4 | import Schema from '../types/Schema'; 5 | import ta from './TypeAssertions'; 6 | 7 | export default function loadSchema(input: { 8 | objects: unknown[]; 9 | scalars?: unknown[]; 10 | }): Schema { 11 | const i = ta.ObjectKeys(['scalars', 'objects']).validate(input, 'input'); 12 | const objects = ta.ArrayOf(ta.AnyObject).validate(i.objects, 'input.objects'); 13 | const scalars = ta.Void.or(ta.ArrayOf(ta.AnyObject)).validate( 14 | i.scalars, 15 | 'input.objects', 16 | ); 17 | const types: Schema = {Root: null as any}; 18 | const typeNames: string[] = []; 19 | 20 | let rootObject: null | Record = null; 21 | objects.forEach(Type => { 22 | const typeName = ta.String.validate(Type.name, 'Type.name'); 23 | if (typeName === 'Root') { 24 | if (rootObject !== null) { 25 | throw new Error( 26 | `Duplicate Root Object. You can only have one Root Object.`, 27 | ); 28 | } 29 | rootObject = Type; 30 | } else if (typeName) { 31 | if (typeNames.indexOf(typeName) !== -1) { 32 | throw new Error( 33 | `Duplicate Object, "${typeName}". Each object & scalar must have a unique name.`, 34 | ); 35 | } 36 | typeNames.push(typeName); 37 | } 38 | }); 39 | 40 | if (scalars) { 41 | scalars.forEach(Scalar => { 42 | const scalarName = ta.String.validate(Scalar.name, 'Scalar.name'); 43 | if (typeNames.indexOf(scalarName) !== -1) { 44 | throw new Error( 45 | `Duplicate Scalar, "${scalarName}". Each object & scalar must have a unique name.`, 46 | ); 47 | } else if (scalarName === 'Root') { 48 | throw new Error('You cannot have a scalar called Root'); 49 | } else if (scalarName) { 50 | typeNames.push(scalarName); 51 | } 52 | }); 53 | } 54 | if (scalars) { 55 | scalars.forEach(Scalar => { 56 | const scalarName = ta.String.validate(Scalar.name, 'Scalar.name'); 57 | types[scalarName] = normalizeScalar(Scalar, typeNames); 58 | }); 59 | } 60 | 61 | // objects have `id`s and a collection of `fields` 62 | objects.forEach(Type => { 63 | const typeName = ta.String.validate(Type.name, 'Type.name'); 64 | types[typeName] = normalizeObject(Type, typeNames); 65 | }); 66 | if (!types.Root) { 67 | throw new Error('You must provide a Root object'); 68 | } 69 | return types; 70 | } 71 | 72 | export function loadSchemaFromFiles(dirname: string): Schema { 73 | dirname = dirname.replace(/(\\|\/)$/, ''); 74 | const schema: {objects: any[]; scalars: any[]} = {objects: [], scalars: []}; 75 | readdirSync(dirname + '/objects').forEach(filename => { 76 | let t = require(dirname + '/objects/' + filename); 77 | if (t.default) t = t.default; 78 | schema.objects.push(t); 79 | }); 80 | let scalars = null; 81 | try { 82 | scalars = readdirSync(dirname + '/scalars'); 83 | } catch (ex) { 84 | if (ex.code !== 'ENOENT') throw ex; 85 | } 86 | if (scalars) { 87 | scalars.forEach(filename => { 88 | let t = require(dirname + '/scalars/' + filename); 89 | if (t.default) t = t.default; 90 | schema.scalars.push(t); 91 | }); 92 | } 93 | return loadSchema(schema); 94 | } 95 | -------------------------------------------------------------------------------- /src/load-schema/normalize-field.ts: -------------------------------------------------------------------------------- 1 | import getType from './get-type'; 2 | import SchemaKind from '../types/SchemaKind'; 3 | import {Field} from '../types/Schema'; 4 | import ta from './TypeAssertions'; 5 | 6 | function normalizeField( 7 | field: unknown, 8 | fieldName: string, 9 | typeName: string, 10 | typeNames: Array, 11 | ): Field { 12 | const ctx = typeName + '.' + fieldName; 13 | const f = ta.String.or( 14 | ta.ObjectKeys(['type', 'description', 'args', 'auth', 'resolve']), 15 | ).validate(field, ctx); 16 | if (typeof f === 'string') { 17 | return { 18 | kind: SchemaKind.FieldProperty, 19 | name: typeName, 20 | description: undefined, 21 | resultType: getType(f, ctx, typeNames), 22 | auth: 'public', 23 | }; 24 | } 25 | const resolve = ta.Void.or(ta.Fn).validate(f.resolve, ctx + '.resolve'); 26 | if (resolve) { 27 | return { 28 | kind: SchemaKind.FieldMethod, 29 | name: typeName, 30 | description: ta.Void.or(ta.String).validate( 31 | f.description, 32 | ctx + '.description', 33 | ), 34 | resultType: getType(f.type, ctx + '.type', typeNames), 35 | argType: getType( 36 | f.args === undefined ? 'void' : f.args, 37 | ctx + '.args', 38 | typeNames, 39 | ), 40 | auth: ta 41 | .Literal<'public'>('public') 42 | .or(ta.Fn) 43 | .validate(f.auth === undefined ? 'public' : f.auth, ctx + '.auth'), 44 | resolve, 45 | }; 46 | } 47 | return { 48 | kind: SchemaKind.FieldProperty, 49 | name: typeName, 50 | description: ta.Void.or(ta.String).validate( 51 | f.description, 52 | ctx + '.description', 53 | ), 54 | resultType: getType(f.type, ctx + '.type', typeNames), 55 | auth: ta 56 | .Literal<'public'>('public') 57 | .or(ta.Fn) 58 | .validate(f.auth === undefined ? 'public' : f.auth, ctx + '.auth'), 59 | }; 60 | } 61 | 62 | export default normalizeField; 63 | -------------------------------------------------------------------------------- /src/load-schema/normalize-fields.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import {Field} from '../types/Schema'; 3 | import normalizeField from './normalize-field'; 4 | import ta from './TypeAssertions'; 5 | 6 | function normalizeFields( 7 | fields: unknown, 8 | typeName: string, 9 | typeNames: Array, 10 | ): {[fieldName: string]: Field} { 11 | const result = {}; 12 | const f = ta.AnyObject.validate(fields, typeName + '.fields'); 13 | Object.keys(f).forEach(fieldName => { 14 | const field = f[fieldName]; 15 | if (field === undefined) return; 16 | assert( 17 | /^[A-Za-z0-9]+$/.test(fieldName), 18 | `Expected ${typeName}'s field names to match [A-Za-z0-9]+ but got '${fieldName}'`, 19 | ); 20 | result[fieldName] = normalizeField(field, fieldName, typeName, typeNames); 21 | }); 22 | return result; 23 | } 24 | 25 | export default normalizeFields; 26 | -------------------------------------------------------------------------------- /src/load-schema/normalize-mutation.ts: -------------------------------------------------------------------------------- 1 | import getType from './get-type'; 2 | import SchemaKind from '../types/SchemaKind'; 3 | import {Mutation} from '../types/Schema'; 4 | import ta from './TypeAssertions'; 5 | 6 | function normalizeMutation( 7 | mutation: unknown, 8 | typeName: string, 9 | mutationName: string, 10 | typeNames: Array, 11 | ): Mutation { 12 | const ctx = typeName + '.' + mutationName; 13 | const m = ta 14 | .ObjectKeys(['type', 'description', 'args', 'resolve', 'auth']) 15 | .validate(mutation, ctx); 16 | return { 17 | kind: SchemaKind.Mutation, 18 | name: mutationName, 19 | description: ta.Void.or(ta.String).validate( 20 | m.description, 21 | ctx + '.description', 22 | ), 23 | argType: getType( 24 | m.args === undefined ? 'void' : m.args, 25 | ctx + '.args', 26 | typeNames, 27 | ), 28 | resultType: getType( 29 | m.type === undefined ? 'void' : m.type, 30 | ctx + '.type', 31 | typeNames, 32 | ), 33 | auth: ta 34 | .Literal<'public'>('public') 35 | .or(ta.Fn) 36 | .validate(m.auth === undefined ? 'public' : m.auth, ctx + '.auth'), 37 | resolve: ta.Fn.validate(m.resolve, ctx + '.resolve'), 38 | }; 39 | } 40 | 41 | export default normalizeMutation; 42 | -------------------------------------------------------------------------------- /src/load-schema/normalize-mutations.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import normalizeMutation from './normalize-mutation'; 3 | import SchemaKind from '../types/SchemaKind'; 4 | import {Field, Mutation} from '../types/Schema'; 5 | import ta from './TypeAssertions'; 6 | import {validateArg} from '../runner/validate'; 7 | 8 | function normalizeMutations( 9 | mutations: unknown, 10 | typeName: string, 11 | typeNames: string[], 12 | fields: {[name: string]: Field}, 13 | ): {[mutationName: string]: Mutation} { 14 | const result: {[mutationName: string]: Mutation} = {}; 15 | const m = ta.Void.or(ta.AnyObject).validate( 16 | mutations, 17 | typeName + '.mutations', 18 | ); 19 | if (m === undefined) { 20 | return {}; 21 | } 22 | Object.keys(m).forEach(mutationName => { 23 | const mutation = m[mutationName]; 24 | if (mutation === undefined) { 25 | return; 26 | } 27 | assert( 28 | /^[A-Za-z]+$/.test(mutationName), 29 | `Expected ${typeName}'s mutation names to match [A-Za-z0-9]+ but got '${mutationName}'`, 30 | ); 31 | if (mutationName === 'set') { 32 | const set = ta.Fn.validate(m.set, 'Set Mutation'); 33 | result.set = { 34 | kind: SchemaKind.Mutation, 35 | name: typeName + '.set', 36 | description: 'Set a property on the given ' + typeName, 37 | argType: { 38 | kind: SchemaKind.Object, 39 | properties: { 40 | id: { 41 | kind: SchemaKind.Union, 42 | elements: [{kind: SchemaKind.String}, {kind: SchemaKind.Number}], 43 | }, 44 | field: { 45 | kind: SchemaKind.Union, 46 | elements: Object.keys(fields).map(n => ({ 47 | kind: SchemaKind.Literal, 48 | value: n, 49 | })), 50 | }, 51 | value: { 52 | kind: SchemaKind.Any, 53 | }, 54 | }, 55 | }, 56 | resultType: {kind: SchemaKind.Void}, 57 | auth: 'public', 58 | resolve( 59 | args: {id: number | string; field: string; value: any}, 60 | ctx: any, 61 | mCtx, 62 | ): void { 63 | validateArg(fields[args.field].resultType, args.value, mCtx.schema); 64 | return set(args, ctx, mCtx); 65 | }, 66 | } as Mutation; 67 | } else { 68 | result[mutationName] = normalizeMutation( 69 | mutation, 70 | typeName, 71 | mutationName, 72 | typeNames, 73 | ); 74 | } 75 | }); 76 | return result; 77 | } 78 | 79 | export default normalizeMutations; 80 | -------------------------------------------------------------------------------- /src/load-schema/normalize-object.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import getTypeName from '../utils/type-name-from-value'; 3 | import normalizeFields from './normalize-fields'; 4 | import normalizeMutations from './normalize-mutations'; 5 | import ta from './TypeAssertions'; 6 | import SchemaKind from '../types/SchemaKind'; 7 | import {NodeType} from '../types/Schema'; 8 | 9 | function normalizeObject( 10 | Type: unknown, 11 | typeNames: Array, 12 | ): NodeType { 13 | const typeName = ta.String.validate( 14 | ta.AnyObject.validate(Type, 'ObjectType').name, 15 | 'ObjectType.name', 16 | ); 17 | const t = ta 18 | .ObjectKeys(['name', 'id', 'description', 'fields', 'mutations']) 19 | .validate(Type, typeName); 20 | assert( 21 | /^[A-Za-z]+$/.test(typeName), 22 | `Expected ObjectType.name to match [A-Za-z]+ but got '${typeName}'`, 23 | ); 24 | const id = ta.Void.or(ta.Fn).validate(t.id, typeName + '.id'); 25 | const description = ta.Void.or(ta.String).validate( 26 | t.description, 27 | typeName + '.description', 28 | ); 29 | 30 | assert( 31 | typeName !== 'Root' || id === undefined, 32 | `The Root object always has an ID of "root". You don't need to specify an id function yourself`, 33 | ); 34 | 35 | let idGetter = id; 36 | if (!idGetter) { 37 | if (typeName === 'Root') { 38 | idGetter = () => 'root'; 39 | } else { 40 | idGetter = node => { 41 | if (typeof node.id !== 'string' && typeof node.id !== 'number') { 42 | if (node.id === undefined || node.id === null) { 43 | throw new Error( 44 | 'Node of type ' + typeName + ' does not have an id', 45 | ); 46 | } else { 47 | throw new Error( 48 | 'Expected node of type ' + 49 | typeName + 50 | ' to have either a string or number for the "id" but got "' + 51 | getTypeName(node.id) + 52 | '"', 53 | ); 54 | } 55 | } 56 | return node.id; 57 | }; 58 | } 59 | } 60 | const fields = normalizeFields(t.fields, typeName, typeNames); 61 | 62 | const mutations = normalizeMutations( 63 | t.mutations, 64 | typeName, 65 | typeNames, 66 | fields, 67 | ); 68 | return { 69 | kind: SchemaKind.NodeType, 70 | name: typeName, 71 | id: idGetter, 72 | description, 73 | fields, 74 | mutations, 75 | matches(value: any): value is any { 76 | // untyped mode does not support unions 77 | return true; 78 | }, 79 | }; 80 | } 81 | 82 | export default normalizeObject; 83 | -------------------------------------------------------------------------------- /src/load-schema/normalize-scalar.ts: -------------------------------------------------------------------------------- 1 | import SchemaKind from '../types/SchemaKind'; 2 | import {ScalarDeclaration} from '../types/Schema'; 3 | import assert = require('assert'); 4 | import getType from './get-type'; 5 | import ta from './TypeAssertions'; 6 | 7 | function normalizeScalar( 8 | Scalar: unknown, 9 | typeNames: string[], 10 | ): ScalarDeclaration { 11 | const s = ta 12 | .ObjectKeys(['name', 'description', 'validate', 'baseType']) 13 | .validate(Scalar, 'Scalar'); 14 | const name = ta.String.validate(s.name, 'Scalar.name'); 15 | assert( 16 | /^[A-Za-z]+$/.test(name), 17 | `Expected Scalar.name to match [A-Za-z]+ but got '${name}'`, 18 | ); 19 | const description = ta.Void.or(ta.String).validate( 20 | s.description, 21 | name + '.description', 22 | ); 23 | const baseType = getType(s.baseType, name + '.baseType', typeNames); 24 | const validate = ta.Void.or(ta.Fn).validate(s.validate, name + '.validate'); 25 | return { 26 | kind: SchemaKind.Scalar, 27 | name, 28 | description, 29 | baseType, 30 | validate: (validate || (() => true)) as (v: any) => v is any, 31 | }; 32 | } 33 | 34 | export default normalizeScalar; 35 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import {Request, Response, RequestHandler} from 'express'; 2 | import {json} from 'body-parser'; 3 | import handleMessage from './handleMessage'; 4 | import SessionStore from './sessions/SessionStore'; 5 | import Logging from './types/Logging'; 6 | import Schema from './types/Schema'; 7 | import {Ctx} from './Ctx'; 8 | 9 | const jsonBody = json(); 10 | export default function createBicycleMiddleware( 11 | schema: Schema, 12 | logging: Logging, 13 | sessionStore: SessionStore, 14 | getContext: ( 15 | req: Request, 16 | res: Response, 17 | options: {stage: 'query' | 'mutation'}, 18 | ) => Ctx, 19 | ): RequestHandler { 20 | const processRequest: RequestHandler = (req, res, next) => { 21 | handleMessage( 22 | schema, 23 | logging, 24 | sessionStore, 25 | req.body, 26 | () => getContext(req, res, {stage: 'query'}), 27 | () => getContext(req, res, {stage: 'mutation'}), 28 | ).then(response => res.json(response), err => next(err)); 29 | }; 30 | return (req, res, next) => { 31 | if (req.method !== 'POST') return next(); 32 | if (!req.body) { 33 | jsonBody(req, res, err => { 34 | if (err) return next(err); 35 | processRequest(req, res, next); 36 | }); 37 | } else { 38 | processRequest(req, res, next); 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/network-layer.ts: -------------------------------------------------------------------------------- 1 | import request, {Options} from 'then-request'; 2 | import Request from './types/Request'; 3 | import NetworkLayerInterface from './types/NetworkLayerInterface'; 4 | import ServerResponse from './types/ServerResponse'; 5 | 6 | export {Options}; 7 | 8 | class NetworkLayer implements NetworkLayerInterface { 9 | private readonly _url: string; 10 | private readonly _options: Options; 11 | constructor(url?: string, options?: Options) { 12 | this._url = url || '/bicycle'; 13 | this._options = options || {}; 14 | } 15 | send(message: Request): Promise { 16 | return request('POST', this._url, {...this._options, json: message}) 17 | .getBody('utf8') 18 | .then(JSON.parse); 19 | } 20 | } 21 | 22 | export default NetworkLayer; 23 | -------------------------------------------------------------------------------- /src/runner/AccessDenied.src.ts: -------------------------------------------------------------------------------- 1 | // @opaque 2 | export type AccessDeniedType = {}; 3 | export const ACCESS_DENIED = {} as AccessDeniedType; 4 | export function isAccessDenied(v: any): v is AccessDeniedType { 5 | return v === ACCESS_DENIED; 6 | } 7 | -------------------------------------------------------------------------------- /src/runner/AccessDenied.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated opaque-types 3 | */ 4 | 5 | export type AccessDeniedType__Base = {}; 6 | declare const AccessDeniedType__Symbol: unique symbol; 7 | 8 | declare class AccessDeniedType__Class { 9 | private __kind: typeof AccessDeniedType__Symbol; 10 | } 11 | 12 | /** 13 | * @opaque 14 | * @base AccessDeniedType__Base 15 | */ 16 | type AccessDeniedType = AccessDeniedType__Class; 17 | const AccessDeniedType = { 18 | extract(value: AccessDeniedType): AccessDeniedType__Base { 19 | return value as any; 20 | }, 21 | 22 | unsafeCast(value: AccessDeniedType__Base): AccessDeniedType { 23 | return value as any; 24 | }, 25 | }; 26 | export {AccessDeniedType}; 27 | export const ACCESS_DENIED = {} as AccessDeniedType; 28 | export function isAccessDenied(v: any): v is AccessDeniedType { 29 | return v === ACCESS_DENIED; 30 | } 31 | -------------------------------------------------------------------------------- /src/runner/__tests__/args-parser.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import parseArgs from '../legacyArgParser'; 4 | 5 | test('parses args', () => { 6 | expect(parseArgs('(foo: "bar", bing: 10)')).toEqual({foo: 'bar', bing: 10}); 7 | }); 8 | 9 | test('parses undefined args', () => { 10 | expect(parseArgs('(foo: "bar", bing: undefined)')).toEqual({ 11 | foo: 'bar', 12 | bing: null, 13 | }); 14 | expect(parseArgs('(foo: "bar", bing:)')).toEqual({foo: 'bar', bing: null}); 15 | }); 16 | 17 | test('parses empty args', () => { 18 | expect(parseArgs('()')).toEqual({}); 19 | }); 20 | 21 | test('errors empty arg name', () => { 22 | expect(() => parseArgs('(:"foo")')).toThrowError( 23 | 'Argument name cannot be empty string, full string was "(:"foo")"', 24 | ); 25 | }); 26 | 27 | test('errors extra text after end of args', () => { 28 | expect(() => parseArgs('(foo: "bar", bing: 10) as foo')).toThrowError( 29 | 'Closing bracket was reached before end of arguments, full string was "(foo: "bar", bing: 10) as foo"', 30 | ); 31 | }); 32 | 33 | test('errors on missing closing bracket', () => { 34 | expect(() => parseArgs('(foo: "bar", bing: 10')).toThrowError( 35 | 'End of args string reached with no closing bracket, full string was "(foo: "bar", bing: 10"', 36 | ); 37 | }); 38 | 39 | test('errors on invalid JSON for attribute value', () => { 40 | expect(() => parseArgs('(foo: foo, bing: 10)')).toThrowError( 41 | "Could not parse arg \"foo with value 'foo', make sure the argument values are always valid JSON strings.", 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /src/runner/legacyArgParser.ts: -------------------------------------------------------------------------------- 1 | import {defaultState, parseChar} from 'character-parser'; 2 | import {inspect} from 'util'; 3 | import createError from '../utils/create-error'; 4 | 5 | // TODO: actually use this as a fallback when JSON.parse fails 6 | 7 | // parse from string of the form (name: "value" ...) into a JSON object (to match the args format for updates) 8 | export default function parseArgs(args: string): {[key: string]: any} { 9 | const result = {}; 10 | const fullArgsString = args; 11 | let state = 'key'; 12 | if (args[0] !== '(') { 13 | throw new Error('Legacy args must start with an open parenthesis'); 14 | } 15 | args = args.trim().substr(1); // ignore initial open bracket 16 | let currentKey = ''; 17 | let currentValue = ''; 18 | let cpState = defaultState(); 19 | while (args.length) { 20 | switch (state) { 21 | case 'key': 22 | if (args[0] === ':') { 23 | state = 'value'; 24 | cpState = defaultState(); 25 | currentKey = currentKey.trim(); 26 | args = args.substr(1); 27 | if (!currentKey) { 28 | throw createError( 29 | `Argument name cannot be empty string, full string was "${fullArgsString}"`, 30 | { 31 | exposeProd: true, 32 | code: 'ERROR_PARSING_ARGS', 33 | data: {fullArgsString}, 34 | }, 35 | ); 36 | } 37 | } else if (args[0] === ')' && !currentKey.trim()) { 38 | state = 'terminated'; 39 | args = args.substr(1); 40 | } else { 41 | currentKey += args[0]; 42 | args = args.substr(1); 43 | } 44 | break; 45 | case 'value': 46 | if (cpState.isNesting() || (args[0] !== ')' && args[0] !== ',')) { 47 | currentValue += args[0]; 48 | cpState = parseChar(args[0], cpState); 49 | args = args.substr(1); 50 | } else if (args[0] === ')') { 51 | result[currentKey] = parseValue(currentValue.trim(), currentKey); 52 | state = 'terminated'; 53 | args = args.substr(1); 54 | } else { 55 | result[currentKey] = parseValue(currentValue.trim(), currentKey); 56 | state = 'key'; 57 | currentKey = ''; 58 | currentValue = ''; 59 | args = args.substr(1); 60 | } 61 | break; 62 | case 'terminated': 63 | throw createError( 64 | `Closing bracket was reached before end of arguments, full string was "${fullArgsString}"`, 65 | { 66 | exposeProd: true, 67 | code: 'ERROR_PARSING_ARGS', 68 | data: {fullArgsString}, 69 | }, 70 | ); 71 | } 72 | } 73 | if (state !== 'terminated') { 74 | throw createError( 75 | `End of args string reached with no closing bracket, full string was "${fullArgsString}"`, 76 | {exposeProd: true, code: 'ERROR_PARSING_ARGS', data: {fullArgsString}}, 77 | ); 78 | } 79 | return result; 80 | } 81 | function parseValue(value: string, argName: string): any { 82 | try { 83 | return value === 'undefined' || !value ? null : JSON.parse(value); 84 | } catch (ex) { 85 | throw createError( 86 | `Could not parse arg "${argName} with value ${inspect( 87 | value, 88 | )}, make sure the argument values are always valid JSON strings.`, 89 | {exposeProd: true, code: 'ERROR_PARSING_ARG', data: {argName, value}}, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/runner/matchesType.ts: -------------------------------------------------------------------------------- 1 | import SchemaKind from '../types/SchemaKind'; 2 | import Schema from '../types/Schema'; 3 | import ValueType, {NamedType} from '../types/ValueType'; 4 | 5 | export default function matchesType( 6 | type: ValueType, 7 | value: any, 8 | schema: Schema, 9 | allowNodes: boolean, 10 | ): boolean { 11 | switch (type.kind) { 12 | case SchemaKind.Boolean: 13 | return typeof value === 'boolean'; 14 | case SchemaKind.List: 15 | return ( 16 | Array.isArray(value) && 17 | value.every(v => matchesType(type.element, v, schema, allowNodes)) 18 | ); 19 | case SchemaKind.Literal: 20 | return value === type.value; 21 | case SchemaKind.Null: 22 | return value === null; 23 | case SchemaKind.Number: 24 | return typeof value === 'number'; 25 | case SchemaKind.Object: 26 | return ( 27 | value && 28 | typeof value === 'object' && 29 | Object.keys(type.properties).every(p => 30 | matchesType(type.properties[p], value[p], schema, allowNodes), 31 | ) && 32 | Object.keys(value).every(p => 33 | Object.prototype.hasOwnProperty.call(type.properties, p), 34 | ) 35 | ); 36 | case SchemaKind.String: 37 | return typeof value === 'string'; 38 | case SchemaKind.Union: 39 | return type.elements.some(e => matchesType(e, value, schema, allowNodes)); 40 | case SchemaKind.Void: 41 | return value === undefined; 42 | case SchemaKind.Any: 43 | return true; 44 | case SchemaKind.Promise: 45 | throw new Error('Promises are not supported in this location'); 46 | case SchemaKind.Named: 47 | return matchesNamedType(type, value, schema, allowNodes); 48 | } 49 | } 50 | function matchesNamedType( 51 | type: NamedType, 52 | value: any, 53 | schema: Schema, 54 | allowNodes: boolean, 55 | ): boolean { 56 | const t = schema[type.name]; 57 | switch (t.kind) { 58 | case SchemaKind.NodeType: 59 | return allowNodes && t.matches(value) === true; 60 | case SchemaKind.Scalar: 61 | return ( 62 | // never allow nodes in the base type 63 | matchesType(t.baseType, value, schema, false) && 64 | t.validate(value) === true 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/runner/resolveField.ts: -------------------------------------------------------------------------------- 1 | import suggestMatch from '../utils/suggest-match'; 2 | import {validateArg} from './validate'; 3 | import resolveFieldResult from './resolveFieldResult'; 4 | 5 | import {CacheData} from '../types/Cache'; 6 | import {createErrorResult, isErrorResult} from '../types/ErrorResult'; 7 | import Query from '../types/Query'; 8 | import SchemaKind from '../types/SchemaKind'; 9 | import {NodeType} from '../types/Schema'; 10 | 11 | import QueryContext from '../types/QueryContext'; 12 | import parseLegacyArgs from './legacyArgParser'; 13 | 14 | const NS_PER_SEC = 1e9; 15 | const NS_PER_MS = 1e6; 16 | const EMPTY_OBJECT = {}; 17 | 18 | let isPerformanceMonitoring = false; 19 | let timings = EMPTY_OBJECT; 20 | let count = EMPTY_OBJECT; 21 | 22 | export function startMonitoringPerformance() { 23 | isPerformanceMonitoring = true; 24 | return {timings: (timings = {}), count: (count = {})}; 25 | } 26 | export function stopMonitoringPerformance() { 27 | const oldTimings = timings; 28 | const oldCount = count; 29 | isPerformanceMonitoring = false; 30 | timings = EMPTY_OBJECT; 31 | count = EMPTY_OBJECT; 32 | return {timings: oldTimings, count: oldCount}; 33 | } 34 | 35 | const waitingFields: Map Promise)[]> = new Map(); 36 | const queue: string[] = []; 37 | let running = false; 38 | 39 | const highPrecisionTiming = typeof process.hrtime === 'function'; 40 | function runTiming() { 41 | running = true; 42 | const start: any = highPrecisionTiming ? process.hrtime() : Date.now(); 43 | const id = queue.pop(); 44 | if (!id) { 45 | return; 46 | } 47 | const fns = waitingFields.get(id) || []; 48 | waitingFields.delete(id); 49 | return Promise.all(fns.map(f => f())).then(() => { 50 | const durationDiff: any = highPrecisionTiming 51 | ? process.hrtime(start) 52 | : Date.now() - start; 53 | const durationNanoseconds = highPrecisionTiming 54 | ? durationDiff[0] * NS_PER_SEC + durationDiff[1] 55 | : durationDiff * NS_PER_MS; 56 | if (typeof timings[id] !== 'number') { 57 | timings[id] = 0; 58 | } 59 | if (typeof count[id] !== 'number') { 60 | count[id] = 0; 61 | } 62 | timings[id] += durationNanoseconds; 63 | count[id] += fns.length; 64 | if (queue.length) { 65 | runTiming(); 66 | } else { 67 | running = false; 68 | } 69 | }); 70 | } 71 | 72 | function time(fn: (...args: any[]) => any, id: string) { 73 | return (...args: any[]) => 74 | new Promise((resolve, reject) => { 75 | if (!waitingFields.has(id)) { 76 | waitingFields.set(id, []); 77 | queue.push(id); 78 | } 79 | (waitingFields.get(id) || []).push(() => { 80 | return Promise.resolve(null) 81 | .then(() => fn(...args)) 82 | .then(resolve, reject); 83 | }); 84 | if (!running) { 85 | runTiming(); 86 | } 87 | }); 88 | } 89 | 90 | /** 91 | * Resolve a single field on a node to a value to be returned to the client 92 | */ 93 | export default function resolveField( 94 | type: NodeType, 95 | value: any, 96 | name: string, 97 | subQuery: true | Query, 98 | qCtx: QueryContext, 99 | ): Promise { 100 | const fname = name.split('(')[0]; 101 | const args = 102 | name.indexOf('(') !== -1 103 | ? name 104 | .split('(') 105 | .slice(1) 106 | .join('(') 107 | .replace(/\)$/, '') 108 | : undefined; 109 | let parsedArg: any = undefined; 110 | if (args) { 111 | if (/^\s*[a-zA-Z0-9]+\s*\:/.test(args)) { 112 | parsedArg = parseLegacyArgs('(' + args + ')'); 113 | } else { 114 | parsedArg = JSON.parse(args); 115 | } 116 | } 117 | const field = type.fields[fname]; 118 | if (!field) { 119 | const suggestion = suggestMatch(Object.keys(type.fields), fname); 120 | return Promise.resolve( 121 | createErrorResult( 122 | `Field "${fname}" does not exist on type "${type.name}"${suggestion}`, 123 | undefined, 124 | 'MISSING_FIELD_NAME', 125 | ), 126 | ); 127 | } 128 | if (field.kind === SchemaKind.FieldProperty) { 129 | return resolveFieldResult( 130 | field.resultType, 131 | value[fname], 132 | subQuery, 133 | qCtx, 134 | ).then(result => (result === undefined ? null : result)); 135 | } 136 | return Promise.resolve(null) 137 | .then(() => { 138 | if (typeof field.resolve !== 'function') { 139 | return createErrorResult( 140 | `Expected ${type.name}.${fname}.resolve to be a function.`, 141 | undefined, 142 | 'INVALID_SCHEMA', 143 | ); 144 | } 145 | let resolveField = field.resolve; 146 | if (isPerformanceMonitoring) { 147 | resolveField = time(resolveField, `${type.name}.${fname}`); 148 | } 149 | validateArg(field.argType, parsedArg, qCtx.schema); 150 | return Promise.resolve( 151 | field.auth === 'public' || 152 | field.auth(value, parsedArg, qCtx.context, subQuery, qCtx), 153 | ).then(hasAuth => { 154 | if (!hasAuth) { 155 | return createErrorResult( 156 | `Auth failed for ${type.name}.${fname}.`, 157 | undefined, 158 | 'AUTH_FAILED', 159 | ); 160 | } 161 | return resolveField(value, parsedArg, qCtx.context, subQuery, { 162 | fieldName: fname, 163 | subQuery, 164 | schema: qCtx.schema, 165 | context: qCtx.context, 166 | result: qCtx.result, 167 | logging: qCtx.logging, 168 | startedQueries: qCtx.startedQueries, 169 | }); 170 | }); 171 | }) 172 | .then(value => { 173 | if (isErrorResult(value)) return value; 174 | return resolveFieldResult(field.resultType, value, subQuery, qCtx); 175 | }) 176 | .then(value => (value === undefined ? null : value)); 177 | } 178 | -------------------------------------------------------------------------------- /src/runner/resolveFieldResult.ts: -------------------------------------------------------------------------------- 1 | import Query from '../types/Query'; 2 | import SchemaKind from '../types/SchemaKind'; 3 | import matchesType from './matchesType'; 4 | import ValueType from '../types/ValueType'; 5 | import {CacheData, CacheDataBase} from '../types/Cache'; 6 | import {validateResult} from './validate'; 7 | import typeNameFromDefinition from '../utils/type-name-from-definition'; 8 | import typeNameFromValue from '../utils/type-name-from-value'; 9 | import createError from '../utils/create-error'; 10 | import runQuery from './runQuery'; 11 | 12 | import QueryContext from '../types/QueryContext'; 13 | 14 | function getError(type: ValueType, value: any): Error { 15 | const expected = typeNameFromDefinition(type); 16 | if (process.env.NODE_ENV !== 'production') { 17 | const actual = typeNameFromValue(value); 18 | return createError( 19 | `Expected result to be of type "${expected}" but got "${actual}"`, 20 | {exposeProd: false, code: 'INVALID_RESULT_TYPE', data: {value, expected}}, 21 | ); 22 | } 23 | return createError(`Expected result to be of type "${expected}"`, { 24 | exposeProd: true, 25 | code: 'INVALID_RESULT_TYPE', 26 | data: {expected}, 27 | }); 28 | } 29 | 30 | function hasNamedType(type: ValueType): boolean { 31 | if (type._hasNamedType !== undefined) { 32 | return type._hasNamedType; 33 | } 34 | let result = false; 35 | switch (type.kind) { 36 | case SchemaKind.Union: 37 | result = type.elements.some(hasNamedType); 38 | break; 39 | case SchemaKind.List: 40 | result = hasNamedType(type.element); 41 | break; 42 | case SchemaKind.Object: 43 | result = Object.keys(type.properties).some(key => 44 | hasNamedType(type.properties[key]), 45 | ); 46 | break; 47 | case SchemaKind.Named: 48 | result = true; 49 | break; 50 | default: 51 | result = false; 52 | break; 53 | } 54 | type._hasNamedType = result; 55 | return result; 56 | } 57 | 58 | export default async function resolveFieldResult( 59 | type: ValueType, 60 | value: any, 61 | subQuery: true | Query, 62 | qCtx: QueryContext, 63 | ): Promise { 64 | if (hasNamedType(type)) { 65 | switch (type.kind) { 66 | case SchemaKind.Union: 67 | if ( 68 | type.elements.some( 69 | e => 70 | !hasNamedType(e) && matchesType(type, value, qCtx.schema, false), 71 | ) 72 | ) { 73 | return value; 74 | } 75 | const matchingElements = type.elements.filter( 76 | e => hasNamedType(e) && matchesType(type, value, qCtx.schema, true), 77 | ); 78 | if (matchingElements.length === 1) { 79 | return await resolveFieldResult( 80 | matchingElements[0], 81 | value, 82 | subQuery, 83 | qCtx, 84 | ); 85 | } else if (matchingElements.length === 0) { 86 | // it did not match any of the types in the union 87 | throw getError(type, value); 88 | } else { 89 | // it mached more tahn one of the types in the union 90 | throw createError( 91 | `The result was ambiguous. It could be more than one of the different elements in the union "${typeNameFromDefinition( 92 | type, 93 | )}"`, 94 | { 95 | exposeProd: true, 96 | code: 'INVALID_RESULT_TYPE', 97 | data: {expected: typeNameFromDefinition(type)}, 98 | }, 99 | ); 100 | } 101 | case SchemaKind.List: 102 | if (!Array.isArray(value)) { 103 | throw getError(type, value); 104 | } 105 | return Promise.all( 106 | value.map( 107 | async (v): Promise => { 108 | const result = await resolveFieldResult( 109 | type.element, 110 | v, 111 | subQuery, 112 | qCtx, 113 | ); 114 | if (Array.isArray(result)) { 115 | return result as any; 116 | } 117 | return result; 118 | }, 119 | ), 120 | ); 121 | case SchemaKind.Object: 122 | if ( 123 | !value || 124 | typeof value !== 'object' || 125 | !Object.keys(value).every(p => 126 | Object.prototype.hasOwnProperty.call(type.properties, p), 127 | ) 128 | ) { 129 | throw getError(type, value); 130 | } 131 | const result = {}; 132 | await Promise.all( 133 | Object.keys(type.properties).map(async p => { 134 | result[p] = await resolveFieldResult( 135 | type.properties[p], 136 | value[p], 137 | subQuery, 138 | qCtx, 139 | ); 140 | }), 141 | ); 142 | return result; 143 | case SchemaKind.Named: 144 | const s = qCtx.schema[type.name]; 145 | if (s.kind == SchemaKind.Scalar) { 146 | validateResult(s.baseType, value, qCtx.schema); 147 | if (s.validate(value) !== true) { 148 | throw getError(type, value); 149 | } 150 | return value; 151 | } 152 | if (s.matches(value) !== true) { 153 | throw createError( 154 | 'Expected subQuery to be an Object but got ' + 155 | typeNameFromValue(subQuery) + 156 | ' while getting ' + 157 | type.name, 158 | {}, 159 | ); 160 | } 161 | if (!subQuery || typeof subQuery !== 'object') { 162 | throw createError( 163 | 'Expected subQuery to be an Object but got ' + 164 | typeNameFromValue(subQuery) + 165 | ' while getting ' + 166 | type.name, 167 | {}, 168 | ); 169 | } 170 | return runQuery(s, value, subQuery, qCtx); 171 | } 172 | } 173 | validateResult(type, value, qCtx.schema); 174 | return value; 175 | } 176 | -------------------------------------------------------------------------------- /src/runner/runMutation.ts: -------------------------------------------------------------------------------- 1 | import MutationContext from '../types/MutationContext'; 2 | import MutationResult from '../types/MutationResult'; 3 | import {Mutation} from '../types/Schema'; 4 | import SchemaKind from '../types/SchemaKind'; 5 | import {validateArg, validateResult} from './validate'; 6 | import reportError from '../error-reporting'; 7 | import {AccessDeniedType, ACCESS_DENIED, isAccessDenied} from './AccessDenied'; 8 | 9 | export default function runMutation( 10 | mutation: Mutation, 11 | arg: Arg, 12 | mCtx: MutationContext, 13 | ): Promise> { 14 | return Promise.resolve(null) 15 | .then(() => { 16 | validateArg(mutation.argType, arg, mCtx.schema); 17 | return mutation.auth === 'public' 18 | ? true 19 | : mutation.auth(arg, mCtx.context, mCtx); 20 | }) 21 | .then(auth => { 22 | if (auth !== true) { 23 | return ACCESS_DENIED; 24 | } 25 | return mutation.resolve(arg, mCtx.context, mCtx); 26 | }) 27 | .then( 28 | (value: Result | AccessDeniedType): MutationResult => { 29 | if (isAccessDenied(value)) { 30 | return { 31 | s: false, 32 | v: { 33 | message: 'You do not have permission to call this mutation.', 34 | code: 'AUTH_ERROR', 35 | }, 36 | }; 37 | } 38 | if (mutation.resultType.kind === SchemaKind.Void) { 39 | return {s: true, v: (undefined as any) as Result}; 40 | } 41 | validateResult(mutation.resultType, value, mCtx.schema); 42 | return {s: true, v: value}; 43 | }, 44 | ) 45 | .catch( 46 | (ex: any): MutationResult => { 47 | reportError(ex, mCtx.logging); 48 | if (process.env.NODE_ENV === 'production' && !ex.exposeProd) { 49 | return { 50 | s: false, 51 | v: { 52 | message: 53 | 'Unexpected error while running ' + 54 | mutation.name + 55 | ' (If you are the software developer on this system, you can display ' + 56 | 'the actual error message by setting `NODE_ENV="development"` or setting ' + 57 | 'a property of `exposeProd = true` on the error object)', 58 | code: 'PRODUCTION_ERROR', 59 | }, 60 | }; 61 | } 62 | return { 63 | s: false, 64 | v: { 65 | message: ex.message + ' while running ' + mutation.name, 66 | data: ex.data, 67 | code: ex.code || undefined, 68 | }, 69 | }; 70 | }, 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/runner/runQuery.ts: -------------------------------------------------------------------------------- 1 | import typeNameFromValue from '../utils/type-name-from-value'; 2 | import resolveField from './resolveField'; 3 | import reportError from '../error-reporting'; 4 | import ErrorResult, {createErrorResult} from '../types/ErrorResult'; 5 | import Logging from '../types/Logging'; 6 | import Query from '../types/Query'; 7 | import {NodeType} from '../types/Schema'; 8 | 9 | import NodeID, {createNodeID, getNode} from '../types/NodeID'; 10 | import QueryContext, { 11 | MutableQuery, 12 | NormalizedQuery, 13 | } from '../types/QueryContext'; 14 | import isCached from '../utils/is-cached'; 15 | 16 | function getErrorObject( 17 | err: Error, 18 | context: string, 19 | logging: Logging, 20 | ): ErrorResult { 21 | const result = 22 | process.env.NODE_ENV === 'production' && !err.exposeProd 23 | ? createErrorResult( 24 | 'An unexpected error was encountered ' + 25 | context + 26 | ' (if you are the developer of this app, you can set "NODE_ENV" to "development" to expose the full error)', 27 | {}, 28 | 'PRODUCTION_ERROR', 29 | ) 30 | : createErrorResult( 31 | err.message + ' ' + context, 32 | err.data || {}, 33 | err.code, 34 | ); 35 | err.message += ' ' + context; 36 | reportError(err, logging); 37 | return result; 38 | } 39 | 40 | function makeMutableCopy(query: Query): MutableQuery { 41 | const result: MutableQuery = {}; 42 | Object.keys(query).forEach(key => { 43 | const q = query[key]; 44 | if (q === true) { 45 | result[key] = true; 46 | } else { 47 | result[key] = makeMutableCopy(q); 48 | } 49 | }); 50 | return result; 51 | } 52 | function subtractQueryPart( 53 | existingQuery: MutableQuery, 54 | newQuery: Query, 55 | ): void | Query { 56 | const result: MutableQuery = {}; 57 | let added = false; 58 | Object.keys(newQuery).forEach(key => { 59 | const existingPart = existingQuery[key]; 60 | const newPart = newQuery[key]; 61 | if (!existingPart) { 62 | existingQuery[key] = newPart === true ? true : makeMutableCopy(newPart); 63 | result[key] = newPart; 64 | added = true; 65 | return; 66 | } 67 | if (existingPart === true || newPart === true) { 68 | return; 69 | } 70 | const diff = subtractQueryPart(existingPart, newPart); 71 | if (diff) { 72 | result[key] = diff; 73 | added = true; 74 | return; 75 | } 76 | }); 77 | return added ? result : undefined; 78 | } 79 | function subtractQuery( 80 | id: NodeID, 81 | alreadyStartedQueries: NormalizedQuery, 82 | query: Query, 83 | ): void | Query { 84 | if (!alreadyStartedQueries[id.n]) { 85 | alreadyStartedQueries[id.n] = {}; 86 | alreadyStartedQueries[id.n][id.i] = makeMutableCopy(query); 87 | return query; 88 | } 89 | if (!alreadyStartedQueries[id.n][id.i]) { 90 | alreadyStartedQueries[id.n][id.i] = makeMutableCopy(query); 91 | return query; 92 | } 93 | return subtractQueryPart(alreadyStartedQueries[id.n][id.i], query); 94 | } 95 | export default function runQuery( 96 | type: NodeType, 97 | value: any, 98 | query: Query, 99 | qCtx: QueryContext, 100 | ): Promise { 101 | return Promise.resolve(null) 102 | .then(() => type.id(value, qCtx.context, qCtx)) 103 | .then( 104 | id => { 105 | const nodeID = createNodeID(type.name, id); 106 | const result = getNode(qCtx.result, nodeID); 107 | const remainingQuery = subtractQuery( 108 | nodeID, 109 | qCtx.startedQueries, 110 | query, 111 | ); 112 | if (!remainingQuery) { 113 | return nodeID; 114 | } 115 | return Promise.all( 116 | Object.keys(remainingQuery).map(key => { 117 | const subQuery = remainingQuery[key]; 118 | if ( 119 | !( 120 | subQuery === true || 121 | (subQuery != null && typeof subQuery === 'object') 122 | ) 123 | ) { 124 | result[key] = createErrorResult( 125 | 'Expected subQuery to be "true" or an Object but got ' + 126 | typeNameFromValue(subQuery) + 127 | ' while getting ' + 128 | type.name + 129 | '(' + 130 | id + 131 | ').' + 132 | key, 133 | {}, 134 | 'INVALID_SUB_QUERY', 135 | ); 136 | return; 137 | } 138 | if (isCached(qCtx.result, nodeID, key, subQuery)) { 139 | return; 140 | } 141 | return resolveField(type, value, key, subQuery, qCtx) 142 | .then(null, err => { 143 | return getErrorObject( 144 | err, 145 | 'while getting ' + type.name + '(' + nodeID.i + ').' + key, 146 | qCtx.logging, 147 | ); 148 | }) 149 | .then(value => { 150 | result[key] = value; 151 | }); 152 | }), 153 | ).then(() => nodeID); 154 | }, 155 | err => { 156 | return getErrorObject( 157 | err, 158 | 'while getting ID of ' + type.name, 159 | qCtx.logging, 160 | ); 161 | }, 162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /src/runner/validate.ts: -------------------------------------------------------------------------------- 1 | import matchesType from './matchesType'; 2 | import Schema from '../types/Schema'; 3 | import ValueType from '../types/ValueType'; 4 | import typeNameFromDefinition from '../utils/type-name-from-definition'; 5 | import typeNameFromValue from '../utils/type-name-from-value'; 6 | import createError from '../utils/create-error'; 7 | 8 | interface Options { 9 | name: string; 10 | exposeValueInProduction: boolean; 11 | code: string; 12 | } 13 | 14 | function validate( 15 | type: ValueType, 16 | value: any, 17 | schema: Schema, 18 | options: Options, 19 | ): void { 20 | if (!matchesType(type, value, schema, false)) { 21 | const expected = typeNameFromDefinition(type); 22 | if ( 23 | options.exposeValueInProduction || 24 | process.env.NODE_ENV !== 'production' 25 | ) { 26 | const actual = typeNameFromValue(value); 27 | throw createError( 28 | `Expected ${ 29 | options.name 30 | } to be of type "${expected}" but got "${actual}"`, 31 | { 32 | exposeProd: options.exposeValueInProduction, 33 | code: options.code, 34 | data: {value, expected}, 35 | }, 36 | ); 37 | } 38 | throw createError(`Expected ${options.name} to be of type "${expected}"`, { 39 | exposeProd: true, 40 | code: options.code, 41 | data: {expected}, 42 | }); 43 | } 44 | } 45 | 46 | function createValidator( 47 | options: Options, 48 | ): (type: ValueType, value: any, schema: Schema) => void { 49 | return (type, value, schema) => validate(type, value, schema, options); 50 | } 51 | 52 | export const validateArg = createValidator({ 53 | name: 'arg', 54 | exposeValueInProduction: true, 55 | code: 'INVALID_ARGUMENT_TYPE', 56 | }); 57 | export const validateResult = createValidator({ 58 | name: 'result', 59 | exposeValueInProduction: false, 60 | code: 'INVALID_RESULT_TYPE', 61 | }); 62 | -------------------------------------------------------------------------------- /src/scripts/helpers/getApiItem.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import * as utils from './utils'; 3 | 4 | export default function getApiItem(path: string) { 5 | const spl = path.split('#'); 6 | assert(spl.length === 2); 7 | const [filename, member] = spl; 8 | assert(filename.indexOf('./') === 0); 9 | const pkg = utils.getApiPackage(`lib/${filename.substr(2)}.d.ts`); 10 | const exportedMembers = pkg.findMembersByName(member); 11 | assert(exportedMembers.length === 1); 12 | 13 | const exportedMember = exportedMembers[0]; 14 | 15 | return exportedMember; 16 | } 17 | -------------------------------------------------------------------------------- /src/scripts/helpers/printApiItem.ts: -------------------------------------------------------------------------------- 1 | import {StringBuilder} from '@microsoft/tsdoc'; 2 | import * as ae from '@microsoft/api-extractor'; 3 | import * as utils from './utils'; 4 | import {MarkdownEmitter} from './MarkdownEmitter'; 5 | const prettier = require('prettier'); 6 | 7 | const md = new MarkdownEmitter(); 8 | function trimLeadingWhitespace(str: string) { 9 | const split = str.trim().split('\n'); 10 | let shortestIndent = null; 11 | for (let i = 1; i < split.length; i++) { 12 | const indent = /^\s*/.exec(split[i])![0]; 13 | if (shortestIndent === null || indent.length < shortestIndent.length) { 14 | shortestIndent = indent; 15 | } 16 | } 17 | if (shortestIndent) { 18 | for (let i = 1; i < split.length; i++) { 19 | split[i] = split[i].substring(shortestIndent.length); 20 | } 21 | } 22 | return split.join('\n'); 23 | } 24 | function formatType(str: string): string { 25 | return trimLeadingWhitespace( 26 | (prettier.format(`type T = ${str}`, { 27 | parser: 'typescript', 28 | printWidth: 40, 29 | semi: true, 30 | singleQuote: true, 31 | bracketSpacing: false, 32 | trailingComma: 'all', 33 | }) as string) 34 | .trim() 35 | .substr(`type T = `.length) 36 | .replace(/\;$/, ''), 37 | ); 38 | } 39 | export default function printApiItem(item: ae.ApiItem, options: any): string { 40 | if (item instanceof ae.ApiInterface) { 41 | // R 42 | return [ 43 | ``, 44 | ``, 45 | ...item.members.map(member => { 46 | if (member instanceof ae.ApiPropertySignature) { 47 | const str = new StringBuilder(); 48 | member.tsdocComment && 49 | md.emit(str, member.tsdocComment.summarySection, { 50 | insideTable: true, 51 | }); 52 | // const referenceIndex = member.excerptTokens 53 | // .map(t => t.kind) 54 | // .indexOf(ae.ExcerptTokenKind.Reference); 55 | // const optional = 56 | // member.excerptTokens[referenceIndex + 1] && 57 | // /^\s*\?\:\s*$/.test(member.excerptTokens[referenceIndex + 1].text); 58 | // 61 | return ``; 66 | } 67 | console.error('Unsupported member ApiItem Type:'); 68 | console.error(utils.serialize(member)); 69 | return process.exit(1); 70 | }), 71 | `
    NameTypeDescription
    ${ 59 | // optional ? '' : '✔' 60 | // }
    ${
    62 |             member.name
    63 |           }
    ${formatType(
    64 |             member.propertyTypeExcerpt.text.replace(/\n */gm, ' '),
    65 |           )}
    ${str.toString()}
    `, 72 | ].join('\n'); 73 | } 74 | console.error('Unsupported ApiItem Type:'); 75 | console.error(utils.serialize(item)); 76 | return process.exit(1); 77 | } 78 | -------------------------------------------------------------------------------- /src/scripts/helpers/processFile.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | import {runInNewContext} from 'vm'; 3 | import getApiItem from './getApiItem'; 4 | import printApiItem from './printApiItem'; 5 | 6 | function evaluate(expression: string): any { 7 | const sandbox = {sandboxvar: null as any}; 8 | runInNewContext('sandboxvar=' + expression.trim(), sandbox); 9 | return sandbox.sandboxvar; 10 | } 11 | const PREFIX = '') === 0, 17 | 'expected ""', 18 | ); 19 | const config = split[i].split(' -->')[0]; 20 | const match = /^start *([^\(]+)(?:\(([^\)]+)\))? *$/.exec(config); 21 | assert(match, 'Expected ""'); 22 | const path = match![1]; 23 | const options = match![2] ? evaluate(match![2]) : {}; 24 | const apiItem = getApiItem(path); 25 | const documentation = printApiItem(apiItem, options); 26 | split[i] = config + ' -->\n\n' + documentation + '\n\n'; 27 | } 28 | return split.join(PREFIX); 29 | } 30 | -------------------------------------------------------------------------------- /src/scripts/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import {tmpdir} from 'os'; 2 | import * as ae from '@microsoft/api-extractor'; 3 | import cuid = require('cuid'); 4 | import {sync as rimraf} from 'rimraf'; 5 | import {readFileSync} from 'fs'; 6 | import assert = require('assert'); 7 | 8 | export function getApiPackage(entryPoint: string) { 9 | const outputFolder = tmpdir() + '/' + cuid(); 10 | const extractor = new ae.Extractor( 11 | { 12 | compiler: { 13 | configType: 'tsconfig', 14 | rootFolder: '.', 15 | }, 16 | project: { 17 | entryPointSourceFile: entryPoint, 18 | }, 19 | apiReviewFile: { 20 | enabled: false, 21 | }, 22 | apiJsonFile: { 23 | enabled: true, 24 | outputFolder, 25 | }, 26 | dtsRollup: { 27 | enabled: false, 28 | }, 29 | }, 30 | {localBuild: true}, 31 | ); 32 | extractor.processProject({}); 33 | const json = JSON.parse( 34 | readFileSync(outputFolder + '/bicycle.api.json', 'utf8'), 35 | ); 36 | rimraf(outputFolder); 37 | const item = ae.ApiItem.deserialize(json) as ae.ApiPackage; 38 | assert(item instanceof ae.ApiPackage); 39 | const entryPoints = item.findEntryPointsByPath(''); 40 | assert(entryPoints.length === 1); 41 | return entryPoints[0]; 42 | } 43 | 44 | /** 45 | * convert an ApiItem to a JSON structure 46 | */ 47 | export function serialize(item: ae.ApiItem) { 48 | const result: any = {}; 49 | item.serializeInto(result); 50 | return result; 51 | } 52 | -------------------------------------------------------------------------------- /src/scripts/prepare-docs.ts: -------------------------------------------------------------------------------- 1 | import {readdirSync, readFileSync, writeFileSync} from 'fs'; 2 | import processFile from './helpers/processFile'; 3 | 4 | readdirSync(`docs`).forEach(doc => { 5 | writeFileSync( 6 | `docs/${doc}`, 7 | processFile(readFileSync(`docs/${doc}`, 'utf8')), 8 | ); 9 | }); 10 | -------------------------------------------------------------------------------- /src/server-rendering.ts: -------------------------------------------------------------------------------- 1 | import cuid = require('cuid'); 2 | import {Request, Response} from 'express'; 3 | import notEqual from './utils/not-equal'; 4 | import mergeQueries from './utils/merge-queries'; 5 | import runQueryAgainstCache, { 6 | QueryCacheResult, 7 | } from './utils/run-query-against-cache'; 8 | import getSessionID from './utils/get-session-id'; 9 | import {runQuery} from './runner'; 10 | 11 | import SessionStore from './sessions/SessionStore'; 12 | import Cache from './types/Cache'; 13 | import Logging from './types/Logging'; 14 | import Query from './types/Query'; 15 | import Schema from './types/Schema'; 16 | import SessionID from './types/SessionID'; 17 | import ServerPreparation, { 18 | createServerPreparation, 19 | } from './types/ServerPreparation'; 20 | 21 | import {BaseRootQuery} from './typed-helpers/query'; 22 | import withContext, {Ctx} from './Ctx'; 23 | import SessionVersion from './types/SessionVersion'; 24 | 25 | export class FakeClient { 26 | _sessionID: SessionID; 27 | _query: Query; 28 | _cache: Cache; 29 | constructor(sessionID: SessionID) { 30 | this._sessionID = sessionID; 31 | this._query = {}; 32 | this._cache = {Root: {root: {}}}; 33 | } 34 | queryCache(query: BaseRootQuery): QueryCacheResult; 35 | queryCache(query: Query): QueryCacheResult; 36 | queryCache( 37 | query: Query | BaseRootQuery, 38 | ): QueryCacheResult { 39 | const q = query instanceof BaseRootQuery ? query._query : query; 40 | this._query = mergeQueries(this._query, q); 41 | return runQueryAgainstCache(this._cache, q); 42 | } 43 | update() { 44 | throw new Error('Bicycle server renderer does not implement update'); 45 | } 46 | subscribe() { 47 | throw new Error('Bicycle server renderer does not implement subscribe'); 48 | } 49 | } 50 | 51 | export default function prepare( 52 | schema: Schema, 53 | logging: Logging, 54 | sessionStore: SessionStore, 55 | getContext: ( 56 | req: Request, 57 | res: Response, 58 | options: {stage: 'query' | 'mutation'}, 59 | ) => Ctx, 60 | fn: ( 61 | client: FakeClient, 62 | req: Request, 63 | res: Response, 64 | ...args: any[] 65 | ) => TResult, 66 | ): ( 67 | req: Request, 68 | res: Response, 69 | ...args: any[] 70 | ) => Promise<{serverPreparation: ServerPreparation; result: TResult}> { 71 | return (req, res, ...args: any[]) => { 72 | return Promise.all([ 73 | getSessionID(sessionStore), 74 | getContext(req, res, {stage: 'query'}), 75 | ]).then(([sessionID, context]) => { 76 | const client = new FakeClient(sessionID); 77 | return withContext(context, context => { 78 | const queryContext = {schema, logging, context}; 79 | return new Promise((resolve, reject) => { 80 | function next() { 81 | try { 82 | const oldQuery = client._query; 83 | const oldCache = client._cache; 84 | const result = fn(client, req, res, ...args); 85 | const newQuery = client._query; 86 | if (!notEqual(oldQuery, newQuery)) { 87 | return resolve(result); 88 | } 89 | runQuery(newQuery, queryContext).then( 90 | data => { 91 | if (notEqual(oldCache, data)) { 92 | client._cache = data; 93 | return next(); 94 | } else { 95 | return resolve(result); 96 | } 97 | }, 98 | err => { 99 | reject(err); 100 | }, 101 | ); 102 | } catch (ex) { 103 | reject(ex); 104 | } 105 | } 106 | next(); 107 | }).then(result => { 108 | return sessionStore 109 | .tx(sessionID, () => { 110 | const version = SessionVersion.unsafeCast(cuid()); 111 | return Promise.resolve({ 112 | session: { 113 | versions: [ 114 | {version, query: client._query, cache: client._cache}, 115 | ], 116 | mutations: {}, 117 | }, 118 | result: version, 119 | }); 120 | }) 121 | .then(version => ({ 122 | result, 123 | serverPreparation: createServerPreparation( 124 | sessionID, 125 | version, 126 | client._query, 127 | client._cache, 128 | ), 129 | })); 130 | }); 131 | }); 132 | }); 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import BicycleServerCore, {Options} from './server-core'; 2 | import loadSchema, {loadSchemaFromFiles} from './load-schema'; 3 | 4 | export {Options}; 5 | 6 | /** 7 | * BicycleServer provides methods for exposing your 8 | * schema over a network, and for directly querying 9 | * your schema on the server. 10 | * 11 | * @public 12 | */ 13 | class BicycleServer extends BicycleServerCore { 14 | constructor( 15 | schema: {objects: any[]; scalars?: any[]} | string, 16 | options: Options = {}, 17 | ) { 18 | super( 19 | typeof schema === 'string' 20 | ? loadSchemaFromFiles(schema) 21 | : loadSchema(schema), 22 | options, 23 | ); 24 | } 25 | } 26 | 27 | export default BicycleServer; 28 | -------------------------------------------------------------------------------- /src/sessions/HashLRU.ts: -------------------------------------------------------------------------------- 1 | export default class HashLRU { 2 | private readonly _max: number; 3 | private _previousSize = 0; 4 | private _currentSize = 0; 5 | private _previous = Object.create(null); 6 | private _current = Object.create(null); 7 | constructor(max: number) { 8 | this._max = max; 9 | if (typeof max !== 'number' || max < 1 || (max | 0) !== max) { 10 | throw Error( 11 | 'hashlru must have a max value, of type number, greater than 0', 12 | ); 13 | } 14 | } 15 | private _update(key: Key, value: Value) { 16 | if (this._current[key] === undefined) this._currentSize++; 17 | this._current[key] = value; 18 | if (this._currentSize >= this._max) { 19 | this._previousSize = this._max; 20 | this._currentSize = 0; 21 | this._previous = this._current; 22 | this._current = Object.create(null); 23 | } else if (this._previous[key] !== undefined) { 24 | this._previousSize--; 25 | this._previous[key] = undefined; 26 | } 27 | } 28 | 29 | has(key: Key): boolean { 30 | return ( 31 | this._previous[key] !== undefined || this._current[key] !== undefined 32 | ); 33 | } 34 | remove(key: Key): void { 35 | if (this._current[key] !== undefined) { 36 | this._currentSize--; 37 | this._current[key] = undefined; 38 | } 39 | if (this._previous[key] !== undefined) { 40 | this._previousSize--; 41 | this._previous[key] = undefined; 42 | } 43 | } 44 | get(key: Key): Value | void { 45 | let v = this._current[key]; 46 | if (v !== undefined) return v; 47 | if ((v = this._previous[key]) !== undefined) { 48 | this._update(key, v); 49 | return v; 50 | } 51 | } 52 | set(key: Key, value: Value): void { 53 | if (this._current[key] !== undefined) this._current[key] = value; 54 | else this._update(key, value); 55 | } 56 | clear(): void { 57 | this._current = Object.create(null); 58 | this._previous = Object.create(null); 59 | this._currentSize = 0; 60 | this._previousSize = 0; 61 | } 62 | getMaxSize() { 63 | return this._max; 64 | } 65 | getSize() { 66 | return this._currentSize + this._previousSize; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/sessions/MemorySessionStore.ts: -------------------------------------------------------------------------------- 1 | import LockByID from '@authentication/lock-by-id'; 2 | import SessionID from '../types/SessionID'; 3 | import SessionStore, {Session} from './SessionStore'; 4 | import HashLRU from './HashLRU'; 5 | 6 | const DEFAULT_SIZE = process.env.BICYCLE_SESSION_STORE_SIZE 7 | ? parseInt(process.env.BICYCLE_SESSION_STORE_SIZE, 10) 8 | : 100; 9 | 10 | class MemorySessionStore implements SessionStore { 11 | private readonly _cache: HashLRU; 12 | private readonly _lock = new LockByID(); 13 | constructor(size: number = DEFAULT_SIZE) { 14 | this._cache = new HashLRU(size); 15 | } 16 | tx( 17 | id: SessionID, 18 | fn: (session: Session | null) => Promise<{session: Session; result: T}>, 19 | ): Promise { 20 | return this._lock.withLock(SessionID.extract(id), async () => { 21 | const {session, result} = await fn(this._cache.get(id) || null); 22 | this._cache.set(id, session); 23 | return result; 24 | }); 25 | } 26 | getSessionCount() { 27 | return this._cache.getSize(); 28 | } 29 | getMaxSessionCount() { 30 | return this._cache.getMaxSize() * 2; 31 | } 32 | } 33 | 34 | export default MemorySessionStore; 35 | -------------------------------------------------------------------------------- /src/sessions/SessionStore.ts: -------------------------------------------------------------------------------- 1 | import Cache from '../types/Cache'; 2 | import MutationResult from '../types/MutationResult'; 3 | import Query from '../types/Query'; 4 | import SessionID from '../types/SessionID'; 5 | import SessionVersion from '../types/SessionVersion'; 6 | 7 | export interface SessionState { 8 | query: Query; 9 | cache: Cache; 10 | version: SessionVersion; 11 | } 12 | 13 | export interface Session { 14 | versions: SessionState[]; 15 | mutations: {[key: string /* MutationID */]: MutationResult}; 16 | } 17 | 18 | export default interface SessionStore { 19 | readonly getSessionID?: () => PromiseLike; 20 | tx( 21 | id: SessionID, 22 | fn: (session: Session | null) => Promise<{session: Session; result: T}>, 23 | ): Promise; 24 | getSessionCount?: () => number; 25 | getMaxSessionCount?: () => number; 26 | } 27 | -------------------------------------------------------------------------------- /src/test-schema/objects/root.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Root', 3 | fields: { 4 | todoById: { 5 | type: 'Todo', 6 | args: {id: 'string'}, 7 | resolve(root: {}, {id}: {id: string}, {db}: {db: any}) { 8 | return db.getTodo(id); 9 | }, 10 | }, 11 | todos: { 12 | type: 'Todo[]', 13 | resolve(root: {}, args: void, {db}: {db: any}) { 14 | return db.getTodos(); 15 | }, 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/test-schema/objects/todo.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Todo', 3 | fields: { 4 | id: 'id', 5 | title: 'string', 6 | completed: 'boolean', 7 | }, 8 | mutations: { 9 | addTodo: { 10 | type: {id: 'id'}, 11 | args: {title: 'string', completed: 'boolean'}, 12 | resolve( 13 | {title, completed}: {title: string; completed: boolean}, 14 | {db}: {db: any}, 15 | ): {id: string} { 16 | return db.addTodo({title, completed}).then((id: string) => ({id})); 17 | }, 18 | }, 19 | toggleAll: { 20 | args: {checked: 'boolean'}, 21 | resolve({checked}: {checked: boolean}, {db}: {db: any}) { 22 | return db.toggleAll(checked); 23 | }, 24 | }, 25 | toggle: { 26 | args: {id: 'id', checked: 'boolean'}, 27 | resolve({id, checked}: {id: string; checked: boolean}, {db}: {db: any}) { 28 | return db.toggle(id, checked); 29 | }, 30 | }, 31 | destroy: { 32 | args: {id: 'id'}, 33 | resolve({id}: {id: string}, {db}: {db: any}) { 34 | return db.destroy(id); 35 | }, 36 | }, 37 | save: { 38 | args: {id: 'id', title: 'string'}, 39 | resolve({id, title}: {id: string; title: string}, {db}: {db: any}) { 40 | return db.setTitle(id, title); 41 | }, 42 | }, 43 | clearCompleted: { 44 | resolve(args: void, {db}: {db: any}) { 45 | return db.clearCompleted(); 46 | }, 47 | }, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/test-schema/scalars/id.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'id', 3 | baseType: 'string', 4 | validate(value: string): boolean { 5 | // validate that it matches the format of the values returned by uuid() 6 | return /^[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}$/.test( 7 | value, 8 | ); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/typed-helpers/client.ts: -------------------------------------------------------------------------------- 1 | // generated by ts-bicycle 2 | // do not edit by hand 3 | 4 | import BicycleClient from '../client'; 5 | 6 | export default class Client extends BicycleClient { 7 | defineOptimisticUpdaters(updates: T) { 8 | super.defineOptimisticUpdaters(updates); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/typed-helpers/query.ts: -------------------------------------------------------------------------------- 1 | import Q from '../types/Query'; 2 | import {OptimisticUpdateHandler} from '../client/optimistic'; 3 | 4 | const stringify: (value: any) => string = require('stable-stringify'); 5 | 6 | export {stringify}; 7 | export {Q}; 8 | 9 | export function merge(a: Q, b: Q): Q { 10 | const result = {}; 11 | for (const key in a) { 12 | result[key] = a[key]; 13 | } 14 | for (const key in b) { 15 | const bVal = b[key]; 16 | if (bVal === true || !result[key]) { 17 | result[key] = bVal; 18 | } else { 19 | result[key] = merge(result[key], bVal); 20 | } 21 | } 22 | return result; 23 | } 24 | export function addField(query: Q, field: string, subQuery: true | Q): Q { 25 | const result = {}; 26 | for (const key in query) { 27 | result[key] = query[key]; 28 | } 29 | if (subQuery === true || !result[field]) { 30 | result[field] = subQuery; 31 | } else { 32 | result[field] = merge(result[field], subQuery); 33 | } 34 | return result; 35 | } 36 | 37 | export abstract class BaseQuery { 38 | readonly _query: Q; 39 | /** 40 | * Usage: 41 | * type Result = typeof Query.$type; 42 | */ 43 | public readonly $type: TResult; 44 | constructor(query: Q) { 45 | this._query = query; 46 | this.$type = null as any; 47 | } 48 | } 49 | 50 | export abstract class BaseRootQuery extends BaseQuery { 51 | protected _root: true = true; 52 | } 53 | 54 | export class Mutation { 55 | readonly _name: string; 56 | readonly _args: any; 57 | readonly _optimisticUpdate: OptimisticUpdateHandler | void; 58 | /** 59 | * Usage: 60 | * type Result = typeof Mutation.$type; 61 | */ 62 | public readonly $type: TResult; 63 | constructor( 64 | name: string, 65 | args: any, 66 | optimisticUpdate: OptimisticUpdateHandler | void, 67 | ) { 68 | this._name = name; 69 | this._args = args; 70 | this._optimisticUpdate = optimisticUpdate; 71 | this.$type = null as any; 72 | } 73 | } 74 | 75 | /** 76 | * Usage: 77 | * 78 | * type TResult = typeof getType(query); 79 | * 80 | * @param query A bicycle query 81 | */ 82 | function getType(query: BaseQuery): TResult; 83 | function getType(query: Mutation): TResult; 84 | function getType( 85 | query: (...args: any[]) => Mutation, 86 | ): TResult; 87 | function getType( 88 | query: 89 | | BaseQuery 90 | | Mutation 91 | | ((...args: any[]) => Mutation), 92 | ): TResult { 93 | return null as any; 94 | } 95 | export {getType}; 96 | -------------------------------------------------------------------------------- /src/types/Cache.ts: -------------------------------------------------------------------------------- 1 | import ErrorResult, {isErrorResult} from './ErrorResult'; 2 | import NodeID from './NodeID'; 3 | 4 | export type CacheDataPrimative = 5 | | void 6 | | null 7 | | string 8 | | number 9 | | boolean 10 | | ErrorResult; 11 | 12 | // cannot include the array here as it makes this data type circular 13 | export type CacheDataBase = CacheDataPrimative | NodeID | CacheObject; 14 | 15 | export type CacheUpdateDataBase = 16 | | CacheDataPrimative 17 | | NodeID 18 | | CacheUpdateObject; 19 | 20 | // use any here to support arbitrary nesting of arrays 21 | export type CacheData = CacheDataBase | (CacheDataBase | any[])[]; 22 | export type CacheUpdateData = CacheUpdateDataBase | (CacheDataBase | any[])[]; 23 | 24 | export interface CacheUpdateObject { 25 | [name: string]: CacheUpdateData; 26 | } 27 | 28 | // a CacheObject can always safely be used in place of a CacheUpdateObject 29 | export interface CacheObject extends CacheUpdateObject { 30 | [name: string]: CacheData; 31 | } 32 | 33 | export interface NodeCache extends NodeCacheUpdate { 34 | [id: string]: void | CacheObject; 35 | } 36 | interface Cache extends CacheUpdate { 37 | [nodeName: string]: void | NodeCache; 38 | } 39 | export default Cache; 40 | 41 | export interface NodeCacheUpdate { 42 | [id: string]: void | CacheUpdateObject; 43 | } 44 | export interface CacheUpdate { 45 | [nodeName: string]: void | NodeCacheUpdate; 46 | } 47 | 48 | export function isCacheObject(cache: CacheData): cache is CacheObject; 49 | export function isCacheObject( 50 | cache: CacheUpdateData, 51 | ): cache is CacheUpdateObject; 52 | export function isCacheObject( 53 | cache: CacheData | CacheUpdateData, 54 | ): cache is CacheObject | CacheUpdateObject { 55 | return !!( 56 | cache && 57 | typeof cache === 'object' && 58 | !Array.isArray(cache) && 59 | !isErrorResult(cache) 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/types/ErrorResult.ts: -------------------------------------------------------------------------------- 1 | import CacheUpdateType from '../CacheUpdateType'; 2 | 3 | export function createErrorResult( 4 | message: string, 5 | data?: any, 6 | code?: string, 7 | ): ErrorResult { 8 | return { 9 | _type: CacheUpdateType.ERROR, 10 | message: message, 11 | data: data, 12 | code: code, 13 | }; 14 | } 15 | 16 | export function isErrorResult(cache: any): cache is ErrorResult { 17 | return !!( 18 | cache && 19 | typeof cache === 'object' && 20 | (cache as ErrorResult)._type === CacheUpdateType.ERROR 21 | ); 22 | } 23 | export default interface ErrorResult { 24 | readonly _type: CacheUpdateType.ERROR; 25 | readonly message: string; 26 | readonly data: any; 27 | readonly code?: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/types/Logging.ts: -------------------------------------------------------------------------------- 1 | import BicycleRequest from './Request'; 2 | import MutationResult from './MutationResult'; 3 | import ServerResponse from './ServerResponse'; 4 | 5 | export default interface Logging { 6 | readonly disableDefaultLogging: boolean; 7 | readonly onError: (e: {error: Error}) => any; 8 | readonly onRequestStart: (e: {readonly request: BicycleRequest}) => any; 9 | readonly onRequestEnd: ( 10 | e: {readonly request: BicycleRequest; readonly response: ServerResponse}, 11 | ) => any; 12 | readonly onMutationStart: ( 13 | e: { 14 | mutation: {readonly method: string; readonly args: Object}; 15 | context: any; 16 | }, 17 | ) => any; 18 | readonly onMutationEnd: ( 19 | e: { 20 | mutation: {readonly method: string; readonly args: Object}; 21 | result: MutationResult; 22 | context: any; 23 | }, 24 | ) => any; 25 | readonly onQueryStart: (e: {query: Object; context: any}) => any; 26 | readonly onQueryEnd: ( 27 | e: {query: Object; cacheResult: Object; context: any}, 28 | ) => any; 29 | } 30 | -------------------------------------------------------------------------------- /src/types/MutationContext.ts: -------------------------------------------------------------------------------- 1 | import Logging from './Logging'; 2 | import Schema from './Schema'; 3 | 4 | export default interface MutationContext { 5 | schema: Schema; 6 | logging: Logging; 7 | context: Context; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/MutationID.src.ts: -------------------------------------------------------------------------------- 1 | // @opaque 2 | type MutationID = string; 3 | export default MutationID; 4 | -------------------------------------------------------------------------------- /src/types/MutationID.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated opaque-types 3 | */ 4 | 5 | export type MutationID__Base = string; 6 | declare const MutationID__Symbol: unique symbol; 7 | 8 | declare class MutationID__Class { 9 | private __kind: typeof MutationID__Symbol; 10 | } 11 | 12 | /** 13 | * @opaque 14 | * @base MutationID__Base 15 | */ 16 | type MutationID = MutationID__Class; 17 | const MutationID = { 18 | extract(value: MutationID): MutationID__Base { 19 | return value as any; 20 | }, 21 | 22 | unsafeCast(value: MutationID__Base): MutationID { 23 | return value as any; 24 | }, 25 | }; 26 | export default MutationID; 27 | -------------------------------------------------------------------------------- /src/types/MutationResult.ts: -------------------------------------------------------------------------------- 1 | export interface SuccessMutationResult { 2 | readonly s: true; 3 | readonly v: T; 4 | } 5 | export interface MutationError { 6 | readonly message: string; 7 | readonly data?: any; 8 | readonly code?: string; 9 | } 10 | 11 | export interface FailureMutationResult { 12 | readonly s: false; 13 | readonly v: MutationError; 14 | } 15 | type MutationResult = SuccessMutationResult | FailureMutationResult; 16 | export default MutationResult; 17 | -------------------------------------------------------------------------------- /src/types/NetworkLayerInterface.ts: -------------------------------------------------------------------------------- 1 | import Request from './Request'; 2 | import ServerResponse from './ServerResponse'; 3 | 4 | export default interface NetworkLayerInterface { 5 | send: (message: Request) => PromiseLike; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/NodeID.ts: -------------------------------------------------------------------------------- 1 | import CacheUpdateType from '../CacheUpdateType'; 2 | import Cache, {CacheData, CacheObject} from './Cache'; 3 | 4 | export default interface NodeID { 5 | _type: CacheUpdateType.NODE_ID; 6 | // the node type's name 7 | n: string; 8 | // the node's id 9 | i: string | number; 10 | } 11 | export function createNodeID(name: string, id: string): NodeID { 12 | return {_type: CacheUpdateType.NODE_ID, n: name, i: id}; 13 | } 14 | export function isID(id: CacheData): id is NodeID { 15 | return !!( 16 | id && 17 | typeof id === 'object' && 18 | (id as NodeID)._type === CacheUpdateType.NODE_ID 19 | ); 20 | } 21 | export function getNode(cache: Cache, id: NodeID): CacheObject { 22 | const nodeCache = cache[id.n] || (cache[id.n] = {}); 23 | return nodeCache[id.i] || (nodeCache[id.i] = {}); 24 | } 25 | export function getNodeIfExists(cache: Cache, id: NodeID): CacheObject | null { 26 | const nodeCache = cache[id.n]; 27 | if (!nodeCache) { 28 | return null; 29 | } 30 | return nodeCache[id.i] || null; 31 | } 32 | -------------------------------------------------------------------------------- /src/types/Query.ts: -------------------------------------------------------------------------------- 1 | interface Query { 2 | readonly [key: string]: true | Query; 3 | } 4 | export default Query; 5 | export interface QueryUpdate { 6 | readonly [key: string]: boolean | QueryUpdate; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/QueryContext.ts: -------------------------------------------------------------------------------- 1 | import Cache from './Cache'; 2 | import Logging from './Logging'; 3 | import Schema from './Schema'; 4 | 5 | export default interface QueryContext { 6 | schema: Schema; 7 | context: Context; 8 | result: Cache; 9 | logging: Logging; 10 | /** 11 | * This represents a normalised record of which fields are already being 12 | * queried, so that everything is only queried once. e.g. for a query like: 13 | * 14 | * {foo: {a: true, b: {c: true, d: true}}, bar: {a: true, b: {c: true, d: true}}} 15 | * 16 | * if `foo` and `bar` refer to an object with the same `ObjectID` we will only resolve all 17 | * the fields within that object once. 18 | */ 19 | startedQueries: NormalizedQuery; 20 | } 21 | 22 | export interface MutableQuery { 23 | [key: string]: true | MutableQuery; 24 | } 25 | export interface NodeQueries { 26 | [id: string]: void | MutableQuery; 27 | } 28 | export interface NormalizedQuery { 29 | [nodeName: string]: void | NodeQueries; 30 | } 31 | -------------------------------------------------------------------------------- /src/types/Request.ts: -------------------------------------------------------------------------------- 1 | import Mutation from '../client/mutation'; 2 | import MutationID from './MutationID'; 3 | import SessionID from './SessionID'; 4 | import SessionVersion from './SessionVersion'; 5 | import Query, {QueryUpdate} from './Query'; 6 | 7 | export const enum RequestKind { 8 | NEW_SESSION = 1, 9 | UPDATE = 2, 10 | RESTORE_SESSION = 3, 11 | } 12 | export interface NewSession { 13 | readonly k: RequestKind.NEW_SESSION; 14 | readonly q: Query; 15 | } 16 | export interface Update { 17 | readonly k: RequestKind.UPDATE; 18 | readonly s: SessionID; 19 | readonly v: SessionVersion; 20 | readonly q: void | QueryUpdate; 21 | readonly m: void | {m: string; a: any; i: MutationID}[]; 22 | } 23 | export interface RestoreSession { 24 | readonly k: RequestKind.RESTORE_SESSION; 25 | readonly s: SessionID; 26 | readonly q: Query; 27 | readonly m: void | {m: string; a: any; i: MutationID}[]; 28 | } 29 | type Request = NewSession | Update | RestoreSession; 30 | export default Request; 31 | 32 | export function createNewSessionRequest(query: Query): Request { 33 | return { 34 | k: RequestKind.NEW_SESSION, 35 | q: query, 36 | }; 37 | } 38 | 39 | export function createUpdateRequest( 40 | sessionID: SessionID, 41 | version: SessionVersion, 42 | query: void | QueryUpdate, 43 | mutations: void | Mutation[], 44 | ): Request { 45 | return { 46 | k: RequestKind.UPDATE, 47 | s: sessionID, 48 | v: version, 49 | q: query, 50 | m: mutations 51 | ? mutations.map(({mutation: {method, args, id}}) => ({ 52 | m: method, 53 | a: args, 54 | i: id, 55 | })) 56 | : undefined, 57 | }; 58 | } 59 | export function createRestoreRequest( 60 | sessionID: SessionID, 61 | query: Query, 62 | mutations: void | Mutation[], 63 | ): Request { 64 | return { 65 | k: RequestKind.RESTORE_SESSION, 66 | s: sessionID, 67 | q: query, 68 | m: mutations 69 | ? mutations.map(({mutation: {method, args, id}}) => ({ 70 | m: method, 71 | a: args, 72 | i: id, 73 | })) 74 | : undefined, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/types/Schema.ts: -------------------------------------------------------------------------------- 1 | import Query from './Query'; 2 | import SchemaKind from './SchemaKind'; 3 | import ValueType from './ValueType'; 4 | import QueryContext from './QueryContext'; 5 | import MutationContext from './MutationContext'; 6 | 7 | export interface FieldMethod { 8 | kind: SchemaKind.FieldMethod; 9 | name: string; 10 | description: void | string; 11 | resultType: ValueType; 12 | argType: ValueType; 13 | auth: 14 | | 'public' 15 | | (( 16 | value: Value, 17 | arg: Arg, 18 | context: Context, 19 | subQuery: true | Query, 20 | qCtx: QueryContext, 21 | ) => boolean | PromiseLike); 22 | resolve: ( 23 | value: Value, 24 | arg: Arg, 25 | context: Context, 26 | subQuery: true | Query, 27 | qCtx: QueryContext & {fieldName: string; subQuery: true | Query}, 28 | ) => Result | PromiseLike; 29 | } 30 | export interface FieldProperty { 31 | kind: SchemaKind.FieldProperty; 32 | name: string; 33 | description: void | string; 34 | auth: 35 | | 'public' 36 | | (( 37 | value: Value, 38 | arg: void, 39 | context: Context, 40 | subQuery: true | Query, 41 | qCtx: QueryContext, 42 | ) => boolean | PromiseLike); 43 | resultType: ValueType; 44 | } 45 | export type Field = 46 | | FieldProperty 47 | | FieldMethod; 48 | 49 | export interface Mutation { 50 | kind: SchemaKind.Mutation; 51 | /** 52 | * The name for use in error messages, e.g. Account.create 53 | */ 54 | name: string; 55 | description: void | string; 56 | resultType: ValueType; 57 | argType: ValueType; 58 | auth: 59 | | 'public' 60 | | (( 61 | arg: Arg, 62 | context: Context, 63 | mCtx: MutationContext, 64 | ) => boolean | PromiseLike); 65 | resolve: ( 66 | arg: Arg, 67 | context: Context, 68 | mCtx: MutationContext, 69 | ) => Result | PromiseLike; 70 | } 71 | 72 | export interface NodeType { 73 | kind: SchemaKind.NodeType; 74 | name: string; 75 | description: void | string; 76 | id: (obj: any, ctx: Context, qCtx: QueryContext) => string; 77 | matches: (obj: any) => obj is Value; 78 | fields: {[fieldName: string]: Field}; 79 | mutations: {[mutationName: string]: Mutation}; 80 | } 81 | 82 | export interface ScalarDeclaration { 83 | kind: SchemaKind.Scalar; 84 | name: string; 85 | description: void | string; 86 | baseType: ValueType; 87 | validate(value: BaseType): value is TypeTag; 88 | } 89 | type Schema = { 90 | Root: NodeType<{}, Context>; 91 | [key: string]: NodeType | ScalarDeclaration; 92 | }; 93 | export default Schema; 94 | -------------------------------------------------------------------------------- /src/types/SchemaKind.ts: -------------------------------------------------------------------------------- 1 | const enum SchemaKind { 2 | // primative types 3 | Any = 'Any', 4 | Boolean = 'Boolean', 5 | Number = 'Number', 6 | String = 'String', 7 | Void = 'Void', 8 | Null = 'Null', 9 | 10 | // complex types 11 | List = 'List', 12 | Literal = 'Literal', 13 | Union = 'Union', 14 | Object = 'Object', 15 | Promise = 'Promise', 16 | 17 | // named type 18 | Named = 'Named', 19 | Scalar = 'Scalar', 20 | 21 | // ast nodes 22 | FieldMethod = 'FieldMethod', 23 | FieldProperty = 'FieldProperty', 24 | NodeType = 'NodeType', 25 | Mutation = 'Mutation', 26 | } 27 | export default SchemaKind; 28 | -------------------------------------------------------------------------------- /src/types/ServerPreparation.ts: -------------------------------------------------------------------------------- 1 | import Cache from './Cache'; 2 | import SessionID from './SessionID'; 3 | import SessionVersion from './SessionVersion'; 4 | import Query from './Query'; 5 | 6 | export default interface ServerPreparation { 7 | readonly s: SessionID; 8 | readonly v: SessionVersion; 9 | readonly q: Query; 10 | readonly c: Cache; 11 | } 12 | 13 | export function createServerPreparation( 14 | sessionID: SessionID, 15 | version: SessionVersion, 16 | query: Query, 17 | cache: Cache, 18 | ): ServerPreparation { 19 | return { 20 | s: sessionID, 21 | v: version, 22 | q: query, 23 | c: cache, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/types/ServerResponse.ts: -------------------------------------------------------------------------------- 1 | import MutationResult from './MutationResult'; 2 | import SessionID from './SessionID'; 3 | import SessionVersion from './SessionVersion'; 4 | import Cache, {CacheUpdate} from './Cache'; 5 | 6 | export const enum ServerResponseKind { 7 | EXPIRED = 0, 8 | NEW_SESSION = 1, 9 | UPDATE = 2, 10 | RESTORE = 3, 11 | } 12 | export interface ServerResponseExpired { 13 | readonly k: ServerResponseKind.EXPIRED; 14 | readonly m: void | MutationResult[]; 15 | } 16 | export interface ServerResponseNewSession { 17 | readonly k: ServerResponseKind.NEW_SESSION; 18 | readonly s: SessionID; 19 | readonly v: SessionVersion; 20 | readonly d: Cache; 21 | } 22 | export interface ServerResponseUpdate { 23 | readonly k: ServerResponseKind.UPDATE; 24 | readonly v: SessionVersion; 25 | readonly d: void | CacheUpdate; 26 | readonly m: void | MutationResult[]; 27 | } 28 | export interface ServerResponseRestore { 29 | readonly k: ServerResponseKind.RESTORE; 30 | readonly v: SessionVersion; 31 | readonly d: Cache; 32 | readonly m: void | MutationResult[]; 33 | } 34 | type ServerResponse = 35 | | ServerResponseExpired 36 | | ServerResponseNewSession 37 | | ServerResponseUpdate 38 | | ServerResponseRestore; 39 | export default ServerResponse; 40 | 41 | export function createExpiredSessionResponse( 42 | mutationResults: void | MutationResult[], 43 | ): ServerResponseExpired { 44 | return { 45 | k: ServerResponseKind.EXPIRED, 46 | m: mutationResults, 47 | }; 48 | } 49 | 50 | export function createNewSessionResponse( 51 | sessionID: SessionID, 52 | version: SessionVersion, 53 | data: Cache, 54 | ): ServerResponseNewSession { 55 | return { 56 | k: ServerResponseKind.NEW_SESSION, 57 | s: sessionID, 58 | v: version, 59 | d: data, 60 | }; 61 | } 62 | 63 | export function createUpdateResponse( 64 | version: SessionVersion, 65 | data: void | CacheUpdate, 66 | mutationResults: void | MutationResult[], 67 | ): ServerResponseUpdate { 68 | return { 69 | k: ServerResponseKind.UPDATE, 70 | v: version, 71 | d: data, 72 | m: mutationResults, 73 | }; 74 | } 75 | export function createRestoreResponse( 76 | version: SessionVersion, 77 | data: Cache, 78 | mutationResults: void | MutationResult[], 79 | ): ServerResponseRestore { 80 | return { 81 | k: ServerResponseKind.RESTORE, 82 | v: version, 83 | d: data, 84 | m: mutationResults, 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/types/SessionID.src.ts: -------------------------------------------------------------------------------- 1 | // @opaque 2 | type SessionID = string; 3 | export default SessionID; 4 | -------------------------------------------------------------------------------- /src/types/SessionID.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated opaque-types 3 | */ 4 | 5 | export type SessionID__Base = string; 6 | declare const SessionID__Symbol: unique symbol; 7 | 8 | declare class SessionID__Class { 9 | private __kind: typeof SessionID__Symbol; 10 | } 11 | 12 | /** 13 | * @opaque 14 | * @base SessionID__Base 15 | */ 16 | type SessionID = SessionID__Class; 17 | const SessionID = { 18 | extract(value: SessionID): SessionID__Base { 19 | return value as any; 20 | }, 21 | 22 | unsafeCast(value: SessionID__Base): SessionID { 23 | return value as any; 24 | }, 25 | }; 26 | export default SessionID; 27 | -------------------------------------------------------------------------------- /src/types/SessionVersion.src.ts: -------------------------------------------------------------------------------- 1 | // @opaque 2 | type SessionVersion = string; 3 | export default SessionVersion; 4 | -------------------------------------------------------------------------------- /src/types/SessionVersion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @generated opaque-types 3 | */ 4 | 5 | export type SessionVersion__Base = string; 6 | declare const SessionVersion__Symbol: unique symbol; 7 | 8 | declare class SessionVersion__Class { 9 | private __kind: typeof SessionVersion__Symbol; 10 | } 11 | 12 | /** 13 | * @opaque 14 | * @base SessionVersion__Base 15 | */ 16 | type SessionVersion = SessionVersion__Class; 17 | const SessionVersion = { 18 | extract(value: SessionVersion): SessionVersion__Base { 19 | return value as any; 20 | }, 21 | 22 | unsafeCast(value: SessionVersion__Base): SessionVersion { 23 | return value as any; 24 | }, 25 | }; 26 | export default SessionVersion; 27 | -------------------------------------------------------------------------------- /src/types/ValueType.ts: -------------------------------------------------------------------------------- 1 | import SchemaKind from './SchemaKind'; 2 | 3 | export interface LocationInfo { 4 | fileName: string; 5 | line: number; 6 | } 7 | 8 | export function isPrimativeType( 9 | valueType: ValueType, 10 | ): valueType is PrimativeType { 11 | return ( 12 | valueType.kind === SchemaKind.Boolean || 13 | valueType.kind === SchemaKind.Number || 14 | valueType.kind === SchemaKind.String || 15 | valueType.kind === SchemaKind.Void || 16 | valueType.kind === SchemaKind.Null 17 | ); 18 | } 19 | 20 | export interface PrimativeType { 21 | kind: 22 | | SchemaKind.Boolean 23 | | SchemaKind.Number 24 | | SchemaKind.String 25 | | SchemaKind.Void 26 | | SchemaKind.Null 27 | | SchemaKind.Any; 28 | loc?: LocationInfo; 29 | _hasNamedType?: boolean; 30 | } 31 | 32 | export interface ListType { 33 | kind: SchemaKind.List; 34 | loc?: LocationInfo; 35 | element: ValueType; 36 | _hasNamedType?: boolean; 37 | } 38 | export interface LiteralType { 39 | kind: SchemaKind.Literal; 40 | loc?: LocationInfo; 41 | value: boolean | number | string; 42 | _hasNamedType?: boolean; 43 | } 44 | export interface UnionType { 45 | kind: SchemaKind.Union; 46 | loc?: LocationInfo; 47 | elements: ValueType[]; 48 | _hasNamedType?: boolean; 49 | } 50 | export interface ObjectType { 51 | kind: SchemaKind.Object; 52 | loc?: LocationInfo; 53 | properties: { 54 | [key: string]: ValueType; 55 | }; 56 | _hasNamedType?: boolean; 57 | } 58 | export interface PromiseType { 59 | kind: SchemaKind.Promise; 60 | loc?: LocationInfo; 61 | result: ValueType; 62 | _hasNamedType?: boolean; 63 | } 64 | 65 | export interface NamedType { 66 | kind: SchemaKind.Named; 67 | loc?: LocationInfo; 68 | name: string; 69 | _hasNamedType?: boolean; 70 | } 71 | 72 | export type ValueType = 73 | | PrimativeType 74 | | ListType 75 | | LiteralType 76 | | UnionType 77 | | ObjectType 78 | | PromiseType 79 | | NamedType; 80 | 81 | export default ValueType; 82 | -------------------------------------------------------------------------------- /src/utils/__tests__/diff-cache.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import diffCache from '../diff-cache'; 5 | 6 | test('returns undefined when there is no difference', () => { 7 | expect(diffCache(freeze({}), freeze({}))).toBe(undefined); 8 | expect( 9 | diffCache( 10 | freeze({Item: {foo: {bar: 10}}}), 11 | freeze({Item: {foo: {bar: 10}}}), 12 | ), 13 | ).toBe(undefined); 14 | expect( 15 | diffCache( 16 | freeze({Item: {foo: {bar: [1, 2, 3]}}}), 17 | freeze({Item: {foo: {bar: [1, 2, 3]}}}), 18 | ), 19 | ).toBe(undefined); 20 | expect( 21 | diffCache( 22 | freeze({Item: {foo: {bar: [{foo: 10}]}}}), 23 | freeze({Item: {foo: {bar: [{foo: 10}]}}}), 24 | ), 25 | ).toBe(undefined); 26 | }); 27 | test('removing items does not count as a "difference"', () => { 28 | expect(diffCache(freeze({Item: {foo: {bar: 10}}}), freeze({}))).toEqual( 29 | undefined, 30 | ); 31 | expect( 32 | diffCache(freeze({Item: {foo: {bar: 10}}}), freeze({Item: {foo: {}}})), 33 | ).toEqual(undefined); 34 | }); 35 | test('returns the correct diff when there is a difference', () => { 36 | expect(diffCache(freeze({}), freeze({Item: {foo: {bar: 10}}}))).toEqual({ 37 | Item: {foo: {bar: 10}}, 38 | }); 39 | expect( 40 | diffCache(freeze({Item: {foo: {}}}), freeze({Item: {foo: {bar: 10}}})), 41 | ).toEqual({Item: {foo: {bar: 10}}}); 42 | }); 43 | -------------------------------------------------------------------------------- /src/utils/__tests__/diff-queries.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import diffQueries from '../diff-queries'; 5 | import Query from '../../types/Query'; 6 | 7 | test('returns undefined when there is no difference', () => { 8 | expect(diffQueries(freeze({}), freeze({}))).toBe(undefined); 9 | expect( 10 | diffQueries(freeze({foo: true}), freeze({foo: true})), 11 | ).toBe(undefined); 12 | expect( 13 | diffQueries( 14 | freeze({foo: {bar: true}}), 15 | freeze({foo: {bar: true}}), 16 | ), 17 | ).toBe(undefined); 18 | // underscore prefixed properties are ignored by diff-queries 19 | expect( 20 | diffQueries( 21 | freeze({foo: true, _bar: true}), 22 | freeze({foo: true}), 23 | ), 24 | ).toBe(undefined); 25 | expect( 26 | diffQueries( 27 | freeze({foo: true}), 28 | freeze({foo: true, _bar: true}), 29 | ), 30 | ).toBe(undefined); 31 | }); 32 | test('returns the correct diff when there is a difference', () => { 33 | expect(diffQueries(freeze({foo: true}), freeze({}))).toEqual({ 34 | foo: false, 35 | }); 36 | expect(diffQueries(freeze({}), freeze({foo: true}))).toEqual({ 37 | foo: true, 38 | }); 39 | expect( 40 | diffQueries(freeze({foo: {bar: true}}), freeze({foo: {}})), 41 | ).toEqual({ 42 | foo: {bar: false}, 43 | }); 44 | expect( 45 | diffQueries(freeze({foo: {}}), freeze({foo: {bar: true}})), 46 | ).toEqual({ 47 | foo: {bar: true}, 48 | }); 49 | expect( 50 | diffQueries(freeze({}), freeze({foo: {bar: true}})), 51 | ).toEqual({ 52 | foo: {bar: true}, 53 | }); 54 | expect( 55 | diffQueries(freeze({foo: {bar: true}}), freeze({})), 56 | ).toEqual({ 57 | foo: false, 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-session-id.test.ts: -------------------------------------------------------------------------------- 1 | import SessionStore from '../../sessions/SessionStore'; 2 | import SessionID from '../../types/SessionID'; 3 | import getSessionID from '../get-session-id'; 4 | 5 | const sessionStore = {} as SessionStore; 6 | 7 | test('generates a string of 16 random characters', () => { 8 | const results = []; 9 | // run this many many times to compensate for the randomness in generating a random ID. 10 | // and to verify that a unique ID is generted across a reasonably large set of IDs. 11 | for (let i = 0; i < 2000; i++) { 12 | results.push( 13 | getSessionID(sessionStore).then(id => { 14 | expect(typeof id).toBe('string'); 15 | expect(SessionID.extract(id).length).toBe(16); 16 | // Note that we verify that there are no `=` signs used for padding. 17 | expect(id).toMatch(/^[A-Za-z0-9\+\/]{16}$/); 18 | return id; 19 | }), 20 | ); 21 | } 22 | return Promise.all(results).then(ids => { 23 | ids.forEach((id, index) => { 24 | expect(ids.indexOf(id)).toBe(index); // expect ids to be unique 25 | }); 26 | }); 27 | }); 28 | 29 | test('defers to store if provided', () => { 30 | return getSessionID({ 31 | ...sessionStore, 32 | getSessionID() { 33 | return Promise.resolve(SessionID.unsafeCast('1')); 34 | }, 35 | }).then(id => { 36 | expect(id).toBe('1'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/__tests__/merge-cache.test.ts: -------------------------------------------------------------------------------- 1 | import freeze from '../freeze'; 2 | import mergeCache from '../merge-cache'; 3 | import {createErrorResult} from '../../types/ErrorResult'; 4 | 5 | test('merges changes and returns a new object', () => { 6 | expect(mergeCache(freeze({}), freeze({Item: {foo: {foo: 10}}}))).toEqual({ 7 | Item: {foo: {foo: 10}}, 8 | }); 9 | expect( 10 | mergeCache(freeze({Item: {foo: {}}}), freeze({Item: {foo: {bar: 10}}})), 11 | ).toEqual({ 12 | Item: {foo: {bar: 10}}, 13 | }); 14 | expect(mergeCache(freeze({}), freeze({Item: {foo: {bar: 10}}}))).toEqual({ 15 | Item: {foo: {bar: 10}}, 16 | }); 17 | expect( 18 | mergeCache( 19 | freeze({Item: {foo: {bar: [1, 2, 3]}}}), 20 | freeze({Item: {foo: {bar: [1, 2, 4]}}}), 21 | ), 22 | ).toEqual({Item: {foo: {bar: [1, 2, 4]}}}); 23 | expect( 24 | mergeCache( 25 | freeze({Item: {foo: {bar: [1, 2, 3]}}}), 26 | freeze({Item: {foo: {bar: [1, 2]}}}), 27 | ), 28 | ).toEqual({ 29 | Item: {foo: {bar: [1, 2]}}, 30 | }); 31 | expect(mergeCache(freeze({}), freeze({Item: {foo: {bar: null}}}))).toEqual({ 32 | Item: {foo: {bar: null}}, 33 | }); 34 | const e = createErrorResult('Some Error'); 35 | expect( 36 | (mergeCache( 37 | freeze({Item: {foo: {whatever: 10}}}), 38 | freeze({Item: {foo: {whatever: e}}}), 39 | ) as any).Item.foo.whatever, 40 | ).toBe(e); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/__tests__/merge-queries.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import mergeQueries from '../merge-queries'; 5 | 6 | test('merges queries and removes " as whatever" from the end', () => { 7 | expect(mergeQueries(freeze({foo: true}), freeze({foo: false}))).toEqual({}); 8 | expect(mergeQueries(freeze({}), freeze({foo: true}))).toEqual({foo: true}); 9 | expect( 10 | mergeQueries(freeze({foo: {bar: true}}), freeze({foo: {bar: false}})), 11 | ).toEqual({foo: {}}); 12 | expect(mergeQueries(freeze({foo: {}}), freeze({foo: {bar: true}}))).toEqual({ 13 | foo: {bar: true}, 14 | }); 15 | expect(mergeQueries(freeze({}), freeze({foo: {bar: true}}))).toEqual({ 16 | foo: {bar: true}, 17 | }); 18 | expect( 19 | mergeQueries(freeze({foo: {bar: true}}), freeze({foo: false})), 20 | ).toEqual({}); 21 | expect( 22 | mergeQueries( 23 | freeze({'foo as bar': {bar: true}}), 24 | freeze({'foo as thingy': {bing: true}}), 25 | ), 26 | ).toEqual({foo: {bar: true, bing: true}}); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/__tests__/not-equal.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import notEqual from '../not-equal'; 5 | 6 | test('returns false when there is no difference', () => { 7 | expect(notEqual(freeze({}), freeze({}))).toBe(false); 8 | expect(notEqual(freeze({foo: 10}), freeze({foo: 10}))).toBe(false); 9 | expect(notEqual(freeze({foo: {bar: 10}}), freeze({foo: {bar: 10}}))).toBe( 10 | false, 11 | ); 12 | expect(notEqual(freeze({foo: [1, 2, 3]}), freeze({foo: [1, 2, 3]}))).toBe( 13 | false, 14 | ); 15 | expect(notEqual(freeze({foo: null}), freeze({foo: null}))).toBe(false); 16 | }); 17 | test('returns true when there is a difference', () => { 18 | expect(notEqual(freeze({foo: 10}), freeze({}))).toBe(true); 19 | expect(notEqual(freeze({}), freeze({foo: 10}))).toBe(true); 20 | expect(notEqual(freeze({foo: {bar: 10}}), freeze({foo: {}}))).toBe(true); 21 | expect(notEqual(freeze({foo: {}}), freeze({foo: {bar: 10}}))).toBe(true); 22 | expect(notEqual(freeze({}), freeze({foo: {bar: 10}}))).toBe(true); 23 | expect(notEqual(freeze({foo: {bar: 10}}), freeze({}))).toBe(true); 24 | expect(notEqual(freeze({foo: [1, 2, 3]}), freeze({foo: [1, 2, 4]}))).toBe( 25 | true, 26 | ); 27 | expect(notEqual(freeze({foo: [1, 2, 3]}), freeze({foo: [1, 2]}))).toBe(true); 28 | expect(notEqual(freeze({foo: null}), freeze({foo: undefined}))).toBe(true); 29 | expect(notEqual(freeze({}), freeze({foo: null}))).toBe(true); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/__tests__/run-query-against-cache.test.ts: -------------------------------------------------------------------------------- 1 | import {createNodeID} from '../../types/NodeID'; 2 | import {createErrorResult} from '../../types/ErrorResult'; 3 | import freeze from '../freeze'; 4 | import runQueryAgainstCache from '../run-query-against-cache'; 5 | 6 | test('returns the result of running the query', () => { 7 | const cache = freeze({ 8 | Item: {foo: {a: 10, b: 20}}, 9 | Root: {root: {items: [createNodeID('Item', 'foo')], others: [null]}}, 10 | }); 11 | expect(runQueryAgainstCache(cache, {items: {a: true, b: true}})).toEqual({ 12 | result: {items: [{a: 10, b: 20}]}, 13 | loaded: true, 14 | errors: [], 15 | errorDetails: [], 16 | }); 17 | expect(runQueryAgainstCache(cache, {others: {a: true, b: true}})).toEqual({ 18 | result: {others: [null]}, 19 | loaded: true, 20 | errors: [], 21 | errorDetails: [], 22 | }); 23 | }); 24 | test('returns partial result if still loading', () => { 25 | const errA = createErrorResult('A sample error'); 26 | const errB = createErrorResult('A sample error'); 27 | const cache = freeze({ 28 | Item: {foo: {a: 10, b: errA, c: errB}}, 29 | Root: {root: {items: [createNodeID('Item', 'foo')]}}, 30 | }); 31 | expect( 32 | runQueryAgainstCache(cache, { 33 | items: {a: true, b: true, c: true}, 34 | }), 35 | ).toEqual({ 36 | result: {items: [{a: 10, b: errA, c: errB}]}, 37 | loaded: true, 38 | errors: [errA.message], 39 | errorDetails: [errA, errB], 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/__tests__/suggest-match.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import suggestMatch from '../suggest-match'; 5 | 6 | test('returns empty string when there is no close match', () => { 7 | expect(suggestMatch(freeze(['foo', 'bar']), 'whatever')).toBe(''); 8 | }); 9 | test('returns a string recommending the closest match when one is available', () => { 10 | expect(suggestMatch(freeze(['foo', 'bar']), 'barr')).toBe( 11 | ' maybe you meant to use "bar"', 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/__tests__/type-name-from-definition.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import typeName from '../type-name-from-definition'; 5 | import SchemaKind from '../../types/SchemaKind'; 6 | 7 | test('NamedTypeReference', () => { 8 | expect( 9 | typeName( 10 | freeze({kind: SchemaKind.Named as SchemaKind.Named, name: 'MyType'}), 11 | ), 12 | ).toBe('MyType'); 13 | }); 14 | test('Array', () => { 15 | expect( 16 | typeName( 17 | freeze({ 18 | kind: SchemaKind.List as SchemaKind.List, 19 | element: {kind: SchemaKind.Named as SchemaKind.Named, name: 'MyType'}, 20 | }), 21 | ), 22 | ).toBe('MyType[]'); 23 | }); 24 | -------------------------------------------------------------------------------- /src/utils/__tests__/type-name-from-value.test.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import freeze from '../freeze'; 4 | import typeNameFromValue from '../type-name-from-value'; 5 | 6 | test('null', () => { 7 | expect(typeNameFromValue(null)).toBe('null'); 8 | }); 9 | test('[]', () => { 10 | expect(typeNameFromValue(freeze([]))).toBe('[]'); 11 | }); 12 | test('[0]', () => { 13 | expect(typeNameFromValue(freeze([0]))).toBe('number[]'); 14 | }); 15 | test('[0, 1, 2, 3]', () => { 16 | expect(typeNameFromValue(freeze([0, 1, 2, 3]))).toBe('number[]'); 17 | }); 18 | test('[0, true]', () => { 19 | expect(typeNameFromValue(freeze([0, true]))).toBe('(boolean | number)[]'); 20 | }); 21 | test('{}', () => { 22 | expect(typeNameFromValue(freeze({}))).toBe('{}'); 23 | }); 24 | test('0', () => { 25 | expect(typeNameFromValue(0)).toBe('number'); 26 | }); 27 | -------------------------------------------------------------------------------- /src/utils/create-error.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Error { 3 | code?: string; 4 | data?: any; 5 | exposeProd?: boolean; 6 | } 7 | } 8 | function createError( 9 | msg: string, 10 | data: {readonly [key: string]: unknown}, 11 | Constructor: typeof Error = Error, 12 | ) { 13 | const result = new Constructor(msg); 14 | Object.keys(data).forEach(key => { 15 | result[key] = data[key]; 16 | }); 17 | return result; 18 | } 19 | 20 | export default createError; 21 | -------------------------------------------------------------------------------- /src/utils/diff-cache.ts: -------------------------------------------------------------------------------- 1 | import Cache, { 2 | CacheObject, 3 | CacheUpdate, 4 | CacheUpdateObject, 5 | NodeCache, 6 | NodeCacheUpdate, 7 | isCacheObject, 8 | } from '../types/Cache'; 9 | 10 | function deepEqual(a: any, b: any): boolean { 11 | if (a === b) { 12 | return true; 13 | } 14 | if (Array.isArray(a) && Array.isArray(b)) { 15 | return a.length === b.length && a.every((val, i) => deepEqual(val, b[i])); 16 | } 17 | if (a && b && typeof a === 'object' && typeof b === 'object') { 18 | const aKeys = Object.keys(a); 19 | const bKeys = Object.keys(b); 20 | return ( 21 | aKeys.length === bKeys.length && 22 | aKeys.every(key => key in b && deepEqual(a[key], b[key])) 23 | ); 24 | } 25 | return false; 26 | } 27 | function diffCacheObject( 28 | before: CacheObject, 29 | after: CacheObject, 30 | ): void | CacheUpdateObject { 31 | let result: void | CacheUpdateObject = undefined; 32 | Object.keys(after).forEach(key => { 33 | const beforeProp = before[key]; 34 | const afterProp = after[key]; 35 | if (beforeProp === afterProp) return; 36 | if ( 37 | Array.isArray(beforeProp) && 38 | Array.isArray(afterProp) && 39 | beforeProp.length === afterProp.length && 40 | beforeProp.every((val, i) => deepEqual(val, afterProp[i])) 41 | ) 42 | return; 43 | if (isCacheObject(beforeProp) && isCacheObject(afterProp)) { 44 | const d = diffCacheObject(beforeProp, afterProp); 45 | if (d) { 46 | if (!result) result = {}; 47 | result[key] = d; 48 | } 49 | return; 50 | } 51 | if (!result) result = {}; 52 | result[key] = afterProp; 53 | }); 54 | return result; 55 | } 56 | 57 | function diffNodeCache( 58 | before: NodeCache, 59 | after: NodeCache, 60 | ): void | NodeCacheUpdate { 61 | let result: void | NodeCacheUpdate = undefined; 62 | Object.keys(after).forEach(key => { 63 | const beforeValue = before[key]; 64 | const afterValue = after[key]; 65 | if (afterValue) { 66 | if (beforeValue) { 67 | const d = diffCacheObject(beforeValue, afterValue); 68 | if (d) { 69 | if (!result) result = {}; 70 | result[key] = d; 71 | } 72 | } else { 73 | if (!result) result = {}; 74 | result[key] = afterValue; 75 | } 76 | } 77 | }); 78 | return result; 79 | } 80 | export default function diffCache( 81 | before: Cache, 82 | after: Cache, 83 | ): void | CacheUpdate { 84 | let result: void | CacheUpdate = undefined; 85 | Object.keys(after).forEach(key => { 86 | const beforeValue = before[key]; 87 | const afterValue = after[key]; 88 | if (afterValue) { 89 | if (beforeValue) { 90 | const d = diffNodeCache(beforeValue, afterValue); 91 | if (d) { 92 | if (!result) result = {}; 93 | result[key] = d; 94 | } 95 | } else { 96 | if (!result) result = {}; 97 | result[key] = afterValue; 98 | } 99 | } 100 | }); 101 | return result; 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/diff-queries.ts: -------------------------------------------------------------------------------- 1 | import Query, {QueryUpdate} from '../types/Query'; 2 | 3 | export default function diffQueries( 4 | oldQuery: Query, 5 | newQuery: Query, 6 | ): QueryUpdate | void { 7 | let result: {[key: string]: boolean | QueryUpdate} | void = undefined; 8 | Object.keys(oldQuery).forEach(key => { 9 | if (key[0] === '_') return; 10 | if (oldQuery[key] && !newQuery[key]) { 11 | if (!result) result = {}; 12 | result[key] = false; 13 | } 14 | }); 15 | Object.keys(newQuery).forEach(key => { 16 | if (key[0] === '_') return; 17 | const oldV = oldQuery[key]; 18 | const newV = newQuery[key]; 19 | if (!oldV) { 20 | if (!result) result = {}; 21 | result[key] = newV; 22 | } else if (typeof oldV !== typeof newV) { 23 | if (!result) result = {}; 24 | result[key] = newV; 25 | } else if ( 26 | oldV !== newV && 27 | typeof oldV === 'object' && 28 | typeof newV === 'object' 29 | ) { 30 | const d = diffQueries(oldV, newV); 31 | if (d !== undefined) { 32 | if (!result) result = {}; 33 | result[key] = d; 34 | } 35 | } 36 | }); 37 | return result; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/freeze.ts: -------------------------------------------------------------------------------- 1 | export default function id(v: T): T { 2 | if (process.env.NODE_ENV !== 'production') { 3 | return require('deep-freeze')(v); 4 | } 5 | return v; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/get-session-id.ts: -------------------------------------------------------------------------------- 1 | import SessionID from '../types/SessionID'; 2 | import SessionStore from '../sessions/SessionStore'; 3 | import {randomBytes} from 'crypto'; 4 | 5 | function randomBytesAsync(n: number): Promise { 6 | return new Promise((resolve, reject) => { 7 | randomBytes(n, (err, buf) => { 8 | if (err) reject(err); 9 | else resolve(buf); 10 | }); 11 | }); 12 | } 13 | 14 | export default function getSessionID( 15 | sessionStore: SessionStore, 16 | ): Promise { 17 | return sessionStore && sessionStore.getSessionID 18 | ? Promise.resolve(null).then(() => sessionStore.getSessionID!()) 19 | : randomBytesAsync(12).then(sessionID => 20 | SessionID.unsafeCast(sessionID.toString('base64')), 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/is-cached.ts: -------------------------------------------------------------------------------- 1 | import Cache, {CacheData, CacheObject} from '../types/Cache'; 2 | import {isErrorResult} from '../types/ErrorResult'; 3 | import NodeID, {isID, getNodeIfExists} from '../types/NodeID'; 4 | import Query from '../types/Query'; 5 | 6 | export default function isCached( 7 | cache: Cache, 8 | nodeID: NodeID, 9 | key: string, 10 | query: true | Query, 11 | ): boolean { 12 | function resolveValue(value: CacheData, subQuery: true | Query): boolean { 13 | if (value === undefined) { 14 | return false; 15 | } else if (isErrorResult(value)) { 16 | return true; 17 | } else if (isID(value)) { 18 | if (subQuery === true) { 19 | return true; 20 | } else { 21 | return recurse(getNodeIfExists(cache, value), subQuery); 22 | } 23 | } else if (Array.isArray(value)) { 24 | return value.every(v => resolveValue(v, subQuery)); 25 | } else if (value && typeof value === 'object') { 26 | return Object.keys(value).every(key => 27 | resolveValue(value[key], subQuery), 28 | ); 29 | } 30 | return true; 31 | } 32 | function recurse(node: CacheObject | null, query: Query): boolean { 33 | if (!node) { 34 | return false; 35 | } 36 | return Object.keys(query).every(key => { 37 | if (key[0] === '_') return true; 38 | const cacheKey = key.replace(/ as [a-zA-Z0-9]+$/, ''); 39 | const value = node[cacheKey]; 40 | const subQuery = query[key]; 41 | return resolveValue(value, subQuery); 42 | }); 43 | } 44 | 45 | const entryNode = getNodeIfExists(cache, nodeID); 46 | if (!entryNode) { 47 | return false; 48 | } 49 | return resolveValue(entryNode[key], query); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/merge-cache.ts: -------------------------------------------------------------------------------- 1 | import Cache, { 2 | CacheUpdate, 3 | CacheObject, 4 | CacheUpdateObject, 5 | NodeCache, 6 | NodeCacheUpdate, 7 | isCacheObject, 8 | } from '../types/Cache'; 9 | import {isErrorResult} from '../types/ErrorResult'; 10 | 11 | const EMPTY_OBJECT = {}; 12 | function mergeCacheObject( 13 | cache: CacheObject, 14 | update: CacheUpdateObject, 15 | ): CacheObject { 16 | const result: CacheObject = {}; 17 | if (cache !== EMPTY_OBJECT) { 18 | Object.keys(cache).forEach(key => { 19 | result[key] = cache[key]; 20 | }); 21 | } 22 | Object.keys(update).forEach(key => { 23 | const c = cache[key]; 24 | const u = update[key]; 25 | if (isErrorResult(u)) { 26 | result[key] = u; 27 | } else if (isCacheObject(u)) { 28 | if (isCacheObject(c)) { 29 | result[key] = mergeCacheObject(c, u); 30 | } else { 31 | result[key] = mergeCacheObject(EMPTY_OBJECT, u); 32 | } 33 | } else { 34 | result[key] = u; 35 | } 36 | }); 37 | return result; 38 | } 39 | 40 | function mergeNodeCache(cache: NodeCache, update: NodeCacheUpdate): NodeCache { 41 | const result: NodeCache = {}; 42 | if (cache !== EMPTY_OBJECT) { 43 | Object.keys(cache).forEach(key => { 44 | result[key] = cache[key]; 45 | }); 46 | } 47 | Object.keys(update).forEach(id => { 48 | const v = update[id]; 49 | if (v) { 50 | result[id] = mergeCacheObject(cache[id] || EMPTY_OBJECT, v); 51 | } 52 | }); 53 | return result; 54 | } 55 | 56 | export default function mergeCache(cache: Cache, update: CacheUpdate): Cache { 57 | const result: Cache = {}; 58 | Object.keys(cache).forEach(key => { 59 | result[key] = cache[key]; 60 | }); 61 | Object.keys(update).forEach(typeName => { 62 | const v = update[typeName]; 63 | if (v) { 64 | result[typeName] = mergeNodeCache(cache[typeName] || EMPTY_OBJECT, v); 65 | } 66 | }); 67 | return result; 68 | } 69 | -------------------------------------------------------------------------------- /src/utils/merge-queries.ts: -------------------------------------------------------------------------------- 1 | import Query, {QueryUpdate} from '../types/Query'; 2 | 3 | export default function mergeQueries(...queries: Array): Query { 4 | const result = {}; 5 | for (const query of queries) { 6 | Object.keys(query).forEach(key => { 7 | if (key[0] === '_') return; 8 | const resultKey = key.replace(/ as [a-zA-Z0-9]+$/, ''); 9 | const q = query[key]; 10 | if (q === false) { 11 | if (resultKey in result) delete result[resultKey]; 12 | } else if (q === true) { 13 | result[resultKey] = query[key]; 14 | } else if (q && typeof q === 'object') { 15 | result[resultKey] = mergeQueries(result[resultKey] || {}, q); 16 | } else { 17 | if (process.env.NODE_ENV !== 'production') { 18 | throw new Error( 19 | 'Invalid type in query ' + 20 | require('util').inspect(query) + 21 | ', queries should match `{[key: string]: boolean}`', 22 | ); 23 | } 24 | throw new Error( 25 | 'Invalid type in query ' + 26 | query + 27 | ', queries should match `{[key: string]: boolean}`', 28 | ); 29 | } 30 | }); 31 | } 32 | return result; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/not-equal.ts: -------------------------------------------------------------------------------- 1 | export default function notEqual( 2 | oldValue: {readonly [key: string]: unknown}, 3 | newValue: {readonly [key: string]: unknown}, 4 | ): boolean { 5 | if (oldValue === newValue) return false; 6 | return ( 7 | Object.keys(oldValue).some(key => { 8 | const oldProp = oldValue[key]; 9 | const newProp = newValue[key]; 10 | if (oldProp === newProp) return false; 11 | if ( 12 | typeof oldProp === 'object' && 13 | oldProp && 14 | typeof newProp === 'object' && 15 | newProp 16 | ) { 17 | if (Array.isArray(oldProp) && Array.isArray(newProp)) { 18 | return ( 19 | oldProp.length !== newProp.length || 20 | oldProp.some((valOld: unknown, i) => { 21 | const valNew: unknown = newProp[i]; 22 | if (valOld === valNew) return false; 23 | if ( 24 | typeof valOld === 'object' && 25 | typeof valNew === 'object' && 26 | valOld && 27 | valNew 28 | ) { 29 | return notEqual(valOld, valNew); 30 | } 31 | return true; 32 | }) 33 | ); 34 | } 35 | if (!Array.isArray(oldProp)) { 36 | return notEqual(oldProp, newProp); 37 | } 38 | } 39 | return true; 40 | }) || Object.keys(newValue).some(key => !(key in oldValue)) 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/run-query-against-cache.ts: -------------------------------------------------------------------------------- 1 | import Cache, {CacheData, CacheObject} from '../types/Cache'; 2 | import ErrorResult, {isErrorResult} from '../types/ErrorResult'; 3 | import {isID, getNode} from '../types/NodeID'; 4 | import Query from '../types/Query'; 5 | import freeze from './freeze'; 6 | 7 | const EMPTY_ARRAY: Array = freeze([]); 8 | let spareArray: Array = []; 9 | let spareDetailsArray: Array = []; 10 | 11 | const EMPTY_OBJECT = {}; 12 | function getRoot(cache: Cache): CacheObject { 13 | const c = cache.Root; 14 | if (!c) return EMPTY_OBJECT; 15 | return c.root || EMPTY_OBJECT; 16 | } 17 | export interface QueryCacheResult { 18 | result: TResult; 19 | loaded: boolean; 20 | errors: ReadonlyArray; 21 | errorDetails: ReadonlyArray; 22 | } 23 | export default function runQueryAgainstCache( 24 | cache: Cache, 25 | query: Query, 26 | ): QueryCacheResult { 27 | let loaded = true; 28 | let errors = spareArray; 29 | let errorDetails = spareDetailsArray; 30 | function resolveValue(value: CacheData, subQuery: true | Query): any { 31 | if (value === undefined) { 32 | loaded = false; 33 | return undefined; 34 | } else if (isErrorResult(value)) { 35 | if (errors.indexOf(value.message) === -1) { 36 | errors.push(value.message); 37 | } 38 | errorDetails.push(value); 39 | return value; 40 | } else if (isID(value)) { 41 | if (subQuery === true) { 42 | return {}; 43 | } else { 44 | return recurse(getNode(cache, value), subQuery); 45 | } 46 | } else if (Array.isArray(value)) { 47 | return value.map(v => resolveValue(v, subQuery)); 48 | } else if (value && typeof value === 'object') { 49 | const result = {}; 50 | Object.keys(value).forEach(key => { 51 | result[key] = resolveValue(value[key], subQuery); 52 | }); 53 | return result; 54 | } else { 55 | return value; 56 | } 57 | } 58 | function recurse(node: CacheObject, query: Query) { 59 | const result = {}; 60 | Object.keys(query).forEach(key => { 61 | if (key[0] === '_') return; 62 | const cacheKey = key.replace(/ as [a-zA-Z0-9]+$/, ''); 63 | const resultKey = / as [a-zA-Z0-9]+$/.test(key) 64 | ? (key.split(' as ').pop() as string) 65 | : cacheKey.split('(')[0]; 66 | 67 | const value = node[cacheKey]; 68 | const subQuery = query[key]; 69 | result[resultKey] = resolveValue(value, subQuery); 70 | }); 71 | return result; 72 | } 73 | const result = recurse(getRoot(cache), query); 74 | if (errors.length === 0) { 75 | errors = EMPTY_ARRAY; 76 | errorDetails = EMPTY_ARRAY; 77 | } else { 78 | spareArray = []; 79 | spareDetailsArray = []; 80 | errors = freeze(errors); 81 | errorDetails = freeze(errorDetails); 82 | } 83 | 84 | return {result, loaded, errors, errorDetails}; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/suggest-match.ts: -------------------------------------------------------------------------------- 1 | import leven = require('leven'); 2 | 3 | /** 4 | * Find the closest match for a string in a list of valid strings. This can be used to suggest corrections for typos. 5 | */ 6 | export default function suggestMatch(validKeys: string[], key: string): string { 7 | if (process.env.NODE_ENV === 'production') return ''; 8 | const closestMatch = validKeys.reduce( 9 | (best, validKey) => { 10 | const distance = leven(key, validKey); 11 | return distance < best.distance ? {distance, validKey} : best; 12 | }, 13 | {distance: Infinity, validKey: ''}, 14 | ); 15 | return closestMatch.distance < key.length / 2 16 | ? ` maybe you meant to use "${closestMatch.validKey}"` 17 | : ``; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/type-name-from-definition.ts: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util'; 2 | import SchemaKind from '../types/SchemaKind'; 3 | import ValueType from '../types/ValueType'; 4 | 5 | export default function typeNameFromDefinition(type: ValueType): string { 6 | switch (type.kind) { 7 | case SchemaKind.Boolean: 8 | return 'boolean'; 9 | case SchemaKind.List: 10 | const needsBrackets = type.element.kind === SchemaKind.Union; 11 | return ( 12 | (needsBrackets ? '(' : '') + 13 | typeNameFromDefinition(type.element) + 14 | (needsBrackets ? ')' : '') + 15 | '[]' 16 | ); 17 | case SchemaKind.Literal: 18 | return inspect(type.value); 19 | case SchemaKind.Named: 20 | return type.name; 21 | case SchemaKind.Null: 22 | return 'null'; 23 | case SchemaKind.Any: 24 | return 'any'; 25 | case SchemaKind.Number: 26 | return 'number'; 27 | case SchemaKind.Object: 28 | return ( 29 | '{' + 30 | Object.keys(type.properties) 31 | .sort() 32 | .map(p => p + ': ' + typeNameFromDefinition(type.properties[p])) 33 | .join(', ') + 34 | '}' 35 | ); 36 | case SchemaKind.Promise: 37 | return 'Promise<' + typeNameFromDefinition(type.result) + '>'; 38 | case SchemaKind.String: 39 | return 'string'; 40 | case SchemaKind.Union: 41 | return type.elements 42 | .map(e => typeNameFromDefinition(e)) 43 | .sort() 44 | .join(' | '); 45 | case SchemaKind.Void: 46 | return 'void'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/type-name-from-value.ts: -------------------------------------------------------------------------------- 1 | export default function typeNameFromValue(obj: any): string { 2 | if (process.env.NODE_ENV === 'production') { 3 | if (obj === null) return 'null'; 4 | if (obj === undefined) return 'void'; 5 | if (Array.isArray(obj)) return 'Array'; 6 | return typeof obj; 7 | } else { 8 | if (obj === null) return 'null'; 9 | if (obj === undefined) return 'void'; 10 | if (Array.isArray(obj)) { 11 | if (obj.length === 0) return '[]'; 12 | if (obj.length === 1) return typeNameFromValue(obj[0]) + '[]'; 13 | const subType = obj 14 | .map(typeNameFromValue) 15 | .reduce((acc, val) => { 16 | return acc.indexOf(val) === -1 ? acc.concat([val]) : acc; 17 | }, []); 18 | if (subType.length === 1) return subType[0] + '[]'; 19 | else return '(' + subType.sort().join(' | ') + ')[]'; 20 | } 21 | if (typeof obj === 'object') { 22 | return ( 23 | '{' + 24 | Object.keys(obj) 25 | .sort() 26 | .map(name => name + ': ' + typeNameFromValue(obj[name])) 27 | .join(', ') + 28 | '}' 29 | ); 30 | } 31 | return typeof obj; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "declaration": true, 5 | "outDir": "lib", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "lib": [ 9 | "dom", 10 | "es5", 11 | "es2015.promise", 12 | "es2015.collection", 13 | "es2015.symbol.wellknown" 14 | ], 15 | "sourceMap": true, 16 | "jsx": "preserve", 17 | "moduleResolution": "node", 18 | "pretty": true, 19 | "rootDir": "src", 20 | "forceConsistentCasingInFileNames": true, 21 | "strict": true, 22 | "suppressImplicitAnyIndexErrors": true, 23 | "noUnusedLocals": true, 24 | "preserveConstEnums": true 25 | } 26 | } -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | This website was created with [Docusaurus](https://docusaurus.io/). 2 | 3 | # What's In This Document 4 | 5 | * [Get Started in 5 Minutes](#get-started-in-5-minutes) 6 | * [Directory Structure](#directory-structure) 7 | * [Editing Content](#editing-content) 8 | * [Adding Content](#adding-content) 9 | * [Full Documentation](#full-documentation) 10 | 11 | # Get Started in 5 Minutes 12 | 13 | 1. Make sure all the dependencies for the website are installed: 14 | 15 | ```sh 16 | # Install dependencies 17 | $ yarn 18 | ``` 19 | 2. Run your dev server: 20 | 21 | ```sh 22 | # Start the site 23 | $ yarn start 24 | ``` 25 | 26 | ## Directory Structure 27 | 28 | Your project file structure should look something like this 29 | 30 | ``` 31 | my-docusaurus/ 32 | docs/ 33 | doc-1.md 34 | doc-2.md 35 | doc-3.md 36 | website/ 37 | blog/ 38 | 2016-3-11-oldest-post.md 39 | 2017-10-24-newest-post.md 40 | core/ 41 | node_modules/ 42 | pages/ 43 | static/ 44 | css/ 45 | img/ 46 | package.json 47 | sidebar.json 48 | siteConfig.js 49 | ``` 50 | 51 | # Editing Content 52 | 53 | ## Editing an existing docs page 54 | 55 | Edit docs by navigating to `docs/` and editing the corresponding document: 56 | 57 | `docs/doc-to-be-edited.md` 58 | 59 | ```markdown 60 | --- 61 | id: page-needs-edit 62 | title: This Doc Needs To Be Edited 63 | --- 64 | 65 | Edit me... 66 | ``` 67 | 68 | For more information about docs, click [here](https://docusaurus.io/docs/en/navigation) 69 | 70 | ## Editing an existing blog post 71 | 72 | Edit blog posts by navigating to `website/blog` and editing the corresponding post: 73 | 74 | `website/blog/post-to-be-edited.md` 75 | ```markdown 76 | --- 77 | id: post-needs-edit 78 | title: This Blog Post Needs To Be Edited 79 | --- 80 | 81 | Edit me... 82 | ``` 83 | 84 | For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) 85 | 86 | # Adding Content 87 | 88 | ## Adding a new docs page to an existing sidebar 89 | 90 | 1. Create the doc as a new markdown file in `/docs`, example `docs/newly-created-doc.md`: 91 | 92 | ```md 93 | --- 94 | id: newly-created-doc 95 | title: This Doc Needs To Be Edited 96 | --- 97 | 98 | My new content here.. 99 | ``` 100 | 101 | 1. Refer to that doc's ID in an existing sidebar in `website/sidebar.json`: 102 | 103 | ```javascript 104 | // Add newly-created-doc to the Getting Started category of docs 105 | { 106 | "docs": { 107 | "Getting Started": [ 108 | "quick-start", 109 | "newly-created-doc" // new doc here 110 | ], 111 | ... 112 | }, 113 | ... 114 | } 115 | ``` 116 | 117 | For more information about adding new docs, click [here](https://docusaurus.io/docs/en/navigation) 118 | 119 | ## Adding a new blog post 120 | 121 | 1. Make sure there is a header link to your blog in `website/siteConfig.js`: 122 | 123 | `website/siteConfig.js` 124 | ```javascript 125 | headerLinks: [ 126 | ... 127 | { blog: true, label: 'Blog' }, 128 | ... 129 | ] 130 | ``` 131 | 132 | 2. Create the blog post with the format `YYYY-MM-DD-My-Blog-Post-Title.md` in `website/blog`: 133 | 134 | `website/blog/2018-05-21-New-Blog-Post.md` 135 | 136 | ```markdown 137 | --- 138 | author: Frank Li 139 | authorURL: https://twitter.com/foobarbaz 140 | authorFBID: 503283835 141 | title: New Blog Post 142 | --- 143 | 144 | Lorem Ipsum... 145 | ``` 146 | 147 | For more information about blog posts, click [here](https://docusaurus.io/docs/en/adding-blog) 148 | 149 | ## Adding items to your site's top navigation bar 150 | 151 | 1. Add links to docs, custom pages or external links by editing the headerLinks field of `website/siteConfig.js`: 152 | 153 | `website/siteConfig.js` 154 | ```javascript 155 | { 156 | headerLinks: [ 157 | ... 158 | /* you can add docs */ 159 | { doc: 'my-examples', label: 'Examples' }, 160 | /* you can add custom pages */ 161 | { page: 'help', label: 'Help' }, 162 | /* you can add external links */ 163 | { href: 'https://github.com/facebook/Docusaurus', label: 'GitHub' }, 164 | ... 165 | ], 166 | ... 167 | } 168 | ``` 169 | 170 | For more information about the navigation bar, click [here](https://docusaurus.io/docs/en/navigation) 171 | 172 | ## Adding custom pages 173 | 174 | 1. Docusaurus uses React components to build pages. The components are saved as .js files in `website/pages/en`: 175 | 1. If you want your page to show up in your navigation header, you will need to update `website/siteConfig.js` to add to the `headerLinks` element: 176 | 177 | `website/siteConfig.js` 178 | ```javascript 179 | { 180 | headerLinks: [ 181 | ... 182 | { page: 'my-new-custom-page', label: 'My New Custom Page' }, 183 | ... 184 | ], 185 | ... 186 | } 187 | ``` 188 | 189 | For more information about custom pages, click [here](https://docusaurus.io/docs/en/custom-pages). 190 | 191 | # Full Documentation 192 | 193 | Full documentation can be found on the [website](https://docusaurus.io/). 194 | -------------------------------------------------------------------------------- /website/blog/2019-02-22-new-website.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Announcing Bicycle Website 3 | author: ForbesLindesay 4 | --- 5 | 6 | The bicycle website now has a shiny new logo and website. Here's the new logo: 7 | 8 | ![logo](assets/logo.svg) 9 | 10 | With dedicated guides to getting started with either: 11 | 12 | - [Plain JavaScript](/docs/getting-started-js.html) 13 | - [TypeScript](/docs/getting-started-ts.html) 14 | 15 | Bicycle is an alternative to Apollo/Relay that aims to be easier to use by handling updating the cache after mutations completely automatically. To do this, it does require a node.js backend, but you can still bring any database of your choice. 16 | 17 | ![Use Any Database](assets/any-db.svg) 18 | 19 | Over the next few weeks, I'm going to be working on fleshing out the reference API. -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | const docsUrl = this.props.config.docsUrl; 14 | const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; 15 | const langPart = `${language && language !== 'en' ? `${language}/` : ''}`; 16 | return `${baseUrl}${docsPart}${langPart}${doc}`; 17 | } 18 | 19 | pageUrl(doc, language) { 20 | const baseUrl = this.props.config.baseUrl; 21 | return ( 22 | baseUrl + (language && language !== 'en' ? `${language}/` : '') + doc 23 | ); 24 | } 25 | 26 | render() { 27 | return ( 28 | 90 | ); 91 | } 92 | } 93 | 94 | module.exports = Footer; 95 | -------------------------------------------------------------------------------- /website/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "This file is auto-generated by write-translations.js", 3 | "localized-strings": { 4 | "next": "Next", 5 | "previous": "Previous", 6 | "tagline": "A data fetching and synchronisation library for JavaScript", 7 | "docs": { 8 | "server-api": { 9 | "title": "BicycleClient", 10 | "sidebar_label": "Client API" 11 | }, 12 | "getting-started-js": { 13 | "title": "Getting Started - JavaScript", 14 | "sidebar_label": "JavaScript" 15 | }, 16 | "getting-started-ts": { 17 | "title": "Getting Started - TypeScript", 18 | "sidebar_label": "TypeScript" 19 | }, 20 | "getting-started": { 21 | "title": "Getting Started", 22 | "sidebar_label": "Intro" 23 | }, 24 | "js-schema": { 25 | "title": "JavaScript Schema", 26 | "sidebar_label": "Schema" 27 | }, 28 | "js-server": { 29 | "title": "BicycleServer", 30 | "sidebar_label": "Server" 31 | }, 32 | "sessions-memory": { 33 | "title": "MemorySession", 34 | "sidebar_label": "MemorySession" 35 | }, 36 | "performance": { 37 | "title": "Performance", 38 | "sidebar_label": "Performance" 39 | } 40 | }, 41 | "links": { 42 | "Docs": "Docs", 43 | "Blog": "Blog" 44 | }, 45 | "categories": { 46 | "Getting Started": "Getting Started", 47 | "JavaScript Reference": "JavaScript Reference" 48 | } 49 | }, 50 | "pages-strings": { 51 | "Help Translate|recruit community translators for your project": "Help Translate", 52 | "Edit this Doc|recruitment message asking to edit the doc source": "Edit", 53 | "Translate this Doc|recruitment message asking to translate the docs": "Translate" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "^1.7.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /website/pages/en/help.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | 12 | const Container = CompLibrary.Container; 13 | const GridBlock = CompLibrary.GridBlock; 14 | 15 | function Help(props) { 16 | const {config: siteConfig, language = ''} = props; 17 | const {baseUrl, docsUrl} = siteConfig; 18 | const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; 19 | const langPart = `${language ? `${language}/` : ''}`; 20 | const docUrl = doc => `${baseUrl}${docsPart}${langPart}${doc}`; 21 | 22 | const supportLinks = [ 23 | { 24 | content: `Learn more using the [documentation on this site.](${docUrl( 25 | 'doc1.html', 26 | )})`, 27 | title: 'Browse Docs', 28 | }, 29 | { 30 | content: 'Ask questions about the documentation and project', 31 | title: 'Join the community', 32 | }, 33 | { 34 | content: "Find out what's new with this project", 35 | title: 'Stay up to date', 36 | }, 37 | ]; 38 | 39 | return ( 40 |
    41 | 42 |
    43 |
    44 |

    Need help?

    45 |
    46 |

    This project is maintained by a dedicated group of people.

    47 | 48 |
    49 |
    50 |
    51 | ); 52 | } 53 | 54 | module.exports = Help; 55 | -------------------------------------------------------------------------------- /website/pages/en/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | const CompLibrary = require('../../core/CompLibrary.js'); 11 | 12 | const Container = CompLibrary.Container; 13 | 14 | class Users extends React.Component { 15 | render() { 16 | const {config: siteConfig} = this.props; 17 | if ((siteConfig.users || []).length === 0) { 18 | return null; 19 | } 20 | 21 | const editUrl = `${siteConfig.repoUrl}/edit/master/website/siteConfig.js`; 22 | const showcase = siteConfig.users.map(user => ( 23 | 24 | {user.caption} 25 | 26 | )); 27 | 28 | return ( 29 |
    30 | 31 |
    32 |
    33 |

    Who is Using This?

    34 |

    This project is used by many folks

    35 |
    36 |
    {showcase}
    37 |

    Are you using this project?

    38 | 39 | Add your company 40 | 41 |
    42 |
    43 |
    44 | ); 45 | } 46 | } 47 | 48 | module.exports = Users; 49 | -------------------------------------------------------------------------------- /website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "Getting Started": ["getting-started", "getting-started-js", "getting-started-ts"], 4 | "JavaScript Reference": ["js-schema", "js-server"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /website/siteConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | // See https://docusaurus.io/docs/site-config for all the possible 9 | // site configuration options. 10 | 11 | // List of projects/orgs using your project for the users page. 12 | const users = [ 13 | { 14 | caption: 'Save Willpower', 15 | image: '/img/users/savewillpower.svg', 16 | infoLink: 'https://savewillpower.com/', 17 | pinned: true, 18 | }, 19 | { 20 | caption: 'Canoe Slalom Entries', 21 | image: 'https://www.canoeslalomentries.co.uk/favicon.ico', 22 | infoLink: 'https://www.canoeslalomentries.co.uk', 23 | pinned: true, 24 | }, 25 | { 26 | caption: 'Jepso', 27 | image: '/img/users/jepso.svg', 28 | infoLink: 'https://www.jepso.com/', 29 | pinned: true, 30 | }, 31 | ]; 32 | 33 | const siteConfig = { 34 | title: 'Bicycle', // Title for your website. 35 | tagline: 'A data fetching and synchronisation library for JavaScript', 36 | url: 'https://www.bicyclejs.org', // Your website URL 37 | baseUrl: '/', // Base URL for your project */ 38 | 39 | // Used for publishing and more 40 | projectName: 'bicycle', 41 | organizationName: 'bicyclejs', 42 | 43 | // For no header links in the top nav bar -> headerLinks: [], 44 | headerLinks: [ 45 | {doc: 'getting-started', label: 'Docs'}, 46 | {blog: true, label: 'Blog'}, 47 | ], 48 | 49 | // If you have users set above, you add it here: 50 | users, 51 | 52 | /* path to images for header/footer */ 53 | headerIcon: 'img/logo/logo-white.svg', 54 | footerIcon: 'img/logo/logo-color.svg', 55 | favicon: 'img/favicon.png', 56 | 57 | /* Colors for website */ 58 | colors: { 59 | primaryColor: '#5A3AF4', 60 | secondaryColor: '#6C4FF5', 61 | }, 62 | 63 | /* Custom fonts for website */ 64 | /* 65 | fonts: { 66 | myFont: [ 67 | "Times New Roman", 68 | "Serif" 69 | ], 70 | myOtherFont: [ 71 | "-apple-system", 72 | "system-ui" 73 | ] 74 | }, 75 | */ 76 | 77 | // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. 78 | copyright: `Copyright © ${new Date().getFullYear()} ForbesLindesay`, 79 | 80 | highlight: { 81 | // Highlight.js theme to use for syntax highlighting in code blocks. 82 | theme: 'default', 83 | }, 84 | 85 | // Add custom scripts here that would be placed in