@query-farm/vgi 0.27.0 → 0.29.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/README.md +26 -7
- package/dist/arguments/argument-spec.d.ts +53 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
- package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
- package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
- package/dist/arrow/impl-flechette/index.d.ts +1 -1
- package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
- package/dist/arrow/impl-flechette/schema.d.ts +2 -0
- package/dist/arrow/index.d.ts +1 -1
- package/dist/arrow/schema-types.d.ts +3 -1
- package/dist/arrow/types.d.ts +39 -3
- package/dist/catalog/interface.d.ts +43 -0
- package/dist/client/client.d.ts +1 -1
- package/dist/client-entry.js +360 -178
- package/dist/client-entry.js.map +14 -14
- package/dist/functions/table.d.ts +63 -0
- package/dist/functions/types.d.ts +38 -0
- package/dist/generated/vgi-client.d.ts +59 -0
- package/dist/generated/vgi-protocol-schemas.d.ts +19 -0
- package/dist/http/fetch.d.ts +10 -0
- package/dist/index.core.d.ts +2 -2
- package/dist/index.js +1203 -834
- package/dist/index.js.map +40 -37
- package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
- package/dist/protocol/handlers/function.d.ts +11 -0
- package/dist/protocol/serializers/bind.d.ts +1 -2
- package/dist/protocol/serializers/cardinality.d.ts +1 -2
- package/dist/protocol/serializers/splits.d.ts +126 -0
- package/dist/protocol/types.d.ts +27 -0
- package/dist/request-auth.d.ts +28 -0
- package/dist/serve-entry.d.ts +4 -0
- package/dist/serve-entry.js +745 -359
- package/dist/serve-entry.js.map +32 -29
- package/dist/split-token.d.ts +88 -0
- package/dist/worker-cf-entry.js +1029 -619
- package/dist/worker-cf-entry.js.map +37 -34
- package/package.json +3 -3
- package/src/arguments/argument-spec.ts +81 -0
- package/src/arguments/arguments.ts +0 -1
- package/src/arrow/impl-arrowjs/index.ts +1 -1
- package/src/arrow/impl-arrowjs/iterate.ts +21 -0
- package/src/arrow/impl-arrowjs/schema.ts +4 -0
- package/src/arrow/impl-flechette/index.ts +1 -1
- package/src/arrow/impl-flechette/iterate.ts +24 -1
- package/src/arrow/impl-flechette/schema.ts +4 -0
- package/src/arrow/index.ts +1 -1
- package/src/arrow/schema-types.ts +4 -0
- package/src/arrow/types.ts +39 -3
- package/src/catalog/interface.ts +124 -29
- package/src/catalog/read-only.ts +18 -18
- package/src/client/client.ts +6 -2
- package/src/functions/aggregate.ts +3 -0
- package/src/functions/copy-from.ts +2 -1
- package/src/functions/copy-to.ts +2 -1
- package/src/functions/scalar.ts +11 -0
- package/src/functions/table-buffering.ts +3 -0
- package/src/functions/table-in-out.ts +8 -4
- package/src/functions/table.ts +129 -2
- package/src/functions/types.ts +40 -0
- package/src/generated/vgi-client.ts +63 -1
- package/src/generated/vgi-protocol-schemas.ts +240 -14
- package/src/http/fetch.ts +117 -2
- package/src/index.core.ts +2 -2
- package/src/protocol/handlers/aggregate.ts +10 -5
- package/src/protocol/handlers/catalog/admin.ts +41 -48
- package/src/protocol/handlers/catalog/index_methods.ts +4 -11
- package/src/protocol/handlers/catalog/macro.ts +8 -16
- package/src/protocol/handlers/catalog/shared.ts +10 -57
- package/src/protocol/handlers/catalog/table.ts +33 -96
- package/src/protocol/handlers/catalog/view.ts +13 -17
- package/src/protocol/handlers/function.ts +311 -18
- package/src/protocol/handlers/table-buffering.ts +7 -4
- package/src/protocol/serializers/bind.ts +56 -63
- package/src/protocol/serializers/cardinality.ts +7 -7
- package/src/protocol/serializers/init.ts +52 -10
- package/src/protocol/serializers/splits.ts +219 -0
- package/src/protocol/state-serializer.ts +55 -1
- package/src/protocol/types.ts +27 -0
- package/src/request-auth.ts +73 -0
- package/src/serve-entry.ts +5 -0
- package/src/split-token.ts +297 -0
- package/src/worker-cf-entry.ts +2 -1
- package/src/worker.ts +30 -10
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ArrowSerializableDataclass field declaration order so the format is
|
|
4
4
|
// positional-compatible with the Python reader.
|
|
5
5
|
|
|
6
|
-
import { type VgiSchema, schema,
|
|
6
|
+
import { type VgiSchema, schema, field, type VgiBatch, type VgiDataType, utf8, binary, bool, struct, dictionary, int16 } from "../../arrow/index.js";
|
|
7
7
|
import { Arguments } from "../../arguments/arguments.js";
|
|
8
8
|
import { FunctionType } from "../../types.js";
|
|
9
9
|
import type { BindRequest, BindResponse, CopyFromContext, CopyToContext } from "../types.js";
|
|
@@ -16,59 +16,58 @@ import {
|
|
|
16
16
|
import { toUint8Array, buildSingleRowBatch } from "./shared.js";
|
|
17
17
|
import { serializeArguments, deserializeArguments } from "./arguments.js";
|
|
18
18
|
|
|
19
|
+
// The BindRequest wire record. This must equal `BindRequestSchema` in
|
|
20
|
+
// src/generated/vgi-protocol-schemas.ts EXACTLY — same columns, same order,
|
|
21
|
+
// same types, same nullability — and src/__tests__/wire-schema-completeness.ts
|
|
22
|
+
// asserts that it does.
|
|
23
|
+
//
|
|
24
|
+
// Exactly, not approximately. It is tempting to reason that every peer reads
|
|
25
|
+
// these columns by name, so a nullable column with nothing to say can be left
|
|
26
|
+
// out and a bind that opens no COPY scan can keep a shorter "legacy" shape.
|
|
27
|
+
// That is what this file used to do, and it is wrong: vgi-rpc-go validates a
|
|
28
|
+
// request against its declared parameter contract with `arrow.Schema.Equal`,
|
|
29
|
+
// which is order-, name-, type- AND nullability-sensitive. A record short one
|
|
30
|
+
// nullable column fails EVERY call of that shape, with a message that prints
|
|
31
|
+
// two schemas in full and leaves the reader to diff them by eye.
|
|
32
|
+
//
|
|
33
|
+
// So `copy_from` and `copy_to` are always present and null on a non-COPY bind,
|
|
34
|
+
// `schema_name` is LAST (not before them), and `function_type` is a dictionary
|
|
35
|
+
// rather than plain utf8.
|
|
36
|
+
const COPY_FROM_STRUCT_TYPE = struct([
|
|
37
|
+
field("format", utf8(), false),
|
|
38
|
+
field("file_path", utf8(), false),
|
|
39
|
+
field("expected_schema", binary(), false),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const COPY_TO_STRUCT_TYPE = struct([
|
|
43
|
+
field("format", utf8(), false),
|
|
44
|
+
field("file_path", utf8(), false),
|
|
45
|
+
]);
|
|
46
|
+
|
|
19
47
|
const BIND_REQUEST_SCHEMA = schema([
|
|
20
48
|
field("function_name", utf8(), false),
|
|
21
49
|
field("arguments", binary(), false),
|
|
22
|
-
field("function_type", utf8(), false),
|
|
50
|
+
field("function_type", dictionary(utf8(), int16()), false),
|
|
23
51
|
field("input_schema", binary(), true),
|
|
24
52
|
field("settings", binary(), true),
|
|
25
53
|
field("secrets", binary(), true),
|
|
26
54
|
field("attach_opaque_data", binary(), true),
|
|
27
55
|
field("transaction_opaque_data", binary(), true),
|
|
28
56
|
field("resolved_secrets_provided", bool(), false),
|
|
29
|
-
// Time travel AT clause
|
|
30
|
-
// Both null when the scan has no AT clause. See BindRequest.at_unit.
|
|
57
|
+
// Time travel AT clause. Both null when the scan has no AT clause.
|
|
31
58
|
field("at_unit", utf8(), true),
|
|
32
59
|
field("at_value", utf8(), true),
|
|
60
|
+
// Null unless this bind opens a COPY scan / sink.
|
|
61
|
+
field("copy_from", COPY_FROM_STRUCT_TYPE, true),
|
|
62
|
+
field("copy_to", COPY_TO_STRUCT_TYPE, true),
|
|
33
63
|
// Catalog schema owning the function. A worker may register one name in
|
|
34
64
|
// several schemas, so the bare name is not a unique key — resolution is by
|
|
35
65
|
// (schema_name, function_name). Null for callers with no catalog context.
|
|
36
66
|
field("schema_name", utf8(), true),
|
|
37
67
|
]);
|
|
38
68
|
|
|
39
|
-
// COPY ... FROM context — a nullable nested struct<format, file_path,
|
|
40
|
-
// expected_schema>. Byte-for-byte the shape the C++ extension builds in
|
|
41
|
-
// vgi_rpc_types.cpp (BuildBindRequest's copy_from branch) and the Python
|
|
42
|
-
// BindRequest.copy_from field. Only appended to the serialized batch when the
|
|
43
|
-
// bind actually opens a COPY scan, so ordinary scans keep the legacy wire shape.
|
|
44
|
-
const COPY_FROM_STRUCT_TYPE = struct([
|
|
45
|
-
field("format", utf8(), false),
|
|
46
|
-
field("file_path", utf8(), false),
|
|
47
|
-
field("expected_schema", binary(), false),
|
|
48
|
-
]);
|
|
49
|
-
const COPY_FROM_FIELD = field("copy_from", COPY_FROM_STRUCT_TYPE, true);
|
|
50
|
-
|
|
51
|
-
// COPY ... TO context — a nullable nested struct<format, file_path>.
|
|
52
|
-
// Byte-for-byte the shape the C++ extension builds in vgi_rpc_types.cpp
|
|
53
|
-
// (BuildBindRequest's copy_to branch) and the Python BindRequest.copy_to field.
|
|
54
|
-
// Only appended to the serialized batch when the bind actually opens a COPY TO
|
|
55
|
-
// sink, so ordinary scans keep the legacy wire shape.
|
|
56
|
-
const COPY_TO_STRUCT_TYPE = struct([
|
|
57
|
-
field("format", utf8(), false),
|
|
58
|
-
field("file_path", utf8(), false),
|
|
59
|
-
]);
|
|
60
|
-
const COPY_TO_FIELD = field("copy_to", COPY_TO_STRUCT_TYPE, true);
|
|
61
|
-
|
|
62
|
-
const BIND_RESPONSE_SCHEMA = schema([
|
|
63
|
-
field("output_schema", binary(), false),
|
|
64
|
-
field("opaque_data", binary(), true),
|
|
65
|
-
field("lookup_secret_types", list(field("item", utf8(), true)), false),
|
|
66
|
-
field("lookup_scopes", list(field("item", utf8(), true)), false),
|
|
67
|
-
field("lookup_names", list(field("item", utf8(), true)), false),
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
69
|
export function serializeBindRequest(req: BindRequest): VgiBatch {
|
|
71
|
-
|
|
70
|
+
return buildSingleRowBatch(BIND_REQUEST_SCHEMA, {
|
|
72
71
|
function_name: req.function_name,
|
|
73
72
|
arguments: serializeArguments(req.arguments),
|
|
74
73
|
function_type: req.function_type,
|
|
@@ -80,33 +79,18 @@ export function serializeBindRequest(req: BindRequest): VgiBatch {
|
|
|
80
79
|
resolved_secrets_provided: req.resolved_secrets_provided ?? false,
|
|
81
80
|
at_unit: req.at_unit ?? null,
|
|
82
81
|
at_value: req.at_value ?? null,
|
|
82
|
+
copy_from: req.copy_from
|
|
83
|
+
? {
|
|
84
|
+
format: req.copy_from.format,
|
|
85
|
+
file_path: req.copy_from.file_path,
|
|
86
|
+
expected_schema: serializeSchema(req.copy_from.expected_schema),
|
|
87
|
+
}
|
|
88
|
+
: null,
|
|
89
|
+
copy_to: req.copy_to
|
|
90
|
+
? { format: req.copy_to.format, file_path: req.copy_to.file_path }
|
|
91
|
+
: null,
|
|
83
92
|
schema_name: req.schema_name ?? null,
|
|
84
|
-
};
|
|
85
|
-
// Append the copy_from / copy_to struct columns only for COPY scans/sinks, so
|
|
86
|
-
// non-COPY binds serialize to the exact legacy shape (fields matched by name
|
|
87
|
-
// on the reader). At most one is present in practice, but both are handled
|
|
88
|
-
// generically so the wire stays additive either way.
|
|
89
|
-
const extraFields: VgiField[] = [];
|
|
90
|
-
if (req.copy_from) {
|
|
91
|
-
extraFields.push(COPY_FROM_FIELD);
|
|
92
|
-
row.copy_from = {
|
|
93
|
-
format: req.copy_from.format,
|
|
94
|
-
file_path: req.copy_from.file_path,
|
|
95
|
-
expected_schema: serializeSchema(req.copy_from.expected_schema),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
if (req.copy_to) {
|
|
99
|
-
extraFields.push(COPY_TO_FIELD);
|
|
100
|
-
row.copy_to = {
|
|
101
|
-
format: req.copy_to.format,
|
|
102
|
-
file_path: req.copy_to.file_path,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
if (extraFields.length > 0) {
|
|
106
|
-
const schemaWithCopy = schema([...BIND_REQUEST_SCHEMA.fields, ...extraFields]);
|
|
107
|
-
return buildSingleRowBatch(schemaWithCopy, row);
|
|
108
|
-
}
|
|
109
|
-
return buildSingleRowBatch(BIND_REQUEST_SCHEMA, row);
|
|
93
|
+
});
|
|
110
94
|
}
|
|
111
95
|
|
|
112
96
|
function parseCopyFromContext(raw: any): CopyFromContext | null {
|
|
@@ -218,4 +202,13 @@ export function deserializeBindResponse(
|
|
|
218
202
|
};
|
|
219
203
|
}
|
|
220
204
|
|
|
221
|
-
|
|
205
|
+
// BIND_REQUEST_SCHEMA has no codegen counterpart — the generated
|
|
206
|
+
// BindParamsSchema is only the `{request: binary}` envelope, so the inner
|
|
207
|
+
// record is spelled out above. The BindResponse copy that used to sit beside
|
|
208
|
+
// it did have one, and the two had already drifted: this file called
|
|
209
|
+
// `opaque_data` nullable where the generated BindResultSchema calls it
|
|
210
|
+
// non-null. Nobody read the copy (handlers/function.ts wraps with the
|
|
211
|
+
// generated schema), which is exactly how a duplicate goes stale unnoticed —
|
|
212
|
+
// and a stale copy is how a *correct* client ends up rejected at its first
|
|
213
|
+
// call. Use BindResultSchema from src/generated/vgi-protocol-schemas.ts.
|
|
214
|
+
export { BIND_REQUEST_SCHEMA };
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
2
|
// TableFunctionCardinalityRequest / TableCardinality wire serialization.
|
|
3
3
|
|
|
4
|
-
import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary
|
|
4
|
+
import { type VgiSchema, schema, type VgiField, field, type VgiDataType, binary } from "../../arrow/index.js";
|
|
5
5
|
import type { TableFunctionCardinalityRequest, TableCardinality } from "../types.js";
|
|
6
6
|
import { deserializeBatch, batchToScalarDict } from "../../util/arrow/index.js";
|
|
7
7
|
import { toUint8Array } from "./shared.js";
|
|
8
8
|
import { deserializeBindRequest } from "./bind.js";
|
|
9
9
|
|
|
10
|
-
const TABLE_CARDINALITY_SCHEMA = schema([
|
|
11
|
-
field("estimate", int64(), true),
|
|
12
|
-
field("max", int64(), true),
|
|
13
|
-
]);
|
|
14
|
-
|
|
15
10
|
const TABLE_FUNCTION_CARDINALITY_REQUEST_SCHEMA = schema([
|
|
16
11
|
field("bind_call", binary(), false),
|
|
17
12
|
field("bind_opaque_data", binary(), true),
|
|
@@ -42,4 +37,9 @@ export function serializeTableCardinality(
|
|
|
42
37
|
};
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
|
|
40
|
+
// The TableCardinality result shape used to be restated here as well; it is
|
|
41
|
+
// generated as TableFunctionCardinalityResultSchema, which is what
|
|
42
|
+
// handlers/function.ts already wraps with. Only the request shape stays hand-
|
|
43
|
+
// written, because codegen emits no schema for the inner request record (the
|
|
44
|
+
// generated Params schema is just the `{request: binary}` envelope).
|
|
45
|
+
export { TABLE_FUNCTION_CARDINALITY_REQUEST_SCHEMA };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// bind_opaque_data: binary (nullable)
|
|
7
7
|
// projection_ids: list<int64> (nullable)
|
|
8
8
|
// pushdown_filters: large_binary (nullable) — nested IPC of filter batch
|
|
9
|
-
// join_keys: list<large_binary> (
|
|
9
|
+
// join_keys: list<large_binary> (nullable list of IPC-serialized batches)
|
|
10
10
|
// phase: utf8 (nullable) — dictionary(int16,string) in Python
|
|
11
11
|
// execution_id: binary (nullable)
|
|
12
12
|
// init_opaque_data: binary (nullable)
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
int64,
|
|
22
22
|
float64,
|
|
23
23
|
list,
|
|
24
|
+
largeBinary,
|
|
25
|
+
dictionary,
|
|
26
|
+
int16,
|
|
24
27
|
} from "../../arrow/index.js";
|
|
25
28
|
// RecordBatch type alias kept for the joinKeys closure below — it's a runtime
|
|
26
29
|
// value that lands in BindRequest, not a wire shape.
|
|
@@ -43,14 +46,31 @@ import { toUint8Array, buildSingleRowBatch } from "./shared.js";
|
|
|
43
46
|
import { serializeBindRequest, deserializeBindRequest } from "./bind.js";
|
|
44
47
|
import { decodeDictValue } from "../../util/arrow/index.js";
|
|
45
48
|
|
|
49
|
+
// The InitRequest wire record. This must equal `InitRequestSchema` in
|
|
50
|
+
// src/generated/vgi-protocol-schemas.ts EXACTLY — see the note on
|
|
51
|
+
// BIND_REQUEST_SCHEMA in bind.ts for why "reads by name, so close enough" is
|
|
52
|
+
// not good enough (vgi-rpc-go compares with `arrow.Schema.Equal`).
|
|
53
|
+
//
|
|
54
|
+
// This declaration had drifted in six ways at once, none of which any test
|
|
55
|
+
// could see: it was missing `split_tokens` and `row_limit` entirely, typed
|
|
56
|
+
// `pushdown_filters` and the `join_keys` items as 32-bit binary where the
|
|
57
|
+
// protocol says large, typed `phase` and the two order-by enums as plain utf8
|
|
58
|
+
// where the protocol says dictionary, and put `finalize_state_id` last instead
|
|
59
|
+
// of ninth.
|
|
46
60
|
const INIT_REQUEST_SCHEMA = makeSchema([
|
|
47
61
|
field("bind_call", binary(), false),
|
|
48
62
|
field("output_schema", binary(), false),
|
|
49
63
|
field("bind_opaque_data", binary(), true),
|
|
50
|
-
field("projection_ids", list(field("item", int64(),
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
field("
|
|
64
|
+
field("projection_ids", list(field("item", int64(), true)), true),
|
|
65
|
+
// Large: a serialized filter tree, a set of join keys, or a batch of split
|
|
66
|
+
// tokens can exceed the 2 GiB that 32-bit offsets address.
|
|
67
|
+
field("pushdown_filters", largeBinary(), true),
|
|
68
|
+
field("join_keys", list(field("item", largeBinary(), true)), true),
|
|
69
|
+
field("split_tokens", list(field("item", largeBinary(), true)), true),
|
|
70
|
+
field("row_limit", int64(), true),
|
|
71
|
+
field("phase", dictionary(utf8(), int16()), true),
|
|
72
|
+
// Buffered-table finalize stream identifier.
|
|
73
|
+
field("finalize_state_id", binary(), true),
|
|
54
74
|
field("execution_id", binary(), true),
|
|
55
75
|
field("init_opaque_data", binary(), true),
|
|
56
76
|
// Per-substream identity for parallel streaming table-in-out (client-minted,
|
|
@@ -58,16 +78,21 @@ const INIT_REQUEST_SCHEMA = makeSchema([
|
|
|
58
78
|
field("substream_id", binary(), true),
|
|
59
79
|
// Order pushdown hints from DuckDB's RowGroupPruner (all null when no hint).
|
|
60
80
|
field("order_by_column_name", utf8(), true),
|
|
61
|
-
field("order_by_direction", utf8(), true),
|
|
62
|
-
field("order_by_null_order", utf8(), true),
|
|
81
|
+
field("order_by_direction", dictionary(utf8(), int16()), true),
|
|
82
|
+
field("order_by_null_order", dictionary(utf8(), int16()), true),
|
|
63
83
|
field("order_by_limit", int64(), true),
|
|
64
84
|
// TABLESAMPLE pushdown hints from DuckDB's SamplingPushdown optimizer.
|
|
65
85
|
field("tablesample_percentage", float64(), true),
|
|
66
86
|
field("tablesample_seed", int64(), true),
|
|
67
|
-
// Buffered-table finalize stream identifier — C++ appends this last.
|
|
68
|
-
field("finalize_state_id", binary(), true),
|
|
69
87
|
]);
|
|
70
88
|
|
|
89
|
+
// The init stream's header. Codegen emits nothing for it (the generated
|
|
90
|
+
// InitParamsSchema covers only the request envelope), so it is hand-written —
|
|
91
|
+
// which is exactly why it must be hand-written ONCE. It used to be restated at
|
|
92
|
+
// the `protocol.exchange("init", ...)` registration in handlers/function.ts,
|
|
93
|
+
// and two copies of a wire schema is how a *correct* client ends up rejected:
|
|
94
|
+
// the copies disagree on some field's nullability and neither is wrong on its
|
|
95
|
+
// own terms.
|
|
71
96
|
const GLOBAL_INIT_RESPONSE_SCHEMA = makeSchema([
|
|
72
97
|
field("execution_id", binary(), false),
|
|
73
98
|
field("opaque_data", binary(), true),
|
|
@@ -87,7 +112,10 @@ export function serializeInitRequest(req: InitRequest): VgiBatch {
|
|
|
87
112
|
? serializeBatch(req.pushdown_filters)
|
|
88
113
|
: null,
|
|
89
114
|
join_keys: (req.join_keys ?? []).map((b) => serializeBatch(b)),
|
|
115
|
+
split_tokens: req.split_tokens ?? null,
|
|
116
|
+
row_limit: req.row_limit ?? null,
|
|
90
117
|
phase: req.phase ?? null,
|
|
118
|
+
finalize_state_id: req.finalize_state_id ?? null,
|
|
91
119
|
execution_id: req.execution_id ?? null,
|
|
92
120
|
init_opaque_data: req.init_opaque_data ?? null,
|
|
93
121
|
substream_id: req.substream_id ?? null,
|
|
@@ -97,7 +125,6 @@ export function serializeInitRequest(req: InitRequest): VgiBatch {
|
|
|
97
125
|
order_by_limit: req.order_by_limit ?? null,
|
|
98
126
|
tablesample_percentage: req.tablesample_percentage ?? null,
|
|
99
127
|
tablesample_seed: req.tablesample_seed ?? null,
|
|
100
|
-
finalize_state_id: req.finalize_state_id ?? null,
|
|
101
128
|
};
|
|
102
129
|
return buildSingleRowBatch(INIT_REQUEST_SCHEMA, row);
|
|
103
130
|
}
|
|
@@ -167,6 +194,7 @@ export function deserializeInitRequest(
|
|
|
167
194
|
? toUint8Array(params.bind_opaque_data)
|
|
168
195
|
: null,
|
|
169
196
|
projection_ids: projectionIds,
|
|
197
|
+
row_limit: params.row_limit == null ? null : Number(params.row_limit),
|
|
170
198
|
pushdown_filters: params.pushdown_filters
|
|
171
199
|
? deserializeBatch(toUint8Array(params.pushdown_filters))
|
|
172
200
|
: null,
|
|
@@ -175,6 +203,20 @@ export function deserializeInitRequest(
|
|
|
175
203
|
finalize_state_id: params.finalize_state_id
|
|
176
204
|
? toUint8Array(params.finalize_state_id)
|
|
177
205
|
: null,
|
|
206
|
+
// The envelopes this init redeems. Read defensively: the column is absent
|
|
207
|
+
// on a pre-1.4.0 client and null on every non-split scan, and neither is an
|
|
208
|
+
// error — only a present, non-empty list means "this is a split init".
|
|
209
|
+
split_tokens: (() => {
|
|
210
|
+
const raw = params.split_tokens;
|
|
211
|
+
if (raw == null) return null;
|
|
212
|
+
const iter: Iterable<unknown> = Array.isArray(raw)
|
|
213
|
+
? raw
|
|
214
|
+
: typeof (raw as { [Symbol.iterator]?: unknown })[Symbol.iterator] === "function"
|
|
215
|
+
? (raw as Iterable<unknown>)
|
|
216
|
+
: [];
|
|
217
|
+
const tokens = [...iter].filter((e) => e != null).map((e) => toUint8Array(e));
|
|
218
|
+
return tokens.length > 0 ? tokens : null;
|
|
219
|
+
})(),
|
|
178
220
|
execution_id: params.execution_id
|
|
179
221
|
? toUint8Array(params.execution_id)
|
|
180
222
|
: null,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// TableFunctionPlanRequest / PlanResponse / ScanSplit wire serialization.
|
|
3
|
+
//
|
|
4
|
+
// Field ORDER and nullability are wire-significant: they mirror vgi/protocol.py
|
|
5
|
+
// exactly, and the extension rejects a response whose schema disagrees.
|
|
6
|
+
|
|
7
|
+
import { type VgiSchema, schema, field, binary, bool, int64, list } from "../../arrow/index.js";
|
|
8
|
+
import {
|
|
9
|
+
ScanSplitSchema,
|
|
10
|
+
TableFunctionPlanResultSchema,
|
|
11
|
+
} from "../../generated/vgi-protocol-schemas.js";
|
|
12
|
+
import type { BindRequest } from "../types.js";
|
|
13
|
+
import { deserializeBatch, batchToScalarDict } from "../../util/arrow/index.js";
|
|
14
|
+
import { toUint8Array } from "./shared.js";
|
|
15
|
+
import { deserializeBindRequest } from "./bind.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One named, independently redeemable unit of scan work.
|
|
19
|
+
*
|
|
20
|
+
* A worker sets `payload` and nothing else; the framework stamps `token` from
|
|
21
|
+
* it, so an author cannot forget the consistency anchor or mis-bind the
|
|
22
|
+
* fingerprint. The client sends the TOKEN back, never the raw payload.
|
|
23
|
+
*/
|
|
24
|
+
export interface ScanSplit {
|
|
25
|
+
/** The worker's own bytes NAMING this unit of work. */
|
|
26
|
+
payload: Uint8Array;
|
|
27
|
+
/** Framework-stamped envelope. Set by the framework, never by a worker. */
|
|
28
|
+
token?: Uint8Array;
|
|
29
|
+
estimatedRows?: number | bigint | null;
|
|
30
|
+
/** True if `estimatedRows` is exact — unlocks COUNT(*) from statistics. */
|
|
31
|
+
rowsExact?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Load-bearing for engines that bin-pack (DataFusion weight, Trino
|
|
34
|
+
* SplitWeight). Omitting it degrades them to round-robin by count; a greedily
|
|
35
|
+
* claiming client needs no cost model at all.
|
|
36
|
+
*/
|
|
37
|
+
estimatedBytes?: number | bigint | null;
|
|
38
|
+
/** 2-row (min, max) batch in the existing `vgi_partition_values` encoding. */
|
|
39
|
+
partitionBounds?: Uint8Array | null;
|
|
40
|
+
columnStatistics?: Uint8Array | null;
|
|
41
|
+
locationIds?: number[] | null;
|
|
42
|
+
startPosition?: Uint8Array | null;
|
|
43
|
+
/** Absent means UNBOUNDED — a shard read forever. */
|
|
44
|
+
endPosition?: Uint8Array | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a table function's `plan` hook returns. */
|
|
48
|
+
export interface PlanResult {
|
|
49
|
+
/**
|
|
50
|
+
* One entry per unit of work. EMPTY is legal and means "no work": a
|
|
51
|
+
* fully-pruned scan reaches it, and the client must produce an empty result
|
|
52
|
+
* rather than an error.
|
|
53
|
+
*/
|
|
54
|
+
splits: ScanSplit[];
|
|
55
|
+
/**
|
|
56
|
+
* Continued enumeration. More than one MUST partition the remaining
|
|
57
|
+
* enumeration disjointly and exhaustively. No client checks this: a dedup was
|
|
58
|
+
* tried and removed (it needed a set holding a copy of every token, it compared
|
|
59
|
+
* token bytes so it could never work on a keyed worker where each mint uses a
|
|
60
|
+
* fresh nonce, and the most a client can do with a duplicate is refuse anyway).
|
|
61
|
+
* Violating it returns DUPLICATE ROWS, silently.
|
|
62
|
+
*/
|
|
63
|
+
nextCursors?: Uint8Array[];
|
|
64
|
+
/** NORMATIVE cap on redemption concurrency, not advisory. */
|
|
65
|
+
maxWorkers?: number | null;
|
|
66
|
+
estimatedTotalSplits?: number | bigint | null;
|
|
67
|
+
estimatedTotalRows?: number | bigint | null;
|
|
68
|
+
estimatedTotalBytes?: number | bigint | null;
|
|
69
|
+
/**
|
|
70
|
+
* The counter a stale token is detected against, and the anchor the framework
|
|
71
|
+
* stamps into every token in this plan.
|
|
72
|
+
*/
|
|
73
|
+
catalogVersion?: number | bigint | null;
|
|
74
|
+
/**
|
|
75
|
+
* `catalog` (the default) or `transaction`. A transaction-scoped plan is not
|
|
76
|
+
* cacheable and is not redeemable after commit or rollback.
|
|
77
|
+
*/
|
|
78
|
+
scope?: "catalog" | "transaction";
|
|
79
|
+
cacheMaxAgeSeconds?: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The request a client sends to divide a scan. */
|
|
83
|
+
export interface TableFunctionPlanRequest {
|
|
84
|
+
bind_call: BindRequest;
|
|
85
|
+
bind_opaque_data: Uint8Array | null;
|
|
86
|
+
projection_ids: number[] | null;
|
|
87
|
+
pushdown_filters: Uint8Array | null;
|
|
88
|
+
/** The primary sizing lever: every engine is byte-driven. */
|
|
89
|
+
target_split_bytes: bigint | null;
|
|
90
|
+
/**
|
|
91
|
+
* The parallelism FLOOR — a small but expensive table still needs one reader
|
|
92
|
+
* per thread, which a byte target alone would not give it.
|
|
93
|
+
*/
|
|
94
|
+
min_splits: bigint | null;
|
|
95
|
+
/**
|
|
96
|
+
* A place in the ENUMERATION of splits — NOT a place in the data. A cursor
|
|
97
|
+
* lives for one plan call; a position is checkpointed and must survive
|
|
98
|
+
* restarts, upgrades and key rotation.
|
|
99
|
+
*/
|
|
100
|
+
cursor: Uint8Array | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The ScanSplit shape, from codegen rather than hand-written.
|
|
105
|
+
*
|
|
106
|
+
* It used to be spelled out here, and hand-written is how four SDKs ended up
|
|
107
|
+
* disagreeing about which of these columns were binary and which were
|
|
108
|
+
* large_binary — a disagreement the client surfaced as "the worker bypassed the
|
|
109
|
+
* framework", on every scan.
|
|
110
|
+
*/
|
|
111
|
+
export const SCAN_SPLIT_SCHEMA: VgiSchema = ScanSplitSchema as VgiSchema;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The PlanResponse shape, taken from codegen rather than hand-written: it is
|
|
115
|
+
* regenerated from the canonical protocol, so it cannot drift.
|
|
116
|
+
*/
|
|
117
|
+
export const PLAN_RESPONSE_SCHEMA: VgiSchema = TableFunctionPlanResultSchema as VgiSchema;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Decode a plan request.
|
|
121
|
+
*
|
|
122
|
+
* A client sends only the sizing inputs it actually has — the DuckDB extension
|
|
123
|
+
* sends seven columns, because it has no basis to invent a byte target it did
|
|
124
|
+
* not set — so every field but `bind_call` is read defensively.
|
|
125
|
+
*/
|
|
126
|
+
export function deserializePlanRequest(params: Record<string, any>): TableFunctionPlanRequest {
|
|
127
|
+
const bindCallBytes = toUint8Array(params.bind_call);
|
|
128
|
+
const bindCall = deserializeBindRequest(batchToScalarDict(deserializeBatch(bindCallBytes)));
|
|
129
|
+
|
|
130
|
+
let projectionIds: number[] | null = null;
|
|
131
|
+
if (params.projection_ids != null) {
|
|
132
|
+
const raw = params.projection_ids;
|
|
133
|
+
if (Array.isArray(raw)) projectionIds = raw.map(Number);
|
|
134
|
+
else if (raw && typeof raw[Symbol.iterator] === "function") projectionIds = [...raw].map(Number);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const asBigInt = (v: unknown): bigint | null =>
|
|
138
|
+
v == null ? null : typeof v === "bigint" ? v : BigInt(Number(v));
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
bind_call: bindCall,
|
|
142
|
+
bind_opaque_data: params.bind_opaque_data ? toUint8Array(params.bind_opaque_data) : null,
|
|
143
|
+
projection_ids: projectionIds,
|
|
144
|
+
pushdown_filters: params.pushdown_filters ? toUint8Array(params.pushdown_filters) : null,
|
|
145
|
+
target_split_bytes: asBigInt(params.target_split_bytes),
|
|
146
|
+
min_splits: asBigInt(params.min_splits),
|
|
147
|
+
cursor: params.cursor ? toUint8Array(params.cursor) : null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The raw bind fields the split-token fingerprint is derived from.
|
|
153
|
+
*
|
|
154
|
+
* Read from the UNDECODED bind_call dict rather than the parsed BindRequest,
|
|
155
|
+
* because the fingerprint must be reproducible byte-for-byte between the plan
|
|
156
|
+
* that minted a token and the init that redeems it. A decoded-then-re-encoded
|
|
157
|
+
* argument list is not guaranteed to round-trip to identical bytes; the wire
|
|
158
|
+
* bytes are.
|
|
159
|
+
*/
|
|
160
|
+
export function fingerprintInputs(bindCallBytes: Uint8Array): {
|
|
161
|
+
schemaName: string;
|
|
162
|
+
functionName: string;
|
|
163
|
+
args: Uint8Array;
|
|
164
|
+
settings: Uint8Array;
|
|
165
|
+
} {
|
|
166
|
+
const dict = batchToScalarDict(deserializeBatch(bindCallBytes));
|
|
167
|
+
return {
|
|
168
|
+
schemaName: dict.schema_name == null ? "" : String(dict.schema_name),
|
|
169
|
+
functionName: String(dict.function_name ?? ""),
|
|
170
|
+
args: dict.arguments ? toUint8Array(dict.arguments) : new Uint8Array(0),
|
|
171
|
+
settings: dict.settings ? toUint8Array(dict.settings) : new Uint8Array(0),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build the row dict for one `ScanSplit`. */
|
|
176
|
+
export function scanSplitRow(split: ScanSplit, token: Uint8Array): Record<string, any> {
|
|
177
|
+
const asBigInt = (v: number | bigint | null | undefined): bigint | null =>
|
|
178
|
+
v == null ? null : typeof v === "bigint" ? v : BigInt(v);
|
|
179
|
+
return {
|
|
180
|
+
// Cleared, not forwarded. The payload is sealed INTO the token, and shipping
|
|
181
|
+
// the plaintext in the field beside the ciphertext made the seal decorative.
|
|
182
|
+
// No client reads it — the C++ side pulls `token` alone — and redemption
|
|
183
|
+
// recovers the payload from inside the envelope.
|
|
184
|
+
payload: new Uint8Array(0),
|
|
185
|
+
token,
|
|
186
|
+
estimated_rows: asBigInt(split.estimatedRows),
|
|
187
|
+
rows_exact: split.rowsExact === true,
|
|
188
|
+
estimated_bytes: asBigInt(split.estimatedBytes),
|
|
189
|
+
partition_bounds: split.partitionBounds ?? null,
|
|
190
|
+
column_statistics: split.columnStatistics ?? null,
|
|
191
|
+
location_ids: split.locationIds ?? null,
|
|
192
|
+
start_position: split.startPosition ?? null,
|
|
193
|
+
end_position: split.endPosition ?? null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Build the row dict for a `PlanResponse`, given already-serialized splits. */
|
|
198
|
+
export function planResponseRow(result: PlanResult, splitBlobs: Uint8Array[]): Record<string, any> {
|
|
199
|
+
const asBigInt = (v: number | bigint | null | undefined): bigint | null =>
|
|
200
|
+
v == null ? null : typeof v === "bigint" ? v : BigInt(v);
|
|
201
|
+
return {
|
|
202
|
+
splits: splitBlobs,
|
|
203
|
+
next_cursors: result.nextCursors ?? [],
|
|
204
|
+
execution_id: null,
|
|
205
|
+
init_opaque_data: new Uint8Array(0),
|
|
206
|
+
max_workers: asBigInt(result.maxWorkers),
|
|
207
|
+
estimated_total_splits: asBigInt(result.estimatedTotalSplits),
|
|
208
|
+
estimated_total_rows: asBigInt(result.estimatedTotalRows),
|
|
209
|
+
estimated_total_bytes: asBigInt(result.estimatedTotalBytes),
|
|
210
|
+
catalog_version: asBigInt(result.catalogVersion),
|
|
211
|
+
scope: result.scope ?? "catalog",
|
|
212
|
+
locations: null,
|
|
213
|
+
partitioning: [],
|
|
214
|
+
sort_order: [],
|
|
215
|
+
cache_max_age_seconds: asBigInt(result.cacheMaxAgeSeconds),
|
|
216
|
+
start_position: new Uint8Array(0),
|
|
217
|
+
end_position: new Uint8Array(0),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
bool,
|
|
15
15
|
float64,
|
|
16
16
|
utf8,
|
|
17
|
+
list as makeList,
|
|
17
18
|
nullType,
|
|
19
|
+
typeSignature,
|
|
18
20
|
isStruct,
|
|
19
21
|
isBinary,
|
|
20
22
|
serializeBatch,
|
|
@@ -40,6 +42,58 @@ export const EXCHANGE_STATE_SCHEMA = makeSchema([
|
|
|
40
42
|
const TEXT_ENCODER = new TextEncoder();
|
|
41
43
|
const TEXT_DECODER = new TextDecoder();
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Child field name for an inferred list. Arrow matches list children by
|
|
47
|
+
* position, not name, so this is cosmetic — but keeping it at Arrow's
|
|
48
|
+
* conventional `item` makes a dumped schema read the way people expect.
|
|
49
|
+
*/
|
|
50
|
+
const LIST_CHILD_NAME = "item";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Element type for an array with nothing to infer from — `[]`, or an array
|
|
54
|
+
* whose every element is null.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately NOT `nullType()`, which is the semantically honest choice:
|
|
57
|
+
* flechette's column builder rejects a Null-typed list child ("Unsupported
|
|
58
|
+
* data type: Null"), so a `[]` in userState would round-trip on arrow-js and
|
|
59
|
+
* throw on flechette. Utf8 builds on both backends and, with no non-null
|
|
60
|
+
* element to carry, is indistinguishable on the way back out: `[]` returns
|
|
61
|
+
* `[]` and `[null, null]` returns `[null, null]`.
|
|
62
|
+
*/
|
|
63
|
+
const emptyListElementType = (): VgiDataType => utf8();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Infer the single Arrow element type an array's contents share.
|
|
67
|
+
*
|
|
68
|
+
* Arrow lists are homogeneous, so a genuinely mixed array (`[1, "a"]`,
|
|
69
|
+
* or structs with differing field sets) has no faithful representation and is
|
|
70
|
+
* rejected rather than silently coerced. Nulls are skipped — they are
|
|
71
|
+
* representable at any element type — and an array with no non-null element
|
|
72
|
+
* falls back to {@link emptyListElementType}.
|
|
73
|
+
*/
|
|
74
|
+
function inferElementType(values: any[]): VgiDataType {
|
|
75
|
+
let elementType: VgiDataType | null = null;
|
|
76
|
+
let elementSig = "";
|
|
77
|
+
for (const v of values) {
|
|
78
|
+
if (v === null || v === undefined) continue;
|
|
79
|
+
const t = inferFieldType(v);
|
|
80
|
+
const sig = typeSignature(t);
|
|
81
|
+
if (elementType === null) {
|
|
82
|
+
elementType = t;
|
|
83
|
+
elementSig = sig;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (sig !== elementSig) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`inferFieldType: arrays in userState must be homogeneous; ` +
|
|
89
|
+
`found both '${elementSig}' and '${sig}' in the same array ` +
|
|
90
|
+
`(split the values into separate fields, or normalize them to one type)`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return elementType ?? emptyListElementType();
|
|
95
|
+
}
|
|
96
|
+
|
|
43
97
|
/** Infer an Arrow DataType from a JS value for user state serialization. */
|
|
44
98
|
export function inferFieldType(value: any): VgiDataType {
|
|
45
99
|
if (value === null || value === undefined) return nullType();
|
|
@@ -52,7 +106,7 @@ export function inferFieldType(value: any): VgiDataType {
|
|
|
52
106
|
if (value instanceof Uint8Array || value instanceof ArrayBuffer) return binary();
|
|
53
107
|
if (ArrayBuffer.isView(value)) return binary();
|
|
54
108
|
if (Array.isArray(value)) {
|
|
55
|
-
|
|
109
|
+
return makeList(field(LIST_CHILD_NAME, inferElementType(value), true));
|
|
56
110
|
}
|
|
57
111
|
if (value instanceof Map) {
|
|
58
112
|
throw new Error(`inferFieldType: Map is not supported in userState (use a plain object instead)`);
|
package/src/protocol/types.ts
CHANGED
|
@@ -125,6 +125,33 @@ export interface InitRequest {
|
|
|
125
125
|
* bytes the worker's combine() chose.
|
|
126
126
|
*/
|
|
127
127
|
finalize_state_id: Uint8Array | null;
|
|
128
|
+
/**
|
|
129
|
+
* The framework-stamped split envelopes this init redeems, or null when this
|
|
130
|
+
* is not a split init.
|
|
131
|
+
*
|
|
132
|
+
* A LIST because an engine whose partition count IS its concurrency bin-packs
|
|
133
|
+
* at planning time and reads a whole group per partition; DuckDB always sends
|
|
134
|
+
* exactly one. A worker handed several concatenates them in the order given.
|
|
135
|
+
*/
|
|
136
|
+
split_tokens: Uint8Array[] | null;
|
|
137
|
+
/**
|
|
138
|
+
* The VERIFIED payloads behind `split_tokens` — the worker's own bytes, with
|
|
139
|
+
* the envelope already opened and stripped.
|
|
140
|
+
*
|
|
141
|
+
* Populated by the protocol handler, which is what holds the signing key, so
|
|
142
|
+
* an unverified token never reaches a function. Null when this is not a split
|
|
143
|
+
* init.
|
|
144
|
+
*/
|
|
145
|
+
split_payloads?: Uint8Array[] | null;
|
|
146
|
+
/**
|
|
147
|
+
* A plain fetch limit, when the engine can supply one.
|
|
148
|
+
*
|
|
149
|
+
* Always null from DuckDB — `TableFunctionInitInput` carries no limit, and
|
|
150
|
+
* the Top-N `order_by_limit` means something different ("top K by that
|
|
151
|
+
* column"). DataFusion supplies a real value via `TableProvider::scan(limit)`.
|
|
152
|
+
* Over-producing is legal; the engine re-applies the limit above the scan.
|
|
153
|
+
*/
|
|
154
|
+
row_limit: number | null;
|
|
128
155
|
execution_id: Uint8Array | null;
|
|
129
156
|
init_opaque_data: Uint8Array | null;
|
|
130
157
|
/**
|