Skip to content
This repository has been archived by the owner on Jul 6, 2020. It is now read-only.

Persistent cache #133

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/__snapshots__/store.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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,
}
`;
48 changes: 43 additions & 5 deletions src/cacheExchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +34,7 @@ import {
ResolverConfig,
OptimisticMutationConfig,
KeyingConfig,
StorageAdapter,
} from './types';

type OperationResultWithMeta = OperationResult & {
Expand Down Expand Up @@ -88,6 +104,7 @@ export interface CacheExchangeOpts {
optimistic?: OptimisticMutationConfig;
keys?: KeyingConfig;
schema?: IntrospectionQuery;
storage?: StorageAdapter;
}

export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
Expand All @@ -104,6 +121,14 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
opts.keys
);

let hydration: void | Promise<void>;
if (opts.storage) {
const storage = opts.storage;
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
hydration = storage.read().then(data => {
store.hydrateData(data, storage);
});
}

const optimisticKeys = new Set();
const ops: OperationMap = new Map();
const deps: DependentOperations = makeDict();
Expand Down Expand Up @@ -244,16 +269,29 @@ 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<Operation>);

const inputOps$ = pipe(
concat([bufferedOps$, sharedOps$]),
map(addTypeNames),
tap(optimisticUpdate),
share
);

// 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
Expand Down Expand Up @@ -303,7 +341,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
forward(
merge([
pipe(
sharedOps$,
inputOps$,
filter(op => !isCacheableQuery(op))
),
cacheOps$,
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface InMemoryData {
links: NodeMap<Link>;
}

let currentOptimisticKey: null | number = null;
export let currentOptimisticKey: null | number = null;

const makeDict = <T>(): Dict<T> => Object.create(null);

Expand Down
3 changes: 3 additions & 0 deletions src/helpers/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
51 changes: 50 additions & 1 deletion src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -402,3 +402,52 @@ 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);
});
});
41 changes: 40 additions & 1 deletion src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,29 @@ 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';
import { SchemaPredicates } from './ast/schemaPredicates';

let currentStore: null | Store = null;
let currentDependencies: null | Set<string> = 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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -286,4 +308,21 @@ export class Store implements Cache {
): void {
writeFragment(this, dataFragment, data, variables);
}

hydrateData(data: object, storage: StorageAdapter) {
for (const key in data) {
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]);
break;
case 114:
InMemoryData.writeRecord(this.data, entityKey, fieldKey, data[key]);
break;
}
}
this.storage = storage;
}
}
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerializedEntries>;
andyrichardson marked this conversation as resolved.
Show resolved Hide resolved
write(data: SerializedEntries): Promise<void>;
}

export type ErrorCode =
| 1
| 2
Expand Down