@query-farm/vgi-rpc 0.1.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 (92) hide show
  1. package/LICENSE.md +191 -0
  2. package/README.md +332 -0
  3. package/dist/client/connect.d.ts +10 -0
  4. package/dist/client/connect.d.ts.map +1 -0
  5. package/dist/client/index.d.ts +6 -0
  6. package/dist/client/index.d.ts.map +1 -0
  7. package/dist/client/introspect.d.ts +30 -0
  8. package/dist/client/introspect.d.ts.map +1 -0
  9. package/dist/client/ipc.d.ts +34 -0
  10. package/dist/client/ipc.d.ts.map +1 -0
  11. package/dist/client/pipe.d.ts +63 -0
  12. package/dist/client/pipe.d.ts.map +1 -0
  13. package/dist/client/stream.d.ts +52 -0
  14. package/dist/client/stream.d.ts.map +1 -0
  15. package/dist/client/types.d.ts +25 -0
  16. package/dist/client/types.d.ts.map +1 -0
  17. package/dist/constants.d.ts +15 -0
  18. package/dist/constants.d.ts.map +1 -0
  19. package/dist/dispatch/describe.d.ts +14 -0
  20. package/dist/dispatch/describe.d.ts.map +1 -0
  21. package/dist/dispatch/stream.d.ts +20 -0
  22. package/dist/dispatch/stream.d.ts.map +1 -0
  23. package/dist/dispatch/unary.d.ts +9 -0
  24. package/dist/dispatch/unary.d.ts.map +1 -0
  25. package/dist/errors.d.ts +12 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/http/common.d.ts +16 -0
  28. package/dist/http/common.d.ts.map +1 -0
  29. package/dist/http/dispatch.d.ts +18 -0
  30. package/dist/http/dispatch.d.ts.map +1 -0
  31. package/dist/http/handler.d.ts +16 -0
  32. package/dist/http/handler.d.ts.map +1 -0
  33. package/dist/http/index.d.ts +4 -0
  34. package/dist/http/index.d.ts.map +1 -0
  35. package/dist/http/token.d.ts +24 -0
  36. package/dist/http/token.d.ts.map +1 -0
  37. package/dist/http/types.d.ts +30 -0
  38. package/dist/http/types.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +2493 -0
  42. package/dist/index.js.map +34 -0
  43. package/dist/protocol.d.ts +62 -0
  44. package/dist/protocol.d.ts.map +1 -0
  45. package/dist/schema.d.ts +38 -0
  46. package/dist/schema.d.ts.map +1 -0
  47. package/dist/server.d.ts +19 -0
  48. package/dist/server.d.ts.map +1 -0
  49. package/dist/types.d.ts +71 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/util/schema.d.ts +20 -0
  52. package/dist/util/schema.d.ts.map +1 -0
  53. package/dist/util/zstd.d.ts +5 -0
  54. package/dist/util/zstd.d.ts.map +1 -0
  55. package/dist/wire/reader.d.ts +40 -0
  56. package/dist/wire/reader.d.ts.map +1 -0
  57. package/dist/wire/request.d.ts +15 -0
  58. package/dist/wire/request.d.ts.map +1 -0
  59. package/dist/wire/response.d.ts +25 -0
  60. package/dist/wire/response.d.ts.map +1 -0
  61. package/dist/wire/writer.d.ts +59 -0
  62. package/dist/wire/writer.d.ts.map +1 -0
  63. package/package.json +32 -0
  64. package/src/client/connect.ts +310 -0
  65. package/src/client/index.ts +14 -0
  66. package/src/client/introspect.ts +138 -0
  67. package/src/client/ipc.ts +225 -0
  68. package/src/client/pipe.ts +661 -0
  69. package/src/client/stream.ts +297 -0
  70. package/src/client/types.ts +31 -0
  71. package/src/constants.ts +22 -0
  72. package/src/dispatch/describe.ts +155 -0
  73. package/src/dispatch/stream.ts +151 -0
  74. package/src/dispatch/unary.ts +35 -0
  75. package/src/errors.ts +22 -0
  76. package/src/http/common.ts +89 -0
  77. package/src/http/dispatch.ts +340 -0
  78. package/src/http/handler.ts +247 -0
  79. package/src/http/index.ts +6 -0
  80. package/src/http/token.ts +149 -0
  81. package/src/http/types.ts +49 -0
  82. package/src/index.ts +52 -0
  83. package/src/protocol.ts +144 -0
  84. package/src/schema.ts +114 -0
  85. package/src/server.ts +159 -0
  86. package/src/types.ts +162 -0
  87. package/src/util/schema.ts +31 -0
  88. package/src/util/zstd.ts +49 -0
  89. package/src/wire/reader.ts +113 -0
  90. package/src/wire/request.ts +98 -0
  91. package/src/wire/response.ts +181 -0
  92. package/src/wire/writer.ts +137 -0
@@ -0,0 +1,297 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ RecordBatch,
6
+ Schema,
7
+ Field,
8
+ makeData,
9
+ Struct,
10
+ vectorFromArray,
11
+ } from "apache-arrow";
12
+ import { STATE_KEY } from "../constants.js";
13
+ import { RpcError } from "../errors.js";
14
+ import { ARROW_CONTENT_TYPE, serializeIpcStream } from "../http/common.js";
15
+ import {
16
+ inferArrowType,
17
+ dispatchLogOrError,
18
+ extractBatchRows,
19
+ readResponseBatches,
20
+ } from "./ipc.js";
21
+ import type { LogMessage, StreamSession } from "./types.js";
22
+
23
+ type CompressFn = (data: Uint8Array, level: number) => Uint8Array;
24
+ type DecompressFn = (data: Uint8Array) => Uint8Array;
25
+
26
+ export class HttpStreamSession implements StreamSession {
27
+ private _baseUrl: string;
28
+ private _prefix: string;
29
+ private _method: string;
30
+ private _stateToken: string | null;
31
+ private _outputSchema: Schema;
32
+ private _inputSchema?: Schema;
33
+ private _onLog?: (msg: LogMessage) => void;
34
+ private _pendingBatches: RecordBatch[];
35
+ private _finished: boolean;
36
+ private _header: Record<string, any> | null;
37
+ private _compressionLevel?: number;
38
+ private _compressFn?: CompressFn;
39
+ private _decompressFn?: DecompressFn;
40
+
41
+ constructor(opts: {
42
+ baseUrl: string;
43
+ prefix: string;
44
+ method: string;
45
+ stateToken: string | null;
46
+ outputSchema: Schema;
47
+ inputSchema?: Schema;
48
+ onLog?: (msg: LogMessage) => void;
49
+ pendingBatches: RecordBatch[];
50
+ finished: boolean;
51
+ header: Record<string, any> | null;
52
+ compressionLevel?: number;
53
+ compressFn?: CompressFn;
54
+ decompressFn?: DecompressFn;
55
+ }) {
56
+ this._baseUrl = opts.baseUrl;
57
+ this._prefix = opts.prefix;
58
+ this._method = opts.method;
59
+ this._stateToken = opts.stateToken;
60
+ this._outputSchema = opts.outputSchema;
61
+ this._inputSchema = opts.inputSchema;
62
+ this._onLog = opts.onLog;
63
+ this._pendingBatches = opts.pendingBatches;
64
+ this._finished = opts.finished;
65
+ this._header = opts.header;
66
+ this._compressionLevel = opts.compressionLevel;
67
+ this._compressFn = opts.compressFn;
68
+ this._decompressFn = opts.decompressFn;
69
+ }
70
+
71
+ get header(): Record<string, any> | null {
72
+ return this._header;
73
+ }
74
+
75
+ private _buildHeaders(): Record<string, string> {
76
+ const headers: Record<string, string> = {
77
+ "Content-Type": ARROW_CONTENT_TYPE,
78
+ };
79
+ if (this._compressionLevel != null) {
80
+ headers["Content-Encoding"] = "zstd";
81
+ headers["Accept-Encoding"] = "zstd";
82
+ }
83
+ return headers;
84
+ }
85
+
86
+ private _prepareBody(content: Uint8Array): Uint8Array {
87
+ if (this._compressionLevel != null && this._compressFn) {
88
+ return this._compressFn(content, this._compressionLevel);
89
+ }
90
+ return content;
91
+ }
92
+
93
+ private async _readResponse(resp: Response): Promise<Uint8Array<ArrayBuffer>> {
94
+ let body = new Uint8Array(await resp.arrayBuffer());
95
+ if (resp.headers.get("Content-Encoding") === "zstd" && this._decompressFn) {
96
+ body = new Uint8Array(this._decompressFn(body));
97
+ }
98
+ return body;
99
+ }
100
+
101
+ /**
102
+ * Send an exchange request and return the data rows.
103
+ */
104
+ async exchange(input: Record<string, any>[]): Promise<Record<string, any>[]> {
105
+ if (this._stateToken === null) {
106
+ throw new RpcError(
107
+ "ProtocolError",
108
+ "Stream has finished \u2014 no state token available",
109
+ "",
110
+ );
111
+ }
112
+
113
+ // We need to determine the input schema from the data.
114
+ // Build a batch from the input rows using the output schema's field types.
115
+ // For exchange, the input schema matches what the server expects.
116
+ // We'll use the keys from input[0] to figure out columns.
117
+ if (input.length === 0) {
118
+ // Zero-row exchange: build an empty batch with state token.
119
+ // Use inputSchema from __describe__ if available; fall back to
120
+ // outputSchema so the server sees the correct column names.
121
+ const zeroSchema = this._inputSchema ?? this._outputSchema;
122
+ const emptyBatch = this._buildEmptyBatch(zeroSchema);
123
+ const metadata = new Map<string, string>();
124
+ metadata.set(STATE_KEY, this._stateToken);
125
+ const batchWithMeta = new RecordBatch(
126
+ zeroSchema,
127
+ emptyBatch.data,
128
+ metadata,
129
+ );
130
+ return this._doExchange(zeroSchema, [batchWithMeta]);
131
+ }
132
+
133
+ // Infer schema from first row values (input schema may differ from output).
134
+ const keys = Object.keys(input[0]);
135
+ const fields = keys.map((key) => {
136
+ // Find first non-null value to infer type
137
+ let sample: any = undefined;
138
+ for (const row of input) {
139
+ if (row[key] != null) { sample = row[key]; break; }
140
+ }
141
+ const arrowType = inferArrowType(sample);
142
+ const nullable = input.some((row) => row[key] == null);
143
+ return new Field(key, arrowType, nullable);
144
+ });
145
+
146
+ const inputSchema = new Schema(fields);
147
+ const children = inputSchema.fields.map((f) => {
148
+ const values = input.map((row) => row[f.name]);
149
+ return vectorFromArray(values, f.type).data[0];
150
+ });
151
+
152
+ const structType = new Struct(inputSchema.fields);
153
+ const data = makeData({
154
+ type: structType,
155
+ length: input.length,
156
+ children,
157
+ nullCount: 0,
158
+ });
159
+
160
+ const metadata = new Map<string, string>();
161
+ metadata.set(STATE_KEY, this._stateToken);
162
+ const batch = new RecordBatch(inputSchema, data, metadata);
163
+
164
+ return this._doExchange(inputSchema, [batch]);
165
+ }
166
+
167
+ private async _doExchange(
168
+ schema: Schema,
169
+ batches: RecordBatch[],
170
+ ): Promise<Record<string, any>[]> {
171
+ 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
+ );
180
+
181
+ const responseBody = await this._readResponse(resp);
182
+ const { batches: responseBatches } = await readResponseBatches(responseBody);
183
+
184
+ let resultRows: Record<string, any>[] = [];
185
+ for (const batch of responseBatches) {
186
+ if (batch.numRows === 0) {
187
+ // Could be log/error or state token
188
+ dispatchLogOrError(batch, this._onLog);
189
+ // Check for state token
190
+ const token = batch.metadata?.get(STATE_KEY);
191
+ if (token) {
192
+ this._stateToken = token;
193
+ }
194
+ continue;
195
+ }
196
+
197
+ // Data batch — extract state token from metadata
198
+ const token = batch.metadata?.get(STATE_KEY);
199
+ if (token) {
200
+ this._stateToken = token;
201
+ }
202
+
203
+ resultRows = extractBatchRows(batch);
204
+ }
205
+
206
+ return resultRows;
207
+ }
208
+
209
+ private _buildEmptyBatch(schema: Schema): RecordBatch {
210
+ const children = schema.fields.map((f) => {
211
+ return makeData({ type: f.type, length: 0, nullCount: 0 });
212
+ });
213
+ const structType = new Struct(schema.fields);
214
+ const data = makeData({
215
+ type: structType,
216
+ length: 0,
217
+ children,
218
+ nullCount: 0,
219
+ });
220
+ return new RecordBatch(schema, data);
221
+ }
222
+
223
+ /**
224
+ * Iterate over producer stream batches.
225
+ */
226
+ async *[Symbol.asyncIterator](): AsyncIterableIterator<Record<string, any>[]> {
227
+ // Yield pre-loaded batches from init
228
+ for (const batch of this._pendingBatches) {
229
+ if (batch.numRows === 0) {
230
+ dispatchLogOrError(batch, this._onLog);
231
+ continue;
232
+ }
233
+ yield extractBatchRows(batch);
234
+ }
235
+ this._pendingBatches = [];
236
+
237
+ if (this._finished) return;
238
+ if (this._stateToken === null) return;
239
+
240
+ // Follow continuation tokens
241
+ while (true) {
242
+ const responseBody = await this._sendContinuation(this._stateToken);
243
+ const { batches } = await readResponseBatches(responseBody);
244
+
245
+ let gotContinuation = false;
246
+ for (const batch of batches) {
247
+ if (batch.numRows === 0) {
248
+ // Check for continuation token
249
+ const token = batch.metadata?.get(STATE_KEY);
250
+ if (token) {
251
+ this._stateToken = token;
252
+ gotContinuation = true;
253
+ continue;
254
+ }
255
+ // Log/error batch
256
+ dispatchLogOrError(batch, this._onLog);
257
+ continue;
258
+ }
259
+
260
+ yield extractBatchRows(batch);
261
+ }
262
+
263
+ if (!gotContinuation) break;
264
+ }
265
+ }
266
+
267
+ private async _sendContinuation(token: string): Promise<Uint8Array> {
268
+ const emptySchema = new Schema([]);
269
+ const metadata = new Map<string, string>();
270
+ metadata.set(STATE_KEY, token);
271
+
272
+ const structType = new Struct(emptySchema.fields);
273
+ const data = makeData({
274
+ type: structType,
275
+ length: 1,
276
+ children: [],
277
+ nullCount: 0,
278
+ });
279
+ const batch = new RecordBatch(emptySchema, data, metadata);
280
+ const body = serializeIpcStream(emptySchema, [batch]);
281
+
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
+ );
290
+
291
+ return this._readResponse(resp);
292
+ }
293
+
294
+ close(): void {
295
+ // No-op for HTTP (stateless)
296
+ }
297
+ }
@@ -0,0 +1,31 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export interface HttpConnectOptions {
5
+ prefix?: string;
6
+ onLog?: (msg: LogMessage) => void;
7
+ compressionLevel?: number;
8
+ }
9
+
10
+ export interface LogMessage {
11
+ level: string;
12
+ message: string;
13
+ extra?: Record<string, any>;
14
+ }
15
+
16
+ export interface StreamSession {
17
+ readonly header: Record<string, any> | null;
18
+ exchange(input: Record<string, any>[]): Promise<Record<string, any>[]>;
19
+ [Symbol.asyncIterator](): AsyncIterableIterator<Record<string, any>[]>;
20
+ close(): void;
21
+ }
22
+
23
+ export interface PipeConnectOptions {
24
+ onLog?: (msg: LogMessage) => void;
25
+ }
26
+
27
+ export interface SubprocessConnectOptions extends PipeConnectOptions {
28
+ cwd?: string;
29
+ env?: Record<string, string>;
30
+ stderr?: "inherit" | "pipe" | "ignore";
31
+ }
@@ -0,0 +1,22 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** Well-known metadata keys matching Python's metadata.py */
5
+
6
+ export const RPC_METHOD_KEY = "vgi_rpc.method";
7
+ export const LOG_LEVEL_KEY = "vgi_rpc.log_level";
8
+ export const LOG_MESSAGE_KEY = "vgi_rpc.log_message";
9
+ export const LOG_EXTRA_KEY = "vgi_rpc.log_extra";
10
+ export const REQUEST_VERSION_KEY = "vgi_rpc.request_version";
11
+ export const REQUEST_VERSION = "1";
12
+
13
+ export const SERVER_ID_KEY = "vgi_rpc.server_id";
14
+ export const REQUEST_ID_KEY = "vgi_rpc.request_id";
15
+
16
+ export const PROTOCOL_NAME_KEY = "vgi_rpc.protocol_name";
17
+ export const DESCRIBE_VERSION_KEY = "vgi_rpc.describe_version";
18
+ export const DESCRIBE_VERSION = "2";
19
+
20
+ export const DESCRIBE_METHOD_NAME = "__describe__";
21
+
22
+ export const STATE_KEY = "vgi_rpc.stream_state#b64";
@@ -0,0 +1,155 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ Schema,
6
+ Field,
7
+ RecordBatch,
8
+ Utf8,
9
+ Bool,
10
+ Binary,
11
+ vectorFromArray,
12
+ makeData,
13
+ Struct,
14
+ } from "apache-arrow";
15
+ import type { MethodDefinition } from "../types.js";
16
+ import {
17
+ PROTOCOL_NAME_KEY,
18
+ REQUEST_VERSION_KEY,
19
+ REQUEST_VERSION,
20
+ DESCRIBE_VERSION_KEY,
21
+ DESCRIBE_VERSION,
22
+ SERVER_ID_KEY,
23
+ } from "../constants.js";
24
+ import { serializeSchema } from "../util/schema.js";
25
+
26
+ /**
27
+ * The schema for the __describe__ response, matching Python's _DESCRIBE_SCHEMA.
28
+ */
29
+ export const DESCRIBE_SCHEMA = new Schema([
30
+ new Field("name", new Utf8(), false),
31
+ new Field("method_type", new Utf8(), false),
32
+ new Field("doc", new Utf8(), true),
33
+ new Field("has_return", new Bool(), false),
34
+ new Field("params_schema_ipc", new Binary(), false),
35
+ new Field("result_schema_ipc", new Binary(), false),
36
+ new Field("param_types_json", new Utf8(), true),
37
+ new Field("param_defaults_json", new Utf8(), true),
38
+ new Field("has_header", new Bool(), false),
39
+ new Field("header_schema_ipc", new Binary(), true),
40
+ ]);
41
+
42
+ /**
43
+ * Build the __describe__ response batch and metadata.
44
+ */
45
+ export function buildDescribeBatch(
46
+ protocolName: string,
47
+ methods: Map<string, MethodDefinition>,
48
+ serverId: string,
49
+ ): { batch: RecordBatch; metadata: Map<string, string> } {
50
+ // Sort methods by name for consistent ordering
51
+ const sortedEntries = [...methods.entries()].sort(([a], [b]) =>
52
+ a.localeCompare(b),
53
+ );
54
+
55
+ const names: (string | null)[] = [];
56
+ const methodTypes: (string | null)[] = [];
57
+ const docs: (string | null)[] = [];
58
+ const hasReturns: boolean[] = [];
59
+ const paramsSchemas: (Uint8Array | null)[] = [];
60
+ const resultSchemas: (Uint8Array | null)[] = [];
61
+ const paramTypesJsons: (string | null)[] = [];
62
+ const paramDefaultsJsons: (string | null)[] = [];
63
+ const hasHeaders: boolean[] = [];
64
+ const headerSchemas: (Uint8Array | null)[] = [];
65
+
66
+ for (const [name, method] of sortedEntries) {
67
+ names.push(name);
68
+ methodTypes.push(method.type);
69
+ docs.push(method.doc ?? null);
70
+
71
+ // Unary methods with non-empty result schema have a return value
72
+ const hasReturn =
73
+ method.type === "unary" && method.resultSchema.fields.length > 0;
74
+ hasReturns.push(hasReturn);
75
+
76
+ paramsSchemas.push(serializeSchema(method.paramsSchema));
77
+ resultSchemas.push(serializeSchema(method.resultSchema));
78
+
79
+ // Build param_types_json
80
+ if (method.paramTypes && Object.keys(method.paramTypes).length > 0) {
81
+ paramTypesJsons.push(JSON.stringify(method.paramTypes));
82
+ } else {
83
+ paramTypesJsons.push(null);
84
+ }
85
+
86
+ // Build param_defaults_json
87
+ if (method.defaults && Object.keys(method.defaults).length > 0) {
88
+ const safe: Record<string, any> = {};
89
+ 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
+ ) {
96
+ safe[k] = v;
97
+ }
98
+ }
99
+ paramDefaultsJsons.push(
100
+ Object.keys(safe).length > 0 ? JSON.stringify(safe) : null,
101
+ );
102
+ } else {
103
+ paramDefaultsJsons.push(null);
104
+ }
105
+
106
+ hasHeaders.push(!!method.headerSchema);
107
+ headerSchemas.push(
108
+ method.headerSchema ? serializeSchema(method.headerSchema) : null,
109
+ );
110
+ }
111
+
112
+ // Build the batch using vectorFromArray for each column
113
+ const nameArr = vectorFromArray(names, new Utf8());
114
+ const methodTypeArr = vectorFromArray(methodTypes, new Utf8());
115
+ const docArr = vectorFromArray(docs, new Utf8());
116
+ const hasReturnArr = vectorFromArray(hasReturns, new Bool());
117
+ const paramsSchemaArr = vectorFromArray(paramsSchemas, new Binary());
118
+ const resultSchemaArr = vectorFromArray(resultSchemas, new Binary());
119
+ const paramTypesArr = vectorFromArray(paramTypesJsons, new Utf8());
120
+ const paramDefaultsArr = vectorFromArray(paramDefaultsJsons, new Utf8());
121
+ const hasHeaderArr = vectorFromArray(hasHeaders, new Bool());
122
+ const headerSchemaArr = vectorFromArray(headerSchemas, new Binary());
123
+
124
+ const children = [
125
+ nameArr.data[0],
126
+ methodTypeArr.data[0],
127
+ docArr.data[0],
128
+ hasReturnArr.data[0],
129
+ paramsSchemaArr.data[0],
130
+ resultSchemaArr.data[0],
131
+ paramTypesArr.data[0],
132
+ paramDefaultsArr.data[0],
133
+ hasHeaderArr.data[0],
134
+ headerSchemaArr.data[0],
135
+ ];
136
+
137
+ const structType = new Struct(DESCRIBE_SCHEMA.fields);
138
+ const data = makeData({
139
+ type: structType,
140
+ length: sortedEntries.length,
141
+ children,
142
+ nullCount: 0,
143
+ });
144
+
145
+ // Build metadata for the batch
146
+ const metadata = new Map<string, string>();
147
+ metadata.set(PROTOCOL_NAME_KEY, protocolName);
148
+ metadata.set(REQUEST_VERSION_KEY, REQUEST_VERSION);
149
+ metadata.set(DESCRIBE_VERSION_KEY, DESCRIBE_VERSION);
150
+ metadata.set(SERVER_ID_KEY, serverId);
151
+
152
+ const batch = new RecordBatch(DESCRIBE_SCHEMA, data, metadata);
153
+
154
+ return { batch, metadata };
155
+ }
@@ -0,0 +1,151 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Schema } from "apache-arrow";
5
+ import type { MethodDefinition } from "../types.js";
6
+ import { OutputCollector } from "../types.js";
7
+ import type { IpcStreamWriter } from "../wire/writer.js";
8
+ import type { IpcStreamReader } from "../wire/reader.js";
9
+ import { buildResultBatch, buildErrorBatch } from "../wire/response.js";
10
+
11
+ const EMPTY_SCHEMA = new Schema([]);
12
+
13
+ /**
14
+ * Dispatch a stream RPC call (producer or exchange).
15
+ *
16
+ * Producer streams (empty input schema):
17
+ * - Client sends tick batches (empty schema, 0 rows)
18
+ * - Server reads each tick, calls produce(state, out)
19
+ * - Server writes output batch(es) for each tick
20
+ * - When produce() calls out.finish(), server closes output stream
21
+ *
22
+ * Exchange streams (real input schema):
23
+ * - Client sends data batches
24
+ * - Server reads each batch, calls exchange(state, input, out)
25
+ * - Server writes output batch(es) for each input
26
+ * - Stream ends when client closes input (EOS)
27
+ */
28
+ export async function dispatchStream(
29
+ method: MethodDefinition,
30
+ params: Record<string, any>,
31
+ writer: IpcStreamWriter,
32
+ reader: IpcStreamReader,
33
+ serverId: string,
34
+ requestId: string | null,
35
+ ): Promise<void> {
36
+ const isProducer =
37
+ !method.inputSchema || method.inputSchema.fields.length === 0;
38
+
39
+ let state: any;
40
+ try {
41
+ if (isProducer) {
42
+ state = await method.producerInit!(params);
43
+ } else {
44
+ state = await method.exchangeInit!(params);
45
+ }
46
+ } catch (error: any) {
47
+ const errSchema = method.headerSchema ?? EMPTY_SCHEMA;
48
+ const errBatch = buildErrorBatch(errSchema, error, serverId, requestId);
49
+ writer.writeStream(errSchema, [errBatch]);
50
+ // Still need to consume the input stream from the client
51
+ const inputSchema = await reader.openNextStream();
52
+ if (inputSchema) {
53
+ while ((await reader.readNextBatch()) !== null) {
54
+ // drain
55
+ }
56
+ }
57
+ return;
58
+ }
59
+
60
+ // Support dynamic output schemas: init may return state with __outputSchema
61
+ // to override the method's registered output schema (needed for methods
62
+ // like VGI's "init" that produce different schemas per invocation).
63
+ const outputSchema = state?.__outputSchema ?? method.outputSchema!;
64
+
65
+ // Effective producer mode: check state override (VGI "init" is registered as
66
+ // exchange but may act as producer depending on the function type).
67
+ const effectiveProducer = state?.__isProducer ?? isProducer;
68
+
69
+ // Write header IPC stream if method has a header schema
70
+ if (method.headerSchema && method.headerInit) {
71
+ try {
72
+ const headerOut = new OutputCollector(method.headerSchema, true, serverId, requestId);
73
+ 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
+ ];
84
+ writer.writeStream(method.headerSchema, headerBatches);
85
+ } catch (error: any) {
86
+ const errBatch = buildErrorBatch(method.headerSchema, error, serverId, requestId);
87
+ writer.writeStream(method.headerSchema, [errBatch]);
88
+ // Drain input stream so client doesn't hang
89
+ const inputSchema = await reader.openNextStream();
90
+ if (inputSchema) {
91
+ while ((await reader.readNextBatch()) !== null) {}
92
+ }
93
+ return;
94
+ }
95
+ }
96
+
97
+ // Open the input IPC stream (ticks or data from client)
98
+ const inputSchema = await reader.openNextStream();
99
+ if (!inputSchema) {
100
+ const errBatch = buildErrorBatch(
101
+ outputSchema,
102
+ new Error("Expected input stream but got EOF"),
103
+ serverId,
104
+ requestId,
105
+ );
106
+ writer.writeStream(outputSchema, [errBatch]);
107
+ return;
108
+ }
109
+
110
+ // Use a single continuous IPC stream for all output (matching Python vgi-rpc).
111
+ // DuckDB exchanges are ping-pong: one input batch → one output batch on the
112
+ // same stream. We use IncrementalStream which writes bytes synchronously.
113
+ const stream = writer.openStream(outputSchema);
114
+
115
+ try {
116
+ while (true) {
117
+ const inputBatch = await reader.readNextBatch();
118
+ if (!inputBatch) break;
119
+
120
+ const out = new OutputCollector(outputSchema, effectiveProducer, serverId, requestId);
121
+
122
+ if (isProducer) {
123
+ await method.producerFn!(state, out);
124
+ } else {
125
+ await method.exchangeFn!(state, inputBatch, out);
126
+ }
127
+
128
+ for (const emitted of out.batches) {
129
+ stream.write(emitted.batch);
130
+ }
131
+
132
+ if (out.finished) {
133
+ break;
134
+ }
135
+ }
136
+ } catch (error: any) {
137
+ stream.write(buildErrorBatch(outputSchema, error, serverId, requestId));
138
+ }
139
+
140
+ stream.close();
141
+
142
+ // Drain remaining input so transport stays synchronized for next request.
143
+ // Matches Python's _drain_stream() called after every streaming method.
144
+ // Needed when the loop exits early (out.finished, error) while client
145
+ // is still sending batches.
146
+ try {
147
+ while ((await reader.readNextBatch()) !== null) {}
148
+ } catch {
149
+ // Suppress errors during drain (broken pipe, etc.)
150
+ }
151
+ }
@@ -0,0 +1,35 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Schema } from "apache-arrow";
5
+ import type { MethodDefinition } from "../types.js";
6
+ import { OutputCollector } from "../types.js";
7
+ import type { IpcStreamWriter } from "../wire/writer.js";
8
+ import { buildResultBatch, buildErrorBatch } from "../wire/response.js";
9
+
10
+ /**
11
+ * Dispatch a unary RPC call.
12
+ * Calls the handler with parsed params, writes result or error batch.
13
+ * Supports client-directed logging via ctx.clientLog().
14
+ */
15
+ export async function dispatchUnary(
16
+ method: MethodDefinition,
17
+ params: Record<string, any>,
18
+ writer: IpcStreamWriter,
19
+ serverId: string,
20
+ requestId: string | null,
21
+ ): Promise<void> {
22
+ const schema = method.resultSchema;
23
+ const out = new OutputCollector(schema, true, serverId, requestId);
24
+
25
+ try {
26
+ const result = await method.handler!(params, out);
27
+ const resultBatch = buildResultBatch(schema, result, serverId, requestId);
28
+ // Collect log batches (from clientLog) + result batch
29
+ const batches = [...out.batches.map((b) => b.batch), resultBatch];
30
+ writer.writeStream(schema, batches);
31
+ } catch (error: any) {
32
+ const batch = buildErrorBatch(schema, error, serverId, requestId);
33
+ writer.writeStream(schema, [batch]);
34
+ }
35
+ }