import {Observable, Subscriber, Subscription} from 'rxjs';
import {decodeNodeId, encodeNodeId, ListId, ListParams, NodeId, NodeIdEnc, nodeTypeFromTypename} from './id';
import {QueriesService} from './queries/queries.service';
import {ListResult, queryList, queryNode} from './load';
import {PageInfo} from '../../generated/graphql-dgql';
const CACHE_FAST_DEBOUNCE_TIME_MS = 200;
const CACHE_INTERACTIVE_DEBOUNCE_TIME_MS = 500;
const CACHE_STALE_TIME_MS = 5000;
export abstract class DataQuery<I, T, R, P> extends Observable<T> {
id: I;
loading = false;
protected currentData?: T;
protected lastLoadTime = 0;
protected pSetParamsNoUpdate = false;
get hasData(): boolean {
return this.currentData !== undefined;
}
get current(): T {
return this.currentData;
}
protected currentQueryParams?: P;
get params(): P | undefined {
return this.currentQueryParams;
}
set params(p: P) {
this.currentQueryParams = p;
if (!this.pSetParamsNoUpdate) {
this.loadDebounced();
}
}
protected subscribers: Set<Subscriber<T>> = new Set();
protected innerQueryFn: (id: I, p: P) => Promise<R>;
protected innerMapFn: (r: R) => T;
protected stateLock = 0;
protected loadTimeout = null;
protected hydrated = false;
protected isNextSubLazy = false;
interactive = false;
protected constructor(id: I, query: (id: I, p: P) => Promise<R>, map: (r: R) => T) {
super((subscriber) => {
this.addSubscriber(subscriber, this.isNextSubLazy);
this.isNextSubLazy = false;
});
this.id = id;
this.innerQueryFn = query;
this.innerMapFn = map;
}
dataAsPromise(): Promise<T> {
if (this.hasData) {
return Promise.resolve(this.current);
}
return new Promise((resolve, reject) => {
const sub = this.subscribe(
(data) => {
resolve(data);
sub.unsubscribe();
},
(error) => {
reject(error);
sub.unsubscribe();
}
);
});
}
private loadImpl(fut: Promise<R>) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
this.lastLoadTime = Date.now();
this.loading = true;
const stateLock = ++this.stateLock;
fut
.then((data) => {
if (stateLock !== this.stateLock) {
return;
}
this.insertResult(data);
this.loading = false;
this.hydrated = false;
})
.catch((error) => {
if (stateLock !== this.stateLock) {
return;
}
this.emitErrorToAllSubscribers(error);
this.loading = false;
this.hydrated = false;
});
}
load(): void {
this.hydrated = false;
this.loadImpl(this.innerQueryFn(this.id, this.currentQueryParams));
}
hydrateRaw(preparedData: Promise<R>): void {
if (this.hasData) {
return;
}
this.hydrated = true;
this.loadImpl(preparedData);
}
loadIfNeeded(): void {
if (this.loading) {
return;
}
if (!this.hasData || Date.now() - this.lastLoadTime > CACHE_STALE_TIME_MS) {
this.load();
}
}
loadDebounced(interactive = this.interactive): void {
if (this.loadTimeout) {
return;
}
this.loadTimeout = setTimeout(
() => {
this.loadTimeout = null;
this.load();
},
interactive ? CACHE_INTERACTIVE_DEBOUNCE_TIME_MS : CACHE_FAST_DEBOUNCE_TIME_MS
);
}
invalidate(): void {
this.currentData = undefined;
this.emitUpdateToAllSubscribers();
}
protected addSubscriber(subscriber: Subscriber<T>, lazy: boolean): {unsubscribe: () => void} {
this.subscribers.add(subscriber);
if (this.current !== undefined) {
subscriber.next(this.current);
}
if (!this.hydrated && (!lazy || !this.hasData)) {
this.loadIfNeeded();
}
return {
unsubscribe: () => {
this.subscribers.delete(subscriber);
}
};
}
subscribeLazy(...args): Subscription {
this.isNextSubLazy = true;
return this.subscribe(...args);
}
emitUpdateToAllSubscribers(): void {
for (const sub of this.subscribers) {
sub.next(this.current);
}
}
emitErrorToAllSubscribers(error: unknown): void {
for (const sub of this.subscribers) {
sub.error(error);
}
}
insertResult(result: R): void {
this.currentData = this.innerMapFn(result);
this.emitUpdateToAllSubscribers();
}
get subscriberCount(): number {
return this.subscribers.size;
}
}
export class DataNode<T> extends DataQuery<NodeId, T, T, void> {
constructor(queries: QueriesService, id: NodeId) {
super(id, queryNode(queries), (data) => data);
}
set params(p) {
throw new Error('parameters not available on nodes');
}
loadIfNeeded(): void {
if (!this.loading && Date.now() - this.lastLoadTime > CACHE_STALE_TIME_MS) {
this.load();
}
}
}
export class DataList<T, F> extends DataQuery<ListId, Map<NodeIdEnc, T>, ListResult<T>, ListParams<F>> {
private pCursor?: NodeId;
private pCount = 10;
private pFilter?: F;
private pForward = true;
private pageInfo?: PageInfo;
private pTotalCount?: number;
private previouslyHadPageContents = false;
private pNodes: NodeCache;
constructor(queries: QueriesService, nodes: NodeCache, id: ListId) {
super(id, queryList(queries, nodes), (result) => {
this.pageInfo = result.pageInfo;
this.pTotalCount = result.totalCount;
if (this.forward) {
this.pageInfo.hasPreviousPage = this.previouslyHadPageContents;
} else {
this.pageInfo.hasNextPage = this.previouslyHadPageContents;
}
this.previouslyHadPageContents = !!result.items.size;
return result.items;
});
this.pNodes = nodes;
this.pSetParamsNoUpdate = true;
this.setParams();
this.pSetParamsNoUpdate = false;
}
setParams(): void {
this.params = {
cursor: this.pCursor,
count: this.pCount,
forward: this.pForward,
filter: this.pFilter
};
}
get totalCount(): number {
return this.pTotalCount;
}
get currentItems(): T[] {
if (!this.hasData) {
return [];
}
return [...this.current.values()];
}
get filter(): F | undefined {
return this.pFilter;
}
set filter(f: F) {
this.pFilter = f;
this.setParams();
}
get cursor(): NodeId {
return this.pCursor;
}
set cursor(c: NodeId) {
this.pCursor = c;
this.setParams();
}
get count(): number {
return this.pCount;
}
set count(c: number) {
this.pCount = c;
this.setParams();
}
get forward(): boolean {
return this.pForward;
}
set forward(f: boolean) {
this.pForward = f;
this.setParams();
}
get firstPageItemId(): NodeId | null {
const firstKey = this.current ? this.current.keys().next()?.value || null : null;
return firstKey ? decodeNodeId(firstKey) : null;
}
get lastPageItemId(): NodeId | null {
if (!this.current) {
return;
}
const keys = [...this.current.keys()];
return keys[keys.length - 1] ? decodeNodeId(keys[keys.length - 1]) : null;
}
currentHasNode(key: NodeId): boolean {
return this.current?.has(encodeNodeId(key));
}
get hasPrevPage(): boolean {
return !this.pageInfo || this.pageInfo.hasPreviousPage;
}
get hasNextPage(): boolean {
return !this.pageInfo || this.pageInfo.hasNextPage;
}
firstPage(): boolean {
this.cursor = null;
this.forward = true;
this.previouslyHadPageContents = false;
this.invalidate();
return true;
}
prevPage(): boolean {
if (this.pageInfo && !this.pageInfo.hasPreviousPage) {
return false;
}
this.cursor = this.firstPageItemId;
this.forward = false;
this.invalidate();
return true;
}
nextPage(): boolean {
if (this.pageInfo && !this.pageInfo.hasNextPage) {
return false;
}
this.cursor = this.lastPageItemId;
this.forward = true;
this.invalidate();
return true;
}
hydrateInitial<IdT extends T & {id: string; __typename: string}>(data: Promise<HydrateList<IdT>>): void {
this.hydrateRaw(
data.then((value) => ({
totalCount: value.totalCount,
pageInfo: value.pageInfo,
items: this.pNodes.insertNodes(value.nodes || [])
}))
);
}
}
export type HydrateList<T> = {
totalCount: number;
pageInfo: PageInfo;
nodes?: (T | null)[];
};
export class NodeCache {
nodes: Map<NodeIdEnc, DataNode<unknown>> = new Map();
constructor(private queries: QueriesService) {}
private createNode(id: NodeId) {
const encodedId = encodeNodeId(id);
this.nodes.set(encodedId, new DataNode(this.queries, id));
}
getNode<T>(id: NodeId): DataNode<T> {
const encodedId = encodeNodeId(id);
if (!this.nodes.has(encodedId)) {
this.createNode(id);
}
return this.nodes.get(encodedId) as DataNode<T>;
}
insertNodes<T extends {id?: string; __typename?: string}>(nodes: T[]): Map<NodeIdEnc, T> {
const map = new Map();
for (const node of nodes) {
if (!node?.id) {
continue;
}
const type = nodeTypeFromTypename(node.__typename);
const id = {type, id: node.id};
const dataNode: DataNode<T> = this.getNode(id);
if (!dataNode.hasData) {
dataNode.insertResult(node);
}
map.set(encodeNodeId(id), node);
}
return map;
}
}