@query-farm/vgi 0.6.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 (206) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +494 -0
  3. package/dist/__dbg.test-d.d.ts +1 -0
  4. package/dist/arguments/argument-spec.d.ts +51 -0
  5. package/dist/arguments/arguments.d.ts +13 -0
  6. package/dist/arrow/codec/branded.d.ts +83 -0
  7. package/dist/arrow/codec/registry.d.ts +29 -0
  8. package/dist/arrow/codec/repr.d.ts +23 -0
  9. package/dist/arrow/codec/type-descriptors.d.ts +109 -0
  10. package/dist/arrow/impl-arrowjs/build.d.ts +19 -0
  11. package/dist/arrow/impl-arrowjs/canonical.d.ts +15 -0
  12. package/dist/arrow/impl-arrowjs/empty.d.ts +7 -0
  13. package/dist/arrow/impl-arrowjs/filter.d.ts +11 -0
  14. package/dist/arrow/impl-arrowjs/index.d.ts +11 -0
  15. package/dist/arrow/impl-arrowjs/ipc.d.ts +24 -0
  16. package/dist/arrow/impl-arrowjs/iterate.d.ts +39 -0
  17. package/dist/arrow/impl-arrowjs/project.d.ts +10 -0
  18. package/dist/arrow/impl-arrowjs/schema.d.ts +62 -0
  19. package/dist/arrow/impl-arrowjs/statistics.d.ts +13 -0
  20. package/dist/arrow/impl-flechette/build.d.ts +14 -0
  21. package/dist/arrow/impl-flechette/canonical.d.ts +18 -0
  22. package/dist/arrow/impl-flechette/empty.d.ts +9 -0
  23. package/dist/arrow/impl-flechette/filter.d.ts +10 -0
  24. package/dist/arrow/impl-flechette/index.d.ts +11 -0
  25. package/dist/arrow/impl-flechette/ipc.d.ts +33 -0
  26. package/dist/arrow/impl-flechette/iterate.d.ts +29 -0
  27. package/dist/arrow/impl-flechette/message-meta.d.ts +10 -0
  28. package/dist/arrow/impl-flechette/normalize-type.d.ts +11 -0
  29. package/dist/arrow/impl-flechette/project.d.ts +3 -0
  30. package/dist/arrow/impl-flechette/schema.d.ts +47 -0
  31. package/dist/arrow/impl-flechette/statistics.d.ts +13 -0
  32. package/dist/arrow/index.d.ts +11 -0
  33. package/dist/arrow/predicates.d.ts +47 -0
  34. package/dist/arrow/repr.test-d.d.ts +52 -0
  35. package/dist/arrow/schema-types.d.ts +71 -0
  36. package/dist/arrow/types.d.ts +57 -0
  37. package/dist/catalog/attach-option.d.ts +33 -0
  38. package/dist/catalog/attach-options.d.ts +30 -0
  39. package/dist/catalog/composite.d.ts +33 -0
  40. package/dist/catalog/descriptors.d.ts +154 -0
  41. package/dist/catalog/interface.d.ts +239 -0
  42. package/dist/catalog/read-only.d.ts +33 -0
  43. package/dist/client/client.d.ts +189 -0
  44. package/dist/client/errors.d.ts +26 -0
  45. package/dist/client/helpers.d.ts +12 -0
  46. package/dist/client/protocol.d.ts +16 -0
  47. package/dist/client/types.d.ts +125 -0
  48. package/dist/client-entry.d.ts +11 -0
  49. package/dist/client-entry.js +48 -0
  50. package/dist/client-entry.js.map +9 -0
  51. package/dist/codec/asd.d.ts +25 -0
  52. package/dist/crypto.d.ts +36 -0
  53. package/dist/errors.d.ts +24 -0
  54. package/dist/filter-pushdown/collector.d.ts +32 -0
  55. package/dist/filter-pushdown/deserialize.d.ts +16 -0
  56. package/dist/filter-pushdown/evaluate.d.ts +51 -0
  57. package/dist/filter-pushdown/index.d.ts +4 -0
  58. package/dist/filter-pushdown/types.d.ts +84 -0
  59. package/dist/functions/aggregate.d.ts +129 -0
  60. package/dist/functions/copy-from.d.ts +81 -0
  61. package/dist/functions/copy-to.d.ts +121 -0
  62. package/dist/functions/registry.d.ts +16 -0
  63. package/dist/functions/scalar.d.ts +98 -0
  64. package/dist/functions/storage-cf-do.d.ts +47 -0
  65. package/dist/functions/storage.d.ts +86 -0
  66. package/dist/functions/table-buffering.d.ts +99 -0
  67. package/dist/functions/table-in-out.d.ts +64 -0
  68. package/dist/functions/table.d.ts +118 -0
  69. package/dist/functions/types.d.ts +157 -0
  70. package/dist/generated/vgi-client.d.ts +466 -0
  71. package/dist/generated/vgi-protocol-schemas.d.ts +115 -0
  72. package/dist/index.core.d.ts +42 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +10888 -0
  75. package/dist/index.js.map +85 -0
  76. package/dist/metadata/resolve.d.ts +3 -0
  77. package/dist/metadata/serialize.d.ts +12 -0
  78. package/dist/metadata/types.d.ts +47 -0
  79. package/dist/protocol/dispatch.d.ts +23 -0
  80. package/dist/protocol/handlers/aggregate.d.ts +3 -0
  81. package/dist/protocol/handlers/catalog/admin.d.ts +3 -0
  82. package/dist/protocol/handlers/catalog/index.d.ts +3 -0
  83. package/dist/protocol/handlers/catalog/index_methods.d.ts +3 -0
  84. package/dist/protocol/handlers/catalog/macro.d.ts +3 -0
  85. package/dist/protocol/handlers/catalog/shared.d.ts +70 -0
  86. package/dist/protocol/handlers/catalog/table.d.ts +3 -0
  87. package/dist/protocol/handlers/catalog/view.d.ts +3 -0
  88. package/dist/protocol/handlers/function.d.ts +8 -0
  89. package/dist/protocol/handlers/shared.d.ts +31 -0
  90. package/dist/protocol/handlers/table-buffering.d.ts +3 -0
  91. package/dist/protocol/serialize.d.ts +4 -0
  92. package/dist/protocol/serializers/arguments.d.ts +3 -0
  93. package/dist/protocol/serializers/bind.d.ts +9 -0
  94. package/dist/protocol/serializers/cardinality.d.ts +7 -0
  95. package/dist/protocol/serializers/init.d.ts +9 -0
  96. package/dist/protocol/serializers/shared.d.ts +12 -0
  97. package/dist/protocol/state-serializer.d.ts +23 -0
  98. package/dist/protocol/types.d.ts +129 -0
  99. package/dist/secrets/helpers.d.ts +15 -0
  100. package/dist/types.d.ts +52 -0
  101. package/dist/util/arrow/index.d.ts +1 -0
  102. package/dist/util/bytes.d.ts +2 -0
  103. package/dist/util/statistics.d.ts +7 -0
  104. package/dist/worker-cf-entry.d.ts +30 -0
  105. package/dist/worker-cf-entry.js +8767 -0
  106. package/dist/worker-cf-entry.js.map +74 -0
  107. package/dist/worker.d.ts +36 -0
  108. package/package.json +96 -0
  109. package/src/arguments/argument-spec.ts +200 -0
  110. package/src/arguments/arguments.ts +81 -0
  111. package/src/arrow/codec/branded.ts +206 -0
  112. package/src/arrow/codec/registry.ts +674 -0
  113. package/src/arrow/codec/repr.ts +181 -0
  114. package/src/arrow/codec/type-descriptors.ts +137 -0
  115. package/src/arrow/impl-arrowjs/build.ts +88 -0
  116. package/src/arrow/impl-arrowjs/canonical.ts +509 -0
  117. package/src/arrow/impl-arrowjs/empty.ts +72 -0
  118. package/src/arrow/impl-arrowjs/filter.ts +53 -0
  119. package/src/arrow/impl-arrowjs/index.ts +54 -0
  120. package/src/arrow/impl-arrowjs/ipc.ts +67 -0
  121. package/src/arrow/impl-arrowjs/iterate.ts +159 -0
  122. package/src/arrow/impl-arrowjs/project.ts +73 -0
  123. package/src/arrow/impl-arrowjs/schema.ts +256 -0
  124. package/src/arrow/impl-arrowjs/statistics.ts +185 -0
  125. package/src/arrow/impl-flechette/build.ts +65 -0
  126. package/src/arrow/impl-flechette/canonical.ts +289 -0
  127. package/src/arrow/impl-flechette/empty.ts +20 -0
  128. package/src/arrow/impl-flechette/filter.ts +42 -0
  129. package/src/arrow/impl-flechette/index.ts +54 -0
  130. package/src/arrow/impl-flechette/ipc.ts +110 -0
  131. package/src/arrow/impl-flechette/iterate.ts +121 -0
  132. package/src/arrow/impl-flechette/message-meta.ts +175 -0
  133. package/src/arrow/impl-flechette/normalize-type.ts +99 -0
  134. package/src/arrow/impl-flechette/project.ts +64 -0
  135. package/src/arrow/impl-flechette/schema.ts +202 -0
  136. package/src/arrow/impl-flechette/statistics.ts +198 -0
  137. package/src/arrow/index.ts +129 -0
  138. package/src/arrow/predicates.ts +66 -0
  139. package/src/arrow/repr.test-d.ts +189 -0
  140. package/src/arrow/schema-types.ts +213 -0
  141. package/src/arrow/types.ts +74 -0
  142. package/src/catalog/attach-option.ts +107 -0
  143. package/src/catalog/attach-options.ts +79 -0
  144. package/src/catalog/composite.ts +180 -0
  145. package/src/catalog/descriptors.ts +164 -0
  146. package/src/catalog/interface.ts +721 -0
  147. package/src/catalog/read-only.ts +909 -0
  148. package/src/client/client.ts +1348 -0
  149. package/src/client/errors.ts +69 -0
  150. package/src/client/helpers.ts +56 -0
  151. package/src/client/protocol.ts +33 -0
  152. package/src/client/types.ts +140 -0
  153. package/src/client-entry.ts +112 -0
  154. package/src/codec/asd.ts +147 -0
  155. package/src/crypto.ts +143 -0
  156. package/src/errors.ts +61 -0
  157. package/src/filter-pushdown/collector.ts +147 -0
  158. package/src/filter-pushdown/deserialize.ts +253 -0
  159. package/src/filter-pushdown/evaluate.ts +591 -0
  160. package/src/filter-pushdown/index.ts +28 -0
  161. package/src/filter-pushdown/types.ts +91 -0
  162. package/src/functions/aggregate.ts +384 -0
  163. package/src/functions/copy-from.ts +282 -0
  164. package/src/functions/copy-to.ts +319 -0
  165. package/src/functions/registry.ts +258 -0
  166. package/src/functions/scalar.ts +356 -0
  167. package/src/functions/storage-cf-do.ts +303 -0
  168. package/src/functions/storage.ts +521 -0
  169. package/src/functions/table-buffering.ts +562 -0
  170. package/src/functions/table-in-out.ts +373 -0
  171. package/src/functions/table.ts +521 -0
  172. package/src/functions/types.ts +192 -0
  173. package/src/generated/vgi-client.ts +560 -0
  174. package/src/generated/vgi-protocol-schemas.ts +864 -0
  175. package/src/index.core.ts +267 -0
  176. package/src/index.ts +11 -0
  177. package/src/metadata/resolve.ts +109 -0
  178. package/src/metadata/serialize.ts +242 -0
  179. package/src/metadata/types.ts +60 -0
  180. package/src/protocol/dispatch.ts +52 -0
  181. package/src/protocol/handlers/aggregate.ts +355 -0
  182. package/src/protocol/handlers/catalog/admin.ts +368 -0
  183. package/src/protocol/handlers/catalog/index.ts +25 -0
  184. package/src/protocol/handlers/catalog/index_methods.ts +65 -0
  185. package/src/protocol/handlers/catalog/macro.ts +114 -0
  186. package/src/protocol/handlers/catalog/shared.ts +291 -0
  187. package/src/protocol/handlers/catalog/table.ts +401 -0
  188. package/src/protocol/handlers/catalog/view.ts +119 -0
  189. package/src/protocol/handlers/function.ts +302 -0
  190. package/src/protocol/handlers/shared.ts +80 -0
  191. package/src/protocol/handlers/table-buffering.ts +207 -0
  192. package/src/protocol/serialize.ts +23 -0
  193. package/src/protocol/serializers/arguments.ts +160 -0
  194. package/src/protocol/serializers/bind.ts +212 -0
  195. package/src/protocol/serializers/cardinality.ts +45 -0
  196. package/src/protocol/serializers/init.ts +247 -0
  197. package/src/protocol/serializers/shared.ts +46 -0
  198. package/src/protocol/state-serializer.ts +181 -0
  199. package/src/protocol/types.ts +153 -0
  200. package/src/secrets/helpers.ts +70 -0
  201. package/src/types.ts +71 -0
  202. package/src/util/arrow/index.ts +23 -0
  203. package/src/util/bytes.ts +14 -0
  204. package/src/util/statistics.ts +40 -0
  205. package/src/worker-cf-entry.ts +172 -0
  206. package/src/worker.ts +212 -0
package/src/crypto.ts ADDED
@@ -0,0 +1,143 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: LicenseRef-QueryFarm-Source-Available-1.0
3
+
4
+ // Catalog opaque-data AEAD envelopes.
5
+ //
6
+ // `attach_opaque_data` and `transaction_opaque_data` are implementation-chosen
7
+ // byte strings the catalog returns and the client round-trips back. On HTTP
8
+ // transport (where one worker authenticates many principals) the worker seals
9
+ // each value in an authenticated-encrypted envelope whose AAD binds the
10
+ // caller's (domain, principal); the transaction envelope additionally binds
11
+ // its parent attach envelope. A value sealed for one principal — or one
12
+ // attach — cannot be opened by another.
13
+ //
14
+ // Workers with no signing key (subprocess / unix transports) pass values
15
+ // through unchanged, since OS process ownership already enforces identity.
16
+ //
17
+ // Wire format: version(1 byte) || nonce(24 bytes) || ciphertext+tag.
18
+ // Matches vgi-python's vgi_rpc.crypto / vgi.worker and vgi-go's vgi/crypto.go.
19
+
20
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
21
+ import type { AuthContext } from "@query-farm/vgi-rpc";
22
+
23
+ const _UTF8 = new TextEncoder();
24
+
25
+ // v2: the inner attach plaintext is uuid(16) || catalog_bytes — catalog_attach
26
+ // prepends a framework-minted 16-byte UUID that storage shards on. Bumped from
27
+ // 1 so a stale v1 token (no uuid prefix) is cleanly rejected at open.
28
+ export const ATTACH_ENVELOPE_VERSION = 2;
29
+ export const TRANSACTION_ENVELOPE_VERSION = 2;
30
+
31
+ /** Width of the framework UUID prepended to every attach plaintext. */
32
+ export const ATTACH_UUID_LEN = 16;
33
+
34
+ const NONCE_LEN = 24; // XChaCha20-Poly1305 nonce
35
+ const TAG_LEN = 16; // Poly1305 tag
36
+ const MIN_ENVELOPE_LEN = 1 + NONCE_LEN + TAG_LEN;
37
+
38
+ const ATTACH_AAD_PREFIX = _UTF8.encode("vgi.attach_opaque_data.v1\0");
39
+ const TRANSACTION_AAD_PREFIX = _UTF8.encode("vgi.transaction_opaque_data.v1\0");
40
+
41
+ /**
42
+ * Thrown when an envelope cannot be opened — wrong principal, wrong parent
43
+ * attach, tampered, malformed, or simply unknown. Every failure mode maps to
44
+ * this single error so a probing caller cannot distinguish them.
45
+ */
46
+ export class OpaqueDataRejectedError extends Error {
47
+ constructor(field = "opaque data") {
48
+ super(`${field} not recognized`);
49
+ this.name = "OpaqueDataRejectedError";
50
+ }
51
+ }
52
+
53
+ function concat(...parts: Uint8Array[]): Uint8Array {
54
+ let total = 0;
55
+ for (const p of parts) total += p.length;
56
+ const out = new Uint8Array(total);
57
+ let off = 0;
58
+ for (const p of parts) {
59
+ out.set(p, off);
60
+ off += p.length;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Stretch/compress an operator-supplied key to the 32 bytes XChaCha20-Poly1305
67
+ * requires. Exactly-32-byte keys pass through unchanged; any other length is
68
+ * collapsed via SHA-256. Matches the vgi-python / vgi-go ports.
69
+ */
70
+ async function normalizeKey(key: Uint8Array): Promise<Uint8Array> {
71
+ if (key.length === 32) return key;
72
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", key as BufferSource));
73
+ }
74
+
75
+ /**
76
+ * Build the identity portion of an opaque-data AAD. Unauthenticated requests
77
+ * get a fixed anonymous tail, so an anonymous caller cannot open an envelope
78
+ * sealed for a real principal.
79
+ */
80
+ export function identityTail(auth: AuthContext | undefined): Uint8Array {
81
+ if (!auth || !auth.authenticated) return _UTF8.encode("\0anonymous");
82
+ const domain = _UTF8.encode(auth.domain ?? "");
83
+ const principal = _UTF8.encode(auth.principal ?? "");
84
+ return concat(Uint8Array.of(0x01), domain, Uint8Array.of(0x00), principal);
85
+ }
86
+
87
+ /** AAD for an `attach_opaque_data` envelope: prefix + caller identity. */
88
+ export function attachAad(auth: AuthContext | undefined): Uint8Array {
89
+ return concat(ATTACH_AAD_PREFIX, identityTail(auth));
90
+ }
91
+
92
+ /**
93
+ * AAD for a `transaction_opaque_data` envelope. Binds both the caller identity
94
+ * and the parent attach envelope, so a transaction value minted under one
95
+ * attach cannot be replayed against a different attach even by the same
96
+ * principal.
97
+ */
98
+ export function transactionAad(
99
+ auth: AuthContext | undefined,
100
+ attachEnvelope: Uint8Array,
101
+ ): Uint8Array {
102
+ return concat(TRANSACTION_AAD_PREFIX, identityTail(auth), Uint8Array.of(0x00), attachEnvelope);
103
+ }
104
+
105
+ /** Seal `payload` into an AEAD envelope: version || nonce || ciphertext+tag. */
106
+ export async function sealBytes(
107
+ payload: Uint8Array,
108
+ key: Uint8Array,
109
+ aad: Uint8Array,
110
+ version: number,
111
+ ): Promise<Uint8Array> {
112
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
113
+ const ciphertext = xchacha20poly1305(await normalizeKey(key), nonce, aad).encrypt(payload);
114
+ const out = new Uint8Array(1 + NONCE_LEN + ciphertext.length);
115
+ out[0] = version & 0xff;
116
+ out.set(nonce, 1);
117
+ out.set(ciphertext, 1 + NONCE_LEN);
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Open and verify an envelope produced by {@link sealBytes}. Every failure
123
+ * mode — malformed, wrong version, tampered, wrong key, wrong AAD
124
+ * (cross-principal / cross-attach replay) — throws {@link OpaqueDataRejectedError}.
125
+ */
126
+ export async function openBytes(
127
+ token: Uint8Array,
128
+ key: Uint8Array,
129
+ aad: Uint8Array,
130
+ version: number,
131
+ field = "opaque data",
132
+ ): Promise<Uint8Array> {
133
+ if (token.length < MIN_ENVELOPE_LEN || token[0] !== (version & 0xff)) {
134
+ throw new OpaqueDataRejectedError(field);
135
+ }
136
+ const nonce = token.subarray(1, 1 + NONCE_LEN);
137
+ const ciphertext = token.subarray(1 + NONCE_LEN);
138
+ try {
139
+ return xchacha20poly1305(await normalizeKey(key), nonce, aad).decrypt(ciphertext);
140
+ } catch {
141
+ throw new OpaqueDataRejectedError(field);
142
+ }
143
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // VGI-specific error classes.
3
+
4
+ export class VgiError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "VgiError";
8
+ }
9
+ }
10
+
11
+ export class RowCountMismatchError extends VgiError {
12
+ constructor(expected: number, actual: number) {
13
+ super(
14
+ `Scalar function output row count (${actual}) does not match ` +
15
+ `input row count (${expected})`
16
+ );
17
+ this.name = "RowCountMismatchError";
18
+ }
19
+ }
20
+
21
+ export class FunctionNotFoundError extends VgiError {
22
+ constructor(name: string, available?: string[]) {
23
+ super(`Unknown function '${name}'`);
24
+ this.name = "FunctionNotFoundError";
25
+ }
26
+ }
27
+
28
+ export class CatalogReadOnlyError extends VgiError {
29
+ constructor(operation: string) {
30
+ super(`catalog is read-only: ${operation} is not supported`);
31
+ this.name = "CatalogReadOnlyError";
32
+ }
33
+ }
34
+
35
+ export class CatalogNotFoundError extends VgiError {
36
+ constructor(entity: string, name: string) {
37
+ super(`${entity} '${name}' not found`);
38
+ this.name = "CatalogNotFoundError";
39
+ }
40
+ }
41
+
42
+ export class CatalogAlreadyExistsError extends VgiError {
43
+ constructor(entity: string, name: string) {
44
+ super(`${entity} '${name}' already exists`);
45
+ this.name = "CatalogAlreadyExistsError";
46
+ }
47
+ }
48
+
49
+ export class ArgumentValidationError extends VgiError {
50
+ constructor(message: string) {
51
+ super(message);
52
+ this.name = "ArgumentValidationError";
53
+ }
54
+ }
55
+
56
+ export class NoCatalogError extends VgiError {
57
+ constructor() {
58
+ super("No catalog is configured for this worker");
59
+ this.name = "NoCatalogError";
60
+ }
61
+ }
@@ -0,0 +1,147 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // FilteringOutputCollector — wraps an OutputCollector and applies pushdown
3
+ // filters to every emitted batch — plus human-readable formatters used in
4
+ // debug logs.
5
+
6
+ import { type VgiBatch, type VgiSchema, isBatch } from "../arrow/index.js";
7
+ import type { OutputCollector } from "@query-farm/vgi-rpc";
8
+ import { batchFromColumns } from "../util/arrow/index.js";
9
+ import { ComparisonOp, type ExprNode, type Filter } from "./types.js";
10
+ import { PushdownFilters } from "./evaluate.js";
11
+
12
+ /**
13
+ * Format pushdown filters as a human-readable SQL-like string.
14
+ * Returns "(none)" when no filters exist.
15
+ */
16
+ export function formatPushedFilters(filters: PushdownFilters | undefined): string {
17
+ if (!filters || filters.filters.length === 0) return "(none)";
18
+ const sql = filters.toSql();
19
+ return sql || "(none)";
20
+ }
21
+
22
+ /**
23
+ * Format pushdown filters using Python-style `repr()` — e.g.
24
+ * `PushdownFilters([ConstantFilter(n < 4999)])`. Matches vgi-python's
25
+ * `_format_pushed_filters_safe` output so tests that pattern-match the
26
+ * repr (e.g. `dynamic_filter` checking for `ConstantFilter(n <`) work
27
+ * identically across workers.
28
+ */
29
+ export function reprPushedFilters(filters: PushdownFilters | undefined): string {
30
+ if (!filters || filters.filters.length === 0) return "(none)";
31
+ const parts = filters.filters.map(reprFilter);
32
+ return `PushdownFilters([${parts.join(", ")}])`;
33
+ }
34
+
35
+ function opSymbol(op: ComparisonOp): string {
36
+ switch (op) {
37
+ case ComparisonOp.EQ: return "==";
38
+ case ComparisonOp.NE: return "!=";
39
+ case ComparisonOp.GT: return ">";
40
+ case ComparisonOp.GE: return ">=";
41
+ case ComparisonOp.LT: return "<";
42
+ case ComparisonOp.LE: return "<=";
43
+ }
44
+ }
45
+
46
+ function reprFilter(f: Filter): string {
47
+ switch (f.type) {
48
+ case "constant":
49
+ return `ConstantFilter(${f.columnName} ${opSymbol(f.op)} ${reprValue(f.value)})`;
50
+ case "is_null":
51
+ return `IsNullFilter(${f.columnName} IS NULL)`;
52
+ case "is_not_null":
53
+ return `IsNotNullFilter(${f.columnName} IS NOT NULL)`;
54
+ case "in": {
55
+ const values = [...f.values];
56
+ const preview = values.length > 5
57
+ ? `${JSON.stringify(values.slice(0, 3))}...(${values.length} total)`
58
+ : JSON.stringify(values);
59
+ return `InFilter(${f.columnName} IN ${preview})`;
60
+ }
61
+ case "and": {
62
+ const kids = f.children.map(reprFilter).join(" AND ");
63
+ return `AndFilter(${kids})`;
64
+ }
65
+ case "or": {
66
+ const kids = f.children.map(reprFilter).join(" OR ");
67
+ return `OrFilter(${kids})`;
68
+ }
69
+ case "struct":
70
+ return `StructFilter(${f.columnName}.${f.childName}: ${reprFilter(f.childFilter)})`;
71
+ case "expression":
72
+ return `ExpressionFilter(${f.columnName}: ${reprExpr(f.expr)})`;
73
+ }
74
+ }
75
+
76
+ function reprExpr(e: ExprNode): string {
77
+ switch (e.expr_type) {
78
+ case "column_ref": return `col#${e.index}`;
79
+ case "constant": return reprValue(e.value);
80
+ case "function": return `${e.function_name}(${e.children.map(reprExpr).join(", ")})`;
81
+ case "comparison": return `(${reprExpr(e.left)} ${opSymbol(e.op)} ${reprExpr(e.right)})`;
82
+ case "conjunction":
83
+ return `(${e.children.map(reprExpr).join(e.conjunction_type === "and" ? " AND " : " OR ")})`;
84
+ }
85
+ }
86
+
87
+ function reprValue(v: any): string {
88
+ if (typeof v === "string") return JSON.stringify(v);
89
+ if (typeof v === "bigint") return String(v);
90
+ if (v === null || v === undefined) return "null";
91
+ return String(v);
92
+ }
93
+
94
+ /**
95
+ * Wraps an OutputCollector to automatically apply pushdown filters
96
+ * to every emitted batch.
97
+ */
98
+ export class FilteringOutputCollector {
99
+ constructor(
100
+ private inner: OutputCollector,
101
+ private filters: PushdownFilters,
102
+ ) {}
103
+
104
+ get outputSchema(): VgiSchema {
105
+ return this.inner.outputSchema;
106
+ }
107
+
108
+ get finished(): boolean {
109
+ return this.inner.finished;
110
+ }
111
+
112
+ emit(batch: VgiBatch, metadata?: Map<string, string>): void;
113
+ emit(columns: Record<string, any[]>): void;
114
+ emit(
115
+ batchOrColumns: VgiBatch | Record<string, any[]>,
116
+ metadata?: Map<string, string>,
117
+ ): void {
118
+ let batch: VgiBatch;
119
+ if (isBatch(batchOrColumns)) {
120
+ batch = batchOrColumns;
121
+ } else {
122
+ batch = batchFromColumns(batchOrColumns as Record<string, any[]>, this.inner.outputSchema as any);
123
+ }
124
+ const filtered = this.filters.apply(batch as any);
125
+ this.inner.emit(filtered as any, metadata);
126
+ }
127
+
128
+ emitRow(values: Record<string, any>): void {
129
+ const columns: Record<string, any[]> = {};
130
+ for (const [key, value] of Object.entries(values)) {
131
+ columns[key] = [value];
132
+ }
133
+ this.emit(columns);
134
+ }
135
+
136
+ finish(): void {
137
+ this.inner.finish();
138
+ }
139
+
140
+ clientLog(
141
+ level: string,
142
+ message: string,
143
+ extra?: Record<string, string>,
144
+ ): void {
145
+ this.inner.clientLog(level, message, extra);
146
+ }
147
+ }
@@ -0,0 +1,253 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Deserialize pushdown filters from the wire format (Arrow RecordBatch with
3
+ // JSON specs in column 0 and value-ref scalars in columns 1+).
4
+
5
+ import { type VgiBatch, type VgiDataType, readCanonicalValue } from "../arrow/index.js";
6
+ import { ComparisonOp, type ExprNode, type Filter } from "./types.js";
7
+ import { PushdownFilters } from "./evaluate.js";
8
+
9
+ function parseFilter(
10
+ spec: any,
11
+ getValue: (ref: number) => any,
12
+ getJoinKeysColumn?: (columnName: string) => any[] | null,
13
+ ): Filter | null {
14
+ const columnName: string = spec.column_name ?? "";
15
+ const columnIndex: number = spec.column_index ?? 0;
16
+
17
+ switch (spec.type) {
18
+ case "constant":
19
+ return {
20
+ type: "constant",
21
+ columnName,
22
+ columnIndex,
23
+ op: spec.op as ComparisonOp,
24
+ value: getValue(spec.value_ref),
25
+ };
26
+
27
+ case "is_null":
28
+ return { type: "is_null", columnName, columnIndex };
29
+
30
+ case "is_not_null":
31
+ return { type: "is_not_null", columnName, columnIndex };
32
+
33
+ case "in": {
34
+ const listScalar = getValue(spec.value_ref);
35
+ // listScalar is an Arrow list value — extract elements into a Set
36
+ const values = new Set<any>();
37
+ if (listScalar && typeof listScalar[Symbol.iterator] === "function") {
38
+ for (const v of listScalar) {
39
+ values.add(v);
40
+ }
41
+ } else if (Array.isArray(listScalar)) {
42
+ for (const v of listScalar) {
43
+ values.add(v);
44
+ }
45
+ }
46
+ return { type: "in", columnName, columnIndex, values };
47
+ }
48
+
49
+ case "and": {
50
+ // AND: drop unparseable children; the remaining ones still filter correctly
51
+ // (conjunction is weaker with fewer children, which is safe — extra rows
52
+ // pass but DuckDB re-filters client-side).
53
+ const andChildren = (spec.children ?? [])
54
+ .map((c: any) => parseFilter(c, getValue, getJoinKeysColumn))
55
+ .filter((f: Filter | null): f is Filter => f !== null);
56
+ if (andChildren.length === 0) return null;
57
+ return { type: "and", columnName, columnIndex, children: andChildren };
58
+ }
59
+
60
+ case "or": {
61
+ // OR: dropping a child would strengthen the filter (fewer rows pass),
62
+ // which is unsafe. If any child fails to parse, drop the whole OR.
63
+ const orChildren: Filter[] = [];
64
+ for (const c of spec.children ?? []) {
65
+ const parsed = parseFilter(c, getValue, getJoinKeysColumn);
66
+ if (parsed === null) return null;
67
+ orChildren.push(parsed);
68
+ }
69
+ return { type: "or", columnName, columnIndex, children: orChildren };
70
+ }
71
+
72
+ case "struct": {
73
+ const childFilter = parseFilter(spec.child_filter, getValue, getJoinKeysColumn);
74
+ if (childFilter === null) return null;
75
+ return {
76
+ type: "struct",
77
+ columnName,
78
+ columnIndex,
79
+ childIndex: spec.child_index ?? 0,
80
+ childName: spec.child_name ?? "",
81
+ childFilter,
82
+ };
83
+ }
84
+
85
+ case "join_keys": {
86
+ // vgi-python `FilterType.JOIN_KEYS`. DuckDB's dynamic_or_filter
87
+ // optimizer can promote IN / OR lists or join-predicate pushdowns into
88
+ // this shape. Look the keys_column up in the init request's join_keys
89
+ // batches and materialize as an InFilter. If no callback or no batch
90
+ // matches, fall through as null (graceful degradation).
91
+ const keysColumn: string = spec.keys_column ?? "";
92
+ if (!getJoinKeysColumn) return null;
93
+ const values = getJoinKeysColumn(keysColumn);
94
+ if (values === null) return null;
95
+ const set = new Set<any>(values);
96
+ return { type: "in", columnName, columnIndex, values: set };
97
+ }
98
+
99
+ case "expression": {
100
+ // Parse the serialized expression tree (DuckDB bound-expression → JSON
101
+ // emitted by FilterSerializer::SerializeExpression in C++). The tree
102
+ // may reference constants by `value_ref` — resolve those now so the
103
+ // evaluator sees plain JS values.
104
+ const expr = parseExprNode(spec.expr, getValue);
105
+ if (!expr) return null;
106
+ return { type: "expression", columnName, columnIndex, expr };
107
+ }
108
+
109
+ default:
110
+ throw new Error(`Unknown filter type: ${spec.type}`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Build a getJoinKeysColumn lookup from an InitRequest's `joinKeys` batches.
116
+ * Each batch typically has a single column whose name is the join-keys column
117
+ * referenced by `spec.keys_column` on a join_keys filter.
118
+ */
119
+ export function buildJoinKeysLookup(
120
+ joinKeyBatches: VgiBatch[],
121
+ ): (columnName: string) => any[] | null {
122
+ const index = new Map<string, any[]>();
123
+ for (const batch of joinKeyBatches) {
124
+ for (const field of batch.schema.fields) {
125
+ const col = batch.getChild(field.name);
126
+ if (!col) continue;
127
+ const type = field.type as unknown as VgiDataType;
128
+ const values: any[] = [];
129
+ for (let i = 0; i < batch.numRows; i++) {
130
+ // Canonical read so join keys match the column cells they filter
131
+ // against (built via readCanonicalValue in evaluate.ts).
132
+ values.push(readCanonicalValue(type, col, i));
133
+ }
134
+ index.set(field.name, values);
135
+ }
136
+ }
137
+ return (name: string) => index.get(name) ?? null;
138
+ }
139
+
140
+ // Parse a serialized expression subtree. Returns null if the shape doesn't
141
+ // look like one we can evaluate (unknown expr_type); the caller skips the
142
+ // whole filter in that case, which is equivalent to not pushing it down.
143
+ function parseExprNode(
144
+ spec: any,
145
+ getValue: (ref: number) => any,
146
+ ): ExprNode | null {
147
+ if (!spec || typeof spec !== "object") return null;
148
+ switch (spec.expr_type) {
149
+ case "column_ref":
150
+ return { expr_type: "column_ref", index: Number(spec.index ?? 0) };
151
+ case "constant":
152
+ return { expr_type: "constant", value: getValue(Number(spec.value_ref)) };
153
+ case "function": {
154
+ const kids: ExprNode[] = [];
155
+ for (const c of spec.children ?? []) {
156
+ const k = parseExprNode(c, getValue);
157
+ if (!k) return null;
158
+ kids.push(k);
159
+ }
160
+ return { expr_type: "function", function_name: String(spec.function_name ?? ""), children: kids };
161
+ }
162
+ case "comparison": {
163
+ const left = parseExprNode(spec.left, getValue);
164
+ const right = parseExprNode(spec.right, getValue);
165
+ if (!left || !right) return null;
166
+ return {
167
+ expr_type: "comparison",
168
+ op: opFromString(spec.op),
169
+ left,
170
+ right,
171
+ };
172
+ }
173
+ case "conjunction": {
174
+ const kids: ExprNode[] = [];
175
+ for (const c of spec.children ?? []) {
176
+ const k = parseExprNode(c, getValue);
177
+ if (!k) return null;
178
+ kids.push(k);
179
+ }
180
+ return {
181
+ expr_type: "conjunction",
182
+ conjunction_type: spec.conjunction_type === "or" ? "or" : "and",
183
+ children: kids,
184
+ };
185
+ }
186
+ default:
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function opFromString(s: string): ComparisonOp {
192
+ switch (s) {
193
+ case "eq": case "==": return ComparisonOp.EQ;
194
+ case "ne": case "!=": return ComparisonOp.NE;
195
+ case "gt": case ">": return ComparisonOp.GT;
196
+ case "ge": case ">=": return ComparisonOp.GE;
197
+ case "lt": case "<": return ComparisonOp.LT;
198
+ case "le": case "<=": return ComparisonOp.LE;
199
+ default: return ComparisonOp.EQ;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Deserialize pushdown filters from an Arrow RecordBatch.
205
+ *
206
+ * Wire format:
207
+ * - Column 0: UTF8 — JSON array of filter specs. Field metadata: vgi_filter_version: "1"
208
+ * - Columns 1+: Scalar values referenced by value_ref → column N+1
209
+ */
210
+ export function deserializeFilters(
211
+ batch: VgiBatch,
212
+ getJoinKeysColumn?: (columnName: string) => any[] | null,
213
+ ): PushdownFilters {
214
+ // Validate version
215
+ const field0 = batch.schema.fields[0];
216
+ const version = field0.metadata?.get("vgi_filter_version");
217
+ if (!version) {
218
+ throw new Error(
219
+ "Filter batch missing vgi_filter_version metadata on field 0",
220
+ );
221
+ }
222
+ if (version !== "1") {
223
+ throw new Error(`Unsupported filter version: ${version}`);
224
+ }
225
+
226
+ // Parse JSON filter specs from column 0, row 0
227
+ const jsonCol = batch.getChildAt(0)!;
228
+ const jsonStr = jsonCol.get(0) as string;
229
+ let specs: any[];
230
+ try {
231
+ specs = JSON.parse(jsonStr);
232
+ } catch (e) {
233
+ throw new Error(`Failed to parse filter JSON: ${e}`);
234
+ }
235
+
236
+ // Value resolver: value_ref N → column N+1, row 0. Read through the canonical
237
+ // reader so filter literals land in the SAME representation as the column
238
+ // cells they're compared against (e.g. timestamp -> bigint on both backends),
239
+ // and so it works on flechette (no arrow-js `.get()`).
240
+ const getValue = (ref: number): any => {
241
+ const col = batch.getChildAt(ref + 1);
242
+ if (!col) {
243
+ throw new Error(`Filter value_ref ${ref} references non-existent column ${ref + 1}`);
244
+ }
245
+ const type = batch.schema.fields[ref + 1].type as unknown as VgiDataType;
246
+ return readCanonicalValue(type, col, 0);
247
+ };
248
+
249
+ const filters = specs
250
+ .map((spec) => parseFilter(spec, getValue, getJoinKeysColumn))
251
+ .filter((f): f is Filter => f !== null);
252
+ return new PushdownFilters(filters, version);
253
+ }