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

persistant layer for graphcache #137

Merged
merged 5 commits into from
Dec 14, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
47 changes: 42 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';
import { makeDict, Store, clearOptimistic } from './store';
Expand All @@ -18,6 +33,7 @@ import {
ResolverConfig,
OptimisticMutationConfig,
KeyingConfig,
StorageAdapter,
} from './types';

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

export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
Expand All @@ -103,6 +120,13 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
opts.keys
);

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s going on here with any?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typescript being weird.... It's saying .storage is possibly undefined....

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be because storage isn’t reassigned. Maybe we can try opts.storage!?

Copy link
Author

@JoviDeCroock JoviDeCroock Dec 14, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works.... opts!.storage!

EDIT: moving to a var works better

});
}

const optimisticKeys = new Set();
const ops: OperationMap = new Map();
const deps: DependentOperations = makeDict();
Expand Down Expand Up @@ -243,16 +267,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 @@ -302,7 +339,7 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
forward(
merge([
pipe(
sharedOps$,
inputOps$,
filter(op => !isCacheableQuery(op))
),
cacheOps$,
Expand Down
12 changes: 12 additions & 0 deletions src/store/__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,
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
}
`;
44 changes: 39 additions & 5 deletions src/store/data.ts
Original file line number Diff line number Diff line change
@@ -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<T> = Record<string, T>;
Expand Down Expand Up @@ -28,6 +34,8 @@ let currentDependencies: null | Set<string> = null;
let currentOptimisticKey: null | number = null;

export const makeDict = (): any => Object.create(null);
export const storage: { current: StorageAdapter | null } = { current: null };
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
let persistanceBatch: SerializedEntries = makeDict();
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved

const makeNodeMap = <T>(): NodeMap<T> => ({
optimistic: makeDict(),
Expand All @@ -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 (storage.current) {
defer(() => {
(storage.current as StorageAdapter).write(persistanceBatch);
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
persistanceBatch = makeDict();
});
}

currentData = null;
Expand Down Expand Up @@ -260,12 +278,18 @@ export const gc = (data: InMemoryData) => {
delete data.refCount[entityKey];
data.records.base.delete(entityKey);
data.gcBatch.delete(entityKey);
if (storage.current) {
storage.current.remove(prefixKey('r', entityKey));
}

// 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 (storage.current) {
storage.current.remove(prefixKey('l', entityKey));
}
for (const key in linkNode) {
updateRCForLink(data.gcBatch, data.refCount, linkNode[key], -1);
}
Expand Down Expand Up @@ -308,10 +332,15 @@ export const readLink = (
export const writeRecord = (
entityKey: string,
fieldKey: string,
value: EntityField
value: EntityField,
skipPersistance?: boolean
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
) => {
updateDependencies(entityKey, fieldKey);
setNode(currentData!.records, entityKey, fieldKey, value);
if (storage.current && !skipPersistance && !currentOptimisticKey) {
const key = prefixKey('r', joinKeys(entityKey, fieldKey));
persistanceBatch[key] = value;
}
};

export const hasField = (entityKey: string, fieldKey: string): boolean =>
Expand All @@ -322,7 +351,8 @@ export const hasField = (entityKey: string, fieldKey: string): boolean =>
export const writeLink = (
entityKey: string,
fieldKey: string,
link: Link | undefined
link: Link | undefined,
skipPersistance?: boolean
) => {
const data = currentData!;
// Retrieve the reference counting dict or the optimistic reference locking dict
Expand All @@ -339,6 +369,10 @@ export const writeLink = (
(data.refLock[currentOptimisticKey] = makeDict());
links = data.links.optimistic[currentOptimisticKey];
} else {
if (storage.current && !skipPersistance) {
const key = prefixKey('l', joinKeys(entityKey, fieldKey));
persistanceBatch[key] = link;
}
refCount = data.refCount;
links = data.links.base;
gcBatch = data.gcBatch;
Expand Down
3 changes: 3 additions & 0 deletions src/store/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}`;
55 changes: 54 additions & 1 deletion src/store/store.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -405,3 +405,56 @@ 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(),
remove: 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);
});
});
19 changes: 19 additions & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
UpdatesConfig,
OptimisticMutationConfig,
KeyingConfig,
StorageAdapter,
} from '../types';

import { read, readFragment } from '../operations/query';
Expand Down Expand Up @@ -186,4 +187,22 @@ export class Store implements Cache {
): void {
writeFragment(this, dataFragment, data, variables);
}

hydrateData(data: object, adapter: StorageAdapter) {
InMemoryData.storage.current = adapter;
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], true);
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
break;
case 114:
InMemoryData.writeRecord(entityKey, fieldKey, data[key], true);
break;
}
}
InMemoryData.clearDataState();
}
}
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ export interface KeyingConfig {
[typename: string]: KeyGenerator;
}

export type SerializedEntry = EntityField | Connection[] | Link;

export interface SerializedEntries {
[key: string]: SerializedEntry;
}

export interface StorageAdapter {
read(): Promise<SerializedEntries>;
remove(key: string): Promise<void>;
write(data: SerializedEntries): Promise<void>;
}

export type ErrorCode =
| 1
| 2
Expand Down