vectrojs 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +822 -2
  4. package/bin/vectro.mjs +82 -0
  5. package/dist/vectro.wasm +0 -0
  6. package/mcp/server.mjs +288 -0
  7. package/package.json +91 -4
  8. package/src/arrow.mjs +905 -0
  9. package/src/bitpack.mjs +135 -0
  10. package/src/browser.mjs +129 -0
  11. package/src/bytes.mjs +60 -0
  12. package/src/csv-kernel.mjs +205 -0
  13. package/src/csv.mjs +270 -0
  14. package/src/dataset.mjs +1763 -0
  15. package/src/dates.mjs +139 -0
  16. package/src/emitter.mjs +109 -0
  17. package/src/engine.mjs +881 -0
  18. package/src/files-browser.mjs +104 -0
  19. package/src/files-node.mjs +59 -0
  20. package/src/frames.mjs +191 -0
  21. package/src/fuse.mjs +512 -0
  22. package/src/interp.mjs +305 -0
  23. package/src/join.mjs +534 -0
  24. package/src/kernels.mjs +312 -0
  25. package/src/ndjson.mjs +136 -0
  26. package/src/node.mjs +120 -0
  27. package/src/pack.mjs +114 -0
  28. package/src/parallel-sort.mjs +99 -0
  29. package/src/partition-kernels.mjs +388 -0
  30. package/src/partition.mjs +395 -0
  31. package/src/plan.mjs +551 -0
  32. package/src/portable.mjs +21 -0
  33. package/src/rank-kernels.mjs +81 -0
  34. package/src/rows.mjs +91 -0
  35. package/src/serve/catalog.mjs +148 -0
  36. package/src/serve/flight.mjs +270 -0
  37. package/src/serve/formats.mjs +87 -0
  38. package/src/serve/http.mjs +182 -0
  39. package/src/serve/index.mjs +9 -0
  40. package/src/serve/odata.mjs +291 -0
  41. package/src/serve/pg.mjs +588 -0
  42. package/src/serve/pgcatalog.mjs +220 -0
  43. package/src/serve/pgtypes.mjs +199 -0
  44. package/src/sketch.mjs +75 -0
  45. package/src/sort-kernels.mjs +117 -0
  46. package/src/sort.mjs +102 -0
  47. package/src/split-worker-core.mjs +252 -0
  48. package/src/split-worker-node.mjs +8 -0
  49. package/src/split-worker.mjs +7 -0
  50. package/src/split.mjs +741 -0
  51. package/src/sql.mjs +1558 -0
  52. package/src/sqltext.mjs +132 -0
  53. package/src/static-kernels.mjs +164 -0
  54. package/src/stream-worker.mjs +553 -0
  55. package/src/table-kernels.mjs +323 -0
  56. package/src/table.mjs +2206 -0
  57. package/src/textmap.mjs +159 -0
  58. package/src/vectors.mjs +138 -0
  59. package/src/wasm.mjs +254 -0
  60. package/src/window.mjs +160 -0
  61. package/src/worker-browser.mjs +21 -0
  62. package/src/worker-loop.mjs +106 -0
  63. package/src/worker.mjs +22 -0
  64. package/src/zones.mjs +75 -0
package/src/rows.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // Copying rows between tables: the rows of several tables (or ranges of
2
+ // them) one after another as a new table, with category labels merged and
3
+ // free text given its own bytes. Used by datasets (chunks, spills) and by
4
+ // set operations (UNION).
5
+ import { Table } from './table.mjs';
6
+ import { decodeOne } from './pack.mjs';
7
+ import { STR_SHIFT, STR_ESCAPED } from './csv-kernel.mjs';
8
+
9
+ export const textLength = (v) => { const field = v - Math.floor(v / STR_SHIFT) * STR_SHIFT; return field >= STR_ESCAPED ? field - STR_ESCAPED : field; };
10
+ // Rows [lo, hi) of column c as plain numbers in out (packed: decoded).
11
+ export function plainInto(engine, c, lo, hi, out, f32) {
12
+ const src = engine.live(c.data);
13
+ if (!c.enc) { out.set(src.subarray(lo, hi)); return; }
14
+ for (let i = lo; i < hi; i++) out[i - lo] = decodeOne(c.enc, src[i], f32);
15
+ }
16
+
17
+ // A table's category codes as codes of the dataset's dictionary (new labels added).
18
+ export function remapCodes(data, dict, labels) {
19
+ if (dict === labels.dict) return;
20
+ const lut = new Float64Array(dict.length);
21
+ let same = dict.length <= labels.dict.length;
22
+ dict.forEach((s, j) => {
23
+ let g = labels.codes.get(s);
24
+ if (g === undefined) { g = labels.dict.length; labels.dict.push(s); labels.codes.set(s, g); }
25
+ lut[j] = g;
26
+ if (g !== j) same = false;
27
+ });
28
+ if (same) return;
29
+ for (let i = 0; i < data.length; i++) { const v = data[i]; data[i] = v === v ? lut[v] : NaN; }
30
+ }
31
+
32
+ const sameDict = (a, b) => a === b || (a.length === b.length && a.every((s, i) => s === b[i]));
33
+
34
+ // The rows [lo, hi) of each part, one after another, as a new table (owned,
35
+ // plain columns; categories share a dictionary; free text gets its own bytes).
36
+ export function copyRows(engine, parts) {
37
+ const first = parts[0][0];
38
+ const T = first.type;
39
+ const n = parts.reduce((a, [, lo, hi]) => a + (hi - lo), 0);
40
+ const cols = new Map(), owned = [], ownedBuffers = [];
41
+ const table = new Table(engine, cols, n, { owned, ownedBuffers });
42
+ try {
43
+ for (const name of first.columnNames) {
44
+ const c0 = first.cols.get(name);
45
+ if (c0.strings) {
46
+ let total = 0;
47
+ for (const [t, lo, hi] of parts) { const p = engine.live(t.cols.get(name).data); for (let i = lo; i < hi; i++) if (p[i] === p[i]) total += textLength(p[i]); }
48
+ const data = engine.alloc(Float64Array, n), strings = engine.alloc(Uint8Array, total);
49
+ cols.set(name, { data, dict: null, codes: null, strings });
50
+ owned.push(name);
51
+ ownedBuffers.push(strings);
52
+ const o = engine.live(data), sb = engine.live(strings);
53
+ let at = 0, row = 0;
54
+ for (const [t, lo, hi] of parts) {
55
+ const c = t.cols.get(name), p = engine.live(c.data), b = engine.live(c.strings);
56
+ for (let i = lo; i < hi; i++, row++) {
57
+ const v = p[i];
58
+ if (v !== v) { o[row] = NaN; continue; }
59
+ const start = Math.floor(v / STR_SHIFT), len = textLength(v);
60
+ sb.set(b.subarray(start, start + len), at);
61
+ o[row] = at * STR_SHIFT + (v - start * STR_SHIFT);
62
+ at += len;
63
+ }
64
+ }
65
+ continue;
66
+ }
67
+ const data = engine.alloc(T, n);
68
+ let dict = null, codes = null;
69
+ if (c0.dict) {
70
+ dict = c0.dict;
71
+ if (parts.some(([t]) => !sameDict(t.cols.get(name).dict, dict))) dict = [...dict];
72
+ codes = dict === c0.dict ? c0.codes : new Map(dict.map((s, i) => [s, i]));
73
+ }
74
+ cols.set(name, { data, dict, codes, date: c0.date });
75
+ owned.push(name);
76
+ let row = 0;
77
+ for (const [t, lo, hi] of parts) {
78
+ const c = t.cols.get(name), o = engine.live(data).subarray(row, row + hi - lo);
79
+ plainInto(engine, c, lo, hi, o, T === Float32Array);
80
+ if (dict && c.dict !== dict && !sameDict(c.dict, dict)) remapCodes(o, c.dict, { dict, codes });
81
+ row += hi - lo;
82
+ }
83
+ }
84
+ for (const c of cols.values()) { c.data = engine.live(c.data); if (c.strings) c.strings = engine.live(c.strings); }
85
+ return table;
86
+ } catch (err) {
87
+ table.dispose();
88
+ throw err;
89
+ }
90
+ }
91
+
@@ -0,0 +1,148 @@
1
+ // Tables from files in a data folder, for the servers (the HTTP SQL server
2
+ // in http.mjs, the MCP server in mcp/): CSV / TSV, JSON lines (with a vector
3
+ // index when the lines carry embeddings), Arrow / Feather files and .vectro
4
+ // datasets (opened on disk, not loaded). Files outside the folder are refused.
5
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
6
+ import { resolve, sep, extname, basename, join } from 'node:path';
7
+ import { VectorIndex } from '../vectors.mjs';
8
+ import { Engine } from '../node.mjs';
9
+ import { parseNDJSON } from '../ndjson.mjs';
10
+
11
+ export const MAX_FILE_BYTES = 2 ** 31;
12
+ export const FREE_TEXT_ROWS = 5_000_000;
13
+ export const LOADABLE = new Set(['.csv', '.tsv', '.jsonl', '.ndjson', '.arrow', '.feather', '.ipc', '.vectro']);
14
+
15
+ // (a mistake by whoever asked: shown to them as it is)
16
+ export class LoadError extends Error {}
17
+
18
+ export const kindOf = (c) => (c.strings ? 'text' : c.dict ? 'category' : c.date ? 'date' : 'number');
19
+ export const columnsOf = (t) => Object.fromEntries([...t.cols].map(([n, c]) => [n, kindOf(c)]));
20
+ export const tableName = (file) => basename(file, extname(file)).replace(/[^A-Za-z0-9_]/g, '_').replace(/^(\d)/, '_$1');
21
+
22
+ // A path inside root (symbolic links resolved), or a LoadError.
23
+ export function safePath(root, path) {
24
+ if (typeof path !== 'string' || !path) throw new LoadError('path is required');
25
+ let real;
26
+ try { real = realpathSync(resolve(root, path)); } catch { throw new LoadError(`no such file: ${path}`); }
27
+ if (real !== root && !real.startsWith(root + sep)) throw new LoadError('that file is outside the data folder this server may read');
28
+ const st = statSync(real);
29
+ if (!st.isFile()) throw new LoadError(`not a file: ${path}`);
30
+ if (st.size > MAX_FILE_BYTES && extname(real).toLowerCase() !== '.vectro') throw new LoadError('file is larger than 2 GB (convert it to a .vectro dataset)');
31
+ return real;
32
+ }
33
+
34
+ // One file as a table: { table, index?, embedding?, skipped?, onDisk? }.
35
+ export async function loadFile(engine, real, { types } = {}) {
36
+ const ext = extname(real).toLowerCase();
37
+ if (ext === '.vectro') return { table: engine.openDataset(real), onDisk: true };
38
+ const bytes = new Uint8Array(readFileSync(real));
39
+ if (ext === '.arrow' || ext === '.feather' || ext === '.ipc') return { table: engine.fromArrow(bytes) };
40
+ if (ext === '.jsonl' || ext === '.ndjson') return loadJSONLines(engine, bytes, types);
41
+ if (ext === '.csv' || ext === '.tsv' || ext === '.txt') {
42
+ const delimiter = ext === '.tsv' ? '\t' : ',';
43
+ const table = engine.parseCSV(bytes, { types: types ?? {}, delimiter });
44
+ // text that rarely repeats (names, titles) loads as free text, which can be
45
+ // shown but not grouped, filtered or joined on: tools need that, so up to
46
+ // FREE_TEXT_ROWS rows it is loaded again as categories
47
+ const free = [...table.cols].filter(([n, c]) => c.strings && !(types && n in types)).map(([n]) => n);
48
+ if (!free.length || table.length > FREE_TEXT_ROWS) return { table };
49
+ table.dispose();
50
+ return { table: engine.parseCSV(bytes, { types: { ...types, ...Object.fromEntries(free.map((n) => [n, 'category'])) }, delimiter }) };
51
+ }
52
+ throw new LoadError(`can't load ${ext || 'files without an extension'} (CSV, TSV, JSON lines, Arrow / Feather or .vectro)`);
53
+ }
54
+
55
+ // Every loadable file directly in root: [{ name, path }].
56
+ export function filesIn(root) {
57
+ return readdirSync(root, { withFileTypes: true })
58
+ .filter((d) => d.isFile() && LOADABLE.has(extname(d.name).toLowerCase()))
59
+ .map((d) => ({ name: tableName(d.name), path: join(root, d.name) }));
60
+ }
61
+
62
+ // A table's memory back (datasets closed).
63
+ export function release(table) { if (table?.isDataset) table.close(); else table?.dispose(); }
64
+
65
+ async function loadWithEmbeddings(engine, bytes, field, dim, kinds) {
66
+ const names = [...kinds].filter(([k, v]) => v && k !== field).map(([k]) => k);
67
+ const cols = Object.fromEntries(names.map((n) => [n, []]));
68
+ const vectors = [];
69
+ let skipped = 0;
70
+ for (const line of new TextDecoder().decode(bytes).split('\n')) {
71
+ if (!line.trim()) continue;
72
+ let o;
73
+ try { o = JSON.parse(line); } catch { skipped++; continue; }
74
+ const v = o?.[field];
75
+ if (!o || typeof o !== 'object' || !Array.isArray(v) || v.length !== dim) { skipped++; continue; }
76
+ vectors.push(v);
77
+ for (const n of names) {
78
+ const x = o[n];
79
+ cols[n].push(kinds.get(n) === 'number' ? (typeof x === 'number' ? x : typeof x === 'boolean' ? +x : typeof x === 'string' && x.trim() ? Number(x) : NaN) : x == null || typeof x === 'object' ? '' : String(x));
80
+ }
81
+ }
82
+ const table = engine.table(cols);
83
+ const index = await VectorIndex.create(engine, { vectors, dim, metric: 'cosine', columns: cols });
84
+ return { table, index, skipped, embedding: { field, dim } };
85
+ }
86
+
87
+ function loadJSONLines(engine, bytes, types) {
88
+ // column names and kinds from the first 1,000 lines
89
+ const kinds = new Map(), arrays = new Map();
90
+ const text = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 1 << 20)));
91
+ for (const line of text.split('\n').slice(0, 1000)) {
92
+ let o;
93
+ try { o = JSON.parse(line); } catch { continue; }
94
+ if (!o || typeof o !== 'object' || Array.isArray(o)) continue;
95
+ for (const [k, v] of Object.entries(o)) {
96
+ if (Array.isArray(v) && v.length && v.every((x) => typeof x === 'number')) { if (!arrays.has(k)) arrays.set(k, v.length); }
97
+ if (v === null || typeof v === 'object') { if (!kinds.has(k)) kinds.set(k, null); continue; }
98
+ const kind = typeof v === 'number' || typeof v === 'boolean' ? 'number' : 'category';
99
+ if (kinds.get(k) !== 'category') kinds.set(k, kind);
100
+ }
101
+ }
102
+ for (const [k, v] of Object.entries(types ?? {})) kinds.set(k, v === 'number' ? 'number' : 'category');
103
+ if (arrays.size) { const [field, dim] = arrays.entries().next().value; return loadWithEmbeddings(engine, bytes, field, dim, kinds); }
104
+ const names = [...kinds].filter(([, v]) => v).map(([k]) => k);
105
+ if (!names.length) throw new LoadError('no JSON objects with plain values found in the first lines');
106
+ const r = parseNDJSON(bytes, 0, bytes.length, names, names.map((n) => kinds.get(n)));
107
+ const cols = Object.fromEntries(names.map((n, k) => [n, r.dicts[k] ? Array.from(r.cols[k], (code) => r.dicts[k][code]) : r.cols[k]]));
108
+ return { table: engine.table(cols), skipped: r.bad };
109
+ }
110
+
111
+ // The tables a server serves: given in code, or the files in a data folder
112
+ // (reload() reads it again). Shared by the HTTP and PostgreSQL servers.
113
+ // { engine, entries: Map name -> { table, source, owned }, named(), reload(), version, close() }
114
+ export async function openTables({ tables = {}, root = null, engine = null, log = () => {} } = {}) {
115
+ const own = !engine && !Object.values(tables).some((t) => t?.engine);
116
+ engine ??= Object.values(tables).find((t) => t?.engine)?.engine ?? new Engine();
117
+ const entries = new Map();
118
+ for (const [name, table] of Object.entries(tables)) entries.set(name, { table, source: 'given', owned: false });
119
+ const rootPath = root ? realpathSync(resolve(String(root).replace(/^~(?=\/|$)/, process.env.HOME ?? '~'))) : null;
120
+ const self = {
121
+ engine, entries, root: rootPath, version: 0,
122
+ named: () => Object.fromEntries([...entries].map(([n, e]) => [n, e.table])),
123
+ async reload() {
124
+ if (!rootPath) throw new LoadError('this server has no data folder to reload');
125
+ for (const [name, e] of entries) if (e.owned) { release(e.table); entries.delete(name); }
126
+ const loaded = [], failed = [];
127
+ for (const { name, path } of filesIn(rootPath)) {
128
+ if (entries.has(name)) continue; // (tables given in code win)
129
+ try {
130
+ const { table } = await loadFile(engine, path);
131
+ entries.set(name, { table, source: path, owned: true });
132
+ loaded.push(name);
133
+ } catch (err) { failed.push({ file: path, error: err.message }); log(`skipped ${path}: ${err.message}`); }
134
+ }
135
+ self.version++;
136
+ return { loaded, failed };
137
+ },
138
+ async close() {
139
+ for (const e of entries.values()) if (e.owned) release(e.table);
140
+ entries.clear();
141
+ if (own) await engine.close();
142
+ },
143
+ };
144
+ if (rootPath) await self.reload();
145
+ return self;
146
+ }
147
+
148
+ export { realpathSync };
@@ -0,0 +1,270 @@
1
+ // Arrow Flight SQL over Vectro tables: pandas, Polars, DuckDB and anything
2
+ // with an ADBC or Flight SQL driver ask in SQL and get Arrow back, column by
3
+ // column, with no conversion to rows on either side.
4
+ //
5
+ // import { serveFlight } from 'vectrojs/serve';
6
+ // await serveFlight({ tables: { orders }, port: 31337 }); // grpc://localhost:31337
7
+ //
8
+ // # Python: pip install adbc-driver-flightsql
9
+ // import adbc_driver_flightsql.dbapi as flight
10
+ // with flight.connect('grpc://localhost:31337') as c: c.cursor().execute('SELECT ...').fetch_arrow_table()
11
+ //
12
+ // gRPC over HTTP/2 (node:http2; TLS with a certificate) and protobuf are
13
+ // written here; no dependencies. Served: statements and prepared statements
14
+ // (the SQL travels in the ticket), catalogs, schemas, tables (with their
15
+ // Arrow schemas), table types; keys are empty. Read only. With a password,
16
+ // clients sign in with Handshake (basic) and get a bearer token.
17
+ import { createServer, createSecureServer, constants } from 'node:http2';
18
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
19
+ import { readFileSync } from 'node:fs';
20
+ import { once } from 'node:events';
21
+ import { gunzipSync } from 'node:zlib';
22
+ import { sql, columnType, displayName } from '../sql.mjs';
23
+ import { typedSchema, typedBatch, typedDictionary } from '../arrow.mjs';
24
+ import { openTables } from './catalog.mjs';
25
+
26
+ const BATCH = 65_536;
27
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
28
+ const SQL_PREFIX = 'type.googleapis.com/arrow.flight.protocol.sql.';
29
+ const STATUS = { ok: 0, invalid: 3, notFound: 5, unimplemented: 12, internal: 13, unauthenticated: 16 };
30
+ class FlightError extends Error { constructor(code, message) { super(message); this.code = code; } }
31
+ const fail = (code, m) => { throw new FlightError(code, m); };
32
+
33
+ // ---- protobuf ----------------------------------------------------------------------------
34
+ // decode: Map field number -> [values] (varints as numbers / BigInt-safe Numbers,
35
+ // length-delimited as Buffers); encode: [[field, kind, value]], kind 'v' (varint),
36
+ // 'b' (bytes / string / encoded message), 'q' (fixed64)
37
+ export function pbDecode(buf) {
38
+ const out = new Map();
39
+ let i = 0;
40
+ const varint = () => { let x = 0n, s = 0n; for (;;) { const b = buf[i++]; x |= BigInt(b & 0x7f) << s; if (!(b & 0x80)) return x; s += 7n; } };
41
+ while (i < buf.length) {
42
+ const key = Number(varint()), field = key >>> 3, wire = key & 7;
43
+ let v;
44
+ if (wire === 0) v = Number(BigInt.asIntN(64, varint()));
45
+ else if (wire === 2) { const n = Number(varint()); v = buf.subarray(i, i + n); i += n; }
46
+ else if (wire === 1) { v = buf.readBigInt64LE(i); i += 8; }
47
+ else if (wire === 5) { v = buf.readInt32LE(i); i += 4; }
48
+ else throw new FlightError(STATUS.invalid, `bad protobuf wire type ${wire}`);
49
+ if (!out.has(field)) out.set(field, []);
50
+ out.get(field).push(v);
51
+ }
52
+ return out;
53
+ }
54
+ const pbVarint = (n) => { const out = []; let x = BigInt.asUintN(64, BigInt(n)); do { let b = Number(x & 0x7fn); x >>= 7n; if (x) b |= 0x80; out.push(b); } while (x); return Buffer.from(out); };
55
+ export function pbEncode(fields) {
56
+ const parts = [];
57
+ for (const [field, kind, value] of fields) {
58
+ if (value === undefined || value === null) continue;
59
+ if (kind === 'v') { parts.push(pbVarint(field << 3), pbVarint(value)); continue; }
60
+ if (kind === 'q') { const b = Buffer.alloc(8); b.writeBigInt64LE(BigInt(value)); parts.push(pbVarint((field << 3) | 1), b); continue; }
61
+ const b = Buffer.isBuffer(value) ? value : value instanceof Uint8Array ? Buffer.from(value.buffer, value.byteOffset, value.byteLength) : Buffer.from(String(value), 'utf8');
62
+ parts.push(pbVarint((field << 3) | 2), pbVarint(b.length), b);
63
+ }
64
+ return Buffer.concat(parts);
65
+ }
66
+ const str = (m, f) => (m.get(f)?.[0] ? Buffer.from(m.get(f)[0]).toString('utf8') : null);
67
+ const bytesOf = (m, f) => m.get(f)?.[0] ?? null;
68
+ const any = (type, value) => pbEncode([[1, 'b', SQL_PREFIX + type], [2, 'b', value]]);
69
+ function unpackAny(buf) {
70
+ const m = pbDecode(buf);
71
+ const url = str(m, 1) ?? '';
72
+ return { type: url.startsWith(SQL_PREFIX) ? url.slice(SQL_PREFIX.length) : url, value: pbDecode(bytesOf(m, 2) ?? Buffer.alloc(0)), raw: bytesOf(m, 2) ?? Buffer.alloc(0) };
73
+ }
74
+
75
+ // ---- results: Arrow fields and columns --------------------------------------------------------
76
+ const ARROW = { int: 'int64', float: 'float64', bool: 'bool', text: 'utf8', date: 'timestamp', day: 'date32' };
77
+ const TABLE_TYPES = ['TABLE'];
78
+ // a Flight data message: header (flatbuffer Message) and body, from an IPC message
79
+ const flightData = (m) => pbEncode([[2, 'b', m.bytes.subarray(8, m.metaLength)], [1000, 'b', m.bytes.subarray(m.metaLength)]]);
80
+
81
+ export async function serveFlight({ tables = {}, root = null, engine = null, catalog = null, port = 31337, host = '127.0.0.1', user = null, password = null, tls = null, database = 'vectro', log = () => {}, logQueries = false } = {}) {
82
+ if (!password && !LOOPBACK.has(host)) throw new Error('vectro: a password is needed to accept Flight connections from other machines (--password, or --host 127.0.0.1)');
83
+ const shared = !!catalog;
84
+ catalog ??= await openTables({ tables, root, engine, log });
85
+ const tokens = new Set();
86
+ const named = () => Object.fromEntries([...catalog.entries].map(([n, e]) => [n, e.table]));
87
+
88
+ const checkBasic = (header) => {
89
+ const m = /^Basic\s+(.+)$/i.exec(header ?? '');
90
+ if (!m) return false;
91
+ const [u, ...rest] = Buffer.from(m[1], 'base64').toString().split(':');
92
+ const given = Buffer.from(rest.join(':')), want = Buffer.from(String(password));
93
+ return (!user || u === user) && given.length === want.length && timingSafeEqual(given, want);
94
+ };
95
+ const authorized = (headers) => {
96
+ if (!password) return true;
97
+ const h = headers.authorization ?? '';
98
+ const bearer = /^Bearer\s+(.+)$/i.exec(h);
99
+ return (bearer && tokens.has(bearer[1])) || checkBasic(h);
100
+ };
101
+
102
+ // a query's Arrow fields (from its column types, before it runs)
103
+ const compile = (text) => {
104
+ let q;
105
+ try { q = sql(text, named(), { types: true, foldCase: true, database }); } catch (err) { fail(STATUS.invalid, err.message.replace(/^vectro( SQL)?:\s*/, '')); }
106
+ return { q, fields: q.columns.map((c) => ({ name: displayName(c.name), col: c.name, type: ARROW[c.type] ?? 'float64' })) };
107
+ };
108
+ const tableFields = (t) => t.columnNames.map((c) => ({ name: displayName(c), type: ARROW[columnType(t, c)] ?? 'float64' }));
109
+
110
+ // catalog answers: { fields, columns (arrays), n }
111
+ const like = (pattern) => (pattern == null ? () => true : ((re) => (s) => re.test(s))(new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.')}$`, 's')));
112
+ function catalogAnswer(type, m) {
113
+ const fieldsOf = (spec) => spec.map(([name, t, nullable = true]) => ({ name, type: t, nullable }));
114
+ switch (type) {
115
+ case 'CommandGetCatalogs': return { fields: fieldsOf([['catalog_name', 'utf8', false]]), columns: [[database]], n: 1 };
116
+ case 'CommandGetDbSchemas': {
117
+ const ok = (str(m, 1) ?? database) === database && like(str(m, 2))('public');
118
+ return { fields: fieldsOf([['catalog_name', 'utf8'], ['db_schema_name', 'utf8', false]]), columns: ok ? [[database], ['public']] : [[], []], n: ok ? 1 : 0 };
119
+ }
120
+ case 'CommandGetTableTypes': return { fields: fieldsOf([['table_type', 'utf8', false]]), columns: [TABLE_TYPES], n: TABLE_TYPES.length };
121
+ case 'CommandGetTables': {
122
+ const wantTypes = (m.get(4) ?? []).map((b) => Buffer.from(b).toString());
123
+ const inCatalog = (str(m, 1) ?? database) === database && like(str(m, 2))('public') && (!wantTypes.length || wantTypes.includes('TABLE'));
124
+ const names = inCatalog ? Object.keys(named()).filter((n) => !n.includes('.')).filter(like(str(m, 3))).sort() : [];
125
+ const withSchema = !!m.get(5)?.[0];
126
+ const spec = [['catalog_name', 'utf8'], ['db_schema_name', 'utf8'], ['table_name', 'utf8', false], ['table_type', 'utf8', false]];
127
+ if (withSchema) spec.push(['table_schema', 'binary', false]);
128
+ const columns = [names.map(() => database), names.map(() => 'public'), names, names.map(() => 'TABLE')];
129
+ if (withSchema) columns.push(names.map((n) => typedSchema(tableFields(named()[n])).bytes));
130
+ return { fields: fieldsOf(spec), columns, n: names.length };
131
+ }
132
+ case 'CommandGetPrimaryKeys': return { fields: fieldsOf([['catalog_name', 'utf8'], ['db_schema_name', 'utf8'], ['table_name', 'utf8', false], ['column_name', 'utf8', false], ['key_name', 'utf8'], ['key_sequence', 'int32', false]]), columns: [[], [], [], [], [], []], n: 0 };
133
+ case 'CommandGetImportedKeys': case 'CommandGetExportedKeys': case 'CommandGetCrossReference':
134
+ return { fields: fieldsOf([['pk_catalog_name', 'utf8'], ['pk_db_schema_name', 'utf8'], ['pk_table_name', 'utf8', false], ['pk_column_name', 'utf8', false], ['fk_catalog_name', 'utf8'], ['fk_db_schema_name', 'utf8'], ['fk_table_name', 'utf8', false], ['fk_column_name', 'utf8', false], ['key_sequence', 'int32', false], ['fk_key_name', 'utf8'], ['pk_key_name', 'utf8'], ['update_rule', 'int8', false], ['delete_rule', 'int8', false]]), columns: Array.from({ length: 13 }, () => []), n: 0 };
135
+ default: return null;
136
+ }
137
+ }
138
+ const CATALOG = new Set(['CommandGetCatalogs', 'CommandGetDbSchemas', 'CommandGetTableTypes', 'CommandGetTables', 'CommandGetPrimaryKeys', 'CommandGetImportedKeys', 'CommandGetExportedKeys', 'CommandGetCrossReference']);
139
+
140
+ // FlightDescriptor -> { fields, ticket }
141
+ function describe(desc) {
142
+ const d = pbDecode(desc);
143
+ if (d.get(1)?.[0] !== 2) fail(STATUS.invalid, 'only command descriptors (Flight SQL) are served');
144
+ const cmd = unpackAny(bytesOf(d, 2) ?? Buffer.alloc(0));
145
+ if (cmd.type === 'CommandStatementQuery' || cmd.type === 'CommandPreparedStatementQuery') {
146
+ const text = cmd.type === 'CommandStatementQuery' ? str(cmd.value, 1) : Buffer.from(bytesOf(cmd.value, 1) ?? []).toString();
147
+ if (!text) fail(STATUS.invalid, 'no SQL given');
148
+ if (logQueries) log(`[flight] ${text.replace(/\s+/g, ' ').slice(0, 2000)}`);
149
+ const { fields } = compile(text);
150
+ return { fields, ticket: any('TicketStatementQuery', pbEncode([[1, 'b', text]])), desc };
151
+ }
152
+ if (CATALOG.has(cmd.type)) return { fields: catalogAnswer(cmd.type, cmd.value).fields, ticket: bytesOf(d, 2), desc };
153
+ if (cmd.type === 'CommandStatementUpdate' || cmd.type === 'CommandPreparedStatementUpdate') fail(STATUS.invalid, 'Vectro serves its tables read only');
154
+ fail(STATUS.unimplemented, `${cmd.type || 'this command'} is not supported`);
155
+ }
156
+ const flightInfo = ({ fields, ticket, desc }) => pbEncode([
157
+ [1, 'b', typedSchema(fields).bytes], [2, 'b', desc],
158
+ [3, 'b', pbEncode([[1, 'b', pbEncode([[1, 'b', ticket]])]])],
159
+ [4, 'v', -1], [5, 'v', -1],
160
+ ]);
161
+
162
+ // DoGet: the rows as FlightData messages
163
+ async function* rowsOf(ticket) {
164
+ const t = unpackAny(ticket);
165
+ let fields, columns, n, table = null;
166
+ if (t.type === 'TicketStatementQuery') {
167
+ const text = Buffer.from(bytesOf(t.value, 1) ?? []).toString();
168
+ const c = compile(text);
169
+ fields = c.fields;
170
+ table = await c.q.collectAsync();
171
+ const data = await table.toColumnsAsync();
172
+ columns = fields.map((f) => data[f.col]);
173
+ n = table.length;
174
+ table.dispose();
175
+ } else if (CATALOG.has(t.type)) ({ fields, columns, n } = catalogAnswer(t.type, t.value));
176
+ else fail(STATUS.invalid, 'unknown ticket');
177
+ yield flightData(typedSchema(fields));
178
+ for (let a = 0; a < n || a === 0; a += BATCH) {
179
+ yield flightData(typedBatch(fields, columns, a, Math.min(n, a + BATCH)));
180
+ if (n === 0) break;
181
+ }
182
+ }
183
+
184
+ // ---- gRPC ----
185
+ const frame = (msg) => { const h = Buffer.alloc(5); h.writeUInt32BE(msg.length, 1); return Buffer.concat([h, msg]); };
186
+ function messagesOf(buf) {
187
+ const out = [];
188
+ for (let i = 0; i + 5 <= buf.length;) {
189
+ const compressed = buf[i], len = buf.readUInt32BE(i + 1);
190
+ const body = buf.subarray(i + 5, i + 5 + len);
191
+ out.push(compressed ? gunzipSync(body) : body);
192
+ i += 5 + len;
193
+ }
194
+ return out;
195
+ }
196
+ const respondError = (stream, code, message) => {
197
+ if (stream.destroyed) return;
198
+ const trailers = { 'grpc-status': String(code), 'grpc-message': encodeURIComponent(message).slice(0, 4000) };
199
+ if (stream.headersSent) { stream.sendTrailers?.(trailers); return; }
200
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', ...trailers }, { endStream: true });
201
+ };
202
+ async function reply(stream, headers, gen) {
203
+ stream.respond({ ':status': 200, 'content-type': 'application/grpc', ...headers }, { waitForTrailers: true });
204
+ let status = 0, message = '';
205
+ try {
206
+ for await (const msg of gen) if (!stream.write(frame(msg))) await once(stream, 'drain');
207
+ } catch (err) { status = err.code ?? STATUS.internal; message = err.message; if (!(err instanceof FlightError)) log(`[flight] error: ${err.stack}`); }
208
+ stream.once('wantTrailers', () => stream.sendTrailers({ 'grpc-status': String(status), ...(message ? { 'grpc-message': encodeURIComponent(message).slice(0, 4000) } : {}) }));
209
+ stream.end();
210
+ }
211
+
212
+ const ACTIONS = [['CreatePreparedStatement', 'a prepared statement: its handle and schema'], ['ClosePreparedStatement', 'forget a prepared statement']];
213
+ async function handle(stream, headers) {
214
+ const method = String(headers[':path'] ?? '').replace('/arrow.flight.protocol.FlightService/', '');
215
+ const chunks = [];
216
+ for await (const c of stream) chunks.push(c);
217
+ const msgs = messagesOf(Buffer.concat(chunks));
218
+ if (method === 'Handshake') { // basic sign-in: a bearer token in the answer's headers
219
+ if (password && !checkBasic(headers.authorization)) return respondError(stream, STATUS.unauthenticated, 'wrong user name or password');
220
+ const token = randomBytes(24).toString('base64url');
221
+ tokens.add(token);
222
+ if (tokens.size > 10_000) tokens.delete(tokens.values().next().value);
223
+ return reply(stream, password ? { authorization: `Bearer ${token}` } : {}, (async function* () { yield pbEncode([[1, 'v', 0], [2, 'b', Buffer.alloc(0)]]); })());
224
+ }
225
+ if (!authorized(headers)) return respondError(stream, STATUS.unauthenticated, 'sign in first (Handshake with a user name and password)');
226
+ const one = msgs[0] ?? Buffer.alloc(0);
227
+ switch (method) {
228
+ case 'GetFlightInfo': return reply(stream, {}, (async function* () { yield flightInfo(describe(one)); })());
229
+ case 'GetSchema': return reply(stream, {}, (async function* () { yield pbEncode([[1, 'b', typedSchema(describe(one).fields).bytes]]); })());
230
+ case 'DoGet': return reply(stream, {}, rowsOf(bytesOf(pbDecode(one), 1) ?? Buffer.alloc(0)));
231
+ case 'ListActions': return reply(stream, {}, (async function* () { for (const [type, description] of ACTIONS) yield pbEncode([[1, 'b', type], [2, 'b', description]]); })());
232
+ case 'DoAction': {
233
+ const a = pbDecode(one), type = str(a, 1), body = unpackAny(bytesOf(a, 2) ?? Buffer.alloc(0));
234
+ if (type === 'CreatePreparedStatement') {
235
+ const text = str(body.value, 1) ?? '';
236
+ const { fields } = compile(text);
237
+ const result = pbEncode([[1, 'b', text], [2, 'b', typedSchema(fields).bytes]]);
238
+ return reply(stream, {}, (async function* () { yield pbEncode([[1, 'b', any('ActionCreatePreparedStatementResult', result)]]); })());
239
+ }
240
+ if (type === 'ClosePreparedStatement') return reply(stream, {}, (async function* () {})());
241
+ return respondError(stream, STATUS.unimplemented, `action ${type} is not supported (Vectro serves its tables read only)`);
242
+ }
243
+ case 'ListFlights': return reply(stream, {}, (async function* () {})());
244
+ case 'DoPut': return respondError(stream, STATUS.unimplemented, 'Vectro serves its tables read only (no DoPut, no parameters for prepared statements yet)');
245
+ default: return respondError(stream, STATUS.unimplemented, `${method} is not supported`);
246
+ }
247
+ }
248
+
249
+ const server = tls ? createSecureServer({ cert: tls.cert ?? readFileSync(tls.certFile), key: tls.key ?? readFileSync(tls.keyFile) }) : createServer();
250
+ server.on('stream', (stream, headers) => {
251
+ stream.on('error', () => {});
252
+ handle(stream, headers).catch((err) => { if (!(err instanceof FlightError)) log(`[flight] error: ${err.stack}`); respondError(stream, err.code ?? STATUS.internal, err.message); });
253
+ });
254
+ server.on('sessionError', () => {});
255
+ server.listen(port, host);
256
+ await once(server, 'listening');
257
+ const actual = server.address().port;
258
+ const sessions = new Set();
259
+ server.on('session', (s) => { sessions.add(s); s.on('close', () => sessions.delete(s)); });
260
+ return {
261
+ port: actual, host, server, catalog,
262
+ url: `${tls ? 'grpc+tls' : 'grpc'}://${host.includes(':') ? `[${host}]` : host}:${actual}`,
263
+ async close() {
264
+ for (const s of sessions) s.destroy();
265
+ await new Promise((r) => server.close(r));
266
+ if (!shared) await catalog.close();
267
+ },
268
+ };
269
+ }
270
+ export { constants as http2Constants };
@@ -0,0 +1,87 @@
1
+ // Query results as bytes for HTTP clients: JSON, CSV, JSON lines and Arrow,
2
+ // written a slice of rows at a time (a large result never becomes one string).
3
+ // columns: [{ name, type: 'number' | 'text' | 'date' }]
4
+ // data: table.toColumns() (numbers as typed arrays, text as { codes, labels } or strings)
5
+ // Each cell is text made once per label (a category's labels are few), or a
6
+ // number / date written straight away.
7
+ import { writeArrow } from '../arrow.mjs';
8
+ import { displayName } from '../sql.mjs';
9
+
10
+ const SLICE = 65_536;
11
+
12
+ export const FORMATS = {
13
+ json: 'application/json; charset=utf-8',
14
+ csv: 'text/csv; charset=utf-8',
15
+ ndjson: 'application/x-ndjson; charset=utf-8',
16
+ arrow: 'application/vnd.apache.arrow.stream',
17
+ };
18
+
19
+ export const columnsOf = (table) => [...table.cols].map(([name, c]) => ({ name: displayName(name), col: name, type: c.dict || c.strings ? 'text' : c.date ? 'date' : 'number' }));
20
+
21
+ // RFC 4180: quoted when it holds a comma, a quote or a line break
22
+ const csvText = (v) => (/[",\r\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v);
23
+
24
+ // ISO 8601 in UTC, as Date.toISOString writes it, with each day's part made once
25
+ const days = new Map();
26
+ const p2 = (x) => (x < 10 ? `0${x}` : `${x}`);
27
+ function iso(ms) {
28
+ const day = Math.floor(ms / 86_400_000);
29
+ let d = days.get(day);
30
+ if (d === undefined) { if (days.size > 100_000) days.clear(); const x = new Date(day * 86_400_000).toISOString(); d = x.slice(0, x.indexOf('T') + 1); days.set(day, d); }
31
+ let t = ms - day * 86_400_000;
32
+ const h = Math.floor(t / 3_600_000); t -= h * 3_600_000;
33
+ const m = Math.floor(t / 60_000); t -= m * 60_000;
34
+ const s = Math.floor(t / 1000), f = Math.floor(t - s * 1000);
35
+ return `${d}${p2(h)}:${p2(m)}:${p2(s)}.${f < 10 ? `00${f}` : f < 100 ? `0${f}` : f}Z`;
36
+ }
37
+ const dateOK = (v) => v === v && v > -8.64e15 && v < 8.64e15;
38
+
39
+ // cell(i) for one column, as text for the format; null cells as `missing`
40
+ function cells(col, d, format) {
41
+ const missing = format === 'csv' ? '' : 'null';
42
+ const quote = format === 'csv' ? csvText : JSON.stringify;
43
+ if (d && d.labels) {
44
+ const text = d.labels.map((s) => (s === '' ? missing : quote(s)));
45
+ const codes = d.codes;
46
+ return (i) => text[codes[i]] ?? missing;
47
+ }
48
+ if (col.type === 'text') return (i) => (d[i] === '' ? missing : quote(d[i]));
49
+ if (col.type === 'date') return format === 'csv' ? (i) => (dateOK(d[i]) ? iso(d[i]) : missing) : (i) => (dateOK(d[i]) ? `"${iso(d[i])}"` : missing);
50
+ return (i) => { const v = d[i]; return v === v && v !== Infinity && v !== -Infinity ? `${v}` : missing; };
51
+ }
52
+
53
+ // The body as chunks (strings or bytes), for res.write one after another.
54
+ export function* encode(format, columns, data, n, extra = {}) {
55
+ if (format === 'arrow') {
56
+ yield writeArrow(Object.fromEntries(columns.map((c) => [c.name, data[c.col ?? c.name]])), { dates: columns.filter((c) => c.type === 'date').map((c) => c.name) });
57
+ return;
58
+ }
59
+ const cell = columns.map((c) => cells(c, data[c.col ?? c.name], format));
60
+ const K = cell.length;
61
+ const rows = (a, b, start, sep, end, keys) => {
62
+ let s = '';
63
+ for (let i = a; i < b; i++) {
64
+ s += start;
65
+ for (let k = 0; k < K; k++) s += (k ? sep : '') + (keys ? keys[k] : '') + cell[k](i);
66
+ s += end;
67
+ }
68
+ return s;
69
+ };
70
+ if (format === 'csv') {
71
+ yield `${columns.map((c) => csvText(c.name)).join(',')}\r\n`;
72
+ for (let a = 0; a < n; a += SLICE) yield rows(a, Math.min(n, a + SLICE), '', ',', '\r\n', null);
73
+ return;
74
+ }
75
+ if (format === 'ndjson') {
76
+ const keys = columns.map((c) => `${JSON.stringify(c.name)}:`);
77
+ for (let a = 0; a < n; a += SLICE) yield rows(a, Math.min(n, a + SLICE), '{', ',', '}\n', keys);
78
+ return;
79
+ }
80
+ // json: { columns, rows: [[...]...], ... }
81
+ yield `{"columns":${JSON.stringify(columns.map(({ name, type }) => ({ name, type })))},"rows":[`;
82
+ for (let a = 0; a < n; a += SLICE) {
83
+ const s = rows(a, Math.min(n, a + SLICE), '[', ',', '],', null);
84
+ yield a + SLICE >= n ? s.slice(0, -1) : s; // (no comma after the last row)
85
+ }
86
+ yield Object.keys(extra).length ? `],${JSON.stringify(extra).slice(1)}` : ']}';
87
+ }