@query-farm/vgi 0.27.0 → 0.29.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 (83) hide show
  1. package/README.md +26 -7
  2. package/dist/arguments/argument-spec.d.ts +53 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
  4. package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
  5. package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
  6. package/dist/arrow/impl-flechette/index.d.ts +1 -1
  7. package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
  8. package/dist/arrow/impl-flechette/schema.d.ts +2 -0
  9. package/dist/arrow/index.d.ts +1 -1
  10. package/dist/arrow/schema-types.d.ts +3 -1
  11. package/dist/arrow/types.d.ts +39 -3
  12. package/dist/catalog/interface.d.ts +43 -0
  13. package/dist/client/client.d.ts +1 -1
  14. package/dist/client-entry.js +360 -178
  15. package/dist/client-entry.js.map +14 -14
  16. package/dist/functions/table.d.ts +63 -0
  17. package/dist/functions/types.d.ts +38 -0
  18. package/dist/generated/vgi-client.d.ts +59 -0
  19. package/dist/generated/vgi-protocol-schemas.d.ts +19 -0
  20. package/dist/http/fetch.d.ts +10 -0
  21. package/dist/index.core.d.ts +2 -2
  22. package/dist/index.js +1203 -834
  23. package/dist/index.js.map +40 -37
  24. package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
  25. package/dist/protocol/handlers/function.d.ts +11 -0
  26. package/dist/protocol/serializers/bind.d.ts +1 -2
  27. package/dist/protocol/serializers/cardinality.d.ts +1 -2
  28. package/dist/protocol/serializers/splits.d.ts +126 -0
  29. package/dist/protocol/types.d.ts +27 -0
  30. package/dist/request-auth.d.ts +28 -0
  31. package/dist/serve-entry.d.ts +4 -0
  32. package/dist/serve-entry.js +745 -359
  33. package/dist/serve-entry.js.map +32 -29
  34. package/dist/split-token.d.ts +88 -0
  35. package/dist/worker-cf-entry.js +1029 -619
  36. package/dist/worker-cf-entry.js.map +37 -34
  37. package/package.json +3 -3
  38. package/src/arguments/argument-spec.ts +81 -0
  39. package/src/arguments/arguments.ts +0 -1
  40. package/src/arrow/impl-arrowjs/index.ts +1 -1
  41. package/src/arrow/impl-arrowjs/iterate.ts +21 -0
  42. package/src/arrow/impl-arrowjs/schema.ts +4 -0
  43. package/src/arrow/impl-flechette/index.ts +1 -1
  44. package/src/arrow/impl-flechette/iterate.ts +24 -1
  45. package/src/arrow/impl-flechette/schema.ts +4 -0
  46. package/src/arrow/index.ts +1 -1
  47. package/src/arrow/schema-types.ts +4 -0
  48. package/src/arrow/types.ts +39 -3
  49. package/src/catalog/interface.ts +124 -29
  50. package/src/catalog/read-only.ts +18 -18
  51. package/src/client/client.ts +6 -2
  52. package/src/functions/aggregate.ts +3 -0
  53. package/src/functions/copy-from.ts +2 -1
  54. package/src/functions/copy-to.ts +2 -1
  55. package/src/functions/scalar.ts +11 -0
  56. package/src/functions/table-buffering.ts +3 -0
  57. package/src/functions/table-in-out.ts +8 -4
  58. package/src/functions/table.ts +129 -2
  59. package/src/functions/types.ts +40 -0
  60. package/src/generated/vgi-client.ts +63 -1
  61. package/src/generated/vgi-protocol-schemas.ts +240 -14
  62. package/src/http/fetch.ts +117 -2
  63. package/src/index.core.ts +2 -2
  64. package/src/protocol/handlers/aggregate.ts +10 -5
  65. package/src/protocol/handlers/catalog/admin.ts +41 -48
  66. package/src/protocol/handlers/catalog/index_methods.ts +4 -11
  67. package/src/protocol/handlers/catalog/macro.ts +8 -16
  68. package/src/protocol/handlers/catalog/shared.ts +10 -57
  69. package/src/protocol/handlers/catalog/table.ts +33 -96
  70. package/src/protocol/handlers/catalog/view.ts +13 -17
  71. package/src/protocol/handlers/function.ts +311 -18
  72. package/src/protocol/handlers/table-buffering.ts +7 -4
  73. package/src/protocol/serializers/bind.ts +56 -63
  74. package/src/protocol/serializers/cardinality.ts +7 -7
  75. package/src/protocol/serializers/init.ts +52 -10
  76. package/src/protocol/serializers/splits.ts +219 -0
  77. package/src/protocol/state-serializer.ts +55 -1
  78. package/src/protocol/types.ts +27 -0
  79. package/src/request-auth.ts +73 -0
  80. package/src/serve-entry.ts +5 -0
  81. package/src/split-token.ts +297 -0
  82. package/src/worker-cf-entry.ts +2 -1
  83. package/src/worker.ts +30 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@query-farm/vgi",
3
- "version": "0.27.0",
3
+ "version": "0.29.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)",
@@ -90,12 +90,12 @@
90
90
  },
91
91
  "peerDependencies": {
92
92
  "@query-farm/apache-arrow": "^21.1.1",
93
- "@query-farm/vgi-rpc": ">=0.20.0 <0.21.0"
93
+ "@query-farm/vgi-rpc": ">=0.22.0 <0.23.0"
94
94
  },
95
95
  "devDependencies": {
96
96
  "@duckdb/node-api": "^1.5.5-r.4",
97
97
  "@query-farm/apache-arrow": "^21.1.1",
98
- "@query-farm/vgi-rpc": "^0.20.0",
98
+ "@query-farm/vgi-rpc": "^0.22.0",
99
99
  "@types/bun": "latest",
100
100
  "typescript": "^7.0.2"
101
101
  },
@@ -167,6 +167,87 @@ function choiceEquals(choice: unknown, value: unknown): boolean {
167
167
  return choice === value;
168
168
  }
169
169
 
170
+ /**
171
+ * Narrow a bind-time argument value from Arrow's bigint to a plain number.
172
+ *
173
+ * DuckDB types an integer literal as BIGINT, so `series(3)` arrives as `3n`
174
+ * whether or not anyone wanted 64 bits. Nobody writes `BigInt(1000)` for a page
175
+ * size, so the arg paths narrow — and they now all narrow the same way, which
176
+ * they did not before (two sites used safeNumber, two a bare Number).
177
+ *
178
+ * This is lossy above 2^53, and that is a deliberate call rather than an
179
+ * oversight. Every int64 *argument* across the fixtures and every shipped
180
+ * worker is a small control value — count, page_size, batch_size, increment,
181
+ * lag_minutes, restatement_lookback_days. The large int64s in the test suite
182
+ * are all somewhere else entirely:
183
+ *
184
+ * - column data (copy_to/types.test, copy_to/parallel.test) — never touches
185
+ * this path;
186
+ * - filter-pushdown constants (filter_pushdown/integers.test pins 2^53+1 on
187
+ * purpose) — deserialize.ts keeps those bigint, with its own regression
188
+ * test;
189
+ * - varargs values (constant_columns) — read raw off bindCall.arguments,
190
+ * because extractArgs' copy of them is not what the function reads;
191
+ * - time-travel versions — carried as strings (`at_value`).
192
+ *
193
+ * Preserving the bigint on overflow was tried and reverted: it buys nothing for
194
+ * any argument that exists, and costs a JS type that depends on magnitude, so a
195
+ * worker doing `args.n + 1` would work in every test and fail on one caller's
196
+ * input. If an argument ever genuinely needs the full range — a snowflake id, a
197
+ * nanosecond epoch — the fix is to make int64 arguments bigint by declared
198
+ * type, not by magnitude.
199
+ */
200
+ export function narrowArgValue(value: unknown): unknown {
201
+ return typeof value === "bigint" ? Number(value) : value;
202
+ }
203
+
204
+ /**
205
+ * Reject a type *factory* where an Arrow *type* was expected.
206
+ *
207
+ * `params: { n: int64 }` (no parentheses) is accepted by the type system —
208
+ * `VgiDataType` is loose enough — and then silently produces an ArgumentSpec
209
+ * whose `arrowType` is a Function. Nothing complains until the schema is
210
+ * serialized or the bind arrives, and the error at that point names neither the
211
+ * argument nor the cause.
212
+ *
213
+ * The mistake is easy to make on purpose, too: the same identifier is a
214
+ * ready-made type on one entry point and a factory on another.
215
+ *
216
+ * @query-farm/vgi int, int32, float32, bool → type instances
217
+ * @query-farm/vgi/worker-cf int, int32, float32, bool → factories
218
+ *
219
+ * So moving a worker to Cloudflare can turn a correct declaration into a
220
+ * silently wrong one with no other edit. Failing here, at definition time,
221
+ * makes that a startup error naming the field.
222
+ */
223
+ export function assertArrowType(value: unknown, where: string): void {
224
+ if (typeof value === "function") {
225
+ const name = (value as { name?: string }).name;
226
+ const call = name ? `${name}()` : "the factory";
227
+ throw new ArgumentValidationError(
228
+ `${where} is a type factory, not an Arrow type — call it: ${call}. ` +
229
+ `(Note that int, int32, float32 and bool are ready-made types on ` +
230
+ `@query-farm/vgi but factories on @query-farm/vgi/worker-cf, so the ` +
231
+ `same name may need parentheses on one entry point and not the other. ` +
232
+ `int64(), float64(), utf8() and the rest are factories everywhere.)`,
233
+ );
234
+ }
235
+ if (value === null || value === undefined) {
236
+ throw new ArgumentValidationError(`${where} is ${String(value)}, not an Arrow type.`);
237
+ }
238
+ }
239
+
240
+ /** {@link assertArrowType} over a record of declared types, e.g. `params`. */
241
+ export function assertArrowTypes(
242
+ types: Record<string, unknown> | undefined,
243
+ where: string,
244
+ ): void {
245
+ if (!types) return;
246
+ for (const [name, type] of Object.entries(types)) {
247
+ assertArrowType(type, `${where}.${name}`);
248
+ }
249
+ }
250
+
170
251
  /**
171
252
  * Enforce a bind-time const value against its declared {@link ArgumentConstraints},
172
253
  * throwing {@link ArgumentValidationError} on violation. Mirrors the Python SDK's
@@ -3,7 +3,6 @@
3
3
  // Holds positional and named scalar values from DuckDB.
4
4
 
5
5
  import type { VgiSchema } from "../arrow/index.js";
6
- import { safeNumber } from "../util/arrow/index.js";
7
6
 
8
7
  export class Arguments {
9
8
  readonly positional: (any | null)[];
@@ -17,7 +17,7 @@ export {
17
17
  int8, int16, int32, int64,
18
18
  uint8, uint16, uint32, uint64,
19
19
  float16, float32, float64,
20
- utf8, binary, fixedSizeBinary,
20
+ utf8, largeUtf8, binary, largeBinary, fixedSizeBinary,
21
21
  decimal, decimal128, decimal256,
22
22
  date, dateDay, dateMillisecond,
23
23
  time, timeSecond, timeMillisecond, timeMicrosecond, timeNanosecond,
@@ -151,8 +151,29 @@ export function batchToSecretDict(
151
151
  /**
152
152
  * Safe number coercion for BigInt values.
153
153
  */
154
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
155
+ const MIN_SAFE = -MAX_SAFE;
156
+
157
+ /**
158
+ * Narrow a bigint to a number, refusing to do it lossily.
159
+ *
160
+ * Arrow int64/uint64 values arrive as bigint. Several bind-time argument paths
161
+ * want a plain number, and used to get one from a bare `Number(value)` — which
162
+ * silently rounds above 2^53, so an id or a nanosecond timestamp passed as a
163
+ * function argument came back subtly wrong with nothing logged anywhere.
164
+ *
165
+ * Throwing is the lesser evil: a caller that genuinely wants a lossy narrowing
166
+ * can still write `Number(v)` themselves, but nobody has to discover the loss
167
+ * from mismatched output weeks later.
168
+ */
154
169
  export function safeNumber(value: any): number {
155
170
  if (typeof value === "bigint") {
171
+ if (value > MAX_SAFE || value < MIN_SAFE) {
172
+ throw new RangeError(
173
+ `${value} cannot be represented exactly as a JS number ` +
174
+ `(|value| > Number.MAX_SAFE_INTEGER). Keep it as a bigint.`,
175
+ );
176
+ }
156
177
  return Number(value);
157
178
  }
158
179
  return value as number;
@@ -20,7 +20,9 @@ import {
20
20
  Float32 as A_Float32,
21
21
  Float64 as A_Float64,
22
22
  Utf8 as A_Utf8,
23
+ LargeUtf8 as A_LargeUtf8,
23
24
  Binary as A_Binary,
25
+ LargeBinary as A_LargeBinary,
24
26
  Decimal as A_Decimal,
25
27
  Date_ as A_Date,
26
28
  Time as A_Time,
@@ -134,7 +136,9 @@ export const float32 = (): VgiDataType => new A_Float32() as unknown as VgiDataT
134
136
  export const float64 = (): VgiDataType => new A_Float64() as unknown as VgiDataType;
135
137
 
136
138
  export const utf8 = (): VgiDataType => new A_Utf8() as unknown as VgiDataType;
139
+ export const largeUtf8 = (): VgiDataType => new A_LargeUtf8() as unknown as VgiDataType;
137
140
  export const binary = (): VgiDataType => new A_Binary() as unknown as VgiDataType;
141
+ export const largeBinary = (): VgiDataType => new A_LargeBinary() as unknown as VgiDataType;
138
142
  export const fixedSizeBinary = (byteWidth: number): VgiDataType =>
139
143
  new A_FixedSizeBinary(byteWidth) as unknown as VgiDataType;
140
144
 
@@ -22,7 +22,7 @@ export {
22
22
  int8, int16, int32, int64,
23
23
  uint8, uint16, uint32, uint64,
24
24
  float16, float32, float64,
25
- utf8, binary, fixedSizeBinary,
25
+ utf8, largeUtf8, binary, largeBinary, fixedSizeBinary,
26
26
  decimal, decimal128, decimal256,
27
27
  date, dateDay, dateMillisecond,
28
28
  time, timeSecond, timeMillisecond, timeMicrosecond, timeNanosecond,
@@ -115,7 +115,30 @@ export function batchToSecretDict(
115
115
  /**
116
116
  * BigInt → number coercion (lossy for values past 2^53).
117
117
  */
118
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
119
+ const MIN_SAFE = -MAX_SAFE;
120
+
121
+ /**
122
+ * Narrow a bigint to a number, refusing to do it lossily.
123
+ *
124
+ * Arrow int64/uint64 values arrive as bigint. Several bind-time argument paths
125
+ * want a plain number, and used to get one from a bare `Number(value)` — which
126
+ * silently rounds above 2^53, so an id or a nanosecond timestamp passed as a
127
+ * function argument came back subtly wrong with nothing logged anywhere.
128
+ *
129
+ * Throwing is the lesser evil: a caller that genuinely wants a lossy narrowing
130
+ * can still write `Number(v)` themselves, but nobody has to discover the loss
131
+ * from mismatched output weeks later.
132
+ */
118
133
  export function safeNumber(value: any): number {
119
- if (typeof value === "bigint") return Number(value);
134
+ if (typeof value === "bigint") {
135
+ if (value > MAX_SAFE || value < MIN_SAFE) {
136
+ throw new RangeError(
137
+ `${value} cannot be represented exactly as a JS number ` +
138
+ `(|value| > Number.MAX_SAFE_INTEGER). Keep it as a bigint.`,
139
+ );
140
+ }
141
+ return Number(value);
142
+ }
120
143
  return value as number;
121
144
  }
@@ -23,7 +23,9 @@ import {
23
23
  float32 as f_float32,
24
24
  float64 as f_float64,
25
25
  utf8 as f_utf8,
26
+ largeUtf8 as f_largeUtf8,
26
27
  binary as f_binary,
28
+ largeBinary as f_largeBinary,
27
29
  fixedSizeBinary as f_fixedSizeBinary,
28
30
  decimal as f_decimal,
29
31
  decimal128 as f_decimal128,
@@ -111,7 +113,9 @@ export const float32 = (): VgiDataType => f_float32() as unknown as VgiDataType;
111
113
  export const float64 = (): VgiDataType => f_float64() as unknown as VgiDataType;
112
114
 
113
115
  export const utf8 = (): VgiDataType => f_utf8() as unknown as VgiDataType;
116
+ export const largeUtf8 = (): VgiDataType => f_largeUtf8() as unknown as VgiDataType;
114
117
  export const binary = (): VgiDataType => f_binary() as unknown as VgiDataType;
118
+ export const largeBinary = (): VgiDataType => f_largeBinary() as unknown as VgiDataType;
115
119
  export const fixedSizeBinary = (stride: number): VgiDataType =>
116
120
  f_fixedSizeBinary(stride) as unknown as VgiDataType;
117
121
 
@@ -81,7 +81,7 @@ export {
81
81
  int, int8, int16, int32, int64,
82
82
  uint8, uint16, uint32, uint64,
83
83
  float16, float32, float64,
84
- utf8, binary, fixedSizeBinary,
84
+ utf8, largeUtf8, binary, largeBinary, fixedSizeBinary,
85
85
  decimal, decimal128, decimal256,
86
86
  date, dateDay, dateMillisecond,
87
87
  time, timeSecond, timeMillisecond, timeMicrosecond, timeNanosecond,
@@ -25,7 +25,9 @@ import type {
25
25
  IntType,
26
26
  FloatType,
27
27
  Utf8Type,
28
+ LargeUtf8Type,
28
29
  BinaryType,
30
+ LargeBinaryType,
29
31
  FixedSizeBinaryType,
30
32
  DecimalType,
31
33
  Date32Type, Date64Type,
@@ -83,7 +85,9 @@ export const float32 = (): FloatType => impl.float32() as unknown as FloatType;
83
85
  export const float64 = (): FloatType => impl.float64() as unknown as FloatType;
84
86
 
85
87
  export const utf8 = (): Utf8Type => impl.utf8() as unknown as Utf8Type;
88
+ export const largeUtf8 = (): LargeUtf8Type => impl.largeUtf8() as unknown as LargeUtf8Type;
86
89
  export const binary = (): BinaryType => impl.binary() as unknown as BinaryType;
90
+ export const largeBinary = (): LargeBinaryType => impl.largeBinary() as unknown as LargeBinaryType;
87
91
  export const fixedSizeBinary = (byteWidth: number): FixedSizeBinaryType =>
88
92
  impl.fixedSizeBinary(byteWidth) as unknown as FixedSizeBinaryType;
89
93
 
@@ -33,9 +33,43 @@ export interface VgiSchema {
33
33
  readonly metadata: Map<string, string>;
34
34
  }
35
35
 
36
- /** Column view. Generic dropped: arrow-js parameterizes on DataType, flechette
37
- * on value type, so the facade exposes an erased shape. Callers cast at the
38
- * use site (the column's static schema makes the value type known there). */
36
+ /**
37
+ * Column view over one column of a batch.
38
+ *
39
+ * The value type is erased by default: arrow-js parameterizes on DataType and
40
+ * flechette on value type, so the facade cannot name one without picking a
41
+ * backend. Supply `T` at the use site — the column's declared type makes it
42
+ * known there — and the cast disappears:
43
+ *
44
+ * ```ts
45
+ * const ns = batch.getChildAt(0)! as Iterable<bigint | null>;
46
+ * for (const v of ns) { ... } // v: bigint | null
47
+ * ```
48
+ *
49
+ * A type parameter here would read better, but it cannot be had cheaply: both
50
+ * backends' native batches are assigned to VgiBatch structurally, and a
51
+ * concrete `get(): unknown` does not satisfy a generic `get(): T`. Adding one
52
+ * would mean casting at ~25 internal sites to remove one cast in user code.
53
+ *
54
+ * **These are the backend's own values, not codec output.** `get()` and
55
+ * iteration return whatever the Arrow implementation stores, which for some
56
+ * types is not the value the SDK documents:
57
+ *
58
+ * | Arrow type | here | {@link iterRows} |
59
+ * | ---------------- | ----------------------------- | --------------------- |
60
+ * | `int64` | `bigint` | `bigint` (same) |
61
+ * | `utf8`, `float64`| `string`, `number` | same |
62
+ * | `decimal128` | backend limbs (`DecimalBigNum`)| `bigint`, unscaled |
63
+ * | `timestamp[us]` | millisecond `number` | microsecond `bigint` |
64
+ * | `date32` | millisecond `number` | `Date` |
65
+ *
66
+ * `repr: "raw"` on a function selects the *codec's* representation and has no
67
+ * effect on this path.
68
+ *
69
+ * So: reach for `getChildAt` on integer, float, boolean, string and binary
70
+ * columns, where it is the cheapest correct thing. For temporal, decimal and
71
+ * nested types go through {@link iterRows}, which runs the codec.
72
+ */
39
73
  export interface VgiColumn {
40
74
  readonly type: VgiDataType;
41
75
  readonly length: number;
@@ -46,7 +80,9 @@ export interface VgiColumn {
46
80
  export interface VgiBatch {
47
81
  readonly schema: VgiSchema;
48
82
  readonly numRows: number;
83
+ /** See {@link VgiColumn} — this does NOT run the codec. */
49
84
  getChild(name: string): VgiColumn | null;
85
+ /** See {@link VgiColumn} — this does NOT run the codec. */
50
86
  getChildAt(index: number): VgiColumn | null;
51
87
  }
52
88
 
@@ -164,7 +164,7 @@ export const encodeAttachCatalogInfo = (v: AttachCatalogInfo): Uint8Array =>
164
164
  import { deserializeBatch, serializeBatch, batchFromColumns } from "../util/arrow/index.js";
165
165
  import { schema as schema_, field as field_, type VgiDataType, type VgiField } from "../arrow/index.js";
166
166
  import { toUint8Array } from "../util/bytes.js";
167
- import { ScanBranchSchema } from "../generated/vgi-protocol-schemas.js";
167
+ import { ScanBranchSchema, ScanFunctionResultSchema } from "../generated/vgi-protocol-schemas.js";
168
168
 
169
169
  /**
170
170
  * Result from `tableScanFunctionGet` — tells the VGI DuckDB extension which
@@ -181,6 +181,37 @@ export interface ScanFunctionResult {
181
181
  requiredExtensions: string[];
182
182
  }
183
183
 
184
+ /**
185
+ * Encode a ScanFunctionResult wire record — the single-row batch shape
186
+ * `{ function_name, arguments, required_extensions }` that
187
+ * `catalog_table_scan_function_get` returns and that a function-backed table
188
+ * inlines into `TableInfo.scan_function`.
189
+ *
190
+ * It exists so there is exactly one place that names those columns. The
191
+ * inline-scan path used to restate the schema locally even though codegen
192
+ * emits it; a hand-written wire schema is how four SDKs ended up disagreeing
193
+ * about which columns were binary and which were large_binary.
194
+ *
195
+ * `argumentsBytes` is the already-serialized inner arguments batch (one column
196
+ * per argument: `arg_<index>` positional, bare name for named).
197
+ */
198
+ export function encodeScanFunctionResult(
199
+ functionName: string,
200
+ argumentsBytes: Uint8Array,
201
+ requiredExtensions: string[] = [],
202
+ ): Uint8Array {
203
+ return serializeBatch(
204
+ batchFromColumns(
205
+ {
206
+ function_name: [functionName],
207
+ arguments: [argumentsBytes],
208
+ required_extensions: [requiredExtensions],
209
+ },
210
+ ScanFunctionResultSchema as any,
211
+ ),
212
+ );
213
+ }
214
+
184
215
  /**
185
216
  * Decode a ScanFunctionResult from a wire-shape inner result dict.
186
217
  *
@@ -237,24 +268,59 @@ export function decodeScanFunctionResult(inner: Record<string, unknown>): ScanFu
237
268
  export function singleBranchResult(
238
269
  legacy: { function_name: string; arguments: unknown; required_extensions?: string[] },
239
270
  ): { branches: Uint8Array[]; required_extensions: string[] } {
240
- const branchBatch = batchFromColumns(
241
- {
242
- function_name: [legacy.function_name],
243
- arguments: [toUint8Array(legacy.arguments)],
244
- branch_filter: [null],
245
- writable: [false],
246
- source_catalog: [null],
247
- source_schema: [null],
248
- source_table: [null],
249
- },
250
- ScanBranchSchema as any,
251
- );
252
271
  return {
253
- branches: [serializeBatch(branchBatch)],
272
+ branches: [
273
+ encodeScanBranch({ function_name: legacy.function_name, arguments: toUint8Array(legacy.arguments) }),
274
+ ],
254
275
  required_extensions: legacy.required_extensions ?? [],
255
276
  };
256
277
  }
257
278
 
279
+ /**
280
+ * The ONE place `ScanBranch`'s columns are named.
281
+ *
282
+ * Both branch builders used to carry their own copy of this key list, and both
283
+ * copies were missing `format_name` / `format_locations` after those fields
284
+ * were added to the schema. `format_locations` is a LIST, and Arrow's assembler
285
+ * dereferences a list's `children[0]` while writing, so the omission did not
286
+ * degrade anything — it killed every scan through those builders with
287
+ * "Cannot read properties of undefined (reading 'slice')", including tables
288
+ * that predated format branches entirely. One key list is the fix; the
289
+ * completeness test in src/__tests__ pins it against ScanBranchSchema.
290
+ *
291
+ * Every column the schema declares must appear here, null included.
292
+ */
293
+ function encodeScanBranch(cols: {
294
+ function_name: string;
295
+ arguments: Uint8Array;
296
+ branch_filter?: string | null;
297
+ writable?: boolean;
298
+ source_catalog?: string | null;
299
+ source_schema?: string | null;
300
+ source_table?: string | null;
301
+ format_name?: string | null;
302
+ format_locations?: string[] | null;
303
+ format_options?: Uint8Array | null;
304
+ }): Uint8Array {
305
+ return serializeBatch(
306
+ batchFromColumns(
307
+ {
308
+ function_name: [cols.function_name],
309
+ arguments: [cols.arguments],
310
+ branch_filter: [cols.branch_filter ?? null],
311
+ writable: [cols.writable ?? false],
312
+ source_catalog: [cols.source_catalog ?? null],
313
+ source_schema: [cols.source_schema ?? null],
314
+ source_table: [cols.source_table ?? null],
315
+ format_name: [cols.format_name ?? null],
316
+ format_locations: [cols.format_locations ?? null],
317
+ format_options: [cols.format_options ?? null],
318
+ },
319
+ ScanBranchSchema as any,
320
+ ),
321
+ );
322
+ }
323
+
258
324
  /**
259
325
  * One physical source backing a multi-branch scan, in the shape the
260
326
  * `buildScanBranchesResult` helper consumes. Mirrors vgi-python's
@@ -275,6 +341,16 @@ export interface ScanBranchInput {
275
341
  branchFilter?: string | null;
276
342
  /** Declares this branch as the INSERT target (at most one per table). */
277
343
  writable?: boolean;
344
+ /** Custom file FORMAT this branch reads, for a format-backed branch. */
345
+ formatName?: string | null;
346
+ /** Locations the format branch reads. */
347
+ formatLocations?: string[] | null;
348
+ /** Reader options for a format branch, as a 1-row IPC batch whose COLUMN
349
+ * NAMES are the option names — the same shape `arguments` uses, because an
350
+ * option value may be any Arrow type. These BECOME the reader's named
351
+ * arguments, so without them a format branch cannot pass its reader a
352
+ * delimiter, a header flag, or anything else. */
353
+ formatOptions?: Uint8Array | null;
278
354
  /**
279
355
  * Catalog-table branch (lakehouse federation): leave `functionName` empty and
280
356
  * set these to scan the base table
@@ -292,6 +368,26 @@ export interface ScanBranchInput {
292
368
  * positional args, the bare name for named args. Mirrors Python's
293
369
  * `ScanBranch.to_row_dict` argument handling.
294
370
  */
371
+ /**
372
+ * Encode a format branch's reader options.
373
+ *
374
+ * Same shape as a branch's `arguments` blob — a 1-row batch whose COLUMN NAMES
375
+ * are the option names — because an option value may be any Arrow type, so no
376
+ * static schema could describe the map. Exported because a worker building a
377
+ * format branch needs it and has no other way to produce those bytes.
378
+ */
379
+ export function encodeFormatOptions(
380
+ options: Record<string, { value: unknown; type: VgiDataType }>,
381
+ ): Uint8Array {
382
+ const fields: VgiField[] = [];
383
+ const values: Record<string, unknown[]> = {};
384
+ for (const [name, arg] of Object.entries(options)) {
385
+ fields.push(field_(name, arg.type, true));
386
+ values[name] = [arg.value];
387
+ }
388
+ return serializeBatch(batchFromColumns(values, schema_(fields)));
389
+ }
390
+
295
391
  function serializeBranchArguments(branch: ScanBranchInput): Uint8Array {
296
392
  const fields: VgiField[] = [];
297
393
  const values: Record<string, unknown[]> = {};
@@ -328,21 +424,20 @@ export function buildScanBranchesResult(
328
424
  branches: ScanBranchInput[],
329
425
  requiredExtensions: string[] = [],
330
426
  ): { branches: Uint8Array[]; required_extensions: string[] } {
331
- const serializedBranches = branches.map((branch) => {
332
- const branchBatch = batchFromColumns(
333
- {
334
- function_name: [branch.functionName],
335
- arguments: [serializeBranchArguments(branch)],
336
- branch_filter: [branch.branchFilter ?? null],
337
- writable: [branch.writable ?? false],
338
- source_catalog: [branch.sourceCatalog ?? null],
339
- source_schema: [branch.sourceSchema ?? null],
340
- source_table: [branch.sourceTable ?? null],
341
- },
342
- ScanBranchSchema as any,
343
- );
344
- return serializeBatch(branchBatch);
345
- });
427
+ const serializedBranches = branches.map((branch) =>
428
+ encodeScanBranch({
429
+ function_name: branch.functionName,
430
+ arguments: serializeBranchArguments(branch),
431
+ branch_filter: branch.branchFilter,
432
+ writable: branch.writable,
433
+ source_catalog: branch.sourceCatalog,
434
+ source_schema: branch.sourceSchema,
435
+ source_table: branch.sourceTable,
436
+ format_name: branch.formatName,
437
+ format_locations: branch.formatLocations,
438
+ format_options: branch.formatOptions,
439
+ }),
440
+ );
346
441
  return {
347
442
  branches: serializedBranches,
348
443
  required_extensions: requiredExtensions,
@@ -17,6 +17,7 @@ import {
17
17
  type IndexConstraintType,
18
18
  type CopyFromFormatInfo,
19
19
  encodeAttachCatalogInfo,
20
+ encodeScanFunctionResult,
20
21
  encodeFunctionInfo,
21
22
  } from "./interface.js";
22
23
  import type { CatalogDescriptor, SchemaDescriptor, TableDescriptor, ViewDescriptor, MacroDescriptor, SettingDescriptor, SecretTypeDescriptor, ForeignKeyDef, DefaultValue } from "./descriptors.js";
@@ -473,6 +474,22 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
473
474
  supports_batch_index:
474
475
  ((f.kind as string) === "table_buffering" && f.meta.requiresInputBatchIndex === true) ||
475
476
  ((f.kind as string) === "table" && f.meta.supportsBatchIndex === true),
477
+ // Split-based scan planning (protocol 1.4.0). A function opts in by
478
+ // declaring `supportsSplits` AND implementing plan()/onSplit(): the
479
+ // declaration is what a distributed engine reads to decide it can
480
+ // retry a task, because a split NAMES its work and re-running one
481
+ // reads exactly the same rows.
482
+ supports_splits: (f.kind as string) === "table" && f.meta.supportsSplits === true,
483
+ // The worker applies pushed filters EXACTLY, so the engine may drop
484
+ // its own copy. Wrong answers if declared falsely.
485
+ filters_exactly_applied:
486
+ (f.kind as string) === "table" && f.meta.filtersExactlyApplied === true,
487
+ // Addressable positions in the data, for incremental/streaming reads.
488
+ supports_positions: (f.kind as string) === "table" && f.meta.supportsPositions === true,
489
+ // null means UNBOUNDED, not "expires immediately" — a client must not
490
+ // assume a TTL exists, or long-running streams are foreclosed.
491
+ split_token_ttl_seconds:
492
+ (f.kind as string) === "table" ? (f.meta.splitTokenTtlSeconds ?? null) : null,
476
493
  partition_kind:
477
494
  (f.kind as string) === "table" && f.meta.partitionKind
478
495
  ? f.meta.partitionKind
@@ -993,22 +1010,5 @@ function inlineScanFunction(
993
1010
  ): Uint8Array {
994
1011
  const argSchema = argumentSpecsToSchema(func.argumentSpecs);
995
1012
  const argBytes = serializeArgsBatch(args ?? new Arguments(), argSchema);
996
- const scanSchema = schema_([
997
- field("function_name", utf8(), false),
998
- field("arguments", binary(), false),
999
- field(
1000
- "required_extensions",
1001
- list(field("item", utf8(), true)),
1002
- false,
1003
- ),
1004
- ]);
1005
- const batch = batchFromColumns(
1006
- {
1007
- function_name: [func.meta.name],
1008
- arguments: [argBytes],
1009
- required_extensions: [[] as string[]],
1010
- },
1011
- scanSchema,
1012
- );
1013
- return serializeBatch(batch);
1013
+ return encodeScanFunctionResult(func.meta.name, argBytes);
1014
1014
  }
@@ -70,7 +70,7 @@ import type {
70
70
  * Works with any RpcClient transport (subprocess or HTTP):
71
71
  * ```ts
72
72
  * import { subprocessConnect } from "@query-farm/vgi-rpc";
73
- * import { VgiClient, Arguments } from "vgi";
73
+ * import { VgiClient, Arguments } from "@query-farm/vgi";
74
74
  *
75
75
  * const rpc = subprocessConnect(["./my-worker"]);
76
76
  * const client = new VgiClient(rpc);
@@ -157,6 +157,10 @@ export class VgiClient {
157
157
  bind_call: bindRequest,
158
158
  output_schema: bindResponse.output_schema,
159
159
  bind_opaque_data: bindResponse.opaque_data,
160
+ // This client does not plan scans, so it never redeems a split, and it
161
+ // pushes no limit down.
162
+ split_tokens: null,
163
+ row_limit: null,
160
164
  projection_ids: opts?.projectionIds ?? null,
161
165
  pushdown_filters: opts?.pushdownFilters ?? null,
162
166
  join_keys: opts?.joinKeys ?? [],
@@ -1302,7 +1306,7 @@ export class VgiClient {
1302
1306
  transactionOpaqueData?: TransactionOpaqueData,
1303
1307
  ): Promise<void> {
1304
1308
  const schema = schema_([
1305
- field("attach_opaque_data", binary(), true),
1309
+ field("attach_opaque_data", binary(), false),
1306
1310
  field("schema_name", utf8(), false),
1307
1311
  field("name", utf8(), false),
1308
1312
  field("macro_type", utf8(), false),
@@ -20,6 +20,7 @@ import {
20
20
  type ArgumentSpec,
21
21
  type ArgumentConstraints,
22
22
  } from "../arguments/argument-spec.js";
23
+ import { assertArrowType, assertArrowTypes } from "../arguments/argument-spec.js";
23
24
  import type {
24
25
  FunctionMeta,
25
26
  VgiFunction,
@@ -333,6 +334,8 @@ export function deleteExecutionState(executionId: Uint8Array): void {
333
334
  export function defineAggregate<TArgs = Record<string, any>, TState = any>(
334
335
  config: AggregateFunctionConfig<TArgs, TState>,
335
336
  ): VgiFunction & { aggregateConfig: AggregateFunctionConfig<TArgs, TState> } {
337
+ assertArrowTypes((config as any).args, `defineAggregate("${config.name}"): args`);
338
+
336
339
  const specs: ArgumentSpec[] = [];
337
340
  let posIdx = 0;
338
341
  const varargsSet = new Set(config.varargs ?? []);
@@ -31,6 +31,7 @@ import type {
31
31
  import type { VgiFunction, FunctionMeta, StreamHandlers, FunctionExample } from "./types.js";
32
32
  import type { CopySecretLookup } from "./copy-to.js";
33
33
  import type { ArgumentSpec } from "../arguments/argument-spec.js";
34
+ import { narrowArgValue } from "../arguments/argument-spec.js";
34
35
  import { batchToScalarDict, batchToSecretDict, safeNumber } from "../util/arrow/index.js";
35
36
  import { BoundStorage, storage as globalStorage } from "./storage.js";
36
37
  import type { TableProcessParams } from "./table.js";
@@ -165,7 +166,7 @@ export function defineCopyFromFunction<TArgs = Record<string, unknown>>(
165
166
  }
166
167
  val = opt.default;
167
168
  }
168
- if (typeof val === "bigint") val = safeNumber(val);
169
+ val = narrowArgValue(val);
169
170
  if (opt.choices && !opt.choices.includes(val)) {
170
171
  throw new Error(
171
172
  `COPY (FORMAT ${config.format}): option '${name}' value ${JSON.stringify(val)} ` +
@@ -38,6 +38,7 @@
38
38
  import { type VgiSchema, schema, type VgiBatch, type VgiDataType } from "../arrow/index.js";
39
39
  import type { BindRequest } from "../protocol/types.js";
40
40
  import type { FunctionMeta, FunctionExample } from "./types.js";
41
+ import { narrowArgValue } from "../arguments/argument-spec.js";
41
42
  import { safeNumber } from "../util/arrow/index.js";
42
43
  import {
43
44
  defineTableBufferingFunction,
@@ -200,7 +201,7 @@ export function defineCopyToFunction<TArgs = Record<string, unknown>>(
200
201
  }
201
202
  val = opt.default;
202
203
  }
203
- if (typeof val === "bigint") val = safeNumber(val);
204
+ val = narrowArgValue(val);
204
205
  if (opt.choices && !opt.choices.includes(val)) {
205
206
  throw new Error(
206
207
  `COPY (FORMAT ${config.format}): option '${name}' value ${JSON.stringify(val)} ` +