@query-farm/vgi-rpc 0.6.3 → 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 +68 -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 -3507
  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 +794 -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 +69 -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
@@ -2,162 +2,201 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import {
5
- Binary,
6
- Bool,
7
- Field,
8
- makeData,
9
- RecordBatch,
10
- Schema,
11
- Struct,
12
- Utf8,
13
- vectorFromArray,
14
- } from "@query-farm/apache-arrow";
5
+ batchFromColumns,
6
+ binary,
7
+ bool,
8
+ field,
9
+ schema as makeSchema,
10
+ utf8,
11
+ type VgiBatch,
12
+ withBatchMetadata,
13
+ } from "../arrow/index.js";
15
14
  import {
16
15
  DESCRIBE_VERSION,
17
16
  DESCRIBE_VERSION_KEY,
17
+ PROTOCOL_HASH_KEY,
18
18
  PROTOCOL_NAME_KEY,
19
+ PROTOCOL_VERSION_KEY,
19
20
  REQUEST_VERSION,
20
21
  REQUEST_VERSION_KEY,
21
22
  SERVER_ID_KEY,
22
23
  } from "../constants.js";
23
24
  import type { MethodDefinition } from "../types.js";
24
25
  import { serializeSchema } from "../util/schema.js";
26
+ import { sha256Hex } from "../util/web-crypto.js";
25
27
 
26
28
  /**
27
- * The schema for the __describe__ response, matching Python's _DESCRIBE_SCHEMA.
29
+ * Slim DESCRIBE_VERSION 4 schema. Python-flavoured fields (doc,
30
+ * param_types_json, param_defaults_json, param_docs_json) are not on the
31
+ * wire — Arrow IPC schema bytes are the authoritative type information;
32
+ * everything else is source-level metadata that callers should consult the
33
+ * Protocol class for.
28
34
  */
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
- new Field("is_exchange", new Bool(), true),
41
- new Field("param_docs_json", new Utf8(), true),
35
+ export const DESCRIBE_SCHEMA = makeSchema([
36
+ field("name", utf8(), false),
37
+ field("method_type", utf8(), false),
38
+ field("has_return", bool(), false),
39
+ field("params_schema_ipc", binary(), false),
40
+ field("result_schema_ipc", binary(), false),
41
+ field("has_header", bool(), false),
42
+ field("header_schema_ipc", binary(), true),
43
+ field("is_exchange", bool(), true),
42
44
  ]);
43
45
 
46
+ /**
47
+ * Compute the SHA-256 hex digest of the canonical describe payload. Mirrors
48
+ * Python's vgi_rpc.introspect.compute_protocol_hash byte-for-byte, so two
49
+ * implementations of the same Protocol that produce identical Arrow IPC
50
+ * schema bytes for params/result/header will hash to the same value.
51
+ */
52
+ async function computeProtocolHash(
53
+ protocolName: string,
54
+ rows: ReadonlyArray<{
55
+ name: string;
56
+ methodType: string;
57
+ hasReturn: boolean;
58
+ hasHeader: boolean;
59
+ isExchange: boolean | null;
60
+ paramsIpc: Uint8Array;
61
+ resultIpc: Uint8Array;
62
+ headerIpc: Uint8Array | null;
63
+ }>,
64
+ ): Promise<string> {
65
+ // Web Crypto's `subtle.digest` takes a single buffer, so we accumulate the
66
+ // canonical byte stream into a single Uint8Array and hash once. The byte
67
+ // sequence below must remain byte-for-byte identical to the Python
68
+ // implementation in vgi_rpc.introspect.compute_protocol_hash.
69
+ const enc = new TextEncoder();
70
+ const parts: Uint8Array[] = [];
71
+ const push = (v: string | Uint8Array) => parts.push(typeof v === "string" ? enc.encode(v) : v);
72
+
73
+ push("vgi_rpc.describe.v");
74
+ push(DESCRIBE_VERSION);
75
+ push("|");
76
+ push(REQUEST_VERSION);
77
+ push("|");
78
+ push(protocolName);
79
+ push("|");
80
+ for (const r of rows) {
81
+ push(Uint8Array.of(0x1f));
82
+ push(r.name);
83
+ push(Uint8Array.of(0x1e));
84
+ push(r.methodType);
85
+ push(Uint8Array.of(0x1e));
86
+ push(r.hasReturn ? "1" : "0");
87
+ push(Uint8Array.of(0x1e));
88
+ push(r.hasHeader ? "1" : "0");
89
+ push(Uint8Array.of(0x1e));
90
+ push(r.isExchange === null ? "-" : r.isExchange ? "1" : "0");
91
+ push(Uint8Array.of(0x1e));
92
+ push(r.paramsIpc);
93
+ push(Uint8Array.of(0x1e));
94
+ push(r.resultIpc);
95
+ push(Uint8Array.of(0x1e));
96
+ if (r.headerIpc) push(r.headerIpc);
97
+ }
98
+
99
+ let total = 0;
100
+ for (const p of parts) total += p.length;
101
+ const buf = new Uint8Array(total);
102
+ let off = 0;
103
+ for (const p of parts) {
104
+ buf.set(p, off);
105
+ off += p.length;
106
+ }
107
+ return sha256Hex(buf);
108
+ }
109
+
44
110
  /**
45
111
  * Build the __describe__ response batch and metadata.
46
112
  */
47
- export function buildDescribeBatch(
113
+ export async function buildDescribeBatch(
48
114
  protocolName: string,
49
115
  methods: Map<string, MethodDefinition>,
50
116
  serverId: string,
51
- ): { batch: RecordBatch; metadata: Map<string, string> } {
117
+ protocolVersion?: string,
118
+ ): Promise<{ batch: VgiBatch; metadata: Map<string, string> }> {
52
119
  // Sort methods by name for consistent ordering
53
120
  const sortedEntries = [...methods.entries()].sort(([a], [b]) => a.localeCompare(b));
54
121
 
55
122
  const names: (string | null)[] = [];
56
123
  const methodTypes: (string | null)[] = [];
57
- const docs: (string | null)[] = [];
58
124
  const hasReturns: boolean[] = [];
59
125
  const paramsSchemas: (Uint8Array | null)[] = [];
60
126
  const resultSchemas: (Uint8Array | null)[] = [];
61
- const paramTypesJsons: (string | null)[] = [];
62
- const paramDefaultsJsons: (string | null)[] = [];
63
127
  const hasHeaders: boolean[] = [];
64
128
  const headerSchemas: (Uint8Array | null)[] = [];
65
129
  const isExchanges: (boolean | null)[] = [];
66
- const paramDocsJsons: (string | null)[] = [];
130
+
131
+ const hashRows: Array<{
132
+ name: string;
133
+ methodType: string;
134
+ hasReturn: boolean;
135
+ hasHeader: boolean;
136
+ isExchange: boolean | null;
137
+ paramsIpc: Uint8Array;
138
+ resultIpc: Uint8Array;
139
+ headerIpc: Uint8Array | null;
140
+ }> = [];
67
141
 
68
142
  for (const [name, method] of sortedEntries) {
69
143
  names.push(name);
70
144
  methodTypes.push(method.type);
71
- docs.push(method.doc ?? null);
72
145
 
73
- // Unary methods with non-empty result schema have a return value
74
146
  const hasReturn = method.type === "unary" && method.resultSchema.fields.length > 0;
75
147
  hasReturns.push(hasReturn);
76
148
 
77
- paramsSchemas.push(serializeSchema(method.paramsSchema));
78
- resultSchemas.push(serializeSchema(method.resultSchema));
79
-
80
- // Build param_types_json
81
- if (method.paramTypes && Object.keys(method.paramTypes).length > 0) {
82
- paramTypesJsons.push(JSON.stringify(method.paramTypes));
83
- } else {
84
- paramTypesJsons.push(null);
85
- }
86
-
87
- // Build param_defaults_json
88
- if (method.defaults && Object.keys(method.defaults).length > 0) {
89
- const safe: Record<string, any> = {};
90
- for (const [k, v] of Object.entries(method.defaults)) {
91
- if (v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
92
- safe[k] = v;
93
- }
94
- }
95
- paramDefaultsJsons.push(Object.keys(safe).length > 0 ? JSON.stringify(safe) : null);
96
- } else {
97
- paramDefaultsJsons.push(null);
98
- }
99
-
100
- hasHeaders.push(!!method.headerSchema);
101
- headerSchemas.push(method.headerSchema ? serializeSchema(method.headerSchema) : null);
102
-
103
- // is_exchange: true for exchange, false for producer, null for unary
104
- if (method.exchangeFn) {
105
- isExchanges.push(true);
106
- } else if (method.producerFn) {
107
- isExchanges.push(false);
108
- } else {
109
- isExchanges.push(null);
110
- }
111
-
112
- // param_docs_json: no docstring source in TypeScript, always null
113
- paramDocsJsons.push(null);
149
+ const paramsIpc = serializeSchema(method.paramsSchema);
150
+ const resultIpc = serializeSchema(method.resultSchema);
151
+ paramsSchemas.push(paramsIpc);
152
+ resultSchemas.push(resultIpc);
153
+
154
+ const hasHeader = !!method.headerSchema;
155
+ hasHeaders.push(hasHeader);
156
+ const headerIpc = method.headerSchema ? serializeSchema(method.headerSchema) : null;
157
+ headerSchemas.push(headerIpc);
158
+
159
+ let isExchange: boolean | null;
160
+ if (method.exchangeFn) isExchange = true;
161
+ else if (method.producerFn) isExchange = false;
162
+ else isExchange = null;
163
+ isExchanges.push(isExchange);
164
+
165
+ hashRows.push({
166
+ name,
167
+ methodType: method.type,
168
+ hasReturn,
169
+ hasHeader,
170
+ isExchange,
171
+ paramsIpc,
172
+ resultIpc,
173
+ headerIpc,
174
+ });
114
175
  }
115
176
 
116
- // Build the batch using vectorFromArray for each column
117
- const nameArr = vectorFromArray(names, new Utf8());
118
- const methodTypeArr = vectorFromArray(methodTypes, new Utf8());
119
- const docArr = vectorFromArray(docs, new Utf8());
120
- const hasReturnArr = vectorFromArray(hasReturns, new Bool());
121
- const paramsSchemaArr = vectorFromArray(paramsSchemas, new Binary());
122
- const resultSchemaArr = vectorFromArray(resultSchemas, new Binary());
123
- const paramTypesArr = vectorFromArray(paramTypesJsons, new Utf8());
124
- const paramDefaultsArr = vectorFromArray(paramDefaultsJsons, new Utf8());
125
- const hasHeaderArr = vectorFromArray(hasHeaders, new Bool());
126
- const headerSchemaArr = vectorFromArray(headerSchemas, new Binary());
127
- const isExchangeArr = vectorFromArray(isExchanges, new Bool());
128
- const paramDocsArr = vectorFromArray(paramDocsJsons, new Utf8());
129
-
130
- const children = [
131
- nameArr.data[0],
132
- methodTypeArr.data[0],
133
- docArr.data[0],
134
- hasReturnArr.data[0],
135
- paramsSchemaArr.data[0],
136
- resultSchemaArr.data[0],
137
- paramTypesArr.data[0],
138
- paramDefaultsArr.data[0],
139
- hasHeaderArr.data[0],
140
- headerSchemaArr.data[0],
141
- isExchangeArr.data[0],
142
- paramDocsArr.data[0],
143
- ];
144
-
145
- const structType = new Struct(DESCRIBE_SCHEMA.fields);
146
- const data = makeData({
147
- type: structType,
148
- length: sortedEntries.length,
149
- children,
150
- nullCount: 0,
177
+ const baseBatch = batchFromColumns(DESCRIBE_SCHEMA, {
178
+ name: names,
179
+ method_type: methodTypes,
180
+ has_return: hasReturns,
181
+ params_schema_ipc: paramsSchemas,
182
+ result_schema_ipc: resultSchemas,
183
+ has_header: hasHeaders,
184
+ header_schema_ipc: headerSchemas,
185
+ is_exchange: isExchanges,
151
186
  });
152
187
 
153
- // Build metadata for the batch
188
+ const protocolHash = await computeProtocolHash(protocolName, hashRows);
189
+
154
190
  const metadata = new Map<string, string>();
155
191
  metadata.set(PROTOCOL_NAME_KEY, protocolName);
156
192
  metadata.set(REQUEST_VERSION_KEY, REQUEST_VERSION);
157
193
  metadata.set(DESCRIBE_VERSION_KEY, DESCRIBE_VERSION);
194
+ metadata.set(PROTOCOL_HASH_KEY, protocolHash);
158
195
  metadata.set(SERVER_ID_KEY, serverId);
196
+ if (protocolVersion) {
197
+ metadata.set(PROTOCOL_VERSION_KEY, protocolVersion);
198
+ }
159
199
 
160
- const batch = new RecordBatch(DESCRIBE_SCHEMA, data, metadata);
161
-
200
+ const batch = withBatchMetadata(baseBatch, metadata);
162
201
  return { batch, metadata };
163
202
  }
@@ -1,16 +1,16 @@
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 "@query-farm/apache-arrow";
4
+ import { conformBatchToSchema, schema as makeSchema, withBatchMetadata } from "../arrow/index.js";
5
+ import { CANCEL_KEY } from "../constants.js";
5
6
  import { type ExternalLocationConfig, maybeExternalizeBatch } from "../external.js";
6
- import type { MethodDefinition } from "../types.js";
7
+ import type { MethodDefinition, TransportKind } from "../types.js";
7
8
  import { OutputCollector } from "../types.js";
8
- import { conformBatchToSchema } from "../util/conform.js";
9
9
  import type { IpcStreamReader } from "../wire/reader.js";
10
10
  import { buildErrorBatch, buildResultBatch } from "../wire/response.js";
11
11
  import type { IpcStreamWriter } from "../wire/writer.js";
12
12
 
13
- const EMPTY_SCHEMA = new Schema([]);
13
+ const EMPTY_SCHEMA = makeSchema([]);
14
14
 
15
15
  /**
16
16
  * Dispatch a stream RPC call (producer or exchange).
@@ -35,6 +35,7 @@ export async function dispatchStream(
35
35
  serverId: string,
36
36
  requestId: string | null,
37
37
  externalConfig?: ExternalLocationConfig,
38
+ kind?: TransportKind,
38
39
  ): Promise<void> {
39
40
  const isProducer = !!method.producerFn;
40
41
 
@@ -48,7 +49,7 @@ export async function dispatchStream(
48
49
  } catch (error: any) {
49
50
  const errSchema = method.headerSchema ?? EMPTY_SCHEMA;
50
51
  const errBatch = buildErrorBatch(errSchema, error, serverId, requestId);
51
- writer.writeStream(errSchema, [errBatch]);
52
+ await writer.writeStream(errSchema, [errBatch]);
52
53
  // Still need to consume the input stream from the client
53
54
  const inputSchema = await reader.openNextStream();
54
55
  if (inputSchema) {
@@ -71,14 +72,14 @@ export async function dispatchStream(
71
72
  // Write header IPC stream if method has a header schema
72
73
  if (method.headerSchema && method.headerInit) {
73
74
  try {
74
- const headerOut = new OutputCollector(method.headerSchema, true, serverId, requestId);
75
+ const headerOut = new OutputCollector(method.headerSchema, true, serverId, requestId, undefined, undefined, kind);
75
76
  const headerValues = method.headerInit(params, state, headerOut);
76
77
  const headerBatch = buildResultBatch(method.headerSchema, headerValues, serverId, requestId);
77
78
  const headerBatches = [...headerOut.batches.map((b) => b.batch), headerBatch];
78
- writer.writeStream(method.headerSchema, headerBatches);
79
+ await writer.writeStream(method.headerSchema, headerBatches);
79
80
  } catch (error: any) {
80
81
  const errBatch = buildErrorBatch(method.headerSchema, error, serverId, requestId);
81
- writer.writeStream(method.headerSchema, [errBatch]);
82
+ await writer.writeStream(method.headerSchema, [errBatch]);
82
83
  // Drain input stream so client doesn't hang
83
84
  const inputSchema = await reader.openNextStream();
84
85
  if (inputSchema) {
@@ -92,7 +93,7 @@ export async function dispatchStream(
92
93
  const inputSchema = await reader.openNextStream();
93
94
  if (!inputSchema) {
94
95
  const errBatch = buildErrorBatch(outputSchema, new Error("Expected input stream but got EOF"), serverId, requestId);
95
- writer.writeStream(outputSchema, [errBatch]);
96
+ await writer.writeStream(outputSchema, [errBatch]);
96
97
  return;
97
98
  }
98
99
 
@@ -101,32 +102,54 @@ export async function dispatchStream(
101
102
  // same stream. We use IncrementalStream which writes bytes synchronously.
102
103
  const stream = writer.openStream(outputSchema);
103
104
 
104
- // Expected input schema for casting compatible types (e.g., decimal→double)
105
- const expectedInputSchema = method.inputSchema;
105
+ // Expected input schema for casting compatible types (e.g., decimal→double).
106
+ // State.__inputSchema overrides the method-registration schema per call,
107
+ // mirroring the __outputSchema pattern. Used by dynamic-input exchange
108
+ // methods (e.g., VGI's init, which binds to a user-supplied input shape).
109
+ const expectedInputSchema = state?.__inputSchema ?? method.inputSchema;
106
110
 
107
111
  try {
108
112
  while (true) {
109
113
  let inputBatch = await reader.readNextBatch();
110
114
  if (!inputBatch) break;
111
115
 
116
+ // Client cancellation: if the input batch carries vgi_rpc.cancel metadata,
117
+ // end the stream cleanly without calling the producer/exchange handler.
118
+ // The onCancel hook (if registered) runs once so state objects can
119
+ // release resources.
120
+ if (inputBatch.metadata?.get(CANCEL_KEY)) {
121
+ if (method.onCancel) {
122
+ try {
123
+ await method.onCancel(state);
124
+ } catch (err) {
125
+ console.debug?.(`onCancel hook failed: ${err instanceof Error ? err.message : err}`);
126
+ }
127
+ }
128
+ break;
129
+ }
130
+
112
131
  // Cast compatible input types when schema doesn't match exactly.
113
- // If conformance fails (e.g., completely different schemas like a dummy
114
- // registration schema vs actual data), pass the original batch through
115
- // the exchange handler may handle dynamic schemas internally.
116
- if (expectedInputSchema && !isProducer && inputBatch.schema !== expectedInputSchema) {
132
+ // Gated on effectiveProducer (not isProducer) so methods that flip to
133
+ // producer mode via state.__isProducer skip the conform entirely the
134
+ // tick batches they receive have a dummy shape that shouldn't be checked.
135
+ // Any conformance failure falls through with the original batch; the
136
+ // exchange handler owns input-shape validation if it cares.
137
+ if (expectedInputSchema && !effectiveProducer && inputBatch.schema !== expectedInputSchema) {
117
138
  try {
118
139
  inputBatch = conformBatchToSchema(inputBatch, expectedInputSchema);
119
140
  } catch (e) {
120
- if (e instanceof TypeError) {
121
- // Field name/count mismatch propagate as error (matches Python behavior).
122
- throw e;
123
- }
124
- // Other conformance failures: pass through for dynamic schema handlers.
141
+ // Field name/count mismatch (TypeError) is a hard contract violation —
142
+ // propagate as an RpcError so callers see a structured failure instead
143
+ // of a downstream hang or silent garbage. Other conform failures (e.g.
144
+ // type-cast issues for dynamic-input handlers) fall through with the
145
+ // original batch handlers that bind their input shape per-call
146
+ // should set state.__inputSchema so the conform doesn't run at all.
147
+ if (e instanceof TypeError) throw e;
125
148
  console.debug?.(`Schema conformance skipped: ${e instanceof Error ? e.message : e}`);
126
149
  }
127
150
  }
128
151
 
129
- const out = new OutputCollector(outputSchema, effectiveProducer, serverId, requestId);
152
+ const out = new OutputCollector(outputSchema, effectiveProducer, serverId, requestId, undefined, undefined, kind);
130
153
 
131
154
  if (isProducer) {
132
155
  await method.producerFn!(state, out);
@@ -139,7 +162,13 @@ export async function dispatchStream(
139
162
  if (externalConfig) {
140
163
  batch = await maybeExternalizeBatch(batch, externalConfig);
141
164
  }
142
- stream.write(batch);
165
+ // Attach per-emit metadata (e.g. vgi_batch_index,
166
+ // vgi_partition_values#b64) as the RecordBatch message's
167
+ // custom_metadata so the C++ extension can read it off the wire.
168
+ if (emitted.metadata && emitted.metadata.size > 0) {
169
+ batch = withBatchMetadata(batch, emitted.metadata);
170
+ }
171
+ await stream.write(batch);
143
172
  }
144
173
 
145
174
  if (out.finished) {
@@ -147,10 +176,10 @@ export async function dispatchStream(
147
176
  }
148
177
  }
149
178
  } catch (error: any) {
150
- stream.write(buildErrorBatch(outputSchema, error, serverId, requestId));
179
+ await stream.write(buildErrorBatch(outputSchema, error, serverId, requestId));
151
180
  }
152
181
 
153
- stream.close();
182
+ await stream.close();
154
183
 
155
184
  // Drain remaining input so transport stays synchronized for next request.
156
185
  // Matches Python's _drain_stream() called after every streaming method.
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { type ExternalLocationConfig, maybeExternalizeBatch } from "../external.js";
5
- import type { MethodDefinition } from "../types.js";
5
+ import type { MethodDefinition, TransportKind } from "../types.js";
6
6
  import { OutputCollector } from "../types.js";
7
7
  import { buildErrorBatch, buildResultBatch } from "../wire/response.js";
8
8
  import type { IpcStreamWriter } from "../wire/writer.js";
@@ -19,9 +19,10 @@ export async function dispatchUnary(
19
19
  serverId: string,
20
20
  requestId: string | null,
21
21
  externalConfig?: ExternalLocationConfig,
22
+ kind?: TransportKind,
22
23
  ): Promise<void> {
23
24
  const schema = method.resultSchema;
24
- const out = new OutputCollector(schema, true, serverId, requestId);
25
+ const out = new OutputCollector(schema, true, serverId, requestId, undefined, undefined, kind);
25
26
 
26
27
  try {
27
28
  const result = await method.handler!(params, out);
@@ -31,9 +32,9 @@ export async function dispatchUnary(
31
32
  }
32
33
  // Collect log batches (from clientLog) + result batch
33
34
  const batches = [...out.batches.map((b) => b.batch), resultBatch];
34
- writer.writeStream(schema, batches);
35
+ await writer.writeStream(schema, batches);
35
36
  } catch (error: any) {
36
37
  const batch = buildErrorBatch(schema, error, serverId, requestId);
37
- writer.writeStream(schema, [batch]);
38
+ await writer.writeStream(schema, [batch]);
38
39
  }
39
40
  }
package/src/errors.ts CHANGED
@@ -20,3 +20,79 @@ export class VersionError extends Error {
20
20
  this.name = "VersionError";
21
21
  }
22
22
  }
23
+
24
+ /** Well-known values for the `vgi_rpc.error_kind` batch metadata key. Mirrors
25
+ * Python's `vgi_rpc.metadata.ERROR_KIND_*` constants. */
26
+ export const ERROR_KIND_METHOD_NOT_IMPLEMENTED = "method_not_implemented";
27
+ export const ERROR_KIND_SESSION_LOST = "session_lost";
28
+ export const ERROR_KIND_SERVER_DRAINING = "server_draining";
29
+ export const ERROR_KIND_PROTOCOL_VERSION_MISMATCH = "protocol_version_mismatch";
30
+
31
+ /** Raised when the client's declared `vgi_rpc.protocol_version` is
32
+ * incompatible with the server's. Subclass of `VersionError` so existing
33
+ * catch sites continue to write a typed error stream and keep serving.
34
+ * Carries a directional message that tells the reader which side to
35
+ * upgrade. Mirrors Python's `vgi_rpc.rpc.ProtocolVersionError`. */
36
+ export class ProtocolVersionError extends VersionError {
37
+ static readonly errorKind = ERROR_KIND_PROTOCOL_VERSION_MISMATCH;
38
+ readonly errorKind = ERROR_KIND_PROTOCOL_VERSION_MISMATCH;
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = "ProtocolVersionError";
42
+ }
43
+ }
44
+
45
+ const SEMVER_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
46
+
47
+ /** Parse a canonical semver string into `[major, minor, patch]`. Throws on
48
+ * any input that isn't `MAJOR.MINOR.PATCH` with non-negative integers and
49
+ * no leading zeros (except literal `0`). No prereleases, no build metadata.
50
+ * Mirrors Python's `vgi_rpc.metadata.parse_version`. */
51
+ export function parseProtocolVersion(value: string): [number, number, number] {
52
+ const m = SEMVER_REGEX.exec(value);
53
+ if (!m) {
54
+ throw new Error(
55
+ `Invalid protocol version '${value}': expected canonical semver ` +
56
+ "MAJOR.MINOR.PATCH with non-negative integers and no leading zeros " +
57
+ "(no prereleases or build metadata).",
58
+ );
59
+ }
60
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
61
+ }
62
+
63
+ /** Raised when a client invokes a method the server does not implement.
64
+ *
65
+ * Mirrors Python's `vgi_rpc.rpc.MethodNotImplementedError`. The static
66
+ * `errorKind` is hoisted onto the error batch metadata as
67
+ * `vgi_rpc.error_kind` so clients can branch on the typed marker without
68
+ * string-matching the message.
69
+ */
70
+ export class MethodNotImplementedError extends Error {
71
+ static readonly errorKind = ERROR_KIND_METHOD_NOT_IMPLEMENTED;
72
+ readonly errorKind = ERROR_KIND_METHOD_NOT_IMPLEMENTED;
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "MethodNotImplementedError";
76
+ }
77
+ }
78
+
79
+ /** Raised when a sticky session token is malformed, expired, evicted, or
80
+ * bound to a different worker / principal. HTTP-only. */
81
+ export class SessionLostError extends Error {
82
+ static readonly errorKind = ERROR_KIND_SESSION_LOST;
83
+ readonly errorKind = ERROR_KIND_SESSION_LOST;
84
+ constructor(message: string) {
85
+ super(message);
86
+ this.name = "SessionLostError";
87
+ }
88
+ }
89
+
90
+ /** Raised when `ctx.openSession` is called while the server is draining. */
91
+ export class ServerDrainingError extends Error {
92
+ static readonly errorKind = ERROR_KIND_SERVER_DRAINING;
93
+ readonly errorKind = ERROR_KIND_SERVER_DRAINING;
94
+ constructor(message: string) {
95
+ super(message);
96
+ this.name = "ServerDrainingError";
97
+ }
98
+ }