@query-farm/vgi 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog/item-encoding.d.ts +17 -0
  3. package/dist/catalog/read-only.d.ts +16 -0
  4. package/dist/client-entry.js +19 -1
  5. package/dist/client-entry.js.map +3 -3
  6. package/dist/filter-pushdown/evaluate.d.ts +9 -0
  7. package/dist/filter-pushdown/history.d.ts +66 -0
  8. package/dist/filter-pushdown/index.d.ts +1 -0
  9. package/dist/functions/storage-cf-do.d.ts +2 -2
  10. package/dist/functions/storage.d.ts +39 -3
  11. package/dist/functions/table-in-out.d.ts +13 -8
  12. package/dist/functions/types.d.ts +14 -1
  13. package/dist/http/fetch.d.ts +1 -1
  14. package/dist/index.core.d.ts +1 -1
  15. package/dist/index.js +1280 -924
  16. package/dist/index.js.map +21 -17
  17. package/dist/protocol/carried-init-request.d.ts +28 -0
  18. package/dist/protocol/dispatch.d.ts +26 -7
  19. package/dist/protocol/handlers/function.d.ts +0 -1
  20. package/dist/protocol/handlers/shared.d.ts +0 -7
  21. package/dist/protocol/state-serializer.d.ts +29 -2
  22. package/dist/serve-entry.js +395 -188
  23. package/dist/serve-entry.js.map +16 -12
  24. package/dist/util/zstd-sync.d.ts +7 -0
  25. package/dist/worker-cf-entry.js +5047 -4710
  26. package/dist/worker-cf-entry.js.map +25 -21
  27. package/package.json +5 -4
  28. package/src/catalog/item-encoding.ts +59 -0
  29. package/src/catalog/read-only.ts +87 -19
  30. package/src/client/errors.ts +19 -0
  31. package/src/filter-pushdown/evaluate.ts +55 -8
  32. package/src/filter-pushdown/history.ts +239 -0
  33. package/src/filter-pushdown/index.ts +8 -0
  34. package/src/functions/storage-cf-do.ts +7 -12
  35. package/src/functions/storage.ts +54 -11
  36. package/src/functions/table-buffering.ts +0 -1
  37. package/src/functions/table-in-out.ts +99 -60
  38. package/src/functions/table.ts +41 -38
  39. package/src/functions/types.ts +14 -1
  40. package/src/http/fetch.ts +1 -9
  41. package/src/index.core.ts +1 -0
  42. package/src/protocol/carried-init-request.ts +139 -0
  43. package/src/protocol/dispatch.ts +28 -9
  44. package/src/protocol/handlers/catalog/admin.ts +5 -2
  45. package/src/protocol/handlers/function.ts +18 -15
  46. package/src/protocol/handlers/shared.ts +0 -22
  47. package/src/protocol/state-serializer.ts +109 -37
  48. package/src/util/zstd-sync.ts +50 -0
@@ -84,9 +84,22 @@ function hexEncode(buf: Uint8Array): string {
84
84
  // FunctionStorage interface
85
85
  // ============================================================================
86
86
 
87
+ /**
88
+ * Which slot of an execution's worker state a row occupies: a table-in-out
89
+ * substream's client-minted `substream_id` (raw bytes), or -- only for a client
90
+ * that sent none -- a numeric worker id, the process id (an int64 key). See
91
+ * {@link BoundStorage.put}.
92
+ */
93
+ export type WorkerStateKey = number | Uint8Array;
94
+
87
95
  export interface FunctionStorage {
88
- workerPut(executionId: Uint8Array, workerId: number, state: Uint8Array): Promise<void>;
96
+ /** Upsert `state` into the execution's worker-state slot `worker`. */
97
+ workerPut(executionId: Uint8Array, worker: WorkerStateKey, state: Uint8Array): Promise<void>;
98
+ /** Drain every worker-state slot of the execution, whatever its key. */
89
99
  workerCollect(executionId: Uint8Array): Promise<Uint8Array[]>;
100
+ /** Read every worker-state slot without draining. A slot keyed by a
101
+ * numeric worker id reports that id; one keyed by bytes (a substream id)
102
+ * has none and reports 0. */
90
103
  workerScan(executionId: Uint8Array): Promise<Array<[number, Uint8Array]>>;
91
104
  queuePush(executionId: Uint8Array, items: Uint8Array[]): Promise<number>;
92
105
  queuePop(executionId: Uint8Array): Promise<Uint8Array | null>;
@@ -148,8 +161,9 @@ function getDefaultDbPath(): string {
148
161
  }
149
162
 
150
163
  // Worker state rides the unified function_state table under a reserved
151
- // namespace, keyed by the worker/process id (8-byte big-endian) — exactly the
152
- // mapping the Cloudflare DO client uses, so both backends share one schema.
164
+ // namespace, keyed by the substream id (its raw bytes) or, for a client that
165
+ // sent none, the worker/process id (8-byte big-endian) — exactly the mapping
166
+ // the Cloudflare DO client uses, so both backends share one schema.
153
167
  const NS_WORKER = new TextEncoder().encode("worker");
154
168
 
155
169
  /** Encode an int64 worker id as an 8-byte big-endian state key. */
@@ -159,6 +173,12 @@ function int64Key(v: number): Uint8Array {
159
173
  return b;
160
174
  }
161
175
 
176
+ /** The state key for a worker-state slot: a substream id as-is, a numeric
177
+ * worker id as an int64. */
178
+ export function workerStateKey(worker: WorkerStateKey): Uint8Array {
179
+ return typeof worker === "number" ? int64Key(worker) : worker;
180
+ }
181
+
162
182
  function int64FromKey(b: Uint8Array): number {
163
183
  if (b.length !== 8) return 0;
164
184
  return Number(new DataView(b.buffer, b.byteOffset, 8).getBigInt64(0, false));
@@ -300,18 +320,19 @@ export class FunctionStorageSqlite implements FunctionStorage {
300
320
  return total;
301
321
  }
302
322
 
303
- // Worker state rides function_state under ns=worker, keyed by worker id.
323
+ // Worker state rides function_state under ns=worker, keyed by substream id
324
+ // or worker id.
304
325
 
305
326
  async workerPut(
306
327
  executionId: Uint8Array,
307
- workerId: number,
328
+ worker: WorkerStateKey,
308
329
  state: Uint8Array
309
330
  ): Promise<void> {
310
331
  this._db
311
332
  .prepare(
312
333
  "INSERT OR REPLACE INTO function_state (scope_id, ns, key, value) VALUES (?, ?, ?, ?)"
313
334
  )
314
- .run(bufferArg(executionId), bufferArg(NS_WORKER), bufferArg(int64Key(workerId)), bufferArg(state));
335
+ .run(bufferArg(executionId), bufferArg(NS_WORKER), bufferArg(workerStateKey(worker)), bufferArg(state));
315
336
  }
316
337
 
317
338
  async workerCollect(executionId: Uint8Array): Promise<Uint8Array[]> {
@@ -381,14 +402,36 @@ export class FunctionStorageSqlite implements FunctionStorage {
381
402
  export class BoundStorage {
382
403
  private _base: FunctionStorage;
383
404
  private _executionId: Uint8Array;
384
-
385
- constructor(storage: FunctionStorage, executionId: Uint8Array) {
405
+ private _substreamId: Uint8Array | null;
406
+
407
+ /**
408
+ * @param substreamId The table-in-out substream this storage serves -- the
409
+ * client-minted `InitRequest.substream_id`, identical across the
410
+ * substream's init, every tick and its finalize -- or `null` when the
411
+ * client sent none. Keys {@link put}.
412
+ */
413
+ constructor(storage: FunctionStorage, executionId: Uint8Array, substreamId: Uint8Array | null = null) {
386
414
  this._base = storage;
387
415
  this._executionId = executionId;
388
- }
389
-
416
+ this._substreamId = substreamId;
417
+ }
418
+
419
+ /**
420
+ * Upsert this substream's state into the execution's worker state, which
421
+ * {@link collect} drains at finalize.
422
+ *
423
+ * One row per **substream**, keyed by its `substream_id`. Storage is
424
+ * already scoped to the execution, and every connection of a fanned-out
425
+ * scan shares that execution -- so the key has to tell those connections
426
+ * apart. The process id only does when each connection is its own process;
427
+ * under the launcher, TCP or HTTP one process serves many of them, and a
428
+ * per-process key let them overwrite each other's state so finish()
429
+ * undercounted. The pid remains only for a client that sends no
430
+ * `substream_id`, which it keys as before. Mirrors vgi-python's
431
+ * `TableInOutFunction.process` (072e543).
432
+ */
390
433
  put(state: Uint8Array): Promise<void> {
391
- return this._base.workerPut(this._executionId, process.pid, state);
434
+ return this._base.workerPut(this._executionId, this._substreamId ?? process.pid, state);
392
435
  }
393
436
 
394
437
  collect(): Promise<Uint8Array[]> {
@@ -347,7 +347,6 @@ export function defineTableBufferingFunction<
347
347
  createStreamHandlers(
348
348
  request: InitRequest,
349
349
  response: GlobalInitResponse,
350
- _accumulatedState?: any,
351
350
  ): StreamHandlers {
352
351
  const args = extractArgs(request.bind_call);
353
352
  const settings = batchToScalarDict(request.bind_call.settings);
@@ -3,7 +3,7 @@
3
3
  // Two-phase: INPUT phase receives and transforms batches,
4
4
  // FINALIZE phase emits final results.
5
5
 
6
- import { type VgiSchema, schema, type VgiField, type VgiDataType, type VgiBatch, nullType, withBatchMetadata, serializeBatch, deserializeBatch } from "../arrow/index.js";
6
+ import { type VgiSchema, schema, type VgiField, type VgiDataType, type VgiBatch, nullType, isNull, withBatchMetadata, serializeBatch, deserializeBatch } from "../arrow/index.js";
7
7
  import type { OutputCollector } from "@query-farm/vgi-rpc";
8
8
  import { DEFAULT_MAX_WORKERS, TableInOutPhase } from "../types.js";
9
9
  import type {
@@ -33,6 +33,13 @@ import {
33
33
  FilteringOutputCollector,
34
34
  type PushdownFilters,
35
35
  } from "../filter-pushdown/index.js";
36
+ import {
37
+ decodeDynamicFilterMetadata,
38
+ EMPTY_FILTER_HISTORY,
39
+ type FilterDeltaHistory,
40
+ recordFilterDelta,
41
+ replayFilterHistory,
42
+ } from "../filter-pushdown/history.js";
36
43
  import { FunctionStability } from "../types.js";
37
44
  import { BoundStorage, storage as defaultStorage, FrameworkNS } from "./storage.js";
38
45
  import { serializeUserState, deserializeUserState } from "../protocol/state-serializer.js";
@@ -70,12 +77,14 @@ export interface TableInOutProcessParams<TArgs = Record<string, any>> {
70
77
  storage: BoundStorage;
71
78
  /**
72
79
  * Stable client-minted id for this streaming table-in-out substream.
73
- * Present (identical across init / every process() / finalize) when the
74
- * client fanned this function out across per-substream workers; use it to
75
- * key per-substream accumulated state in shared storage so a finalize()
76
- * that lands on a different HTTP backend than the process() calls still
77
- * finds it. `null`/`undefined` for the serial path or an old client.
78
- * Mirrors vgi-python's `ProcessParams.substream_id`.
80
+ * Present (identical across init / every process() / finalize) whenever the
81
+ * client sent one -- the DuckDB extension does for every substream. The
82
+ * framework keys the accumulated state it auto-persists by it
83
+ * ({@link BoundStorage.put}), so substreams of one execution that one
84
+ * process serves keep separate states and finalize() receives each; use it
85
+ * likewise for any per-substream state you keep in shared storage yourself.
86
+ * `null`/`undefined` for a client that sent none. Mirrors vgi-python's
87
+ * `ProcessParams.substream_id`.
79
88
  */
80
89
  substreamId?: Uint8Array | null;
81
90
  /**
@@ -298,7 +307,7 @@ export function defineTableInOutFunction<
298
307
  createStreamHandlers(
299
308
  request: InitRequest,
300
309
  response: GlobalInitResponse,
301
- accumulatedState?: any,
310
+ filterHistory?: FilterDeltaHistory | null,
302
311
  ): StreamHandlers {
303
312
  const args = extractArgs(request.bind_call);
304
313
  const settings = batchToScalarDict(request.bind_call.settings);
@@ -315,20 +324,31 @@ export function defineTableInOutFunction<
315
324
  // Deserialize pushdown filters. Pass a join-keys column lookup so that
316
325
  // filters DuckDB promoted to join_keys (IN/OR lists, etc.) are
317
326
  // materialized as InFilters rather than silently dropped.
318
- const pushdownFilters = request.pushdown_filters
319
- ? deserializeFilters(request.pushdown_filters, {
320
- outputSchema: request.output_schema,
321
- joinKeyBatches: request.join_keys,
322
- extensionFunctions: meta.additionalFilterFunctions,
323
- runtimeAlgorithms: meta.runtimeFilterAlgorithms,
324
- evaluationContexts: meta.filterEvaluationContexts,
325
- })
326
- : undefined;
327
-
328
- // Create BoundStorage for cross-phase/cross-worker data sharing
327
+ // An HTTP continuation replays the dynamic-filter deltas its cursor
328
+ // carries, so the filters are the ones the previous exchange left.
329
+ const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
330
+ const pushdownFilters = replayFilterHistory(
331
+ request.pushdown_filters
332
+ ? deserializeFilters(request.pushdown_filters, {
333
+ outputSchema: request.output_schema,
334
+ joinKeyBatches: request.join_keys,
335
+ extensionFunctions: meta.additionalFilterFunctions,
336
+ runtimeAlgorithms: meta.runtimeFilterAlgorithms,
337
+ evaluationContexts: meta.filterEvaluationContexts,
338
+ })
339
+ : undefined,
340
+ carriedFilterHistory,
341
+ );
342
+
343
+ // Create BoundStorage for cross-phase/cross-worker data sharing. Keyed
344
+ // per substream, not per process: every connection of a fanned-out
345
+ // scan shares this execution's storage, and one process may serve many
346
+ // of them (see BoundStorage.put). Rebuilt from the sealed init request
347
+ // on every HTTP continuation, so the key is the same on every tick.
329
348
  const boundStorage = new BoundStorage(
330
349
  defaultStorage,
331
350
  response.execution_id ?? new Uint8Array(16),
351
+ request.substream_id ?? null,
332
352
  );
333
353
 
334
354
  const processParams: TableInOutProcessParams<TArgs> = {
@@ -382,12 +402,10 @@ export function defineTableInOutFunction<
382
402
  ) => {
383
403
  if (!pState.state.materialized) {
384
404
  const finalizeStates: TState[] = [];
385
- if (accumulatedState != null) {
386
- finalizeStates.push(accumulatedState as TState);
387
- }
388
- // Collect any states persisted by INPUT exchanges. Subprocess
389
- // path reads from SQLite; HTTP path reads from CF DO / wherever
390
- // the configured storage backend lives.
405
+ // Collect the states INPUT exchanges persisted -- the only
406
+ // source, on every transport: each INPUT turn upserts its
407
+ // substream's state here (SQLite, a CF DO, wherever the
408
+ // configured backend lives).
391
409
  const stored = await boundStorage.collect();
392
410
  for (const bytes of stored) {
393
411
  finalizeStates.push(deserializeUserState(bytes) as TState);
@@ -467,11 +485,12 @@ export function defineTableInOutFunction<
467
485
  return {
468
486
  outputSchema,
469
487
  inputSchema: request.bind_call.input_schema,
470
- exchangeInit: () => ({ state, processParams }),
488
+ exchangeInit: () => ({ state, processParams, filterHistory: carriedFilterHistory }),
471
489
  exchangeFn: async (
472
490
  eState: {
473
491
  state: TState;
474
492
  processParams: TableInOutProcessParams<TArgs>;
493
+ filterHistory: FilterDeltaHistory;
475
494
  },
476
495
  input: VgiBatch,
477
496
  out: OutputCollector
@@ -480,7 +499,7 @@ export function defineTableInOutFunction<
480
499
  // custom metadata (attached by the C++ WriteInputBatch), so process()
481
500
  // can answer a 0-row `notModified` batch instead of recomputing.
482
501
  applyRevalidationValidators(eState.processParams, input);
483
- applyDynamicFilterUpdate(eState.processParams, input);
502
+ applyDynamicFilterUpdate(eState, input);
484
503
  // Bake emit metadata onto the batch (0-row HTTP survival), then
485
504
  // reconcile emitted batches to the (possibly projected) output schema
486
505
  // by name — a process() may emit its full declared schema and let the
@@ -529,27 +548,23 @@ function applyRevalidationValidators(
529
548
  params.ifModifiedSince = md?.get(CACHE_IF_MODIFIED_SINCE_KEY);
530
549
  }
531
550
 
551
+ /**
552
+ * Apply the dynamic-filter delta an input batch carries (if any) and record it
553
+ * in the compacted history an HTTP cursor carries to the next exchange, so a
554
+ * tightened filter survives the turn boundary (the extension sends each delta
555
+ * once per stream). See `filter-pushdown/history.ts`.
556
+ */
532
557
  function applyDynamicFilterUpdate(
533
- params: { pushdownFilters?: PushdownFilters },
558
+ eState: { processParams: { pushdownFilters?: PushdownFilters }; filterHistory: FilterDeltaHistory },
534
559
  input: VgiBatch,
535
560
  ): void {
536
561
  const encoded = ((input as any)?.metadata as Map<string, string> | undefined)?.get("vgi_pushdown_filters");
537
562
  if (!encoded) return;
538
- if (!params.pushdownFilters) throw new Error("dynamic filter delta received without an initial snapshot");
539
- if (new TextEncoder().encode(encoded).byteLength > (24 << 20)) {
540
- throw new Error("base64 dynamic filter metadata exceeds the encoded-size limit");
541
- }
542
- if (encoded.length % 4 !== 0 ||
543
- !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
544
- throw new Error("dynamic filter metadata is not valid base64");
545
- }
546
- const binary = (globalThis as any).atob
547
- ? (globalThis as any).atob(encoded)
548
- : Buffer.from(encoded, "base64").toString("binary");
549
- const bytes = Uint8Array.from(binary, (character: string) => character.charCodeAt(0));
550
- if (bytes.byteLength > (17 << 20)) throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
551
- const current = params.pushdownFilters;
552
- params.pushdownFilters = current.applyDelta(deserializeBatch(bytes));
563
+ const current = eState.processParams.pushdownFilters;
564
+ if (!current) throw new Error("dynamic filter delta received without an initial snapshot");
565
+ const recorded = recordFilterDelta(current, eState.filterHistory, decodeDynamicFilterMetadata(encoded));
566
+ eState.processParams.pushdownFilters = recorded.filters;
567
+ eState.filterHistory = recorded.history;
553
568
  }
554
569
 
555
570
  /**
@@ -725,14 +740,17 @@ export interface RowTransformConfig<TArgs = Record<string, any>> {
725
740
  /**
726
741
  * Positional args = the per-row INPUT COLUMNS (real typed args on the wire,
727
742
  * no synthetic TABLE placeholder). Read from `batch` by declared name in
728
- * process(); NOT surfaced on `params.args`.
743
+ * process(); NOT surfaced on `params.args`. A `Null` type declares the
744
+ * column ANY: the input column then arrives with whatever type DuckDB
745
+ * resolved for the call (see `params.bindCall.input_schema` in onBind).
729
746
  */
730
747
  args?: Record<string, VgiDataType>;
731
748
  /**
732
749
  * Trailing VARARGS input columns: the per-row input is N columns of the
733
750
  * declared type. A varargs blended function has no per-column declared
734
751
  * names (the C++ bind names them col0..colN-1), so process() reads the
735
- * columns POSITIONALLY off `batch`.
752
+ * columns POSITIONALLY off `batch`. A `Null` element type declares ANY
753
+ * varargs: each runtime column resolves its own type.
736
754
  */
737
755
  varargs?: { name: string; type: VgiDataType; doc?: string };
738
756
  /** Named (string-position) args stay bind-time scalars on `params.args`. */
@@ -805,20 +823,30 @@ export function defineRowTransformFunction<
805
823
  let posIdx = 0;
806
824
  if (config.args) {
807
825
  for (const [name, type] of Object.entries(config.args)) {
826
+ // A Null arrow type declares the input column ANY (same convention as
827
+ // defineTableFunction / defineScalarFunction): advertised on the wire as
828
+ // vgi_type=any, and the client builds the input schema from the type
829
+ // DuckDB resolved for the call. Read the concrete type off
830
+ // bindCall.input_schema in onBind.
831
+ const isAny = isNull(type);
808
832
  specs.push({
809
833
  name,
810
834
  position: posIdx++,
811
- arrowType: type,
835
+ arrowType: isAny ? nullType() : type,
836
+ isAnyType: isAny,
812
837
  doc: config.argDocs?.[name],
813
838
  ...constraintSpecFields(config.argConstraints?.[name]),
814
839
  });
815
840
  }
816
841
  }
817
842
  if (config.varargs) {
843
+ // ANY varargs: every runtime column resolves its own type.
844
+ const isAny = isNull(config.varargs.type);
818
845
  specs.push({
819
846
  name: config.varargs.name,
820
847
  position: posIdx++,
821
- arrowType: config.varargs.type,
848
+ arrowType: isAny ? nullType() : config.varargs.type,
849
+ isAnyType: isAny,
822
850
  isVarargs: true,
823
851
  doc: config.varargs.doc ?? config.argDocs?.[config.varargs.name],
824
852
  ...constraintSpecFields(config.argConstraints?.[config.varargs.name]),
@@ -919,7 +947,7 @@ export function defineRowTransformFunction<
919
947
  createStreamHandlers(
920
948
  request: InitRequest,
921
949
  response: GlobalInitResponse,
922
- _accumulatedState?: any,
950
+ filterHistory?: FilterDeltaHistory | null,
923
951
  ): StreamHandlers {
924
952
  const args = extractArgs(request.bind_call);
925
953
  const settings = batchToScalarDict(request.bind_call.settings);
@@ -932,19 +960,26 @@ export function defineRowTransformFunction<
932
960
  ? projectSchema(projIds, request.output_schema)
933
961
  : request.output_schema;
934
962
 
935
- const pushdownFilters = request.pushdown_filters
936
- ? deserializeFilters(request.pushdown_filters, {
937
- outputSchema: request.output_schema,
938
- joinKeyBatches: request.join_keys,
939
- extensionFunctions: meta.additionalFilterFunctions,
940
- runtimeAlgorithms: meta.runtimeFilterAlgorithms,
941
- evaluationContexts: meta.filterEvaluationContexts,
942
- })
943
- : undefined;
963
+ // An HTTP continuation replays the dynamic-filter deltas its cursor
964
+ // carries, so the filters are the ones the previous exchange left.
965
+ const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
966
+ const pushdownFilters = replayFilterHistory(
967
+ request.pushdown_filters
968
+ ? deserializeFilters(request.pushdown_filters, {
969
+ outputSchema: request.output_schema,
970
+ joinKeyBatches: request.join_keys,
971
+ extensionFunctions: meta.additionalFilterFunctions,
972
+ runtimeAlgorithms: meta.runtimeFilterAlgorithms,
973
+ evaluationContexts: meta.filterEvaluationContexts,
974
+ })
975
+ : undefined,
976
+ carriedFilterHistory,
977
+ );
944
978
 
945
979
  const boundStorage = new BoundStorage(
946
980
  defaultStorage,
947
981
  response.execution_id ?? new Uint8Array(16),
982
+ request.substream_id ?? null,
948
983
  );
949
984
 
950
985
  const processParams: RowTransformProcessParams<TArgs> = {
@@ -986,14 +1021,18 @@ export function defineRowTransformFunction<
986
1021
  return {
987
1022
  outputSchema,
988
1023
  inputSchema: request.bind_call.input_schema,
989
- exchangeInit: () => ({ state: null, processParams }),
1024
+ exchangeInit: () => ({ state: null, processParams, filterHistory: carriedFilterHistory }),
990
1025
  exchangeFn: async (
991
- eState: { state: null; processParams: RowTransformProcessParams<TArgs> },
1026
+ eState: {
1027
+ state: null;
1028
+ processParams: RowTransformProcessParams<TArgs>;
1029
+ filterHistory: FilterDeltaHistory;
1030
+ },
992
1031
  input: VgiBatch,
993
1032
  out: OutputCollector,
994
1033
  ) => {
995
1034
  applyRevalidationValidators(eState.processParams, input);
996
- applyDynamicFilterUpdate(eState.processParams, input);
1035
+ applyDynamicFilterUpdate(eState, input);
997
1036
  let wrappedOut: OutputCollector = makeMetadataBakingCollector(out);
998
1037
  if (projIds) {
999
1038
  wrappedOut = makeSchemaReconcilingCollector(wrappedOut, outputSchema);
@@ -9,7 +9,6 @@ import {
9
9
  isBatch,
10
10
  isNull,
11
11
  nullType,
12
- deserializeBatch,
13
12
  readCanonicalValue,
14
13
  } from "../arrow/index.js";
15
14
  import { codecFor } from "../arrow/codec/registry.js";
@@ -47,6 +46,13 @@ import {
47
46
  FilteringOutputCollector,
48
47
  type PushdownFilters,
49
48
  } from "../filter-pushdown/index.js";
49
+ import {
50
+ decodeDynamicFilterMetadata,
51
+ EMPTY_FILTER_HISTORY,
52
+ type FilterDeltaHistory,
53
+ recordFilterDelta,
54
+ replayFilterHistory,
55
+ } from "../filter-pushdown/history.js";
50
56
  import type { ColumnStatistics } from "../util/statistics.js";
51
57
  import {
52
58
  FunctionStability,
@@ -58,30 +64,6 @@ import {
58
64
  import { BoundStorage, storage as globalStorage } from "./storage.js";
59
65
  import { CACHE_IF_MODIFIED_SINCE_KEY, CACHE_IF_NONE_MATCH_KEY } from "../cache-control.js";
60
66
 
61
- // Base64-decode a string into raw bytes. Used to unpack the dynamic filter
62
- // update DuckDB attaches to each tick batch's custom metadata.
63
- function base64Decode(s: string): Uint8Array {
64
- if (new TextEncoder().encode(s).byteLength > (24 << 20)) {
65
- throw new Error("base64 dynamic filter metadata exceeds the encoded-size limit");
66
- }
67
- if (s.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
68
- throw new Error("dynamic filter metadata is not valid base64");
69
- }
70
- const bin = (globalThis as any).atob ? (globalThis as any).atob(s) : Buffer.from(s, "base64").toString("binary");
71
- const out = new Uint8Array(bin.length);
72
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
73
- if (out.byteLength > (17 << 20)) throw new Error("dynamic filter IPC payload exceeds the encoded-size limit");
74
- return out;
75
- }
76
-
77
- // Read the first RecordBatch from an Arrow IPC stream buffer. Returns null if
78
- // the stream has no batches.
79
- function deserializeFilterBatch(bytes: Uint8Array): VgiBatch | null {
80
- if (!bytes || bytes.length === 0) return null;
81
- const batch = deserializeBatch(bytes);
82
- return batch.numRows === 0 && batch.schema.fields.length === 0 ? null : batch;
83
- }
84
-
85
67
  // Wrap an OutputCollector so each emitted RecordBatch is projected-by-name
86
68
  // to the bound outputSchema before forwarding. Lenient: workers can emit
87
69
  // over-wide batches (full declared schema) and the framework drops the
@@ -191,6 +173,17 @@ export interface TableProcessParams<TArgs = Record<string, any>> {
191
173
  ifModifiedSince?: string;
192
174
  }
193
175
 
176
+ /**
177
+ * A table stream's live handler state: the user's state, the params its
178
+ * process() sees, and the compacted dynamic-filter history an HTTP cursor
179
+ * carries to the next turn (see `filter-pushdown/history.ts`).
180
+ */
181
+ interface TableHandlerState<TArgs, TState> {
182
+ state: TState;
183
+ processParams: TableProcessParams<TArgs>;
184
+ filterHistory: FilterDeltaHistory;
185
+ }
186
+
194
187
  // ============================================================================
195
188
  // Functional API
196
189
  // ============================================================================
@@ -529,7 +522,8 @@ export function defineTableFunction<
529
522
 
530
523
  createStreamHandlers(
531
524
  request: InitRequest,
532
- response: GlobalInitResponse
525
+ response: GlobalInitResponse,
526
+ filterHistory?: FilterDeltaHistory | null,
533
527
  ): StreamHandlers {
534
528
  const args = extractArgs(request.bind_call);
535
529
  const settings = batchToScalarDict(request.bind_call.settings);
@@ -553,9 +547,14 @@ export function defineTableFunction<
553
547
  runtimeAlgorithms: meta.runtimeFilterAlgorithms,
554
548
  evaluationContexts: meta.filterEvaluationContexts,
555
549
  };
556
- const pushdownFilters = request.pushdown_filters
557
- ? deserializeFilters(request.pushdown_filters, filterOptions)
558
- : undefined;
550
+ // An HTTP continuation replays the dynamic-filter deltas its cursor
551
+ // carries, so process() sees the filters the previous turn left, not the
552
+ // init snapshot (the extension sends each delta once per stream).
553
+ const carriedFilterHistory = filterHistory ?? EMPTY_FILTER_HISTORY;
554
+ const pushdownFilters = replayFilterHistory(
555
+ request.pushdown_filters ? deserializeFilters(request.pushdown_filters, filterOptions) : undefined,
556
+ carriedFilterHistory,
557
+ );
559
558
 
560
559
  const boundStorage = new BoundStorage(globalStorage, response.execution_id);
561
560
 
@@ -593,9 +592,13 @@ export function defineTableFunction<
593
592
 
594
593
  return {
595
594
  outputSchema,
596
- producerInit: () => ({ state, processParams }),
595
+ producerInit: (): TableHandlerState<TArgs, TState> => ({
596
+ state,
597
+ processParams,
598
+ filterHistory: carriedFilterHistory,
599
+ }),
597
600
  onTick: (
598
- pState: { state: TState; processParams: TableProcessParams<TArgs> },
601
+ pState: TableHandlerState<TArgs, TState>,
599
602
  tickMetadata: Map<string, string> | undefined,
600
603
  ) => {
601
604
  if (!tickMetadata) return;
@@ -613,21 +616,21 @@ export function defineTableFunction<
613
616
  // Dynamic filter pushdown: DuckDB's Top-N optimizer tightens filters
614
617
  // between ticks and serializes an atomic v2 delta into the tick
615
618
  // batch's custom metadata under `vgi_pushdown_filters` (base64 of a
616
- // filter IPC stream). Apply it to the prior snapshot so process()
617
- // sees the revised advisory predicate state.
619
+ // filter IPC stream). Apply it to the prior state so process() sees
620
+ // the revised advisory predicate state, and record it in the
621
+ // compacted history an HTTP cursor carries to the next turn.
618
622
  const encoded = tickMetadata.get("vgi_pushdown_filters");
619
623
  if (!encoded) return;
620
624
  const current = pState.processParams.pushdownFilters;
621
625
  if (!current) throw new Error("dynamic filter delta received without an initial snapshot");
622
- const bytes = base64Decode(encoded);
623
- const filterBatch = deserializeFilterBatch(bytes);
624
- if (!filterBatch) throw new Error("dynamic filter metadata must contain one RecordBatch");
625
626
  // Assignment happens only after complete validation, so a rejected
626
627
  // delta leaves the immutable prior state intact.
627
- pState.processParams.pushdownFilters = current.applyDelta(filterBatch);
628
+ const recorded = recordFilterDelta(current, pState.filterHistory, decodeDynamicFilterMetadata(encoded));
629
+ pState.processParams.pushdownFilters = recorded.filters;
630
+ pState.filterHistory = recorded.history;
628
631
  },
629
632
  producerFn: async (
630
- pState: { state: TState; processParams: TableProcessParams<TArgs> },
633
+ pState: TableHandlerState<TArgs, TState>,
631
634
  out: OutputCollector
632
635
  ) => {
633
636
  const current = pState.processParams.pushdownFilters;
@@ -26,6 +26,12 @@ import type { ArgumentSpec } from "../arguments/argument-spec.js";
26
26
  export interface HandlerState<T = any> {
27
27
  /** Serializable user state for HTTP exchange round-trips. */
28
28
  state: T;
29
+ /**
30
+ * The compacted dynamic-filter history, for a stream that applies
31
+ * `vgi_pushdown_filters` deltas. Carried in the HTTP cursor so the next turn
32
+ * rebuilds the filters this one left.
33
+ */
34
+ filterHistory?: import("../filter-pushdown/history.js").FilterDeltaHistory;
29
35
  [key: string]: any;
30
36
  }
31
37
 
@@ -229,10 +235,17 @@ export interface VgiFunction {
229
235
  defaultOutputSchema?: VgiSchema;
230
236
  bind(request: BindRequest): BindResponse | Promise<BindResponse>;
231
237
  globalInit(request: InitRequest): GlobalInitResponse | Promise<GlobalInitResponse>;
238
+ /**
239
+ * Build the stream's handlers. On an HTTP continuation `filterHistory` is
240
+ * the compacted dynamic-filter history the cursor carried: a function that
241
+ * applies `vgi_pushdown_filters` deltas replays it onto the init snapshot and
242
+ * keeps the (updated) history on its handler state as `filterHistory`, which
243
+ * the dispatcher writes back into the next cursor.
244
+ */
232
245
  createStreamHandlers(
233
246
  request: InitRequest,
234
247
  response: GlobalInitResponse,
235
- accumulatedState?: any,
248
+ filterHistory?: import("../filter-pushdown/history.js").FilterDeltaHistory | null,
236
249
  ): StreamHandlers;
237
250
  cardinality?(request: TableFunctionCardinalityRequest): TableCardinality | Promise<TableCardinality>;
238
251
  /**
package/src/http/fetch.ts CHANGED
@@ -13,7 +13,6 @@
13
13
  import {
14
14
  AuthContext,
15
15
  createHttpHandler,
16
- unpackStateToken,
17
16
  type AuthenticateFn,
18
17
  type DispatchHook,
19
18
  type DispatchInfo,
@@ -30,7 +29,7 @@ import { setRequestAuthScope, type RequestAuthHolder } from "../request-auth.js"
30
29
 
31
30
  export interface VgiFetchOptions {
32
31
  /** Wire-protocol config (registry + catalogInterface). */
33
- protocol: Omit<ProtocolConfig, "recoverExchangeState">;
32
+ protocol: ProtocolConfig;
34
33
  /** HMAC key for state-token signing. Pass a stable, secret 32-byte key
35
34
  * (e.g. derived from a Wrangler secret). Required because Workers don't
36
35
  * preserve in-memory state across requests/instances. */
@@ -196,13 +195,6 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
196
195
  const protocol: Protocol = buildVgiProtocol({
197
196
  signingKey: opts.signingKey,
198
197
  ...opts.protocol,
199
- recoverExchangeState: async (opaqueData: Uint8Array) => {
200
- const tokenString = new TextDecoder().decode(opaqueData);
201
- // principal binding is enforced by the HTTP handler on the request that
202
- // carried this token; the recovery path itself is not principal-scoped.
203
- const unpacked = await unpackStateToken(tokenString, opts.signingKey, tokenTtl, undefined);
204
- return arrowStateSerializer.deserialize(unpacked.stateBytes);
205
- },
206
198
  });
207
199
 
208
200
  const handler = createHttpHandler(protocol, {
package/src/index.core.ts CHANGED
@@ -282,6 +282,7 @@ export type { ScalarComputeRow, ScalarOutputValue, ScalarComputeResult } from ".
282
282
  // Storage
283
283
  export {
284
284
  type FunctionStorage,
285
+ type WorkerStateKey,
285
286
  FunctionStorageSqlite,
286
287
  BoundStorage,
287
288
  UnknownInvocationError,