@uwdata/mosaic-core 0.1.0 → 0.2.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/src/Selection.js CHANGED
@@ -1,82 +1,278 @@
1
1
  import { or } from '@uwdata/mosaic-sql';
2
2
  import { Param } from './Param.js';
3
- import { skipClient } from './util/skip-client.js';
4
3
 
4
+ /**
5
+ * Test if a value is a Selection instance.
6
+ * @param {*} x The value to test.
7
+ * @returns {boolean} True if the input is a Selection, false otherwise.
8
+ */
5
9
  export function isSelection(x) {
6
10
  return x instanceof Selection;
7
11
  }
8
12
 
13
+ /**
14
+ * Represents a dynamic set of query filter predicates.
15
+ */
9
16
  export class Selection extends Param {
10
17
 
11
- static intersect() {
12
- return new Selection();
18
+ /**
19
+ * Create a new Selection instance with an
20
+ * intersect (conjunction) resolution strategy.
21
+ * @param {object} [options] The selection options.
22
+ * @param {boolean} [options.cross=false] Boolean flag indicating
23
+ * cross-filtered resolution. If true, selection clauses will not
24
+ * be applied to the clients they are associated with.
25
+ * @returns {Selection} The new Selection instance.
26
+ */
27
+ static intersect({ cross = false } = {}) {
28
+ return new Selection(new SelectionResolver({ cross }));
13
29
  }
14
30
 
15
- static crossfilter() {
16
- return new Selection({ cross: true });
31
+ /**
32
+ * Create a new Selection instance with a
33
+ * union (disjunction) resolution strategy.
34
+ * @param {object} [options] The selection options.
35
+ * @param {boolean} [options.cross=false] Boolean flag indicating
36
+ * cross-filtered resolution. If true, selection clauses will not
37
+ * be applied to the clients they are associated with.
38
+ * @returns {Selection} The new Selection instance.
39
+ */
40
+ static union({ cross = false } = {}) {
41
+ return new Selection(new SelectionResolver({ cross, union: true }));
17
42
  }
18
43
 
19
- static union() {
20
- return new Selection({ union: true });
44
+ /**
45
+ * Create a new Selection instance with a singular resolution strategy
46
+ * that keeps only the most recent selection clause.
47
+ * @param {object} [options] The selection options.
48
+ * @param {boolean} [options.cross=false] Boolean flag indicating
49
+ * cross-filtered resolution. If true, selection clauses will not
50
+ * be applied to the clients they are associated with.
51
+ * @returns {Selection} The new Selection instance.
52
+ */
53
+ static single({ cross = false } = {}) {
54
+ return new Selection(new SelectionResolver({ cross, single: true }));
21
55
  }
22
56
 
23
- static single() {
24
- return new Selection({ single: true });
57
+ /**
58
+ * Create a new Selection instance with a
59
+ * cross-filtered intersect resolution strategy.
60
+ * @returns {Selection} The new Selection instance.
61
+ */
62
+ static crossfilter() {
63
+ return new Selection(new SelectionResolver({ cross: true }));
25
64
  }
26
65
 
27
- constructor({ union, cross, single } = {}) {
66
+ /**
67
+ * Create a new Selection instance.
68
+ * @param {SelectionResolver} resolver The selection resolution
69
+ * strategy to apply.
70
+ */
71
+ constructor(resolver = new SelectionResolver()) {
28
72
  super([]);
29
- this.active = null;
30
- this.union = !!union;
31
- this.cross = !!cross;
32
- this.single = !!single;
73
+ this._resolved = this._value;
74
+ this._resolver = resolver;
33
75
  }
34
76
 
77
+ /**
78
+ * Create a cloned copy of this Selection instance.
79
+ * @returns {this} A clone of this selection.
80
+ */
35
81
  clone() {
36
- const s = new Selection();
37
- s.active = this.active;
38
- s.union = this.union;
39
- s.cross = this.cross;
40
- s._value = this._value;
82
+ const s = new Selection(this._resolver);
83
+ s._value = s._resolved = this._value;
41
84
  return s;
42
85
  }
43
86
 
87
+ /**
88
+ * Create a clone of this Selection with clauses corresponding
89
+ * to provided source removed.
90
+ * @param {*} source The clause source to remove.
91
+ * @returns {this} A cloned and updated Selection.
92
+ */
93
+ remove(source) {
94
+ const s = this.clone();
95
+ s._value = s._resolved = s._resolver.resolve(this._resolved, { source });
96
+ s._value.active = { source };
97
+ return s;
98
+ }
99
+
100
+ /**
101
+ * The current active (most recently updated) selection clause.
102
+ */
103
+ get active() {
104
+ return this.clauses.active;
105
+ }
106
+
107
+ /**
108
+ * The value corresponding to the current active selection clause.
109
+ * This method ensures compatibility where a normal Param is expected.
110
+ */
44
111
  get value() {
45
- // return value of most recently added clause
46
- const { clauses } = this;
47
- return clauses[clauses.length - 1]?.value;
112
+ // return value of the active clause
113
+ return this.active?.value;
48
114
  }
49
115
 
116
+ /**
117
+ * The current array of selection clauses.
118
+ */
50
119
  get clauses() {
51
120
  return super.value;
52
121
  }
53
122
 
123
+ /**
124
+ * Emit an activate event with the given selection clause.
125
+ * @param {*} clause The clause repesenting the potential activation.
126
+ */
54
127
  activate(clause) {
55
128
  this.emit('activate', clause);
56
129
  }
57
130
 
131
+ /**
132
+ * Update the selection with a new selection clause.
133
+ * @param {*} clause The selection clause to add.
134
+ * @returns {this} This Selection instance.
135
+ */
58
136
  update(clause) {
137
+ // we maintain an up-to-date list of all resolved clauses
138
+ // this ensures consistent clause state across unemitted event values
139
+ this._resolved = this._resolver.resolve(this._resolved, clause, true);
140
+ this._resolved.active = clause;
141
+ return super.update(this._resolved);
142
+ }
143
+
144
+ /**
145
+ * Upon value-typed updates, sets the current clause list to the
146
+ * input value and returns the active clause value.
147
+ * @param {string} type The event type.
148
+ * @param {*} value The input event value.
149
+ * @returns {*} For value-typed events, returns the active clause
150
+ * values. Otherwise returns the input event value as-is.
151
+ */
152
+ willEmit(type, value) {
153
+ if (type === 'value') {
154
+ this._value = value;
155
+ return this.value;
156
+ }
157
+ return value;
158
+ }
159
+
160
+ /**
161
+ * Upon value-typed updates, returns a dispatch queue filter function.
162
+ * The return value depends on the selection resolution strategy.
163
+ * @param {string} type The event type.
164
+ * @param {*} value The input event value.
165
+ * @returns {*} For value-typed events, returns a dispatch queue filter
166
+ * function. Otherwise returns null.
167
+ */
168
+ emitQueueFilter(type, value) {
169
+ return type === 'value'
170
+ ? this._resolver.queueFilter(value)
171
+ : null;
172
+ }
173
+
174
+ /**
175
+ * Indicates if a selection clause should not be applied to a given client.
176
+ * The return value depends on the selection resolution strategy.
177
+ * @param {*} client The selection clause.
178
+ * @param {*} clause The client to test.
179
+ * @returns True if the client should be skipped, false otherwise.
180
+ */
181
+ skip(client, clause) {
182
+ return this._resolver.skip(client, clause);
183
+ }
184
+
185
+ /**
186
+ * Return a selection query predicate for the given client.
187
+ * @param {*} client The client whose data may be filtered.
188
+ * @returns {*} The query predicate for filtering client data,
189
+ * based on the current state of this selection.
190
+ */
191
+ predicate(client) {
192
+ const { clauses } = this;
193
+ return this._resolver.predicate(clauses, clauses.active, client);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Implements selection clause resolution strategies.
199
+ */
200
+ export class SelectionResolver {
201
+
202
+ /**
203
+ * Create a new selection resolved instance.
204
+ * @param {object} [options] The resolution strategy options.
205
+ * @param {boolean} [options.union=false] Boolean flag to indicate a union strategy.
206
+ * If false, an intersection strategy is used.
207
+ * @param {boolean} [options.cross=false] Boolean flag to indicate cross-filtering.
208
+ * @param {boolean} [options.single=false] Boolean flag to indicate single clauses only.
209
+ */
210
+ constructor({ union, cross, single } = {}) {
211
+ this.union = !!union;
212
+ this.cross = !!cross;
213
+ this.single = !!single;
214
+ }
215
+
216
+ /**
217
+ * Resolve a list of selection clauses according to the resolution strategy.
218
+ * @param {*[]} clauseList An array of selection clauses.
219
+ * @param {*} clause A new selection clause to add.
220
+ * @returns {*[]} An updated array of selection clauses.
221
+ */
222
+ resolve(clauseList, clause, reset = false) {
59
223
  const { source, predicate } = clause;
60
- this.active = clause;
61
- const clauses = this.single ? [] : this.clauses.filter(c => source !== c.source);
224
+ const filtered = clauseList.filter(c => source !== c.source);
225
+ const clauses = this.single ? [] : filtered;
226
+ if (this.single && reset) filtered.forEach(c => c.source?.reset?.());
62
227
  if (predicate) clauses.push(clause);
63
- return super.update(clauses);
228
+ return clauses;
64
229
  }
65
230
 
66
- predicate(client) {
67
- const { active, clauses, cross, union } = this;
231
+ /**
232
+ * Indicates if a selection clause should not be applied to a given client.
233
+ * The return value depends on the resolution strategy.
234
+ * @param {*} client The selection clause.
235
+ * @param {*} clause The client to test.
236
+ * @returns True if the client should be skipped, false otherwise.
237
+ */
238
+ skip(client, clause) {
239
+ return this.cross && clause?.clients?.has(client);
240
+ }
241
+
242
+ /**
243
+ * Return a selection query predicate for the given client.
244
+ * @param {*[]} clauseList An array of selection clauses.
245
+ * @param {*} active The current active selection clause.
246
+ * @param {*} client The client whose data may be filtered.
247
+ * @returns {*} The query predicate for filtering client data,
248
+ * based on the current state of this selection.
249
+ */
250
+ predicate(clauseList, active, client) {
251
+ const { union } = this;
68
252
 
69
253
  // do nothing if cross-filtering and client is currently active
70
- if (cross && skipClient(client, active)) return undefined;
254
+ if (this.skip(client, active)) return undefined;
71
255
 
72
256
  // remove client-specific predicates if cross-filtering
73
- const list = (cross
74
- ? clauses.filter(clause => !skipClient(client, clause))
75
- : clauses
76
- ).map(s => s.predicate);
257
+ const predicates = clauseList
258
+ .filter(clause => !this.skip(client, clause))
259
+ .map(clause => clause.predicate);
77
260
 
78
261
  // return appropriate conjunction or disjunction
79
262
  // an array of predicates is implicitly conjunctive
80
- return union && list.length > 1 ? or(list) : list;
263
+ return union && predicates.length > 1 ? or(predicates) : predicates;
264
+ }
265
+
266
+ /**
267
+ * Returns a filter function for queued selection updates.
268
+ * @param {*} value The new event value that will be enqueued.
269
+ * @returns {(value: *) => boolean|null} A dispatch queue filter
270
+ * function, or null if all unemitted event values should be filtered.
271
+ */
272
+ queueFilter(value) {
273
+ if (this.cross) {
274
+ const source = value.active?.source;
275
+ return clauses => clauses.active?.source !== source;
276
+ }
81
277
  }
82
278
  }
@@ -1,6 +1,6 @@
1
1
  import { tableFromIPC } from 'apache-arrow';
2
2
 
3
- export function restClient(uri = 'http://localhost:3000/') {
3
+ export function restConnector(uri = 'http://localhost:3000/') {
4
4
  return {
5
5
  async query(query) {
6
6
  const req = fetch(uri, {
@@ -1,6 +1,6 @@
1
1
  import { tableFromIPC } from 'apache-arrow';
2
2
 
3
- export function socketClient(uri = 'ws://localhost:3000/') {
3
+ export function socketConnector(uri = 'ws://localhost:3000/') {
4
4
  const queue = [];
5
5
  let connected = false;
6
6
  let request = null;
@@ -1,6 +1,6 @@
1
1
  import * as duckdb from '@duckdb/duckdb-wasm';
2
2
 
3
- export async function wasmClient(options) {
3
+ export async function wasmConnector(options) {
4
4
  const db = await initDatabase(options);
5
5
  const con = await db.connect();
6
6
 
package/src/index.js CHANGED
@@ -2,9 +2,12 @@ export { MosaicClient } from './MosaicClient.js';
2
2
  export { Coordinator, coordinator } from './Coordinator.js';
3
3
  export { Selection, isSelection } from './Selection.js';
4
4
  export { Param, isParam } from './Param.js';
5
+ export { Priority } from './QueryManager.js';
6
+
7
+ export { restConnector } from './connectors/rest.js';
8
+ export { socketConnector } from './connectors/socket.js';
9
+ export { wasmConnector } from './connectors/wasm.js';
10
+
5
11
  export { distinct } from './util/distinct.js';
6
- export { sqlFrom } from './util/sql-from.js';
12
+ export { synchronizer } from './util/synchronizer.js';
7
13
  export { throttle } from './util/throttle.js';
8
- export { restClient } from './clients/rest.js';
9
- export { socketClient } from './clients/socket.js';
10
- export { wasmClient } from './clients/wasm.js';
@@ -0,0 +1,180 @@
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.
6
+ */
7
+ export class AsyncDispatch {
8
+
9
+ /**
10
+ * Create a new asynchronous dispatcher instance.
11
+ */
12
+ constructor() {
13
+ this._callbacks = new Map;
14
+ }
15
+
16
+ /**
17
+ * Add an event listener callback for the provided event type.
18
+ * @param {string} type The event type.
19
+ * @param {(value: *) => Promise?} callback The event handler
20
+ * callback function to add. If the callback has already been
21
+ * added for the event type, this method has no effect.
22
+ */
23
+ addEventListener(type, callback) {
24
+ if (!this._callbacks.has(type)) {
25
+ this._callbacks.set(type, {
26
+ callbacks: new Set,
27
+ pending: null,
28
+ queue: new DispatchQueue()
29
+ });
30
+ }
31
+ const entry = this._callbacks.get(type);
32
+ entry.callbacks.add(callback);
33
+ }
34
+
35
+ /**
36
+ * Remove an event listener callback for the provided event type.
37
+ * @param {string} type The event type.
38
+ * @param {(value: *) => Promise?} callback The event handler
39
+ * callback function to remove.
40
+ */
41
+ removeEventListener(type, callback) {
42
+ const entry = this._callbacks.get(type);
43
+ if (entry) {
44
+ entry.callbacks.delete(callback);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Lifecycle method that returns the event value to emit.
50
+ * This default implementation simply returns the input value as-is.
51
+ * Subclasses may override this method to implement custom transformations
52
+ * prior to emitting an event value to all listeners.
53
+ * @param {string} type The event type.
54
+ * @param {*} value The event value.
55
+ * @returns The (possibly transformed) event value to emit.
56
+ */
57
+ willEmit(type, value) {
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * Lifecycle method that returns a filter function for updating the
63
+ * queue of unemitted event values prior to enqueueing a new value.
64
+ * This default implementation simply returns null, indicating that
65
+ * any other unemitted event values should be dropped (that is, all
66
+ * queued events are filtered)
67
+ * @param {*} value The new event value that will be enqueued.
68
+ * @returns {(value: *) => boolean|null} A dispatch queue filter
69
+ * function, or null if all unemitted event values should be filtered.
70
+ */
71
+ emitQueueFilter() {
72
+ // removes all pending items
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Cancel all unemitted event values for the given event type.
78
+ * @param {string} type The event type.
79
+ */
80
+ cancel(type) {
81
+ const entry = this._callbacks.get(type);
82
+ entry?.queue.clear();
83
+ }
84
+
85
+ /**
86
+ * Emit an event value to listeners for the given event type.
87
+ * If a previous emit has not yet resolved, the event value
88
+ * will be queued to be emitted later.
89
+ * The actual event value given to listeners will be the result
90
+ * of passing the input value through the emitValue() method.
91
+ * @param {string} type The event type.
92
+ * @param {*} value The event value.
93
+ */
94
+ emit(type, value) {
95
+ const entry = this._callbacks.get(type) || {};
96
+ if (entry.pending) {
97
+ entry.queue.enqueue(value, this.emitQueueFilter(type, value));
98
+ } else {
99
+ const event = this.willEmit(type, value);
100
+ const { callbacks, queue } = entry;
101
+ if (callbacks?.size) {
102
+ const promise = Promise
103
+ .allSettled(Array.from(callbacks, callback => callback(event)))
104
+ .then(() => {
105
+ entry.pending = null;
106
+ if (!queue.isEmpty()) {
107
+ this.emit(type, queue.dequeue());
108
+ }
109
+ });
110
+ entry.pending = promise;
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Queue for managing unemitted event values.
118
+ */
119
+ export class DispatchQueue {
120
+
121
+ /**
122
+ * Create a new dispatch queue instance.
123
+ */
124
+ constructor() {
125
+ this.clear();
126
+ }
127
+
128
+ /**
129
+ * Clear the queue state of all event values.
130
+ */
131
+ clear() {
132
+ this.next = null;
133
+ }
134
+
135
+ /**
136
+ * Indicate if the queue is empty.
137
+ * @returns {boolean} True if queue is empty, false otherwise.
138
+ */
139
+ isEmpty() {
140
+ return !this.next;
141
+ }
142
+
143
+ /**
144
+ * Add a new value to the queue, and optionally filter the
145
+ * current queue content in response.
146
+ * @param {*} value The value to add.
147
+ * @param {(value: *) => boolean} [filter] An optional filter
148
+ * function to apply to existing queue content. If unspecified
149
+ * or falsy, all previously queued values are removed. Otherwise,
150
+ * the provided function is applied to all queue entries. The
151
+ * entry is retained if the filter function returns a truthy value,
152
+ * otherwise the entry is removed.
153
+ */
154
+ enqueue(value, filter) {
155
+ const tail = { value };
156
+ if (filter && this.next) {
157
+ let curr = this;
158
+ while (curr.next) {
159
+ if (filter(curr.next.value)) {
160
+ curr = curr.next;
161
+ } else {
162
+ curr.next = curr.next.next;
163
+ }
164
+ }
165
+ curr.next = tail;
166
+ } else {
167
+ this.next = tail;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Remove and return the next queued event value.
173
+ * @returns {*} The next event value in the queue.
174
+ */
175
+ dequeue() {
176
+ const { next } = this;
177
+ this.next = next?.next;
178
+ return next?.value;
179
+ }
180
+ }
@@ -0,0 +1,58 @@
1
+ const requestIdle = typeof requestIdleCallback !== 'undefined'
2
+ ? requestIdleCallback
3
+ : setTimeout;
4
+
5
+ export const voidCache = () => ({
6
+ get: () => undefined,
7
+ set: (key, value) => value,
8
+ clear: () => {}
9
+ });
10
+
11
+ export function lruCache({
12
+ max = 1000, // max entries
13
+ ttl = 3 * 60 * 60 * 1000 // time-to-live, default 3 hours
14
+ } = {}) {
15
+ let cache = new Map;
16
+
17
+ function evict() {
18
+ const expire = performance.now() - ttl;
19
+ let lruKey = null;
20
+ let lruLast = Infinity;
21
+
22
+ for (const [key, value] of cache) {
23
+ const { last } = value;
24
+
25
+ // least recently used entry seen so far
26
+ if (last < lruLast) {
27
+ lruKey = key;
28
+ lruLast = last;
29
+ }
30
+
31
+ // remove if time since last access exceeds ttl
32
+ if (expire > last) {
33
+ cache.delete(key);
34
+ }
35
+ }
36
+
37
+ // remove lru entry
38
+ if (lruKey) {
39
+ cache.delete(lruKey);
40
+ }
41
+ }
42
+
43
+ return {
44
+ get(key) {
45
+ const entry = cache.get(key);
46
+ if (entry) {
47
+ entry.last = performance.now();
48
+ return entry.value;
49
+ }
50
+ },
51
+ set(key, value) {
52
+ cache.set(key, { last: performance.now(), value });
53
+ if (cache.size > max) requestIdle(evict);
54
+ return value;
55
+ },
56
+ clear() { cache = new Map; }
57
+ };
58
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Create a new priority queue instance.
3
+ * @param {number} ranks An integer number of rank-order priority levels.
4
+ * @returns A priority queue instance.
5
+ */
6
+ export function priorityQueue(ranks) {
7
+ // one list for each integer priority level
8
+ const queue = Array.from(
9
+ { length: ranks },
10
+ () => ({ head: null, tail: null })
11
+ );
12
+
13
+ return {
14
+ /**
15
+ * Indicate if the queue is empty.
16
+ * @returns [boolean] true if empty, false otherwise.
17
+ */
18
+ isEmpty() {
19
+ return queue.every(list => !list.head);
20
+ },
21
+
22
+ /**
23
+ * Insert an item into the queue with a given priority rank.
24
+ * @param {*} item The item to add.
25
+ * @param {number} rank The integer priority rank.
26
+ * Priority ranks are integers starting at zero.
27
+ * Lower ranks indicate higher priority.
28
+ */
29
+ insert(item, rank) {
30
+ const list = queue[rank];
31
+ if (!list) {
32
+ throw new Error(`Invalid queue priority rank: ${rank}`);
33
+ }
34
+
35
+ const node = { item, next: null };
36
+ if (list.head === null) {
37
+ list.head = list.tail = node;
38
+ } else {
39
+ list.tail = (list.tail.next = node);
40
+ }
41
+ },
42
+
43
+ /**
44
+ * Remove a set of items from the queue, regardless of priority rank.
45
+ * If a provided item is not in the queue it will be ignored.
46
+ * @param {(item: *) => boolean} test A predicate function to test
47
+ * if an item should be removed (true to drop, false to keep).
48
+ */
49
+ remove(test) {
50
+ for (const list of queue) {
51
+ let { head, tail } = list;
52
+ for (let prev = null, curr = head; curr; prev = curr, curr = curr.next) {
53
+ if (test(curr.item)) {
54
+ if (curr === head) {
55
+ head = curr.next;
56
+ } else {
57
+ prev.next = curr.next;
58
+ }
59
+ if (curr === tail) tail = prev || head;
60
+ }
61
+ }
62
+ list.head = head;
63
+ list.tail = tail;
64
+ }
65
+ },
66
+
67
+ /**
68
+ * Remove and return the next highest priority item.
69
+ * @returns {*} The next item in the queue,
70
+ * or undefined if this queue is empty.
71
+ */
72
+ next() {
73
+ for (const list of queue) {
74
+ const { head } = list;
75
+ if (head !== null) {
76
+ list.head = head.next;
77
+ if (list.tail === head) {
78
+ list.tail = null;
79
+ }
80
+ return head.item;
81
+ }
82
+ }
83
+ }
84
+ };
85
+ }