@uwdata/mosaic-core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Scalable and extensible linked data views.",
5
5
  "keywords": [
6
6
  "mosaic",
@@ -10,7 +10,7 @@
10
10
  "interface"
11
11
  ],
12
12
  "license": "BSD-3-Clause",
13
- "author": "Jeffrey Heer (http://idl.cs.washington.edu)",
13
+ "author": "Jeffrey Heer (https://idl.uw.edu)",
14
14
  "type": "module",
15
15
  "main": "src/index.js",
16
16
  "module": "src/index.js",
@@ -24,16 +24,16 @@
24
24
  "prebuild": "rimraf dist && mkdir dist",
25
25
  "build": "node ../../esbuild.js mosaic-core",
26
26
  "lint": "eslint src test",
27
- "test": "mocha 'test/**/*-test.js'",
27
+ "test": "vitest run --dangerouslyIgnoreUnhandledErrors",
28
28
  "prepublishOnly": "npm run test && npm run lint && npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@duckdb/duckdb-wasm": "^1.28.1-dev195.0",
32
- "@uwdata/mosaic-sql": "^0.9.0",
33
- "apache-arrow": "^15.0.2"
31
+ "@duckdb/duckdb-wasm": "^1.28.1-dev278.0",
32
+ "@uwdata/flechette": "^1.0.2",
33
+ "@uwdata/mosaic-sql": "^0.11.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@uwdata/mosaic-duckdb": "^0.9.0"
36
+ "@uwdata/mosaic-duckdb": "^0.11.0"
37
37
  },
38
- "gitHead": "89bb9b0dfa747aed691eaeba35379525a6764c61"
38
+ "gitHead": "861d616f39926a1d2aee83b59dbdd70b0b3caf12"
39
39
  }
@@ -1,9 +1,15 @@
1
1
  import { socketConnector } from './connectors/socket.js';
2
- import { FilterGroup } from './FilterGroup.js';
2
+ import { DataCubeIndexer } from './DataCubeIndexer.js';
3
+ import { MosaicClient } from './MosaicClient.js';
3
4
  import { QueryManager, Priority } from './QueryManager.js';
4
5
  import { queryFieldInfo } from './util/field-info.js';
6
+ import { QueryResult } from './util/query-result.js';
5
7
  import { voidLogger } from './util/void-logger.js';
6
8
 
9
+ /**
10
+ * The singleton Coordinator instance.
11
+ * @type {Coordinator}
12
+ */
7
13
  let _instance;
8
14
 
9
15
  /**
@@ -20,67 +26,119 @@ export function coordinator(instance) {
20
26
  return _instance;
21
27
  }
22
28
 
29
+ /**
30
+ * @typedef {import('@uwdata/mosaic-sql').Query | string} QueryType
31
+ */
32
+
33
+ /**
34
+ * A Mosaic Coordinator manages all database communication for clients and
35
+ * handles selection updates. The Coordinator also performs optimizations
36
+ * including query caching, consolidation, and data cube indexing.
37
+ * @param {*} [db] Database connector. Defaults to a web socket connection.
38
+ * @param {object} [options] Coordinator options.
39
+ * @param {*} [options.logger=console] The logger to use, defaults to `console`.
40
+ * @param {*} [options.manager] The query manager to use.
41
+ * @param {boolean} [options.cache=true] Boolean flag to enable/disable query caching.
42
+ * @param {boolean} [options.consolidate=true] Boolean flag to enable/disable query consolidation.
43
+ * @param {import('./DataCubeIndexer.js').DataCubeIndexerOptions} [options.indexes]
44
+ * Data cube indexer options.
45
+ */
23
46
  export class Coordinator {
24
- constructor(db = socketConnector(), options = {}) {
25
- const {
26
- logger = console,
27
- manager = new QueryManager()
28
- } = options;
47
+ constructor(db = socketConnector(), {
48
+ logger = console,
49
+ manager = new QueryManager(),
50
+ cache = true,
51
+ consolidate = true,
52
+ indexes = {}
53
+ } = {}) {
54
+ /** @type {QueryManager} */
29
55
  this.manager = manager;
30
- this.logger(logger);
31
- this.configure(options);
56
+ this.manager.cache(cache);
57
+ this.manager.consolidate(consolidate);
32
58
  this.databaseConnector(db);
59
+ this.logger(logger);
33
60
  this.clear();
34
- }
35
-
36
- logger(logger) {
37
- if (arguments.length) {
38
- this._logger = logger || voidLogger();
39
- this.manager.logger(this._logger);
40
- }
41
- return this._logger;
61
+ this.dataCubeIndexer = new DataCubeIndexer(this, indexes);
42
62
  }
43
63
 
44
64
  /**
45
- * Set configuration options for this coordinator.
46
- * @param {object} [options] Configration options.
47
- * @param {boolean} [options.cache=true] Boolean flag to enable/disable query caching.
48
- * @param {boolean} [options.consolidate=true] Boolean flag to enable/disable query consolidation.
49
- * @param {boolean|object} [options.indexes=true] Boolean flag to enable/disable
50
- * automatic data cube indexes or an index options object.
65
+ * Clear the coordinator state.
66
+ * @param {object} [options] Options object.
67
+ * @param {boolean} [options.clients=true] If true, disconnect all clients.
68
+ * @param {boolean} [options.cache=true] If true, clear the query cache.
51
69
  */
52
- configure({ cache = true, consolidate = true, indexes = true } = {}) {
53
- this.manager.cache(cache);
54
- this.manager.consolidate(consolidate);
55
- this.indexes = indexes;
56
- }
57
-
58
70
  clear({ clients = true, cache = true } = {}) {
59
71
  this.manager.clear();
60
72
  if (clients) {
73
+ this.filterGroups?.forEach(group => group.disconnect());
74
+ this.filterGroups = new Map;
61
75
  this.clients?.forEach(client => this.disconnect(client));
62
- this.filterGroups?.forEach(group => group.finalize());
63
76
  this.clients = new Set;
64
- this.filterGroups = new Map;
65
77
  }
66
78
  if (cache) this.manager.cache().clear();
67
79
  }
68
80
 
81
+ /**
82
+ * Get or set the database connector.
83
+ * @param {*} [db] The database connector to use.
84
+ * @returns The current database connector.
85
+ */
69
86
  databaseConnector(db) {
70
87
  return this.manager.connector(db);
71
88
  }
72
89
 
90
+ /**
91
+ * Get or set the logger.
92
+ * @param {*} logger The logger to use.
93
+ * @returns The current logger
94
+ */
95
+ logger(logger) {
96
+ if (arguments.length) {
97
+ this._logger = logger || voidLogger();
98
+ this.manager.logger(this._logger);
99
+ }
100
+ return this._logger;
101
+ }
102
+
73
103
  // -- Query Management ----
74
104
 
105
+ /**
106
+ * Cancel previosuly submitted query requests. These queries will be
107
+ * canceled if they are queued but have not yet been submitted.
108
+ * @param {QueryResult[]} requests An array
109
+ * of query result objects, such as those returned by the `query` method.
110
+ */
75
111
  cancel(requests) {
76
112
  this.manager.cancel(requests);
77
113
  }
78
114
 
115
+ /**
116
+ * Issue a query for which no result (return value) is needed.
117
+ * @param {QueryType | QueryType[]} query The query or an array of queries.
118
+ * Each query should be either a Query builder object or a SQL string.
119
+ * @param {object} [options] An options object.
120
+ * @param {number} [options.priority] The query priority, defaults to
121
+ * `Priority.Normal`.
122
+ * @returns {QueryResult} A query result
123
+ * promise.
124
+ */
79
125
  exec(query, { priority = Priority.Normal } = {}) {
80
- query = Array.isArray(query) ? query.join(';\n') : query;
126
+ query = Array.isArray(query) ? query.filter(x => x).join(';\n') : query;
81
127
  return this.manager.request({ type: 'exec', query }, priority);
82
128
  }
83
129
 
130
+ /**
131
+ * Issue a query to the backing database. The submitted query may be
132
+ * consolidate with other queries and its results may be cached.
133
+ * @param {QueryType} query The query as either a Query builder object
134
+ * or a SQL string.
135
+ * @param {object} [options] An options object.
136
+ * @param {'arrow' | 'json'} [options.type] The query result format type.
137
+ * @param {boolean} [options.cache=true] If true, cache the query result.
138
+ * @param {number} [options.priority] The query priority, defaults to
139
+ * `Priority.Normal`.
140
+ * @returns {QueryResult} A query result promise.
141
+ */
84
142
  query(query, {
85
143
  type = 'arrow',
86
144
  cache = true,
@@ -90,15 +148,38 @@ export class Coordinator {
90
148
  return this.manager.request({ type, query, cache, options }, priority);
91
149
  }
92
150
 
151
+ /**
152
+ * Issue a query to prefetch data for later use. The query result is cached
153
+ * for efficient future access.
154
+ * @param {QueryType} query The query as either a Query builder object
155
+ * or a SQL string.
156
+ * @param {object} [options] An options object.
157
+ * @param {'arrow' | 'json'} [options.type] The query result format type.
158
+ * @returns {QueryResult} A query result promise.
159
+ */
93
160
  prefetch(query, options = {}) {
94
161
  return this.query(query, { ...options, cache: true, priority: Priority.Low });
95
162
  }
96
163
 
164
+ /**
165
+ * Create a bundle of queries that can be loaded into the cache.
166
+ *
167
+ * @param {string} name The name of the bundle.
168
+ * @param {[string | {sql: string}, {alias: string}]} queries The queries to save into the bundle.
169
+ * @param {number} priority Request priority.
170
+ * @returns {QueryResult} A query result promise.
171
+ */
97
172
  createBundle(name, queries, priority = Priority.Low) {
98
- const options = { name, queries };
173
+ const options = { name, queries: queries.map(q => typeof q == 'string' ? {sql: q} : q) };
99
174
  return this.manager.request({ type: 'create-bundle', options }, priority);
100
175
  }
101
176
 
177
+ /**
178
+ * Load a bundle into the cache.
179
+ * @param {string} name The name of the bundle.
180
+ * @param {number} priority Request priority.
181
+ * @returns {QueryResult} A query result promise.
182
+ */
102
183
  loadBundle(name, priority = Priority.High) {
103
184
  const options = { name };
104
185
  return this.manager.request({ type: 'load-bundle', options }, priority);
@@ -106,27 +187,44 @@ export class Coordinator {
106
187
 
107
188
  // -- Client Management ----
108
189
 
190
+ /**
191
+ * Update client data by submitting the given query and returning the
192
+ * data (or error) to the client.
193
+ * @param {MosaicClient} client A Mosaic client.
194
+ * @param {QueryType} query The data query.
195
+ * @param {number} [priority] The query priority.
196
+ * @returns {Promise} A Promise that resolves upon completion of the update.
197
+ */
109
198
  updateClient(client, query, priority = Priority.Normal) {
110
199
  client.queryPending();
111
- return this.query(query, { priority }).then(
112
- data => client.queryResult(data).update(),
113
- err => { client.queryError(err); this._logger.error(err); }
114
- );
200
+ return this.query(query, { priority })
201
+ .then(
202
+ data => client.queryResult(data).update(),
203
+ err => { this._logger.error(err); client.queryError(err); }
204
+ )
205
+ .catch(err => this._logger.error(err));
115
206
  }
116
207
 
208
+ /**
209
+ * Issue a query request for a client. If the query is null or undefined,
210
+ * the client is simply updated. Otherwise `updateClient` is called. As a
211
+ * side effect, this method clears the current data cube indexer state.
212
+ * @param {MosaicClient} client The client to update.
213
+ * @param {QueryType | null} [query] The query to issue.
214
+ */
117
215
  requestQuery(client, query) {
118
- this.filterGroups.get(client.filterBy)?.reset();
216
+ this.dataCubeIndexer.clear();
119
217
  return query
120
218
  ? this.updateClient(client, query)
121
- : client.update();
219
+ : Promise.resolve(client.update());
122
220
  }
123
221
 
124
222
  /**
125
223
  * Connect a client to the coordinator.
126
- * @param {import('./MosaicClient.js').MosaicClient} client the client to disconnect
224
+ * @param {MosaicClient} client The Mosaic client to connect.
127
225
  */
128
226
  async connect(client) {
129
- const { clients, filterGroups, indexes } = this;
227
+ const { clients } = this;
130
228
 
131
229
  if (clients.has(client)) {
132
230
  throw new Error('Client already connected.');
@@ -134,36 +232,108 @@ export class Coordinator {
134
232
  clients.add(client); // mark as connected
135
233
  client.coordinator = this;
136
234
 
235
+ // initialize client lifecycle
236
+ this.initializeClient(client);
237
+
238
+ // connect filter selection
239
+ connectSelection(this, client.filterBy, client);
240
+ }
241
+
242
+ async initializeClient(client) {
137
243
  // retrieve field statistics
138
244
  const fields = client.fields();
139
245
  if (fields?.length) {
140
246
  client.fieldInfo(await queryFieldInfo(this, fields));
141
247
  }
142
248
 
143
- // connect filters
144
- const filter = client.filterBy;
145
- if (filter) {
146
- if (filterGroups.has(filter)) {
147
- filterGroups.get(filter).add(client);
148
- } else {
149
- const group = new FilterGroup(this, filter, indexes);
150
- filterGroups.set(filter, group.add(client));
151
- }
152
- }
153
-
154
- client.requestQuery();
249
+ // request data query
250
+ return client.requestQuery();
155
251
  }
156
252
 
157
253
  /**
158
254
  * Disconnect a client from the coordinator.
159
- *
160
- * @param {import('./MosaicClient.js').MosaicClient} client the client to disconnect
255
+ * @param {MosaicClient} client The Mosaic client to disconnect.
161
256
  */
162
257
  disconnect(client) {
163
258
  const { clients, filterGroups } = this;
164
259
  if (!clients.has(client)) return;
165
260
  clients.delete(client);
166
- filterGroups.get(client.filterBy)?.remove(client);
167
261
  client.coordinator = null;
262
+
263
+ const group = filterGroups.get(client.filterBy);
264
+ if (group) {
265
+ group.clients.delete(client);
266
+ }
168
267
  }
169
268
  }
269
+
270
+ /**
271
+ * Connect a selection-client pair to the coordinator to process updates.
272
+ * @param {Coordinator} mc The Mosaic coordinator.
273
+ * @param {import('./Selection.js').Selection} selection A selection.
274
+ * @param {MosaicClient} client A Mosiac client that is filtered by the
275
+ * given selection.
276
+ */
277
+ function connectSelection(mc, selection, client) {
278
+ if (!selection) return;
279
+ let entry = mc.filterGroups.get(selection);
280
+ if (!entry) {
281
+ const activate = clause => activateSelection(mc, selection, clause);
282
+ const value = () => updateSelection(mc, selection);
283
+
284
+ selection.addEventListener('activate', activate);
285
+ selection.addEventListener('value', value);
286
+
287
+ entry = {
288
+ selection,
289
+ clients: new Set,
290
+ disconnect() {
291
+ selection.removeEventListener('activate', activate);
292
+ selection.removeEventListener('value', value);
293
+ }
294
+ };
295
+ mc.filterGroups.set(selection, entry);
296
+ }
297
+ entry.clients.add(client);
298
+ }
299
+
300
+ /**
301
+ * Activate a selection, providing a clause indicative of potential
302
+ * next updates. Activation provides a preview of likely next events,
303
+ * enabling potential precomputation to optimize updates.
304
+ * @param {Coordinator} mc The Mosaic coordinator.
305
+ * @param {import('./Selection.js').Selection} selection A selection.
306
+ * @param {import('./util/selection-types.js').SelectionClause} clause A
307
+ * selection clause representative of the activation.
308
+ */
309
+ function activateSelection(mc, selection, clause) {
310
+ const { dataCubeIndexer, filterGroups } = mc;
311
+ const { clients } = filterGroups.get(selection);
312
+ for (const client of clients) {
313
+ dataCubeIndexer.index(client, selection, clause);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Process an updated selection value, querying filtered data for any
319
+ * associated clients.
320
+ * @param {Coordinator} mc The Mosaic coordinator.
321
+ * @param {import('./Selection.js').Selection} selection A selection.
322
+ * @returns {Promise} A Promise that resolves when the update completes.
323
+ */
324
+ function updateSelection(mc, selection) {
325
+ const { dataCubeIndexer, filterGroups } = mc;
326
+ const { clients } = filterGroups.get(selection);
327
+ const { active } = selection;
328
+ return Promise.allSettled(Array.from(clients, client => {
329
+ const info = dataCubeIndexer.index(client, selection, active);
330
+ const filter = info ? null : selection.predicate(client);
331
+
332
+ // skip due to cross-filtering
333
+ if (info?.skip || (!info && !filter)) return;
334
+
335
+ // @ts-ignore
336
+ const query = info?.query(active.predicate) ?? client.query(filter);
337
+ return mc.updateClient(client, query);
338
+ }));
339
+ }