@query-farm/vgi 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +52 -8
  2. package/dist/arguments/argument-spec.d.ts +57 -2
  3. package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
  4. package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
  5. package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
  6. package/dist/arrow/impl-flechette/index.d.ts +1 -1
  7. package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
  8. package/dist/arrow/impl-flechette/schema.d.ts +2 -0
  9. package/dist/arrow/index.d.ts +1 -1
  10. package/dist/arrow/schema-types.d.ts +3 -1
  11. package/dist/arrow/types.d.ts +39 -3
  12. package/dist/catalog/composite.d.ts +13 -13
  13. package/dist/catalog/descriptors.d.ts +7 -3
  14. package/dist/catalog/interface.d.ts +108 -38
  15. package/dist/catalog/read-only.d.ts +32 -15
  16. package/dist/client/client.d.ts +37 -30
  17. package/dist/client/types.d.ts +2 -0
  18. package/dist/client-entry.d.ts +1 -1
  19. package/dist/client-entry.js +791 -447
  20. package/dist/client-entry.js.map +18 -17
  21. package/dist/filter-pushdown/capabilities.d.ts +3 -0
  22. package/dist/filter-pushdown/deserialize.d.ts +14 -15
  23. package/dist/filter-pushdown/evaluate.d.ts +14 -44
  24. package/dist/filter-pushdown/index.d.ts +2 -2
  25. package/dist/filter-pushdown/types.d.ts +120 -68
  26. package/dist/functions/aggregate.d.ts +4 -0
  27. package/dist/functions/registry.d.ts +5 -5
  28. package/dist/functions/scalar.d.ts +6 -1
  29. package/dist/functions/table-buffering.d.ts +9 -0
  30. package/dist/functions/table-in-out.d.ts +13 -0
  31. package/dist/functions/table.d.ts +72 -2
  32. package/dist/functions/types.d.ts +60 -2
  33. package/dist/generated/vgi-client.d.ts +114 -26
  34. package/dist/generated/vgi-protocol-schemas.d.ts +34 -0
  35. package/dist/http/fetch.d.ts +16 -0
  36. package/dist/index.core.d.ts +4 -3
  37. package/dist/index.js +3396 -2064
  38. package/dist/index.js.map +55 -49
  39. package/dist/metadata/types.d.ts +15 -2
  40. package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
  41. package/dist/protocol/handlers/function.d.ts +11 -0
  42. package/dist/protocol/handlers/shared.d.ts +3 -1
  43. package/dist/protocol/serializers/bind.d.ts +1 -2
  44. package/dist/protocol/serializers/cardinality.d.ts +1 -2
  45. package/dist/protocol/serializers/splits.d.ts +126 -0
  46. package/dist/protocol/types.d.ts +36 -3
  47. package/dist/request-auth.d.ts +28 -0
  48. package/dist/schema-path.d.ts +7 -0
  49. package/dist/serve-entry.d.ts +25 -0
  50. package/dist/serve-entry.js +1129 -569
  51. package/dist/serve-entry.js.map +36 -32
  52. package/dist/split-token.d.ts +88 -0
  53. package/dist/types.d.ts +8 -0
  54. package/dist/worker-cf-entry.js +2928 -1689
  55. package/dist/worker-cf-entry.js.map +51 -44
  56. package/dist/worker.d.ts +1 -1
  57. package/package.json +3 -3
  58. package/src/arguments/argument-spec.ts +93 -4
  59. package/src/arguments/arguments.ts +0 -1
  60. package/src/arrow/impl-arrowjs/index.ts +1 -1
  61. package/src/arrow/impl-arrowjs/iterate.ts +21 -0
  62. package/src/arrow/impl-arrowjs/schema.ts +4 -0
  63. package/src/arrow/impl-flechette/index.ts +1 -1
  64. package/src/arrow/impl-flechette/iterate.ts +24 -1
  65. package/src/arrow/impl-flechette/schema.ts +4 -0
  66. package/src/arrow/index.ts +1 -1
  67. package/src/arrow/schema-types.ts +4 -0
  68. package/src/arrow/types.ts +39 -3
  69. package/src/catalog/composite.ts +26 -26
  70. package/src/catalog/descriptors.ts +12 -3
  71. package/src/catalog/interface.ts +217 -72
  72. package/src/catalog/read-only.ts +141 -84
  73. package/src/client/client.ts +97 -73
  74. package/src/client/types.ts +2 -0
  75. package/src/client-entry.ts +2 -1
  76. package/src/filter-pushdown/capabilities.ts +13 -0
  77. package/src/filter-pushdown/collector.ts +22 -35
  78. package/src/filter-pushdown/deserialize.ts +657 -221
  79. package/src/filter-pushdown/evaluate.ts +410 -499
  80. package/src/filter-pushdown/index.ts +16 -2
  81. package/src/filter-pushdown/types.ts +88 -71
  82. package/src/functions/aggregate.ts +12 -1
  83. package/src/functions/copy-from.ts +2 -1
  84. package/src/functions/copy-to.ts +2 -1
  85. package/src/functions/registry.ts +24 -22
  86. package/src/functions/scalar.ts +37 -0
  87. package/src/functions/table-buffering.ts +48 -6
  88. package/src/functions/table-in-out.ts +194 -33
  89. package/src/functions/table.ts +187 -22
  90. package/src/functions/types.ts +64 -1
  91. package/src/generated/vgi-client.ts +122 -28
  92. package/src/generated/vgi-protocol-schemas.ts +455 -58
  93. package/src/http/fetch.ts +129 -2
  94. package/src/index.core.ts +4 -2
  95. package/src/metadata/resolve.ts +43 -1
  96. package/src/metadata/serialize.ts +43 -5
  97. package/src/metadata/types.ts +17 -1
  98. package/src/protocol/dispatch.ts +1 -1
  99. package/src/protocol/handlers/aggregate.ts +23 -12
  100. package/src/protocol/handlers/catalog/admin.ts +47 -54
  101. package/src/protocol/handlers/catalog/index_methods.ts +6 -13
  102. package/src/protocol/handlers/catalog/macro.ts +12 -20
  103. package/src/protocol/handlers/catalog/shared.ts +12 -57
  104. package/src/protocol/handlers/catalog/table.ts +49 -112
  105. package/src/protocol/handlers/catalog/view.ts +18 -22
  106. package/src/protocol/handlers/function.ts +311 -18
  107. package/src/protocol/handlers/shared.ts +12 -3
  108. package/src/protocol/handlers/table-buffering.ts +11 -8
  109. package/src/protocol/serializers/bind.ts +69 -67
  110. package/src/protocol/serializers/cardinality.ts +7 -7
  111. package/src/protocol/serializers/init.ts +52 -10
  112. package/src/protocol/serializers/splits.ts +221 -0
  113. package/src/protocol/state-serializer.ts +55 -1
  114. package/src/protocol/types.ts +36 -3
  115. package/src/request-auth.ts +73 -0
  116. package/src/schema-path.ts +25 -0
  117. package/src/serve-entry.ts +69 -2
  118. package/src/split-token.ts +297 -0
  119. package/src/types.ts +9 -0
  120. package/src/worker-cf-entry.ts +2 -1
  121. package/src/worker.ts +79 -14
@@ -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, type VgiField, field, type VgiBatch, type VgiDataType, utf8, binary, bool, list, struct } from "../../arrow/index.js";
6
+ import { type VgiSchema, schema, field, type VgiBatch, type VgiDataType, utf8, binary, bool, struct, dictionary, int16, list } 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,60 @@ 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_path` and `argument_names` are last, 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 (additive, nullable, name-keyed -> wire-compatible).
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
- // (schema_name, function_name). Null for callers with no catalog context.
36
- field("schema_name", utf8(), true),
37
- ]);
38
-
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),
65
+ // (schema_path, function_name). Null for callers with no catalog context.
66
+ field("schema_path", list(field("item", utf8(), true)), true),
67
+ // Complete resolved logical signature, including const args and varargs.
68
+ field("argument_names", list(field("item", utf8(), true)), true),
68
69
  ]);
69
70
 
70
71
  export function serializeBindRequest(req: BindRequest): VgiBatch {
71
- const row: Record<string, any> = {
72
+ return buildSingleRowBatch(BIND_REQUEST_SCHEMA, {
72
73
  function_name: req.function_name,
73
74
  arguments: serializeArguments(req.arguments),
74
75
  function_type: req.function_type,
@@ -80,33 +81,19 @@ export function serializeBindRequest(req: BindRequest): VgiBatch {
80
81
  resolved_secrets_provided: req.resolved_secrets_provided ?? false,
81
82
  at_unit: req.at_unit ?? null,
82
83
  at_value: req.at_value ?? null,
83
- 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);
84
+ copy_from: req.copy_from
85
+ ? {
86
+ format: req.copy_from.format,
87
+ file_path: req.copy_from.file_path,
88
+ expected_schema: serializeSchema(req.copy_from.expected_schema),
89
+ }
90
+ : null,
91
+ copy_to: req.copy_to
92
+ ? { format: req.copy_to.format, file_path: req.copy_to.file_path }
93
+ : null,
94
+ schema_path: req.schema_path ?? null,
95
+ argument_names: req.argument_names ?? null,
96
+ });
110
97
  }
111
98
 
112
99
  function parseCopyFromContext(raw: any): CopyFromContext | null {
@@ -180,7 +167,13 @@ export function deserializeBindRequest(
180
167
  at_unit: params.at_unit ? String(params.at_unit) : null,
181
168
  at_value: params.at_value ? String(params.at_value) : null,
182
169
  // Empty string -> null, same convention as at_unit/at_value.
183
- schema_name: params.schema_name ? String(params.schema_name) : null,
170
+ schema_path: params.schema_path
171
+ ? (Array.isArray(params.schema_path) ? params.schema_path : [...params.schema_path]).map(String)
172
+ : null,
173
+ argument_names: params.argument_names
174
+ ? (Array.isArray(params.argument_names) ? params.argument_names : [...params.argument_names])
175
+ .map((value: unknown) => value == null ? null : String(value))
176
+ : null,
184
177
  // COPY ... FROM context — absent for ordinary scans (params.copy_from
185
178
  // undefined -> null). The struct column decodes to a plain object.
186
179
  copy_from: parseCopyFromContext(params.copy_from),
@@ -218,4 +211,13 @@ export function deserializeBindResponse(
218
211
  };
219
212
  }
220
213
 
221
- export { BIND_REQUEST_SCHEMA, BIND_RESPONSE_SCHEMA };
214
+ // BIND_REQUEST_SCHEMA has no codegen counterpart — the generated
215
+ // BindParamsSchema is only the `{request: binary}` envelope, so the inner
216
+ // record is spelled out above. The BindResponse copy that used to sit beside
217
+ // it did have one, and the two had already drifted: this file called
218
+ // `opaque_data` nullable where the generated BindResultSchema calls it
219
+ // non-null. Nobody read the copy (handlers/function.ts wraps with the
220
+ // generated schema), which is exactly how a duplicate goes stale unnoticed —
221
+ // and a stale copy is how a *correct* client ends up rejected at its first
222
+ // call. Use BindResultSchema from src/generated/vgi-protocol-schemas.ts.
223
+ 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, int64 } from "../../arrow/index.js";
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
- export { TABLE_CARDINALITY_SCHEMA, TABLE_FUNCTION_CARDINALITY_REQUEST_SCHEMA };
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> (non-null list of IPC-serialized batches)
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(), false)), true),
51
- field("pushdown_filters", binary(), true),
52
- field("join_keys", list(field("item", binary(), true)), false),
53
- field("phase", utf8(), true),
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,221 @@
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
+ schemaPath: string[];
162
+ functionName: string;
163
+ args: Uint8Array;
164
+ settings: Uint8Array;
165
+ } {
166
+ const dict = batchToScalarDict(deserializeBatch(bindCallBytes));
167
+ return {
168
+ schemaPath: dict.schema_path == null
169
+ ? []
170
+ : (Array.isArray(dict.schema_path) ? dict.schema_path : [...dict.schema_path]).map(String),
171
+ functionName: String(dict.function_name ?? ""),
172
+ args: dict.arguments ? toUint8Array(dict.arguments) : new Uint8Array(0),
173
+ settings: dict.settings ? toUint8Array(dict.settings) : new Uint8Array(0),
174
+ };
175
+ }
176
+
177
+ /** Build the row dict for one `ScanSplit`. */
178
+ export function scanSplitRow(split: ScanSplit, token: Uint8Array): Record<string, any> {
179
+ const asBigInt = (v: number | bigint | null | undefined): bigint | null =>
180
+ v == null ? null : typeof v === "bigint" ? v : BigInt(v);
181
+ return {
182
+ // Cleared, not forwarded. The payload is sealed INTO the token, and shipping
183
+ // the plaintext in the field beside the ciphertext made the seal decorative.
184
+ // No client reads it — the C++ side pulls `token` alone — and redemption
185
+ // recovers the payload from inside the envelope.
186
+ payload: new Uint8Array(0),
187
+ token,
188
+ estimated_rows: asBigInt(split.estimatedRows),
189
+ rows_exact: split.rowsExact === true,
190
+ estimated_bytes: asBigInt(split.estimatedBytes),
191
+ partition_bounds: split.partitionBounds ?? null,
192
+ column_statistics: split.columnStatistics ?? null,
193
+ location_ids: split.locationIds ?? null,
194
+ start_position: split.startPosition ?? null,
195
+ end_position: split.endPosition ?? null,
196
+ };
197
+ }
198
+
199
+ /** Build the row dict for a `PlanResponse`, given already-serialized splits. */
200
+ export function planResponseRow(result: PlanResult, splitBlobs: Uint8Array[]): Record<string, any> {
201
+ const asBigInt = (v: number | bigint | null | undefined): bigint | null =>
202
+ v == null ? null : typeof v === "bigint" ? v : BigInt(v);
203
+ return {
204
+ splits: splitBlobs,
205
+ next_cursors: result.nextCursors ?? [],
206
+ execution_id: null,
207
+ init_opaque_data: new Uint8Array(0),
208
+ max_workers: asBigInt(result.maxWorkers),
209
+ estimated_total_splits: asBigInt(result.estimatedTotalSplits),
210
+ estimated_total_rows: asBigInt(result.estimatedTotalRows),
211
+ estimated_total_bytes: asBigInt(result.estimatedTotalBytes),
212
+ catalog_version: asBigInt(result.catalogVersion),
213
+ scope: result.scope ?? "catalog",
214
+ locations: null,
215
+ partitioning: [],
216
+ sort_order: [],
217
+ cache_max_age_seconds: asBigInt(result.cacheMaxAgeSeconds),
218
+ start_position: new Uint8Array(0),
219
+ end_position: new Uint8Array(0),
220
+ };
221
+ }
@@ -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
- throw new Error(`inferFieldType: arrays are not supported in userState (convert to a serializable form first)`);
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)`);