diff --git a/src/cacheExchange.ts b/src/cacheExchange.ts index 2432be7..d012074 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'; import { makeDict, Store, clearOptimistic } from './store'; @@ -18,6 +33,7 @@ import { ResolverConfig, OptimisticMutationConfig, KeyingConfig, + StorageAdapter, } from './types'; type OperationResultWithMeta = OperationResult & { @@ -87,6 +103,7 @@ export interface CacheExchangeOpts { optimistic?: OptimisticMutationConfig; keys?: KeyingConfig; schema?: IntrospectionQuery; + storage?: StorageAdapter; } export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ @@ -103,6 +120,14 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ opts.keys ); + let hydration: void | Promise; + if (opts.storage) { + const storage = opts.storage; + hydration = storage.read().then(data => { + store.hydrateData(data, storage); + }); + } + const optimisticKeys = new Set(); const ops: OperationMap = new Map(); const deps: DependentOperations = makeDict(); @@ -243,8 +268,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 @@ -252,7 +290,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 @@ -302,7 +340,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({ forward( merge([ pipe( - sharedOps$, + inputOps$, filter(op => !isCacheableQuery(op)) ), cacheOps$, diff --git a/src/store/__snapshots__/store.test.ts.snap b/src/store/__snapshots__/store.test.ts.snap new file mode 100644 index 0000000..3ab78ac --- /dev/null +++ b/src/store/__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/data.ts b/src/store/data.ts index 2001ebb..431a1da 100644 --- a/src/store/data.ts +++ b/src/store/data.ts @@ -1,6 +1,12 @@ -import { Link, EntityField, FieldInfo } from '../types'; +import { + Link, + EntityField, + FieldInfo, + StorageAdapter, + SerializedEntries, +} from '../types'; import { invariant, currentDebugStack } from '../helpers/help'; -import { fieldInfoOfKey, joinKeys } from './keys'; +import { fieldInfoOfKey, joinKeys, prefixKey } from './keys'; import { defer } from './timing'; type Dict = Record; @@ -21,6 +27,7 @@ export interface InMemoryData { refLock: OptimisticMap>; records: NodeMap; links: NodeMap; + storage: StorageAdapter | null; } let currentData: null | InMemoryData = null; @@ -28,6 +35,7 @@ let currentDependencies: null | Set = null; let currentOptimisticKey: null | number = null; export const makeDict = (): any => Object.create(null); +let persistenceBatch: SerializedEntries = makeDict(); const makeNodeMap = (): NodeMap => ({ optimistic: makeDict(), @@ -51,9 +59,19 @@ export const initDataState = ( /** Reset the data state after read/write is complete */ export const clearDataState = () => { const data = currentData!; + if (!data.gcScheduled && data.gcBatch.size > 0) { data.gcScheduled = true; - defer(() => gc(data)); + defer(() => { + gc(data); + }); + } + + if (data.storage) { + defer(() => { + data.storage!.write(persistenceBatch); + persistenceBatch = makeDict(); + }); } currentData = null; @@ -85,6 +103,7 @@ export const make = (queryRootKey: string): InMemoryData => ({ refLock: makeDict(), links: makeNodeMap(), records: makeNodeMap(), + storage: null, }); /** Adds a node value to a NodeMap (taking optimistic values into account */ @@ -260,12 +279,18 @@ export const gc = (data: InMemoryData) => { delete data.refCount[entityKey]; data.records.base.delete(entityKey); data.gcBatch.delete(entityKey); + if (data.storage) { + persistenceBatch[prefixKey('r', entityKey)] = undefined; + } // Delete all the entity's links, but also update the reference count // for those links (which can lead to an unrolled recursive GC of the children) const linkNode = data.links.base.get(entityKey); if (linkNode !== undefined) { data.links.base.delete(entityKey); + if (data.storage) { + persistenceBatch[prefixKey('l', entityKey)] = undefined; + } for (const key in linkNode) { updateRCForLink(data.gcBatch, data.refCount, linkNode[key], -1); } @@ -312,6 +337,10 @@ export const writeRecord = ( ) => { updateDependencies(entityKey, fieldKey); setNode(currentData!.records, entityKey, fieldKey, value); + if (currentData!.storage && !currentOptimisticKey) { + const key = prefixKey('r', joinKeys(entityKey, fieldKey)); + persistenceBatch[key] = value; + } }; export const hasField = (entityKey: string, fieldKey: string): boolean => @@ -339,6 +368,10 @@ export const writeLink = ( (data.refLock[currentOptimisticKey] = makeDict()); links = data.links.optimistic[currentOptimisticKey]; } else { + if (data.storage) { + const key = prefixKey('l', joinKeys(entityKey, fieldKey)); + persistenceBatch[key] = link; + } refCount = data.refCount; links = data.links.base; gcBatch = data.gcBatch; diff --git a/src/store/keys.ts b/src/store/keys.ts index 7de2883..d65976b 100644 --- a/src/store/keys.ts +++ b/src/store/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/store.test.ts b/src/store/store.test.ts index 04b1948..6cdabda 100644 --- a/src/store/store.test.ts +++ b/src/store/store.test.ts @@ -1,6 +1,6 @@ import gql from 'graphql-tag'; -import { Data } from '../types'; +import { Data, StorageAdapter } from '../types'; import { query } from '../operations/query'; import { write, writeOptimistic } from '../operations/write'; import * as InMemoryData from './data'; @@ -405,3 +405,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); + + write( + store, + { + query: Appointment, + variables: { id: '1' }, + }, + expectedData + ); + + 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/store.ts b/src/store/store.ts index b3931a3..b230ca4 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -12,6 +12,7 @@ import { UpdatesConfig, OptimisticMutationConfig, KeyingConfig, + StorageAdapter, } from '../types'; import { read, readFragment } from '../operations/query'; @@ -186,4 +187,22 @@ export class Store implements Cache { ): void { writeFragment(this, dataFragment, data, variables); } + + hydrateData(data: object, adapter: StorageAdapter) { + InMemoryData.initDataState(this.data, 0); + for (const key in data) { + const entityKey = key.slice(2, key.indexOf('.')); + const fieldKey = key.slice(key.indexOf('.') + 1); + switch (key.charCodeAt(0)) { + case 108: + InMemoryData.writeLink(entityKey, fieldKey, data[key]); + break; + case 114: + InMemoryData.writeRecord(entityKey, fieldKey, data[key]); + break; + } + } + InMemoryData.clearDataState(); + this.data.storage = adapter; + } } 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