@query-farm/vgi-rpc 0.6.4 → 0.7.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 (160) hide show
  1. package/dist/access-log.d.ts +50 -0
  2. package/dist/access-log.d.ts.map +1 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
  4. package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
  5. package/dist/arrow/impl-flechette/index.d.ts +102 -0
  6. package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
  7. package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
  8. package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
  9. package/dist/arrow/index.d.ts +4 -0
  10. package/dist/arrow/index.d.ts.map +1 -0
  11. package/dist/arrow/predicates.d.ts +44 -0
  12. package/dist/arrow/predicates.d.ts.map +1 -0
  13. package/dist/arrow/types.d.ts +62 -0
  14. package/dist/arrow/types.d.ts.map +1 -0
  15. package/dist/client/capabilities.d.ts +25 -0
  16. package/dist/client/capabilities.d.ts.map +1 -0
  17. package/dist/client/connect.d.ts.map +1 -1
  18. package/dist/client/introspect.d.ts +7 -0
  19. package/dist/client/introspect.d.ts.map +1 -1
  20. package/dist/client/ipc.d.ts +8 -2
  21. package/dist/client/ipc.d.ts.map +1 -1
  22. package/dist/client/pipe.d.ts.map +1 -1
  23. package/dist/client/stream.d.ts +11 -2
  24. package/dist/client/stream.d.ts.map +1 -1
  25. package/dist/client/uploadUrl.d.ts +25 -0
  26. package/dist/client/uploadUrl.d.ts.map +1 -0
  27. package/dist/constants.d.ts +15 -1
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/crypto.d.ts +22 -0
  30. package/dist/crypto.d.ts.map +1 -0
  31. package/dist/dispatch/describe.d.ts +10 -6
  32. package/dist/dispatch/describe.d.ts.map +1 -1
  33. package/dist/dispatch/stream.d.ts +2 -2
  34. package/dist/dispatch/stream.d.ts.map +1 -1
  35. package/dist/dispatch/unary.d.ts +2 -2
  36. package/dist/dispatch/unary.d.ts.map +1 -1
  37. package/dist/errors.d.ts +46 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/external.d.ts +25 -5
  40. package/dist/external.d.ts.map +1 -1
  41. package/dist/http/bearer.d.ts.map +1 -1
  42. package/dist/http/common.d.ts +42 -7
  43. package/dist/http/common.d.ts.map +1 -1
  44. package/dist/http/dispatch.d.ts +20 -2
  45. package/dist/http/dispatch.d.ts.map +1 -1
  46. package/dist/http/handler.d.ts.map +1 -1
  47. package/dist/http/index.d.ts +1 -0
  48. package/dist/http/index.d.ts.map +1 -1
  49. package/dist/http/mtls.d.ts +2 -1
  50. package/dist/http/mtls.d.ts.map +1 -1
  51. package/dist/http/oauth-pkce.d.ts +141 -0
  52. package/dist/http/oauth-pkce.d.ts.map +1 -0
  53. package/dist/http/pages.d.ts +3 -0
  54. package/dist/http/pages.d.ts.map +1 -1
  55. package/dist/http/sticky.d.ts +124 -0
  56. package/dist/http/sticky.d.ts.map +1 -0
  57. package/dist/http/token.d.ts +38 -12
  58. package/dist/http/token.d.ts.map +1 -1
  59. package/dist/http/types.d.ts +66 -5
  60. package/dist/http/types.d.ts.map +1 -1
  61. package/dist/index.d.ts +6 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1275 -3511
  64. package/dist/index.js.map +19 -37
  65. package/dist/launcher/hash.d.ts +22 -0
  66. package/dist/launcher/hash.d.ts.map +1 -0
  67. package/dist/launcher/index.d.ts +23 -0
  68. package/dist/launcher/index.d.ts.map +1 -0
  69. package/dist/launcher/launch.d.ts +27 -0
  70. package/dist/launcher/launch.d.ts.map +1 -0
  71. package/dist/launcher/lock.d.ts +19 -0
  72. package/dist/launcher/lock.d.ts.map +1 -0
  73. package/dist/launcher/serve-unix.d.ts +54 -0
  74. package/dist/launcher/serve-unix.d.ts.map +1 -0
  75. package/dist/launcher/state.d.ts +59 -0
  76. package/dist/launcher/state.d.ts.map +1 -0
  77. package/dist/otel.d.ts.map +1 -1
  78. package/dist/protocol.d.ts +16 -2
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/schema.d.ts +45 -18
  81. package/dist/schema.d.ts.map +1 -1
  82. package/dist/server.d.ts +23 -2
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/types.d.ts +216 -12
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/util/gzip.d.ts +10 -0
  87. package/dist/util/gzip.d.ts.map +1 -0
  88. package/dist/util/schema.d.ts +3 -15
  89. package/dist/util/schema.d.ts.map +1 -1
  90. package/dist/util/web-crypto.d.ts +22 -0
  91. package/dist/util/web-crypto.d.ts.map +1 -0
  92. package/dist/util/zstd.d.ts +26 -3
  93. package/dist/util/zstd.d.ts.map +1 -1
  94. package/dist/wire/opaque.d.ts +11 -0
  95. package/dist/wire/opaque.d.ts.map +1 -0
  96. package/dist/wire/reader.d.ts +5 -5
  97. package/dist/wire/reader.d.ts.map +1 -1
  98. package/dist/wire/request.d.ts +11 -3
  99. package/dist/wire/request.d.ts.map +1 -1
  100. package/dist/wire/response.d.ts +6 -6
  101. package/dist/wire/response.d.ts.map +1 -1
  102. package/dist/wire/writer.d.ts +49 -39
  103. package/dist/wire/writer.d.ts.map +1 -1
  104. package/package.json +24 -10
  105. package/src/access-log.ts +195 -0
  106. package/src/arrow/impl-arrowjs/index.ts +433 -0
  107. package/src/arrow/impl-flechette/index.ts +414 -0
  108. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  109. package/src/arrow/index.ts +89 -0
  110. package/src/arrow/predicates.ts +56 -0
  111. package/src/arrow/types.ts +73 -0
  112. package/src/client/capabilities.ts +84 -0
  113. package/src/client/connect.ts +103 -26
  114. package/src/client/introspect.ts +60 -38
  115. package/src/client/ipc.ts +37 -27
  116. package/src/client/pipe.ts +12 -9
  117. package/src/client/stream.ts +34 -19
  118. package/src/client/uploadUrl.ts +169 -0
  119. package/src/constants.ts +18 -1
  120. package/src/crypto.ts +95 -0
  121. package/src/dispatch/describe.ts +146 -107
  122. package/src/dispatch/stream.ts +53 -24
  123. package/src/dispatch/unary.ts +5 -4
  124. package/src/errors.ts +76 -0
  125. package/src/external.ts +43 -29
  126. package/src/http/bearer.ts +2 -5
  127. package/src/http/common.ts +90 -23
  128. package/src/http/dispatch.ts +373 -46
  129. package/src/http/handler.ts +790 -68
  130. package/src/http/index.ts +1 -0
  131. package/src/http/mtls.ts +18 -3
  132. package/src/http/oauth-pkce.ts +1035 -0
  133. package/src/http/pages.ts +30 -15
  134. package/src/http/sticky.ts +429 -0
  135. package/src/http/token.ts +165 -75
  136. package/src/http/types.ts +67 -5
  137. package/src/index.ts +40 -1
  138. package/src/launcher/hash.ts +104 -0
  139. package/src/launcher/index.ts +35 -0
  140. package/src/launcher/launch.ts +284 -0
  141. package/src/launcher/lock.ts +171 -0
  142. package/src/launcher/serve-unix.ts +385 -0
  143. package/src/launcher/state.ts +245 -0
  144. package/src/otel.ts +39 -33
  145. package/src/protocol.ts +27 -3
  146. package/src/schema.ts +107 -56
  147. package/src/server.ts +196 -20
  148. package/src/types.ts +322 -18
  149. package/src/util/gzip.ts +63 -0
  150. package/src/util/schema.ts +4 -22
  151. package/src/util/web-crypto.ts +98 -0
  152. package/src/util/zstd.ts +133 -14
  153. package/src/wire/opaque.ts +37 -0
  154. package/src/wire/reader.ts +5 -4
  155. package/src/wire/request.ts +67 -8
  156. package/src/wire/response.ts +51 -85
  157. package/src/wire/writer.ts +165 -69
  158. package/dist/util/conform.d.ts +0 -18
  159. package/dist/util/conform.d.ts.map +0 -1
  160. package/src/util/conform.ts +0 -94
package/src/external.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * download URL and SHA-256 checksum in metadata.
11
11
  */
12
12
 
13
- import { type RecordBatch, RecordBatchReader, RecordBatchStreamWriter, type Schema } from "@query-farm/apache-arrow";
13
+ import { deserializeBatch, serializeBatch, type VgiBatch, type VgiSchema } from "./arrow/index.js";
14
14
  import { LOCATION_KEY, LOCATION_SHA256_KEY, LOG_LEVEL_KEY } from "./constants.js";
15
15
  import { zstdCompress, zstdDecompress } from "./util/zstd.js";
16
16
  import { buildEmptyBatch } from "./wire/response.js";
@@ -25,6 +25,28 @@ export interface ExternalStorage {
25
25
  upload(data: Uint8Array, contentEncoding: string): Promise<string>;
26
26
  }
27
27
 
28
+ /** A pre-signed PUT/GET URL pair for client-side data upload. */
29
+ export interface UploadUrl {
30
+ /** Pre-signed PUT URL the client uploads to. */
31
+ uploadUrl: string;
32
+ /** Pre-signed GET URL the server fetches from. */
33
+ downloadUrl: string;
34
+ /** Expiration time (UTC) for the URL pair. */
35
+ expiresAt: Date;
36
+ }
37
+
38
+ /**
39
+ * Generates pre-signed upload URL pairs for client-vended externalization.
40
+ *
41
+ * Implementations must be safe to call from multiple concurrent requests.
42
+ * Object lifecycle is the operator's responsibility — uploaded objects are
43
+ * not automatically deleted by vgi-rpc.
44
+ */
45
+ export interface UploadUrlProvider {
46
+ /** Allocate one upload/download URL pair. */
47
+ generateUploadUrl(): Promise<UploadUrl> | UploadUrl;
48
+ }
49
+
28
50
  /** Configuration for external storage of large batches. */
29
51
  export interface ExternalLocationConfig {
30
52
  /** Storage backend for uploading. */
@@ -70,7 +92,7 @@ async function sha256Hex(data: Uint8Array): Promise<string> {
70
92
  // ---------------------------------------------------------------------------
71
93
 
72
94
  /** Returns true if the batch is a zero-row pointer to external data. */
73
- export function isExternalLocationBatch(batch: RecordBatch): boolean {
95
+ export function isExternalLocationBatch(batch: VgiBatch): boolean {
74
96
  if (batch.numRows !== 0) return false;
75
97
  const meta = batch.metadata;
76
98
  if (!meta) return false;
@@ -82,7 +104,7 @@ export function isExternalLocationBatch(batch: RecordBatch): boolean {
82
104
  // ---------------------------------------------------------------------------
83
105
 
84
106
  /** Create a zero-row pointer batch with location URL and optional SHA-256. */
85
- export function makeExternalLocationBatch(schema: Schema, url: string, sha256?: string): RecordBatch {
107
+ export function makeExternalLocationBatch(schema: VgiSchema, url: string, sha256?: string): VgiBatch {
86
108
  const metadata = new Map<string, string>();
87
109
  metadata.set(LOCATION_KEY, url);
88
110
  if (sha256) {
@@ -95,22 +117,13 @@ export function makeExternalLocationBatch(schema: Schema, url: string, sha256?:
95
117
  // IPC serialization helpers
96
118
  // ---------------------------------------------------------------------------
97
119
 
98
- function serializeBatchToIpc(batch: RecordBatch): Uint8Array {
99
- const writer = new RecordBatchStreamWriter();
100
- writer.reset(undefined, batch.schema);
101
- writer.write(batch);
102
- writer.close();
103
- return writer.toUint8Array(true);
120
+ function serializeBatchToIpc(batch: VgiBatch): Uint8Array {
121
+ return serializeBatch(batch);
104
122
  }
105
123
 
106
- function batchByteSize(batch: RecordBatch): number {
107
- // Arrow TS data.byteLength doesn't reflect actual data size.
124
+ function batchByteSize(batch: VgiBatch): number {
108
125
  // Estimate from IPC serialization size for threshold check.
109
- const writer = new RecordBatchStreamWriter();
110
- writer.reset(undefined, batch.schema);
111
- writer.write(batch);
112
- writer.close();
113
- return writer.toUint8Array(true).byteLength;
126
+ return serializeBatch(batch).byteLength;
114
127
  }
115
128
 
116
129
  // ---------------------------------------------------------------------------
@@ -122,9 +135,9 @@ function batchByteSize(batch: RecordBatch): number {
122
135
  * Returns the original batch unchanged if below threshold or no config.
123
136
  */
124
137
  export async function maybeExternalizeBatch(
125
- batch: RecordBatch,
138
+ batch: VgiBatch,
126
139
  config?: ExternalLocationConfig | null,
127
- ): Promise<RecordBatch> {
140
+ ): Promise<VgiBatch> {
128
141
  if (!config?.storage) return batch;
129
142
  if (batch.numRows === 0) return batch;
130
143
 
@@ -140,7 +153,7 @@ export async function maybeExternalizeBatch(
140
153
  // Optionally compress
141
154
  let contentEncoding = "";
142
155
  if (config.compression?.algorithm === "zstd") {
143
- ipcData = zstdCompress(ipcData, config.compression.level ?? 3) as Uint8Array;
156
+ ipcData = (await zstdCompress(ipcData, config.compression.level ?? 3)) as Uint8Array;
144
157
  contentEncoding = "zstd";
145
158
  }
146
159
 
@@ -160,9 +173,9 @@ export async function maybeExternalizeBatch(
160
173
  * Returns the original batch unchanged if not a pointer or no config.
161
174
  */
162
175
  export async function resolveExternalLocation(
163
- batch: RecordBatch,
176
+ batch: VgiBatch,
164
177
  config?: ExternalLocationConfig | null,
165
- ): Promise<RecordBatch> {
178
+ ): Promise<VgiBatch> {
166
179
  if (!config) return batch;
167
180
  if (!isExternalLocationBatch(batch)) return batch;
168
181
 
@@ -182,10 +195,14 @@ export async function resolveExternalLocation(
182
195
  }
183
196
  let data = new Uint8Array(await response.arrayBuffer());
184
197
 
185
- // Decompress if needed
198
+ // Decompress if needed. Cap the decompressed size at 16x the
199
+ // compressed body — generous for typical Arrow IPC zstd ratios but
200
+ // tight enough that a tiny response cannot inflate to multi-GB.
201
+ // Mirrors Python's external_fetch.fetch_url.
186
202
  const contentEncoding = response.headers.get("Content-Encoding");
187
203
  if (contentEncoding === "zstd") {
188
- data = new Uint8Array(zstdDecompress(data));
204
+ const cap = data.byteLength * 16;
205
+ data = new Uint8Array(await zstdDecompress(data, cap));
189
206
  }
190
207
 
191
208
  // Verify SHA-256 if present
@@ -198,12 +215,9 @@ export async function resolveExternalLocation(
198
215
  }
199
216
 
200
217
  // Parse IPC stream
201
- const reader = await RecordBatchReader.from(data);
202
- await reader.open();
203
- const resolved = reader.next();
204
- if (!resolved || resolved.done || !resolved.value) {
218
+ const resolved = deserializeBatch(data);
219
+ if (resolved.numRows === 0 && resolved.schema.fields.length === 0) {
205
220
  throw new Error(`No data batch found in external IPC stream from ${url}`);
206
221
  }
207
-
208
- return resolved.value;
222
+ return resolved;
209
223
  }
@@ -1,8 +1,8 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { timingSafeEqual } from "node:crypto";
5
4
  import type { AuthContext } from "../auth.js";
5
+ import { constantTimeEqual } from "../util/web-crypto.js";
6
6
  import type { AuthenticateFn } from "./auth.js";
7
7
 
8
8
  /** Receives the raw bearer token string, returns an AuthContext on success. Must throw on failure. */
@@ -30,10 +30,7 @@ export function bearerAuthenticate(options: { validate: BearerValidateFn }): Aut
30
30
  /** Constant-time string comparison to prevent timing attacks on token lookup. */
31
31
  function safeEqual(a: string, b: string): boolean {
32
32
  const enc = new TextEncoder();
33
- const bufA = enc.encode(a);
34
- const bufB = enc.encode(b);
35
- if (bufA.byteLength !== bufB.byteLength) return false;
36
- return timingSafeEqual(bufA, bufB);
33
+ return constantTimeEqual(enc.encode(a), enc.encode(b));
37
34
  }
38
35
 
39
36
  /**
@@ -1,11 +1,66 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { type RecordBatch, RecordBatchReader, RecordBatchStreamWriter, type Schema } from "@query-farm/apache-arrow";
5
- import { conformBatchToSchema } from "../util/conform.js";
4
+ import {
5
+ conformBatchToSchema,
6
+ deserializeBatch,
7
+ serializeBatches,
8
+ type VgiBatch,
9
+ type VgiSchema,
10
+ } from "../arrow/index.js";
11
+ import { RPC_ERROR_HEADER } from "../constants.js";
12
+ import type { CookieSpec } from "../types.js";
6
13
 
7
14
  export const ARROW_CONTENT_TYPE = "application/vnd.apache.arrow.stream";
8
15
 
16
+ // Sticky session header conventions (HTTP-only). Mirrors Python's
17
+ // `vgi_rpc.http._common`. Headers — not cookies — so multiple concurrent
18
+ // sessions to one host from a single client multiplex correctly.
19
+ export const SESSION_HEADER = "VGI-Session";
20
+ export const SESSION_ACCEPT_HEADER = "VGI-Session-Accept";
21
+ export const SESSION_CLOSE_HEADER = "VGI-Session-Close";
22
+ export const STICKY_ENABLED_HEADER = "VGI-Sticky-Enabled";
23
+ export const STICKY_DEFAULT_TTL_HEADER = "VGI-Sticky-Default-TTL";
24
+ export const STICKY_ECHO_HEADERS_HEADER = "VGI-Sticky-Echo-Headers";
25
+
26
+ /** Prefix the server uses to tell the client "echo this header on subsequent
27
+ * requests in this session". Clients capture and replay
28
+ * `VGI-Echo-<name>: <value>` as plain `<name>: <value>` for the session
29
+ * lifetime — used for client-driven routing (e.g. `fly-force-instance-id`). */
30
+ export const ECHO_HEADER_PREFIX = "VGI-Echo-";
31
+
32
+ /** Framework-managed sticky session teardown endpoint path component.
33
+ * `DELETE {prefix}/__session__` idempotently closes the session referenced
34
+ * by the request's `VGI-Session` header. */
35
+ export const SESSION_ENDPOINT = "__session__";
36
+
37
+ /** Serialize a CookieSpec into a Set-Cookie header value. */
38
+ export function formatSetCookieHeader(c: CookieSpec): string {
39
+ const parts: string[] = [];
40
+ if (c.delete) {
41
+ parts.push(`${c.name}=`);
42
+ parts.push("Max-Age=0");
43
+ } else {
44
+ parts.push(`${c.name}=${c.value}`);
45
+ if (c.maxAge !== undefined) parts.push(`Max-Age=${c.maxAge}`);
46
+ if (c.expires) parts.push(`Expires=${c.expires.toUTCString()}`);
47
+ }
48
+ if (c.path) parts.push(`Path=${c.path}`);
49
+ if (c.domain) parts.push(`Domain=${c.domain}`);
50
+ if (c.secure) parts.push("Secure");
51
+ if (c.httpOnly) parts.push("HttpOnly");
52
+ if (c.sameSite) parts.push(`SameSite=${c.sameSite}`);
53
+ if (c.partitioned) parts.push("Partitioned");
54
+ return parts.join("; ");
55
+ }
56
+
57
+ /** Append Set-Cookie headers for each queued CookieSpec onto an existing Headers object. */
58
+ export function appendCookieHeaders(headers: Headers, cookies: readonly CookieSpec[]): void {
59
+ for (const c of cookies) {
60
+ headers.append("Set-Cookie", formatSetCookieHeader(c));
61
+ }
62
+ }
63
+
9
64
  export class HttpRpcError extends Error {
10
65
  constructor(
11
66
  message: string,
@@ -16,35 +71,47 @@ export class HttpRpcError extends Error {
16
71
  }
17
72
  }
18
73
 
19
- /** Serialize a schema + batches into a complete IPC stream as Uint8Array. */
20
- export function serializeIpcStream(schema: Schema, batches: RecordBatch[]): Uint8Array {
21
- const writer = new RecordBatchStreamWriter();
22
- writer.reset(undefined, schema);
23
- for (const batch of batches) {
24
- writer.write(conformBatchToSchema(batch, schema));
25
- }
26
- writer.close();
27
- return writer.toUint8Array(true);
74
+ /**
75
+ * Serialize a schema + batches into a complete IPC stream as Uint8Array.
76
+ *
77
+ * A single IPC stream is `[schema_msg, batch_msg, batch_msg, ..., EOS]`.
78
+ * Each backend implements `serializeBatches` to write that atomically —
79
+ * arrow-js via `RecordBatchStreamWriter`, flechette via `tablesToIPC`
80
+ * (added in our flechette fork). Naive concatenation of per-batch streams
81
+ * produces multiple EOS markers and breaks readers.
82
+ */
83
+ export function serializeIpcStream(schema: VgiSchema, batches: VgiBatch[]): Uint8Array {
84
+ const conformed = batches.map((b) => conformBatchToSchema(b, schema));
85
+ return serializeBatches(schema, conformed);
28
86
  }
29
87
 
30
- /** Create a Response with Arrow IPC content type. Casts Uint8Array for TS lib compat. */
88
+ /**
89
+ * Create a Response with Arrow IPC content type.
90
+ *
91
+ * Server errors (status 500) are translated to HTTP 200 with an
92
+ * ``X-VGI-RPC-Error: true`` header so that clients which discard
93
+ * response bodies on 5xx still receive the Arrow IPC error metadata.
94
+ * Client errors (400, 401, 404, 415) are passed through unchanged.
95
+ */
31
96
  export function arrowResponse(body: Uint8Array, status = 200, extraHeaders?: Headers): Response {
32
97
  const headers = extraHeaders ?? new Headers();
33
98
  headers.set("Content-Type", ARROW_CONTENT_TYPE);
99
+ if (status === 500) {
100
+ headers.set(RPC_ERROR_HEADER, "true");
101
+ return new Response(body as unknown as BodyInit, { status: 200, headers });
102
+ }
34
103
  return new Response(body as unknown as BodyInit, { status, headers });
35
104
  }
36
105
 
37
- /** Read schema + first batch from an IPC stream body. */
38
- export async function readRequestFromBody(body: Uint8Array): Promise<{ schema: Schema; batch: RecordBatch }> {
39
- const reader = await RecordBatchReader.from(body);
40
- await reader.open();
41
- const schema = reader.schema;
42
- if (!schema) {
106
+ /** Read schema + first batch from an IPC stream body via the facade. */
107
+ export async function readRequestFromBody(body: Uint8Array): Promise<{ schema: VgiSchema; batch: VgiBatch }> {
108
+ const batch = deserializeBatch(body);
109
+ // Reject only truly empty bodies. A zero-field, zero-row batch with batch
110
+ // metadata is a legal exchange/cancel/continuation signal — the state
111
+ // token rides on `batch.metadata` and downstream code (cancel detection,
112
+ // schema conformance gating) is built to handle it.
113
+ if (batch.schema.fields.length === 0 && batch.numRows === 0 && (batch.metadata?.size ?? 0) === 0) {
43
114
  throw new HttpRpcError("Empty IPC stream: no schema", 400);
44
115
  }
45
- const batches = reader.readAll();
46
- if (batches.length === 0) {
47
- throw new HttpRpcError("IPC stream contains no batches", 400);
48
- }
49
- return { schema, batch: batches[0] };
116
+ return { schema: batch.schema, batch };
50
117
  }