Multi-core analytics inside any JavaScript app. Vectro keeps data in columns and compiles each query, filter included, into one SIMD WebAssembly loop, then runs it on every CPU core. It runs in Node, in browsers (any page) and on Cloudflare Workers, with no native addon, compiler or server.
Version 0.2.1. Node 20+ and modern browsers. MIT license.
Website and docs: vectrojs.dev. Quick start · API reference · Race it in your browser · Benchmarks
import { Engine } from 'vectrojs';
const engine = new Engine(); // all cores
const orders = engine.readCSV('orders.csv'); // parsed in parallel
orders.filter("region == 'EU' && price > 100").agg({
orders: 'count()',
revenue: 'sum(price * qty)',
avgPrice: 'mean(price)',
}); // ~3 ms on 10M rows
orders.groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();npm install vectrojsSave this as try.mjs (or set "type": "module" in package.json) and run node try.mjs:
import { Engine, sql } from 'vectrojs';
const engine = new Engine(); // uses every core
const orders = engine.table({
region: ['EU', 'US', 'EU', 'APAC', 'US'],
price: [120, 80, 300, 45, 99],
});
console.log(sql('SELECT region, COUNT(*) AS n, SUM(price) AS revenue FROM orders GROUP BY region ORDER BY revenue DESC', { orders }).toRows());
// [ { region: 'EU', n: 2, revenue: 420 }, { region: 'US', n: 2, revenue: 179 }, { region: 'APAC', n: 1, revenue: 45 } ]
await engine.close();For your own data, engine.readCSV('orders.csv') reads a CSV file on every core.
Serve a folder of CSV / Arrow / .vectro files to BI tools, with no code:
npx -y -p vectrojs vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
psql -h 127.0.0.1 -p 5433 -U vectro vectro # or Power BI, Tableau, Metabase, Grafana| Part | Status |
|---|---|
| Engine: tables, filters, group by, joins, sort, CSV / Arrow / JSON lines | Stable: answers checked against plain JavaScript and DuckDB |
SQL (sql(...)) |
Stable for what's listed under SQL below; anything else gives a clear error |
Datasets bigger than memory (.vectro files) |
Stable; the file format may still change before 1.0 |
vectro serve: HTTP, OData, PostgreSQL protocol, Arrow Flight SQL |
Beta: checked with the drivers BI tools use, not yet with every application |
Power BI .mez and Tableau .taco connectors |
Experimental: not signed, not yet tried in the applications |
| Streams, vector search, MCP server | Experimental: the API may change |
What changed: CHANGELOG.md. Problems and ideas: issues.
-
Tables:
engine.table({ price: Float64Array, region: string[] }),engine.readCSV(path),engine.parseCSV(text | bytes). -
Queries:
filter(expr)(lazy),agg({ name: 'sum(expr)' | 'mean' | 'min' | 'max' | 'count()' }),sum/mean/min/max/count(expr),groupBy(keys).agg(...),derive(name, expr),collect(),toRows(limit). -
Group by any keys:
groupBy('region'),groupBy(['region', 'day']),groupBy('account_id'). Keys are category columns or any numbers (big IDs, amounts, negatives; NaN is its own group, -0 groups with 0). Groups come back sorted by key: numbers ascending (NaN last), text in the order its labels first appeared (sort the result for A to Z). Small keys (a category, or whole numbers in a narrow range, alone or combined) are group numbers as they are. With many groups on a big table, every thread splits the rows into partitions by key range; each partition is added up in a small hash table of its own (in the CPU's cache) and its groups sorted, so the groups come out in key order with no sort of the whole result, and sums are added in row order (the same answer on any number of threads). Fewer groups are numbered by every thread at once in one shared hash table. On 10M rows vs DuckDB (compare/bench-groupby.mjs, answers checked):Keys Vectro DuckDB region + day (2,920 pairs) 6 ms 39 ms price (100K amounts) 27 ms 49 ms account (1M big IDs) 36 ms 88 ms account + region (5.7M pairs) 50 ms 139 ms Vectro's groups also come back sorted by key; DuckDB's come back unsorted.
-
Missing values are NaN for numbers and
''for text.sum,mean,minandmaxskip them (meandivides by the values present;min/maxof nothing is NaN, thesumof nothing inaggis 0),count(x)andcountDistinct(x)count the values present (blank text included as missing) andcount(x > 5)the rows where the condition holds.isMissing(x)andcoalesce(x, y, 0)(first value present) work in any expression. CSV blanks,nullin arrays, rows and JSON, and Arrow nulls load as missing. In SQL, a comparison with a missing value is unknown, soNOT (x > 5),x <> 5,x NOT IN (...),NOT BETWEENandNOT LIKEdrop those rows, as in other databases; in table expressions comparisons follow JavaScript (x != 5and!(x > 5)are true where x is missing), so add&& !isMissing(x)where that matters. -
Dates and times: a date column holds milliseconds since 1970 (UTC). Arrays of
Dateobjects become date columns (ortable(cols, { dates: ['ts'] })for ms or ISO text); CSV columns of ISO dates are found by themselves (ortypes: { ts: 'date' }); Arrow dates and timestamps load as dates; streams takecolumns: { ts: 'date' }(ISO text or ms). Compare with ISO text,filter("ts >= '2024-03-01'"),date('2024-03-01T12:00:00Z'), or aDateparam.year(ts),month(ts),day(ts),hour(ts),minute(ts),second(ts),weekday(ts)(1 = Monday) anddayStart(ts),hourStart(ts),weekStart(ts),monthStart(ts)compile to plain arithmetic in the fused kernels:sum(year(ts))over 10M rows takes 26 ms (a JS loop withDate: 800 ms). Rows come back withDateobjects; Arrow writesTimestamp(ms, UTC). -
Distinct counts:
countDistinct(expr)inaggandgroupBy().agg({ customers: 'countDistinct(customer_id)' }), exact; missing values (NaN) don't count, as in SQL. On split tables and streams the workers' distinct values are merged exactly.approxDistinct(expr)gives the same answer on a table, and on split tables, streams and live windows it uses HyperLogLog sketches (4 KB, within ~1.6%): each worker keeps one per time slice and group, and sketches merge, so "distinct customers in the last 5 minutes" is a cheap read.DistinctSketch(exported) builds, merges and ships sketches yourself. Whole numbers in a small range (IDs, codes) are counted with one byte per (group, value), marked on every thread; other values are numbered by the hash table first, or, with many of them, sorted by key range on every thread (as a group-by with many groups) and counted in runs; values a whole step apart (IDs like 1e9 + 7919k) get the byte map too. On 10M rows vs DuckDB (compare/bench-distinct.mjs): 365 days 5 ms vs 5; regions per day 9 ms vs 36; 1M accounts 11 ms vs 51 (1M random IDs up to 2^40, no common step, from an earlier run: 24 ms vs 46); accounts per region (5.7M pairs) 35 ms vs 103. -
Compressed columns:
table.compress()(orengine.table(cols, { compress: true }),parseCSV(text, { compress: true })) stores each number and category column in 1, 2 or 4 bytes per row when every value comes back exactly: small counts and category codes in 1 byte, prices in cents and timestamps on whole seconds in 4, missing values as a reserved code. It is in place (views keep working) and checked value by value with the kernels' own arithmetic, so answers never change. The fused kernels read packed columns directly (SIMD widen, convert, scale), so do group-by kernels; sort, top and collect copy the codes, so results stay small; joins and other operations decode what they need (a join's result is unpacked). 10M orders (npm run bench:pack): 458 MB → 143 MB (3.2x smaller), compressed in 0.34 s; queries run about as fast as on plain columns (a new column 4.0 → 3.0 ms, filtered revenue 2.4 → 2.9 ms, group by region 4.7 → 4.4 ms, top 10 8.4 → 12.9 ms). The whole test suite also runs with every table compressed (npm run test:compressed). -
SQL:
sql(text, { orders, customers })runs a SELECT over your tables and returns a lazy query (.toRows(),.collect(),.explain()):import { sql } from 'vectrojs'; sql(`SELECT c.tier, COUNT(*) AS n, SUM(o.price * o.qty) AS revenue FROM orders o JOIN customers c ON o.cust = c.cust WHERE o.region = 'EU' AND o.ts >= DATE '2024-01-01' GROUP BY c.tier HAVING COUNT(*) > 100 ORDER BY revenue DESC LIMIT 5`, { orders, customers }).toRows();
SELECT [DISTINCT], FROM with aliases, [INNER | LEFT | RIGHT | FULL [OUTER] | CROSS] JOIN ... ON / USING, WHERE, GROUP BY (columns, expressions, select aliases or positions), HAVING, ORDER BY ... ASC/DESC [NULLS FIRST | LAST], LIMIT / OFFSET / FETCH FIRST; COUNT(*), COUNT(x), COUNT(DISTINCT x), SUM, AVG, MIN, MAX, STDDEV / VARIANCE (sample and population, exact in two passes), APPROX_COUNT_DISTINCT, MEDIAN, PERCENTILE_CONT / PERCENTILE_DISC WITHIN GROUP (ORDER BY x), QUANTILE_CONT anywhere in SELECT / HAVING / ORDER BY, and
FILTER (WHERE ...)on aggregates; BETWEEN, IN, LIKE (andESCAPE), IS [NOT] NULL, CASE, % (the exact remainder), COALESCE, NULLIF, ROUND (to negative digits too), LEAST, GREATEST, CAST (to integers rounding as DuckDB does, missing when out of range; DECIMAL(p, s) rounds to s digits; REAL is 32-bit), DATE / TIMESTAMP literals, INTERVAL '7 days', EXTRACT, DATE_TRUNC, YEAR(), MONTH()... JOIN ... ON takes any condition in every kind of join: equalities (hashed; an expression on each side too,ON a.k + 1 = b.k), comparisons and BETWEEN (a range join),IS NOT DISTINCT FROM, OR. Column names can use any script (Größe). It compiles to the planner below, so SQL gets every optimization, on tables and on datasets bigger than memory.Subqueries and set operations: WITH (CTEs, with column names too), subqueries in FROM and JOIN, UNION [ALL] / INTERSECT / EXCEPT, SELECT without FROM, and in expressions IN (SELECT ...), EXISTS (SELECT ...) and (SELECT one value), correlated too when the link is an equality (
WHERE o.cust = c.cust). Every subquery becomes a join, so the whole statement is one plan with no row-by-row loops.Window functions:
f(...) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE BETWEEN ... AND ...])with ROW_NUMBER, RANK, DENSE_RANK, PERCENT_RANK, CUME_DIST, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE, SUM, AVG, MIN, MAX, COUNT; frames by rows or by value (RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW), over grouped results too (RANK() OVER (PARTITION BY region ORDER BY SUM(qty) DESC)). Running and moving sums use prefix sums, and moving MIN / MAX use a monotonic queue, so each window costs one pass after its sort.sql(`SELECT region, cust, spend FROM ( SELECT region, cust, SUM(price) AS spend, RANK() OVER (PARTITION BY region ORDER BY SUM(price) DESC) AS r FROM orders WHERE cust IN (SELECT cust FROM customers WHERE tier = 1) GROUP BY region, cust) t WHERE r <= 3 ORDER BY region, spend DESC`, { orders, customers }).toRows();
Text: UPPER, LOWER, TRIM / LTRIM / RTRIM (and
TRIM(LEADING 'x' FROM s)), SUBSTRING / SUBSTR (andFROM ... FOR), LEFT, RIGHT, LENGTH, REPLACE, REVERSE, REPEAT, LPAD, RPAD, SPLIT_PART, STRPOS / POSITION(a IN b), STARTS_WITH, ENDS_WITH, CONTAINS, CONCAT, CONCAT_WS,||, ASCII, CHR, REGEXP_MATCHES / REGEXP_REPLACE / REGEXP_EXTRACT (JavaScript syntax; a pattern whose repeated group itself repeats or has|, such as(a+)+$, is refused, as it could take very long), STRFTIME, ILIKE, text CASE / COALESCE / NULLIF,CAST(x AS VARCHAR)and text to numbers or dates, and comparing text with</>. Each function runs once per distinct value, not per row (a category column has few labels), and the answer is a category column again, so it groups, sorts and joins like any other:GROUP BY UPPER(region)on 10M rows 31 ms (DuckDB 47),region || '-' || qtyas a key 85 ms (106),STRFTIME(ts, '%Y-%m')51 ms (70; times are rounded to the month first), ILIKE on a column 30 ms (54). A CASE whose results are all quoted labels is worked out as label codes in the fused pass (CASE text + group 6.2 ms vs 25). Missing text is'', as everywhere in Vectro, and positions count characters from 1 as in DuckDB.Outer joins: RIGHT and FULL JOIN (with ON or USING) are the matching rows (FULL: plus the left rows without a match) followed by the right rows whose keys no left row has. The join marks which right rows it matched as it probes, so the rows without a match come from the same pass (RIGHT JOIN
- group 30 vs 64 ms; FULL JOIN + count 19 vs 29 ms).
compare/check-sql.mjsruns 40 such queries (CTEs, set operations, correlated subqueries, windows, percentiles, text functions on awkward strings, outer joins, missing values) in Vectro and DuckDB on the same 200K rows; every answer is the same. Not yet: INTERSECT ALL / EXCEPT ALL, correlation on anything but equalities, MIN / MAX of text, and STRING_AGG. The MCP server has asqltool. The same SQL text vs DuckDB on 10M orders + 100K customers (compare/bench-sql.mjs, answers checked, best of 5): Vectro is faster on all 20 queries (1.3–6.0x): filtered totals 4.3 vs 10.1 ms, group by region 7.4 vs 36.8 ms, join + filter + group 19.8 vs 32.8 ms, top 10 customers 13.1 vs 50.3 ms, distinct customers per month 34.2 vs 82.5 ms, top 20 rows 9.1 vs 25.3 ms, CASE inside SUM 6.6 vs 39.6 ms, IN (subquery) 23.4 vs 29.7 ms, correlated subquery 16.8 vs 61.4 ms, top 3 per region with RANK 38 vs 139 ms, ROW_NUMBER over all rows 129 vs 221 ms, running total by day 9.8 vs 17.3 ms, MEDIAN per region 39.1 vs 83.9 ms, FULL JOIN + count 19.3 vs 29.3 ms. A filter likern <= 3on ROW_NUMBER or RANK keeps only the rows that can rank that high before sorting (picked on every thread); percentiles of a few partitions count values in buckets on every thread and select only within the wanted buckets. Window functions and percentiles gather all their rows, so on a dataset they need its rows (the columns used) to fit in memory. -
Lazy queries (a planner):
t.lazy()records a chain (filter,derive,select,join,groupBy().agg,sort,limit/top) and optimizes it as a whole before running it withcollect(),toRows(),agg()orcount()(and…Async). Derived columns are inlined where they are used (never built unless needed as a column, and then only for matching rows); filters move below derives, selects and sorts, belowgroupBywhen they only use its keys, and into each side of a join; joins and sorts copy only the columns used later;sort+limitbecomes top-k.explain()prints the plan. Every kernel also computes constant parts once and repeated parts once per row (sum(year(ts)): 26 → 7.5 ms).npm run bench:plan, 10M orders, step by step vs lazy: derive + filter + total 19.8 → 2.9 ms; join then filter 144 → 22 ms; sort then first 10 177 → 12 ms. -
Joins:
orders.join(customers, { on: 'customer_id' }), several keys (on: ['region', 'day']), different names (on: { left: 'cust', right: 'id' }),how: 'inner'(default),'left','right'or'full'; besides keys,range: { left, right, op }(a.t < b.t: a range join),where: 'expr'(checked on each pair) andcross: truejoin on any condition (right / full with those: tables in memory, not datasets). Put the smaller table on the right: its keys go into one hash table built on every thread, then every left row looks its key up on every thread, and the joined columns are gathered on every thread. Text keys match by label; a missing (NaN) key never matches; a left join fills unmatched right columns with NaN or''. Rows come in left-table order. On 10M orders vs DuckDB (compare/bench-join.mjs, joined table built in full, answers checked):Join Vectro DuckDB × 100K customers 77 ms 210 ms × 1M accounts 151 ms 251 ms left join, half the keys match 82 ms 206 ms two keys: region + day (2,920 rows) 61 ms 189 ms -
Text functions on category columns:
startsWith(col, 'x'),endsWith,contains,in(col, 'a', 'b')andlike(col, 'EU-%'). They run once per distinct label (not per row) and become a few range checks on the codes, so text filters run as fast as number filters. -
Zone maps: each column keeps the min and max of every 16,384-row block (built on the first filter that uses it; streams keep them up to date as batches arrive). A filter with
column < numberparts narrows the rows it reads to the run from the first block that can match to the last (on tables of 65,536 rows or more, when that saves at least a tenth of them), which pays off on time-ordered data such as logs, orders and streams; datasets also skip whole chunks. Exact: only blocks that provably fail are skipped. -
Sort and top-k:
sort(by)andtop(k, by), e.g.top(10, '-revenue')for the 10 largest. Keys are columns or expressions ('-(price * qty)'), several keys sort in order (['region', '-price']), text sorts by label, ties keep table order and missing values come last.topdoesn't sort everything: it finds a cutoff from a sample, then collects only the rows past it, on all cores. Split tables and streams havetopAsync. Full sorts of 50,000+ rows run on every core: a parallel radix sort (11-bit digits, digits that never change are skipped, whole numbers sort as offsets), then every column is gathered in the new order on every core. Sorting 10M rows by-pricetakes ~117 ms (one thread: ~350 ms; a JavaScript comparator sort: ~4.1 s). -
Async API: every query has an
…Asyncversion that runs on the worker threads while your server or page keeps working. -
Expressions are a small, safe language: numbers, columns,
+ - * /, comparisons,&& || !,? :,abs sqrt floor ceil min max. There is noeval, so formulas typed by users or AI models are safe to run.
A dataset is a table kept on disk, in one file, read a chunk at a time. Its size has no limit from memory (an engine holds at most 4 GB), and it opens instantly. Node only.
engine.csvToDataset('orders.csv', 'orders.vectro'); // a CSV of any size, read in pieces (or csvToDatasetAsync)
const w = engine.createDataset('events.vectro'); // or append tables (or { column: values })
w.append(todaysEvents); w.close();
engine.writeDataset('eu.vectro', orders.filter("region == 'EU'")); // a table, or a query
const orders = engine.openDataset('orders.vectro');
orders.filter("ts >= '2024-06-01'").groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();
await orders.lazy().join(customers, { on: 'cust' }).top(10, '-price').toRowsAsync();
sql('SELECT region, COUNT(DISTINCT cust) FROM orders GROUP BY region', { orders }).toRows();-
The file: chunks of 4M rows. Each column of each chunk is stored compressed (see compressed columns) and packed to the bit: a quantity 1..9 takes 4 bits, a price in cents up to 999.99 takes 17, a column that counts up at a fixed step (row numbers, times every second) takes none. A chunk loads with one read per column, then every thread unpacks the bits back to the 1-, 2- or 4-byte codes the kernels read; nothing is parsed. The footer holds the schema, one dictionary per text column and the min / max of every column for each chunk and each 64K-row block.
-
Queries are the usual lazy plans, run chunk by chunk with the same kernels on every core: only the columns a query uses are read, and chunks and blocks the filters rule out are not read at all. Totals add up across chunks (a mean is a sum and a count), groups merge by key,
top(k)keeps the best k so far and passes the k-th value to later chunks as a filter,limitstops reading once it has its rows. With the…Asynccalls the next chunk is read while the current one is computed. -
Many groups: keys that are labels or whole-number IDs add straight into one array per aggregation (each thread its own range of groups); exact distinct counts of IDs use one bitmap. Other keys keep partial groups in memory and, past 8M groups, spill them to disk split by key range; each range is merged on its own (split again if still too big), in key order.
-
Joins: the other table (or a dataset's matching rows) is the build side, hashed once for all chunks (IDs in a small range use a direct lookup array instead). When both sides are datasets and the other side has more than 16M matching rows, both go to disk split by key range and each range is joined on its own (a "grace" hash join): 100M orders x 20M accounts in 7.6 s with 406 MB of engine memory. Rows then come out range by range rather than in the left table's order.
-
Results come back as normal tables.
writeDataset(path, query)writes a query's result as a new dataset chunk by chunk, so it needn't fit in memory either; a fullsortwritten this way is an external sort (by ranges of the first key). -
In the browser (and other runtimes without Node's files), datasets open read-only from a File the user picked or dropped, a Blob, or a URL (HTTP range requests, neighbouring reads merged): only the chunks and columns a query needs are fetched. Queries use the async calls.
import { Engine, sql } from 'vectrojs/browser'; const engine = new Engine(); const orders = await engine.openDataset('/data/orders.vectro'); // or input.files[0] await sql('SELECT region, COUNT(*) FROM orders GROUP BY region', { orders }).toRowsAsync();
A billion rows (a 5.1 GB file) open in a page in 51 ms; "the last day" answers in 24 ms reading 0.3 MB; a full group-by streams 2.7 GB in 7 s from a local server.
Pages also write datasets, into the browser's own file storage (OPFS), to a file the user chose (
showSaveFilePicker()), or into memory (memoryFile(), then.blob()). Bytes stream to disk chunk by chunk, so a CSV bigger than memory converts too:await engine.csvToDataset(droppedFile, 'orders.vectro', { onProgress: (p) => show(p) }); await engine.writeDataset('eu.vectro', orders.filter("region == 'EU'")); const orders = await engine.openStoredDataset('orders.vectro'); await engine.storedDatasets(); // [{ name, size }]; also storedDatasetFile(name) to download
A 246 MB CSV (5M rows) becomes a 24 MB dataset in browser storage in 2.8 s. Try
examples/dataset/(serve it withnode examples/browser-threads/serve.mjs 8780 --data <folder of .vectro files>). Spilling works in the browser too: huge group-bys, sorts written as datasets and joins of two big datasets put their partitions in a temporary folder in browser storage, removed when the query ends. -
Also:
append: trueadds to an existing dataset;ds.lastRuntells how many chunks and bytes a query read;explain()shows the plan; the MCP server opens.vectrofiles.
Measured against DuckDB 2.0 on its own database file, both on disk, the same
SQL on identical rows (compare/bench-dataset.mjs: the rows come from whole-number
formulas exact in both, answers compared; best of 3):
| Query | 100M rows: Vectro | DuckDB | 1 billion rows: Vectro | DuckDB |
|---|---|---|---|---|
| scan everything (count, revenue) | 70 ms | 73 ms | 478 ms | 662 ms |
| filter: region and quantity | 100 ms | 87 ms | 691 ms | 729 ms |
| the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 4 ms |
| group by region | 142 ms | 149 ms | 1.05 s | 1.25 s |
| top 10 of 1M customers | 529 ms | 637 ms | 4.39 s | 4.50 s |
| top 10 rows by price | 15 ms | 100 ms | 11 ms | 843 ms |
| join 1M customers, group by tier | 126 ms | 85 ms | 880 ms | 627 ms |
| distinct customers per region | 268 ms | 650 ms | 2.43 s | 6.84 s |
| file on disk | 0.5 GB | 0.5 GB | 5.1 GB | 5.4 GB |
| peak memory (RSS) | 344 MB | 2.9 GB | 0.37 GB | 6.9 GB |
A billion rows would take 37 GB as plain columns in memory (this laptop has 16 GB); Vectro's engine never used more than 163 MB for them. At a billion rows Vectro is level or faster on 7 of the 8 queries (only the join is slower, 1.4x); at 100M rows it is slower on three: the filter (1.1x), the last day (3 vs 1 ms) and the join (1.5x). Top rows read only the blocks of rows whose stored min / max can still beat the rows kept so far (here 1 chunk of 239), so they take the same few milliseconds at any size. A join whose right side has unique whole-number keys and a GROUP BY on its columns looks each left key's group up in one array instead of joining rows; DuckDB is still faster there (1.4x at a billion rows, 1.5x at 100M).
npx -y -p vectrojs vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
curl -G http://127.0.0.1:8787/sql --data-urlencode 'q=SELECT region, COUNT(*) AS n FROM orders GROUP BY 1' -d format=csv| Endpoint | Gives |
|---|---|
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. Missing values are null (empty in CSV); ±Infinity is "Infinity" / "-Infinity" |
GET /tables, GET /tables/<name> |
table names, rows, column types |
GET /tables/<name>/rows?format=csv |
a whole table |
POST /reload |
reads the data folder again (new files become tables) |
GET /odata, /odata/$metadata, /odata/<table>?$filter=... |
an OData v4 feed (below) |
- Excel (Windows and Mac): Data > Get Data > From Web, then the
/sql?...&format=csvURL. 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. - OData feed (
/odata, OData v4): Power BI and Excel (Get Data > OData feed,http://host:8787/odata) and Tableau (OData connector) see every table with its column types, and their filters, column choices, sorting and paging run in Vectro ($filter,$select,$orderby,$top,$skip,$count,$apply=groupby(...)/aggregate(...), pages of 50,000 rows with@odata.nextLink). With--token, give any user name and the token as the password (Basic). Checked with Microsoft's OData libraries (the CSDL validator and Simple.OData.Client, incompare/check-drivers.mjs). On 10M orders: a filtered page answers in 44 ms, a 50,000-row page in 0.13 s, a group-by in 28 ms; for importing all of a very large table, use the PostgreSQL connector (pages far into a table cost more). - 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(). - In your own app:
import { serve } from 'vectrojs/serve'; await serve({ tables: { orders }, port: 8787 }). - Safety: listens on 127.0.0.1 unless
--hostis given;--token <secret>requiresAuthorization: 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).
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.
npx -y -p vectrojs vectro serve --root ~/data --pg 5433 --user me --password secret
psql -h 127.0.0.1 -p 5433 -U me vectroVectro answers as a PostgreSQL 16 database (read only), so anything with a
Postgres connector connects live: Power BI (Get Data > PostgreSQL
database, server localhost:5433, database vectro; Import or DirectQuery),
Tableau (Connect > PostgreSQL), Metabase, Grafana, DBeaver, Excel through
ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro's engine.
- The protocol: SCRAM-SHA-256 (or
--auth md5 | password), TLS with--tls-cert/--tls-key, the simple and the extended query protocol (prepared statements,$1parameters, text and binary values, portals read a part at a time), BEGIN / COMMIT, SET / SHOW, DECLARE / FETCH cursors, cancel requests, PostgreSQL error codes. In code:import { servePg } from 'vectrojs/serve'. - Types: whole-number columns are
bigint, other numbersdouble precision, texttext, datestimestamp(datewhen every value is at midnight); answers are typed the same way from the query (COUNT is bigint, AVG double, comparisons boolean), before any row is read. - The catalog:
information_schemaandpg_cataloghold the served tables, so tools list tables and columns; the long metadata queries drivers send (Npgsql's type loading, pgJDBC's getTables / getColumns) are answered as PostgreSQL would; other catalog questions (keys, indexes, comments) get empty answers. psql's\dtand\dnwork. - Checked with the real drivers (
compare/check-drivers.mjs): pgJDBC 42.7.4, which Tableau, DBeaver and Metabase use, and Npgsql 4.0.17, the one Power BI's PostgreSQL connector ships with; both get what PostgreSQL gives, including Power BI's own catalog queries and folded group-bys. Neither Power BI nor Tableau itself has been tried yet. - Speed (10M orders, same machine as a local PostgreSQL 16 with the same rows): a group by region answers in 20 to 80 ms through psql against PostgreSQL's 480 to 720 ms; 1M rows reach psql in 1.0 s (PostgreSQL 0.7 s) and a Java program through pgJDBC in 0.9 s (PostgreSQL 0.6 to 1.0 s).
- Safety: without
--passwordonly this machine may connect; with--host 0.0.0.0a password is required. Writes (INSERT, CREATE...) are refused. - Live dashboards (examples/bi: a 10M-order
sample shop, Docker setup, and steps for Power BI, Tableau, Metabase,
Grafana and Excel): Metabase v0.64 reads the tables and runs the questions
its editor builds (joins included) in 80 to 600 ms; Grafana 13's
time-series dashboard (its
$__timeFilter/$__timeGroupAliasmacros, a moving average) answers each panel in 20 to 220 ms.
- Packaged connectors (connectors):
Vectro.mezputs Vectro in Power BI's Get Data list (tables through the OData feed, or SQL of your own; anonymous or a token) andvectro.tacoin Tableau's Connect list (the PostgreSQL protocol with Tableau's PostgreSQL SQL). Built bynode connectors/build.mjs; the Power Query code parses with Microsoft's parser, the Tableau files validate against the Connector SDK's schemas. Not signed yet, and not yet loaded in the real applications.
npx -y -p vectrojs vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)import adbc_driver_flightsql.dbapi as flight # pip install adbc-driver-flightsql
with flight.connect('grpc://localhost:31337') as conn:
df = conn.cursor().execute('SELECT region, SUM(qty) FROM orders GROUP BY 1').fetch_df() # pandas
# polars.read_database(sql, conn); duckdb.sql('... FROM arrow') on fetch_arrow_table()Flight SQL answers in Arrow, column by column, so nothing is turned into
rows on either side: the fastest way to move Vectro's results into Python or
any Arrow tool. gRPC (HTTP/2) and protobuf are written in src/serve/flight.mjs
with no dependencies. Served: statements and prepared statements, the
catalog (catalogs, schemas, tables with their Arrow schemas, table types;
adbc_get_objects works), sign-in with a user name and password (Handshake,
then a bearer token). Columns come typed: whole numbers int64, other numbers
float64, text utf8, dates timestamp[ms, UTC] (or date32), booleans bool.
Checked with the ADBC Flight SQL driver, pandas, Polars, DuckDB and
pyarrow.flight (compare/check-drivers.mjs). On 10M orders: a group-by
answers in 97 ms; all 10M rows (9 columns, 790 MB as Arrow) arrive in 2.9 s,
against 19.3 s for the same rows through the PostgreSQL protocol (pgJDBC).
Not yet: parameters for prepared statements (DoPut), writes.
mcp/server.mjs lets Claude, ChatGPT or any MCP client analyze your data
files with Vectro: it loads CSV, TSV or JSON-lines files and answers questions
on all cores, locally. It needs no dependencies.
# Claude Code
claude mcp add vectro -- node /path/to/vectro/mcp/server.mjs --root ~/data// Claude Desktop: claude_desktop_config.json
{ "mcpServers": { "vectro": { "command": "node", "args": ["/path/to/vectro/mcp/server.mjs", "--root", "/Users/you/data"] } } }Tools: load_file, list_tables, describe_table (column stats, common
labels), query (filter, group_by, aggregates, sort, limit),
search_similar, add_column, drop_table. A JSON-lines file with an
embedding field (an array of numbers) also gets a vector index, so the
assistant can ask for the rows most similar to a row (or to a vector), with a
filter. The assistant can only read files under --root, and its
expressions are Vectro's small math language (no code runs). Mistakes come
back as tool errors the assistant can read and fix. Answers include ms, the
time Vectro took.
npm run bench:zones (10M time-ordered rows, one every 10 ms, M1 Pro):
| Query | With zone maps | Without | Faster |
|---|---|---|---|
| Revenue, last hour | 0.68 ms | 4.00 ms | 5.9× |
| Revenue, last 6 hours | 1.13 ms | 3.22 ms | 2.8× |
| One hour in the middle, EU only | 0.42 ms | 3.73 ms | 8.8× |
| By region, last hour | 1.16 ms | 5.00 ms | 4.3× |
| Top 10 orders, last hour | 4.15 ms | 5.82 ms | 1.4× |
On a live stream of 7M rows (7 workers), "last minute" and "last 10 minutes" took 0.21–0.25 ms against 1.6–1.7 ms for a full scan (6–8×). The first filtered query on a column of a 10M-row table builds its zone maps (~25–50 ms, once).
Exact nearest-neighbour search over embeddings, on every core, in any runtime (Node, browsers, edge), with filters on any other column:
import { Engine, VectorIndex } from 'vectrojs';
const index = await VectorIndex.create(new Engine(), {
vectors: embeddings, // number[][] or one flat Float32Array
dim: 384,
metric: 'cosine', // or 'dot', 'l2'
columns: { id: ids, title: titles, year: years },
});
await index.searchAsync(queryEmbedding, { k: 10, filter: 'year >= 2020' });
// -> [{ id, title, year, score }, ...] best firstEmbeddings are stored as float32 columns, the score is a fused expression
computed 24 dimensions per pass with the query as scalars, and top(k) picks
the best, so it reuses the compiled kernels, filters and split tables (pass a
SplitEngine to use Web Workers on any page). It is exact, not approximate:
no index to build or tune, results identical to brute force.
npm run bench:vectors (M1 Pro, 384 dimensions, top 10, cosine):
| Embeddings | Vectro | Plain JS loop (Float32Array) |
|---|---|---|
| 100,000 (154 MB) | 9.3 ms | 36.6 ms (3.9×) |
| 200,000 (307 MB) | 13.6 ms | 72.6 ms (5.3×) |
| 500,000 (768 MB), an earlier run | 22.8 ms | 179.3 ms (7.9×) |
With a SplitEngine, every worker searches its own rows in one round trip
and the page keeps the best k. In Chrome (examples/check/vectors.html?n=200000),
8 Web Workers took 12.4 ms against 21 ms on the page's own thread; at 50,000
vectors the two were about equal (10.1 vs 8.8 ms). Keep the tab in front when
measuring: browsers slow down hidden tabs.
table.select(...columns) (a narrow view, no copy) and
splitTable.topAsync(k, by, { columns }) keep top-k cheap on wide tables.
A stream is a live table spread over split-table workers. Rows arrive as CSV lines or JSON lines and are parsed in 256 KB batches into growing columns. Queries are the same as for any split table.
import { SplitEngine } from 'vectrojs'; // browsers: from 'vectrojs/browser'
const engine = new SplitEngine({ workers: 4 }); // default: one worker per core
const orders = await engine.streamAsync({
format: 'ndjson', // or 'csv' (no header)
columns: { ts: 'number', price: 'number', qty: 'number', region: 'category' },
keepRows: 2_000_000, // or keepMs: 60_000, timeColumn: 'ts'
});
// each worker reads its own feed: nothing passes through this thread
await Promise.all(engine.workers.map((_, w) =>
orders.connect(w, { url: `wss://feed.example.com/orders?part=${w}` }))); // or { host, port } for TCP (Node)
// ...or push chunks you receive yourself: socket.on('data', (c) => orders.push(c))
// running totals, updated as rows arrive
const live = await orders.windowAsync({
by: 'region', every: 1000, last: 300_000, timeColumn: 'ts',
agg: { orders: 'count()', revenue: 'sum(price * qty)', avg: 'mean(price)' },
});
await live.readAsync(); // per region, last 5 minutes (~1 ms)
await live.readAsync({ series: true }); // per second and region, for charts
await orders.filter("region == 'EU'").aggAsync({ big: 'count(price > 900)' }); // any query, over the rows kept
const fixed = await orders.snapshotAsync(); // a fixed copy, for derive and toRows- JSON lines are parsed byte by byte straight into columns (no object per
row): keys in any order, extra and nested keys skipped,
nulland missing values empty, bad lines counted (badLines) and skipped. - Windows keep one small total per (slice of
everyms, group) in each worker, so reading costs about a millisecond, and the totals outlive rows dropped bykeepRows. The window ends at the newest time seen. - Backpressure (TCP and push): when a worker has more than 4 MB waiting to
be parsed, it pauses its socket (or delays its reply to
push) until it's down to 1 MB. TCP then slows the sender. WebSockets can't be paused, so a WebSocket feed faster than the workers grows their backlog. - Queries go first: parsing runs one batch per event-loop turn, so a query waits for at most one batch.
In a browser (examples/stream/, Chrome, Web Workers each reading its own
WebSocket of JSON lines): with 4 workers it kept up with the feed's
2 million orders per second; with 8 workers it took in 3.5–3.6 million per
second (the test feed, one Node thread writing JSON, is the likely limit).
The page never waited more than 4 ms, and the 30-second window read in
0.1–11 ms. Run it: npm run serve:split, then open
/examples/stream/?workers=8&rate=500000.
In Node (npm run bench:stream, M1 Pro, 7 workers, 10M rows kept,
localhost TCP, a live query every 250 ms over every kept row):
| Receiver | Rows/s taken in | Query median / max | Main thread stalled up to |
|---|---|---|---|
| Plain Node, 1 connection (split/Number parsing, a loop per query) | 3.2–3.3M | 19–20 / 23 ms | 40–41 ms |
| Vectro, 1 connection, chunks pushed to workers | 17.0–17.5M | 13–14 / 22–32 ms | 6–9 ms |
| Vectro, 1 connection per worker | 16.7–18.5M | 12–14 / 29–62 ms | 12 ms (19–47 ms on an overloaded machine) |
The producer runs on the same machine, so all 8 cores are busy. JSON lines
(FORMAT=ndjson): Vectro took in 9.3–10.3M rows/s against 2.45M for plain Node
using JSON.parse per line, with live queries at 12–14 ms (plain: 18.5 ms).
When you control both ends, skip text. An Emitter (Node) sends column
batches as Vectro frames: numbers as raw float64, categories as u32 codes
with each label sent once per connection, every section 8-byte aligned. The
receiving worker copies number columns straight into memory with no parsing.
import { Emitter, SplitEngine } from 'vectrojs';
// sender
const feed = await Emitter.listen({ port: 9000, columns: { ts: 'number', price: 'number', region: 'category' } });
await feed.write({ ts, price, region: ['EU', 'US', ...] }); // Float64Array / arrays, same length
await feed.write({ ts, price, region: { codes, labels: REGIONS } }); // faster: codes into a labels array
// receiver: one connection per worker; the emitter deals batches round-robin
const engine = new SplitEngine();
const orders = await engine.streamAsync({ format: 'vectro', columns: { ts: 'number', price: 'number', region: 'category' } });
await Promise.all(engine.workers.map((_, w) => orders.connect(w, { host: 'feed.local', port: 9000 })));write() waits when every connection has 8 MB unsent (TCP backpressure), and
large batches go out in frames of at most 65,536 rows. Over WebSockets, send
each frame as one binary message. The frame layout is documented at the top of
src/frames.mjs (BatchEncoder / FrameReader work without the Emitter too).
Measured (FORMAT=vectro npm run bench:stream, M1 Pro, localhost, a live
query every 250 ms; the machine was busy, load average ~19, so CSV measured
5.4–6.1M rows/s at the same time instead of its usual 17M):
| Rows/s | |
|---|---|
| CSV lines, 7 workers (same busy machine) | 5.4–6.1M |
| Emitter → 7 workers, end to end (Vectro frames or Arrow) | 63–78M |
| Emitter alone (one thread, to a process that discards), labels as text / as codes | 22M / 113–117M |
| Receivers alone (pre-encoded frames from one test thread): 1 / 4 / 7 workers | 29M / 88M / 100M+ |
One sender thread is enough on one machine: frames are built without zero-filling
and category codes pass straight through, so it outruns the receivers. Sender
threads behind one redirecting port were tried (branch experiment/threaded-emitter)
and measured no gain.
Vectro reads and writes Arrow IPC, the binary table format of pandas, Polars,
DuckDB, pyarrow and Arrow Flight: the stream format and the file format
(.arrow / .feather), including LZ4-compressed files. No dependencies.
import { readFileSync, writeFileSync } from 'node:fs';
import { Engine, readArrow, writeArrow } from 'vectrojs';
const engine = new Engine();
const sales = engine.fromArrow(readFileSync('sales.feather')); // or columns: ['price', 'region']
writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true })); // pandas.read_feather opens it
// split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
// streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
const split = new SplitEngine(); // import { SplitEngine } from 'vectrojs'
const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
// ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })- Types: ints, floats (16/32/64-bit), bools, dates, times, timestamps and
durations become number columns (times in milliseconds, nulls as NaN);
strings and dictionary-encoded strings become category columns (nulls as
''). Lists, structs, decimals and binary are refused unless left out withcolumns. 64-bit integers are exact up to 2^53. - Writing: numbers as float64 (float32 tables as float32), categories as
dictionary-encoded strings, which pandas opens as
Categorical. - Checked against pyarrow 26: files pyarrow writes (every type above, nulls,
delta and replaced dictionaries, LZ4, pandas
to_feather) read back exactly (test/fixtures/arrow, made byscripts/make-arrow-fixtures.py), and pyarrow and pandas read Vectro's files back exactly. - Speed (M1 Pro, busy machine): a 10M-row, 5-column Feather file loads in ~0.43 s uncompressed and ~1.1 s with LZ4 (pyarrow, with native threads: 0.16 s / 0.07 s). Arrow streams from the Emitter: 71–75M rows/s into 7 workers, the same as Vectro frames. ZSTD-compressed files aren't supported yet.
| Import | Where | How it runs |
|---|---|---|
vectrojs |
Node | Worker threads on all cores, shared memory |
vectrojs/browser → Engine |
Pages with COOP/COEP headers | Web Workers, shared memory (async API) |
vectrojs/browser → SplitEngine |
Any page | Each Web Worker owns a share of the rows; async API |
vectrojs/portable |
Cloudflare Workers and other edge runtimes | One thread; a precompiled 11 KB module plus a vectorized interpreter |
Tested in Node 20, 22, 24 and 25 (Linux, macOS, Windows), Chrome, Safari 26.5, Firefox 158, Safari on iOS (real iPhone) and Cloudflare Workers.
| Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
|---|---|---|---|---|
| Filtered revenue | 3.2 ms | 10.1 ms | 772 ms | 74 ms |
| Dashboard, 4 aggregates | 4.1 ms | 23.9 ms | 359 ms | 171 ms |
| Group by region (8 groups) | 6.5 ms | 39.7 ms | 2.59 s | 1.00 s |
| Group by customer (100K groups) | 8.1 ms | 13.2 ms | 3.90 s | 772 ms |
| Filter into a new table | 6.4 ms | 55.7 ms | 540 ms | 124 ms |
| New column | 3.7 ms | 32.3 ms | 1.50 s | 125 ms |
Best of 5, each system using all 8 cores (Arquero is single-threaded), the
same answers from every system. Vectro is 1.6–8.7x faster than DuckDB here
(3–9x on five of the six, 1.6x with 100K groups). *SQLite from a separate
run (npm run bench:sqlite, best of 3).
Run them yourself: cd compare && npm install && npm run bench. Timings vary
with the machine's state (battery, background load), so compare systems in the
same run. The numbers in this README were measured on 1 November 2026
(commit b2a3033). These runs shared the laptop with other jobs (load average 3–8 for
the in-memory runs, up to 15 for the billion-row run), and ratios move by up
to about ±30% between runs. More comparisons in compare/: SQL (bench-sql.mjs: Vectro faster
on all 20 queries), joins (bench-join.mjs: faster on all 4), group-by
(bench-groupby.mjs: faster on all 4, 2.4–2.8x with millions of groups),
distinct counts (bench-distinct.mjs: level or faster on all 4). On files
on disk (bench-dataset.mjs) DuckDB is still faster at joins with a GROUP
BY on the joined table (1.4–1.5x), and at 100M rows also at the filter (1.1x)
and the last-day lookup (3 vs 1 ms).
src/ the engine (Node, browser and portable entry points); src/serve: the HTTP (and OData), PostgreSQL and Flight SQL servers
bin/ the vectro command (vectro serve)
mcp/ the MCP server for AI assistants
dist/ vectro.wasm, the precompiled kernels for edge runtimes (npm run build)
test/ correctness tests; VECTRO_MODE=single|interp|edge|browser-sim selects a mode
bench/ Vectro-only benchmarks (threads, async, skewed filters, CSV)
compare/ Vectro vs DuckDB, SQLite and Arquero (own package.json)
examples/ browser pages (threads, split tables, correctness check, phone test)
and a Cloudflare Worker
scripts/ build-wasm.mjs, make-check-expected.mjs
npm run test:all # every test in all five modes (Node, single thread, interpreter, edge, browser-sim)
npm run bench # Node benchmarks
npm run serve:split # browser pages without isolation headers, port 8767Browser checks: run a server, then open /examples/check/ in a browser to run
the same queries on every engine it supports and compare them with Node's
answers.
64-bit integers beyond 2^53 and time zones other than UTC. A dataset is
written by one writer at a time; a join on text keys needs its
other side to fit in memory, exact distinct counts of non-ID values don't
spill, and approxDistinct on a dataset counts exactly. Joins are for normal
tables (not split tables or streams yet). Split tables
have topAsync but no full sort.
Binary streams have number and category columns only; Arrow ZSTD compression isn't supported.
Streams take flat records only (no nested JSON fields), and windows group by
at most one category column. Memory is capped at 4 GB per engine (datasets go past it) and freed with dispose();
a table dropped without it is freed once JavaScript collects it, which can be much later.

