@query-farm/vgi 0.17.0 → 0.19.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 (40) hide show
  1. package/dist/arrow/impl-arrowjs/index.d.ts +1 -0
  2. package/dist/arrow/impl-arrowjs/metadata.d.ts +12 -0
  3. package/dist/arrow/impl-flechette/index.d.ts +1 -0
  4. package/dist/arrow/impl-flechette/metadata.d.ts +13 -0
  5. package/dist/arrow/index.d.ts +1 -1
  6. package/dist/catalog/descriptors.d.ts +13 -7
  7. package/dist/client-entry.js +16 -5
  8. package/dist/client-entry.js.map +9 -8
  9. package/dist/functions/scalar.d.ts +10 -0
  10. package/dist/functions/table-in-out.d.ts +121 -0
  11. package/dist/functions/types.d.ts +12 -0
  12. package/dist/generated/vgi-client.d.ts +3 -1
  13. package/dist/index.core.d.ts +1 -1
  14. package/dist/index.js +310 -21
  15. package/dist/index.js.map +16 -15
  16. package/dist/protocol/types.d.ts +12 -0
  17. package/dist/serve-entry.js +14 -4
  18. package/dist/serve-entry.js.map +8 -7
  19. package/dist/worker-cf-entry.js +188 -26
  20. package/dist/worker-cf-entry.js.map +14 -13
  21. package/dist/worker.d.ts +1 -1
  22. package/package.json +3 -3
  23. package/src/arrow/impl-arrowjs/index.ts +1 -0
  24. package/src/arrow/impl-arrowjs/metadata.ts +20 -0
  25. package/src/arrow/impl-flechette/index.ts +1 -0
  26. package/src/arrow/impl-flechette/metadata.ts +25 -0
  27. package/src/arrow/index.ts +2 -0
  28. package/src/catalog/descriptors.ts +13 -7
  29. package/src/catalog/read-only.ts +31 -14
  30. package/src/client/client.ts +1 -0
  31. package/src/functions/registry.ts +53 -5
  32. package/src/functions/scalar.ts +17 -1
  33. package/src/functions/table-in-out.ts +480 -4
  34. package/src/functions/types.ts +12 -0
  35. package/src/generated/vgi-client.ts +4 -2
  36. package/src/generated/vgi-protocol-schemas.ts +3 -2
  37. package/src/index.core.ts +11 -1
  38. package/src/protocol/serializers/init.ts +7 -0
  39. package/src/protocol/types.ts +12 -0
  40. package/src/worker.ts +1 -1
package/dist/worker.d.ts CHANGED
@@ -32,5 +32,5 @@ export declare class Worker {
32
32
  * @param writable outgoing response sink (a `net.Socket`/`Duplex`, or an fd
33
33
  * number). Omit for the stdout fd.
34
34
  */
35
- serveStream(readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream, writable?: number | import("node:net").Socket): Promise<void>;
35
+ serveStream(readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream, writable?: number | import("node:net").Socket | import("@query-farm/vgi-rpc").ByteSink): Promise<void>;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@query-farm/vgi",
3
- "version": "0.17.0",
3
+ "version": "0.19.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)",
@@ -86,12 +86,12 @@
86
86
  },
87
87
  "peerDependencies": {
88
88
  "@query-farm/apache-arrow": "^21.1.1",
89
- "@query-farm/vgi-rpc": "^0.13.0"
89
+ "@query-farm/vgi-rpc": "^0.14.0"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@duckdb/node-api": "^1.5.2-r.1",
93
93
  "@query-farm/apache-arrow": "^21.1.1",
94
- "@query-farm/vgi-rpc": "^0.13.0",
94
+ "@query-farm/vgi-rpc": "^0.14.0",
95
95
  "@types/bun": "latest",
96
96
  "typescript": "^6.0.3"
97
97
  },
@@ -41,6 +41,7 @@ export {
41
41
  export { filterBatch } from "./filter.js";
42
42
  export { projectSchema, projectBatch } from "./project.js";
43
43
  export { readCanonicalValue } from "./canonical.js";
44
+ export { withBatchMetadata } from "./metadata.js";
44
45
 
45
46
  // IPC
46
47
  export {
@@ -0,0 +1,20 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Per-record-batch metadata attachment (arrow-js backend).
3
+
4
+ import { RecordBatch } from "@query-farm/apache-arrow";
5
+ import type { VgiBatch } from "../types.js";
6
+
7
+ /**
8
+ * Re-emit a batch with a different per-record-batch metadata map (same schema
9
+ * + data, no copy). The @query-farm/apache-arrow fork's RecordBatch carries
10
+ * the map through IPC as the Message's `custom_metadata` field.
11
+ *
12
+ * Mirrors vgi-rpc's arrow facade helper of the same name — needed here so a
13
+ * worker can bake metadata into the batch object itself (vgi-rpc's HTTP
14
+ * exchange dispatch drops the separate emit-metadata argument for 0-row
15
+ * batches; a batch-carried map survives).
16
+ */
17
+ export function withBatchMetadata(batch: VgiBatch, metadata: Map<string, string>): VgiBatch {
18
+ const a = batch as unknown as RecordBatch;
19
+ return new RecordBatch(a.schema, a.data, metadata) as unknown as VgiBatch;
20
+ }
@@ -41,6 +41,7 @@ export {
41
41
  export { filterBatch } from "./filter.js";
42
42
  export { projectSchema, projectBatch } from "./project.js";
43
43
  export { readCanonicalValue } from "./canonical.js";
44
+ export { withBatchMetadata } from "./metadata.js";
44
45
 
45
46
  // IPC
46
47
  export {
@@ -0,0 +1,25 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Per-record-batch metadata attachment (flechette backend).
3
+
4
+ import type { VgiBatch } from "../types.js";
5
+
6
+ /**
7
+ * Re-emit a batch with a different per-record-batch metadata map (same schema
8
+ * + data). Shallow-clones the flechette Table so the caller's reference is
9
+ * not mutated, then pins the map so that:
10
+ * - `batch.metadata` surfaces it to consumer code (matching arrow-js's
11
+ * RecordBatch.metadata getter behavior), and
12
+ * - the patched flechette encoder picks it up via `_vgiRecordMetadata` and
13
+ * emits it as the IPC Message's `custom_metadata` field.
14
+ *
15
+ * Mirrors vgi-rpc's arrow facade helper of the same name.
16
+ */
17
+ export function withBatchMetadata(batch: VgiBatch, metadata: Map<string, string>): VgiBatch {
18
+ const t = batch as any;
19
+ const clone = Object.assign(Object.create(Object.getPrototypeOf(t)), t);
20
+ if (metadata && metadata.size > 0) {
21
+ clone._vgiRecordMetadata = metadata;
22
+ clone.metadata = metadata;
23
+ }
24
+ return clone as unknown as VgiBatch;
25
+ }
@@ -53,6 +53,8 @@ export {
53
53
  emptyBatch, batchFromRows, batchFromColumns, columnFromArray,
54
54
  iterRows, batchToScalarDict, batchToSecretDict, safeNumber, decodeDictValue,
55
55
  filterBatch, projectSchema, projectBatch,
56
+ // per-record-batch metadata attachment
57
+ withBatchMetadata,
56
58
  // canonical single-value read (backend-agnostic, lossless)
57
59
  readCanonicalValue,
58
60
  // IPC
@@ -60,18 +60,24 @@ export interface TableDescriptor {
60
60
  */
61
61
  statisticsCacheMaxAgeSeconds?: number | null;
62
62
  /**
63
- * Dotted-path column references that MUST appear in a WHERE expression for
64
- * any scan of this table. Top-level names (`"country"`) or struct subfields
65
- * (`"bbox.xmin"`, `"nested.outer.inner"`). Empty/undefined (default) means
66
- * no enforcement — the zero-cost fast path for every existing table.
63
+ * Required WHERE-filter groups in conjunctive normal form — an AND (outer
64
+ * list) of OR-groups (inner lists) of dotted-path column references that MUST
65
+ * appear in a WHERE expression for any scan of this table. A group is
66
+ * satisfied when any one of its paths has a filter; every group must be
67
+ * satisfied. So `[["accession_number"], ["ticker", "cik"]]` means
68
+ * "accession_number AND one of (ticker, cik)"; a single-path group
69
+ * `[["country"]]` is a plain mandatory filter. Paths are top-level names
70
+ * (`"country"`) or struct subfields (`"bbox.xmin"`, `"nested.outer.inner"`).
71
+ * Empty/undefined (default) means no enforcement — the zero-cost fast path
72
+ * for every existing table.
67
73
  *
68
74
  * Satisfaction is prefix-based: a present filter on a shorter path satisfies
69
75
  * any required path it is a prefix of. So a whole-struct filter on `bbox`
70
76
  * satisfies all of `bbox.xmin` / `.xmax` / `.ymin` / `.ymax`. The VGI DuckDB
71
- * extension's optimizer pass consults this list at bind time and throws a
72
- * `BinderException` listing any unsatisfied paths.
77
+ * extension's optimizer pass consults this at bind time and throws a
78
+ * `BinderException` listing any unsatisfied groups.
73
79
  */
74
- requiredFieldFilterPaths?: string[];
80
+ requiredFilters?: string[][];
75
81
  supportsTimeTravel?: boolean;
76
82
  /**
77
83
  * Inline cardinality estimate/max surfaced through TableInfo. When set,
@@ -235,26 +235,34 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
235
235
  const pkIndices = resolveIndexGroups(colSchema, t.primaryKey);
236
236
  const fkBytes = serializeForeignKeys(t.foreignKey, name);
237
237
 
238
- // Validate required_field_filter_paths: the leading dotted segment of
239
- // each path must be a real column on this table. Struct subfield
238
+ // Validate required_filters: an AND (outer list) of OR-groups (inner
239
+ // lists). Each OR-group must be non-empty, and the leading dotted segment
240
+ // of each path must be a real column on this table. Struct subfield
240
241
  // validity is not checked here — DuckDB's binder catches typos at scan
241
242
  // time, and the descriptor doesn't unpack STRUCT subfields. (Mirrors
242
243
  // vgi-python's Table descriptor validation.)
243
- const requiredFieldFilterPaths = t.requiredFieldFilterPaths ?? [];
244
- if (requiredFieldFilterPaths.length > 0) {
244
+ const requiredFilters = t.requiredFilters ?? [];
245
+ if (requiredFilters.length > 0) {
245
246
  const columnNames = new Set(colSchema.fields.map((f) => f.name));
246
- for (const path of requiredFieldFilterPaths) {
247
- if (!path) {
247
+ for (const group of requiredFilters) {
248
+ if (!group || group.length === 0) {
248
249
  throw new Error(
249
- `Table '${t.name}': requiredFieldFilterPaths must not contain empty strings`
250
+ `Table '${t.name}': requiredFilters must not contain empty groups`
250
251
  );
251
252
  }
252
- const head = path.split(".", 1)[0];
253
- if (!columnNames.has(head)) {
254
- throw new Error(
255
- `Table '${t.name}': requiredFieldFilterPaths path '${path}' references ` +
256
- `unknown column '${head}'. Available columns: ${[...columnNames].sort().join(", ")}`
257
- );
253
+ for (const path of group) {
254
+ if (!path) {
255
+ throw new Error(
256
+ `Table '${t.name}': requiredFilters must not contain empty strings`
257
+ );
258
+ }
259
+ const head = path.split(".", 1)[0];
260
+ if (!columnNames.has(head)) {
261
+ throw new Error(
262
+ `Table '${t.name}': requiredFilters path '${path}' references ` +
263
+ `unknown column '${head}'. Available columns: ${[...columnNames].sort().join(", ")}`
264
+ );
265
+ }
258
266
  }
259
267
  }
260
268
  }
@@ -292,7 +300,7 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
292
300
  cardinality_max: t.inlinedCardinality
293
301
  ? Number(t.inlinedCardinality.max)
294
302
  : null,
295
- required_field_filter_paths: requiredFieldFilterPaths,
303
+ required_filters: requiredFilters,
296
304
  };
297
305
  }));
298
306
  }
@@ -437,6 +445,15 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
437
445
  (f.kind as string) === "table_buffering" && f.meta.sinkOrderDependent === true,
438
446
  requires_input_batch_index:
439
447
  (f.kind as string) === "table_buffering" && f.meta.requiresInputBatchIndex === true,
448
+ // Blended ("UNNEST-style") table-in-out: positional args ARE the
449
+ // per-row input columns (real typed args, no TABLE placeholder), so
450
+ // one registration serves literal / column / LATERAL call shapes.
451
+ // Set by defineRowTransformFunction; the C++ extension reads it to
452
+ // enter the in-out registration branch with real-typed args and
453
+ // drive the literal single-row scan-mode. False for scalars —
454
+ // mirrors Python's `input_from_args=False if is_scalar else ...`.
455
+ input_from_args:
456
+ f.kind === "table_in_out" && f.meta.inputFromArgs === true,
440
457
  required_settings: meta.requiredSettings,
441
458
  required_secrets: requiredSecrets.map((s) => ({
442
459
  secret_type: s.secret_type,
@@ -170,6 +170,7 @@ export class VgiClient {
170
170
  tablesample_seed: ts?.seed == null ? null : BigInt(ts.seed),
171
171
  execution_id: opts?.executionId ?? null,
172
172
  init_opaque_data: null,
173
+ substream_id: null,
173
174
  };
174
175
 
175
176
  const initBatch = serializeInitRequest(initRequest);
@@ -123,16 +123,40 @@ function filterByArgumentTypes(
123
123
  matched = r2.matched;
124
124
  }
125
125
  } else {
126
- // Table functions: compare positional arg types
126
+ // Table functions: compare declared positional specs against the
127
+ // argument types.
127
128
  const posSpecs = specs
128
129
  .filter(s => typeof s.position === "number" && !s.isTableInput)
129
130
  .sort((a, b) => (a.position as number) - (b.position as number));
130
131
 
131
132
  const posTypes: (VgiDataType | null)[] = [];
132
- const argsSchema = args.argumentsSchema;
133
- for (let i = 0; i < args.positional.length; i++) {
134
- const field = argsSchema?.fields.find(f => f.name === `positional_${i}`);
135
- posTypes.push(field ? field.type : null);
133
+ if (func.meta.inputFromArgs) {
134
+ // Blended ("UNNEST-style"): the positional args ARE the per-row input
135
+ // columns, so they are NOT on the wire (args.positional is empty).
136
+ // Score the declared positional specs against the INPUT_SCHEMA column
137
+ // types instead (coercibly, via scoreTypes), so same-arity overloads
138
+ // disambiguate by type — matching what DuckDB's binder resolved.
139
+ // Expand a trailing varargs spec across the remaining input columns
140
+ // (mirrors the scalar column path).
141
+ if (inputSchema) {
142
+ let varArgsSpec: ArgumentSpec | null = null;
143
+ for (const spec of posSpecs) {
144
+ if (spec.isVarargs) varArgsSpec = spec;
145
+ const pos = spec.position as number;
146
+ posTypes.push(pos < inputSchema.fields.length ? inputSchema.fields[pos].type : null);
147
+ }
148
+ if (varArgsSpec) {
149
+ for (let i = (varArgsSpec.position as number) + 1; i < inputSchema.fields.length; i++) {
150
+ posTypes.push(inputSchema.fields[i].type);
151
+ }
152
+ }
153
+ }
154
+ } else {
155
+ const argsSchema = args.argumentsSchema;
156
+ for (let i = 0; i < args.positional.length; i++) {
157
+ const field = argsSchema?.fields.find(f => f.name === `positional_${i}`);
158
+ posTypes.push(field ? field.type : null);
159
+ }
136
160
  }
137
161
  const r = scoreTypes(posSpecs, posTypes);
138
162
  score += r.score;
@@ -207,6 +231,30 @@ export class FunctionRegistry {
207
231
  );
208
232
  const hasVarargs = posSpecs.some(s => s.isVarargs);
209
233
  const nonVarargs = posSpecs.filter(s => !s.isVarargs).length;
234
+ if (func.meta.inputFromArgs) {
235
+ // Blended ("UNNEST-style") overload: the positional params ARE the
236
+ // per-row input columns, so they are NOT on the wire
237
+ // (args.positional is empty). Resolve by INPUT-COLUMN count (arity)
238
+ // against the declared positional params instead — e.g.
239
+ // geo_encode(52,13) -> 2 input cols -> the 2-positional overload.
240
+ // Named (str-position) args still come from the wire arguments.
241
+ const numInputCols = context.inputSchema?.fields.length ?? 0;
242
+ if (hasVarargs) {
243
+ if (numInputCols < nonVarargs) return false;
244
+ } else if (numInputCols !== posSpecs.length) {
245
+ return false;
246
+ }
247
+ // Unknown named argument disqualifies the overload.
248
+ const validNamed = new Set(
249
+ func.argumentSpecs
250
+ .filter(s => typeof s.position === "string")
251
+ .map(s => s.position as string),
252
+ );
253
+ for (const key of args.named.keys()) {
254
+ if (!validNamed.has(key)) return false;
255
+ }
256
+ return true;
257
+ }
210
258
  if (hasVarargs) {
211
259
  return numArgs >= nonVarargs;
212
260
  }
@@ -44,6 +44,7 @@ import {
44
44
  type ArgumentConstraints,
45
45
  } from "../arguments/argument-spec.js";
46
46
  import { batchToScalarDict, batchToSecretDict, batchFromColumns, projectBatch, safeNumber } from "../util/arrow/index.js";
47
+ import { cacheControlMetadata, type CacheControl } from "../cache-control.js";
47
48
 
48
49
  // ============================================================================
49
50
  // Scalar Bind Parameters
@@ -181,6 +182,15 @@ export interface ScalarFunctionConfig<
181
182
  maxWorkers?: number;
182
183
  requiredSettings?: string[];
183
184
  requiredSecrets?: string[];
185
+ /**
186
+ * Result-cache opt-in: when set, this CacheControl's `vgi.cache.*` metadata
187
+ * rides every output batch's custom metadata (per-batch — NOT the schema,
188
+ * which the IPC stream fixes at open), so the extension can memoize the
189
+ * scalar's output per distinct input value. A pure, deterministic scalar
190
+ * only — advertising this on a non-pure scalar serves stale rows. Mirrors
191
+ * vgi-python's `ScalarFunction.CACHE_CONTROL`.
192
+ */
193
+ cacheControl?: CacheControl;
184
194
  }
185
195
 
186
196
  export function defineScalarFunction<
@@ -394,7 +404,13 @@ export function defineScalarFunction<
394
404
  );
395
405
  }
396
406
 
397
- out.emit(outputBatch);
407
+ // Result-cache opt-in: a scalar declaring cacheControl rides its
408
+ // vgi.cache.* keys on the emit path's per-batch custom metadata so
409
+ // the extension can memoize the output per distinct input value.
410
+ out.emit(
411
+ outputBatch,
412
+ config.cacheControl ? cacheControlMetadata(config.cacheControl) : undefined,
413
+ );
398
414
  },
399
415
  };
400
416
  },