@query-farm/vgi-rpc 0.2.4 → 0.3.2

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 (75) hide show
  1. package/README.md +3 -3
  2. package/dist/client/connect.d.ts.map +1 -1
  3. package/dist/client/index.d.ts +3 -3
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/introspect.d.ts +1 -1
  6. package/dist/client/introspect.d.ts.map +1 -1
  7. package/dist/client/ipc.d.ts +1 -1
  8. package/dist/client/ipc.d.ts.map +1 -1
  9. package/dist/client/pipe.d.ts +2 -2
  10. package/dist/client/pipe.d.ts.map +1 -1
  11. package/dist/client/stream.d.ts +1 -1
  12. package/dist/client/stream.d.ts.map +1 -1
  13. package/dist/dispatch/describe.d.ts +1 -1
  14. package/dist/dispatch/describe.d.ts.map +1 -1
  15. package/dist/dispatch/stream.d.ts +1 -1
  16. package/dist/dispatch/stream.d.ts.map +1 -1
  17. package/dist/dispatch/unary.d.ts.map +1 -1
  18. package/dist/http/common.d.ts +1 -1
  19. package/dist/http/common.d.ts.map +1 -1
  20. package/dist/http/dispatch.d.ts.map +1 -1
  21. package/dist/http/handler.d.ts.map +1 -1
  22. package/dist/http/index.d.ts +3 -1
  23. package/dist/http/index.d.ts.map +1 -1
  24. package/dist/http/token.d.ts.map +1 -1
  25. package/dist/http/types.d.ts.map +1 -1
  26. package/dist/index.d.ts +7 -7
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2162 -2119
  29. package/dist/index.js.map +26 -26
  30. package/dist/protocol.d.ts +1 -1
  31. package/dist/protocol.d.ts.map +1 -1
  32. package/dist/schema.d.ts +1 -1
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/server.d.ts +1 -1
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/types.d.ts +1 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/util/schema.d.ts +1 -1
  39. package/dist/util/schema.d.ts.map +1 -1
  40. package/dist/util/zstd.d.ts.map +1 -1
  41. package/dist/wire/reader.d.ts +1 -1
  42. package/dist/wire/reader.d.ts.map +1 -1
  43. package/dist/wire/request.d.ts +1 -1
  44. package/dist/wire/request.d.ts.map +1 -1
  45. package/dist/wire/response.d.ts +1 -1
  46. package/dist/wire/response.d.ts.map +1 -1
  47. package/dist/wire/writer.d.ts +1 -1
  48. package/dist/wire/writer.d.ts.map +1 -1
  49. package/package.json +9 -5
  50. package/src/client/connect.ts +12 -20
  51. package/src/client/index.ts +8 -8
  52. package/src/client/introspect.ts +11 -15
  53. package/src/client/ipc.ts +18 -32
  54. package/src/client/pipe.ts +17 -37
  55. package/src/client/stream.ts +20 -46
  56. package/src/dispatch/describe.ts +15 -27
  57. package/src/dispatch/stream.ts +7 -21
  58. package/src/dispatch/unary.ts +1 -2
  59. package/src/http/common.ts +9 -19
  60. package/src/http/dispatch.ts +115 -110
  61. package/src/http/handler.ts +20 -55
  62. package/src/http/index.ts +3 -1
  63. package/src/http/token.ts +2 -7
  64. package/src/http/types.ts +2 -6
  65. package/src/index.ts +44 -41
  66. package/src/protocol.ts +8 -8
  67. package/src/schema.ts +12 -16
  68. package/src/server.ts +16 -36
  69. package/src/types.ts +9 -36
  70. package/src/util/schema.ts +1 -1
  71. package/src/util/zstd.ts +2 -8
  72. package/src/wire/reader.ts +2 -4
  73. package/src/wire/request.ts +4 -15
  74. package/src/wire/response.ts +9 -25
  75. package/src/wire/writer.ts +1 -5
@@ -1,23 +1,11 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import {
5
- RecordBatch,
6
- Schema,
7
- Field,
8
- makeData,
9
- Struct,
10
- vectorFromArray,
11
- } from "apache-arrow";
4
+ import { Field, makeData, RecordBatch, Schema, Struct, vectorFromArray } from "@query-farm/apache-arrow";
12
5
  import { STATE_KEY } from "../constants.js";
13
6
  import { RpcError } from "../errors.js";
14
7
  import { ARROW_CONTENT_TYPE, serializeIpcStream } from "../http/common.js";
15
- import {
16
- inferArrowType,
17
- dispatchLogOrError,
18
- extractBatchRows,
19
- readResponseBatches,
20
- } from "./ipc.js";
8
+ import { dispatchLogOrError, extractBatchRows, inferArrowType, readResponseBatches } from "./ipc.js";
21
9
  import type { LogMessage, StreamSession } from "./types.js";
22
10
 
23
11
  type CompressFn = (data: Uint8Array, level: number) => Uint8Array;
@@ -103,11 +91,7 @@ export class HttpStreamSession implements StreamSession {
103
91
  */
104
92
  async exchange(input: Record<string, any>[]): Promise<Record<string, any>[]> {
105
93
  if (this._stateToken === null) {
106
- throw new RpcError(
107
- "ProtocolError",
108
- "Stream has finished \u2014 no state token available",
109
- "",
110
- );
94
+ throw new RpcError("ProtocolError", "Stream has finished \u2014 no state token available", "");
111
95
  }
112
96
 
113
97
  // We need to determine the input schema from the data.
@@ -122,11 +106,7 @@ export class HttpStreamSession implements StreamSession {
122
106
  const emptyBatch = this._buildEmptyBatch(zeroSchema);
123
107
  const metadata = new Map<string, string>();
124
108
  metadata.set(STATE_KEY, this._stateToken);
125
- const batchWithMeta = new RecordBatch(
126
- zeroSchema,
127
- emptyBatch.data,
128
- metadata,
129
- );
109
+ const batchWithMeta = new RecordBatch(zeroSchema, emptyBatch.data, metadata);
130
110
  return this._doExchange(zeroSchema, [batchWithMeta]);
131
111
  }
132
112
 
@@ -134,9 +114,12 @@ export class HttpStreamSession implements StreamSession {
134
114
  const keys = Object.keys(input[0]);
135
115
  const fields = keys.map((key) => {
136
116
  // Find first non-null value to infer type
137
- let sample: any = undefined;
117
+ let sample: any;
138
118
  for (const row of input) {
139
- if (row[key] != null) { sample = row[key]; break; }
119
+ if (row[key] != null) {
120
+ sample = row[key];
121
+ break;
122
+ }
140
123
  }
141
124
  const arrowType = inferArrowType(sample);
142
125
  const nullable = input.some((row) => row[key] == null);
@@ -164,19 +147,13 @@ export class HttpStreamSession implements StreamSession {
164
147
  return this._doExchange(inputSchema, [batch]);
165
148
  }
166
149
 
167
- private async _doExchange(
168
- schema: Schema,
169
- batches: RecordBatch[],
170
- ): Promise<Record<string, any>[]> {
150
+ private async _doExchange(schema: Schema, batches: RecordBatch[]): Promise<Record<string, any>[]> {
171
151
  const body = serializeIpcStream(schema, batches);
172
- const resp = await fetch(
173
- `${this._baseUrl}${this._prefix}/${this._method}/exchange`,
174
- {
175
- method: "POST",
176
- headers: this._buildHeaders(),
177
- body: this._prepareBody(body) as unknown as BodyInit,
178
- },
179
- );
152
+ const resp = await fetch(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, {
153
+ method: "POST",
154
+ headers: this._buildHeaders(),
155
+ body: this._prepareBody(body) as unknown as BodyInit,
156
+ });
180
157
 
181
158
  const responseBody = await this._readResponse(resp);
182
159
  const { batches: responseBatches } = await readResponseBatches(responseBody);
@@ -279,14 +256,11 @@ export class HttpStreamSession implements StreamSession {
279
256
  const batch = new RecordBatch(emptySchema, data, metadata);
280
257
  const body = serializeIpcStream(emptySchema, [batch]);
281
258
 
282
- const resp = await fetch(
283
- `${this._baseUrl}${this._prefix}/${this._method}/exchange`,
284
- {
285
- method: "POST",
286
- headers: this._buildHeaders(),
287
- body: this._prepareBody(body) as unknown as BodyInit,
288
- },
289
- );
259
+ const resp = await fetch(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, {
260
+ method: "POST",
261
+ headers: this._buildHeaders(),
262
+ body: this._prepareBody(body) as unknown as BodyInit,
263
+ });
290
264
 
291
265
  return this._readResponse(resp);
292
266
  }
@@ -2,25 +2,25 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import {
5
- Schema,
5
+ Binary,
6
+ Bool,
6
7
  Field,
8
+ makeData,
7
9
  RecordBatch,
10
+ Schema,
11
+ Struct,
8
12
  Utf8,
9
- Bool,
10
- Binary,
11
13
  vectorFromArray,
12
- makeData,
13
- Struct,
14
- } from "apache-arrow";
15
- import type { MethodDefinition } from "../types.js";
14
+ } from "@query-farm/apache-arrow";
16
15
  import {
16
+ DESCRIBE_VERSION,
17
+ DESCRIBE_VERSION_KEY,
17
18
  PROTOCOL_NAME_KEY,
18
- REQUEST_VERSION_KEY,
19
19
  REQUEST_VERSION,
20
- DESCRIBE_VERSION_KEY,
21
- DESCRIBE_VERSION,
20
+ REQUEST_VERSION_KEY,
22
21
  SERVER_ID_KEY,
23
22
  } from "../constants.js";
23
+ import type { MethodDefinition } from "../types.js";
24
24
  import { serializeSchema } from "../util/schema.js";
25
25
 
26
26
  /**
@@ -48,9 +48,7 @@ export function buildDescribeBatch(
48
48
  serverId: string,
49
49
  ): { batch: RecordBatch; metadata: Map<string, string> } {
50
50
  // Sort methods by name for consistent ordering
51
- const sortedEntries = [...methods.entries()].sort(([a], [b]) =>
52
- a.localeCompare(b),
53
- );
51
+ const sortedEntries = [...methods.entries()].sort(([a], [b]) => a.localeCompare(b));
54
52
 
55
53
  const names: (string | null)[] = [];
56
54
  const methodTypes: (string | null)[] = [];
@@ -69,8 +67,7 @@ export function buildDescribeBatch(
69
67
  docs.push(method.doc ?? null);
70
68
 
71
69
  // Unary methods with non-empty result schema have a return value
72
- const hasReturn =
73
- method.type === "unary" && method.resultSchema.fields.length > 0;
70
+ const hasReturn = method.type === "unary" && method.resultSchema.fields.length > 0;
74
71
  hasReturns.push(hasReturn);
75
72
 
76
73
  paramsSchemas.push(serializeSchema(method.paramsSchema));
@@ -87,26 +84,17 @@ export function buildDescribeBatch(
87
84
  if (method.defaults && Object.keys(method.defaults).length > 0) {
88
85
  const safe: Record<string, any> = {};
89
86
  for (const [k, v] of Object.entries(method.defaults)) {
90
- if (
91
- v === null ||
92
- typeof v === "string" ||
93
- typeof v === "number" ||
94
- typeof v === "boolean"
95
- ) {
87
+ if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
96
88
  safe[k] = v;
97
89
  }
98
90
  }
99
- paramDefaultsJsons.push(
100
- Object.keys(safe).length > 0 ? JSON.stringify(safe) : null,
101
- );
91
+ paramDefaultsJsons.push(Object.keys(safe).length > 0 ? JSON.stringify(safe) : null);
102
92
  } else {
103
93
  paramDefaultsJsons.push(null);
104
94
  }
105
95
 
106
96
  hasHeaders.push(!!method.headerSchema);
107
- headerSchemas.push(
108
- method.headerSchema ? serializeSchema(method.headerSchema) : null,
109
- );
97
+ headerSchemas.push(method.headerSchema ? serializeSchema(method.headerSchema) : null);
110
98
  }
111
99
 
112
100
  // Build the batch using vectorFromArray for each column
@@ -1,12 +1,12 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { Schema } from "apache-arrow";
4
+ import { Schema } from "@query-farm/apache-arrow";
5
5
  import type { MethodDefinition } from "../types.js";
6
6
  import { OutputCollector } from "../types.js";
7
- import type { IpcStreamWriter } from "../wire/writer.js";
8
7
  import type { IpcStreamReader } from "../wire/reader.js";
9
- import { buildResultBatch, buildErrorBatch } from "../wire/response.js";
8
+ import { buildErrorBatch, buildResultBatch } from "../wire/response.js";
9
+ import type { IpcStreamWriter } from "../wire/writer.js";
10
10
 
11
11
  const EMPTY_SCHEMA = new Schema([]);
12
12
 
@@ -33,8 +33,7 @@ export async function dispatchStream(
33
33
  serverId: string,
34
34
  requestId: string | null,
35
35
  ): Promise<void> {
36
- const isProducer =
37
- !method.inputSchema || method.inputSchema.fields.length === 0;
36
+ const isProducer = !!method.producerFn;
38
37
 
39
38
  let state: any;
40
39
  try {
@@ -71,16 +70,8 @@ export async function dispatchStream(
71
70
  try {
72
71
  const headerOut = new OutputCollector(method.headerSchema, true, serverId, requestId);
73
72
  const headerValues = method.headerInit(params, state, headerOut);
74
- const headerBatch = buildResultBatch(
75
- method.headerSchema,
76
- headerValues,
77
- serverId,
78
- requestId,
79
- );
80
- const headerBatches = [
81
- ...headerOut.batches.map((b) => b.batch),
82
- headerBatch,
83
- ];
73
+ const headerBatch = buildResultBatch(method.headerSchema, headerValues, serverId, requestId);
74
+ const headerBatches = [...headerOut.batches.map((b) => b.batch), headerBatch];
84
75
  writer.writeStream(method.headerSchema, headerBatches);
85
76
  } catch (error: any) {
86
77
  const errBatch = buildErrorBatch(method.headerSchema, error, serverId, requestId);
@@ -97,12 +88,7 @@ export async function dispatchStream(
97
88
  // Open the input IPC stream (ticks or data from client)
98
89
  const inputSchema = await reader.openNextStream();
99
90
  if (!inputSchema) {
100
- const errBatch = buildErrorBatch(
101
- outputSchema,
102
- new Error("Expected input stream but got EOF"),
103
- serverId,
104
- requestId,
105
- );
91
+ const errBatch = buildErrorBatch(outputSchema, new Error("Expected input stream but got EOF"), serverId, requestId);
106
92
  writer.writeStream(outputSchema, [errBatch]);
107
93
  return;
108
94
  }
@@ -1,11 +1,10 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { Schema } from "apache-arrow";
5
4
  import type { MethodDefinition } from "../types.js";
6
5
  import { OutputCollector } from "../types.js";
6
+ import { buildErrorBatch, buildResultBatch } from "../wire/response.js";
7
7
  import type { IpcStreamWriter } from "../wire/writer.js";
8
- import { buildResultBatch, buildErrorBatch } from "../wire/response.js";
9
8
 
10
9
  /**
11
10
  * Dispatch a unary RPC call.
@@ -2,13 +2,13 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import {
5
- RecordBatchStreamWriter,
6
- RecordBatchReader,
5
+ makeData,
7
6
  RecordBatch,
8
- Schema,
7
+ RecordBatchReader,
8
+ RecordBatchStreamWriter,
9
+ type Schema,
9
10
  Struct,
10
- makeData,
11
- } from "apache-arrow";
11
+ } from "@query-farm/apache-arrow";
12
12
 
13
13
  export const ARROW_CONTENT_TYPE = "application/vnd.apache.arrow.stream";
14
14
 
@@ -31,14 +31,9 @@ export class HttpRpcError extends Error {
31
31
  * match the writer's schema. Cloning each child Data with the schema's field
32
32
  * type fixes the type metadata while preserving the underlying buffers.
33
33
  */
34
- function conformBatchToSchema(
35
- batch: RecordBatch,
36
- schema: Schema,
37
- ): RecordBatch {
34
+ function conformBatchToSchema(batch: RecordBatch, schema: Schema): RecordBatch {
38
35
  if (batch.numRows === 0) return batch;
39
- const children = schema.fields.map((f, i) =>
40
- batch.data.children[i].clone(f.type),
41
- );
36
+ const children = schema.fields.map((f, i) => batch.data.children[i].clone(f.type));
42
37
  const structType = new Struct(schema.fields);
43
38
  const data = makeData({
44
39
  type: structType,
@@ -51,10 +46,7 @@ function conformBatchToSchema(
51
46
  }
52
47
 
53
48
  /** Serialize a schema + batches into a complete IPC stream as Uint8Array. */
54
- export function serializeIpcStream(
55
- schema: Schema,
56
- batches: RecordBatch[],
57
- ): Uint8Array {
49
+ export function serializeIpcStream(schema: Schema, batches: RecordBatch[]): Uint8Array {
58
50
  const writer = new RecordBatchStreamWriter();
59
51
  writer.reset(undefined, schema);
60
52
  for (const batch of batches) {
@@ -72,9 +64,7 @@ export function arrowResponse(body: Uint8Array, status = 200, extraHeaders?: Hea
72
64
  }
73
65
 
74
66
  /** Read schema + first batch from an IPC stream body. */
75
- export async function readRequestFromBody(
76
- body: Uint8Array,
77
- ): Promise<{ schema: Schema; batch: RecordBatch }> {
67
+ export async function readRequestFromBody(body: Uint8Array): Promise<{ schema: Schema; batch: RecordBatch }> {
78
68
  const reader = await RecordBatchReader.from(body);
79
69
  await reader.open();
80
70
  const schema = reader.schema;
@@ -1,27 +1,24 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { Schema, RecordBatch } from "apache-arrow";
4
+ import { RecordBatch, RecordBatchReader, Schema } from "@query-farm/apache-arrow";
5
+ import { STATE_KEY } from "../constants.js";
6
+ import { buildDescribeBatch, DESCRIBE_SCHEMA } from "../dispatch/describe.js";
5
7
  import type { MethodDefinition } from "../types.js";
6
8
  import { OutputCollector } from "../types.js";
7
- import { parseRequest } from "../wire/request.js";
8
- import {
9
- buildResultBatch,
10
- buildErrorBatch,
11
- buildEmptyBatch,
12
- } from "../wire/response.js";
13
- import { buildDescribeBatch, DESCRIBE_SCHEMA } from "../dispatch/describe.js";
14
- import { STATE_KEY } from "../constants.js";
15
9
  import { serializeSchema } from "../util/schema.js";
16
- import {
17
- HttpRpcError,
18
- serializeIpcStream,
19
- readRequestFromBody,
20
- arrowResponse,
21
- } from "./common.js";
10
+ import { parseRequest } from "../wire/request.js";
11
+ import { buildEmptyBatch, buildErrorBatch, buildResultBatch } from "../wire/response.js";
12
+ import { arrowResponse, HttpRpcError, readRequestFromBody, serializeIpcStream } from "./common.js";
22
13
  import { packStateToken, unpackStateToken } from "./token.js";
23
14
  import type { StateSerializer } from "./types.js";
24
15
 
16
+ async function deserializeSchema(bytes: Uint8Array): Promise<Schema> {
17
+ const reader = await RecordBatchReader.from(bytes);
18
+ await reader.open();
19
+ return reader.schema!;
20
+ }
21
+
25
22
  const EMPTY_SCHEMA = new Schema([]);
26
23
 
27
24
  export interface DispatchContext {
@@ -54,10 +51,7 @@ export async function httpDispatchUnary(
54
51
  const parsed = parseRequest(reqSchema, reqBatch);
55
52
 
56
53
  if (parsed.methodName !== method.name) {
57
- throw new HttpRpcError(
58
- `Method name in request '${parsed.methodName}' does not match URL '${method.name}'`,
59
- 400,
60
- );
54
+ throw new HttpRpcError(`Method name in request '${parsed.methodName}' does not match URL '${method.name}'`, 400);
61
55
  }
62
56
 
63
57
  const out = new OutputCollector(schema, true, ctx.serverId, parsed.requestId);
@@ -79,8 +73,7 @@ export async function httpDispatchStreamInit(
79
73
  body: Uint8Array,
80
74
  ctx: DispatchContext,
81
75
  ): Promise<Response> {
82
- const isProducer =
83
- !method.inputSchema || method.inputSchema.fields.length === 0;
76
+ const isProducer = !!method.producerFn;
84
77
  const outputSchema = method.outputSchema!;
85
78
  const inputSchema = method.inputSchema ?? EMPTY_SCHEMA;
86
79
 
@@ -88,10 +81,7 @@ export async function httpDispatchStreamInit(
88
81
  const parsed = parseRequest(reqSchema, reqBatch);
89
82
 
90
83
  if (parsed.methodName !== method.name) {
91
- throw new HttpRpcError(
92
- `Method name in request '${parsed.methodName}' does not match URL '${method.name}'`,
93
- 400,
94
- );
84
+ throw new HttpRpcError(`Method name in request '${parsed.methodName}' does not match URL '${method.name}'`, 400);
95
85
  }
96
86
 
97
87
  // Init state
@@ -116,56 +106,28 @@ export async function httpDispatchStreamInit(
116
106
  let headerBytes: Uint8Array | null = null;
117
107
  if (method.headerSchema && method.headerInit) {
118
108
  try {
119
- const headerOut = new OutputCollector(
120
- method.headerSchema,
121
- true,
122
- ctx.serverId,
123
- parsed.requestId,
124
- );
109
+ const headerOut = new OutputCollector(method.headerSchema, true, ctx.serverId, parsed.requestId);
125
110
  const headerValues = method.headerInit(parsed.params, state, headerOut);
126
- const headerBatch = buildResultBatch(
127
- method.headerSchema,
128
- headerValues,
129
- ctx.serverId,
130
- parsed.requestId,
131
- );
132
- const headerBatches = [
133
- ...headerOut.batches.map((b) => b.batch),
134
- headerBatch,
135
- ];
111
+ const headerBatch = buildResultBatch(method.headerSchema, headerValues, ctx.serverId, parsed.requestId);
112
+ const headerBatches = [...headerOut.batches.map((b) => b.batch), headerBatch];
136
113
  headerBytes = serializeIpcStream(method.headerSchema, headerBatches);
137
114
  } catch (error: any) {
138
- const errBatch = buildErrorBatch(
139
- method.headerSchema,
140
- error,
141
- ctx.serverId,
142
- parsed.requestId,
143
- );
115
+ const errBatch = buildErrorBatch(method.headerSchema, error, ctx.serverId, parsed.requestId);
144
116
  return arrowResponse(serializeIpcStream(method.headerSchema, [errBatch]), 500);
145
117
  }
146
118
  }
147
119
 
148
120
  if (effectiveProducer) {
149
- return produceStreamResponse(
150
- method,
151
- state,
152
- resolvedOutputSchema,
153
- inputSchema,
154
- ctx,
155
- parsed.requestId,
156
- headerBytes,
157
- );
121
+ // Producer method — produce data inline in the init response.
122
+ // For exchange-registered methods acting as producers (__isProducer),
123
+ // produceStreamResponse falls back to exchangeFn with tick batches.
124
+ return produceStreamResponse(method, state, resolvedOutputSchema, inputSchema, ctx, parsed.requestId, headerBytes);
158
125
  } else {
159
126
  // Exchange: serialize state into signed token, return zero-row batch with token
160
127
  const stateBytes = ctx.stateSerializer.serialize(state);
161
128
  const schemaBytes = serializeSchema(resolvedOutputSchema);
162
129
  const inputSchemaBytes = serializeSchema(inputSchema);
163
- const token = packStateToken(
164
- stateBytes,
165
- schemaBytes,
166
- inputSchemaBytes,
167
- ctx.signingKey,
168
- );
130
+ const token = packStateToken(stateBytes, schemaBytes, inputSchemaBytes, ctx.signingKey);
169
131
 
170
132
  const tokenMeta = new Map<string, string>();
171
133
  tokenMeta.set(STATE_KEY, token);
@@ -189,8 +151,7 @@ export async function httpDispatchStreamExchange(
189
151
  body: Uint8Array,
190
152
  ctx: DispatchContext,
191
153
  ): Promise<Response> {
192
- const isProducer =
193
- !method.inputSchema || method.inputSchema.fields.length === 0;
154
+ const isProducer = !!method.producerFn;
194
155
 
195
156
  const { batch: reqBatch } = await readRequestFromBody(body);
196
157
 
@@ -200,64 +161,102 @@ export async function httpDispatchStreamExchange(
200
161
  throw new HttpRpcError("Missing state token in exchange request", 400);
201
162
  }
202
163
 
203
- let unpacked;
164
+ let unpacked: import("./token.js").UnpackedToken;
204
165
  try {
205
166
  unpacked = unpackStateToken(tokenBase64, ctx.signingKey, ctx.tokenTtl);
206
167
  } catch (error: any) {
207
168
  throw new HttpRpcError(`Invalid state token: ${error.message}`, 400);
208
169
  }
209
170
 
210
- const state = ctx.stateSerializer.deserialize(unpacked.stateBytes);
171
+ let state: any;
172
+ try {
173
+ state = ctx.stateSerializer.deserialize(unpacked.stateBytes);
174
+ } catch (error: any) {
175
+ console.error(`[httpDispatchStreamExchange] state deserialize error:`, error.message);
176
+ throw new HttpRpcError(`State deserialization failed: ${error.message}`, 500);
177
+ }
211
178
 
212
- // Support dynamic output schemas (same as pipe transport)
213
- const outputSchema = state?.__outputSchema ?? method.outputSchema!;
214
- const inputSchema = method.inputSchema ?? EMPTY_SCHEMA;
179
+ // Recover schemas from the token (the state itself may not contain
180
+ // Schema objects after JSON round-trip — always prefer the token).
181
+ let outputSchema: Schema;
182
+ if (unpacked.schemaBytes.length > 0) {
183
+ outputSchema = await deserializeSchema(unpacked.schemaBytes);
184
+ } else {
185
+ outputSchema = state?.__outputSchema ?? method.outputSchema!;
186
+ }
187
+ let inputSchema: Schema;
188
+ if (unpacked.inputSchemaBytes.length > 0) {
189
+ inputSchema = await deserializeSchema(unpacked.inputSchemaBytes);
190
+ } else {
191
+ inputSchema = method.inputSchema ?? EMPTY_SCHEMA;
192
+ }
215
193
  const effectiveProducer = state?.__isProducer ?? isProducer;
194
+ if (process.env.VGI_DISPATCH_DEBUG)
195
+ console.error(
196
+ `[httpDispatchStreamExchange] method=${method.name} effectiveProducer=${effectiveProducer} stateKeys=${Object.keys(state || {})}`,
197
+ );
216
198
 
217
199
  if (effectiveProducer) {
218
- return produceStreamResponse(
219
- method,
220
- state,
221
- outputSchema,
222
- inputSchema,
223
- ctx,
224
- null,
225
- null,
226
- );
200
+ // Producer continuation — produce more data inline.
201
+ // For exchange-registered methods, falls back to exchangeFn with tick batches.
202
+ return produceStreamResponse(method, state, outputSchema, inputSchema, ctx, null, null);
227
203
  } else {
228
- const out = new OutputCollector(outputSchema, false, ctx.serverId, null);
204
+ // Exchange path also handles exchange-registered methods acting as
205
+ // producers (__isProducer=true). Use producer mode on the OutputCollector
206
+ // when effectiveProducer so finish() is allowed.
207
+ const out = new OutputCollector(outputSchema, effectiveProducer, ctx.serverId, null);
229
208
 
230
209
  try {
231
- await method.exchangeFn!(state, reqBatch, out);
210
+ if (method.exchangeFn) {
211
+ await method.exchangeFn(state, reqBatch, out);
212
+ } else {
213
+ await method.producerFn!(state, out);
214
+ }
232
215
  } catch (error: any) {
216
+ if (process.env.VGI_DISPATCH_DEBUG)
217
+ console.error(
218
+ `[httpDispatchStreamExchange] exchange handler error:`,
219
+ error.message,
220
+ error.stack?.split("\n").slice(0, 5).join("\n"),
221
+ );
233
222
  const errBatch = buildErrorBatch(outputSchema, error, ctx.serverId, null);
234
223
  return arrowResponse(serializeIpcStream(outputSchema, [errBatch]), 500);
235
224
  }
236
225
 
237
- // Repack updated state into new token
238
- const stateBytes = ctx.stateSerializer.serialize(state);
239
- const schemaBytes = serializeSchema(outputSchema);
240
- const inputSchemaBytes = serializeSchema(inputSchema);
241
- const token = packStateToken(
242
- stateBytes,
243
- schemaBytes,
244
- inputSchemaBytes,
245
- ctx.signingKey,
246
- );
247
-
248
- // Merge token into the data batch's metadata (matching Python behavior).
249
- // The Python client expects the token on the data batch itself, not a
250
- // separate zero-row batch.
226
+ // Collect emitted batches
251
227
  const batches: RecordBatch[] = [];
252
- for (const emitted of out.batches) {
253
- const batch = emitted.batch;
254
- if (batch.numRows > 0) {
255
- // This is the data batch merge token into its metadata
256
- const mergedMeta = new Map<string, string>(batch.metadata ?? []);
257
- mergedMeta.set(STATE_KEY, token);
258
- batches.push(new RecordBatch(batch.schema, batch.data, mergedMeta));
259
- } else {
260
- batches.push(batch);
228
+
229
+ if (out.finished) {
230
+ // Stream is done — return data WITHOUT state token.
231
+ // The absence of a token tells the client there's no more data.
232
+ for (const emitted of out.batches) {
233
+ batches.push(emitted.batch);
234
+ }
235
+ } else {
236
+ // More data may follow — repack state into token for next exchange.
237
+ const stateBytes = ctx.stateSerializer.serialize(state);
238
+ const schemaBytes = serializeSchema(outputSchema);
239
+ const inputSchemaBytes = serializeSchema(inputSchema);
240
+ const token = packStateToken(stateBytes, schemaBytes, inputSchemaBytes, ctx.signingKey);
241
+
242
+ for (const emitted of out.batches) {
243
+ const batch = emitted.batch;
244
+ if (batch.numRows > 0) {
245
+ const mergedMeta = new Map<string, string>(batch.metadata ?? []);
246
+ mergedMeta.set(STATE_KEY, token);
247
+ batches.push(new RecordBatch(batch.schema, batch.data, mergedMeta));
248
+ } else {
249
+ batches.push(batch);
250
+ }
251
+ }
252
+
253
+ // Safety net: if no batch carries a state token (e.g. all rows were
254
+ // filtered out by pushdown filters), emit an empty batch with the
255
+ // token so the client knows to continue exchanging.
256
+ if (!batches.some((b) => b.metadata?.get(STATE_KEY))) {
257
+ const tokenMeta = new Map<string, string>();
258
+ tokenMeta.set(STATE_KEY, token);
259
+ batches.push(buildEmptyBatch(outputSchema, tokenMeta));
261
260
  }
262
261
  }
263
262
 
@@ -283,8 +282,19 @@ async function produceStreamResponse(
283
282
  const out = new OutputCollector(outputSchema, true, ctx.serverId, requestId);
284
283
 
285
284
  try {
286
- await method.producerFn!(state, out);
285
+ if (method.producerFn) {
286
+ await method.producerFn(state, out);
287
+ } else {
288
+ // Exchange-registered method acting as producer (e.g. VGI's "init"
289
+ // method which is registered as exchange but may produce based on
290
+ // the __isProducer state flag). Call exchangeFn with an empty tick
291
+ // batch, matching how the subprocess transport dispatches these.
292
+ const tickBatch = buildEmptyBatch(inputSchema);
293
+ await method.exchangeFn!(state, tickBatch, out);
294
+ }
287
295
  } catch (error: any) {
296
+ if (process.env.VGI_DISPATCH_DEBUG)
297
+ console.error(`[produceStreamResponse] error:`, error.message, error.stack?.split("\n").slice(0, 3).join("\n"));
288
298
  allBatches.push(buildErrorBatch(outputSchema, error, ctx.serverId, requestId));
289
299
  break;
290
300
  }
@@ -305,12 +315,7 @@ async function produceStreamResponse(
305
315
  const stateBytes = ctx.stateSerializer.serialize(state);
306
316
  const schemaBytes = serializeSchema(outputSchema);
307
317
  const inputSchemaBytes = serializeSchema(inputSchema);
308
- const token = packStateToken(
309
- stateBytes,
310
- schemaBytes,
311
- inputSchemaBytes,
312
- ctx.signingKey,
313
- );
318
+ const token = packStateToken(stateBytes, schemaBytes, inputSchemaBytes, ctx.signingKey);
314
319
  const tokenMeta = new Map<string, string>();
315
320
  tokenMeta.set(STATE_KEY, token);
316
321
  allBatches.push(buildEmptyBatch(outputSchema, tokenMeta));