rescribet/link-lib

View on GitHub
src/LinkedRenderStore.ts

Summary

Maintainability
F
3 days
Test Coverage
A
91%
import rdfFactory, {
    isNode,
    isQuad,
    NamedNode,
    Node,
    Quad,
    QuadPosition,
    Quadruple,
    SomeTerm,
    Term,
    TermType,
} from "@ontologies/core";
import * as rdf from "@ontologies/rdf";
import * as schema from "@ontologies/schema";

import { ComponentStore } from "./ComponentStore/ComponentStore";
import { DataRecord, Id } from "./datastrucures/DataSlice";
import { DeepRecord } from "./datastrucures/DeepSlice";
import { equals, value } from "./factoryHelpers";
import { APIFetchOpts, LinkedDataAPI } from "./LinkedDataAPI";
import { ProcessBroadcast } from "./ProcessBroadcast";
import { DataProcessor, emptyRequest } from "./processor/DataProcessor";
import { dataToGraphTuple } from "./processor/DataToGraph";
import { RDFStore } from "./RDFStore";
import { Schema } from "./Schema";
import { RecordState } from "./store/RecordState";
import { RecordStatus } from "./store/RecordStatus";
import { TypedRecord } from "./TypedRecord";
import {
    ComponentRegistration,
    DataObject,
    DeltaProcessor,
    Dispatcher,
    ErrorReporter,
    FetchOpts,
    LazyNNArgument,
    LinkedActionResponse,
    LinkedRenderStoreOptions,
    MiddlewareActionHandler,
    OneOrMoreOrNothing,
    Optional,
    ResourceQueueItem,
    SomeNode,
    SomeRequestStatus,
    SubscriptionRegistrationBase,
} from "./types";
import { normalizeType } from "./utilities";
import { DEFAULT_TOPOLOGY, RENDER_CLASS_NAME } from "./utilities/constants";

const normalizedIds = <
    T extends SomeTerm | SomeTerm[] | Array<SomeTerm | undefined> | undefined,
    K extends (T extends undefined ? SomeTerm : SomeTerm | undefined),
>(item: T, defaultValue?: K): string[] => normalizeType(item)
    .map((t) => (t ?? defaultValue)!.value);

/**
 * Main entrypoint into the functionality of link-lib.
 *
 * Before using the methods for querying data and views here, search through your render library (e.g. link-redux) to
 * see if it exposes an API which covers your use-case. Skipping the render library might cause unexpected behaviour and
 * hard to solve bugs.
 */
export class LinkedRenderStore<T, API extends LinkedDataAPI = DataProcessor> implements Dispatcher {
    public static registerRenderer<T>(
        component: T,
        type: LazyNNArgument,
        prop: LazyNNArgument = RENDER_CLASS_NAME,
        topology: LazyNNArgument | Array<NamedNode | undefined> = DEFAULT_TOPOLOGY): Array<ComponentRegistration<T>> {

        const types = normalizedIds(type);
        const props = normalizedIds(prop, RENDER_CLASS_NAME);
        const topologies = normalizedIds(topology, DEFAULT_TOPOLOGY);

        return ComponentStore.registerRenderer(component, types, props, topologies);
    }

    /**
     * Map of {ActionMap} which hold action dispatchers. Calling a dispatcher should execute the action, causing them to
     * be handled like any back-end sent action.
     *
     * Constructing action IRI's and dispatching them in user code was a bit hard, this object allows any middleware to
     * define their actions (within their namespace) in a code-oriented fashion. The middleware has control over how the
     * actions will be dispatched, but it should be the same as if a back-end would have executed the action (via the
     * Exec-Action header).
     */
    public actions: TypedRecord = new TypedRecord();
    /** Whenever a resource has no type, assume it to be this. */
    public defaultType: NamedNode = schema.Thing;
    public deltaProcessors: DeltaProcessor[];
    public report: ErrorReporter;

    public api: API;
    public mapping: ComponentStore<T>;
    public schema: Schema;
    public store: RDFStore;
    public settings: TypedRecord = new TypedRecord();

    /**
     * Enable the bulk api to fetch data with.
     */
    public bulkFetch: boolean = false;

    private _dispatch?: MiddlewareActionHandler;
    private cleanupTimout: number = 500;
    private cleanupTimer: number | undefined;
    private currentBroadcast: Promise<void> | undefined;
    private broadcastHandle: number | undefined;
    private bulkSubscriptions: Array<SubscriptionRegistrationBase<unknown>> = [];
    private subjectSubscriptions: { [k: string]: Array<SubscriptionRegistrationBase<unknown>> } = {};
    private lastPostponed: number | undefined;
    private resourceQueueFlushTimer: number = 100;
    private resourceQueue: ResourceQueueItem[];
    private resourceQueueHandle: number | undefined;

    // tslint:disable-next-line no-object-literal-type-assertion
    public constructor(opts: LinkedRenderStoreOptions<T, API> = {}) {
        if (opts.store) {
            this.store = opts.store;
        } else {
            this.store = new RDFStore({ data: opts.data });
        }

        this.report = opts.report || ((e): void => { throw e; });
        this.api = opts.api || new DataProcessor({
            ...opts.apiOpts,
            dispatch: opts.dispatch,
            report: this.report,
            store: this.store,
        }) as unknown as API;
        this.deltaProcessors = [this.api, this.store];
        if (opts.dispatch) {
            this.dispatch = opts.dispatch;
        }
        this.defaultType = opts.defaultType || schema.Thing;
        this.schema = opts.schema || new Schema(this.store);
        this.mapping = opts.mapping || new ComponentStore(this.schema);
        this.resourceQueue = [];

        this.broadcast = this.broadcast.bind(this);
        this.processResourceQueue = this.processResourceQueue.bind(this);
    }

    public get dispatch(): MiddlewareActionHandler {
        if (typeof this._dispatch === "undefined") {
            throw new Error("Invariant: cannot call `dispatch` before initialization is complete, see createStore");
        }

        return this._dispatch;
    }

    public set dispatch(v: MiddlewareActionHandler) {
        this._dispatch = v;
        this.api.dispatch = v;
    }

    /**
     * Add a custom delta processor in the stack.
     */
    public addDeltaProcessor(processor: DeltaProcessor): void {
        this.deltaProcessors.unshift(processor);
    }

    /**
     * Execute an Action by its IRI. This will result in an HTTP request being done and probably some state changes.
     * @param subject The resource to execute. Generally a schema:Action derivative with a
     *   schema:EntryPoint to describe the request. Currently schema:url is used over schema:urlTemplate
     *   to acquire the request URL, since template processing isn't implemented (yet).
     * @param data An object to send in the body when a non-safe method is used.
     */
    public execActionByIRI(subject: SomeNode, data?: DataObject): Promise<LinkedActionResponse> {
        const preparedData = dataToGraphTuple(data || {});
        return this
            .api
            .execActionByIRI(subject, preparedData)
            .then((res: LinkedActionResponse) => {
                this.broadcast(false, 100);
                return res;
            });
    }

    /**
     * Execute a resource.
     *
     * Every action will fall through the execution middleware layers.
     *
     * @see https://github.com/rescribet/link-lib/wiki/%5BDesign-draft%5D-Actions,-data-streams,-and-middleware
     *
     * @param subject The resource to execute (can be either an IRI or an URI)
     * @param args The arguments to the function defined by the subject.
     */
    public async exec(subject: SomeNode, args?: DataObject): Promise<any> {
        return this.dispatch(subject, args);
    }

    /**
     * Resolve the values at the end of the path
     * @param subject The resource to start descending on
     * @param path A list of linked predicates to descend on.
     */
    public dig(subject: Node | undefined, path: NamedNode[]): SomeTerm[] {
        const [result] = this.digDeeper(subject, path);

        return result.map((q) => q[QuadPosition.object]);
    }

    /**
     * @internal See {dig}
     * @param subject
     * @param path
     * @param subject - The subject traversed.
     */
    public digDeeper(subject: Node | undefined, path: Array<NamedNode | NamedNode[]>): [Quadruple[], SomeNode[]] {
        if (path.length === 0 || typeof subject === "undefined") {
            return [[], []];
        }

        const last = path.length - 1;
        let ids: Node[] = [subject];
        const intermediates: Node[] = [subject];
        const values: Quadruple[] = [];
        for (let i = 0; i <= last; i++) {
            const field = path[i];
            const segmentIds = ids;
            ids = [];

            for (const id of segmentIds) {
                const quads = this.getResourcePropertyRaw(id, field);

                if (i === last) {
                    values.push(...quads);
                    continue;
                }

                const next = quads
                  .map((q) => q[QuadPosition.object] as Node)
                  .filter((v) => isNode(v));
                intermediates.push(...next);
                ids.push(...next);
            }
        }

        return [values, intermediates];
    }

    /**
     * Retrieve the subjects from {subject} to find all resources which have an object at the
     * end of the {path} which matches {match}.
     * @param subject The resource to start descending on.
     * @param path A list of linked predicates to descend on.
     * @param match The value which the predicate at the end of {path} has to match for its subject to return.
     */
    public findSubject(subject: Node | undefined, path: NamedNode[], match: Term | Term[]): Node[] {
        if (path.length === 0 || typeof subject === "undefined") {
            return [];
        }

        const remaining = path.slice();
        const pred = remaining.shift();
        const props = this.getResourceProperties(subject, pred);

        if (props.length === 0) {
            return [];
        }

        if (remaining.length === 0) {
            const finder = Array.isArray(match)
                ? (p: Term): boolean => match.some((m) => equals(m, p))
                : (p: Term): boolean => equals(match, p);

            return props.find(finder) ? [subject] : [];
        }

        return props
            .map((term) => (term.termType === TermType.NamedNode || term.termType === TermType.BlankNode)
                && this.findSubject(term as Node, remaining, match))
            .flat(1)
            .filter<Node>(Boolean as any);
    }

    /**
     * Finds the best render component for a given property in respect to a topology.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @param type The type(s) of the resource to render.
     * @param predicate The predicate(s) (property(s)) to render.
     * @param [topology] The topology of the resource, if any
     * @returns The most appropriate renderer, if any.
     */
    public getComponentForProperty(type: NamedNode | NamedNode[] | undefined = this.defaultType,
                                   predicate: NamedNode | NamedNode[],
                                   topology: NamedNode = DEFAULT_TOPOLOGY): Optional<T> {
        if (type === undefined || (Array.isArray(type) && type.length === 0)) {
            return undefined;
        }
        const types = normalizeType(type).map(value);
        const predicates = normalizeType(predicate).map(value);

        return this.mapping.getRenderComponent(
            types,
            predicates,
            value(topology),
            value(this.defaultType),
        );
    }

    /**
     * Finds the best render component for a type in respect to a topology.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @see LinkedRenderStore#getComponentForProperty
     * @param type The type(s) of the resource to render.
     * @param [topology] The topology of the resource, if any
     * @returns The most appropriate renderer, if any.
     */
    public getComponentForType(
        type: NamedNode | NamedNode[],
        topology: NamedNode = DEFAULT_TOPOLOGY,
    ): Optional<T> {
        return this.getComponentForProperty(type, RENDER_CLASS_NAME, topology);
    }

    /**
     * Efficiently queues a resource to be fetched later.
     *
     * This skips the overhead of creating a promise and allows the subsystem to retrieve multiple resource in one
     * round trip, sacrificing loading status for performance.
     * @renderlibrary This should only be used by render-libraries, not by application code.
     */
    public queueEntity(iri: NamedNode, opts?: FetchOpts): void {
        if (!(opts && opts.reload) && !this.shouldLoadResource(iri)) {
            return;
        }

        this.store.getInternalStore().store.transition(iri.value, RecordState.Queued);
        this.resourceQueue.push([iri, opts]);
        this.scheduleResourceQueue();
    }

    /**
     * Queue a linked-delta to be processed.
     *
     * Note: This should only be used by render-libraries (e.g. link-redux), not by application code.
     * @renderlibrary This should only be used by render-libraries, not by application code.
     */
    public queueDelta(delta: Array<Quadruple|void> | Quad[], expedite = false): Promise<void> {
        const quadArr = isQuad(delta[0])
            ? (delta as Quad[]).map((s: Quad) => rdfFactory.qdrFromQuad(s))
            : delta as Quadruple[];

        for (const dp of this.deltaProcessors) {
            dp.queueDelta(quadArr);
        }

        return this.broadcastWithExpedite(expedite);
    }

    /**
     * Will fetch the entity with the URL {iri}. When a resource under that subject is already present, it will not
     * be fetched again.
     *
     * @deprecated Use {queueEntity} instead
     * @param iri The Node of the resource
     * @param opts The options for fetch-/processing the resource.
     * @return A promise with the resulting entity
     */
    public async getEntity(iri: NamedNode, opts?: FetchOpts): Promise<void> {
        const apiOpts: APIFetchOpts = {};
        let preExistingData;
        if (opts && opts.reload) {
            apiOpts.clearPreviousData = true;
            preExistingData = this.tryEntity(iri);
        }
        if (preExistingData !== undefined) {
            // TODO: refactor to use removeRecord
            this.store.removeQuads(preExistingData);
        }
        await this.api.getEntity(iri, apiOpts);
        return this.broadcast();
    }

    /**
     * Resolves all the properties {property} of resource {subject} to their statements.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @param {Node} subject The resource to get the properties for.
     * @param {Node | Node[]} property
     * @return {Statement[]} All the statements of {property} on {subject}, or an empty array when none are present.
     */
    public getResourcePropertyRaw(subject: Node | undefined, property: OneOrMoreOrNothing<Node>): Quadruple[] {
        if (typeof subject === "undefined" || typeof property === "undefined") {
            return [];
        }

        return this.store.getResourcePropertyRaw(subject, property);
    }

    /**
     * Resolves all the properties {property} of resource {subject} to a value.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @typeParam TT The expected return type for the properties.
     * @param {Node} subject The resource to get the properties for.
     * @param {Node | Node[]} property
     * @return {Term[]} The resolved values of {property}, or an empty array when none are present.
     */
    public getResourceProperties<TT extends Term = SomeTerm>(
      subject: Node | undefined,
      property: OneOrMoreOrNothing<Node>,
    ): TT[] {
        if (typeof subject === "undefined" || typeof property === "undefined") {
           return [];
        }

        return this.store.getResourceProperties<TT>(subject, property);
    }

    /**
     * Resolves the property {property} of resource {subject} to a value.
     *
     * When more than one statement on {property} is present, a random one will be chosen. See
     * {LinkedResourceContainer#getResourceProperties} to retrieve all the values.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @typeParam TT The expected return type for the property.
     * @param {Node} subject The resource to get the properties for.
     * @param {Node | Node[]} property
     * @return {Term | undefined} The resolved value of {property}, or undefined when none are present.
     */
    public getResourceProperty<TT extends Term = SomeTerm>(
      subject: Node | undefined,
      property: OneOrMoreOrNothing<Node>,
    ): TT | undefined {
        if (typeof subject === "undefined" || typeof property === "undefined") {
            return undefined;
        }

        return this.store.getResourceProperty<TT>(subject, property);
    }

    public getState(recordId: Id): RecordStatus {
        return this.store.getInternalStore().store.getStatus(recordId);
    }

    /**
     * Retrieve the (network) status of the resource {iri}.
     *
     * Status 202 indicates that the resource has been queued for fetching (subject to change).
     * @deprecated
     */
    public getStatus(iri: Node): SomeRequestStatus {
        if (iri.termType === TermType.BlankNode) {
            return emptyRequest;
        }

        if (this.resourceQueue.find(([resource]) => equals(resource, iri))) {
            return {
                lastRequested: new Date(),
                lastResponseHeaders: null,
                requested: true,
                status: 202,
                subject: iri,
                timesRequested: 1,
            };
        }

        return this.api.getStatus(iri);
    }

    /**
     * Process a linked-delta onto the store.
     *
     * This should generally only be called from the middleware or the data api
     * @param delta An array of [s, p, o, g] arrays containing the delta.
     * @param expedite Will immediately process the delta rather than waiting for an idle moment, useful in conjunction
     *  with event handlers within the UI needing immediate feedback. Might cause jumpy interfaces.
     */
    public processDelta(delta: Quadruple[], expedite = false): Promise<void> {
        const processors = this.deltaProcessors;
        for (const processor of processors) {
            processor.processDelta(delta);
        }

        return this.broadcastWithExpedite(expedite);
    }

    /**
     * Bulk register components formatted with {LinkedRenderStore.registerRenderer}.
     * @see LinkedRenderStore.registerRenderer
     */
    public registerAll(...components: Array<ComponentRegistration<T> | Array<ComponentRegistration<T>>>): void {
        const registerItem = (i: ComponentRegistration<T>): void => {
            this.mapping.registerRenderer(
                i.component,
                i.type,
                i.property,
                i.topology,
            );
        };

        for (const component of components) {
            const innerRegs = normalizeType(component);

            for (const registration of innerRegs) {
                registerItem(registration);
            }
        }
    }

    /**
     * Remove a resource from the store, when views are still rendered the resource will be re-fetched.
     *
     * @unstable
     */
    public removeResource(subject: Node, expedite = false): Promise<void> {
        this.api.invalidate(subject);
        this.store.removeResource(subject);

        return this.broadcastWithExpedite(expedite);
    }

    /**
     * Resets the render store mappings and the schema graph.
     *
     * Note: This should only be used by render-libraries (e.g. link-redux), not by application code.
     */
    public reset(): void {
        this.store = new RDFStore();
        this.schema = new Schema(this.store);
        this.mapping = new ComponentStore(this.schema);
    }

    /**
     * Get a render component for a rendering {property} on resource {subject}.
     *
     * @renderlibrary
     * @param {Node} subject
     * @param {NamedNode | NamedNode[]} predicate
     * @param {NamedNode} topology
     * @return {T | undefined}
     */
    public resourcePropertyComponent(
        subject: Node,
        predicate: NamedNode | NamedNode[],
        topology?: NamedNode,
    ): Optional<T> {
        return this.getComponentForProperty(
            this.store.getResourceProperties(subject, rdf.type),
            predicate,
            topology || DEFAULT_TOPOLOGY,
        );
    }

    /**
     * Get a render component for {subject}.
     *
     * @renderlibrary
     * @param {Node} subject The resource to get the renderer for.
     * @param {NamedNode} topology The topology to take into account when picking the renderer.
     * @return {T | undefined}
     */
    public resourceComponent(
        subject: Node,
        topology?: NamedNode,
    ): Optional<T> {
        return this.getComponentForProperty(
            this.store.getResourceProperties(subject, rdf.type),
            RENDER_CLASS_NAME,
            topology || DEFAULT_TOPOLOGY,
        );
    }

    /**
     * Determine if it makes sense to load a resource.
     *
     * @renderlibrary
     * @unstable
     */
    public shouldLoadResource(subject: Node): boolean {
        const currentState = this.getState(subject.value).current;

        return subject.termType === "NamedNode" && currentState === RecordState.Absent;
    }

    /**
     * Listen for data changes by subscribing to store changes.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @param registration
     * @param registration[0] Will be called with the new statements as its argument.
     * @param registration[1] Options for the callback.
     * @return function Unsubscription function.
     */
    public subscribe(registration: SubscriptionRegistrationBase<unknown>): () => void {
        registration.subscribedAt = Date.now();
        const subjectFilter = registration.subjectFilter
          ?.flatMap((s) => [s, this.store.getInternalStore().store.primary(s)]);

        if (typeof subjectFilter !== "undefined") {
            for (let i = 0, len = subjectFilter.length; i < len; i++) {
                if (!this.subjectSubscriptions[subjectFilter[i]]) {
                    this.subjectSubscriptions[subjectFilter[i]] = [];
                }
                this.subjectSubscriptions[subjectFilter[i]].push(registration);
            }

            return (): void => {
                registration.markedForDelete = true;
                this.markForCleanup();
            };
        }

        this.bulkSubscriptions.push(registration);

        return (): void => {
            registration.markedForDelete = true;
            this.bulkSubscriptions.splice(this.bulkSubscriptions.indexOf(registration), 1);
        };
    }

    /** @internal */
    public touch(_iri: string | NamedNode, _err?: Error): boolean {
        this.broadcast();
        return true;
    }

    /**
     * Returns an entity from the cache directly.
     * This won't cause any network requests even if the entity can't be found.
     *
     * @renderlibrary This should only be used by render-libraries, not by application code.
     * @param iri The Node of the resource.
     * @returns The object if found, or undefined.
     */
    public tryEntity(iri: Node): Quadruple[] {
        return this.store.quadsFor(iri);
    }

    /** @deprecated Use getRecord */
    public tryRecord(id: Node | string): DataRecord | undefined {
        return this.getRecord(id);
    }

    /**
     * Returns a record from the store.
     * This won't cause any network requests even if the record isn't present.
     *
     * @param id The id of the resource.
     * @returns The object if found, or undefined.
     */
    public getRecord(id: Node | string): DataRecord | undefined {
        const recordId = typeof id === "string" ? id : id.value;
        return this.store.getInternalStore().store.getRecord(recordId);
    }

    /**
     * Returns a record from the store, inlining all local ids.
     * This won't cause any network requests even if the record isn't present.
     *
     * @param id The id of the resource.
     * @returns The object if found, or undefined.
     */
    public collectRecord(id: SomeNode | string): DeepRecord | undefined {
        const recordId = typeof id === "string" ? id : id.value;
        return this.store.getInternalStore().store.collectRecord(recordId);
    }

    /**
     * Broadcasts buffered to all subscribers.
     * The actual broadcast might be executed asynchronously to prevent lag.
     *
     * @param buffer Controls whether processing can be delayed until enough is available.
     * @param maxTimeout Set to 0 to execute immediately.
     * Note: This should only be used by render-libraries (e.g. link-redux), not by application code.
     */
    private broadcast(buffer = true, maxTimeout = 1000): Promise<void> {
        if (maxTimeout !== 0 && this.currentBroadcast || this.broadcastHandle) {
            return this.currentBroadcast || Promise.resolve();
        }

        if (buffer) {
            if (this.store.workAvailable() >= 2) {
                if (this.broadcastHandle) {
                    window.clearTimeout(this.broadcastHandle);
                }
                if ((this.lastPostponed === undefined) || Date.now() - this.lastPostponed <= maxTimeout) {
                    if (this.lastPostponed === undefined) {
                        this.lastPostponed = Date.now();
                    }
                    this.broadcastHandle = window.setTimeout(() => {
                        this.broadcastHandle = undefined;
                        this.broadcast(buffer, maxTimeout);
                    }, 200);

                    return this.currentBroadcast || Promise.resolve();
                }
            }
            this.lastPostponed = undefined;
            this.broadcastHandle = undefined;
        }
        if (this.store.workAvailable() === 0) {
            return Promise.resolve();
        }

        let flushResult = new Set<string>();
        for (const dp of this.deltaProcessors) {
            flushResult = new Set([...flushResult, ...Array.from(dp.flush())]);
        }
        const subjects = Array.from(flushResult);

        const subscriptions = subjects.flatMap((sId) => this.subjectSubscriptions[sId]);
        const subjectRegs = new Set<SubscriptionRegistrationBase<unknown>>();

        for (const reg of subscriptions) {
            if (!reg || reg.markedForDelete) {
                continue;
            }

            const allowed = reg.subjectFilter
              ? reg.subjectFilter.some((s) => subjects.includes(s))
              : true;

            if (allowed) {
                subjectRegs.add(reg);
            }
        }

        if (this.bulkSubscriptions.length === 0 && subjectRegs.size === 0) {
            return Promise.resolve();
        }

        return this.currentBroadcast = new ProcessBroadcast({
            bulkSubscriptions: this.bulkSubscriptions.slice(),
            changedSubjects: subjects,
            subjectSubscriptions: Array.from(subjectRegs),
            timeout: maxTimeout,
        }).run()
          .then(() => {
              this.currentBroadcast = undefined;
              if (this.store.workAvailable() > 0) {
                  this.broadcast();
              }
          });
    }

    private broadcastWithExpedite(expedite: boolean): Promise<void> {
        return this.broadcast(!expedite, expedite ? 0 : 500);
    }

    private markForCleanup(): void {
        if (this.cleanupTimer) {
            return;
        }

        this.cleanupTimer = window.setTimeout(() => {
            this.cleanupTimer = undefined;
            for (const [ k, v ] of Object.entries(this.subjectSubscriptions)) {
                this.subjectSubscriptions[k] = v.filter((p) => !p.markedForDelete);
            }
        }, this.cleanupTimout);
    }

    private scheduleResourceQueue(): void {
        if (this.resourceQueueHandle) {
            return;
        }

        if (typeof window === "undefined") {
            setTimeout(this.processResourceQueue, this.resourceQueueFlushTimer);
        } else if (typeof window.requestIdleCallback !== "undefined") {
            this.resourceQueueHandle = window.requestIdleCallback(
              this.processResourceQueue,
              { timeout: this.resourceQueueFlushTimer },
            );
        } else {
            this.resourceQueueHandle = window.setTimeout(
              this.processResourceQueue,
              this.resourceQueueFlushTimer,
            );
        }
    }

    private async processResourceQueue(): Promise<void> {
        this.resourceQueueHandle = undefined;
        const queue = this.resourceQueue;
        this.resourceQueue = [];

        if (this.bulkFetch) {
            await this.api.getEntities(queue);
            return this.broadcast();
        } else {
            for (let i = 0; i < queue.length; i++) {
                try {
                    const [iri, opts] = queue[i];
                    await this.getEntity(iri, opts);
                } catch (e) {
                    this.report(e);
                }
            }
        }
    }
}