@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.
Files changed (35) hide show
  1. package/dist/arrow/impl-arrowjs/index.d.ts +7 -0
  2. package/dist/arrow/impl-flechette/arrowjs-shape.d.ts +16 -0
  3. package/dist/arrow/impl-flechette/compat.d.ts +22 -0
  4. package/dist/arrow/impl-flechette/index.d.ts +2 -0
  5. package/dist/arrow/impl-flechette/ipc.d.ts +1 -1
  6. package/dist/arrow/index.d.ts +2 -2
  7. package/dist/arrow/predicates.d.ts +16 -0
  8. package/dist/cache-control.d.ts +7 -0
  9. package/dist/client-entry.js +5 -1
  10. package/dist/client-entry.js.map +4 -4
  11. package/dist/index.core.d.ts +2 -2
  12. package/dist/index.js +56 -4
  13. package/dist/index.js.map +10 -10
  14. package/dist/serve-entry.js +6 -1
  15. package/dist/serve-entry.js.map +5 -5
  16. package/dist/util/arrow/index.d.ts +1 -1
  17. package/dist/worker-cf-entry.js +53 -4
  18. package/dist/worker-cf-entry.js.map +9 -9
  19. package/package.json +5 -5
  20. package/src/arrow/impl-arrowjs/index.ts +10 -0
  21. package/src/arrow/impl-flechette/arrowjs-shape.ts +78 -0
  22. package/src/arrow/impl-flechette/canonical.ts +19 -0
  23. package/src/arrow/impl-flechette/compat.ts +254 -0
  24. package/src/arrow/impl-flechette/empty.ts +5 -1
  25. package/src/arrow/impl-flechette/index.ts +8 -0
  26. package/src/arrow/impl-flechette/ipc.ts +72 -6
  27. package/src/arrow/impl-flechette/normalize-type.ts +10 -2
  28. package/src/arrow/impl-flechette/schema.ts +13 -9
  29. package/src/arrow/index.ts +3 -0
  30. package/src/arrow/predicates.ts +59 -0
  31. package/src/cache-control.ts +8 -0
  32. package/src/functions/registry.ts +21 -4
  33. package/src/index.core.ts +3 -0
  34. package/src/protocol/handlers/function.ts +9 -1
  35. 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.20.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 \u2014 invalid ESM that throws on first import. Modules not listed stay side-effect-free, so consumers still tree-shake normally.",
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.0"
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.0"
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.0",
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
- const cols = schema.fields.map((f) => columnFromArray([], f.type as any));
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, cols[i].type as any, (f as any).nullable ?? true, (f as any).metadata ?? null),
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
- return tableToIPC(batch as any, { format: "stream" }) as Uint8Array;
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(bytes: Uint8Array): VgiBatch {
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
- return int(type.bitWidth, type.isSigned ?? type.signed ?? true);
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
- return fixedSizeList(fField(type.children[0]), type.listSize);
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
- export const int8 = (): VgiDataType => f_int8() as unknown as VgiDataType;
95
- export const int16 = (): VgiDataType => f_int16() as unknown as VgiDataType;
96
- export const int32 = (): VgiDataType => f_int32() as unknown as VgiDataType;
97
- export const int64 = (): VgiDataType => f_int64() as unknown as VgiDataType;
98
- export const uint8 = (): VgiDataType => f_uint8() as unknown as VgiDataType;
99
- export const uint16 = (): VgiDataType => f_uint16() as unknown as VgiDataType;
100
- export const uint32 = (): VgiDataType => f_uint32() as unknown as VgiDataType;
101
- export const uint64 = (): VgiDataType => f_uint64() as unknown as VgiDataType;
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;