vectrojs 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +828 -4
  3. package/bin/vectro.mjs +82 -0
  4. package/dist/vectro.wasm +0 -0
  5. package/mcp/server.mjs +288 -0
  6. package/package.json +84 -6
  7. package/src/arrow.mjs +905 -0
  8. package/src/bitpack.mjs +135 -0
  9. package/src/browser.mjs +129 -0
  10. package/src/bytes.mjs +60 -0
  11. package/src/csv-kernel.mjs +205 -0
  12. package/src/csv.mjs +270 -0
  13. package/src/dataset.mjs +1763 -0
  14. package/src/dates.mjs +139 -0
  15. package/src/emitter.mjs +109 -0
  16. package/src/engine.mjs +881 -0
  17. package/src/files-browser.mjs +104 -0
  18. package/src/files-node.mjs +59 -0
  19. package/src/frames.mjs +191 -0
  20. package/src/fuse.mjs +512 -0
  21. package/src/interp.mjs +305 -0
  22. package/src/join.mjs +534 -0
  23. package/src/kernels.mjs +312 -0
  24. package/src/ndjson.mjs +136 -0
  25. package/src/node.mjs +120 -0
  26. package/src/pack.mjs +114 -0
  27. package/src/parallel-sort.mjs +99 -0
  28. package/src/partition-kernels.mjs +388 -0
  29. package/src/partition.mjs +395 -0
  30. package/src/plan.mjs +551 -0
  31. package/src/portable.mjs +21 -0
  32. package/src/rank-kernels.mjs +81 -0
  33. package/src/rows.mjs +91 -0
  34. package/src/serve/catalog.mjs +148 -0
  35. package/src/serve/flight.mjs +270 -0
  36. package/src/serve/formats.mjs +87 -0
  37. package/src/serve/http.mjs +182 -0
  38. package/src/serve/index.mjs +9 -0
  39. package/src/serve/odata.mjs +291 -0
  40. package/src/serve/pg.mjs +588 -0
  41. package/src/serve/pgcatalog.mjs +220 -0
  42. package/src/serve/pgtypes.mjs +199 -0
  43. package/src/sketch.mjs +75 -0
  44. package/src/sort-kernels.mjs +117 -0
  45. package/src/sort.mjs +102 -0
  46. package/src/split-worker-core.mjs +252 -0
  47. package/src/split-worker-node.mjs +8 -0
  48. package/src/split-worker.mjs +7 -0
  49. package/src/split.mjs +741 -0
  50. package/src/sql.mjs +1558 -0
  51. package/src/sqltext.mjs +132 -0
  52. package/src/static-kernels.mjs +164 -0
  53. package/src/stream-worker.mjs +553 -0
  54. package/src/table-kernels.mjs +323 -0
  55. package/src/table.mjs +2247 -0
  56. package/src/textmap.mjs +159 -0
  57. package/src/vectors.mjs +138 -0
  58. package/src/wasm.mjs +254 -0
  59. package/src/window.mjs +160 -0
  60. package/src/worker-browser.mjs +21 -0
  61. package/src/worker-loop.mjs +106 -0
  62. package/src/worker.mjs +22 -0
  63. package/src/zones.mjs +75 -0
@@ -0,0 +1,1763 @@
1
+ // Datasets: tables bigger than memory, kept on disk (Node.js).
2
+ //
3
+ // engine.csvToDataset('orders.csv', 'orders.vectro'); // a CSV of any size
4
+ // const w = engine.createDataset('orders.vectro'); // or append tables
5
+ // w.append(table); w.append(moreRows); w.close();
6
+ //
7
+ // const orders = engine.openDataset('orders.vectro');
8
+ // orders.filter("region == 'EU'").groupBy('day').agg({ revenue: 'sum(price * qty)' }).toRows();
9
+ // sql('SELECT region, COUNT(*) FROM orders GROUP BY region', { orders }).toRows();
10
+ //
11
+ // The file holds chunks of rows (4M by default). Each column of each chunk
12
+ // is stored compressed, exactly as it sits in engine memory (pack.mjs), so
13
+ // loading a chunk is one read per column: no parsing, no decoding. A footer
14
+ // holds the schema, the category labels (one dictionary for the whole
15
+ // dataset) and the min / max of every column in every chunk.
16
+ //
17
+ // A query is the usual lazy plan (plan.mjs), run one chunk at a time:
18
+ // - only the columns the plan uses are read, and chunks whose min / max
19
+ // rule out the filters are not read at all;
20
+ // - filters, derived columns and joins (the other table in memory) run on
21
+ // each chunk with the normal kernels, on every thread;
22
+ // - the first step that needs all rows takes partial results per chunk
23
+ // and merges them: totals add up (a mean is a sum and a count), groups
24
+ // are merged by key as they come (memory follows the groups, not the
25
+ // rows), top-k keeps the best k of each chunk, limit stops reading
26
+ // once it has its rows, collect concatenates the matching rows;
27
+ // - everything after that step runs in memory on the merged result.
28
+ // With async calls (toRowsAsync, aggAsync...) the next chunk is read while
29
+ // the current one is computed.
30
+ //
31
+ // File layout: 'VECTRODS', version (u32), 0 (u32); column blobs (64-byte
32
+ // aligned); footer JSON; footer offset and length (f64 each); 'VECTRODS'.
33
+
34
+ import { Table, rewriteText } from './table.mjs';
35
+ import { Query, execute, findDataset, optimize, vars, namesOf, joinNames } from './plan.mjs';
36
+ import { parse, toSource, isMask } from './fuse.mjs';
37
+ import { KINDS, decodeOne } from './pack.mjs';
38
+ import { conditions, mayMatch, computeZones } from './zones.mjs';
39
+ import { STR_SHIFT, STR_ESCAPED } from './csv-kernel.mjs';
40
+ import { inspectCSV, loadBytes, loadBytesAsync } from './csv.mjs';
41
+ import { freeBuild } from './join.mjs';
42
+ import { copyRows, plainInto, remapCodes, textLength } from './rows.mjs';
43
+ export { copyRows };
44
+ import { OP } from './kernels.mjs';
45
+ import { bitPlan, packBits, unpackBits, unpackModule, seqModule } from './bitpack.mjs';
46
+
47
+ // Files: Node registers its own (files-node.mjs: paths, sync and worker-thread
48
+ // reads, writing, temporary folders for spilling). Without them (browsers,
49
+ // edge runtimes) datasets open from a File / Blob or a URL and are read
50
+ // asynchronously; writing and spilling need Node.
51
+ let files = null;
52
+ export function setFiles(f) { files = f; }
53
+ const need = (what) => {
54
+ if (!files) throw new Error(`vectro: ${what} needs Node (here, datasets open read-only from a File, Blob or URL: openDatasetAsync)`);
55
+ return files;
56
+ };
57
+
58
+ // Temporary files for spilling (huge group-bys, sorts, joins): Node's temp
59
+ // folder (from its files), or browser storage (files-browser.mjs: opfsTemp,
60
+ // registered by vectrojs/browser). Its calls may be async; the spilling code
61
+ // asks for them as { call } requests that the drivers resolve.
62
+ let tempStorage = null;
63
+ export function setTempStorage(t) { tempStorage = t; }
64
+ const temp = () => {
65
+ const t = tempStorage ?? (files && { dir: files.tempDir, join: files.join, openWrite: (p) => files.openWrite(p), openRead: (p) => files.openRead(p), remove: files.remove });
66
+ if (!t) throw new Error('vectro: this query needs to spill to disk (too many groups, a big sort or join), which needs Node or browser file storage');
67
+ return t;
68
+ };
69
+ function* tempDataset(engine, path) { // (a spill file, written and closed)
70
+ const file = yield { call: () => temp().openRead(path) };
71
+ const footer = file.readSync ? readFooter(file) : yield { call: () => readFooterAsync(file) };
72
+ return new Dataset(engine, file, footer);
73
+ }
74
+ function* tempWriter(engine, path, opts) { return new DatasetWriter(engine, yield { call: () => temp().openWrite(path) }, opts); }
75
+ function* closeWriter(w) { w.close(); yield { call: () => w.file.done?.() }; }
76
+ const removeTemp = (path) => { try { const r = tempStorage ? tempStorage.remove(path) : files?.remove(path); r?.catch?.(() => {}); } catch { /* (best effort) */ } };
77
+
78
+ const MAGIC = new TextEncoder().encode('VECTRODS');
79
+ const VERSION = 2; // (2: bit-packed columns; version 1 files read too)
80
+ const HEADER = 16, TAIL = 24, ALIGN = 64;
81
+ const SLACK = 16;
82
+ const READ_PIECE = 1 << 20; // reads are cut into pieces this big, read by several threads at once // bytes after packed codes (SIMD loads read a little past the end)
83
+ export const CHUNK_ROWS = 1 << 22;
84
+ const BLOCK = 1 << 16; // min / max per block of rows inside a chunk: a query reads only the blocks that can match
85
+ // partial groups are merged again once this many pile up (tests make it small)
86
+ // how often partial results went to disk (tests, benchmarks)
87
+ export const stats = { spills: 0, resplits: 0, joinPartitions: 0 };
88
+ export const tuning = { mergeRows: 1 << 20, denseMin: undefined, spillRows: 1 << 23, partitionRows: 1 << 24, joinRows: 1 << 24 };
89
+
90
+ // ---- writing --------------------------------------------------------------------------
91
+
92
+ export class DatasetWriter {
93
+ // rowsPerChunk: rows per chunk (the last one may be shorter); append: add
94
+ // to an existing dataset (same columns) instead of replacing it.
95
+ // bitPack: false stores packed codes in whole bytes (bigger files, no unpacking).
96
+ // path: a path (Node) or a writable file object (see createDatasetAsync).
97
+ constructor(engine, path, { rowsPerChunk = CHUNK_ROWS, append = false, bitPack = true, footer = null } = {}) {
98
+ this.engine = engine;
99
+ this.bitPack = bitPack;
100
+ this.path = typeof path === 'string' ? path : path.name;
101
+ this.rowsPerChunk = Math.max(1, Math.floor(rowsPerChunk));
102
+ this.pending = null; // rows waiting for a full chunk (a table this writer owns)
103
+ this.meta = null;
104
+ this.labels = new Map(); // category column -> { dict, codes }: one dictionary for the dataset
105
+ this.file = typeof path === 'string' ? need('writing a dataset').openWrite(path, { append }) : path;
106
+ if (this.file.existed) {
107
+ if (!footer) try { footer = readFooter(this.file); } catch (err) { this.file.close(); throw err; }
108
+ const { meta, footerStart } = footer;
109
+ this.meta = meta;
110
+ this.pos = footerStart; // new chunks overwrite the old footer
111
+ const v = new Uint8Array(4);
112
+ new DataView(v.buffer).setUint32(0, VERSION, true);
113
+ this.file.write(v, 8); // (new chunks may use this version's features)
114
+ meta.version = VERSION;
115
+ for (const c of meta.columns) if (c.kind === 'category') this.labels.set(c.name, { dict: c.dict, codes: new Map(c.dict.map((s, i) => [s, i])) });
116
+ } else {
117
+ const head = new Uint8Array(HEADER);
118
+ head.set(MAGIC);
119
+ new DataView(head.buffer).setUint32(8, VERSION, true);
120
+ this.file.write(head, 0);
121
+ this.pos = HEADER;
122
+ }
123
+ }
124
+
125
+ // Add rows: a Table (only the rows matching its filter; or its rows
126
+ // lo..hi) or { name: values }.
127
+ append(source, lo = 0, hi = Infinity) {
128
+ const { engine } = this;
129
+ let t = source instanceof Table ? source : engine.table(source, { compress: false });
130
+ const temps = t === source ? [] : [t];
131
+ try {
132
+ if (t.pred) { t = t.collect(); temps.push(t); }
133
+ this.#schema(t);
134
+ hi = Math.min(hi, t.n);
135
+ if (this.pending) {
136
+ const all = copyRows(engine, [[this.pending, 0, this.pending.n], [t, lo, hi]]);
137
+ this.pending.dispose();
138
+ this.pending = null;
139
+ temps.push(all);
140
+ t = all;
141
+ lo = 0;
142
+ hi = all.n;
143
+ }
144
+ for (; hi - lo >= this.rowsPerChunk; lo += this.rowsPerChunk) this.#writeChunk(t, lo, lo + this.rowsPerChunk);
145
+ if (lo < hi || !this.meta.chunks.length) this.pending = copyRows(engine, [[t, lo, hi]]);
146
+ } finally {
147
+ for (const x of temps) x.dispose();
148
+ }
149
+ return this;
150
+ }
151
+
152
+ // Write what's left and the footer. Returns { path, rows, chunks, bytes }.
153
+ close() {
154
+ if (this.summary) return this.summary;
155
+ if (this.aborted) throw new Error('vectro: this dataset writer was aborted');
156
+ try {
157
+ if (!this.meta) throw new Error('vectro: append at least one table to a dataset (it can have no rows)');
158
+ if (this.pending && (this.pending.n || !this.meta.chunks.length)) this.#writeChunk(this.pending, 0, this.pending.n);
159
+ for (const c of this.meta.columns) if (c.kind === 'category') c.dict = this.labels.get(c.name).dict;
160
+ const json = new TextEncoder().encode(JSON.stringify(this.meta));
161
+ const { off } = this.#write(json);
162
+ const tail = new Uint8Array(TAIL), dv = new DataView(tail.buffer);
163
+ dv.setFloat64(0, off, true);
164
+ dv.setFloat64(8, json.length, true);
165
+ tail.set(MAGIC, 16);
166
+ this.file.write(tail, this.pos);
167
+ this.pos += TAIL;
168
+ this.file.truncate(this.pos);
169
+ this.summary = { path: this.path, rows: this.meta.rows, chunks: this.meta.chunks.length, bytes: this.pos };
170
+ return this.summary;
171
+ } finally {
172
+ this.pending?.dispose();
173
+ this.pending = null;
174
+ this.file.close();
175
+ }
176
+ }
177
+
178
+ // Stop without a footer (the file is left unreadable as a dataset; a
179
+ // browser file is discarded).
180
+ abort() {
181
+ if (this.summary || this.aborted) return;
182
+ this.aborted = true;
183
+ this.pending?.dispose();
184
+ this.pending = null;
185
+ if (this.file.abort) this.file.abort(); else this.file.close();
186
+ }
187
+
188
+ // The same as append / close, then waiting until the bytes are written
189
+ // (browser files write in the background; in Node they already are).
190
+ async appendAsync(source, lo, hi) { this.append(source, lo, hi); await this.file.flush?.(); return this; }
191
+ async closeAsync() { const summary = this.close(); await this.file.done?.(); return summary; }
192
+
193
+ #schema(t) {
194
+ const kindOf = (c) => (c.strings ? 'text' : c.dict ? 'category' : c.date ? 'date' : 'number');
195
+ const names = t.columnNames;
196
+ if (!this.meta) {
197
+ this.meta = { version: VERSION, type: t.type === Float32Array ? 'f32' : 'f64', rows: 0, columns: names.map((name) => ({ name, kind: kindOf(t.cols.get(name)) })), chunks: [] };
198
+ for (const c of this.meta.columns) if (c.kind === 'category') this.labels.set(c.name, { dict: [], codes: new Map() });
199
+ return;
200
+ }
201
+ const want = this.meta.columns;
202
+ if (names.length !== want.length || want.some((c) => !t.cols.has(c.name))) {
203
+ throw new Error(`vectro: appended columns (${names.join(', ')}) differ from the dataset's (${want.map((c) => c.name).join(', ')})`);
204
+ }
205
+ for (const c of want) {
206
+ const k = kindOf(t.cols.get(c.name));
207
+ if (k !== c.kind) throw new Error(`vectro: column "${c.name}" is ${k} here but ${c.kind} in the dataset`);
208
+ }
209
+ }
210
+
211
+ // Rows [lo, hi) of t as one chunk: plain values (text as dataset codes),
212
+ // min / max, then packed with the kernels and written column by column.
213
+ #writeChunk(t, lo, hi) {
214
+ const { engine } = this;
215
+ const n = hi - lo, T = this.meta.type === 'f32' ? Float32Array : Float64Array, f32 = T === Float32Array;
216
+ const chunk = { rows: n, cols: {} };
217
+ const numeric = this.meta.columns.filter((m) => m.kind !== 'text');
218
+ const cols = new Map(numeric.map((m) => [m.name, { data: engine.alloc(T, n), dict: null, codes: null }]));
219
+ const ct = new Table(engine, cols, n, { owned: [...cols.keys()], zones: false });
220
+ try {
221
+ for (const m of this.meta.columns) {
222
+ const c = t.cols.get(m.name);
223
+ if (m.kind === 'text') { chunk.cols[m.name] = this.#writeText(c, lo, hi); continue; }
224
+ const out = engine.live(cols.get(m.name).data);
225
+ plainInto(engine, c, lo, hi, out, f32);
226
+ if (m.kind === 'category') remapCodes(out, c.dict, this.labels.get(m.name));
227
+ }
228
+ for (const [name, c] of cols) {
229
+ c.data = engine.live(c.data);
230
+ const [min, max, missing] = n ? engine.eval(['min(v == v ? v : Infinity)', 'max(v == v ? v : -Infinity)', 'count(v != v)'], { v: c.data }) : [Infinity, -Infinity, 0];
231
+ chunk.cols[name] = { min: jsonNumber(min), max: jsonNumber(max), missing };
232
+ if (n > BLOCK) { const z = computeZones(engine.live(c.data), n, BLOCK); chunk.cols[name].blocks = { min: Array.from(z.mins, jsonNumber), max: Array.from(z.maxs, jsonNumber) }; }
233
+ }
234
+ if (n) ct.compress();
235
+ for (const [name, c] of cols) {
236
+ const d = engine.live(c.data);
237
+ const seq = c.enc && !chunk.cols[name].missing ? sequence(d, n) : null;
238
+ if (seq) { Object.assign(chunk.cols[name], { off: 0, len: 0, enc: c.enc, seq }); continue; } // (nothing to store: rebuilt on read)
239
+ const plan = c.enc && this.bitPack ? bitPlan(d, n, KINDS[c.enc.kind].nan, KINDS[c.enc.kind].bytes * 8) : null;
240
+ if (plan) { // each code in only the bits it needs
241
+ Object.assign(chunk.cols[name], this.#write(packBits(d, n, plan)), { enc: c.enc, bits: plan.bits, bbase: plan.base }, plan.missing !== null ? { bmissing: plan.missing } : {});
242
+ continue;
243
+ }
244
+ const bytes = c.enc ? KINDS[c.enc.kind].bytes * n : d.BYTES_PER_ELEMENT * n;
245
+ Object.assign(chunk.cols[name], this.#write(new Uint8Array(d.buffer, d.byteOffset, bytes)), c.enc ? { enc: c.enc } : {});
246
+ }
247
+ } finally {
248
+ ct.dispose();
249
+ }
250
+ this.meta.chunks.push(chunk);
251
+ this.meta.rows += n;
252
+ }
253
+
254
+ // Free text: the chunk's own bytes (only the values it uses), and each
255
+ // value's position in them.
256
+ #writeText(c, lo, hi) {
257
+ const { engine } = this;
258
+ const ptrs = engine.live(c.data), bytes = engine.live(c.strings);
259
+ let total = 0;
260
+ for (let i = lo; i < hi; i++) { const v = ptrs[i]; if (v === v) total += textLength(v); }
261
+ const outPtr = new Float64Array(hi - lo), outBytes = new Uint8Array(total);
262
+ for (let i = lo, at = 0; i < hi; i++) {
263
+ const v = ptrs[i];
264
+ if (v !== v) { outPtr[i - lo] = NaN; continue; }
265
+ const start = Math.floor(v / STR_SHIFT), field = v - start * STR_SHIFT, len = textLength(v);
266
+ outBytes.set(bytes.subarray(start, start + len), at);
267
+ outPtr[i - lo] = at * STR_SHIFT + field;
268
+ at += len;
269
+ }
270
+ const p = this.#write(new Uint8Array(outPtr.buffer)), s = this.#write(outBytes);
271
+ return { off: p.off, len: p.len, soff: s.off, slen: s.len };
272
+ }
273
+
274
+ #write(u8) {
275
+ const pad = (ALIGN - (this.pos % ALIGN)) % ALIGN;
276
+ if (pad) { this.file.write(new Uint8Array(pad), this.pos); this.pos += pad; }
277
+ const off = this.pos;
278
+ this.file.write(u8, off);
279
+ this.pos += u8.length;
280
+ return { off, len: u8.length };
281
+ }
282
+ }
283
+
284
+ // Codes first, first + step, first + 2 * step... (row numbers, times at a
285
+ // fixed interval): { first, step }, else null.
286
+ function sequence(codes, n) {
287
+ if (n < 2) return null;
288
+ const first = codes[0], step = codes[1] - first;
289
+ for (let i = 2; i < n; i++) if (codes[i] !== first + i * step) return null;
290
+ return { first, step };
291
+ }
292
+
293
+ const jsonNumber = (v) => (Number.isFinite(v) ? v : String(v)); // JSON has no Infinity
294
+ const fromJson = (v) => (typeof v === 'string' ? Number(v) : v);
295
+
296
+ // ---- reading --------------------------------------------------------------------------
297
+
298
+ // The footer of a dataset file: { meta, footerStart, size }. A generator
299
+ // that yields { read: bytes, pos } (filled by readFooter / readFooterAsync).
300
+ function* footerG(file) {
301
+ const { size, name } = file;
302
+ const head = new Uint8Array(HEADER), tail = new Uint8Array(TAIL);
303
+ if (size < HEADER + TAIL) throw new Error(`vectro: ${name} is not a Vectro dataset (or wasn't closed)`);
304
+ yield { read: head, pos: 0 };
305
+ yield { read: tail, pos: size - TAIL };
306
+ if (!MAGIC.every((b, i) => head[i] === b && tail[16 + i] === b)) throw new Error(`vectro: ${name} is not a Vectro dataset (or wasn't closed)`);
307
+ const version = new DataView(head.buffer).getUint32(8, true);
308
+ if (version < 1 || version > VERSION) throw new Error(`vectro: ${name} is dataset version ${version}; this Vectro reads versions 1 to ${VERSION}`);
309
+ const dv = new DataView(tail.buffer);
310
+ const footerStart = dv.getFloat64(0, true), len = dv.getFloat64(8, true);
311
+ const json = new Uint8Array(len);
312
+ yield { read: json, pos: footerStart };
313
+ return { meta: JSON.parse(new TextDecoder().decode(json)), footerStart, size };
314
+ }
315
+
316
+ function readFooter(file) {
317
+ const g = footerG(file);
318
+ for (let step = g.next(); ; ) {
319
+ if (step.done) return step.value;
320
+ file.readSync(step.value.read, step.value.pos);
321
+ step = g.next();
322
+ }
323
+ }
324
+
325
+ async function readFooterAsync(file) {
326
+ const g = footerG(file);
327
+ for (let step = g.next(); ; ) {
328
+ if (step.done) return step.value;
329
+ await file.read(step.value.read, step.value.pos);
330
+ step = g.next();
331
+ }
332
+ }
333
+
334
+ // A dataset in a Blob or File (a browser's file input or drop; anything with
335
+ // slice()): read-only, async.
336
+ export function blobFile(blob, name = blob.name ?? 'dataset') {
337
+ return {
338
+ name,
339
+ size: blob.size,
340
+ async read(u8, pos) { u8.set(new Uint8Array(await blob.slice(pos, pos + u8.length).arrayBuffer())); },
341
+ close() {},
342
+ };
343
+ }
344
+
345
+ // A dataset at a URL, read with HTTP range requests (the server must answer
346
+ // "Range: bytes=a-b" with 206): only the parts a query needs are fetched.
347
+ export async function urlFile(url, { fetch: get = globalThis.fetch, headers = {} } = {}) {
348
+ const probe = await get(url, { headers: { ...headers, Range: 'bytes=0-0' } });
349
+ const range = probe.headers.get('content-range'); // bytes 0-0/12345
350
+ await probe.body?.cancel?.();
351
+ if (probe.status !== 206 || !range) throw new Error(`vectro: ${url} doesn't answer range requests (HTTP ${probe.status}); serve it with Range support, or download it first`);
352
+ const size = Number(range.split('/')[1]);
353
+ return {
354
+ name: url,
355
+ size,
356
+ pieceBytes: 8 << 20, // (fewer, bigger requests)
357
+ coalesce: 1 << 16, // neighbouring reads closer than this become one request
358
+ async read(u8, pos) {
359
+ if (!u8.length) return;
360
+ const res = await get(url, { headers: { ...headers, Range: `bytes=${pos}-${pos + u8.length - 1}` } });
361
+ if (res.status !== 206) throw new Error(`vectro: ${url}: HTTP ${res.status} for a range request`);
362
+ const got = new Uint8Array(await res.arrayBuffer());
363
+ if (got.length !== u8.length) throw new Error(`vectro: ${url}: expected ${u8.length} bytes, got ${got.length}`);
364
+ u8.set(got);
365
+ },
366
+ close() {},
367
+ };
368
+ }
369
+
370
+ // A dataset from a path (Node), a URL, a File / Blob or a file object (see
371
+ // blobFile): reads its footer and returns the Dataset.
372
+ export async function openDatasetAsync(engine, source) {
373
+ let file = source;
374
+ if (typeof source === 'string') file = /^https?:/i.test(source) || !files ? await urlFile(source) : files.openRead(source);
375
+ else if (typeof Blob === 'function' && source instanceof Blob) file = blobFile(source);
376
+ let footer;
377
+ try { footer = file.readSync ? readFooter(file) : await readFooterAsync(file); } catch (err) { file.close(); throw err; }
378
+ return new Dataset(engine, file, footer);
379
+ }
380
+
381
+ const bytesOf = (a, len) => new Uint8Array(a.buffer, a.byteOffset, len);
382
+
383
+ export class Dataset {
384
+ // source: a path (Node), or a file object from blobFile / urlFile (those
385
+ // need openDatasetAsync, which reads the footer first).
386
+ constructor(engine, source, footer = null) {
387
+ const file = typeof source === 'string' ? need('opening a dataset by path').openRead(source) : source;
388
+ if (!footer) {
389
+ if (!file.readSync) throw new Error('vectro: open this dataset with openDatasetAsync (it is read asynchronously)');
390
+ try { footer = readFooter(file); } catch (err) { file.close(); throw err; }
391
+ }
392
+ const { meta, size } = footer;
393
+ this.engine = engine;
394
+ this.file = file;
395
+ this.path = file.name;
396
+ this.meta = meta;
397
+ this.bytes = size;
398
+ this.isDataset = true;
399
+ this.type = meta.type === 'f32' ? Float32Array : Float64Array;
400
+ // column info the planner and SQL look at (labels, dates, free text)
401
+ this.cols = new Map(meta.columns.map((c) => [c.name, c.kind === 'category'
402
+ ? { dict: c.dict, codes: new Map(c.dict.map((s, i) => [s, i])) }
403
+ : { dict: null, codes: null, date: c.kind === 'date', strings: c.kind === 'text' || undefined }]));
404
+ this.lastRun = null; // { chunks, read, skipped, rows, bytesRead, ms } of the last query
405
+ }
406
+
407
+ get length() { return this.meta.rows; }
408
+ get columnNames() { return this.meta.columns.map((c) => c.name); }
409
+ get chunkCount() { return this.meta.chunks.length; }
410
+
411
+ // ---- queries (see plan.mjs) -------------------------------------------------------
412
+ lazy() { return new Query({ op: 'scan', table: this }); }
413
+ filter(expr, params) { return this.lazy().filter(expr, params); }
414
+ derive(name, expr, params) { return this.lazy().derive(name, expr, params); }
415
+ select(...names) { return this.lazy().select(...names); }
416
+ sort(by) { return this.lazy().sort(by); }
417
+ top(k, by) { return this.lazy().top(k, by); }
418
+ limit(n, offset) { return this.lazy().limit(n, offset); }
419
+ join(other, opts) { return this.lazy().join(other, opts); }
420
+ groupBy(keys) { return this.lazy().groupBy(keys); }
421
+ agg(spec, params) { return this.lazy().agg(spec, params); }
422
+ aggAsync(spec, params) { return this.lazy().aggAsync(spec, params); }
423
+ count(expr) { return expr ? this.agg({ v: `count(${expr})` }).v : this.lazy().count(); }
424
+ async countAsync(expr) { return expr ? (await this.aggAsync({ v: `count(${expr})` })).v : this.lazy().countAsync(); }
425
+ sum(expr, params) { return this.agg({ v: `sum(${expr})` }, params).v; }
426
+ mean(expr, params) { return this.agg({ v: `mean(${expr})` }, params).v; }
427
+ min(expr, params) { return this.agg({ v: `min(${expr})` }, params).v; }
428
+ max(expr, params) { return this.agg({ v: `max(${expr})` }, params).v; }
429
+ head(n) { return this.lazy().limit(n).collect(); }
430
+ headAsync(n) { return this.lazy().limit(n).collectAsync(); }
431
+ toRows(limit = Infinity) { return this.lazy().toRows(limit); }
432
+ toRowsAsync(limit = Infinity) { return this.lazy().toRowsAsync(limit); }
433
+ collect() { return this.lazy().collect(); } // the whole dataset in memory
434
+ collectAsync() { return this.lazy().collectAsync(); }
435
+ explain() { return this.lazy().explain(); }
436
+
437
+ // Called by plan.mjs for a plan that reads a dataset.
438
+ runPlan(plan, sync) { return sync ? driveSync(runPlanG(plan)) : driveAsync(runPlanG(plan)); }
439
+
440
+ async close() {
441
+ if (this.file) { const f = this.file; this.file = null; f.close(); }
442
+ }
443
+
444
+ // ---- chunks -------------------------------------------------------------------------
445
+ // Rows [lo, hi) of chunk i (a piece) as a table, with engine arrays ready
446
+ // to be read into. Every column has fixed-width rows, so a row range is one
447
+ // read per column (free text: its positions, plus the chunk's text bytes).
448
+ #allocChunk({ i, lo, hi }, names) {
449
+ const { engine } = this;
450
+ const ch = i < 0 ? { rows: 0, cols: {} } : this.meta.chunks[i];
451
+ const n = hi - lo;
452
+ const cols = new Map(), owned = [], ownedBuffers = [], reads = [], seqs = [], unpacks = [], temps = [];
453
+ for (const name of names) {
454
+ const m = ch.cols[name], base = this.cols.get(name);
455
+ if (base.strings) {
456
+ const data = engine.alloc(Float64Array, n), strings = engine.alloc(Uint8Array, m?.slen ?? 0);
457
+ cols.set(name, { data, dict: null, codes: null, strings });
458
+ ownedBuffers.push(strings);
459
+ if (m) reads.push([data, m.off + lo * 8, n * 8], [strings, m.soff, m.slen]);
460
+ } else if (m?.enc) {
461
+ const k = KINDS[m.enc.kind];
462
+ const data = engine.alloc(k.Type, n + SLACK / k.bytes);
463
+ cols.set(name, { data, dict: base.dict, codes: base.codes, date: base.date, enc: m.enc, plainType: this.type, rows: n });
464
+ if (m.seq) seqs.push([name, m.seq]);
465
+ else if (m.bits) { // bit-packed: the words holding rows lo..hi, unpacked after the read
466
+ const w0 = (lo * m.bits) / 32, w1 = Math.ceil((hi * m.bits) / 32); // (lo is a multiple of 65,536: whole words)
467
+ const words = engine.alloc(Uint8Array, (w1 - w0) * 4 + 8);
468
+ temps.push(words);
469
+ reads.push([words, m.off + w0 * 4, (w1 - w0) * 4]);
470
+ unpacks.push([words, data, n, { bits: m.bits, base: m.bbase, missing: m.bmissing ?? null }, k.bytes]);
471
+ } else reads.push([data, m.off + lo * k.bytes, n * k.bytes]);
472
+ } else {
473
+ const w = this.type.BYTES_PER_ELEMENT, data = engine.alloc(this.type, n);
474
+ cols.set(name, { data, dict: base.dict, codes: base.codes, date: base.date });
475
+ if (m) reads.push([data, m.off + lo * w, n * w]);
476
+ }
477
+ owned.push(name);
478
+ }
479
+ for (const c of cols.values()) { c.data = engine.live(c.data); if (c.strings) c.strings = engine.live(c.strings); }
480
+ for (const [name, { first, step }] of seqs) unpacks.push(['seq', cols.get(name).data, n, { first: first + lo * step, step }, KINDS[cols.get(name).enc.kind].bytes]);
481
+ const table = new Table(engine, cols, n, { owned, ownedBuffers, zones: false });
482
+ return { table, reads, unpacks, temps, bytes: reads.reduce((a, r) => a + r[2], 0) };
483
+ }
484
+
485
+ // Bit-packed columns back to their codes, on every thread (a generator).
486
+ *#unpackG(unpacks) {
487
+ const { engine } = this;
488
+ for (const [words, data, n, plan, width] of unpacks) {
489
+ if (!n) continue;
490
+ if (words === 'seq') { // fixed-step column: rebuilt (on every thread)
491
+ if (!engine.jit) { const d = engine.live(data); for (let j = 0; j < n; j++) d[j] = plan.first + j * plan.step; continue; }
492
+ engine._bitKernels ??= new Map();
493
+ let v = engine._bitKernels.get(`seq|${width}`);
494
+ if (!v) engine._bitKernels.set(`seq|${width}`, (v = engine._register(seqModule(width, engine.isShared))));
495
+ yield* engine._unitsG(v, n, [engine.live(data).byteOffset, plan.first, plan.step]);
496
+ continue;
497
+ }
498
+ if (!engine.jit) { unpackBits(engine.live(words), n, plan, engine.live(data)); continue; }
499
+ engine._bitKernels ??= new Map();
500
+ const key = `${width}|${plan.bits}`;
501
+ let v = engine._bitKernels.get(key);
502
+ if (!v) engine._bitKernels.set(key, (v = engine._register(unpackModule(width, plan.bits, engine.isShared))));
503
+ yield* engine._unitsG(v, n, [engine.live(words).byteOffset, engine.live(data).byteOffset, plan.base, plan.missing !== null ? 1 : 0, plan.missing ?? 0]);
504
+ }
505
+ }
506
+
507
+ // The reads in pieces of at most READ_PIECE bytes: [array, file position, bytes, offset in array].
508
+ #pieces(reads) {
509
+ const out = [], size = this.file.pieceBytes ?? READ_PIECE;
510
+ for (const [arr, off, len] of reads) for (let at = 0; at < len; at += size) out.push([arr, off + at, Math.min(size, len - at), at]);
511
+ return out;
512
+ }
513
+
514
+ _readSync(piece, names) {
515
+ const { file } = this;
516
+ if (!file?.readSync) throw new Error(file ? 'vectro: this dataset is read asynchronously (a File, Blob or URL): use toRowsAsync(), aggAsync(), collectAsync()...' : 'vectro: this dataset is closed');
517
+ const { table, reads, unpacks, temps, bytes } = this.#allocChunk(piece, names);
518
+ const { engine } = this;
519
+ try {
520
+ const pieces = this.#pieces(reads);
521
+ if (file.fd !== undefined && engine.W > 0 && engine.canBlock !== false && pieces.length > 1) { // every thread reads pieces straight into engine memory
522
+ const jobs = engine.alloc(Float64Array, pieces.length * 4);
523
+ try {
524
+ const j = engine.live(jobs);
525
+ pieces.forEach(([arr, off, len, at], u) => { j.set([file.fd, off, len, engine.live(arr).byteOffset + at], u * 4); });
526
+ engine._drive((function* () {
527
+ yield { op: OP.READ, n: pieces.length, arrays: [jobs], F: (J, u) => file.readSync(new Uint8Array(engine.memory.buffer, J[u * 4 + 3], J[u * 4 + 2]), J[u * 4 + 1]) };
528
+ })());
529
+ } finally { engine.free(jobs); }
530
+ } else for (const [arr, off, len] of reads) file.readSync(bytesOf(engine.live(arr), len), off);
531
+ this.engine._drive(this.#unpackG(unpacks));
532
+ } catch (err) { table.dispose(); throw err; } finally { for (const t of temps) this.engine.free(t); }
533
+ if (this.lastRun) this.lastRun.bytesRead += bytes;
534
+ return table;
535
+ }
536
+
537
+ async _readAsync(piece, names) {
538
+ const { file } = this;
539
+ if (!file) throw new Error('vectro: this dataset is closed');
540
+ const { table, reads, unpacks, temps, bytes } = this.#allocChunk(piece, names);
541
+ try {
542
+ const view = (arr, at, len) => new Uint8Array(this.engine.live(arr).buffer, this.engine.live(arr).byteOffset + at, len);
543
+ if (file.coalesce) { // (a URL: neighbouring reads as one request each)
544
+ const sorted = reads.filter((r) => r[2]).sort((a, b) => a[1] - b[1]), groups = [];
545
+ for (const r of sorted) {
546
+ const g = groups[groups.length - 1];
547
+ if (g && r[1] - g.end <= file.coalesce && r[1] + r[2] - g.start <= (file.pieceBytes ?? READ_PIECE)) { g.items.push(r); g.end = Math.max(g.end, r[1] + r[2]); } else groups.push({ start: r[1], end: r[1] + r[2], items: [r] });
548
+ }
549
+ await Promise.all(groups.map(async (g) => {
550
+ const buf = new Uint8Array(g.end - g.start);
551
+ await file.read(buf, g.start);
552
+ for (const [arr, off, len] of g.items) view(arr, 0, len).set(buf.subarray(off - g.start, off - g.start + len));
553
+ }));
554
+ } else {
555
+ await Promise.all(this.#pieces(reads).map(async ([arr, off, len, at]) => { // (in pieces: several reads at once)
556
+ if (this.engine.isShared) return file.read(view(arr, at, len), off); // straight into engine memory
557
+ const buf = new Uint8Array(len); // (plain memory may move while the read runs)
558
+ await file.read(buf, off);
559
+ view(arr, at, len).set(buf);
560
+ }));
561
+ }
562
+ await this.engine._driveAsync(this.#unpackG(unpacks));
563
+ } catch (err) { table.dispose(); throw err; } finally { for (const t of temps) this.engine.free(t); }
564
+ if (this.lastRun) this.lastRun.bytesRead += bytes;
565
+ return table;
566
+ }
567
+
568
+ // The pieces { i, lo, hi } (rows lo..hi of chunk i) that can match every
569
+ // `column <op> number` condition: chunks by their min / max, then the
570
+ // blocks from the first that can match to the last.
571
+ _piecesFor(conds) {
572
+ const out = [];
573
+ this.meta.chunks.forEach((ch, i) => {
574
+ for (const { col, op, v } of conds) {
575
+ const z = ch.cols[col];
576
+ if (!z) continue;
577
+ if (z.missing === ch.rows) return; // only missing values: no comparison holds
578
+ if (!mayMatch(op, fromJson(z.min), fromJson(z.max), v)) return;
579
+ }
580
+ let first = 0, last = Math.ceil(ch.rows / BLOCK) - 1;
581
+ const zoned = conds.filter((c) => ch.cols[c.col]?.blocks);
582
+ if (zoned.length) {
583
+ const ok = (b) => zoned.every(({ col, op, v }) => { const z = ch.cols[col].blocks; return mayMatch(op, fromJson(z.min[b]), fromJson(z.max[b]), v); });
584
+ while (first <= last && !ok(first)) first++;
585
+ while (last >= first && !ok(last)) last--;
586
+ if (first > last) return;
587
+ }
588
+ out.push({ i, lo: first * BLOCK, hi: Math.min(ch.rows, (last + 1) * BLOCK) });
589
+ });
590
+ return out;
591
+ }
592
+ }
593
+
594
+ // ---- running plans ----------------------------------------------------------------------
595
+ // A plan runs as a generator of requests: { run: node } (a plan over tables in
596
+ // memory), { read: dataset, i, names } (a chunk as a table), { prefetch: ... }
597
+ // (start reading a chunk; async only).
598
+
599
+ function driveSync(gen) {
600
+ let step = gen.next();
601
+ while (!step.done) {
602
+ const v = step.value;
603
+ let out, err = null;
604
+ try {
605
+ out = v.run ? execute(v.run, true) : v.read ? v.read._readSync(v.piece, v.names) : v.call ? v.call() : undefined; // ({ flush }: nothing to wait for)
606
+ if (typeof out?.then === 'function') throw new Error('vectro: this needs the async calls (toRowsAsync, aggAsync, writeDatasetAsync...)');
607
+ } catch (e) { err = e; }
608
+ step = err ? gen.throw(err) : gen.next(out);
609
+ }
610
+ return step.value;
611
+ }
612
+
613
+ async function driveAsync(gen) {
614
+ const pending = new Map(); // chunk -> promise of its table
615
+ try {
616
+ let step = gen.next();
617
+ while (!step.done) {
618
+ const v = step.value;
619
+ let out, err = null;
620
+ try {
621
+ if (v.run) out = await execute(v.run, false);
622
+ else if (v.flush) await v.flush.file?.flush?.(); // (a writer's bytes on disk before more come)
623
+ else if (v.call) out = await v.call();
624
+ else if (v.prefetch) {
625
+ const key = `${v.prefetch.path}\0${v.piece.i}\0${v.piece.lo}`;
626
+ if (!pending.has(key)) { const p = v.prefetch._readAsync(v.piece, v.names); p.catch(() => {}); pending.set(key, p); }
627
+ } else if (v.read) {
628
+ const key = `${v.read.path}\0${v.piece.i}\0${v.piece.lo}`, p = pending.get(key);
629
+ pending.delete(key);
630
+ out = await (p ?? v.read._readAsync(v.piece, v.names));
631
+ }
632
+ } catch (e) { err = e; }
633
+ step = err ? gen.throw(err) : gen.next(out);
634
+ }
635
+ return step.value;
636
+ } finally {
637
+ for (const p of pending.values()) p.then((t) => t.dispose(), () => {});
638
+ }
639
+ }
640
+
641
+ const BLOCKING = { aggregate: 'group', total: 'total', limit: 'head', sort: 'gather', output: 'collect', window: 'gather' };
642
+ const union = (a, b) => new Set([...a, ...b]);
643
+ const onKeys = (on) => (typeof on === 'string' || Array.isArray(on) ? [[on].flat(), [on].flat()] : [[on?.left].flat(), [on?.right].flat()]);
644
+
645
+ // The joins in a per-chunk plan, marked to return views (scans kept as they are).
646
+ function viewJoins(node) {
647
+ if (node.op === 'scan' || node.op === 'unmatched') return node;
648
+ if (node.op === 'join') return { ...node, opts: { ...node.opts, view: true }, left: viewJoins(node.left) };
649
+ return { ...node, input: viewJoins(node.input) };
650
+ }
651
+
652
+ // node with `target` (the same object) replaced by `repl`
653
+ function substitute(node, target, repl) {
654
+ if (node === target) return repl;
655
+ if (node.op === 'scan' || node.op === 'unmatched') return node;
656
+ if (node.op === 'join') return { ...node, left: substitute(node.left, target, repl), right: substitute(node.right, target, repl) };
657
+ return { ...node, input: substitute(node.input, target, repl) };
658
+ }
659
+
660
+ // The dataset columns a plan reads (null: all of them). Mirrors what the
661
+ // executor in plan.mjs asks of its input.
662
+ function needs(node, need, ds) {
663
+ switch (node.op) {
664
+ case 'scan': return node.table === ds ? need : undefined;
665
+ case 'unmatched': return undefined;
666
+ case 'filter': return needs(node.input, need && union(need, vars(node.ast)), ds);
667
+ case 'derive':
668
+ if (!need) return needs(node.input, null, ds);
669
+ if (!need.has(node.name)) return needs(node.input, need, ds);
670
+ return needs(node.input, union([...need].filter((n) => n !== node.name), vars(node.ast)), ds);
671
+ case 'select': return needs(node.input, new Set(need ? node.names.filter((n) => need.has(n)) : node.names), ds);
672
+ case 'aggregate': return needs(node.input, new Set([...node.keys, ...Object.values(node.spec).flatMap((a) => [...vars(a)])]), ds);
673
+ case 'total': return needs(node.input, new Set(Object.values(node.spec).flatMap((a) => [...vars(a)])), ds);
674
+ case 'sort': return needs(node.input, need && union(need, node.by.flatMap((b) => [...vars(parse(String(b)))])), ds);
675
+ case 'limit': return needs(node.input, need, ds);
676
+ case 'output': return needs(node.input, null, ds);
677
+ case 'textmap': return needs(node.input, need && new Set([...[...need].filter((n) => n !== node.name), ...node.inputs]), ds);
678
+ case 'window': {
679
+ const made = new Set(node.items.map((it) => it.name));
680
+ const inputs = [...node.partition, ...node.order.map((o) => o.name), ...node.items.map((it) => it.arg).filter(Boolean)];
681
+ return needs(node.input, need && new Set([...[...need].filter((n) => !made.has(n)), ...inputs]), ds);
682
+ }
683
+ case 'join': {
684
+ const { left } = joinNames(node);
685
+ const needL = new Set(onKeys(node.opts.on)[0]);
686
+ for (const n of need ?? namesOf(node)) if (left.has(n)) needL.add(n);
687
+ for (const n of namesOf(node.right)) if (left.has(n)) needL.add(n); // (they decide the names of right columns)
688
+ return needs(node.left, needL, ds);
689
+ }
690
+ }
691
+ return null;
692
+ }
693
+
694
+ // Give `result` the columns it shares with `temp`, then free temp.
695
+ function adopt(result, temp) {
696
+ if (!temp || result === temp) return;
697
+ if (result?.cols) {
698
+ for (const [name, c] of result.cols) {
699
+ const own = [...temp.cols].find(([n, tc]) => tc.data === c.data && temp.owned.includes(n));
700
+ if (own) { temp.owned = temp.owned.filter((n) => n !== own[0]); if (!result.owned.includes(name)) result.owned.push(name); }
701
+ if (c.strings && temp.ownedBuffers.includes(c.strings)) { temp.ownedBuffers = temp.ownedBuffers.filter((b) => b !== c.strings); result.ownedBuffers.push(c.strings); }
702
+ }
703
+ }
704
+ temp.dispose();
705
+ }
706
+
707
+ // A per-chunk result we can keep after the chunk is freed (else a copy).
708
+ function* keep(engine, r, chunk) {
709
+ const own = r !== chunk && r.columnNames.every((n) => r.owned.includes(n)) && [...r.cols.values()].every((c) => !c.strings || r.ownedBuffers.includes(c.strings));
710
+ if (own) return r;
711
+ const copy = copyRows(engine, [[r, 0, r.n]]);
712
+ if (r !== chunk) r.dispose();
713
+ return copy;
714
+ }
715
+
716
+ // A join whose two sides are both too big for memory (a "grace" hash join):
717
+ // both sides' rows go to partition files by ranges of the (first) join key,
718
+ // with the same split points; then for each range, its right rows are held
719
+ // in memory and its left rows stream past them chunk by chunk, through the
720
+ // rest of the per-chunk plan (acc). Rows come out range by range, not in the
721
+ // left table's order.
722
+ function* partitionedJoinG(J, viewTop, ds, names, acc) {
723
+ const { engine } = ds;
724
+ const [lk, rk] = onKeys(J.opts.on);
725
+ const Rds = findDataset(J.right);
726
+ const count = Math.min(64, Math.max(2, Math.ceil((2 * J.partitioned.rows) / tuning.joinRows)));
727
+ const splitters = (Rds.cols.has(rk[0]) ? zoneSplitters(Rds, rk[0], count) : null) ?? (ds.cols.has(lk[0]) ? zoneSplitters(ds, lk[0], count) : null) ?? [];
728
+ const rs = yield* Spill.create(engine, rk[0], splitters);
729
+ let ls;
730
+ try { ls = yield* Spill.create(engine, lk[0], splitters); } catch (err) { rs.cleanup(); throw err; }
731
+ stats.joinPartitions++;
732
+ try {
733
+ yield* runPlanG({ op: 'output', input: J.right }, rs);
734
+ const keep = namesOf(J.left).filter((n) => names.includes(n) || !ds.cols.has(n)); // (the left columns used, and derived ones)
735
+ yield* runPlanG({ op: 'output', input: { op: 'select', names: keep, input: J.left } }, ls);
736
+ const rp = yield* rs.closeG(), lp = yield* ls.closeG();
737
+ let ran = false;
738
+ for (let j = 0; j < lp.length && !acc.done; j++) {
739
+ if (!lp[j] || (!rp[j] && (J.opts.how ?? 'inner') !== 'left')) continue; // (no rows can come out of this range)
740
+ const R = rp[j] ? yield* readAll(engine, rp[j]) : emptyLike(engine, rs);
741
+ const L = yield* tempDataset(engine, lp[j]);
742
+ try {
743
+ for (let i = 0; i < L.chunkCount && !acc.done; i++) {
744
+ const c = yield { read: L, piece: { i, lo: 0, hi: L.meta.chunks[i].rows }, names: L.columnNames };
745
+ try {
746
+ ds.lastRun.rows += c.n;
747
+ ran = true;
748
+ yield* acc.chunk(substitute(viewTop, J, { ...J, left: { op: 'scan', table: c }, right: { op: 'scan', table: R } }), c);
749
+ } finally { c.dispose(); }
750
+ }
751
+ } finally {
752
+ L.close();
753
+ const builds = engine._joinBuilds?.get(R.cols.get(rk[0])?.data); // (this range's hash table: freed now)
754
+ if (builds) { for (const b of builds.values()) freeBuild(engine, b); engine._joinBuilds.delete(R.cols.get(rk[0]).data); }
755
+ R.dispose();
756
+ }
757
+ }
758
+ if (!ran) { // no rows on one side: the plan still runs once, on no rows, for the result's shape
759
+ const scan = (function find(n) { return n.op === 'scan' ? n : find(n.op === 'join' ? n.left : n.input); })(J.left);
760
+ const empty = yield { read: ds, piece: { i: -1, lo: 0, hi: 0 }, names };
761
+ const R = emptyLike(engine, rs);
762
+ try { yield* acc.chunk(substitute(substitute(viewTop, J, { ...J, right: { op: 'scan', table: R } }), scan, { op: 'scan', table: empty }), empty); } finally { empty.dispose(); R.dispose(); }
763
+ }
764
+ } finally { rs.cleanup(); ls.cleanup(); }
765
+ }
766
+
767
+ // A dataset's rows as one table in memory.
768
+ function* readAll(engine, path) {
769
+ const D = yield* tempDataset(engine, path), parts = [];
770
+ try {
771
+ yield* eachChunk(D, function* (t) { parts.push(t); });
772
+ return copyRows(engine, parts.map((t) => [t, 0, t.n]));
773
+ } finally { for (const t of parts) t.dispose(); D.close(); }
774
+ }
775
+
776
+ // A table with no rows and the columns a spill was given.
777
+ function emptyLike(engine, spill) {
778
+ const w = spill.writers.find((x) => x.meta);
779
+ const cols = new Map(), owned = [];
780
+ for (const c of w.meta.columns) {
781
+ const labels = c.kind === 'category' ? w.labels.get(c.name).dict : null;
782
+ cols.set(c.name, c.kind === 'text' ? { data: engine.alloc(Float64Array, 0), dict: null, codes: null, strings: engine.alloc(Uint8Array, 0) } : { data: engine.alloc(Float64Array, 0), dict: labels, codes: labels && new Map(labels.map((s, i) => [s, i])), date: c.kind === 'date' });
783
+ owned.push(c.name);
784
+ }
785
+ return new Table(engine, cols, 0, { owned, ownedBuffers: [...cols.values()].filter((c) => c.strings).map((c) => c.strings) });
786
+ }
787
+
788
+ // Every join's right side (the build side) as one plain table, made once:
789
+ // a dataset there is read in full (its matching rows and used columns), a
790
+ // filtered or packed table is collected / decoded, so each chunk's join finds
791
+ // the same table (and its hash table, kept in engine._joinBuilds).
792
+ function* materializeRights(node, temps) {
793
+ if (node.op === 'scan' || node.op === 'unmatched') return node; // (unmatched: a FULL JOIN's right rows, made by its join)
794
+ if (node.op === 'union') { // each input with a dataset in it: read (its matching rows) into memory
795
+ const inputs = [];
796
+ for (const input of node.inputs) {
797
+ if (!findDataset(input)) { inputs.push(yield* materializeRights(input, temps)); continue; }
798
+ const t = yield* runPlanG({ op: 'output', input });
799
+ temps.push(t);
800
+ inputs.push({ op: 'scan', table: t });
801
+ }
802
+ return { ...node, inputs };
803
+ }
804
+ if (node.op === 'join') {
805
+ const left = yield* materializeRights(node.left, temps);
806
+ let right = node.right;
807
+ const L = findDataset(left), Rds = findDataset(right);
808
+ if (L && Rds) { // both on disk: too many right rows to hold? then both sides go to disk by key range
809
+ const [lk, rk] = onKeys(node.opts.on);
810
+ const textKey = L.cols.get(lk[0])?.dict || Rds.cols.get(rk[0])?.dict; // (codes differ between files: not by range)
811
+ const rows = textKey ? 0 : (yield* runPlanG({ op: 'total', spec: { n: parse('count()') }, input: right })).n;
812
+ if (rows > tuning.joinRows) return { ...node, left, right, partitioned: { rows } };
813
+ }
814
+ const plain = right.op === 'scan' && !right.table.isDataset && !right.table.pred && ![...right.table.cols.values()].some((c) => c.enc);
815
+ if (!plain) {
816
+ let t = findDataset(right) ? yield* runPlanG({ op: 'output', input: right }) : yield { run: { op: 'output', input: right } };
817
+ if (t.pred || [...t.cols.values()].some((c) => c.enc) || t.owned.length < t.cols.size) { const copy = copyRows(t.engine, [[t, 0, t.n]]); adopt(null, t); t = copy; }
818
+ temps.push(t);
819
+ right = { op: 'scan', table: t };
820
+ }
821
+ return { ...node, left, right };
822
+ }
823
+ return { ...node, input: yield* materializeRights(node.input, temps) };
824
+ }
825
+
826
+ // sink: a DatasetWriter that takes the result (matching rows go straight to
827
+ // it, chunk by chunk, so the result needn't fit in memory either).
828
+ export function* runPlanG(plan, sink = null) {
829
+ const temps = [];
830
+ let result;
831
+ try {
832
+ plan = yield* materializeRights(plan, temps);
833
+ const path = [plan];
834
+ for (let n = plan; n.op !== 'scan' && n.op !== 'union'; ) { n = n.op === 'join' ? n.left : n.input; path.push(n); }
835
+ const scan = path[path.length - 1];
836
+ if (!scan.table?.isDataset) result = yield { run: plan };
837
+ else result = yield* runChunksG(plan, path, sink);
838
+ if (sink && result) { // (the result itself was built in memory)
839
+ if (result.cols) sink.append(result);
840
+ else sink.append(Object.fromEntries(Object.entries(result).map(([k, v]) => [k, [v]])));
841
+ }
842
+ return sink ? null : result;
843
+ } finally {
844
+ for (const t of temps) adopt(sink ? null : result, t);
845
+ if (sink && result?.cols) result.dispose();
846
+ }
847
+ }
848
+
849
+ function* runChunksG(plan, path, sink) {
850
+ const scan = path[path.length - 1], ds = scan.table, { engine } = ds;
851
+ const t0 = performance.now();
852
+ ds.lastRun = { chunks: ds.chunkCount, read: 0, skipped: 0, rows: 0, bytesRead: 0, ms: 0 };
853
+ // the first step above the dataset that needs all rows
854
+ let bi = path.length - 2;
855
+ while (!BLOCKING[path[bi].op]) bi--;
856
+ let B = path[bi], kind = BLOCKING[B.op];
857
+ const below = path.slice(bi + 1, -1); // (the steps between the dataset and B: what each chunk runs)
858
+ if (B.op === 'sort' && bi > 0 && path[bi - 1].op === 'limit' && path[bi - 1].input === B) { B = path[bi - 1]; kind = 'top'; }
859
+ const top = kind === 'top' ? B.input.input : B.input; // what runs on each chunk
860
+ // columns to read, and the chunks the filters right above the dataset allow
861
+ const need = needs(plan, null, ds);
862
+ let names = need ? ds.columnNames.filter((n) => need.has(n)) : ds.columnNames;
863
+ if (!names.length) names = [smallestColumn(ds)]; // (count() still needs the row count)
864
+ const conds = [];
865
+ for (let k = path.length - 2; k >= 0 && path[k].op === 'filter'; k--) {
866
+ let rewritten;
867
+ try { rewritten = toSource(rewriteText(path[k].ast, ds.cols, toSource(path[k].ast))); } catch { continue; } // (the run reports it)
868
+ const round = ds.type === Float32Array ? Math.fround : (v) => v;
869
+ for (const c of conditions(rewritten, ds.cols, {})) conds.push({ ...c, v: round(c.v) });
870
+ }
871
+ const viewTop = viewJoins(top); // (joins on each chunk: unique right keys as views, see joinViewG)
872
+ const pieces = ds._piecesFor(conds);
873
+ ds.lastRun.skipped = ds.chunkCount - pieces.length;
874
+ const list = pieces.length ? pieces : [{ i: -1, lo: 0, hi: 0 }]; // (no rows, but the plan still runs once for the result's shape)
875
+ // columns made below B (names made above it, such as a result's renames, are not: B sees the dataset's)
876
+ const derived = new Set(below.filter((n) => n.op === 'derive' || n.op === 'textmap' || n.op === 'join').flatMap((n) => (n.op === 'join' ? namesOf(n.right) : [n.name])));
877
+ // derived columns that are arithmetic on number columns of the dataset (a top-k cutoff can compare them)
878
+ derived.numeric = new Set(below.filter((n) => n.op === 'derive' && arithmetic(n.ast, ds)).map((n) => n.name));
879
+ const acc = accumulator(kind, B, engine, sink, ds, derived);
880
+ let finalTable = null;
881
+ engine._joinRuns = (engine._joinRuns ?? 0) + 1; // joins keep their right side's build while a dataset runs
882
+ engine._joinBuilds ??= new Map();
883
+ try {
884
+ let pj = viewTop;
885
+ while (pj.op !== 'scan' && !(pj.op === 'join' && pj.partitioned)) pj = pj.op === 'join' ? pj.left : pj.input;
886
+ if (pj.op === 'join') yield* partitionedJoinG(pj, viewTop, ds, names, acc);
887
+ else {
888
+ // top-k: each chunk is narrowed to the blocks that can still beat the
889
+ // k-th row so far (acc.prune), just before it is read or prefetched
890
+ let at = 0, pruned = 0;
891
+ const next = () => { // the next piece to read (null: none left)
892
+ while (at < list.length) {
893
+ const p = list[at++], q = acc.prune && p.i >= 0 ? acc.prune(p) : p;
894
+ if (q) return q;
895
+ pruned++;
896
+ }
897
+ return null;
898
+ };
899
+ let piece = next(), k = 0;
900
+ while (piece) {
901
+ if (k++ > 0 && acc.done) break;
902
+ const chunk = yield { read: ds, piece, names };
903
+ // (top-k: no prefetch during the first chunk, so the next one is narrowed by its result)
904
+ const ahead = acc.done || (acc.prune && !acc.ready() && k === 1) ? null : next();
905
+ if (ahead) yield { prefetch: ds, piece: ahead, names };
906
+ ds.lastRun.read += piece.i >= 0 ? 1 : 0;
907
+ ds.lastRun.rows += chunk.n;
908
+ try {
909
+ yield* acc.chunk(substitute(viewTop, scan, { op: 'scan', table: chunk }), chunk);
910
+ } finally {
911
+ chunk.dispose();
912
+ }
913
+ piece = ahead ?? (acc.done ? null : next());
914
+ }
915
+ ds.lastRun.skipped += pruned;
916
+ }
917
+ const merged = yield* acc.finish();
918
+ if (kind === 'total' || merged === null) return merged;
919
+ if (merged.isDataset) { // spilled groups: the rest of the plan reads them from disk
920
+ try { return yield* runPlanG(substitute(plan, B, { op: 'scan', table: merged }), sink); } finally { merged.close(); removeTemp(merged.temporary); }
921
+ }
922
+ finalTable = merged;
923
+ const rest = substitute(plan, kind === 'group' ? B : top, { op: 'scan', table: merged });
924
+ const result = yield { run: rest };
925
+ adopt(result, merged);
926
+ finalTable = null;
927
+ return result;
928
+ } finally {
929
+ finalTable?.dispose();
930
+ acc.dispose();
931
+ if (--engine._joinRuns === 0) {
932
+ for (const builds of engine._joinBuilds.values()) for (const b of builds.values()) freeBuild(engine, b);
933
+ engine._joinBuilds = null;
934
+ }
935
+ ds.lastRun.ms = performance.now() - t0;
936
+ }
937
+ }
938
+
939
+ function smallestColumn(ds) {
940
+ const ch = ds.meta.chunks[0];
941
+ if (!ch) return ds.columnNames[0];
942
+ return ds.columnNames.filter((n) => !ds.cols.get(n).strings).sort((a, b) => (ch.cols[a]?.len ?? 0) - (ch.cols[b]?.len ?? 0))[0] ?? ds.columnNames[0];
943
+ }
944
+
945
+ // How per-chunk results are kept and merged, per kind of step:
946
+ // chunk(P, table): run the step on one chunk's plan P (may yield runs)
947
+ // finish(): the merged result (a table, or totals for 'total')
948
+ function accumulator(kind, B, engine, sink, ds, derived) {
949
+ if (kind === 'group' || kind === 'total') return groupAccumulator(kind, B, engine, ds, derived);
950
+ if (kind === 'gather' && sink && B.op === 'sort') { const first = firstSortKey(B.by[0], ds, derived); if (first && /^[A-Za-z_]\w*$/.test(first.expr)) return sortSpillAccumulator(B, engine, ds, derived, first); }
951
+ const parts = [];
952
+ let rows = 0;
953
+ // top-k: once k rows are kept, a row whose first key is worse than the
954
+ // k-th can't make it, so later chunks get that cutoff as a filter (ties pass)
955
+ const k = B.n + (B.offset ?? 0);
956
+ const first = kind === 'top' && k > 0 ? firstSortKey(B.input.by[0], ds, derived, true) : null;
957
+ let cutoff = null;
958
+ // the leading sort keys that are plain number columns of the dataset (their
959
+ // min / max per block are in the footer), and the k-th row's values of them
960
+ const zkeys = [];
961
+ if (first) for (const by of B.input.by) {
962
+ const key = firstSortKey(by, ds, derived, true);
963
+ const c = key && /^[A-Za-z_]\w*$/.test(key.expr) && !derived.has(key.expr) && ds.cols.get(key.expr);
964
+ if (!c || c.dict || c.strings) break;
965
+ zkeys.push(key);
966
+ }
967
+ // the leading sort keys that sort by value (the k-th row's values of them are found)
968
+ const ckeys = [];
969
+ if (first) for (const by of B.input.by) { const key = firstSortKey(by, ds, derived, true); if (!key) break; ckeys.push(key); }
970
+ let bound = null, dense = false, cutFirst = null;
971
+ const acc = {
972
+ done: false,
973
+ ready: () => bound !== null,
974
+ // piece narrowed to the blocks that may hold a row at least as good as
975
+ // the k-th so far (from the first such block to the last; null: none): a
976
+ // block whose max (desc) / min (asc) of the first key is worse can't, nor
977
+ // one that at best ties it there and is worse on the next key, and so on
978
+ // (missing values sort last: never better)
979
+ prune: zkeys.length ? (piece) => {
980
+ if (!bound) return piece;
981
+ const ch = ds.meta.chunks[piece.i];
982
+ const b0 = Math.floor(piece.lo / BLOCK), b1 = Math.ceil(piece.hi / BLOCK);
983
+ const zone = (z, b, which) => fromJson(z.blocks && b1 - b0 > 0 ? z.blocks[which][b] : z[which]);
984
+ const may = (b) => {
985
+ for (let j = 0; j < zkeys.length; j++) {
986
+ const z = ch.cols[zkeys[j].expr], v = bound[j];
987
+ if (!z || v !== v) return true;
988
+ if (z.missing === ch.rows) return false; // (only missing values: last)
989
+ const lo = zone(z, b, 'min'), hi = zone(z, b, 'max');
990
+ if (zkeys[j].desc ? hi > v : lo < v) return true; // may be better on this key
991
+ if (zkeys[j].desc ? hi < v : lo > v) return false; // worse on this key
992
+ if (!(lo <= v && v <= hi)) return false; // (an all-missing block)
993
+ // at best a tie on this key: the next key decides
994
+ }
995
+ return true; // (ties with the k-th pass: the merge picks)
996
+ };
997
+ let first = b0, last = b1 - 1;
998
+ while (first <= last && !may(first)) first++;
999
+ while (last > first && !may(last)) last--;
1000
+ if (first > last) return null;
1001
+ return { i: piece.i, lo: Math.max(piece.lo, first * BLOCK), hi: Math.min(piece.hi, (last + 1) * BLOCK) };
1002
+ } : null,
1003
+ *chunk(P, chunk) {
1004
+ let node;
1005
+ if (kind === 'top') {
1006
+ node = { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: P } } };
1007
+ if (cutoff !== null) { // only rows at least as good as the k-th so far: keep them all (the merge picks the best)
1008
+ const F = { op: 'filter', ast: cutFirst, input: P };
1009
+ node = dense ? { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: F } } } : { op: 'output', input: F };
1010
+ }
1011
+ }
1012
+ else if (kind === 'head') node = { op: 'output', input: { op: 'limit', n: B.n + (B.offset ?? 0) - rows, offset: 0, input: P } };
1013
+ else node = { op: 'output', input: P };
1014
+ let r = yield { run: node };
1015
+ if (kind === 'top' && cutoff !== null && !dense && r.n > Math.max(8 * k, 1 << 14)) {
1016
+ // many rows beat the cutoff (e.g. newest first over time-ordered chunks):
1017
+ // the best k of them now, and top-k straight away on the chunks after
1018
+ dense = true;
1019
+ const own = yield* keep(engine, r, chunk);
1020
+ const best = yield { run: { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: { op: 'scan', table: own } } } } };
1021
+ r = yield* keep(engine, best, own);
1022
+ if (r !== own) own.dispose();
1023
+ }
1024
+ if (sink && kind === 'collect') { // straight to the dataset being written (or to spill partitions)
1025
+ if (sink.appendG) { yield* sink.appendG(yield* keep(engine, r, chunk)); return; }
1026
+ try { sink.append(r); } finally { if (r !== chunk) r.dispose(); }
1027
+ yield { flush: sink };
1028
+ return;
1029
+ }
1030
+ parts.push(yield* keep(engine, r, chunk));
1031
+ rows += r.n;
1032
+ if (kind === 'head' && rows >= B.n + (B.offset ?? 0)) acc.done = true;
1033
+ if (kind === 'top' && (parts.length >= 16 || (first && rows >= k && parts.length > 1))) { // keep the best k of what we have
1034
+ const all = merge();
1035
+ const best = yield { run: { op: 'output', input: { op: 'limit', n: k, offset: 0, input: { ...B.input, input: { op: 'scan', table: all } } } } };
1036
+ parts.push(yield* keep(engine, best, all));
1037
+ all.dispose();
1038
+ rows = parts[0].n;
1039
+ }
1040
+ if (first && rows >= k && parts.length === 1) { // the k-th row's first key
1041
+ const spec = {};
1042
+ ckeys.forEach((c, j) => { spec[`c${j}`] = parse(`min(${c.expr})`); });
1043
+ const r = yield { run: { op: 'total', spec, input: { op: 'limit', n: 1, offset: k - 1, input: { op: 'scan', table: parts[0] } } } };
1044
+ const num = (x) => (x instanceof Date ? x.getTime() : typeof x === 'number' ? x : NaN);
1045
+ const vals = ckeys.map((_, j) => num(r[`c${j}`]));
1046
+ if (vals[0] === vals[0]) {
1047
+ cutoff = vals[0];
1048
+ cutFirst = parse(`(${first.expr}) ${first.desc ? '>=' : '<='} ${toSource({ t: 'num', v: cutoff })}`);
1049
+ }
1050
+ if (zkeys.length) bound = vals.slice(0, zkeys.length);
1051
+ }
1052
+ },
1053
+ *finish() {
1054
+ if (sink && kind === 'collect') return null;
1055
+ return merge();
1056
+ },
1057
+ dispose() { for (const p of parts.splice(0)) p.dispose(); },
1058
+ };
1059
+ // all parts as one table (parts emptied)
1060
+ const merge = () => {
1061
+ const ps = parts.splice(0);
1062
+ if (ps.length === 1) return ps[0];
1063
+ try { return copyRows(engine, ps.map((p) => [p, 0, p.n])); } finally { for (const p of ps) p.dispose(); }
1064
+ };
1065
+ return acc;
1066
+ }
1067
+
1068
+ // ---- spilling: partial results on disk, split by key ranges --------------------
1069
+ // Rows go to partition files by their first key: partition j holds the keys
1070
+ // in [splitters[j-1], splitters[j]), and the last one the missing keys, so
1071
+ // partitions in order hold the keys in order, and each can be finished on
1072
+ // its own.
1073
+ const PART = '__vectro_part';
1074
+ class Spill {
1075
+ // (made with yield* Spill.create(...): the files may open asynchronously)
1076
+ constructor(engine, key, splitters, dir, paths, writers) {
1077
+ const P = splitters.length + 2;
1078
+ Object.assign(this, { engine, dir, paths, writers, rows: 0 });
1079
+ this.partAst = parse(`(${key}) != (${key}) ? ${P - 1} : (0${splitters.map((v) => ` + ((${key}) >= ${toSource({ t: 'num', v })})`).join('')})`);
1080
+ }
1081
+
1082
+ static *create(engine, key, splitters) {
1083
+ const store = temp();
1084
+ const dir = yield { call: () => store.dir('vectro-spill-') };
1085
+ const paths = Array.from({ length: splitters.length + 2 }, (_, j) => store.join(dir, `part-${j}.vectro`));
1086
+ const writers = [];
1087
+ try { for (const p of paths) writers.push(yield* tempWriter(engine, p, { rowsPerChunk: 1 << 18 })); } catch (err) { for (const w of writers) w.abort(); removeTemp(dir); throw err; }
1088
+ return new Spill(engine, key, splitters, dir, paths, writers);
1089
+ }
1090
+
1091
+ *appendG(t) { yield* this.add(t); } // (as a sink: runPlanG writes a query's rows here)
1092
+
1093
+ // t's rows, each to its partition (t is freed)
1094
+ *add(t) {
1095
+ try {
1096
+ if (!t.n) return;
1097
+ this.rows += t.n;
1098
+ const S = yield { run: { op: 'output', input: { op: 'sort', by: [PART], input: { op: 'derive', name: PART, ast: this.partAst, input: { op: 'scan', table: t } } } } };
1099
+ const touched = new Set();
1100
+ try {
1101
+ const part = this.engine.live(S.cols.get(PART).data), view = S.select(...t.columnNames);
1102
+ for (let a = 0; a < S.n; ) {
1103
+ const j = part[a];
1104
+ let b = a;
1105
+ while (b < S.n && part[b] === j) b++;
1106
+ this.writers[j].append(view, a, b);
1107
+ touched.add(this.writers[j]);
1108
+ a = b;
1109
+ }
1110
+ } finally { S.dispose(); }
1111
+ for (const w of touched) yield { flush: w }; // (browser files: written before more comes)
1112
+ } finally { t.dispose(); }
1113
+ }
1114
+
1115
+ // the partition files in key order (null: no rows), closed
1116
+ *closeG() {
1117
+ for (const w of this.writers) { if (!w.meta) w.abort(); else yield* closeWriter(w); }
1118
+ return this.paths.map((p, j) => (this.writers[j].meta ? p : null));
1119
+ }
1120
+
1121
+ cleanup() { for (const w of this.writers) w.abort(); removeTemp(this.dir); }
1122
+ }
1123
+
1124
+ // Up to P - 1 split points for a column, from the dataset's min / max per
1125
+ // block (an even spread of the values), or null if they're all the same.
1126
+ function zoneSplitters(ds, name, P) {
1127
+ const vals = [];
1128
+ for (const ch of ds.meta.chunks) {
1129
+ const z = ch.cols[name];
1130
+ if (!z || z.missing === ch.rows) continue;
1131
+ const add = (v) => { v = fromJson(v); if (Number.isFinite(v)) vals.push(v); };
1132
+ if (z.blocks) for (let b = 0; b < z.blocks.min.length; b++) { add(z.blocks.min[b]); add(z.blocks.max[b]); }
1133
+ else { add(z.min); add(z.max); }
1134
+ }
1135
+ return quantiles(vals.sort((a, b) => a - b), P);
1136
+ }
1137
+
1138
+ function quantiles(sorted, P) {
1139
+ const out = [];
1140
+ for (let i = 1; i < P; i++) {
1141
+ const v = sorted[Math.floor((i * sorted.length) / P)];
1142
+ if (v !== undefined && v > sorted[0] && (!out.length || v > out[out.length - 1])) out.push(v);
1143
+ }
1144
+ return out.length ? out : null;
1145
+ }
1146
+
1147
+ // Every chunk of a dataset as tables (all columns), one at a time.
1148
+ function* eachChunk(D, fn) {
1149
+ for (let i = 0; i < D.chunkCount; i++) {
1150
+ const t = yield { read: D, piece: { i, lo: 0, hi: D.meta.chunks[i].rows }, names: D.columnNames };
1151
+ yield* fn(t);
1152
+ }
1153
+ }
1154
+
1155
+ // A full sort whose result goes to a dataset (it needn't fit in memory):
1156
+ // the rows go to partitions by the first sort key, each partition is sorted
1157
+ // in memory, and the partitions are written in order (missing keys last).
1158
+ function sortSpillAccumulator(B, engine, ds, derived, first) {
1159
+ let spill = null, empty = null; // (empty: the result's columns, if no rows match)
1160
+ return {
1161
+ done: false,
1162
+ *chunk(P, chunk) {
1163
+ const t = yield* keep(engine, yield { run: { op: 'output', input: P } }, chunk);
1164
+ if (!t.n && !empty) { empty = t; return; }
1165
+ if (!spill) {
1166
+ const count = Math.min(64, Math.max(2, Math.ceil((2 * ds.length) / tuning.partitionRows)));
1167
+ let splitters = !derived.has(first.expr) && ds.cols.has(first.expr) ? zoneSplitters(ds, first.expr, count) : null;
1168
+ if (!splitters && t.cols.has(first.expr)) { // from the first rows
1169
+ const v = engine.live(t.cols.get(first.expr).data), vals = [];
1170
+ for (let i = 0; i < t.n; i++) if (v[i] === v[i]) vals.push(v[i]);
1171
+ splitters = quantiles(vals.sort((a, b) => a - b), count);
1172
+ }
1173
+ spill = yield* Spill.create(engine, first.expr, splitters ?? []);
1174
+ stats.spills++;
1175
+ }
1176
+ yield* spill.add(t);
1177
+ },
1178
+ *finish() {
1179
+ spill ??= yield* Spill.create(engine, first.expr, []);
1180
+ const paths = yield* spill.closeG();
1181
+ const ordered = first.desc ? [...paths.slice(0, -1).reverse(), paths[paths.length - 1]] : paths;
1182
+ const outPath = temp().join(spill.dir, 'sorted.vectro');
1183
+ const out = yield* tempWriter(engine, outPath);
1184
+ let ok = false;
1185
+ try {
1186
+ for (const p of ordered) if (p) yield* sortPartition(p, 0, out);
1187
+ if (!out.meta) out.append(empty); // (no rows at all)
1188
+ yield* closeWriter(out);
1189
+ ok = true;
1190
+ } finally { if (!ok) out.abort(); }
1191
+ const result = yield* tempDataset(engine, outPath);
1192
+ result.temporary = spill.dir;
1193
+ spill = null;
1194
+ return result;
1195
+ },
1196
+ dispose() { spill?.cleanup(); empty?.dispose(); },
1197
+ };
1198
+
1199
+ function* sortPartition(path, depth, out) {
1200
+ const D = yield* tempDataset(engine, path);
1201
+ try {
1202
+ const splitters = D.length > tuning.partitionRows && depth < 4 ? zoneSplitters(D, first.expr, 16) : null;
1203
+ if (splitters) { // split again
1204
+ const sub = yield* Spill.create(engine, first.expr, splitters);
1205
+ stats.resplits++;
1206
+ try {
1207
+ yield* eachChunk(D, (t) => sub.add(t));
1208
+ const ps = yield* sub.closeG();
1209
+ for (const p of first.desc ? [...ps.slice(0, -1).reverse(), ps[ps.length - 1]] : ps) if (p) yield* sortPartition(p, depth + 1, out);
1210
+ } finally { sub.cleanup(); }
1211
+ return;
1212
+ }
1213
+ const parts = [];
1214
+ let all;
1215
+ try {
1216
+ yield* eachChunk(D, function* (t) { parts.push(t); });
1217
+ all = copyRows(engine, parts.map((t) => [t, 0, t.n]));
1218
+ } finally { for (const t of parts) t.dispose(); }
1219
+ let sorted;
1220
+ try { sorted = yield* keep(engine, yield { run: { op: 'output', input: { op: 'sort', by: B.by, input: { op: 'scan', table: all } } } }, all); } finally { all.dispose(); }
1221
+ try { out.append(sorted); } finally { sorted.dispose(); }
1222
+ yield { flush: out };
1223
+ } finally { D.close(); }
1224
+ }
1225
+ }
1226
+
1227
+ // The first sort key as an expression and its direction, if it sorts by
1228
+ // value (a text column sorts by label: no cutoff then).
1229
+ // (numeric: a derived column that is arithmetic on number columns counts as a value too)
1230
+ function firstSortKey(by, ds, derived, numeric = false) {
1231
+ const src = String(by).trim();
1232
+ const desc = src.startsWith('-');
1233
+ const expr = desc ? src.slice(1).trim() : src;
1234
+ const ident = /^[A-Za-z_]\w*$/.test(expr);
1235
+ if (ident && !derived.has(expr) && (ds.cols.get(expr)?.dict || ds.cols.get(expr)?.strings)) return null;
1236
+ if (ident && derived.has(expr) && !(numeric && derived.numeric?.has(expr))) return null; // (a join's text column, or a derived label: be safe)
1237
+ return { expr, desc };
1238
+ }
1239
+
1240
+ // numbers, number columns of ds, + - * / and minus only
1241
+ function arithmetic(ast, ds) {
1242
+ if (ast.t === 'num') return true;
1243
+ if (ast.t === 'var') { const c = ds.cols.get(ast.name); return !!c && !c.dict && !c.strings; }
1244
+ if (ast.t === 'un') return ast.op === '-' && arithmetic(ast.x, ds);
1245
+ if (ast.t === 'bin') return ['+', '-', '*', '/'].includes(ast.op) && arithmetic(ast.l, ds) && arithmetic(ast.r, ds);
1246
+ return false;
1247
+ }
1248
+
1249
+ // totals and groups: each aggregation as partial values per chunk, merged
1250
+ // sum, count -> added; min, max -> min, max (missing skipped); mean -> a
1251
+ // sum and a count of the values present; countDistinct / approxDistinct ->
1252
+ // the distinct (key, value) pairs, merged as groups, counted at the end
1253
+ // (exact on datasets).
1254
+ const DV = '__vectro_dv';
1255
+ function groupAccumulator(kind, B, engine, ds, derived) {
1256
+ const keys = kind === 'group' ? B.keys : [];
1257
+ const partial = {}, merge = {}, outs = [], distinct = [];
1258
+ Object.entries(B.spec).forEach(([out, ast], j) => {
1259
+ const p = `__vectro_p${j}`;
1260
+ if (ast.fn === 'countDistinct' || ast.fn === 'approxDistinct') {
1261
+ // (a category column: its missing values, '', are not counted)
1262
+ const a0 = ast.args[0], blankable = a0.t === 'var' && !derived.has(a0.name) && ds.cols.get(a0.name)?.dict?.includes('');
1263
+ const arg = blankable ? parse(`${a0.name} != '' ? ${a0.name} : 0 / 0`) : a0;
1264
+ distinct.push({ out, arg, parts: [], rows: 0, last: 0, bitmap: bitmapPlan(ds, derived, keys, a0) });
1265
+ outs.push({ out, distinct: distinct.length - 1 });
1266
+ return;
1267
+ }
1268
+ if (ast.fn === 'mean') {
1269
+ const src = toSource(ast.args[0]);
1270
+ partial[`${p}s`] = { t: 'call', fn: 'sum', args: [ast.args[0]] };
1271
+ partial[`${p}n`] = parse(`count((${src}) == (${src}))`);
1272
+ merge[`${p}s`] = parse(`sum(${p}s)`);
1273
+ merge[`${p}n`] = parse(`sum(${p}n)`);
1274
+ outs.push({ out, mean: p });
1275
+ return;
1276
+ }
1277
+ partial[p] = ast;
1278
+ merge[p] = parse(`${ast.fn === 'count' ? 'sum' : ast.fn}(${p})`);
1279
+ outs.push({ out, col: p });
1280
+ });
1281
+ if (!Object.keys(partial).length) { partial.__vectro_rows = parse('count()'); merge.__vectro_rows = parse('sum(__vectro_rows)'); }
1282
+ const main = { parts: [], rows: 0, last: 0 };
1283
+ const dense = kind === 'group' ? densePlan(ds, derived, keys, partial, engine) : null;
1284
+ // too many groups to keep: partial groups go to disk by key range
1285
+ const canSpill = kind === 'group' && !dense && !distinct.length;
1286
+ let spill = null, chunksDone = 0;
1287
+ let totals = null; // kind 'total': partial values so far
1288
+ const combine = (fn, a, b) => (fn === 'min' ? (b < a || a !== a ? b : a) : fn === 'max' ? (b > a || a !== a ? b : a) : a + b);
1289
+ const mergeFn = Object.fromEntries(Object.entries(merge).map(([k, a]) => [k, a.fn]));
1290
+
1291
+ // grouped partial tables of one kind, merged again when they pile up
1292
+ function* compact(state, groupKeys, spec, force) {
1293
+ if (!force && (state.parts.length < 2 || state.rows < Math.max(tuning.mergeRows, 2 * state.last))) return;
1294
+ if (state.parts.length < 2 && force) return;
1295
+ const ps = state.parts.splice(0);
1296
+ let all;
1297
+ try { all = copyRows(engine, ps.map((p) => [p, 0, p.n])); } finally { for (const p of ps) p.dispose(); }
1298
+ try {
1299
+ const g = yield { run: { op: 'output', input: { op: 'aggregate', keys: groupKeys, spec, input: { op: 'scan', table: all } } } };
1300
+ state.parts.push(yield* keep(engine, g, all));
1301
+ } finally { all.dispose(); }
1302
+ state.rows = state.last = state.parts[0].n;
1303
+ }
1304
+
1305
+ const distinctKeys = [...keys, DV];
1306
+ const distinctMerge = { __vectro_dn: parse('sum(__vectro_dn)') };
1307
+ return {
1308
+ done: false,
1309
+ *chunk(P, chunk) {
1310
+ if (kind === 'total') {
1311
+ const r = yield { run: { op: 'total', input: P, spec: partial } };
1312
+ totals = totals ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, combine(mergeFn[k], totals[k], v)])) : r;
1313
+ } else if (dense?.kernel) { // the matching rows added into the running totals, on every thread
1314
+ yield { run: { op: 'groupInto', input: P, group: dense.code, K: dense.groups, sources: dense.sources, state: dense.state } };
1315
+ } else if (dense) { // group numbers and values of the matching rows, added into the arrays
1316
+ let node = P;
1317
+ node = { op: 'derive', name: DV, ast: dense.code, input: node };
1318
+ for (const it of dense.items) node = { op: 'derive', name: it.e, ast: it.ast, input: node };
1319
+ const r = yield { run: { op: 'output', input: { op: 'select', names: [DV, ...dense.items.map((it) => it.e)], input: node } } };
1320
+ try {
1321
+ denseAdd(engine.live(r.cols.get(DV).data), dense.items.map((it) => engine.live(r.cols.get(it.e).data)), dense.items.map((it) => it.fn), dense.accs, dense.seen, r.n);
1322
+ } finally { if (r !== chunk) r.dispose(); }
1323
+ } else {
1324
+ const r = yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: partial, input: P } } };
1325
+ const kept = yield* keep(engine, r, chunk);
1326
+ main.parts.push(kept);
1327
+ main.rows += kept.n;
1328
+ chunksDone++;
1329
+ if (!spill) yield* compact(main, keys, merge, false);
1330
+ if (canSpill && !spill && main.rows > tuning.spillRows) {
1331
+ const estimate = (main.rows * Math.max(1, ds.chunkCount)) / chunksDone;
1332
+ const P = Math.min(64, Math.max(4, Math.ceil((2 * estimate) / tuning.partitionRows)));
1333
+ const k0 = keys[0];
1334
+ let splitters = !derived.has(k0) && ds.cols.has(k0) ? zoneSplitters(ds, k0, P) : null;
1335
+ if (!splitters) { // from the groups so far (they are sorted by key)
1336
+ const g = main.parts[0], v = engine.live(g.cols.get(k0).data);
1337
+ const vals = [];
1338
+ for (let i = 0; i < g.n; i++) if (v[i] === v[i]) vals.push(v[i]);
1339
+ splitters = quantiles(vals.sort((a, b) => a - b), P);
1340
+ }
1341
+ if (splitters) { spill = yield* Spill.create(engine, k0, splitters); stats.spills++; }
1342
+ }
1343
+ if (spill) { for (const t of main.parts.splice(0)) yield* spill.add(t); main.rows = 0; }
1344
+ }
1345
+ for (const d of distinct) {
1346
+ if (d.bitmap) { // one code per matching row: its group and value, as a bit
1347
+ const r = yield { run: { op: 'output', input: { op: 'select', names: [DV], input: { op: 'derive', name: DV, ast: d.bitmap.code, input: P } } } };
1348
+ try { setBits(d.bitmap.bits, engine.live(r.cols.get(DV).data), r.n); } finally { if (r !== chunk) r.dispose(); }
1349
+ continue;
1350
+ }
1351
+ const r = yield { run: { op: 'output', input: { op: 'aggregate', keys: distinctKeys, spec: { __vectro_dn: parse('count()') }, input: { op: 'derive', name: DV, ast: d.arg, input: P } } } };
1352
+ const kept = yield* keep(engine, r, chunk);
1353
+ d.parts.push(kept);
1354
+ d.rows += kept.n;
1355
+ yield* compact(d, distinctKeys, distinctMerge, false);
1356
+ }
1357
+ },
1358
+ *finish() {
1359
+ // distinct counts: per group, the distinct values present
1360
+ const counts = [];
1361
+ for (const d of distinct) {
1362
+ if (d.bitmap) { counts.push(d.bitmap); continue; } // (counted below, per group)
1363
+ yield* compact(d, distinctKeys, distinctMerge, true);
1364
+ const pairs = d.parts[0];
1365
+ if (kind === 'total') counts.push((yield { run: { op: 'total', input: { op: 'scan', table: pairs }, spec: { c: parse(`count(${DV})`) } } }).c);
1366
+ else counts.push(yield* keep(engine, yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: { c: parse(`count(${DV})`) }, input: { op: 'scan', table: pairs } } } }, pairs));
1367
+ }
1368
+ if (kind === 'total') {
1369
+ const out = {};
1370
+ const value = (c) => (c.bits ? countBits(c.bits, 0, c.range) - blankBit(c, 0) : c);
1371
+ for (const o of outs) out[o.out] = o.distinct !== undefined ? value(counts[o.distinct]) : o.mean ? totals[`${o.mean}s`] / totals[`${o.mean}n`] : totals[o.col];
1372
+ return out;
1373
+ }
1374
+ if (spill) return yield* finishSpilled();
1375
+ yield* compact(main, keys, merge, true);
1376
+ return finalize(dense ? denseTable(engine, dense, keys, ds) : main.parts.pop(), counts);
1377
+ },
1378
+ dispose() {
1379
+ if (dense?.state.acc) { engine.free(dense.state.acc); dense.state.acc = null; }
1380
+ for (const p of main.parts.splice(0)) p.dispose();
1381
+ for (const d of distinct) for (const p of d.parts.splice(0)) p.dispose();
1382
+ spill?.cleanup();
1383
+ },
1384
+ };
1385
+
1386
+ // Each partition's groups merged and finished, in key order, into a
1387
+ // dataset of the result (the rest of the plan reads it chunk by chunk).
1388
+ // A partition too big to merge in memory is split again by its first key.
1389
+ function* finishSpilled() {
1390
+ const paths = yield* spill.closeG();
1391
+ const outPath = temp().join(spill.dir, 'groups.vectro');
1392
+ const out = yield* tempWriter(engine, outPath);
1393
+ let ok = false;
1394
+ try {
1395
+ for (const p of paths) if (p) yield* mergePartition(p, 0, out);
1396
+ yield* closeWriter(out);
1397
+ ok = true;
1398
+ } finally { if (!ok) out.abort(); }
1399
+ const result = yield* tempDataset(engine, outPath);
1400
+ const dir = spill.dir;
1401
+ spill = null;
1402
+ result.temporary = dir; // (deleted after the rest of the plan ran)
1403
+ return result;
1404
+ }
1405
+
1406
+ function* mergePartition(path, depth, out) {
1407
+ const D = yield* tempDataset(engine, path);
1408
+ try {
1409
+ if (!D.length) return;
1410
+ const splitters = D.length > tuning.partitionRows && depth < 4 ? zoneSplitters(D, keys[0], 16) : null;
1411
+ if (splitters) { // split again
1412
+ const sub = yield* Spill.create(engine, keys[0], splitters);
1413
+ stats.resplits++;
1414
+ try {
1415
+ yield* eachChunk(D, (t) => sub.add(t));
1416
+ for (const p of yield* sub.closeG()) if (p) yield* mergePartition(p, depth + 1, out);
1417
+ } finally { sub.cleanup(); }
1418
+ return;
1419
+ }
1420
+ const parts = [];
1421
+ let all;
1422
+ try {
1423
+ yield* eachChunk(D, function* (t) { parts.push(t); });
1424
+ all = copyRows(engine, parts.map((t) => [t, 0, t.n]));
1425
+ } finally { for (const t of parts) t.dispose(); }
1426
+ let G;
1427
+ try { G = yield* keep(engine, yield { run: { op: 'output', input: { op: 'aggregate', keys, spec: merge, input: { op: 'scan', table: all } } } }, all); } finally { all.dispose(); }
1428
+ const F = finalize(G, []);
1429
+ try { out.append(F); } finally { F.dispose(); }
1430
+ yield { flush: out };
1431
+ } finally { D.close(); }
1432
+ }
1433
+
1434
+ // Merged partial groups G -> the result: keys, then each aggregation.
1435
+ function finalize(G, counts) {
1436
+ try {
1437
+ const cols = new Map(), owned = [];
1438
+ const take = (t, name) => { t.owned = t.owned.filter((n) => n !== name); return t.cols.get(name); };
1439
+ for (const k of keys) { cols.set(k, take(G, k)); owned.push(k); }
1440
+ for (const o of outs) {
1441
+ if (o.col) cols.set(o.out, take(G, o.col));
1442
+ else if (o.mean) {
1443
+ const data = engine.alloc(Float64Array, G.n);
1444
+ if (G.n) engine.eval('s / n', { s: G.cols.get(`${o.mean}s`).data, n: G.cols.get(`${o.mean}n`).data }, data);
1445
+ cols.set(o.out, { data: engine.live(data), dict: null, codes: null });
1446
+ } else if (counts[o.distinct].bits) { // each group's bits
1447
+ const bm = counts[o.distinct], data = engine.alloc(Float64Array, G.n);
1448
+ const keyData = keys.map((k) => engine.live(G.cols.get(k).data)), out = engine.live(data);
1449
+ for (let r = 0; r < G.n; r++) {
1450
+ let g = 0;
1451
+ for (let k = 0; k < keys.length; k++) g += (keyData[k][r] - bm.mins[k]) * bm.strides[k];
1452
+ out[r] = countBits(bm.bits, g * bm.range, (g + 1) * bm.range) - blankBit(bm, g);
1453
+ }
1454
+ cols.set(o.out, { data: engine.live(data), dict: null, codes: null });
1455
+ } else {
1456
+ const C = counts[o.distinct];
1457
+ if (C.n !== G.n) throw new Error(`vectro: internal: ${C.n} distinct-count groups for ${G.n} groups`);
1458
+ cols.set(o.out, take(C, 'c'));
1459
+ }
1460
+ owned.push(o.out);
1461
+ }
1462
+ for (const c of cols.values()) c.data = engine.live(c.data);
1463
+ return new Table(engine, cols, G.n, { owned });
1464
+ } finally {
1465
+ G.dispose();
1466
+ for (const C of counts) C.dispose?.();
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ // Exact distinct counts as one bitmap: when the counted column and the group
1472
+ // keys are dataset columns of whole numbers (or labels) with known ranges,
1473
+ // bit (group * range + value - min) marks that a group has that value.
1474
+ // Null when it doesn't apply (the distinct pairs are merged instead).
1475
+ const MAX_BITS = 2 ** 31; // 256 MB
1476
+ function bitmapPlan(ds, derived, keys, arg) {
1477
+ if (arg.t !== 'var') return null;
1478
+ const v = wholeRange(ds, derived, arg.name), g = v && denseKeys(ds, derived, keys);
1479
+ if (!g || g.groups * v[1] > MAX_BITS) return null;
1480
+ const blank = ds.cols.get(arg.name).dict?.indexOf('') ?? -1; // (the blank label's code: missing text)
1481
+ return { code: parse(`(${g.src}) * ${v[1]} + ((${arg.name}) - (${v[0]}))`), range: v[1], blank, mins: g.mins, strides: g.strides, bits: new Uint32Array(Math.ceil((g.groups * v[1]) / 32)) };
1482
+ }
1483
+
1484
+ // [min, size, missing] of a dataset column's values when they are whole
1485
+ // numbers one apart (counts, IDs, codes) or labels, from the chunk
1486
+ // statistics; else null.
1487
+ function wholeRange(ds, derived, name) {
1488
+ if (!ds.cols.has(name) || derived.has(name)) return null;
1489
+ const base = ds.cols.get(name);
1490
+ if (base.strings) return null;
1491
+ if (base.dict) return [0, Math.max(1, base.dict.length), 0];
1492
+ let lo = Infinity, hi = -Infinity, missing = 0;
1493
+ for (const ch of ds.meta.chunks) {
1494
+ const z = ch.cols[name];
1495
+ missing += z.missing;
1496
+ if (z.missing === ch.rows) continue;
1497
+ if (!z.enc || z.enc.div || z.enc.mul !== 1) return null;
1498
+ lo = Math.min(lo, fromJson(z.min));
1499
+ hi = Math.max(hi, fromJson(z.max));
1500
+ }
1501
+ return lo === Infinity ? [0, 1, missing] : [lo, hi - lo + 1, missing];
1502
+ }
1503
+
1504
+ // Group keys as one dense group number: (k1 - min1) * stride1 + ..., in key
1505
+ // order (labels in dictionary order). Null unless every key qualifies.
1506
+ function denseKeys(ds, derived, keys) {
1507
+ const mins = [], sizes = [];
1508
+ for (const k of keys) {
1509
+ const r = wholeRange(ds, derived, k);
1510
+ if (!r || r[2]) return null; // (missing keys form a group of their own: not here)
1511
+ mins.push(r[0]);
1512
+ sizes.push(r[1]);
1513
+ }
1514
+ const groups = sizes.reduce((a, b) => a * b, 1);
1515
+ const strides = sizes.map((_, k) => sizes.slice(k + 1).reduce((a, b) => a * b, 1));
1516
+ let src = '0';
1517
+ keys.forEach((k, j) => { src = `${src} + ((${k}) - (${mins[j]})) * ${strides[j]}`; });
1518
+ return { src, groups, mins, sizes, strides };
1519
+ }
1520
+
1521
+ // Many groups with dense keys: each chunk's values are added straight into
1522
+ // one array per partial value, indexed by group number (no partial tables
1523
+ // to merge). Null when it doesn't apply or the groups are few (the group
1524
+ // kernels are faster there).
1525
+ // With compiled kernels the rows are added on every thread (each thread its
1526
+ // own range of groups, see Table._groupInto); else in plain JS.
1527
+ const DENSE_MIN = 1 << 16, DENSE_MAX = 1 << 24;
1528
+ function densePlan(ds, derived, keys, partial, engine) {
1529
+ const g = keys.length && denseKeys(ds, derived, keys);
1530
+ if (!g || g.groups < (tuning.denseMin ?? DENSE_MIN) || g.groups > DENSE_MAX) return null;
1531
+ const names = Object.keys(partial);
1532
+ const items = names.map((name, j) => {
1533
+ const { fn, args } = partial[name], arg = args[0], a = arg && toSource(arg);
1534
+ const e = `__vectro_e${j}`;
1535
+ if (fn === 'count') return { name, fn: 'add', e, ast: arg ? parse(isMask(arg) ? `(${a}) ? 1 : 0` : `(${a}) == (${a}) ? 1 : 0`) : parse('1') };
1536
+ return { name, fn, e, ast: arg }; // sum, min, max
1537
+ });
1538
+ // the kernel's reductions: rows per group first, then each item (min / max with a count of values present)
1539
+ const sources = ['count(1)'], at = (src) => { let i = sources.indexOf(src); if (i < 0) { i = sources.length; sources.push(src); } return i; };
1540
+ for (const it of items) {
1541
+ const a = toSource(it.ast), present = `(${a}) == (${a})`;
1542
+ if (it.fn === 'add') it.slot = a === '1' ? 0 : at(`sum(${a})`);
1543
+ else if (it.fn === 'sum') it.slot = at(`sum(${present} ? (${a}) : 0)`);
1544
+ else { it.slot = at(`${it.fn}(${present} ? (${a}) : ${it.fn === 'min' ? 'Infinity' : '-Infinity'})`); it.presentSlot = at(`sum(${present} ? 1 : 0)`); }
1545
+ }
1546
+ const kernel = engine.jit && sources.length <= 8;
1547
+ if (g.groups * (kernel ? sources.length : names.length + 1) * 8 > 2 ** 30) return null;
1548
+ return {
1549
+ ...g, items, code: parse(g.src), kernel, sources: sources.map((x) => parse(x)), state: { acc: null },
1550
+ seen: kernel ? null : new Float64Array(g.groups),
1551
+ accs: kernel ? null : items.map((it) => new Float64Array(g.groups).fill(it.fn === 'min' || it.fn === 'max' ? NaN : 0)),
1552
+ };
1553
+ }
1554
+
1555
+ // The groups that have rows, in group-number (= key) order, as a table of
1556
+ // the keys and the partial values (as merged partial tables would be).
1557
+ function denseTable(engine, dense, keys, ds) {
1558
+ const { items, mins, sizes, strides, groups: K } = dense;
1559
+ let { seen, accs } = dense;
1560
+ if (dense.kernel) { // (the kernel's totals: per item its slot; min / max are missing where no value was present)
1561
+ const acc = dense.state.acc ? engine.live(dense.state.acc) : new Float64Array(K * dense.sources.length);
1562
+ const slot = (j) => acc.subarray(j * K, (j + 1) * K);
1563
+ seen = slot(0);
1564
+ accs = items.map((it) => {
1565
+ const v = slot(it.slot);
1566
+ if (it.presentSlot === undefined) return v;
1567
+ const p = slot(it.presentSlot), out = new Float64Array(K);
1568
+ for (let g = 0; g < K; g++) out[g] = p[g] ? v[g] : NaN;
1569
+ return out;
1570
+ });
1571
+ }
1572
+ let n = 0;
1573
+ for (let g = 0; g < seen.length; g++) if (seen[g]) n++;
1574
+ const cols = new Map(), owned = [];
1575
+ const add = (name, desc) => { cols.set(name, desc); owned.push(name); };
1576
+ keys.forEach((k) => { const base = ds.cols.get(k); add(k, { data: engine.alloc(Float64Array, n), dict: base.dict, codes: base.codes, date: base.date }); });
1577
+ items.forEach((it) => add(it.name, { data: engine.alloc(Float64Array, n), dict: null, codes: null }));
1578
+ const keyOut = keys.map((k) => engine.live(cols.get(k).data)), accOut = items.map((it) => engine.live(cols.get(it.name).data));
1579
+ for (let g = 0, r = 0; g < seen.length; g++) {
1580
+ if (!seen[g]) continue;
1581
+ for (let k = 0; k < keys.length; k++) keyOut[k][r] = mins[k] + (Math.floor(g / strides[k]) % sizes[k]);
1582
+ for (let j = 0; j < items.length; j++) accOut[j][r] = accs[j][g];
1583
+ r++;
1584
+ }
1585
+ for (const c of cols.values()) c.data = engine.live(c.data);
1586
+ return new Table(engine, cols, n, { owned });
1587
+ }
1588
+
1589
+ // (a plain function: the hot loop)
1590
+ function denseAdd(gs, values, fns, accs, seen, n) {
1591
+ for (let i = 0; i < n; i++) seen[gs[i]]++;
1592
+ for (let j = 0; j < values.length; j++) {
1593
+ const v = values[j], acc = accs[j], fn = fns[j];
1594
+ if (fn === 'add') for (let i = 0; i < n; i++) acc[gs[i]] += v[i];
1595
+ else if (fn === 'sum') for (let i = 0; i < n; i++) { const x = v[i]; if (x === x) acc[gs[i]] += x; }
1596
+ else if (fn === 'min') for (let i = 0; i < n; i++) { const x = v[i], g = gs[i], a = acc[g]; if (x < a || (a !== a && x === x)) acc[g] = x; }
1597
+ else for (let i = 0; i < n; i++) { const x = v[i], g = gs[i], a = acc[g]; if (x > a || (a !== a && x === x)) acc[g] = x; }
1598
+ }
1599
+ }
1600
+
1601
+ function setBits(bits, codes, n) {
1602
+ for (let i = 0; i < n; i++) {
1603
+ const c = codes[i];
1604
+ if (c === c) bits[c >>> 5] |= 1 << (c & 31); // (missing values: NaN, not counted)
1605
+ }
1606
+ }
1607
+
1608
+ // 1 when group g of a bitmap saw the blank label (missing text: not counted), else 0
1609
+ function blankBit(bm, g) {
1610
+ if (bm.blank < 0) return 0;
1611
+ const i = g * bm.range + bm.blank;
1612
+ return (bm.bits[i >>> 5] >>> (i & 31)) & 1;
1613
+ }
1614
+
1615
+ function countBits(bits, from, to) {
1616
+ let n = 0;
1617
+ const pop = (x) => { x -= (x >>> 1) & 0x55555555; x = (x & 0x33333333) + ((x >>> 2) & 0x33333333); return (((x + (x >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; };
1618
+ for (let b = from; b < to; ) {
1619
+ const w = b >>> 5, off = b & 31, take = Math.min(32 - off, to - b);
1620
+ const mask = take === 32 ? 0xffffffff : ((1 << take) - 1) << off;
1621
+ n += pop((bits[w] & mask) >>> 0);
1622
+ b += take;
1623
+ }
1624
+ return n;
1625
+ }
1626
+
1627
+ // ---- CSV of any size -> dataset ------------------------------------------------------------
1628
+ // Read in pieces (pieceBytes, cut at row ends), each parsed on every thread
1629
+ // with the column types found in the first piece. Returns the writer's summary.
1630
+ export function csvToDataset(engine, csvPath, outPath, opts = {}) {
1631
+ const file = need('csvToDataset').openRead(csvPath);
1632
+ try {
1633
+ const writer = new DatasetWriter(engine, outPath, opts);
1634
+ const gen = csvPiecesG(engine, file.size, writer, opts);
1635
+ let step = gen.next();
1636
+ while (!step.done) {
1637
+ const v = step.value;
1638
+ let out, err = null;
1639
+ try { out = v.read ? file.readSync(v.read, v.pos) : v.parse ? loadBytes(engine, v.parse, v.opts) : undefined; } catch (e) { err = e; }
1640
+ step = err ? gen.throw(err) : gen.next(out);
1641
+ }
1642
+ return writer.close();
1643
+ } finally { file.close(); }
1644
+ }
1645
+
1646
+ // The same without blocking: reads with async I/O, parses on the worker
1647
+ // threads. csv: a path (Node), a URL, or a File / Blob; out: a path (Node) or
1648
+ // a writable file object (browsers: see files-browser.mjs).
1649
+ export async function csvToDatasetAsync(engine, csv, out, opts = {}) {
1650
+ const file = typeof csv === 'string' && files && !/^https?:/i.test(csv) ? files.openRead(csv)
1651
+ : typeof csv === 'string' ? await urlFile(csv) : typeof Blob === 'function' && csv instanceof Blob ? blobFile(csv) : csv;
1652
+ try {
1653
+ const writer = await createDatasetAsync(engine, out, opts);
1654
+ try {
1655
+ const gen = csvPiecesG(engine, file.size, writer, opts);
1656
+ let step = gen.next();
1657
+ while (!step.done) {
1658
+ const v = step.value;
1659
+ let got, err = null;
1660
+ try { got = v.read ? await file.read(v.read, v.pos) : v.parse ? await loadBytesAsync(engine, v.parse, v.opts) : await writer.file.flush?.(); } catch (e) { err = e; }
1661
+ step = err ? gen.throw(err) : gen.next(got);
1662
+ }
1663
+ } catch (err) { writer.abort(); throw err; }
1664
+ return await writer.closeAsync();
1665
+ } finally { file.close(); }
1666
+ }
1667
+
1668
+ // Yields { read: bytes, pos } (fill bytes from the file at pos), { parse:
1669
+ // engine bytes, opts } (a table) and { flush } (wait for the writer); appends
1670
+ // each piece's rows to the writer (aborted on an error).
1671
+ // opts.onProgress({ bytes, size, rows }) is called after each piece.
1672
+ function* csvPiecesG(engine, size, writer, { pieceBytes = 64 << 20, header = true, delimiter = ',', types = {}, sampleRows, rowsPerChunk, bitPack, append, onProgress, ...rest } = {}) {
1673
+ try {
1674
+ let pos = 0, carry = new Uint8Array(0), head = new Uint8Array(0), kinds = null;
1675
+ while (true) {
1676
+ const want = Math.min(pieceBytes, size - pos);
1677
+ const buf = new Uint8Array(carry.length + want);
1678
+ buf.set(carry, 0);
1679
+ yield { read: buf.subarray(carry.length), pos };
1680
+ pos += want;
1681
+ const last = pos >= size;
1682
+ let start = 0;
1683
+ if (!kinds) { // the first piece: column names and types
1684
+ const info = inspectCSV(buf, { delimiter, header, types, sampleRows });
1685
+ kinds = Object.fromEntries(info.names.map((n, i) => [n, info.kinds[i]]));
1686
+ head = header ? buf.slice(0, info.start) : new Uint8Array(0);
1687
+ start = info.start;
1688
+ }
1689
+ const end = last ? buf.length : rowsEnd(buf, start);
1690
+ if (end > start || (!writer.meta && last)) { // (a header and no rows: still the columns)
1691
+ const rows = end > start ? buf.subarray(start, end) : new Uint8Array(0);
1692
+ const bytes = engine.alloc(Uint8Array, head.length + rows.length);
1693
+ bytes.set(head, 0);
1694
+ bytes.set(rows, head.length);
1695
+ const t = yield { parse: bytes, opts: { delimiter, header, types: kinds, ...rest } };
1696
+ try { writer.append(t); } finally { t.dispose(); }
1697
+ yield { flush: writer };
1698
+ onProgress?.({ bytes: pos, size, rows: writer.meta.rows + (writer.pending?.n ?? 0) });
1699
+ }
1700
+ if (last) break;
1701
+ carry = buf.slice(Math.max(start, end));
1702
+ }
1703
+ } catch (err) {
1704
+ writer.abort();
1705
+ throw err;
1706
+ }
1707
+ }
1708
+
1709
+ // Where the last complete row of buf[start..] ends (quotes may hold newlines).
1710
+ function rowsEnd(buf, start) {
1711
+ const NL = 10, QUOTE = 34;
1712
+ if (buf.indexOf(QUOTE, start) === -1) return buf.lastIndexOf(NL) + 1;
1713
+ let inQuotes = false, end = start;
1714
+ for (let i = start; i < buf.length; i++) {
1715
+ const c = buf[i];
1716
+ if (c === QUOTE) inQuotes = !inQuotes;
1717
+ else if (c === NL && !inQuotes) end = i + 1;
1718
+ }
1719
+ return end;
1720
+ }
1721
+
1722
+ // Write a table, a query or a dataset as a dataset. A query over a dataset
1723
+ // that ends in matching rows is written chunk by chunk.
1724
+ export function writeDataset(engine, path, source, opts) {
1725
+ const writer = new DatasetWriter(engine, path, opts);
1726
+ try {
1727
+ if (source instanceof Query || source?.isDataset) {
1728
+ const plan = optimize({ op: 'output', input: (source.isDataset ? source.lazy() : source).node });
1729
+ if (findDataset(plan)) driveSync(runPlanG(plan, writer));
1730
+ else { const t = execute(plan, true); try { writer.append(t); } finally { t.dispose(); } }
1731
+ } else writer.append(source);
1732
+ } catch (err) {
1733
+ writer.abort();
1734
+ throw err;
1735
+ }
1736
+ return writer.close();
1737
+ }
1738
+
1739
+ // A writer for a path (Node) or a writable file object (browsers), reading
1740
+ // the footer first when appending to a file that has one.
1741
+ export async function createDatasetAsync(engine, target, opts = {}) {
1742
+ const file = typeof target === 'string' ? need('writing a dataset').openWrite(target, { append: opts.append }) : target;
1743
+ let footer = null;
1744
+ if (file.existed) try { footer = file.readSync ? readFooter(file) : await readFooterAsync(file); } catch (err) { file.close(); throw err; }
1745
+ return new DatasetWriter(engine, file, { ...opts, footer });
1746
+ }
1747
+
1748
+ // writeDataset without blocking: queries run with the async calls, and the
1749
+ // writer's bytes are on disk before more are made.
1750
+ export async function writeDatasetAsync(engine, target, source, opts) {
1751
+ const writer = await createDatasetAsync(engine, target, opts);
1752
+ try {
1753
+ if (source instanceof Query || source?.isDataset) {
1754
+ const plan = optimize({ op: 'output', input: (source.isDataset ? source.lazy() : source).node });
1755
+ if (findDataset(plan)) await driveAsync(runPlanG(plan, writer));
1756
+ else { const t = await execute(plan, false); try { await writer.appendAsync(t); } finally { t.dispose(); } }
1757
+ } else await writer.appendAsync(source);
1758
+ } catch (err) {
1759
+ writer.abort();
1760
+ throw err;
1761
+ }
1762
+ return writer.closeAsync();
1763
+ }