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 +131 -5
- package/bin/facetful.mjs +190 -0
- package/core.js +235 -4
- package/facetful_wasm.wasm +0 -0
- package/index.d.ts +61 -0
- package/index.js +50 -1
- package/package.json +16 -4
- package/udfs.d.ts +15 -0
- package/udfs.js +205 -0
- package/worker.js +50 -4
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
|
|
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,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`COUNT(DISTINCT x)`, `||`, `select
|
|
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,
|
package/bin/facetful.mjs
ADDED
|
@@ -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
|
|
14
|
-
// -> bytes read
|
|
15
|
-
// sync access handles; environments without OPFS (Node smoke test)
|
|
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
|
+
}
|
package/facetful_wasm.wasm
CHANGED
|
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.
|
|
4
|
-
"description": "Tiny columnar SQL engine for browser faceting — Rust/wasm, ~
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|