vectrojs 0.0.1 → 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/CHANGELOG.md +71 -0
- package/README.md +821 -4
- package/bin/vectro.mjs +82 -0
- package/dist/vectro.wasm +0 -0
- package/mcp/server.mjs +288 -0
- package/package.json +84 -6
- package/src/arrow.mjs +905 -0
- package/src/bitpack.mjs +135 -0
- package/src/browser.mjs +129 -0
- package/src/bytes.mjs +60 -0
- package/src/csv-kernel.mjs +205 -0
- package/src/csv.mjs +270 -0
- package/src/dataset.mjs +1763 -0
- package/src/dates.mjs +139 -0
- package/src/emitter.mjs +109 -0
- package/src/engine.mjs +881 -0
- package/src/files-browser.mjs +104 -0
- package/src/files-node.mjs +59 -0
- package/src/frames.mjs +191 -0
- package/src/fuse.mjs +512 -0
- package/src/interp.mjs +305 -0
- package/src/join.mjs +534 -0
- package/src/kernels.mjs +312 -0
- package/src/ndjson.mjs +136 -0
- package/src/node.mjs +120 -0
- package/src/pack.mjs +114 -0
- package/src/parallel-sort.mjs +99 -0
- package/src/partition-kernels.mjs +388 -0
- package/src/partition.mjs +395 -0
- package/src/plan.mjs +551 -0
- package/src/portable.mjs +21 -0
- package/src/rank-kernels.mjs +81 -0
- package/src/rows.mjs +91 -0
- package/src/serve/catalog.mjs +148 -0
- package/src/serve/flight.mjs +270 -0
- package/src/serve/formats.mjs +87 -0
- package/src/serve/http.mjs +182 -0
- package/src/serve/index.mjs +9 -0
- package/src/serve/odata.mjs +291 -0
- package/src/serve/pg.mjs +588 -0
- package/src/serve/pgcatalog.mjs +220 -0
- package/src/serve/pgtypes.mjs +199 -0
- package/src/sketch.mjs +75 -0
- package/src/sort-kernels.mjs +117 -0
- package/src/sort.mjs +102 -0
- package/src/split-worker-core.mjs +252 -0
- package/src/split-worker-node.mjs +8 -0
- package/src/split-worker.mjs +7 -0
- package/src/split.mjs +741 -0
- package/src/sql.mjs +1558 -0
- package/src/sqltext.mjs +132 -0
- package/src/static-kernels.mjs +164 -0
- package/src/stream-worker.mjs +553 -0
- package/src/table-kernels.mjs +323 -0
- package/src/table.mjs +2206 -0
- package/src/textmap.mjs +159 -0
- package/src/vectors.mjs +138 -0
- package/src/wasm.mjs +254 -0
- package/src/window.mjs +160 -0
- package/src/worker-browser.mjs +21 -0
- package/src/worker-loop.mjs +106 -0
- package/src/worker.mjs +22 -0
- package/src/zones.mjs +75 -0
package/README.md
CHANGED
|
@@ -1,6 +1,823 @@
|
|
|
1
|
-
# Vectro
|
|
1
|
+
# Vectro.js
|
|
2
2
|
|
|
3
|
-
Multi-core analytics inside any JavaScript app
|
|
4
|
-
|
|
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
|
-
|
|
8
|
+
> Version 0.1.0, the first release. 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) | 8 ms | 34 ms |
|
|
86
|
+
| price (100K amounts) | 23 ms | 42 ms |
|
|
87
|
+
| account (1M big IDs) | 29 ms | 61 ms |
|
|
88
|
+
| account + region (5.7M pairs) | 42 ms | 115 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 4 ms vs 4; regions per
|
|
128
|
+
day 7 ms vs 31; 1M accounts 9 ms vs 44 (1M random IDs up to 2^40, no common
|
|
129
|
+
step: 24 ms vs 46); accounts per region (5.7M pairs) 30 ms vs 85.
|
|
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 30 ms (DuckDB 42), `region ||
|
|
202
|
+
'-' || qty` as a key 85 ms (92), `STRFTIME(ts, '%Y-%m')` 48 ms (61; times
|
|
203
|
+
are rounded to the month first), ILIKE on a column 29 ms (46). A CASE whose
|
|
204
|
+
results are all quoted labels is worked out as label codes in the fused
|
|
205
|
+
pass (CASE text + group 5.7 ms vs 21). 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 27 vs 54 ms; FULL JOIN + count 17 vs 26 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, one run): Vectro is faster on all
|
|
223
|
+
20 queries: filtered totals 3.4 vs 10.1 ms, group by region 7.8 vs
|
|
224
|
+
30.7 ms, join + filter + group 17.3 vs 29.0 ms, top 10 customers 12.9 vs
|
|
225
|
+
42.5 ms, distinct customers per month 30.8 vs 70.6 ms, top 20 rows 8.2 vs
|
|
226
|
+
22.3 ms, CASE inside SUM 7.6 vs 35.2 ms, IN (subquery) 19.7 vs 25.2 ms,
|
|
227
|
+
correlated subquery 16.0 vs 50.6 ms, top 3 per region with RANK 36 vs
|
|
228
|
+
119 ms, ROW_NUMBER over all rows 124 vs 179 ms, running total by day 8.6
|
|
229
|
+
vs 14.6 ms, MEDIAN per region 33.6 vs 71.7 ms, FULL JOIN + count 16.9 vs
|
|
230
|
+
25.5 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 | 65 ms | 222 ms |
|
|
261
|
+
| × 1M accounts | 135 ms | 255 ms |
|
|
262
|
+
| left join, half the keys match | 76 ms | 191 ms |
|
|
263
|
+
| two keys: region + day (2,920 rows) | 64 ms | 167 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 ~130 ms (one thread: ~350 ms; a JavaScript comparator
|
|
286
|
+
sort: ~4.6 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) | 49 ms | 61 ms | 460 ms | 648 ms |
|
|
387
|
+
| filter: region and quantity | 70 ms | 72 ms | 665 ms | 723 ms |
|
|
388
|
+
| the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 5 ms |
|
|
389
|
+
| group by region | 114 ms | 124 ms | 1.13 s | 1.24 s |
|
|
390
|
+
| top 10 of 1M customers | 460 ms | 487 ms | 4.46 s | 4.62 s |
|
|
391
|
+
| top 10 rows by price | 10 ms | 87 ms | 11 ms | 827 ms |
|
|
392
|
+
| join 1M customers, group by tier | 84 ms | 68 ms | 745 ms | 587 ms |
|
|
393
|
+
| distinct customers per region | 247 ms | 517 ms | 2.45 s | 6.50 s |
|
|
394
|
+
| file on disk | 0.5 GB | 0.5 GB | 5.1 GB | 5.4 GB |
|
|
395
|
+
| peak memory (RSS) | 343 MB | 3.1 GB | **0.37 GB** | 6.6 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. Vectro is level
|
|
399
|
+
or faster on 7 of the 8 queries. Top rows read only the blocks of rows whose
|
|
400
|
+
stored min / max can still beat the rows kept so far (here 1 chunk of 239),
|
|
401
|
+
so they take the same few milliseconds at any size. A join whose right side
|
|
402
|
+
has unique whole-number keys and a GROUP BY on its columns looks each left
|
|
403
|
+
key's group up in one array instead of joining rows; DuckDB is still faster
|
|
404
|
+
there (1.3x).
|
|
405
|
+
|
|
406
|
+
## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
|
|
407
|
+
|
|
408
|
+
```sh
|
|
409
|
+
npx vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
|
|
410
|
+
curl 'http://127.0.0.1:8787/sql?q=SELECT region, COUNT(*) AS n FROM orders GROUP BY 1&format=csv'
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
| Endpoint | Gives |
|
|
414
|
+
|---|---|
|
|
415
|
+
| `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` |
|
|
416
|
+
| `GET /tables`, `GET /tables/<name>` | table names, rows, column types |
|
|
417
|
+
| `GET /tables/<name>/rows?format=csv` | a whole table |
|
|
418
|
+
| `POST /reload` | reads the data folder again (new files become tables) |
|
|
419
|
+
| `GET /odata`, `/odata/$metadata`, `/odata/<table>?$filter=...` | an OData v4 feed (below) |
|
|
420
|
+
|
|
421
|
+
- **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.
|
|
422
|
+
- **OData feed** (`/odata`, OData v4): Power BI and Excel (Get Data > OData
|
|
423
|
+
feed, `http://host:8787/odata`) and Tableau (OData connector) see every table
|
|
424
|
+
with its column types, and their filters, column choices, sorting and
|
|
425
|
+
paging run in Vectro (`$filter`, `$select`, `$orderby`, `$top`, `$skip`,
|
|
426
|
+
`$count`, `$apply=groupby(...)/aggregate(...)`, pages of 50,000 rows with
|
|
427
|
+
`@odata.nextLink`). With `--token`, give any user name and the token as the
|
|
428
|
+
password (Basic). Checked with Microsoft's OData libraries (the CSDL
|
|
429
|
+
validator and Simple.OData.Client, in `compare/check-drivers.mjs`). On 10M
|
|
430
|
+
orders: a filtered page answers in 44 ms, a 50,000-row page in 0.13 s, a
|
|
431
|
+
group-by in 28 ms; for importing all of a very large table, use the
|
|
432
|
+
PostgreSQL connector (pages far into a table cost more).
|
|
433
|
+
- **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()`.
|
|
434
|
+
- **In your own app**: `import { serve } from 'vectrojs/serve'; await serve({ tables: { orders }, port: 8787 })`.
|
|
435
|
+
- **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).
|
|
436
|
+
|
|
437
|
+
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.
|
|
438
|
+
|
|
439
|
+
## The PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
|
|
440
|
+
|
|
441
|
+
```sh
|
|
442
|
+
npx vectro serve --root ~/data --pg 5433 --user me --password secret
|
|
443
|
+
psql -h 127.0.0.1 -p 5433 -U me vectro
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Vectro answers as a PostgreSQL 16 database (read only), so anything with a
|
|
447
|
+
Postgres connector connects live: **Power BI** (Get Data > PostgreSQL
|
|
448
|
+
database, server `localhost:5433`, database `vectro`; Import or DirectQuery),
|
|
449
|
+
**Tableau** (Connect > PostgreSQL), Metabase, Grafana, DBeaver, Excel through
|
|
450
|
+
ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro's engine.
|
|
451
|
+
|
|
452
|
+
- **The protocol**: SCRAM-SHA-256 (or `--auth md5 | password`), TLS with
|
|
453
|
+
`--tls-cert` / `--tls-key`, the simple and the extended query protocol
|
|
454
|
+
(prepared statements, `$1` parameters, text and binary values, portals read
|
|
455
|
+
a part at a time), BEGIN / COMMIT, SET / SHOW, DECLARE / FETCH cursors,
|
|
456
|
+
cancel requests, PostgreSQL error codes. In code: `import { servePg } from 'vectrojs/serve'`.
|
|
457
|
+
- **Types**: whole-number columns are `bigint`, other numbers `double
|
|
458
|
+
precision`, text `text`, dates `timestamp` (`date` when every value is at
|
|
459
|
+
midnight); answers are typed the same way from the query (COUNT is bigint,
|
|
460
|
+
AVG double, comparisons boolean), before any row is read.
|
|
461
|
+
- **The catalog**: `information_schema` and `pg_catalog` hold the served
|
|
462
|
+
tables, so tools list tables and columns; the long metadata queries drivers
|
|
463
|
+
send (Npgsql's type loading, pgJDBC's getTables / getColumns) are answered
|
|
464
|
+
as PostgreSQL would; other catalog questions (keys, indexes, comments) get
|
|
465
|
+
empty answers. psql's `\dt` and `\dn` work.
|
|
466
|
+
- **Checked with the real drivers** (`compare/check-drivers.mjs`): pgJDBC
|
|
467
|
+
42.7.4, which Tableau, DBeaver and Metabase use, and Npgsql 4.0.17, the one
|
|
468
|
+
Power BI's PostgreSQL connector ships with; both get what PostgreSQL gives,
|
|
469
|
+
including Power BI's own catalog queries and folded group-bys. Neither
|
|
470
|
+
Power BI nor Tableau itself has been tried yet.
|
|
471
|
+
- **Speed** (10M orders, same machine as a local PostgreSQL 16 with the same
|
|
472
|
+
rows): a group by region answers in 20 to 80 ms through psql against
|
|
473
|
+
PostgreSQL's 480 to 720 ms; 1M rows reach psql in 1.0 s (PostgreSQL 0.7 s)
|
|
474
|
+
and a Java program through pgJDBC in 0.9 s (PostgreSQL 0.6 to 1.0 s).
|
|
475
|
+
- **Safety**: without `--password` only this machine may connect; with
|
|
476
|
+
`--host 0.0.0.0` a password is required. Writes (INSERT, CREATE...) are refused.
|
|
477
|
+
- **Live dashboards** ([examples/bi](examples/bi/README.md): a 10M-order
|
|
478
|
+
sample shop, Docker setup, and steps for Power BI, Tableau, Metabase,
|
|
479
|
+
Grafana and Excel): Metabase v0.64 reads the tables and runs the questions
|
|
480
|
+
its editor builds (joins included) in 80 to 600 ms; Grafana 13's
|
|
481
|
+
time-series dashboard (its `$__timeFilter` / `$__timeGroupAlias` macros,
|
|
482
|
+
a moving average) answers each panel in 20 to 220 ms.
|
|
483
|
+
|
|
484
|
+

|
|
485
|
+
|
|
486
|
+
- **Packaged connectors** ([connectors](connectors/README.md)): `Vectro.mez`
|
|
487
|
+
puts Vectro in Power BI's Get Data list (tables through the OData feed, or
|
|
488
|
+
SQL of your own; anonymous or a token) and `vectro.taco` in Tableau's
|
|
489
|
+
Connect list (the PostgreSQL protocol with Tableau's PostgreSQL SQL). Built
|
|
490
|
+
by `node connectors/build.mjs`; the Power Query code parses with Microsoft's
|
|
491
|
+
parser, the Tableau files validate against the Connector SDK's schemas.
|
|
492
|
+
Not signed yet, and not yet loaded in the real applications.
|
|
493
|
+
|
|
494
|
+
## Arrow Flight SQL: pandas, Polars, DuckDB
|
|
495
|
+
|
|
496
|
+
```sh
|
|
497
|
+
npx vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
import adbc_driver_flightsql.dbapi as flight # pip install adbc-driver-flightsql
|
|
502
|
+
with flight.connect('grpc://localhost:31337') as conn:
|
|
503
|
+
df = conn.cursor().execute('SELECT region, SUM(qty) FROM orders GROUP BY 1').fetch_df() # pandas
|
|
504
|
+
# polars.read_database(sql, conn); duckdb.sql('... FROM arrow') on fetch_arrow_table()
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Flight SQL answers in Arrow, column by column, so nothing is turned into
|
|
508
|
+
rows on either side: the fastest way to move Vectro's results into Python or
|
|
509
|
+
any Arrow tool. gRPC (HTTP/2) and protobuf are written in `src/serve/flight.mjs`
|
|
510
|
+
with no dependencies. Served: statements and prepared statements, the
|
|
511
|
+
catalog (catalogs, schemas, tables with their Arrow schemas, table types;
|
|
512
|
+
`adbc_get_objects` works), sign-in with a user name and password (Handshake,
|
|
513
|
+
then a bearer token). Columns come typed: whole numbers int64, other numbers
|
|
514
|
+
float64, text utf8, dates timestamp[ms, UTC] (or date32), booleans bool.
|
|
515
|
+
Checked with the ADBC Flight SQL driver, pandas, Polars, DuckDB and
|
|
516
|
+
pyarrow.flight (`compare/check-drivers.mjs`). On 10M orders: a group-by
|
|
517
|
+
answers in 97 ms; all 10M rows (9 columns, 790 MB as Arrow) arrive in 2.9 s,
|
|
518
|
+
against 19.3 s for the same rows through the PostgreSQL protocol (pgJDBC).
|
|
519
|
+
Not yet: parameters for prepared statements (DoPut), writes.
|
|
520
|
+
|
|
521
|
+
## For AI assistants (MCP server)
|
|
522
|
+
|
|
523
|
+
`mcp/server.mjs` lets Claude, ChatGPT or any MCP client analyze your data
|
|
524
|
+
files with Vectro: it loads CSV, TSV or JSON-lines files and answers questions
|
|
525
|
+
on all cores, locally. It needs no dependencies.
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
# Claude Code
|
|
529
|
+
claude mcp add vectro -- node /path/to/vectro/mcp/server.mjs --root ~/data
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
```json
|
|
533
|
+
// Claude Desktop: claude_desktop_config.json
|
|
534
|
+
{ "mcpServers": { "vectro": { "command": "node", "args": ["/path/to/vectro/mcp/server.mjs", "--root", "/Users/you/data"] } } }
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Tools: `load_file`, `list_tables`, `describe_table` (column stats, common
|
|
538
|
+
labels), `query` (filter, group_by, aggregates, sort, limit),
|
|
539
|
+
`search_similar`, `add_column`, `drop_table`. A JSON-lines file with an
|
|
540
|
+
embedding field (an array of numbers) also gets a vector index, so the
|
|
541
|
+
assistant can ask for the rows most similar to a row (or to a vector), with a
|
|
542
|
+
filter. The assistant can only read files under `--root`, and its
|
|
543
|
+
expressions are Vectro's small math language (no code runs). Mistakes come
|
|
544
|
+
back as tool errors the assistant can read and fix. Answers include `ms`, the
|
|
545
|
+
time Vectro took.
|
|
546
|
+
|
|
547
|
+
## Zone maps
|
|
548
|
+
|
|
549
|
+
`npm run bench:zones` (10M time-ordered rows, one every 10 ms, M1 Pro):
|
|
550
|
+
|
|
551
|
+
| Query | With zone maps | Without | Faster |
|
|
552
|
+
|---|---|---|---|
|
|
553
|
+
| Revenue, last hour | 0.68 ms | 4.00 ms | 5.9× |
|
|
554
|
+
| Revenue, last 6 hours | 1.13 ms | 3.22 ms | 2.8× |
|
|
555
|
+
| One hour in the middle, EU only | 0.42 ms | 3.73 ms | 8.8× |
|
|
556
|
+
| By region, last hour | 1.16 ms | 5.00 ms | 4.3× |
|
|
557
|
+
| Top 10 orders, last hour | 4.15 ms | 5.82 ms | 1.4× |
|
|
558
|
+
|
|
559
|
+
On a live stream of 7M rows (7 workers), "last minute" and "last 10 minutes"
|
|
560
|
+
took 0.21–0.25 ms against 1.6–1.7 ms for a full scan (6–8×). The first
|
|
561
|
+
filtered query on a column of a 10M-row table builds its zone maps
|
|
562
|
+
(~25–50 ms, once).
|
|
563
|
+
|
|
564
|
+
## Vector search
|
|
565
|
+
|
|
566
|
+
Exact nearest-neighbour search over embeddings, on every core, in any
|
|
567
|
+
runtime (Node, browsers, edge), with filters on any other column:
|
|
568
|
+
|
|
569
|
+
```js
|
|
570
|
+
import { Engine, VectorIndex } from 'vectrojs';
|
|
571
|
+
|
|
572
|
+
const index = await VectorIndex.create(new Engine(), {
|
|
573
|
+
vectors: embeddings, // number[][] or one flat Float32Array
|
|
574
|
+
dim: 384,
|
|
575
|
+
metric: 'cosine', // or 'dot', 'l2'
|
|
576
|
+
columns: { id: ids, title: titles, year: years },
|
|
577
|
+
});
|
|
578
|
+
await index.searchAsync(queryEmbedding, { k: 10, filter: 'year >= 2020' });
|
|
579
|
+
// -> [{ id, title, year, score }, ...] best first
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Embeddings are stored as float32 columns, the score is a fused expression
|
|
583
|
+
computed 24 dimensions per pass with the query as scalars, and `top(k)` picks
|
|
584
|
+
the best, so it reuses the compiled kernels, filters and split tables (pass a
|
|
585
|
+
`SplitEngine` to use Web Workers on any page). It is exact, not approximate:
|
|
586
|
+
no index to build or tune, results identical to brute force.
|
|
587
|
+
|
|
588
|
+
`npm run bench:vectors` (M1 Pro, 384 dimensions, top 10, cosine):
|
|
589
|
+
|
|
590
|
+
| Embeddings | Vectro | Plain JS loop (Float32Array) |
|
|
591
|
+
|---|---|---|
|
|
592
|
+
| 100,000 (154 MB) | 11.7 ms | 35.9 ms (3.1×) |
|
|
593
|
+
| 200,000 (307 MB) | 13.8 ms | 71.5 ms (5.2×) |
|
|
594
|
+
| 500,000 (768 MB) | 22.8 ms | 179.3 ms (7.9×) |
|
|
595
|
+
|
|
596
|
+
With a `SplitEngine`, every worker searches its own rows in one round trip
|
|
597
|
+
and the page keeps the best k. In Chrome (`examples/check/vectors.html?n=200000`),
|
|
598
|
+
8 Web Workers took 12.4 ms against 21 ms on the page's own thread; at 50,000
|
|
599
|
+
vectors the two were about equal (10.1 vs 8.8 ms). Keep the tab in front when
|
|
600
|
+
measuring: browsers slow down hidden tabs.
|
|
601
|
+
|
|
602
|
+
`table.select(...columns)` (a narrow view, no copy) and
|
|
603
|
+
`splitTable.topAsync(k, by, { columns })` keep top-k cheap on wide tables.
|
|
604
|
+
|
|
605
|
+
## Streaming (prototype)
|
|
606
|
+
|
|
607
|
+
A stream is a live table spread over split-table workers. Rows arrive as CSV
|
|
608
|
+
lines or JSON lines and are parsed in 256 KB batches into growing columns.
|
|
609
|
+
Queries are the same as for any split table.
|
|
610
|
+
|
|
611
|
+
```js
|
|
612
|
+
import { SplitEngine } from 'vectrojs'; // browsers: from 'vectrojs/browser'
|
|
613
|
+
|
|
614
|
+
const engine = new SplitEngine({ workers: 4 }); // default: one worker per core
|
|
615
|
+
const orders = await engine.streamAsync({
|
|
616
|
+
format: 'ndjson', // or 'csv' (no header)
|
|
617
|
+
columns: { ts: 'number', price: 'number', qty: 'number', region: 'category' },
|
|
618
|
+
keepRows: 2_000_000, // or keepMs: 60_000, timeColumn: 'ts'
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// each worker reads its own feed: nothing passes through this thread
|
|
622
|
+
await Promise.all(engine.workers.map((_, w) =>
|
|
623
|
+
orders.connect(w, { url: `wss://feed.example.com/orders?part=${w}` }))); // or { host, port } for TCP (Node)
|
|
624
|
+
// ...or push chunks you receive yourself: socket.on('data', (c) => orders.push(c))
|
|
625
|
+
|
|
626
|
+
// running totals, updated as rows arrive
|
|
627
|
+
const live = await orders.windowAsync({
|
|
628
|
+
by: 'region', every: 1000, last: 300_000, timeColumn: 'ts',
|
|
629
|
+
agg: { orders: 'count()', revenue: 'sum(price * qty)', avg: 'mean(price)' },
|
|
630
|
+
});
|
|
631
|
+
await live.readAsync(); // per region, last 5 minutes (~1 ms)
|
|
632
|
+
await live.readAsync({ series: true }); // per second and region, for charts
|
|
633
|
+
|
|
634
|
+
await orders.filter("region == 'EU'").aggAsync({ big: 'count(price > 900)' }); // any query, over the rows kept
|
|
635
|
+
const fixed = await orders.snapshotAsync(); // a fixed copy, for derive and toRows
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
- **JSON lines** are parsed byte by byte straight into columns (no object per
|
|
639
|
+
row): keys in any order, extra and nested keys skipped, `null` and missing
|
|
640
|
+
values empty, bad lines counted (`badLines`) and skipped.
|
|
641
|
+
- **Windows** keep one small total per (slice of `every` ms, group) in each
|
|
642
|
+
worker, so reading costs about a millisecond, and the totals outlive rows
|
|
643
|
+
dropped by `keepRows`. The window ends at the newest time seen.
|
|
644
|
+
- **Backpressure (TCP and push):** when a worker has more than 4 MB waiting to
|
|
645
|
+
be parsed, it pauses its socket (or delays its reply to `push`) until it's
|
|
646
|
+
down to 1 MB. TCP then slows the sender. WebSockets can't be paused, so a
|
|
647
|
+
WebSocket feed faster than the workers grows their backlog.
|
|
648
|
+
- **Queries go first:** parsing runs one batch per event-loop turn, so a query
|
|
649
|
+
waits for at most one batch.
|
|
650
|
+
|
|
651
|
+
**In a browser** (`examples/stream/`, Chrome, Web Workers each reading its own
|
|
652
|
+
WebSocket of JSON lines): with 4 workers it kept up with the feed's
|
|
653
|
+
**2 million orders per second**; with 8 workers it took in **3.5–3.6 million per
|
|
654
|
+
second** (the test feed, one Node thread writing JSON, is the likely limit).
|
|
655
|
+
The page never waited more than 4 ms, and the 30-second window read in
|
|
656
|
+
0.1–11 ms. Run it: `npm run serve:split`, then open
|
|
657
|
+
`/examples/stream/?workers=8&rate=500000`.
|
|
658
|
+
|
|
659
|
+
**In Node** (`npm run bench:stream`, M1 Pro, 7 workers, 10M rows kept,
|
|
660
|
+
localhost TCP, a live query every 250 ms over every kept row):
|
|
661
|
+
|
|
662
|
+
| Receiver | Rows/s taken in | Query median / max | Main thread stalled up to |
|
|
663
|
+
|---|---|---|---|
|
|
664
|
+
| Plain Node, 1 connection (split/Number parsing, a loop per query) | 3.2–3.3M | 19–20 / 23 ms | 40–41 ms |
|
|
665
|
+
| Vectro, 1 connection, chunks pushed to workers | 17.0–17.5M | 13–14 / 22–32 ms | 6–9 ms |
|
|
666
|
+
| Vectro, 1 connection per worker | 16.7–18.5M | 12–14 / 29–62 ms | 12 ms (19–47 ms on an overloaded machine) |
|
|
667
|
+
|
|
668
|
+
The producer runs on the same machine, so all 8 cores are busy. JSON lines
|
|
669
|
+
(`FORMAT=ndjson`): Vectro took in 9.3–10.3M rows/s against 2.45M for plain Node
|
|
670
|
+
using `JSON.parse` per line, with live queries at 12–14 ms (plain: 18.5 ms).
|
|
671
|
+
|
|
672
|
+
### Binary streams: Emitter and `format: 'vectro'`
|
|
673
|
+
|
|
674
|
+
When you control both ends, skip text. An `Emitter` (Node) sends column
|
|
675
|
+
batches as **Vectro frames**: numbers as raw float64, categories as u32 codes
|
|
676
|
+
with each label sent once per connection, every section 8-byte aligned. The
|
|
677
|
+
receiving worker copies number columns straight into memory with no parsing.
|
|
678
|
+
|
|
679
|
+
```js
|
|
680
|
+
import { Emitter, SplitEngine } from 'vectrojs';
|
|
681
|
+
|
|
682
|
+
// sender
|
|
683
|
+
const feed = await Emitter.listen({ port: 9000, columns: { ts: 'number', price: 'number', region: 'category' } });
|
|
684
|
+
await feed.write({ ts, price, region: ['EU', 'US', ...] }); // Float64Array / arrays, same length
|
|
685
|
+
await feed.write({ ts, price, region: { codes, labels: REGIONS } }); // faster: codes into a labels array
|
|
686
|
+
|
|
687
|
+
// receiver: one connection per worker; the emitter deals batches round-robin
|
|
688
|
+
const engine = new SplitEngine();
|
|
689
|
+
const orders = await engine.streamAsync({ format: 'vectro', columns: { ts: 'number', price: 'number', region: 'category' } });
|
|
690
|
+
await Promise.all(engine.workers.map((_, w) => orders.connect(w, { host: 'feed.local', port: 9000 })));
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`write()` waits when every connection has 8 MB unsent (TCP backpressure), and
|
|
694
|
+
large batches go out in frames of at most 65,536 rows. Over WebSockets, send
|
|
695
|
+
each frame as one binary message. The frame layout is documented at the top of
|
|
696
|
+
`src/frames.mjs` (`BatchEncoder` / `FrameReader` work without the Emitter too).
|
|
697
|
+
|
|
698
|
+
**Measured** (`FORMAT=vectro npm run bench:stream`, M1 Pro, localhost, a live
|
|
699
|
+
query every 250 ms; the machine was busy, load average ~19, so CSV measured
|
|
700
|
+
5.4–6.1M rows/s at the same time instead of its usual 17M):
|
|
701
|
+
|
|
702
|
+
| | Rows/s |
|
|
703
|
+
|---|---|
|
|
704
|
+
| CSV lines, 7 workers (same busy machine) | 5.4–6.1M |
|
|
705
|
+
| Emitter → 7 workers, end to end (Vectro frames or Arrow) | 63–78M |
|
|
706
|
+
| Emitter alone (one thread, to a process that discards), labels as text / as codes | 22M / 113–117M |
|
|
707
|
+
| Receivers alone (pre-encoded frames from one test thread): 1 / 4 / 7 workers | 29M / 88M / 100M+ |
|
|
708
|
+
|
|
709
|
+
One sender thread is enough on one machine: frames are built without zero-filling
|
|
710
|
+
and category codes pass straight through, so it outruns the receivers. Sender
|
|
711
|
+
threads behind one redirecting port were tried (branch `experiment/threaded-emitter`)
|
|
712
|
+
and measured no gain.
|
|
713
|
+
|
|
714
|
+
## Apache Arrow
|
|
715
|
+
|
|
716
|
+
Vectro reads and writes Arrow IPC, the binary table format of pandas, Polars,
|
|
717
|
+
DuckDB, pyarrow and Arrow Flight: the stream format and the file format
|
|
718
|
+
(`.arrow` / `.feather`), including LZ4-compressed files. No dependencies.
|
|
719
|
+
|
|
720
|
+
```js
|
|
721
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
722
|
+
import { Engine, readArrow, writeArrow } from 'vectrojs';
|
|
723
|
+
|
|
724
|
+
const engine = new Engine();
|
|
725
|
+
const sales = engine.fromArrow(readFileSync('sales.feather')); // or columns: ['price', 'region']
|
|
726
|
+
writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true })); // pandas.read_feather opens it
|
|
727
|
+
// split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
|
|
728
|
+
|
|
729
|
+
// streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
|
|
730
|
+
const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
|
|
731
|
+
// ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
- **Types:** ints, floats (16/32/64-bit), bools, dates, times, timestamps and
|
|
735
|
+
durations become number columns (times in milliseconds, nulls as NaN);
|
|
736
|
+
strings and dictionary-encoded strings become category columns (nulls as
|
|
737
|
+
`''`). Lists, structs, decimals and binary are refused unless left out with
|
|
738
|
+
`columns`. 64-bit integers are exact up to 2^53.
|
|
739
|
+
- **Writing:** numbers as float64 (float32 tables as float32), categories as
|
|
740
|
+
dictionary-encoded strings, which pandas opens as `Categorical`.
|
|
741
|
+
- **Checked against pyarrow 26:** files pyarrow writes (every type above, nulls,
|
|
742
|
+
delta and replaced dictionaries, LZ4, pandas `to_feather`) read back exactly
|
|
743
|
+
(`test/fixtures/arrow`, made by `scripts/make-arrow-fixtures.py`), and pyarrow
|
|
744
|
+
and pandas read Vectro's files back exactly.
|
|
745
|
+
- **Speed** (M1 Pro, busy machine): a 10M-row, 5-column Feather file loads in
|
|
746
|
+
~0.43 s uncompressed and ~1.1 s with LZ4 (pyarrow, with native threads:
|
|
747
|
+
0.16 s / 0.07 s). Arrow streams from the Emitter: 71–75M rows/s into 7
|
|
748
|
+
workers, the same as Vectro frames. ZSTD-compressed files aren't supported yet.
|
|
749
|
+
|
|
750
|
+
## Runtimes
|
|
751
|
+
|
|
752
|
+
| Import | Where | How it runs |
|
|
753
|
+
|---|---|---|
|
|
754
|
+
| `vectrojs` | Node | Worker threads on all cores, shared memory |
|
|
755
|
+
| `vectrojs/browser` → `Engine` | Pages with COOP/COEP headers | Web Workers, shared memory (async API) |
|
|
756
|
+
| `vectrojs/browser` → `SplitEngine` | Any page | Each Web Worker owns a share of the rows; async API |
|
|
757
|
+
| `vectrojs/portable` | Cloudflare Workers and other edge runtimes | One thread; a precompiled 11 KB module plus a vectorized interpreter |
|
|
758
|
+
|
|
759
|
+
Tested in Node 20, 22, 24 and 25 (Linux, macOS, Windows), Chrome, Safari 26.5, Firefox 158, Safari on iOS (real
|
|
760
|
+
iPhone) and Cloudflare Workers.
|
|
761
|
+
|
|
762
|
+
## Performance (10M rows, Apple M1 Pro, Node)
|
|
763
|
+
|
|
764
|
+
| Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
|
|
765
|
+
|---|---|---|---|---|
|
|
766
|
+
| Filtered revenue | 3.3 ms | 9.9 ms | 757 ms | 65 ms |
|
|
767
|
+
| Dashboard, 4 aggregates | 3.6 ms | 22.8 ms | 345 ms | 169 ms |
|
|
768
|
+
| Group by region (8 groups) | 7.7 ms | 35.1 ms | 2.75 s | 1.02 s |
|
|
769
|
+
| Group by customer (100K groups) | 11.0 ms | 11.7 ms | 4.32 s | 723 ms |
|
|
770
|
+
| Filter into a new table | 5.4 ms | 59.6 ms | | 123 ms |
|
|
771
|
+
| New column | 3.3 ms | 25.6 ms | 1.75 s | 114 ms |
|
|
772
|
+
|
|
773
|
+
Best of 5, each system using all 8 cores (Arquero is single-threaded), the
|
|
774
|
+
same answers from every system. \*SQLite from an earlier run
|
|
775
|
+
(`npm run bench:sqlite`).
|
|
776
|
+
|
|
777
|
+
Run them yourself: `cd compare && npm install && npm run bench`. Timings vary
|
|
778
|
+
with the machine's state (battery, background load), so compare systems in the
|
|
779
|
+
same run. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
|
|
780
|
+
on all 20 queries), joins (`bench-join.mjs`: faster on all 4), group-by
|
|
781
|
+
(`bench-groupby.mjs`: faster on all 4, 2.1–2.7x with millions of groups),
|
|
782
|
+
distinct counts (`bench-distinct.mjs`: level or faster on all 4). On files
|
|
783
|
+
on disk (`bench-dataset.mjs`) DuckDB is still faster at joins with a GROUP
|
|
784
|
+
BY on the joined table (1.2–1.3x).
|
|
785
|
+
|
|
786
|
+
## Layout
|
|
787
|
+
|
|
788
|
+
```
|
|
789
|
+
src/ the engine (Node, browser and portable entry points); src/serve: the HTTP (and OData), PostgreSQL and Flight SQL servers
|
|
790
|
+
bin/ the vectro command (vectro serve)
|
|
791
|
+
mcp/ the MCP server for AI assistants
|
|
792
|
+
dist/ vectro.wasm, the precompiled kernels for edge runtimes (npm run build)
|
|
793
|
+
test/ correctness tests; VECTRO_MODE=single|interp|edge|browser-sim selects a mode
|
|
794
|
+
bench/ Vectro-only benchmarks (threads, async, skewed filters, CSV)
|
|
795
|
+
compare/ Vectro vs DuckDB, SQLite and Arquero (own package.json)
|
|
796
|
+
examples/ browser pages (threads, split tables, correctness check, phone test)
|
|
797
|
+
and a Cloudflare Worker
|
|
798
|
+
scripts/ build-wasm.mjs, make-check-expected.mjs
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
## Development
|
|
802
|
+
|
|
803
|
+
```bash
|
|
804
|
+
npm run test:all # every test in all five modes (Node, single thread, interpreter, edge, browser-sim)
|
|
805
|
+
npm run bench # Node benchmarks
|
|
806
|
+
npm run serve:split # browser pages without isolation headers, port 8767
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Browser checks: run a server, then open `/examples/check/` in a browser to run
|
|
810
|
+
the same queries on every engine it supports and compare them with Node's
|
|
811
|
+
answers.
|
|
812
|
+
|
|
813
|
+
## Not done yet
|
|
814
|
+
|
|
815
|
+
64-bit integers beyond 2^53 and time zones other than UTC. A dataset is
|
|
816
|
+
written by one writer at a time; a join on text keys needs its
|
|
817
|
+
other side to fit in memory, exact distinct counts of non-ID values don't
|
|
818
|
+
spill, and `approxDistinct` on a dataset counts exactly. Joins are for normal
|
|
819
|
+
tables (not split tables or streams yet). Split tables
|
|
820
|
+
have `topAsync` but no full sort.
|
|
821
|
+
Binary streams have number and category columns only; Arrow ZSTD compression isn't supported.
|
|
822
|
+
Streams take flat records only (no nested JSON fields), and windows group by
|
|
823
|
+
at most one category column. Memory is capped at 4 GB per engine (datasets go past it) and freed with `dispose()`.
|