@uwdata/mosaic-core 0.8.0 → 0.10.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.
@@ -1,52 +1,55 @@
1
1
  import { consolidator } from './QueryConsolidator.js';
2
2
  import { lruCache, voidCache } from './util/cache.js';
3
3
  import { priorityQueue } from './util/priority-queue.js';
4
- import { queryResult } from './util/query-result.js';
4
+ import { QueryResult } from './util/query-result.js';
5
5
 
6
6
  export const Priority = { High: 0, Normal: 1, Low: 2 };
7
7
 
8
- export function QueryManager() {
9
- const queue = priorityQueue(3);
10
- let db;
11
- let clientCache;
12
- let logger;
13
- let recorders = [];
14
- let pending = null;
15
- let consolidate;
16
-
17
- function next() {
18
- if (pending || queue.isEmpty()) return;
19
- const { request, result } = queue.next();
20
- pending = submit(request, result);
21
- pending.finally(() => { pending = null; next(); });
8
+ export class QueryManager {
9
+ constructor() {
10
+ this.queue = priorityQueue(3);
11
+ this.db = null;
12
+ this.clientCache = null;
13
+ this._logger = null;
14
+ this._logQueries = false;
15
+ this.recorders = [];
16
+ this.pending = null;
17
+ this._consolidate = null;
22
18
  }
23
19
 
24
- function enqueue(entry, priority = Priority.Normal) {
25
- queue.insert(entry, priority);
26
- next();
20
+ next() {
21
+ if (this.pending || this.queue.isEmpty()) return;
22
+ const { request, result } = this.queue.next();
23
+ this.pending = this.submit(request, result);
24
+ this.pending.finally(() => { this.pending = null; this.next(); });
27
25
  }
28
26
 
29
- function recordQuery(sql) {
30
- if (recorders.length && sql) {
31
- recorders.forEach(rec => rec.add(sql));
27
+ enqueue(entry, priority = Priority.Normal) {
28
+ this.queue.insert(entry, priority);
29
+ this.next();
30
+ }
31
+
32
+ recordQuery(sql) {
33
+ if (this.recorders.length && sql) {
34
+ this.recorders.forEach(rec => rec.add(sql));
32
35
  }
33
36
  }
34
37
 
35
- async function submit(request, result) {
38
+ async submit(request, result) {
36
39
  try {
37
40
  const { query, type, cache = false, record = true, options } = request;
38
41
  const sql = query ? `${query}` : null;
39
42
 
40
43
  // update recorders
41
44
  if (record) {
42
- recordQuery(sql);
45
+ this.recordQuery(sql);
43
46
  }
44
47
 
45
48
  // check query cache
46
49
  if (cache) {
47
- const cached = clientCache.get(sql);
50
+ const cached = this.clientCache.get(sql);
48
51
  if (cached) {
49
- logger.debug('Cache');
52
+ this._logger.debug('Cache');
50
53
  result.fulfill(cached);
51
54
  return;
52
55
  }
@@ -54,80 +57,87 @@ export function QueryManager() {
54
57
 
55
58
  // issue query, potentially cache result
56
59
  const t0 = performance.now();
57
- const data = await db.query({ type, sql, ...options });
58
- if (cache) clientCache.set(sql, data);
59
- logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
+ if (this._logQueries) {
61
+ this._logger.debug('Query', { type, sql, ...options });
62
+ }
63
+ const data = await this.db.query({ type, sql, ...options });
64
+ if (cache) this.clientCache.set(sql, data);
65
+ this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
66
  result.fulfill(data);
61
67
  } catch (err) {
62
68
  result.reject(err);
63
69
  }
64
70
  }
65
71
 
66
- return {
67
- cache(value) {
68
- return value !== undefined
69
- ? (clientCache = value === true ? lruCache() : (value || voidCache()))
70
- : clientCache;
71
- },
72
-
73
- logger(value) {
74
- return value ? (logger = value) : logger;
75
- },
76
-
77
- connector(connector) {
78
- return connector ? (db = connector) : db;
79
- },
80
-
81
- consolidate(flag) {
82
- if (flag && !consolidate) {
83
- consolidate = consolidator(enqueue, clientCache, recordQuery);
84
- } else if (!flag && consolidate) {
85
- consolidate = null;
86
- }
87
- },
88
-
89
- request(request, priority = Priority.Normal) {
90
- const result = queryResult();
91
- const entry = { request, result };
92
- if (consolidate) {
93
- consolidate.add(entry, priority);
94
- } else {
95
- enqueue(entry, priority);
96
- }
97
- return result;
98
- },
99
-
100
- cancel(requests) {
101
- const set = new Set(requests);
102
- queue.remove(({ result }) => set.has(result));
103
- },
104
-
105
- clear() {
106
- queue.remove(({ result }) => {
107
- result.reject('Cleared');
108
- return true;
109
- });
110
- },
111
-
112
- record() {
113
- let state = [];
114
- const recorder = {
115
- add(query) {
116
- state.push(query);
117
- },
118
- reset() {
119
- state = [];
120
- },
121
- snapshot() {
122
- return state.slice();
123
- },
124
- stop() {
125
- recorders = recorders.filter(x => x !== recorder);
126
- return state;
127
- }
128
- };
129
- recorders.push(recorder);
130
- return recorder;
72
+ cache(value) {
73
+ return value !== undefined
74
+ ? (this.clientCache = value === true ? lruCache() : (value || voidCache()))
75
+ : this.clientCache;
76
+ }
77
+
78
+ logger(value) {
79
+ return value ? (this._logger = value) : this._logger;
80
+ }
81
+
82
+ logQueries(value) {
83
+ return value !== undefined ? this._logQueries = !!value : this._logQueries;
84
+ }
85
+
86
+ connector(connector) {
87
+ return connector ? (this.db = connector) : this.db;
88
+ }
89
+
90
+ consolidate(flag) {
91
+ if (flag && !this._consolidate) {
92
+ this._consolidate = consolidator(this.enqueue.bind(this), this.clientCache, this.recordQuery.bind(this));
93
+ } else if (!flag && this._consolidate) {
94
+ this._consolidate = null;
95
+ }
96
+ }
97
+
98
+ request(request, priority = Priority.Normal) {
99
+ const result = new QueryResult();
100
+ const entry = { request, result };
101
+ if (this._consolidate) {
102
+ this._consolidate.add(entry, priority);
103
+ } else {
104
+ this.enqueue(entry, priority);
105
+ }
106
+ return result;
107
+ }
108
+
109
+ cancel(requests) {
110
+ const set = new Set(requests);
111
+ if (set.size) {
112
+ this.queue.remove(({ result }) => set.has(result));
131
113
  }
132
- };
114
+ }
115
+
116
+ clear() {
117
+ this.queue.remove(({ result }) => {
118
+ result.reject('Cleared');
119
+ return true;
120
+ });
121
+ }
122
+
123
+ record() {
124
+ let state = [];
125
+ const recorder = {
126
+ add(query) {
127
+ state.push(query);
128
+ },
129
+ reset() {
130
+ state = [];
131
+ },
132
+ snapshot() {
133
+ return state.slice();
134
+ },
135
+ stop() {
136
+ this.recorders = this.recorders.filter(x => x !== recorder);
137
+ return state;
138
+ }
139
+ };
140
+ this.recorders.push(recorder);
141
+ return recorder;
142
+ }
133
143
  }
package/src/Selection.js CHANGED
@@ -22,10 +22,13 @@ export class Selection extends Param {
22
22
  * @param {boolean} [options.cross=false] Boolean flag indicating
23
23
  * cross-filtered resolution. If true, selection clauses will not
24
24
  * be applied to the clients they are associated with.
25
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
26
+ * of clauses should correspond to an empty selection with no records. This
27
+ * setting determines the default selection state.
25
28
  * @returns {Selection} The new Selection instance.
26
29
  */
27
- static intersect({ cross = false } = {}) {
28
- return new Selection(new SelectionResolver({ cross }));
30
+ static intersect({ cross = false, empty = false } = {}) {
31
+ return new Selection(new SelectionResolver({ cross, empty }));
29
32
  }
30
33
 
31
34
  /**
@@ -35,10 +38,13 @@ export class Selection extends Param {
35
38
  * @param {boolean} [options.cross=false] Boolean flag indicating
36
39
  * cross-filtered resolution. If true, selection clauses will not
37
40
  * be applied to the clients they are associated with.
41
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
42
+ * of clauses should correspond to an empty selection with no records. This
43
+ * setting determines the default selection state.
38
44
  * @returns {Selection} The new Selection instance.
39
45
  */
40
- static union({ cross = false } = {}) {
41
- return new Selection(new SelectionResolver({ cross, union: true }));
46
+ static union({ cross = false, empty = false } = {}) {
47
+ return new Selection(new SelectionResolver({ cross, empty, union: true }));
42
48
  }
43
49
 
44
50
  /**
@@ -48,19 +54,26 @@ export class Selection extends Param {
48
54
  * @param {boolean} [options.cross=false] Boolean flag indicating
49
55
  * cross-filtered resolution. If true, selection clauses will not
50
56
  * be applied to the clients they are associated with.
57
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
58
+ * of clauses should correspond to an empty selection with no records. This
59
+ * setting determines the default selection state.
51
60
  * @returns {Selection} The new Selection instance.
52
61
  */
53
- static single({ cross = false } = {}) {
54
- return new Selection(new SelectionResolver({ cross, single: true }));
62
+ static single({ cross = false, empty = false } = {}) {
63
+ return new Selection(new SelectionResolver({ cross, empty, single: true }));
55
64
  }
56
65
 
57
66
  /**
58
67
  * Create a new Selection instance with a
59
68
  * cross-filtered intersect resolution strategy.
69
+ * @param {object} [options] The selection options.
70
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
71
+ * of clauses should correspond to an empty selection with no records. This
72
+ * setting determines the default selection state.
60
73
  * @returns {Selection} The new Selection instance.
61
74
  */
62
- static crossfilter() {
63
- return new Selection(new SelectionResolver({ cross: true }));
75
+ static crossfilter({ empty = false } = {}) {
76
+ return new Selection(new SelectionResolver({ cross: true, empty }));
64
77
  }
65
78
 
66
79
  /**
@@ -98,19 +111,17 @@ export class Selection extends Param {
98
111
  }
99
112
 
100
113
  /**
101
- * The current active (most recently updated) selection clause.
114
+ * The selection clause resolver.
102
115
  */
103
- get active() {
104
- return this.clauses.active;
116
+ get resolver() {
117
+ return this._resolver;
105
118
  }
106
119
 
107
120
  /**
108
- * The value corresponding to the current active selection clause.
109
- * This method ensures compatibility where a normal Param is expected.
121
+ * Indicate if this selection has a single resolution strategy.
110
122
  */
111
- get value() {
112
- // return value of the active clause
113
- return this.active?.value;
123
+ get single() {
124
+ return this._resolver.single;
114
125
  }
115
126
 
116
127
  /**
@@ -121,10 +132,27 @@ export class Selection extends Param {
121
132
  }
122
133
 
123
134
  /**
124
- * Indicate if this selection has a single resolution strategy.
135
+ * The current active (most recently updated) selection clause.
125
136
  */
126
- get single() {
127
- return this._resolver.single;
137
+ get active() {
138
+ return this.clauses.active;
139
+ }
140
+
141
+ /**
142
+ * The value corresponding to the current active selection clause.
143
+ * This method ensures compatibility where a normal Param is expected.
144
+ */
145
+ get value() {
146
+ return this.active?.value;
147
+ }
148
+
149
+ /**
150
+ * The value corresponding to a given source. Returns undefined if
151
+ * this selection does not include a clause from this source.
152
+ * @param {*} source The clause source to look up the value for.
153
+ */
154
+ valueFor(source) {
155
+ return this.clauses.find(c => c.source === source)?.value;
128
156
  }
129
157
 
130
158
  /**
@@ -217,11 +245,15 @@ export class SelectionResolver {
217
245
  * If false, an intersection strategy is used.
218
246
  * @param {boolean} [options.cross=false] Boolean flag to indicate cross-filtering.
219
247
  * @param {boolean} [options.single=false] Boolean flag to indicate single clauses only.
248
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
249
+ * of clauses should correspond to an empty selection with no records. This
250
+ * setting determines the default selection state.
220
251
  */
221
- constructor({ union, cross, single } = {}) {
252
+ constructor({ union, cross, single, empty } = {}) {
222
253
  this.union = !!union;
223
254
  this.cross = !!cross;
224
255
  this.single = !!single;
256
+ this.empty = !!empty;
225
257
  }
226
258
 
227
259
  /**
@@ -259,7 +291,11 @@ export class SelectionResolver {
259
291
  * based on the current state of this selection.
260
292
  */
261
293
  predicate(clauseList, active, client) {
262
- const { union } = this;
294
+ const { empty, union } = this;
295
+
296
+ if (empty && !clauseList.length) {
297
+ return ['FALSE'];
298
+ }
263
299
 
264
300
  // do nothing if cross-filtering and client is currently active
265
301
  if (this.skip(client, active)) return undefined;
@@ -0,0 +1,161 @@
1
+ import {
2
+ SQLExpression, and, contains, isBetween, isNotDistinct, literal,
3
+ or, prefix, regexp_matches, suffix
4
+ } from '@uwdata/mosaic-sql';
5
+ import { MosaicClient } from './MosaicClient.js';
6
+
7
+ /**
8
+ * @typedef {import('./util/selection-types.js').SelectionClause} SelectionClause
9
+ * @typedef {import('./util/selection-types.js').Scale} Scale
10
+ * @typedef {import('./util/selection-types.js').Extent} Extent
11
+ * @typedef {import('./util/selection-types.js').MatchMethod} MatchMethod
12
+ * @typedef {import('./util/selection-types.js').BinMethod} BinMethod
13
+ * @typedef {SQLExpression | string} Field
14
+ */
15
+
16
+ /**
17
+ * Generate a selection clause for a single selected point value.
18
+ * @param {Field} field The table column or expression to select.
19
+ * @param {*} value The selected value.
20
+ * @param {object} options Additional clause properties.
21
+ * @param {*} options.source The source component generating this clause.
22
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
23
+ * with this clause. These clients are not filtered by this clause in
24
+ * cross-filtering contexts.
25
+ * @returns {SelectionClause} The generated selection clause.
26
+ */
27
+ export function clausePoint(field, value, {
28
+ source,
29
+ clients = source ? new Set([source]) : undefined
30
+ }) {
31
+ /** @type {SQLExpression | null} */
32
+ const predicate = value !== undefined
33
+ ? isNotDistinct(field, literal(value))
34
+ : null;
35
+ return {
36
+ meta: { type: 'point' },
37
+ source,
38
+ clients,
39
+ value,
40
+ predicate
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Generate a selection clause for multiple selected point values.
46
+ * @param {Field[]} fields The table columns or expressions to select.
47
+ * @param {any[][]} value The selected values, as an array of arrays where
48
+ * each subarray contains values corresponding to each *fields* entry.
49
+ * @param {object} options Additional clause properties.
50
+ * @param {*} options.source The source component generating this clause.
51
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
52
+ * with this clause. These clients are not filtered by this clause in
53
+ * cross-filtering contexts.
54
+ * @returns {SelectionClause} The generated selection clause.
55
+ */
56
+ export function clausePoints(fields, value, {
57
+ source,
58
+ clients = source ? new Set([source]) : undefined
59
+ }) {
60
+ /** @type {SQLExpression | null} */
61
+ let predicate = null;
62
+ if (value) {
63
+ const clauses = value.map(vals => {
64
+ const list = vals.map((v, i) => isNotDistinct(fields[i], literal(v)));
65
+ return list.length > 1 ? and(list) : list[0];
66
+ });
67
+ predicate = clauses.length > 1 ? or(clauses) : clauses[0];
68
+ }
69
+ return {
70
+ meta: { type: 'point' },
71
+ source,
72
+ clients,
73
+ value,
74
+ predicate
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Generate a selection clause for a selected 1D interval.
80
+ * @param {Field} field The table column or expression to select.
81
+ * @param {Extent} value The selected interval as a [lo, hi] array.
82
+ * @param {object} options Additional clause properties.
83
+ * @param {*} options.source The source component generating this clause.
84
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
85
+ * with this clause. These clients are not filtered by this clause in
86
+ * cross-filtering contexts.
87
+ * @param {Scale} [options.scale] The scale mapping descriptor.
88
+ * @param {BinMethod} [options.bin] A binning method hint.
89
+ * @param {number} [options.pixelSize=1] The interactive pixel size.
90
+ * @returns {SelectionClause} The generated selection clause.
91
+ */
92
+ export function clauseInterval(field, value, {
93
+ source,
94
+ clients = source ? new Set([source]) : undefined,
95
+ bin,
96
+ scale,
97
+ pixelSize = 1
98
+ }) {
99
+ /** @type {SQLExpression | null} */
100
+ const predicate = value != null ? isBetween(field, value) : null;
101
+ /** @type {import('./util/selection-types.js').IntervalMetadata} */
102
+ const meta = { type: 'interval', scales: scale && [scale], bin, pixelSize };
103
+ return { meta, source, clients, value, predicate };
104
+ }
105
+
106
+ /**
107
+ * Generate a selection clause for multiple selected intervals.
108
+ * @param {Field[]} fields The table columns or expressions to select.
109
+ * @param {Extent[]} value The selected intervals, as an array of extents.
110
+ * @param {object} options Additional clause properties.
111
+ * @param {*} options.source The source component generating this clause.
112
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
113
+ * with this clause. These clients are not filtered by this clause in
114
+ * cross-filtering contexts.
115
+ * @param {Scale[]} [options.scales] The scale mapping descriptors,
116
+ * in an order matching the given *fields* and *value* extents.
117
+ * @param {BinMethod} [options.bin] A binning method hint.
118
+ * @param {number} [options.pixelSize=1] The interactive pixel size.
119
+ * @returns {SelectionClause} The generated selection clause.
120
+ */
121
+ export function clauseIntervals(fields, value, {
122
+ source,
123
+ clients = source ? new Set([source]) : undefined,
124
+ bin,
125
+ scales = [],
126
+ pixelSize = 1
127
+ }) {
128
+ /** @type {SQLExpression | null} */
129
+ const predicate = value != null
130
+ ? and(fields.map((f, i) => isBetween(f, value[i])))
131
+ : null;
132
+ /** @type {import('./util/selection-types.js').IntervalMetadata} */
133
+ const meta = { type: 'interval', scales, bin, pixelSize };
134
+ return { meta, source, clients, value, predicate };
135
+ }
136
+
137
+ const MATCH_METHODS = { contains, prefix, suffix, regexp: regexp_matches };
138
+
139
+ /**
140
+ * Generate a selection clause for text search matching.
141
+ * @param {Field} field The table column or expression to select.
142
+ * @param {string} value The selected text search query string.
143
+ * @param {object} options Additional clause properties.
144
+ * @param {*} options.source The source component generating this clause.
145
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
146
+ * with this clause. These clients are not filtered by this clause in
147
+ * cross-filtering contexts.
148
+ * @param {MatchMethod} [options.method] The
149
+ * text matching method to use. Defaults to `'contains'`.
150
+ * @returns {SelectionClause} The generated selection clause.
151
+ */
152
+ export function clauseMatch(field, value, {
153
+ source, clients = undefined, method = 'contains'
154
+ }) {
155
+ let fn = MATCH_METHODS[method];
156
+ /** @type {SQLExpression | null} */
157
+ const predicate = value ? fn(field, literal(value)) : null;
158
+ /** @type {import('./util/selection-types.js').MatchMetadata} */
159
+ const meta = { type: 'match', method };
160
+ return { meta, source, clients, value, predicate };
161
+ }
@@ -2,6 +2,13 @@ import { tableFromIPC } from 'apache-arrow';
2
2
 
3
3
  export function restConnector(uri = 'http://localhost:3000/') {
4
4
  return {
5
+ /**
6
+ * Query the DuckDB server.
7
+ * @param {object} query
8
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
9
+ * @param {string} query.sql A SQL query string.
10
+ * @returns the query result
11
+ */
5
12
  async query(query) {
6
13
  const req = fetch(uri, {
7
14
  method: 'POST',
@@ -81,6 +81,13 @@ export function socketConnector(uri = 'ws://localhost:3000/') {
81
81
  get connected() {
82
82
  return connected;
83
83
  },
84
+ /**
85
+ * Query the DuckDB server.
86
+ * @param {object} query
87
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
88
+ * @param {string} query.sql A SQL query string.
89
+ * @returns the query result
90
+ */
84
91
  query(query) {
85
92
  return new Promise(
86
93
  (resolve, reject) => enqueue(query, resolve, reject)
@@ -45,7 +45,7 @@ export function wasmConnector(options = {}) {
45
45
  /**
46
46
  * Query the DuckDB-WASM instance.
47
47
  * @param {object} query
48
- * @param {string} [query.type] The query type: 'exec', 'arrow', or 'json'.
48
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
49
49
  * @param {string} query.sql A SQL query string.
50
50
  * @returns the query result
51
51
  */
@@ -55,7 +55,7 @@ export function wasmConnector(options = {}) {
55
55
  const result = await con.query(sql);
56
56
  return type === 'exec' ? undefined
57
57
  : type === 'arrow' ? result
58
- : Array.from(result);
58
+ : result.toArray();
59
59
  }
60
60
  };
61
61
  }
package/src/index.js CHANGED
@@ -8,12 +8,22 @@ export { restConnector } from './connectors/rest.js';
8
8
  export { socketConnector } from './connectors/socket.js';
9
9
  export { wasmConnector } from './connectors/wasm.js';
10
10
 
11
+ export {
12
+ clauseInterval,
13
+ clauseIntervals,
14
+ clausePoint,
15
+ clausePoints,
16
+ clauseMatch
17
+ } from './SelectionClause.js';
18
+
11
19
  export {
12
20
  isArrowTable,
13
21
  convertArrowArrayType,
14
22
  convertArrowValue,
15
23
  convertArrowColumn
16
24
  } from './util/convert-arrow.js'
25
+
17
26
  export { distinct } from './util/distinct.js';
18
27
  export { synchronizer } from './util/synchronizer.js';
19
28
  export { throttle } from './util/throttle.js';
29
+ export { toDataColumns } from './util/to-data-columns.js'
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Event dispatcher supporting asynchronous updates.
3
- * If an event handler callback returns a Promise, this dispatcher will
4
- * wait for all such Promises to settle before dispatching future events
5
- * of the same type.
2
+ * Event dispatcher supporting asynchronous updates. If an event handler
3
+ * callback returns a Promise, the dispatcher waits for all such Promises
4
+ * to settle before dispatching future events of the same type.
6
5
  */
7
6
  export class AsyncDispatch {
8
7
 
@@ -63,7 +62,7 @@ export class AsyncDispatch {
63
62
  * queue of unemitted event values prior to enqueueing a new value.
64
63
  * This default implementation simply returns null, indicating that
65
64
  * any other unemitted event values should be dropped (that is, all
66
- * queued events are filtered)
65
+ * queued events are filtered).
67
66
  * @param {string} type The event type.
68
67
  * @param {*} value The new event value that will be enqueued.
69
68
  * @returns {(value: *) => boolean|null} A dispatch queue filter
@@ -83,6 +82,17 @@ export class AsyncDispatch {
83
82
  entry?.queue.clear();
84
83
  }
85
84
 
85
+ /**
86
+ * Returns a promise that resolves when any pending updates complete for
87
+ * the event of the given type currently being processed. The Promise will
88
+ * resolve immediately if the queue for the given event type is empty.
89
+ * @param {string} type The event type to wait for.
90
+ * @returns {Promise} A pending event promise.
91
+ */
92
+ async pending(type) {
93
+ await this._callbacks.get(type)?.pending;
94
+ }
95
+
86
96
  /**
87
97
  * Emit an event value to listeners for the given event type.
88
98
  * If a previous emit has not yet resolved, the event value