Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

(graphcache) - Apply commutativity to all operations (#593)

* Simplify while loop in clearDataState further

* Implement initial commutative mutation logic

* Stop squashing layers around optimistic mutation updates

* Test new optimistic layers for data.ts

* Group tests in cacheExchange.test.ts

* Add commutative mutation tests to cacheExchange.test.ts

* Add special handling for commutative subscriptions

* Add changeset

* Fix lint errors

* Let CodeSandbox CI build the actual packages

authored by

Phil Plückthun and committed by
GitHub
8506ea17 f9fb49b3

+1354 -864
+5
.changeset/funny-parents-beg.md
··· 1 + --- 2 + '@urql/exchange-graphcache': patch 3 + --- 4 + 5 + Apply commutative layers to all operations, so now including mutations and subscriptions, to ensure that unordered data is written in the correct order.
+1218 -826
exchanges/graphcache/src/cacheExchange.test.ts
··· 9 9 Source, 10 10 pipe, 11 11 map, 12 + merge, 12 13 mergeMap, 13 14 filter, 14 15 fromValue, ··· 37 38 }, 38 39 }; 39 40 40 - it('writes queries to the cache', () => { 41 - const client = createClient({ url: 'http://0.0.0.0' }); 42 - const op = client.createRequestOperation('query', { 43 - key: 1, 44 - query: queryOne, 45 - }); 41 + describe('data dependencies', () => { 42 + it('writes queries to the cache', () => { 43 + const client = createClient({ url: 'http://0.0.0.0' }); 44 + const op = client.createRequestOperation('query', { 45 + key: 1, 46 + query: queryOne, 47 + }); 46 48 47 - const response = jest.fn( 48 - (forwardOp: Operation): OperationResult => { 49 - expect(forwardOp.key).toBe(op.key); 50 - return { operation: forwardOp, data: queryOneData }; 51 - } 52 - ); 49 + const response = jest.fn( 50 + (forwardOp: Operation): OperationResult => { 51 + expect(forwardOp.key).toBe(op.key); 52 + return { operation: forwardOp, data: queryOneData }; 53 + } 54 + ); 53 55 54 - const { source: ops$, next } = makeSubject<Operation>(); 55 - const result = jest.fn(); 56 - const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 56 + const { source: ops$, next } = makeSubject<Operation>(); 57 + const result = jest.fn(); 58 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 57 59 58 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 60 + pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 59 61 60 - next(op); 61 - next(op); 62 - expect(response).toHaveBeenCalledTimes(1); 63 - expect(result).toHaveBeenCalledTimes(2); 62 + next(op); 63 + next(op); 64 + expect(response).toHaveBeenCalledTimes(1); 65 + expect(result).toHaveBeenCalledTimes(2); 64 66 65 - expect(result.mock.calls[0][0]).toHaveProperty( 66 - 'operation.context.meta.cacheOutcome', 67 - 'miss' 68 - ); 69 - expect(result.mock.calls[1][0]).toHaveProperty( 70 - 'operation.context.meta.cacheOutcome', 71 - 'hit' 72 - ); 73 - }); 67 + expect(result.mock.calls[0][0]).toHaveProperty( 68 + 'operation.context.meta.cacheOutcome', 69 + 'miss' 70 + ); 71 + expect(result.mock.calls[1][0]).toHaveProperty( 72 + 'operation.context.meta.cacheOutcome', 73 + 'hit' 74 + ); 75 + }); 74 76 75 - it('updates related queries when their data changes', () => { 76 - const queryMultiple = gql` 77 - { 78 - authors { 79 - id 80 - name 77 + it('updates related queries when their data changes', () => { 78 + const queryMultiple = gql` 79 + { 80 + authors { 81 + id 82 + name 83 + } 81 84 } 82 - } 83 - `; 85 + `; 84 86 85 - const queryMultipleData = { 86 - __typename: 'Query', 87 - authors: [ 88 - { 89 - __typename: 'Author', 90 - id: '123', 91 - name: 'Author', 92 - }, 93 - ], 94 - }; 87 + const queryMultipleData = { 88 + __typename: 'Query', 89 + authors: [ 90 + { 91 + __typename: 'Author', 92 + id: '123', 93 + name: 'Author', 94 + }, 95 + ], 96 + }; 95 97 96 - const client = createClient({ url: 'http://0.0.0.0' }); 97 - const { source: ops$, next } = makeSubject<Operation>(); 98 + const client = createClient({ url: 'http://0.0.0.0' }); 99 + const { source: ops$, next } = makeSubject<Operation>(); 98 100 99 - const reexec = jest 100 - .spyOn(client, 'reexecuteOperation') 101 - .mockImplementation(next); 101 + const reexec = jest 102 + .spyOn(client, 'reexecuteOperation') 103 + .mockImplementation(next); 102 104 103 - const opOne = client.createRequestOperation('query', { 104 - key: 1, 105 - query: queryOne, 106 - }); 105 + const opOne = client.createRequestOperation('query', { 106 + key: 1, 107 + query: queryOne, 108 + }); 107 109 108 - const opMultiple = client.createRequestOperation('query', { 109 - key: 2, 110 - query: queryMultiple, 111 - }); 110 + const opMultiple = client.createRequestOperation('query', { 111 + key: 2, 112 + query: queryMultiple, 113 + }); 112 114 113 - const response = jest.fn( 114 - (forwardOp: Operation): OperationResult => { 115 - if (forwardOp.key === 1) { 116 - return { operation: opOne, data: queryOneData }; 117 - } else if (forwardOp.key === 2) { 118 - return { operation: opMultiple, data: queryMultipleData }; 119 - } 115 + const response = jest.fn( 116 + (forwardOp: Operation): OperationResult => { 117 + if (forwardOp.key === 1) { 118 + return { operation: opOne, data: queryOneData }; 119 + } else if (forwardOp.key === 2) { 120 + return { operation: opMultiple, data: queryMultipleData }; 121 + } 120 122 121 - return undefined as any; 122 - } 123 - ); 123 + return undefined as any; 124 + } 125 + ); 124 126 125 - const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 126 - const result = jest.fn(); 127 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 128 + const result = jest.fn(); 127 129 128 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 130 + pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 129 131 130 - next(opOne); 131 - expect(response).toHaveBeenCalledTimes(1); 132 - expect(result).toHaveBeenCalledTimes(1); 132 + next(opOne); 133 + expect(response).toHaveBeenCalledTimes(1); 134 + expect(result).toHaveBeenCalledTimes(1); 133 135 134 - next(opMultiple); 135 - expect(response).toHaveBeenCalledTimes(2); 136 - expect(reexec).toHaveBeenCalledWith(opOne); 137 - expect(result).toHaveBeenCalledTimes(3); 138 - }); 136 + next(opMultiple); 137 + expect(response).toHaveBeenCalledTimes(2); 138 + expect(reexec).toHaveBeenCalledWith(opOne); 139 + expect(result).toHaveBeenCalledTimes(3); 140 + }); 139 141 140 - it('updates related queries when a mutation update touches query data', () => { 141 - jest.useFakeTimers(); 142 + it('updates related queries when a mutation update touches query data', () => { 143 + jest.useFakeTimers(); 142 144 143 - const balanceFragment = gql` 144 - fragment BalanceFragment on Author { 145 - id 146 - balance { 147 - amount 145 + const balanceFragment = gql` 146 + fragment BalanceFragment on Author { 147 + id 148 + balance { 149 + amount 150 + } 148 151 } 149 - } 150 - `; 152 + `; 151 153 152 - const queryById = gql` 153 - query($id: ID!) { 154 - author(id: $id) { 155 - id 156 - name 157 - ...BalanceFragment 154 + const queryById = gql` 155 + query($id: ID!) { 156 + author(id: $id) { 157 + id 158 + name 159 + ...BalanceFragment 160 + } 158 161 } 159 - } 160 162 161 - ${balanceFragment} 162 - `; 163 + ${balanceFragment} 164 + `; 163 165 164 - const queryByIdDataA = { 165 - __typename: 'Query', 166 - author: { 167 - __typename: 'Author', 168 - id: '1', 169 - name: 'Author 1', 170 - balance: { 171 - __typename: 'Balance', 172 - amount: 100, 166 + const queryByIdDataA = { 167 + __typename: 'Query', 168 + author: { 169 + __typename: 'Author', 170 + id: '1', 171 + name: 'Author 1', 172 + balance: { 173 + __typename: 'Balance', 174 + amount: 100, 175 + }, 173 176 }, 174 - }, 175 - }; 177 + }; 176 178 177 - const queryByIdDataB = { 178 - __typename: 'Query', 179 - author: { 180 - __typename: 'Author', 181 - id: '2', 182 - name: 'Author 2', 183 - balance: { 184 - __typename: 'Balance', 185 - amount: 200, 179 + const queryByIdDataB = { 180 + __typename: 'Query', 181 + author: { 182 + __typename: 'Author', 183 + id: '2', 184 + name: 'Author 2', 185 + balance: { 186 + __typename: 'Balance', 187 + amount: 200, 188 + }, 186 189 }, 187 - }, 188 - }; 190 + }; 189 191 190 - const mutation = gql` 191 - mutation($userId: ID!, $amount: Int!) { 192 - updateBalance(userId: $userId, amount: $amount) { 193 - userId 194 - balance { 195 - amount 192 + const mutation = gql` 193 + mutation($userId: ID!, $amount: Int!) { 194 + updateBalance(userId: $userId, amount: $amount) { 195 + userId 196 + balance { 197 + amount 198 + } 196 199 } 197 200 } 198 - } 199 - `; 201 + `; 200 202 201 - const mutationData = { 202 - __typename: 'Mutation', 203 - updateBalance: { 204 - __typename: 'UpdateBalanceResult', 205 - userId: '1', 206 - balance: { 207 - __typename: 'Balance', 208 - amount: 1000, 203 + const mutationData = { 204 + __typename: 'Mutation', 205 + updateBalance: { 206 + __typename: 'UpdateBalanceResult', 207 + userId: '1', 208 + balance: { 209 + __typename: 'Balance', 210 + amount: 1000, 211 + }, 209 212 }, 210 - }, 211 - }; 213 + }; 212 214 213 - const client = createClient({ url: 'http://0.0.0.0' }); 214 - const { source: ops$, next } = makeSubject<Operation>(); 215 + const client = createClient({ url: 'http://0.0.0.0' }); 216 + const { source: ops$, next } = makeSubject<Operation>(); 215 217 216 - const reexec = jest 217 - .spyOn(client, 'reexecuteOperation') 218 - .mockImplementation(next); 218 + const reexec = jest 219 + .spyOn(client, 'reexecuteOperation') 220 + .mockImplementation(next); 219 221 220 - const opOne = client.createRequestOperation('query', { 221 - key: 1, 222 - query: queryById, 223 - variables: { id: 1 }, 224 - }); 222 + const opOne = client.createRequestOperation('query', { 223 + key: 1, 224 + query: queryById, 225 + variables: { id: 1 }, 226 + }); 225 227 226 - const opTwo = client.createRequestOperation('query', { 227 - key: 2, 228 - query: queryById, 229 - variables: { id: 2 }, 230 - }); 228 + const opTwo = client.createRequestOperation('query', { 229 + key: 2, 230 + query: queryById, 231 + variables: { id: 2 }, 232 + }); 231 233 232 - const opMutation = client.createRequestOperation('mutation', { 233 - key: 3, 234 - query: mutation, 235 - variables: { userId: '1', amount: 1000 }, 236 - }); 234 + const opMutation = client.createRequestOperation('mutation', { 235 + key: 3, 236 + query: mutation, 237 + variables: { userId: '1', amount: 1000 }, 238 + }); 237 239 238 - const response = jest.fn( 239 - (forwardOp: Operation): OperationResult => { 240 - if (forwardOp.key === 1) { 241 - return { operation: opOne, data: queryByIdDataA }; 242 - } else if (forwardOp.key === 2) { 243 - return { operation: opTwo, data: queryByIdDataB }; 244 - } else if (forwardOp.key === 3) { 245 - return { operation: opMutation, data: mutationData }; 240 + const response = jest.fn( 241 + (forwardOp: Operation): OperationResult => { 242 + if (forwardOp.key === 1) { 243 + return { operation: opOne, data: queryByIdDataA }; 244 + } else if (forwardOp.key === 2) { 245 + return { operation: opTwo, data: queryByIdDataB }; 246 + } else if (forwardOp.key === 3) { 247 + return { operation: opMutation, data: mutationData }; 248 + } 249 + 250 + return undefined as any; 246 251 } 252 + ); 247 253 248 - return undefined as any; 249 - } 250 - ); 254 + const result = jest.fn(); 255 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 251 256 252 - const result = jest.fn(); 253 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 257 + const updates = { 258 + Mutation: { 259 + updateBalance: jest.fn((result, _args, cache) => { 260 + const { 261 + updateBalance: { userId, balance }, 262 + } = result; 263 + cache.writeFragment(balanceFragment, { id: userId, balance }); 264 + }), 265 + }, 266 + }; 254 267 255 - const updates = { 256 - Mutation: { 257 - updateBalance: jest.fn((result, _args, cache) => { 258 - const { 259 - updateBalance: { userId, balance }, 260 - } = result; 261 - cache.writeFragment(balanceFragment, { id: userId, balance }); 262 - }), 263 - }, 264 - }; 268 + const keys = { 269 + Balance: () => null, 270 + }; 265 271 266 - const keys = { 267 - Balance: () => null, 268 - }; 272 + pipe( 273 + cacheExchange({ updates, keys })({ forward, client })(ops$), 274 + tap(result), 275 + publish 276 + ); 269 277 270 - pipe( 271 - cacheExchange({ updates, keys })({ forward, client })(ops$), 272 - tap(result), 273 - publish 274 - ); 278 + next(opTwo); 279 + jest.runAllTimers(); 280 + expect(response).toHaveBeenCalledTimes(1); 275 281 276 - next(opTwo); 277 - jest.runAllTimers(); 278 - expect(response).toHaveBeenCalledTimes(1); 279 - 280 - next(opOne); 281 - jest.runAllTimers(); 282 - expect(response).toHaveBeenCalledTimes(2); 282 + next(opOne); 283 + jest.runAllTimers(); 284 + expect(response).toHaveBeenCalledTimes(2); 283 285 284 - next(opMutation); 285 - jest.runAllTimers(); 286 + next(opMutation); 287 + jest.runAllTimers(); 286 288 287 - expect(response).toHaveBeenCalledTimes(3); 288 - expect(updates.Mutation.updateBalance).toHaveBeenCalledTimes(1); 289 + expect(response).toHaveBeenCalledTimes(3); 290 + expect(updates.Mutation.updateBalance).toHaveBeenCalledTimes(1); 289 291 290 - expect(reexec).toHaveBeenCalledTimes(1); 291 - expect(reexec.mock.calls[0][0].key).toBe(1); 292 + expect(reexec).toHaveBeenCalledTimes(1); 293 + expect(reexec.mock.calls[0][0].key).toBe(1); 292 294 293 - expect(result.mock.calls[2][0]).toHaveProperty( 294 - 'data.author.balance.amount', 295 - 1000 296 - ); 297 - }); 295 + expect(result.mock.calls[2][0]).toHaveProperty( 296 + 'data.author.balance.amount', 297 + 1000 298 + ); 299 + }); 298 300 299 - it('does nothing when no related queries have changed', () => { 300 - const queryUnrelated = gql` 301 - { 302 - user { 303 - id 304 - name 301 + it('does nothing when no related queries have changed', () => { 302 + const queryUnrelated = gql` 303 + { 304 + user { 305 + id 306 + name 307 + } 305 308 } 306 - } 307 - `; 309 + `; 308 310 309 - const queryUnrelatedData = { 310 - __typename: 'Query', 311 - user: { 312 - __typename: 'User', 313 - id: 'me', 314 - name: 'Me', 315 - }, 316 - }; 311 + const queryUnrelatedData = { 312 + __typename: 'Query', 313 + user: { 314 + __typename: 'User', 315 + id: 'me', 316 + name: 'Me', 317 + }, 318 + }; 319 + 320 + const client = createClient({ url: 'http://0.0.0.0' }); 321 + const { source: ops$, next } = makeSubject<Operation>(); 322 + const reexec = jest 323 + .spyOn(client, 'reexecuteOperation') 324 + .mockImplementation(next); 317 325 318 - const client = createClient({ url: 'http://0.0.0.0' }); 319 - const { source: ops$, next } = makeSubject<Operation>(); 320 - const reexec = jest 321 - .spyOn(client, 'reexecuteOperation') 322 - .mockImplementation(next); 326 + const opOne = client.createRequestOperation('query', { 327 + key: 1, 328 + query: queryOne, 329 + }); 330 + const opUnrelated = client.createRequestOperation('query', { 331 + key: 2, 332 + query: queryUnrelated, 333 + }); 323 334 324 - const opOne = client.createRequestOperation('query', { 325 - key: 1, 326 - query: queryOne, 327 - }); 328 - const opUnrelated = client.createRequestOperation('query', { 329 - key: 2, 330 - query: queryUnrelated, 331 - }); 335 + const response = jest.fn( 336 + (forwardOp: Operation): OperationResult => { 337 + if (forwardOp.key === 1) { 338 + return { operation: opOne, data: queryOneData }; 339 + } else if (forwardOp.key === 2) { 340 + return { operation: opUnrelated, data: queryUnrelatedData }; 341 + } 332 342 333 - const response = jest.fn( 334 - (forwardOp: Operation): OperationResult => { 335 - if (forwardOp.key === 1) { 336 - return { operation: opOne, data: queryOneData }; 337 - } else if (forwardOp.key === 2) { 338 - return { operation: opUnrelated, data: queryUnrelatedData }; 343 + return undefined as any; 339 344 } 345 + ); 340 346 341 - return undefined as any; 342 - } 343 - ); 347 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 348 + const result = jest.fn(); 344 349 345 - const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 346 - const result = jest.fn(); 350 + pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 347 351 348 - pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish); 352 + next(opOne); 353 + expect(response).toHaveBeenCalledTimes(1); 349 354 350 - next(opOne); 351 - expect(response).toHaveBeenCalledTimes(1); 355 + next(opUnrelated); 356 + expect(response).toHaveBeenCalledTimes(2); 352 357 353 - next(opUnrelated); 354 - expect(response).toHaveBeenCalledTimes(2); 355 - 356 - expect(reexec).not.toHaveBeenCalled(); 357 - expect(result).toHaveBeenCalledTimes(2); 358 + expect(reexec).not.toHaveBeenCalled(); 359 + expect(result).toHaveBeenCalledTimes(2); 360 + }); 358 361 }); 359 362 360 - it('writes optimistic mutations to the cache', () => { 361 - jest.useFakeTimers(); 363 + describe('optimistic updates', () => { 364 + it('writes optimistic mutations to the cache', () => { 365 + jest.useFakeTimers(); 362 366 363 - const mutation = gql` 364 - mutation { 365 - concealAuthor { 366 - id 367 - name 367 + const mutation = gql` 368 + mutation { 369 + concealAuthor { 370 + id 371 + name 372 + } 368 373 } 369 - } 370 - `; 374 + `; 371 375 372 - const optimisticMutationData = { 373 - __typename: 'Mutation', 374 - concealAuthor: { 375 - __typename: 'Author', 376 - id: '123', 377 - name: '[REDACTED OFFLINE]', 378 - }, 379 - }; 376 + const optimisticMutationData = { 377 + __typename: 'Mutation', 378 + concealAuthor: { 379 + __typename: 'Author', 380 + id: '123', 381 + name: '[REDACTED OFFLINE]', 382 + }, 383 + }; 380 384 381 - const mutationData = { 382 - __typename: 'Mutation', 383 - concealAuthor: { 384 - __typename: 'Author', 385 - id: '123', 386 - name: '[REDACTED ONLINE]', 387 - }, 388 - }; 385 + const mutationData = { 386 + __typename: 'Mutation', 387 + concealAuthor: { 388 + __typename: 'Author', 389 + id: '123', 390 + name: '[REDACTED ONLINE]', 391 + }, 392 + }; 389 393 390 - const client = createClient({ url: 'http://0.0.0.0' }); 391 - const { source: ops$, next } = makeSubject<Operation>(); 394 + const client = createClient({ url: 'http://0.0.0.0' }); 395 + const { source: ops$, next } = makeSubject<Operation>(); 392 396 393 - const reexec = jest 394 - .spyOn(client, 'reexecuteOperation') 395 - .mockImplementation(next); 397 + const reexec = jest 398 + .spyOn(client, 'reexecuteOperation') 399 + .mockImplementation(next); 396 400 397 - const opOne = client.createRequestOperation('query', { 398 - key: 1, 399 - query: queryOne, 400 - }); 401 + const opOne = client.createRequestOperation('query', { 402 + key: 1, 403 + query: queryOne, 404 + }); 401 405 402 - const opMutation = client.createRequestOperation('mutation', { 403 - key: 2, 404 - query: mutation, 405 - }); 406 + const opMutation = client.createRequestOperation('mutation', { 407 + key: 2, 408 + query: mutation, 409 + }); 406 410 407 - const response = jest.fn( 408 - (forwardOp: Operation): OperationResult => { 409 - if (forwardOp.key === 1) { 410 - return { operation: opOne, data: queryOneData }; 411 - } else if (forwardOp.key === 2) { 412 - return { operation: opMutation, data: mutationData }; 411 + const response = jest.fn( 412 + (forwardOp: Operation): OperationResult => { 413 + if (forwardOp.key === 1) { 414 + return { operation: opOne, data: queryOneData }; 415 + } else if (forwardOp.key === 2) { 416 + return { operation: opMutation, data: mutationData }; 417 + } 418 + 419 + return undefined as any; 413 420 } 421 + ); 414 422 415 - return undefined as any; 416 - } 417 - ); 423 + const result = jest.fn(); 424 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 418 425 419 - const result = jest.fn(); 420 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 426 + const optimistic = { 427 + concealAuthor: jest.fn(() => optimisticMutationData.concealAuthor) as any, 428 + }; 421 429 422 - const optimistic = { 423 - concealAuthor: jest.fn(() => optimisticMutationData.concealAuthor) as any, 424 - }; 430 + pipe( 431 + cacheExchange({ optimistic })({ forward, client })(ops$), 432 + tap(result), 433 + publish 434 + ); 425 435 426 - pipe( 427 - cacheExchange({ optimistic })({ forward, client })(ops$), 428 - tap(result), 429 - publish 430 - ); 436 + next(opOne); 437 + jest.runAllTimers(); 438 + expect(response).toHaveBeenCalledTimes(1); 431 439 432 - next(opOne); 433 - jest.runAllTimers(); 434 - expect(response).toHaveBeenCalledTimes(1); 440 + next(opMutation); 441 + expect(response).toHaveBeenCalledTimes(1); 442 + expect(optimistic.concealAuthor).toHaveBeenCalledTimes(1); 443 + expect(reexec).toHaveBeenCalledTimes(1); 435 444 436 - next(opMutation); 437 - expect(response).toHaveBeenCalledTimes(1); 438 - expect(optimistic.concealAuthor).toHaveBeenCalledTimes(1); 439 - expect(reexec).toHaveBeenCalledTimes(1); 445 + jest.runAllTimers(); 446 + expect(response).toHaveBeenCalledTimes(2); 447 + expect(result).toHaveBeenCalledTimes(4); 448 + }); 440 449 441 - jest.runAllTimers(); 442 - expect(response).toHaveBeenCalledTimes(2); 443 - expect(result).toHaveBeenCalledTimes(4); 444 - }); 450 + it('correctly clears on error', () => { 451 + jest.useFakeTimers(); 445 452 446 - it('correctly clears on error', () => { 447 - jest.useFakeTimers(); 453 + const authorsQuery = gql` 454 + query { 455 + authors { 456 + id 457 + name 458 + } 459 + } 460 + `; 448 461 449 - const authorsQuery = gql` 450 - query { 451 - authors { 452 - id 453 - name 462 + const authorsQueryData = { 463 + __typename: 'Query', 464 + authors: [ 465 + { 466 + __typename: 'Author', 467 + id: '1', 468 + name: 'Author', 469 + }, 470 + ], 471 + }; 472 + 473 + const mutation = gql` 474 + mutation { 475 + addAuthor { 476 + id 477 + name 478 + } 454 479 } 455 - } 456 - `; 480 + `; 457 481 458 - const authorsQueryData = { 459 - __typename: 'Query', 460 - authors: [ 461 - { 482 + const optimisticMutationData = { 483 + __typename: 'Mutation', 484 + addAuthor: { 462 485 __typename: 'Author', 463 - id: '1', 464 - name: 'Author', 486 + id: '123', 487 + name: '[REDACTED OFFLINE]', 465 488 }, 466 - ], 467 - }; 468 - 469 - const mutation = gql` 470 - mutation { 471 - addAuthor { 472 - id 473 - name 474 - } 475 - } 476 - `; 489 + }; 477 490 478 - const optimisticMutationData = { 479 - __typename: 'Mutation', 480 - addAuthor: { 481 - __typename: 'Author', 482 - id: '123', 483 - name: '[REDACTED OFFLINE]', 484 - }, 485 - }; 491 + const client = createClient({ url: 'http://0.0.0.0' }); 492 + const { source: ops$, next } = makeSubject<Operation>(); 486 493 487 - const client = createClient({ url: 'http://0.0.0.0' }); 488 - const { source: ops$, next } = makeSubject<Operation>(); 494 + const reexec = jest 495 + .spyOn(client, 'reexecuteOperation') 496 + .mockImplementation(next); 489 497 490 - const reexec = jest 491 - .spyOn(client, 'reexecuteOperation') 492 - .mockImplementation(next); 498 + const opOne = client.createRequestOperation('query', { 499 + key: 1, 500 + query: authorsQuery, 501 + }); 493 502 494 - const opOne = client.createRequestOperation('query', { 495 - key: 1, 496 - query: authorsQuery, 497 - }); 503 + const opMutation = client.createRequestOperation('mutation', { 504 + key: 2, 505 + query: mutation, 506 + }); 498 507 499 - const opMutation = client.createRequestOperation('mutation', { 500 - key: 2, 501 - query: mutation, 502 - }); 508 + const response = jest.fn( 509 + (forwardOp: Operation): OperationResult => { 510 + if (forwardOp.key === 1) { 511 + return { operation: opOne, data: authorsQueryData }; 512 + } else if (forwardOp.key === 2) { 513 + return { 514 + operation: opMutation, 515 + error: 'error' as any, 516 + data: { __typename: 'Mutation', addAuthor: null }, 517 + }; 518 + } 503 519 504 - const response = jest.fn( 505 - (forwardOp: Operation): OperationResult => { 506 - if (forwardOp.key === 1) { 507 - return { operation: opOne, data: authorsQueryData }; 508 - } else if (forwardOp.key === 2) { 509 - return { 510 - operation: opMutation, 511 - error: 'error' as any, 512 - data: { __typename: 'Mutation', addAuthor: null }, 513 - }; 520 + return undefined as any; 514 521 } 515 - 516 - return undefined as any; 517 - } 518 - ); 522 + ); 519 523 520 - const result = jest.fn(); 521 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 524 + const result = jest.fn(); 525 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 522 526 523 - const optimistic = { 524 - addAuthor: jest.fn(() => optimisticMutationData.addAuthor) as any, 525 - }; 527 + const optimistic = { 528 + addAuthor: jest.fn(() => optimisticMutationData.addAuthor) as any, 529 + }; 526 530 527 - const updates = { 528 - Mutation: { 529 - addAuthor: jest.fn((data, _, cache) => { 530 - cache.updateQuery({ query: authorsQuery }, (prevData: any) => ({ 531 - ...prevData, 532 - authors: [...prevData.authors, data.addAuthor], 533 - })); 534 - }), 535 - }, 536 - }; 531 + const updates = { 532 + Mutation: { 533 + addAuthor: jest.fn((data, _, cache) => { 534 + cache.updateQuery({ query: authorsQuery }, (prevData: any) => ({ 535 + ...prevData, 536 + authors: [...prevData.authors, data.addAuthor], 537 + })); 538 + }), 539 + }, 540 + }; 537 541 538 - pipe( 539 - cacheExchange({ optimistic, updates })({ forward, client })(ops$), 540 - tap(result), 541 - publish 542 - ); 542 + pipe( 543 + cacheExchange({ optimistic, updates })({ forward, client })(ops$), 544 + tap(result), 545 + publish 546 + ); 543 547 544 - next(opOne); 545 - jest.runAllTimers(); 546 - expect(response).toHaveBeenCalledTimes(1); 548 + next(opOne); 549 + jest.runAllTimers(); 550 + expect(response).toHaveBeenCalledTimes(1); 547 551 548 - next(opMutation); 549 - expect(response).toHaveBeenCalledTimes(1); 550 - expect(optimistic.addAuthor).toHaveBeenCalledTimes(1); 551 - expect(updates.Mutation.addAuthor).toHaveBeenCalledTimes(1); 552 - expect(reexec).toHaveBeenCalledTimes(1); 552 + next(opMutation); 553 + expect(response).toHaveBeenCalledTimes(1); 554 + expect(optimistic.addAuthor).toHaveBeenCalledTimes(1); 555 + expect(updates.Mutation.addAuthor).toHaveBeenCalledTimes(1); 556 + expect(reexec).toHaveBeenCalledTimes(1); 553 557 554 - jest.runAllTimers(); 555 - expect(updates.Mutation.addAuthor).toHaveBeenCalledTimes(2); 556 - expect(response).toHaveBeenCalledTimes(2); 557 - expect(result).toHaveBeenCalledTimes(4); 558 + jest.runAllTimers(); 559 + expect(updates.Mutation.addAuthor).toHaveBeenCalledTimes(2); 560 + expect(response).toHaveBeenCalledTimes(2); 561 + expect(result).toHaveBeenCalledTimes(4); 562 + }); 558 563 }); 559 564 560 - it('follows resolvers on initial write', () => { 561 - const client = createClient({ url: 'http://0.0.0.0' }); 562 - const { source: ops$, next } = makeSubject<Operation>(); 565 + describe('custom resolvers', () => { 566 + it('follows resolvers on initial write', () => { 567 + const client = createClient({ url: 'http://0.0.0.0' }); 568 + const { source: ops$, next } = makeSubject<Operation>(); 563 569 564 - const opOne = client.createRequestOperation('query', { 565 - key: 1, 566 - query: queryOne, 567 - }); 570 + const opOne = client.createRequestOperation('query', { 571 + key: 1, 572 + query: queryOne, 573 + }); 568 574 569 - const response = jest.fn( 570 - (forwardOp: Operation): OperationResult => { 571 - if (forwardOp.key === 1) { 572 - return { operation: opOne, data: queryOneData }; 573 - } 575 + const response = jest.fn( 576 + (forwardOp: Operation): OperationResult => { 577 + if (forwardOp.key === 1) { 578 + return { operation: opOne, data: queryOneData }; 579 + } 574 580 575 - return undefined as any; 576 - } 577 - ); 581 + return undefined as any; 582 + } 583 + ); 578 584 579 - const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 585 + const forward: ExchangeIO = ops$ => pipe(ops$, map(response)); 580 586 581 - const result = jest.fn(); 582 - const fakeResolver = jest.fn(); 587 + const result = jest.fn(); 588 + const fakeResolver = jest.fn(); 583 589 584 - pipe( 585 - cacheExchange({ 586 - resolvers: { 587 - Author: { 588 - name: () => { 589 - fakeResolver(); 590 - return 'newName'; 590 + pipe( 591 + cacheExchange({ 592 + resolvers: { 593 + Author: { 594 + name: () => { 595 + fakeResolver(); 596 + return 'newName'; 597 + }, 591 598 }, 592 599 }, 593 - }, 594 - })({ forward, client })(ops$), 595 - tap(result), 596 - publish 597 - ); 600 + })({ forward, client })(ops$), 601 + tap(result), 602 + publish 603 + ); 598 604 599 - next(opOne); 600 - expect(response).toHaveBeenCalledTimes(1); 601 - expect(fakeResolver).toHaveBeenCalledTimes(1); 602 - expect(result).toHaveBeenCalledTimes(1); 603 - expect(result.mock.calls[0][0].data).toEqual({ 604 - __typename: 'Query', 605 - author: { 606 - __typename: 'Author', 607 - id: '123', 608 - name: 'newName', 609 - }, 605 + next(opOne); 606 + expect(response).toHaveBeenCalledTimes(1); 607 + expect(fakeResolver).toHaveBeenCalledTimes(1); 608 + expect(result).toHaveBeenCalledTimes(1); 609 + expect(result.mock.calls[0][0].data).toEqual({ 610 + __typename: 'Query', 611 + author: { 612 + __typename: 'Author', 613 + id: '123', 614 + name: 'newName', 615 + }, 616 + }); 610 617 }); 611 - }); 612 618 613 - it('follows resolvers for mutations', () => { 614 - jest.useFakeTimers(); 619 + it('follows resolvers for mutations', () => { 620 + jest.useFakeTimers(); 615 621 616 - const mutation = gql` 617 - mutation { 618 - concealAuthor { 619 - id 620 - name 621 - __typename 622 + const mutation = gql` 623 + mutation { 624 + concealAuthor { 625 + id 626 + name 627 + __typename 628 + } 622 629 } 623 - } 624 - `; 630 + `; 625 631 626 - const mutationData = { 627 - __typename: 'Mutation', 628 - concealAuthor: { 629 - __typename: 'Author', 630 - id: '123', 631 - name: '[REDACTED ONLINE]', 632 - }, 633 - }; 632 + const mutationData = { 633 + __typename: 'Mutation', 634 + concealAuthor: { 635 + __typename: 'Author', 636 + id: '123', 637 + name: '[REDACTED ONLINE]', 638 + }, 639 + }; 640 + 641 + const client = createClient({ url: 'http://0.0.0.0' }); 642 + const { source: ops$, next } = makeSubject<Operation>(); 634 643 635 - const client = createClient({ url: 'http://0.0.0.0' }); 636 - const { source: ops$, next } = makeSubject<Operation>(); 644 + const opOne = client.createRequestOperation('query', { 645 + key: 1, 646 + query: queryOne, 647 + }); 637 648 638 - const opOne = client.createRequestOperation('query', { 639 - key: 1, 640 - query: queryOne, 641 - }); 649 + const opMutation = client.createRequestOperation('mutation', { 650 + key: 2, 651 + query: mutation, 652 + }); 642 653 643 - const opMutation = client.createRequestOperation('mutation', { 644 - key: 2, 645 - query: mutation, 646 - }); 654 + const response = jest.fn( 655 + (forwardOp: Operation): OperationResult => { 656 + if (forwardOp.key === 1) { 657 + return { operation: opOne, data: queryOneData }; 658 + } else if (forwardOp.key === 2) { 659 + return { operation: opMutation, data: mutationData }; 660 + } 647 661 648 - const response = jest.fn( 649 - (forwardOp: Operation): OperationResult => { 650 - if (forwardOp.key === 1) { 651 - return { operation: opOne, data: queryOneData }; 652 - } else if (forwardOp.key === 2) { 653 - return { operation: opMutation, data: mutationData }; 662 + return undefined as any; 654 663 } 664 + ); 655 665 656 - return undefined as any; 657 - } 658 - ); 666 + const result = jest.fn(); 667 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 659 668 660 - const result = jest.fn(); 661 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 669 + const fakeResolver = jest.fn(); 662 670 663 - const fakeResolver = jest.fn(); 664 - 665 - pipe( 666 - cacheExchange({ 667 - resolvers: { 668 - Author: { 669 - name: () => { 670 - fakeResolver(); 671 - return 'newName'; 671 + pipe( 672 + cacheExchange({ 673 + resolvers: { 674 + Author: { 675 + name: () => { 676 + fakeResolver(); 677 + return 'newName'; 678 + }, 672 679 }, 673 680 }, 674 - }, 675 - })({ forward, client })(ops$), 676 - tap(result), 677 - publish 678 - ); 681 + })({ forward, client })(ops$), 682 + tap(result), 683 + publish 684 + ); 679 685 680 - next(opOne); 681 - jest.runAllTimers(); 682 - expect(response).toHaveBeenCalledTimes(1); 686 + next(opOne); 687 + jest.runAllTimers(); 688 + expect(response).toHaveBeenCalledTimes(1); 683 689 684 - next(opMutation); 685 - expect(response).toHaveBeenCalledTimes(1); 686 - expect(fakeResolver).toHaveBeenCalledTimes(1); 690 + next(opMutation); 691 + expect(response).toHaveBeenCalledTimes(1); 692 + expect(fakeResolver).toHaveBeenCalledTimes(1); 687 693 688 - jest.runAllTimers(); 689 - expect(result.mock.calls[1][0].data).toEqual({ 690 - __typename: 'Mutation', 691 - concealAuthor: { 692 - __typename: 'Author', 693 - id: '123', 694 - name: 'newName', 695 - }, 694 + jest.runAllTimers(); 695 + expect(result.mock.calls[1][0].data).toEqual({ 696 + __typename: 'Mutation', 697 + concealAuthor: { 698 + __typename: 'Author', 699 + id: '123', 700 + name: 'newName', 701 + }, 702 + }); 696 703 }); 697 - }); 698 704 699 - it('follows nested resolvers for mutations', () => { 700 - jest.useFakeTimers(); 705 + it('follows nested resolvers for mutations', () => { 706 + jest.useFakeTimers(); 701 707 702 - const mutation = gql` 703 - mutation { 704 - concealAuthors { 705 - id 706 - name 707 - book { 708 + const mutation = gql` 709 + mutation { 710 + concealAuthors { 708 711 id 709 - title 712 + name 713 + book { 714 + id 715 + title 716 + __typename 717 + } 710 718 __typename 711 719 } 712 - __typename 713 720 } 714 - } 715 - `; 721 + `; 716 722 717 - const client = createClient({ url: 'http://0.0.0.0' }); 718 - const { source: ops$, next } = makeSubject<Operation>(); 723 + const client = createClient({ url: 'http://0.0.0.0' }); 724 + const { source: ops$, next } = makeSubject<Operation>(); 719 725 720 - const query = gql` 721 - query { 722 - authors { 723 - id 724 - name 725 - book { 726 + const query = gql` 727 + query { 728 + authors { 726 729 id 727 - title 730 + name 731 + book { 732 + id 733 + title 734 + __typename 735 + } 728 736 __typename 729 737 } 730 - __typename 731 738 } 732 - } 733 - `; 739 + `; 734 740 735 - const queryOperation = client.createRequestOperation('query', { 736 - key: 1, 737 - query, 738 - }); 741 + const queryOperation = client.createRequestOperation('query', { 742 + key: 1, 743 + query, 744 + }); 739 745 740 - const mutationOperation = client.createRequestOperation('mutation', { 741 - key: 2, 742 - query: mutation, 743 - }); 746 + const mutationOperation = client.createRequestOperation('mutation', { 747 + key: 2, 748 + query: mutation, 749 + }); 744 750 745 - const mutationData = { 746 - __typename: 'Mutation', 747 - concealAuthors: [ 748 - { 749 - __typename: 'Author', 750 - id: '123', 751 - book: null, 752 - name: '[REDACTED ONLINE]', 753 - }, 754 - { 755 - __typename: 'Author', 756 - id: '456', 757 - name: 'Formidable', 758 - book: { 759 - id: '1', 760 - title: 'AwesomeGQL', 761 - __typename: 'Book', 751 + const mutationData = { 752 + __typename: 'Mutation', 753 + concealAuthors: [ 754 + { 755 + __typename: 'Author', 756 + id: '123', 757 + book: null, 758 + name: '[REDACTED ONLINE]', 762 759 }, 763 - }, 764 - ], 765 - }; 760 + { 761 + __typename: 'Author', 762 + id: '456', 763 + name: 'Formidable', 764 + book: { 765 + id: '1', 766 + title: 'AwesomeGQL', 767 + __typename: 'Book', 768 + }, 769 + }, 770 + ], 771 + }; 766 772 767 - const queryData = { 768 - __typename: 'Query', 769 - authors: [ 770 - { 771 - __typename: 'Author', 772 - id: '123', 773 - name: '[REDACTED ONLINE]', 774 - book: null, 775 - }, 776 - { 777 - __typename: 'Author', 778 - id: '456', 779 - name: 'Formidable', 780 - book: { 781 - id: '1', 782 - title: 'AwesomeGQL', 783 - __typename: 'Book', 773 + const queryData = { 774 + __typename: 'Query', 775 + authors: [ 776 + { 777 + __typename: 'Author', 778 + id: '123', 779 + name: '[REDACTED ONLINE]', 780 + book: null, 784 781 }, 785 - }, 786 - ], 787 - }; 782 + { 783 + __typename: 'Author', 784 + id: '456', 785 + name: 'Formidable', 786 + book: { 787 + id: '1', 788 + title: 'AwesomeGQL', 789 + __typename: 'Book', 790 + }, 791 + }, 792 + ], 793 + }; 788 794 789 - const response = jest.fn( 790 - (forwardOp: Operation): OperationResult => { 791 - if (forwardOp.key === 1) { 792 - return { operation: queryOperation, data: queryData }; 793 - } else if (forwardOp.key === 2) { 794 - return { operation: mutationOperation, data: mutationData }; 795 - } 795 + const response = jest.fn( 796 + (forwardOp: Operation): OperationResult => { 797 + if (forwardOp.key === 1) { 798 + return { operation: queryOperation, data: queryData }; 799 + } else if (forwardOp.key === 2) { 800 + return { operation: mutationOperation, data: mutationData }; 801 + } 796 802 797 - return undefined as any; 798 - } 799 - ); 803 + return undefined as any; 804 + } 805 + ); 800 806 801 - const result = jest.fn(); 802 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 807 + const result = jest.fn(); 808 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 803 809 804 - const fakeResolver = jest.fn(); 805 - const called: any[] = []; 810 + const fakeResolver = jest.fn(); 811 + const called: any[] = []; 806 812 807 - pipe( 808 - cacheExchange({ 809 - resolvers: { 810 - Author: { 811 - name: parent => { 812 - called.push(parent.name); 813 - fakeResolver(); 814 - return 'Secret Author'; 813 + pipe( 814 + cacheExchange({ 815 + resolvers: { 816 + Author: { 817 + name: parent => { 818 + called.push(parent.name); 819 + fakeResolver(); 820 + return 'Secret Author'; 821 + }, 815 822 }, 816 - }, 817 - Book: { 818 - title: parent => { 819 - called.push(parent.title); 820 - fakeResolver(); 821 - return 'Secret Book'; 823 + Book: { 824 + title: parent => { 825 + called.push(parent.title); 826 + fakeResolver(); 827 + return 'Secret Book'; 828 + }, 822 829 }, 823 830 }, 824 - }, 825 - })({ forward, client })(ops$), 826 - tap(result), 827 - publish 828 - ); 831 + })({ forward, client })(ops$), 832 + tap(result), 833 + publish 834 + ); 829 835 830 - next(queryOperation); 831 - jest.runAllTimers(); 832 - expect(response).toHaveBeenCalledTimes(1); 833 - expect(fakeResolver).toHaveBeenCalledTimes(3); 836 + next(queryOperation); 837 + jest.runAllTimers(); 838 + expect(response).toHaveBeenCalledTimes(1); 839 + expect(fakeResolver).toHaveBeenCalledTimes(3); 834 840 835 - next(mutationOperation); 836 - jest.runAllTimers(); 837 - expect(response).toHaveBeenCalledTimes(2); 838 - expect(fakeResolver).toHaveBeenCalledTimes(6); 839 - expect(result.mock.calls[1][0].data).toEqual({ 840 - __typename: 'Mutation', 841 - concealAuthors: [ 842 - { 843 - __typename: 'Author', 844 - id: '123', 845 - book: null, 846 - name: 'Secret Author', 847 - }, 848 - { 849 - __typename: 'Author', 850 - id: '456', 851 - name: 'Secret Author', 852 - book: { 853 - id: '1', 854 - title: 'Secret Book', 855 - __typename: 'Book', 841 + next(mutationOperation); 842 + jest.runAllTimers(); 843 + expect(response).toHaveBeenCalledTimes(2); 844 + expect(fakeResolver).toHaveBeenCalledTimes(6); 845 + expect(result.mock.calls[1][0].data).toEqual({ 846 + __typename: 'Mutation', 847 + concealAuthors: [ 848 + { 849 + __typename: 'Author', 850 + id: '123', 851 + book: null, 852 + name: 'Secret Author', 856 853 }, 857 - }, 858 - ], 859 - }); 854 + { 855 + __typename: 'Author', 856 + id: '456', 857 + name: 'Secret Author', 858 + book: { 859 + id: '1', 860 + title: 'Secret Book', 861 + __typename: 'Book', 862 + }, 863 + }, 864 + ], 865 + }); 860 866 861 - expect(called).toEqual([ 862 - // Query 863 - '[REDACTED ONLINE]', 864 - 'Formidable', 865 - 'AwesomeGQL', 866 - // Mutation 867 - '[REDACTED ONLINE]', 868 - 'Formidable', 869 - 'AwesomeGQL', 870 - ]); 867 + expect(called).toEqual([ 868 + // Query 869 + '[REDACTED ONLINE]', 870 + 'Formidable', 871 + 'AwesomeGQL', 872 + // Mutation 873 + '[REDACTED ONLINE]', 874 + 'Formidable', 875 + 'AwesomeGQL', 876 + ]); 877 + }); 871 878 }); 872 879 873 - it('reexecutes query and returns data on partial result', () => { 874 - jest.useFakeTimers(); 875 - const client = createClient({ url: 'http://0.0.0.0' }); 876 - const { source: ops$, next } = makeSubject<Operation>(); 877 - const reexec = jest 878 - .spyOn(client, 'reexecuteOperation') 879 - // Empty mock to avoid going in an endless loop, since we would again return 880 - // partial data. 881 - .mockImplementation(() => undefined); 880 + describe('schema awareness', () => { 881 + it('reexecutes query and returns data on partial result', () => { 882 + jest.useFakeTimers(); 883 + const client = createClient({ url: 'http://0.0.0.0' }); 884 + const { source: ops$, next } = makeSubject<Operation>(); 885 + const reexec = jest 886 + .spyOn(client, 'reexecuteOperation') 887 + // Empty mock to avoid going in an endless loop, since we would again return 888 + // partial data. 889 + .mockImplementation(() => undefined); 882 890 883 - const initialQuery = gql` 884 - query { 885 - todos { 886 - id 887 - text 888 - __typename 891 + const initialQuery = gql` 892 + query { 893 + todos { 894 + id 895 + text 896 + __typename 897 + } 889 898 } 890 - } 891 - `; 899 + `; 892 900 893 - const query = gql` 894 - query { 895 - todos { 896 - id 897 - text 898 - complete 899 - author { 901 + const query = gql` 902 + query { 903 + todos { 900 904 id 901 - name 905 + text 906 + complete 907 + author { 908 + id 909 + name 910 + __typename 911 + } 902 912 __typename 903 913 } 904 - __typename 914 + } 915 + `; 916 + 917 + const initialQueryOperation = client.createRequestOperation('query', { 918 + key: 1, 919 + query: initialQuery, 920 + }); 921 + 922 + const queryOperation = client.createRequestOperation('query', { 923 + key: 2, 924 + query, 925 + }); 926 + 927 + const queryData = { 928 + __typename: 'Query', 929 + todos: [ 930 + { 931 + __typename: 'Todo', 932 + id: '123', 933 + text: 'Learn', 934 + }, 935 + { 936 + __typename: 'Todo', 937 + id: '456', 938 + text: 'Teach', 939 + }, 940 + ], 941 + }; 942 + 943 + const response = jest.fn( 944 + (forwardOp: Operation): OperationResult => { 945 + if (forwardOp.key === 1) { 946 + return { operation: initialQueryOperation, data: queryData }; 947 + } else if (forwardOp.key === 2) { 948 + return { operation: queryOperation, data: queryData }; 949 + } 950 + 951 + return undefined as any; 905 952 } 906 - } 907 - `; 953 + ); 954 + 955 + const result = jest.fn(); 956 + const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 957 + 958 + pipe( 959 + cacheExchange({ 960 + // eslint-disable-next-line 961 + schema: require('./test-utils/simple_schema.json'), 962 + })({ forward, client })(ops$), 963 + tap(result), 964 + publish 965 + ); 966 + 967 + next(initialQueryOperation); 968 + jest.runAllTimers(); 969 + expect(response).toHaveBeenCalledTimes(1); 970 + expect(reexec).toHaveBeenCalledTimes(0); 971 + expect(result.mock.calls[0][0].data).toEqual({ 972 + __typename: 'Query', 973 + todos: [ 974 + { 975 + __typename: 'Todo', 976 + id: '123', 977 + text: 'Learn', 978 + }, 979 + { 980 + __typename: 'Todo', 981 + id: '456', 982 + text: 'Teach', 983 + }, 984 + ], 985 + }); 986 + 987 + expect(result.mock.calls[0][0]).toHaveProperty( 988 + 'operation.context.meta', 989 + undefined 990 + ); 991 + 992 + next(queryOperation); 993 + jest.runAllTimers(); 994 + expect(result).toHaveBeenCalledTimes(2); 995 + expect(reexec).toHaveBeenCalledTimes(1); 996 + expect(result.mock.calls[1][0].stale).toBe(true); 997 + expect(result.mock.calls[1][0].data).toEqual({ 998 + __typename: 'Query', 999 + todos: [ 1000 + { 1001 + __typename: 'Todo', 1002 + author: null, 1003 + complete: null, 1004 + id: '123', 1005 + text: 'Learn', 1006 + }, 1007 + { 1008 + __typename: 'Todo', 1009 + author: null, 1010 + complete: null, 1011 + id: '456', 1012 + text: 'Teach', 1013 + }, 1014 + ], 1015 + }); 908 1016 909 - const initialQueryOperation = client.createRequestOperation('query', { 910 - key: 1, 911 - query: initialQuery, 1017 + expect(result.mock.calls[1][0]).toHaveProperty( 1018 + 'operation.context.meta.cacheOutcome', 1019 + 'partial' 1020 + ); 912 1021 }); 1022 + }); 913 1023 914 - const queryOperation = client.createRequestOperation('query', { 915 - key: 2, 916 - query, 917 - }); 1024 + describe('commutativity', () => { 1025 + it('applies results that come in out-of-order commutatively and consistently', () => { 1026 + jest.useFakeTimers(); 1027 + 1028 + let data: any; 918 1029 919 - const queryData = { 920 - __typename: 'Query', 921 - todos: [ 1030 + const client = createClient({ 1031 + url: 'http://0.0.0.0', 1032 + requestPolicy: 'cache-and-network', 1033 + }); 1034 + const { source: ops$, next: next } = makeSubject<Operation>(); 1035 + const query = gql` 922 1036 { 923 - __typename: 'Todo', 924 - id: '123', 925 - text: 'Learn', 926 - }, 1037 + index 1038 + } 1039 + `; 1040 + 1041 + const result = (operation: Operation): Source<OperationResult> => 1042 + pipe( 1043 + fromValue({ 1044 + operation, 1045 + data: { 1046 + __typename: 'Query', 1047 + index: operation.key, 1048 + }, 1049 + }), 1050 + delay(operation.key === 2 ? 5 : operation.key * 10) 1051 + ); 1052 + 1053 + const output = jest.fn(result => { 1054 + data = result.data; 1055 + }); 1056 + 1057 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 1058 + pipe( 1059 + ops$, 1060 + filter(op => op.operationName !== 'teardown'), 1061 + mergeMap(result) 1062 + ); 1063 + 1064 + pipe(cacheExchange()({ forward, client })(ops$), tap(output), publish); 1065 + 1066 + next(client.createRequestOperation('query', { key: 1, query })); 1067 + next(client.createRequestOperation('query', { key: 2, query })); 1068 + 1069 + // This shouldn't have any effect: 1070 + next(client.createRequestOperation('teardown', { key: 2, query })); 1071 + 1072 + next(client.createRequestOperation('query', { key: 3, query })); 1073 + 1074 + jest.advanceTimersByTime(5); 1075 + expect(output).toHaveBeenCalledTimes(1); 1076 + expect(data.index).toBe(2); 1077 + 1078 + jest.advanceTimersByTime(10); 1079 + expect(output).toHaveBeenCalledTimes(2); 1080 + expect(data.index).toBe(2); 1081 + 1082 + jest.advanceTimersByTime(30); 1083 + expect(output).toHaveBeenCalledTimes(3); 1084 + expect(data.index).toBe(3); 1085 + }); 1086 + 1087 + it('applies optimistic updates on top of commutative queries', () => { 1088 + let data: any; 1089 + const client = createClient({ url: 'http://0.0.0.0' }); 1090 + const { source: ops$, next: nextOp } = makeSubject<Operation>(); 1091 + const { source: res$, next: nextRes } = makeSubject<OperationResult>(); 1092 + 1093 + const reexec = jest 1094 + .spyOn(client, 'reexecuteOperation') 1095 + .mockImplementation(nextOp); 1096 + 1097 + const query = gql` 927 1098 { 928 - __typename: 'Todo', 929 - id: '456', 930 - text: 'Teach', 931 - }, 932 - ], 933 - }; 1099 + node { 1100 + id 1101 + name 1102 + } 1103 + } 1104 + `; 934 1105 935 - const response = jest.fn( 936 - (forwardOp: Operation): OperationResult => { 937 - if (forwardOp.key === 1) { 938 - return { operation: initialQueryOperation, data: queryData }; 939 - } else if (forwardOp.key === 2) { 940 - return { operation: queryOperation, data: queryData }; 1106 + const mutation = gql` 1107 + mutation { 1108 + node { 1109 + id 1110 + name 1111 + } 941 1112 } 1113 + `; 942 1114 943 - return undefined as any; 944 - } 945 - ); 1115 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 1116 + merge([ 1117 + pipe( 1118 + ops$, 1119 + filter(() => false) 1120 + ) as any, 1121 + res$, 1122 + ]); 946 1123 947 - const result = jest.fn(); 948 - const forward: ExchangeIO = ops$ => pipe(ops$, delay(1), map(response)); 1124 + const optimistic = { 1125 + node: () => ({ 1126 + __typename: 'Node', 1127 + id: 'node', 1128 + name: 'optimistic', 1129 + }), 1130 + }; 949 1131 950 - pipe( 951 - cacheExchange({ 952 - // eslint-disable-next-line 953 - schema: require('./test-utils/simple_schema.json'), 954 - })({ forward, client })(ops$), 955 - tap(result), 956 - publish 957 - ); 1132 + pipe( 1133 + cacheExchange({ optimistic })({ forward, client })(ops$), 1134 + tap(result => { 1135 + if (result.operation.operationName === 'query') { 1136 + data = result.data; 1137 + } 1138 + }), 1139 + publish 1140 + ); 958 1141 959 - next(initialQueryOperation); 960 - jest.runAllTimers(); 961 - expect(response).toHaveBeenCalledTimes(1); 962 - expect(reexec).toHaveBeenCalledTimes(0); 963 - expect(result.mock.calls[0][0].data).toEqual({ 964 - __typename: 'Query', 965 - todos: [ 966 - { 967 - __typename: 'Todo', 968 - id: '123', 969 - text: 'Learn', 1142 + const queryOpA = client.createRequestOperation('query', { key: 1, query }); 1143 + const mutationOp = client.createRequestOperation('mutation', { 1144 + key: 2, 1145 + query: mutation, 1146 + }); 1147 + const queryOpB = client.createRequestOperation('query', { key: 3, query }); 1148 + 1149 + expect(data).toBe(undefined); 1150 + 1151 + nextOp(queryOpA); 1152 + 1153 + nextRes({ 1154 + operation: queryOpA, 1155 + data: { 1156 + __typename: 'Query', 1157 + node: { 1158 + __typename: 'Node', 1159 + id: 'node', 1160 + name: 'query a', 1161 + }, 970 1162 }, 971 - { 972 - __typename: 'Todo', 973 - id: '456', 974 - text: 'Teach', 1163 + }); 1164 + 1165 + expect(data).toHaveProperty('node.name', 'query a'); 1166 + 1167 + nextOp(mutationOp); 1168 + expect(reexec).toHaveBeenCalledTimes(1); 1169 + expect(data).toHaveProperty('node.name', 'optimistic'); 1170 + 1171 + nextOp(queryOpB); 1172 + nextRes({ 1173 + operation: queryOpB, 1174 + data: { 1175 + __typename: 'Query', 1176 + node: { 1177 + __typename: 'Node', 1178 + id: 'node', 1179 + name: 'query b', 1180 + }, 975 1181 }, 976 - ], 1182 + }); 1183 + 1184 + expect(data).toHaveProperty('node.name', 'query b'); 977 1185 }); 978 1186 979 - expect(result.mock.calls[0][0]).toHaveProperty( 980 - 'operation.context.meta', 981 - undefined 982 - ); 1187 + it('applies mutation results on top of commutative queries', () => { 1188 + let data: any; 1189 + const client = createClient({ url: 'http://0.0.0.0' }); 1190 + const { source: ops$, next: nextOp } = makeSubject<Operation>(); 1191 + const { source: res$, next: nextRes } = makeSubject<OperationResult>(); 983 1192 984 - next(queryOperation); 985 - jest.runAllTimers(); 986 - expect(result).toHaveBeenCalledTimes(2); 987 - expect(reexec).toHaveBeenCalledTimes(1); 988 - expect(result.mock.calls[1][0].stale).toBe(true); 989 - expect(result.mock.calls[1][0].data).toEqual({ 990 - __typename: 'Query', 991 - todos: [ 1193 + const reexec = jest 1194 + .spyOn(client, 'reexecuteOperation') 1195 + .mockImplementation(nextOp); 1196 + 1197 + const query = gql` 992 1198 { 993 - __typename: 'Todo', 994 - author: null, 995 - complete: null, 996 - id: '123', 997 - text: 'Learn', 1199 + node { 1200 + id 1201 + name 1202 + } 1203 + } 1204 + `; 1205 + 1206 + const mutation = gql` 1207 + mutation { 1208 + node { 1209 + id 1210 + name 1211 + } 1212 + } 1213 + `; 1214 + 1215 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 1216 + merge([ 1217 + pipe( 1218 + ops$, 1219 + filter(() => false) 1220 + ) as any, 1221 + res$, 1222 + ]); 1223 + 1224 + pipe( 1225 + cacheExchange()({ forward, client })(ops$), 1226 + tap(result => { 1227 + if (result.operation.operationName === 'query') { 1228 + data = result.data; 1229 + } 1230 + }), 1231 + publish 1232 + ); 1233 + 1234 + const queryOpA = client.createRequestOperation('query', { key: 1, query }); 1235 + const mutationOp = client.createRequestOperation('mutation', { 1236 + key: 2, 1237 + query: mutation, 1238 + }); 1239 + const queryOpB = client.createRequestOperation('query', { key: 3, query }); 1240 + 1241 + expect(data).toBe(undefined); 1242 + 1243 + nextOp(queryOpA); 1244 + nextOp(mutationOp); 1245 + nextOp(queryOpB); 1246 + 1247 + nextRes({ 1248 + operation: queryOpA, 1249 + data: { 1250 + __typename: 'Query', 1251 + node: { 1252 + __typename: 'Node', 1253 + id: 'node', 1254 + name: 'query a', 1255 + }, 998 1256 }, 999 - { 1000 - __typename: 'Todo', 1001 - author: null, 1002 - complete: null, 1003 - id: '456', 1004 - text: 'Teach', 1257 + }); 1258 + 1259 + expect(data).toHaveProperty('node.name', 'query a'); 1260 + 1261 + nextRes({ 1262 + operation: mutationOp, 1263 + data: { 1264 + __typename: 'Mutation', 1265 + node: { 1266 + __typename: 'Node', 1267 + id: 'node', 1268 + name: 'mutation', 1269 + }, 1005 1270 }, 1006 - ], 1271 + }); 1272 + 1273 + expect(reexec).toHaveBeenCalledTimes(1); 1274 + expect(data).toHaveProperty('node.name', 'mutation'); 1275 + 1276 + nextRes({ 1277 + operation: queryOpB, 1278 + data: { 1279 + __typename: 'Query', 1280 + node: { 1281 + __typename: 'Node', 1282 + id: 'node', 1283 + name: 'query b', 1284 + }, 1285 + }, 1286 + }); 1287 + 1288 + expect(reexec).toHaveBeenCalledTimes(2); 1289 + expect(data).toHaveProperty('node.name', 'query b'); 1007 1290 }); 1008 1291 1009 - expect(result.mock.calls[1][0]).toHaveProperty( 1010 - 'operation.context.meta.cacheOutcome', 1011 - 'partial' 1012 - ); 1013 - }); 1292 + it('applies optimistic updates on top of commutative queries', () => { 1293 + let data: any; 1294 + const client = createClient({ url: 'http://0.0.0.0' }); 1295 + const { source: ops$, next: nextOp } = makeSubject<Operation>(); 1296 + const { source: res$, next: nextRes } = makeSubject<OperationResult>(); 1014 1297 1015 - it('applies results that come in out-of-order commutatively and consistently', () => { 1016 - jest.useFakeTimers(); 1298 + jest.spyOn(client, 'reexecuteOperation').mockImplementation(nextOp); 1017 1299 1018 - let data: any; 1300 + const query = gql` 1301 + { 1302 + node { 1303 + id 1304 + name 1305 + } 1306 + } 1307 + `; 1019 1308 1020 - const client = createClient({ 1021 - url: 'http://0.0.0.0', 1022 - requestPolicy: 'cache-and-network', 1023 - }); 1024 - const { source: ops$, next: next } = makeSubject<Operation>(); 1025 - const query = gql` 1026 - { 1027 - index 1028 - } 1029 - `; 1309 + const mutation = gql` 1310 + mutation { 1311 + node { 1312 + id 1313 + name 1314 + optimistic 1315 + } 1316 + } 1317 + `; 1030 1318 1031 - const result = (operation: Operation): Source<OperationResult> => 1319 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 1320 + merge([ 1321 + pipe( 1322 + ops$, 1323 + filter(() => false) 1324 + ) as any, 1325 + res$, 1326 + ]); 1327 + 1328 + const optimistic = { 1329 + node: () => ({ 1330 + __typename: 'Node', 1331 + id: 'node', 1332 + name: 'optimistic', 1333 + }), 1334 + }; 1335 + 1032 1336 pipe( 1033 - fromValue({ 1034 - operation, 1035 - data: { 1036 - __typename: 'Query', 1037 - index: operation.key, 1038 - }, 1337 + cacheExchange({ optimistic })({ forward, client })(ops$), 1338 + tap(result => { 1339 + if (result.operation.operationName === 'query') { 1340 + data = result.data; 1341 + } 1039 1342 }), 1040 - delay(operation.key === 2 ? 5 : operation.key * 10) 1343 + publish 1041 1344 ); 1042 1345 1043 - const output = jest.fn(result => { 1044 - data = result.data; 1346 + const queryOp = client.createRequestOperation('query', { key: 1, query }); 1347 + const mutationOp = client.createRequestOperation('mutation', { 1348 + key: 2, 1349 + query: mutation, 1350 + }); 1351 + 1352 + expect(data).toBe(undefined); 1353 + 1354 + nextOp(queryOp); 1355 + nextOp(mutationOp); 1356 + 1357 + nextRes({ 1358 + operation: queryOp, 1359 + data: { 1360 + __typename: 'Query', 1361 + node: { 1362 + __typename: 'Node', 1363 + id: 'node', 1364 + name: 'query a', 1365 + }, 1366 + }, 1367 + }); 1368 + 1369 + expect(data).toHaveProperty('node.name', 'optimistic'); 1370 + 1371 + nextRes({ 1372 + operation: mutationOp, 1373 + data: { 1374 + __typename: 'Query', 1375 + node: { 1376 + __typename: 'Node', 1377 + id: 'node', 1378 + name: 'mutation', 1379 + }, 1380 + }, 1381 + }); 1382 + 1383 + expect(data).toHaveProperty('node.name', 'mutation'); 1045 1384 }); 1046 1385 1047 - const forward = (ops$: Source<Operation>): Source<OperationResult> => 1386 + it('allows subscription results to be commutative when necessary', () => { 1387 + let data: any; 1388 + const client = createClient({ url: 'http://0.0.0.0' }); 1389 + const { source: ops$, next: nextOp } = makeSubject<Operation>(); 1390 + const { source: res$, next: nextRes } = makeSubject<OperationResult>(); 1391 + 1392 + jest.spyOn(client, 'reexecuteOperation').mockImplementation(nextOp); 1393 + 1394 + const query = gql` 1395 + { 1396 + node { 1397 + id 1398 + name 1399 + } 1400 + } 1401 + `; 1402 + 1403 + const subscription = gql` 1404 + subscription { 1405 + node { 1406 + id 1407 + name 1408 + } 1409 + } 1410 + `; 1411 + 1412 + const forward = (ops$: Source<Operation>): Source<OperationResult> => 1413 + merge([ 1414 + pipe( 1415 + ops$, 1416 + filter(() => false) 1417 + ) as any, 1418 + res$, 1419 + ]); 1420 + 1048 1421 pipe( 1049 - ops$, 1050 - filter(op => op.operationName !== 'teardown'), 1051 - mergeMap(result) 1422 + cacheExchange()({ forward, client })(ops$), 1423 + tap(result => { 1424 + if (result.operation.operationName === 'query') { 1425 + data = result.data; 1426 + } 1427 + }), 1428 + publish 1052 1429 ); 1053 1430 1054 - pipe(cacheExchange()({ forward, client })(ops$), tap(output), publish); 1431 + const queryOpA = client.createRequestOperation('query', { key: 1, query }); 1432 + const subscriptionOp = client.createRequestOperation('subscription', { 1433 + key: 3, 1434 + query: subscription, 1435 + }); 1055 1436 1056 - next(client.createRequestOperation('query', { key: 1, query })); 1057 - next(client.createRequestOperation('query', { key: 2, query })); 1437 + nextOp(queryOpA); 1438 + // Force commutative layers to be created: 1439 + nextOp(client.createRequestOperation('query', { key: 2, query })); 1440 + nextOp(subscriptionOp); 1058 1441 1059 - // This shouldn't have any effect: 1060 - next(client.createRequestOperation('teardown', { key: 2, query })); 1442 + nextRes({ 1443 + operation: queryOpA, 1444 + data: { 1445 + __typename: 'Query', 1446 + node: { 1447 + __typename: 'Node', 1448 + id: 'node', 1449 + name: 'query a', 1450 + }, 1451 + }, 1452 + }); 1061 1453 1062 - next(client.createRequestOperation('query', { key: 3, query })); 1063 - 1064 - jest.advanceTimersByTime(5); 1065 - expect(output).toHaveBeenCalledTimes(1); 1066 - expect(data.index).toBe(2); 1454 + nextRes({ 1455 + operation: subscriptionOp, 1456 + data: { 1457 + node: { 1458 + __typename: 'Node', 1459 + id: 'node', 1460 + name: 'subscription', 1461 + }, 1462 + }, 1463 + }); 1067 1464 1068 - jest.advanceTimersByTime(10); 1069 - expect(output).toHaveBeenCalledTimes(2); 1070 - expect(data.index).toBe(2); 1071 - 1072 - jest.advanceTimersByTime(30); 1073 - expect(output).toHaveBeenCalledTimes(3); 1074 - expect(data.index).toBe(3); 1465 + expect(data).toHaveProperty('node.name', 'subscription'); 1466 + }); 1075 1467 });
+13 -5
exchanges/graphcache/src/cacheExchange.ts
··· 29 29 import { query, write, writeOptimistic } from './operations'; 30 30 import { hydrateData } from './store/data'; 31 31 import { makeDict } from './helpers/dict'; 32 - import { Store, noopDataState, clearLayer, reserveLayer } from './store'; 32 + import { Store, noopDataState, reserveLayer } from './store'; 33 33 34 34 import { 35 35 UpdatesConfig, ··· 77 77 // Returns whether an operation is a mutation 78 78 const isMutationOperation = (op: Operation): boolean => 79 79 op.operationName === 'mutation'; 80 + 81 + // Returns whether an operation is a subscription 82 + const isSubscriptionOperation = (op: Operation): boolean => 83 + op.operationName === 'subscription'; 80 84 81 85 // Returns whether an operation can potentially be read from cache 82 86 const isCacheableQuery = (op: Operation): boolean => { ··· 171 175 172 176 // This registers queries with the data layer to ensure commutativity 173 177 const prepareCacheForResult = (operation: Operation) => { 174 - if (operation.operationName === 'query') { 178 + if (isQueryOperation(operation)) { 175 179 reserveLayer(store.data, operation.key); 176 180 } else if (operation.operationName === 'teardown') { 177 181 noopDataState(store.data, operation.key); ··· 180 184 181 185 // This executes an optimistic update for mutations and registers it if necessary 182 186 const optimisticUpdate = (operation: Operation) => { 187 + const { key } = operation; 183 188 if (isOptimisticMutation(operation)) { 184 - const { key } = operation; 185 189 const { dependencies } = writeOptimistic(store, operation, key); 186 190 if (dependencies.size !== 0) { 187 191 optimisticKeysToDependencies.set(key, dependencies); ··· 189 193 collectPendingOperations(pendingOperations, dependencies); 190 194 executePendingOperations(operation, pendingOperations); 191 195 } 196 + } else { 197 + noopDataState(store.data, key, true); 192 198 } 193 199 }; 194 200 ··· 244 250 const { key } = operation; 245 251 const pendingOperations = new Set<number>(); 246 252 247 - if (!isQuery) { 253 + if (isMutationOperation(operation)) { 248 254 collectPendingOperations( 249 255 pendingOperations, 250 256 optimisticKeysToDependencies.get(key) 251 257 ); 252 258 optimisticKeysToDependencies.delete(key); 253 - clearLayer(store.data, key); 259 + } else if (isSubscriptionOperation(operation)) { 260 + // If we're writing a subscription, we ad-hoc reserve a layer 261 + reserveLayer(store.data, operation.key); 254 262 } 255 263 256 264 let writeDependencies: Set<string> | void;
+76 -4
exchanges/graphcache/src/store/data.test.ts
··· 218 218 expect(data.optimisticOrder).toEqual([]); 219 219 }); 220 220 221 - it('creates optimistic layers that may be removed using clearLayer', () => { 221 + it('creates optimistic layers that may be removed later', () => { 222 222 InMemoryData.reserveLayer(data, 1); 223 - InMemoryData.reserveLayer(data, 2); 224 223 225 - InMemoryData.initDataState(data, 2); 224 + InMemoryData.initDataState(data, 2, true); 226 225 InMemoryData.writeRecord('Query', 'index', 2); 227 226 InMemoryData.clearDataState(); 228 227 ··· 230 229 expect(InMemoryData.readRecord('Query', 'index')).toBe(2); 231 230 232 231 // Actively clearing out layer 2 233 - InMemoryData.clearLayer(data, 2); 232 + InMemoryData.noopDataState(data, 2); 234 233 235 234 InMemoryData.initDataState(data, null); 236 235 expect(InMemoryData.readRecord('Query', 'index')).toBe(undefined); ··· 333 332 334 333 InMemoryData.noopDataState(data, 1); 335 334 expect(data.optimisticOrder).toEqual([]); 335 + }); 336 + 337 + it('respects non-reserved optimistic layers', () => { 338 + InMemoryData.reserveLayer(data, 1); 339 + 340 + InMemoryData.initDataState(data, 2, true); 341 + InMemoryData.writeRecord('Query', 'index', 2); 342 + InMemoryData.clearDataState(); 343 + 344 + InMemoryData.reserveLayer(data, 3); 345 + 346 + expect(data.optimisticOrder).toEqual([3, 2, 1]); 347 + expect([...data.commutativeKeys]).toEqual([1, 3]); 348 + 349 + InMemoryData.initDataState(data, 1); 350 + InMemoryData.writeRecord('Query', 'index', 1); 351 + InMemoryData.clearDataState(); 352 + expect(data.optimisticOrder).toEqual([3, 2]); 353 + 354 + InMemoryData.initDataState(data, null); 355 + expect(InMemoryData.readRecord('Query', 'index')).toBe(2); 356 + 357 + InMemoryData.initDataState(data, 3); 358 + InMemoryData.writeRecord('Query', 'index', 3); 359 + InMemoryData.clearDataState(); 360 + expect(data.optimisticOrder).toEqual([3, 2]); 361 + 362 + InMemoryData.initDataState(data, null); 363 + expect(InMemoryData.readRecord('Query', 'index')).toBe(3); 364 + }); 365 + 366 + it('squashes when optimistic layers are completed', () => { 367 + InMemoryData.reserveLayer(data, 1); 368 + 369 + InMemoryData.initDataState(data, 2, true); 370 + InMemoryData.writeRecord('Query', 'index', 2); 371 + InMemoryData.clearDataState(); 372 + expect(data.optimisticOrder).toEqual([2, 1]); 373 + 374 + InMemoryData.initDataState(data, 1); 375 + InMemoryData.writeRecord('Query', 'index', 1); 376 + InMemoryData.clearDataState(); 377 + expect(data.optimisticOrder).toEqual([2]); 378 + 379 + // Delete optimistic layer 380 + InMemoryData.noopDataState(data, 2); 381 + expect(data.optimisticOrder).toEqual([]); 382 + 383 + InMemoryData.initDataState(data, null); 384 + expect(InMemoryData.readRecord('Query', 'index')).toBe(1); 385 + }); 386 + 387 + it('squashes when optimistic layers are replaced with actual data', () => { 388 + InMemoryData.reserveLayer(data, 1); 389 + 390 + InMemoryData.initDataState(data, 2, true); 391 + InMemoryData.writeRecord('Query', 'index', 2); 392 + InMemoryData.clearDataState(); 393 + expect(data.optimisticOrder).toEqual([2, 1]); 394 + 395 + InMemoryData.initDataState(data, 1); 396 + InMemoryData.writeRecord('Query', 'index', 1); 397 + InMemoryData.clearDataState(); 398 + expect(data.optimisticOrder).toEqual([2]); 399 + 400 + // Convert optimistic layer to commutative layer 401 + InMemoryData.initDataState(data, 2); 402 + InMemoryData.writeRecord('Query', 'index', 2); 403 + InMemoryData.clearDataState(); 404 + expect(data.optimisticOrder).toEqual([]); 405 + 406 + InMemoryData.initDataState(data, null); 407 + expect(InMemoryData.readRecord('Query', 'index')).toBe(2); 336 408 }); 337 409 });
+41 -28
exchanges/graphcache/src/store/data.ts
··· 60 60 export const initDataState = ( 61 61 data: InMemoryData, 62 62 layerKey: number | null, 63 - forceOptimistic?: boolean 63 + isOptimistic?: boolean 64 64 ) => { 65 65 currentData = data; 66 66 currentDependencies = new Set(); ··· 71 71 if (!layerKey) { 72 72 currentOptimisticKey = null; 73 73 } else if ( 74 - forceOptimistic || 75 - (data.commutativeKeys.size > 1 && data.commutativeKeys.has(layerKey)) 74 + isOptimistic || 75 + (data.optimisticOrder.length > 1 && 76 + data.optimisticOrder.indexOf(layerKey) > -1) 76 77 ) { 78 + // If this operation isn't optimistic and we see it for the first time, 79 + // then it must've been optimistic in the past, so we can proactively 80 + // clear the optimistic data before writing 81 + if (!isOptimistic && !data.commutativeKeys.has(layerKey)) { 82 + clearLayer(data, layerKey); 83 + data.commutativeKeys.add(layerKey); 84 + } 77 85 // An optimistic update of a mutation may force an optimistic layer, 78 86 // or this Query update may be applied optimistically since it's part 79 87 // of a commutate chain ··· 83 91 // Otherwise we don't create an optimistic layer and clear the 84 92 // operation's one if it already exists 85 93 currentOptimisticKey = null; 86 - clearLayer(data, layerKey); 94 + deleteLayer(data, layerKey); 87 95 } 88 96 }; 89 97 90 98 /** Reset the data state after read/write is complete */ 91 99 export const clearDataState = () => { 92 100 const data = currentData!; 93 - const optimisticKey = currentOptimisticKey; 101 + const layerKey = currentOptimisticKey; 94 102 currentOptimisticKey = null; 95 103 96 104 // Determine whether the current operation has been a commutative layer 97 - if (optimisticKey && data.commutativeKeys.has(optimisticKey)) { 105 + if (layerKey && data.optimisticOrder.indexOf(layerKey) > -1) { 98 106 // Find the lowest index of the commutative layers 99 107 // The first part of `optimisticOrder` are the non-commutative layers 100 108 const commutativeIndex = ··· 103 111 // Squash all layers in reverse order (low priority upwards) that have 104 112 // been written already 105 113 let i = data.optimisticOrder.length; 106 - while (--i >= commutativeIndex) { 107 - if (data.refLock[data.optimisticOrder[i]]) { 108 - squashLayer(data.optimisticOrder[i]); 109 - } else { 110 - break; 111 - } 114 + while ( 115 + --i >= commutativeIndex && 116 + data.refLock[data.optimisticOrder[i]] && 117 + data.commutativeKeys.has(data.optimisticOrder[i]) 118 + ) { 119 + squashLayer(data.optimisticOrder[i]); 112 120 } 113 121 } 114 122 ··· 136 144 }; 137 145 138 146 /** Initialises then resets the data state, which may squash this layer if necessary */ 139 - export const noopDataState = (data: InMemoryData, layerKey: number | null) => { 140 - initDataState(data, layerKey); 147 + export const noopDataState = ( 148 + data: InMemoryData, 149 + layerKey: number | null, 150 + isOptimistic?: boolean 151 + ) => { 152 + initDataState(data, layerKey, isOptimistic); 141 153 clearDataState(); 142 154 }; 143 155 ··· 309 321 const rc = data.refCount[entityKey] || 0; 310 322 if (rc <= 0) { 311 323 // Each optimistic layer may also still contain some references to marked entities 312 - for (const optimisticKey in data.refLock) { 313 - const refCount = data.refLock[optimisticKey]; 324 + for (const layerKey in data.refLock) { 325 + const refCount = data.refLock[layerKey]; 314 326 const locks = refCount[entityKey] || 0; 315 327 // If the optimistic layer has any references to the entity, don't GC it, 316 328 // otherwise delete the reference count from the optimistic layer ··· 455 467 if (!data.commutativeKeys.has(layerKey)) { 456 468 // The new layer needs to be reserved in front of all other commutative 457 469 // keys but after all non-commutative keys (which are added by `forceUpdate`) 458 - data.optimisticOrder.splice( 459 - data.optimisticOrder.length - data.commutativeKeys.size, 460 - 0, 461 - layerKey 462 - ); 470 + data.optimisticOrder.unshift(layerKey); 463 471 data.commutativeKeys.add(layerKey); 464 472 } 465 473 }; ··· 477 485 } 478 486 }; 479 487 480 - /** Removes an optimistic layer of links and records */ 488 + /** Clears all links and records of an optimistic layer */ 481 489 export const clearLayer = (data: InMemoryData, layerKey: number) => { 490 + if (data.refLock[layerKey]) { 491 + delete data.refLock[layerKey]; 492 + delete data.records.optimistic[layerKey]; 493 + delete data.links.optimistic[layerKey]; 494 + } 495 + }; 496 + 497 + /** Deletes links and records of an optimistic layer, and the layer itself */ 498 + const deleteLayer = (data: InMemoryData, layerKey: number) => { 482 499 const index = data.optimisticOrder.indexOf(layerKey); 483 500 if (index > -1) { 484 501 data.optimisticOrder.splice(index, 1); 485 502 data.commutativeKeys.delete(layerKey); 486 503 } 487 504 488 - if (data.refLock[layerKey]) { 489 - delete data.refLock[layerKey]; 490 - delete data.records.optimistic[layerKey]; 491 - delete data.links.optimistic[layerKey]; 492 - } 505 + clearLayer(data, layerKey); 493 506 }; 494 507 495 508 /** Merges an optimistic layer of links and records into the base data */ ··· 515 528 } 516 529 517 530 currentDependencies = prevDependencies; 518 - clearLayer(currentData!, layerKey); 531 + deleteLayer(currentData!, layerKey); 519 532 }; 520 533 521 534 /** Return an array of FieldInfo (info on all the fields and their arguments) for a given entity */
+1 -1
scripts/rollup/settings.js
··· 47 47 export const hasPreact = externalModules.includes('preact'); 48 48 export const hasSvelte = externalModules.includes('svelte'); 49 49 export const mayReexport = hasReact || hasPreact || hasSvelte; 50 - export const isCI = !!process.env.CI; 50 + export const isCI = !!process.env.CIRCLECI;