From 833fc79d3127021e03bbce99ce5c24692a533970 Mon Sep 17 00:00:00 2001 From: Vedanta Somnathe Date: Wed, 24 Dec 2025 18:32:18 -0600 Subject: [PATCH 01/30] observe dispatch (sync) --- .../mosaic/core/src/util/AsyncDispatch.ts | 122 +++++++++++++----- .../mosaic/core/src/util/ObserveDispatch.ts | 54 ++++++++ 2 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 packages/mosaic/core/src/util/ObserveDispatch.ts diff --git a/packages/mosaic/core/src/util/AsyncDispatch.ts b/packages/mosaic/core/src/util/AsyncDispatch.ts index 592dc3fdb..580799c05 100644 --- a/packages/mosaic/core/src/util/AsyncDispatch.ts +++ b/packages/mosaic/core/src/util/AsyncDispatch.ts @@ -1,4 +1,4 @@ -type EventCallback = (value: T) => void | Promise; +export type EventCallback = (value: T) => void | Promise; interface DispatchEntry { callbacks: Set>; @@ -12,16 +12,12 @@ interface QueueNode { } /** - * Event dispatcher supporting asynchronous updates. If an event handler - * callback returns a Promise, the dispatcher waits for all such Promises - * to settle before dispatching future events of the same type. + * Abstract base class for event dispatchers. + * Provides common event listener management and lifecycle methods. */ -export class AsyncDispatch { - _callbacks: Map>; +export abstract class Dispatch { + protected _callbacks: Map>>; - /** - * Create a new asynchronous dispatcher instance. - */ constructor() { this._callbacks = new Map(); } @@ -35,14 +31,10 @@ export class AsyncDispatch { */ addEventListener(type: string, callback: EventCallback): void { if (!this._callbacks.has(type)) { - this._callbacks.set(type, { - callbacks: new Set>(), - pending: null, - queue: new DispatchQueue() - }); + this._callbacks.set(type, new Set>()); } - const entry = this._callbacks.get(type)!; - entry.callbacks.add(callback); + const callbacks = this._callbacks.get(type)!; + callbacks.add(callback); } /** @@ -52,9 +44,9 @@ export class AsyncDispatch { * callback function to remove. */ removeEventListener(type: string, callback: EventCallback): void { - const entry = this._callbacks.get(type); - if (entry) { - entry.callbacks.delete(callback); + const callbacks = this._callbacks.get(type); + if (callbacks) { + callbacks.delete(callback); } } @@ -73,14 +65,14 @@ export class AsyncDispatch { /** * Lifecycle method that returns a filter function for updating the - * queue of unemitted event values prior to enqueueing a new value. + * queue of un-emitted event values prior to enqueueing a new value. * This default implementation simply returns null, indicating that - * unknown other unemitted event values should be dropped (that is, all + * unknown other un-emitted event values should be dropped (that is, all * queued events are filtered). * @param type The event type. * @param value The new event value that will be enqueued. * @returns A dispatch queue filter - * function, or null if all unemitted event values should be filtered. + * function, or null if all un-emitted event values should be filtered. */ emitQueueFilter( _type: string, // eslint-disable-line @typescript-eslint/no-unused-vars @@ -91,11 +83,72 @@ export class AsyncDispatch { } /** - * Cancel all unemitted event values for the given event type. + * Cancel all un-emitted event values for the given event type. + * @param _type The event type. + */ + cancel(_type: string): void { + // eslint-disable-line @typescript-eslint/no-unused-vars + // Default implementation - subclasses can override if needed + } + + /** + * Emit an event value to listeners for the given event type. + * @param type The event type. + * @param value The event value. + */ + abstract emit(type: string, value: T): void; +} + +/** + * Event dispatcher supporting asynchronous updates. If an event handler + * callback returns a Promise, the dispatcher waits for all such Promises + * to settle before dispatching future events of the same type. + */ +export class AsyncDispatch extends Dispatch { + private _entries: Map>; + + /** + * Create a new asynchronous dispatcher instance. + */ + constructor() { + super(); + this._entries = new Map(); + } + + /** + * Add an event listener callback for the provided event type. * @param type The event type. + * @param callback The event handler + * callback function to add. If the callback has already been + * added for the event type, this method has no effect. */ - cancel(type: string): void { - const entry = this._callbacks.get(type); + override addEventListener(type: string, callback: EventCallback): void { + super.addEventListener(type, callback); + if (!this._entries.has(type)) { + this._entries.set(type, { + callbacks: this._callbacks.get(type)!, + pending: null, + queue: new DispatchQueue(), + }); + } + } + + /** + * Remove an event listener callback for the provided event type. + * @param type The event type. + * @param callback The event handler + * callback function to remove. + */ + override removeEventListener(type: string, callback: EventCallback): void { + super.removeEventListener(type, callback); + } + + /** + * Cancel all un-emitted event values for the given event type. + * @param type The event type. + */ + override cancel(type: string): void { + const entry = this._entries.get(type); entry?.queue.clear(); } @@ -107,7 +160,7 @@ export class AsyncDispatch { * @returns A pending event promise. */ async pending(type: string): Promise { - await this._callbacks.get(type)?.pending; + await this._entries.get(type)?.pending; } /** @@ -115,12 +168,19 @@ export class AsyncDispatch { * If a previous emit has not yet resolved, the event value * will be queued to be emitted later. * The actual event value given to listeners will be the result - * of passing the input value through the emitValue() method. + * of passing the input value through the willEmit() method. * @param type The event type. * @param value The event value. */ - emit(type: string, value: T): void { - const entry = this._callbacks.get(type) || {} as DispatchEntry; + override emit(type: string, value: T): void { + if (!this._entries.has(type)) { + this._entries.set(type, { + callbacks: this._callbacks.get(type) || new Set>(), + pending: null, + queue: new DispatchQueue(), + }); + } + const entry = this._entries.get(type)!; if (entry.pending) { // an earlier emit is still processing // enqueue the current update, possibly filtering other pending updates @@ -131,7 +191,7 @@ export class AsyncDispatch { if (callbacks?.size) { // broadcast update to callbacks, which may return promises // wait until promises resolve, then process pending updates - const callbackValues = Array.from(callbacks, cb => cb(event)); + const callbackValues = Array.from(callbacks, (cb) => cb(event)); entry.pending = Promise.allSettled(callbackValues).then(() => { entry.pending = null; if (!queue.isEmpty()) { @@ -144,7 +204,7 @@ export class AsyncDispatch { } /** - * Queue for managing unemitted event values. + * Queue for managing un-emitted event values. */ export class DispatchQueue { next: QueueNode | null = null; diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts new file mode 100644 index 000000000..dbc9d2e40 --- /dev/null +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -0,0 +1,54 @@ +import { Dispatch, EventCallback } from "./AsyncDispatch.js"; + +/** + * Synchronous event dispatcher that pushes events directly to callbacks + * without queuing or Promise handling. Suitable for simple observer patterns. + */ +export class ObserveDispatch extends Dispatch { + constructor() { + super(); + } + + /** + * Subscribe to events of the given type. Essentially + * a shorthand/wrapper for addEventListener(). + * @param type The event type. + * @param callback The event handler callback function to add. + * @returns This ObserveDispatch instance for method chaining. + */ + subscribe(type: string, callback: EventCallback): this { + this.addEventListener(type, callback); + return this; + } + + /** + * Unsubscribe from events of the given type. Essentially + * a shorthand/wrapper for removeEventListener(). + * @param type The event type. + * @param callback The event handler callback function to remove. + * @returns This ObserveDispatch instance for method chaining. + */ + unsubscribe(type: string, callback: EventCallback): this { + this.removeEventListener(type, callback); + return this; + } + + /** + * Emit an event value to listeners for the given event type. + * Events are dispatched *synchronously* to all registered callbacks. + * This is different from the AsyncDispatch class, which use queues to + * manage un-emitted event values. + * @param type The event type. + * @param value The event value. + */ + override emit(type: string, value: T): void { + const callbacks = this._callbacks.get(type); + if (callbacks && callbacks.size > 0) { + const event = this.willEmit(type, value); + // Execute all callbacks synchronously + for (const callback of callbacks) { + callback(event); + } + } + } +} From 5fd08b9e9b7d29f6791df390023149d0bd47d07b Mon Sep 17 00:00:00 2001 From: Vedanta Somnathe Date: Wed, 24 Dec 2025 18:33:36 -0600 Subject: [PATCH 02/30] small change in cancel() --- packages/mosaic/core/src/util/AsyncDispatch.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/mosaic/core/src/util/AsyncDispatch.ts b/packages/mosaic/core/src/util/AsyncDispatch.ts index 580799c05..def4dcf16 100644 --- a/packages/mosaic/core/src/util/AsyncDispatch.ts +++ b/packages/mosaic/core/src/util/AsyncDispatch.ts @@ -86,10 +86,7 @@ export abstract class Dispatch { * Cancel all un-emitted event values for the given event type. * @param _type The event type. */ - cancel(_type: string): void { - // eslint-disable-line @typescript-eslint/no-unused-vars - // Default implementation - subclasses can override if needed - } + cancel(_type: string): void {} /** * Emit an event value to listeners for the given event type. From 9e5729ab508aebef3abebb0698daa568114abf06 Mon Sep 17 00:00:00 2001 From: Vedanta Somnathe Date: Wed, 24 Dec 2025 18:39:27 -0600 Subject: [PATCH 03/30] terminology -- observe --- packages/mosaic/core/src/util/ObserveDispatch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index dbc9d2e40..441ed7807 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -16,7 +16,7 @@ export class ObserveDispatch extends Dispatch { * @param callback The event handler callback function to add. * @returns This ObserveDispatch instance for method chaining. */ - subscribe(type: string, callback: EventCallback): this { + observe(type: string, callback: EventCallback): this { this.addEventListener(type, callback); return this; } @@ -28,7 +28,7 @@ export class ObserveDispatch extends Dispatch { * @param callback The event handler callback function to remove. * @returns This ObserveDispatch instance for method chaining. */ - unsubscribe(type: string, callback: EventCallback): this { + unobserve(type: string, callback: EventCallback): this { this.removeEventListener(type, callback); return this; } From 85cf4f46c224b6ae9e09253ebae218089e697802 Mon Sep 17 00:00:00 2001 From: Vedanta Somnathe Date: Sat, 27 Dec 2025 23:30:58 -0600 Subject: [PATCH 04/30] Event bus and logger observe() API in effect --- dev/index.html | 3 +- packages/mosaic/core/src/Coordinator.ts | 30 +++++++++++-- packages/mosaic/core/src/EventBus.ts | 49 ++++++++++++++++++++ packages/mosaic/core/src/QueryManager.ts | 57 +++++++++++++++++------- packages/mosaic/core/src/types.ts | 1 + 5 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 packages/mosaic/core/src/EventBus.ts diff --git a/dev/index.html b/dev/index.html index 71bcd4acb..e0287481d 100644 --- a/dev/index.html +++ b/dev/index.html @@ -156,7 +156,8 @@ } function setQueryLog() { - vg.coordinator().manager.logQueries(qlogToggle.checked); + // logQueries is no longer in use + // vg.coordinator().manager.logQueries(qlogToggle.checked); } function setCache() { diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 7c66d285e..4e2c056a6 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -11,6 +11,7 @@ import { type MosaicClient } from './MosaicClient.js'; import { type SelectionClause } from './SelectionClause.js'; import { MaybeArray } from '@uwdata/mosaic-sql'; import { Table } from '@uwdata/flechette'; +import { EventBus, EventType } from './EventBus.js'; interface FilterGroupEntry { selection: Selection; @@ -50,6 +51,7 @@ export class Coordinator { public clients = new Set; public filterGroups = new Map; protected _logger: Logger = voidLogger(); + public eventBus: EventBus; /** * @param db Database connector. Defaults to a web socket connection. @@ -77,7 +79,9 @@ export class Coordinator { consolidate = true, preagg = {} } = options; + this.eventBus = new EventBus(); this.manager = manager; + this.manager.eventBus = this.eventBus; this.manager.cache(cache); this.manager.consolidate(consolidate); this.databaseConnector(db); @@ -126,6 +130,11 @@ export class Coordinator { if (arguments.length) { this._logger = logger || voidLogger(); this.manager.logger(this._logger); + // subscribe logger to events + this.eventBus.observe(EventType.QueryStart, (event) => this._logger.info('Query started:', event)); + this.eventBus.observe(EventType.QueryEnd, (event) => this._logger.info('Query ended:', event)); + this.eventBus.observe(EventType.ClientConnect, (event) => this._logger.info('Client connected:', event)); + this.eventBus.observe(EventType.Error, (event) => this._logger.error('Error:', event.message)); } return this._logger!; } @@ -194,6 +203,7 @@ export class Coordinator { cache?: boolean; persist?: boolean; priority?: number; + clientId?: string; [key: string]: unknown; } = {} ): QueryResult { @@ -201,9 +211,10 @@ export class Coordinator { type = 'arrow', cache = true, priority = Priority.Normal, + clientId, ...otherOptions } = options; - return this.manager.request({ type, query, cache, options: otherOptions }, priority); + return this.manager.request({ type, query, cache, options: otherOptions, clientId }, priority); } /** @@ -245,12 +256,12 @@ export class Coordinator { priority: number = Priority.Normal ): Promise { client.queryPending(); - return client._pending = this.query(query, { priority }) + return client._pending = this.query(query, { priority, clientId: (client as any).id }) .then( data => client.queryResult(data).update(), - err => { this._logger?.error(err); client.queryError(err); } + err => { this.eventBus.emit(EventType.Error, { message: err, timestamp: Date.now() }); client.queryError(err); } ) - .catch(err => this._logger?.error(err)); + .catch(err => { this.eventBus.emit(EventType.Error, { message: err, timestamp: Date.now() }); }); } /** @@ -279,6 +290,17 @@ export class Coordinator { throw new Error('Client already connected.'); } + // assign client id if not present + if (!(client as any).id) { + (client as any).id = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + // emit ClientConnect + this.eventBus.emit(EventType.ClientConnect, { + clientId: (client as any).id, + timestamp: Date.now() + }); + // add client to client set clients?.add(client); diff --git a/packages/mosaic/core/src/EventBus.ts b/packages/mosaic/core/src/EventBus.ts new file mode 100644 index 000000000..617c7c01a --- /dev/null +++ b/packages/mosaic/core/src/EventBus.ts @@ -0,0 +1,49 @@ +import { ObserveDispatch } from './util/ObserveDispatch.js'; + +export enum EventType { + QueryStart = 'query-start', + QueryEnd = 'query-end', + ClientConnect = 'client-connect', + ClientStateChange = 'client-state-change', + Error = 'error' +} + +export interface QueryStartEvent { + query: string; + materialized: boolean; + clientId?: string; + timestamp: number; +} + +export interface QueryEndEvent { + query: string; + materialized: boolean; + clientId?: string; + timestamp: number; +} + +export interface ClientConnectEvent { + clientId: string; + timestamp: number; +} + +export interface ErrorEvent { + message: unknown; + timestamp: number; +} + +export class EventBus { + private dispatch = new ObserveDispatch(); + + observe(type: EventType, callback: (value: any) => void): void { + this.dispatch.observe(type, callback); + } + + unobserve(type: EventType, callback: (value: any) => void): void { + this.dispatch.unobserve(type, callback); + } + + emit(type: EventType, value: any): void { + this.dispatch.emit(type, value); + } +} diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 776fbcd29..67b842cab 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -5,6 +5,7 @@ import { lruCache, voidCache } from './util/cache.js'; import { PriorityQueue } from './util/priority-queue.js'; import { QueryResult, QueryState } from './util/query-result.js'; import { voidLogger } from './util/void-logger.js'; +import { EventBus, EventType } from './EventBus.js'; export const Priority = Object.freeze({ High: 0, Normal: 1, Low: 2 }); @@ -13,23 +14,23 @@ export class QueryManager { private db: Connector | null; private clientCache: Cache | null; private _logger: Logger; - private _logQueries: boolean; private _consolidate: ReturnType | null; /** Requests pending with the query manager. */ public pendingResults: QueryResult[]; private maxConcurrentRequests: number; private pendingExec: boolean; + public eventBus?: EventBus; - constructor(maxConcurrentRequests: number = 32) { + constructor(maxConcurrentRequests: number = 32, eventBus?: EventBus) { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; this._logger = voidLogger(); - this._logQueries = false; this._consolidate = null; this.pendingResults = []; this.maxConcurrentRequests = maxConcurrentRequests; this.pendingExec = false; + this.eventBus = eventBus; } next(): void { @@ -77,9 +78,20 @@ export class QueryManager { */ async submit(request: QueryRequest, result: QueryResult): Promise { try { - const { query, type, cache = false, options } = request; + const { query, type, cache = false, options, clientId } = request; const sql = Array.isArray(query) ? query.filter(x => x).join(';\n') : query ? String(query) : null; + // emit QueryStart + // this `if` check is our version of the `logQueries` flag + if (this.eventBus) { + this.eventBus.emit(EventType.QueryStart, { + query: sql || '', + materialized: cache, + clientId, + timestamp: Date.now() + }); + } + // check query cache if (cache) { const cached = this.clientCache!.get(sql!); @@ -87,13 +99,22 @@ export class QueryManager { const data = await cached; this._logger.debug('Cache'); result.ready(data); + // emit QueryEnd for cached + if (this.eventBus) { + this.eventBus.emit(EventType.QueryEnd, { + query: sql || '', + materialized: cache, + clientId, + timestamp: Date.now() + }); + } return; } } // issue query, potentially cache result const t0 = performance.now(); - if (this._logQueries) { + if (this.eventBus) { this._logger.debug('Query', { type, sql, ...options }); } @@ -107,7 +128,22 @@ export class QueryManager { this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`); result.ready(type === 'exec' ? null : data); + + if (this.eventBus) { + this.eventBus.emit(EventType.QueryEnd, { + query: sql || '', + materialized: cache, + clientId, + timestamp: Date.now() + }); + } } catch (err) { + if (this.eventBus) { + this.eventBus.emit(EventType.Error, { + message: err, + timestamp: Date.now() + }); + } result.reject(err); } } @@ -136,17 +172,6 @@ export class QueryManager { return value ? (this._logger = value) : this._logger; } - /** - * Get or set if queries should be logged. - * @param value Whether to log queries - * @returns Current logging state - */ - logQueries(): boolean; - logQueries(value: boolean): boolean; - logQueries(value?: boolean): boolean { - return value !== undefined ? this._logQueries = !!value : this._logQueries; - } - /** * Get or set the database connector. * @param connector Connector to set diff --git a/packages/mosaic/core/src/types.ts b/packages/mosaic/core/src/types.ts index 45f8a06bd..652b6d17f 100644 --- a/packages/mosaic/core/src/types.ts +++ b/packages/mosaic/core/src/types.ts @@ -15,6 +15,7 @@ export interface QueryRequest { query: MaybeArray; cache?: boolean; options?: Record; + clientId?: string; } /** Type for an entry within a query manager. */ From a82c5a44e70c1d651ed2376d1e0da79bfe6b69fe Mon Sep 17 00:00:00 2001 From: Vedanta Date: Wed, 7 Jan 2026 23:10:39 -0600 Subject: [PATCH 05/30] timestamp + abstractions --- packages/mosaic/core/src/Coordinator.ts | 8 ++++---- packages/mosaic/core/src/EventBus.ts | 20 ++++++++++--------- packages/mosaic/core/src/QueryManager.ts | 4 ---- .../mosaic/core/src/util/AsyncDispatch.ts | 10 ---------- 4 files changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 4e2c056a6..0d5e1c0dd 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -130,6 +130,7 @@ export class Coordinator { if (arguments.length) { this._logger = logger || voidLogger(); this.manager.logger(this._logger); + // subscribe logger to events this.eventBus.observe(EventType.QueryStart, (event) => this._logger.info('Query started:', event)); this.eventBus.observe(EventType.QueryEnd, (event) => this._logger.info('Query ended:', event)); @@ -259,9 +260,9 @@ export class Coordinator { return client._pending = this.query(query, { priority, clientId: (client as any).id }) .then( data => client.queryResult(data).update(), - err => { this.eventBus.emit(EventType.Error, { message: err, timestamp: Date.now() }); client.queryError(err); } + err => { this.eventBus.emit(EventType.Error, { message: err }); client.queryError(err); } ) - .catch(err => { this.eventBus.emit(EventType.Error, { message: err, timestamp: Date.now() }); }); + .catch(err => { this.eventBus.emit(EventType.Error, { message: err }); }); } /** @@ -297,8 +298,7 @@ export class Coordinator { // emit ClientConnect this.eventBus.emit(EventType.ClientConnect, { - clientId: (client as any).id, - timestamp: Date.now() + clientId: (client as any).id }); // add client to client set diff --git a/packages/mosaic/core/src/EventBus.ts b/packages/mosaic/core/src/EventBus.ts index 617c7c01a..da2312381 100644 --- a/packages/mosaic/core/src/EventBus.ts +++ b/packages/mosaic/core/src/EventBus.ts @@ -7,29 +7,29 @@ export enum EventType { ClientStateChange = 'client-state-change', Error = 'error' } +export interface MosaicEvent { + timestamp: number; + // Extend later with more fields +} -export interface QueryStartEvent { +export interface QueryStartEvent extends MosaicEvent { query: string; materialized: boolean; clientId?: string; - timestamp: number; } -export interface QueryEndEvent { +export interface QueryEndEvent extends MosaicEvent { query: string; materialized: boolean; clientId?: string; - timestamp: number; } -export interface ClientConnectEvent { +export interface ClientConnectEvent extends MosaicEvent { clientId: string; - timestamp: number; } -export interface ErrorEvent { +export interface ErrorEvent extends MosaicEvent { message: unknown; - timestamp: number; } export class EventBus { @@ -44,6 +44,8 @@ export class EventBus { } emit(type: EventType, value: any): void { - this.dispatch.emit(type, value); + // Provides extensibility over ObservableDispatch to add fields, + // additional logic relevant to Mosaic + this.dispatch.emit(type, { ...value, timestamp: Date.now() }); } } diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 67b842cab..01220513c 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -88,7 +88,6 @@ export class QueryManager { query: sql || '', materialized: cache, clientId, - timestamp: Date.now() }); } @@ -105,7 +104,6 @@ export class QueryManager { query: sql || '', materialized: cache, clientId, - timestamp: Date.now() }); } return; @@ -134,14 +132,12 @@ export class QueryManager { query: sql || '', materialized: cache, clientId, - timestamp: Date.now() }); } } catch (err) { if (this.eventBus) { this.eventBus.emit(EventType.Error, { message: err, - timestamp: Date.now() }); } result.reject(err); diff --git a/packages/mosaic/core/src/util/AsyncDispatch.ts b/packages/mosaic/core/src/util/AsyncDispatch.ts index def4dcf16..589a3d6a2 100644 --- a/packages/mosaic/core/src/util/AsyncDispatch.ts +++ b/packages/mosaic/core/src/util/AsyncDispatch.ts @@ -130,16 +130,6 @@ export class AsyncDispatch extends Dispatch { } } - /** - * Remove an event listener callback for the provided event type. - * @param type The event type. - * @param callback The event handler - * callback function to remove. - */ - override removeEventListener(type: string, callback: EventCallback): void { - super.removeEventListener(type, callback); - } - /** * Cancel all un-emitted event values for the given event type. * @param type The event type. From a7c7ceabb99ef2232e1878c0a13b41202b31b209 Mon Sep 17 00:00:00 2001 From: Vedanta Date: Wed, 7 Jan 2026 23:10:39 -0600 Subject: [PATCH 06/30] Moving logger on client as observer --- dev/index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev/index.html b/dev/index.html index e0287481d..96f7e51e8 100644 --- a/dev/index.html +++ b/dev/index.html @@ -158,6 +158,9 @@ function setQueryLog() { // logQueries is no longer in use // vg.coordinator().manager.logQueries(qlogToggle.checked); + // + // + // Add custom observer here that redirects logs to concolse } function setCache() { From bf9f9f9bbf8a016bd7684e7bd0cf8a3002d1b577 Mon Sep 17 00:00:00 2001 From: Vedanta Date: Thu, 8 Jan 2026 10:02:20 -0600 Subject: [PATCH 07/30] removed client id --- packages/mosaic/core/src/Coordinator.ts | 13 +++---------- packages/mosaic/core/src/EventBus.ts | 4 +--- packages/mosaic/core/src/QueryManager.ts | 5 +---- packages/mosaic/core/src/types.ts | 1 - 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 0d5e1c0dd..df7ea3dc1 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -204,7 +204,6 @@ export class Coordinator { cache?: boolean; persist?: boolean; priority?: number; - clientId?: string; [key: string]: unknown; } = {} ): QueryResult { @@ -212,10 +211,9 @@ export class Coordinator { type = 'arrow', cache = true, priority = Priority.Normal, - clientId, ...otherOptions } = options; - return this.manager.request({ type, query, cache, options: otherOptions, clientId }, priority); + return this.manager.request({ type, query, cache, options: otherOptions }, priority); } /** @@ -257,7 +255,7 @@ export class Coordinator { priority: number = Priority.Normal ): Promise { client.queryPending(); - return client._pending = this.query(query, { priority, clientId: (client as any).id }) + return client._pending = this.query(query, { priority }) .then( data => client.queryResult(data).update(), err => { this.eventBus.emit(EventType.Error, { message: err }); client.queryError(err); } @@ -291,14 +289,9 @@ export class Coordinator { throw new Error('Client already connected.'); } - // assign client id if not present - if (!(client as any).id) { - (client as any).id = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } - // emit ClientConnect this.eventBus.emit(EventType.ClientConnect, { - clientId: (client as any).id + // additional arguments for later -- for comprehensive client data }); // add client to client set diff --git a/packages/mosaic/core/src/EventBus.ts b/packages/mosaic/core/src/EventBus.ts index da2312381..bde86c30c 100644 --- a/packages/mosaic/core/src/EventBus.ts +++ b/packages/mosaic/core/src/EventBus.ts @@ -15,17 +15,15 @@ export interface MosaicEvent { export interface QueryStartEvent extends MosaicEvent { query: string; materialized: boolean; - clientId?: string; } export interface QueryEndEvent extends MosaicEvent { query: string; materialized: boolean; - clientId?: string; } export interface ClientConnectEvent extends MosaicEvent { - clientId: string; + clientId?: string } export interface ErrorEvent extends MosaicEvent { diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 01220513c..626ef87a7 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -78,7 +78,7 @@ export class QueryManager { */ async submit(request: QueryRequest, result: QueryResult): Promise { try { - const { query, type, cache = false, options, clientId } = request; + const { query, type, cache = false, options } = request; const sql = Array.isArray(query) ? query.filter(x => x).join(';\n') : query ? String(query) : null; // emit QueryStart @@ -87,7 +87,6 @@ export class QueryManager { this.eventBus.emit(EventType.QueryStart, { query: sql || '', materialized: cache, - clientId, }); } @@ -103,7 +102,6 @@ export class QueryManager { this.eventBus.emit(EventType.QueryEnd, { query: sql || '', materialized: cache, - clientId, }); } return; @@ -131,7 +129,6 @@ export class QueryManager { this.eventBus.emit(EventType.QueryEnd, { query: sql || '', materialized: cache, - clientId, }); } } catch (err) { diff --git a/packages/mosaic/core/src/types.ts b/packages/mosaic/core/src/types.ts index 652b6d17f..45f8a06bd 100644 --- a/packages/mosaic/core/src/types.ts +++ b/packages/mosaic/core/src/types.ts @@ -15,7 +15,6 @@ export interface QueryRequest { query: MaybeArray; cache?: boolean; options?: Record; - clientId?: string; } /** Type for an entry within a query manager. */ From 35cb08b8644065284d6b3b06a2a2e7ddfdf77c2a Mon Sep 17 00:00:00 2001 From: Vedanta Date: Fri, 9 Jan 2026 16:18:03 -0600 Subject: [PATCH 08/30] removed event bus & replaced w/ ObserveDispatch --- packages/mosaic/core/src/Coordinator.ts | 7 ++-- packages/mosaic/core/src/EventBus.ts | 49 ------------------------ packages/mosaic/core/src/Events.ts | 30 +++++++++++++++ packages/mosaic/core/src/QueryManager.ts | 7 ++-- 4 files changed, 38 insertions(+), 55 deletions(-) delete mode 100644 packages/mosaic/core/src/EventBus.ts create mode 100644 packages/mosaic/core/src/Events.ts diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index df7ea3dc1..2e64e29d9 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -11,7 +11,8 @@ import { type MosaicClient } from './MosaicClient.js'; import { type SelectionClause } from './SelectionClause.js'; import { MaybeArray } from '@uwdata/mosaic-sql'; import { Table } from '@uwdata/flechette'; -import { EventBus, EventType } from './EventBus.js'; +import { EventType } from './Events.js'; +import { ObserveDispatch } from './util/ObserveDispatch.js'; interface FilterGroupEntry { selection: Selection; @@ -51,7 +52,7 @@ export class Coordinator { public clients = new Set; public filterGroups = new Map; protected _logger: Logger = voidLogger(); - public eventBus: EventBus; + public eventBus: ObserveDispatch; // temporary measures until we finalize event types /** * @param db Database connector. Defaults to a web socket connection. @@ -79,7 +80,7 @@ export class Coordinator { consolidate = true, preagg = {} } = options; - this.eventBus = new EventBus(); + this.eventBus = new ObserveDispatch(); this.manager = manager; this.manager.eventBus = this.eventBus; this.manager.cache(cache); diff --git a/packages/mosaic/core/src/EventBus.ts b/packages/mosaic/core/src/EventBus.ts deleted file mode 100644 index bde86c30c..000000000 --- a/packages/mosaic/core/src/EventBus.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ObserveDispatch } from './util/ObserveDispatch.js'; - -export enum EventType { - QueryStart = 'query-start', - QueryEnd = 'query-end', - ClientConnect = 'client-connect', - ClientStateChange = 'client-state-change', - Error = 'error' -} -export interface MosaicEvent { - timestamp: number; - // Extend later with more fields -} - -export interface QueryStartEvent extends MosaicEvent { - query: string; - materialized: boolean; -} - -export interface QueryEndEvent extends MosaicEvent { - query: string; - materialized: boolean; -} - -export interface ClientConnectEvent extends MosaicEvent { - clientId?: string -} - -export interface ErrorEvent extends MosaicEvent { - message: unknown; -} - -export class EventBus { - private dispatch = new ObserveDispatch(); - - observe(type: EventType, callback: (value: any) => void): void { - this.dispatch.observe(type, callback); - } - - unobserve(type: EventType, callback: (value: any) => void): void { - this.dispatch.unobserve(type, callback); - } - - emit(type: EventType, value: any): void { - // Provides extensibility over ObservableDispatch to add fields, - // additional logic relevant to Mosaic - this.dispatch.emit(type, { ...value, timestamp: Date.now() }); - } -} diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts new file mode 100644 index 000000000..edd9d3fe8 --- /dev/null +++ b/packages/mosaic/core/src/Events.ts @@ -0,0 +1,30 @@ +export enum EventType { + QueryStart = 'query-start', + QueryEnd = 'query-end', + ClientConnect = 'client-connect', + ClientStateChange = 'client-state-change', + Error = 'error' +} + +export interface MosaicEvent { + timestamp: number; + // Extend later with more fields +} + +export interface QueryStartEvent extends MosaicEvent { + query: string; + materialized: boolean; +} + +export interface QueryEndEvent extends MosaicEvent { + query: string; + materialized: boolean; +} + +export interface ClientConnectEvent extends MosaicEvent { + clientId?: string; +} + +export interface ErrorEvent extends MosaicEvent { + message: unknown; +} diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 626ef87a7..8157fa9a3 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -5,7 +5,8 @@ import { lruCache, voidCache } from './util/cache.js'; import { PriorityQueue } from './util/priority-queue.js'; import { QueryResult, QueryState } from './util/query-result.js'; import { voidLogger } from './util/void-logger.js'; -import { EventBus, EventType } from './EventBus.js'; +import { EventType } from './Events.js'; +import { ObserveDispatch } from './util/ObserveDispatch.js'; export const Priority = Object.freeze({ High: 0, Normal: 1, Low: 2 }); @@ -19,9 +20,9 @@ export class QueryManager { public pendingResults: QueryResult[]; private maxConcurrentRequests: number; private pendingExec: boolean; - public eventBus?: EventBus; + public eventBus?; - constructor(maxConcurrentRequests: number = 32, eventBus?: EventBus) { + constructor(maxConcurrentRequests: number = 32, eventBus?: ObserveDispatch) { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; From 496492d7953152ac6d336c7de2f33b5686aa73be Mon Sep 17 00:00:00 2001 From: Vedanta Date: Sat, 17 Jan 2026 07:52:10 -0600 Subject: [PATCH 09/30] stronger typesafety --- packages/mosaic/core/src/Coordinator.ts | 6 ++--- packages/mosaic/core/src/Events.ts | 23 +++++++++++++++---- packages/mosaic/core/src/QueryManager.ts | 4 ++-- .../mosaic/core/src/util/ObserveDispatch.ts | 2 +- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 2e64e29d9..bade18fdc 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -11,7 +11,7 @@ import { type MosaicClient } from './MosaicClient.js'; import { type SelectionClause } from './SelectionClause.js'; import { MaybeArray } from '@uwdata/mosaic-sql'; import { Table } from '@uwdata/flechette'; -import { EventType } from './Events.js'; +import { EventType, MosaicEvent, MosaicEvents } from './Events.js'; import { ObserveDispatch } from './util/ObserveDispatch.js'; interface FilterGroupEntry { @@ -52,7 +52,7 @@ export class Coordinator { public clients = new Set; public filterGroups = new Map; protected _logger: Logger = voidLogger(); - public eventBus: ObserveDispatch; // temporary measures until we finalize event types + public eventBus: ObserveDispatch>; /** * @param db Database connector. Defaults to a web socket connection. @@ -136,7 +136,7 @@ export class Coordinator { this.eventBus.observe(EventType.QueryStart, (event) => this._logger.info('Query started:', event)); this.eventBus.observe(EventType.QueryEnd, (event) => this._logger.info('Query ended:', event)); this.eventBus.observe(EventType.ClientConnect, (event) => this._logger.info('Client connected:', event)); - this.eventBus.observe(EventType.Error, (event) => this._logger.error('Error:', event.message)); + this.eventBus.observe(EventType.Error, (event) => this._logger.error('Error:', (event as ErrorEvent).message)); } return this._logger!; } diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index edd9d3fe8..beca18f48 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -1,11 +1,18 @@ export enum EventType { - QueryStart = 'query-start', - QueryEnd = 'query-end', - ClientConnect = 'client-connect', - ClientStateChange = 'client-state-change', - Error = 'error' + QueryStart = "query-start", + QueryEnd = "query-end", + ClientConnect = "client-connect", + ClientStateChange = "client-state-change", + Error = "error", } +export type EventMap = { + [EventType.QueryStart]: Omit; + [EventType.QueryEnd]: Omit; + [EventType.ClientConnect]: Omit; + [EventType.Error]: Omit; +}; + export interface MosaicEvent { timestamp: number; // Extend later with more fields @@ -28,3 +35,9 @@ export interface ClientConnectEvent extends MosaicEvent { export interface ErrorEvent extends MosaicEvent { message: unknown; } + +export type MosaicEvents = + | QueryStartEvent + | QueryEndEvent + | ClientConnectEvent + | ErrorEvent; diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 8157fa9a3..04a904810 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -5,7 +5,7 @@ import { lruCache, voidCache } from './util/cache.js'; import { PriorityQueue } from './util/priority-queue.js'; import { QueryResult, QueryState } from './util/query-result.js'; import { voidLogger } from './util/void-logger.js'; -import { EventType } from './Events.js'; +import { EventType, MosaicEvent, MosaicEvents } from './Events.js'; import { ObserveDispatch } from './util/ObserveDispatch.js'; export const Priority = Object.freeze({ High: 0, Normal: 1, Low: 2 }); @@ -22,7 +22,7 @@ export class QueryManager { private pendingExec: boolean; public eventBus?; - constructor(maxConcurrentRequests: number = 32, eventBus?: ObserveDispatch) { + constructor(maxConcurrentRequests: number = 32, eventBus?: ObserveDispatch>) { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index 441ed7807..5e055aad6 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -44,7 +44,7 @@ export class ObserveDispatch extends Dispatch { override emit(type: string, value: T): void { const callbacks = this._callbacks.get(type); if (callbacks && callbacks.size > 0) { - const event = this.willEmit(type, value); + const event = this.willEmit(type, { ...value, timestamp: new Date() }); // Execute all callbacks synchronously for (const callback of callbacks) { callback(event); From daf93327b95e37a498a2de91ea3b634fa28f0ee4 Mon Sep 17 00:00:00 2001 From: Vedanta Date: Sat, 17 Jan 2026 09:01:46 -0600 Subject: [PATCH 10/30] logger removed -- client now subscribes with eventBus --- .prettierrc.json | 6 ++ dev/index.html | 15 +++-- dev/query/index.html | 15 +++++ packages/mosaic/core/src/Coordinator.ts | 29 +-------- packages/mosaic/core/src/QueryManager.ts | 64 +++++-------------- .../mosaic/core/src/preagg/PreAggregator.ts | 3 +- packages/mosaic/core/src/util/void-logger.ts | 23 ------- 7 files changed, 52 insertions(+), 103 deletions(-) create mode 100644 .prettierrc.json delete mode 100644 packages/mosaic/core/src/util/void-logger.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..1568571b3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 90 +} diff --git a/dev/index.html b/dev/index.html index 96f7e51e8..d9904ab0b 100644 --- a/dev/index.html +++ b/dev/index.html @@ -156,11 +156,16 @@ } function setQueryLog() { - // logQueries is no longer in use - // vg.coordinator().manager.logQueries(qlogToggle.checked); - // - // - // Add custom observer here that redirects logs to concolse + const bus = vg.coordinator().eventBus; + + bus.observe("QueryStart", (event) => + console.info("Query started:", event), + ); + bus.observe("QueryEnd", (event) => console.info("Query ended:", event)); + bus.observe("ClientConnect", (event) => + console.info("Client connected:", event), + ); + bus.observe("Error", (event) => console.error("Error:", event)); } function setCache() { diff --git a/dev/query/index.html b/dev/query/index.html index 1d8e523ca..ba8461068 100644 --- a/dev/query/index.html +++ b/dev/query/index.html @@ -73,6 +73,21 @@ } enable(); }); + + function setQueryLog() { + const bus = vg.coordinator().eventBus; + + bus.observe("query-start", (event) => + console.info("Query started:", event), + ); + bus.observe("query-end", (event) => console.info("Query ended:", event)); + bus.observe("client-connect", (event) => + console.info("Client connected:", event), + ); + bus.observe("error", (event) => console.error("Error:", event)); + } + + setQueryLog() diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index bade18fdc..d0150774e 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -2,10 +2,9 @@ import { SocketConnector } from './connectors/socket.js'; import { type Connector } from './connectors/Connector.js'; import { PreAggregator, type PreAggregateInfo, type PreAggregateOptions } from './preagg/PreAggregator.js'; -import { voidLogger } from './util/void-logger.js'; import { QueryManager, Priority } from './QueryManager.js'; import { type Selection } from './Selection.js'; -import { type Logger, type QueryType } from './types.js'; +import { type QueryType } from './types.js'; import { type QueryResult } from './util/query-result.js'; import { type MosaicClient } from './MosaicClient.js'; import { type SelectionClause } from './SelectionClause.js'; @@ -51,13 +50,11 @@ export class Coordinator { public preaggregator: PreAggregator; public clients = new Set; public filterGroups = new Map; - protected _logger: Logger = voidLogger(); public eventBus: ObserveDispatch>; /** * @param db Database connector. Defaults to a web socket connection. * @param options Coordinator options. - * @param options.logger The logger to use, defaults to `console`. * @param options.manager The query manager to use. * @param options.cache Boolean flag to enable/disable query caching. * @param options.consolidate Boolean flag to enable/disable query consolidation. @@ -66,7 +63,6 @@ export class Coordinator { constructor( db: Connector = new SocketConnector(), options: { - logger?: Logger | null; manager?: QueryManager; cache?: boolean; consolidate?: boolean; @@ -74,7 +70,6 @@ export class Coordinator { } = {} ) { const { - logger = console, manager = new QueryManager(), cache = true, consolidate = true, @@ -86,7 +81,6 @@ export class Coordinator { this.manager.cache(cache); this.manager.consolidate(consolidate); this.databaseConnector(db); - this.logger(logger); this.clear(); this.preaggregator = new PreAggregator(this, preagg); } @@ -121,26 +115,7 @@ export class Coordinator { ? this.manager.connector(db) : this.manager.connector(); } - - /** - * Get or set the logger. - * @param logger The logger to use. - * @returns The current logger - */ - logger(logger?: Logger | null): Logger { - if (arguments.length) { - this._logger = logger || voidLogger(); - this.manager.logger(this._logger); - - // subscribe logger to events - this.eventBus.observe(EventType.QueryStart, (event) => this._logger.info('Query started:', event)); - this.eventBus.observe(EventType.QueryEnd, (event) => this._logger.info('Query ended:', event)); - this.eventBus.observe(EventType.ClientConnect, (event) => this._logger.info('Client connected:', event)); - this.eventBus.observe(EventType.Error, (event) => this._logger.error('Error:', (event as ErrorEvent).message)); - } - return this._logger!; - } - + // -- Query Management ---- /** diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 04a904810..2d6003c6a 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -1,10 +1,9 @@ import type { Connector } from './connectors/Connector.js'; -import type { Cache, Logger, QueryEntry, QueryRequest } from './types.js'; +import type { Cache, QueryEntry, QueryRequest } from './types.js'; import { consolidator } from './QueryConsolidator.js'; import { lruCache, voidCache } from './util/cache.js'; import { PriorityQueue } from './util/priority-queue.js'; import { QueryResult, QueryState } from './util/query-result.js'; -import { voidLogger } from './util/void-logger.js'; import { EventType, MosaicEvent, MosaicEvents } from './Events.js'; import { ObserveDispatch } from './util/ObserveDispatch.js'; @@ -14,7 +13,6 @@ export class QueryManager { private queue: PriorityQueue; private db: Connector | null; private clientCache: Cache | null; - private _logger: Logger; private _consolidate: ReturnType | null; /** Requests pending with the query manager. */ public pendingResults: QueryResult[]; @@ -26,7 +24,6 @@ export class QueryManager { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; - this._logger = voidLogger(); this._consolidate = null; this.pendingResults = []; this.maxConcurrentRequests = maxConcurrentRequests; @@ -54,7 +51,7 @@ export class QueryManager { if (result.state === QueryState.ready) { result.fulfill(); } else if (result.state === QueryState.done) { - this._logger.warn('Found resolved query in pending results.'); + this.eventBus?.emit(EventType.Error, { message: 'Found resolved query in pending results.' }); } } if (request.type === 'exec') this.pendingExec = false; @@ -82,38 +79,27 @@ export class QueryManager { const { query, type, cache = false, options } = request; const sql = Array.isArray(query) ? query.filter(x => x).join(';\n') : query ? String(query) : null; - // emit QueryStart - // this `if` check is our version of the `logQueries` flag - if (this.eventBus) { - this.eventBus.emit(EventType.QueryStart, { - query: sql || '', - materialized: cache, - }); - } + this.eventBus?.emit(EventType.QueryStart, { + query: sql || '', + materialized: cache, + }); // check query cache if (cache) { const cached = this.clientCache!.get(sql!); if (cached) { const data = await cached; - this._logger.debug('Cache'); result.ready(data); - // emit QueryEnd for cached - if (this.eventBus) { - this.eventBus.emit(EventType.QueryEnd, { - query: sql || '', - materialized: cache, - }); - } + this.eventBus?.emit(EventType.QueryEnd, { + query: sql || '', + materialized: cache, + }); return; } } // issue query, potentially cache result const t0 = performance.now(); - if (this.eventBus) { - this._logger.debug('Query', { type, sql, ...options }); - } // @ts-expect-error type may be exec | json | arrow const promise = this.db!.query({ type, sql: sql!, ...options }); @@ -123,21 +109,16 @@ export class QueryManager { if (cache) this.clientCache!.set(sql!, data); - this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`); result.ready(type === 'exec' ? null : data); - if (this.eventBus) { - this.eventBus.emit(EventType.QueryEnd, { - query: sql || '', - materialized: cache, - }); - } + this.eventBus?.emit(EventType.QueryEnd, { + query: sql || '', + materialized: cache, + }); } catch (err) { - if (this.eventBus) { - this.eventBus.emit(EventType.Error, { - message: err, - }); - } + this.eventBus?.emit(EventType.Error, { + message: err, + }); result.reject(err); } } @@ -155,17 +136,6 @@ export class QueryManager { : this.clientCache; } - /** - * Get or set the current logger. - * @param value Logger to set - * @returns Current logger - */ - logger(): Logger; - logger(value: Logger): Logger; - logger(value?: Logger): Logger { - return value ? (this._logger = value) : this._logger; - } - /** * Get or set the database connector. * @param connector Connector to set diff --git a/packages/mosaic/core/src/preagg/PreAggregator.ts b/packages/mosaic/core/src/preagg/PreAggregator.ts index 9aa5bb1f8..daf19a655 100644 --- a/packages/mosaic/core/src/preagg/PreAggregator.ts +++ b/packages/mosaic/core/src/preagg/PreAggregator.ts @@ -5,6 +5,7 @@ import type { Selection } from '../Selection.js'; import type { BinMethod, ClauseSource, IntervalMetadata, SelectionClause } from '../SelectionClause.js'; import { fnv_hash } from '../util/hash.js'; import { preaggColumns, PreAggColumnsResult } from './preagg-columns.js'; +import { EventType } from '../Events.js'; const Skip = { skip: true, result: null }; @@ -215,7 +216,7 @@ export class PreAggregator { createSchema(schema), createTable(info.table, info.create, { temp: false }) ]); - info.result.catch((e: Error) => mc.logger().error(e)); + info.result.catch((e: Error) => mc.eventBus.emit(EventType.Error, {message: e})); } entries.set(client, info); diff --git a/packages/mosaic/core/src/util/void-logger.ts b/packages/mosaic/core/src/util/void-logger.ts deleted file mode 100644 index 87bb341cd..000000000 --- a/packages/mosaic/core/src/util/void-logger.ts +++ /dev/null @@ -1,23 +0,0 @@ -interface Logger { - debug(...args: unknown[]): void; - info(...args: unknown[]): void; - log(...args: unknown[]): void; - warn(...args: unknown[]): void; - error(...args: unknown[]): void; - group(label?: string): void; - groupCollapsed(label?: string): void; - groupEnd(): void; -} - -export function voidLogger(): Logger { - return { - debug(): void {}, - info(): void {}, - log(): void {}, - warn(): void {}, - error(): void {}, - group(): void {}, - groupCollapsed(): void {}, - groupEnd(): void {} - }; -} \ No newline at end of file From 5e00e984c0ee62b33ce25b92e2685532355c6121 Mon Sep 17 00:00:00 2001 From: Vedanta Date: Mon, 19 Jan 2026 18:36:50 -0600 Subject: [PATCH 11/30] overlooked errors --- .prettierrc.json | 6 ------ packages/mosaic/core/src/Coordinator.ts | 5 ----- packages/mosaic/core/src/Events.ts | 2 +- packages/mosaic/core/src/util/ObserveDispatch.ts | 4 ---- 4 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 .prettierrc.json diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 1568571b3..000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "semi": true, - "trailingComma": "none", - "singleQuote": true, - "printWidth": 90 -} diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index d0150774e..3127cddf3 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -265,11 +265,6 @@ export class Coordinator { throw new Error('Client already connected.'); } - // emit ClientConnect - this.eventBus.emit(EventType.ClientConnect, { - // additional arguments for later -- for comprehensive client data - }); - // add client to client set clients?.add(client); diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index beca18f48..e51ef05a4 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -33,7 +33,7 @@ export interface ClientConnectEvent extends MosaicEvent { } export interface ErrorEvent extends MosaicEvent { - message: unknown; + message: string; } export type MosaicEvents = diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index 5e055aad6..08367152c 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -5,10 +5,6 @@ import { Dispatch, EventCallback } from "./AsyncDispatch.js"; * without queuing or Promise handling. Suitable for simple observer patterns. */ export class ObserveDispatch extends Dispatch { - constructor() { - super(); - } - /** * Subscribe to events of the given type. Essentially * a shorthand/wrapper for addEventListener(). From 05be647ea6063f480280141738fe8492641f9cb2 Mon Sep 17 00:00:00 2001 From: Vedanta Date: Sat, 7 Feb 2026 13:17:39 -0600 Subject: [PATCH 12/30] warning type --- dev/index.html | 9 +++++---- dev/query/index.html | 1 + packages/mosaic/core/src/Events.ts | 10 ++++++++-- packages/mosaic/core/src/QueryManager.ts | 2 +- packages/mosaic/core/src/util/AsyncDispatch.ts | 6 +++--- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/dev/index.html b/dev/index.html index d9904ab0b..add7158fe 100644 --- a/dev/index.html +++ b/dev/index.html @@ -158,14 +158,15 @@ function setQueryLog() { const bus = vg.coordinator().eventBus; - bus.observe("QueryStart", (event) => + bus.observe("query-start", (event) => console.info("Query started:", event), ); - bus.observe("QueryEnd", (event) => console.info("Query ended:", event)); - bus.observe("ClientConnect", (event) => + bus.observe("query-end", (event) => console.info("Query ended:", event)); + bus.observe("client-connect", (event) => console.info("Client connected:", event), ); - bus.observe("Error", (event) => console.error("Error:", event)); + bus.observe("warning", (event) => console.warn("Warning:", event)); + bus.observe("error", (event) => console.error("Error:", event)); } function setCache() { diff --git a/dev/query/index.html b/dev/query/index.html index ba8461068..9b22a82c0 100644 --- a/dev/query/index.html +++ b/dev/query/index.html @@ -84,6 +84,7 @@ bus.observe("client-connect", (event) => console.info("Client connected:", event), ); + bus.observe("warning", (event) => console.warn("Warning:", event)); bus.observe("error", (event) => console.error("Error:", event)); } diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index e51ef05a4..940d7e2f7 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -3,19 +3,20 @@ export enum EventType { QueryEnd = "query-end", ClientConnect = "client-connect", ClientStateChange = "client-state-change", + Warning = "warning", Error = "error", -} +} export type EventMap = { [EventType.QueryStart]: Omit; [EventType.QueryEnd]: Omit; [EventType.ClientConnect]: Omit; + [EventType.Warning]: Omit; [EventType.Error]: Omit; }; export interface MosaicEvent { timestamp: number; - // Extend later with more fields } export interface QueryStartEvent extends MosaicEvent { @@ -32,6 +33,10 @@ export interface ClientConnectEvent extends MosaicEvent { clientId?: string; } +export interface WarningEvent extends MosaicEvent { + message: string; +} + export interface ErrorEvent extends MosaicEvent { message: string; } @@ -40,4 +45,5 @@ export type MosaicEvents = | QueryStartEvent | QueryEndEvent | ClientConnectEvent + | WarningEvent | ErrorEvent; diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 2d6003c6a..095c6f614 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -51,7 +51,7 @@ export class QueryManager { if (result.state === QueryState.ready) { result.fulfill(); } else if (result.state === QueryState.done) { - this.eventBus?.emit(EventType.Error, { message: 'Found resolved query in pending results.' }); + this.eventBus?.emit(EventType.Warning, { message: 'Found resolved query in pending results.' }); } } if (request.type === 'exec') this.pendingExec = false; diff --git a/packages/mosaic/core/src/util/AsyncDispatch.ts b/packages/mosaic/core/src/util/AsyncDispatch.ts index 589a3d6a2..b268a0158 100644 --- a/packages/mosaic/core/src/util/AsyncDispatch.ts +++ b/packages/mosaic/core/src/util/AsyncDispatch.ts @@ -65,9 +65,9 @@ export abstract class Dispatch { /** * Lifecycle method that returns a filter function for updating the - * queue of un-emitted event values prior to enqueueing a new value. + * queue of unemitted event values prior to enqueueing a new value. * This default implementation simply returns null, indicating that - * unknown other un-emitted event values should be dropped (that is, all + * unknown other unemitted event values should be dropped (that is, all * queued events are filtered). * @param type The event type. * @param value The new event value that will be enqueued. @@ -178,7 +178,7 @@ export class AsyncDispatch extends Dispatch { if (callbacks?.size) { // broadcast update to callbacks, which may return promises // wait until promises resolve, then process pending updates - const callbackValues = Array.from(callbacks, (cb) => cb(event)); + const callbackValues = Array.from(callbacks, cb => cb(event)); entry.pending = Promise.allSettled(callbackValues).then(() => { entry.pending = null; if (!queue.isEmpty()) { From fc558819d44ac4950319dbd05d91b39eabc6133a Mon Sep 17 00:00:00 2001 From: Vedanta Date: Mon, 9 Mar 2026 18:28:26 -0500 Subject: [PATCH 13/30] merge-related changes --- dev/index.html | 8 ++++---- packages/mosaic/core/src/util/ObserveDispatch.ts | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/dev/index.html b/dev/index.html index add7158fe..4215d9d18 100644 --- a/dev/index.html +++ b/dev/index.html @@ -156,7 +156,7 @@ } function setQueryLog() { - const bus = vg.coordinator().eventBus; + const bus = coordinator().eventBus; bus.observe("query-start", (event) => console.info("Query started:", event), @@ -170,15 +170,15 @@ } function setCache() { - vg.coordinator().manager.cache(cacheToggle.checked); + coordinator().manager.cache(cacheToggle.checked); } function setConsolidate() { - vg.coordinator().manager.consolidate(consolidateToggle.checked); + coordinator().manager.consolidate(consolidateToggle.checked); } function setPreAggregate() { - vg.coordinator().preaggregator.enabled = preaggToggle.checked; + coordinator().preaggregator.enabled = preaggToggle.checked; } function reload() { diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index 08367152c..cbf89adcf 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -31,9 +31,6 @@ export class ObserveDispatch extends Dispatch { /** * Emit an event value to listeners for the given event type. - * Events are dispatched *synchronously* to all registered callbacks. - * This is different from the AsyncDispatch class, which use queues to - * manage un-emitted event values. * @param type The event type. * @param value The event value. */ From 845a3481b157aa0f3da6778d055ba5091d717bdd Mon Sep 17 00:00:00 2001 From: Vedanta Date: Tue, 31 Mar 2026 14:33:14 -0500 Subject: [PATCH 14/30] devtools bus map changed + logger separately added --- dev/index.html | 2 +- packages/mosaic/core/src/Coordinator.ts | 187 ++++++++++-------- packages/mosaic/core/src/Events.ts | 88 +++++++-- packages/mosaic/core/src/QueryManager.ts | 113 +++++++---- packages/mosaic/core/src/index.ts | 1 + packages/mosaic/core/src/logger.ts | 77 ++++++++ .../mosaic/core/src/preagg/PreAggregator.ts | 67 ++++--- .../mosaic/core/src/util/ObserveDispatch.ts | 48 +++-- packages/mosaic/core/test/coordinator.test.ts | 72 +++++-- .../mosaic/core/test/query-manager.test.ts | 146 ++++++++++++-- 10 files changed, 583 insertions(+), 218 deletions(-) create mode 100644 packages/mosaic/core/src/logger.ts diff --git a/dev/index.html b/dev/index.html index 4215d9d18..32ca10af5 100644 --- a/dev/index.html +++ b/dev/index.html @@ -157,7 +157,7 @@ function setQueryLog() { const bus = coordinator().eventBus; - + bus.observe("query-start", (event) => console.info("Query started:", event), ); diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 3127cddf3..599c55ddd 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -1,17 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { SocketConnector } from './connectors/socket.js'; -import { type Connector } from './connectors/Connector.js'; -import { PreAggregator, type PreAggregateInfo, type PreAggregateOptions } from './preagg/PreAggregator.js'; -import { QueryManager, Priority } from './QueryManager.js'; -import { type Selection } from './Selection.js'; -import { type QueryType } from './types.js'; -import { type QueryResult } from './util/query-result.js'; -import { type MosaicClient } from './MosaicClient.js'; -import { type SelectionClause } from './SelectionClause.js'; -import { MaybeArray } from '@uwdata/mosaic-sql'; -import { Table } from '@uwdata/flechette'; -import { EventType, MosaicEvent, MosaicEvents } from './Events.js'; -import { ObserveDispatch } from './util/ObserveDispatch.js'; +import { SocketConnector } from "./connectors/socket.js"; +import { type Connector } from "./connectors/Connector.js"; +import { + PreAggregator, + type PreAggregateInfo, + type PreAggregateOptions, +} from "./preagg/PreAggregator.js"; +import { QueryManager, Priority } from "./QueryManager.js"; +import { type Selection } from "./Selection.js"; +import { type QueryType } from "./types.js"; +import { type QueryResult } from "./util/query-result.js"; +import { type MosaicClient } from "./MosaicClient.js"; +import { type SelectionClause } from "./SelectionClause.js"; +import { MaybeArray } from "@uwdata/mosaic-sql"; +import { Table } from "@uwdata/flechette"; +import { ErrorEvent, EventType, type MosaicEventMap } from "./Events.js"; +import { ObserveDispatch } from "./util/ObserveDispatch.js"; interface FilterGroupEntry { selection: Selection; @@ -29,9 +33,7 @@ let _instance: Coordinator; * @param instance The coordinator instance to set * @returns The coordinator instance */ -export function coordinator( - instance?: Coordinator -): Coordinator { +export function coordinator(instance?: Coordinator): Coordinator { if (instance) { _instance = instance; } else if (_instance == null) { @@ -48,9 +50,9 @@ export function coordinator( export class Coordinator { public manager: QueryManager; public preaggregator: PreAggregator; - public clients = new Set; - public filterGroups = new Map; - public eventBus: ObserveDispatch>; + public clients = new Set(); + public filterGroups = new Map(); + public eventBus: ObserveDispatch; /** * @param db Database connector. Defaults to a web socket connection. @@ -67,15 +69,15 @@ export class Coordinator { cache?: boolean; consolidate?: boolean; preagg?: PreAggregateOptions; - } = {} + } = {}, ) { const { manager = new QueryManager(), cache = true, consolidate = true, - preagg = {} + preagg = {}, } = options; - this.eventBus = new ObserveDispatch(); + this.eventBus = new ObserveDispatch(); this.manager = manager; this.manager.eventBus = this.eventBus; this.manager.cache(cache); @@ -95,10 +97,10 @@ export class Coordinator { const { clients = true, cache = true } = options; this.manager.clear(); if (clients) { - this.filterGroups?.forEach(group => group.disconnect()); - this.filterGroups = new Map; - this.clients?.forEach(client => this.disconnect(client)); - this.clients = new Set; + this.filterGroups?.forEach((group) => group.disconnect()); + this.filterGroups = new Map(); + this.clients?.forEach((client) => this.disconnect(client)); + this.clients = new Set(); } if (cache) this.manager.cache()!.clear(); } @@ -111,11 +113,9 @@ export class Coordinator { databaseConnector(): Connector | null; databaseConnector(db: Connector): Connector; databaseConnector(db?: Connector): Connector | null { - return db - ? this.manager.connector(db) - : this.manager.connector(); + return db ? this.manager.connector(db) : this.manager.connector(); } - + // -- Query Management ---- /** @@ -136,10 +136,10 @@ export class Coordinator { */ exec( query: MaybeArray, - options: { priority?: number } = {} + options: { priority?: number } = {}, ): QueryResult { const { priority = Priority.Normal } = options; - return this.manager.request({ type: 'exec', query }, priority); + return this.manager.request({ type: "exec", query }, priority); } /** @@ -156,40 +156,43 @@ export class Coordinator { query( query: QueryType, options?: { - type?: 'arrow'; + type?: "arrow"; cache?: boolean; persist?: boolean; priority?: number; [key: string]: unknown; - } + }, ): QueryResult; query( query: QueryType, options?: { - type?: 'json'; + type?: "json"; cache?: boolean; persist?: boolean; priority?: number; [key: string]: unknown; - } + }, ): QueryResult; query( query: QueryType, options: { - type?: 'arrow' | 'json'; + type?: "arrow" | "json"; cache?: boolean; persist?: boolean; priority?: number; [key: string]: unknown; - } = {} + } = {}, ): QueryResult { const { - type = 'arrow', + type = "arrow", cache = true, priority = Priority.Normal, ...otherOptions } = options; - return this.manager.request({ type, query, cache, options: otherOptions }, priority); + return this.manager.request( + { type, query, cache, options: otherOptions }, + priority, + ); } /** @@ -202,17 +205,18 @@ export class Coordinator { */ prefetch( query: QueryType, - options?: { type?: 'arrow'; [key: string]: unknown } - ): QueryResult
- prefetch( - query: QueryType, - options?: { type?: 'json'; [key: string]: unknown } - ): QueryResult + options?: { type?: "arrow"; [key: string]: unknown }, + ): QueryResult
; prefetch( query: QueryType, - options: any = {} - ): QueryResult { - return this.query(query, { ...options, cache: true, priority: Priority.Low }); + options?: { type?: "json"; [key: string]: unknown }, + ): QueryResult; + prefetch(query: QueryType, options: any = {}): QueryResult { + return this.query(query, { + ...options, + cache: true, + priority: Priority.Low, + }); } // -- Client Management ---- @@ -228,15 +232,30 @@ export class Coordinator { updateClient( client: MosaicClient, query: QueryType, - priority: number = Priority.Normal + priority: number = Priority.Normal, ): Promise { client.queryPending(); - return client._pending = this.query(query, { priority }) + return (client._pending = this.query(query, { priority }) .then( - data => client.queryResult(data).update(), - err => { this.eventBus.emit(EventType.Error, { message: err }); client.queryError(err); } + (data) => client.queryResult(data).update(), + (err) => { + this.eventBus.emit( + EventType.Error, + new ErrorEvent({ + message: err instanceof Error ? err.message : String(err), + }), + ); + client.queryError(err); + }, ) - .catch(err => { this.eventBus.emit(EventType.Error, { message: err }); }); + .catch((err) => { + this.eventBus.emit( + EventType.Error, + new ErrorEvent({ + message: err instanceof Error ? err.message : String(err), + }), + ); + })); } /** @@ -246,7 +265,10 @@ export class Coordinator { * @param client The client to update. * @param query The query to issue. */ - requestQuery(client: MosaicClient, query?: QueryType | null): Promise { + requestQuery( + client: MosaicClient, + query?: QueryType | null, + ): Promise { this.preaggregator.clear(); return query ? this.updateClient(client, query) @@ -262,7 +284,7 @@ export class Coordinator { const { clients } = this; if (clients?.has(client)) { - throw new Error('Client already connected.'); + throw new Error("Client already connected."); } // add client to client set @@ -305,26 +327,27 @@ export class Coordinator { function connectSelection( mc: Coordinator, selection: Selection, - client: MosaicClient + client: MosaicClient, ): void { if (!selection) return; let entry = mc.filterGroups?.get(selection); if (!entry) { - const activate = (clause: SelectionClause) => activateSelection(mc, selection, clause); + const activate = (clause: SelectionClause) => + activateSelection(mc, selection, clause); const value = () => updateSelection(mc, selection); // @ts-expect-error todo: update selection dispatch types - selection.addEventListener('activate', activate); - selection.addEventListener('value', value); + selection.addEventListener("activate", activate); + selection.addEventListener("value", value); entry = { selection, - clients: new Set, + clients: new Set(), disconnect() { // @ts-expect-error todo: update selection dispatch types - selection.removeEventListener('activate', activate); - selection.removeEventListener('value', value); - } + selection.removeEventListener("activate", activate); + selection.removeEventListener("value", value); + }, }; mc.filterGroups?.set(selection, entry); } @@ -342,7 +365,7 @@ function connectSelection( function activateSelection( mc: Coordinator, selection: Selection, - clause: SelectionClause + clause: SelectionClause, ): void { const { preaggregator, filterGroups } = mc; const { clients } = filterGroups.get(selection)!; @@ -362,27 +385,31 @@ function activateSelection( */ function updateSelection( mc: Coordinator, - selection: Selection + selection: Selection, ): Promise[]> { const { preaggregator, filterGroups } = mc; const { clients } = filterGroups!.get(selection)!; const { active } = selection; - return Promise.allSettled(Array.from(clients, async (client: MosaicClient) => { - // if client is not enabled, register a request for later - if (!client.enabled) return client.requestQuery(); + return Promise.allSettled( + Array.from(clients, async (client: MosaicClient) => { + // if client is not enabled, register a request for later + if (!client.enabled) return client.requestQuery(); - // if client is initializing, wait for it to complete - if (!client.initialized) await client.pending; + // if client is initializing, wait for it to complete + if (!client.initialized) await client.pending; - // check if we can handle selection update via preaggregation - const info = preaggregator.request(client, selection, active); - const filter = info ? null : selection.predicate(client); + // check if we can handle selection update via preaggregation + const info = preaggregator.request(client, selection, active); + const filter = info ? null : selection.predicate(client); - // skip due to cross-filtering - if (info?.skip || (!info && !filter)) return; + // skip due to cross-filtering + if (info?.skip || (!info && !filter)) return; - // generate and issue update query - const query = (info as PreAggregateInfo)?.query(active.predicate!) ?? client.query(filter); - return mc.updateClient(client, query); - })); + // generate and issue update query + const query = + (info as PreAggregateInfo)?.query(active.predicate!) ?? + client.query(filter); + return mc.updateClient(client, query); + }), + ); } diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index 940d7e2f7..0f7305286 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -2,43 +2,83 @@ export enum EventType { QueryStart = "query-start", QueryEnd = "query-end", ClientConnect = "client-connect", - ClientStateChange = "client-state-change", Warning = "warning", Error = "error", -} - -export type EventMap = { - [EventType.QueryStart]: Omit; - [EventType.QueryEnd]: Omit; - [EventType.ClientConnect]: Omit; - [EventType.Warning]: Omit; - [EventType.Error]: Omit; -}; +} + +export abstract class MosaicEvent { + readonly type: T; + readonly timestamp: number; -export interface MosaicEvent { - timestamp: number; + protected constructor(type: T, timestamp: number = Date.now()) { + this.type = type; + this.timestamp = timestamp; + } } -export interface QueryStartEvent extends MosaicEvent { +export interface QueryLifecycleEventInit { query: string; materialized: boolean; + timestamp?: number; } -export interface QueryEndEvent extends MosaicEvent { - query: string; - materialized: boolean; +export class QueryStartEvent extends MosaicEvent { + readonly query: string; + readonly materialized: boolean; + + constructor({ query, materialized, timestamp }: QueryLifecycleEventInit) { + super(EventType.QueryStart, timestamp); + this.query = query; + this.materialized = materialized; + } } -export interface ClientConnectEvent extends MosaicEvent { +export class QueryEndEvent extends MosaicEvent { + readonly query: string; + readonly materialized: boolean; + + constructor({ query, materialized, timestamp }: QueryLifecycleEventInit) { + super(EventType.QueryEnd, timestamp); + this.query = query; + this.materialized = materialized; + } +} + +export interface ClientConnectEventInit { clientId?: string; + timestamp?: number; } -export interface WarningEvent extends MosaicEvent { - message: string; +export class ClientConnectEvent extends MosaicEvent { + readonly clientId?: string; + + constructor({ clientId, timestamp }: ClientConnectEventInit = {}) { + super(EventType.ClientConnect, timestamp); + this.clientId = clientId; + } } -export interface ErrorEvent extends MosaicEvent { +export interface MessageEventInit { message: string; + timestamp?: number; +} + +export class WarningEvent extends MosaicEvent { + readonly message: string; + + constructor({ message, timestamp }: MessageEventInit) { + super(EventType.Warning, timestamp); + this.message = message; + } +} + +export class ErrorEvent extends MosaicEvent { + readonly message: string; + + constructor({ message, timestamp }: MessageEventInit) { + super(EventType.Error, timestamp); + this.message = message; + } } export type MosaicEvents = @@ -47,3 +87,11 @@ export type MosaicEvents = | ClientConnectEvent | WarningEvent | ErrorEvent; + +export type MosaicEventMap = { + [EventType.QueryStart]: QueryStartEvent; + [EventType.QueryEnd]: QueryEndEvent; + [EventType.ClientConnect]: ClientConnectEvent; + [EventType.Warning]: WarningEvent; + [EventType.Error]: ErrorEvent; +}; diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 095c6f614..3f40cc9cf 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -1,11 +1,18 @@ -import type { Connector } from './connectors/Connector.js'; -import type { Cache, QueryEntry, QueryRequest } from './types.js'; -import { consolidator } from './QueryConsolidator.js'; -import { lruCache, voidCache } from './util/cache.js'; -import { PriorityQueue } from './util/priority-queue.js'; -import { QueryResult, QueryState } from './util/query-result.js'; -import { EventType, MosaicEvent, MosaicEvents } from './Events.js'; -import { ObserveDispatch } from './util/ObserveDispatch.js'; +import type { Connector } from "./connectors/Connector.js"; +import type { Cache, QueryEntry, QueryRequest } from "./types.js"; +import { consolidator } from "./QueryConsolidator.js"; +import { lruCache, voidCache } from "./util/cache.js"; +import { PriorityQueue } from "./util/priority-queue.js"; +import { QueryResult, QueryState } from "./util/query-result.js"; +import { + ErrorEvent, + EventType, + MosaicEventMap, + QueryEndEvent, + QueryStartEvent, + WarningEvent, +} from "./Events.js"; +import { ObserveDispatch } from "./util/ObserveDispatch.js"; export const Priority = Object.freeze({ High: 0, Normal: 1, Low: 2 }); @@ -18,9 +25,12 @@ export class QueryManager { public pendingResults: QueryResult[]; private maxConcurrentRequests: number; private pendingExec: boolean; - public eventBus?; + public eventBus?: ObserveDispatch; - constructor(maxConcurrentRequests: number = 32, eventBus?: ObserveDispatch>) { + constructor( + maxConcurrentRequests: number = 32, + eventBus?: ObserveDispatch, + ) { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; @@ -32,7 +42,11 @@ export class QueryManager { } next(): void { - if (this.queue.isEmpty() || this.pendingResults.length > this.maxConcurrentRequests || this.pendingExec) { + if ( + this.queue.isEmpty() || + this.pendingResults.length > this.maxConcurrentRequests || + this.pendingExec + ) { return; } @@ -42,19 +56,27 @@ export class QueryManager { const { request, result } = entry; this.pendingResults.push(result); - if (request.type === 'exec') this.pendingExec = true; + if (request.type === "exec") this.pendingExec = true; this.submit(request, result).finally(() => { // return from the queue all requests that are ready - while (this.pendingResults.length && this.pendingResults[0].state !== QueryState.pending) { + while ( + this.pendingResults.length && + this.pendingResults[0].state !== QueryState.pending + ) { const result = this.pendingResults.shift()!; if (result.state === QueryState.ready) { result.fulfill(); } else if (result.state === QueryState.done) { - this.eventBus?.emit(EventType.Warning, { message: 'Found resolved query in pending results.' }); + this.eventBus?.emit( + EventType.Warning, + new WarningEvent({ + message: "Found resolved query in pending results.", + }), + ); } } - if (request.type === 'exec') this.pendingExec = false; + if (request.type === "exec") this.pendingExec = false; this.next(); }); } @@ -77,12 +99,20 @@ export class QueryManager { async submit(request: QueryRequest, result: QueryResult): Promise { try { const { query, type, cache = false, options } = request; - const sql = Array.isArray(query) ? query.filter(x => x).join(';\n') : query ? String(query) : null; - - this.eventBus?.emit(EventType.QueryStart, { - query: sql || '', - materialized: cache, - }); + const sql = Array.isArray(query) + ? query.filter((x) => x).join(";\n") + : query + ? String(query) + : null; + const queryText = sql || ""; + + this.eventBus?.emit( + EventType.QueryStart, + new QueryStartEvent({ + query: queryText, + materialized: cache, + }), + ); // check query cache if (cache) { @@ -90,17 +120,17 @@ export class QueryManager { if (cached) { const data = await cached; result.ready(data); - this.eventBus?.emit(EventType.QueryEnd, { - query: sql || '', - materialized: cache, - }); + this.eventBus?.emit( + EventType.QueryEnd, + new QueryEndEvent({ + query: queryText, + materialized: cache, + }), + ); return; } } - // issue query, potentially cache result - const t0 = performance.now(); - // @ts-expect-error type may be exec | json | arrow const promise = this.db!.query({ type, sql: sql!, ...options }); if (cache) this.clientCache!.set(sql!, promise); @@ -111,14 +141,20 @@ export class QueryManager { result.ready(type === 'exec' ? null : data); - this.eventBus?.emit(EventType.QueryEnd, { - query: sql || '', - materialized: cache, - }); + this.eventBus?.emit( + EventType.QueryEnd, + new QueryEndEvent({ + query: queryText, + materialized: cache, + }), + ); } catch (err) { - this.eventBus?.emit(EventType.Error, { - message: err, - }); + this.eventBus?.emit( + EventType.Error, + new ErrorEvent({ + message: err instanceof Error ? err.message : String(err), + }), + ); result.reject(err); } } @@ -132,7 +168,7 @@ export class QueryManager { cache(value: Cache | boolean): Cache; cache(value?: Cache | boolean): Cache | null { return value !== undefined - ? (this.clientCache = value === true ? lruCache() : (value || voidCache())) + ? (this.clientCache = value === true ? lruCache() : value || voidCache()) : this.clientCache; } @@ -153,7 +189,10 @@ export class QueryManager { */ consolidate(flag: boolean): void { if (flag && !this._consolidate) { - this._consolidate = consolidator(this.enqueue.bind(this), this.clientCache!); + this._consolidate = consolidator( + this.enqueue.bind(this), + this.clientCache!, + ); } else if (!flag && this._consolidate) { this._consolidate = null; } diff --git a/packages/mosaic/core/src/index.ts b/packages/mosaic/core/src/index.ts index db1244cb6..9f442d250 100644 --- a/packages/mosaic/core/src/index.ts +++ b/packages/mosaic/core/src/index.ts @@ -1,6 +1,7 @@ export { MosaicClient } from './MosaicClient.js'; export { makeClient } from './make-client.js'; export { Coordinator, coordinator } from './Coordinator.js'; +export { observeLogger } from './logger.js'; export { Selection, isSelection } from './Selection.js'; export { Param, isParam } from './Param.js'; export { Priority } from './QueryManager.js'; diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts new file mode 100644 index 000000000..9ce0578b6 --- /dev/null +++ b/packages/mosaic/core/src/logger.ts @@ -0,0 +1,77 @@ +import type { Coordinator } from "./Coordinator.js"; +import type { Logger } from "./types.js"; +import { + EventType, + type ErrorEvent, + type QueryEndEvent, + type QueryStartEvent, + type WarningEvent, +} from "./Events.js"; + +function now(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} + +/** + * Observe coordinator events and log them in a way that mirrors prior + * coordinator-internal logging behavior. + * + * @param coordinator The coordinator to observe. + * @param logger The logger to use. Pass `null` to disable logging. + * @returns Unsubscribe function. + */ +export function observeLogger( + coordinator: Coordinator, + logger: Logger | null = console, +): () => void { + if (!logger) return () => {}; + + // Track start times by query text; use a stack per query to support overlap. + const starts = new Map(); + + const onQueryStart = (event: QueryStartEvent): void => { + const key = event.query; + const stack = starts.get(key) ?? []; + stack.push(now()); + starts.set(key, stack); + + logger.groupCollapsed(`query ${key}`); + }; + + const onQueryEnd = (event: QueryEndEvent): void => { + const key = event.query; + const stack = starts.get(key); + const t0 = stack?.pop(); + const elapsed = t0 == null ? undefined : (now() - t0).toFixed(1); + + if (elapsed != null) { + logger.log(key, elapsed); + } else { + logger.log(key); + } + + logger.groupEnd(); + }; + + const onWarning = (event: WarningEvent): void => { + logger.warn(event.message); + }; + + const onError = (event: ErrorEvent): void => { + logger.error(event.message); + }; + + coordinator.eventBus.observe(EventType.QueryStart, onQueryStart); + coordinator.eventBus.observe(EventType.QueryEnd, onQueryEnd); + coordinator.eventBus.observe(EventType.Warning, onWarning); + coordinator.eventBus.observe(EventType.Error, onError); + + return () => { + coordinator.eventBus.unobserve(EventType.QueryStart, onQueryStart); + coordinator.eventBus.unobserve(EventType.QueryEnd, onQueryEnd); + coordinator.eventBus.unobserve(EventType.Warning, onWarning); + coordinator.eventBus.unobserve(EventType.Error, onError); + }; +} diff --git a/packages/mosaic/core/src/preagg/PreAggregator.ts b/packages/mosaic/core/src/preagg/PreAggregator.ts index daf19a655..1688de075 100644 --- a/packages/mosaic/core/src/preagg/PreAggregator.ts +++ b/packages/mosaic/core/src/preagg/PreAggregator.ts @@ -5,7 +5,7 @@ import type { Selection } from '../Selection.js'; import type { BinMethod, ClauseSource, IntervalMetadata, SelectionClause } from '../SelectionClause.js'; import { fnv_hash } from '../util/hash.js'; import { preaggColumns, PreAggColumnsResult } from './preagg-columns.js'; -import { EventType } from '../Events.js'; +import { ErrorEvent, EventType } from '../Events.js'; const Skip = { skip: true, result: null }; @@ -62,10 +62,10 @@ export class PreAggregator { * @param coordinator A Mosaic coordinator. * @param options Pre-aggregation options. */ - constructor(coordinator: Coordinator, { - schema = 'mosaic', - enabled = true - }: PreAggregateOptions = {}) { + constructor( + coordinator: Coordinator, + { schema = "mosaic", enabled = true }: PreAggregateOptions = {}, + ) { this.entries = new Map(); this.active = null; this.mc = coordinator; @@ -216,7 +216,12 @@ export class PreAggregator { createSchema(schema), createTable(info.table, info.create, { temp: false }) ]); - info.result.catch((e: Error) => mc.eventBus.emit(EventType.Error, {message: e})); + info.result.catch((e: Error) => + mc.eventBus.emit( + EventType.Error, + new ErrorEvent({ message: e.message }), + ), + ); } entries.set(client, info); @@ -268,15 +273,19 @@ function activeColumns(clause: SelectionClause): ActiveColumnsResult { } else { // selection clause predicate has type AndNode // multiple interval selection - predicate = (p?: ExprNode) => p - ? and((p as AndNode).clauses.map( - (c, i) => isBetween(`active${i}`, c.extent?.map(bins[i]!)) - )) - : []; + predicate = (p?: ExprNode) => + p + ? and( + (p as AndNode).clauses.map((c, i) => + isBetween(`active${i}`, c.extent?.map(bins[i]!)), + ), + ) + : []; columns = Object.fromEntries( - (clausePred as AndNode).clauses.map( - (p, i) => [`active${i}`, bins[i]!(p.expr)] - ) + (clausePred as AndNode).clauses.map((p, i) => [ + `active${i}`, + bins[i]!(p.expr), + ]), ); } } @@ -299,24 +308,22 @@ const BIN: Record FunctionNode> = { ceil, round }; function binInterval( scale: ScaleOptions, pixelSize: number, - bin?: BinMethod + bin?: BinMethod, ): ((value: ExprValue) => ExprNode) | undefined { const { type, domain, range, apply, sqlApply } = scaleTransform(scale)!; if (!apply) return; // unsupported scale type const binFn = BIN[`${bin}`.toLowerCase()] || floor; - const dom = domain!.map(x => Number(x)); + const dom = domain!.map((x) => Number(x)); const lo = apply(Math.min(...dom)); const hi = apply(Math.max(...dom)); - const s = (type === 'identity' - ? 1 - : Math.abs(range![1] - range![0]) / (hi - lo)) / pixelSize; - const scalar = s === 1 - ? (x: ExprValue) => x - : (x: ExprValue) => mul(float64(s), x); - const diff = lo === 0 - ? (x: ExprValue) => x - : (x: ExprValue) => sub(x, float64(lo)); - return value => int32(binFn(scalar(diff(sqlApply(value))))); + const s = + (type === "identity" ? 1 : Math.abs(range![1] - range![0]) / (hi - lo)) / + pixelSize; + const scalar = + s === 1 ? (x: ExprValue) => x : (x: ExprValue) => mul(float64(s), x); + const diff = + lo === 0 ? (x: ExprValue) => x : (x: ExprValue) => sub(x, float64(lo)); + return (value) => int32(binFn(scalar(diff(sqlApply(value))))); } /** @@ -331,7 +338,7 @@ function preaggregateInfo( query: SelectQuery, active: ActiveColumnsResult, preaggCols: PreAggColumnsResult, - schema: string + schema: string, ): PreAggregateInfo { const { dims, groupby, output, preagg } = preaggCols; const { columns = {} } = active; @@ -420,9 +427,11 @@ function subqueryPushdown(query: Query, cols: string[]): void { // if an aggregation query, we need to push to groupby as well // we also deduplicate as the column may already be present const set = new Set( - q._groupby.flatMap(x => x instanceof ColumnNameRefNode ? [x.name] : []) + q._groupby.flatMap((x) => + x instanceof ColumnNameRefNode ? [x.name] : [], + ), ); - q.groupby(cols.filter(c => !set.has(c))); + q.groupby(cols.filter((c) => !set.has(c))); } } q.subqueries.forEach(pushdown); diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index cbf89adcf..4ce613724 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -1,47 +1,55 @@ import { Dispatch, EventCallback } from "./AsyncDispatch.js"; +type EventMap = Record; +type EventKey = Extract; + /** - * Synchronous event dispatcher that pushes events directly to callbacks - * without queuing or Promise handling. Suitable for simple observer patterns. + * Synchronous event dispatcher that pushes pre-built events directly + * to callbacks without queueing or Promise handling. */ -export class ObserveDispatch extends Dispatch { +export class ObserveDispatch extends Dispatch< + E[EventKey] +> { /** - * Subscribe to events of the given type. Essentially - * a shorthand/wrapper for addEventListener(). + * Subscribe to events of the given type. + * Shorthand for addEventListener(). * @param type The event type. * @param callback The event handler callback function to add. * @returns This ObserveDispatch instance for method chaining. */ - observe(type: string, callback: EventCallback): this { - this.addEventListener(type, callback); + observe>(type: K, callback: EventCallback): this { + this.addEventListener(type, callback as EventCallback]>); return this; } /** - * Unsubscribe from events of the given type. Essentially - * a shorthand/wrapper for removeEventListener(). + * Unsubscribe from events of the given type. + * Shorthand for removeEventListener(). * @param type The event type. * @param callback The event handler callback function to remove. * @returns This ObserveDispatch instance for method chaining. */ - unobserve(type: string, callback: EventCallback): this { - this.removeEventListener(type, callback); + unobserve>( + type: K, + callback: EventCallback, + ): this { + this.removeEventListener(type, callback as EventCallback]>); return this; } /** - * Emit an event value to listeners for the given event type. + * Emit an already-constructed event value to listeners for the given event type. * @param type The event type. - * @param value The event value. + * @param value The complete event object. */ - override emit(type: string, value: T): void { + emit>(type: K, value: E[K]): void; + override emit(type: string, value: E[EventKey]): void { const callbacks = this._callbacks.get(type); - if (callbacks && callbacks.size > 0) { - const event = this.willEmit(type, { ...value, timestamp: new Date() }); - // Execute all callbacks synchronously - for (const callback of callbacks) { - callback(event); - } + if (!callbacks?.size) return; + + const event = this.willEmit(type, value); + for (const callback of callbacks) { + callback(event); } } } diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index 4f4349933..ca7ca5976 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -1,6 +1,6 @@ import { Query } from '@uwdata/mosaic-sql'; -import { describe, it, expect } from 'vitest'; -import { clausePoint, type Connector, Coordinator, coordinator, type JSONQueryRequest, makeClient, Selection } from '../src/index.js'; +import { describe, it, expect, vi } from 'vitest'; +import { clausePoint, type Connector, Coordinator, coordinator, type JSONQueryRequest, makeClient, observeLogger, Selection } from '../src/index.js'; import { QueryResult, QueryState } from '../src/util/query-result.js'; async function wait() { @@ -37,7 +37,7 @@ describe('coordinator', () => { }, } as unknown as Connector; - const coord = new Coordinator(connector, { logger: null }); + const coord = new Coordinator(connector); const r0 = coord.query('SELECT 0'); const r1 = coord.query('SELECT 1'); @@ -54,7 +54,6 @@ describe('coordinator', () => { expect(coord.manager.pendingResults).toHaveLength(4); // resolve promises in reverse order - promises.at(3)!.fulfill(0); await wait(); @@ -98,7 +97,7 @@ describe('coordinator', () => { // Mock the connector const connector = { async query(req: JSONQueryRequest) { - const index = req.sql.includes("WHERE") ? 1 : 0; + const index = req.sql.includes('WHERE') ? 1 : 0; events.push(`CONNECT ${index}`); return { index }; }, @@ -106,7 +105,6 @@ describe('coordinator', () => { // disable cache to ensure routing through connector const coord = new Coordinator(connector, { - logger: null, cache: false, preagg: { enabled: false } }); @@ -120,16 +118,16 @@ describe('coordinator', () => { async prepare() { await wait(); // force wait prepared = true; - events.push("PREPARE"); + events.push('PREPARE'); }, query(filter = []) { events.push(`QUERY ${prepared}`); - return Query.select("*").from("foo").where(filter); + return Query.select('*').from('foo').where(filter); } }); // fire selection update - filterBy.update(clausePoint("foo", 1, { source: {} })); + filterBy.update(clausePoint('foo', 1, { source: {} })); // await initial query, then selection update await client.pending; @@ -139,11 +137,57 @@ describe('coordinator', () => { // query calls should come post-initialization // all queries should include filter clause expect(events).toStrictEqual([ - "PREPARE", - "QUERY true", - "CONNECT 1", - "QUERY true", - "CONNECT 1", + 'PREPARE', + 'QUERY true', + 'CONNECT 1', + 'QUERY true', + 'CONNECT 1', ]); }); + + it('observeLogger reproduces old-style query logging and supports unsubscribe', async () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + preagg: { enabled: false }, + }); + + const logger = { + debug: vi.fn(), + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + group: vi.fn(), + groupCollapsed: vi.fn(), + groupEnd: vi.fn(), + }; + + const unobserve = observeLogger(coord, logger); + + await coord.query('SELECT 1', { cache: false }); + + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.groupCollapsed).toHaveBeenCalledWith('query SELECT 1'); + + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith('SELECT 1', expect.any(String)); + + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + + unobserve(); + + await coord.query('SELECT 1', { cache: false }); + + // no additional logger calls after unsubscribe + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index c4f4be54b..9a1cc5d37 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -1,24 +1,31 @@ -import { describe, it, expect } from 'vitest'; -import { QueryManager } from '../src/QueryManager.js'; -import { QueryResult } from '../src/util/query-result.js'; -import { QueryRequest } from '../src/types.js'; +import { describe, it, expect } from "vitest"; +import { QueryManager } from "../src/QueryManager.js"; +import { QueryResult } from "../src/util/query-result.js"; +import type { QueryRequest } from "../src/types.js"; +import { ObserveDispatch } from "../src/util/ObserveDispatch.js"; +import { + EventType, + type MosaicEventMap, + QueryStartEvent, + QueryEndEvent, +} from "../src/Events.js"; -describe('QueryManager', () => { - it('should run a simple query', async () => { +describe("QueryManager", () => { + it("should run a simple query", async () => { const queryManager = new QueryManager(); // Mock the connector queryManager.connector({ // @ts-expect-error assumes type value query: async ({ sql }) => { - expect(sql).toBe('SELECT 1'); + expect(sql).toBe("SELECT 1"); return [{ column: 1 }]; - } + }, }); const request: QueryRequest = { - type: 'arrow', - query: 'SELECT 1' + type: "arrow", + query: "SELECT 1", }; const result = queryManager.request(request); @@ -28,26 +35,26 @@ describe('QueryManager', () => { expect(data).toEqual([{ column: 1 }]); }); - it('should not run a query when there is a pending exec', async () => { + it("should not run a query when there is a pending exec", async () => { const queryManager = new QueryManager(); // Mock the connector queryManager.connector({ // @ts-expect-error assumes type value query: ({ sql }) => { - expect(sql).toBe('CREATE TABLE test (id INT)'); + expect(sql).toBe("CREATE TABLE test (id INT)"); return new Promise(() => {}); - } + }, }); const request1: QueryRequest = { - type: 'exec', - query: 'CREATE TABLE test (id INT)' + type: "exec", + query: "CREATE TABLE test (id INT)", }; const request2: QueryRequest = { - type: 'arrow', - query: 'SELECT * FROM test' + type: "arrow", + query: "SELECT * FROM test", }; queryManager.request(request1); @@ -55,4 +62,109 @@ describe('QueryManager', () => { expect(queryManager.pendingResults).toHaveLength(1); }); + + it("emits typed QueryStart and QueryEnd events with type and timestamp", async () => { + const eventBus = new ObserveDispatch(); + const queryManager = new QueryManager(32, eventBus); + + const starts: QueryStartEvent[] = []; + const ends: QueryEndEvent[] = []; + + eventBus.observe(EventType.QueryStart, (event) => { + starts.push(event); + }); + eventBus.observe(EventType.QueryEnd, (event) => { + ends.push(event); + }); + + queryManager.connector({ + // @ts-expect-error assumes type value + query: async ({ sql }) => [{ sql }], + }); + + const result = queryManager.request({ + type: "arrow", + query: "SELECT 42", + cache: false, + }); + + await result; + + expect(starts).toHaveLength(1); + expect(ends).toHaveLength(1); + + const start = starts[0]; + const end = ends[0]; + + expect(start).toBeInstanceOf(QueryStartEvent); + expect(end).toBeInstanceOf(QueryEndEvent); + + expect(start.type).toBe(EventType.QueryStart); + expect(end.type).toBe(EventType.QueryEnd); + + expect(typeof start.timestamp).toBe("number"); + expect(typeof end.timestamp).toBe("number"); + + expect(start.query).toBe("SELECT 42"); + expect(end.query).toBe("SELECT 42"); + + expect(start.materialized).toBe(false); + expect(end.materialized).toBe(false); + }); + + it("emits QueryStart and QueryEnd for cached requests", async () => { + const eventBus = new ObserveDispatch(); + const queryManager = new QueryManager(32, eventBus); + + const starts: QueryStartEvent[] = []; + const ends: QueryEndEvent[] = []; + let queryCalls = 0; + + eventBus.observe(EventType.QueryStart, (event) => { + starts.push(event); + }); + eventBus.observe(EventType.QueryEnd, (event) => { + ends.push(event); + }); + + queryManager.connector({ + // @ts-expect-error assumes type value + query: async () => { + queryCalls += 1; + return [{ value: 1 }]; + }, + }); + + queryManager.cache(true); + + await queryManager.request({ + type: "arrow", + query: "SELECT 1", + cache: true, + }); + + await queryManager.request({ + type: "arrow", + query: "SELECT 1", + cache: true, + }); + + expect(queryCalls).toBe(1); + expect(starts).toHaveLength(2); + expect(ends).toHaveLength(2); + + for (const event of starts) { + expect(event.type).toBe(EventType.QueryStart); + expect(typeof event.timestamp).toBe("number"); + expect(event.query).toBe("SELECT 1"); + expect(event.materialized).toBe(true); + } + + for (const event of ends) { + expect(event.type).toBe(EventType.QueryEnd); + expect(typeof event.timestamp).toBe("number"); + expect(event.query).toBe("SELECT 1"); + expect(event.materialized).toBe(true); + } + }); }); From 6753cdae824965d2ee5da791db7b1a5ce1473dc6 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Thu, 14 May 2026 11:54:55 +1200 Subject: [PATCH 15/30] Use core query logger helper in dev demos Replace the demo-local event bus observers with the shared observeLogger helper from mosaic-core. This keeps the dev pages aligned with the public event-bus logging path instead of duplicating logging logic in each demo. The gallery query log checkbox now installs and removes the observer cleanly, so repeated toggles do not stack listeners. The dev pages also use vg.coordinator() consistently when updating coordinator settings. --- dev/index.html | 24 +++++++++--------------- dev/query/index.html | 18 ++---------------- dev/setup.ts | 3 ++- 3 files changed, 13 insertions(+), 32 deletions(-) diff --git a/dev/index.html b/dev/index.html index 32ca10af5..280643511 100644 --- a/dev/index.html +++ b/dev/index.html @@ -113,7 +113,7 @@
diff --git a/dev/setup.ts b/dev/setup.ts index baa8cb71c..a06a5acb8 100644 --- a/dev/setup.ts +++ b/dev/setup.ts @@ -1,7 +1,8 @@ -import { DuckDBWASMConnector, RestConnector, SocketConnector } from '@uwdata/mosaic-core'; +import { DuckDBWASMConnector, observeLogger, RestConnector, SocketConnector } from '@uwdata/mosaic-core'; import { createAPIContext } from '@uwdata/vgplot'; export { parseSpec, astToDOM, astToESM } from '@uwdata/mosaic-spec'; +export { observeLogger }; export const vg = createAPIContext(); // make API accessible for console debugging From 37e6d4be75127673421a91ca95a37aa2b4dd9546 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Thu, 14 May 2026 12:23:47 +1200 Subject: [PATCH 16/30] Remove unused client connect event surface Drop the unused client-connect event type, event class, init interface, and event map entries from the core event definitions. The event is not currently emitted or observed, and keeping it in the public-looking event model implies a client lifecycle API that this PR does not define yet. --- packages/mosaic/core/src/Events.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index 0f7305286..f843e3b81 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -1,7 +1,6 @@ export enum EventType { QueryStart = "query-start", QueryEnd = "query-end", - ClientConnect = "client-connect", Warning = "warning", Error = "error", } @@ -44,20 +43,6 @@ export class QueryEndEvent extends MosaicEvent { } } -export interface ClientConnectEventInit { - clientId?: string; - timestamp?: number; -} - -export class ClientConnectEvent extends MosaicEvent { - readonly clientId?: string; - - constructor({ clientId, timestamp }: ClientConnectEventInit = {}) { - super(EventType.ClientConnect, timestamp); - this.clientId = clientId; - } -} - export interface MessageEventInit { message: string; timestamp?: number; @@ -84,14 +69,12 @@ export class ErrorEvent extends MosaicEvent { export type MosaicEvents = | QueryStartEvent | QueryEndEvent - | ClientConnectEvent | WarningEvent | ErrorEvent; export type MosaicEventMap = { [EventType.QueryStart]: QueryStartEvent; [EventType.QueryEnd]: QueryEndEvent; - [EventType.ClientConnect]: ClientConnectEvent; [EventType.Warning]: WarningEvent; [EventType.Error]: ErrorEvent; }; From 0c8491741ef5910962b7294aa544fa4e280eb5eb Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Thu, 14 May 2026 12:35:49 +1200 Subject: [PATCH 17/30] Harden public Mosaic event class API Rename concrete event classes with a Mosaic prefix so the public API is consistent and avoids collisions with DOM event names such as ErrorEvent. Keep the shared event base internal, expose a singular MosaicEvent union, and export the event classes and related event types from the core package entrypoint for event bus consumers. --- packages/mosaic/core/src/Coordinator.ts | 6 ++-- packages/mosaic/core/src/Events.ts | 34 +++++++++---------- packages/mosaic/core/src/QueryManager.ts | 20 +++++------ packages/mosaic/core/src/index.ts | 13 +++++++ packages/mosaic/core/src/logger.ts | 16 ++++----- .../mosaic/core/src/preagg/PreAggregator.ts | 4 +-- .../mosaic/core/test/query-manager.test.ts | 16 ++++----- 7 files changed, 61 insertions(+), 48 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 599c55ddd..a3c13f548 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -14,7 +14,7 @@ import { type MosaicClient } from "./MosaicClient.js"; import { type SelectionClause } from "./SelectionClause.js"; import { MaybeArray } from "@uwdata/mosaic-sql"; import { Table } from "@uwdata/flechette"; -import { ErrorEvent, EventType, type MosaicEventMap } from "./Events.js"; +import { EventType, MosaicErrorEvent, type MosaicEventMap } from "./Events.js"; import { ObserveDispatch } from "./util/ObserveDispatch.js"; interface FilterGroupEntry { @@ -241,7 +241,7 @@ export class Coordinator { (err) => { this.eventBus.emit( EventType.Error, - new ErrorEvent({ + new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), }), ); @@ -251,7 +251,7 @@ export class Coordinator { .catch((err) => { this.eventBus.emit( EventType.Error, - new ErrorEvent({ + new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), }), ); diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index f843e3b81..6889b77c6 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -5,7 +5,7 @@ export enum EventType { Error = "error", } -export abstract class MosaicEvent { +abstract class MosaicEventBase { readonly type: T; readonly timestamp: number; @@ -21,7 +21,7 @@ export interface QueryLifecycleEventInit { timestamp?: number; } -export class QueryStartEvent extends MosaicEvent { +export class MosaicQueryStartEvent extends MosaicEventBase { readonly query: string; readonly materialized: boolean; @@ -32,7 +32,7 @@ export class QueryStartEvent extends MosaicEvent { } } -export class QueryEndEvent extends MosaicEvent { +export class MosaicQueryEndEvent extends MosaicEventBase { readonly query: string; readonly materialized: boolean; @@ -43,38 +43,38 @@ export class QueryEndEvent extends MosaicEvent { } } -export interface MessageEventInit { +export interface MosaicMessageEventInit { message: string; timestamp?: number; } -export class WarningEvent extends MosaicEvent { +export class MosaicWarningEvent extends MosaicEventBase { readonly message: string; - constructor({ message, timestamp }: MessageEventInit) { + constructor({ message, timestamp }: MosaicMessageEventInit) { super(EventType.Warning, timestamp); this.message = message; } } -export class ErrorEvent extends MosaicEvent { +export class MosaicErrorEvent extends MosaicEventBase { readonly message: string; - constructor({ message, timestamp }: MessageEventInit) { + constructor({ message, timestamp }: MosaicMessageEventInit) { super(EventType.Error, timestamp); this.message = message; } } -export type MosaicEvents = - | QueryStartEvent - | QueryEndEvent - | WarningEvent - | ErrorEvent; +export type MosaicEvent = + | MosaicQueryStartEvent + | MosaicQueryEndEvent + | MosaicWarningEvent + | MosaicErrorEvent; export type MosaicEventMap = { - [EventType.QueryStart]: QueryStartEvent; - [EventType.QueryEnd]: QueryEndEvent; - [EventType.Warning]: WarningEvent; - [EventType.Error]: ErrorEvent; + [EventType.QueryStart]: MosaicQueryStartEvent; + [EventType.QueryEnd]: MosaicQueryEndEvent; + [EventType.Warning]: MosaicWarningEvent; + [EventType.Error]: MosaicErrorEvent; }; diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 3f40cc9cf..d89aae11a 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -5,12 +5,12 @@ import { lruCache, voidCache } from "./util/cache.js"; import { PriorityQueue } from "./util/priority-queue.js"; import { QueryResult, QueryState } from "./util/query-result.js"; import { - ErrorEvent, EventType, - MosaicEventMap, - QueryEndEvent, - QueryStartEvent, - WarningEvent, + MosaicErrorEvent, + type MosaicEventMap, + MosaicQueryEndEvent, + MosaicQueryStartEvent, + MosaicWarningEvent, } from "./Events.js"; import { ObserveDispatch } from "./util/ObserveDispatch.js"; @@ -70,7 +70,7 @@ export class QueryManager { } else if (result.state === QueryState.done) { this.eventBus?.emit( EventType.Warning, - new WarningEvent({ + new MosaicWarningEvent({ message: "Found resolved query in pending results.", }), ); @@ -108,7 +108,7 @@ export class QueryManager { this.eventBus?.emit( EventType.QueryStart, - new QueryStartEvent({ + new MosaicQueryStartEvent({ query: queryText, materialized: cache, }), @@ -122,7 +122,7 @@ export class QueryManager { result.ready(data); this.eventBus?.emit( EventType.QueryEnd, - new QueryEndEvent({ + new MosaicQueryEndEvent({ query: queryText, materialized: cache, }), @@ -143,7 +143,7 @@ export class QueryManager { this.eventBus?.emit( EventType.QueryEnd, - new QueryEndEvent({ + new MosaicQueryEndEvent({ query: queryText, materialized: cache, }), @@ -151,7 +151,7 @@ export class QueryManager { } catch (err) { this.eventBus?.emit( EventType.Error, - new ErrorEvent({ + new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), }), ); diff --git a/packages/mosaic/core/src/index.ts b/packages/mosaic/core/src/index.ts index 9f442d250..1b4a13b12 100644 --- a/packages/mosaic/core/src/index.ts +++ b/packages/mosaic/core/src/index.ts @@ -2,6 +2,13 @@ export { MosaicClient } from './MosaicClient.js'; export { makeClient } from './make-client.js'; export { Coordinator, coordinator } from './Coordinator.js'; export { observeLogger } from './logger.js'; +export { + EventType, + MosaicErrorEvent, + MosaicQueryEndEvent, + MosaicQueryStartEvent, + MosaicWarningEvent, +} from './Events.js'; export { Selection, isSelection } from './Selection.js'; export { Param, isParam } from './Param.js'; export { Priority } from './QueryManager.js'; @@ -39,5 +46,11 @@ export type { QueryResult } from './util/query-result.js'; export * from './types.js'; export type * from './connectors/Connector.js'; +export type { + MosaicEvent, + MosaicEventMap, + MosaicMessageEventInit, + QueryLifecycleEventInit +} from './Events.js'; export type * from './Selection.js'; export type * from './SelectionClause.js'; diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index 9ce0578b6..48b287104 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -2,10 +2,10 @@ import type { Coordinator } from "./Coordinator.js"; import type { Logger } from "./types.js"; import { EventType, - type ErrorEvent, - type QueryEndEvent, - type QueryStartEvent, - type WarningEvent, + type MosaicErrorEvent, + type MosaicQueryEndEvent, + type MosaicQueryStartEvent, + type MosaicWarningEvent, } from "./Events.js"; function now(): number { @@ -31,7 +31,7 @@ export function observeLogger( // Track start times by query text; use a stack per query to support overlap. const starts = new Map(); - const onQueryStart = (event: QueryStartEvent): void => { + const onQueryStart = (event: MosaicQueryStartEvent): void => { const key = event.query; const stack = starts.get(key) ?? []; stack.push(now()); @@ -40,7 +40,7 @@ export function observeLogger( logger.groupCollapsed(`query ${key}`); }; - const onQueryEnd = (event: QueryEndEvent): void => { + const onQueryEnd = (event: MosaicQueryEndEvent): void => { const key = event.query; const stack = starts.get(key); const t0 = stack?.pop(); @@ -55,11 +55,11 @@ export function observeLogger( logger.groupEnd(); }; - const onWarning = (event: WarningEvent): void => { + const onWarning = (event: MosaicWarningEvent): void => { logger.warn(event.message); }; - const onError = (event: ErrorEvent): void => { + const onError = (event: MosaicErrorEvent): void => { logger.error(event.message); }; diff --git a/packages/mosaic/core/src/preagg/PreAggregator.ts b/packages/mosaic/core/src/preagg/PreAggregator.ts index 1688de075..632df6f04 100644 --- a/packages/mosaic/core/src/preagg/PreAggregator.ts +++ b/packages/mosaic/core/src/preagg/PreAggregator.ts @@ -5,7 +5,7 @@ import type { Selection } from '../Selection.js'; import type { BinMethod, ClauseSource, IntervalMetadata, SelectionClause } from '../SelectionClause.js'; import { fnv_hash } from '../util/hash.js'; import { preaggColumns, PreAggColumnsResult } from './preagg-columns.js'; -import { ErrorEvent, EventType } from '../Events.js'; +import { EventType, MosaicErrorEvent } from '../Events.js'; const Skip = { skip: true, result: null }; @@ -219,7 +219,7 @@ export class PreAggregator { info.result.catch((e: Error) => mc.eventBus.emit( EventType.Error, - new ErrorEvent({ message: e.message }), + new MosaicErrorEvent({ message: e.message }), ), ); } diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 9a1cc5d37..331f50d52 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -6,8 +6,8 @@ import { ObserveDispatch } from "../src/util/ObserveDispatch.js"; import { EventType, type MosaicEventMap, - QueryStartEvent, - QueryEndEvent, + MosaicQueryStartEvent, + MosaicQueryEndEvent, } from "../src/Events.js"; describe("QueryManager", () => { @@ -67,8 +67,8 @@ describe("QueryManager", () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(32, eventBus); - const starts: QueryStartEvent[] = []; - const ends: QueryEndEvent[] = []; + const starts: MosaicQueryStartEvent[] = []; + const ends: MosaicQueryEndEvent[] = []; eventBus.observe(EventType.QueryStart, (event) => { starts.push(event); @@ -96,8 +96,8 @@ describe("QueryManager", () => { const start = starts[0]; const end = ends[0]; - expect(start).toBeInstanceOf(QueryStartEvent); - expect(end).toBeInstanceOf(QueryEndEvent); + expect(start).toBeInstanceOf(MosaicQueryStartEvent); + expect(end).toBeInstanceOf(MosaicQueryEndEvent); expect(start.type).toBe(EventType.QueryStart); expect(end.type).toBe(EventType.QueryEnd); @@ -116,8 +116,8 @@ describe("QueryManager", () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(32, eventBus); - const starts: QueryStartEvent[] = []; - const ends: QueryEndEvent[] = []; + const starts: MosaicQueryStartEvent[] = []; + const ends: MosaicQueryEndEvent[] = []; let queryCalls = 0; eventBus.observe(EventType.QueryStart, (event) => { From 988ab10e1e49af3772c95a006e5054f59fa5f80c Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Thu, 14 May 2026 12:46:26 +1200 Subject: [PATCH 18/30] Use standard listener API for event bus observers Remove the observe/unobserve aliases from ObserveDispatch and type the standard addEventListener/removeEventListener methods instead. This keeps the event bus aligned with the existing Dispatch API while preserving event-type-specific callback inference for Mosaic events. Update the logger helper and query manager tests to use the standard listener methods. --- packages/mosaic/core/src/logger.ts | 16 +++++----- .../mosaic/core/src/util/ObserveDispatch.ts | 31 +++++++++++-------- .../mosaic/core/test/query-manager.test.ts | 8 ++--- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index 48b287104..a58c826cd 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -63,15 +63,15 @@ export function observeLogger( logger.error(event.message); }; - coordinator.eventBus.observe(EventType.QueryStart, onQueryStart); - coordinator.eventBus.observe(EventType.QueryEnd, onQueryEnd); - coordinator.eventBus.observe(EventType.Warning, onWarning); - coordinator.eventBus.observe(EventType.Error, onError); + coordinator.eventBus.addEventListener(EventType.QueryStart, onQueryStart); + coordinator.eventBus.addEventListener(EventType.QueryEnd, onQueryEnd); + coordinator.eventBus.addEventListener(EventType.Warning, onWarning); + coordinator.eventBus.addEventListener(EventType.Error, onError); return () => { - coordinator.eventBus.unobserve(EventType.QueryStart, onQueryStart); - coordinator.eventBus.unobserve(EventType.QueryEnd, onQueryEnd); - coordinator.eventBus.unobserve(EventType.Warning, onWarning); - coordinator.eventBus.unobserve(EventType.Error, onError); + coordinator.eventBus.removeEventListener(EventType.QueryStart, onQueryStart); + coordinator.eventBus.removeEventListener(EventType.QueryEnd, onQueryEnd); + coordinator.eventBus.removeEventListener(EventType.Warning, onWarning); + coordinator.eventBus.removeEventListener(EventType.Error, onError); }; } diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index 4ce613724..ad0f02ddf 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -11,30 +11,35 @@ export class ObserveDispatch extends Dispatch< E[EventKey] > { /** - * Subscribe to events of the given type. - * Shorthand for addEventListener(). + * Add an event listener callback for the provided event type. * @param type The event type. * @param callback The event handler callback function to add. - * @returns This ObserveDispatch instance for method chaining. */ - observe>(type: K, callback: EventCallback): this { - this.addEventListener(type, callback as EventCallback]>); - return this; + addEventListener>( + type: K, + callback: EventCallback, + ): void; + override addEventListener( + type: string, + callback: EventCallback]>, + ): void { + super.addEventListener(type, callback); } /** - * Unsubscribe from events of the given type. - * Shorthand for removeEventListener(). + * Remove an event listener callback for the provided event type. * @param type The event type. * @param callback The event handler callback function to remove. - * @returns This ObserveDispatch instance for method chaining. */ - unobserve>( + removeEventListener>( type: K, callback: EventCallback, - ): this { - this.removeEventListener(type, callback as EventCallback]>); - return this; + ): void; + override removeEventListener( + type: string, + callback: EventCallback]>, + ): void { + super.removeEventListener(type, callback); } /** diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 331f50d52..275003fcd 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -70,10 +70,10 @@ describe("QueryManager", () => { const starts: MosaicQueryStartEvent[] = []; const ends: MosaicQueryEndEvent[] = []; - eventBus.observe(EventType.QueryStart, (event) => { + eventBus.addEventListener(EventType.QueryStart, (event) => { starts.push(event); }); - eventBus.observe(EventType.QueryEnd, (event) => { + eventBus.addEventListener(EventType.QueryEnd, (event) => { ends.push(event); }); @@ -120,10 +120,10 @@ describe("QueryManager", () => { const ends: MosaicQueryEndEvent[] = []; let queryCalls = 0; - eventBus.observe(EventType.QueryStart, (event) => { + eventBus.addEventListener(EventType.QueryStart, (event) => { starts.push(event); }); - eventBus.observe(EventType.QueryEnd, (event) => { + eventBus.addEventListener(EventType.QueryEnd, (event) => { ends.push(event); }); From e72d949a82e70f451b3735782b2672b12f377252 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 09:47:29 +1200 Subject: [PATCH 19/30] Restore coordinator logger compatibility Reintroduce the Coordinator logger constructor option and logger() getter/setter as a compatibility layer over the new event bus logging path. The restored API keeps existing logger, custom logger, and null suppression behavior working while still routing query lifecycle logs through observeLogger. Add coordinator tests covering constructor logger configuration, logger replacement without duplicate subscriptions, and disabling logging with logger(null). --- packages/mosaic/core/src/Coordinator.ts | 27 ++++++- packages/mosaic/core/test/coordinator.test.ts | 70 ++++++++++++++++--- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index a3c13f548..f80ee6b10 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -8,7 +8,7 @@ import { } from "./preagg/PreAggregator.js"; import { QueryManager, Priority } from "./QueryManager.js"; import { type Selection } from "./Selection.js"; -import { type QueryType } from "./types.js"; +import { type Logger, type QueryType } from "./types.js"; import { type QueryResult } from "./util/query-result.js"; import { type MosaicClient } from "./MosaicClient.js"; import { type SelectionClause } from "./SelectionClause.js"; @@ -16,6 +16,7 @@ import { MaybeArray } from "@uwdata/mosaic-sql"; import { Table } from "@uwdata/flechette"; import { EventType, MosaicErrorEvent, type MosaicEventMap } from "./Events.js"; import { ObserveDispatch } from "./util/ObserveDispatch.js"; +import { observeLogger } from "./logger.js"; interface FilterGroupEntry { selection: Selection; @@ -53,10 +54,13 @@ export class Coordinator { public clients = new Set(); public filterGroups = new Map(); public eventBus: ObserveDispatch; + protected _logger: Logger | null = null; + private _unobserveLogger: () => void = () => {}; /** * @param db Database connector. Defaults to a web socket connection. * @param options Coordinator options. + * @param options.logger The logger to use, defaults to `console`. Pass `null` to disable logging. * @param options.manager The query manager to use. * @param options.cache Boolean flag to enable/disable query caching. * @param options.consolidate Boolean flag to enable/disable query consolidation. @@ -65,6 +69,7 @@ export class Coordinator { constructor( db: Connector = new SocketConnector(), options: { + logger?: Logger | null; manager?: QueryManager; cache?: boolean; consolidate?: boolean; @@ -72,6 +77,7 @@ export class Coordinator { } = {}, ) { const { + logger = console, manager = new QueryManager(), cache = true, consolidate = true, @@ -83,6 +89,7 @@ export class Coordinator { this.manager.cache(cache); this.manager.consolidate(consolidate); this.databaseConnector(db); + this.logger(logger); this.clear(); this.preaggregator = new PreAggregator(this, preagg); } @@ -116,6 +123,24 @@ export class Coordinator { return db ? this.manager.connector(db) : this.manager.connector(); } + /** + * Get or set the logger. + * + * @deprecated Prefer observeLogger(coordinator, logger) for event-bus based logging. + * @param logger The logger to use. Pass `null` to disable logging. + * @returns The current logger. + */ + logger(): Logger | null; + logger(logger: Logger | null): Logger | null; + logger(logger?: Logger | null): Logger | null { + if (arguments.length) { + this._logger = logger ?? null; + this._unobserveLogger(); + this._unobserveLogger = observeLogger(this, this._logger); + } + return this._logger; + } + // -- Query Management ---- /** diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index ca7ca5976..739b9113e 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -1,12 +1,25 @@ import { Query } from '@uwdata/mosaic-sql'; import { describe, it, expect, vi } from 'vitest'; -import { clausePoint, type Connector, Coordinator, coordinator, type JSONQueryRequest, makeClient, observeLogger, Selection } from '../src/index.js'; +import { clausePoint, type Connector, Coordinator, coordinator, type JSONQueryRequest, type Logger, makeClient, observeLogger, Selection } from '../src/index.js'; import { QueryResult, QueryState } from '../src/util/query-result.js'; async function wait() { return new Promise(resolve => setTimeout(resolve, 0)); } +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + group: vi.fn(), + groupCollapsed: vi.fn(), + groupEnd: vi.fn(), + }; +} + describe('coordinator', () => { it('has accessible singleton', () => { // Mock the connector, avoid instantiating default socket connector @@ -158,16 +171,7 @@ describe('coordinator', () => { preagg: { enabled: false }, }); - const logger = { - debug: vi.fn(), - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - group: vi.fn(), - groupCollapsed: vi.fn(), - groupEnd: vi.fn(), - }; + const logger = createLogger(); const unobserve = observeLogger(coord, logger); @@ -190,4 +194,48 @@ describe('coordinator', () => { expect(logger.log).toHaveBeenCalledTimes(1); expect(logger.groupEnd).toHaveBeenCalledTimes(1); }); + + it('supports legacy coordinator logger configuration through the event bus', async () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const logger = createLogger(); + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + logger, + preagg: { enabled: false }, + }); + + expect(coord.logger()).toBe(logger); + + await coord.query('SELECT 1', { cache: false }); + + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + + const nextLogger = createLogger(); + expect(coord.logger(nextLogger)).toBe(nextLogger); + + await coord.query('SELECT 2', { cache: false }); + + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + expect(nextLogger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(nextLogger.log).toHaveBeenCalledTimes(1); + expect(nextLogger.groupEnd).toHaveBeenCalledTimes(1); + + expect(coord.logger(null)).toBeNull(); + + await coord.query('SELECT 3', { cache: false }); + + expect(nextLogger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(nextLogger.log).toHaveBeenCalledTimes(1); + expect(nextLogger.groupEnd).toHaveBeenCalledTimes(1); + }); }); From 4337b90f5b46675fb7fb4f93da3bce348122e814 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 10:09:43 +1200 Subject: [PATCH 20/30] Clarify coordinator ownership of event bus Make the coordinator event bus the authoritative observation surface by marking it readonly and wiring custom query managers through an internal QueryManager event-bus hook. This removes the public mutable QueryManager eventBus field so query managers act only as event producers, while observers continue to attach to Coordinator.eventBus. Add a regression test covering custom manager construction to ensure query lifecycle events still flow through the coordinator-owned bus. --- packages/mosaic/core/src/Coordinator.ts | 4 +-- packages/mosaic/core/src/QueryManager.ts | 26 +++++++++------- packages/mosaic/core/test/coordinator.test.ts | 31 ++++++++++++++++++- .../mosaic/core/test/query-manager.test.ts | 6 ++-- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index f80ee6b10..ebd785b38 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -53,7 +53,7 @@ export class Coordinator { public preaggregator: PreAggregator; public clients = new Set(); public filterGroups = new Map(); - public eventBus: ObserveDispatch; + public readonly eventBus: ObserveDispatch; protected _logger: Logger | null = null; private _unobserveLogger: () => void = () => {}; @@ -85,7 +85,7 @@ export class Coordinator { } = options; this.eventBus = new ObserveDispatch(); this.manager = manager; - this.manager.eventBus = this.eventBus; + this.manager.setEventBus(this.eventBus); this.manager.cache(cache); this.manager.consolidate(consolidate); this.databaseConnector(db); diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index d89aae11a..f242a5b35 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -25,12 +25,9 @@ export class QueryManager { public pendingResults: QueryResult[]; private maxConcurrentRequests: number; private pendingExec: boolean; - public eventBus?: ObserveDispatch; + private _eventBus: ObserveDispatch | null; - constructor( - maxConcurrentRequests: number = 32, - eventBus?: ObserveDispatch, - ) { + constructor(maxConcurrentRequests: number = 32) { this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; @@ -38,7 +35,14 @@ export class QueryManager { this.pendingResults = []; this.maxConcurrentRequests = maxConcurrentRequests; this.pendingExec = false; - this.eventBus = eventBus; + this._eventBus = null; + } + + /** + * @internal Wire query lifecycle events to the owning coordinator. + */ + setEventBus(eventBus: ObserveDispatch): void { + this._eventBus = eventBus; } next(): void { @@ -68,7 +72,7 @@ export class QueryManager { if (result.state === QueryState.ready) { result.fulfill(); } else if (result.state === QueryState.done) { - this.eventBus?.emit( + this._eventBus?.emit( EventType.Warning, new MosaicWarningEvent({ message: "Found resolved query in pending results.", @@ -106,7 +110,7 @@ export class QueryManager { : null; const queryText = sql || ""; - this.eventBus?.emit( + this._eventBus?.emit( EventType.QueryStart, new MosaicQueryStartEvent({ query: queryText, @@ -120,7 +124,7 @@ export class QueryManager { if (cached) { const data = await cached; result.ready(data); - this.eventBus?.emit( + this._eventBus?.emit( EventType.QueryEnd, new MosaicQueryEndEvent({ query: queryText, @@ -141,7 +145,7 @@ export class QueryManager { result.ready(type === 'exec' ? null : data); - this.eventBus?.emit( + this._eventBus?.emit( EventType.QueryEnd, new MosaicQueryEndEvent({ query: queryText, @@ -149,7 +153,7 @@ export class QueryManager { }), ); } catch (err) { - this.eventBus?.emit( + this._eventBus?.emit( EventType.Error, new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index 739b9113e..2fe8ebd39 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -1,6 +1,7 @@ import { Query } from '@uwdata/mosaic-sql'; import { describe, it, expect, vi } from 'vitest'; -import { clausePoint, type Connector, Coordinator, coordinator, type JSONQueryRequest, type Logger, makeClient, observeLogger, Selection } from '../src/index.js'; +import { clausePoint, type Connector, Coordinator, coordinator, EventType, type JSONQueryRequest, type Logger, makeClient, type MosaicQueryStartEvent, observeLogger, Selection } from '../src/index.js'; +import { QueryManager } from '../src/QueryManager.js'; import { QueryResult, QueryState } from '../src/util/query-result.js'; async function wait() { @@ -195,6 +196,34 @@ describe('coordinator', () => { expect(logger.groupEnd).toHaveBeenCalledTimes(1); }); + it('wires custom query managers to the coordinator event bus', async () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const manager = new QueryManager(); + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + logger: null, + manager, + preagg: { enabled: false }, + }); + const starts: MosaicQueryStartEvent[] = []; + + coord.eventBus.addEventListener(EventType.QueryStart, (event) => { + starts.push(event); + }); + + await coord.query('SELECT 1', { cache: false }); + + expect(coord.manager).toBe(manager); + expect(starts).toHaveLength(1); + expect(starts[0]?.query).toBe('SELECT 1'); + }); + it('supports legacy coordinator logger configuration through the event bus', async () => { const connector = { async query() { diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 275003fcd..343a1e10b 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -65,7 +65,8 @@ describe("QueryManager", () => { it("emits typed QueryStart and QueryEnd events with type and timestamp", async () => { const eventBus = new ObserveDispatch(); - const queryManager = new QueryManager(32, eventBus); + const queryManager = new QueryManager(); + queryManager.setEventBus(eventBus); const starts: MosaicQueryStartEvent[] = []; const ends: MosaicQueryEndEvent[] = []; @@ -114,7 +115,8 @@ describe("QueryManager", () => { it("emits QueryStart and QueryEnd for cached requests", async () => { const eventBus = new ObserveDispatch(); - const queryManager = new QueryManager(32, eventBus); + const queryManager = new QueryManager(); + queryManager.setEventBus(eventBus); const starts: MosaicQueryStartEvent[] = []; const ends: MosaicQueryEndEvent[] = []; From 9f642dc0913091f687ec0b93114641fbc4e67ea8 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 11:13:54 +1200 Subject: [PATCH 21/30] Simplify async dispatch listener state Keep callback registration in the shared Dispatch callback map and store only async queue state in AsyncDispatch entries. This avoids stale per-entry callback sets when an event type is emitted before listeners are added. Add focused AsyncDispatch coverage for listener registration, removal, queue replacement, queue filtering, and cancellation behavior. --- .../mosaic/core/src/util/AsyncDispatch.ts | 20 +-- .../mosaic/core/test/async-dispatch.test.ts | 125 ++++++++++++++++++ 2 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 packages/mosaic/core/test/async-dispatch.test.ts diff --git a/packages/mosaic/core/src/util/AsyncDispatch.ts b/packages/mosaic/core/src/util/AsyncDispatch.ts index b268a0158..be44a49fd 100644 --- a/packages/mosaic/core/src/util/AsyncDispatch.ts +++ b/packages/mosaic/core/src/util/AsyncDispatch.ts @@ -1,7 +1,6 @@ export type EventCallback = (value: T) => void | Promise; interface DispatchEntry { - callbacks: Set>; pending: Promise | null; queue: DispatchQueue; } @@ -76,18 +75,12 @@ export abstract class Dispatch { */ emitQueueFilter( _type: string, // eslint-disable-line @typescript-eslint/no-unused-vars - _value: unknown // eslint-disable-line @typescript-eslint/no-unused-vars - ): ((value: unknown) => boolean | null) | null { + _value: T // eslint-disable-line @typescript-eslint/no-unused-vars + ): ((value: T) => boolean | null) | null { // removes all pending items return null; } - /** - * Cancel all un-emitted event values for the given event type. - * @param _type The event type. - */ - cancel(_type: string): void {} - /** * Emit an event value to listeners for the given event type. * @param type The event type. @@ -123,7 +116,6 @@ export class AsyncDispatch extends Dispatch { super.addEventListener(type, callback); if (!this._entries.has(type)) { this._entries.set(type, { - callbacks: this._callbacks.get(type)!, pending: null, queue: new DispatchQueue(), }); @@ -134,7 +126,7 @@ export class AsyncDispatch extends Dispatch { * Cancel all un-emitted event values for the given event type. * @param type The event type. */ - override cancel(type: string): void { + cancel(type: string): void { const entry = this._entries.get(type); entry?.queue.clear(); } @@ -162,7 +154,6 @@ export class AsyncDispatch extends Dispatch { override emit(type: string, value: T): void { if (!this._entries.has(type)) { this._entries.set(type, { - callbacks: this._callbacks.get(type) || new Set>(), pending: null, queue: new DispatchQueue(), }); @@ -174,7 +165,8 @@ export class AsyncDispatch extends Dispatch { entry.queue.enqueue(value, this.emitQueueFilter(type, value)); } else { const event = this.willEmit(type, value); - const { callbacks, queue } = entry; + const callbacks = this._callbacks.get(type); + const { queue } = entry; if (callbacks?.size) { // broadcast update to callbacks, which may return promises // wait until promises resolve, then process pending updates @@ -256,4 +248,4 @@ export class DispatchQueue { this.next = next?.next || null; return next?.value; } -} \ No newline at end of file +} diff --git a/packages/mosaic/core/test/async-dispatch.test.ts b/packages/mosaic/core/test/async-dispatch.test.ts new file mode 100644 index 000000000..ba6df0da5 --- /dev/null +++ b/packages/mosaic/core/test/async-dispatch.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { AsyncDispatch } from "../src/util/AsyncDispatch.js"; + +class FilteredDispatch extends AsyncDispatch { + override emitQueueFilter(): (value: number) => boolean { + return (value: number) => value % 2 === 0; + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("AsyncDispatch", () => { + it("emits to listeners added after an initial listener-less emit", () => { + const dispatch = new AsyncDispatch(); + const values: number[] = []; + + dispatch.emit("value", 1); + dispatch.addEventListener("value", (value) => { + values.push(value); + }); + dispatch.emit("value", 2); + + expect(values).toEqual([2]); + }); + + it("deduplicates and removes listeners", () => { + const dispatch = new AsyncDispatch(); + const values: number[] = []; + const listener = (value: number): void => { + values.push(value); + }; + + dispatch.addEventListener("value", listener); + dispatch.addEventListener("value", listener); + dispatch.emit("value", 1); + + dispatch.removeEventListener("value", listener); + dispatch.emit("value", 2); + + expect(values).toEqual([1]); + }); + + it("replaces queued values by default while an emit is pending", async () => { + const dispatch = new AsyncDispatch(); + const values: number[] = []; + const gate = deferred(); + let block = true; + + dispatch.addEventListener("value", async (value) => { + values.push(value); + if (block) { + block = false; + await gate.promise; + } + }); + + dispatch.emit("value", 1); + dispatch.emit("value", 2); + dispatch.emit("value", 3); + + expect(values).toEqual([1]); + + gate.resolve(); + await dispatch.pending("value"); + await dispatch.pending("value"); + + expect(values).toEqual([1, 3]); + }); + + it("filters queued values using emitQueueFilter", async () => { + const dispatch = new FilteredDispatch(); + const values: number[] = []; + const gate = deferred(); + let block = true; + + dispatch.addEventListener("value", async (value) => { + values.push(value); + if (block) { + block = false; + await gate.promise; + } + }); + + dispatch.emit("value", 1); + dispatch.emit("value", 2); + dispatch.emit("value", 3); + dispatch.emit("value", 4); + + gate.resolve(); + await dispatch.pending("value"); + await dispatch.pending("value"); + + expect(values).toEqual([1, 2, 4]); + }); + + it("cancels queued values without interrupting a pending emit", async () => { + const dispatch = new AsyncDispatch(); + const values: number[] = []; + const gate = deferred(); + let block = true; + + dispatch.addEventListener("value", async (value) => { + values.push(value); + if (block) { + block = false; + await gate.promise; + } + }); + + dispatch.emit("value", 1); + dispatch.emit("value", 2); + dispatch.cancel("value"); + + gate.resolve(); + await dispatch.pending("value"); + + expect(values).toEqual([1]); + }); +}); From ff06b2f3a76e8bd9322928b2c6b0779b916cc340 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 11:13:59 +1200 Subject: [PATCH 22/30] Remove stale selection queue filter suppression Drop the ts-expect-error on Selection.emitQueueFilter now that the dispatch queue filter hook is typed against the dispatched value type. This keeps Selection aligned with the generic AsyncDispatch contract without carrying an unnecessary TypeScript suppression. --- packages/mosaic/core/src/Selection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/mosaic/core/src/Selection.ts b/packages/mosaic/core/src/Selection.ts index c167ccee1..0704fdbce 100644 --- a/packages/mosaic/core/src/Selection.ts +++ b/packages/mosaic/core/src/Selection.ts @@ -244,7 +244,6 @@ export class Selection extends Param { * @returns A dispatch queue filter function. For non-value events, * returns a function that always returns null (no filtering). */ - // @ts-expect-error selection and param use differing value types emitQueueFilter( type: string, value: SelectionClauseArray From 13e13df3b4276cf0a2b08ec83324a35b7593a73c Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 11:40:59 +1200 Subject: [PATCH 23/30] Balance query lifecycle events with request ids Add query ids to query lifecycle events and make QueryEnd the terminal event for both successful and failed query attempts. QueryEnd now carries a coarse status so observers can distinguish success from error without treating Error events as lifecycle terminators. Wire QueryManager to emit Error followed by QueryEnd(status: "error") when submit fails, while preserving QueryEnd(status: "success") for normal and cached results. Update observeLogger to correlate starts and ends by query id instead of raw SQL text, avoiding fragile matching for concurrent identical queries and ensuring failed query groups are closed. Add coverage for query ids/status, cached lifecycle events, failed query termination, and logger cleanup on query errors. --- packages/mosaic/core/src/Events.ts | 26 ++++++++-- packages/mosaic/core/src/QueryManager.ts | 33 ++++++++++-- packages/mosaic/core/src/logger.ts | 19 +++---- packages/mosaic/core/test/coordinator.test.ts | 27 ++++++++++ .../mosaic/core/test/query-manager.test.ts | 52 +++++++++++++++++++ 5 files changed, 137 insertions(+), 20 deletions(-) diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index 6889b77c6..b14ff4a5a 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -16,53 +16,71 @@ abstract class MosaicEventBase { } export interface QueryLifecycleEventInit { + queryId: number; query: string; materialized: boolean; timestamp?: number; } +export type QueryEndStatus = "success" | "error"; + +export interface QueryEndEventInit extends QueryLifecycleEventInit { + status: QueryEndStatus; +} + export class MosaicQueryStartEvent extends MosaicEventBase { + readonly queryId: number; readonly query: string; readonly materialized: boolean; - constructor({ query, materialized, timestamp }: QueryLifecycleEventInit) { + constructor({ queryId, query, materialized, timestamp }: QueryLifecycleEventInit) { super(EventType.QueryStart, timestamp); + this.queryId = queryId; this.query = query; this.materialized = materialized; } } export class MosaicQueryEndEvent extends MosaicEventBase { + readonly queryId: number; readonly query: string; readonly materialized: boolean; + readonly status: QueryEndStatus; - constructor({ query, materialized, timestamp }: QueryLifecycleEventInit) { + constructor({ queryId, query, materialized, status, timestamp }: QueryEndEventInit) { super(EventType.QueryEnd, timestamp); + this.queryId = queryId; this.query = query; this.materialized = materialized; + this.status = status; } } export interface MosaicMessageEventInit { message: string; + queryId?: number; timestamp?: number; } export class MosaicWarningEvent extends MosaicEventBase { readonly message: string; + readonly queryId?: number; - constructor({ message, timestamp }: MosaicMessageEventInit) { + constructor({ message, queryId, timestamp }: MosaicMessageEventInit) { super(EventType.Warning, timestamp); this.message = message; + this.queryId = queryId; } } export class MosaicErrorEvent extends MosaicEventBase { readonly message: string; + readonly queryId?: number; - constructor({ message, timestamp }: MosaicMessageEventInit) { + constructor({ message, queryId, timestamp }: MosaicMessageEventInit) { super(EventType.Error, timestamp); this.message = message; + this.queryId = queryId; } } diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index f242a5b35..07c7dab2f 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -26,6 +26,7 @@ export class QueryManager { private maxConcurrentRequests: number; private pendingExec: boolean; private _eventBus: ObserveDispatch | null; + private _nextQueryId: number; constructor(maxConcurrentRequests: number = 32) { this.queue = new PriorityQueue(3); @@ -36,6 +37,7 @@ export class QueryManager { this.maxConcurrentRequests = maxConcurrentRequests; this.pendingExec = false; this._eventBus = null; + this._nextQueryId = 1; } /** @@ -101,6 +103,10 @@ export class QueryManager { * @param result The query result. */ async submit(request: QueryRequest, result: QueryResult): Promise { + let queryId: number | undefined; + let queryText = ""; + let materialized = false; + try { const { query, type, cache = false, options } = request; const sql = Array.isArray(query) @@ -108,13 +114,16 @@ export class QueryManager { : query ? String(query) : null; - const queryText = sql || ""; + queryId = this._nextQueryId++; + queryText = sql || ""; + materialized = cache; this._eventBus?.emit( EventType.QueryStart, new MosaicQueryStartEvent({ + queryId, query: queryText, - materialized: cache, + materialized, }), ); @@ -127,8 +136,10 @@ export class QueryManager { this._eventBus?.emit( EventType.QueryEnd, new MosaicQueryEndEvent({ + queryId, query: queryText, - materialized: cache, + materialized, + status: "success", }), ); return; @@ -148,8 +159,10 @@ export class QueryManager { this._eventBus?.emit( EventType.QueryEnd, new MosaicQueryEndEvent({ + queryId, query: queryText, - materialized: cache, + materialized, + status: "success", }), ); } catch (err) { @@ -157,8 +170,20 @@ export class QueryManager { EventType.Error, new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), + queryId, }), ); + if (queryId != null) { + this._eventBus?.emit( + EventType.QueryEnd, + new MosaicQueryEndEvent({ + queryId, + query: queryText, + materialized, + status: "error", + }), + ); + } result.reject(err); } } diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index a58c826cd..a0731e322 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -28,28 +28,23 @@ export function observeLogger( ): () => void { if (!logger) return () => {}; - // Track start times by query text; use a stack per query to support overlap. - const starts = new Map(); + const starts = new Map(); const onQueryStart = (event: MosaicQueryStartEvent): void => { - const key = event.query; - const stack = starts.get(key) ?? []; - stack.push(now()); - starts.set(key, stack); + starts.set(event.queryId, now()); - logger.groupCollapsed(`query ${key}`); + logger.groupCollapsed(`query ${event.query}`); }; const onQueryEnd = (event: MosaicQueryEndEvent): void => { - const key = event.query; - const stack = starts.get(key); - const t0 = stack?.pop(); + const t0 = starts.get(event.queryId); + starts.delete(event.queryId); const elapsed = t0 == null ? undefined : (now() - t0).toFixed(1); if (elapsed != null) { - logger.log(key, elapsed); + logger.log(event.query, elapsed); } else { - logger.log(key); + logger.log(event.query); } logger.groupEnd(); diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index 2fe8ebd39..75614c6a2 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -196,6 +196,33 @@ describe('coordinator', () => { expect(logger.groupEnd).toHaveBeenCalledTimes(1); }); + it('observeLogger closes query groups for failed queries', async () => { + const connector = { + async query() { + throw new Error('boom'); + }, + } as unknown as Connector; + + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + preagg: { enabled: false }, + }); + + const logger = createLogger(); + observeLogger(coord, logger); + + await expect(coord.query('SELECT fail', { cache: false })).rejects.toThrow('boom'); + + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.groupCollapsed).toHaveBeenCalledWith('query SELECT fail'); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith('boom'); + expect(logger.log).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith('SELECT fail', expect.any(String)); + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + }); + it('wires custom query managers to the coordinator event bus', async () => { const connector = { async query() { diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 343a1e10b..4749832f5 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -6,6 +6,7 @@ import { ObserveDispatch } from "../src/util/ObserveDispatch.js"; import { EventType, type MosaicEventMap, + MosaicErrorEvent, MosaicQueryStartEvent, MosaicQueryEndEvent, } from "../src/Events.js"; @@ -108,9 +109,12 @@ describe("QueryManager", () => { expect(start.query).toBe("SELECT 42"); expect(end.query).toBe("SELECT 42"); + expect(start.queryId).toBe(1); + expect(end.queryId).toBe(start.queryId); expect(start.materialized).toBe(false); expect(end.materialized).toBe(false); + expect(end.status).toBe("success"); }); it("emits QueryStart and QueryEnd for cached requests", async () => { @@ -167,6 +171,54 @@ describe("QueryManager", () => { expect(typeof event.timestamp).toBe("number"); expect(event.query).toBe("SELECT 1"); expect(event.materialized).toBe(true); + expect(event.status).toBe("success"); } + + expect(starts.map((event) => event.queryId)).toEqual([1, 2]); + expect(ends.map((event) => event.queryId)).toEqual([1, 2]); + }); + + it("emits QueryEnd with error status when a query fails", async () => { + const eventBus = new ObserveDispatch(); + const queryManager = new QueryManager(); + queryManager.setEventBus(eventBus); + + const starts: MosaicQueryStartEvent[] = []; + const errors: MosaicErrorEvent[] = []; + const ends: MosaicQueryEndEvent[] = []; + + eventBus.addEventListener(EventType.QueryStart, (event) => { + starts.push(event); + }); + eventBus.addEventListener(EventType.Error, (event) => { + errors.push(event); + }); + eventBus.addEventListener(EventType.QueryEnd, (event) => { + ends.push(event); + }); + + queryManager.connector({ + query: async () => { + throw new Error("boom"); + }, + }); + + await expect(queryManager.request({ + type: "arrow", + query: "SELECT fail", + cache: false, + })).rejects.toThrow("boom"); + + expect(starts).toHaveLength(1); + expect(errors).toHaveLength(1); + expect(ends).toHaveLength(1); + + expect(errors[0].message).toBe("boom"); + expect(errors[0].queryId).toBe(starts[0].queryId); + + expect(ends[0].query).toBe("SELECT fail"); + expect(ends[0].materialized).toBe(false); + expect(ends[0].queryId).toBe(starts[0].queryId); + expect(ends[0].status).toBe("error"); }); }); From c1b01cf6224d76643937e1c027302da84df0118d Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 11:52:51 +1200 Subject: [PATCH 24/30] Preserve original errors on MosaicErrorEvent MosaicErrorEvent now carries an optional original error payload in addition to its stable message string. This keeps existing message-based consumers working while allowing diagnostic observers to retain Error objects, stack traces, and non-Error rejection values. Thread the original caught value through QueryManager, Coordinator, and PreAggregator error emissions, and update observeLogger to prefer the original error payload when available. Add focused test coverage to verify query failures preserve the Error object and logger output still closes the query group correctly. --- packages/mosaic/core/src/Coordinator.ts | 2 ++ packages/mosaic/core/src/Events.ts | 8 +++++++- packages/mosaic/core/src/QueryManager.ts | 1 + packages/mosaic/core/src/index.ts | 1 + packages/mosaic/core/src/logger.ts | 2 +- packages/mosaic/core/src/preagg/PreAggregator.ts | 2 +- packages/mosaic/core/test/coordinator.test.ts | 5 +++-- packages/mosaic/core/test/query-manager.test.ts | 5 ++++- 8 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index ebd785b38..406a417dd 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -268,6 +268,7 @@ export class Coordinator { EventType.Error, new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), + error: err, }), ); client.queryError(err); @@ -278,6 +279,7 @@ export class Coordinator { EventType.Error, new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), + error: err, }), ); })); diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index b14ff4a5a..6d26befa8 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -62,6 +62,10 @@ export interface MosaicMessageEventInit { timestamp?: number; } +export interface MosaicErrorEventInit extends MosaicMessageEventInit { + error?: unknown; +} + export class MosaicWarningEvent extends MosaicEventBase { readonly message: string; readonly queryId?: number; @@ -76,11 +80,13 @@ export class MosaicWarningEvent extends MosaicEventBase { export class MosaicErrorEvent extends MosaicEventBase { readonly message: string; readonly queryId?: number; + readonly error?: unknown; - constructor({ message, queryId, timestamp }: MosaicMessageEventInit) { + constructor({ message, queryId, error, timestamp }: MosaicErrorEventInit) { super(EventType.Error, timestamp); this.message = message; this.queryId = queryId; + this.error = error; } } diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 07c7dab2f..878fc439d 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -170,6 +170,7 @@ export class QueryManager { EventType.Error, new MosaicErrorEvent({ message: err instanceof Error ? err.message : String(err), + error: err, queryId, }), ); diff --git a/packages/mosaic/core/src/index.ts b/packages/mosaic/core/src/index.ts index 1b4a13b12..fb88f0c8d 100644 --- a/packages/mosaic/core/src/index.ts +++ b/packages/mosaic/core/src/index.ts @@ -48,6 +48,7 @@ export * from './types.js'; export type * from './connectors/Connector.js'; export type { MosaicEvent, + MosaicErrorEventInit, MosaicEventMap, MosaicMessageEventInit, QueryLifecycleEventInit diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index a0731e322..3bb2fdf48 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -55,7 +55,7 @@ export function observeLogger( }; const onError = (event: MosaicErrorEvent): void => { - logger.error(event.message); + logger.error(event.error ?? event.message); }; coordinator.eventBus.addEventListener(EventType.QueryStart, onQueryStart); diff --git a/packages/mosaic/core/src/preagg/PreAggregator.ts b/packages/mosaic/core/src/preagg/PreAggregator.ts index 632df6f04..dd8e76a26 100644 --- a/packages/mosaic/core/src/preagg/PreAggregator.ts +++ b/packages/mosaic/core/src/preagg/PreAggregator.ts @@ -219,7 +219,7 @@ export class PreAggregator { info.result.catch((e: Error) => mc.eventBus.emit( EventType.Error, - new MosaicErrorEvent({ message: e.message }), + new MosaicErrorEvent({ message: e.message, error: e }), ), ); } diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index 75614c6a2..18acfd36b 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -197,9 +197,10 @@ describe('coordinator', () => { }); it('observeLogger closes query groups for failed queries', async () => { + const error = new Error('boom'); const connector = { async query() { - throw new Error('boom'); + throw error; }, } as unknown as Connector; @@ -217,7 +218,7 @@ describe('coordinator', () => { expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); expect(logger.groupCollapsed).toHaveBeenCalledWith('query SELECT fail'); expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith('boom'); + expect(logger.error).toHaveBeenCalledWith(error); expect(logger.log).toHaveBeenCalledTimes(1); expect(logger.log).toHaveBeenCalledWith('SELECT fail', expect.any(String)); expect(logger.groupEnd).toHaveBeenCalledTimes(1); diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 4749832f5..0439d12bd 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -197,9 +197,11 @@ describe("QueryManager", () => { ends.push(event); }); + const error = new Error("boom"); + queryManager.connector({ query: async () => { - throw new Error("boom"); + throw error; }, }); @@ -214,6 +216,7 @@ describe("QueryManager", () => { expect(ends).toHaveLength(1); expect(errors[0].message).toBe("boom"); + expect(errors[0].error).toBe(error); expect(errors[0].queryId).toBe(starts[0].queryId); expect(ends[0].query).toBe("SELECT fail"); From f28be8813d141b4b125cd0b87e410f14f5d9d914 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 12:33:01 +1200 Subject: [PATCH 25/30] Harden observeLogger event rendering Derive query elapsed time from QueryStart/QueryEnd event timestamps instead of observer-local wall-clock reads, keeping the event stream as the source of truth for logger rendering. Only close console groups that this observer opened for a matching query id, so unmatched QueryEnd events cannot accidentally close an unrelated console group. Clear in-flight query state on unsubscribe to avoid retaining observer-local lifecycle state. Add focused coverage for timestamp-based elapsed logging, unmatched QueryEnd handling, and unsubscribe/reobserve behavior. --- packages/mosaic/core/src/logger.ts | 14 +-- packages/mosaic/core/test/coordinator.test.ts | 117 +++++++++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index 3bb2fdf48..3d6021a07 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -8,12 +8,6 @@ import { type MosaicWarningEvent, } from "./Events.js"; -function now(): number { - return typeof performance !== "undefined" && typeof performance.now === "function" - ? performance.now() - : Date.now(); -} - /** * Observe coordinator events and log them in a way that mirrors prior * coordinator-internal logging behavior. @@ -31,15 +25,16 @@ export function observeLogger( const starts = new Map(); const onQueryStart = (event: MosaicQueryStartEvent): void => { - starts.set(event.queryId, now()); + starts.set(event.queryId, event.timestamp); logger.groupCollapsed(`query ${event.query}`); }; const onQueryEnd = (event: MosaicQueryEndEvent): void => { const t0 = starts.get(event.queryId); + const openedGroup = t0 != null; starts.delete(event.queryId); - const elapsed = t0 == null ? undefined : (now() - t0).toFixed(1); + const elapsed = t0 == null ? undefined : (event.timestamp - t0).toFixed(1); if (elapsed != null) { logger.log(event.query, elapsed); @@ -47,7 +42,7 @@ export function observeLogger( logger.log(event.query); } - logger.groupEnd(); + if (openedGroup) logger.groupEnd(); }; const onWarning = (event: MosaicWarningEvent): void => { @@ -68,5 +63,6 @@ export function observeLogger( coordinator.eventBus.removeEventListener(EventType.QueryEnd, onQueryEnd); coordinator.eventBus.removeEventListener(EventType.Warning, onWarning); coordinator.eventBus.removeEventListener(EventType.Error, onError); + starts.clear(); }; } diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index 18acfd36b..a2d1c3fca 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -1,6 +1,6 @@ import { Query } from '@uwdata/mosaic-sql'; import { describe, it, expect, vi } from 'vitest'; -import { clausePoint, type Connector, Coordinator, coordinator, EventType, type JSONQueryRequest, type Logger, makeClient, type MosaicQueryStartEvent, observeLogger, Selection } from '../src/index.js'; +import { clausePoint, type Connector, Coordinator, coordinator, EventType, type JSONQueryRequest, type Logger, makeClient, MosaicQueryEndEvent, MosaicQueryStartEvent, observeLogger, Selection } from '../src/index.js'; import { QueryManager } from '../src/QueryManager.js'; import { QueryResult, QueryState } from '../src/util/query-result.js'; @@ -224,6 +224,121 @@ describe('coordinator', () => { expect(logger.groupEnd).toHaveBeenCalledTimes(1); }); + it('observeLogger derives elapsed time from query event timestamps', () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + logger: null, + preagg: { enabled: false }, + }); + const logger = createLogger(); + observeLogger(coord, logger); + + coord.eventBus.emit( + EventType.QueryStart, + new MosaicQueryStartEvent({ + queryId: 1, + query: 'SELECT timed', + materialized: false, + timestamp: 100, + }), + ); + coord.eventBus.emit( + EventType.QueryEnd, + new MosaicQueryEndEvent({ + queryId: 1, + query: 'SELECT timed', + materialized: false, + status: 'success', + timestamp: 123.45, + }), + ); + + expect(logger.log).toHaveBeenCalledWith('SELECT timed', '23.5'); + expect(logger.groupEnd).toHaveBeenCalledTimes(1); + }); + + it('observeLogger does not close a group for unmatched query end events', () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + logger: null, + preagg: { enabled: false }, + }); + const logger = createLogger(); + observeLogger(coord, logger); + + coord.eventBus.emit( + EventType.QueryEnd, + new MosaicQueryEndEvent({ + queryId: 1, + query: 'SELECT unmatched', + materialized: false, + status: 'success', + }), + ); + + expect(logger.log).toHaveBeenCalledWith('SELECT unmatched'); + expect(logger.groupEnd).not.toHaveBeenCalled(); + }); + + it('observeLogger clears in-flight query state on unsubscribe', () => { + const connector = { + async query() { + return [{ value: 1 }]; + }, + } as unknown as Connector; + + const coord = new Coordinator(connector, { + cache: false, + consolidate: false, + logger: null, + preagg: { enabled: false }, + }); + const logger = createLogger(); + const unobserve = observeLogger(coord, logger); + + coord.eventBus.emit( + EventType.QueryStart, + new MosaicQueryStartEvent({ + queryId: 1, + query: 'SELECT in_flight', + materialized: false, + timestamp: 100, + }), + ); + + unobserve(); + observeLogger(coord, logger); + + coord.eventBus.emit( + EventType.QueryEnd, + new MosaicQueryEndEvent({ + queryId: 1, + query: 'SELECT in_flight', + materialized: false, + status: 'success', + timestamp: 200, + }), + ); + + expect(logger.groupCollapsed).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith('SELECT in_flight'); + expect(logger.groupEnd).not.toHaveBeenCalled(); + }); + it('wires custom query managers to the coordinator event bus', async () => { const connector = { async query() { From d16cd60da105f4ccc219e21e72e4199dc8704890 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Fri, 15 May 2026 12:39:19 +1200 Subject: [PATCH 26/30] Clarify QueryManager event bus attachment Rename the internal QueryManager event bus wiring method from setEventBus to attachEventBus to better reflect that the coordinator owns the observable event bus and attaches it during construction. Guard against attaching a different bus after one has already been set, while allowing idempotent attachment of the same bus. This makes the one-time wiring contract explicit for custom QueryManager instances without introducing a separate public observation surface. Add test coverage for the attachment semantics. --- packages/mosaic/core/src/Coordinator.ts | 2 +- packages/mosaic/core/src/QueryManager.ts | 9 +++++++-- .../mosaic/core/test/query-manager.test.ts | 18 +++++++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 406a417dd..7ca501fe6 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -85,7 +85,7 @@ export class Coordinator { } = options; this.eventBus = new ObserveDispatch(); this.manager = manager; - this.manager.setEventBus(this.eventBus); + this.manager.attachEventBus(this.eventBus); this.manager.cache(cache); this.manager.consolidate(consolidate); this.databaseConnector(db); diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 878fc439d..84a2291a2 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -41,9 +41,14 @@ export class QueryManager { } /** - * @internal Wire query lifecycle events to the owning coordinator. + * @internal Attach query lifecycle events to the owning coordinator's bus. + * Query managers do not own their own observable event surface; the + * coordinator attaches its bus once during construction. */ - setEventBus(eventBus: ObserveDispatch): void { + attachEventBus(eventBus: ObserveDispatch): void { + if (this._eventBus && this._eventBus !== eventBus) { + throw new Error("QueryManager event bus is already attached."); + } this._eventBus = eventBus; } diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 0439d12bd..6a9b410c4 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -67,7 +67,7 @@ describe("QueryManager", () => { it("emits typed QueryStart and QueryEnd events with type and timestamp", async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); - queryManager.setEventBus(eventBus); + queryManager.attachEventBus(eventBus); const starts: MosaicQueryStartEvent[] = []; const ends: MosaicQueryEndEvent[] = []; @@ -120,7 +120,7 @@ describe("QueryManager", () => { it("emits QueryStart and QueryEnd for cached requests", async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); - queryManager.setEventBus(eventBus); + queryManager.attachEventBus(eventBus); const starts: MosaicQueryStartEvent[] = []; const ends: MosaicQueryEndEvent[] = []; @@ -181,7 +181,7 @@ describe("QueryManager", () => { it("emits QueryEnd with error status when a query fails", async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); - queryManager.setEventBus(eventBus); + queryManager.attachEventBus(eventBus); const starts: MosaicQueryStartEvent[] = []; const errors: MosaicErrorEvent[] = []; @@ -224,4 +224,16 @@ describe("QueryManager", () => { expect(ends[0].queryId).toBe(starts[0].queryId); expect(ends[0].status).toBe("error"); }); + + it("treats event bus attachment as one-time coordinator wiring", () => { + const eventBus = new ObserveDispatch(); + const otherBus = new ObserveDispatch(); + const queryManager = new QueryManager(); + + queryManager.attachEventBus(eventBus); + expect(() => queryManager.attachEventBus(eventBus)).not.toThrow(); + expect(() => queryManager.attachEventBus(otherBus)).toThrow( + "QueryManager event bus is already attached.", + ); + }); }); From 66e8506d1fa1288e860edb3a0fb72cbad94b03ba Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Tue, 19 May 2026 09:24:56 +1200 Subject: [PATCH 27/30] Quiet coordinator query-order test logging Restore the explicit null logger for the coordinator query-order test so the default event-bus logger does not write query groups during test runs. This preserves the original test intent while leaving logger behavior in the rest of the coordinator suite unchanged. --- packages/mosaic/core/test/coordinator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/core/test/coordinator.test.ts b/packages/mosaic/core/test/coordinator.test.ts index a2d1c3fca..53c28e7ce 100644 --- a/packages/mosaic/core/test/coordinator.test.ts +++ b/packages/mosaic/core/test/coordinator.test.ts @@ -51,7 +51,7 @@ describe('coordinator', () => { }, } as unknown as Connector; - const coord = new Coordinator(connector); + const coord = new Coordinator(connector, { logger: null }); const r0 = coord.query('SELECT 0'); const r1 = coord.query('SELECT 1'); From 264ca4a25e5e07feafee87da9384944c35b3e3b0 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Tue, 19 May 2026 09:27:47 +1200 Subject: [PATCH 28/30] Respect disabled coordinator logging in widget Guard widget logger calls now that Coordinator.logger() can return null. This preserves the existing widget diagnostics when a logger is configured while allowing coordinator().logger(null) to silence widget logging without throwing. Keep the change local to the widget logging call sites so the deprecated coordinator logger contract remains unchanged for core consumers. --- packages/vgplot/widget/src/index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/vgplot/widget/src/index.js b/packages/vgplot/widget/src/index.js index da2de0f99..b6174fec7 100644 --- a/packages/vgplot/widget/src/index.js +++ b/packages/vgplot/widget/src/index.js @@ -61,7 +61,7 @@ export default { async function updateSpec() { const spec = getSpec(); reset(); - logger.log('Setting spec:', spec); + logger?.log('Setting spec:', spec); const dom = await instantiateSpec(spec); view.el.replaceChildren(dom.element); @@ -101,27 +101,27 @@ export default { view.model.on('change:preagg_schema', () => configureCoordinator()); view.model.on('msg:custom', (msg, buffers) => { - logger.group(`query ${msg.uuid}`); - logger.log('received message', msg, buffers); + logger?.group(`query ${msg.uuid}`); + logger?.log('received message', msg, buffers); const query = openQueries.get(msg.uuid); openQueries.delete(msg.uuid); - logger.log(query.query.sql, (performance.now() - query.startTime).toFixed(1)); + logger?.log(query.query.sql, (performance.now() - query.startTime).toFixed(1)); if (msg.error) { query.reject(msg.error); - logger.error(msg.error); + logger?.error(msg.error); } else { switch (msg.type) { case 'arrow': { const table = decodeIPC(buffers[0].buffer); - logger.log('table', table); + logger?.log('table', table); query.resolve(table); break; } case 'json': { - logger.log('json', msg.result); + logger?.log('json', msg.result); query.resolve(msg.result); break; } @@ -131,7 +131,7 @@ export default { } } } - logger.groupEnd(); + logger?.groupEnd(); }); coordinator().databaseConnector(connector); From aeb71838e72a152adc07d9985329170b7ef911a2 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Tue, 19 May 2026 09:28:41 +1200 Subject: [PATCH 29/30] Normalize event bus core quote style Switch the event bus core changes and query manager tests to the single-quote style used by the surrounding mosaic core package. This keeps the new event bus files and touched coordinator/query manager code consistent with the existing source conventions without changing behavior. --- packages/mosaic/core/src/Coordinator.ts | 52 +++++------ packages/mosaic/core/src/Events.ts | 10 +-- packages/mosaic/core/src/QueryManager.ts | 36 ++++---- packages/mosaic/core/src/logger.ts | 6 +- .../mosaic/core/src/util/ObserveDispatch.ts | 2 +- .../mosaic/core/test/query-manager.test.ts | 90 +++++++++---------- 6 files changed, 98 insertions(+), 98 deletions(-) diff --git a/packages/mosaic/core/src/Coordinator.ts b/packages/mosaic/core/src/Coordinator.ts index 7ca501fe6..f2042e296 100644 --- a/packages/mosaic/core/src/Coordinator.ts +++ b/packages/mosaic/core/src/Coordinator.ts @@ -1,22 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { SocketConnector } from "./connectors/socket.js"; -import { type Connector } from "./connectors/Connector.js"; +import { SocketConnector } from './connectors/socket.js'; +import { type Connector } from './connectors/Connector.js'; import { PreAggregator, type PreAggregateInfo, type PreAggregateOptions, -} from "./preagg/PreAggregator.js"; -import { QueryManager, Priority } from "./QueryManager.js"; -import { type Selection } from "./Selection.js"; -import { type Logger, type QueryType } from "./types.js"; -import { type QueryResult } from "./util/query-result.js"; -import { type MosaicClient } from "./MosaicClient.js"; -import { type SelectionClause } from "./SelectionClause.js"; -import { MaybeArray } from "@uwdata/mosaic-sql"; -import { Table } from "@uwdata/flechette"; -import { EventType, MosaicErrorEvent, type MosaicEventMap } from "./Events.js"; -import { ObserveDispatch } from "./util/ObserveDispatch.js"; -import { observeLogger } from "./logger.js"; +} from './preagg/PreAggregator.js'; +import { QueryManager, Priority } from './QueryManager.js'; +import { type Selection } from './Selection.js'; +import { type Logger, type QueryType } from './types.js'; +import { type QueryResult } from './util/query-result.js'; +import { type MosaicClient } from './MosaicClient.js'; +import { type SelectionClause } from './SelectionClause.js'; +import { MaybeArray } from '@uwdata/mosaic-sql'; +import { Table } from '@uwdata/flechette'; +import { EventType, MosaicErrorEvent, type MosaicEventMap } from './Events.js'; +import { ObserveDispatch } from './util/ObserveDispatch.js'; +import { observeLogger } from './logger.js'; interface FilterGroupEntry { selection: Selection; @@ -164,7 +164,7 @@ export class Coordinator { options: { priority?: number } = {}, ): QueryResult { const { priority = Priority.Normal } = options; - return this.manager.request({ type: "exec", query }, priority); + return this.manager.request({ type: 'exec', query }, priority); } /** @@ -181,7 +181,7 @@ export class Coordinator { query( query: QueryType, options?: { - type?: "arrow"; + type?: 'arrow'; cache?: boolean; persist?: boolean; priority?: number; @@ -191,7 +191,7 @@ export class Coordinator { query( query: QueryType, options?: { - type?: "json"; + type?: 'json'; cache?: boolean; persist?: boolean; priority?: number; @@ -201,7 +201,7 @@ export class Coordinator { query( query: QueryType, options: { - type?: "arrow" | "json"; + type?: 'arrow' | 'json'; cache?: boolean; persist?: boolean; priority?: number; @@ -209,7 +209,7 @@ export class Coordinator { } = {}, ): QueryResult { const { - type = "arrow", + type = 'arrow', cache = true, priority = Priority.Normal, ...otherOptions @@ -230,11 +230,11 @@ export class Coordinator { */ prefetch( query: QueryType, - options?: { type?: "arrow"; [key: string]: unknown }, + options?: { type?: 'arrow'; [key: string]: unknown }, ): QueryResult
; prefetch( query: QueryType, - options?: { type?: "json"; [key: string]: unknown }, + options?: { type?: 'json'; [key: string]: unknown }, ): QueryResult; prefetch(query: QueryType, options: any = {}): QueryResult { return this.query(query, { @@ -311,7 +311,7 @@ export class Coordinator { const { clients } = this; if (clients?.has(client)) { - throw new Error("Client already connected."); + throw new Error('Client already connected.'); } // add client to client set @@ -364,16 +364,16 @@ function connectSelection( const value = () => updateSelection(mc, selection); // @ts-expect-error todo: update selection dispatch types - selection.addEventListener("activate", activate); - selection.addEventListener("value", value); + selection.addEventListener('activate', activate); + selection.addEventListener('value', value); entry = { selection, clients: new Set(), disconnect() { // @ts-expect-error todo: update selection dispatch types - selection.removeEventListener("activate", activate); - selection.removeEventListener("value", value); + selection.removeEventListener('activate', activate); + selection.removeEventListener('value', value); }, }; mc.filterGroups?.set(selection, entry); diff --git a/packages/mosaic/core/src/Events.ts b/packages/mosaic/core/src/Events.ts index 6d26befa8..63152fc0b 100644 --- a/packages/mosaic/core/src/Events.ts +++ b/packages/mosaic/core/src/Events.ts @@ -1,8 +1,8 @@ export enum EventType { - QueryStart = "query-start", - QueryEnd = "query-end", - Warning = "warning", - Error = "error", + QueryStart = 'query-start', + QueryEnd = 'query-end', + Warning = 'warning', + Error = 'error', } abstract class MosaicEventBase { @@ -22,7 +22,7 @@ export interface QueryLifecycleEventInit { timestamp?: number; } -export type QueryEndStatus = "success" | "error"; +export type QueryEndStatus = 'success' | 'error'; export interface QueryEndEventInit extends QueryLifecycleEventInit { status: QueryEndStatus; diff --git a/packages/mosaic/core/src/QueryManager.ts b/packages/mosaic/core/src/QueryManager.ts index 84a2291a2..da137dbb6 100644 --- a/packages/mosaic/core/src/QueryManager.ts +++ b/packages/mosaic/core/src/QueryManager.ts @@ -1,9 +1,9 @@ -import type { Connector } from "./connectors/Connector.js"; -import type { Cache, QueryEntry, QueryRequest } from "./types.js"; -import { consolidator } from "./QueryConsolidator.js"; -import { lruCache, voidCache } from "./util/cache.js"; -import { PriorityQueue } from "./util/priority-queue.js"; -import { QueryResult, QueryState } from "./util/query-result.js"; +import type { Connector } from './connectors/Connector.js'; +import type { Cache, QueryEntry, QueryRequest } from './types.js'; +import { consolidator } from './QueryConsolidator.js'; +import { lruCache, voidCache } from './util/cache.js'; +import { PriorityQueue } from './util/priority-queue.js'; +import { QueryResult, QueryState } from './util/query-result.js'; import { EventType, MosaicErrorEvent, @@ -11,8 +11,8 @@ import { MosaicQueryEndEvent, MosaicQueryStartEvent, MosaicWarningEvent, -} from "./Events.js"; -import { ObserveDispatch } from "./util/ObserveDispatch.js"; +} from './Events.js'; +import { ObserveDispatch } from './util/ObserveDispatch.js'; export const Priority = Object.freeze({ High: 0, Normal: 1, Low: 2 }); @@ -47,7 +47,7 @@ export class QueryManager { */ attachEventBus(eventBus: ObserveDispatch): void { if (this._eventBus && this._eventBus !== eventBus) { - throw new Error("QueryManager event bus is already attached."); + throw new Error('QueryManager event bus is already attached.'); } this._eventBus = eventBus; } @@ -67,7 +67,7 @@ export class QueryManager { const { request, result } = entry; this.pendingResults.push(result); - if (request.type === "exec") this.pendingExec = true; + if (request.type === 'exec') this.pendingExec = true; this.submit(request, result).finally(() => { // return from the queue all requests that are ready @@ -82,12 +82,12 @@ export class QueryManager { this._eventBus?.emit( EventType.Warning, new MosaicWarningEvent({ - message: "Found resolved query in pending results.", + message: 'Found resolved query in pending results.', }), ); } } - if (request.type === "exec") this.pendingExec = false; + if (request.type === 'exec') this.pendingExec = false; this.next(); }); } @@ -109,18 +109,18 @@ export class QueryManager { */ async submit(request: QueryRequest, result: QueryResult): Promise { let queryId: number | undefined; - let queryText = ""; + let queryText = ''; let materialized = false; try { const { query, type, cache = false, options } = request; const sql = Array.isArray(query) - ? query.filter((x) => x).join(";\n") + ? query.filter((x) => x).join(';\n') : query ? String(query) : null; queryId = this._nextQueryId++; - queryText = sql || ""; + queryText = sql || ''; materialized = cache; this._eventBus?.emit( @@ -144,7 +144,7 @@ export class QueryManager { queryId, query: queryText, materialized, - status: "success", + status: 'success', }), ); return; @@ -167,7 +167,7 @@ export class QueryManager { queryId, query: queryText, materialized, - status: "success", + status: 'success', }), ); } catch (err) { @@ -186,7 +186,7 @@ export class QueryManager { queryId, query: queryText, materialized, - status: "error", + status: 'error', }), ); } diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index 3d6021a07..ac5b89675 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -1,12 +1,12 @@ -import type { Coordinator } from "./Coordinator.js"; -import type { Logger } from "./types.js"; +import type { Coordinator } from './Coordinator.js'; +import type { Logger } from './types.js'; import { EventType, type MosaicErrorEvent, type MosaicQueryEndEvent, type MosaicQueryStartEvent, type MosaicWarningEvent, -} from "./Events.js"; +} from './Events.js'; /** * Observe coordinator events and log them in a way that mirrors prior diff --git a/packages/mosaic/core/src/util/ObserveDispatch.ts b/packages/mosaic/core/src/util/ObserveDispatch.ts index ad0f02ddf..1c4d5d072 100644 --- a/packages/mosaic/core/src/util/ObserveDispatch.ts +++ b/packages/mosaic/core/src/util/ObserveDispatch.ts @@ -1,4 +1,4 @@ -import { Dispatch, EventCallback } from "./AsyncDispatch.js"; +import { Dispatch, EventCallback } from './AsyncDispatch.js'; type EventMap = Record; type EventKey = Extract; diff --git a/packages/mosaic/core/test/query-manager.test.ts b/packages/mosaic/core/test/query-manager.test.ts index 6a9b410c4..e5206a0bd 100644 --- a/packages/mosaic/core/test/query-manager.test.ts +++ b/packages/mosaic/core/test/query-manager.test.ts @@ -1,32 +1,32 @@ -import { describe, it, expect } from "vitest"; -import { QueryManager } from "../src/QueryManager.js"; -import { QueryResult } from "../src/util/query-result.js"; -import type { QueryRequest } from "../src/types.js"; -import { ObserveDispatch } from "../src/util/ObserveDispatch.js"; +import { describe, it, expect } from 'vitest'; +import { QueryManager } from '../src/QueryManager.js'; +import { QueryResult } from '../src/util/query-result.js'; +import type { QueryRequest } from '../src/types.js'; +import { ObserveDispatch } from '../src/util/ObserveDispatch.js'; import { EventType, type MosaicEventMap, MosaicErrorEvent, MosaicQueryStartEvent, MosaicQueryEndEvent, -} from "../src/Events.js"; +} from '../src/Events.js'; -describe("QueryManager", () => { - it("should run a simple query", async () => { +describe('QueryManager', () => { + it('should run a simple query', async () => { const queryManager = new QueryManager(); // Mock the connector queryManager.connector({ // @ts-expect-error assumes type value query: async ({ sql }) => { - expect(sql).toBe("SELECT 1"); + expect(sql).toBe('SELECT 1'); return [{ column: 1 }]; }, }); const request: QueryRequest = { - type: "arrow", - query: "SELECT 1", + type: 'arrow', + query: 'SELECT 1', }; const result = queryManager.request(request); @@ -36,26 +36,26 @@ describe("QueryManager", () => { expect(data).toEqual([{ column: 1 }]); }); - it("should not run a query when there is a pending exec", async () => { + it('should not run a query when there is a pending exec', async () => { const queryManager = new QueryManager(); // Mock the connector queryManager.connector({ // @ts-expect-error assumes type value query: ({ sql }) => { - expect(sql).toBe("CREATE TABLE test (id INT)"); + expect(sql).toBe('CREATE TABLE test (id INT)'); return new Promise(() => {}); }, }); const request1: QueryRequest = { - type: "exec", - query: "CREATE TABLE test (id INT)", + type: 'exec', + query: 'CREATE TABLE test (id INT)', }; const request2: QueryRequest = { - type: "arrow", - query: "SELECT * FROM test", + type: 'arrow', + query: 'SELECT * FROM test', }; queryManager.request(request1); @@ -64,7 +64,7 @@ describe("QueryManager", () => { expect(queryManager.pendingResults).toHaveLength(1); }); - it("emits typed QueryStart and QueryEnd events with type and timestamp", async () => { + it('emits typed QueryStart and QueryEnd events with type and timestamp', async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); queryManager.attachEventBus(eventBus); @@ -85,8 +85,8 @@ describe("QueryManager", () => { }); const result = queryManager.request({ - type: "arrow", - query: "SELECT 42", + type: 'arrow', + query: 'SELECT 42', cache: false, }); @@ -104,20 +104,20 @@ describe("QueryManager", () => { expect(start.type).toBe(EventType.QueryStart); expect(end.type).toBe(EventType.QueryEnd); - expect(typeof start.timestamp).toBe("number"); - expect(typeof end.timestamp).toBe("number"); + expect(typeof start.timestamp).toBe('number'); + expect(typeof end.timestamp).toBe('number'); - expect(start.query).toBe("SELECT 42"); - expect(end.query).toBe("SELECT 42"); + expect(start.query).toBe('SELECT 42'); + expect(end.query).toBe('SELECT 42'); expect(start.queryId).toBe(1); expect(end.queryId).toBe(start.queryId); expect(start.materialized).toBe(false); expect(end.materialized).toBe(false); - expect(end.status).toBe("success"); + expect(end.status).toBe('success'); }); - it("emits QueryStart and QueryEnd for cached requests", async () => { + it('emits QueryStart and QueryEnd for cached requests', async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); queryManager.attachEventBus(eventBus); @@ -144,14 +144,14 @@ describe("QueryManager", () => { queryManager.cache(true); await queryManager.request({ - type: "arrow", - query: "SELECT 1", + type: 'arrow', + query: 'SELECT 1', cache: true, }); await queryManager.request({ - type: "arrow", - query: "SELECT 1", + type: 'arrow', + query: 'SELECT 1', cache: true, }); @@ -161,24 +161,24 @@ describe("QueryManager", () => { for (const event of starts) { expect(event.type).toBe(EventType.QueryStart); - expect(typeof event.timestamp).toBe("number"); - expect(event.query).toBe("SELECT 1"); + expect(typeof event.timestamp).toBe('number'); + expect(event.query).toBe('SELECT 1'); expect(event.materialized).toBe(true); } for (const event of ends) { expect(event.type).toBe(EventType.QueryEnd); - expect(typeof event.timestamp).toBe("number"); - expect(event.query).toBe("SELECT 1"); + expect(typeof event.timestamp).toBe('number'); + expect(event.query).toBe('SELECT 1'); expect(event.materialized).toBe(true); - expect(event.status).toBe("success"); + expect(event.status).toBe('success'); } expect(starts.map((event) => event.queryId)).toEqual([1, 2]); expect(ends.map((event) => event.queryId)).toEqual([1, 2]); }); - it("emits QueryEnd with error status when a query fails", async () => { + it('emits QueryEnd with error status when a query fails', async () => { const eventBus = new ObserveDispatch(); const queryManager = new QueryManager(); queryManager.attachEventBus(eventBus); @@ -197,7 +197,7 @@ describe("QueryManager", () => { ends.push(event); }); - const error = new Error("boom"); + const error = new Error('boom'); queryManager.connector({ query: async () => { @@ -206,26 +206,26 @@ describe("QueryManager", () => { }); await expect(queryManager.request({ - type: "arrow", - query: "SELECT fail", + type: 'arrow', + query: 'SELECT fail', cache: false, - })).rejects.toThrow("boom"); + })).rejects.toThrow('boom'); expect(starts).toHaveLength(1); expect(errors).toHaveLength(1); expect(ends).toHaveLength(1); - expect(errors[0].message).toBe("boom"); + expect(errors[0].message).toBe('boom'); expect(errors[0].error).toBe(error); expect(errors[0].queryId).toBe(starts[0].queryId); - expect(ends[0].query).toBe("SELECT fail"); + expect(ends[0].query).toBe('SELECT fail'); expect(ends[0].materialized).toBe(false); expect(ends[0].queryId).toBe(starts[0].queryId); - expect(ends[0].status).toBe("error"); + expect(ends[0].status).toBe('error'); }); - it("treats event bus attachment as one-time coordinator wiring", () => { + it('treats event bus attachment as one-time coordinator wiring', () => { const eventBus = new ObserveDispatch(); const otherBus = new ObserveDispatch(); const queryManager = new QueryManager(); @@ -233,7 +233,7 @@ describe("QueryManager", () => { queryManager.attachEventBus(eventBus); expect(() => queryManager.attachEventBus(eventBus)).not.toThrow(); expect(() => queryManager.attachEventBus(otherBus)).toThrow( - "QueryManager event bus is already attached.", + 'QueryManager event bus is already attached.', ); }); }); From 296f6c70d566f7455a84d324e668db9d809d4d9f Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Tue, 19 May 2026 09:30:12 +1200 Subject: [PATCH 30/30] Clarify unmatched query end logging Document why observeLogger logs unmatched QueryEnd events without closing a console group. This makes the intentionally asymmetric handling explicit for future maintainers. The existing behavior and coordinator coverage remain unchanged. --- packages/mosaic/core/src/logger.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mosaic/core/src/logger.ts b/packages/mosaic/core/src/logger.ts index ac5b89675..eb572e826 100644 --- a/packages/mosaic/core/src/logger.ts +++ b/packages/mosaic/core/src/logger.ts @@ -39,6 +39,7 @@ export function observeLogger( if (elapsed != null) { logger.log(event.query, elapsed); } else { + // Surface unmatched terminal events without closing a group this observer did not open. logger.log(event.query); }