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 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, single table, SQLite semantics (3-valued logic, null-skipping
76
- aggregates, truncating integer division, NULL-first ascending sorts).
77
- Idioms: `IN`, `BETWEEN`, `IS [NOT] NULL`, `[NOT] LIKE`, `CASE WHEN`, `CAST`,
78
- `COUNT(DISTINCT x)`, `||`, `select *`. Aggregates: count, sum, avg, min, max,
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);
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.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
- tables.set(name, handle);
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
- tables.set(e.data.name, handle);
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
- tables.set(e.data.name, handle);
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
- tables.set(e.data.name, handle);
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 });