import { FilterExpr, type Query } from '@uwdata/mosaic-sql'; import { type Coordinator } from './Coordinator.js'; import { type Selection } from './Selection.js'; import { throttle } from './util/throttle.js'; export type ClientQuery = Query | string | null; export function isMosaicClient(x: unknown): x is MosaicClient { return x instanceof MosaicClient; } /** * A Mosaic client is a data consumer that indicates its data needs to a * Mosaic coordinator via the query method. The coordinator is responsible * for issuing queries and returning results to the client. * * The client life-cycle consists of connection to a coordinator, * initialization (potentially involving queries for data schema and summary * statistic information), and then interactive queries that may be driven by * an associated selection. When no longer needed, a client should be * disconnected from the coordinator. * * When enabled, a client will initialize and respond to query update requests. * If disabled, the client will delay initialization and not respond to queries * until enabled again. Disabling a client can improve system performance when * associated interface elements are offscreen or disabled. */ export class MosaicClient { _filterBy: Selection | undefined; _requestUpdate: () => void; _coordinator: Coordinator | null; _pending: Promise; _enabled: boolean; /** * Initialization state. One of `0` (uninitialized), `-1` (preparing), * or `1` (initialized). */ _initialized: -1 | 0 | 1; _request: Query | boolean | null; /** * Create a new client instance. * @param filterSelection An optional selection to * interactively filter this client's data. If provided, a coordinator * will re-query and update the client when the selection updates. */ constructor(filterSelection?: Selection) { this._filterBy = filterSelection; this._requestUpdate = throttle(() => this.requestQuery(), true); this._coordinator = null; this._pending = Promise.resolve(); this._enabled = true; this._initialized = 0; this._request = null; } /** * @returns this client's connected coordinator. */ get coordinator(): Coordinator | null { return this._coordinator; } /** * Set this client's connected coordinator. */ set coordinator(coordinator: Coordinator | null) { this._coordinator = coordinator; } /** * Return this client's enabled state. */ get enabled(): boolean { return this._enabled; } /** * Return this client's initialization state: * `true` if initialization is complete, `false` otherwise. */ get initialized(): boolean { return this._initialized > 0; } /** * Set this client's enabled state; */ set enabled(state: boolean) { state = !!state; // ensure boolean if (this._enabled !== state) { this._enabled = state; if (state) { if (!this._initialized) { // initialization includes a query request this.initialize(); } else if (this._request) { // request query now if requested while disabled this.requestQuery(this._request === true ? undefined : this._request); } this._request = null; } } } /** * Return a Promise that resolves once the client has updated. */ get pending(): Promise { return this._pending; } /** * @returns this client's filter selection. */ get filterBy(): Selection | undefined { return this._filterBy; } /** * Return a boolean indicating if the client query can be sped up with * materialized views of pre-aggregated data. Should return true if changes * to the filterBy selection do not change the groupby domain of the client * query. */ get filterStable(): boolean { return true; } /** * Prepare the client before the `query()` method is called. Subclasses * should override this method as needed, potentially issuing one or more * queries to gather data or metadata needed prior to `query` calls. */ async prepare(): Promise { } /** * Return a query specifying the data needed by this client. * @param filter The filtering criteria to apply in the query. * @returns The client query */ query(filter?: FilterExpr | null): ClientQuery { // eslint-disable-line @typescript-eslint/no-unused-vars return null; } /** * Called by the coordinator to inform the client that a query is pending. * @returns this */ queryPending(): this { return this; } /** * Called by the coordinator to return a query result. * @param data The query result. * @returns this */ queryResult(data: unknown): this { // eslint-disable-line @typescript-eslint/no-unused-vars return this; } /** * Called by the coordinator to report a query execution error. * @param error * @returns this */ queryError(error: Error): this { // eslint-disable-line @typescript-eslint/no-unused-vars // do nothing, the coordinator logs the error return this; } /** * Request the coordinator to execute a query for this client. * If an explicit query is not provided, the client `query` method will * be called, filtered by the current `filterBy` selection. This method has * no effect if the client is not connected to a coordinator. If the client * is connected by currently disabled, the request will be serviced if the * client is later enabled. * @param query The query to request. If unspecified, the query * will be determined by the client's `query` method and the current * `filterBy` selection state. */ requestQuery(query?: Query): Promise | null { if (this._enabled) { const q = query || this.query(this.filterBy?.predicate(this)); return this._coordinator!.requestQuery(this, q); } else { this._request = query ?? true; return null; } } /** * Request that the coordinator perform a throttled update of this client * using the default query. Unlike requestQuery, for which every call results * in an executed query, multiple calls to requestUpdate may be consolidated * into a single update. This method has no effect if the client is not * connected to a coordinator. If the client is connected but currently * disabled, the request will be serviced if the client is later enabled. */ requestUpdate(): void { if (this._enabled) { this._requestUpdate(); } else { this.requestQuery(); } } /** * Reset this client, calling the prepare method and query requests. This * method has no effect if the client is not registered with a coordinator. */ initialize(): void { if (!this._enabled) { // clear flag so we initialize when enabled again this._initialized = 0; } else if (this._coordinator) { // if connected, let's initialize this._initialized = -1; this._pending = this.prepare().then(() => { this._initialized = 1; return this.requestQuery(); }); } } /** * Remove this client: disconnect from the coordinator and free up any * resource use. This method has no effect if the client is not connected * to a coordinator. * * If overriding this method in a client subclass, be sure to also * disconnect from the coordinator. */ destroy(): void { this._enabled = false; this.coordinator?.disconnect(this); } /** * Requests a client update, for example to (re-)render an interface * component. */ update(): this | Promise { return this; } }