Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Observable client cache #341

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"dependencies": {
"@finch-graphql/browser-polyfill": "^3.1.3",
"@finch-graphql/types": "^3.1.3",
"eventemitter3": "^5.0.0",
"graphql-tag": "^2.11.0",
"uuid": "^8.3.2"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/client/FinchClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ describe('FinchClient', () => {
expect(browser.runtime.connect).toHaveBeenCalledTimes(1);
await client.query('query foo { bar }');
const timeoutPendingQuery = client.query(
'query foo { bar }',
'query bar { baz }',
{},
{ timeout: 100 },
);
const pendingQuery = client.query(
'query foo { bar }',
'query baz { qux }',
{},
{ timeout: 1000 },
);
Expand Down
133 changes: 113 additions & 20 deletions packages/client/src/client/FinchClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DocumentNode, GraphQLFormattedError } from 'graphql';
import gql from 'graphql-tag';
import { FinchCache, Listener } from './cache';
import { Awaited, FinchCachePolicy } from '@finch-graphql/types';
import { QueryCache } from './cache';
import {
FinchDefaultPortName,
FinchQueryOptions,
Expand All @@ -10,16 +11,18 @@ import { isDocumentNode } from '../utils';
import { messageCreator, queryApi } from './client';
import { connectPort } from '@finch-graphql/browser-polyfill';
import { v4 } from 'uuid';
import { FinchCacheStatus, FinchQueryObservable } from './types';

interface FinchClientOptions {
cache?: FinchCache;
cache?: QueryCache;
id?: string;
messageKey?: string;
portName?: string;
useMessages?: boolean;
messageTimeout?: number;
autoStart?: boolean;
maxPortTimeoutCount?: number;
cachePolicy?: FinchCachePolicy;
}

export enum FinchClientStatus {
Expand All @@ -35,7 +38,6 @@ export enum FinchClientStatus {
* are made and also any query caching.
*/
export class FinchClient {
private cache: FinchCache | undefined;
private id: string | undefined;
private messageKey: string | undefined;
private port: browser.runtime.Port | chrome.runtime.Port | null;
Expand All @@ -46,7 +48,13 @@ export class FinchClient {
private portTimeoutCount = 0;
private maxPortTimeoutCount = 10;
private cancellableQueries: Set<() => void> = new Set();
private subscriptions: WeakMap<
FinchQueryObservable<unknown>,
() => void
> = new WeakMap();
private cachePolicy: FinchCachePolicy;
public status = FinchClientStatus.Idle;
public cache: QueryCache = new QueryCache();

/**
*
Expand All @@ -66,14 +74,18 @@ export class FinchClient {
messageTimeout,
autoStart = true,
maxPortTimeoutCount = 10,
cachePolicy = FinchCachePolicy.CacheFirst,
}: FinchClientOptions = {}) {
this.cache = cache;
if (cache) {
this.cache = cache;
}
this.id = id;
this.messageKey = messageKey;
this.portName = portName || this.portName;
this.useMessages = useMessages ?? false;
this.messageTimeout = messageTimeout ?? this.messageTimeout;
this.maxPortTimeoutCount = maxPortTimeoutCount;
this.cachePolicy = cachePolicy;
if (autoStart) {
this.start();
}
Expand Down Expand Up @@ -251,20 +263,72 @@ export class FinchClient {
options: FinchQueryOptions = {},
) {
const documentNode = isDocumentNode(query) ? query : gql(query);
const result = await this.queryApi<Query, Variables>(
documentNode,
variables,
{
id: this.id,
messageKey: this.messageKey,
...options,
},
);
let cache = this.cache?.getCache(documentNode, variables) as
| FinchQueryObservable<Query>
| undefined;
const respondWithCache =
(options.cachePolicy ?? this.cachePolicy) === FinchCachePolicy.CacheFirst;

const snapshot = cache?.getSnapshot();

if (this.cache && result?.data) {
this.cache.setCache(documentNode, variables, result.data);
if (snapshot.loading) {
return snapshot;
}
return result;

if (!snapshot.data && !snapshot.errors && !snapshot.loading) {
cache.update({
...snapshot,
cacheStatus: FinchCacheStatus.Fresh,
loading: true,
});
}

const pendingFetch = new Promise(async resolve => {
let result: Awaited<Omit<typeof snapshot, 'cacheStatus' | 'loading'>>;
try {
result = await this.queryApi<Query, Variables>(
documentNode,
variables,
{
id: this.id,
messageKey: this.messageKey,
...options,
},
);

if (this.cache) {
this.cache.setCache(documentNode, variables, {
data: result?.data ?? snapshot.data,
errors: result?.errors,
loading: false,
cacheStatus: FinchCacheStatus.Fresh,
});
this.queryLifecycleManager(documentNode, variables);

resolve(result);
return;
}
} catch (e) {
result = {
data: snapshot?.data,
errors: [e],
};
if (this.cache) {
this.cache.setCache(documentNode, variables, {
data: snapshot.data,
errors: [e],
loading: false,
cacheStatus: FinchCacheStatus.Fresh,
});
}
}
resolve(result);
});

if (respondWithCache && snapshot.data && !snapshot.errors) {
return snapshot;
}
return pendingFetch;
}

/**
Expand Down Expand Up @@ -303,15 +367,44 @@ export class FinchClient {
* @param listener A Function that is called when the cache is updated
* @returns A function that unsubscribes from the query.
*/
subscribe<Query extends unknown>(
query: string | DocumentNode,
subscribe<Query extends DocumentNode>(
query: Query,
variables: unknown,
listener: Listener<Query>,
listener: () => void,
) {
if (!this.cache) {
return () => {};
}
const documentNode = isDocumentNode(query) ? query : gql(query);
return this.cache.subscribe<Query>(documentNode, variables, listener);
const cache = this.cache.getCache(documentNode, variables);
return cache.subscribe(listener);
}

/**
* Query lifecycle manager manages the lifecycle of a query. This is mainly used
* revalidating queries when the cache is stale.
* @param query A Document node or string to query the api
* @param variables Variables for this query
* @param observable The observable to subscribe to
*/
private queryLifecycleManager<
Query extends {} = {},
Variables extends GenericVariables = {}
>(query: DocumentNode, variables?: Variables, options?: FinchQueryOptions) {
const observable = this.cache?.getCache(
query,
variables,
) as FinchQueryObservable<Query>;
const unsubscribe = this.subscriptions.get(observable);
if (unsubscribe) {
unsubscribe();
}
const subscription = observable.subscribe(() => {
const snapshot = observable.getSnapshot();
if (snapshot.cacheStatus === FinchCacheStatus.Stale) {
this.query(query, variables, { timeout: options?.timeout });
}
});
this.subscriptions.set(observable, subscription);
}
}
39 changes: 39 additions & 0 deletions packages/client/src/client/cache/Observable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import EventEmitter from 'eventemitter3';

export class Observable<T = any> {
private value: T;
private emitter: EventEmitter;

constructor(value: T) {
this.value = value;
this.emitter = new EventEmitter();
}

/**
* The subscribe method allows for subscribing to changes in the observable value
* @param function to be called when the observable changes
* @returns a function that can be called to unsubscribe
*/
public subscribe = (fn: () => void) => {
this.emitter.on('change', fn);
return () => {
this.emitter.off('change', fn);
};
};

/**
* This method allows for the getting of a snapshot of the current value
*/
public getSnapshot = () => {
return this.value;
};

/**
* This method allows for the updating of the observable value
* @param value The new value to update the observable to
*/
public update = (value: T) => {
this.value = value;
this.emitter.emit('change');
};
}
67 changes: 29 additions & 38 deletions packages/client/src/client/cache/QueryCache.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { DocumentNode, print } from 'graphql';
import { Listener, FinchCache } from './types';

type Cache = Map<string, unknown>;

interface ListenerMap {
[key: string]: Array<Listener<unknown>>;
}
import { Observable } from './Observable';
import {
FinchCache,
FinchQueryObservable,
FinchQueryResults,
FinchCacheStatus,
} from '../types';

type Cache = Map<string, FinchQueryObservable<unknown>>;
interface QueryCacheOptions {
hydrate?: Cache;
}
Expand All @@ -18,8 +19,7 @@ interface QueryCacheOptions {
* @implements FinchCache
*/
export class QueryCache implements FinchCache {
cache: Cache;
listeners: ListenerMap;
store: Cache;

/**
* serializeQuery is a static method on the the QueryCache class that allow
Expand All @@ -39,48 +39,39 @@ export class QueryCache implements FinchCache {
* @param options.hydrate A Map with the serialized query cache.
*/
constructor(options: QueryCacheOptions = {}) {
this.cache = options.hydrate ?? new Map();
this.listeners = {};
}

subscribe<Query extends unknown>(
doc: DocumentNode,
variables: any,
listener: Listener<Query>,
) {
const key = QueryCache.serializeQuery(doc, variables);
if (typeof this.listeners[key] === 'undefined') {
this.listeners[key] = [];
}
this.listeners[key].push(listener);
return () => {
const index = this.listeners[key].indexOf(listener);
this.listeners[key] = [
...this.listeners[key].slice(0, index),
...this.listeners[key].slice(index + 1),
];
};
this.store = options.hydrate ?? new Map();
}

setCache<Query extends unknown>(
doc: DocumentNode,
variables: any,
result: Query,
result: FinchQueryResults<Query>,
) {
const key = QueryCache.serializeQuery(doc, variables);
this.setCacheKey<Query>(key, result);
}

getCache<Query extends unknown>(doc: DocumentNode, variables: any) {
const key = QueryCache.serializeQuery(doc, variables);
return this.cache.get(key) as Query | undefined;
let cache = this.store.get(key) as FinchQueryObservable<Query> | undefined;
if (!cache) {
cache = new Observable<FinchQueryResults<Query>>({
data: undefined,
errors: undefined,
loading: false,
cacheStatus: FinchCacheStatus.Unknown,
});
this.store.set(key, cache);
}
return cache;
}

private setCacheKey<Query extends unknown>(key: string, value: Query) {
const listeners = this.listeners[key] ?? [];
this.cache.set(key, value);
listeners.forEach(fn => {
fn(value);
});
private setCacheKey<Query extends unknown>(
key: string,
value: FinchQueryResults<Query>,
) {
const cache =
this.store.get(key) ?? new Observable<FinchQueryResults<Query>>(value);
cache.update(value);
}
}
2 changes: 1 addition & 1 deletion packages/client/src/client/cache/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { QueryCache } from './QueryCache';
export type { Listener, FinchCache } from './types';
export { Observable } from './Observable';
20 changes: 0 additions & 20 deletions packages/client/src/client/cache/types.ts

This file was deleted.

Loading