From 023120cd15c2972a18bfa754315bb58ec1ff9f23 Mon Sep 17 00:00:00 2001 From: jovi De Croock Date: Fri, 13 Dec 2019 11:08:08 +0000 Subject: [PATCH 1/4] adapt persistent cache to the new gc-implementation --- src/cacheExchange.ts | 48 +++++++++++++++++++++++++++++++++++---- src/helpers/data.ts | 2 +- src/helpers/keys.ts | 3 +++ src/store.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++- src/store.ts | 39 +++++++++++++++++++++++++++++++- src/types.ts | 11 +++++++++ 6 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/cacheExchange.ts b/src/cacheExchange.ts index d79fc85..56a5bca 100644 --- a/src/cacheExchange.ts +++ b/src/cacheExchange.ts @@ -8,7 +8,22 @@ import { } from 'urql/core'; import { IntrospectionQuery } from 'graphql'; -import { filter, map, merge, pipe, share, tap } from 'wonka'; +import { + filter, + map, + merge, + pipe, + share, + tap, + fromPromise, + fromArray, + buffer, + take, + mergeMap, + concat, + empty, + Source, +} from 'wonka'; import { query, write, writeOptimistic } from './operations'; import { SchemaPredicates } from './ast/schemaPredicates'; import { makeDict } from './helpers/dict'; @@ -19,6 +34,7 @@ import { ResolverConfig, OptimisticMutationConfig, KeyingConfig, + StorageAdapter, } from './types'; type OperationResultWithMeta = OperationResult & { @@ -88,6 +104,7 @@ export interface CacheExchangeOpts { optimistic?: OptimisticMutationConfig; keys?: KeyingConfig; schema?: IntrospectionQuery; + storage?: StorageAdapter; } export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ @@ -104,6 +121,14 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ opts.keys ); + let hydration: void | Promise; + if (opts.storage) { + const storage = opts.storage; + hydration = opts.storage.read().then(data => { + store.hydrateData(data, storage); + }); + } + const optimisticKeys = new Set(); const ops: OperationMap = new Map(); const deps: DependentOperations = makeDict(); @@ -244,8 +269,21 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ }; return ops$ => { - const sharedOps$ = pipe( - ops$, + const sharedOps$ = pipe(ops$, share); + + // Buffer operations while waiting on hydration to finish + // If no hydration takes place we replace this stream with an empty one + const bufferedOps$ = hydration + ? pipe( + sharedOps$, + buffer(fromPromise(hydration)), + take(1), + mergeMap(fromArray) + ) + : (empty as Source); + + const inputOps$ = pipe( + concat([bufferedOps$, sharedOps$]), map(addTypeNames), tap(optimisticUpdate), share @@ -253,7 +291,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ // Filter by operations that are cacheable and attempt to query them from the cache const cache$ = pipe( - sharedOps$, + inputOps$, filter(op => isCacheableQuery(op)), map(operationResultFromCache), share @@ -303,7 +341,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ forward( merge([ pipe( - sharedOps$, + inputOps$, filter(op => !isCacheableQuery(op)) ), cacheOps$, diff --git a/src/helpers/data.ts b/src/helpers/data.ts index d7837dc..1ac41f9 100644 --- a/src/helpers/data.ts +++ b/src/helpers/data.ts @@ -19,7 +19,7 @@ export interface InMemoryData { links: NodeMap; } -let currentOptimisticKey: null | number = null; +export let currentOptimisticKey: null | number = null; const makeDict = (): Dict => Object.create(null); diff --git a/src/helpers/keys.ts b/src/helpers/keys.ts index 7de2883..d65976b 100644 --- a/src/helpers/keys.ts +++ b/src/helpers/keys.ts @@ -23,3 +23,6 @@ export const fieldInfoOfKey = (fieldKey: string): FieldInfo => { export const joinKeys = (parentKey: string, key: string) => `${parentKey}.${key}`; + +/** Prefix key with its owner type Link / Record */ +export const prefixKey = (owner: 'l' | 'r', key: string) => `${owner}|${key}`; diff --git a/src/store.test.ts b/src/store.test.ts index d4f3b24..8fb6d55 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -7,7 +7,7 @@ import { getCurrentDependencies, } from './store'; -import { Data } from './types'; +import { Data, StorageAdapter } from './types'; import { query } from './operations/query'; import { write, writeOptimistic } from './operations/write'; @@ -402,3 +402,55 @@ describe('Store with OptimisticMutationConfig', () => { }); }); }); + +describe('Store with storage', () => { + const expectedData = { + __typename: 'Query', + appointment: { + __typename: 'Appointment', + id: '1', + info: 'urql meeting', + }, + }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('should be able to store and rehydrate data', () => { + const storage: StorageAdapter = { read: jest.fn(), write: jest.fn() }; + let store = new Store(); + + store.hydrateData(Object.create(null), storage); + + initStoreState(store, 0); + + write( + store, + { + query: Appointment, + variables: { id: '1' }, + }, + expectedData + ); + + clearStoreState(); + expect(storage.write).not.toHaveBeenCalled(); + + jest.runAllTimers(); + expect(storage.write).toHaveBeenCalled(); + + const serialisedStore = (storage.write as any).mock.calls[0][0]; + expect(serialisedStore).toMatchSnapshot(); + + store = new Store(); + store.hydrateData(serialisedStore, storage); + + const { data } = query(store, { + query: Appointment, + variables: { id: '1' }, + }); + + expect(data).toEqual(expectedData); + }); +}); diff --git a/src/store.ts b/src/store.ts index 464c557..af75693 100644 --- a/src/store.ts +++ b/src/store.ts @@ -14,11 +14,14 @@ import { UpdatesConfig, OptimisticMutationConfig, KeyingConfig, + StorageAdapter, + SerializedEntry, + SerializedEntries, } from './types'; import * as InMemoryData from './helpers/data'; import { invariant, currentDebugStack } from './helpers/help'; -import { defer, keyOfField } from './helpers'; +import { defer, keyOfField, joinKeys, prefixKey } from './helpers'; import { read, readFragment } from './operations/query'; import { writeFragment, startWrite } from './operations/write'; import { invalidate } from './operations/invalidate'; @@ -26,12 +29,14 @@ import { SchemaPredicates } from './ast/schemaPredicates'; let currentStore: null | Store = null; let currentDependencies: null | Set = null; +let currentBatch: null | SerializedEntries = null; // Initialise a store run by resetting its internal state export const initStoreState = (store: Store, optimisticKey: null | number) => { InMemoryData.setCurrentOptimisticKey(optimisticKey); currentStore = store; currentDependencies = new Set(); + currentBatch = null; if (process.env.NODE_ENV !== 'production') { currentDebugStack.length = 0; @@ -44,6 +49,13 @@ export const clearStoreState = () => { defer((currentStore as Store).gc); } + if (currentBatch !== null) { + const storage = (currentStore as Store).storage as StorageAdapter; + const batch = currentBatch; + defer(() => storage.write(batch)); + currentBatch = null; + } + InMemoryData.setCurrentOptimisticKey(null); currentStore = null; currentDependencies = null; @@ -80,6 +92,7 @@ export class Store implements Cache { optimisticMutations: OptimisticMutationConfig; keys: KeyingConfig; schemaPredicates?: SchemaPredicates; + storage?: StorageAdapter; rootFields: { query: string; mutation: string; subscription: string }; rootNames: { [name: string]: RootField }; @@ -173,6 +186,13 @@ export class Store implements Cache { return key ? `${typename}:${key}` : null; } + writeToBatch(owner: 'l' | 'r', key: string, value: SerializedEntry) { + if (this.storage && InMemoryData.currentOptimisticKey) { + if (currentBatch === null) currentBatch = Object.create(null); + (currentBatch as SerializedEntries)[prefixKey(owner, key)] = value; + } + } + clearOptimistic(optimisticKey: number) { InMemoryData.clearOptimistic(this.data, optimisticKey); } @@ -182,6 +202,7 @@ export class Store implements Cache { } writeRecord(field: EntityField, entityKey: string, fieldKey: string) { + this.writeToBatch('r', joinKeys(entityKey, fieldKey), field); InMemoryData.writeRecord(this.data, entityKey, fieldKey, field); } @@ -211,6 +232,7 @@ export class Store implements Cache { } writeLink(link: undefined | Link, entityKey: string, fieldKey: string) { + this.writeToBatch('l', joinKeys(entityKey, fieldKey), link); return InMemoryData.writeLink(this.data, entityKey, fieldKey, link); } @@ -286,4 +308,19 @@ export class Store implements Cache { ): void { writeFragment(this, dataFragment, data, variables); } + + hydrateData(data: object, storage: StorageAdapter) { + for (const key in data) { + switch (key.charCodeAt(0)) { + case 108: + InMemoryData.writeLink(this.data, key.slice(2), '', data[key]); + break; + case 114: + InMemoryData.writeRecord(this.data, key.slice(2), '', data[key]); + break; + } + } + + this.storage = storage; + } } diff --git a/src/types.ts b/src/types.ts index bbea6b2..1a48314 100644 --- a/src/types.ts +++ b/src/types.ts @@ -172,6 +172,17 @@ export interface KeyingConfig { [typename: string]: KeyGenerator; } +export type SerializedEntry = EntityField | Connection[] | Link; + +export interface SerializedEntries { + [key: string]: SerializedEntry; +} + +export interface StorageAdapter { + read(): Promise; + write(data: SerializedEntries): Promise; +} + export type ErrorCode = | 1 | 2 From 103a91496e423fdeadb0157e2c041d6b41df135c Mon Sep 17 00:00:00 2001 From: jovi De Croock Date: Fri, 13 Dec 2019 11:20:52 +0000 Subject: [PATCH 2/4] test the store methods --- src/__snapshots__/store.test.ts.snap | 12 ++++++++++++ src/store.test.ts | 3 --- src/store.ts | 8 ++++---- 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 src/__snapshots__/store.test.ts.snap diff --git a/src/__snapshots__/store.test.ts.snap b/src/__snapshots__/store.test.ts.snap new file mode 100644 index 0000000..3ab78ac --- /dev/null +++ b/src/__snapshots__/store.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store with storage should be able to store and rehydrate data 1`] = ` +Object { + "l|Query.appointment({\\"id\\":\\"1\\"})": "Appointment:1", + "r|Appointment:1.__typename": "Appointment", + "r|Appointment:1.id": "1", + "r|Appointment:1.info": "urql meeting", + "r|Query.__typename": "Query", + "r|Query.appointment({\\"id\\":\\"1\\"})": undefined, +} +`; diff --git a/src/store.test.ts b/src/store.test.ts index 8fb6d55..759c5bd 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -423,8 +423,6 @@ describe('Store with storage', () => { store.hydrateData(Object.create(null), storage); - initStoreState(store, 0); - write( store, { @@ -434,7 +432,6 @@ describe('Store with storage', () => { expectedData ); - clearStoreState(); expect(storage.write).not.toHaveBeenCalled(); jest.runAllTimers(); diff --git a/src/store.ts b/src/store.ts index af75693..bdef1c1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -187,7 +187,7 @@ export class Store implements Cache { } writeToBatch(owner: 'l' | 'r', key: string, value: SerializedEntry) { - if (this.storage && InMemoryData.currentOptimisticKey) { + if (this.storage && !InMemoryData.currentOptimisticKey) { if (currentBatch === null) currentBatch = Object.create(null); (currentBatch as SerializedEntries)[prefixKey(owner, key)] = value; } @@ -311,16 +311,16 @@ export class Store implements Cache { hydrateData(data: object, storage: StorageAdapter) { for (const key in data) { + const [entityKey, fieldKey] = key.slice(2).split('.'); switch (key.charCodeAt(0)) { case 108: - InMemoryData.writeLink(this.data, key.slice(2), '', data[key]); + InMemoryData.writeLink(this.data, entityKey, fieldKey, data[key]); break; case 114: - InMemoryData.writeRecord(this.data, key.slice(2), '', data[key]); + InMemoryData.writeRecord(this.data, entityKey, fieldKey, data[key]); break; } } - this.storage = storage; } } From 1ae466b6771ea5fcd6a34d89bfeee77a2e64cdee Mon Sep 17 00:00:00 2001 From: jovi De Croock Date: Fri, 13 Dec 2019 12:59:14 +0000 Subject: [PATCH 3/4] refactor key splitting --- src/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/store.ts b/src/store.ts index bdef1c1..0faff09 100644 --- a/src/store.ts +++ b/src/store.ts @@ -311,7 +311,9 @@ export class Store implements Cache { hydrateData(data: object, storage: StorageAdapter) { for (const key in data) { - const [entityKey, fieldKey] = key.slice(2).split('.'); + const baseKey = key.slice(2); + const entityKey = baseKey.slice(0, baseKey.indexOf('.')); + const fieldKey = baseKey.slice(baseKey.indexOf('.') + 1); switch (key.charCodeAt(0)) { case 108: InMemoryData.writeLink(this.data, entityKey, fieldKey, data[key]); From 5e980283941ea0b009d745d248b63a350e23210c Mon Sep 17 00:00:00 2001 From: jovi De Croock Date: Fri, 13 Dec 2019 13:21:58 +0000 Subject: [PATCH 4/4] resolve comment about opts.storage being confusing --- src/cacheExchange.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cacheExchange.ts b/src/cacheExchange.ts index 56a5bca..25da29c 100644 --- a/src/cacheExchange.ts +++ b/src/cacheExchange.ts @@ -124,7 +124,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ let hydration: void | Promise; if (opts.storage) { const storage = opts.storage; - hydration = opts.storage.read().then(data => { + hydration = storage.read().then(data => { store.hydrateData(data, storage); }); }