facetful 0.4.0 → 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
@@ -25,6 +25,22 @@ browser** — facet counts, pivots, top-k, filters — at interaction speed.
25
25
 
26
26
  ## Quickstart
27
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
+
28
44
  ```js
29
45
  import { Facetful } from "facetful";
30
46
 
@@ -53,13 +69,31 @@ The SQL table is always named `t`. Multiple tables coexist:
53
69
  | method | use when |
54
70
  |---|---|
55
71
  | `openParquet(name, buf)` | the default: Parquet in, OPFS-cached compiled image, instant repeat visits |
56
- | `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 |
57
74
  | `loadOpfs(name, path, {cacheBytes})` | the file is in OPFS; open lazily, spill-over for big data |
58
75
 
59
76
  Persistence is always explicit (`storeOpfs`), never write-behind. OPFS needs a
60
77
  secure context (https or localhost); everything degrades to memory-only
61
78
  without one.
62
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
+
63
97
  ## Derived tables: `materialize`
64
98
 
65
99
  ```js
@@ -104,6 +138,50 @@ for the join once. Correlated subqueries beyond `inner.k = outer.k` equalities,
104
138
  `NOT IN` follows SQL's NULL rule (a NULL in the set makes it select nothing);
105
139
  `NOT EXISTS` doesn't, and is usually what you mean.
106
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
+
107
185
  ## Parquet support
108
186
 
109
187
  Reading uses [hyparquet](https://github.com/hyparam/hyparquet) (~20 KB gz),
@@ -122,7 +200,8 @@ browser transcoder (the native CLI has no such limit).
122
200
  SELECT-only, SQLite semantics (3-valued logic, null-skipping aggregates,
123
201
  truncating integer division, NULL-first ascending sorts). Idioms: `IN`,
124
202
  `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,
203
+ `COUNT(DISTINCT x)`, `||`, `select *`, `JOIN`/`WITH`/subqueries and
204
+ user-defined functions as above. Aggregates: count, sum, avg, min, max,
126
205
  count(distinct), median, stddev, group_concat. Scalars: math (abs, round,
127
206
  floor, ceil, sqrt, pow, exp, ln, sign), text (lower, upper, length, substr,
128
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;
@@ -157,6 +296,59 @@ export class Engine {
157
296
  return { handle, rows: this.w.table_total_rows(handle) };
158
297
  }
159
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
+
160
352
  /** Make `handle` reachable by `name` from other tables' queries (FROM / JOIN). */
161
353
  catalogRegister(name, handle) {
162
354
  const b = this.enc.encode(name);
@@ -226,3 +418,9 @@ export function transferables(result) {
226
418
  }
227
419
  return t;
228
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;
@@ -104,6 +136,24 @@ export declare class Facetful {
104
136
  options?: { table?: string; persist?: string },
105
137
  ): Promise<{ rows: number; bytes: number; elapsedMs: number }>;
106
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 }>;
107
157
 
108
158
  /**
109
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
  }
@@ -85,6 +86,40 @@ export class Facetful {
85
86
  return this._call({ cmd: "materialize", name, sql, table, persist });
86
87
  }
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
+
88
123
  /** Persist a .facetful image into OPFS at `path` (e.g. "facetful/plants.facetful"). */
89
124
  async storeOpfs(path, buffer) {
90
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.4.0",
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,6 +5,7 @@
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
@@ -126,6 +127,9 @@ self.onmessage = async (e) => {
126
127
  if (e.data.hyparquetUrl) hyparquetUrl = e.data.hyparquetUrl;
127
128
  const wasmBytes = await (await fetch(e.data.wasmUrl)).arrayBuffer();
128
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);
129
133
  reply({ ok: true });
130
134
  } else if (cmd === "load") {
131
135
  const { handle, rows } = engine.openTable(e.data.buffer);
@@ -186,6 +190,30 @@ self.onmessage = async (e) => {
186
190
  const { handle, rows } = engine.openImage(img);
187
191
  setTable(e.data.name, handle);
188
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) });
189
217
  } else if (cmd === "storeOpfs") {
190
218
  await opfsWrite(e.data.path, new Uint8Array(e.data.buffer));
191
219
  reply({ ok: true, bytes: e.data.buffer.byteLength });