@uwdata/mosaic-core 0.0.1 → 0.1.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.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Scalable and extensible linked data views.",
5
5
  "keywords": [
6
6
  "mosaic",
@@ -29,7 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@duckdb/duckdb-wasm": "^1.20.0",
32
- "@uwdata/mosaic-sql": "^0.0.1",
32
+ "@uwdata/mosaic-sql": "^0.1.0",
33
33
  "apache-arrow": "^11.0.0"
34
- }
34
+ },
35
+ "gitHead": "a7967c35349bdf7f00abb113ce1dd9abb233cd62"
35
36
  }
package/src/Catalog.js CHANGED
@@ -1,17 +1,16 @@
1
- import { Query, count, max, min, isNull } from '@uwdata/mosaic-sql';
2
1
  import { jsType } from './util/js-type.js';
2
+ import { summarize } from './util/summarize.js';
3
3
 
4
4
  const object = () => Object.create(null);
5
5
 
6
6
  export class Catalog {
7
- constructor(mc) {
8
- this.mc = mc;
7
+ constructor(coordinator) {
8
+ this.mc = coordinator;
9
9
  this.clear();
10
10
  }
11
11
 
12
12
  clear() {
13
13
  this.tables = object();
14
- this.fields = object();
15
14
  }
16
15
 
17
16
  async tableInfo(table) {
@@ -21,62 +20,49 @@ export class Catalog {
21
20
  }
22
21
 
23
22
  const q = this.mc.query(
24
- `PRAGMA table_info('${table}')`,
25
- { type: 'json' }
23
+ `DESCRIBE "${table}"`,
24
+ { type: 'json', cache: false }
26
25
  );
27
26
 
28
27
  return (cache[table] = q.then(result => {
29
28
  const columns = object();
30
29
  for (const entry of result) {
31
- columns[entry.name] = { ...entry, jstype: jsType(entry.type) };
30
+ columns[entry.column_name] = {
31
+ table,
32
+ column: entry.column_name,
33
+ sqlType: entry.column_type,
34
+ type: jsType(entry.column_type),
35
+ nullable: entry.null === 'YES'
36
+ };
32
37
  }
33
38
  return columns;
34
39
  }));
35
40
  }
36
41
 
37
- async fieldInfo(table, column) {
38
- const info = await this.tableInfo(table);
39
- const colInfo = info[column];
42
+ async fieldInfo({ table, column, stats }) {
43
+ const tableInfo = await this.tableInfo(table);
44
+ const colInfo = tableInfo[column];
40
45
 
41
46
  // column does not exist
42
47
  if (colInfo == null) return;
43
48
 
44
- const cache = this.fields;
45
- const key = `${table}.${column}`;
46
- if (cache[key]) {
47
- return cache[key];
48
- }
49
-
50
- const promise = this.mc.query(
51
- Query.from(table).select({
52
- rows: count(),
53
- nulls: count().where(isNull(column)),
54
- min: min(column),
55
- max: max(column)
56
- }, { cache: false })
57
- ).then(result => {
58
- const [ stats ] = Array.from(result);
59
- return { table, column, type: colInfo.jstype, ...stats };
60
- });
49
+ // no need for summary statistics
50
+ if (!stats?.length) return colInfo;
61
51
 
62
- return (cache[key] = promise);
52
+ const result = await this.mc.query(summarize(colInfo, stats));
53
+ const info = { ...colInfo, ...(Array.from(result)[0]) };
54
+ return info;
63
55
  }
64
56
 
65
57
  async queryFields(fields) {
66
58
  const list = await resolveFields(this, fields);
67
- const data = await Promise.all(
68
- list.map(f => this.fieldInfo(f.table, f.column))
69
- )
59
+ const data = await Promise.all(list.map(f => this.fieldInfo(f)));
70
60
  return data.filter(x => x);
71
61
  }
72
62
  }
73
63
 
74
64
  async function resolveFields(catalog, list) {
75
- if (list.length === 1 && list[0].column === '*') {
76
- const table = list[0].table;
77
- const info = await catalog.tableInfo(table);
78
- return Object.keys(info).map(column => ({ table, column }));
79
- } else {
80
- return list;
81
- }
65
+ return list.length === 1 && list[0].column === '*'
66
+ ? Object.values(await catalog.tableInfo(list[0].table))
67
+ : list;
82
68
  }
@@ -1,7 +1,8 @@
1
1
  import { socketClient } from './clients/socket.js';
2
2
  import { Catalog } from './Catalog.js';
3
3
  import { FilterGroup } from './FilterGroup.js';
4
- import { QueryCache } from './QueryCache.js';
4
+ import { QueryCache, voidCache } from './QueryCache.js';
5
+ import { voidLogger } from './util/void-logger.js';
5
6
 
6
7
  let _instance;
7
8
 
@@ -15,28 +16,30 @@ export function coordinator(instance) {
15
16
  }
16
17
 
17
18
  export class Coordinator {
18
- constructor(db = socketClient()) {
19
- this.cache = new QueryCache();
19
+ constructor(db = socketClient(), options = {}) {
20
20
  this.catalog = new Catalog(this);
21
- this.indexes = true;
21
+ this.logger(options.logger || console);
22
+ this.configure(options);
22
23
  this.databaseClient(db);
23
24
  this.clear();
24
25
  }
25
26
 
27
+ logger(logger) {
28
+ return arguments.length
29
+ ? (this._logger = logger || voidLogger())
30
+ : this._logger;
31
+ }
32
+
26
33
  configure({ cache = true, indexes = true }) {
27
- this.cache = cache ? new QueryCache() : {
28
- get: () => undefined,
29
- set: (key, result) => result,
30
- clear: () => {}
31
- };
34
+ this.cache = cache ? new QueryCache() : voidCache();
32
35
  this.indexes = indexes;
33
36
  }
34
37
 
35
38
  clear({ clients = true, cache = true, catalog = false } = {}) {
36
39
  if (clients) {
37
- this.clients?.forEach((_, client) => this.disconnect(client));
40
+ this.clients?.forEach(client => this.disconnect(client));
38
41
  this.filterGroups?.forEach(group => group.finalize());
39
- this.clients = new Map;
42
+ this.clients = new Set;
40
43
  this.filterGroups = new Map;
41
44
  }
42
45
  if (cache) this.cache.clear();
@@ -54,18 +57,22 @@ export class Coordinator {
54
57
  try {
55
58
  await this.db.query({ type: 'exec', sql });
56
59
  } catch (err) {
57
- console.error(err);
60
+ this._logger.error(err);
58
61
  }
59
62
  }
60
63
 
61
- async query(query, { type = 'arrow', cache = true } = {}) {
64
+ query(query, { type = 'arrow', cache = true } = {}) {
62
65
  const sql = String(query);
66
+ const t0 = performance.now();
63
67
  const cached = this.cache.get(sql);
64
68
  if (cached) {
69
+ this._logger.debug('Cache');
65
70
  return cached;
66
71
  } else {
67
72
  const request = this.db.query({ type, sql });
68
- return cache ? this.cache.set(sql, request) : request;
73
+ const result = cache ? this.cache.set(sql, request) : request;
74
+ result.then(() => this._logger.debug(`Query: ${performance.now() - t0}`));
75
+ return result;
69
76
  }
70
77
  }
71
78
 
@@ -75,24 +82,31 @@ export class Coordinator {
75
82
  client.queryPending();
76
83
  result = await this.query(query);
77
84
  } catch (err) {
78
- console.error(err);
85
+ this._logger.error(err);
79
86
  client.queryError(err);
80
87
  return;
81
88
  }
82
89
  try {
83
90
  client.queryResult(result).update();
84
91
  } catch (err) {
85
- console.error(err);
92
+ this._logger.error(err);
86
93
  }
87
94
  }
88
95
 
96
+ async requestQuery(client, query) {
97
+ this.filterGroups.get(client.filterBy)?.reset();
98
+ return query
99
+ ? this.updateClient(client, query)
100
+ : client.update();
101
+ }
102
+
89
103
  async connect(client) {
90
104
  const { catalog, clients, filterGroups, indexes } = this;
91
105
 
92
106
  if (clients.has(client)) {
93
107
  throw new Error('Client already connected.');
94
108
  }
95
- clients.set(client, null); // mark as connected
109
+ clients.add(client); // mark as connected
96
110
 
97
111
  // retrieve field statistics
98
112
  const fields = client.fields();
@@ -111,27 +125,13 @@ export class Coordinator {
111
125
  }
112
126
  }
113
127
 
114
- // query handler
115
- const handler = async (query) => {
116
- const q = query || client.query(filter?.predicate(client));
117
- filterGroups.get(filter)?.reset();
118
- if (q) this.updateClient(client, q);
119
- };
120
- clients.set(client, handler);
121
-
122
- // register request handler, if defined
123
- client.request?.addEventListener('value', handler);
124
-
125
- // TODO analyze / consolidate queries?
126
- handler();
128
+ client.requestQuery();
127
129
  }
128
130
 
129
131
  disconnect(client) {
130
132
  const { clients, filterGroups } = this;
131
133
  if (!clients.has(client)) return;
132
- const handler = clients.get(client);
133
134
  clients.delete(client);
134
135
  filterGroups.get(client.filterBy)?.remove(client);
135
- client.request?.removeEventListener(handler);
136
136
  }
137
137
  }
@@ -49,13 +49,14 @@ export class DataTileIndexer {
49
49
  const activeView = this.activeView = getActiveView(active);
50
50
  if (!activeView) return false; // active selection clause not compatible
51
51
 
52
- console.warn('DATA TILE INDEX CONSTRUCTION');
52
+ this.mc.logger().warn('DATA TILE INDEX CONSTRUCTION');
53
53
 
54
54
  // create a selection with the active client removed
55
55
  const sel = this.selection.clone().update({ source });
56
56
 
57
57
  // generate data tile indices
58
58
  const indices = this.indices = new Map;
59
+ const promises = [];
59
60
  for (const client of clients) {
60
61
  if (sel.cross && skipClient(client, active)) continue;
61
62
  const index = getIndexColumns(client);
@@ -76,10 +77,10 @@ export class DataTileIndexer {
76
77
  const id = (fnv_hash(sql) >>> 0).toString(16);
77
78
  const table = `tile_index_${id}`;
78
79
  indices.set(client, { table, ...index });
79
- createIndex(this.mc, table, sql);
80
+ promises.push(createIndex(this.mc, table, sql));
80
81
  }
81
82
 
82
- return true;
83
+ return promises;
83
84
  }
84
85
 
85
86
  async update() {
@@ -185,16 +186,17 @@ async function createIndex(mc, table, query) {
185
186
  try {
186
187
  await mc.exec(`CREATE TEMP TABLE IF NOT EXISTS ${table} AS ${query}`);
187
188
  } catch (err) {
188
- console.error(err);
189
+ mc.logger().error(err);
189
190
  }
190
191
  }
191
192
 
193
+ const NO_INDEX = { from: NaN };
194
+
192
195
  function getIndexColumns(client) {
196
+ if (!client.filterIndexable) return NO_INDEX;
193
197
  const q = client.query();
194
198
  const from = getBaseTable(q);
195
- if (!from || !q.groupby || !client.filterIndexable) {
196
- return { from: NaN }; // early exit
197
- }
199
+ if (!from || !q.groupby) return NO_INDEX;
198
200
  const g = new Set(q.groupby().map(c => c.column));
199
201
 
200
202
  let aggr = [];
@@ -2,11 +2,11 @@ import { DataTileIndexer } from './DataTileIndexer.js';
2
2
  import { throttle } from './util/throttle.js';
3
3
 
4
4
  export class FilterGroup {
5
- constructor(mc, selection, index = true) {
6
- this.mc = mc;
5
+ constructor(coordinator, selection, index = true) {
6
+ this.mc = coordinator;
7
7
  this.selection = selection;
8
8
  this.clients = new Set();
9
- this.indexer = index ? new DataTileIndexer(mc, selection) : null;
9
+ this.indexer = index ? new DataTileIndexer(this.mc, selection) : null;
10
10
 
11
11
  const { value, activate } = this.handlers = {
12
12
  value: throttle(() => this.update()),
@@ -1,41 +1,105 @@
1
+ import { coordinator } from './Coordinator.js';
2
+ import { throttle } from './util/throttle.js';
3
+
4
+ /**
5
+ * Base class for Mosaic clients.
6
+ */
1
7
  export class MosaicClient {
8
+ /**
9
+ * Constructor.
10
+ * @param {*} filterSelection An optional selection to interactively filter
11
+ * this client's data. If provided, a coordinator will re-query and update
12
+ * the client when the selection updates.
13
+ */
2
14
  constructor(filterSelection) {
3
15
  this._filterBy = filterSelection;
16
+ this._requestUpdate = throttle(() => this.requestQuery(), true);
4
17
  }
5
18
 
19
+ /**
20
+ * Return this client's filter selection.
21
+ */
6
22
  get filterBy() {
7
23
  return this._filterBy;
8
24
  }
9
25
 
26
+ /**
27
+ * Return a boolean indicating if the client query can be indexed. Should
28
+ * return true if changes to the filterBy selection does not change the
29
+ * groupby domain of the client query.
30
+ */
10
31
  get filterIndexable() {
11
32
  return true;
12
33
  }
13
34
 
35
+ /**
36
+ * Return an array of fields queried by this client.
37
+ */
14
38
  fields() {
15
39
  return null;
16
40
  }
17
41
 
42
+ /**
43
+ * Called by the coordinator to set the field statistics for this client.
44
+ * @returns {this}
45
+ */
18
46
  fieldStats() {
19
47
  return this;
20
48
  }
21
49
 
50
+ /**
51
+ * Return a query specifying the data needed by this client.
52
+ */
22
53
  query() {
23
54
  return null;
24
55
  }
25
56
 
57
+ /**
58
+ * Called by the coordinator to inform the client that a query is pending.
59
+ */
26
60
  queryPending() {
27
61
  return this;
28
62
  }
29
63
 
64
+ /**
65
+ * Called by the coordinator to return a query result.
66
+ */
30
67
  queryResult() {
31
68
  return this;
32
69
  }
33
70
 
71
+ /**
72
+ * Called by the coordinator to report a query execution error.
73
+ */
34
74
  queryError(error) {
35
75
  console.error(error);
36
76
  return this;
37
77
  }
38
78
 
79
+ /**
80
+ * Request the coordinator to execute a query for this client.
81
+ * If an explicit query is not provided, the client query method will
82
+ * be called, filtered by the current filterBy selection.
83
+ */
84
+ requestQuery(query) {
85
+ const q = query || this.query(this.filterBy?.predicate(this));
86
+ return coordinator().requestQuery(this, q);
87
+ }
88
+
89
+ /**
90
+ * Request that the coordinator perform a throttled update of this client
91
+ * using the default query. Unlike requestQuery, for which every call will
92
+ * result in an executed query, multiple calls to requestUpdate may be
93
+ * consolidated into a single update.
94
+ */
95
+ requestUpdate() {
96
+ this._requestUpdate();
97
+ }
98
+
99
+ /**
100
+ * Requests a client update.
101
+ * For example to (re-)render an interface component.
102
+ */
39
103
  update() {
40
104
  return this;
41
105
  }
@@ -1,19 +1,25 @@
1
- export function isSignal(x) {
2
- return x instanceof Signal;
1
+ import { distinct } from './util/distinct.js';
2
+
3
+ export function isParam(x) {
4
+ return x instanceof Param;
3
5
  }
4
6
 
5
- export class Signal {
7
+ export class Param {
6
8
  constructor(value) {
7
9
  this._value = value;
8
10
  this._listeners = new Map;
9
11
  }
10
12
 
13
+ static value(value) {
14
+ return new Param(value);
15
+ }
16
+
11
17
  get value() {
12
18
  return this._value;
13
19
  }
14
20
 
15
21
  update(value, { force } = {}) {
16
- const changed = this._value !== value;
22
+ const changed = distinct(this._value, value);
17
23
  if (changed) this._value = value;
18
24
  if (changed || force) this.emit('value', this.value);
19
25
  return this;
@@ -21,7 +27,7 @@ export class Signal {
21
27
 
22
28
  addEventListener(type, callback) {
23
29
  let list = this._listeners.get(type) || [];
24
- if (list.indexOf(callback) < 0) {
30
+ if (!list.includes(callback)) {
25
31
  list = list.concat(callback);
26
32
  }
27
33
  this._listeners.set(type, list);
package/src/QueryCache.js CHANGED
@@ -1,3 +1,13 @@
1
+ const requestIdle = typeof requestIdleCallback !== 'undefined'
2
+ ? requestIdleCallback
3
+ : setTimeout;
4
+
5
+ export const voidCache = () => ({
6
+ get: () => undefined,
7
+ set: (key, result) => result,
8
+ clear: () => {}
9
+ });
10
+
1
11
  export class QueryCache {
2
12
  constructor({
3
13
  max = 1000, // max entries
@@ -22,19 +32,9 @@ export class QueryCache {
22
32
 
23
33
  set(key, promise) {
24
34
  const { cache, max } = this;
25
- const now = performance.now();
26
-
27
- const receive = promise.then(result => {
28
- console.log(`Query: ${Math.round(performance.now() - now)}`);
29
- return result;
30
- });
31
-
32
- cache.set(key, { last: now, promise });
33
- if (cache.size > max) {
34
- setTimeout(() => this.evict());
35
- }
36
-
37
- return receive;
35
+ cache.set(key, { last: performance.now(), promise });
36
+ if (cache.size > max) requestIdle(() => this.evict());
37
+ return promise;
38
38
  }
39
39
 
40
40
  evict() {
package/src/Selection.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { or } from '@uwdata/mosaic-sql';
2
- import { Signal } from './Signal.js';
2
+ import { Param } from './Param.js';
3
3
  import { skipClient } from './util/skip-client.js';
4
4
 
5
5
  export function isSelection(x) {
6
6
  return x instanceof Selection;
7
7
  }
8
8
 
9
- export class Selection extends Signal {
9
+ export class Selection extends Param {
10
10
 
11
11
  static intersect() {
12
12
  return new Selection();
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { MosaicClient } from './MosaicClient.js';
2
2
  export { Coordinator, coordinator } from './Coordinator.js';
3
3
  export { Selection, isSelection } from './Selection.js';
4
- export { Signal, isSignal } from './Signal.js';
4
+ export { Param, isParam } from './Param.js';
5
+ export { distinct } from './util/distinct.js';
5
6
  export { sqlFrom } from './util/sql-from.js';
6
7
  export { throttle } from './util/throttle.js';
7
8
  export { restClient } from './clients/rest.js';
@@ -0,0 +1,14 @@
1
+ export function distinct(a, b) {
2
+ return a === b ? false
3
+ : a instanceof Date && b instanceof Date ? +a !== +b
4
+ : Array.isArray(a) && Array.isArray(b) ? distinctArray(a, b)
5
+ : true;
6
+ }
7
+
8
+ export function distinctArray(a, b) {
9
+ if (a.length !== b.length) return true;
10
+ for (let i = 0; i < a.length; ++i) {
11
+ if (a[i] !== b[i]) return true;
12
+ }
13
+ return false;
14
+ }
@@ -0,0 +1,23 @@
1
+ import { Query, count, isNull, max, min } from '@uwdata/mosaic-sql';
2
+
3
+ export const Count = 'count';
4
+ export const Nulls = 'nulls';
5
+ export const Max = 'max';
6
+ export const Min = 'min';
7
+ export const Distinct = 'distinct';
8
+
9
+ export const Stats = { Count, Nulls, Max, Min, Distinct };
10
+
11
+ export const statMap = {
12
+ [Count]: count,
13
+ [Distinct]: column => count(column).distinct(),
14
+ [Max]: max,
15
+ [Min]: min,
16
+ [Nulls]: column => count().where(isNull(column))
17
+ };
18
+
19
+ export function summarize({ table, column }, stats) {
20
+ return Query
21
+ .from(table)
22
+ .select(stats.map(s => [s, statMap[s](column)]));
23
+ }
@@ -1,11 +1,14 @@
1
- export function throttle(callback) {
1
+ const NIL = {};
2
+
3
+ export function throttle(callback, debounce = false) {
2
4
  let curr;
3
5
  let next;
6
+ let pending = NIL;
4
7
 
5
8
  function invoke(event) {
6
9
  curr = callback(event).then(() => {
7
10
  if (next) {
8
- const value = next;
11
+ const { value } = next;
9
12
  next = null;
10
13
  invoke(value);
11
14
  } else {
@@ -15,8 +18,23 @@ export function throttle(callback) {
15
18
  }
16
19
 
17
20
  function enqueue(event) {
18
- next = event;
21
+ next = { event };
22
+ }
23
+
24
+ function process(event) {
25
+ curr ? enqueue(event) : invoke(event);
26
+ }
27
+
28
+ function delay(event) {
29
+ if (pending !== event) {
30
+ requestAnimationFrame(() => {
31
+ const e = pending;
32
+ pending = NIL;
33
+ process(e);
34
+ });
35
+ }
36
+ pending = event;
19
37
  }
20
38
 
21
- return event => curr ? enqueue(event) : invoke(event);
39
+ return debounce ? delay : process;
22
40
  }
@@ -0,0 +1,9 @@
1
+ export function voidLogger() {
2
+ return {
3
+ debug() {},
4
+ info() {},
5
+ log() {},
6
+ warn() {},
7
+ error() {}
8
+ };
9
+ }