vectrojs 0.1.0 → 0.1.2

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 CHANGED
@@ -1,5 +1,59 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ A correctness release, from comparing about 1,700 new SQL queries with DuckDB
6
+ on the same data. No API changes. Every case below now gives DuckDB's answer,
7
+ or a clear error.
8
+
9
+ **Engine**
10
+ - A query too big for the 4GB memory (a join with billions of output rows)
11
+ now fails with `out of memory (4GB limit)`. Before, the size wrapped around
12
+ in the allocator, and later queries on the same engine could return wrong
13
+ numbers.
14
+ - Missing text (`''`) sorts last, up or down, as missing numbers do, in
15
+ `sort`, `top`, window ordering and SQL `ORDER BY`.
16
+ - `null` and `undefined` in a text column load as missing text, also when
17
+ the column starts with one (`engine.table`, `tableFromRows`).
18
+ - A missing text key never matches in a join, as the docs say.
19
+ - A filter with no column in it (`'1'`, `'1 == 0'`) keeps every row or none,
20
+ instead of failing.
21
+ - Comparing a text column with `<` or `>` and then grouping gives a clear
22
+ error instead of overflowing the stack. On datasets, the unknown-column
23
+ error lists every column.
24
+
25
+ **SQL**
26
+ - Conditions follow SQL's three values everywhere: a condition on a missing
27
+ value is NULL when selected, `IS TRUE` / `IS FALSE` treat it as neither,
28
+ comparing two conditions is unknown if either is, and a `NULL` literal in
29
+ `AND` / `OR` is unknown rather than true.
30
+ - `WHERE TRUE`, `WHERE 1 = 1`, `WHERE NULL` and other constant conditions work.
31
+ - `ORDER BY text NULLS FIRST` works.
32
+ - `x NOT IN (SELECT ...)` keeps every row when the subquery is empty and no
33
+ row when it contains a NULL.
34
+ - `LEAST` and `GREATEST` skip missing values (NULL only if all are).
35
+ - The same aggregate twice (`SUM(x) AS a, SUM(x) AS b`) gives NULL both times
36
+ over no values.
37
+ - Subqueries in the SELECT list or HAVING of a GROUP BY query work.
38
+
39
+ ## 0.1.1
40
+
41
+ A performance release. No API changes.
42
+
43
+ **Faster group-by**
44
+ - SQL `SUM` no longer computes a hidden count of non-missing values when the
45
+ column has none missing, and sum, mean, min and max skip the per-row
46
+ missing-value check on such columns.
47
+ - On many threads, a group-by remembers its key range and merges the
48
+ threads' partial results with a WebAssembly kernel instead of a JavaScript
49
+ loop. This also removes the 31-thread limit for queries with many groups.
50
+ - Measured on 2M orders (`SELECT product_id, sum(amount) ... GROUP BY 1`,
51
+ `compare/bench-2m-groupby.mjs`, numbers in `compare/results.json`): with
52
+ 1,000 products, 3.0 ms on 1 thread (DuckDB 2.0: 6.8 ms) and 2.3 ms on 8
53
+ threads (DuckDB 2.0: 2.4 ms). Before this release Vectro was 1.9x slower
54
+ than DuckDB on 8 threads in this case; it is now level or faster at every
55
+ size from 100 to 100,000 products.
56
+
3
57
  ## 0.1.0 (first release)
4
58
 
5
59
  The first public release. Everything below is new.
package/README.md CHANGED
@@ -5,7 +5,7 @@ and compiles each query, filter included, into one SIMD WebAssembly loop, then
5
5
  runs it on every CPU core. It runs in Node, in browsers (any page) and on
6
6
  Cloudflare Workers, with no native addon, compiler or server.
7
7
 
8
- > Version 0.1.0, the first release. Node 20+ and modern browsers. MIT license.
8
+ > Version 0.1.2. Node 20+ and modern browsers. MIT license.
9
9
 
10
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
11
 
@@ -30,18 +30,27 @@ orders.groupBy('region').agg({ revenue: 'sum(price * qty)' }).toRows();
30
30
  npm install vectrojs
31
31
  ```
32
32
 
33
+ Save this as `try.mjs` (or set `"type": "module"` in package.json) and run `node try.mjs`:
34
+
33
35
  ```js
34
36
  import { Engine, sql } from 'vectrojs';
35
37
 
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();
38
+ const engine = new Engine(); // uses every core
39
+ const orders = engine.table({
40
+ region: ['EU', 'US', 'EU', 'APAC', 'US'],
41
+ price: [120, 80, 300, 45, 99],
42
+ });
43
+ console.log(sql('SELECT region, COUNT(*) AS n, SUM(price) AS revenue FROM orders GROUP BY region ORDER BY revenue DESC', { orders }).toRows());
44
+ // [ { region: 'EU', n: 2, revenue: 420 }, { region: 'US', n: 2, revenue: 179 }, { region: 'APAC', n: 1, revenue: 45 } ]
45
+ await engine.close();
39
46
  ```
40
47
 
48
+ For your own data, `engine.readCSV('orders.csv')` reads a CSV file on every core.
49
+
41
50
  Serve a folder of CSV / Arrow / `.vectro` files to BI tools, with no code:
42
51
 
43
52
  ```sh
44
- npx vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
53
+ npx -y -p vectrojs vectro serve --root ./data --pg 5433 # HTTP on 8787, PostgreSQL on 5433
45
54
  psql -h 127.0.0.1 -p 5433 -U vectro vectro # or Power BI, Tableau, Metabase, Grafana
46
55
  ```
47
56
 
@@ -82,10 +91,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
82
91
 
83
92
  | Keys | Vectro | DuckDB |
84
93
  |---|---|---|
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 |
94
+ | region + day (2,920 pairs) | 6 ms | 39 ms |
95
+ | price (100K amounts) | 27 ms | 49 ms |
96
+ | account (1M big IDs) | 36 ms | 88 ms |
97
+ | account + region (5.7M pairs) | 50 ms | 139 ms |
89
98
 
90
99
  Vectro's groups also come back sorted by key; DuckDB's come back unsorted.
91
100
  - **Missing values** are NaN for numbers and `''` for text. `sum`, `mean`,
@@ -124,9 +133,9 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
124
133
  by the hash table first, or, with many of them, sorted by key range on
125
134
  every thread (as a group-by with many groups) and counted in runs; values
126
135
  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.
136
+ vs DuckDB (`compare/bench-distinct.mjs`): 365 days 5 ms vs 5; regions per
137
+ day 9 ms vs 36; 1M accounts 11 ms vs 51 (1M random IDs up to 2^40, no common
138
+ step, from an earlier run: 24 ms vs 46); accounts per region (5.7M pairs) 35 ms vs 103.
130
139
  - **Compressed columns:** `table.compress()` (or `engine.table(cols, { compress:
131
140
  true })`, `parseCSV(text, { compress: true })`) stores each number and
132
141
  category column in 1, 2 or 4 bytes per row when every value comes back
@@ -198,11 +207,11 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
198
207
  dates, and comparing text with `<` / `>`. Each function runs once per
199
208
  distinct value, not per row (a category column has few labels), and the
200
209
  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
210
+ other: `GROUP BY UPPER(region)` on 10M rows 31 ms (DuckDB 47), `region ||
211
+ '-' || qty` as a key 85 ms (106), `STRFTIME(ts, '%Y-%m')` 51 ms (70; times
212
+ are rounded to the month first), ILIKE on a column 30 ms (54). A CASE whose
204
213
  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
214
+ pass (CASE text + group 6.2 ms vs 25). Missing text
206
215
  is `''`, as everywhere in Vectro, and positions count characters from 1 as
207
216
  in DuckDB.
208
217
 
@@ -210,7 +219,7 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
210
219
  rows (FULL: plus the left rows without a match) followed by the right rows
211
220
  whose keys no left row has. The join marks which right rows it matched as
212
221
  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).
222
+ + group 30 vs 64 ms; FULL JOIN + count 19 vs 29 ms).
214
223
 
215
224
  `compare/check-sql.mjs` runs 40 such queries (CTEs, set operations,
216
225
  correlated subqueries, windows, percentiles, text functions on awkward
@@ -219,15 +228,15 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
219
228
  correlation on anything but equalities, MIN / MAX of text, and STRING_AGG.
220
229
  The MCP server has
221
230
  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
+ (`compare/bench-sql.mjs`, answers checked, best of 5): Vectro is faster on all
232
+ 20 queries (1.3–6.0x): filtered totals 4.3 vs 10.1 ms, group by region 7.4 vs
233
+ 36.8 ms, join + filter + group 19.8 vs 32.8 ms, top 10 customers 13.1 vs
234
+ 50.3 ms, distinct customers per month 34.2 vs 82.5 ms, top 20 rows 9.1 vs
235
+ 25.3 ms, CASE inside SUM 6.6 vs 39.6 ms, IN (subquery) 23.4 vs 29.7 ms,
236
+ correlated subquery 16.8 vs 61.4 ms, top 3 per region with RANK 38 vs
237
+ 139 ms, ROW_NUMBER over all rows 129 vs 221 ms, running total by day 9.8
238
+ vs 17.3 ms, MEDIAN per region 39.1 vs 83.9 ms, FULL JOIN + count 19.3 vs
239
+ 29.3 ms. A filter like `rn <= 3` on ROW_NUMBER or RANK keeps
231
240
  only the rows that can rank that high before sorting (picked on every
232
241
  thread); percentiles of a few partitions count values in buckets on every
233
242
  thread and select only within the wanted buckets. Window functions and
@@ -257,10 +266,10 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
257
266
 
258
267
  | Join | Vectro | DuckDB |
259
268
  |---|---|---|
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 |
269
+ | × 100K customers | 77 ms | 210 ms |
270
+ | × 1M accounts | 151 ms | 251 ms |
271
+ | left join, half the keys match | 82 ms | 206 ms |
272
+ | two keys: region + day (2,920 rows) | 61 ms | 189 ms |
264
273
  - **Text functions** on category columns: `startsWith(col, 'x')`,
265
274
  `endsWith`, `contains`, `in(col, 'a', 'b')` and `like(col, 'EU-%')`. They run
266
275
  once per distinct label (not per row) and become a few range checks on the
@@ -282,8 +291,8 @@ What changed: [CHANGELOG.md](CHANGELOG.md). Problems and ideas:
282
291
  of 50,000+ rows run on every core: a parallel radix sort (11-bit digits,
283
292
  digits that never change are skipped, whole numbers sort as offsets), then
284
293
  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).
294
+ by `-price` takes ~117 ms (one thread: ~350 ms; a JavaScript comparator
295
+ sort: ~4.1 s).
287
296
  - **Async API:** every query has an `…Async` version that runs on the worker
288
297
  threads while your server or page keeps working.
289
298
  - **Expressions** are a small, safe language: numbers, columns, `+ - * /`,
@@ -383,31 +392,33 @@ formulas exact in both, answers compared; best of 3):
383
392
 
384
393
  | Query | 100M rows: Vectro | DuckDB | 1 billion rows: Vectro | DuckDB |
385
394
  |---|---|---|---|---|
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 |
395
+ | scan everything (count, revenue) | 70 ms | 73 ms | 478 ms | 662 ms |
396
+ | filter: region and quantity | 100 ms | 87 ms | 691 ms | 729 ms |
397
+ | the last day (1 chunk read) | 3 ms | 1 ms | 3 ms | 4 ms |
398
+ | group by region | 142 ms | 149 ms | 1.05 s | 1.25 s |
399
+ | top 10 of 1M customers | 529 ms | 637 ms | 4.39 s | 4.50 s |
400
+ | top 10 rows by price | 15 ms | 100 ms | 11 ms | 843 ms |
401
+ | join 1M customers, group by tier | 126 ms | 85 ms | 880 ms | 627 ms |
402
+ | distinct customers per region | 268 ms | 650 ms | 2.43 s | 6.84 s |
394
403
  | 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 |
404
+ | peak memory (RSS) | 344 MB | 2.9 GB | **0.37 GB** | 6.9 GB |
396
405
 
397
406
  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
407
+ 16 GB); Vectro's engine never used more than 163 MB for them. At a billion
408
+ rows Vectro is level or faster on 7 of the 8 queries (only the join is
409
+ slower, 1.4x); at 100M rows it is slower on three: the filter (1.1x), the
410
+ last day (3 vs 1 ms) and the join (1.5x). Top rows read only the blocks of rows whose
400
411
  stored min / max can still beat the rows kept so far (here 1 chunk of 239),
401
412
  so they take the same few milliseconds at any size. A join whose right side
402
413
  has unique whole-number keys and a GROUP BY on its columns looks each left
403
414
  key's group up in one array instead of joining rows; DuckDB is still faster
404
- there (1.3x).
415
+ there (1.4x at a billion rows, 1.5x at 100M).
405
416
 
406
417
  ## SQL over HTTP: Excel, Power BI, Google Sheets, pandas
407
418
 
408
419
  ```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'
420
+ npx -y -p vectrojs vectro serve --root ~/data # every CSV, TSV, JSON-lines, Arrow / Feather and .vectro file is a table
421
+ 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
411
422
  ```
412
423
 
413
424
  | Endpoint | Gives |
@@ -439,7 +450,7 @@ On 10M orders: a group by region answers in 9 ms over HTTP; 1M rows export in 0.
439
450
  ## The PostgreSQL protocol: Power BI, Tableau, Metabase, Grafana, psql
440
451
 
441
452
  ```sh
442
- npx vectro serve --root ~/data --pg 5433 --user me --password secret
453
+ npx -y -p vectrojs vectro serve --root ~/data --pg 5433 --user me --password secret
443
454
  psql -h 127.0.0.1 -p 5433 -U me vectro
444
455
  ```
445
456
 
@@ -494,7 +505,7 @@ ODBC, and every driver. Queries arrive in each tool's own SQL and run on Vectro'
494
505
  ## Arrow Flight SQL: pandas, Polars, DuckDB
495
506
 
496
507
  ```sh
497
- npx vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
508
+ npx -y -p vectrojs vectro serve --root ~/data --flight 31337 # grpc://localhost:31337 (--user / --password, --tls-cert / --tls-key)
498
509
  ```
499
510
 
500
511
  ```python
@@ -589,9 +600,9 @@ no index to build or tune, results identical to brute force.
589
600
 
590
601
  | Embeddings | Vectro | Plain JS loop (Float32Array) |
591
602
  |---|---|---|
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×) |
603
+ | 100,000 (154 MB) | 9.3 ms | 36.6 ms (3.9×) |
604
+ | 200,000 (307 MB) | 13.6 ms | 72.6 ms (5.3×) |
605
+ | 500,000 (768 MB), an earlier run | 22.8 ms | 179.3 ms (7.9×) |
595
606
 
596
607
  With a `SplitEngine`, every worker searches its own rows in one round trip
597
608
  and the page keeps the best k. In Chrome (`examples/check/vectors.html?n=200000`),
@@ -727,6 +738,7 @@ writeFileSync('eu.feather', sales.filter("region == 'EU'").toArrow({ file: true
727
738
  // split tables: await engine.fromArrowAsync(bytes), await table.toArrowAsync()
728
739
 
729
740
  // streams: any Arrow IPC stream over TCP or WebSocket, e.g. from pyarrow
741
+ const split = new SplitEngine(); // import { SplitEngine } from 'vectrojs'
730
742
  const orders = await split.streamAsync({ format: 'arrow', columns: { ts: 'number', region: 'category' } });
731
743
  // ...or Vectro's Emitter speaking Arrow: Emitter.listen({ columns, format: 'arrow' })
732
744
  ```
@@ -763,25 +775,30 @@ iPhone) and Cloudflare Workers.
763
775
 
764
776
  | Query | Vectro | DuckDB 2.0 | SQLite* | Arquero |
765
777
  |---|---|---|---|---|
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 |
778
+ | Filtered revenue | 3.2 ms | 10.1 ms | 772 ms | 74 ms |
779
+ | Dashboard, 4 aggregates | 4.1 ms | 23.9 ms | 359 ms | 171 ms |
780
+ | Group by region (8 groups) | 6.5 ms | 39.7 ms | 2.59 s | 1.00 s |
781
+ | Group by customer (100K groups) | 8.1 ms | 13.2 ms | 3.90 s | 772 ms |
782
+ | Filter into a new table | 6.4 ms | 55.7 ms | 540 ms | 124 ms |
783
+ | New column | 3.7 ms | 32.3 ms | 1.50 s | 125 ms |
772
784
 
773
785
  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`).
786
+ same answers from every system. Vectro is 1.6–8.7x faster than DuckDB here
787
+ (3–9x on five of the six, 1.6x with 100K groups). \*SQLite from a separate
788
+ run (`npm run bench:sqlite`, best of 3).
776
789
 
777
790
  Run them yourself: `cd compare && npm install && npm run bench`. Timings vary
778
791
  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
792
+ same run. The numbers in this README were measured on 1 November 2026
793
+ (commit b2a3033). These runs shared the laptop with other jobs (load average 3–8 for
794
+ the in-memory runs, up to 15 for the billion-row run), and ratios move by up
795
+ to about ±30% between runs. More comparisons in `compare/`: SQL (`bench-sql.mjs`: Vectro faster
780
796
  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),
797
+ (`bench-groupby.mjs`: faster on all 4, 2.4–2.8x with millions of groups),
782
798
  distinct counts (`bench-distinct.mjs`: level or faster on all 4). On files
783
799
  on disk (`bench-dataset.mjs`) DuckDB is still faster at joins with a GROUP
784
- BY on the joined table (1.2–1.3x).
800
+ BY on the joined table (1.4–1.5x), and at 100M rows also at the filter (1.1x)
801
+ and the last-day lookup (3 vs 1 ms).
785
802
 
786
803
  ## Layout
787
804
 
package/mcp/server.mjs CHANGED
@@ -14,7 +14,7 @@ import { createInterface } from 'node:readline';
14
14
  import { Engine, sql } from '../src/node.mjs';
15
15
  import { LoadError, safePath as safePathIn, loadFile, columnsOf, kindOf } from '../src/serve/catalog.mjs';
16
16
 
17
- const VERSION = '0.1.0';
17
+ const VERSION = '0.1.2';
18
18
  const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
19
19
  const MAX_ROWS = 500, DEFAULT_ROWS = 50;
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vectrojs",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Multi-core analytics engine for JavaScript: columnar tables, SQL and fused SIMD WebAssembly kernels for Node, browsers and edge runtimes, with no native code. Serves tables to Power BI, Tableau, Metabase, Grafana, pandas and Polars.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/dataset.mjs CHANGED
@@ -834,7 +834,12 @@ export function* runPlanG(plan, sink = null) {
834
834
  for (let n = plan; n.op !== 'scan' && n.op !== 'union'; ) { n = n.op === 'join' ? n.left : n.input; path.push(n); }
835
835
  const scan = path[path.length - 1];
836
836
  if (!scan.table?.isDataset) result = yield { run: plan };
837
- else result = yield* runChunksG(plan, path, sink);
837
+ else {
838
+ try { result = yield* runChunksG(plan, path, sink); } catch (err) { // (chunks are read with only the columns used: name them all)
839
+ if (/^vectro: unknown column /.test(err?.message)) err.message = err.message.replace(/\(columns: [^)]*\)$/, `(columns: ${scan.table.columnNames.join(', ')})`);
840
+ throw err;
841
+ }
842
+ }
838
843
  if (sink && result) { // (the result itself was built in memory)
839
844
  if (result.cols) sink.append(result);
840
845
  else sink.append(Object.fromEntries(Object.entries(result).map(([k, v]) => [k, [v]])));
package/src/engine.mjs CHANGED
@@ -32,7 +32,7 @@ const DEFAULT_MIN_PARALLEL_WASM = {
32
32
  const PAGE = 65536;
33
33
  const MAX_PAGES = 65536; // 4GB, the most a 32-bit WASM address can reach
34
34
  const HEAP_START = 64; // keep address 0 unused
35
- const align16 = (n) => (n + 15) & ~15;
35
+ const align16 = (n) => Math.ceil(n / 16) * 16; // (not bitwise: sizes may pass 2^31 bytes, and must not wrap)
36
36
 
37
37
  // Can this runtime compile WebAssembly from bytes? (Cloudflare Workers and
38
38
  // Vercel Edge can't: they only run modules bundled ahead of time.)
@@ -141,6 +141,8 @@ export class Engine {
141
141
  // WASM SIMD kernels; other shared arrays fall back to JS kernels.
142
142
  alloc(Type, n) {
143
143
  const size = align16(Math.max(16, n * Type.BYTES_PER_ELEMENT));
144
+ // too big for the 4GB memory (a huge join output, say): fail before touching the free list
145
+ if (!(size <= MAX_PAGES * PAGE - 64)) throw new RangeError('vectro: out of memory (4GB limit)');
144
146
  let ptr = -1;
145
147
  for (let i = 0; i < this.freeList.length; i++) {
146
148
  const blk = this.freeList[i];
package/src/fuse.mjs CHANGED
@@ -125,6 +125,12 @@ export function walk(n, f) {
125
125
  else if (n.t === 'cond') { walk(n.c, f); walk(n.x, f); walk(n.y, f); }
126
126
  }
127
127
 
128
+ // text left in an expression a kernel computes (a text column was compared with < or >, or
129
+ // isn't one): a clear error, not a loop between the value and condition emitters
130
+ function noText(n) {
131
+ if (n.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(n.v)} can only be compared with == or != to a text column (to compare dates, load the column as dates: { dates: ['name'] })`);
132
+ }
133
+
128
134
  function checkElementwise(n, src) {
129
135
  walk(n, (m) => {
130
136
  if (m.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(m.v)} can only be compared (== / !=) with a text column, in "${src}"`);
@@ -162,7 +168,10 @@ export function analyze(spec) {
162
168
 
163
169
  // Constant parts computed now: 2 * 3 -> 6, floor(2.5) -> 2, (1 ? x : y) -> x.
164
170
  // Same results as the kernels (IEEE doubles; f32 kernels round literals).
165
- const FOLD_BIN = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b };
171
+ const FOLD_BIN = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b,
172
+ // as the kernels compute them: 1 / 0, a comparison with NaN false (but != true), NaN counting as true in && and ||
173
+ '==': (a, b) => +(a === b), '!=': (a, b) => +(a !== b), '<': (a, b) => +(a < b), '<=': (a, b) => +(a <= b), '>': (a, b) => +(a > b), '>=': (a, b) => +(a >= b),
174
+ '&&': (a, b) => +(a !== 0 && b !== 0), '||': (a, b) => +(a !== 0 || b !== 0) };
166
175
  const FOLD_FN = { abs: Math.abs, sqrt: Math.sqrt, floor: Math.floor, ceil: Math.ceil, min: Math.min, max: Math.max };
167
176
  export function fold(n) {
168
177
  const isNum = (x) => x.t === 'num';
@@ -255,8 +264,8 @@ export class Emitter {
255
264
  this.memo.set(m, l);
256
265
  }
257
266
 
258
- vec(n, k) { return this.#reuse(n, k, V128, () => this.#vec(n, k)); }
259
- sca(n) { return this.#reuse(n, 'sca', this.ty.val, () => this.#sca(n)); }
267
+ vec(n, k) { noText(n); return this.#reuse(n, k, V128, () => this.#vec(n, k)); }
268
+ sca(n) { noText(n); return this.#reuse(n, 'sca', this.ty.val, () => this.#sca(n)); }
260
269
 
261
270
  #vec(n, k) {
262
271
  const { f, env } = this, V = VEC[this.t];
package/src/join.mjs CHANGED
@@ -415,7 +415,7 @@ function* buildRight(engine, t, L, R, lk, rk, how) {
415
415
  for (let k = 0; k < nk; k++) {
416
416
  const a = L.cols.get(lk[k]), b = R.cols.get(rk[k]);
417
417
  if (!b.dict) { build.rightKeys.push(b.data); continue; }
418
- const lut = b.dict.map((label) => a.codes?.get(label) ?? NaN); // a label the left table lacks matches nothing
418
+ const lut = b.dict.map((label) => (label === '' ? NaN : a.codes?.get(label) ?? NaN)); // missing text, or a label the left table lacks, matches nothing
419
419
  const mapped = alloc(L.type, nR);
420
420
  const src = engine.live(b.data), dst = engine.live(mapped);
421
421
  for (let i = 0; i < nR; i++) dst[i] = lut[src[i]];
package/src/sort.mjs CHANGED
@@ -22,10 +22,11 @@ export function parseKeys(by, cols) {
22
22
  }
23
23
 
24
24
  // Ranks for a category column's codes: code -> position of its label A..Z.
25
+ // Missing text ('') ranks NaN, so it comes last either way, as a missing number does.
25
26
  export function labelRanks(dict, desc) {
26
27
  const order = dict.map((_, i) => i).sort((a, b) => (dict[a] < dict[b] ? -1 : dict[a] > dict[b] ? 1 : 0));
27
28
  const rank = new Float64Array(dict.length);
28
- order.forEach((code, r) => { rank[code] = desc ? -r : r; });
29
+ order.forEach((code, r) => { rank[code] = dict[code] === '' || dict[code] == null ? NaN : desc ? -r : r; });
29
30
  return rank;
30
31
  }
31
32
 
package/src/sql.mjs CHANGED
@@ -872,20 +872,39 @@ function compileSelect(ast, env, outer, tail) {
872
872
  }
873
873
  // join rel (its columns `cols`) on outer key ASTs = rel key columns; the
874
874
  // rel's columns get fresh names; returns them
875
- function attach(rel, outerKeys, relKeys, cols) {
875
+ // a subquery's columns looked up on the outer rows by key; in a grouped SELECT (ctx.step deferred),
876
+ // done after grouping, so the columns are there for the grouped rows
877
+ function attach(rel, outerKeys, relKeys, cols, ctx) {
876
878
  const tag = n.hidden++;
877
879
  const fresh = (c) => `__s${tag}_${c}`;
878
- let rq = rel.query;
879
- const all = [...new Set([...relKeys, ...cols])];
880
- all.forEach((c) => { rq = rq.derive(fresh(c), c); });
881
- rq = rq.select(...all.map(fresh));
882
- let lefts = outerKeys.map((t) => hide(t));
883
- let rights = relKeys.map(fresh);
884
- if (!lefts.length) { const k = `__x${n.hidden++}`; q = q.derive(k, '0'); rq = rq.derive(k, '0'); lefts = [k]; rights = [k]; }
885
- q = q.join(rq, { on: { left: lefts, right: rights }, how: 'left', view: true }); // (its keys are unique: right columns looked up, left rows not copied)
880
+ const run = () => {
881
+ let rq = rel.query;
882
+ const all = [...new Set([...relKeys, ...cols])];
883
+ all.forEach((c) => { rq = rq.derive(fresh(c), c); });
884
+ rq = rq.select(...all.map(fresh));
885
+ let lefts = outerKeys.map((t) => hide(t));
886
+ let rights = relKeys.map(fresh);
887
+ if (!lefts.length) { const k = `__x${n.hidden++}`; q = q.derive(k, '0'); rq = rq.derive(k, '0'); lefts = [k]; rights = [k]; }
888
+ q = q.join(rq, { on: { left: lefts, right: rights }, how: 'left', view: true }); // (its keys are unique: right columns looked up, left rows not copied)
889
+ };
890
+ if (ctx?.step) ctx.step({ name: fresh(cols[0]), inputs: [], run }); else run();
886
891
  return cols.map(fresh);
887
892
  }
888
- function subqueryValue(e, ctx) {
893
+ // x NOT IN (subquery), as SQL has it: true where the subquery has no rows (x missing or not);
894
+ // else true only where x is present, not among the values, and none of them is missing
895
+ function notInValue(e, ctx, rel, vcol, mcol, relKeys, outerKeys) { // (rel: one row per value and key, mcol 1 on each)
896
+ const missing = rel.kinds?.get(vcol) === 'text' ? `${vcol} == ''` : `${vcol} != ${vcol}`;
897
+ let sq = rel.query;
898
+ let keys = relKeys;
899
+ if (!keys.length) { sq = sq.derive('__g', '0'); keys = ['__g']; }
900
+ sq = sq.groupBy(keys).agg({ __n: 'count()', __nn: `count(${missing})` });
901
+ const [cn, cnn] = attach({ ...rel, query: sq, names: [...keys, '__n', '__nn'] }, outerKeys, relKeys, ['__n', '__nn'], ctx);
902
+ const [m] = attach(rel, [translate(e.x, ctx), ...outerKeys], [vcol, ...relKeys], [mcol], ctx);
903
+ const isIn = bin('==', v(m), num(1));
904
+ const none = bin('==', call('coalesce', v(cn), num(0)), num(0));
905
+ return bin('||', none, bin('&&', bin('&&', present(e.x, ctx), { t: 'un', op: '!', x: isIn }), bin('==', call('coalesce', v(cnn), num(0)), num(0))));
906
+ }
907
+ function subqueryValue(e, ctx, negate = false) {
889
908
  if (e.k === 'exists') {
890
909
  const { inner, pairs } = correlate(e.q);
891
910
  const body = inner.body;
@@ -893,7 +912,7 @@ function compileSelect(ast, env, outer, tail) {
893
912
  const sel = { ...body, distinct: true, items: [...keyItems, { e: { k: 'num', v: 1 }, alias: '__m' }], groupBy: [] };
894
913
  if (body.groupBy?.length || body.having) fail('EXISTS (SELECT ... GROUP BY ...) is not supported');
895
914
  const rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: pairs.length ? null : 1, offset: 0 }, env, outerHere);
896
- const [m] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__m']);
915
+ const [m] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__m'], ctx);
897
916
  return bin('==', v(m), num(1));
898
917
  }
899
918
  if (e.k === 'insub') {
@@ -904,7 +923,8 @@ function compileSelect(ast, env, outer, tail) {
904
923
  const rel = compileQuery(inner, env, outerHere);
905
924
  if (rel.names.length !== 1) fail('IN (SELECT ...) needs exactly one column');
906
925
  const dist = { ...rel, query: rel.query.groupBy([rel.names[0]]).agg({ __m: 'count()' }).derive('__m1', '1') };
907
- const [m] = attach({ ...dist, names: [rel.names[0], '__m1'] }, [translate(e.x, ctx)], [rel.names[0]], ['__m1']);
926
+ if (negate) return notInValue(e, ctx, { ...dist, names: [rel.names[0], '__m', '__m1'] }, rel.names[0], '__m1', [], []);
927
+ const [m] = attach({ ...dist, names: [rel.names[0], '__m1'] }, [translate(e.x, ctx)], [rel.names[0]], ['__m1'], ctx);
908
928
  return bin('==', v(m), num(1));
909
929
  }
910
930
  fail('IN (SELECT ...) needs exactly one column');
@@ -913,7 +933,8 @@ function compileSelect(ast, env, outer, tail) {
913
933
  const sel = { ...body, distinct: !body.groupBy?.length, items: [{ e: body.items[0].e, alias: '__v' }, ...keyItems, ...(body.groupBy?.length ? [] : [{ e: { k: 'num', v: 1 }, alias: '__m' }])] };
914
934
  let rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: null, offset: 0 }, env, outerHere);
915
935
  if (body.groupBy?.length) { const keys = ['__v', ...keyItems.map((it) => it.alias)]; rel = { ...rel, query: rel.query.groupBy(keys).agg({ __c: 'count()' }).derive('__m', '1'), names: [...keys, '__m'] }; }
916
- const [m] = attach(rel, [translate(e.x, ctx), ...pairs.map((pr) => translate(pr.outer, ctx))], ['__v', ...keyItems.map((it) => it.alias)], ['__m']);
936
+ if (negate) return notInValue(e, ctx, rel, '__v', '__m', keyItems.map((it) => it.alias), pairs.map((pr) => translate(pr.outer, ctx)));
937
+ const [m] = attach(rel, [translate(e.x, ctx), ...pairs.map((pr) => translate(pr.outer, ctx))], ['__v', ...keyItems.map((it) => it.alias)], ['__m'], ctx);
917
938
  return bin('==', v(m), num(1));
918
939
  }
919
940
  // (SELECT one value)
@@ -923,14 +944,14 @@ function compileSelect(ast, env, outer, tail) {
923
944
  if (!pairs.length) {
924
945
  const rel = compileQuery(inner, env, outerHere);
925
946
  if (rel.names.length !== 1) fail('a subquery used as a value needs exactly one column');
926
- const [c] = attach(rel, [], [], [rel.names[0]]);
947
+ const [c] = attach(rel, [], [], [rel.names[0]], ctx);
927
948
  return v(c);
928
949
  }
929
950
  if (!(body.items.length === 1 && hasAgg(body.items[0].e)) || body.groupBy?.length) fail('a correlated subquery used as a value must compute one aggregate, e.g. (SELECT MAX(x) FROM t WHERE t.k = o.k)');
930
951
  const keyItems = pairs.map((pr, i) => ({ e: pr.inner, alias: `__k${i}` }));
931
952
  const sel = { ...body, items: [...keyItems, { e: body.items[0].e, alias: '__v' }], groupBy: keyItems.map((it) => it.e) };
932
953
  const rel = compileQuery({ ...inner, body: sel, orderBy: [], limit: null, offset: 0 }, env, outerHere);
933
- const [c] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__v']);
954
+ const [c] = attach(rel, pairs.map((pr) => translate(pr.outer, ctx)), keyItems.map((it) => it.alias), ['__v'], ctx);
934
955
  const it = body.items[0].e;
935
956
  return it.k === 'call' && it.fn === 'COUNT' ? call('coalesce', v(c), num(0)) : v(c); // (no rows: COUNT is 0)
936
957
  }
@@ -1099,9 +1120,19 @@ function compileSelect(ast, env, outer, tail) {
1099
1120
  // true where a value is there (SQL's NULL is NaN for numbers, '' for text); null: always there (a literal)
1100
1121
  function present(e, ctx) {
1101
1122
  if (e.k === 'num' || e.k === 'str' || e.k === 'date' || e.k === 'interval') return num(1);
1123
+ if (isCond(e)) return bin('||', translate(e, ctx), negated(e, ctx)); // (a condition: known where it's true or false)
1102
1124
  const x = translate(e, ctx);
1103
1125
  return kindOf(e, ctx) === 'text' ? bin('!=', x, { t: 'str', v: '' }) : bin('==', x, x);
1104
1126
  }
1127
+ // a true / false / unknown condition (comparisons, AND, OR, NOT, IS, IN, LIKE...): translate()
1128
+ // gives where it's true, negated() where it's false; elsewhere it's unknown
1129
+ function isCond(e) {
1130
+ if (e.k === 'num') return !!e.bool;
1131
+ if (e.k === 'bin') return ['AND', 'OR', 'IS', '=', '<>', '!=', '<', '<=', '>', '>=', '~', '~*', '!~', '!~*'].includes(e.op);
1132
+ return ['not', 'isnull', 'like', 'in', 'between', 'exists', 'insub'].includes(e.k);
1133
+ }
1134
+ // a condition as a value: 1 true, 0 false, NaN unknown (SQL's NULL boolean)
1135
+ function condValue(e, ctx, t) { return cond(t, num(1), cond(negated(e, ctx), num(0), num(NaN))); }
1105
1136
  // NOT x with SQL's three values: where x is unknown (a missing value), so is NOT x, and the
1106
1137
  // row is dropped. Comparisons become their opposites (false on missing values already).
1107
1138
  const FLIP = { '=': '<>', '<>': '=', '!=': '=', '<': '>=', '<=': '>', '>': '<=', '>=': '<' };
@@ -1118,7 +1149,8 @@ function compileSelect(ast, env, outer, tail) {
1118
1149
  case 'in':
1119
1150
  if (kindOf(x.x, ctx) === 'text') return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
1120
1151
  return x.list.map((i) => T({ k: 'bin', op: '<>', l: x.x, r: i })).reduce((a, b) => bin('&&', a, b));
1121
- case 'like': case 'insub': return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
1152
+ case 'like': return bin('&&', { t: 'un', op: '!', x: T(x) }, present(x.x, ctx));
1153
+ case 'insub': return subqueryValue(x, ctx, true);
1122
1154
  case 'exists': case 'isnull': return { t: 'un', op: '!', x: T(x) };
1123
1155
  default: { // a true / false value (1 / 0): NOT of a missing one stays missing
1124
1156
  const v = T(x);
@@ -1146,9 +1178,16 @@ function compileSelect(ast, env, outer, tail) {
1146
1178
  case 'exists': case 'insub': case 'subq': return subqueryValue(e, ctx);
1147
1179
  case 'window': return ctx.window(e);
1148
1180
  case 'bin': {
1149
- if (e.op === 'AND') return bin('&&', T(e.l), T(e.r));
1150
- if (e.op === 'OR') return bin('||', T(e.l), T(e.r));
1181
+ const B = (x) => (x.k === 'null' ? num(0) : T(x)); // (a NULL condition is unknown: never true)
1182
+ if (e.op === 'AND') return bin('&&', B(e.l), B(e.r));
1183
+ if (e.op === 'OR') return bin('||', B(e.l), B(e.r));
1151
1184
  const op = { '=': '==', '<>': '!=' }[e.op] ?? e.op;
1185
+ if (op === 'IS' && e.r.k === 'num' && e.r.bool) { // x IS TRUE / IS FALSE: never unknown (unknown is neither)
1186
+ const x = e.l;
1187
+ if (isCond(x)) return e.r.v ? T(x) : negated(x, ctx);
1188
+ if (x.k === 'null') return num(0);
1189
+ return e.r.v ? bin('&&', bin('!=', T(x), num(0)), present(x, ctx)) : bin('==', T(x), num(0));
1190
+ }
1152
1191
  if (op === 'IS') { // a IS b: equal, or both missing
1153
1192
  const a = T(e.l), b = T(e.r);
1154
1193
  return bin('||', bin('==', a, b), bin('&&', call('isMissing', a), call('isMissing', b)));
@@ -1159,6 +1198,8 @@ function compileSelect(ast, env, outer, tail) {
1159
1198
  return op.startsWith('!') ? bin('==', m, num(0)) : bin('==', m, num(1));
1160
1199
  }
1161
1200
  if (['==', '!=', '<', '<=', '>', '>='].includes(op)) {
1201
+ if (isCond(e.l) || isCond(e.r)) // (conditions compared: unknown if either one is)
1202
+ return bin('&&', bin(op, T(e.l), T(e.r)), bin('&&', present(e.l, ctx), present(e.r, ctx)));
1162
1203
  const lk = kindOf(e.l, ctx), rk = kindOf(e.r, ctx);
1163
1204
  if (lk === 'text' || rk === 'text') {
1164
1205
  if ((lk === 'date' && e.r.k === 'str') || (rk === 'date' && e.l.k === 'str')) // a date written as text
@@ -1265,7 +1306,11 @@ function compileSelect(ast, env, outer, tail) {
1265
1306
  const s = bin('*', a[0], num(f));
1266
1307
  return bin('/', cond(bin('>=', s, num(0)), call('floor', bin('+', s, num(0.5))), call('ceil', bin('-', s, num(0.5)))), num(f));
1267
1308
  }
1268
- case 'LEAST': case 'GREATEST': if (a.length < 2) fail(`${e.fn}() needs two values or more`); return a.reduce((x, y) => call(e.fn === 'LEAST' ? 'min' : 'max', x, y));
1309
+ case 'LEAST': case 'GREATEST': { // missing values skipped, as in PostgreSQL and DuckDB (NULL only if all are)
1310
+ if (a.length < 2) fail(`${e.fn}() needs two values or more`);
1311
+ const f = e.fn === 'LEAST' ? 'min' : 'max';
1312
+ return a.reduce((x, y) => call(f, call('coalesce', x, y), call('coalesce', y, x)));
1313
+ }
1269
1314
  case 'COALESCE': case 'IFNULL': case 'NVL': if (a.length < 2) fail(`${e.fn}() needs two values or more`); return call('coalesce', ...a);
1270
1315
  case 'NULLIF': need(2); return cond(bin('==', a[0], a[1]), num(NaN), a[0]);
1271
1316
  case 'DATE_TRUNC': {
@@ -1303,7 +1348,7 @@ function compileSelect(ast, env, outer, tail) {
1303
1348
  if (ast.where) {
1304
1349
  if (hasAgg(ast.where)) fail('aggregates (SUM, COUNT...) can\'t be used in WHERE: use HAVING');
1305
1350
  if (hasWindow(ast.where)) fail('window functions can\'t be used in WHERE (filter on them in an outer query)');
1306
- const t = translate(ast.where, plain);
1351
+ const t = ast.where.k === 'null' ? num(0) : translate(ast.where, plain); // (WHERE NULL: unknown, no row)
1307
1352
  q = q.filter(src(t));
1308
1353
  }
1309
1354
 
@@ -1440,7 +1485,9 @@ function compileSelect(ast, env, outer, tail) {
1440
1485
  // aggregates, each once
1441
1486
  const aggs = new Map(); // sqlKey -> name
1442
1487
  const spec = {}, extraKeys = [];
1443
- const aggOf = (e) => {
1488
+ const aggDone = new Map(); // (an aggregate used twice: the same expression, SUM's NULL check included)
1489
+ const aggOf = (e) => { const k = key(e); if (!aggDone.has(k)) aggDone.set(k, aggOnce(e)); return aggDone.get(k); };
1490
+ const aggOnce = (e) => {
1444
1491
  const k = key(e);
1445
1492
  if (aggs.has(k)) return v(aggs.get(k));
1446
1493
  const name = `__a${aggs.size}_${n.hidden++}`;
@@ -1480,8 +1527,9 @@ function compileSelect(ast, env, outer, tail) {
1480
1527
  return translate(e, post);
1481
1528
  };
1482
1529
  planWindows(translatePost, (x) => kindOf(x, post));
1483
- const outs = withCols(items.map((it) => ({ ...it, ast: textConst(translatePost(it.e), post) })));
1484
- const havingAst = ast.having ? translatePost(ast.having) : null;
1530
+ const tri = (e, t) => { if (!isCond(e) || e.k === 'num' || t.t === 'var') return t; try { return condValue(e, post, t); } catch { return t; } }; // (built from GROUP BY expressions: as it is)
1531
+ const outs = withCols(items.map((it) => { const t = translatePost(it.e); return { ...it, ast: textConst(tri(it.e, t), post) }; }));
1532
+ const havingAst = ast.having ? (ast.having.k === 'null' ? num(0) : translatePost(ast.having)) : null;
1485
1533
  const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, translatePost) }));
1486
1534
  q = q.groupBy([...keys.map((k) => k.name), ...extraKeys]).agg(Object.keys(spec).length ? spec : { __a_rows: 'count()' });
1487
1535
  const late = new Set(windowVars.values()), after = []; // (those using window values: after the windows)
@@ -1490,16 +1538,16 @@ function compileSelect(ast, env, outer, tail) {
1490
1538
  applyWindows();
1491
1539
  for (const st of after) st.run();
1492
1540
  for (const o of outs) if (o.ast.t !== 'var') q = q.derive(o.col, src(o.ast));
1493
- for (const o of orderAsts) sortKey(o);
1541
+ for (const o of orderAsts) sortKey(o, orderKind(o, outs, post));
1494
1542
  return finish(outs, (o) => (o.e.k === 'col' ? post.kindOfCol(o.e) ?? kinds.get(resolveSafe(o.e)) : kindOf(o.e, post)), post);
1495
1543
  }
1496
1544
  const pre = windowCtx(plain);
1497
1545
  planWindows((e) => (e.k === 'col' ? colOf(e) : translate(e, pre)), (x) => kindOf(x, pre));
1498
1546
  applyWindows();
1499
- const outs = withCols(items.map((it) => ({ ...it, ast: textConst(it.e.k === 'col' ? colOf(it.e) : translate(it.e, pre), pre) })));
1547
+ const outs = withCols(items.map((it) => { const t = it.e.k === 'col' ? colOf(it.e) : translate(it.e, pre); return { ...it, ast: textConst(isCond(it.e) && it.e.k !== 'num' ? condValue(it.e, pre, t) : t, pre) }; }));
1500
1548
  const orderAsts = orderBy.map((o) => ({ ...o, ast: orderTarget(o.e, outs, (e) => (e.k === 'col' ? colOf(e) : translate(e, pre))) }));
1501
1549
  for (const o of outs) if (o.ast.t !== 'var') q = q.derive(o.col, src(o.ast));
1502
- for (const o of orderAsts) sortKey(o);
1550
+ for (const o of orderAsts) sortKey(o, orderKind(o, outs, pre));
1503
1551
  if (ast.distinct) q = q.groupBy([...new Set(outs.map((o) => o.col))]).agg({ __distinct_rows: 'count()' });
1504
1552
  return finish(outs, (o) => (o.e.k === 'col' ? (sessionRef(o.e)?.kind ?? kinds.get(o.e.resolved ? o.e.name : resolve(o.e))) : kindOf(o.e, pre)), pre);
1505
1553
 
@@ -1516,11 +1564,17 @@ function compileSelect(ast, env, outer, tail) {
1516
1564
  const t = tr(e), same = outs.find((o) => toSourceAst(o.ast) === toSourceAst(t));
1517
1565
  return same ? v(same.col) : t;
1518
1566
  }
1519
- function sortKey(o) {
1567
+ // the kind (text or not) of what an ORDER BY item sorts: a select item by number or name, or the expression
1568
+ function orderKind(o, outs, ctx) {
1569
+ const e = o.e.k === 'num' ? outs[o.e.v - 1]?.e : (o.e.k === 'col' && !o.e.table && outs.find((x) => x.name === o.e.name)?.e) || o.e;
1570
+ try { return kindOf(e, ctx); } catch { return null; }
1571
+ }
1572
+ function sortKey(o, kind) {
1520
1573
  let k;
1521
1574
  if (o.ast.t === 'var') k = o.ast.name;
1522
1575
  else { k = `__o${n.hidden++}`; q = q.derive(k, src(o.ast)); }
1523
- if (o.nullsFirst) { const h = `__nf${n.hidden++}`; q = q.derive(h, `${k} == ${k} ? 1 : 0`); sortKeys.push(h); }
1576
+ // missing sorts last on its own (NaN, and '' for text); NULLS FIRST sorts present values after missing ones first
1577
+ if (o.nullsFirst) { const h = `__nf${n.hidden++}`; q = q.derive(h, kind === 'text' ? `${k} == '' ? 0 : 1` : `${k} == ${k} ? 1 : 0`); sortKeys.push(h); }
1524
1578
  sortKeys.push(o.desc ? `-${k}` : k);
1525
1579
  }
1526
1580
  function finish(outs, kindFn, ctx) {
package/src/table.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { parseKeys, labelRanks, sortIndex } from './sort.mjs';
9
9
  import { ZONE_ROWS, computeZones, zoneRange } from './zones.mjs';
10
- import { parse, toSource, walk, analyze, isMask } from './fuse.mjs';
10
+ import { parse, toSource, walk, analyze, isMask, fold } from './fuse.mjs';
11
11
  import { generateGroupBy, generateCompact, generateHashGroups, generateMarkPairs, generateStridedKeys } from './table-kernels.mjs';
12
12
  import { decodeString } from './csv-kernel.mjs';
13
13
  import { maxUnits } from './kernels.mjs';
@@ -31,6 +31,10 @@ const IDENTITY = { sum: 0, count: 0, mean: 0, min: Infinity, max: -Infinity };
31
31
  const MAX_GROUP_SLOTS = 2 ** 28; // threads * aggregations * groups (2GB of float64)
32
32
  const PARALLEL_SORT_ROWS = 50_000; // smaller tables sort on the calling thread
33
33
  const TIME_STEPS = [86_400_000, 3_600_000, 60_000, 1_000]; // a day, an hour, a minute, a second (ms): see #directGroups
34
+ // Column facts worked out once and remembered with the column's array
35
+ // (arrays are never written once in a table):
36
+ const MISSING = new WeakMap(); // { n: rows, missing: how many missing values }
37
+ const KEY_RANGE = new WeakMap(); // { n: rows, range: [min, max, how many aren't whole] } (see #directGroups)
34
38
 
35
39
  export class Table {
36
40
  // cols: Map name -> { data, dict, codes, strings }
@@ -103,17 +107,18 @@ export class Table {
103
107
  for (const name of Object.keys(rows[0])) {
104
108
  checkName(name);
105
109
  const data = engine.alloc(type, n);
106
- if (typeof rows[0][name] === 'string') {
110
+ const first = firstPresent(rows.map((r) => r[name]));
111
+ if (typeof first === 'string') {
107
112
  const codes = new Map(), dict = [];
108
113
  for (let i = 0; i < n; i++) {
109
- const s = rows[i][name];
114
+ const s = rows[i][name] ?? ''; // (null / undefined: missing text)
110
115
  let c = codes.get(s);
111
116
  if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
112
117
  data[i] = c;
113
118
  }
114
119
  cols.set(name, { data, dict, codes });
115
120
  } else {
116
- const date = rows[0][name] instanceof Date;
121
+ const date = first instanceof Date;
117
122
  if (date && type === Float32Array) throw new Error(f32Dates(name));
118
123
  for (let i = 0; i < n; i++) { const v = rows[i][name]; data[i] = v === null || v === undefined ? NaN : date ? toMillis(v) : v; } // (null: missing)
119
124
  cols.set(name, { data, dict: null, codes: null, ...(date ? { date: true } : {}) });
@@ -310,7 +315,14 @@ export class Table {
310
315
  // Lazy filter: returns a view of the same columns with a condition attached.
311
316
  filter(expr, params = {}) {
312
317
  const merged = this.#mergeParams(params);
313
- const { src } = this.#bind(expr, merged);
318
+ let { ast, src } = this.#bind(expr, merged);
319
+ walk(ast, (m) => { if (m.t === 'str') throw new SyntaxError(`vectro: text like ${JSON.stringify(m.v)} can only be compared with == or != to a text column, in "${expr}" (to compare dates, load the column as dates: { dates: ['name'] })`); });
320
+ const k = fold(ast);
321
+ if (k.t === 'num') { // no column in it (1 == 1, 0): every row, or none (a kernel needs a column to read)
322
+ if (k.v !== 0) return new Table(this.engine, this.cols, this.n, { pred: this.pred, params: merged, zones: this.zones });
323
+ const c = [...this.cols.keys()].find((x) => !this.cols.get(x).strings);
324
+ src = `${c} != ${c} && ${c} == ${c}`;
325
+ }
314
326
  const pred = this.pred ? `(${this.pred}) && (${src})` : src;
315
327
  return new Table(this.engine, this.cols, this.n, { pred, params: merged, zones: this.zones });
316
328
  }
@@ -578,6 +590,19 @@ export class Table {
578
590
  return Math.floor((engine._directSlots ?? DIRECT_SLOTS) / (engine.T * totals));
579
591
  }
580
592
 
593
+ // Is src a number column of this table with no missing values? Counted
594
+ // once per column (one pass on every thread) and remembered with its array.
595
+ *#noMissingG(src) {
596
+ const c = src && IDENT.test(src) ? this.cols.get(src) : null;
597
+ if (!c || c.dict || c.strings) return false;
598
+ let known = MISSING.get(c.data);
599
+ if (known?.n !== this.n) {
600
+ const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
601
+ MISSING.set(c.data, (known = { n: this.n, missing: (yield* this.engine.evalG(['count(k != k)'], { k }))[0] }));
602
+ }
603
+ return known.missing === 0;
604
+ }
605
+
581
606
  // Keys small enough to be group numbers as they are: a text column's codes
582
607
  // or whole numbers 0..2^24, several combined as k1 * size2 + k2..., while
583
608
  // the accumulators stay small (or no larger than the rows). Else null.
@@ -590,7 +615,9 @@ export class Table {
590
615
  if (!this.n) { sizes.push(0); offsets.push(0); steps.push(1); continue; }
591
616
  // whole numbers in a range under 2^24 (from their minimum: 202301..202412, IDs from 1e9, negatives)
592
617
  const k = c.enc ? { data: c.data, enc: c.enc, n: this.n, type: this.type === Float32Array ? 'f32' : 'f64' } : c.data;
593
- const [lo, hi, frac] = yield* engine.evalG(['min(k)', 'max(k)', 'count(k != floor(k))'], { k });
618
+ let known = KEY_RANGE.get(c.data);
619
+ if (known?.n !== this.n) KEY_RANGE.set(c.data, (known = { n: this.n, range: yield* engine.evalG(['min(k)', 'max(k)', 'count(k != floor(k))'], { k }) }));
620
+ const [lo, hi, frac] = known.range;
594
621
  if (frac > 0 || !Number.isFinite(lo) || !Number.isFinite(hi)) return null;
595
622
  // or times a whole number of days (hours, minutes, seconds) apart: numbered by that unit
596
623
  let step = 1;
@@ -604,9 +631,16 @@ export class Table {
604
631
  offsets.push(lo);
605
632
  steps.push(step);
606
633
  }
634
+ const fits = (K) => !(K > 2 ** 24 || engine.T * J * K * (engine.jit && K <= 1024 ? 4 : 1) > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n));
635
+ // One key from a small positive number (IDs from 1, years from 1990):
636
+ // numbered from 0 with a few groups left empty, so the key column itself
637
+ // is the group number and needs no copy.
638
+ if (keys.length === 1 && steps[0] === 1 && offsets[0] > 0 && offsets[0] <= Math.max(1024, sizes[0]) && offsets[0] + sizes[0] <= maxK && fits(offsets[0] + sizes[0])) {
639
+ sizes[0] += offsets[0];
640
+ offsets[0] = 0;
641
+ }
607
642
  const K = sizes.reduce((a, b) => a * b, 1);
608
- const C = engine.jit && K <= 1024 ? 4 : 1;
609
- if (K > 2 ** 24 || engine.T * J * K * C > (engine._maxGroupSlots ?? MAX_GROUP_SLOTS) || (K > 65_536 && K > 8 * this.n)) return null;
643
+ if (!fits(K)) return null;
610
644
  if (K > maxK) { if (info) info.K = K; return null; }
611
645
  const below = sizes.map((_, k) => sizes.slice(k + 1).reduce((a, b) => a * b, 1));
612
646
  const keyColumn = (k, keep) => directKeys(keep, below[k], sizes[k], steps[k], offsets[k]); // key k of each kept group (codes for text)
@@ -747,12 +781,20 @@ export class Table {
747
781
  *#runGroupsG(keys, groups, allItems, merged) {
748
782
  const { engine } = this;
749
783
  const { K } = groups;
750
- const items = allItems.filter((it) => !DISTINCT.has(it.fn));
784
+ // Over a column with no missing values, count(x) is the row count (always
785
+ // counted) and sum / mean / min / max need no check for missing values:
786
+ // SQL's SUM(x) is sum(x) and count(x), so this takes a whole accumulator off.
787
+ const full = new Set();
788
+ for (const it of allItems) if (!DISTINCT.has(it.fn) && (yield* this.#noMissingG(it.raw))) full.add(it);
789
+ const rowCount = (it) => it.fn === 'count' && (!it.arg || full.has(it));
790
+ const items = allItems.filter((it) => !DISTINCT.has(it.fn) && !rowCount(it));
751
791
  // means need the count of values present: one more kernel item each
752
- const kernelItems = [...items, ...items.filter((it) => it.present).map((it) => ({ fn: 'count', arg: it.present, of: it }))]; // means, mins, maxes
792
+ const kernelItems = [...items.map((it) => (full.has(it) ? { fn: it.fn, arg: it.raw } : it)),
793
+ ...items.filter((it) => it.present && !full.has(it)).map((it) => ({ fn: 'count', arg: it.present, of: it }))]; // means, mins, maxes
753
794
  const totals = yield* this.#groupTotalsG(groups, kernelItems, merged);
754
795
  if (!totals) return null; // (lookup groups the kernels can't take)
755
- const presentOf = (it) => totals[1 + kernelItems.findIndex((k) => k.of === it)];
796
+ const totalOf = (it) => (rowCount(it) ? totals[0] : totals[1 + items.indexOf(it)]);
797
+ const presentOf = (it) => (full.has(it) ? totals[0] : totals[1 + kernelItems.findIndex((k) => k.of === it)]);
756
798
  // Keep groups that have rows, sorted by key (codes for text keys, so
757
799
  // dictionary order; NaN last), and build whole columns.
758
800
  let keep = keptGroups(totals[0], K);
@@ -768,7 +810,7 @@ export class Table {
768
810
  }
769
811
  // each item's value for every group number
770
812
  const perGroup = allItems.map((it) => {
771
- const i = items.indexOf(it), v = i >= 0 ? totals[i + 1] : it.perGroup;
813
+ const v = DISTINCT.has(it.fn) ? it.perGroup : totalOf(it);
772
814
  return it.fn === 'mean' ? ratioOf(v, presentOf(it)) : it.fn === 'min' || it.fn === 'max' ? presentOnly(v, presentOf(it)) : v;
773
815
  });
774
816
  if (engine.jit && m >= PARALLEL_SORT_ROWS) return yield* this.#groupTableG(keys, groups, allItems, perGroup, keep);
@@ -963,29 +1005,34 @@ export class Table {
963
1005
  interpretGroup(engine, t, analysis.items, predAst, cols, merged, groups.data, acc, K, this.n);
964
1006
  }
965
1007
 
966
- // Combine threads (and copies) into one value per group and aggregation.
1008
+ // Combine threads (and copies) into one value per group and aggregation:
1009
+ // the thread blocks by fused passes added into thread 0's, 15 blocks a
1010
+ // pass (kernels, so fast from the first query; small ones run on this
1011
+ // thread), then the C copies of each group by a plain loop.
967
1012
  totals = [];
968
1013
  for (const [j, it] of analysis.items.entries()) {
969
- const op = it.reduce === 'min' ? Math.min : it.reduce === 'max' ? Math.max : (a, b) => a + b;
970
- if (C > 1 || T === 1 || !engine.jit) { // a plain loop is enough
971
- const a = engine.live(acc);
972
- const out = new Float64Array(K).fill(IDENTITY[it.reduce]);
973
- for (let s = 0; s < T; s++) {
974
- const [lo] = slots(s, j);
975
- for (let g = 0; g < K; g++) for (let c = 0; c < C; c++) out[g] = op(out[g], a[lo + g * C + c]);
1014
+ let from = T;
1015
+ if (T > 1 && engine.jit) {
1016
+ for (let s0 = 1; s0 < T; s0 += 15) {
1017
+ const vars = { t0: acc.subarray(...slots(0, j)) };
1018
+ for (let s = s0; s < Math.min(T, s0 + 15); s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
1019
+ const vnames = Object.keys(vars);
1020
+ const expr = it.reduce === 'min' || it.reduce === 'max'
1021
+ ? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
1022
+ : vnames.join(' + ');
1023
+ yield* engine.evalG(expr, vars, vars.t0);
976
1024
  }
977
- totals.push(out);
978
- continue;
1025
+ from = 1;
1026
+ }
1027
+ const a = engine.live(acc);
1028
+ if (C === 1 && from === 1) { const [lo, hi] = slots(0, j); totals.push(a.slice(lo, hi)); continue; }
1029
+ const op = it.reduce === 'min' ? Math.min : it.reduce === 'max' ? Math.max : (x, y) => x + y;
1030
+ const out = new Float64Array(K).fill(IDENTITY[it.reduce]);
1031
+ for (let s = 0; s < from; s++) {
1032
+ const [lo] = slots(s, j);
1033
+ for (let g = 0; g < K; g++) for (let c = 0; c < C; c++) out[g] = op(out[g], a[lo + g * C + c]);
979
1034
  }
980
- // many groups: one fused pass over all thread blocks, written into thread 0's
981
- const vars = {};
982
- for (let s = 0; s < T; s++) vars[`t${s}`] = acc.subarray(...slots(s, j));
983
- const vnames = Object.keys(vars);
984
- const expr = it.reduce === 'min' || it.reduce === 'max'
985
- ? vnames.slice(1).reduce((e, nm) => `${it.reduce}(${e}, ${nm})`, vnames[0])
986
- : vnames.join(' + ');
987
- yield* engine.evalG(expr, vars, vars.t0);
988
- totals.push(Float64Array.from(engine.live(vars.t0)));
1035
+ totals.push(out);
989
1036
  }
990
1037
  engine.free(acc);
991
1038
  }
@@ -1821,12 +1868,14 @@ function checkName(name) {
1821
1868
  // (mean divides by the values present), min and max skip them, count(x)
1822
1869
  // counts the values present (count(condition) still counts where it holds).
1823
1870
  // arg is the bound source; returns the aggregation item.
1871
+ // (raw: the argument as given; where it is a column with no missing values,
1872
+ // the group-by uses it as it is and takes counts from the row count)
1824
1873
  function missingRules(fn, arg) {
1825
1874
  const present = `(${arg}) == (${arg})`;
1826
- if (fn === 'count') return { fn, arg: isMask(parse(arg)) ? arg : present };
1827
- if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present }; // (present: NaN when nothing is)
1828
- if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present };
1829
- return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present } : { fn, arg: `(${present} ? (${arg}) : 0)` }; // sum, mean
1875
+ if (fn === 'count') return isMask(parse(arg)) ? { fn, arg } : { fn, arg: present, raw: arg };
1876
+ if (fn === 'min') return { fn, arg: `(${present} ? (${arg}) : Infinity)`, present, raw: arg }; // (present: NaN when nothing is)
1877
+ if (fn === 'max') return { fn, arg: `(${present} ? (${arg}) : -Infinity)`, present, raw: arg };
1878
+ return fn === 'mean' ? { fn, arg: `(${present} ? (${arg}) : 0)`, present, raw: arg } : { fn, arg: `(${present} ? (${arg}) : 0)`, raw: arg }; // sum, mean
1830
1879
  }
1831
1880
 
1832
1881
  // a category column given as codes into a labels array: { codes, labels }
@@ -1850,7 +1899,9 @@ function codedText(engine, type, { codes: src, labels }) {
1850
1899
  }
1851
1900
 
1852
1901
  const isDates = (values) => Array.isArray(values) && values.find((v) => v != null) instanceof Date;
1853
- const isText = (values) => !ArrayBuffer.isView(values) && values.length > 0 && typeof values[0] === 'string';
1902
+ // the first value present decides (a column may start with null / undefined: missing)
1903
+ const firstPresent = (values) => { for (const v of values) if (v !== null && v !== undefined) return v; return undefined; };
1904
+ const isText = (values) => !ArrayBuffer.isView(values) && values.length > 0 && typeof firstPresent(values) === 'string';
1854
1905
 
1855
1906
  function copyNumbers(engine, type, values) {
1856
1907
  const data = engine.alloc(type, values.length);
@@ -1863,7 +1914,7 @@ function encodeText(engine, type, values) {
1863
1914
  const data = engine.alloc(type, values.length);
1864
1915
  const codes = new Map(), dict = [];
1865
1916
  for (let i = 0; i < values.length; i++) {
1866
- const s = values[i];
1917
+ const s = values[i] ?? ''; // (null / undefined: missing text, as in JSON)
1867
1918
  let c = codes.get(s);
1868
1919
  if (c === undefined) { c = dict.length; dict.push(s); codes.set(s, c); }
1869
1920
  data[i] = c;