@query-farm/vgi 0.30.0 → 0.32.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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog/item-encoding.d.ts +17 -0
  3. package/dist/catalog/read-only.d.ts +16 -0
  4. package/dist/client-entry.js +19 -1
  5. package/dist/client-entry.js.map +3 -3
  6. package/dist/filter-pushdown/evaluate.d.ts +9 -0
  7. package/dist/filter-pushdown/history.d.ts +66 -0
  8. package/dist/filter-pushdown/index.d.ts +1 -0
  9. package/dist/functions/storage-cf-do.d.ts +2 -2
  10. package/dist/functions/storage.d.ts +39 -3
  11. package/dist/functions/table-in-out.d.ts +13 -8
  12. package/dist/functions/types.d.ts +14 -1
  13. package/dist/http/fetch.d.ts +1 -1
  14. package/dist/index.core.d.ts +1 -1
  15. package/dist/index.js +1280 -924
  16. package/dist/index.js.map +21 -17
  17. package/dist/protocol/carried-init-request.d.ts +28 -0
  18. package/dist/protocol/dispatch.d.ts +26 -7
  19. package/dist/protocol/handlers/function.d.ts +0 -1
  20. package/dist/protocol/handlers/shared.d.ts +0 -7
  21. package/dist/protocol/state-serializer.d.ts +29 -2
  22. package/dist/serve-entry.js +395 -188
  23. package/dist/serve-entry.js.map +16 -12
  24. package/dist/util/zstd-sync.d.ts +7 -0
  25. package/dist/worker-cf-entry.js +5047 -4710
  26. package/dist/worker-cf-entry.js.map +25 -21
  27. package/package.json +5 -4
  28. package/src/catalog/item-encoding.ts +59 -0
  29. package/src/catalog/read-only.ts +87 -19
  30. package/src/client/errors.ts +19 -0
  31. package/src/filter-pushdown/evaluate.ts +55 -8
  32. package/src/filter-pushdown/history.ts +239 -0
  33. package/src/filter-pushdown/index.ts +8 -0
  34. package/src/functions/storage-cf-do.ts +7 -12
  35. package/src/functions/storage.ts +54 -11
  36. package/src/functions/table-buffering.ts +0 -1
  37. package/src/functions/table-in-out.ts +99 -60
  38. package/src/functions/table.ts +41 -38
  39. package/src/functions/types.ts +14 -1
  40. package/src/http/fetch.ts +1 -9
  41. package/src/index.core.ts +1 -0
  42. package/src/protocol/carried-init-request.ts +139 -0
  43. package/src/protocol/dispatch.ts +28 -9
  44. package/src/protocol/handlers/catalog/admin.ts +5 -2
  45. package/src/protocol/handlers/function.ts +18 -15
  46. package/src/protocol/handlers/shared.ts +0 -22
  47. package/src/protocol/state-serializer.ts +109 -37
  48. package/src/util/zstd-sync.ts +50 -0
@@ -0,0 +1,139 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // The init request an HTTP stream carries in its cursor: packed once, parsed
3
+ // once per process.
4
+ //
5
+ // An HTTP stream has no memory between turns, so its cursor carries the whole
6
+ // init request (~9 KB of Arrow IPC for a DuckDB scan: the bind call with its
7
+ // arguments, settings and sealed attach, the output schema, the filter
8
+ // snapshot) and every turn used to re-parse it: the IPC read, the scalar-dict
9
+ // walk and the nested bind-call / schema / filter reads cost ~0.9 ms, the
10
+ // largest VGI-side share of the ~4 ms a 1000-row turn took. The cursor is also
11
+ // re-sealed and base64-encoded on every turn, so its size is paid twice per
12
+ // turn in AEAD and base64 work, and twice again on the wire.
13
+ //
14
+ // - `packInitRequest` zstd-compresses the request once, at /init (~9 KB ->
15
+ // ~2.6 KB), behind a one-byte codec tag. A runtime without synchronous zstd
16
+ // stores it raw.
17
+ // - `parseCarriedInitRequest` keeps a small per-process LRU from the packed
18
+ // bytes to their parse. It is a pure memo of a pure function: the key is the
19
+ // exact bytes (hash, then a full comparison), so a hit returns precisely
20
+ // what parsing those bytes would -- and those bytes came out of a cursor the
21
+ // worker sealed. Nothing principal-dependent is cached: the split tokens and
22
+ // the sealed attach are still opened, under the caller, on every turn. A
23
+ // miss (cold process, eviction, another replica's stream) parses as before,
24
+ // so correctness never depends on a hit.
25
+
26
+ import { deserializeBatch } from "../arrow/index.js";
27
+ import { batchToScalarDict } from "../util/arrow/index.js";
28
+ import { zstdSync } from "../util/zstd-sync.js";
29
+ import { deserializeInitRequest } from "./serialize.js";
30
+ import type { InitRequest } from "./types.js";
31
+
32
+ const RAW = 0;
33
+ const ZSTD = 1;
34
+
35
+ /** Pack an init request's IPC bytes for the cursor: `u8 codec | payload`. */
36
+ export function packInitRequest(ipc: Uint8Array): Uint8Array {
37
+ const codec = zstdSync();
38
+ const compressed = codec ? codec.compress(ipc) : null;
39
+ const useZstd = compressed !== null && compressed.byteLength < ipc.byteLength;
40
+ const payload = useZstd ? compressed! : ipc;
41
+ const packed = new Uint8Array(payload.byteLength + 1);
42
+ packed[0] = useZstd ? ZSTD : RAW;
43
+ packed.set(payload, 1);
44
+ return packed;
45
+ }
46
+
47
+ function unpackInitRequest(packed: Uint8Array): Uint8Array {
48
+ if (packed.byteLength === 0) throw new Error("state token carries no init request");
49
+ switch (packed[0]) {
50
+ case RAW:
51
+ return packed.slice(1);
52
+ case ZSTD: {
53
+ const codec = zstdSync();
54
+ if (!codec) {
55
+ throw new Error(
56
+ "state token carries a zstd-compressed init request, and this runtime has no synchronous zstd " +
57
+ "(it was minted by a worker running under Bun or Node >= 22.15 with the same signing key)",
58
+ );
59
+ }
60
+ return codec.decompress(packed.subarray(1));
61
+ }
62
+ default:
63
+ throw new Error(`state token carries an init request with unknown codec ${packed[0]}`);
64
+ }
65
+ }
66
+
67
+ /** What a continuation needs from the carried init request. */
68
+ export interface CarriedInitRequest {
69
+ /**
70
+ * The parsed request -- a fresh top-level object with a fresh `bind_call`
71
+ * each call, so the per-turn assignments the dispatcher makes (split
72
+ * payloads) never reach the shared parse. The nested values (Arrow
73
+ * schemas and batches, arguments) are shared and must be treated as
74
+ * read-only, as they always were.
75
+ */
76
+ request: InitRequest;
77
+ /** The raw bind-call IPC bytes, which the split-token fingerprint hashes. */
78
+ bindCallIpc: Uint8Array;
79
+ }
80
+
81
+ interface Parsed {
82
+ packed: Uint8Array;
83
+ request: InitRequest;
84
+ bindCallIpc: Uint8Array;
85
+ }
86
+
87
+ /** Streams whose parse a process keeps. Past this, the least recently used is dropped. */
88
+ const MAX_ENTRIES = 128;
89
+ const parsed = new Map<number, Parsed>();
90
+ const stats = { parses: 0, hits: 0 };
91
+
92
+ /** FNV-1a over the bytes, folded with the length. */
93
+ function hashBytes(bytes: Uint8Array): number {
94
+ let h = 0x811c9dc5 ^ bytes.byteLength;
95
+ for (let i = 0; i < bytes.byteLength; i++) {
96
+ h ^= bytes[i];
97
+ h = Math.imul(h, 0x01000193);
98
+ }
99
+ return h >>> 0;
100
+ }
101
+
102
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
103
+ if (a.byteLength !== b.byteLength) return false;
104
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Parse the init request a cursor carries (as packed by
110
+ * {@link packInitRequest}), from the process memo when this stream's turns have
111
+ * been here before.
112
+ */
113
+ export function parseCarriedInitRequest(packed: Uint8Array): CarriedInitRequest {
114
+ const key = hashBytes(packed);
115
+ let entry = parsed.get(key);
116
+ if (entry && sameBytes(entry.packed, packed)) {
117
+ // Refresh its recency.
118
+ parsed.delete(key);
119
+ parsed.set(key, entry);
120
+ stats.hits++;
121
+ } else {
122
+ const dict = batchToScalarDict(deserializeBatch(unpackInitRequest(packed)));
123
+ stats.parses++;
124
+ entry = { packed: packed.slice(), request: deserializeInitRequest(dict), bindCallIpc: dict.bind_call };
125
+ // A hash collision simply replaces the other stream's entry.
126
+ parsed.delete(key);
127
+ if (parsed.size >= MAX_ENTRIES) parsed.delete(parsed.keys().next().value!);
128
+ parsed.set(key, entry);
129
+ }
130
+ return {
131
+ request: { ...entry.request, bind_call: { ...entry.request.bind_call } },
132
+ bindCallIpc: entry.bindCallIpc,
133
+ };
134
+ }
135
+
136
+ /** Cumulative parse / memo-hit counts for this process (observability and tests). */
137
+ export function carriedInitRequestStats(): { parses: number; hits: number; entries: number } {
138
+ return { ...stats, entries: parsed.size };
139
+ }
@@ -20,13 +20,6 @@ export interface ProtocolConfig {
20
20
  registry: FunctionRegistry;
21
21
  catalogInterface?: CatalogInterface;
22
22
  catalogName?: string;
23
- /**
24
- * Recover accumulated exchange state from FINALIZE init_opaque_data.
25
- * For HTTP transport, this unpacks the state token that the C++ extension
26
- * passes from the last INPUT exchange to the FINALIZE init request.
27
- * Returns the deserialized VGI dispatch state object (with userState field).
28
- */
29
- recoverExchangeState?: (opaqueData: Uint8Array) => any;
30
23
  /**
31
24
  * AEAD signing key for sealing catalog opaque-data envelopes. Pass the same
32
25
  * 32-byte key used for HTTP state tokens. When omitted (subprocess / unix
@@ -36,12 +29,38 @@ export interface ProtocolConfig {
36
29
  signingKey?: Uint8Array;
37
30
  }
38
31
 
32
+ /**
33
+ * Wire name of the VGI protocol: the `vgi_rpc.protocol` routing key and the
34
+ * `{protocol}` HTTP path segment.
35
+ *
36
+ * The name carries the major version, so an incompatible major is a
37
+ * *different* protocol and therefore a 404 — an answer every proxy, WAF and
38
+ * load balancer understands without an Arrow parser, and one that lets
39
+ * `vgi.v2` and a future `vgi.v3` be served side by side while clients migrate.
40
+ * That matters for this consumer specifically: the DuckDB extension ships to
41
+ * users and cannot be flag-dayed.
42
+ *
43
+ * Declared rather than derived. Until the transports made the routing key
44
+ * required, each implementation's wire name defaulted to whatever its local
45
+ * type was called, which left the six ports disagreeing four ways — Python
46
+ * `VgiProtocol`, Java and C# `VgiService`, Go the framework default `Service`,
47
+ * this port `vgi` — so no client could address them all. The canonical name is
48
+ * decided in vgi-python and emitted by the DuckDB extension; this is that same
49
+ * string, not an independent choice.
50
+ *
51
+ * Since `@query-farm/vgi-rpc` 0.24.0 the name is also bound into the AEAD
52
+ * associated data of every state and call token (`TokenScope.protocol`), so a
53
+ * caller that opens a token minted by this protocol has to name it. Exported
54
+ * so the HTTP entry points build that scope from the same constant the
55
+ * protocol is registered under rather than a second copy of the literal.
56
+ */
57
+ export const VGI_PROTOCOL_NAME = "vgi.v2";
58
+
39
59
  export function buildVgiProtocol(config: ProtocolConfig): Protocol {
40
- const protocol = new Protocol("vgi", { protocolVersion: "2.0.0" });
60
+ const protocol = new Protocol(VGI_PROTOCOL_NAME, { protocolVersion: "2.0.0" });
41
61
 
42
62
  registerFunctionMethods(protocol, {
43
63
  registry: config.registry,
44
- recoverExchangeState: config.recoverExchangeState,
45
64
  signingKey: config.signingKey,
46
65
  catalogInterface: config.catalogInterface,
47
66
  });
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary, utf8, bool } from "../../../arrow/index.js";
6
6
  import { Protocol, type CallContext } from "@query-farm/vgi-rpc";
7
- import { encodeSchemaInfo, encodeTableInfo, encodeViewInfo, encodeFunctionInfo, encodeCatalogInfo } from "../../../generated/vgi-client.js";
7
+ import { encodeSchemaInfo, encodeTableInfo, encodeViewInfo, encodeCatalogInfo } from "../../../generated/vgi-client.js";
8
+ import { encodeFunctionInfoOnce } from "../../../catalog/item-encoding.js";
8
9
  import { encodeCopyFromFormatInfo } from "../../../catalog/interface.js";
9
10
  import {
10
11
  CatalogAttachParamsSchema,
@@ -360,8 +361,10 @@ export function registerCatalogAdminMethods(protocol: Protocol, getCatalog: GetC
360
361
  decodeDictValue(params.type),
361
362
  params.transaction_opaque_data ? toUint8Array(params.transaction_opaque_data) : undefined
362
363
  );
364
+ // A frozen item (a descriptor catalog's cached listing) is encoded once;
365
+ // anything else is encoded per call, as before (see item-encoding.ts).
363
366
  return wrapResult({
364
- items: funcs.map((f) => encodeFunctionInfo(f)),
367
+ items: funcs.map((f) => encodeFunctionInfoOnce(f)),
365
368
  }, CatalogSchemaContentsFunctionsResultSchema);
366
369
  },
367
370
  });
@@ -37,9 +37,9 @@ import {
37
37
  unwrapRequest,
38
38
  wrapResult,
39
39
  overloadContext,
40
- recoverFinalizeState,
41
40
  } from "./shared.js";
42
41
  import { GLOBAL_INIT_RESPONSE_SCHEMA } from "../serializers/init.js";
42
+ import { parseCarriedInitRequest } from "../carried-init-request.js";
43
43
  import { openAttach } from "./catalog/shared.js";
44
44
  import { currentRequestAuth } from "../../request-auth.js";
45
45
  import { batchFromColumns, serializeBatch } from "../../util/arrow/index.js";
@@ -61,7 +61,6 @@ import {
61
61
 
62
62
  export interface FunctionHandlerConfig {
63
63
  registry: FunctionRegistry;
64
- recoverExchangeState?: (opaqueData: Uint8Array) => any;
65
64
  signingKey?: Uint8Array;
66
65
  /**
67
66
  * Used to map a bind's `attach_opaque_data` to its catalog, so resolution can
@@ -314,11 +313,11 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
314
313
  // FunctionStorage (e.g. Cloudflare DO).
315
314
  const initResponse = await func.globalInit(request);
316
315
 
317
- // For FINALIZE over HTTP, recover accumulated INPUT state from init_opaque_data.
318
- // The C++ extension passes the last exchange state token as init_opaque_data.
319
- const accumulatedState = recoverFinalizeState(request, config.recoverExchangeState);
320
-
321
- const handlers = func.createStreamHandlers(request, initResponse, accumulatedState);
316
+ // A FINALIZE stream reads its accumulated states from storage, where
317
+ // every INPUT turn persisted them. Its `init_opaque_data` is the primary
318
+ // init's opaque data echoed back (vgi-python's InitRequest), never a
319
+ // cursor, so nothing here opens it.
320
+ const handlers = func.createStreamHandlers(request, initResponse);
322
321
 
323
322
  // Initialize the appropriate handler
324
323
  let handlerState: HandlerState | undefined;
@@ -384,9 +383,10 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
384
383
  // Deserialized from token — reconstruct from serializable refs.
385
384
  // Infrastructure (processParams, BoundStorage) is recreated fresh.
386
385
  // Mutable user state is merged from state.userState.
387
- const initRequestBatch = deserializeBatch(state.initRequestIpc);
388
- const initRequestDict = batchToScalarDict(initRequestBatch);
389
- const request = deserializeInitRequest(initRequestDict);
386
+ // Parsed once per process per stream (carried-init-request.ts): the
387
+ // cursor carries the packed request unchanged on every turn.
388
+ const carried = parseCarriedInitRequest(state.initRequestPacked);
389
+ const request = carried.request;
390
390
  // Re-derive the split payloads. The cursor carries the wire request,
391
391
  // which has `split_tokens` but not the opened `split_payloads` — that
392
392
  // field only ever existed in memory on the init turn. Without this a
@@ -399,7 +399,7 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
399
399
  // pre-deserialization blob from the cursor — handing it the decoded
400
400
  // object hashes a different shape and every token fails as
401
401
  // SPLIT_TOKEN_INVALID "minted for a different bind".
402
- await resolveSplitPayloads(request, { bind_call: initRequestDict.bind_call }, {
402
+ await resolveSplitPayloads(request, { bind_call: carried.bindCallIpc }, {
403
403
  // Same principal the tokens were sealed under. The exchange dispatch
404
404
  // gets no ctx (see stripAttach above), so the request-scoped identity
405
405
  // is the only source — opening under the anonymous tail would fail
@@ -418,10 +418,10 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
418
418
  opaque_data: opaqueData,
419
419
  };
420
420
 
421
- // Recover accumulated state for FINALIZE phase from initOpaqueData
422
- const recoveredState = recoverFinalizeState(request, config.recoverExchangeState);
423
-
424
- handlers = func.createStreamHandlers(request, initResponse, recoveredState);
421
+ // The cursor's compacted dynamic-filter history: without it every turn
422
+ // after the one that received a delta would scan under the init
423
+ // snapshot again, because the extension sends each delta only once.
424
+ handlers = func.createStreamHandlers(request, initResponse, state.filterHistory ?? null);
425
425
  if (handlers.producerInit) {
426
426
  handlerState = handlers.producerInit();
427
427
  } else if (handlers.exchangeInit) {
@@ -453,6 +453,9 @@ export function registerFunctionMethods(protocol: Protocol, config: FunctionHand
453
453
  if (handlerState?.state !== undefined) {
454
454
  state.userState = handlerState!.state;
455
455
  }
456
+ // And the dynamic-filter history this turn left (compacted: bounded by
457
+ // the number of predicate ids, not the number of turns).
458
+ state.filterHistory = handlerState?.filterHistory ?? null;
456
459
  },
457
460
  headerSchema: initHeaderSchema,
458
461
  headerInit: (params: any, state: any, ctx: any) => {
@@ -5,8 +5,6 @@ import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary
5
5
  import type { OverloadContext } from "../../functions/registry.js";
6
6
  import { batchToScalarDict, deserializeBatch, serializeBatch, batchFromColumns } from "../../util/arrow/index.js";
7
7
  import { toUint8Array } from "../../util/bytes.js";
8
- import { TableInOutPhase } from "../../types.js";
9
- import type { InitRequest } from "../types.js";
10
8
  import { normalizeSchemaPath } from "../../schema-path.js";
11
9
 
12
10
  // The Python vgi-rpc framework wraps ALL non-void unary results in a single
@@ -91,23 +89,3 @@ export function overloadContext(
91
89
  catalogName,
92
90
  };
93
91
  }
94
-
95
- /**
96
- * Recover accumulated exchange state from a FINALIZE init request.
97
- * For HTTP transport, this unpacks the state token that the C++ extension
98
- * passes from the last INPUT exchange to the FINALIZE init request.
99
- */
100
- export function recoverFinalizeState(
101
- request: InitRequest,
102
- recoverExchangeState: ((opaqueData: Uint8Array) => any) | undefined,
103
- ): any {
104
- if (request.phase === TableInOutPhase.FINALIZE && request.init_opaque_data && recoverExchangeState) {
105
- try {
106
- const recovered = recoverExchangeState(request.init_opaque_data);
107
- return recovered?.userState;
108
- } catch (e: any) {
109
- throw new Error(`Failed to recover FINALIZE state from init_opaque_data: ${e.message}`);
110
- }
111
- }
112
- return undefined;
113
- }
@@ -1,7 +1,6 @@
1
1
  // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
- // Arrow IPC state serializer for HTTP transport.
3
- // Replaces JSON serialization with Arrow IPC for the exchange state token,
4
- // avoiding base64/hex overhead and staying consistent with the rest of the protocol.
2
+ // State serializer for the HTTP transport: the exchange state an HTTP cursor
3
+ // carries between turns, and the Arrow IPC encoding of user state inside it.
5
4
 
6
5
  import {
7
6
  type VgiDataType,
@@ -27,21 +26,42 @@ import {
27
26
  import { codecFor } from "../arrow/codec/registry.js";
28
27
  import type { StateSerializer } from "@query-farm/vgi-rpc";
29
28
  import { toUint8Array } from "../util/bytes.js";
29
+ import { decodeFilterHistory, encodeFilterHistory } from "../filter-pushdown/history.js";
30
+ import { packInitRequest } from "./carried-init-request.js";
30
31
 
31
- /** Schema for the exchange state carried in HTTP state tokens. */
32
+ /**
33
+ * The fields of the exchange state an HTTP cursor carries, by name and type.
34
+ *
35
+ * Descriptive: {@link arrowStateSerializer} writes them as a flat binary frame
36
+ * (see there), not as an Arrow batch of this schema. Kept exported for API
37
+ * stability.
38
+ */
32
39
  export const EXCHANGE_STATE_SCHEMA = makeSchema([
33
40
  field("function_name", binary(), false),
41
+ // The init request, packed by carried-init-request.ts (a codec byte, then
42
+ // the IPC bytes, zstd-compressed where the runtime can).
34
43
  field("init_request", binary(), false),
35
44
  field("execution_id", binary(), false),
36
45
  field("max_workers", int64(), false),
37
46
  field("opaque_data", binary(), true),
38
47
  field("is_producer", bool(), false),
39
48
  field("user_state", binary(), true),
49
+ // The stream's compacted dynamic-filter history (filter-pushdown/history.ts),
50
+ // null when no delta has been applied. Bounded by the number of predicate
51
+ // ids, so the token stays flat-sized however many turns the scan takes.
52
+ field("filter_history", binary(), true),
40
53
  ]);
41
54
 
42
55
  const TEXT_ENCODER = new TextEncoder();
43
56
  const TEXT_DECODER = new TextDecoder();
44
57
 
58
+ /** Version byte of the exchange state frame (never 0xFF, the first byte of an Arrow IPC stream). */
59
+ const STATE_FORMAT_VERSION = 1;
60
+ const FLAG_PRODUCER = 1;
61
+ const FLAG_OPAQUE_DATA = 2;
62
+ const FLAG_USER_STATE = 4;
63
+ const FLAG_FILTER_HISTORY = 8;
64
+
45
65
  /**
46
66
  * Child field name for an inferred list. Arrow matches list children by
47
67
  * position, not name, so this is cosmetic — but keeping it at Arrow's
@@ -187,49 +207,101 @@ export function deserializeUserState(bytes: Uint8Array | null): any {
187
207
  return result;
188
208
  }
189
209
 
190
- /** Arrow IPC state serializer — stores all data as native binary columns. */
210
+ /**
211
+ * The HTTP exchange state serializer: what a stream's cursor carries between
212
+ * turns.
213
+ *
214
+ * A flat, versioned binary frame, not an Arrow batch. The cursor is built and
215
+ * parsed on every turn of every HTTP stream, and wrapping eight scalars in a
216
+ * one-row Arrow batch cost ~0.4 ms a turn (a generic column builder per field,
217
+ * an IPC schema message each way) plus ~1 KB of framing that was then sealed
218
+ * and base64-encoded twice per turn. The fields are the ones
219
+ * {@link EXCHANGE_STATE_SCHEMA} names; `user_state` stays Arrow IPC inside the
220
+ * frame, because it carries typed user values.
221
+ *
222
+ * Layout (little-endian): `u8 version | u8 flags | f64 max_workers |
223
+ * bytes function_name | bytes init_request | bytes execution_id |
224
+ * [bytes opaque_data] [bytes user_state] [bytes filter_history]`, where
225
+ * `bytes` is `u32 length, payload` and a bracketed field is present only when
226
+ * its flag bit is set. The frame never leaves this worker unsealed, so the
227
+ * version byte exists to turn a token minted by another build into a clean
228
+ * refusal rather than a misread.
229
+ *
230
+ * The name is kept for API stability; the Arrow in it is the user state.
231
+ */
191
232
  export const arrowStateSerializer: StateSerializer = {
192
233
  serialize(state: any): Uint8Array {
193
- const columns: Record<string, any[]> = {
194
- function_name: [TEXT_ENCODER.encode(state.functionName)],
195
- init_request: [state.initRequestIpc],
196
- execution_id: [state.executionId],
197
- max_workers: [state.maxWorkers],
198
- opaque_data: [state.opaqueData ?? null],
199
- is_producer: [state.isProducer],
200
- user_state: [serializeUserState(state.userState)],
201
- };
202
- const batch = batchFromColumns(columns, EXCHANGE_STATE_SCHEMA);
203
- return serializeBatch(batch);
234
+ const functionName = TEXT_ENCODER.encode(state.functionName ?? "");
235
+ // Packed on the init turn (the only one holding the raw bytes); every
236
+ // continuation carries the packed bytes through unchanged.
237
+ const initRequest: Uint8Array = state.initRequestPacked ?? packInitRequest(state.initRequestIpc);
238
+ const executionId = toUint8Array(state.executionId);
239
+ const opaqueData = state.opaqueData != null ? toUint8Array(state.opaqueData) : null;
240
+ const userState = serializeUserState(state.userState);
241
+ const filterHistory = encodeFilterHistory(state.filterHistory);
242
+ const flags =
243
+ (state.isProducer ? FLAG_PRODUCER : 0) |
244
+ (opaqueData ? FLAG_OPAQUE_DATA : 0) |
245
+ (userState ? FLAG_USER_STATE : 0) |
246
+ (filterHistory ? FLAG_FILTER_HISTORY : 0);
247
+ const chunks = [functionName, initRequest, executionId];
248
+ if (opaqueData) chunks.push(opaqueData);
249
+ if (userState) chunks.push(userState);
250
+ if (filterHistory) chunks.push(filterHistory);
251
+ let size = 2 + 8;
252
+ for (const chunk of chunks) size += 4 + chunk.byteLength;
253
+ const out = new Uint8Array(size);
254
+ const view = new DataView(out.buffer);
255
+ out[0] = STATE_FORMAT_VERSION;
256
+ out[1] = flags;
257
+ view.setFloat64(2, Number(state.maxWorkers ?? 1), true);
258
+ let offset = 10;
259
+ for (const chunk of chunks) {
260
+ view.setUint32(offset, chunk.byteLength, true);
261
+ out.set(chunk, offset + 4);
262
+ offset += 4 + chunk.byteLength;
263
+ }
264
+ return out;
204
265
  },
205
266
 
206
267
  deserialize(bytes: Uint8Array): any {
207
- const batch = deserializeBatch(bytes);
208
- const fieldByName = new Map(batch.schema.fields.map((f) => [f.name, f]));
209
- const get = (name: string) => {
210
- const col = batch.getChild(name);
211
- const f = fieldByName.get(name);
212
- if (!col || !f) return null;
213
- const type = f.type as unknown as VgiDataType;
214
- return codecFor(type).canonicalToRich(readCanonicalValue(type, col, 0));
268
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
269
+ if (bytes.byteLength < 10 || bytes[0] !== STATE_FORMAT_VERSION) {
270
+ throw new Error("exchange state has an unknown format (minted by another build of this worker?)");
271
+ }
272
+ const flags = bytes[1];
273
+ const maxWorkers = view.getFloat64(2, true);
274
+ let offset = 10;
275
+ // Each field is copied out, not viewed: Arrow readers want a buffer of
276
+ // their own (byteOffset 0), and nothing should pin the whole frame.
277
+ const take = (): Uint8Array => {
278
+ if (offset + 4 > bytes.byteLength) throw new Error("exchange state is truncated");
279
+ const length = view.getUint32(offset, true);
280
+ offset += 4;
281
+ if (offset + length > bytes.byteLength) throw new Error("exchange state is truncated");
282
+ const chunk = bytes.slice(offset, offset + length);
283
+ offset += length;
284
+ return chunk;
215
285
  };
216
-
217
- const isProducer = get("is_producer");
218
- const opaqueDataRaw = get("opaque_data");
219
- const fnNameRaw = get("function_name");
220
-
286
+ const functionName = TEXT_DECODER.decode(take());
287
+ const initRequestPacked = take();
288
+ const executionId = take();
289
+ const opaqueData = flags & FLAG_OPAQUE_DATA ? take() : null;
290
+ const userStateBytes = flags & FLAG_USER_STATE ? take() : null;
291
+ const filterHistoryBytes = flags & FLAG_FILTER_HISTORY ? take() : null;
292
+ if (offset !== bytes.byteLength) throw new Error("exchange state has trailing bytes");
293
+ const isProducer = (flags & FLAG_PRODUCER) !== 0;
221
294
  return {
222
- functionName: fnNameRaw != null ? TEXT_DECODER.decode(toUint8Array(fnNameRaw)) : "",
223
- initRequestIpc: toUint8Array(get("init_request")),
224
- executionId: toUint8Array(get("execution_id")),
225
- maxWorkers: Number(get("max_workers")),
226
- opaqueData: opaqueDataRaw != null ? toUint8Array(opaqueDataRaw) : null,
295
+ functionName,
296
+ initRequestPacked,
297
+ executionId,
298
+ maxWorkers,
299
+ opaqueData,
227
300
  isProducer,
228
301
  // vgi-rpc dispatch reads __isProducer to choose producer vs exchange mode
229
302
  __isProducer: isProducer,
230
- userState: deserializeUserState(
231
- get("user_state") != null ? toUint8Array(get("user_state")) : null,
232
- ),
303
+ userState: deserializeUserState(userStateBytes),
304
+ filterHistory: decodeFilterHistory(filterHistoryBytes),
233
305
  };
234
306
  },
235
307
  };
@@ -0,0 +1,50 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Synchronous zstd, where the runtime has it.
3
+ //
4
+ // The state serializer is synchronous (vgi-rpc's StateSerializer contract), so
5
+ // it cannot use the async helpers vgi-rpc exposes. Bun has
6
+ // `Bun.zstdCompressSync`; Node >= 22.15 has `zlib.zstdCompressSync`, reached
7
+ // from ESM through `process.getBuiltinModule`. Anything else (workerd, a
8
+ // browser) gets `null` and callers fall back to storing bytes uncompressed.
9
+
10
+ /** A synchronous zstd codec. */
11
+ export interface ZstdSync {
12
+ compress(data: Uint8Array): Uint8Array;
13
+ decompress(data: Uint8Array): Uint8Array;
14
+ }
15
+
16
+ /** Level 3 is zstd's default: ~3.4x on an init request, ~50 us for 9 KB. */
17
+ const LEVEL = 3;
18
+
19
+ function resolveZstdSync(): ZstdSync | null {
20
+ const bun = (globalThis as any).Bun;
21
+ if (typeof bun?.zstdCompressSync === "function" && typeof bun?.zstdDecompressSync === "function") {
22
+ return {
23
+ compress: (data) => new Uint8Array(bun.zstdCompressSync(data, { level: LEVEL })),
24
+ // Copied into a fresh buffer: Arrow readers want byteOffset 0.
25
+ decompress: (data) => new Uint8Array(bun.zstdDecompressSync(data)),
26
+ };
27
+ }
28
+ let zlib: any = null;
29
+ try {
30
+ zlib = (globalThis as any).process?.getBuiltinModule?.("node:zlib") ?? null;
31
+ } catch {
32
+ zlib = null;
33
+ }
34
+ if (typeof zlib?.zstdCompressSync === "function" && typeof zlib?.zstdDecompressSync === "function") {
35
+ const params = { [zlib.constants.ZSTD_c_compressionLevel]: LEVEL };
36
+ return {
37
+ compress: (data) => new Uint8Array(zlib.zstdCompressSync(data, { params })),
38
+ decompress: (data) => new Uint8Array(zlib.zstdDecompressSync(data)),
39
+ };
40
+ }
41
+ return null;
42
+ }
43
+
44
+ let resolved: ZstdSync | null | undefined;
45
+
46
+ /** This runtime's synchronous zstd codec, or `null` when it has none. */
47
+ export function zstdSync(): ZstdSync | null {
48
+ if (resolved === undefined) resolved = resolveZstdSync();
49
+ return resolved;
50
+ }