facetful 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Raznick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # facetful
2
+
3
+ A tiny columnar SQL engine for browser faceting. Rust compiled to ~150 KB gz
4
+ of WebAssembly, zero runtime dependencies, running in a dedicated worker.
5
+
6
+ Built for one job and fast at it: **exploring 100K–5M row datasets in the
7
+ browser** — facet counts, pivots, top-k, filters — at interaction speed.
8
+
9
+ - **Open Parquet instantly.** First visit transcodes once (seconds) and caches
10
+ the compiled image in OPFS; every visit after reopens in ~20–40 ms with zero
11
+ decode. Measured on a real 183K×52 dataset: facet queries 1–4 ms.
12
+ - **SELECT-only SQL**, SQLite-flavored semantics, verified cell-for-cell
13
+ against SQLite by a differential test suite. Friendly errors with carets and
14
+ did-you-mean hints.
15
+ - **Larger-than-memory**: tables can live in OPFS and load column segments
16
+ lazily through a byte-budgeted LRU cache.
17
+ - Dates, medians, stddev, group_concat, `select *`, LIKE fast paths — the
18
+ boring things work.
19
+
20
+ ## Quickstart
21
+
22
+ ```js
23
+ import { Facetful } from "facetful";
24
+
25
+ const db = await Facetful.open();
26
+ const buf = await (await fetch("plants.parquet")).arrayBuffer();
27
+ await db.openParquet("plants", buf); // transcode once, OPFS-cached thereafter
28
+
29
+ const r = await db.query(`
30
+ select "Country/area", count(*) as n, round(sum("Capacity (MW)"), 1) as mw
31
+ from t
32
+ where "Status" = 'operating'
33
+ group by "Country/area"
34
+ order by mw desc limit 15
35
+ `);
36
+
37
+ for (const row of r.rows()) console.log(row);
38
+ r.columnRaw("mw"); // Float64Array + validity bitmap, near-zero copy (charts)
39
+ console.log(r.elapsedMs, r.stats); // ms in worker, row groups pruned
40
+ ```
41
+
42
+ The SQL table is always named `t`. Multiple tables coexist:
43
+ `db.query(sql, { table: "plants" })`.
44
+
45
+ ## The three ways in
46
+
47
+ | method | use when |
48
+ |---|---|
49
+ | `openParquet(name, buf)` | the default: Parquet in, OPFS-cached compiled image, instant repeat visits |
50
+ | `load(name, buf)` | you already have a `.facetful` image (built by the native CLI) |
51
+ | `loadOpfs(name, path, {cacheBytes})` | the file is in OPFS; open lazily, spill-over for big data |
52
+
53
+ Persistence is always explicit (`storeOpfs`), never write-behind. OPFS needs a
54
+ secure context (https or localhost); everything degrades to memory-only
55
+ without one.
56
+
57
+ ## Parquet support
58
+
59
+ Reading uses [hyparquet](https://github.com/hyparam/hyparquet) (~20 KB gz),
60
+ declared as an optional peer dependency and loaded dynamically only when a
61
+ Parquet method is called. Under a bundler, `npm install hyparquet` is enough;
62
+ without one, pass `hyparquetUrl` to `Facetful.open`.
63
+
64
+ Flat schemas only (no nested/repeated columns). BOOLEAN/INT32/INT64 →
65
+ integers (narrowed), FLOAT/DOUBLE → float64, strings → dictionary-encoded when
66
+ it pays, DATE/TIMESTAMP → real date/timestamp columns (days / ms since epoch,
67
+ ISO strings on output). Int64 values beyond 2^53 lose precision in the
68
+ browser transcoder (the native CLI has no such limit).
69
+
70
+ ## SQL dialect, briefly
71
+
72
+ SELECT-only, single table, SQLite semantics (3-valued logic, null-skipping
73
+ aggregates, truncating integer division, NULL-first ascending sorts).
74
+ Idioms: `IN`, `BETWEEN`, `IS [NOT] NULL`, `[NOT] LIKE`, `CASE WHEN`, `CAST`,
75
+ `COUNT(DISTINCT x)`, `||`, `select *`. Aggregates: count, sum, avg, min, max,
76
+ count(distinct), median, stddev, group_concat. Scalars: math (abs, round,
77
+ floor, ceil, sqrt, pow, exp, ln, sign), text (lower, upper, length, substr,
78
+ trim/ltrim/rtrim, replace, instr, concat), null handling (coalesce, ifnull,
79
+ nullif), temporal (year, month, day, hour, minute, second, date, timestamp,
80
+ strftime). Quote column names with spaces: `"Capacity (MW)"`.
81
+
82
+ ## Building the wasm from source
83
+
84
+ The engine lives in the same repository (Rust workspace, zero dependencies).
85
+ `scripts/build-package.sh` builds the wasm (with wasm-opt when available),
86
+ copies it next to this package, and runs `npm pack`.
87
+
88
+ ## License
89
+
90
+ MIT
package/core.js ADDED
@@ -0,0 +1,183 @@
1
+ // Environment-agnostic marshalling over the facetful wasm exports.
2
+ // Used by worker.js in the browser and driven directly by the Node smoke test.
3
+
4
+ const KINDS = { 1: "int", 2: "float", 3: "bool", 4: "text", 5: "date", 6: "timestamp" };
5
+
6
+ // The wasm module declares one import: env.opfs_read(fileId, offset, len, destPtr)
7
+ // -> bytes read. The browser worker supplies a real implementation over OPFS
8
+ // sync access handles; environments without OPFS (Node smoke test) get a stub
9
+ // that fails any read (memory-backed tables never call it).
10
+ export async function instantiate(wasmBytes, opfsRead) {
11
+ let engine = null;
12
+ const env = {
13
+ opfs_read: (fileId, offset, len, destPtr) => {
14
+ if (!opfsRead || !engine) return -1;
15
+ // the view must be built per call: memory.buffer detaches on growth
16
+ return opfsRead(fileId, offset, new Uint8Array(engine.mem(), destPtr, len));
17
+ },
18
+ };
19
+ const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
20
+ engine = new Engine(instance.exports);
21
+ return engine;
22
+ }
23
+
24
+ export class Engine {
25
+ constructor(exports) {
26
+ this.w = exports;
27
+ this.scratch = this.w.alloc(4096);
28
+ this.enc = new TextEncoder();
29
+ this.dec = new TextDecoder();
30
+ }
31
+ mem() {
32
+ return this.w.memory.buffer;
33
+ }
34
+
35
+ /** Open a .facetful image from bytes; returns a table handle. */
36
+ openTable(bytes) {
37
+ const src = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
38
+ const ptr = this.w.alloc(src.byteLength);
39
+ new Uint8Array(this.mem(), ptr, src.byteLength).set(src);
40
+ const handle = this.w.table_open(ptr, src.byteLength);
41
+ if (!handle) throw new Error("not a valid .facetful image");
42
+ return { handle, rows: this.w.table_total_rows(handle) };
43
+ }
44
+
45
+ /** Open a table backed by a registered OPFS file (reads go through opfs_read). */
46
+ openOpfsTable(fileId, fileLen, cacheBytes) {
47
+ const handle = this.w.table_open_opfs(fileId, fileLen, cacheBytes ?? 0);
48
+ if (!handle) throw new Error("not a valid .facetful image (OPFS)");
49
+ return { handle, rows: this.w.table_total_rows(handle) };
50
+ }
51
+
52
+ colByName(tableHandle, name) {
53
+ const b = this.enc.encode(name);
54
+ new Uint8Array(this.mem(), this.scratch, b.byteLength).set(b);
55
+ return this.w.table_col_by_name(tableHandle, this.scratch, b.byteLength);
56
+ }
57
+
58
+ /** Pre-touch one column's segments into the cache; returns bytes read. */
59
+ warmColumn(tableHandle, colIdx) {
60
+ return this.w.table_warm(tableHandle, colIdx);
61
+ }
62
+
63
+ /** { segments, bytes } currently cached. */
64
+ cacheStats(tableHandle) {
65
+ const packed = this.w.table_cache_stats(tableHandle);
66
+ return { segments: Number(packed >> 32n), bytes: Number(packed & 0xffffffffn) * 1024 };
67
+ }
68
+
69
+ /**
70
+ * Compile typed columns into a .facetful image (the baseline compiler —
71
+ * same Rust code path as the native CLI). Columns:
72
+ * { name, kind: "num", data: Float64Array, isInt?, temporal?: "date"|"timestamp",
73
+ * validity?: Uint8Array } // temporal: data = days / ms since epoch
74
+ * { name, kind: "text", offsets: Uint32Array, bytes: Uint8Array, validity? }
75
+ * validity is one byte per row, 0 = null. Returns an image handle; pass it
76
+ * to imageBytes() (copy out, e.g. for OPFS) and/or openImage() (consumes).
77
+ */
78
+ compileTable(rows, columns, { groupTarget = 65536 } = {}) {
79
+ const put = (src) => {
80
+ const p = this.w.alloc(src.byteLength);
81
+ new Uint8Array(this.mem(), p, src.byteLength).set(
82
+ new Uint8Array(src.buffer, src.byteOffset, src.byteLength),
83
+ );
84
+ return p;
85
+ };
86
+ const b = this.w.compile_begin(rows);
87
+ for (const c of columns) {
88
+ const nameB = this.enc.encode(c.name);
89
+ const nameP = put(nameB);
90
+ const validP = c.validity ? put(c.validity) : 0;
91
+ if (c.kind === "num") {
92
+ const dataP = put(c.data);
93
+ const numKind =
94
+ c.temporal === "date" ? 2 : c.temporal === "timestamp" ? 3 : c.isInt ? 1 : 0;
95
+ this.w.compile_add_num(b, nameP, nameB.byteLength, dataP, validP, numKind);
96
+ this.w.dealloc(dataP, c.data.byteLength);
97
+ } else {
98
+ const offP = put(c.offsets);
99
+ const bytesP = put(c.bytes);
100
+ this.w.compile_add_text(b, nameP, nameB.byteLength, offP, bytesP, c.bytes.byteLength, validP);
101
+ this.w.dealloc(offP, c.offsets.byteLength);
102
+ this.w.dealloc(bytesP, c.bytes.byteLength);
103
+ }
104
+ this.w.dealloc(nameP, nameB.byteLength);
105
+ if (validP) this.w.dealloc(validP, c.validity.byteLength);
106
+ }
107
+ const img = this.w.compile_finish(b, groupTarget);
108
+ if (!img) throw new Error("compile failed (mismatched column lengths?)");
109
+ return img;
110
+ }
111
+
112
+ /** Copy a compiled image's bytes out (for OPFS persistence). */
113
+ imageBytes(img) {
114
+ return new Uint8Array(this.mem(), this.w.image_ptr(img), this.w.image_len(img)).slice();
115
+ }
116
+
117
+ /** Open a table over a compiled image; consumes the image handle (no copy). */
118
+ openImage(img) {
119
+ const handle = this.w.image_open_table(img);
120
+ if (!handle) throw new Error("compiled image failed to open");
121
+ return { handle, rows: this.w.table_total_rows(handle) };
122
+ }
123
+
124
+ /** Run SQL; returns { columns, rowCount, stats } with copied-out buffers. */
125
+ query(tableHandle, sql) {
126
+ const sqlBytes = this.enc.encode(sql);
127
+ const sqlPtr = this.w.alloc(sqlBytes.byteLength);
128
+ new Uint8Array(this.mem(), sqlPtr, sqlBytes.byteLength).set(sqlBytes);
129
+ const h = this.w.query_run(tableHandle, sqlPtr, sqlBytes.byteLength);
130
+ try {
131
+ if (this.w.outcome_is_err(h)) {
132
+ const n = this.w.outcome_error(h, this.scratch, 4096);
133
+ throw new QueryError(this.dec.decode(new Uint8Array(this.mem(), this.scratch, n)));
134
+ }
135
+ const rowCount = this.w.outcome_rows(h);
136
+ const nCols = this.w.outcome_cols(h);
137
+ const stats = this.w.outcome_scan_stats(h);
138
+ const columns = [];
139
+ for (let i = 0; i < nCols; i++) {
140
+ const kind = KINDS[this.w.col_kind(h, i)];
141
+ const nameLen = this.w.col_name(h, i, this.scratch, 4096);
142
+ const name = this.dec.decode(new Uint8Array(this.mem(), this.scratch, nameLen));
143
+ const validity = new Uint8Array(
144
+ this.mem(), this.w.col_validity_ptr(h, i), Math.ceil(rowCount / 8),
145
+ ).slice();
146
+ const col = { name, kind, validity };
147
+ if (kind === "int" || kind === "float" || kind === "date" || kind === "timestamp") {
148
+ col.values = new Float64Array(this.mem(), this.w.col_f64_ptr(h, i), rowCount).slice();
149
+ } else if (kind === "bool") {
150
+ col.values = new Uint8Array(this.mem(), this.w.col_bools_ptr(h, i), rowCount).slice();
151
+ } else {
152
+ col.offsets = new Uint32Array(this.mem(), this.w.col_offsets_ptr(h, i), rowCount + 1).slice();
153
+ col.bytes = new Uint8Array(
154
+ this.mem(), this.w.col_bytes_ptr(h, i), this.w.col_bytes_len(h, i),
155
+ ).slice();
156
+ }
157
+ columns.push(col);
158
+ }
159
+ return {
160
+ columns,
161
+ rowCount,
162
+ stats: { scannedGroups: Number(stats & 0xffffffffn), totalGroups: Number(stats >> 32n) },
163
+ };
164
+ } finally {
165
+ this.w.outcome_free(h);
166
+ }
167
+ }
168
+ }
169
+
170
+ export class QueryError extends Error {}
171
+
172
+ /** Transferable list for a query payload (zero-copy postMessage). */
173
+ export function transferables(result) {
174
+ const t = [];
175
+ for (const c of result.columns) {
176
+ t.push(c.validity.buffer);
177
+ if (c.values) t.push(c.values.buffer);
178
+ if (c.offsets) {
179
+ t.push(c.offsets.buffer, c.bytes.buffer);
180
+ }
181
+ }
182
+ return t;
183
+ }
Binary file
package/index.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Type definitions for facetful — the main-thread API (index.js).
2
+ // The engine runs in a dedicated worker; all methods are asynchronous.
3
+
4
+ export type ColumnKind = "int" | "float" | "bool" | "text" | "date" | "timestamp";
5
+
6
+ export interface OpenOptions {
7
+ /** URL of the engine wasm. Default: facetful_wasm.wasm next to the package. */
8
+ wasmUrl?: string | URL;
9
+ /** URL of the worker module. Default: worker.js next to the package. */
10
+ workerUrl?: string | URL;
11
+ /**
12
+ * Module specifier or URL for hyparquet (needed only by the Parquet
13
+ * methods). Default "hyparquet" — resolved by your bundler; pass an
14
+ * explicit URL when running without one.
15
+ */
16
+ hyparquetUrl?: string;
17
+ }
18
+
19
+ export interface QueryStats {
20
+ /** Row groups actually scanned (min/max pruning and early exit skip the rest). */
21
+ scannedGroups: number;
22
+ totalGroups: number;
23
+ }
24
+
25
+ export interface RawColumn {
26
+ name: string;
27
+ kind: ColumnKind;
28
+ /** Bitmap, bit i set = row i is non-null. */
29
+ validity: Uint8Array;
30
+ /** int/float/date/timestamp: f64 lanes (date = days since epoch, timestamp = ms). bool: 0/1 bytes. */
31
+ values?: Float64Array | Uint8Array;
32
+ /** text only: rowCount+1 byte offsets into `bytes`. */
33
+ offsets?: Uint32Array;
34
+ /** text only: UTF-8 blob. */
35
+ bytes?: Uint8Array;
36
+ }
37
+
38
+ export type CellValue = number | string | boolean | null;
39
+
40
+ export declare class Result {
41
+ columns: { name: string; kind: ColumnKind }[];
42
+ rowCount: number;
43
+ stats: QueryStats;
44
+ /** Execution time inside the worker, ms (excludes the message hop). */
45
+ elapsedMs: number;
46
+ /** Raw transferred buffers for a column — near-zero copy, ideal for charts. */
47
+ columnRaw(name: string): RawColumn;
48
+ /** Materialized values with nulls; date/timestamp as ISO strings. */
49
+ column(name: string): CellValue[];
50
+ /** Row objects, materialized lazily. */
51
+ rows(): Generator<Record<string, CellValue>>;
52
+ }
53
+
54
+ export interface LoadResult {
55
+ name: string;
56
+ rows: number;
57
+ }
58
+
59
+ export interface OpenParquetResult {
60
+ rows: number;
61
+ /** "cache" = compiled image reopened from OPFS (no transcode); "transcode" = first visit. */
62
+ source: "cache" | "transcode";
63
+ /** Present when source = "cache". */
64
+ openMs?: number;
65
+ /** Present when source = "transcode". */
66
+ transcodeMs?: number;
67
+ /** Whether the compiled image was persisted to OPFS for next time. */
68
+ cached?: boolean;
69
+ }
70
+
71
+ export declare class Facetful {
72
+ /** Start the engine (spawns the worker, instantiates wasm). */
73
+ static open(options?: OpenOptions): Promise<Facetful>;
74
+
75
+ /** Load a .facetful image from an ArrayBuffer (transferred). */
76
+ load(name: string, buffer: ArrayBuffer): Promise<LoadResult>;
77
+
78
+ /**
79
+ * Open a Parquet file (ArrayBuffer, transferred). The compiled image is
80
+ * cached in OPFS keyed by content hash + format version: repeat visits
81
+ * reopen in ~tens of ms with no transcode. Requires hyparquet (see
82
+ * OpenOptions.hyparquetUrl) and a secure context for the OPFS cache
83
+ * (degrades to memory-only otherwise).
84
+ */
85
+ openParquet(
86
+ name: string,
87
+ buffer: ArrayBuffer,
88
+ options?: { cacheBytes?: number },
89
+ ): Promise<OpenParquetResult>;
90
+
91
+ /** Transcode-only variant: Parquet -> in-memory table, no OPFS. */
92
+ loadParquet(name: string, buffer: ArrayBuffer): Promise<{ rows: number; transcodeMs: number }>;
93
+
94
+ /** Persist a .facetful image into OPFS at `path` (buffer transferred). */
95
+ storeOpfs(path: string, buffer: ArrayBuffer): Promise<{ bytes: number }>;
96
+
97
+ /**
98
+ * Open a table over an OPFS file: metadata reads now, column segments load
99
+ * lazily into an LRU bounded by cacheBytes (default min(deviceMemory/4, 1GB)).
100
+ */
101
+ loadOpfs(
102
+ name: string,
103
+ path: string,
104
+ options?: { cacheBytes?: number },
105
+ ): Promise<{ name: string; rows: number; fileLen: number; cacheBytes: number }>;
106
+
107
+ /** Delete an OPFS file (closes any open handles on it first). */
108
+ removeOpfs(path: string): Promise<void>;
109
+
110
+ /** Pre-touch columns into the segment cache; queries interleave with warming. */
111
+ warm(cols: string[], options?: { table?: string }): Promise<{ bytes: number }>;
112
+
113
+ /** Current segment-cache occupancy for a table. */
114
+ cacheStats(options?: { table?: string }): Promise<{ segments: number; bytes: number }>;
115
+
116
+ /**
117
+ * Run SQL (SELECT-only; the table is always `t`). `table` picks a loaded
118
+ * table by name, defaulting to the most recently loaded. Rejects with an
119
+ * Error whose message is a rendered diagnostic (caret + hint) on SQL errors.
120
+ */
121
+ query(sql: string, options?: { table?: string }): Promise<Result>;
122
+
123
+ /** Terminate the worker. */
124
+ close(): void;
125
+ }
package/index.js ADDED
@@ -0,0 +1,181 @@
1
+ // facetful — main-thread API. The engine runs in a dedicated worker; results
2
+ // arrive as transferable column buffers and are wrapped for convenience here.
3
+ //
4
+ // const db = await Facetful.open({ wasmUrl });
5
+ // await db.load("plants", await (await fetch("plants.facetful")).arrayBuffer());
6
+ // const r = await db.query("select country, count(*) n from t group by country order by n desc");
7
+ // r.column("country") // Array<string|null> (materialized on demand)
8
+ // r.columnRaw("n") // { values: Float64Array, validity } — near-zero copy
9
+ // [...r.rows()] // row objects, materialized lazily
10
+
11
+ export class Facetful {
12
+ static async open({ wasmUrl, workerUrl, hyparquetUrl } = {}) {
13
+ // the no-argument form must stay a literal `new Worker(new URL(...))`
14
+ // expression: bundlers (Vite, webpack) statically analyze exactly that
15
+ // pattern to compile the worker graph
16
+ const worker = workerUrl
17
+ ? new Worker(workerUrl, { type: "module" })
18
+ : new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
19
+ const db = new Facetful(worker);
20
+ await db._call({
21
+ cmd: "init",
22
+ wasmUrl: String(wasmUrl ?? new URL("./facetful_wasm.wasm", import.meta.url)),
23
+ hyparquetUrl,
24
+ });
25
+ return db;
26
+ }
27
+
28
+ constructor(worker) {
29
+ this._worker = worker;
30
+ this._pending = new Map();
31
+ this._nextId = 1;
32
+ worker.onmessage = (e) => {
33
+ const { id, ...msg } = e.data;
34
+ const p = this._pending.get(id);
35
+ if (!p) return;
36
+ this._pending.delete(id);
37
+ if (msg.error) p.reject(new Error(msg.error));
38
+ else p.resolve(msg);
39
+ };
40
+ }
41
+
42
+ _call(msg, transfer = []) {
43
+ const id = this._nextId++;
44
+ return new Promise((resolve, reject) => {
45
+ this._pending.set(id, { resolve, reject });
46
+ this._worker.postMessage({ id, ...msg }, transfer);
47
+ });
48
+ }
49
+
50
+ /** Load a .facetful image (ArrayBuffer). Registered under `name`. */
51
+ async load(name, buffer) {
52
+ const { rows } = await this._call({ cmd: "load", name, buffer }, [buffer]);
53
+ return { name, rows };
54
+ }
55
+
56
+ /**
57
+ * Open a Parquet file (ArrayBuffer) — the headline path. The image compiled
58
+ * from it is cached in OPFS keyed by content hash + format version, so a
59
+ * repeat visit with the same file reopens zero-decode without transcoding.
60
+ * Returns { rows, source: "cache" | "transcode", ... timings }.
61
+ */
62
+ async openParquet(name, buffer, { cacheBytes } = {}) {
63
+ return this._call(
64
+ { cmd: "openParquet", name, buffer, cacheBytes: cacheBytes ?? defaultCacheBudget() },
65
+ [buffer],
66
+ );
67
+ }
68
+
69
+ /** Transcode-only variant: Parquet -> in-memory table, no OPFS involved. */
70
+ async loadParquet(name, buffer) {
71
+ return this._call({ cmd: "loadParquet", name, buffer }, [buffer]);
72
+ }
73
+
74
+ /** Persist a .facetful image into OPFS at `path` (e.g. "facetful/plants.facetful"). */
75
+ async storeOpfs(path, buffer) {
76
+ return this._call({ cmd: "storeOpfs", path, buffer }, [buffer]);
77
+ }
78
+
79
+ /**
80
+ * Open a table over an OPFS file — the spill-over path. Only metadata is
81
+ * read up front; column segments load on demand into an LRU cache bounded
82
+ * by `cacheBytes` (default: min(deviceMemory/4, 1GB)).
83
+ */
84
+ async loadOpfs(name, path, { cacheBytes } = {}) {
85
+ const budget = cacheBytes ?? defaultCacheBudget();
86
+ const { rows, fileLen } = await this._call({ cmd: "loadOpfs", name, path, cacheBytes: budget });
87
+ return { name, rows, fileLen, cacheBytes: budget };
88
+ }
89
+
90
+ async removeOpfs(path) {
91
+ return this._call({ cmd: "removeOpfs", path });
92
+ }
93
+
94
+ /** Pre-touch columns into the cache (background; queries interleave). */
95
+ async warm(cols, { table } = {}) {
96
+ const { bytes } = await this._call({ cmd: "warm", cols, table });
97
+ return { bytes };
98
+ }
99
+
100
+ /** { segments, bytes } currently held by a table's segment cache. */
101
+ async cacheStats({ table } = {}) {
102
+ const { segments, bytes } = await this._call({ cmd: "cacheStats", table });
103
+ return { segments, bytes };
104
+ }
105
+
106
+ /** Run SQL. `table` selects a loaded table (defaults to the last loaded). */
107
+ async query(sql, { table } = {}) {
108
+ const { result } = await this._call({ cmd: "query", sql, table });
109
+ return new Result(result);
110
+ }
111
+
112
+ close() {
113
+ this._worker.terminate();
114
+ }
115
+ }
116
+
117
+ // Cache budget when the caller doesn't set one: a quarter of device memory,
118
+ // capped at 1GB. navigator.deviceMemory is Chromium-only; assume 4GB elsewhere.
119
+ function defaultCacheBudget() {
120
+ const gb = (typeof navigator !== "undefined" && navigator.deviceMemory) || 4;
121
+ return Math.min((gb / 4) * 1024 ** 3, 1024 ** 3);
122
+ }
123
+
124
+ const dec = new TextDecoder();
125
+
126
+ export class Result {
127
+ constructor(r) {
128
+ this.columns = r.columns.map((c) => ({ name: c.name, kind: c.kind }));
129
+ this.rowCount = r.rowCount;
130
+ this.stats = r.stats;
131
+ this.elapsedMs = r.elapsedMs;
132
+ this._cols = r.columns;
133
+ }
134
+
135
+ _find(name) {
136
+ const c = this._cols.find((c) => c.name === name);
137
+ if (!c) throw new Error(`no result column '${name}'`);
138
+ return c;
139
+ }
140
+
141
+ /** Raw buffers: { kind, values | offsets+bytes, validity }. */
142
+ columnRaw(name) {
143
+ return this._find(name);
144
+ }
145
+
146
+ /** Materialized values with nulls, in row order. */
147
+ column(name) {
148
+ const c = this._find(name);
149
+ const out = new Array(this.rowCount);
150
+ for (let i = 0; i < this.rowCount; i++) {
151
+ out[i] = cellValue(c, i);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ *rows() {
157
+ for (let i = 0; i < this.rowCount; i++) {
158
+ const o = {};
159
+ for (const c of this._cols) o[c.name] = cellValue(c, i);
160
+ yield o;
161
+ }
162
+ }
163
+ }
164
+
165
+ function cellValue(c, i) {
166
+ if ((c.validity[i >> 3] & (1 << (i & 7))) === 0) return null;
167
+ switch (c.kind) {
168
+ case "int":
169
+ return c.values[i];
170
+ case "float":
171
+ return c.values[i];
172
+ case "bool":
173
+ return c.values[i] !== 0;
174
+ case "date": // days since epoch -> "YYYY-MM-DD"
175
+ return new Date(c.values[i] * 86400000).toISOString().slice(0, 10);
176
+ case "timestamp": // ms since epoch -> ISO, UTC
177
+ return new Date(c.values[i]).toISOString().replace("T", " ").slice(0, 19);
178
+ default:
179
+ return dec.decode(c.bytes.subarray(c.offsets[i], c.offsets[i + 1]));
180
+ }
181
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "facetful",
3
+ "version": "0.1.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.",
5
+ "license": "MIT",
6
+ "author": "David Raznick",
7
+ "type": "module",
8
+ "main": "./index.js",
9
+ "types": "./index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "default": "./index.js"
14
+ },
15
+ "./core": "./core.js",
16
+ "./worker": "./worker.js",
17
+ "./parquet": "./parquet.js",
18
+ "./facetful_wasm.wasm": "./facetful_wasm.wasm"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "index.d.ts",
23
+ "core.js",
24
+ "worker.js",
25
+ "parquet.js",
26
+ "facetful_wasm.wasm",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "peerDependencies": {
31
+ "hyparquet": ">=1.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "hyparquet": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "keywords": [
39
+ "wasm",
40
+ "sql",
41
+ "columnar",
42
+ "parquet",
43
+ "opfs",
44
+ "facets",
45
+ "analytics",
46
+ "browser"
47
+ ],
48
+ "sideEffects": false,
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/kindly/facetful.git",
52
+ "directory": "js/facetful"
53
+ },
54
+ "homepage": "https://github.com/kindly/facetful#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/kindly/facetful/issues"
57
+ }
58
+ }
package/parquet.js ADDED
@@ -0,0 +1,104 @@
1
+ // Parquet -> compiler input columns, via a caller-supplied hyparquet module.
2
+ // Shared by the worker (browser) and the node differential test; keeping it
3
+ // environment-free is what makes the "parquet path === image path" check
4
+ // runnable headlessly.
5
+
6
+ const enc = new TextEncoder();
7
+
8
+ /** Read a Parquet buffer into compiler input columns. */
9
+ export async function parquetToColumns(h, buffer) {
10
+ const meta = await h.parquetMetadataAsync(buffer);
11
+ const rows = Number(meta.num_rows);
12
+ const root = meta.schema[0];
13
+ const elems = meta.schema.slice(1, 1 + Number(root.num_children));
14
+ for (const e of elems) {
15
+ if (e.num_children) {
16
+ throw new Error(`nested parquet column '${e.name}' is not supported`);
17
+ }
18
+ }
19
+ const isText = (e) => e.type === "BYTE_ARRAY";
20
+ const isInt = (e) => e.type === "INT32" || e.type === "INT64" || e.type === "BOOLEAN";
21
+ const temporalOf = (e) => {
22
+ const lt = e.logical_type?.type;
23
+ const ct = e.converted_type;
24
+ if (lt === "DATE" || ct === "DATE") return "date";
25
+ if (lt === "TIMESTAMP" || ct === "TIMESTAMP_MILLIS" || ct === "TIMESTAMP_MICROS") {
26
+ return "timestamp";
27
+ }
28
+ return undefined;
29
+ };
30
+
31
+ // builders per column
32
+ const byName = new Map();
33
+ for (const e of elems) {
34
+ const b = { elem: e, validity: new Uint8Array(rows).fill(1), anyNull: false };
35
+ if (isText(e)) b.strs = new Array(rows).fill("");
36
+ else b.nums = new Float64Array(rows);
37
+ byName.set(e.name, b);
38
+ }
39
+
40
+ await h.parquetRead({
41
+ file: buffer,
42
+ metadata: meta,
43
+ columns: elems.map((e) => e.name),
44
+ onChunk({ columnName, columnData, rowStart }) {
45
+ const b = byName.get(columnName);
46
+ if (!b) return;
47
+ if (b.strs) {
48
+ for (let i = 0; i < columnData.length; i++) {
49
+ const v = columnData[i];
50
+ if (v == null) {
51
+ b.validity[rowStart + i] = 0;
52
+ b.anyNull = true;
53
+ } else {
54
+ b.strs[rowStart + i] = String(v);
55
+ }
56
+ }
57
+ } else {
58
+ for (let i = 0; i < columnData.length; i++) {
59
+ const v = columnData[i];
60
+ if (v == null) {
61
+ b.validity[rowStart + i] = 0;
62
+ b.anyNull = true;
63
+ } else {
64
+ // BigInt (INT64), Date (DATE/TIMESTAMP -> ms), boolean all coerce
65
+ b.nums[rowStart + i] =
66
+ typeof v === "boolean" ? (v ? 1 : 0) : v instanceof Date ? v.getTime() : Number(v);
67
+ }
68
+ }
69
+ }
70
+ },
71
+ onComplete() {},
72
+ });
73
+
74
+ return {
75
+ rows,
76
+ columns: elems.map((e) => {
77
+ const b = byName.get(e.name);
78
+ const validity = b.anyNull ? b.validity : undefined;
79
+ if (b.strs) {
80
+ const offsets = new Uint32Array(rows + 1);
81
+ const parts = new Array(rows);
82
+ let total = 0;
83
+ for (let i = 0; i < rows; i++) {
84
+ parts[i] = enc.encode(b.strs[i]);
85
+ total += parts[i].byteLength;
86
+ offsets[i + 1] = total;
87
+ }
88
+ const bytes = new Uint8Array(total);
89
+ for (let i = 0; i < rows; i++) bytes.set(parts[i], offsets[i]);
90
+ return { name: e.name, kind: "text", offsets, bytes, validity };
91
+ }
92
+ // int-vs-float comes from the parquet physical type, never from values
93
+ // (a DOUBLE column of integral values must stay Float64, like the CLI);
94
+ // DATE/TIMESTAMP become real temporal columns (days / ms since epoch)
95
+ const temporal = temporalOf(e);
96
+ if (temporal === "date") {
97
+ // hyparquet yields Date objects at UTC midnight; store days
98
+ for (let i = 0; i < rows; i++) b.nums[i] = Math.round(b.nums[i] / 86400000);
99
+ }
100
+ return { name: e.name, kind: "num", data: b.nums, isInt: isInt(e), temporal, validity };
101
+ }),
102
+ };
103
+ }
104
+
package/worker.js ADDED
@@ -0,0 +1,214 @@
1
+ // The engine's dedicated worker: owns the wasm instance, all tables, and the
2
+ // OPFS sync access handles (createSyncAccessHandle only exists in workers).
3
+ // Protocol: {id, cmd, ...} in, {id, ok|error, ...} out; query column buffers
4
+ // are transferred, not cloned.
5
+
6
+ import { instantiate, transferables, QueryError } from "./core.js";
7
+ import { parquetToColumns } from "./parquet.js";
8
+
9
+ let engine = null;
10
+ const tables = new Map(); // name -> handle
11
+ let lastTable = null;
12
+
13
+ // OPFS file registry: the wasm's opfs_read import addresses files by these ids.
14
+ const opfsFiles = new Map(); // fileId -> { handle: FileSystemSyncAccessHandle, path }
15
+ let nextFileId = 1;
16
+
17
+ function opfsRead(fileId, offset, dest) {
18
+ const e = opfsFiles.get(fileId);
19
+ if (!e) return -1;
20
+ return e.handle.read(dest, { at: offset });
21
+ }
22
+
23
+ // Sync access handles are exclusive locks; deleting or rewriting a path
24
+ // requires closing ours first. Any table still reading through a closed
25
+ // handle gets clean read errors rather than corrupt data.
26
+ function closeHandlesFor(path) {
27
+ for (const [id, e] of opfsFiles) {
28
+ if (e.path === path) {
29
+ try { e.handle.close(); } catch { /* already closed */ }
30
+ opfsFiles.delete(id);
31
+ }
32
+ }
33
+ }
34
+
35
+ async function opfsDir(path, create) {
36
+ let dir = await navigator.storage.getDirectory();
37
+ const parts = path.split("/").filter(Boolean);
38
+ const file = parts.pop();
39
+ for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
40
+ return { dir, file };
41
+ }
42
+
43
+ async function opfsWrite(path, bytes) {
44
+ closeHandlesFor(path);
45
+ const { dir, file } = await opfsDir(path, true);
46
+ const fh = await dir.getFileHandle(file, { create: true });
47
+ const h = await fh.createSyncAccessHandle();
48
+ try {
49
+ h.truncate(0);
50
+ h.write(bytes, { at: 0 });
51
+ h.flush();
52
+ } finally {
53
+ h.close();
54
+ }
55
+ }
56
+
57
+ /** Open an OPFS-backed table lazily (metadata now, segments on demand). */
58
+ async function opfsOpenTable(name, path, cacheBytes) {
59
+ // sync access handles are exclusive — reuse ours if this path is already
60
+ // open (positional reads are stateless, so tables can share a handle)
61
+ let fileId = null;
62
+ let h = null;
63
+ for (const [id, e] of opfsFiles) {
64
+ if (e.path === path) {
65
+ fileId = id;
66
+ h = e.handle;
67
+ break;
68
+ }
69
+ }
70
+ const opened = fileId === null;
71
+ if (opened) {
72
+ const { dir, file } = await opfsDir(path, false);
73
+ const fh = await dir.getFileHandle(file);
74
+ h = await fh.createSyncAccessHandle();
75
+ fileId = nextFileId++;
76
+ opfsFiles.set(fileId, { handle: h, path });
77
+ }
78
+ try {
79
+ const { handle, rows } = engine.openOpfsTable(fileId, h.getSize(), cacheBytes);
80
+ tables.set(name, handle);
81
+ lastTable = handle;
82
+ return { rows, fileLen: h.getSize() };
83
+ } catch (err) {
84
+ if (opened) {
85
+ opfsFiles.delete(fileId);
86
+ h.close();
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ // ---------------- Parquet transcode (baseline browser compiler) ------------
93
+
94
+ let hyparquetUrl = "hyparquet"; // bare specifier for bundlers; override in init
95
+ let hp = null;
96
+
97
+ async function loadHyparquet() {
98
+ if (hp) return hp;
99
+ // literal specifier when unconfigured, so bundlers resolve + code-split the
100
+ // optional peer dependency; explicit URLs bypass the bundler entirely
101
+ hp =
102
+ hyparquetUrl === "hyparquet"
103
+ ? await import("hyparquet")
104
+ : await import(/* @vite-ignore */ hyparquetUrl);
105
+ return hp;
106
+ }
107
+
108
+ /** Transcode Parquet -> image handle (in wasm memory). */
109
+ async function transcodeParquet(buffer) {
110
+ const { rows, columns } = await parquetToColumns(await loadHyparquet(), buffer);
111
+ return { rows, img: engine.compileTable(rows, columns) };
112
+ }
113
+
114
+ async function sha256hex(buffer) {
115
+ const d = await crypto.subtle.digest("SHA-256", buffer);
116
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
117
+ }
118
+
119
+ self.onmessage = async (e) => {
120
+ const { id, cmd } = e.data;
121
+ const reply = (msg, transfer = []) => postMessage({ id, ...msg }, transfer);
122
+ try {
123
+ if (cmd === "init") {
124
+ if (e.data.hyparquetUrl) hyparquetUrl = e.data.hyparquetUrl;
125
+ const wasmBytes = await (await fetch(e.data.wasmUrl)).arrayBuffer();
126
+ engine = await instantiate(wasmBytes, opfsRead);
127
+ reply({ ok: true });
128
+ } else if (cmd === "load") {
129
+ const { handle, rows } = engine.openTable(e.data.buffer);
130
+ tables.set(e.data.name, handle);
131
+ lastTable = handle;
132
+ reply({ ok: true, rows });
133
+ } else if (cmd === "loadParquet") {
134
+ // transcode-only path: parquet buffer -> in-memory table
135
+ const t0 = performance.now();
136
+ const { rows, img } = await transcodeParquet(e.data.buffer);
137
+ const { handle } = engine.openImage(img);
138
+ tables.set(e.data.name, handle);
139
+ lastTable = handle;
140
+ reply({ ok: true, rows, transcodeMs: performance.now() - t0 });
141
+ } else if (cmd === "openParquet") {
142
+ // cached path: reopen the compiled image from OPFS when the same source
143
+ // (by content hash) was seen before; otherwise transcode + persist
144
+ const t0 = performance.now();
145
+ const hash = await sha256hex(e.data.buffer);
146
+ const path = `facetful-cache/${hash}-v${engine.w.format_version()}.facetful`;
147
+ try {
148
+ const { rows } = await opfsOpenTable(e.data.name, path, e.data.cacheBytes);
149
+ reply({ ok: true, rows, source: "cache", openMs: performance.now() - t0 });
150
+ return;
151
+ } catch {
152
+ // cache miss (or OPFS unavailable) — fall through to transcode
153
+ }
154
+ const { rows, img } = await transcodeParquet(e.data.buffer);
155
+ const transcodeMs = performance.now() - t0;
156
+ let cached = false;
157
+ let imageBytes = null;
158
+ try {
159
+ imageBytes = engine.imageBytes(img);
160
+ } catch { /* copy-out failed: open uncached */ }
161
+ const { handle } = engine.openImage(img);
162
+ tables.set(e.data.name, handle);
163
+ lastTable = handle;
164
+ if (imageBytes) {
165
+ try {
166
+ await opfsWrite(path, imageBytes);
167
+ cached = true;
168
+ } catch { /* non-secure context or quota: stay memory-only */ }
169
+ }
170
+ reply({ ok: true, rows, source: "transcode", cached, transcodeMs });
171
+ } else if (cmd === "storeOpfs") {
172
+ await opfsWrite(e.data.path, new Uint8Array(e.data.buffer));
173
+ reply({ ok: true, bytes: e.data.buffer.byteLength });
174
+ } else if (cmd === "loadOpfs") {
175
+ const { rows, fileLen } = await opfsOpenTable(e.data.name, e.data.path, e.data.cacheBytes);
176
+ reply({ ok: true, rows, fileLen });
177
+ } else if (cmd === "removeOpfs") {
178
+ closeHandlesFor(e.data.path);
179
+ const { dir, file } = await opfsDir(e.data.path, false);
180
+ await dir.removeEntry(file);
181
+ reply({ ok: true });
182
+ } else if (cmd === "warm") {
183
+ const handle = e.data.table ? tables.get(e.data.table) : lastTable;
184
+ if (!handle) throw new Error("no table loaded");
185
+ let bytes = 0;
186
+ for (const name of e.data.cols) {
187
+ const idx = engine.colByName(handle, name);
188
+ if (idx < 0) throw new Error(`no column '${name}'`);
189
+ bytes += engine.warmColumn(handle, idx);
190
+ // yield between columns so queued queries interleave with warming
191
+ await new Promise((r) => setTimeout(r, 0));
192
+ }
193
+ reply({ ok: true, bytes });
194
+ } else if (cmd === "cacheStats") {
195
+ const handle = e.data.table ? tables.get(e.data.table) : lastTable;
196
+ if (!handle) throw new Error("no table loaded");
197
+ reply({ ok: true, ...engine.cacheStats(handle) });
198
+ } else if (cmd === "query") {
199
+ const handle = e.data.table ? tables.get(e.data.table) : lastTable;
200
+ if (!handle) throw new Error(`no table loaded${e.data.table ? `: '${e.data.table}'` : ""}`);
201
+ const t0 = performance.now();
202
+ const result = engine.query(handle, e.data.sql);
203
+ result.elapsedMs = performance.now() - t0;
204
+ reply({ ok: true, result }, transferables(result));
205
+ } else {
206
+ throw new Error(`unknown command '${cmd}'`);
207
+ }
208
+ } catch (err) {
209
+ reply({
210
+ error: String(err && err.message ? err.message : err),
211
+ isQueryError: err instanceof QueryError,
212
+ });
213
+ }
214
+ };