vectrojs 0.0.1 → 0.1.1

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +828 -4
  3. package/bin/vectro.mjs +82 -0
  4. package/dist/vectro.wasm +0 -0
  5. package/mcp/server.mjs +288 -0
  6. package/package.json +84 -6
  7. package/src/arrow.mjs +905 -0
  8. package/src/bitpack.mjs +135 -0
  9. package/src/browser.mjs +129 -0
  10. package/src/bytes.mjs +60 -0
  11. package/src/csv-kernel.mjs +205 -0
  12. package/src/csv.mjs +270 -0
  13. package/src/dataset.mjs +1763 -0
  14. package/src/dates.mjs +139 -0
  15. package/src/emitter.mjs +109 -0
  16. package/src/engine.mjs +881 -0
  17. package/src/files-browser.mjs +104 -0
  18. package/src/files-node.mjs +59 -0
  19. package/src/frames.mjs +191 -0
  20. package/src/fuse.mjs +512 -0
  21. package/src/interp.mjs +305 -0
  22. package/src/join.mjs +534 -0
  23. package/src/kernels.mjs +312 -0
  24. package/src/ndjson.mjs +136 -0
  25. package/src/node.mjs +120 -0
  26. package/src/pack.mjs +114 -0
  27. package/src/parallel-sort.mjs +99 -0
  28. package/src/partition-kernels.mjs +388 -0
  29. package/src/partition.mjs +395 -0
  30. package/src/plan.mjs +551 -0
  31. package/src/portable.mjs +21 -0
  32. package/src/rank-kernels.mjs +81 -0
  33. package/src/rows.mjs +91 -0
  34. package/src/serve/catalog.mjs +148 -0
  35. package/src/serve/flight.mjs +270 -0
  36. package/src/serve/formats.mjs +87 -0
  37. package/src/serve/http.mjs +182 -0
  38. package/src/serve/index.mjs +9 -0
  39. package/src/serve/odata.mjs +291 -0
  40. package/src/serve/pg.mjs +588 -0
  41. package/src/serve/pgcatalog.mjs +220 -0
  42. package/src/serve/pgtypes.mjs +199 -0
  43. package/src/sketch.mjs +75 -0
  44. package/src/sort-kernels.mjs +117 -0
  45. package/src/sort.mjs +102 -0
  46. package/src/split-worker-core.mjs +252 -0
  47. package/src/split-worker-node.mjs +8 -0
  48. package/src/split-worker.mjs +7 -0
  49. package/src/split.mjs +741 -0
  50. package/src/sql.mjs +1558 -0
  51. package/src/sqltext.mjs +132 -0
  52. package/src/static-kernels.mjs +164 -0
  53. package/src/stream-worker.mjs +553 -0
  54. package/src/table-kernels.mjs +323 -0
  55. package/src/table.mjs +2247 -0
  56. package/src/textmap.mjs +159 -0
  57. package/src/vectors.mjs +138 -0
  58. package/src/wasm.mjs +254 -0
  59. package/src/window.mjs +160 -0
  60. package/src/worker-browser.mjs +21 -0
  61. package/src/worker-loop.mjs +106 -0
  62. package/src/worker.mjs +22 -0
  63. package/src/zones.mjs +75 -0
package/README.md CHANGED
@@ -1,6 +1,830 @@
1
- # Vectro
1
+ # Vectro.js
2
2
 
3
- Multi-core analytics inside any JavaScript app: columnar tables and fused SIMD
4
- WebAssembly kernels for Node, browsers and edge runtimes, with no native code.
3
+ Multi-core analytics inside any JavaScript app. Vectro keeps data in columns
4
+ and compiles each query, filter included, into one SIMD WebAssembly loop, then
5
+ runs it on every CPU core. It runs in Node, in browsers (any page) and on
6
+ Cloudflare Workers, with no native addon, compiler or server.
5
7
 
6
- Coming soon. Follow https://github.com/vectrojs
8
+ > Version 0.1.1. Node 20+ and modern browsers. MIT license.
9
+
10
+ **Website and docs: [vectrojs.dev](https://vectrojs.dev)**. [Quick start](https://vectrojs.dev/docs/quick-start) · [API reference](https://vectrojs.dev/api) · [Race it in your browser](https://vectrojs.dev/live) · [Benchmarks](https://vectrojs.dev/benchmarks)
11
+
12
+ ```js
13
+ import { Engine } from 'vectrojs';
14
+
15
+ const engine = new Engine(); // all cores
16
+ const orders = engine.readCSV('orders.csv'); // parsed in parallel
17
+
18
+ orders.filter("region == 'EU' && price > 100").agg({
19
+ orders: 'count()',
20
+ revenue: 'sum(price * qty)',
21
+ avgPrice: 'mean(price)',
22
+ }); // ~3 ms on 10M rows
23
+
24
+ orders.groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();
25
+ ```
26
+
27
+ ## Install and try it
28
+
29
+ ```sh
30
+ npm install vectrojs
31
+ ```
32
+
33
+ ```js
34
+ import { Engine, sql } from 'vectrojs';
35
+
36
+ const engine = new Engine();
37
+ const orders = engine.readCSV('orders.csv');
38
+ sql('SELECT region, COUNT(*) AS n, SUM(price) AS revenue FROM orders GROUP BY region ORDER BY revenue DESC', { orders }).toRows();
39
+ ```
40
+
41
+ Serve a folder of CSV / Arrow / `.vectro` files to BI tools, with no code:
42
+
43
+ ```sh
44
+ npx vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
45
+ psql -h 127.0.0.1 -p 5433 -U vectro vectro # or Power BI, Tableau, Metabase, Grafana
46
+ ```
47
+
48
+ ## What's stable
49
+
50
+ | Part | Status |
51
+ |---|---|
52
+ | Engine: tables, filters, group by, joins, sort, CSV / Arrow / JSON lines | Stable: answers checked against plain JavaScript and DuckDB |
53
+ | SQL (`sql(...)`) | Stable for what's listed under **SQL** below; anything else gives a clear error |
54
+ | Datasets bigger than memory (`.vectro` files) | Stable; the file format may still change before 1.0 |
55
+ | `vectro serve`: HTTP, OData, PostgreSQL protocol, Arrow Flight SQL | Beta: checked with the drivers BI tools use, not yet with every application |
56
+ | Power BI `.mez` and Tableau `.taco` connectors | Experimental: not signed, not yet tried in the applications |
57
+ | Streams, vector search, MCP server | Experimental: the API may change |
58
+
59
+ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
60
+ [issues](https://github.com/vectrojs/vectro/issues).
61
+
62
+ ## What it does
63
+
64
+ - **Tables:** `engine.table({ price: Float64Array, region: string[] })`,
65
+ `engine.readCSV(path)`, `engine.parseCSV(text | bytes)`.
66
+ - **Queries:** `filter(expr)` (lazy), `agg({ name: 'sum(expr)' | 'mean' | 'min' | 'max' | 'count()' })`,
67
+ `sum/mean/min/max/count(expr)`, `groupBy(keys).agg(...)`, `derive(name, expr)`,
68
+ `collect()`, `toRows(limit)`.
69
+ - **Group by any keys:** `groupBy('region')`, `groupBy(['region', 'day'])`,
70
+ `groupBy('account_id')`. Keys are category columns or any numbers (big IDs,
71
+ amounts, negatives; NaN is its own group, -0 groups with 0). Groups come
72
+ back sorted by key: numbers ascending (NaN last), text in the order its
73
+ labels first appeared (sort the result for A to Z). Small keys (a category, or whole numbers in a narrow
74
+ range, alone or combined) are group numbers as they are. With many groups
75
+ on a big table, every thread splits the rows into partitions by key range;
76
+ each partition is added up in a small hash table of its own (in the CPU's
77
+ cache) and its groups sorted, so the groups come out in key order with no
78
+ sort of the whole result, and sums are added in row order (the same answer
79
+ on any number of threads). Fewer groups are numbered by every thread at
80
+ once in one shared hash table. On 10M rows vs DuckDB
81
+ (`compare/bench-groupby.mjs`, answers checked):
82
+
83
+ | Keys | Vectro | DuckDB |
84
+ |---|---|---|
85
+ | region + day (2,920 pairs) | 6 ms | 39 ms |
86
+ | price (100K amounts) | 27 ms | 49 ms |
87
+ | account (1M big IDs) | 36 ms | 88 ms |
88
+ | account + region (5.7M pairs) | 50 ms | 139 ms |
89
+
90
+ Vectro's groups also come back sorted by key; DuckDB's come back unsorted.
91
+ - **Missing values** are NaN for numbers and `''` for text. `sum`, `mean`,
92
+ `min` and `max` skip them (`mean` divides by the values present; `min`/`max`
93
+ of nothing is NaN, the `sum` of nothing in `agg` is 0), `count(x)` and
94
+ `countDistinct(x)` count the values present (blank text included as
95
+ missing) and `count(x > 5)` the rows where the condition holds.
96
+ `isMissing(x)` and `coalesce(x, y, 0)` (first value present) work in any
97
+ expression. CSV blanks, `null` in arrays, rows and JSON, and Arrow nulls
98
+ load as missing. In SQL, a comparison with a missing value is unknown, so
99
+ `NOT (x > 5)`, `x <> 5`, `x NOT IN (...)`, `NOT BETWEEN` and `NOT LIKE`
100
+ drop those rows, as in other databases; in table expressions comparisons
101
+ follow JavaScript (`x != 5` and `!(x > 5)` are true where x is missing), so
102
+ add `&& !isMissing(x)` where that matters.
103
+ - **Dates and times:** a date column holds milliseconds since 1970 (UTC).
104
+ Arrays of `Date` objects become date columns (or `table(cols, { dates:
105
+ ['ts'] })` for ms or ISO text); CSV columns of ISO dates are found by
106
+ themselves (or `types: { ts: 'date' }`); Arrow dates and timestamps load as
107
+ dates; streams take `columns: { ts: 'date' }` (ISO text or ms). Compare with
108
+ ISO text, `filter("ts >= '2024-03-01'")`, `date('2024-03-01T12:00:00Z')`, or
109
+ a `Date` param. `year(ts)`, `month(ts)`, `day(ts)`, `hour(ts)`, `minute(ts)`,
110
+ `second(ts)`, `weekday(ts)` (1 = Monday) and `dayStart(ts)`, `hourStart(ts)`,
111
+ `weekStart(ts)`, `monthStart(ts)` compile to plain arithmetic in the fused
112
+ kernels: `sum(year(ts))` over 10M rows takes 26 ms (a JS loop with `Date`:
113
+ 800 ms). Rows come back with `Date` objects; Arrow writes `Timestamp(ms, UTC)`.
114
+ - **Distinct counts:** `countDistinct(expr)` in `agg` and `groupBy().agg`
115
+ (`{ customers: 'countDistinct(customer_id)' }`), exact; missing values (NaN)
116
+ don't count, as in SQL. On split tables and streams the workers' distinct
117
+ values are merged exactly. `approxDistinct(expr)` gives the same answer on a
118
+ table, and on split tables, streams and **live windows** it uses HyperLogLog
119
+ sketches (4 KB, within ~1.6%): each worker keeps one per time slice and
120
+ group, and sketches merge, so "distinct customers in the last 5 minutes" is
121
+ a cheap read. `DistinctSketch` (exported) builds, merges and ships sketches
122
+ yourself. Whole numbers in a small range (IDs, codes) are counted with one
123
+ byte per (group, value), marked on every thread; other values are numbered
124
+ by the hash table first, or, with many of them, sorted by key range on
125
+ every thread (as a group-by with many groups) and counted in runs; values
126
+ a whole step apart (IDs like 1e9 + 7919k) get the byte map too. On 10M rows
127
+ vs DuckDB (`compare/bench-distinct.mjs`): 365 days 5 ms vs 5; regions per
128
+ day 9 ms vs 36; 1M accounts 11 ms vs 51 (1M random IDs up to 2^40, no common
129
+ step, from an earlier run: 24 ms vs 46); accounts per region (5.7M pairs) 35 ms vs 103.
130
+ - **Compressed columns:** `table.compress()` (or `engine.table(cols, { compress:
131
+ true })`, `parseCSV(text, { compress: true })`) stores each number and
132
+ category column in 1, 2 or 4 bytes per row when every value comes back
133
+ exactly: small counts and category codes in 1 byte, prices in cents and
134
+ timestamps on whole seconds in 4, missing values as a reserved code. It is in
135
+ place (views keep working) and checked value by value with the kernels' own
136
+ arithmetic, so answers never change. The fused kernels read packed columns
137
+ directly (SIMD widen, convert, scale), so do group-by kernels; sort, top and
138
+ collect copy the codes, so results stay small; joins and other operations
139
+ decode what they need (a join's result is unpacked). 10M orders (`npm run bench:pack`): 458 MB → 143 MB (3.2x smaller),
140
+ compressed in 0.34 s; queries run about as fast as on plain columns (a new
141
+ column 4.0 → 3.0 ms, filtered revenue 2.4 → 2.9 ms, group by region 4.7 →
142
+ 4.4 ms, top 10 8.4 → 12.9 ms). The whole test suite also runs with every
143
+ table compressed (`npm run test:compressed`).
144
+ - **SQL:** `sql(text, { orders, customers })` runs a SELECT over your tables
145
+ and returns a lazy query (`.toRows()`, `.collect()`, `.explain()`):
146
+
147
+ ```js
148
+ import { sql } from 'vectrojs';
149
+ sql(`SELECT c.tier, COUNT(*) AS n, SUM(o.price * o.qty) AS revenue
150
+ FROM orders o JOIN customers c ON o.cust = c.cust
151
+ WHERE o.region = 'EU' AND o.ts >= DATE '2024-01-01'
152
+ GROUP BY c.tier HAVING COUNT(*) > 100 ORDER BY revenue DESC LIMIT 5`,
153
+ { orders, customers }).toRows();
154
+ ```
155
+
156
+ SELECT [DISTINCT], FROM with aliases, [INNER | LEFT | RIGHT | FULL [OUTER]
157
+ | CROSS] JOIN ... ON / USING, WHERE, GROUP BY (columns, expressions, select aliases or positions),
158
+ HAVING, ORDER BY ... ASC/DESC [NULLS FIRST | LAST], LIMIT / OFFSET / FETCH
159
+ FIRST; COUNT(*), COUNT(x), COUNT(DISTINCT x), SUM, AVG, MIN, MAX,
160
+ APPROX_COUNT_DISTINCT, MEDIAN, PERCENTILE_CONT / PERCENTILE_DISC WITHIN GROUP
161
+ (ORDER BY x), QUANTILE_CONT anywhere in SELECT / HAVING / ORDER BY; BETWEEN,
162
+ IN, LIKE, IS [NOT] NULL, CASE, %, COALESCE, NULLIF, ROUND, LEAST, GREATEST,
163
+ CAST, DATE / TIMESTAMP literals, INTERVAL '7 days', EXTRACT, DATE_TRUNC,
164
+ YEAR(), MONTH()... It compiles to the planner below, so SQL gets every
165
+ optimization, on tables and on datasets bigger than memory.
166
+
167
+ **Subqueries and set operations:** WITH (CTEs, with column names too),
168
+ subqueries in FROM and JOIN, UNION [ALL] / INTERSECT / EXCEPT, SELECT
169
+ without FROM, and in expressions IN (SELECT ...), EXISTS (SELECT ...) and
170
+ (SELECT one value), correlated too when the link is an equality (`WHERE
171
+ o.cust = c.cust`). Every subquery becomes a join, so the whole statement is
172
+ one plan with no row-by-row loops.
173
+
174
+ **Window functions:** `f(...) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS
175
+ | RANGE BETWEEN ... AND ...])` with ROW_NUMBER, RANK, DENSE_RANK,
176
+ PERCENT_RANK, CUME_DIST, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE,
177
+ NTH_VALUE, SUM, AVG, MIN, MAX, COUNT; frames by rows or by value (`RANGE
178
+ BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW`), over grouped results
179
+ too (`RANK() OVER (PARTITION BY region ORDER BY SUM(qty) DESC)`). Running
180
+ and moving sums use prefix sums, and moving MIN / MAX use a monotonic queue,
181
+ so each window costs one pass after its sort.
182
+
183
+ ```js
184
+ sql(`SELECT region, cust, spend FROM (
185
+ SELECT region, cust, SUM(price) AS spend,
186
+ RANK() OVER (PARTITION BY region ORDER BY SUM(price) DESC) AS r
187
+ FROM orders WHERE cust IN (SELECT cust FROM customers WHERE tier = 1)
188
+ GROUP BY region, cust) t
189
+ WHERE r <= 3 ORDER BY region, spend DESC`, { orders, customers }).toRows();
190
+ ```
191
+
192
+ **Text:** UPPER, LOWER, TRIM / LTRIM / RTRIM (and `TRIM(LEADING 'x' FROM
193
+ s)`), SUBSTRING / SUBSTR (and `FROM ... FOR`), LEFT, RIGHT, LENGTH,
194
+ REPLACE, REVERSE, REPEAT, LPAD, RPAD, SPLIT_PART, STRPOS / POSITION(a IN
195
+ b), STARTS_WITH, ENDS_WITH, CONTAINS, CONCAT, CONCAT_WS, `||`, ASCII, CHR,
196
+ REGEXP_MATCHES / REGEXP_REPLACE / REGEXP_EXTRACT, STRFTIME, ILIKE, text
197
+ CASE / COALESCE / NULLIF, `CAST(x AS VARCHAR)` and text to numbers or
198
+ dates, and comparing text with `<` / `>`. Each function runs once per
199
+ distinct value, not per row (a category column has few labels), and the
200
+ answer is a category column again, so it groups, sorts and joins like any
201
+ other: `GROUP BY UPPER(region)` on 10M rows 31 ms (DuckDB 47), `region ||
202
+ '-' || qty` as a key 85 ms (106), `STRFTIME(ts, '%Y-%m')` 51 ms (70; times
203
+ are rounded to the month first), ILIKE on a column 30 ms (54). A CASE whose
204
+ results are all quoted labels is worked out as label codes in the fused
205
+ pass (CASE text + group 6.2 ms vs 25). Missing text
206
+ is `''`, as everywhere in Vectro, and positions count characters from 1 as
207
+ in DuckDB.
208
+
209
+ **Outer joins:** RIGHT and FULL JOIN (with ON or USING) are the matching
210
+ rows (FULL: plus the left rows without a match) followed by the right rows
211
+ whose keys no left row has. The join marks which right rows it matched as
212
+ it probes, so the rows without a match come from the same pass (RIGHT JOIN
213
+ + group 30 vs 64 ms; FULL JOIN + count 19 vs 29 ms).
214
+
215
+ `compare/check-sql.mjs` runs 40 such queries (CTEs, set operations,
216
+ correlated subqueries, windows, percentiles, text functions on awkward
217
+ strings, outer joins, missing values) in Vectro and DuckDB on the same
218
+ 200K rows; every answer is the same. Not yet: INTERSECT ALL / EXCEPT ALL,
219
+ correlation on anything but equalities, MIN / MAX of text, and STRING_AGG.
220
+ The MCP server has
221
+ a `sql` tool. The same SQL text vs DuckDB on 10M orders + 100K customers
222
+ (`compare/bench-sql.mjs`, answers checked, best of 5): Vectro is faster on all
223
+ 20 queries (1.3–6.0x): filtered totals 4.3 vs 10.1 ms, group by region 7.4 vs
224
+ 36.8 ms, join + filter + group 19.8 vs 32.8 ms, top 10 customers 13.1 vs
225
+ 50.3 ms, distinct customers per month 34.2 vs 82.5 ms, top 20 rows 9.1 vs
226
+ 25.3 ms, CASE inside SUM 6.6 vs 39.6 ms, IN (subquery) 23.4 vs 29.7 ms,
227
+ correlated subquery 16.8 vs 61.4 ms, top 3 per region with RANK 38 vs
228
+ 139 ms, ROW_NUMBER over all rows 129 vs 221 ms, running total by day 9.8
229
+ vs 17.3 ms, MEDIAN per region 39.1 vs 83.9 ms, FULL JOIN + count 19.3 vs
230
+ 29.3 ms. A filter like `rn <= 3` on ROW_NUMBER or RANK keeps
231
+ only the rows that can rank that high before sorting (picked on every
232
+ thread); percentiles of a few partitions count values in buckets on every
233
+ thread and select only within the wanted buckets. Window functions and
234
+ percentiles gather all their rows, so on a dataset they need its rows (the
235
+ columns used) to fit in memory.
236
+ - **Lazy queries (a planner):** `t.lazy()` records a chain (`filter`, `derive`,
237
+ `select`, `join`, `groupBy().agg`, `sort`, `limit`/`top`) and optimizes it as a
238
+ whole before running it with `collect()`, `toRows()`, `agg()` or `count()`
239
+ (and `…Async`). Derived columns are inlined where they are used (never built
240
+ unless needed as a column, and then only for matching rows); filters move
241
+ below derives, selects and sorts, below `groupBy` when they only use its
242
+ keys, and into each side of a join; joins and sorts copy only the columns
243
+ used later; `sort` + `limit` becomes top-k. `explain()` prints the plan.
244
+ Every kernel also computes constant parts once and repeated parts once per
245
+ row (`sum(year(ts))`: 26 → 7.5 ms). `npm run bench:plan`, 10M orders, step by
246
+ step vs lazy: derive + filter + total 19.8 → 2.9 ms; join then filter 144 →
247
+ 22 ms; sort then first 10 177 → 12 ms.
248
+ - **Joins:** `orders.join(customers, { on: 'customer_id' })`, several keys
249
+ (`on: ['region', 'day']`), different names (`on: { left: 'cust', right: 'id' }`),
250
+ `how: 'inner'` (default) or `'left'`. Put the smaller table on the right: its
251
+ keys go into one hash table built on every thread, then every left row looks
252
+ its key up on every thread, and the joined columns are gathered on every
253
+ thread. Text keys match by label; a missing (NaN) key never matches; a left
254
+ join fills unmatched right columns with NaN or `''`. Rows come in left-table
255
+ order. On 10M orders vs DuckDB (`compare/bench-join.mjs`, joined table built
256
+ in full, answers checked):
257
+
258
+ | Join | Vectro | DuckDB |
259
+ |---|---|---|
260
+ | × 100K customers | 77 ms | 210 ms |
261
+ | × 1M accounts | 151 ms | 251 ms |
262
+ | left join, half the keys match | 82 ms | 206 ms |
263
+ | two keys: region + day (2,920 rows) | 61 ms | 189 ms |
264
+ - **Text functions** on category columns: `startsWith(col, 'x')`,
265
+ `endsWith`, `contains`, `in(col, 'a', 'b')` and `like(col, 'EU-%')`. They run
266
+ once per distinct label (not per row) and become a few range checks on the
267
+ codes, so text filters run as fast as number filters.
268
+ - **Zone maps:** each column keeps the min and max of every 16,384-row block
269
+ (built on the first filter that uses it; streams keep them up to date as
270
+ batches arrive). A filter with `column < number` parts narrows the rows it
271
+ reads to the run from the first block that can match to the last (on
272
+ tables of 65,536 rows or more, when that saves at least a tenth of them),
273
+ which pays off on time-ordered data such as logs, orders and streams;
274
+ datasets also skip whole chunks. Exact: only blocks that provably fail are
275
+ skipped.
276
+ - **Sort and top-k:** `sort(by)` and `top(k, by)`, e.g. `top(10, '-revenue')`
277
+ for the 10 largest. Keys are columns or expressions (`'-(price * qty)'`),
278
+ several keys sort in order (`['region', '-price']`), text sorts by label,
279
+ ties keep table order and missing values come last. `top` doesn't sort
280
+ everything: it finds a cutoff from a sample, then collects only the rows
281
+ past it, on all cores. Split tables and streams have `topAsync`. Full sorts
282
+ of 50,000+ rows run on every core: a parallel radix sort (11-bit digits,
283
+ digits that never change are skipped, whole numbers sort as offsets), then
284
+ every column is gathered in the new order on every core. Sorting 10M rows
285
+ by `-price` takes ~117 ms (one thread: ~350 ms; a JavaScript comparator
286
+ sort: ~4.1 s).
287
+ - **Async API:** every query has an `…Async` version that runs on the worker
288
+ threads while your server or page keeps working.
289
+ - **Expressions** are a small, safe language: numbers, columns, `+ - * /`,
290
+ comparisons, `&& || !`, `? :`, `abs sqrt floor ceil min max`. There is no
291
+ `eval`, so formulas typed by users or AI models are safe to run.
292
+
293
+ ## Tables bigger than memory (datasets)
294
+
295
+ A dataset is a table kept on disk, in one file, read a chunk at a time. Its
296
+ size has no limit from memory (an engine holds at most 4 GB), and it opens
297
+ instantly. Node only.
298
+
299
+ ```js
300
+ engine.csvToDataset('orders.csv', 'orders.vectro'); // a CSV of any size, read in pieces (or csvToDatasetAsync)
301
+ const w = engine.createDataset('events.vectro'); // or append tables (or { column: values })
302
+ w.append(todaysEvents); w.close();
303
+ engine.writeDataset('eu.vectro', orders.filter("region == 'EU'")); // a table, or a query
304
+
305
+ const orders = engine.openDataset('orders.vectro');
306
+ orders.filter("ts >= '2024-06-01'").groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();
307
+ await orders.lazy().join(customers, { on: 'cust' }).top(10, '-price').toRowsAsync();
308
+ sql('SELECT region, COUNT(DISTINCT cust) FROM orders GROUP BY region', { orders }).toRows();
309
+ ```
310
+
311
+ - **The file:** chunks of 4M rows. Each column of each chunk is stored
312
+ compressed (see compressed columns) and packed to the bit: a quantity 1..9
313
+ takes 4 bits, a price in cents up to 999.99 takes 17, a column that counts
314
+ up at a fixed step (row numbers, times every second) takes none. A chunk
315
+ loads with one read per column, then every thread unpacks the bits back to
316
+ the 1-, 2- or 4-byte codes the kernels read; nothing is parsed. The
317
+ footer holds the schema, one dictionary per text column and the min / max
318
+ of every column for each chunk and each 64K-row block.
319
+ - **Queries** are the usual lazy plans, run chunk by chunk with the same
320
+ kernels on every core: only the columns a query uses are read, and chunks
321
+ and blocks the filters rule out are not read at all. Totals add up across
322
+ chunks (a mean is a sum and a count), groups merge by key, `top(k)` keeps
323
+ the best k so far and passes the k-th value to later chunks as a filter,
324
+ `limit` stops reading once it has its rows. With the `…Async` calls the
325
+ next chunk is read while the current one is computed.
326
+ - **Many groups:** keys that are labels or whole-number IDs add straight into
327
+ one array per aggregation (each thread its own range of groups); exact
328
+ distinct counts of IDs use one bitmap. Other keys keep partial groups in
329
+ memory and, past 8M groups, **spill** them to disk split by key range; each
330
+ range is merged on its own (split again if still too big), in key order.
331
+ - **Joins:** the other table (or a dataset's matching rows) is the build
332
+ side, hashed once for all chunks (IDs in a small range use a direct lookup
333
+ array instead). When both sides are datasets and the other side has more
334
+ than 16M matching rows, both go to disk split by key range and each range is
335
+ joined on its own (a "grace" hash join): 100M orders x 20M accounts in 7.6 s
336
+ with 406 MB of engine memory. Rows then come out range by range rather than
337
+ in the left table's order.
338
+ - **Results** come back as normal tables. `writeDataset(path, query)` writes
339
+ a query's result as a new dataset chunk by chunk, so it needn't fit in
340
+ memory either; a full `sort` written this way is an external sort (by
341
+ ranges of the first key).
342
+ - **In the browser** (and other runtimes without Node's files), datasets open
343
+ read-only from a File the user picked or dropped, a Blob, or a URL (HTTP
344
+ range requests, neighbouring reads merged): only the chunks and columns a
345
+ query needs are fetched. Queries use the async calls.
346
+
347
+ ```js
348
+ import { Engine, sql } from 'vectrojs/browser';
349
+ const engine = new Engine();
350
+ const orders = await engine.openDataset('/data/orders.vectro'); // or input.files[0]
351
+ await sql('SELECT region, COUNT(*) FROM orders GROUP BY region', { orders }).toRowsAsync();
352
+ ```
353
+
354
+ A billion rows (a 5.1 GB file) open in a page in 51 ms; "the last day"
355
+ answers in 24 ms reading 0.3 MB; a full group-by streams 2.7 GB in 7 s
356
+ from a local server.
357
+
358
+ Pages also **write** datasets, into the browser's own file storage (OPFS),
359
+ to a file the user chose (`showSaveFilePicker()`), or into memory
360
+ (`memoryFile()`, then `.blob()`). Bytes stream to disk chunk by chunk, so a
361
+ CSV bigger than memory converts too:
362
+
363
+ ```js
364
+ await engine.csvToDataset(droppedFile, 'orders.vectro', { onProgress: (p) => show(p) });
365
+ await engine.writeDataset('eu.vectro', orders.filter("region == 'EU'"));
366
+ const orders = await engine.openStoredDataset('orders.vectro');
367
+ await engine.storedDatasets(); // [{ name, size }]; also storedDatasetFile(name) to download
368
+ ```
369
+
370
+ A 246 MB CSV (5M rows) becomes a 24 MB dataset in browser storage in
371
+ 2.8 s. Try `examples/dataset/` (serve it with
372
+ `node examples/browser-threads/serve.mjs 8780 --data <folder of .vectro files>`).
373
+ Spilling works in the browser too: huge group-bys, sorts written as
374
+ datasets and joins of two big datasets put their partitions in a
375
+ temporary folder in browser storage, removed when the query ends.
376
+ - **Also:** `append: true` adds to an existing dataset; `ds.lastRun` tells
377
+ how many chunks and bytes a query read; `explain()` shows the plan; the MCP
378
+ server opens `.vectro` files.
379
+
380
+ Measured against DuckDB 2.0 on its own database file, both on disk, the same
381
+ SQL on identical rows (`compare/bench-dataset.mjs`: the rows come from whole-number
382
+ formulas exact in both, answers compared; best of 3):
383
+
384
+ | Query | 100M rows: Vectro | DuckDB | 1 billion rows: Vectro | DuckDB |
385
+ |---|---|---|---|---|
386
+ | scan everything (count, revenue) | 70 ms | 73 ms | 478 ms | 662 ms |
387
+ | filter: region and quantity | 100 ms | 87 ms | 691 ms | 729 ms |
388
+ | the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 4 ms |
389
+ | group by region | 142 ms | 149 ms | 1.05 s | 1.25 s |
390
+ | top 10 of 1M customers | 529 ms | 637 ms | 4.39 s | 4.50 s |
391
+ | top 10 rows by price | 15 ms | 100 ms | 11 ms | 843 ms |
392
+ | join 1M customers, group by tier | 126 ms | 85 ms | 880 ms | 627 ms |
393
+ | distinct customers per region | 268 ms | 650 ms | 2.43 s | 6.84 s |
394
+ | file on disk | 0.5 GB | 0.5 GB | 5.1 GB | 5.4 GB |
395
+ | peak memory (RSS) | 344 MB | 2.9 GB | **0.37 GB** | 6.9 GB |
396
+
397
+ A billion rows would take 37 GB as plain columns in memory (this laptop has
398
+ 16 GB); Vectro's engine never used more than 163 MB for them. At a billion
399
+ rows Vectro is level or faster on 7 of the 8 queries (only the join is
400
+ slower, 1.4x); at 100M rows it is slower on three: the filter (1.1x), the
401
+ last day (3 vs 1 ms) and the join (1.5x). Top rows read only the blocks of rows whose
402
+ stored min / max can still beat the rows kept so far (here 1 chunk of 239),
403
+ so they take the same few milliseconds at any size. A join whose right side
404
+ has unique whole-number keys and a GROUP BY on its columns looks each left
405
+ key's group up in one array instead of joining rows; DuckDB is still faster
406
+ there (1.4x at a billion rows, 1.5x at 100M).
407
+
408
+ ## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
409
+
410
+ ```sh
411
+ npx vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
412
+ curl 'http://127.0.0.1:8787/sql?q=SELECT region, COUNT(*) AS n FROM orders GROUP BY 1&format=csv'
413
+ ```
414
+
415
+ | Endpoint | Gives |
416
+ |---|---|
417
+ | `GET /sql?q=...` or `POST /sql` (the SQL as the body, or JSON `{ "sql": ... }`) | the answer; `format=json` (default: `{ columns, rows }`), `csv`, `ndjson` or `arrow`; `limit=n` |
418
+ | `GET /tables`, `GET /tables/<name>` | table names, rows, column types |
419
+ | `GET /tables/<name>/rows?format=csv` | a whole table |
420
+ | `POST /reload` | reads the data folder again (new files become tables) |
421
+ | `GET /odata`, `/odata/$metadata`, `/odata/<table>?$filter=...` | an OData v4 feed (below) |
422
+
423
+ - **Excel** (Windows and Mac): Data > Get Data > From Web, then the `/sql?...&format=csv` URL. **Power BI Desktop**: Get Data > Web, the same URL (Import mode; Refresh runs the query again). The Power BI Service and **Google Sheets** (`=IMPORTDATA("https://.../sql?q=...&format=csv&token=...")`) fetch from the cloud, so they need a public address or, for Power BI, a gateway.
424
+ - **OData feed** (`/odata`, OData v4): Power BI and Excel (Get Data > OData
425
+ feed, `http://host:8787/odata`) and Tableau (OData connector) see every table
426
+ with its column types, and their filters, column choices, sorting and
427
+ paging run in Vectro (`$filter`, `$select`, `$orderby`, `$top`, `$skip`,
428
+ `$count`, `$apply=groupby(...)/aggregate(...)`, pages of 50,000 rows with
429
+ `@odata.nextLink`). With `--token`, give any user name and the token as the
430
+ password (Basic). Checked with Microsoft's OData libraries (the CSDL
431
+ validator and Simple.OData.Client, in `compare/check-drivers.mjs`). On 10M
432
+ orders: a filtered page answers in 44 ms, a 50,000-row page in 0.13 s, a
433
+ group-by in 28 ms; for importing all of a very large table, use the
434
+ PostgreSQL connector (pages far into a table cost more).
435
+ - **pandas / Polars**: `pd.read_csv(url)`, `pl.read_csv(url)`; Arrow is ten times faster to fetch: `pyarrow.ipc.open_stream(urlopen(url + '&format=arrow')).read_all()`.
436
+ - **In your own app**: `import { serve } from 'vectrojs/serve'; await serve({ tables: { orders }, port: 8787 })`.
437
+ - **Safety**: listens on 127.0.0.1 unless `--host` is given; `--token <secret>` requires `Authorization: Bearer <secret>` (or `&token=` for tools that can't send headers); SQL here only reads; files outside the folder are never read (symbolic links in the folder are skipped).
438
+
439
+ On 10M orders: a group by region answers in 9 ms over HTTP; 1M rows export in 0.6 s as CSV (42 MB) or JSON, 0.07 s as Arrow (28 MB), and all 10M rows as Arrow in 0.45 s. Live connections (Power BI DirectQuery, Tableau, Metabase, Grafana) use the PostgreSQL protocol, below.
440
+
441
+ ## The PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
442
+
443
+ ```sh
444
+ npx vectro serve --root ~/data --pg 5433 --user me --password secret
445
+ psql -h 127.0.0.1 -p 5433 -U me vectro
446
+ ```
447
+
448
+ Vectro answers as a PostgreSQL 16 database (read only), so anything with a
449
+ Postgres connector connects live: **Power BI** (Get Data > PostgreSQL
450
+ database, server `localhost:5433`, database `vectro`; Import or DirectQuery),
451
+ **Tableau** (Connect > PostgreSQL), Metabase, Grafana, DBeaver, Excel through
452
+ ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro's engine.
453
+
454
+ - **The protocol**: SCRAM-SHA-256 (or `--auth md5 | password`), TLS with
455
+ `--tls-cert` / `--tls-key`, the simple and the extended query protocol
456
+ (prepared statements, `$1` parameters, text and binary values, portals read
457
+ a part at a time), BEGIN / COMMIT, SET / SHOW, DECLARE / FETCH cursors,
458
+ cancel requests, PostgreSQL error codes. In code: `import { servePg } from 'vectrojs/serve'`.
459
+ - **Types**: whole-number columns are `bigint`, other numbers `double
460
+ precision`, text `text`, dates `timestamp` (`date` when every value is at
461
+ midnight); answers are typed the same way from the query (COUNT is bigint,
462
+ AVG double, comparisons boolean), before any row is read.
463
+ - **The catalog**: `information_schema` and `pg_catalog` hold the served
464
+ tables, so tools list tables and columns; the long metadata queries drivers
465
+ send (Npgsql's type loading, pgJDBC's getTables / getColumns) are answered
466
+ as PostgreSQL would; other catalog questions (keys, indexes, comments) get
467
+ empty answers. psql's `\dt` and `\dn` work.
468
+ - **Checked with the real drivers** (`compare/check-drivers.mjs`): pgJDBC
469
+ 42.7.4, which Tableau, DBeaver and Metabase use, and Npgsql 4.0.17, the one
470
+ Power BI's PostgreSQL connector ships with; both get what PostgreSQL gives,
471
+ including Power BI's own catalog queries and folded group-bys. Neither
472
+ Power BI nor Tableau itself has been tried yet.
473
+ - **Speed** (10M orders, same machine as a local PostgreSQL 16 with the same
474
+ rows): a group by region answers in 20 to 80 ms through psql against
475
+ PostgreSQL's 480 to 720 ms; 1M rows reach psql in 1.0 s (PostgreSQL 0.7 s)
476
+ and a Java program through pgJDBC in 0.9 s (PostgreSQL 0.6 to 1.0 s).
477
+ - **Safety**: without `--password` only this machine may connect; with
478
+ `--host 0.0.0.0` a password is required. Writes (INSERT, CREATE...) are refused.
479
+ - **Live dashboards** ([examples/bi](examples/bi/README.md): a 10M-order
480
+ sample shop, Docker setup, and steps for Power BI, Tableau, Metabase,
481
+ Grafana and Excel): Metabase v0.64 reads the tables and runs the questions
482
+ its editor builds (joins included) in 80 to 600 ms; Grafana 13's
483
+ time-series dashboard (its `$__timeFilter` / `$__timeGroupAlias` macros,
484
+ a moving average) answers each panel in 20 to 220 ms.
485
+
486
+ ![Metabase dashboard on Vectro, 10M orders](docs/img/metabase-vectro.jpg)
487
+
488
+ - **Packaged connectors** ([connectors](connectors/README.md)): `Vectro.mez`
489
+ puts Vectro in Power BI's Get Data list (tables through the OData feed, or
490
+ SQL of your own; anonymous or a token) and `vectro.taco` in Tableau's
491
+ Connect list (the PostgreSQL protocol with Tableau's PostgreSQL SQL). Built
492
+ by `node connectors/build.mjs`; the Power Query code parses with Microsoft's
493
+ parser, the Tableau files validate against the Connector SDK's schemas.
494
+ Not signed yet, and not yet loaded in the real applications.
495
+
496
+ ## Arrow Flight SQL: pandas, Polars, DuckDB
497
+
498
+ ```sh
499
+ npx vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
500
+ ```
501
+
502
+ ```python
503
+ import adbc_driver_flightsql.dbapi as flight # pip install adbc-driver-flightsql
504
+ with flight.connect('grpc://localhost:31337') as conn:
505
+ df = conn.cursor().execute('SELECT region, SUM(qty) FROM orders GROUP BY 1').fetch_df() # pandas
506
+ # polars.read_database(sql, conn); duckdb.sql('... FROM arrow') on fetch_arrow_table()
507
+ ```
508
+
509
+ Flight SQL answers in Arrow, column by column, so nothing is turned into
510
+ rows on either side: the fastest way to move Vectro's results into Python or
511
+ any Arrow tool. gRPC (HTTP/2) and protobuf are written in `src/serve/flight.mjs`
512
+ with no dependencies. Served: statements and prepared statements, the
513
+ catalog (catalogs, schemas, tables with their Arrow schemas, table types;
514
+ `adbc_get_objects` works), sign-in with a user name and password (Handshake,
515
+ then a bearer token). Columns come typed: whole numbers int64, other numbers
516
+ float64, text utf8, dates timestamp[ms, UTC] (or date32), booleans bool.
517
+ Checked with the ADBC Flight SQL driver, pandas, Polars, DuckDB and
518
+ pyarrow.flight (`compare/check-drivers.mjs`). On 10M orders: a group-by
519
+ answers in 97 ms; all 10M rows (9 columns, 790 MB as Arrow) arrive in 2.9 s,
520
+ against 19.3 s for the same rows through the PostgreSQL protocol (pgJDBC).
521
+ Not yet: parameters for prepared statements (DoPut), writes.
522
+
523
+ ## For AI assistants (MCP server)
524
+
525
+ `mcp/server.mjs` lets Claude, ChatGPT or any MCP client analyze your data
526
+ files with Vectro: it loads CSV, TSV or JSON-lines files and answers questions
527
+ on all cores, locally. It needs no dependencies.
528
+
529
+ ```bash
530
+ # Claude Code
531
+ claude mcp add vectro -- node /path/to/vectro/mcp/server.mjs --root ~/data
532
+ ```
533
+
534
+ ```json
535
+ // Claude Desktop: claude_desktop_config.json
536
+ { "mcpServers": { "vectro": { "command": "node", "args": ["/path/to/vectro/mcp/server.mjs", "--root", "/Users/you/data"] } } }
537
+ ```
538
+
539
+ Tools: `load_file`, `list_tables`, `describe_table` (column stats, common
540
+ labels), `query` (filter, group_by, aggregates, sort, limit),
541
+ `search_similar`, `add_column`, `drop_table`. A JSON-lines file with an
542
+ embedding field (an array of numbers) also gets a vector index, so the
543
+ assistant can ask for the rows most similar to a row (or to a vector), with a
544
+ filter. The assistant can only read files under `--root`, and its
545
+ expressions are Vectro's small math language (no code runs). Mistakes come
546
+ back as tool errors the assistant can read and fix. Answers include `ms`, the
547
+ time Vectro took.
548
+
549
+ ## Zone maps
550
+
551
+ `npm run bench:zones` (10M time-ordered rows, one every 10 ms, M1 Pro):
552
+
553
+ | Query | With zone maps | Without | Faster |
554
+ |---|---|---|---|
555
+ | Revenue, last hour | 0.68 ms | 4.00 ms | 5.9× |
556
+ | Revenue, last 6 hours | 1.13 ms | 3.22 ms | 2.8× |
557
+ | One hour in the middle, EU only | 0.42 ms | 3.73 ms | 8.8× |
558
+ | By region, last hour | 1.16 ms | 5.00 ms | 4.3× |
559
+ | Top 10 orders, last hour | 4.15 ms | 5.82 ms | 1.4× |
560
+
561
+ On a live stream of 7M rows (7 workers), "last minute" and "last 10 minutes"
562
+ took 0.21–0.25 ms against 1.6–1.7 ms for a full scan (6–8×). The first
563
+ filtered query on a column of a 10M-row table builds its zone maps
564
+ (~25–50 ms, once).
565
+
566
+ ## Vector search
567
+
568
+ Exact nearest-neighbour search over embeddings, on every core, in any
569
+ runtime (Node, browsers, edge), with filters on any other column:
570
+
571
+ ```js
572
+ import { Engine, VectorIndex } from 'vectrojs';
573
+
574
+ const index = await VectorIndex.create(new Engine(), {
575
+ vectors: embeddings, // number[][] or one flat Float32Array
576
+ dim: 384,
577
+ metric: 'cosine', // or 'dot', 'l2'
578
+ columns: { id: ids, title: titles, year: years },
579
+ });
580
+ await index.searchAsync(queryEmbedding, { k: 10, filter: 'year >= 2020' });
581
+ // -> [{ id, title, year, score }, ...] best first
582
+ ```
583
+
584
+ Embeddings are stored as float32 columns, the score is a fused expression
585
+ computed 24 dimensions per pass with the query as scalars, and `top(k)` picks
586
+ the best, so it reuses the compiled kernels, filters and split tables (pass a
587
+ `SplitEngine` to use Web Workers on any page). It is exact, not approximate:
588
+ no index to build or tune, results identical to brute force.
589
+
590
+ `npm run bench:vectors` (M1 Pro, 384 dimensions, top 10, cosine):
591
+
592
+ | Embeddings | Vectro | Plain JS loop (Float32Array) |
593
+ |---|---|---|
594
+ | 100,000 (154 MB) | 9.3 ms | 36.6 ms (3.9×) |
595
+ | 200,000 (307 MB) | 13.6 ms | 72.6 ms (5.3×) |
596
+ | 500,000 (768 MB), an earlier run | 22.8 ms | 179.3 ms (7.9×) |
597
+
598
+ With a `SplitEngine`, every worker searches its own rows in one round trip
599
+ and the page keeps the best k. In Chrome (`examples/check/vectors.html?n=200000`),
600
+ 8 Web Workers took 12.4 ms against 21 ms on the page's own thread; at 50,000
601
+ vectors the two were about equal (10.1 vs 8.8 ms). Keep the tab in front when
602
+ measuring: browsers slow down hidden tabs.
603
+
604
+ `table.select(...columns)` (a narrow view, no copy) and
605
+ `splitTable.topAsync(k, by, { columns })` keep top-k cheap on wide tables.
606
+
607
+ ## Streaming (prototype)
608
+
609
+ A stream is a live table spread over split-table workers. Rows arrive as CSV
610
+ lines or JSON lines and are parsed in 256 KB batches into growing columns.
611
+ Queries are the same as for any split table.
612
+
613
+ ```js
614
+ import { SplitEngine } from 'vectrojs'; // browsers: from 'vectrojs/browser'
615
+
616
+ const engine = new SplitEngine({ workers: 4 }); // default: one worker per core
617
+ const orders = await engine.streamAsync({
618
+ format: 'ndjson', // or 'csv' (no header)
619
+ columns: { ts: 'number', price: 'number', qty: 'number', region: 'category' },
620
+ keepRows: 2_000_000, // or keepMs: 60_000, timeColumn: 'ts'
621
+ });
622
+
623
+ // each worker reads its own feed: nothing passes through this thread
624
+ await Promise.all(engine.workers.map((_, w) =>
625
+ orders.connect(w, { url: `wss://feed.example.com/orders?part=${w}` }))); // or { host, port } for TCP (Node)
626
+ // ...or push chunks you receive yourself: socket.on('data', (c) => orders.push(c))
627
+
628
+ // running totals, updated as rows arrive
629
+ const live = await orders.windowAsync({
630
+ by: 'region', every: 1000, last: 300_000, timeColumn: 'ts',
631
+ agg: { orders: 'count()', revenue: 'sum(price * qty)', avg: 'mean(price)' },
632
+ });
633
+ await live.readAsync(); // per region, last 5 minutes (~1 ms)
634
+ await live.readAsync({ series: true }); // per second and region, for charts
635
+
636
+ await orders.filter("region == 'EU'").aggAsync({ big: 'count(price > 900)' }); // any query, over the rows kept
637
+ const fixed = await orders.snapshotAsync(); // a fixed copy, for derive and toRows
638
+ ```
639
+
640
+ - **JSON lines** are parsed byte by byte straight into columns (no object per
641
+ row): keys in any order, extra and nested keys skipped, `null` and missing
642
+ values empty, bad lines counted (`badLines`) and skipped.
643
+ - **Windows** keep one small total per (slice of `every` ms, group) in each
644
+ worker, so reading costs about a millisecond, and the totals outlive rows
645
+ dropped by `keepRows`. The window ends at the newest time seen.
646
+ - **Backpressure (TCP and push):** when a worker has more than 4 MB waiting to
647
+ be parsed, it pauses its socket (or delays its reply to `push`) until it's
648
+ down to 1 MB. TCP then slows the sender. WebSockets can't be paused, so a
649
+ WebSocket feed faster than the workers grows their backlog.
650
+ - **Queries go first:** parsing runs one batch per event-loop turn, so a query
651
+ waits for at most one batch.
652
+
653
+ **In a browser** (`examples/stream/`, Chrome, Web Workers each reading its own
654
+ WebSocket of JSON lines): with 4 workers it kept up with the feed's
655
+ **2 million orders per second**; with 8 workers it took in **3.5–3.6 million per
656
+ second** (the test feed, one Node thread writing JSON, is the likely limit).
657
+ The page never waited more than 4 ms, and the 30-second window read in
658
+ 0.1–11 ms. Run it: `npm run serve:split`, then open
659
+ `/examples/stream/?workers=8&rate=500000`.
660
+
661
+ **In Node** (`npm run bench:stream`, M1 Pro, 7 workers, 10M rows kept,
662
+ localhost TCP, a live query every 250 ms over every kept row):
663
+
664
+ | Receiver | Rows/s taken in | Query median / max | Main thread stalled up to |
665
+ |---|---|---|---|
666
+ | Plain Node, 1 connection (split/Number parsing, a loop per query) | 3.2–3.3M | 19–20 / 23 ms | 40–41 ms |
667
+ | Vectro, 1 connection, chunks pushed to workers | 17.0–17.5M | 13–14 / 22–32 ms | 6–9 ms |
668
+ | Vectro, 1 connection per worker | 16.7–18.5M | 12–14 / 29–62 ms | 12 ms (19–47 ms on an overloaded machine) |
669
+
670
+ The producer runs on the same machine, so all 8 cores are busy. JSON lines
671
+ (`FORMAT=ndjson`): Vectro took in 9.3–10.3M rows/s against 2.45M for plain Node
672
+ using `JSON.parse` per line, with live queries at 12–14 ms (plain: 18.5 ms).
673
+
674
+ ### Binary streams: Emitter and `format: 'vectro'`
675
+
676
+ When you control both ends, skip text. An `Emitter` (Node) sends column
677
+ batches as **Vectro frames**: numbers as raw float64, categories as u32 codes
678
+ with each label sent once per connection, every section 8-byte aligned. The
679
+ receiving worker copies number columns straight into memory with no parsing.
680
+
681
+ ```js
682
+ import { Emitter, SplitEngine } from 'vectrojs';
683
+
684
+ // sender
685
+ const feed = await Emitter.listen({ port: 9000, columns: { ts: 'number', price: 'number', region: 'category' } });
686
+ await feed.write({ ts, price, region: ['EU', 'US', ...] }); // Float64Array / arrays, same length
687
+ await feed.write({ ts, price, region: { codes, labels: REGIONS } }); // faster: codes into a labels array
688
+
689
+ // receiver: one connection per worker; the emitter deals batches round-robin
690
+ const engine = new SplitEngine();
691
+ const orders = await engine.streamAsync({ format: 'vectro', columns: { ts: 'number', price: 'number', region: 'category' } });
692
+ await Promise.all(engine.workers.map((_, w) => orders.connect(w, { host: 'feed.local', port: 9000 })));
693
+ ```
694
+
695
+ `write()` waits when every connection has 8 MB unsent (TCP backpressure), and
696
+ large batches go out in frames of at most 65,536 rows. Over WebSockets, send
697
+ each frame as one binary message. The frame layout is documented at the top of
698
+ `src/frames.mjs` (`BatchEncoder` / `FrameReader` work without the Emitter too).
699
+
700
+ **Measured** (`FORMAT=vectro npm run bench:stream`, M1 Pro, localhost, a live
701
+ query every 250 ms; the machine was busy, load average ~19, so CSV measured
702
+ 5.4–6.1M rows/s at the same time instead of its usual 17M):
703
+
704
+ | | Rows/s |
705
+ |---|---|
706
+ | CSV lines, 7 workers (same busy machine) | 5.4–6.1M |
707
+ | Emitter → 7 workers, end to end (Vectro frames or Arrow) | 63–78M |
708
+ | Emitter alone (one thread, to a process that discards), labels as text / as codes | 22M / 113–117M |
709
+ | Receivers alone (pre-encoded frames from one test thread): 1 / 4 / 7 workers | 29M / 88M / 100M+ |
710
+
711
+ One sender thread is enough on one machine: frames are built without zero-filling
712
+ and category codes pass straight through, so it outruns the receivers. Sender
713
+ threads behind one redirecting port were tried (branch `experiment/threaded-emitter`)
714
+ and measured no gain.
715
+
716
+ ## Apache Arrow
717
+
718
+ Vectro reads and writes Arrow IPC, the binary table format of pandas, Polars,
719
+ DuckDB, pyarrow and Arrow Flight: the stream format and the file format
720
+ (`.arrow` / `.feather`), including LZ4-compressed files. No dependencies.
721
+
722
+ ```js
723
+ import { readFileSync, writeFileSync } from 'node:fs';
724
+ import { Engine, readArrow, writeArrow } from 'vectrojs';
725
+
726
+ const engine = new Engine();
727
+ const sales = engine.fromArrow(readFileSync('sales.feather')); // or columns: ['price', 'region']
728
+ writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true })); // pandas.read_feather opens it
729
+ // split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
730
+
731
+ // streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
732
+ const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
733
+ // ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })
734
+ ```
735
+
736
+ - **Types:** ints, floats (16/32/64-bit), bools, dates, times, timestamps and
737
+ durations become number columns (times in milliseconds, nulls as NaN);
738
+ strings and dictionary-encoded strings become category columns (nulls as
739
+ `''`). Lists, structs, decimals and binary are refused unless left out with
740
+ `columns`. 64-bit integers are exact up to 2^53.
741
+ - **Writing:** numbers as float64 (float32 tables as float32), categories as
742
+ dictionary-encoded strings, which pandas opens as `Categorical`.
743
+ - **Checked against pyarrow 26:** files pyarrow writes (every type above, nulls,
744
+ delta and replaced dictionaries, LZ4, pandas `to_feather`) read back exactly
745
+ (`test/fixtures/arrow`, made by `scripts/make-arrow-fixtures.py`), and pyarrow
746
+ and pandas read Vectro's files back exactly.
747
+ - **Speed** (M1 Pro, busy machine): a 10M-row, 5-column Feather file loads in
748
+ ~0.43 s uncompressed and ~1.1 s with LZ4 (pyarrow, with native threads:
749
+ 0.16 s / 0.07 s). Arrow streams from the Emitter: 71–75M rows/s into 7
750
+ workers, the same as Vectro frames. ZSTD-compressed files aren't supported yet.
751
+
752
+ ## Runtimes
753
+
754
+ | Import | Where | How it runs |
755
+ |---|---|---|
756
+ | `vectrojs` | Node | Worker threads on all cores, shared memory |
757
+ | `vectrojs/browser` → `Engine` | Pages with COOP/COEP headers | Web Workers, shared memory (async API) |
758
+ | `vectrojs/browser` → `SplitEngine` | Any page | Each Web Worker owns a share of the rows; async API |
759
+ | `vectrojs/portable` | Cloudflare Workers and other edge runtimes | One thread; a precompiled 11 KB module plus a vectorized interpreter |
760
+
761
+ Tested in Node 20, 22, 24 and 25 (Linux, macOS, Windows), Chrome, Safari 26.5, Firefox 158, Safari on iOS (real
762
+ iPhone) and Cloudflare Workers.
763
+
764
+ ## Performance (10M rows, Apple M1 Pro, Node)
765
+
766
+ | Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
767
+ |---|---|---|---|---|
768
+ | Filtered revenue | 3.2 ms | 10.1 ms | 772 ms | 74 ms |
769
+ | Dashboard, 4 aggregates | 4.1 ms | 23.9 ms | 359 ms | 171 ms |
770
+ | Group by region (8 groups) | 6.5 ms | 39.7 ms | 2.59 s | 1.00 s |
771
+ | Group by customer (100K groups) | 8.1 ms | 13.2 ms | 3.90 s | 772 ms |
772
+ | Filter into a new table | 6.4 ms | 55.7 ms | 540 ms | 124 ms |
773
+ | New column | 3.7 ms | 32.3 ms | 1.50 s | 125 ms |
774
+
775
+ Best of 5, each system using all 8 cores (Arquero is single-threaded), the
776
+ same answers from every system. Vectro is 1.6–8.7x faster than DuckDB here
777
+ (3–9x on five of the six, 1.6x with 100K groups). \*SQLite from a separate
778
+ run (`npm run bench:sqlite`, best of 3).
779
+
780
+ Run them yourself: `cd compare && npm install && npm run bench`. Timings vary
781
+ with the machine's state (battery, background load), so compare systems in the
782
+ same run. The numbers in this README were measured on 1 November 2026
783
+ (commit b2a3033). These runs shared the laptop with other jobs (load average 3–8 for
784
+ the in-memory runs, up to 15 for the billion-row run), and ratios move by up
785
+ to about ±30% between runs. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
786
+ on all 20 queries), joins (`bench-join.mjs`: faster on all 4), group-by
787
+ (`bench-groupby.mjs`: faster on all 4, 2.4–2.8x with millions of groups),
788
+ distinct counts (`bench-distinct.mjs`: level or faster on all 4). On files
789
+ on disk (`bench-dataset.mjs`) DuckDB is still faster at joins with a GROUP
790
+ BY on the joined table (1.4–1.5x), and at 100M rows also at the filter (1.1x)
791
+ and the last-day lookup (3 vs 1 ms).
792
+
793
+ ## Layout
794
+
795
+ ```
796
+ src/ the engine (Node, browser and portable entry points); src/serve: the HTTP (and OData), PostgreSQL and Flight SQL servers
797
+ bin/ the vectro command (vectro serve)
798
+ mcp/ the MCP server for AI assistants
799
+ dist/ vectro.wasm, the precompiled kernels for edge runtimes (npm run build)
800
+ test/ correctness tests; VECTRO_MODE=single|interp|edge|browser-sim selects a mode
801
+ bench/ Vectro-only benchmarks (threads, async, skewed filters, CSV)
802
+ compare/ Vectro vs DuckDB, SQLite and Arquero (own package.json)
803
+ examples/ browser pages (threads, split tables, correctness check, phone test)
804
+ and a Cloudflare Worker
805
+ scripts/ build-wasm.mjs, make-check-expected.mjs
806
+ ```
807
+
808
+ ## Development
809
+
810
+ ```bash
811
+ npm run test:all # every test in all five modes (Node, single thread, interpreter, edge, browser-sim)
812
+ npm run bench # Node benchmarks
813
+ npm run serve:split # browser pages without isolation headers, port 8767
814
+ ```
815
+
816
+ Browser checks: run a server, then open `/examples/check/` in a browser to run
817
+ the same queries on every engine it supports and compare them with Node's
818
+ answers.
819
+
820
+ ## Not done yet
821
+
822
+ 64-bit integers beyond 2^53 and time zones other than UTC. A dataset is
823
+ written by one writer at a time; a join on text keys needs its
824
+ other side to fit in memory, exact distinct counts of non-ID values don't
825
+ spill, and `approxDistinct` on a dataset counts exactly. Joins are for normal
826
+ tables (not split tables or streams yet). Split tables
827
+ have `topAsync` but no full sort.
828
+ Binary streams have number and category columns only; Arrow ZSTD compression isn't supported.
829
+ Streams take flat records only (no nested JSON fields), and windows group by
830
+ at most one category column. Memory is capped at 4 GB per engine (datasets go past it) and freed with `dispose()`.