Skip to content
Query.Farm
Talk with Us

Call a worker from TypeScript

VgiClient speaks the VGI protocol directly, so you can call a worker’s functions from TypeScript without an engine in the middle. That makes it the fastest way to test a worker — and it is the same client the HTTP landing page ships to browsers.

This is not how production calls a worker

In production DuckDB is the client: it attaches the worker and calls functions as part of a query plan, which is what you want, because the planner’s projection and filter pushdown only exist there. VgiClient is for tests, tooling, and browser front-ends that want the worker’s data without a query engine.

import { VgiClient, Arguments, subprocessConnect } from "@query-farm/vgi";

const rpc = subprocessConnect(["bun", "run", "./calc.ts"]);
const client = new VgiClient(rpc);

for await (const rows of client.tableFunctionRows({
functionName: "series",
arguments: new Arguments([5]),
})) {
console.log(rows);
}

client.close();
[ { n: 0 }, { n: 1 }, { n: 2 }, { n: 3 }, { n: 4 } ]

The method is an async generator yielding one array per batch, so a large scan streams rather than materializing. Arguments([5]) is positional; the second constructor parameter takes a Map for named arguments.

A scalar function has no arguments of its own — it has input columns, so you hand it batches:

import { VgiClient, subprocessConnect, batchFromColumns, toSchema, int } from "@query-farm/vgi";

const rpc = subprocessConnect(["bun", "run", "./calc.ts"]);
const client = new VgiClient(rpc);

const schema = toSchema({ n: int });
const input = [batchFromColumns({ n: [1n, 2n, 3n] }, schema)];

for await (const rows of client.scalarFunctionRows({ functionName: "double", input })) {
console.log(rows);
}

client.close();
[ { result: 2 }, { result: 4 }, { result: 6 } ]
The first batch determines the input schema

scalarFunctionRows peeks the first batch to learn what it is binding against, so an input iterator that yields nothing throws rather than silently doing nothing: “input iterator yielded no batches; at least one batch is required to determine the input schema”. An empty scan therefore needs one empty batch, not zero batches.

Note the output column is called result, not n — a scalar function’s output schema is its own, not a copy of its input’s.

subprocessConnect spawns the worker and speaks over its stdio. The others take a URL or an address:

Connect withFor
subprocessConnect(argv)A local worker, spawned for you. Best for tests.
httpConnect(url)A worker behind serveVgiWorker or on Cloudflare Workers.
tcpConnect(host, port)Raw Arrow IPC on a trusted network.

All three come from @query-farm/vgi-rpc and are re-exported for convenience. httpConnect is what the browser entry uses — import @query-farm/vgi/client there so no server-side code lands in the bundle.

The client also speaks the catalog side of the protocol, which is how the landing page renders a worker’s contents without knowing anything about it in advance:

const catalogs = await client.catalogs();                    // ["cat"]
const attach = await client.catalogAttach("cat");
const schemas = await client.schemas(attach.attach_opaque_data);
const tables = await client.schemaContentsTables(attach.attach_opaque_data, "data");

catalogAttach returns the opaque handle every later call needs — the worker uses it to tell one attachment from another, so it is not optional plumbing.

Because subprocessConnect needs nothing but the worker file, a test is a few lines and no engine:

import { test, expect } from "bun:test";
import { VgiClient, Arguments, subprocessConnect } from "@query-farm/vgi";

test("series generates 0..n-1", async () => {
const client = new VgiClient(subprocessConnect(["bun", "run", "./calc.ts"]));
const out: number[] = [];
for await (const rows of client.tableFunctionRows({
  functionName: "series",
  arguments: new Arguments([3]),
})) {
  out.push(...rows.map((r) => Number(r.n)));
}
client.close();
expect(out).toEqual([0, 1, 2]);
});
A passing client test is not a passing query

This exercises your function and the protocol, and it will not catch anything the engine does: constraint enforcement at bind, projection and filter pushdown, parallel scan behaviour, or the result cache. Keep at least one end-to-end check against a real engine — the SDK’s own documentation examples are verified that way for exactly this reason.