├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── GraphQLLiveData.js ├── RFC6902Operation.js ├── __snapshots__ │ └── integration.test.js.snap ├── example │ ├── index.js │ ├── schemaString.js │ └── store.js ├── index.js ├── integration.test.js ├── integrationTestQuery.js ├── liveSubscriptionTypeDef.js ├── queryExecutors │ ├── FullQueryExecutor.js │ ├── ReactiveQueryExecutor.js │ ├── reactiveTree │ │ ├── ReactiveNode.js │ │ ├── ReactiveTree.js │ │ ├── iterableValue.js │ │ ├── reactiveNodePaths.js │ │ ├── removeAllSourceRoots.js │ │ ├── updateChildNodes.js │ │ └── updateListChildNodes.js │ └── util │ │ ├── collectSubFields.js │ │ ├── executionContextFromInfo.js │ │ └── iterableLength.js └── subscribeToLiveData.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "plugins": ["jest"], 4 | "env": { 5 | "jest/globals": true 6 | }, 7 | "rules": { 8 | "semi": [2, "never"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-present, Rob Gilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## graphql-live-subscriptions 2 | 3 | **Depricated:** Please use https://github.com/n1ru4l/graphql-live-queries instead. 4 | 5 | `graphql-live-subscriptions` provides RFC6902-compatible JSON patches over GraphQL. Client-side code can implement live data in a few lines by using any of the available RFC6902 "JSON Patch" libraries listed here: http://jsonpatch.com/ 6 | 7 | **Why?** Because I wanted to have the benefits of Live Data and it was unclear if the proposed `@live` directive will ever be added to GraphQL. 8 | 9 | This library came about as a result of the conversation at https://github.com/facebook/graphql/issues/386 10 | 11 | Pull requests are very welcome. 12 | 13 | ### Installation 14 | `npm install graphql-live-subscriptions` 15 | 16 | ### Example 17 | 18 | ```js 19 | import { 20 | liveSubscriptionTypeDef, 21 | subscribeToLiveData, 22 | } from 'graphql-live-subscriptions' 23 | 24 | const schemaString = ` 25 | type Subscription 26 | 27 | type Query { 28 | jedis: [Jedi!]! 29 | } 30 | 31 | type House { 32 | id: ID! 33 | address(includePostalCode: Boolean!): String! 34 | numberOfCats: Int! 35 | numberOfDogs: Int! 36 | } 37 | 38 | type Jedi { 39 | id: ID! 40 | name: String! 41 | houses: [House!]! 42 | } 43 | ` 44 | const resolvers = { 45 | /* graphql-live-subscriptions requires a JSON Scalar resolver */ 46 | JSON: GraphQLJSON, 47 | 48 | Subscription: { 49 | live: { 50 | resolve: source => source, 51 | subscribe: subscribeToLiveData({ 52 | initialState: (source, args, context) => context.store.state, 53 | eventEmitter: (source, args, context) => context.store.eventEmitter, 54 | sourceRoots: { 55 | Jedi: ['houses'], 56 | }, 57 | }), 58 | }, 59 | }, 60 | House: { 61 | address: (house, args) => { 62 | if (args.includePostalCode) { 63 | return `${house.address} ${house.postalCode}` 64 | } 65 | return house.address 66 | }, 67 | }, 68 | Jedi: { 69 | houses: (jedi, args, context) => { 70 | const { state } = context.store 71 | 72 | return jedi.houseIDs.map(id => ( 73 | state.houses.find(house => house.id === id) 74 | )) 75 | }, 76 | }, 77 | } 78 | 79 | const schema = makeExecutableSchema({ 80 | typeDefs: [ 81 | schemaString, 82 | liveSubscriptionTypeDef(), 83 | ], 84 | resolvers, 85 | }) 86 | ``` 87 | 88 | #### Client Subscription 89 | 90 | ```graphql 91 | subscription { 92 | live { 93 | query { 94 | jedis { 95 | firstName 96 | lastName 97 | 98 | houses { 99 | address 100 | } 101 | } 102 | } 103 | patch { op, path, from, value } 104 | } 105 | } 106 | ``` 107 | 108 | 109 | ### API 110 | 111 | #### liveSubscriptionTypeDef({ type, queryType, subscriptionName }) 112 | 113 | typedefs for the live subscription. 114 | 115 | arguments: 116 | * `type = 'LiveSubscription'` - the type of the subscription 117 | * `queryType = 'Query'` - the name of the query root that the live subscription will wrap 118 | * `subscriptionName = 'live'` - the name of the live subscription 119 | 120 | For use with programmatically constructed types. Returns a `GraphQLDataType` with: 121 | * a `query` field - immediately responds with the initial results to the live subscription like a `query` operation would. 122 | * a `patch` field - RFC6902 patch sets sent to the client to update the initial `query` data. 123 | 124 | #### subscribeToLiveData({ initialState, eventEmitter, sourceRoots }) 125 | 126 | arguments: 127 | * `initialState` **function(source, args, context)** returns the latest value to be passed to the query resolver. 128 | * `eventEmitter` **function(source, args, context)** returns either an EventEmitter or a Promise. Events: 129 | * `emit('update', { nextState })` - graphql-live-subscriptions will generate a patch for us by comparing the next state to the previous state and send it to the subscriber. 130 | * `emit('patch', { patch })` - graphql-live-subscriptions will send the patch provided directly to the subscriber. 131 | * `sourceRoots` **Object {[typeName: string]: [fieldName: string]}** - a map of all fields which are not entirely based on their parent's source's state. By default the diffing algorithm only checks for changes to a field if it's parent's source has changed. If it is possible for a field to change it's value or the value of a field nested inside it without the source value changing then it needs to be listed here otherwise it will not generate patches when it changes. 132 | 133 | returns an AsyncIterator that implements the sending of query's and patches. 134 | 135 | ### License 136 | 137 | graphql-live-subscriptions is [MIT-licensed](https://github.com/graphql/graphql-js/blob/master/LICENSE). 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-live-subscriptions", 3 | "version": "1.4.2", 4 | "main": "dist/index.js", 5 | "module": "./dist/index.js", 6 | "author": "Rob Gilson", 7 | "license": "MIT", 8 | "private": false, 9 | "files": [ 10 | "dist/", 11 | "src/" 12 | ], 13 | "scripts": { 14 | "clean": "rm -rf dist && mkdir dist", 15 | "build": "yarn run clean && babel -d ./dist ./src -s", 16 | "prepublishOnly": "npm run build", 17 | "test": "jest", 18 | "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand --watch" 19 | }, 20 | "babel": { 21 | "presets": [ 22 | [ 23 | "@babel/preset-env", 24 | { 25 | "targets": { 26 | "node": "current" 27 | } 28 | } 29 | ] 30 | ], 31 | "plugins": [ 32 | "@babel/plugin-proposal-object-rest-spread" 33 | ] 34 | }, 35 | "dependencies": { 36 | "@d1plo1d/list-diff2": "^0.1.5-0", 37 | "fast-json-patch": "^2.0.6", 38 | "fast-memoize": "^2.3.2" 39 | }, 40 | "peerDependencies": { 41 | "graphql": "^0.13.2 || ^14.0.0", 42 | "graphql-subscriptions": "^0.5.8 || ^1.0.0", 43 | "graphql-type-json": "^0.2.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/cli": "^7.0.0-beta.43", 47 | "@babel/core": "7.0.0-beta.43", 48 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.43", 49 | "@babel/preset-env": "^7.0.0-beta.43", 50 | "babel-core": "7.0.0-bridge.0", 51 | "babel-jest": "^22.4.3", 52 | "eslint": "^4.19.1", 53 | "eslint-config-airbnb-base": "^12.1.0", 54 | "eslint-plugin-import": "^2.11.0", 55 | "eslint-plugin-jest": "^21.15.2", 56 | "graphql": "^0.13.2", 57 | "graphql-subscriptions": "^0.5.8", 58 | "graphql-tools": "^3.0.2", 59 | "graphql-type-json": "^0.2.0", 60 | "immutable": "^3.8.2", 61 | "jest": "^22.4.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/GraphQLLiveData.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLObjectType, 3 | GraphQLNonNull, 4 | GraphQLList, 5 | GraphQLID, 6 | } from 'graphql' 7 | import memoize from 'fast-memoize' 8 | 9 | import RFC6902Operation from './RFC6902Operation' 10 | 11 | const GraphQLLiveData = (options = {}) => { 12 | const { name, resumption } = options 13 | 14 | if (name == null) { 15 | throw new Error('name cannot be null') 16 | } 17 | 18 | const getQueryType = () => { 19 | let { type } = options 20 | 21 | if (typeof type === 'function') { 22 | type = type() 23 | } 24 | if (type == null) { 25 | throw new Error('Canot create GraphQLLiveData for type null') 26 | } 27 | 28 | return type 29 | } 30 | 31 | return new GraphQLObjectType({ 32 | name, 33 | fields: () => { 34 | const fields = { 35 | query: { 36 | type: getQueryType(), 37 | }, 38 | patch: { 39 | type: new GraphQLList(new GraphQLNonNull(RFC6902Operation)), 40 | }, 41 | } 42 | 43 | if (resumption === true) { 44 | return { 45 | ...fields, 46 | resumptionCursor: { 47 | type: new GraphQLNonNull(GraphQLID), 48 | }, 49 | } 50 | } 51 | 52 | return fields 53 | }, 54 | }) 55 | } 56 | 57 | export default memoize(GraphQLLiveData) 58 | -------------------------------------------------------------------------------- /src/RFC6902Operation.js: -------------------------------------------------------------------------------- 1 | import GraphQLJSON from 'graphql-type-json' 2 | import { 3 | GraphQLObjectType, 4 | GraphQLNonNull, 5 | GraphQLString, 6 | } from 'graphql' 7 | 8 | const RFC6902Operation = new GraphQLObjectType({ 9 | name: 'RFC6902Operation', 10 | fields: () => ({ 11 | op: { 12 | type: GraphQLNonNull(GraphQLString), 13 | }, 14 | path: { 15 | type: GraphQLNonNull(GraphQLString), 16 | }, 17 | from: { 18 | type: GraphQLString, 19 | }, 20 | value: { 21 | type: GraphQLJSON, 22 | }, 23 | }), 24 | }) 25 | 26 | export default RFC6902Operation 27 | -------------------------------------------------------------------------------- /src/__snapshots__/integration.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GraphQLLiveData Integration publishes patches on 'update' 1`] = ` 4 | Object { 5 | "live": Object { 6 | "patch": Array [ 7 | Object { 8 | "from": null, 9 | "op": "replace", 10 | "path": "/houses/0/numberOfCats", 11 | "value": 200, 12 | }, 13 | ], 14 | "query": null, 15 | }, 16 | } 17 | `; 18 | 19 | exports[`GraphQLLiveData Integration publishes patches on 'update' 2`] = ` 20 | Object { 21 | "live": Object { 22 | "patch": Array [ 23 | Object { 24 | "from": null, 25 | "op": "replace", 26 | "path": "/houses/0/address", 27 | "value": "123 real st apt. 1", 28 | }, 29 | Object { 30 | "from": null, 31 | "op": "replace", 32 | "path": "/houses/1/address", 33 | "value": "200 legit rd apt. 2", 34 | }, 35 | Object { 36 | "from": null, 37 | "op": "replace", 38 | "path": "/jedis/0/houses/0/address", 39 | "value": "200 legit rd apt. 2 90211", 40 | }, 41 | Object { 42 | "from": null, 43 | "op": "replace", 44 | "path": "/jedis/2/houses/0/address", 45 | "value": "200 legit rd apt. 2 90211", 46 | }, 47 | Object { 48 | "from": null, 49 | "op": "replace", 50 | "path": "/jedis/2/houses/1/address", 51 | "value": "123 real st apt. 1 90210", 52 | }, 53 | ], 54 | "query": null, 55 | }, 56 | } 57 | `; 58 | 59 | exports[`GraphQLLiveData Integration publishes patches on 'update' with new child objects 1`] = ` 60 | Object { 61 | "live": Object { 62 | "patch": Array [ 63 | Object { 64 | "from": null, 65 | "op": "add", 66 | "path": "/jedis/0/primaryAddress", 67 | "value": Object { 68 | "address": "123 real st 90210", 69 | "id": "real_street", 70 | }, 71 | }, 72 | ], 73 | "query": null, 74 | }, 75 | } 76 | `; 77 | 78 | exports[`GraphQLLiveData Integration publishes patches on 'update' with new list entries 1`] = ` 79 | Object { 80 | "live": Object { 81 | "patch": Array [ 82 | Object { 83 | "from": null, 84 | "op": "add", 85 | "path": "/houses/2", 86 | "value": Object { 87 | "address": "somwhere", 88 | "id": "add_that_id", 89 | "numberOfCats": 10, 90 | }, 91 | }, 92 | ], 93 | "query": null, 94 | }, 95 | } 96 | `; 97 | 98 | exports[`GraphQLLiveData Integration publishes patches on 'update' with removed child objects 1`] = ` 99 | Object { 100 | "live": Object { 101 | "patch": Array [ 102 | Object { 103 | "from": null, 104 | "op": "remove", 105 | "path": "/jedis/0/primaryAddress", 106 | "value": null, 107 | }, 108 | ], 109 | "query": null, 110 | }, 111 | } 112 | `; 113 | 114 | exports[`GraphQLLiveData Integration publishes patches on 'update' with removed list entries 1`] = ` 115 | Object { 116 | "live": Object { 117 | "patch": Array [ 118 | Object { 119 | "from": null, 120 | "op": "remove", 121 | "path": "/jedis/0", 122 | "value": null, 123 | }, 124 | ], 125 | "query": null, 126 | }, 127 | } 128 | `; 129 | 130 | exports[`GraphQLLiveData Integration publishes the initialQuery immediately 1`] = ` 131 | Object { 132 | "live": Object { 133 | "patch": null, 134 | "query": Object { 135 | "houses": Array [ 136 | Object { 137 | "address": "123 real st", 138 | "id": "real_street", 139 | "numberOfCats": 5, 140 | }, 141 | Object { 142 | "address": "200 legit rd", 143 | "id": "legit_road", 144 | "numberOfCats": 0, 145 | }, 146 | ], 147 | "jedis": Array [ 148 | Object { 149 | "houses": Array [ 150 | Object { 151 | "address": "200 legit rd 90211", 152 | "id": "legit_road", 153 | }, 154 | ], 155 | "id": "jedi_1", 156 | "name": "Luke Skywalker", 157 | "primaryAddress": null, 158 | }, 159 | Object { 160 | "houses": Array [], 161 | "id": "jedi_2", 162 | "name": "Yoda", 163 | "primaryAddress": null, 164 | }, 165 | Object { 166 | "houses": Array [ 167 | Object { 168 | "address": "200 legit rd 90211", 169 | "id": "legit_road", 170 | }, 171 | Object { 172 | "address": "123 real st 90210", 173 | "id": "real_street", 174 | }, 175 | ], 176 | "id": "jedi_3", 177 | "name": "Mace Windu", 178 | "primaryAddress": null, 179 | }, 180 | ], 181 | }, 182 | }, 183 | } 184 | `; 185 | 186 | exports[`GraphQLLiveData Integration with an empty Array publishes patches on 'update' 1`] = ` 187 | Object { 188 | "live": Object { 189 | "patch": Array [ 190 | Object { 191 | "from": null, 192 | "op": "remove", 193 | "path": "/jedis/0", 194 | "value": null, 195 | }, 196 | Object { 197 | "from": null, 198 | "op": "add", 199 | "path": "/jedis/0", 200 | "value": Object { 201 | "houses": Array [], 202 | "id": "a_different_id", 203 | "name": "Luke Skywalker", 204 | "primaryAddress": null, 205 | }, 206 | }, 207 | ], 208 | "query": null, 209 | }, 210 | } 211 | `; 212 | 213 | exports[`GraphQLLiveData Integration with an empty Array publishes the initialQuery immediately 1`] = ` 214 | Object { 215 | "live": Object { 216 | "patch": null, 217 | "query": Object { 218 | "houses": Array [], 219 | "jedis": Array [ 220 | Object { 221 | "houses": Array [], 222 | "id": "jedi_1", 223 | "name": "Luke Skywalker", 224 | "primaryAddress": null, 225 | }, 226 | Object { 227 | "houses": Array [], 228 | "id": "jedi_2", 229 | "name": "Yoda", 230 | "primaryAddress": null, 231 | }, 232 | Object { 233 | "houses": Array [], 234 | "id": "jedi_3", 235 | "name": "Mace Windu", 236 | "primaryAddress": null, 237 | }, 238 | ], 239 | }, 240 | }, 241 | } 242 | `; 243 | 244 | exports[`GraphQLLiveData Integration with an empty Immutable List publishes patches on 'update' 1`] = ` 245 | Object { 246 | "live": Object { 247 | "patch": Array [ 248 | Object { 249 | "from": null, 250 | "op": "remove", 251 | "path": "/jedis/0", 252 | "value": null, 253 | }, 254 | Object { 255 | "from": null, 256 | "op": "add", 257 | "path": "/jedis/0", 258 | "value": Object { 259 | "houses": Array [], 260 | "id": "a_different_id", 261 | "name": "Luke Skywalker", 262 | "primaryAddress": null, 263 | }, 264 | }, 265 | ], 266 | "query": null, 267 | }, 268 | } 269 | `; 270 | 271 | exports[`GraphQLLiveData Integration with an empty Immutable List publishes the initialQuery immediately 1`] = ` 272 | Object { 273 | "live": Object { 274 | "patch": null, 275 | "query": Object { 276 | "houses": Array [], 277 | "jedis": Array [ 278 | Object { 279 | "houses": Array [], 280 | "id": "jedi_1", 281 | "name": "Luke Skywalker", 282 | "primaryAddress": null, 283 | }, 284 | Object { 285 | "houses": Array [], 286 | "id": "jedi_2", 287 | "name": "Yoda", 288 | "primaryAddress": null, 289 | }, 290 | Object { 291 | "houses": Array [], 292 | "id": "jedi_3", 293 | "name": "Mace Windu", 294 | "primaryAddress": null, 295 | }, 296 | ], 297 | }, 298 | }, 299 | } 300 | `; 301 | -------------------------------------------------------------------------------- /src/example/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { makeExecutableSchema } from 'graphql-tools' 3 | import GraphQLJSON from 'graphql-type-json' 4 | 5 | /* 6 | * if your copy + pasting this example replace this next import with: 7 | * 8 | * import { subscribeToLiveData } from 'graphql-live-subscriptions' 9 | */ 10 | import { subscribeToLiveData } from '../index' 11 | 12 | import schemaString from './schemaString' 13 | 14 | const resolvers = { 15 | /* graphql-live-subscriptions requires a JSON Scalar resolver */ 16 | JSON: GraphQLJSON, 17 | 18 | Subscription: { 19 | live: { 20 | resolve: source => source, 21 | subscribe: subscribeToLiveData({ 22 | initialState: (source, args, context) => context.store.state, 23 | eventEmitter: (source, args, context) => context.store.eventEmitter, 24 | sourceRoots: { 25 | Jedi: ['houses'], 26 | }, 27 | }), 28 | }, 29 | }, 30 | House: { 31 | address: (house, args) => { 32 | if (args.includePostalCode) { 33 | return `${house.address} ${house.postalCode}` 34 | } 35 | return house.address 36 | }, 37 | }, 38 | Jedi: { 39 | houses: (jedi, args, context) => { 40 | const { state } = context.store 41 | 42 | return jedi.houseIDs.map(id => ( 43 | state.houses.find(house => house.id === id) 44 | )) 45 | }, 46 | }, 47 | } 48 | 49 | const schema = makeExecutableSchema({ 50 | typeDefs: schemaString, 51 | resolvers, 52 | }) 53 | 54 | export default schema 55 | -------------------------------------------------------------------------------- /src/example/schemaString.js: -------------------------------------------------------------------------------- 1 | import { liveSubscriptionTypeDef } from '../index' 2 | 3 | const liveSubscription = liveSubscriptionTypeDef({ 4 | queryType: 'LiveQueryRoot', 5 | }) 6 | 7 | const schemaString = ` 8 | type Subscription 9 | 10 | type LiveQueryRoot { 11 | houses: [House!]! 12 | jedis: [Jedi!]! 13 | } 14 | 15 | type Query { 16 | houses: [House!]! 17 | jedis: [Jedi!]! 18 | someNonLiveField: String! 19 | } 20 | 21 | type House { 22 | id: ID! 23 | address(includePostalCode: Boolean!): String! 24 | numberOfCats: Int! 25 | numberOfDogs: Int! 26 | } 27 | 28 | type Jedi { 29 | id: ID! 30 | name: String! 31 | primaryAddress: House 32 | houses: [House!]! 33 | } 34 | ` 35 | 36 | export default [ 37 | schemaString, 38 | liveSubscription, 39 | ] 40 | -------------------------------------------------------------------------------- /src/example/store.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { List, Record } from 'immutable' 4 | 5 | /* 6 | * A simple immutableJS data store for our example. 7 | */ 8 | 9 | const State = Record({ 10 | houses: List(), 11 | jedis: List(), 12 | }) 13 | 14 | export const House = Record({ 15 | id: null, 16 | address: null, 17 | postalCode: null, 18 | numberOfCats: 0, 19 | numberOfDogs: null, 20 | }) 21 | 22 | export const Jedi = Record({ 23 | id: null, 24 | name: null, 25 | primaryAddress: null, 26 | houseIDs: List(), 27 | }) 28 | 29 | export const initialState = State({ 30 | // GraphQL List generated from Immutable List 31 | houses: List([ 32 | House({ 33 | id: 'real_street', 34 | address: '123 real st', 35 | postalCode: '90210', 36 | numberOfCats: 5, 37 | numberOfDogs: 7, 38 | }), 39 | House({ 40 | id: 'legit_road', 41 | address: '200 legit rd', 42 | postalCode: '90211', 43 | numberOfCats: 0, 44 | numberOfDogs: 1, 45 | }), 46 | ]), 47 | // GraphQL List generated from Array 48 | jedis: [ 49 | Jedi({ 50 | id: 'jedi_1', 51 | name: 'Luke Skywalker', 52 | houseIDs: List(['legit_road']), 53 | }), 54 | Jedi({ 55 | id: 'jedi_2', 56 | name: 'Yoda', 57 | houseIDs: List(), 58 | }), 59 | Jedi({ 60 | id: 'jedi_3', 61 | name: 'Mace Windu', 62 | houseIDs: List(['legit_road', 'real_street']), 63 | }), 64 | ], 65 | }) 66 | 67 | const store = () => { 68 | const storeInstance = { 69 | state: initialState, 70 | eventEmitter: new EventEmitter(), 71 | setState: (nextState) => { 72 | storeInstance.state = nextState 73 | }, 74 | } 75 | return storeInstance 76 | } 77 | 78 | export default store 79 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as liveSubscriptionTypeDef } from './liveSubscriptionTypeDef' 2 | export { default as GraphQLLiveData } from './GraphQLLiveData' 3 | export { default as subscribeToLiveData } from './subscribeToLiveData' 4 | -------------------------------------------------------------------------------- /src/integration.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | parse, 3 | subscribe, 4 | } from 'graphql' 5 | import { List } from 'immutable' 6 | 7 | import schema from './example' 8 | import createStore, { House, initialState } from './example/store' 9 | import integrationTestQuery from './integrationTestQuery' 10 | 11 | jest.setTimeout(300) 12 | 13 | // const externallyGeneratedPatch = [ 14 | // { 15 | // op: 'replace', 16 | // path: '/1/address', 17 | // value: '42 Patch Event St.', 18 | // } 19 | // ] 20 | 21 | const createTestSubscription = async ({ state }) => { 22 | const document = parse(integrationTestQuery) 23 | const store = createStore() 24 | 25 | if (state != null) store.state = state 26 | 27 | let subscription = subscribe({ 28 | schema, 29 | document, 30 | contextValue: { 31 | store, 32 | }, 33 | }) 34 | 35 | 36 | if (subscription.then != null) subscription = await subscription 37 | 38 | if (subscription.errors != null) { 39 | expect(JSON.stringify(subscription.errors)).toEqual(null) 40 | } 41 | 42 | return { 43 | ...store, 44 | subscription, 45 | } 46 | } 47 | 48 | const expectSubscriptionResponse = async (subscription) => { 49 | const result = await subscription.next() 50 | 51 | expect(result.done).toEqual(false) 52 | expect(result.value.data).toMatchSnapshot() 53 | } 54 | 55 | describe('GraphQLLiveData Integration', () => { 56 | it('publishes the initialQuery immediately', async () => { 57 | const { subscription } = await createTestSubscription({}) 58 | 59 | await expectSubscriptionResponse(subscription) 60 | }) 61 | 62 | const testEmptyIterable = (iterableType, emptyIterable) => { 63 | const emptyState = initialState 64 | .set('houses', emptyIterable) 65 | .updateIn(['jedis'], jedis => ( 66 | jedis.map(jedi => jedi.set('houseIDs', List())) 67 | )) 68 | 69 | describe(`with an empty ${iterableType}`, () => { 70 | it('publishes the initialQuery immediately', async () => { 71 | const { subscription } = await createTestSubscription({ 72 | state: emptyState, 73 | }) 74 | 75 | await expectSubscriptionResponse(subscription) 76 | }) 77 | 78 | it('publishes patches on \'update\'', async () => { 79 | const { 80 | subscription, 81 | eventEmitter, 82 | state, 83 | } = await createTestSubscription({ 84 | state: emptyState, 85 | }) 86 | let nextState = state 87 | // inital query 88 | await subscription.next() 89 | // first patch 90 | nextState = nextState 91 | .updateIn(['jedis'], (jedis) => { 92 | // eslint-disable-next-line 93 | jedis[0] = jedis[0].set('id', 'a_different_id') 94 | return [...jedis] 95 | }) 96 | eventEmitter.emit('update', { nextState }) 97 | await expectSubscriptionResponse(subscription) 98 | }) 99 | }) 100 | } 101 | 102 | testEmptyIterable('Immutable List', List()) 103 | testEmptyIterable('Array', []) 104 | 105 | it('publishes patches on \'update\'', async () => { 106 | const { 107 | subscription, 108 | eventEmitter, 109 | state, 110 | setState, 111 | } = await createTestSubscription({}) 112 | let nextState = state 113 | // inital query 114 | await subscription.next() 115 | // null change should not create a response 116 | setState(nextState) 117 | eventEmitter.emit('update', { nextState }) 118 | 119 | // first patch 120 | nextState = nextState 121 | .mergeIn(['houses', 0], { 122 | numberOfDogs: 0, 123 | numberOfCats: 200, 124 | }) 125 | setState(nextState) 126 | eventEmitter.emit('update', { nextState }) 127 | await expectSubscriptionResponse(subscription) 128 | 129 | // second patch 130 | nextState = nextState 131 | .updateIn(['houses', 0, 'address'], address => `${address} apt. 1`) 132 | .updateIn(['houses', 1, 'address'], address => `${address} apt. 2`) 133 | setState(nextState) 134 | eventEmitter.emit('update', { nextState }) 135 | await expectSubscriptionResponse(subscription) 136 | }) 137 | 138 | it('publishes patches on \'update\' with new child objects', async () => { 139 | const { 140 | subscription, 141 | eventEmitter, 142 | state, 143 | setState, 144 | } = await createTestSubscription({}) 145 | let nextState = state 146 | // inital query 147 | await subscription.next() 148 | 149 | // first patch 150 | nextState = nextState 151 | .updateIn(['jedis'], (jedis) => { 152 | // eslint-disable-next-line 153 | jedis[0] = jedis[0].set('primaryAddress', state.houses.get(0)) 154 | return [...jedis] 155 | }) 156 | setState(nextState) 157 | eventEmitter.emit('update', { nextState }) 158 | await expectSubscriptionResponse(subscription) 159 | }) 160 | 161 | it('publishes patches on \'update\' with removed child objects', async () => { 162 | const { 163 | subscription, 164 | eventEmitter, 165 | state, 166 | setState, 167 | } = await createTestSubscription({}) 168 | let nextState = state 169 | .updateIn(['jedis'], (jedis) => { 170 | // eslint-disable-next-line 171 | jedis[0] = jedis[0].set('primaryAddress', state.houses.get(0)) 172 | return [...jedis] 173 | }) 174 | // inital query 175 | await subscription.next() 176 | 177 | // first patch 178 | nextState = nextState 179 | .updateIn(['jedis'], (jedis) => { 180 | // eslint-disable-next-line 181 | jedis[0] = jedis[0].set('primaryAddress', null) 182 | return [...jedis] 183 | }) 184 | setState(nextState) 185 | eventEmitter.emit('update', { nextState }) 186 | await expectSubscriptionResponse(subscription) 187 | }) 188 | 189 | it('publishes patches on \'update\' with new list entries', async () => { 190 | const { 191 | subscription, 192 | eventEmitter, 193 | state, 194 | setState, 195 | } = await createTestSubscription({}) 196 | let nextState = state 197 | // inital query 198 | await subscription.next() 199 | 200 | // first patch 201 | const addedHouse = House({ 202 | id: 'add_that_id', 203 | address: 'somwhere', 204 | postalCode: '10210', 205 | numberOfCats: 10, 206 | numberOfDogs: 5, 207 | }) 208 | nextState = nextState 209 | .updateIn(['houses'], houses => houses.push(addedHouse)) 210 | setState(nextState) 211 | eventEmitter.emit('update', { nextState }) 212 | await expectSubscriptionResponse(subscription) 213 | }) 214 | 215 | it('publishes patches on \'update\' with removed list entries', async () => { 216 | const { 217 | subscription, 218 | eventEmitter, 219 | state, 220 | setState, 221 | } = await createTestSubscription({}) 222 | let nextState = state 223 | // inital query 224 | await subscription.next() 225 | 226 | // first patch 227 | nextState = nextState 228 | .updateIn(['jedis'], jedis => jedis.slice(1)) 229 | setState(nextState) 230 | eventEmitter.emit('update', { nextState }) 231 | await expectSubscriptionResponse(subscription) 232 | }) 233 | 234 | // it('publishes a patch on \'patch\'', async () => { 235 | // const { 236 | // subscription, 237 | // emitUpdate, 238 | // emitPatch, 239 | // state, 240 | // } = await createTestSubscription({}) 241 | // // inital query 242 | // await subscription.next() 243 | // // first patch 244 | // emitPatch() 245 | // await expectSubscriptionResponse(subscription) 246 | // // second patch 247 | // state[0].address = state[0].address + ' apt. 1' 248 | // state[1].address = externallyGeneratedPatch[0].value 249 | // emitUpdate() 250 | // await expectSubscriptionResponse(subscription) 251 | // }) 252 | }) 253 | -------------------------------------------------------------------------------- /src/integrationTestQuery.js: -------------------------------------------------------------------------------- 1 | const integrationTestQuery = ` 2 | subscription { 3 | live { 4 | ...CatQueryFragment 5 | 6 | query { 7 | houses { 8 | ... on House { 9 | id 10 | } 11 | 12 | address(includePostalCode: false) 13 | } 14 | 15 | jedis { 16 | id 17 | name 18 | primaryAddress { 19 | id 20 | address(includePostalCode: true) 21 | } 22 | houses { 23 | id 24 | address(includePostalCode: true) 25 | } 26 | } 27 | } 28 | 29 | patch { op, path, from, value } 30 | } 31 | } 32 | 33 | fragment CatQueryFragment on LiveSubscription { 34 | query { 35 | houses { 36 | numberOfCats 37 | } 38 | } 39 | } 40 | ` 41 | 42 | export default integrationTestQuery 43 | -------------------------------------------------------------------------------- /src/liveSubscriptionTypeDef.js: -------------------------------------------------------------------------------- 1 | const liveSubscriptionTypeDef = ({ 2 | type = 'LiveSubscription', 3 | queryType = 'Query', 4 | subscriptionName = 'live', 5 | }) => ` 6 | scalar JSON 7 | 8 | extend type Subscription { 9 | ${subscriptionName}: ${type} 10 | } 11 | 12 | type ${type} { 13 | query: ${queryType} 14 | patch: [RFC6902Operation] 15 | } 16 | 17 | type RFC6902Operation { 18 | op: String! 19 | path: String! 20 | from: String 21 | value: JSON 22 | } 23 | ` 24 | 25 | export default liveSubscriptionTypeDef 26 | -------------------------------------------------------------------------------- /src/queryExecutors/FullQueryExecutor.js: -------------------------------------------------------------------------------- 1 | import * as jsonPatch from 'fast-json-patch' 2 | 3 | import { 4 | parse, 5 | execute, 6 | GraphQLSchema, 7 | introspectionQuery, 8 | GraphQLObjectType, 9 | } from 'graphql' 10 | 11 | const FullQueryExecutor = ({ 12 | context, 13 | resolveInfo, 14 | type, 15 | fieldName, 16 | }) => { 17 | if (fieldName == null) { 18 | throw new Error('fieldName cannot be null') 19 | } 20 | 21 | /* 22 | * build a query for the query node that can be executed on state change 23 | * in order to create query diffs 24 | */ 25 | const rootField = resolveInfo.fieldNodes[0] 26 | 27 | const buildArgumentString = argumentNode => { 28 | if (argumentNode.name == null || argumentNode.name.value == null) { 29 | throw new Error('argument name cannot be null') 30 | } 31 | const name = argumentNode.name.value 32 | const valueNode = argumentNode.value 33 | if (valueNode.kind === 'Variable') { 34 | const value = resolveInfo.variableValues[valueNode.name.value] 35 | return `${name}: ${JSON.stringify(value)}` 36 | } else { 37 | return `${name}: ${valueNode.value}` 38 | } 39 | } 40 | 41 | const buildQueryFromFieldNode = fieldNode => { 42 | const { kind } = fieldNode 43 | let fieldString 44 | if (kind === 'InlineFragment') { 45 | fieldString = `... on ${fieldNode.typeCondition.name.value}` 46 | } else if (kind === 'FragmentSpread') { 47 | return `... ${fieldNode.name.value}` 48 | } else if (kind === 'FragmentDefinition') { 49 | fieldString = ( 50 | `fragment ${fieldNode.name.value} on `+ 51 | `${fieldNode.typeCondition.name.value}` 52 | ) 53 | } else if (kind === 'Field') { 54 | const name = fieldNode.name.value 55 | 56 | const args = fieldNode.arguments.map(buildArgumentString) 57 | const argsString = args.length === 0 ? '' : `(${args.join(', ')})` 58 | 59 | fieldString = `${name}${argsString}` 60 | } 61 | else { 62 | throw new Error(`Unknown kind: ${kind}`) 63 | } 64 | 65 | if (fieldNode.selectionSet == null) return fieldString 66 | 67 | const children = fieldNode 68 | .selectionSet 69 | .selections 70 | 71 | return ( 72 | `${fieldString} {\n` + 73 | children.map(child => 74 | ` ${ buildQueryFromFieldNode(child).replace(/\n/g, `\n `) }\n` 75 | ).join('') + 76 | `}` 77 | ) 78 | 79 | } 80 | 81 | let queryString = buildQueryFromFieldNode(rootField) 82 | queryString = `query {\n ${queryString.replace(/\n/g, `\n `)}\n}` 83 | 84 | for (const fragment of Object.values(resolveInfo.fragments)) { 85 | const fragmentString = buildQueryFromFieldNode(fragment) 86 | queryString = `${queryString}\n${fragmentString}` 87 | } 88 | 89 | const documentAST = parse(queryString) 90 | const querySchema = new GraphQLSchema({ 91 | query: new GraphQLObjectType({ 92 | name: 'LiveDataQuerySchemaRoot', 93 | fields: () => ({ 94 | [fieldName]: { 95 | type: typeof type === 'function' ? type() : type, 96 | }, 97 | }), 98 | }), 99 | }) 100 | 101 | const executeQuery = async state => { 102 | const { data, errors } = await execute( 103 | querySchema, 104 | documentAST, 105 | { 106 | [fieldName]: { 107 | query: state, 108 | }, 109 | }, 110 | context, 111 | ) 112 | if (errors) throw new Error(errors[0]) 113 | return data[fieldName].query 114 | } 115 | 116 | let previousState 117 | 118 | return { 119 | initialQuery: async data => { 120 | previousState = await executeQuery(data) 121 | }, 122 | createPatch: async data => { 123 | const nextState = await executeQuery(data) 124 | const patch = jsonPatch.compare(previousState, nextState) 125 | previousState = nextState 126 | return patch 127 | }, 128 | recordPatch: async patch => { 129 | const patchResults = jsonPatch.applyPatch( 130 | previousState, 131 | jsonPatch.deepClone(patch) 132 | ) 133 | }, 134 | } 135 | } 136 | 137 | export default FullQueryExecutor 138 | -------------------------------------------------------------------------------- /src/queryExecutors/ReactiveQueryExecutor.js: -------------------------------------------------------------------------------- 1 | import { 2 | isListType, 3 | isLeafType, 4 | } from 'graphql' 5 | 6 | import executionContextFromInfo from './util/executionContextFromInfo' 7 | 8 | import ReactiveTree from './reactiveTree/ReactiveTree' 9 | import * as ReactiveNode from './reactiveTree/ReactiveNode' 10 | import iterableValue from './reactiveTree/iterableValue' 11 | 12 | const createInitialQuery = (reactiveNode, source) => { 13 | ReactiveNode.setInitialValue(reactiveNode, source) 14 | // console.log('INITAIL QUERY', reactiveNode.name, reactiveNode.value, 'source', source) 15 | 16 | const { 17 | children, 18 | } = reactiveNode 19 | 20 | if (isLeafType(reactiveNode.type) || reactiveNode.value == null) { 21 | return reactiveNode.value 22 | } 23 | 24 | if (isListType(reactiveNode.type)) { 25 | const json = [] 26 | let index = 0 27 | // eslint-disable-next-line no-restricted-syntax 28 | for (const entry of iterableValue(reactiveNode)) { 29 | const childJSON = createInitialQuery(children[index], entry) 30 | json.push(childJSON) 31 | index += 1 32 | } 33 | return json 34 | } 35 | 36 | const json = {} 37 | // console.log('children???', reactiveNode.children) 38 | reactiveNode.children.forEach((childNode) => { 39 | const childSource = reactiveNode.value 40 | // console.log('uhhh', childNode.name, childSource) 41 | const childJSON = createInitialQuery(childNode, childSource) 42 | // TODO: this is the field name. Should be the alias name 43 | json[childNode.name] = childJSON 44 | }) 45 | // console.log('initial json for', reactiveNode.type, json) 46 | return json 47 | } 48 | 49 | const createPatch = (reactiveNode, source, patch = []) => { 50 | const previousValue = reactiveNode.value 51 | const value = ReactiveNode.getNextValueOrUnchanged(reactiveNode, source) 52 | 53 | const becameNull = previousValue != null && value == null 54 | const becameNotNull = previousValue == null && value !== ReactiveNode.UNCHANGED 55 | 56 | if (becameNotNull || !reactiveNode.initializedValue) { 57 | patch.push({ 58 | op: 'add', 59 | path: reactiveNode.patchPath, 60 | value: createInitialQuery(reactiveNode, source), 61 | }) 62 | return patch 63 | } 64 | 65 | if (becameNull) { 66 | patch.push({ 67 | op: 'remove', 68 | path: reactiveNode.patchPath, 69 | }) 70 | return patch 71 | } 72 | 73 | // console.log(reactiveNode.moves) 74 | reactiveNode.moves.forEach(({ 75 | op, 76 | index, 77 | childNode, 78 | childSource, 79 | }) => { 80 | const path = `${reactiveNode.patchPath}/${index}` 81 | switch (op) { 82 | case 'add': { 83 | patch.push({ 84 | op, 85 | path, 86 | value: createInitialQuery(childNode, childSource), 87 | }) 88 | break 89 | } 90 | case 'remove': { 91 | patch.push({ 92 | op, 93 | path, 94 | }) 95 | break 96 | } 97 | default: { 98 | throw new Error(`invalid op: ${op}`) 99 | } 100 | } 101 | }) 102 | 103 | // eslint-disable-next-line no-param-reassign 104 | if (reactiveNode.moves.length > 0) reactiveNode.moves = [] 105 | 106 | // console.log( 107 | // 'patch', reactiveNode.patchPath, '\nprevious', previousValue, 108 | // '\nnext', value, 109 | // ) 110 | if (value === ReactiveNode.UNCHANGED) { 111 | return patch 112 | } 113 | if (isLeafType(reactiveNode.type)) { 114 | patch.push({ 115 | op: 'replace', 116 | value, 117 | path: reactiveNode.patchPath, 118 | }) 119 | return patch 120 | } 121 | 122 | if (isListType(reactiveNode.type)) { 123 | let index = 0 124 | // Compatible with any Iterable 125 | // eslint-disable-next-line no-restricted-syntax 126 | for (const childSource of value) { 127 | const childNode = reactiveNode.children[index] 128 | createPatch(childNode, childSource, patch) 129 | 130 | index += 1 131 | } 132 | } else { 133 | // console.log('kids these days', reactiveNode.children) 134 | reactiveNode.children.forEach((childNode) => { 135 | const childSource = value 136 | createPatch(childNode, childSource, patch) 137 | }) 138 | } 139 | 140 | // console.log('patch', patch) 141 | return patch 142 | } 143 | 144 | const ReactiveQueryExecutor = ({ 145 | context, 146 | resolveInfo, 147 | fieldName, 148 | sourceRoots, 149 | }) => { 150 | const exeContext = executionContextFromInfo(resolveInfo, context) 151 | 152 | const reactiveTree = ReactiveTree({ 153 | exeContext, 154 | operation: resolveInfo.operation, 155 | subscriptionName: fieldName, 156 | sourceRoots, 157 | }) 158 | 159 | return { 160 | initialQuery: async data => ( 161 | createInitialQuery(reactiveTree.queryRoot, { query: data }) 162 | ), 163 | // TODO: create patches for all source roots once source roots are 164 | // implemented 165 | createPatch: async (data) => { 166 | const patch = [] 167 | createPatch(reactiveTree.queryRoot, { query: data }, patch) 168 | reactiveTree.sourceRootConfig.nodes.forEach((rootNode) => { 169 | // console.log('root', rootNode.patchPath) 170 | createPatch(rootNode, rootNode.sourceValue, patch) 171 | }) 172 | // console.log('PATCH', patch) 173 | return patch 174 | }, 175 | recordPatch: async () => { 176 | const message = ( 177 | 'recordPatch is not yet implemented for ReactiveQueryExecutor' 178 | ) 179 | // eslint-disable-next-line no-console 180 | console.error(message) 181 | throw new Error(message) 182 | }, 183 | } 184 | } 185 | 186 | export default ReactiveQueryExecutor 187 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/ReactiveNode.js: -------------------------------------------------------------------------------- 1 | import { 2 | isListType, 3 | isLeafType, 4 | isNonNullType, 5 | defaultFieldResolver, 6 | } from 'graphql' 7 | import { 8 | getFieldDef, 9 | buildResolveInfo, 10 | resolveFieldValueOrError, 11 | } from 'graphql/execution/execute' 12 | 13 | import updateChildNodes from './updateChildNodes' 14 | import { createPatchPath } from './reactiveNodePaths' 15 | 16 | export const UNCHANGED = Symbol('UNCHANGED') 17 | 18 | export const createNode = ({ 19 | exeContext, 20 | parentType, 21 | type, 22 | fieldNodes, 23 | graphqlPath, 24 | sourceRootConfig, 25 | }) => { 26 | const name = fieldNodes[0].name.value 27 | 28 | const isSourceRoot = ( 29 | sourceRootConfig.whitelist[`${parentType.name}.${name}`] || false 30 | ) 31 | 32 | const reactiveNode = { 33 | initializedValue: false, 34 | moves: [], 35 | isLeaf: isLeafType(type), 36 | isList: isListType(type), 37 | isListEntry: isListType(parentType), 38 | name, 39 | // eg. if path is ['live', 'query', 'foo'] then patchPath is '/foo' 40 | patchPath: createPatchPath(graphqlPath), 41 | children: [], 42 | sourceValue: undefined, 43 | value: undefined, 44 | exeContext, 45 | parentType, 46 | type: isNonNullType(type) ? type.ofType : type, 47 | fieldNodes, 48 | sourceRootConfig, 49 | sourceRootNodeIndex: sourceRootConfig.nodes.length, 50 | isSourceRoot, 51 | graphqlPath, 52 | } 53 | 54 | if (isSourceRoot) { 55 | sourceRootConfig.nodes.push(reactiveNode) 56 | } 57 | 58 | return reactiveNode 59 | } 60 | 61 | /** 62 | * Resolves the field on the given source object. In particular, this 63 | * figures out the value that the field returns by calling its resolve function, 64 | * then calls completeValue to complete promises, serialize scalars, or execute 65 | * the sub-selection-set for objects. 66 | */ 67 | const resolveField = (reactiveNode, source) => { 68 | const { 69 | exeContext, 70 | parentType, 71 | fieldNodes, 72 | graphqlPath, 73 | } = reactiveNode 74 | 75 | const fieldName = fieldNodes[0].name.value 76 | 77 | // console.log('before', reactiveNode.patchPath, fieldNodes[0].name.value, source) 78 | if (reactiveNode.isListEntry) return source 79 | 80 | const fieldDef = getFieldDef(exeContext.schema, parentType, fieldName) 81 | if (!fieldDef) { 82 | return null 83 | } 84 | 85 | const resolveFn = fieldDef.resolve || defaultFieldResolver 86 | 87 | const info = buildResolveInfo( 88 | exeContext, 89 | fieldDef, 90 | fieldNodes, 91 | parentType, 92 | graphqlPath, 93 | ) 94 | 95 | // Get the resolve function, regardless of if its result is normal 96 | // or abrupt (error). 97 | const result = resolveFieldValueOrError( 98 | exeContext, 99 | fieldDef, 100 | fieldNodes, 101 | resolveFn, 102 | source, 103 | info, 104 | ) 105 | 106 | // console.log('after', fieldNodes[0].name.value, reactiveNode.patchPath, 107 | // fieldDef.type, source, result) 108 | return result 109 | } 110 | 111 | export const setInitialValue = (reactiveNode, source) => { 112 | // eslint-disable-next-line no-param-reassign 113 | reactiveNode.initializedValue = true 114 | // eslint-disable-next-line no-param-reassign 115 | reactiveNode.sourceValue = source 116 | // eslint-disable-next-line no-param-reassign 117 | reactiveNode.value = resolveField(reactiveNode, source) 118 | 119 | updateChildNodes(reactiveNode) 120 | // eslint-disable-next-line no-param-reassign 121 | reactiveNode.moves = [] 122 | 123 | return reactiveNode.value 124 | } 125 | 126 | export const getNextValueOrUnchanged = (reactiveNode, source) => { 127 | const previousValue = reactiveNode.value 128 | const nextValue = resolveField(reactiveNode, source) 129 | 130 | // console.log(reactiveNode.patchPath, nextValue) 131 | 132 | if (nextValue === previousValue) return UNCHANGED 133 | 134 | // eslint-disable-next-line no-param-reassign 135 | reactiveNode.sourceValue = source 136 | // eslint-disable-next-line no-param-reassign 137 | reactiveNode.value = nextValue 138 | 139 | updateChildNodes(reactiveNode) 140 | 141 | return nextValue 142 | } 143 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/ReactiveTree.js: -------------------------------------------------------------------------------- 1 | import { addPath } from 'graphql/execution/execute' 2 | import collectSubFields from '../util/collectSubFields' 3 | 4 | import * as ReactiveNode from './ReactiveNode' 5 | 6 | export const createReactiveTreeInner = (opts) => { 7 | const { 8 | exeContext, 9 | parentType, 10 | type, 11 | fieldNodes, 12 | graphqlPath, 13 | sourceRootConfig, 14 | } = opts 15 | 16 | const reactiveNode = ReactiveNode.createNode({ 17 | exeContext, 18 | parentType, 19 | type, 20 | fieldNodes, 21 | graphqlPath, 22 | children: [], 23 | sourceRootConfig, 24 | }) 25 | 26 | return reactiveNode 27 | } 28 | 29 | const ReactiveTree = ({ 30 | exeContext, 31 | operation, 32 | subscriptionName = 'live', 33 | source, 34 | sourceRoots = {}, 35 | }) => { 36 | const { schema } = exeContext 37 | 38 | /* 39 | * TODO: this selection set lookup does not currently support aliases for 40 | * the subscription or the `query` field. 41 | */ 42 | const rootType = schema.getSubscriptionType() 43 | 44 | const rootFields = collectSubFields({ 45 | exeContext, 46 | returnType: rootType, 47 | fieldNodes: [operation], 48 | }) 49 | 50 | const liveDataType = rootType.getFields()[subscriptionName].type 51 | 52 | const liveDataFields = collectSubFields({ 53 | exeContext, 54 | returnType: liveDataType, 55 | fieldNodes: rootFields[subscriptionName], 56 | }) 57 | 58 | const queryFieldDef = liveDataType.getFields().query 59 | 60 | let graphqlPath 61 | graphqlPath = addPath(undefined, subscriptionName) 62 | graphqlPath = addPath(graphqlPath, 'query') 63 | 64 | const sourceRootConfig = { 65 | // all ReactiveNodes that are source roots in the current query in the order 66 | // that they are initially resolved which is the same order they will 67 | // be have patches resolved. 68 | nodes: [], 69 | // whitelist of fields that are to be added as source roots in the form 70 | // {typeName}.{fieldName} 71 | whitelist: {}, 72 | } 73 | Object.entries(sourceRoots).forEach(([typeName, fieldNames]) => { 74 | fieldNames.forEach((fieldName) => { 75 | sourceRootConfig.whitelist[`${typeName}.${fieldName}`] = true 76 | }) 77 | }) 78 | 79 | const queryRoot = createReactiveTreeInner({ 80 | exeContext, 81 | parentType: liveDataType, 82 | type: queryFieldDef.type, 83 | fieldNodes: liveDataFields.query, 84 | graphqlPath, 85 | source: { query: source }, 86 | sourceRootConfig, 87 | }) 88 | 89 | return { 90 | queryRoot, 91 | sourceRootConfig, 92 | } 93 | } 94 | 95 | export default ReactiveTree 96 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/iterableValue.js: -------------------------------------------------------------------------------- 1 | const iterableValue = (reactiveNode) => { 2 | const { value } = reactiveNode 3 | 4 | if (value == null) return [] 5 | 6 | const isIterable = value[Symbol.iterator] != null 7 | return isIterable ? value : [value] 8 | } 9 | 10 | export default iterableValue 11 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/reactiveNodePaths.js: -------------------------------------------------------------------------------- 1 | import { responsePathAsArray } from 'graphql' 2 | 3 | export const createPatchPath = graphqlPath => ( 4 | `/${responsePathAsArray(graphqlPath).slice(2).join('/')}` 5 | ) 6 | 7 | export const updatePatchPaths = (reactiveNode, opts) => { 8 | const { 9 | oldPrefix, 10 | newPrefix, 11 | } = opts 12 | // update the patch path for this node 13 | // eslint-disable-next-line no-param-reassign 14 | reactiveNode.patchPath = reactiveNode.patchPath.replace(oldPrefix, newPrefix) 15 | 16 | // recursively update this node's children's paths 17 | reactiveNode.children.forEach((childNode) => { 18 | updatePatchPaths(childNode, opts) 19 | }) 20 | } 21 | 22 | export const updatePathKey = ({ reactiveNode, key }) => { 23 | // eslint-disable-next-line no-param-reassign 24 | reactiveNode.graphqlPath.key = key 25 | 26 | updatePatchPaths(reactiveNode, { 27 | oldPrefix: reactiveNode.patchPath, 28 | newPrefix: createPatchPath(reactiveNode.graphqlPath), 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/removeAllSourceRoots.js: -------------------------------------------------------------------------------- 1 | const removeAllSourceRoots = (reactiveNode, sourceRootConfig) => { 2 | if (reactiveNode.isSourceRoot) { 3 | // eslint-disable-next-line no-param-reassign 4 | delete sourceRootConfig.nodes[reactiveNode.sourceRootNodeIndex] 5 | } 6 | reactiveNode.children.forEach((childNode) => { 7 | removeAllSourceRoots(childNode, sourceRootConfig) 8 | }) 9 | } 10 | 11 | export default removeAllSourceRoots 12 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/updateChildNodes.js: -------------------------------------------------------------------------------- 1 | import { 2 | isLeafType, 3 | isObjectType, 4 | // isAbstractType, 5 | isListType, 6 | // isNonNullType, 7 | } from 'graphql' 8 | import { 9 | getFieldDef, 10 | addPath, 11 | } from 'graphql/execution/execute' 12 | 13 | import collectSubFields from '../util/collectSubFields' 14 | 15 | import { createNode } from './ReactiveNode' 16 | import updateListChildNodes from './updateListChildNodes' 17 | 18 | 19 | const updateChildNodes = (reactiveNode) => { 20 | const { 21 | exeContext, 22 | type, 23 | fieldNodes, 24 | children, 25 | patchPath, 26 | graphqlPath, 27 | sourceRootConfig, 28 | parentType, 29 | } = reactiveNode 30 | 31 | const { schema } = exeContext 32 | 33 | if (isObjectType(type)) { 34 | const fields = collectSubFields({ 35 | exeContext, 36 | returnType: type, 37 | fieldNodes, 38 | }) 39 | 40 | if (isListType(parentType) && fields.id == null) { 41 | const err = ( 42 | `the ID must be queried for each object in an array in a live subscription (at ${patchPath})` 43 | ) 44 | throw new Error(err) 45 | } 46 | 47 | if (Object.keys(fields).length === children.length) return 48 | 49 | /* 50 | * TODO: recurse down the query to find all fields that have explicitly 51 | * defined reactive entry points and create a checkForNewValue for each of 52 | * them. 53 | * 54 | * The checks will then be run on each state change and if a new new value 55 | * is present then the field and it's child fields are compared to their 56 | * previous values to generate a patch. 57 | */ 58 | Object.entries(fields).forEach(([childResponseName, childFieldNodes]) => { 59 | const childFieldDef = getFieldDef( 60 | schema, 61 | type, 62 | childFieldNodes[0].name.value, 63 | ) 64 | const childPath = addPath(graphqlPath, childResponseName) 65 | 66 | const childReactiveNode = createNode({ 67 | exeContext, 68 | parentType: type, 69 | type: childFieldDef.type, 70 | fieldNodes: childFieldNodes, 71 | graphqlPath: childPath, 72 | sourceRootConfig, 73 | }) 74 | 75 | reactiveNode.children.push(childReactiveNode) 76 | }) 77 | } else if (isListType(type)) { 78 | updateListChildNodes(reactiveNode) 79 | } else if ( 80 | !isLeafType(type) 81 | ) { 82 | throw new Error(`Unsupported GraphQL type: ${type}`) 83 | } 84 | } 85 | 86 | export default updateChildNodes 87 | -------------------------------------------------------------------------------- /src/queryExecutors/reactiveTree/updateListChildNodes.js: -------------------------------------------------------------------------------- 1 | import { isObjectType } from 'graphql' 2 | import { addPath } from 'graphql/execution/execute' 3 | import listDiff from '@d1plo1d/list-diff2' 4 | 5 | import { createNode } from './ReactiveNode' 6 | import removeAllSourceRoots from './removeAllSourceRoots' 7 | import iterableValue from './iterableValue' 8 | import { updatePathKey } from './reactiveNodePaths' 9 | 10 | export const REMOVE = 0 11 | export const ADD = 1 12 | 13 | const concreteType = type => ( 14 | type.ofType ? concreteType(type.ofType) : type 15 | ) 16 | 17 | /* 18 | * for lists of Objects: use the ID of the child nodes to sort/filter them into 19 | * moved/removed/added subsets. 20 | * for lists of scalars: use the value of the child nodes to sort+filter them 21 | * into moved/removed/added subsets. 22 | */ 23 | const updateListChildNodes = (reactiveNode) => { 24 | const { 25 | exeContext, 26 | fieldNodes, 27 | children, 28 | graphqlPath, 29 | sourceRootConfig, 30 | } = reactiveNode 31 | 32 | const isArrayOfObjects = isObjectType(concreteType(reactiveNode.type)) 33 | 34 | const value = Array.from(iterableValue(reactiveNode)) 35 | const previousValue = children.map(child => child.value) 36 | 37 | const { moves } = listDiff( 38 | previousValue, 39 | value, 40 | isArrayOfObjects ? 'id' : undefined, 41 | ) 42 | 43 | /* 44 | * Add or remove nodes and source roots if the entries in the list have moved 45 | */ 46 | // eslint-disable-next-line no-param-reassign 47 | reactiveNode.moves = moves.map((move) => { 48 | switch (move.type) { 49 | case ADD: { 50 | const childNode = createNode({ 51 | exeContext, 52 | parentType: reactiveNode.type, 53 | type: reactiveNode.type.ofType, 54 | fieldNodes, 55 | graphqlPath: addPath(graphqlPath, move.index), 56 | sourceRootConfig, 57 | }) 58 | // add the child at it's index 59 | reactiveNode.children.splice(move.index, 0, childNode) 60 | // return a move that references the new child node for later use in 61 | // patch generation 62 | return { 63 | op: 'add', 64 | index: move.index, 65 | childNode, 66 | childSource: move.item, 67 | } 68 | } 69 | case REMOVE: { 70 | const childNode = reactiveNode.children[move.index] 71 | removeAllSourceRoots(childNode, sourceRootConfig) 72 | // remove the child at it's index 73 | reactiveNode.children.splice(move.index, 1) 74 | 75 | return { 76 | op: 'remove', 77 | index: move.index, 78 | childNode, 79 | } 80 | } 81 | default: { 82 | throw new Error(`invalid move: ${JSON.stringify(move)}`) 83 | } 84 | } 85 | }) 86 | 87 | if (moves.length > 0) { 88 | // update each child's patch + graphql path as it may have shifted in the 89 | // moves 90 | reactiveNode.children.forEach((childNode, index) => { 91 | updatePathKey({ 92 | reactiveNode: childNode, 93 | key: index, 94 | }) 95 | }) 96 | } 97 | } 98 | 99 | export default updateListChildNodes 100 | -------------------------------------------------------------------------------- /src/queryExecutors/util/collectSubFields.js: -------------------------------------------------------------------------------- 1 | import { collectFields } from 'graphql/execution/execute' 2 | 3 | /* 4 | * modified from 5 | * https://github.com/graphql/graphql-js/blob/master/src/execution/execute.js 6 | */ 7 | const collectSubfields = ({ 8 | exeContext, 9 | returnType, 10 | fieldNodes, 11 | }) => { 12 | let subFieldNodes = Object.create(null) 13 | const visitedFragmentNames = Object.create(null) 14 | for (let i = 0; i < fieldNodes.length; i += 1) { 15 | const { selectionSet } = fieldNodes[i] 16 | if (selectionSet) { 17 | subFieldNodes = collectFields( 18 | exeContext, 19 | returnType, 20 | selectionSet, 21 | subFieldNodes, 22 | visitedFragmentNames, 23 | ) 24 | } 25 | } 26 | return subFieldNodes 27 | } 28 | 29 | // TODO: memoize3 this 30 | export default collectSubfields 31 | -------------------------------------------------------------------------------- /src/queryExecutors/util/executionContextFromInfo.js: -------------------------------------------------------------------------------- 1 | const executionContextFromInfo = (resolveInfo, contextValue) => ({ 2 | schema: resolveInfo.schema, 3 | fragments: resolveInfo.fragments, 4 | rootValue: resolveInfo.rootValue, 5 | contextValue, 6 | operation: resolveInfo.operation, 7 | variableValues: resolveInfo.variableValues, 8 | fieldResolver: source => source, 9 | errors: [], 10 | }) 11 | 12 | export default executionContextFromInfo 13 | -------------------------------------------------------------------------------- /src/queryExecutors/util/iterableLength.js: -------------------------------------------------------------------------------- 1 | const iterableLength = (iterable) => { 2 | if (typeof iterable.length === 'number') return iterable.length 3 | 4 | let length = 0 5 | // eslint-disable-next-line 6 | for (const _val of iterable) { 7 | length += 1 8 | } 9 | return length 10 | } 11 | 12 | export default iterableLength 13 | -------------------------------------------------------------------------------- /src/subscribeToLiveData.js: -------------------------------------------------------------------------------- 1 | import { PubSub } from 'graphql-subscriptions' 2 | 3 | // import queryExecutor from './queryExecutors/FullQueryExecutor' 4 | import queryExecutor from './queryExecutors/ReactiveQueryExecutor' 5 | 6 | const eventName = 'liveData' 7 | 8 | const subscribeToLiveData = ({ 9 | fieldName = 'live', 10 | type, 11 | eventEmitter: getEventEmitter, 12 | initialState: getInitialState, 13 | sourceRoots = {}, 14 | }) => async ( 15 | source, 16 | args, 17 | context, 18 | resolveInfo, 19 | ) => { 20 | const onError = (e) => { 21 | // eslint-disable-next-line no-console 22 | console.error(e) 23 | } 24 | 25 | // if (type == null) { 26 | // throw new Error('subscribeToLiveData \'type\' argument is required') 27 | // } 28 | 29 | let eventEmitter = getEventEmitter( 30 | source, 31 | args, 32 | context, 33 | resolveInfo, 34 | ) 35 | if (eventEmitter.then != null) { 36 | /* resolve promises */ 37 | eventEmitter = await eventEmitter 38 | } 39 | if (eventEmitter == null || eventEmitter.on == null) { 40 | const msg = ( 41 | 'eventEmitter must either return a Promise or an instance of EventEmitter' 42 | ) 43 | throw new Error(msg) 44 | } 45 | 46 | const connectionPubSub = new PubSub() 47 | const asyncIterator = connectionPubSub.asyncIterator(eventName) 48 | 49 | let initialState = getInitialState( 50 | source, 51 | args, 52 | context, 53 | resolveInfo, 54 | ) 55 | if (initialState.then != null) { 56 | /* resolve promises */ 57 | initialState = await initialState 58 | } 59 | if (initialState == null) { 60 | throw new Error('initialState cannot return null') 61 | } 62 | 63 | const { initialQuery, createPatch, recordPatch } = (() => { 64 | try { 65 | return queryExecutor({ 66 | context, 67 | resolveInfo, 68 | type, 69 | fieldName, 70 | sourceRoots, 71 | }) 72 | } catch (e) { 73 | onError(e) 74 | throw e 75 | } 76 | })() 77 | 78 | const publishPatch = (patch) => { 79 | // console.log('publish PATCH', patch) 80 | if (patch != null && patch.length > 0) { 81 | connectionPubSub.publish(eventName, { patch }) 82 | } 83 | } 84 | 85 | /* 86 | * initialQuery sets the state of the query executor to diff against 87 | * in later events 88 | */ 89 | try { 90 | await initialQuery(initialState) 91 | } catch (e) { 92 | onError(e) 93 | throw e 94 | } 95 | 96 | const publishInitialQuery = async () => { 97 | /* 98 | * the source is used to generate the initial state and to publish a `query` 99 | * result to the client. 100 | */ 101 | // console.log('INITIAL', resolveInfo.fieldNodes[0].name.value, resolveInfo.fieldNodes[0].selectionSet.selections.map(f => f.name.value)) 102 | connectionPubSub.publish(eventName, { query: initialState }) 103 | // connectionPubSub.publish(eventName, { patch: [ { op: 'replace', value: 'test' } ], query: { houses: ['test'] } }) 104 | // publishPatch([{ 105 | // op: 'replace', 106 | // value: '123 real st apt. 1', 107 | // path: '/houses/0/address', 108 | // }]) 109 | } 110 | 111 | const onUpdate = async ({ nextState }) => { 112 | /* generate and send the patch on state changes */ 113 | try { 114 | const patch = await createPatch(nextState) 115 | publishPatch(patch) 116 | } catch (e) { 117 | onError(e) 118 | throw e 119 | } 120 | } 121 | 122 | const onPatch = async ({ patch }) => { 123 | /* send the externally generated patch and update the state */ 124 | await recordPatch(patch) 125 | publishPatch(patch) 126 | } 127 | 128 | const originalUnsubscribe = connectionPubSub.unsubscribe.bind(connectionPubSub) 129 | connectionPubSub.unsubscribe = (subID) => { 130 | originalUnsubscribe(subID) 131 | eventEmitter.removeListener('update', onUpdate) 132 | eventEmitter.removeListener('patch', onPatch) 133 | } 134 | 135 | setImmediate(async () => { 136 | /* 137 | * immediately set the initialQuery and send the query results upon 138 | * connection 139 | */ 140 | await publishInitialQuery() 141 | /* 142 | * subscribe to changes once the initial query has been sent 143 | */ 144 | eventEmitter.on('update', onUpdate) 145 | eventEmitter.on('patch', onPatch) 146 | }) 147 | 148 | return asyncIterator 149 | } 150 | 151 | export default subscribeToLiveData 152 | --------------------------------------------------------------------------------