facetful 0.3.1 → 0.5.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,11 +17,30 @@ 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
 
23
26
  ## Quickstart
24
27
 
28
+ One install gives the browser library **and** the `facetful` command:
29
+
30
+ ```
31
+ npm install facetful
32
+ npx facetful convert data.csv data.facetful # streaming: any file size, ~20 MB of memory
33
+ npx facetful query data.facetful "select country, count(*) as n from t group by country order by n desc limit 5"
34
+ ```
35
+
36
+ The command runs the same wasm engine the browser does (Node is the second
37
+ runtime), so a `.facetful` built here is exactly what `openParquet` would have
38
+ built, and SQL — including your registered functions, via `--udf module.mjs` —
39
+ behaves identically in both places. CSV can also be opened directly in the
40
+ browser with `loadCsv` (below). The ready-made functions (`json_extract`,
41
+ `to_tz`, `date_trunc`, …, see "User-defined functions") are registered by
42
+ default in both.
43
+
25
44
  ```js
26
45
  import { Facetful } from "facetful";
27
46
 
@@ -50,13 +69,119 @@ The SQL table is always named `t`. Multiple tables coexist:
50
69
  | method | use when |
51
70
  |---|---|
52
71
  | `openParquet(name, buf)` | the default: Parquet in, OPFS-cached compiled image, instant repeat visits |
53
- | `load(name, buf)` | you already have a `.facetful` image (built by the native CLI) |
72
+ | `load(name, buf)` | you already have a `.facetful` image (built by `facetful convert`) |
73
+ | `loadCsv(name, fileOrBuffer, {persist})` | a CSV: streamed through the converter in the worker, types inferred, optionally persisted |
54
74
  | `loadOpfs(name, path, {cacheBytes})` | the file is in OPFS; open lazily, spill-over for big data |
55
75
 
56
76
  Persistence is always explicit (`storeOpfs`), never write-behind. OPFS needs a
57
77
  secure context (https or localhost); everything degrades to memory-only
58
78
  without one.
59
79
 
80
+ ## The `facetful` command
81
+
82
+ ```
83
+ facetful convert in.csv out.facetful [--row-group-size N]
84
+ facetful query file.facetful ["select …"] [--table name=other.facetful …] [--udf module.mjs …]
85
+ facetful materialize in.facetful "select …" out.facetful [--table …] [--udf …]
86
+ ```
87
+
88
+ `convert` streams: two passes over the CSV (types and dictionaries, then
89
+ encoding), row groups written as they finish, memory bounded by the distinct
90
+ values plus one row group — a 200 MB / 3M-row CSV converts in ~4 s at ~70 MB
91
+ of process memory (Node included). Types: int (narrowed), float, ISO date and
92
+ datetime, text; repeated text is dictionary-encoded. `query` without SQL is a
93
+ REPL; tables open lazily, so large files don't load into memory. The
94
+ ready-made functions are registered; a `--udf` module's default export adds
95
+ your own, as an array of `{ name, signature, fn }`.
96
+
97
+ ## Derived tables: `materialize`
98
+
99
+ ```js
100
+ // a grouped result becomes a table of its own — queryable like any other,
101
+ // with the same typed columns, nulls and (here) sort metadata
102
+ await db.materialize("fuel_state", `
103
+ select fuel, state, count(*) as n, round(sum(net_generation_mwh)/1e6, 3) as twh
104
+ from t group by fuel, state order by twh desc`);
105
+ const r = await db.query("select fuel, sum(twh) as twh from t group by fuel", { table: "fuel_state" });
106
+
107
+ // persist the image to OPFS so a later visit can loadOpfs() it instead
108
+ await db.materialize("fuel_state", sql, { persist: "facetful/fuel_state.facetful" });
109
+ ```
110
+
111
+ Pre-aggregate a large detail table once per session and run the facets
112
+ against the rollup; every SELECT item needs a distinct name. Row order is the
113
+ query's output order, so a materialized `ORDER BY` is recorded as the table's
114
+ sort. The CLI has the same verb: `facetful materialize in.facetful "select …" out.facetful`.
115
+
116
+ ## Joins, CTEs, subqueries
117
+
118
+ Every table the worker holds — loaded, opened from Parquet, or materialized —
119
+ is registered under its name, and any query can name it:
120
+
121
+ ```js
122
+ await db.materialize("plants", "select plant_id, state, count(*) as n_gens from t group by plant_id, state");
123
+ await db.query(`
124
+ select t.fuel, p.state, sum(t.mwh) as mwh
125
+ from t left join plants p on t.plant_id = p.plant_id
126
+ where p.n_gens >= 3 group by t.fuel, p.state order by mwh desc`);
127
+ ```
128
+
129
+ `[INNER|LEFT] JOIN … ON a.k = b.k [AND …]` and `USING (k)`; the right side's
130
+ join key must be unique (a dimension). `WITH name AS (…)`, `FROM (select …) s`,
131
+ `x IN (select …)`, `(a, b) IN (select …)`, `EXISTS (select … where d.k = t.k)`.
132
+ All of these are **materializations, not query-time operators**: the first
133
+ query builds the joined or derived table (milliseconds for a fact table
134
+ against a dimension), and it lives in a per-table cache (64 MB default) that
135
+ every later query with the same shape reuses — a facet burst over a join pays
136
+ for the join once. Correlated subqueries beyond `inner.k = outer.k` equalities,
137
+ `FULL`/`RIGHT` joins and non-unique right keys are refused with a clear error.
138
+ `NOT IN` follows SQL's NULL rule (a NULL in the set makes it select nothing);
139
+ `NOT EXISTS` doesn't, and is usually what you mean.
140
+
141
+ ## User-defined functions
142
+
143
+ ```js
144
+ // vectorized: called once per lane (a row group, a group table, or — for a
145
+ // dictionary column — the dictionary itself); `out.values` is the result lane
146
+ await db.registerFunction("regexp", { params: ["text", "text"], returns: "bool" }, (() => {
147
+ const cache = new Map();
148
+ return (args, len, out) => {
149
+ const [s, pattern] = args; // pattern is a literal: broadcast, one value
150
+ let re = cache.get(pattern.values[0]);
151
+ if (!re) cache.set(pattern.values[0], (re = new RegExp(pattern.values[0])));
152
+ for (let i = 0; i < len; i++) out.values[i] = re.test(s.values[i]) ? 1 : 0;
153
+ };
154
+ })());
155
+ await db.query("select fuel, count(*) from t where regexp(plant_name, '^(Big|Little) ') group by fuel");
156
+
157
+ // per row: simpler, ~10x slower on large lanes
158
+ await db.registerFunction("mw_to_gw", { params: ["float"], returns: "float", perRow: true }, (mw) => mw / 1000);
159
+ ```
160
+
161
+ **Ready-made functions**, registered by default (`Facetful.open({ udfs: false })`
162
+ opts out; the list is importable from `facetful/udfs`): `regexp(s, pattern[, flags])`,
163
+ `regexp_extract(s, pattern[, group])`, `regexp_replace(s, pattern, replacement)`,
164
+ `json_extract(doc, '$.a.b[0]')`, `to_tz(ts, 'Europe/London')`
165
+ (Intl's time-zone tables — hundreds of KB the wasm never has to carry),
166
+ `date_trunc('month', ts)`, `date_add(d, 1, 'month')`, `weekday`, `quarter`,
167
+ `country_name('DE')`, `format_number(x, 'en-US:compact')`, `unaccent('Zürich')`,
168
+ `url_host(url)`. Each is a few lines of ordinary JavaScript over what the
169
+ browser already ships; they're as much a set of patterns as a library.
170
+
171
+ Kinds: `int`, `float`, `bool`, `text`, `date`, `timestamp` (dates and timestamps
172
+ arrive as days / ms numbers). Functions bind like built-ins — wrong argument
173
+ types are caret-diagnosed, the declared return type is the column's type — and
174
+ their results go through the same caches, so a `regexp()` filter is evaluated
175
+ once per pattern and served from the mask cache after. `strict` (default)
176
+ gives NULL out for NULL in without calling you; `optional: n` makes the last
177
+ `n` parameters omittable, a parameter kind of `"any"` accepts every type, and
178
+ `variadic` repeats the last parameter; `unregisterFunction(name)` removes one. The function runs in the
179
+ worker: pass a self-contained function (its source is sent — an IIFE for
180
+ state, as above — no closures over your variables) or `{ moduleUrl }`. A
181
+ throwing function fails the query with its message. `regexp()` is the
182
+ browser's `RegExp` here and the `regex` crate in the native CLI — the same SQL
183
+ on both sides.
184
+
60
185
  ## Parquet support
61
186
 
62
187
  Reading uses [hyparquet](https://github.com/hyparam/hyparquet) (~20 KB gz),
@@ -72,10 +197,11 @@ browser transcoder (the native CLI has no such limit).
72
197
 
73
198
  ## SQL dialect, briefly
74
199
 
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,
200
+ SELECT-only, SQLite semantics (3-valued logic, null-skipping aggregates,
201
+ truncating integer division, NULL-first ascending sorts). Idioms: `IN`,
202
+ `BETWEEN`, `IS [NOT] NULL`, `[NOT] LIKE`, `CASE WHEN`, `CAST`,
203
+ `COUNT(DISTINCT x)`, `||`, `select *`, `JOIN`/`WITH`/subqueries and
204
+ user-defined functions as above. Aggregates: count, sum, avg, min, max,
79
205
  count(distinct), median, stddev, group_concat. Scalars: math (abs, round,
80
206
  floor, ceil, sqrt, pow, exp, ln, sign), text (lower, upper, length, substr,
81
207
  trim/ltrim/rtrim, replace, instr, concat), null handling (coalesce, ifnull,
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ // The facetful command, on the same wasm engine the browser runs (design.sv
3
+ // d51): one `npm install facetful` gives the library and this.
4
+ //
5
+ // facetful convert in.csv out.facetful [--row-group-size N]
6
+ // facetful query file.facetful ["select …"] [--table name=other.facetful …] [--udf module.mjs …]
7
+ // facetful materialize in.facetful "select …" out.facetful [--table name=path …] [--udf …]
8
+ //
9
+ // Conversion streams: two passes over the CSV in 1 MB chunks, row groups
10
+ // written as they finish, memory bounded whatever the file size. Tables open
11
+ // lazily through positional reads (the browser's OPFS import, here fs.readSync).
12
+ // The ready-made functions (../udfs.js) are registered; a --udf module's
13
+ // default export adds more: an array of { name, signature, fn }.
14
+ import { readFileSync, openSync, readSync, writeSync, closeSync, fstatSync } from "node:fs";
15
+ import { pathToFileURL } from "node:url";
16
+ import { instantiate, QueryError } from "../core.js";
17
+ import { udfs as builtinUdfs } from "../udfs.js";
18
+
19
+ const args = process.argv.slice(2);
20
+ const cmd = args.shift();
21
+ const usage = () => {
22
+ console.error(`usage: facetful convert in.csv out.facetful [--row-group-size N]
23
+ facetful query file.facetful ["select …"] [--table name=path.facetful …] [--udf module.mjs …]
24
+ facetful materialize in.facetful "select …" out.facetful [--table name=path …] [--udf module.mjs …]`);
25
+ process.exit(2);
26
+ };
27
+
28
+ // positional reads for lazily opened tables: fileId -> fd
29
+ const fds = new Map();
30
+ let nextId = 1;
31
+ const opfsRead = (fileId, offset, dest) => {
32
+ const fd = fds.get(fileId);
33
+ if (fd === undefined) return -1;
34
+ return readSync(fd, dest, 0, dest.length, offset);
35
+ };
36
+ const engine = await instantiate(readFileSync(new URL("../facetful_wasm.wasm", import.meta.url)), opfsRead);
37
+ for (const u of builtinUdfs) engine.registerFunction(u.name, u.signature, u.fn);
38
+
39
+ function openLazy(path) {
40
+ const fd = openSync(path, "r");
41
+ const id = nextId++;
42
+ fds.set(id, fd);
43
+ return engine.openOpfsTable(id, fstatSync(fd).size, 256 << 20);
44
+ }
45
+
46
+ async function registerUdfs(paths) {
47
+ for (const p of paths) {
48
+ const mod = await import(pathToFileURL(p).href);
49
+ const list = Array.isArray(mod.default) ? mod.default : Object.values(mod.default ?? mod);
50
+ for (const u of list) engine.registerFunction(u.name, u.signature, u.fn);
51
+ }
52
+ }
53
+
54
+ /** Split flags out of a positional list: --table name=path, --udf path, --row-group-size N. */
55
+ function parse(argv) {
56
+ const pos = [], tables = [], udfs = [];
57
+ let groupSize = 65536;
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ if (a === "--table") tables.push(argv[++i]);
61
+ else if (a === "--udf") udfs.push(argv[++i]);
62
+ else if (a === "--row-group-size") groupSize = Number(argv[++i]);
63
+ else if (a.startsWith("--")) usage();
64
+ else pos.push(a);
65
+ }
66
+ return { pos, tables, udfs, groupSize };
67
+ }
68
+
69
+ const text = (c, i) => engine.dec.decode(c.bytes.subarray(c.offsets[i], c.offsets[i + 1]));
70
+ const isoDate = (d) => new Date(d * 86400000).toISOString().slice(0, 10);
71
+ const isoTs = (ms) => new Date(ms).toISOString().replace("T", " ").slice(0, 19);
72
+ function cell(c, i) {
73
+ if (!((c.validity[i >> 3] >> (i & 7)) & 1)) return "";
74
+ switch (c.kind) {
75
+ case "text": return text(c, i);
76
+ case "date": return isoDate(c.values[i]);
77
+ case "timestamp": return isoTs(c.values[i]);
78
+ case "bool": return c.values[i] ? "1" : "0";
79
+ default: return String(c.values[i]);
80
+ }
81
+ }
82
+ function printResult(r, ms) {
83
+ const rows = [];
84
+ for (let i = 0; i < r.rowCount; i++) rows.push(r.columns.map((c) => cell(c, i)));
85
+ const widths = r.columns.map((c, j) => Math.max(c.name.length, ...rows.map((row) => row[j].length)));
86
+ const line = (cells) => cells.map((s, j) => s.padEnd(widths[j])).join(" ");
87
+ console.log(line(r.columns.map((c) => c.name)));
88
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
89
+ for (const row of rows) console.log(line(row));
90
+ console.log(`(${r.rowCount} row${r.rowCount === 1 ? "" : "s"}, ${ms.toFixed(1)} ms)`);
91
+ }
92
+
93
+ function convert(input, output, groupSize) {
94
+ const CHUNK = 1 << 20;
95
+ const h = engine.w.convert_begin(groupSize);
96
+ const fail = () => {
97
+ const n = engine.w.convert_error(h, engine.scratch, 4096);
98
+ console.error(engine.dec.decode(new Uint8Array(engine.mem(), engine.scratch, n)));
99
+ process.exit(1);
100
+ };
101
+ const out = openSync(output, "w");
102
+ let written = 0;
103
+ const drain = () => {
104
+ const n = engine.w.convert_output_len(h);
105
+ if (!n) return;
106
+ const p = engine.w.alloc(n);
107
+ engine.w.convert_output_copy(h, p);
108
+ writeSync(out, new Uint8Array(engine.mem(), p, n));
109
+ engine.w.dealloc(p, n);
110
+ written += n;
111
+ };
112
+ const feedFile = () => {
113
+ const fd = openSync(input, "r");
114
+ const buf = engine.w.alloc(CHUNK);
115
+ for (;;) {
116
+ // the view is rebuilt per read: memory may grow while the converter runs
117
+ const n = readSync(fd, new Uint8Array(engine.mem(), buf, CHUNK), 0, CHUNK, null);
118
+ if (n === 0) break;
119
+ if (engine.w.convert_feed(h, buf, n) < 0) fail();
120
+ drain();
121
+ }
122
+ engine.w.dealloc(buf, CHUNK);
123
+ closeSync(fd);
124
+ };
125
+ const t0 = performance.now();
126
+ feedFile();
127
+ if (engine.w.convert_pass2(h) < 0) fail();
128
+ const n = engine.w.convert_schema(h, engine.scratch, 4096);
129
+ const schema = engine.dec.decode(new Uint8Array(engine.mem(), engine.scratch, n)).trimEnd();
130
+ feedFile();
131
+ if (engine.w.convert_finish(h) < 0) fail();
132
+ drain();
133
+ closeSync(out);
134
+ const rows = engine.w.convert_rows(h);
135
+ engine.w.convert_free(h);
136
+ console.error(`${input}: ${rows} rows`);
137
+ for (const l of schema.split("\n")) console.error(" " + l.replace("\t", ": "));
138
+ console.error(`${output}: ${written} bytes (${Math.ceil(rows / groupSize)} row groups, ${(performance.now() - t0).toFixed(0)} ms)`);
139
+ }
140
+
141
+ try {
142
+ if (cmd === "convert") {
143
+ const { pos, groupSize } = parse(args);
144
+ if (pos.length !== 2) usage();
145
+ convert(pos[0], pos[1], groupSize);
146
+ } else if (cmd === "query" || cmd === "materialize") {
147
+ const { pos, tables, udfs } = parse(args);
148
+ if (pos.length < 1) usage();
149
+ await registerUdfs(udfs);
150
+ const { handle } = openLazy(pos[0]);
151
+ for (const spec of tables) {
152
+ const eq = spec.indexOf("=");
153
+ if (eq < 0) usage();
154
+ engine.catalogRegister(spec.slice(0, eq), openLazy(spec.slice(eq + 1)).handle);
155
+ }
156
+ if (cmd === "materialize") {
157
+ if (pos.length !== 3) usage();
158
+ const t0 = performance.now();
159
+ const img = engine.materialize(handle, pos[1]);
160
+ const bytes = engine.imageBytes(img);
161
+ writeSync(openSync(pos[2], "w"), bytes);
162
+ const { rows } = engine.openImage(img);
163
+ console.error(`${pos[2]}: ${rows} rows, ${bytes.byteLength} bytes (${(performance.now() - t0).toFixed(1)} ms)`);
164
+ } else if (pos.length >= 2) {
165
+ const t0 = performance.now();
166
+ const r = engine.query(handle, pos[1]);
167
+ printResult(r, performance.now() - t0);
168
+ } else {
169
+ // REPL: one statement per line
170
+ const rl = (await import("node:readline")).createInterface({ input: process.stdin, output: process.stdout, prompt: "facetful> " });
171
+ rl.prompt();
172
+ rl.on("line", (line) => {
173
+ const sql = line.trim();
174
+ if (sql) {
175
+ try {
176
+ const t0 = performance.now();
177
+ printResult(engine.query(handle, sql), performance.now() - t0);
178
+ } catch (e) {
179
+ console.error(e instanceof QueryError ? e.message : e);
180
+ }
181
+ }
182
+ rl.prompt();
183
+ });
184
+ rl.on("close", () => process.exit(0));
185
+ }
186
+ } else usage();
187
+ } catch (e) {
188
+ console.error(e instanceof QueryError ? e.message : e);
189
+ process.exit(1);
190
+ }
package/core.js CHANGED
@@ -10,10 +10,12 @@ const KINDS = { 1: "int", 2: "float", 3: "bool", 4: "text", 5: "date", 6: "times
10
10
  // Re-interpret as unsigned before building any view over memory.
11
11
  const u32 = (n) => n >>> 0;
12
12
 
13
- // The wasm module declares one import: env.opfs_read(fileId, offset, len, destPtr)
14
- // -> bytes read. The browser worker supplies a real implementation over OPFS
15
- // sync access handles; environments without OPFS (Node smoke test) get a stub
16
- // that fails any read (memory-backed tables never call it).
13
+ // The wasm module declares two imports. env.opfs_read(fileId, offset, len,
14
+ // destPtr) -> bytes read: the browser worker supplies a real implementation
15
+ // over OPFS sync access handles; environments without OPFS (Node smoke test)
16
+ // get a stub that fails any read (memory-backed tables never call it).
17
+ // env.udf_call(id, argc, argsPtr, outPtr, len) evaluates a registered
18
+ // user-defined function over whole lanes (see Engine.registerFunction).
17
19
  export async function instantiate(wasmBytes, opfsRead) {
18
20
  let engine = null;
19
21
  const env = {
@@ -22,18 +24,155 @@ export async function instantiate(wasmBytes, opfsRead) {
22
24
  // the view must be built per call: memory.buffer detaches on growth
23
25
  return opfsRead(fileId, offset, new Uint8Array(engine.mem(), u32(destPtr), u32(len)));
24
26
  },
27
+ udf_call: (id, argc, argsPtr, outPtr, len) => engine._udfCall(id, argc, u32(argsPtr), u32(outPtr), len),
25
28
  };
26
29
  const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
27
30
  engine = new Engine(instance.exports);
28
31
  return engine;
29
32
  }
30
33
 
34
+ /** Lane kinds on the UDF wire (the numbers are the ABI). */
35
+ export const KIND = { int: 0, float: 1, bool: 2, text: 3, date: 4, timestamp: 5 };
36
+ /** parameter-only kind: accepts any argument type (the lane carries its real kind) */
37
+ const ANY = 6;
38
+ const KIND_NAMES = Object.keys(KIND);
39
+ // descriptor words: see facetful-wasm `WasmHost`
40
+ const D_KIND = 0, D_LEN = 1, D_FLAGS = 2, D_DATA = 3, D_AUX = 4, D_VALID = 5, D_BYTES = 6, D_ERR = 7, D_WORDS = 8;
41
+
31
42
  export class Engine {
32
43
  constructor(exports) {
33
44
  this.w = exports;
34
45
  this.scratch = u32(this.w.alloc(4096));
35
46
  this.enc = new TextEncoder();
36
47
  this.dec = new TextDecoder();
48
+ /** id -> { fn, sig, name } for registered user-defined functions */
49
+ this.udfs = new Map();
50
+ }
51
+
52
+ /**
53
+ * Register a user-defined scalar function (design.sv d49). `sig` is
54
+ * `{ params: kind[], returns: kind, strict?, variadic?, optional?, perRow? }`
55
+ * with kinds from KIND (by name; a parameter may also be "any"). `optional`
56
+ * is how many trailing parameters may be omitted; `variadic` lets the last
57
+ * repeat. The function is called ONCE per lane —
58
+ * `fn(args, len, out)` where each arg is `{ kind, values, valid, broadcast }`
59
+ * (`values` a Float64Array for numbers/dates/bools, `string[]` for text;
60
+ * `broadcast` = a literal, one value; `valid` a bitmap or null) and `out` is
61
+ * `{ values, valid }` to fill (`values` a typed array or `string[]`; set a
62
+ * result to null to make it NULL). With `perRow: true` the function is
63
+ * instead called per row with plain values and returns one (or null).
64
+ * Strict (the default) skips NULL inputs and NULL-fills those outputs.
65
+ */
66
+ registerFunction(name, sig, fn) {
67
+ const kinds = (sig.params || []).map((k) => (k === "any" ? ANY : kindOf(k)));
68
+ const ret = kindOf(sig.returns);
69
+ const nameB = this.enc.encode(name);
70
+ const p = u32(this.w.alloc(nameB.byteLength + kinds.length + 1));
71
+ new Uint8Array(this.mem(), p, nameB.byteLength).set(nameB);
72
+ new Uint8Array(this.mem(), p + nameB.byteLength, kinds.length).set(kinds);
73
+ const flags = (sig.strict === false ? 0 : 1) | (sig.variadic ? 2 : 0) | ((sig.optional || 0) << 2);
74
+ const id = this.w.udf_register(p, nameB.byteLength, p + nameB.byteLength, kinds.length, ret, flags);
75
+ this.w.dealloc(p, nameB.byteLength + kinds.length + 1);
76
+ if (!id) {
77
+ const n = this.w.udf_error(this.scratch, 4096);
78
+ throw new Error(`registerFunction('${name}'): ${this.dec.decode(new Uint8Array(this.mem(), this.scratch, n))}`);
79
+ }
80
+ for (const [k, v] of this.udfs) if (v.name === name) this.udfs.delete(k);
81
+ this.udfs.set(id, { fn, sig: { ...sig, strict: sig.strict !== false }, name });
82
+ return id;
83
+ }
84
+
85
+ unregisterFunction(name) {
86
+ const b = this.enc.encode(name);
87
+ const p = u32(this.w.alloc(b.byteLength));
88
+ new Uint8Array(this.mem(), p, b.byteLength).set(b);
89
+ const ok = this.w.udf_unregister(p, b.byteLength) !== 0;
90
+ this.w.dealloc(p, b.byteLength);
91
+ for (const [k, v] of this.udfs) if (v.name === name) this.udfs.delete(k);
92
+ return ok;
93
+ }
94
+
95
+ /** The udf_call import: lanes in wasm memory -> the registered function -> the output lane. */
96
+ _udfCall(id, argc, argsPtr, outPtr, len) {
97
+ const entry = this.udfs.get(id);
98
+ try {
99
+ if (!entry) throw new Error(`no function registered under id ${id}`);
100
+ const mem = this.mem();
101
+ const args = [];
102
+ for (let i = 0; i < argc; i++) {
103
+ const d = new Uint32Array(mem, argsPtr + i * D_WORDS * 4, D_WORDS);
104
+ const n = d[D_LEN];
105
+ const kind = KIND_NAMES[d[D_KIND]];
106
+ const valid = d[D_VALID] ? new Uint8Array(mem, d[D_VALID], (n + 7) >> 3) : null;
107
+ let values;
108
+ if (kind === "text") {
109
+ values = this._decodeLane(new Uint8Array(mem, d[D_DATA], d[D_BYTES]), new Uint32Array(mem, d[D_AUX], n + 1), n);
110
+ } else {
111
+ values = new Float64Array(mem, d[D_DATA], n);
112
+ }
113
+ args.push({ kind, values, valid, broadcast: (d[D_FLAGS] & 1) !== 0 });
114
+ }
115
+ const od = new Uint32Array(mem, outPtr, D_WORDS);
116
+ const retKind = KIND_NAMES[od[D_KIND]];
117
+ const outValid = new Uint8Array(mem, od[D_VALID], (len + 7) >> 3);
118
+ const out = {
119
+ values: retKind === "text" ? new Array(len).fill("") : retKind === "bool" ? new Uint8Array(mem, od[D_DATA], len) : new Float64Array(mem, od[D_DATA], len),
120
+ valid: outValid,
121
+ };
122
+ const { fn, sig } = entry;
123
+ if (sig.perRow) {
124
+ const at = (a, i) => {
125
+ const j = a.broadcast ? 0 : i;
126
+ if (a.valid && !((a.valid[j >> 3] >> (j & 7)) & 1)) return null;
127
+ return a.values[j];
128
+ };
129
+ for (let i = 0; i < len; i++) {
130
+ const row = args.map((a) => at(a, i));
131
+ if (sig.strict && row.includes(null)) { outValid[i >> 3] &= ~(1 << (i & 7)); continue; }
132
+ const v = fn(...row);
133
+ if (v === null || v === undefined) outValid[i >> 3] &= ~(1 << (i & 7));
134
+ else out.values[i] = v;
135
+ }
136
+ } else {
137
+ fn(args, len, out);
138
+ // null results in a plain array mark NULL
139
+ if (retKind === "text") for (let i = 0; i < len; i++) if (out.values[i] == null) { outValid[i >> 3] &= ~(1 << (i & 7)); out.values[i] = ""; }
140
+ }
141
+ if (retKind === "text") {
142
+ const parts = out.values.map((s) => this.enc.encode(typeof s === "string" ? s : String(s)));
143
+ let total = 0;
144
+ for (const q of parts) total += q.byteLength;
145
+ const bp = total ? u32(this.w.alloc(total)) : 0;
146
+ const bytes = new Uint8Array(this.mem(), bp, total);
147
+ const offs = new Uint32Array(this.mem(), od[D_AUX], len + 1);
148
+ let pos = 0;
149
+ offs[0] = 0;
150
+ for (let i = 0; i < len; i++) { bytes.set(parts[i], pos); pos += parts[i].byteLength; offs[i + 1] = pos; }
151
+ const od2 = new Uint32Array(this.mem(), outPtr, D_WORDS);
152
+ od2[D_DATA] = bp;
153
+ od2[D_BYTES] = total;
154
+ }
155
+ return 0;
156
+ } catch (e) {
157
+ const msg = this.enc.encode(`${entry ? entry.name : "udf"}(): ${e && e.message ? e.message : e}`);
158
+ const p = u32(this.w.alloc(msg.byteLength));
159
+ new Uint8Array(this.mem(), p, msg.byteLength).set(msg);
160
+ new Uint32Array(this.mem(), outPtr, D_WORDS)[D_ERR] = p;
161
+ return -msg.byteLength;
162
+ }
163
+ }
164
+
165
+ /** A text lane to string[]: one decode when the bytes are ASCII (decoded
166
+ * length == byte length, so byte offsets are char offsets), else per string. */
167
+ _decodeLane(bytes, offsets, n) {
168
+ const whole = this.dec.decode(bytes);
169
+ const out = new Array(n);
170
+ if (whole.length === bytes.byteLength) {
171
+ for (let i = 0; i < n; i++) out[i] = whole.slice(offsets[i], offsets[i + 1]);
172
+ } else {
173
+ for (let i = 0; i < n; i++) out[i] = this.dec.decode(bytes.subarray(offsets[i], offsets[i + 1]));
174
+ }
175
+ return out;
37
176
  }
38
177
  mem() {
39
178
  return this.w.memory.buffer;
@@ -121,6 +260,30 @@ export class Engine {
121
260
  return img;
122
261
  }
123
262
 
263
+ /**
264
+ * Materialize a query's result as a compiled image: the derived-table
265
+ * primitive. Returns an image handle (pass to openImage / imageBytes), or
266
+ * throws QueryError with the engine's diagnostic.
267
+ */
268
+ materialize(tableHandle, sql, { groupTarget = 65536 } = {}) {
269
+ const sqlBytes = this.enc.encode(sql);
270
+ const sqlPtr = u32(this.w.alloc(sqlBytes.byteLength));
271
+ new Uint8Array(this.mem(), sqlPtr, sqlBytes.byteLength).set(sqlBytes);
272
+ const h = this.w.table_materialize(tableHandle, sqlPtr, sqlBytes.byteLength, groupTarget);
273
+ try {
274
+ if (this.w.outcome_is_err(h)) {
275
+ const n = this.w.outcome_error(h, this.scratch, 4096);
276
+ throw new QueryError(this.dec.decode(new Uint8Array(this.mem(), this.scratch, n)));
277
+ }
278
+ const img = this.w.outcome_image(h);
279
+ if (!img) throw new Error("materialize produced no image");
280
+ return img;
281
+ } finally {
282
+ this.w.dealloc(sqlPtr, sqlBytes.byteLength);
283
+ this.w.outcome_free(h);
284
+ }
285
+ }
286
+
124
287
  /** Copy a compiled image's bytes out (for OPFS persistence). */
125
288
  imageBytes(img) {
126
289
  return new Uint8Array(this.mem(), u32(this.w.image_ptr(img)), u32(this.w.image_len(img))).slice();
@@ -133,6 +296,68 @@ export class Engine {
133
296
  return { handle, rows: this.w.table_total_rows(handle) };
134
297
  }
135
298
 
299
+ /**
300
+ * Stream a CSV into a .facetful image (design.sv d51): `chunks()` returns a
301
+ * fresh (async) iterable of Uint8Array chunks each time it is called — the
302
+ * converter reads the input twice (types and dictionaries, then encoding)
303
+ * in bounded memory; the image is assembled here. Returns
304
+ * { bytes, rows, schema: [{ name, kind }] }.
305
+ */
306
+ async convertCsv(chunks, { groupTarget = 65536 } = {}) {
307
+ const h = this.w.convert_begin(groupTarget);
308
+ const fail = () => {
309
+ const n = this.w.convert_error(h, this.scratch, 4096);
310
+ const msg = this.dec.decode(new Uint8Array(this.mem(), this.scratch, n));
311
+ this.w.convert_free(h);
312
+ throw new Error(`convert: ${msg}`);
313
+ };
314
+ const parts = [];
315
+ let total = 0;
316
+ const drain = () => {
317
+ const n = this.w.convert_output_len(h);
318
+ if (!n) return;
319
+ const p = u32(this.w.alloc(n));
320
+ this.w.convert_output_copy(h, p);
321
+ parts.push(new Uint8Array(this.mem(), p, n).slice());
322
+ this.w.dealloc(p, n);
323
+ total += n;
324
+ };
325
+ const feed = async () => {
326
+ for await (const c of chunks()) {
327
+ const bytes = c instanceof Uint8Array ? c : new Uint8Array(c);
328
+ const p = u32(this.w.alloc(bytes.byteLength));
329
+ new Uint8Array(this.mem(), p, bytes.byteLength).set(bytes);
330
+ const rc = this.w.convert_feed(h, p, bytes.byteLength);
331
+ this.w.dealloc(p, bytes.byteLength);
332
+ if (rc < 0) fail();
333
+ drain();
334
+ }
335
+ };
336
+ await feed();
337
+ if (this.w.convert_pass2(h) < 0) fail();
338
+ const n = this.w.convert_schema(h, this.scratch, 4096);
339
+ const schema = this.dec.decode(new Uint8Array(this.mem(), this.scratch, n)).trimEnd().split("\n")
340
+ .filter(Boolean).map((l) => { const [name, kind] = l.split("\t"); return { name, kind }; });
341
+ await feed();
342
+ if (this.w.convert_finish(h) < 0) fail();
343
+ drain();
344
+ const rows = this.w.convert_rows(h);
345
+ this.w.convert_free(h);
346
+ const bytes = new Uint8Array(total);
347
+ let pos = 0;
348
+ for (const q of parts) { bytes.set(q, pos); pos += q.byteLength; }
349
+ return { bytes, rows, schema };
350
+ }
351
+
352
+ /** Make `handle` reachable by `name` from other tables' queries (FROM / JOIN). */
353
+ catalogRegister(name, handle) {
354
+ const b = this.enc.encode(name);
355
+ const p = u32(this.w.alloc(b.byteLength));
356
+ new Uint8Array(this.mem(), p, b.byteLength).set(b);
357
+ this.w.catalog_register(p, b.byteLength, handle);
358
+ this.w.dealloc(p, b.byteLength);
359
+ }
360
+
136
361
  /** Run SQL; returns { columns, rowCount, stats } with copied-out buffers. */
137
362
  query(tableHandle, sql) {
138
363
  const sqlBytes = this.enc.encode(sql);
@@ -193,3 +418,9 @@ export function transferables(result) {
193
418
  }
194
419
  return t;
195
420
  }
421
+
422
+ function kindOf(k) {
423
+ if (typeof k === "number") return k;
424
+ if (k in KIND) return KIND[k];
425
+ throw new Error(`unknown lane kind '${k}' (expected one of ${Object.keys(KIND).join(", ")})`);
426
+ }
Binary file
package/index.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface OpenOptions {
14
14
  * explicit URL when running without one.
15
15
  */
16
16
  hyparquetUrl?: string;
17
+ /** register the ready-made functions from `facetful/udfs` at open (default true) */
18
+ udfs?: boolean;
17
19
  }
18
20
 
19
21
  export interface QueryStats {
@@ -51,6 +53,36 @@ export declare class Result {
51
53
  rows(): Generator<Record<string, CellValue>>;
52
54
  }
53
55
 
56
+ export type LaneKind = "int" | "float" | "bool" | "text" | "date" | "timestamp";
57
+ export interface UdfSignature {
58
+ /** parameter types; "any" accepts every type (the lane still carries its real kind) */
59
+ params: (LaneKind | "any")[];
60
+ returns: LaneKind;
61
+ /** how many trailing parameters may be omitted */
62
+ optional?: number;
63
+ /** NULL in → NULL out without calling the function for that row (default true) */
64
+ strict?: boolean;
65
+ /** the last parameter type repeats */
66
+ variadic?: boolean;
67
+ /** call per row with plain values instead of once per lane */
68
+ perRow?: boolean;
69
+ }
70
+ export interface UdfLane {
71
+ kind: LaneKind;
72
+ /** Float64Array for int/float/bool/date/timestamp, string[] for text */
73
+ values: Float64Array | string[];
74
+ /** validity bitmap (bit set = present), or null when all present */
75
+ valid: Uint8Array | null;
76
+ /** a literal argument: one value, applies to every row */
77
+ broadcast: boolean;
78
+ }
79
+ export type UdfVectorFn = (
80
+ args: UdfLane[],
81
+ len: number,
82
+ out: { values: Float64Array | Uint8Array | (string | null)[]; valid: Uint8Array },
83
+ ) => void;
84
+ export type UdfRowFn = (...values: (number | string | null)[]) => number | string | boolean | null;
85
+
54
86
  export interface LoadResult {
55
87
  name: string;
56
88
  rows: number;
@@ -92,7 +124,36 @@ export declare class Facetful {
92
124
  loadParquet(name: string, buffer: ArrayBuffer): Promise<{ rows: number; transcodeMs: number }>;
93
125
 
94
126
  /** Persist a .facetful image into OPFS at `path` (buffer transferred). */
127
+ /**
128
+ * Materialize a query's result as a new table `name`, queryable via
129
+ * `{ table: name }`. With `persist`, the image is also written to OPFS at
130
+ * that path for a later `loadOpfs`. `bytes` is the persisted image size (0
131
+ * when not persisted).
132
+ */
133
+ materialize(
134
+ name: string,
135
+ sql: string,
136
+ options?: { table?: string; persist?: string },
137
+ ): Promise<{ rows: number; bytes: number; elapsedMs: number }>;
95
138
  storeOpfs(path: string, buffer: ArrayBuffer): Promise<{ bytes: number }>;
139
+ /**
140
+ * Register a user-defined scalar function (runs in the worker; pass a
141
+ * self-contained function, its source, or `{ moduleUrl }`). Vectorized by
142
+ * default — `fn(args, len, out)` once per lane; `perRow: true` calls
143
+ * `fn(...values)` per row, returning a value or null.
144
+ */
145
+ /** Stream a CSV (File/Blob or ArrayBuffer) into a table; two passes, bounded memory. */
146
+ loadCsv(
147
+ name: string,
148
+ source: Blob | ArrayBuffer,
149
+ options?: { persist?: string; groupTarget?: number },
150
+ ): Promise<{ rows: number; bytes: number; schema: { name: string; kind: string }[]; elapsedMs: number }>;
151
+ registerFunction(
152
+ name: string,
153
+ signature: UdfSignature,
154
+ fn: UdfVectorFn | UdfRowFn | string | { moduleUrl: string },
155
+ ): Promise<{ ok: true }>;
156
+ unregisterFunction(name: string): Promise<{ ok: boolean }>;
96
157
 
97
158
  /**
98
159
  * Open a table over an OPFS file: metadata reads now, column segments load
package/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // [...r.rows()] // row objects, materialized lazily
10
10
 
11
11
  export class Facetful {
12
- static async open({ wasmUrl, workerUrl, hyparquetUrl } = {}) {
12
+ static async open({ wasmUrl, workerUrl, hyparquetUrl, udfs = true } = {}) {
13
13
  // the no-argument form must stay a literal `new Worker(new URL(...))`
14
14
  // expression: bundlers (Vite, webpack) statically analyze exactly that
15
15
  // pattern to compile the worker graph
@@ -21,6 +21,7 @@ export class Facetful {
21
21
  cmd: "init",
22
22
  wasmUrl: String(wasmUrl ?? new URL("./facetful_wasm.wasm", import.meta.url)),
23
23
  hyparquetUrl,
24
+ udfs,
24
25
  });
25
26
  return db;
26
27
  }
@@ -71,6 +72,54 @@ export class Facetful {
71
72
  return this._call({ cmd: "loadParquet", name, buffer }, [buffer]);
72
73
  }
73
74
 
75
+ /**
76
+ * Materialize a query's result as a new table named `name`: a derived,
77
+ * immutable table you then query like any other (`{ table: name }`).
78
+ * Runs against `table` (default: the last loaded). With `persist`, the
79
+ * compiled image is also written to OPFS at that path, so a later visit
80
+ * can `loadOpfs(name, path)` instead of recomputing. Returns { rows, bytes }.
81
+ *
82
+ * The SQL may name other loaded tables in FROM and JOIN, so a joined,
83
+ * persisted table is `materialize("x", "select … from t join dims d using (k)")`.
84
+ */
85
+ async materialize(name, sql, { table, persist } = {}) {
86
+ return this._call({ cmd: "materialize", name, sql, table, persist });
87
+ }
88
+
89
+ /**
90
+ * Convert a CSV (a File/Blob, or an ArrayBuffer) into a table named `name`,
91
+ * streaming in bounded memory — two passes over the input, so a File is
92
+ * read twice. Types are inferred (int, float, date, timestamp, text;
93
+ * repeated text becomes a dictionary). Optionally persist the image to OPFS.
94
+ */
95
+ async loadCsv(name, source, { persist, groupTarget } = {}) {
96
+ const transfer = source instanceof ArrayBuffer ? [source] : [];
97
+ return this._call({ cmd: "loadCsv", name, source, persist, groupTarget }, transfer);
98
+ }
99
+
100
+ /**
101
+ * Register a user-defined scalar function, callable from any query.
102
+ * `signature` = { params: kind[], returns: kind, strict?, variadic?, perRow? }
103
+ * with kinds "int" | "float" | "bool" | "text" | "date" | "timestamp".
104
+ * `fn` runs in the worker, so pass a self-contained function (its source is
105
+ * sent — no closures over your variables) or `{ moduleUrl }` whose default
106
+ * export is the function. Vectorized by default: fn(args, len, out) is
107
+ * called once per lane (see core.js Engine.registerFunction); with
108
+ * `perRow: true` it is called per row with plain values and returns one.
109
+ */
110
+ async registerFunction(name, signature, fn) {
111
+ const msg = { cmd: "registerFunction", name, signature };
112
+ if (typeof fn === "function") msg.source = fn.toString();
113
+ else if (fn && fn.moduleUrl) msg.moduleUrl = fn.moduleUrl;
114
+ else if (typeof fn === "string") msg.source = fn;
115
+ else throw new Error("registerFunction: pass a function, its source text, or { moduleUrl }");
116
+ return this._call(msg);
117
+ }
118
+
119
+ async unregisterFunction(name) {
120
+ return this._call({ cmd: "unregisterFunction", name });
121
+ }
122
+
74
123
  /** Persist a .facetful image into OPFS at `path` (e.g. "facetful/plants.facetful"). */
75
124
  async storeOpfs(path, buffer) {
76
125
  return this._call({ cmd: "storeOpfs", path, buffer }, [buffer]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "facetful",
3
- "version": "0.3.1",
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.",
3
+ "version": "0.5.0",
4
+ "description": "Tiny columnar SQL engine for browser faceting — Rust/wasm, ~240KB gz, zero dependencies. Open Parquet or CSV instantly, facet 1M rows in milliseconds, persist to OPFS; one install gives the library and the `facetful` command.",
5
5
  "license": "MIT",
6
6
  "author": "David Raznick",
7
7
  "type": "module",
@@ -15,7 +15,11 @@
15
15
  "./core": "./core.js",
16
16
  "./worker": "./worker.js",
17
17
  "./parquet": "./parquet.js",
18
- "./facetful_wasm.wasm": "./facetful_wasm.wasm"
18
+ "./facetful_wasm.wasm": "./facetful_wasm.wasm",
19
+ "./udfs": {
20
+ "types": "./udfs.d.ts",
21
+ "default": "./udfs.js"
22
+ }
19
23
  },
20
24
  "files": [
21
25
  "index.js",
@@ -23,6 +27,9 @@
23
27
  "core.js",
24
28
  "worker.js",
25
29
  "parquet.js",
30
+ "udfs.js",
31
+ "udfs.d.ts",
32
+ "bin/facetful.mjs",
26
33
  "facetful_wasm.wasm",
27
34
  "README.md",
28
35
  "LICENSE"
@@ -43,7 +50,9 @@
43
50
  "opfs",
44
51
  "facets",
45
52
  "analytics",
46
- "browser"
53
+ "browser",
54
+ "csv",
55
+ "cli"
47
56
  ],
48
57
  "sideEffects": false,
49
58
  "repository": {
@@ -54,5 +63,8 @@
54
63
  "homepage": "https://github.com/kindly/facetful#readme",
55
64
  "bugs": {
56
65
  "url": "https://github.com/kindly/facetful/issues"
66
+ },
67
+ "bin": {
68
+ "facetful": "bin/facetful.mjs"
57
69
  }
58
70
  }
package/udfs.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { UdfSignature, UdfVectorFn, UdfRowFn } from "./index.js";
2
+
3
+ export interface ReadyMadeUdf {
4
+ name: string;
5
+ signature: UdfSignature;
6
+ fn: UdfVectorFn | UdfRowFn;
7
+ }
8
+
9
+ /**
10
+ * The ready-made functions Facetful.open() registers by default: regexp,
11
+ * json_extract, to_tz, date_trunc, date_add, weekday, quarter, country_name,
12
+ * format_number, unaccent, url_host.
13
+ */
14
+ export const udfs: ReadyMadeUdf[];
15
+ export default udfs;
package/udfs.js ADDED
@@ -0,0 +1,205 @@
1
+ // Ready-made user-defined functions (design.sv d49/d51): things the browser
2
+ // does natively that would cost the wasm engine tens or hundreds of KB —
3
+ // JSON, the Intl tables (timezones, country names, number formats), Unicode
4
+ // normalization, URL parsing — plus the temporal long tail over Date.UTC.
5
+ //
6
+ // Registered by default by Facetful.open() and the `facetful` command
7
+ // (`open({ udfs: false })` opts out). The list is exported for callers that
8
+ // drive core.js directly or want a subset.
9
+ //
10
+ // Each entry is { name, signature, fn }. Functions are self-contained (they run
11
+ // in the worker from their source), vectorized where it pays, per-row where it
12
+ // reads better. Dates are days since 1970-01-01, timestamps ms since the epoch.
13
+
14
+ const DAY = 86400000;
15
+
16
+ export const udfs = [
17
+ // --- regular expressions ---------------------------------------------------
18
+ // regexp(s, pattern[, flags]) -> bool, the browser's RegExp (JIT-compiled, cached per
19
+ // pattern); the native CLI answers the same SQL through the `regex` crate
20
+ {
21
+ name: "regexp",
22
+ signature: { params: ["text", "text", "text"], returns: "bool", optional: 1 },
23
+ fn: (() => {
24
+ const cache = new Map();
25
+ return (args, len, out) => {
26
+ const [s, p] = args;
27
+ const key = p.values[0] + "\u0000" + (args[2] ? args[2].values[0] : "");
28
+ let re = cache.get(key);
29
+ if (!re) cache.set(key, (re = new RegExp(p.values[0], args[2] ? args[2].values[0] : "")));
30
+ for (let i = 0; i < len; i++) out.values[i] = re.test(s.values[i]) ? 1 : 0;
31
+ };
32
+ })(),
33
+ },
34
+ // regexp_extract(s, pattern[, group]) -> text: the match, or group n (number) / a
35
+ // named group (text); NULL when nothing matches
36
+ {
37
+ name: "regexp_extract",
38
+ signature: { params: ["text", "text", "any"], returns: "text", optional: 1 },
39
+ fn: (() => {
40
+ const cache = new Map();
41
+ return (args, len, out) => {
42
+ const [s, p] = args;
43
+ const pat = p.values[0];
44
+ let re = cache.get(pat);
45
+ if (!re) cache.set(pat, (re = new RegExp(pat, "u")));
46
+ const g = args[2] ? args[2].values[0] : 0;
47
+ for (let i = 0; i < len; i++) {
48
+ const m = re.exec(s.values[i]);
49
+ if (!m) { out.values[i] = null; continue; }
50
+ const v = typeof g === "number" ? m[g] : m.groups ? m.groups[g] : undefined;
51
+ out.values[i] = v === undefined ? null : v;
52
+ }
53
+ };
54
+ })(),
55
+ },
56
+ // regexp_replace(s, pattern, replacement[, flags]) -> text: every match replaced;
57
+ // $1 and $<name> refer to groups (the native CLI accepts the same spelling)
58
+ {
59
+ name: "regexp_replace",
60
+ signature: { params: ["text", "text", "text", "text"], returns: "text", optional: 1 },
61
+ fn: (() => {
62
+ const cache = new Map();
63
+ return (args, len, out) => {
64
+ const [s, p, r] = args;
65
+ const flags = args[3] ? args[3].values[0] : "";
66
+ const key = p.values[0] + "\u0000" + flags;
67
+ let re = cache.get(key);
68
+ if (!re) cache.set(key, (re = new RegExp(p.values[0], flags.includes("g") ? flags : flags + "g")));
69
+ const repl = r.values[0];
70
+ for (let i = 0; i < len; i++) out.values[i] = s.values[i].replace(re, repl);
71
+ };
72
+ })(),
73
+ },
74
+ // --- JSON ---------------------------------------------------------------
75
+ // json_extract(doc, '$.a.b[0]') -> text (numbers/bools stringified, null for missing)
76
+ {
77
+ name: "json_extract",
78
+ signature: { params: ["text", "text"], returns: "text" },
79
+ fn: (() => {
80
+ const paths = new Map();
81
+ const parsePath = (p) => {
82
+ let keys = paths.get(p);
83
+ if (!keys) {
84
+ keys = [];
85
+ for (const m of p.replace(/^\$\.?/, "").matchAll(/([^.[\]]+)|\[(\d+)\]/g)) keys.push(m[2] !== undefined ? Number(m[2]) : m[1]);
86
+ paths.set(p, keys);
87
+ }
88
+ return keys;
89
+ };
90
+ return (args, len, out) => {
91
+ const [doc, path] = args;
92
+ const keys = parsePath(path.values[0]);
93
+ for (let i = 0; i < len; i++) {
94
+ let v;
95
+ try { v = JSON.parse(doc.values[i]); } catch { out.values[i] = null; continue; }
96
+ for (const k of keys) { if (v == null) break; v = v[k]; }
97
+ out.values[i] = v == null ? null : typeof v === "object" ? JSON.stringify(v) : String(v);
98
+ }
99
+ };
100
+ })(),
101
+ },
102
+ // --- time zones and the temporal long tail -------------------------------
103
+ // to_tz(ts, 'America/New_York') -> text "YYYY-MM-DD HH:MM:SS" in that zone (Intl's IANA tables)
104
+ {
105
+ name: "to_tz",
106
+ signature: { params: ["timestamp", "text"], returns: "text" },
107
+ fn: (() => {
108
+ const fmts = new Map();
109
+ return (args, len, out) => {
110
+ const [ts, tz] = args;
111
+ const zone = tz.values[0];
112
+ let f = fmts.get(zone);
113
+ if (!f) fmts.set(zone, (f = new Intl.DateTimeFormat("sv-SE", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })));
114
+ for (let i = 0; i < len; i++) out.values[i] = f.format(ts.values[i]);
115
+ };
116
+ })(),
117
+ },
118
+ // date_trunc('month', ts) -> timestamp at the start of the unit (UTC); units: year quarter month week day hour
119
+ {
120
+ name: "date_trunc",
121
+ signature: { params: ["text", "timestamp"], returns: "timestamp" },
122
+ fn: (args, len, out) => {
123
+ const [unit, ts] = args;
124
+ const u = unit.values[0];
125
+ for (let i = 0; i < len; i++) {
126
+ const d = new Date(ts.values[i]);
127
+ let r;
128
+ switch (u) {
129
+ case "year": r = Date.UTC(d.getUTCFullYear(), 0, 1); break;
130
+ case "quarter": r = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - (d.getUTCMonth() % 3), 1); break;
131
+ case "month": r = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); break;
132
+ case "week": { const dow = (d.getUTCDay() + 6) % 7; r = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow); break; } // Monday
133
+ case "day": r = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); break;
134
+ case "hour": r = ts.values[i] - (ts.values[i] % 3600000); break;
135
+ default: throw new Error(`date_trunc: unknown unit '${u}'`);
136
+ }
137
+ out.values[i] = r;
138
+ }
139
+ },
140
+ },
141
+ // date_add(d, n, 'month') -> date; units: day week month year (month arithmetic clamps to the month's end)
142
+ {
143
+ name: "date_add",
144
+ signature: { params: ["date", "int", "text"], returns: "date", perRow: true },
145
+ fn: (d, n, unit) => {
146
+ const t = new Date(d * DAY);
147
+ switch (unit) {
148
+ case "day": return d + n;
149
+ case "week": return d + 7 * n;
150
+ case "month": case "year": {
151
+ const months = unit === "year" ? 12 * n : n;
152
+ const y = t.getUTCFullYear(), m = t.getUTCMonth() + months, day = t.getUTCDate();
153
+ const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
154
+ return Date.UTC(y, m, Math.min(day, last)) / DAY;
155
+ }
156
+ default: throw new Error(`date_add: unknown unit '${unit}'`);
157
+ }
158
+ },
159
+ },
160
+ // weekday(d) -> int, 0 = Monday … 6 = Sunday; quarter(d) -> 1..4
161
+ { name: "weekday", signature: { params: ["date"], returns: "int" }, fn: (args, len, out) => { for (let i = 0; i < len; i++) out.values[i] = (new Date(args[0].values[i] * DAY).getUTCDay() + 6) % 7; } },
162
+ { name: "quarter", signature: { params: ["date"], returns: "int" }, fn: (args, len, out) => { for (let i = 0; i < len; i++) out.values[i] = Math.floor(new Date(args[0].values[i] * DAY).getUTCMonth() / 3) + 1; } },
163
+ // --- Intl tables -----------------------------------------------------------
164
+ // country_name('DE') -> 'Germany' (ISO 3166 alpha-2; optional locale second arg)
165
+ {
166
+ name: "country_name",
167
+ signature: { params: ["text", "text"], returns: "text", optional: 1 },
168
+ fn: (() => {
169
+ const names = new Map();
170
+ return (args, len, out) => {
171
+ const locale = args.length > 1 ? args[1].values[0] : "en";
172
+ let dn = names.get(locale);
173
+ if (!dn) names.set(locale, (dn = new Intl.DisplayNames([locale], { type: "region" })));
174
+ for (let i = 0; i < len; i++) {
175
+ const code = args[0].values[i].toUpperCase();
176
+ try { out.values[i] = /^[A-Z]{2}$/.test(code) ? dn.of(code) : null; } catch { out.values[i] = null; }
177
+ }
178
+ };
179
+ })(),
180
+ },
181
+ // format_number(x, 'en-US') -> '1,234,567.9' ; compact form via 'en-US:compact' -> '1.2M'
182
+ {
183
+ name: "format_number",
184
+ signature: { params: ["float", "text"], returns: "text" },
185
+ fn: (() => {
186
+ const fmts = new Map();
187
+ return (args, len, out) => {
188
+ const spec = args[1].values[0];
189
+ let f = fmts.get(spec);
190
+ if (!f) {
191
+ const [locale, style] = spec.split(":");
192
+ fmts.set(spec, (f = new Intl.NumberFormat(locale, style === "compact" ? { notation: "compact", maximumFractionDigits: 1 } : { maximumFractionDigits: 1 })));
193
+ }
194
+ for (let i = 0; i < len; i++) out.values[i] = f.format(args[0].values[i]);
195
+ };
196
+ })(),
197
+ },
198
+ // --- Unicode and URLs -------------------------------------------------------
199
+ // unaccent('Zürich') -> 'Zurich' : NFD then strip combining marks (\p{M} — the regex class wasm can't afford)
200
+ { name: "unaccent", signature: { params: ["text"], returns: "text" }, fn: (args, len, out) => { for (let i = 0; i < len; i++) out.values[i] = args[0].values[i].normalize("NFD").replace(/\p{M}+/gu, ""); } },
201
+ // url_host('https://www.eia.gov/x?y') -> 'www.eia.gov' (null when not a URL)
202
+ { name: "url_host", signature: { params: ["text"], returns: "text", perRow: true }, fn: (s) => { try { return new URL(s).hostname; } catch { return null; } } },
203
+ ];
204
+
205
+ export default udfs;
package/worker.js CHANGED
@@ -5,9 +5,12 @@
5
5
 
6
6
  import { instantiate, transferables, QueryError } from "./core.js";
7
7
  import { parquetToColumns } from "./parquet.js";
8
+ import { udfs as builtinUdfs } from "./udfs.js";
8
9
 
9
10
  let engine = null;
10
11
  const tables = new Map(); // name -> handle
12
+ // register under a name for FROM / JOIN from other tables' queries
13
+ const setTable = (name, handle) => { setTable(name, handle); engine.catalogRegister(name, handle); };
11
14
  let lastTable = null;
12
15
 
13
16
  // OPFS file registry: the wasm's opfs_read import addresses files by these ids.
@@ -77,7 +80,7 @@ async function opfsOpenTable(name, path, cacheBytes) {
77
80
  }
78
81
  try {
79
82
  const { handle, rows } = engine.openOpfsTable(fileId, h.getSize(), cacheBytes);
80
- tables.set(name, handle);
83
+ setTable(name, handle);
81
84
  lastTable = handle;
82
85
  return { rows, fileLen: h.getSize() };
83
86
  } catch (err) {
@@ -124,10 +127,13 @@ self.onmessage = async (e) => {
124
127
  if (e.data.hyparquetUrl) hyparquetUrl = e.data.hyparquetUrl;
125
128
  const wasmBytes = await (await fetch(e.data.wasmUrl)).arrayBuffer();
126
129
  engine = await instantiate(wasmBytes, opfsRead);
130
+ // the ready-made functions (udfs.js) are on by default: they cost
131
+ // nothing until a query calls one, and agents expect date_trunc to exist
132
+ if (e.data.udfs !== false) for (const u of builtinUdfs) engine.registerFunction(u.name, u.signature, u.fn);
127
133
  reply({ ok: true });
128
134
  } else if (cmd === "load") {
129
135
  const { handle, rows } = engine.openTable(e.data.buffer);
130
- tables.set(e.data.name, handle);
136
+ setTable(e.data.name, handle);
131
137
  lastTable = handle;
132
138
  reply({ ok: true, rows });
133
139
  } else if (cmd === "loadParquet") {
@@ -135,7 +141,7 @@ self.onmessage = async (e) => {
135
141
  const t0 = performance.now();
136
142
  const { rows, img } = await transcodeParquet(e.data.buffer);
137
143
  const { handle } = engine.openImage(img);
138
- tables.set(e.data.name, handle);
144
+ setTable(e.data.name, handle);
139
145
  lastTable = handle;
140
146
  reply({ ok: true, rows, transcodeMs: performance.now() - t0 });
141
147
  } else if (cmd === "openParquet") {
@@ -159,7 +165,7 @@ self.onmessage = async (e) => {
159
165
  imageBytes = engine.imageBytes(img);
160
166
  } catch { /* copy-out failed: open uncached */ }
161
167
  const { handle } = engine.openImage(img);
162
- tables.set(e.data.name, handle);
168
+ setTable(e.data.name, handle);
163
169
  lastTable = handle;
164
170
  if (imageBytes) {
165
171
  try {
@@ -168,6 +174,46 @@ self.onmessage = async (e) => {
168
174
  } catch { /* non-secure context or quota: stay memory-only */ }
169
175
  }
170
176
  reply({ ok: true, rows, source: "transcode", cached, transcodeMs });
177
+ } else if (cmd === "materialize") {
178
+ // derived table: run the query on the source table, compile its result
179
+ // into a new in-memory table under `name`; optionally persist the image
180
+ const src = e.data.table ? tables.get(e.data.table) : lastTable;
181
+ if (!src) throw new Error(`no table loaded${e.data.table ? `: '${e.data.table}'` : ""}`);
182
+ const t0 = performance.now();
183
+ const img = engine.materialize(src, e.data.sql);
184
+ let bytes = 0;
185
+ if (e.data.persist) {
186
+ const image = engine.imageBytes(img);
187
+ bytes = image.byteLength;
188
+ await opfsWrite(e.data.persist, image);
189
+ }
190
+ const { handle, rows } = engine.openImage(img);
191
+ setTable(e.data.name, handle);
192
+ reply({ ok: true, rows, bytes, elapsedMs: performance.now() - t0 });
193
+ } else if (cmd === "loadCsv") {
194
+ // a File/Blob streams twice through the converter; a buffer is one chunk
195
+ const src = e.data.source;
196
+ const chunks = typeof src.stream === "function"
197
+ ? () => src.stream()
198
+ : async function* () { yield new Uint8Array(src); };
199
+ const t0 = performance.now();
200
+ const { bytes, rows, schema } = await engine.convertCsv(chunks, { groupTarget: e.data.groupTarget });
201
+ if (e.data.persist) await opfsWrite(e.data.persist, bytes);
202
+ const { handle } = engine.openTable(bytes);
203
+ setTable(e.data.name, handle);
204
+ lastTable = handle;
205
+ reply({ ok: true, rows, bytes: bytes.byteLength, schema, elapsedMs: performance.now() - t0 });
206
+ } else if (cmd === "registerFunction") {
207
+ // functions don't cross postMessage: `source` is the function's text (an
208
+ // expression — an arrow function, or an IIFE returning one for state)
209
+ const fn = e.data.moduleUrl
210
+ ? (await import(e.data.moduleUrl)).default
211
+ : new Function(`return (${e.data.source});`)();
212
+ if (typeof fn !== "function") throw new Error(`registerFunction('${e.data.name}'): source is not a function`);
213
+ engine.registerFunction(e.data.name, e.data.signature, fn);
214
+ reply({ ok: true });
215
+ } else if (cmd === "unregisterFunction") {
216
+ reply({ ok: engine.unregisterFunction(e.data.name) });
171
217
  } else if (cmd === "storeOpfs") {
172
218
  await opfsWrite(e.data.path, new Uint8Array(e.data.buffer));
173
219
  reply({ ok: true, bytes: e.data.buffer.byteLength });