@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.
- package/LICENSE +134 -0
- package/README.md +494 -0
- package/dist/__dbg.test-d.d.ts +1 -0
- package/dist/arguments/argument-spec.d.ts +51 -0
- package/dist/arguments/arguments.d.ts +13 -0
- package/dist/arrow/codec/branded.d.ts +83 -0
- package/dist/arrow/codec/registry.d.ts +29 -0
- package/dist/arrow/codec/repr.d.ts +23 -0
- package/dist/arrow/codec/type-descriptors.d.ts +109 -0
- package/dist/arrow/impl-arrowjs/build.d.ts +19 -0
- package/dist/arrow/impl-arrowjs/canonical.d.ts +15 -0
- package/dist/arrow/impl-arrowjs/empty.d.ts +7 -0
- package/dist/arrow/impl-arrowjs/filter.d.ts +11 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +11 -0
- package/dist/arrow/impl-arrowjs/ipc.d.ts +24 -0
- package/dist/arrow/impl-arrowjs/iterate.d.ts +39 -0
- package/dist/arrow/impl-arrowjs/project.d.ts +10 -0
- package/dist/arrow/impl-arrowjs/schema.d.ts +62 -0
- package/dist/arrow/impl-arrowjs/statistics.d.ts +13 -0
- package/dist/arrow/impl-flechette/build.d.ts +14 -0
- package/dist/arrow/impl-flechette/canonical.d.ts +18 -0
- package/dist/arrow/impl-flechette/empty.d.ts +9 -0
- package/dist/arrow/impl-flechette/filter.d.ts +10 -0
- package/dist/arrow/impl-flechette/index.d.ts +11 -0
- package/dist/arrow/impl-flechette/ipc.d.ts +33 -0
- package/dist/arrow/impl-flechette/iterate.d.ts +29 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +10 -0
- package/dist/arrow/impl-flechette/normalize-type.d.ts +11 -0
- package/dist/arrow/impl-flechette/project.d.ts +3 -0
- package/dist/arrow/impl-flechette/schema.d.ts +47 -0
- package/dist/arrow/impl-flechette/statistics.d.ts +13 -0
- package/dist/arrow/index.d.ts +11 -0
- package/dist/arrow/predicates.d.ts +47 -0
- package/dist/arrow/repr.test-d.d.ts +52 -0
- package/dist/arrow/schema-types.d.ts +71 -0
- package/dist/arrow/types.d.ts +57 -0
- package/dist/catalog/attach-option.d.ts +33 -0
- package/dist/catalog/attach-options.d.ts +30 -0
- package/dist/catalog/composite.d.ts +33 -0
- package/dist/catalog/descriptors.d.ts +154 -0
- package/dist/catalog/interface.d.ts +239 -0
- package/dist/catalog/read-only.d.ts +33 -0
- package/dist/client/client.d.ts +189 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/helpers.d.ts +12 -0
- package/dist/client/protocol.d.ts +16 -0
- package/dist/client/types.d.ts +125 -0
- package/dist/client-entry.d.ts +11 -0
- package/dist/client-entry.js +48 -0
- package/dist/client-entry.js.map +9 -0
- package/dist/codec/asd.d.ts +25 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/errors.d.ts +24 -0
- package/dist/filter-pushdown/collector.d.ts +32 -0
- package/dist/filter-pushdown/deserialize.d.ts +16 -0
- package/dist/filter-pushdown/evaluate.d.ts +51 -0
- package/dist/filter-pushdown/index.d.ts +4 -0
- package/dist/filter-pushdown/types.d.ts +84 -0
- package/dist/functions/aggregate.d.ts +129 -0
- package/dist/functions/copy-from.d.ts +81 -0
- package/dist/functions/copy-to.d.ts +121 -0
- package/dist/functions/registry.d.ts +16 -0
- package/dist/functions/scalar.d.ts +98 -0
- package/dist/functions/storage-cf-do.d.ts +47 -0
- package/dist/functions/storage.d.ts +86 -0
- package/dist/functions/table-buffering.d.ts +99 -0
- package/dist/functions/table-in-out.d.ts +64 -0
- package/dist/functions/table.d.ts +118 -0
- package/dist/functions/types.d.ts +157 -0
- package/dist/generated/vgi-client.d.ts +466 -0
- package/dist/generated/vgi-protocol-schemas.d.ts +115 -0
- package/dist/index.core.d.ts +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10888 -0
- package/dist/index.js.map +85 -0
- package/dist/metadata/resolve.d.ts +3 -0
- package/dist/metadata/serialize.d.ts +12 -0
- package/dist/metadata/types.d.ts +47 -0
- package/dist/protocol/dispatch.d.ts +23 -0
- package/dist/protocol/handlers/aggregate.d.ts +3 -0
- package/dist/protocol/handlers/catalog/admin.d.ts +3 -0
- package/dist/protocol/handlers/catalog/index.d.ts +3 -0
- package/dist/protocol/handlers/catalog/index_methods.d.ts +3 -0
- package/dist/protocol/handlers/catalog/macro.d.ts +3 -0
- package/dist/protocol/handlers/catalog/shared.d.ts +70 -0
- package/dist/protocol/handlers/catalog/table.d.ts +3 -0
- package/dist/protocol/handlers/catalog/view.d.ts +3 -0
- package/dist/protocol/handlers/function.d.ts +8 -0
- package/dist/protocol/handlers/shared.d.ts +31 -0
- package/dist/protocol/handlers/table-buffering.d.ts +3 -0
- package/dist/protocol/serialize.d.ts +4 -0
- package/dist/protocol/serializers/arguments.d.ts +3 -0
- package/dist/protocol/serializers/bind.d.ts +9 -0
- package/dist/protocol/serializers/cardinality.d.ts +7 -0
- package/dist/protocol/serializers/init.d.ts +9 -0
- package/dist/protocol/serializers/shared.d.ts +12 -0
- package/dist/protocol/state-serializer.d.ts +23 -0
- package/dist/protocol/types.d.ts +129 -0
- package/dist/secrets/helpers.d.ts +15 -0
- package/dist/types.d.ts +52 -0
- package/dist/util/arrow/index.d.ts +1 -0
- package/dist/util/bytes.d.ts +2 -0
- package/dist/util/statistics.d.ts +7 -0
- package/dist/worker-cf-entry.d.ts +30 -0
- package/dist/worker-cf-entry.js +8767 -0
- package/dist/worker-cf-entry.js.map +74 -0
- package/dist/worker.d.ts +36 -0
- package/package.json +96 -0
- package/src/arguments/argument-spec.ts +200 -0
- package/src/arguments/arguments.ts +81 -0
- package/src/arrow/codec/branded.ts +206 -0
- package/src/arrow/codec/registry.ts +674 -0
- package/src/arrow/codec/repr.ts +181 -0
- package/src/arrow/codec/type-descriptors.ts +137 -0
- package/src/arrow/impl-arrowjs/build.ts +88 -0
- package/src/arrow/impl-arrowjs/canonical.ts +509 -0
- package/src/arrow/impl-arrowjs/empty.ts +72 -0
- package/src/arrow/impl-arrowjs/filter.ts +53 -0
- package/src/arrow/impl-arrowjs/index.ts +54 -0
- package/src/arrow/impl-arrowjs/ipc.ts +67 -0
- package/src/arrow/impl-arrowjs/iterate.ts +159 -0
- package/src/arrow/impl-arrowjs/project.ts +73 -0
- package/src/arrow/impl-arrowjs/schema.ts +256 -0
- package/src/arrow/impl-arrowjs/statistics.ts +185 -0
- package/src/arrow/impl-flechette/build.ts +65 -0
- package/src/arrow/impl-flechette/canonical.ts +289 -0
- package/src/arrow/impl-flechette/empty.ts +20 -0
- package/src/arrow/impl-flechette/filter.ts +42 -0
- package/src/arrow/impl-flechette/index.ts +54 -0
- package/src/arrow/impl-flechette/ipc.ts +110 -0
- package/src/arrow/impl-flechette/iterate.ts +121 -0
- package/src/arrow/impl-flechette/message-meta.ts +175 -0
- package/src/arrow/impl-flechette/normalize-type.ts +99 -0
- package/src/arrow/impl-flechette/project.ts +64 -0
- package/src/arrow/impl-flechette/schema.ts +202 -0
- package/src/arrow/impl-flechette/statistics.ts +198 -0
- package/src/arrow/index.ts +129 -0
- package/src/arrow/predicates.ts +66 -0
- package/src/arrow/repr.test-d.ts +189 -0
- package/src/arrow/schema-types.ts +213 -0
- package/src/arrow/types.ts +74 -0
- package/src/catalog/attach-option.ts +107 -0
- package/src/catalog/attach-options.ts +79 -0
- package/src/catalog/composite.ts +180 -0
- package/src/catalog/descriptors.ts +164 -0
- package/src/catalog/interface.ts +721 -0
- package/src/catalog/read-only.ts +909 -0
- package/src/client/client.ts +1348 -0
- package/src/client/errors.ts +69 -0
- package/src/client/helpers.ts +56 -0
- package/src/client/protocol.ts +33 -0
- package/src/client/types.ts +140 -0
- package/src/client-entry.ts +112 -0
- package/src/codec/asd.ts +147 -0
- package/src/crypto.ts +143 -0
- package/src/errors.ts +61 -0
- package/src/filter-pushdown/collector.ts +147 -0
- package/src/filter-pushdown/deserialize.ts +253 -0
- package/src/filter-pushdown/evaluate.ts +591 -0
- package/src/filter-pushdown/index.ts +28 -0
- package/src/filter-pushdown/types.ts +91 -0
- package/src/functions/aggregate.ts +384 -0
- package/src/functions/copy-from.ts +282 -0
- package/src/functions/copy-to.ts +319 -0
- package/src/functions/registry.ts +258 -0
- package/src/functions/scalar.ts +356 -0
- package/src/functions/storage-cf-do.ts +303 -0
- package/src/functions/storage.ts +521 -0
- package/src/functions/table-buffering.ts +562 -0
- package/src/functions/table-in-out.ts +373 -0
- package/src/functions/table.ts +521 -0
- package/src/functions/types.ts +192 -0
- package/src/generated/vgi-client.ts +560 -0
- package/src/generated/vgi-protocol-schemas.ts +864 -0
- package/src/index.core.ts +267 -0
- package/src/index.ts +11 -0
- package/src/metadata/resolve.ts +109 -0
- package/src/metadata/serialize.ts +242 -0
- package/src/metadata/types.ts +60 -0
- package/src/protocol/dispatch.ts +52 -0
- package/src/protocol/handlers/aggregate.ts +355 -0
- package/src/protocol/handlers/catalog/admin.ts +368 -0
- package/src/protocol/handlers/catalog/index.ts +25 -0
- package/src/protocol/handlers/catalog/index_methods.ts +65 -0
- package/src/protocol/handlers/catalog/macro.ts +114 -0
- package/src/protocol/handlers/catalog/shared.ts +291 -0
- package/src/protocol/handlers/catalog/table.ts +401 -0
- package/src/protocol/handlers/catalog/view.ts +119 -0
- package/src/protocol/handlers/function.ts +302 -0
- package/src/protocol/handlers/shared.ts +80 -0
- package/src/protocol/handlers/table-buffering.ts +207 -0
- package/src/protocol/serialize.ts +23 -0
- package/src/protocol/serializers/arguments.ts +160 -0
- package/src/protocol/serializers/bind.ts +212 -0
- package/src/protocol/serializers/cardinality.ts +45 -0
- package/src/protocol/serializers/init.ts +247 -0
- package/src/protocol/serializers/shared.ts +46 -0
- package/src/protocol/state-serializer.ts +181 -0
- package/src/protocol/types.ts +153 -0
- package/src/secrets/helpers.ts +70 -0
- package/src/types.ts +71 -0
- package/src/util/arrow/index.ts +23 -0
- package/src/util/bytes.ts +14 -0
- package/src/util/statistics.ts +40 -0
- package/src/worker-cf-entry.ts +172 -0
- 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
|
+
}
|