@query-farm/vgi-rpc 0.6.4 → 0.7.1

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 (177) hide show
  1. package/dist/access-log.d.ts +55 -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/auth.d.ts +5 -0
  16. package/dist/auth.d.ts.map +1 -1
  17. package/dist/client/capabilities.d.ts +25 -0
  18. package/dist/client/capabilities.d.ts.map +1 -0
  19. package/dist/client/connect.d.ts +10 -0
  20. package/dist/client/connect.d.ts.map +1 -1
  21. package/dist/client/introspect.d.ts +21 -0
  22. package/dist/client/introspect.d.ts.map +1 -1
  23. package/dist/client/ipc.d.ts +8 -2
  24. package/dist/client/ipc.d.ts.map +1 -1
  25. package/dist/client/oauth.d.ts +9 -0
  26. package/dist/client/oauth.d.ts.map +1 -1
  27. package/dist/client/pipe.d.ts +24 -0
  28. package/dist/client/pipe.d.ts.map +1 -1
  29. package/dist/client/stream.d.ts +19 -2
  30. package/dist/client/stream.d.ts.map +1 -1
  31. package/dist/client/types.d.ts +23 -0
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/uploadUrl.d.ts +25 -0
  34. package/dist/client/uploadUrl.d.ts.map +1 -0
  35. package/dist/constants.d.ts +30 -2
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/crypto.d.ts +22 -0
  38. package/dist/crypto.d.ts.map +1 -0
  39. package/dist/dispatch/describe.d.ts +10 -6
  40. package/dist/dispatch/describe.d.ts.map +1 -1
  41. package/dist/dispatch/stream.d.ts +2 -2
  42. package/dist/dispatch/stream.d.ts.map +1 -1
  43. package/dist/dispatch/unary.d.ts +2 -2
  44. package/dist/dispatch/unary.d.ts.map +1 -1
  45. package/dist/errors.d.ts +64 -1
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/external.d.ts +27 -5
  48. package/dist/external.d.ts.map +1 -1
  49. package/dist/http/auth.d.ts +13 -0
  50. package/dist/http/auth.d.ts.map +1 -1
  51. package/dist/http/bearer.d.ts.map +1 -1
  52. package/dist/http/common.d.ts +43 -7
  53. package/dist/http/common.d.ts.map +1 -1
  54. package/dist/http/dispatch.d.ts +20 -2
  55. package/dist/http/dispatch.d.ts.map +1 -1
  56. package/dist/http/handler.d.ts.map +1 -1
  57. package/dist/http/index.d.ts +1 -0
  58. package/dist/http/index.d.ts.map +1 -1
  59. package/dist/http/jwt.d.ts +1 -0
  60. package/dist/http/jwt.d.ts.map +1 -1
  61. package/dist/http/mtls.d.ts +9 -1
  62. package/dist/http/mtls.d.ts.map +1 -1
  63. package/dist/http/oauth-pkce.d.ts +141 -0
  64. package/dist/http/oauth-pkce.d.ts.map +1 -0
  65. package/dist/http/pages.d.ts +3 -0
  66. package/dist/http/pages.d.ts.map +1 -1
  67. package/dist/http/sticky.d.ts +124 -0
  68. package/dist/http/sticky.d.ts.map +1 -0
  69. package/dist/http/token.d.ts +43 -12
  70. package/dist/http/token.d.ts.map +1 -1
  71. package/dist/http/types.d.ts +68 -5
  72. package/dist/http/types.d.ts.map +1 -1
  73. package/dist/index.d.ts +6 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1275 -3511
  76. package/dist/index.js.map +20 -38
  77. package/dist/launcher/hash.d.ts +22 -0
  78. package/dist/launcher/hash.d.ts.map +1 -0
  79. package/dist/launcher/index.d.ts +23 -0
  80. package/dist/launcher/index.d.ts.map +1 -0
  81. package/dist/launcher/launch.d.ts +27 -0
  82. package/dist/launcher/launch.d.ts.map +1 -0
  83. package/dist/launcher/lock.d.ts +19 -0
  84. package/dist/launcher/lock.d.ts.map +1 -0
  85. package/dist/launcher/serve-unix.d.ts +55 -0
  86. package/dist/launcher/serve-unix.d.ts.map +1 -0
  87. package/dist/launcher/state.d.ts +71 -0
  88. package/dist/launcher/state.d.ts.map +1 -0
  89. package/dist/otel.d.ts.map +1 -1
  90. package/dist/protocol.d.ts +19 -2
  91. package/dist/protocol.d.ts.map +1 -1
  92. package/dist/schema.d.ts +45 -18
  93. package/dist/schema.d.ts.map +1 -1
  94. package/dist/server.d.ts +23 -2
  95. package/dist/server.d.ts.map +1 -1
  96. package/dist/types.d.ts +270 -12
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/util/gzip.d.ts +10 -0
  99. package/dist/util/gzip.d.ts.map +1 -0
  100. package/dist/util/schema.d.ts +3 -15
  101. package/dist/util/schema.d.ts.map +1 -1
  102. package/dist/util/web-crypto.d.ts +22 -0
  103. package/dist/util/web-crypto.d.ts.map +1 -0
  104. package/dist/util/zstd.d.ts +26 -3
  105. package/dist/util/zstd.d.ts.map +1 -1
  106. package/dist/wire/opaque.d.ts +11 -0
  107. package/dist/wire/opaque.d.ts.map +1 -0
  108. package/dist/wire/reader.d.ts +5 -5
  109. package/dist/wire/reader.d.ts.map +1 -1
  110. package/dist/wire/request.d.ts +11 -3
  111. package/dist/wire/request.d.ts.map +1 -1
  112. package/dist/wire/response.d.ts +6 -6
  113. package/dist/wire/response.d.ts.map +1 -1
  114. package/dist/wire/writer.d.ts +49 -39
  115. package/dist/wire/writer.d.ts.map +1 -1
  116. package/package.json +35 -21
  117. package/src/access-log.ts +200 -0
  118. package/src/arrow/impl-arrowjs/index.ts +433 -0
  119. package/src/arrow/impl-flechette/index.ts +414 -0
  120. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  121. package/src/arrow/index.ts +89 -0
  122. package/src/arrow/predicates.ts +56 -0
  123. package/src/arrow/types.ts +73 -0
  124. package/src/auth.ts +5 -0
  125. package/src/client/capabilities.ts +84 -0
  126. package/src/client/connect.ts +113 -26
  127. package/src/client/introspect.ts +74 -38
  128. package/src/client/ipc.ts +37 -27
  129. package/src/client/oauth.ts +9 -0
  130. package/src/client/pipe.ts +36 -9
  131. package/src/client/stream.ts +43 -20
  132. package/src/client/types.ts +23 -0
  133. package/src/client/uploadUrl.ts +169 -0
  134. package/src/constants.ts +34 -2
  135. package/src/crypto.ts +95 -0
  136. package/src/dispatch/describe.ts +146 -107
  137. package/src/dispatch/stream.ts +53 -24
  138. package/src/dispatch/unary.ts +5 -4
  139. package/src/errors.ts +87 -0
  140. package/src/external.ts +49 -30
  141. package/src/http/auth.ts +13 -0
  142. package/src/http/bearer.ts +2 -5
  143. package/src/http/common.ts +91 -23
  144. package/src/http/dispatch.ts +373 -46
  145. package/src/http/handler.ts +790 -68
  146. package/src/http/index.ts +1 -0
  147. package/src/http/jwt.ts +1 -0
  148. package/src/http/mtls.ts +25 -3
  149. package/src/http/oauth-pkce.ts +1035 -0
  150. package/src/http/pages.ts +30 -15
  151. package/src/http/sticky.ts +429 -0
  152. package/src/http/token.ts +170 -75
  153. package/src/http/types.ts +69 -5
  154. package/src/index.ts +40 -1
  155. package/src/launcher/hash.ts +104 -0
  156. package/src/launcher/index.ts +35 -0
  157. package/src/launcher/launch.ts +284 -0
  158. package/src/launcher/lock.ts +171 -0
  159. package/src/launcher/serve-unix.ts +386 -0
  160. package/src/launcher/state.ts +257 -0
  161. package/src/otel.ts +39 -33
  162. package/src/protocol.ts +30 -3
  163. package/src/schema.ts +107 -56
  164. package/src/server.ts +196 -20
  165. package/src/types.ts +376 -18
  166. package/src/util/gzip.ts +63 -0
  167. package/src/util/schema.ts +4 -22
  168. package/src/util/web-crypto.ts +98 -0
  169. package/src/util/zstd.ts +133 -14
  170. package/src/wire/opaque.ts +37 -0
  171. package/src/wire/reader.ts +5 -4
  172. package/src/wire/request.ts +67 -8
  173. package/src/wire/response.ts +51 -85
  174. package/src/wire/writer.ts +165 -69
  175. package/dist/util/conform.d.ts +0 -18
  176. package/dist/util/conform.d.ts.map +0 -1
  177. package/src/util/conform.ts +0 -94
package/src/constants.ts CHANGED
@@ -1,25 +1,57 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- /** Well-known metadata keys matching Python's metadata.py */
4
+ // Well-known metadata keys matching Python's metadata.py.
5
5
 
6
+ /** Batch-metadata key carrying the invoked RPC method name. */
6
7
  export const RPC_METHOD_KEY = "vgi_rpc.method";
8
+ /** Batch-metadata key carrying a log batch's severity level. */
7
9
  export const LOG_LEVEL_KEY = "vgi_rpc.log_level";
10
+ /** Batch-metadata key carrying a log batch's message text. */
8
11
  export const LOG_MESSAGE_KEY = "vgi_rpc.log_message";
12
+ /** Batch-metadata key carrying a log batch's structured extra fields. */
9
13
  export const LOG_EXTRA_KEY = "vgi_rpc.log_extra";
14
+ /** Batch-metadata key carrying the wire request-framing version. */
10
15
  export const REQUEST_VERSION_KEY = "vgi_rpc.request_version";
16
+ /** Current wire request-framing version. Distinct from the application-level
17
+ * {@link PROTOCOL_VERSION_KEY protocol version}. */
11
18
  export const REQUEST_VERSION = "1";
12
19
 
20
+ /** Batch-metadata key identifying the server instance that produced a batch. */
13
21
  export const SERVER_ID_KEY = "vgi_rpc.server_id";
22
+ /** Batch-metadata key carrying the client-supplied request id. */
14
23
  export const REQUEST_ID_KEY = "vgi_rpc.request_id";
15
24
 
25
+ /** Batch-metadata key carrying the service / protocol name. */
16
26
  export const PROTOCOL_NAME_KEY = "vgi_rpc.protocol_name";
27
+ /** Batch-metadata key carrying the `__describe__` response schema version. */
17
28
  export const DESCRIBE_VERSION_KEY = "vgi_rpc.describe_version";
18
- export const DESCRIBE_VERSION = "3";
29
+ export const PROTOCOL_HASH_KEY = "vgi_rpc.protocol_hash";
30
+ /** Current `__describe__` response schema version (the slim 8-column schema). */
31
+ export const DESCRIBE_VERSION = "4";
19
32
 
33
+ /** Application protocol surface version. Carried on every request batch from
34
+ * a client bound to a Protocol that declares `protocolVersion`; also emitted
35
+ * in the __describe__ response metadata. Format: canonical semver
36
+ * MAJOR.MINOR.PATCH. Enforced at the dispatch boundary on the server: exact
37
+ * major+minor match required, patch ignored. Distinct from `REQUEST_VERSION`
38
+ * (wire framing). Mirrors Python's `PROTOCOL_VERSION_KEY`. */
39
+ export const PROTOCOL_VERSION_KEY = "vgi_rpc.protocol_version";
40
+
41
+ /** Reserved method name for the introspection (`__describe__`) call. */
20
42
  export const DESCRIBE_METHOD_NAME = "__describe__";
21
43
 
44
+ /** Batch-metadata key carrying the base64-encoded stream continuation/state token. */
22
45
  export const STATE_KEY = "vgi_rpc.stream_state#b64";
46
+ export const CANCEL_KEY = "vgi_rpc.cancel";
23
47
 
24
48
  export const LOCATION_KEY = "vgi_rpc.location";
25
49
  export const LOCATION_SHA256_KEY = "vgi_rpc.location.sha256";
50
+
51
+ /** HTTP response header set when an RPC error is returned over the HTTP transport. */
52
+ export const RPC_ERROR_HEADER = "X-VGI-RPC-Error";
53
+
54
+ /** Top-level metadata key on an EXCEPTION batch identifying the error category.
55
+ * Hoisted by `buildErrorBatch` when the thrown error has a static or instance
56
+ * `errorKind` property. Mirrors Python's `vgi_rpc.metadata.ERROR_KIND_KEY`. */
57
+ export const ERROR_KIND_KEY = "vgi_rpc.error_kind";
package/src/crypto.ts ADDED
@@ -0,0 +1,95 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Generic AEAD seal/open primitives shared by stream-state and sticky-session
6
+ * tokens. Mirrors Python's `vgi_rpc.crypto` module: a tiny envelope around
7
+ * XChaCha20-Poly1305 with a leading version byte so future format bumps stay
8
+ * self-describing.
9
+ *
10
+ * Wire format (returned by {@link sealBytes}, accepted by {@link openBytes}):
11
+ *
12
+ * ```
13
+ * [1B version (1..255)]
14
+ * [24B nonce (XChaCha20-Poly1305, random per envelope)]
15
+ * [.. ciphertext + 16B Poly1305 tag]
16
+ * ```
17
+ *
18
+ * The plaintext frame is fully up to the caller — only the version + nonce +
19
+ * tag overhead is fixed. AAD (`aad`) is bound at the crypto layer so an
20
+ * envelope sealed for one identity fails decryption when presented by another.
21
+ */
22
+
23
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
24
+ import { randomBytes } from "./util/web-crypto.js";
25
+
26
+ const NONCE_LEN = 24;
27
+ const TAG_LEN = 16;
28
+ const VERSION_LEN = 1;
29
+ const MIN_ENVELOPE_LEN = VERSION_LEN + NONCE_LEN + TAG_LEN;
30
+
31
+ /** Thrown by {@link openBytes} for any envelope it cannot open — malformed,
32
+ * tampered, wrong key, wrong AAD, wrong version, truncated, all surface the
33
+ * same way so callers cannot distinguish failure modes via error content. */
34
+ export class SealError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = "SealError";
38
+ }
39
+ }
40
+
41
+ /** Normalise a key to 32 bytes by SHA-256 hashing when it isn't already 32B.
42
+ * Mirrors Python's `normalize_key` so any callers can pass operator-provided
43
+ * keys of arbitrary length without a separate stretching step. */
44
+ export async function normalizeKey(key: Uint8Array): Promise<Uint8Array> {
45
+ if (key.length === 32) return key;
46
+ const digest = await crypto.subtle.digest("SHA-256", key as BufferSource);
47
+ return new Uint8Array(digest);
48
+ }
49
+
50
+ export interface SealOptions {
51
+ /** Associated data bound at the crypto layer — typically a principal or
52
+ * request-scoped identifier. Must match between seal and open. */
53
+ aad: Uint8Array;
54
+ /** Envelope version byte. Defaults to 1; carry through to {@link openBytes}. */
55
+ version?: number;
56
+ }
57
+
58
+ /** Seal `plaintext` under `key` with AEAD, returning the wire envelope. */
59
+ export function sealBytes(plaintext: Uint8Array, key: Uint8Array, opts: SealOptions): Uint8Array {
60
+ if (key.length !== 32) {
61
+ throw new Error("AEAD key must be 32 bytes — call normalizeKey() first");
62
+ }
63
+ const version = opts.version ?? 1;
64
+ if (version < 1 || version > 255) {
65
+ throw new Error(`AEAD envelope version must fit in one byte; got ${version}`);
66
+ }
67
+ const nonce = randomBytes(NONCE_LEN);
68
+ const ciphertext = xchacha20poly1305(key, nonce, opts.aad as Uint8Array).encrypt(plaintext);
69
+ const wire = new Uint8Array(VERSION_LEN + NONCE_LEN + ciphertext.length);
70
+ wire[0] = version;
71
+ wire.set(nonce, VERSION_LEN);
72
+ wire.set(ciphertext, VERSION_LEN + NONCE_LEN);
73
+ return wire;
74
+ }
75
+
76
+ /** Open and verify an envelope produced by {@link sealBytes}. */
77
+ export function openBytes(envelope: Uint8Array, key: Uint8Array, opts: SealOptions): Uint8Array {
78
+ if (key.length !== 32) {
79
+ throw new Error("AEAD key must be 32 bytes — call normalizeKey() first");
80
+ }
81
+ if (envelope.length < MIN_ENVELOPE_LEN) {
82
+ throw new SealError("envelope truncated");
83
+ }
84
+ const expectedVersion = opts.version ?? 1;
85
+ if (envelope[0] !== expectedVersion) {
86
+ throw new SealError(`unsupported envelope version: ${envelope[0]}`);
87
+ }
88
+ const nonce = envelope.subarray(VERSION_LEN, VERSION_LEN + NONCE_LEN);
89
+ const ciphertext = envelope.subarray(VERSION_LEN + NONCE_LEN);
90
+ try {
91
+ return xchacha20poly1305(key, nonce, opts.aad as Uint8Array).decrypt(ciphertext);
92
+ } catch {
93
+ throw new SealError("envelope verification failed");
94
+ }
95
+ }
@@ -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
  }