facetful 0.3.0 → 0.4.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/README.md +51 -4
- package/core.js +33 -0
- package/facetful_wasm.wasm +0 -0
- package/index.d.ts +11 -0
- package/index.js +14 -0
- package/package.json +1 -1
- package/worker.js +22 -4
package/README.md
CHANGED
|
@@ -17,6 +17,9 @@ browser** — facet counts, pivots, top-k, filters — at interaction speed.
|
|
|
17
17
|
- **Filter-mask cache**: WHERE conjuncts are cached as per-row-group bitmaps,
|
|
18
18
|
so facet bursts sharing a filter evaluate it once, and a `LIKE '%needle%'`
|
|
19
19
|
extending a cached needle verifies only the rows the shorter one matched.
|
|
20
|
+
- **Joins, CTEs and subqueries as cached materializations**: `JOIN`, `WITH`,
|
|
21
|
+
`FROM (select …)`, `IN (select …)`, `EXISTS` all build a derived table once
|
|
22
|
+
and serve every later facet from it — no per-query join cost.
|
|
20
23
|
- Dates, medians, stddev, group_concat, `select *`, LIKE fast paths — the
|
|
21
24
|
boring things work.
|
|
22
25
|
|
|
@@ -57,6 +60,50 @@ Persistence is always explicit (`storeOpfs`), never write-behind. OPFS needs a
|
|
|
57
60
|
secure context (https or localhost); everything degrades to memory-only
|
|
58
61
|
without one.
|
|
59
62
|
|
|
63
|
+
## Derived tables: `materialize`
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
// a grouped result becomes a table of its own — queryable like any other,
|
|
67
|
+
// with the same typed columns, nulls and (here) sort metadata
|
|
68
|
+
await db.materialize("fuel_state", `
|
|
69
|
+
select fuel, state, count(*) as n, round(sum(net_generation_mwh)/1e6, 3) as twh
|
|
70
|
+
from t group by fuel, state order by twh desc`);
|
|
71
|
+
const r = await db.query("select fuel, sum(twh) as twh from t group by fuel", { table: "fuel_state" });
|
|
72
|
+
|
|
73
|
+
// persist the image to OPFS so a later visit can loadOpfs() it instead
|
|
74
|
+
await db.materialize("fuel_state", sql, { persist: "facetful/fuel_state.facetful" });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pre-aggregate a large detail table once per session and run the facets
|
|
78
|
+
against the rollup; every SELECT item needs a distinct name. Row order is the
|
|
79
|
+
query's output order, so a materialized `ORDER BY` is recorded as the table's
|
|
80
|
+
sort. The CLI has the same verb: `facetful materialize in.facetful "select …" out.facetful`.
|
|
81
|
+
|
|
82
|
+
## Joins, CTEs, subqueries
|
|
83
|
+
|
|
84
|
+
Every table the worker holds — loaded, opened from Parquet, or materialized —
|
|
85
|
+
is registered under its name, and any query can name it:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
await db.materialize("plants", "select plant_id, state, count(*) as n_gens from t group by plant_id, state");
|
|
89
|
+
await db.query(`
|
|
90
|
+
select t.fuel, p.state, sum(t.mwh) as mwh
|
|
91
|
+
from t left join plants p on t.plant_id = p.plant_id
|
|
92
|
+
where p.n_gens >= 3 group by t.fuel, p.state order by mwh desc`);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`[INNER|LEFT] JOIN … ON a.k = b.k [AND …]` and `USING (k)`; the right side's
|
|
96
|
+
join key must be unique (a dimension). `WITH name AS (…)`, `FROM (select …) s`,
|
|
97
|
+
`x IN (select …)`, `(a, b) IN (select …)`, `EXISTS (select … where d.k = t.k)`.
|
|
98
|
+
All of these are **materializations, not query-time operators**: the first
|
|
99
|
+
query builds the joined or derived table (milliseconds for a fact table
|
|
100
|
+
against a dimension), and it lives in a per-table cache (64 MB default) that
|
|
101
|
+
every later query with the same shape reuses — a facet burst over a join pays
|
|
102
|
+
for the join once. Correlated subqueries beyond `inner.k = outer.k` equalities,
|
|
103
|
+
`FULL`/`RIGHT` joins and non-unique right keys are refused with a clear error.
|
|
104
|
+
`NOT IN` follows SQL's NULL rule (a NULL in the set makes it select nothing);
|
|
105
|
+
`NOT EXISTS` doesn't, and is usually what you mean.
|
|
106
|
+
|
|
60
107
|
## Parquet support
|
|
61
108
|
|
|
62
109
|
Reading uses [hyparquet](https://github.com/hyparam/hyparquet) (~20 KB gz),
|
|
@@ -72,10 +119,10 @@ browser transcoder (the native CLI has no such limit).
|
|
|
72
119
|
|
|
73
120
|
## SQL dialect, briefly
|
|
74
121
|
|
|
75
|
-
SELECT-only,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`COUNT(DISTINCT x)`, `||`, `select
|
|
122
|
+
SELECT-only, SQLite semantics (3-valued logic, null-skipping aggregates,
|
|
123
|
+
truncating integer division, NULL-first ascending sorts). Idioms: `IN`,
|
|
124
|
+
`BETWEEN`, `IS [NOT] NULL`, `[NOT] LIKE`, `CASE WHEN`, `CAST`,
|
|
125
|
+
`COUNT(DISTINCT x)`, `||`, `select *`, `JOIN`/`WITH`/subqueries as above. Aggregates: count, sum, avg, min, max,
|
|
79
126
|
count(distinct), median, stddev, group_concat. Scalars: math (abs, round,
|
|
80
127
|
floor, ceil, sqrt, pow, exp, ln, sign), text (lower, upper, length, substr,
|
|
81
128
|
trim/ltrim/rtrim, replace, instr, concat), null handling (coalesce, ifnull,
|
package/core.js
CHANGED
|
@@ -121,6 +121,30 @@ export class Engine {
|
|
|
121
121
|
return img;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Materialize a query's result as a compiled image: the derived-table
|
|
126
|
+
* primitive. Returns an image handle (pass to openImage / imageBytes), or
|
|
127
|
+
* throws QueryError with the engine's diagnostic.
|
|
128
|
+
*/
|
|
129
|
+
materialize(tableHandle, sql, { groupTarget = 65536 } = {}) {
|
|
130
|
+
const sqlBytes = this.enc.encode(sql);
|
|
131
|
+
const sqlPtr = u32(this.w.alloc(sqlBytes.byteLength));
|
|
132
|
+
new Uint8Array(this.mem(), sqlPtr, sqlBytes.byteLength).set(sqlBytes);
|
|
133
|
+
const h = this.w.table_materialize(tableHandle, sqlPtr, sqlBytes.byteLength, groupTarget);
|
|
134
|
+
try {
|
|
135
|
+
if (this.w.outcome_is_err(h)) {
|
|
136
|
+
const n = this.w.outcome_error(h, this.scratch, 4096);
|
|
137
|
+
throw new QueryError(this.dec.decode(new Uint8Array(this.mem(), this.scratch, n)));
|
|
138
|
+
}
|
|
139
|
+
const img = this.w.outcome_image(h);
|
|
140
|
+
if (!img) throw new Error("materialize produced no image");
|
|
141
|
+
return img;
|
|
142
|
+
} finally {
|
|
143
|
+
this.w.dealloc(sqlPtr, sqlBytes.byteLength);
|
|
144
|
+
this.w.outcome_free(h);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
124
148
|
/** Copy a compiled image's bytes out (for OPFS persistence). */
|
|
125
149
|
imageBytes(img) {
|
|
126
150
|
return new Uint8Array(this.mem(), u32(this.w.image_ptr(img)), u32(this.w.image_len(img))).slice();
|
|
@@ -133,6 +157,15 @@ export class Engine {
|
|
|
133
157
|
return { handle, rows: this.w.table_total_rows(handle) };
|
|
134
158
|
}
|
|
135
159
|
|
|
160
|
+
/** Make `handle` reachable by `name` from other tables' queries (FROM / JOIN). */
|
|
161
|
+
catalogRegister(name, handle) {
|
|
162
|
+
const b = this.enc.encode(name);
|
|
163
|
+
const p = u32(this.w.alloc(b.byteLength));
|
|
164
|
+
new Uint8Array(this.mem(), p, b.byteLength).set(b);
|
|
165
|
+
this.w.catalog_register(p, b.byteLength, handle);
|
|
166
|
+
this.w.dealloc(p, b.byteLength);
|
|
167
|
+
}
|
|
168
|
+
|
|
136
169
|
/** Run SQL; returns { columns, rowCount, stats } with copied-out buffers. */
|
|
137
170
|
query(tableHandle, sql) {
|
|
138
171
|
const sqlBytes = this.enc.encode(sql);
|
package/facetful_wasm.wasm
CHANGED
|
Binary file
|
package/index.d.ts
CHANGED
|
@@ -92,6 +92,17 @@ export declare class Facetful {
|
|
|
92
92
|
loadParquet(name: string, buffer: ArrayBuffer): Promise<{ rows: number; transcodeMs: number }>;
|
|
93
93
|
|
|
94
94
|
/** Persist a .facetful image into OPFS at `path` (buffer transferred). */
|
|
95
|
+
/**
|
|
96
|
+
* Materialize a query's result as a new table `name`, queryable via
|
|
97
|
+
* `{ table: name }`. With `persist`, the image is also written to OPFS at
|
|
98
|
+
* that path for a later `loadOpfs`. `bytes` is the persisted image size (0
|
|
99
|
+
* when not persisted).
|
|
100
|
+
*/
|
|
101
|
+
materialize(
|
|
102
|
+
name: string,
|
|
103
|
+
sql: string,
|
|
104
|
+
options?: { table?: string; persist?: string },
|
|
105
|
+
): Promise<{ rows: number; bytes: number; elapsedMs: number }>;
|
|
95
106
|
storeOpfs(path: string, buffer: ArrayBuffer): Promise<{ bytes: number }>;
|
|
96
107
|
|
|
97
108
|
/**
|
package/index.js
CHANGED
|
@@ -71,6 +71,20 @@ export class Facetful {
|
|
|
71
71
|
return this._call({ cmd: "loadParquet", name, buffer }, [buffer]);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Materialize a query's result as a new table named `name`: a derived,
|
|
76
|
+
* immutable table you then query like any other (`{ table: name }`).
|
|
77
|
+
* Runs against `table` (default: the last loaded). With `persist`, the
|
|
78
|
+
* compiled image is also written to OPFS at that path, so a later visit
|
|
79
|
+
* can `loadOpfs(name, path)` instead of recomputing. Returns { rows, bytes }.
|
|
80
|
+
*
|
|
81
|
+
* The SQL may name other loaded tables in FROM and JOIN, so a joined,
|
|
82
|
+
* persisted table is `materialize("x", "select … from t join dims d using (k)")`.
|
|
83
|
+
*/
|
|
84
|
+
async materialize(name, sql, { table, persist } = {}) {
|
|
85
|
+
return this._call({ cmd: "materialize", name, sql, table, persist });
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
/** Persist a .facetful image into OPFS at `path` (e.g. "facetful/plants.facetful"). */
|
|
75
89
|
async storeOpfs(path, buffer) {
|
|
76
90
|
return this._call({ cmd: "storeOpfs", path, buffer }, [buffer]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "facetful",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Tiny columnar SQL engine for browser faceting — Rust/wasm, ~170KB gz, zero dependencies. Open Parquet instantly, facet 1M rows in milliseconds, persist to OPFS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Raznick",
|
package/worker.js
CHANGED
|
@@ -8,6 +8,8 @@ import { parquetToColumns } from "./parquet.js";
|
|
|
8
8
|
|
|
9
9
|
let engine = null;
|
|
10
10
|
const tables = new Map(); // name -> handle
|
|
11
|
+
// register under a name for FROM / JOIN from other tables' queries
|
|
12
|
+
const setTable = (name, handle) => { setTable(name, handle); engine.catalogRegister(name, handle); };
|
|
11
13
|
let lastTable = null;
|
|
12
14
|
|
|
13
15
|
// OPFS file registry: the wasm's opfs_read import addresses files by these ids.
|
|
@@ -77,7 +79,7 @@ async function opfsOpenTable(name, path, cacheBytes) {
|
|
|
77
79
|
}
|
|
78
80
|
try {
|
|
79
81
|
const { handle, rows } = engine.openOpfsTable(fileId, h.getSize(), cacheBytes);
|
|
80
|
-
|
|
82
|
+
setTable(name, handle);
|
|
81
83
|
lastTable = handle;
|
|
82
84
|
return { rows, fileLen: h.getSize() };
|
|
83
85
|
} catch (err) {
|
|
@@ -127,7 +129,7 @@ self.onmessage = async (e) => {
|
|
|
127
129
|
reply({ ok: true });
|
|
128
130
|
} else if (cmd === "load") {
|
|
129
131
|
const { handle, rows } = engine.openTable(e.data.buffer);
|
|
130
|
-
|
|
132
|
+
setTable(e.data.name, handle);
|
|
131
133
|
lastTable = handle;
|
|
132
134
|
reply({ ok: true, rows });
|
|
133
135
|
} else if (cmd === "loadParquet") {
|
|
@@ -135,7 +137,7 @@ self.onmessage = async (e) => {
|
|
|
135
137
|
const t0 = performance.now();
|
|
136
138
|
const { rows, img } = await transcodeParquet(e.data.buffer);
|
|
137
139
|
const { handle } = engine.openImage(img);
|
|
138
|
-
|
|
140
|
+
setTable(e.data.name, handle);
|
|
139
141
|
lastTable = handle;
|
|
140
142
|
reply({ ok: true, rows, transcodeMs: performance.now() - t0 });
|
|
141
143
|
} else if (cmd === "openParquet") {
|
|
@@ -159,7 +161,7 @@ self.onmessage = async (e) => {
|
|
|
159
161
|
imageBytes = engine.imageBytes(img);
|
|
160
162
|
} catch { /* copy-out failed: open uncached */ }
|
|
161
163
|
const { handle } = engine.openImage(img);
|
|
162
|
-
|
|
164
|
+
setTable(e.data.name, handle);
|
|
163
165
|
lastTable = handle;
|
|
164
166
|
if (imageBytes) {
|
|
165
167
|
try {
|
|
@@ -168,6 +170,22 @@ self.onmessage = async (e) => {
|
|
|
168
170
|
} catch { /* non-secure context or quota: stay memory-only */ }
|
|
169
171
|
}
|
|
170
172
|
reply({ ok: true, rows, source: "transcode", cached, transcodeMs });
|
|
173
|
+
} else if (cmd === "materialize") {
|
|
174
|
+
// derived table: run the query on the source table, compile its result
|
|
175
|
+
// into a new in-memory table under `name`; optionally persist the image
|
|
176
|
+
const src = e.data.table ? tables.get(e.data.table) : lastTable;
|
|
177
|
+
if (!src) throw new Error(`no table loaded${e.data.table ? `: '${e.data.table}'` : ""}`);
|
|
178
|
+
const t0 = performance.now();
|
|
179
|
+
const img = engine.materialize(src, e.data.sql);
|
|
180
|
+
let bytes = 0;
|
|
181
|
+
if (e.data.persist) {
|
|
182
|
+
const image = engine.imageBytes(img);
|
|
183
|
+
bytes = image.byteLength;
|
|
184
|
+
await opfsWrite(e.data.persist, image);
|
|
185
|
+
}
|
|
186
|
+
const { handle, rows } = engine.openImage(img);
|
|
187
|
+
setTable(e.data.name, handle);
|
|
188
|
+
reply({ ok: true, rows, bytes, elapsedMs: performance.now() - t0 });
|
|
171
189
|
} else if (cmd === "storeOpfs") {
|
|
172
190
|
await opfsWrite(e.data.path, new Uint8Array(e.data.buffer));
|
|
173
191
|
reply({ ok: true, bytes: e.data.buffer.byteLength });
|