@query-farm/vgi 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/arrow/impl-arrowjs/index.d.ts +7 -0
- package/dist/arrow/impl-flechette/arrowjs-shape.d.ts +16 -0
- package/dist/arrow/impl-flechette/compat.d.ts +22 -0
- package/dist/arrow/impl-flechette/index.d.ts +2 -0
- package/dist/arrow/impl-flechette/ipc.d.ts +1 -1
- package/dist/arrow/index.d.ts +2 -2
- package/dist/arrow/predicates.d.ts +16 -0
- package/dist/cache-control.d.ts +7 -0
- package/dist/client-entry.js +5 -1
- package/dist/client-entry.js.map +4 -4
- package/dist/index.core.d.ts +2 -2
- package/dist/index.js +56 -4
- package/dist/index.js.map +10 -10
- package/dist/serve-entry.js +6 -1
- package/dist/serve-entry.js.map +5 -5
- package/dist/util/arrow/index.d.ts +1 -1
- package/dist/worker-cf-entry.js +53 -4
- package/dist/worker-cf-entry.js.map +9 -9
- package/package.json +5 -5
- package/src/arrow/impl-arrowjs/index.ts +10 -0
- package/src/arrow/impl-flechette/arrowjs-shape.ts +78 -0
- package/src/arrow/impl-flechette/canonical.ts +19 -0
- package/src/arrow/impl-flechette/compat.ts +254 -0
- package/src/arrow/impl-flechette/empty.ts +5 -1
- package/src/arrow/impl-flechette/index.ts +8 -0
- package/src/arrow/impl-flechette/ipc.ts +72 -6
- package/src/arrow/impl-flechette/normalize-type.ts +10 -2
- package/src/arrow/impl-flechette/schema.ts +13 -9
- package/src/arrow/index.ts +3 -0
- package/src/arrow/predicates.ts +59 -0
- package/src/cache-control.ts +8 -0
- package/src/functions/registry.ts +21 -4
- package/src/index.core.ts +3 -0
- package/src/protocol/handlers/function.ts +9 -1
- package/src/util/arrow/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Query Farm LLC (https://query.farm)",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22.15"
|
|
30
30
|
},
|
|
31
31
|
"type": "module",
|
|
32
|
-
"//sideEffects": "Entry modules are listed so the bundler cannot tree-shake them away. Under a bare `sideEffects: false`, an entry consisting only of re-exports builds to a dangling export list with no imports and no definitions
|
|
32
|
+
"//sideEffects": "Entry modules are listed so the bundler cannot tree-shake them away. Under a bare `sideEffects: false`, an entry consisting only of re-exports builds to a dangling export list with no imports and no definitions — invalid ESM that throws on first import. Modules not listed stay side-effect-free, so consumers still tree-shake normally.",
|
|
33
33
|
"sideEffects": [
|
|
34
34
|
"./src/index.ts",
|
|
35
35
|
"./src/client-entry.ts",
|
|
@@ -83,16 +83,16 @@
|
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@noble/ciphers": "^2.2.0",
|
|
86
|
-
"@query-farm/flechette": "^2.4.
|
|
86
|
+
"@query-farm/flechette": "^2.4.1"
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
89
89
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
90
|
-
"@query-farm/vgi-rpc": "^0.15.
|
|
90
|
+
"@query-farm/vgi-rpc": "^0.15.1"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
93
|
"@duckdb/node-api": "^1.5.2-r.1",
|
|
94
94
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
95
|
-
"@query-farm/vgi-rpc": "^0.15.
|
|
95
|
+
"@query-farm/vgi-rpc": "^0.15.1",
|
|
96
96
|
"@types/bun": "latest",
|
|
97
97
|
"typescript": "^6.0.3"
|
|
98
98
|
},
|
|
@@ -53,3 +53,13 @@ export {
|
|
|
53
53
|
|
|
54
54
|
// Column statistics (sparse-union heavy — backend-specific).
|
|
55
55
|
export { buildStatisticsBatch, type ColumnStatistics } from "./statistics.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* No-op on this backend: arrow-js `RecordBatch`/`Vector` already expose the
|
|
59
|
+
* full API worker code uses, and arrow-js is a peer dependency, so there is
|
|
60
|
+
* only ever one copy. Present so both backends satisfy one facade interface —
|
|
61
|
+
* see impl-flechette/compat.ts for what this does there.
|
|
62
|
+
*/
|
|
63
|
+
export function adoptArrowJsShape<T>(batch: T): T {
|
|
64
|
+
return batch;
|
|
65
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
//
|
|
3
|
+
// arrow-js shape for flechette *values* — the plain objects that have no
|
|
4
|
+
// prototype to patch (types and fields), as opposed to the class methods
|
|
5
|
+
// handled in ./compat.ts.
|
|
6
|
+
//
|
|
7
|
+
// Two differences bite:
|
|
8
|
+
//
|
|
9
|
+
// * Int types spell signedness `signed`; arrow-js spells it `isSigned`.
|
|
10
|
+
// Worker code branches on it to pick a promoted result type —
|
|
11
|
+
// `examples/scalar.ts`'s `promoteForAddition` reads `isSigned` and, when
|
|
12
|
+
// it comes back `undefined`, promotes a signed BIGINT to UINT64, after
|
|
13
|
+
// which the codec rejects every negative value. That is a wrong answer,
|
|
14
|
+
// not a missing property.
|
|
15
|
+
//
|
|
16
|
+
// * A decoded flechette Field carries `metadata: null` when the wire had no
|
|
17
|
+
// custom_metadata; arrow-js always materializes an empty `Map`. Shared
|
|
18
|
+
// code reads `field.metadata.get(…)` unconditionally (describe.json's
|
|
19
|
+
// column comments, macro `vgi_doc` parameter docs), so `null` is a
|
|
20
|
+
// TypeError rather than "no metadata".
|
|
21
|
+
//
|
|
22
|
+
// Both are applied to decoded schemas at the IPC boundary. Kept in a
|
|
23
|
+
// dependency-free module so `schema.ts` / `normalize-type.ts` can use it
|
|
24
|
+
// without pulling in `compat.ts`, which depends on the canonical write path.
|
|
25
|
+
|
|
26
|
+
/** Arrow `Type.Int` — the enum value is identical in both libraries. */
|
|
27
|
+
const TYPE_INT = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Attach `isSigned` to a flechette Int type, recursing through children /
|
|
31
|
+
* dictionary / index types.
|
|
32
|
+
*
|
|
33
|
+
* The alias is a non-enumerable getter so it never shows up in structural
|
|
34
|
+
* comparisons, IPC encoding or `Object.keys` — flechette keeps writing
|
|
35
|
+
* `signed`, and this is purely a read-side view of it. Mutating in place is
|
|
36
|
+
* safe: these objects are freshly built per decode.
|
|
37
|
+
*/
|
|
38
|
+
export function aliasIntSigned<T>(type: T): T {
|
|
39
|
+
const t = type as any;
|
|
40
|
+
if (t == null || typeof t !== "object") return type;
|
|
41
|
+
if (t.typeId === TYPE_INT && !("isSigned" in t)) {
|
|
42
|
+
Object.defineProperty(t, "isSigned", {
|
|
43
|
+
get(this: any) {
|
|
44
|
+
return this.signed;
|
|
45
|
+
},
|
|
46
|
+
enumerable: false,
|
|
47
|
+
configurable: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(t.children)) {
|
|
51
|
+
for (const child of t.children) normalizeField(child);
|
|
52
|
+
}
|
|
53
|
+
if (t.dictionary) aliasIntSigned(t.dictionary);
|
|
54
|
+
if (t.indices) aliasIntSigned(t.indices);
|
|
55
|
+
return type;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Normalize one field: an always-present metadata Map, plus its type. */
|
|
59
|
+
function normalizeField(field: any): void {
|
|
60
|
+
if (field == null || typeof field !== "object") return;
|
|
61
|
+
if (!field.metadata) field.metadata = new Map<string, string>();
|
|
62
|
+
aliasIntSigned(field.type ?? field);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bring a freshly decoded flechette schema up to the arrow-js shape shared
|
|
67
|
+
* code expects: every field (and nested child field) has a metadata `Map`,
|
|
68
|
+
* and every Int type answers to `isSigned`.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeDecodedSchema<T>(schema: T): T {
|
|
71
|
+
const s = schema as any;
|
|
72
|
+
if (s == null || typeof s !== "object") return schema;
|
|
73
|
+
if (!s.metadata) s.metadata = new Map<string, string>();
|
|
74
|
+
if (Array.isArray(s.fields)) {
|
|
75
|
+
for (const f of s.fields) normalizeField(f);
|
|
76
|
+
}
|
|
77
|
+
return schema;
|
|
78
|
+
}
|
|
@@ -31,6 +31,9 @@ import { toFlechetteType } from "./normalize-type.js";
|
|
|
31
31
|
|
|
32
32
|
const MS_PER_DAY = 86_400_000;
|
|
33
33
|
|
|
34
|
+
/** `IntervalUnit.MONTH_DAY_NANO` — same ordinal in arrow-js and flechette. */
|
|
35
|
+
const MONTH_DAY_NANO = 2;
|
|
36
|
+
|
|
34
37
|
const COLUMN_OPTS = {
|
|
35
38
|
useBigInt: true,
|
|
36
39
|
useBigIntTimestamp: true,
|
|
@@ -99,6 +102,21 @@ export function prepareForFlechette(type: VgiDataType, values: unknown[]): unkno
|
|
|
99
102
|
return new Date(isDay ? n * MS_PER_DAY : n);
|
|
100
103
|
});
|
|
101
104
|
}
|
|
105
|
+
if (tid === 11 && (type as any).unit === MONTH_DAY_NANO) {
|
|
106
|
+
// arrow-js represents a MonthDayNano interval as four int32 words
|
|
107
|
+
// [months, days, nanos_lo, nanos_hi]; flechette's builder wants
|
|
108
|
+
// [months, days, nanos] with nanos as one number/bigint. The facade
|
|
109
|
+
// presents arrow-js's shape (see compat.ts, which makes the read side
|
|
110
|
+
// agree), so fold the 64-bit word pair back together here.
|
|
111
|
+
return values.map((v) => {
|
|
112
|
+
if (v == null) return null;
|
|
113
|
+
const w = v as ArrayLike<number>;
|
|
114
|
+
if (typeof (w as any).length !== "number" || w.length < 4) return v;
|
|
115
|
+
const lo = BigInt(w[2] >>> 0);
|
|
116
|
+
const hi = BigInt(w[3] | 0);
|
|
117
|
+
return [w[0] | 0, w[1] | 0, BigInt.asIntN(64, (hi << 32n) | lo)];
|
|
118
|
+
});
|
|
119
|
+
}
|
|
102
120
|
if (tid === 17) {
|
|
103
121
|
return values.map((v) => {
|
|
104
122
|
if (v == null) return null;
|
|
@@ -139,6 +157,7 @@ export function prepareForFlechette(type: VgiDataType, values: unknown[]): unkno
|
|
|
139
157
|
function needsPrepare(type: VgiDataType): boolean {
|
|
140
158
|
const tid = type.typeId;
|
|
141
159
|
if (tid === 8 || tid === 17) return true;
|
|
160
|
+
if (tid === 11 && (type as any).unit === MONTH_DAY_NANO) return true;
|
|
142
161
|
if (tid === 13) return ((type as any).children ?? []).some((c: any) => needsPrepare(c.type));
|
|
143
162
|
if (tid === 12 || tid === 16) {
|
|
144
163
|
const ct = (type as any).children?.[0]?.type;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
//
|
|
3
|
+
// arrow-js shape compatibility for the flechette backend.
|
|
4
|
+
//
|
|
5
|
+
// The facade's job is that a worker written once runs on either backend. Most
|
|
6
|
+
// of that is handled by re-exporting equivalent factories, but a few *methods
|
|
7
|
+
// and values* are read directly off the objects the facade hands back —
|
|
8
|
+
// `batch.getChild(name)`, `batch.slice(…)`, an INTERVAL cell — and there the
|
|
9
|
+
// two libraries genuinely disagree:
|
|
10
|
+
//
|
|
11
|
+
// arrow-js flechette
|
|
12
|
+
// ----------------------------- --------------------------------------
|
|
13
|
+
// Vector#isValid(i) Column has no validity accessor at all
|
|
14
|
+
// (Batch#isValid exists, Column doesn't)
|
|
15
|
+
// RecordBatch#slice(a, b) Table has no row-range slice
|
|
16
|
+
// MonthDayNano → Int32Array(4) → Float64Array(3), nanos via a double
|
|
17
|
+
//
|
|
18
|
+
// Worker code that touches any of those works on arrow-js and breaks on
|
|
19
|
+
// flechette. Rather than push `backend.name === "flechette"` conditionals into
|
|
20
|
+
// shared code and into every example, the gap is closed here, once, on the
|
|
21
|
+
// flechette side — which is what the facade is for.
|
|
22
|
+
//
|
|
23
|
+
// `Column` and `Table` are classes flechette exports, so these are prototype
|
|
24
|
+
// augmentations, installed on import and idempotent. The Int `isSigned` alias
|
|
25
|
+
// lives in ./int-signed.ts instead: Arrow types are plain object literals with
|
|
26
|
+
// no shared prototype, so it has to be applied per instance.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
Column,
|
|
30
|
+
Table,
|
|
31
|
+
batchType,
|
|
32
|
+
columnFromArray as f_columnFromArray,
|
|
33
|
+
interval as f_interval,
|
|
34
|
+
IntervalUnit,
|
|
35
|
+
} from "@query-farm/flechette";
|
|
36
|
+
|
|
37
|
+
import { prepareForFlechette } from "./canonical.js";
|
|
38
|
+
|
|
39
|
+
/** Extraction options the rest of the backend builds columns with. */
|
|
40
|
+
const COLUMN_OPTS = {
|
|
41
|
+
useBigInt: true,
|
|
42
|
+
useBigIntTimestamp: true,
|
|
43
|
+
useDecimalInt: true,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Column#isValid — arrow-js's Vector#isValid(index)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-row null check on a column, matching arrow-js's `Vector#isValid`.
|
|
52
|
+
*
|
|
53
|
+
* flechette pushes validity down to `Batch`, and a `Column` is a list of
|
|
54
|
+
* batches with an `offsets` prefix-sum, so the column index has to be mapped
|
|
55
|
+
* to (batch, batch-local index) the same way `Column#at` does. The common
|
|
56
|
+
* single-batch case is dispatched without the search.
|
|
57
|
+
*/
|
|
58
|
+
function columnIsValid(this: any, index: number): boolean {
|
|
59
|
+
const data: any[] = this.data;
|
|
60
|
+
if (data.length === 1) return batchIsValid(data[0], index);
|
|
61
|
+
const offsets: Int32Array = this.offsets;
|
|
62
|
+
// offsets is a sorted prefix sum of batch lengths; find the owning batch.
|
|
63
|
+
let lo = 0;
|
|
64
|
+
let hi = data.length;
|
|
65
|
+
while (lo < hi) {
|
|
66
|
+
const mid = (lo + hi) >>> 1;
|
|
67
|
+
if (offsets[mid + 1] <= index) lo = mid + 1;
|
|
68
|
+
else hi = mid;
|
|
69
|
+
}
|
|
70
|
+
return batchIsValid(data[lo], index - offsets[lo]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `Batch#isValid` guarded for the all-valid case.
|
|
75
|
+
*
|
|
76
|
+
* flechette only ever calls `Batch#isValid` from `Batch#at`, and the batch
|
|
77
|
+
* constructor short-circuits `at` when there are no nulls — so `isValid` is
|
|
78
|
+
* never reached with an absent validity bitmap *internally*, and it does not
|
|
79
|
+
* guard for one: `decodeBit(undefined, i)` reads `undefined[…]` and returns
|
|
80
|
+
* false. Exposing it through `Column#isValid` walks straight into that, and a
|
|
81
|
+
* fully-populated column reads back as entirely NULL — which is how every
|
|
82
|
+
* aggregate returned NULL instead of a sum.
|
|
83
|
+
*/
|
|
84
|
+
function batchIsValid(batch: any, index: number): boolean {
|
|
85
|
+
if (!batch) return false;
|
|
86
|
+
if (!batch.nullCount || !batch.validity || batch.validity.length === 0) return true;
|
|
87
|
+
return batch.isValid(index);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Table#slice — arrow-js's RecordBatch#slice(begin, end)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Row-range slice of a table, matching arrow-js's `RecordBatch#slice`.
|
|
96
|
+
*
|
|
97
|
+
* flechette has no row-slicing primitive: `Batch#slice` returns a plain value
|
|
98
|
+
* array, not a batch, so there is nothing to rebuild a zero-copy view from.
|
|
99
|
+
* The range is therefore materialized back through `columnFromArray` — the
|
|
100
|
+
* same builder the write path uses — which yields real batches with real
|
|
101
|
+
* validity/offset/value buffers. That matters: a sliced batch is routinely
|
|
102
|
+
* handed straight back to `tableToIPC` (an exchange round replying with
|
|
103
|
+
* `batch.slice(0, 0)` to close a stream), and a hand-rolled batch-shaped
|
|
104
|
+
* object would fault inside the encoder.
|
|
105
|
+
*
|
|
106
|
+
* Values are read through `Column#at`, so they come out decoded; they are run
|
|
107
|
+
* back through `prepareForFlechette` to restore the shapes the builder wants
|
|
108
|
+
* (Date objects, Maps, three-word intervals).
|
|
109
|
+
*
|
|
110
|
+
* The dominant caller is `slice(0, 0)`: "give me an empty batch with this
|
|
111
|
+
* exact schema". That degenerates to `columnFromArray([], type)` and costs
|
|
112
|
+
* nothing.
|
|
113
|
+
*/
|
|
114
|
+
function tableSlice(this: any, begin = 0, end?: number): any {
|
|
115
|
+
const numRows: number = this.numRows;
|
|
116
|
+
const lo = clampIndex(begin, numRows);
|
|
117
|
+
const hi = end === undefined ? numRows : clampIndex(end, numRows);
|
|
118
|
+
const n = Math.max(0, hi - lo);
|
|
119
|
+
const children = this.children.map((col: any) => {
|
|
120
|
+
const values: unknown[] = new Array(n);
|
|
121
|
+
for (let i = 0; i < n; i++) values[i] = col.at(lo + i) ?? null;
|
|
122
|
+
return f_columnFromArray(prepareForFlechette(col.type, values), col.type, COLUMN_OPTS);
|
|
123
|
+
});
|
|
124
|
+
const out: any = new Table(this.schema, children);
|
|
125
|
+
// Preserve the row-object factory (plain object vs. row proxy) the source
|
|
126
|
+
// table was built with; `getFactory` reads it lazily, so a post-construction
|
|
127
|
+
// assignment is enough.
|
|
128
|
+
out.factory = this.factory;
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clampIndex(i: number, len: number): number {
|
|
133
|
+
const v = i < 0 ? len + i : i;
|
|
134
|
+
return v < 0 ? 0 : v > len ? len : v;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// MonthDayNano interval values — arrow-js's four-int32 representation
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read a MonthDayNano interval as arrow-js does: an `Int32Array` of four words
|
|
143
|
+
* `[months, days, nanos_lo, nanos_hi]`.
|
|
144
|
+
*
|
|
145
|
+
* flechette returns `Float64Array.of(months, days, Number(nanos))` instead —
|
|
146
|
+
* a different length, a different element type, and a lossy nanosecond field
|
|
147
|
+
* (`readInt64` coerces to a JS number and throws past 2^53). Worker code that
|
|
148
|
+
* decodes an INTERVAL argument branches on the four-word layout, so on
|
|
149
|
+
* flechette it fell through to a scalar path and died in `BigInt(NaN)` with
|
|
150
|
+
* "RangeError: Not an integer".
|
|
151
|
+
*
|
|
152
|
+
* Reading the raw 16 bytes directly also drops the precision loss: the nanos
|
|
153
|
+
* word pair is copied verbatim rather than routed through a double. The
|
|
154
|
+
* matching write-side conversion lives in ./canonical.ts.
|
|
155
|
+
*/
|
|
156
|
+
function intervalMonthDayNanoValue(this: any, index: number): Int32Array {
|
|
157
|
+
const bytes: Uint8Array = this.values;
|
|
158
|
+
const base = index << 4;
|
|
159
|
+
const out = new Int32Array(4);
|
|
160
|
+
for (let w = 0; w < 4; w++) {
|
|
161
|
+
const p = base + (w << 2);
|
|
162
|
+
out[w] =
|
|
163
|
+
bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Installation
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/** Arrow `Type.Interval`, and `IntervalUnit.MONTH_DAY_NANO`. */
|
|
173
|
+
const TYPE_INTERVAL = 11;
|
|
174
|
+
const MONTH_DAY_NANO = 2;
|
|
175
|
+
|
|
176
|
+
function installTableShape(proto: any): void {
|
|
177
|
+
if (proto && typeof proto.slice !== "function") proto.slice = tableSlice;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function installColumnShape(proto: any): void {
|
|
181
|
+
if (proto && typeof proto.isValid !== "function") proto.isValid = columnIsValid;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function installIntervalShape(proto: any): void {
|
|
185
|
+
if (proto && !proto.__vgiArrowJsInterval) {
|
|
186
|
+
proto.value = intervalMonthDayNanoValue;
|
|
187
|
+
proto.__vgiArrowJsInterval = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Patch the copy of @query-farm/flechette that *this* module imports. Covers
|
|
192
|
+
// every object the facade itself builds or decodes.
|
|
193
|
+
installColumnShape(Column.prototype);
|
|
194
|
+
installTableShape(Table.prototype);
|
|
195
|
+
// The interval batch class is not exported by name; `batchType` resolves it.
|
|
196
|
+
installIntervalShape(
|
|
197
|
+
(batchType(f_interval(IntervalUnit.MONTH_DAY_NANO) as any, {}) as any)?.prototype,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Adoption — for batches built by a *different copy* of flechette
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/** Prototypes already patched, so adoption is O(1) after the first sighting. */
|
|
205
|
+
const adopted = new WeakSet<object>();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Give a batch the arrow-js shape even when it was not built by our copy of
|
|
209
|
+
* flechette.
|
|
210
|
+
*
|
|
211
|
+
* The prototype patches above are scoped to one module instance. npm/bun will
|
|
212
|
+
* happily install a *second* copy of `@query-farm/flechette` — nested under
|
|
213
|
+
* `@query-farm/vgi-rpc`, say, whenever the two version ranges disagree even
|
|
214
|
+
* transiently — and then batches decoded off the wire by vgi-rpc are instances
|
|
215
|
+
* of classes our patches never touched. Worker code that has been calling
|
|
216
|
+
* `col.isValid(i)` happily for the whole bind phase suddenly gets
|
|
217
|
+
* `TypeError: col.isValid is not a function` on the first exchange round, with
|
|
218
|
+
* nothing in the stack to suggest the cause. That is not hypothetical: it took
|
|
219
|
+
* out four integration tests, and a duplicate copy is invisible to
|
|
220
|
+
* `bun install` output and to the lockfile, which lists flechette once.
|
|
221
|
+
*
|
|
222
|
+
* So rather than trusting module identity, patch whatever prototypes we are
|
|
223
|
+
* actually handed, once each. `adopted` keeps it to a WeakSet probe per call.
|
|
224
|
+
*
|
|
225
|
+
* Returns its argument (mutating prototypes, not the batch) so call sites can
|
|
226
|
+
* wrap inline.
|
|
227
|
+
*/
|
|
228
|
+
export function adoptArrowJsShape<T>(batch: T): T {
|
|
229
|
+
const b = batch as any;
|
|
230
|
+
if (b == null || typeof b !== "object") return batch;
|
|
231
|
+
// Each prototype is checked independently: a 0-field batch teaches us nothing
|
|
232
|
+
// about the column class, so short-circuiting on the table alone would leave
|
|
233
|
+
// `isValid` unpatched for every later batch.
|
|
234
|
+
once(b, installTableShape);
|
|
235
|
+
const children: any[] = Array.isArray(b.children) ? b.children : [];
|
|
236
|
+
for (const col of children) {
|
|
237
|
+
if (col == null) continue;
|
|
238
|
+
once(col, installColumnShape);
|
|
239
|
+
// Interval *values* are produced by the batch class, one level further in.
|
|
240
|
+
if (col.type?.typeId === TYPE_INTERVAL && col.type?.unit === MONTH_DAY_NANO) {
|
|
241
|
+
once(col.data?.[0], installIntervalShape);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return batch;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Run `install` on `obj`'s prototype the first time we see that prototype. */
|
|
248
|
+
function once(obj: any, install: (proto: any) => void): void {
|
|
249
|
+
if (obj == null) return;
|
|
250
|
+
const proto = Object.getPrototypeOf(obj);
|
|
251
|
+
if (proto == null || adopted.has(proto)) return;
|
|
252
|
+
adopted.add(proto);
|
|
253
|
+
install(proto);
|
|
254
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { columnFromArray, tableFromColumns } from "@query-farm/flechette";
|
|
5
5
|
import type { VgiSchema, VgiBatch } from "../types.js";
|
|
6
|
+
import { toFlechetteType } from "./normalize-type.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Create an empty (0-row) batch with the given schema.
|
|
@@ -12,9 +13,12 @@ import type { VgiSchema, VgiBatch } from "../types.js";
|
|
|
12
13
|
* makeData scaffolding the arrow-js backend needs.
|
|
13
14
|
*/
|
|
14
15
|
export function emptyBatch(schema: VgiSchema): VgiBatch {
|
|
16
|
+
// Normalize first: a foreign (arrow-js) DataType survives `columnFromArray`
|
|
17
|
+
// but carries arrow-js property names, and flechette's IPC writer reads its
|
|
18
|
+
// own — so the resulting column type serializes with zeroed parameters.
|
|
15
19
|
const cols: Record<string, ReturnType<typeof columnFromArray>> = {};
|
|
16
20
|
for (const f of schema.fields) {
|
|
17
|
-
cols[f.name] = columnFromArray([], f.type as any);
|
|
21
|
+
cols[f.name] = columnFromArray([], toFlechetteType(f.type) as any);
|
|
18
22
|
}
|
|
19
23
|
return tableFromColumns(cols) as unknown as VgiBatch;
|
|
20
24
|
}
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import type { VgiBackendInfo } from "../types.js";
|
|
7
7
|
|
|
8
|
+
// Side-effect import: installs the arrow-js-shaped methods flechette's Column
|
|
9
|
+
// and Table lack (`isValid`, `slice`). Must come before anything hands a
|
|
10
|
+
// column or a table to worker code. See ./compat.ts.
|
|
11
|
+
import "./compat.js";
|
|
12
|
+
|
|
8
13
|
export const backend: VgiBackendInfo = { name: "flechette" };
|
|
9
14
|
|
|
10
15
|
// Schema / Field / DataType factories
|
|
@@ -53,3 +58,6 @@ export {
|
|
|
53
58
|
|
|
54
59
|
// Column statistics — sparse-union min/max via the codec/canonical path.
|
|
55
60
|
export { buildStatisticsBatch, type ColumnStatistics } from "./statistics.js";
|
|
61
|
+
|
|
62
|
+
// Arrow-js shape adoption for batches built by another copy of flechette.
|
|
63
|
+
export { adoptArrowJsShape } from "./compat.js";
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
|
|
18
18
|
import type { VgiSchema, VgiBatch } from "../types.js";
|
|
19
19
|
import { readFirstRecordBatchMeta } from "./message-meta.js";
|
|
20
|
+
import { normalizeDecodedSchema } from "./arrowjs-shape.js";
|
|
21
|
+
import { toFlechetteType } from "./normalize-type.js";
|
|
20
22
|
|
|
21
23
|
// flechette decoding options that match how vgi-typescript callers expect
|
|
22
24
|
// values to come out: 64-bit ints stay as BigInt, decimals as scaled BigInts,
|
|
@@ -28,6 +30,38 @@ const EXTRACT_OPTS = {
|
|
|
28
30
|
useMap: false,
|
|
29
31
|
} as const;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Re-base an IPC stream onto an 8-byte-aligned ArrayBuffer offset.
|
|
35
|
+
*
|
|
36
|
+
* flechette decodes record-batch buffers zero-copy, as
|
|
37
|
+
* `new ArrayType(bytes.buffer, bytes.byteOffset + region.offset, …)`. The Arrow
|
|
38
|
+
* IPC stream format guarantees `region.offset` is 8-byte aligned *relative to
|
|
39
|
+
* the message body*, so that view is only legal when the stream's own
|
|
40
|
+
* `byteOffset` is 8-byte aligned too. `TypedArray` throws
|
|
41
|
+
* `RangeError: Byte offset is not aligned` otherwise, and there is no way to
|
|
42
|
+
* recover after the fact.
|
|
43
|
+
*
|
|
44
|
+
* Nothing upstream promises that alignment. VGI hands us IPC streams that are
|
|
45
|
+
* views into a larger buffer at an arbitrary offset:
|
|
46
|
+
* * `splitLenPrefixed` subarrays past a 4-byte length prefix (odd by
|
|
47
|
+
* construction — buffering init/state payloads);
|
|
48
|
+
* * Arrow Binary column values, which are packed back-to-back in a shared
|
|
49
|
+
* data buffer with no per-value padding (nested IPC in state tokens,
|
|
50
|
+
* `init_request`, secret payloads);
|
|
51
|
+
* * HTTP body slices handed over by the runtime.
|
|
52
|
+
*
|
|
53
|
+
* arrow-js hides this by copying every misaligned buffer inside
|
|
54
|
+
* `toArrayBufferView`, which is why the arrow-js backend never saw it. We copy
|
|
55
|
+
* once here instead — the whole stream, so every body inside it keeps its
|
|
56
|
+
* relative alignment — and only when the input is actually misaligned, so the
|
|
57
|
+
* common already-aligned case stays zero-copy.
|
|
58
|
+
*/
|
|
59
|
+
function align8(bytes: Uint8Array): Uint8Array {
|
|
60
|
+
if ((bytes.byteOffset & 7) === 0) return bytes;
|
|
61
|
+
// `slice` copies into a fresh ArrayBuffer, whose byteOffset is 0.
|
|
62
|
+
return bytes.slice();
|
|
63
|
+
}
|
|
64
|
+
|
|
31
65
|
/**
|
|
32
66
|
* Serialize a Schema to Arrow IPC bytes.
|
|
33
67
|
*
|
|
@@ -41,9 +75,17 @@ export function serializeSchema(schema: VgiSchema): Uint8Array {
|
|
|
41
75
|
// tableFromColumns rebuilds fields with nullable=true, which round-trips
|
|
42
76
|
// wrong on the C++ side (rejects schema-conforming payloads as schema
|
|
43
77
|
// mismatches). See build.ts for the same fix on batches.
|
|
44
|
-
|
|
78
|
+
//
|
|
79
|
+
// Types are normalized to flechette-native first. Callers routinely declare
|
|
80
|
+
// schemas with arrow-js DataType instances (`new FixedSizeList(2, …)`), and
|
|
81
|
+
// flechette's IPC writer reads the *flechette* property names off whatever
|
|
82
|
+
// object it is handed — `stride`, not arrow-js's `listSize` — so a foreign
|
|
83
|
+
// type serializes with its parameters silently zeroed. That is what emitted
|
|
84
|
+
// `DOUBLE[0]` for a `DOUBLE[2]` argument and made DuckDB refuse the cast.
|
|
85
|
+
const types = schema.fields.map((f) => toFlechetteType(f.type));
|
|
86
|
+
const cols = types.map((t) => columnFromArray([], t as any));
|
|
45
87
|
const fields = schema.fields.map((f, i) =>
|
|
46
|
-
f_field(f.name,
|
|
88
|
+
f_field(f.name, types[i], (f as any).nullable ?? true, (f as any).metadata ?? null),
|
|
47
89
|
);
|
|
48
90
|
const flechSchema = {
|
|
49
91
|
version: 5,
|
|
@@ -59,9 +101,9 @@ export function serializeSchema(schema: VgiSchema): Uint8Array {
|
|
|
59
101
|
* decoded table (which may be 0-row if the source was a schema-only stream).
|
|
60
102
|
*/
|
|
61
103
|
export function deserializeSchema(bytes: Uint8Array): VgiSchema {
|
|
62
|
-
const table = tableFromIPC(bytes, EXTRACT_OPTS);
|
|
104
|
+
const table = tableFromIPC(align8(bytes), EXTRACT_OPTS);
|
|
63
105
|
// flechette's Table.schema is the structural shape we need.
|
|
64
|
-
return table.schema as unknown as VgiSchema;
|
|
106
|
+
return normalizeDecodedSchema(table.schema) as unknown as VgiSchema;
|
|
65
107
|
}
|
|
66
108
|
|
|
67
109
|
/**
|
|
@@ -74,7 +116,27 @@ export function deserializeSchema(bytes: Uint8Array): VgiSchema {
|
|
|
74
116
|
*/
|
|
75
117
|
export function serializeBatch(batch: VgiBatch): Uint8Array {
|
|
76
118
|
// VgiBatch is structurally a flechette Table at this point.
|
|
77
|
-
|
|
119
|
+
const t = batch as any;
|
|
120
|
+
// Two things `tableToIPC` will not do on its own, and arrow-js's
|
|
121
|
+
// `_writeRecordBatch` always does:
|
|
122
|
+
//
|
|
123
|
+
// 1. Emit a RecordBatch message when the table has no column data to walk.
|
|
124
|
+
// flechette derives its record batches from `columns[0].data`, so a
|
|
125
|
+
// zero-FIELD table (a legal vgi-rpc shape — `aggregate_update`'s ack,
|
|
126
|
+
// cancel signals, state-token carriers) encodes as schema + EOS with no
|
|
127
|
+
// batch at all. The C++ client reads that as "RPC returned an empty
|
|
128
|
+
// response" and fails the query.
|
|
129
|
+
// 2. Pick up per-batch `custom_metadata`. `withBatchMetadata` pins the map
|
|
130
|
+
// on `_vgiRecordMetadata`, but only the `batchMetadata` encode option
|
|
131
|
+
// puts it on the wire — so cache/state metadata was being dropped on
|
|
132
|
+
// serialize even though it read back correctly in-process.
|
|
133
|
+
//
|
|
134
|
+
// Passing a one-entry positional `batchMetadata` covers both: it synthesises
|
|
135
|
+
// the missing empty batch and attaches the map when there is one. Tables
|
|
136
|
+
// that already have batches and no metadata are unaffected.
|
|
137
|
+
const md: Map<string, string> | undefined = t._vgiRecordMetadata ?? t.metadata;
|
|
138
|
+
const batchMetadata = [md && md.size > 0 ? md : undefined];
|
|
139
|
+
return tableToIPC(t, { format: "stream", batchMetadata } as any) as Uint8Array;
|
|
78
140
|
}
|
|
79
141
|
|
|
80
142
|
/**
|
|
@@ -85,8 +147,12 @@ export function serializeBatch(batch: VgiBatch): Uint8Array {
|
|
|
85
147
|
* multi-batch streams this collapses to a single logical batch, matching
|
|
86
148
|
* the subprocess transport's one-batch-per-call convention.
|
|
87
149
|
*/
|
|
88
|
-
export function deserializeBatch(
|
|
150
|
+
export function deserializeBatch(input: Uint8Array): VgiBatch {
|
|
151
|
+
const bytes = align8(input);
|
|
89
152
|
const table: any = tableFromIPC(bytes, EXTRACT_OPTS);
|
|
153
|
+
// Worker code reads `batch.schema.fields[i].type.isSigned` (arrow-js's
|
|
154
|
+
// spelling) to pick promoted result types; flechette spells it `signed`.
|
|
155
|
+
normalizeDecodedSchema(table.schema);
|
|
90
156
|
// flechette's IPC parser ignores the Message-level `custom_metadata` field
|
|
91
157
|
// and reports row counts only via column children, so a "metadata-only"
|
|
92
158
|
// batch over a 0-field schema (legal in vgi-rpc — used to ferry state
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
list, largeList, fixedSizeList, struct, map, dictionary, union,
|
|
24
24
|
} from "@query-farm/flechette";
|
|
25
25
|
|
|
26
|
+
import { aliasIntSigned } from "./arrowjs-shape.js";
|
|
27
|
+
|
|
26
28
|
function fField(f: any): any {
|
|
27
29
|
return f_field(f.name, toFlechetteType(f.type), f.nullable ?? true, f.metadata ?? null);
|
|
28
30
|
}
|
|
@@ -40,7 +42,9 @@ export function toFlechetteType(type: any): any {
|
|
|
40
42
|
case Type.Bool:
|
|
41
43
|
return bool();
|
|
42
44
|
case Type.Int:
|
|
43
|
-
|
|
45
|
+
// Keep the arrow-js-spelled `isSigned` readable on the result — worker
|
|
46
|
+
// code round-trips types through here and then branches on it.
|
|
47
|
+
return aliasIntSigned(int(type.bitWidth, type.isSigned ?? type.signed ?? true));
|
|
44
48
|
case Type.Float:
|
|
45
49
|
return float(type.precision);
|
|
46
50
|
case Type.Binary:
|
|
@@ -67,7 +71,11 @@ export function toFlechetteType(type: any): any {
|
|
|
67
71
|
case Type.LargeList:
|
|
68
72
|
return (type.typeId === Type.LargeList ? largeList : list)(fField(type.children[0]));
|
|
69
73
|
case Type.FixedSizeList:
|
|
70
|
-
|
|
74
|
+
// arrow-js calls the width `listSize`, flechette calls it `stride`.
|
|
75
|
+
// Reading only the first makes this non-idempotent: normalizing an
|
|
76
|
+
// already-native type would zero the width and DuckDB then refuses to
|
|
77
|
+
// cast INT[3] to INT[0].
|
|
78
|
+
return fixedSizeList(fField(type.children[0]), type.listSize ?? type.stride);
|
|
71
79
|
case Type.Struct:
|
|
72
80
|
return struct(type.children.map(fField));
|
|
73
81
|
case Type.Map: {
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from "@query-farm/flechette";
|
|
53
53
|
|
|
54
54
|
import type { VgiField, VgiSchema, VgiDataType } from "../types.js";
|
|
55
|
+
import { aliasIntSigned } from "./arrowjs-shape.js";
|
|
55
56
|
|
|
56
57
|
// ----- Unit / mode constants -----------------------------------------------
|
|
57
58
|
|
|
@@ -91,16 +92,19 @@ export function field(
|
|
|
91
92
|
export const nullType = (): VgiDataType => f_nullType() as unknown as VgiDataType;
|
|
92
93
|
export const bool = (): VgiDataType => f_bool() as unknown as VgiDataType;
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
export const
|
|
98
|
-
export const
|
|
99
|
-
export const
|
|
100
|
-
export const
|
|
101
|
-
export const
|
|
95
|
+
// Int types carry the `isSigned` alias (arrow-js's spelling of flechette's
|
|
96
|
+
// `signed`) so worker code that branches on signedness reads the same property
|
|
97
|
+
// on both backends — see ./compat.ts.
|
|
98
|
+
export const int8 = (): VgiDataType => aliasIntSigned(f_int8()) as unknown as VgiDataType;
|
|
99
|
+
export const int16 = (): VgiDataType => aliasIntSigned(f_int16()) as unknown as VgiDataType;
|
|
100
|
+
export const int32 = (): VgiDataType => aliasIntSigned(f_int32()) as unknown as VgiDataType;
|
|
101
|
+
export const int64 = (): VgiDataType => aliasIntSigned(f_int64()) as unknown as VgiDataType;
|
|
102
|
+
export const uint8 = (): VgiDataType => aliasIntSigned(f_uint8()) as unknown as VgiDataType;
|
|
103
|
+
export const uint16 = (): VgiDataType => aliasIntSigned(f_uint16()) as unknown as VgiDataType;
|
|
104
|
+
export const uint32 = (): VgiDataType => aliasIntSigned(f_uint32()) as unknown as VgiDataType;
|
|
105
|
+
export const uint64 = (): VgiDataType => aliasIntSigned(f_uint64()) as unknown as VgiDataType;
|
|
102
106
|
export const int = (bitWidth: 8 | 16 | 32 | 64 = 32, signed = true): VgiDataType =>
|
|
103
|
-
f_int(bitWidth, signed) as unknown as VgiDataType;
|
|
107
|
+
aliasIntSigned(f_int(bitWidth, signed)) as unknown as VgiDataType;
|
|
104
108
|
|
|
105
109
|
export const float16 = (): VgiDataType => f_float16() as unknown as VgiDataType;
|
|
106
110
|
export const float32 = (): VgiDataType => f_float32() as unknown as VgiDataType;
|