@query-farm/vgi 0.18.0 → 0.19.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/dist/arrow/impl-arrowjs/index.d.ts +1 -0
- package/dist/arrow/impl-arrowjs/metadata.d.ts +12 -0
- package/dist/arrow/impl-flechette/index.d.ts +1 -0
- package/dist/arrow/impl-flechette/metadata.d.ts +13 -0
- package/dist/arrow/index.d.ts +1 -1
- package/dist/client-entry.js +15 -4
- package/dist/client-entry.js.map +9 -8
- package/dist/functions/scalar.d.ts +10 -0
- package/dist/functions/table-in-out.d.ts +121 -0
- package/dist/functions/types.d.ts +12 -0
- package/dist/generated/vgi-client.d.ts +2 -0
- package/dist/index.core.d.ts +1 -1
- package/dist/index.js +295 -11
- package/dist/index.js.map +16 -15
- package/dist/protocol/types.d.ts +12 -0
- package/dist/serve-entry.js +13 -3
- package/dist/serve-entry.js.map +8 -7
- package/dist/worker-cf-entry.js +173 -16
- package/dist/worker-cf-entry.js.map +14 -13
- package/dist/worker.d.ts +1 -1
- package/package.json +3 -3
- package/src/arrow/impl-arrowjs/index.ts +1 -0
- package/src/arrow/impl-arrowjs/metadata.ts +20 -0
- package/src/arrow/impl-flechette/index.ts +1 -0
- package/src/arrow/impl-flechette/metadata.ts +25 -0
- package/src/arrow/index.ts +2 -0
- package/src/catalog/read-only.ts +9 -0
- package/src/client/client.ts +1 -0
- package/src/functions/registry.ts +53 -5
- package/src/functions/scalar.ts +17 -1
- package/src/functions/table-in-out.ts +480 -4
- package/src/functions/types.ts +12 -0
- package/src/generated/vgi-client.ts +3 -1
- package/src/generated/vgi-protocol-schemas.ts +2 -1
- package/src/index.core.ts +11 -1
- package/src/protocol/serializers/init.ts +7 -0
- package/src/protocol/types.ts +12 -0
- package/src/worker.ts +1 -1
|
@@ -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 } from "../arrow/index.js";
|
|
6
|
+
import { type VgiSchema, schema, type VgiField, type VgiDataType, type VgiBatch, nullType, withBatchMetadata } 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 {
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import type { ArgumentSpec } from "../arguments/argument-spec.js";
|
|
22
22
|
import { batchToScalarDict, batchToSecretDict, projectSchema, projectBatch, emptyBatch } from "../util/arrow/index.js";
|
|
23
|
+
import { CACHE_IF_MODIFIED_SINCE_KEY, CACHE_IF_NONE_MATCH_KEY } from "../cache-control.js";
|
|
23
24
|
import {
|
|
24
25
|
buildJoinKeysLookup,
|
|
25
26
|
deserializeFilters,
|
|
@@ -51,6 +52,28 @@ export interface TableInOutProcessParams<TArgs = Record<string, any>> {
|
|
|
51
52
|
pushdownFilters?: PushdownFilters;
|
|
52
53
|
/** Shared storage for cross-phase and cross-worker data (SQLite-backed). */
|
|
53
54
|
storage: BoundStorage;
|
|
55
|
+
/**
|
|
56
|
+
* Stable client-minted id for this streaming table-in-out substream.
|
|
57
|
+
* Present (identical across init / every process() / finalize) when the
|
|
58
|
+
* client fanned this function out across per-substream workers; use it to
|
|
59
|
+
* key per-substream accumulated state in shared storage so a finalize()
|
|
60
|
+
* that lands on a different HTTP backend than the process() calls still
|
|
61
|
+
* finds it. `null`/`undefined` for the serial path or an old client.
|
|
62
|
+
* Mirrors vgi-python's `ProcessParams.substream_id`.
|
|
63
|
+
*/
|
|
64
|
+
substreamId?: Uint8Array | null;
|
|
65
|
+
/**
|
|
66
|
+
* Conditional-revalidation validator (exchange-mode result cache): the
|
|
67
|
+
* client holds a stale cached result for THIS input unit and asks the
|
|
68
|
+
* worker to confirm freshness cheaply. When set, process() may answer with
|
|
69
|
+
* a 0-row `cacheControlMetadata({ notModified: true, ... })` batch instead
|
|
70
|
+
* of recomputing. Rides the input batch's custom metadata (attached by the
|
|
71
|
+
* C++ WriteInputBatch). Undefined on a normal call.
|
|
72
|
+
*/
|
|
73
|
+
ifNoneMatch?: string;
|
|
74
|
+
/** RFC 3339 Last-Modified validator for conditional revalidation.
|
|
75
|
+
* Companion to {@link ifNoneMatch}. Undefined on a normal call. */
|
|
76
|
+
ifModifiedSince?: string;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
// ============================================================================
|
|
@@ -273,6 +296,7 @@ export function defineTableInOutFunction<
|
|
|
273
296
|
secrets,
|
|
274
297
|
pushdownFilters,
|
|
275
298
|
storage: boundStorage,
|
|
299
|
+
substreamId: request.substream_id ?? null,
|
|
276
300
|
};
|
|
277
301
|
|
|
278
302
|
const phase = request.phase;
|
|
@@ -354,9 +378,21 @@ export function defineTableInOutFunction<
|
|
|
354
378
|
input: VgiBatch,
|
|
355
379
|
out: OutputCollector
|
|
356
380
|
) => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
381
|
+
// Surface conditional-revalidation validators off the input batch's
|
|
382
|
+
// custom metadata (attached by the C++ WriteInputBatch), so process()
|
|
383
|
+
// can answer a 0-row `notModified` batch instead of recomputing.
|
|
384
|
+
applyRevalidationValidators(eState.processParams, input);
|
|
385
|
+
// Bake emit metadata onto the batch (0-row HTTP survival), then
|
|
386
|
+
// reconcile emitted batches to the (possibly projected) output schema
|
|
387
|
+
// by name — a process() may emit its full declared schema and let the
|
|
388
|
+
// framework project (mirrors Python's OutputCollector.emit reconcile).
|
|
389
|
+
let wrappedOut: OutputCollector = makeMetadataBakingCollector(out);
|
|
390
|
+
if (projIds) {
|
|
391
|
+
wrappedOut = makeSchemaReconcilingCollector(wrappedOut, outputSchema);
|
|
392
|
+
}
|
|
393
|
+
if (config.autoApplyFilters && pushdownFilters) {
|
|
394
|
+
wrappedOut = new FilteringOutputCollector(wrappedOut, pushdownFilters) as unknown as OutputCollector;
|
|
395
|
+
}
|
|
360
396
|
await processFn(eState.processParams, eState.state, input, wrappedOut);
|
|
361
397
|
|
|
362
398
|
// Auto-persist accumulated state for FINALIZE recovery (matches Python/Go pattern)
|
|
@@ -371,3 +407,443 @@ export function defineTableInOutFunction<
|
|
|
371
407
|
},
|
|
372
408
|
};
|
|
373
409
|
}
|
|
410
|
+
|
|
411
|
+
// ============================================================================
|
|
412
|
+
// Shared INPUT-phase helpers
|
|
413
|
+
// ============================================================================
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Surface conditional-revalidation validators (exchange-mode result cache)
|
|
417
|
+
* from an input batch's custom metadata onto the process params. The C++
|
|
418
|
+
* WriteInputBatch attaches `vgi.cache.if_none_match` / `if_modified_since`
|
|
419
|
+
* when it holds a stale-but-revalidatable cached result for this input unit.
|
|
420
|
+
* Cleared (undefined) when absent so a later batch on the same stream doesn't
|
|
421
|
+
* see a stale validator.
|
|
422
|
+
*/
|
|
423
|
+
function applyRevalidationValidators(
|
|
424
|
+
params: { ifNoneMatch?: string; ifModifiedSince?: string },
|
|
425
|
+
input: VgiBatch,
|
|
426
|
+
): void {
|
|
427
|
+
const md: Map<string, string> | undefined = (input as any)?.metadata;
|
|
428
|
+
params.ifNoneMatch = md?.get(CACHE_IF_NONE_MATCH_KEY);
|
|
429
|
+
params.ifModifiedSince = md?.get(CACHE_IF_MODIFIED_SINCE_KEY);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Wrap an OutputCollector so a pre-built batch emitted WITH metadata carries
|
|
434
|
+
* that metadata on the batch object itself (merged over any metadata the
|
|
435
|
+
* batch already had), in addition to the separate emit argument.
|
|
436
|
+
*
|
|
437
|
+
* Load-bearing for the HTTP transport: vgi-rpc's HTTP exchange dispatch
|
|
438
|
+
* merges the separate emit-metadata argument into the response batch only
|
|
439
|
+
* when `batch.numRows > 0` — a 0-row emit (e.g. a conditional-revalidation
|
|
440
|
+
* `notModified` reply) would silently lose its `vgi.cache.*` keys. A
|
|
441
|
+
* batch-carried map survives that path on both transports.
|
|
442
|
+
*/
|
|
443
|
+
function makeMetadataBakingCollector(inner: OutputCollector): OutputCollector {
|
|
444
|
+
return new Proxy(inner, {
|
|
445
|
+
get(target, prop, receiver) {
|
|
446
|
+
if (prop === "emit") {
|
|
447
|
+
return function (
|
|
448
|
+
batchOrColumns: VgiBatch | Record<string, any[]>,
|
|
449
|
+
metadata?: Map<string, string>,
|
|
450
|
+
) {
|
|
451
|
+
if (
|
|
452
|
+
batchOrColumns &&
|
|
453
|
+
typeof (batchOrColumns as any).getChild === "function" &&
|
|
454
|
+
metadata &&
|
|
455
|
+
metadata.size > 0
|
|
456
|
+
) {
|
|
457
|
+
const batch = batchOrColumns as VgiBatch;
|
|
458
|
+
const merged = new Map<string, string>((batch as any).metadata ?? []);
|
|
459
|
+
for (const [k, v] of metadata) merged.set(k, v);
|
|
460
|
+
return (target as any).emit(withBatchMetadata(batch, merged), merged);
|
|
461
|
+
}
|
|
462
|
+
return (target as any).emit(batchOrColumns, metadata);
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const v = Reflect.get(target, prop, receiver);
|
|
466
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Wrap an OutputCollector so each emitted pre-built batch is reconciled to the
|
|
473
|
+
* (possibly projected) target output schema by NAME before forwarding — a
|
|
474
|
+
* process() may emit its full declared schema and let the framework project.
|
|
475
|
+
* Mirrors vgi-rpc-python's `OutputCollector.emit` select step. A target column
|
|
476
|
+
* missing from the emitted batch throws a clear error.
|
|
477
|
+
*/
|
|
478
|
+
function makeSchemaReconcilingCollector(
|
|
479
|
+
inner: OutputCollector,
|
|
480
|
+
targetSchema: VgiSchema,
|
|
481
|
+
): OutputCollector {
|
|
482
|
+
function reconcile(batch: VgiBatch): VgiBatch {
|
|
483
|
+
const batchFields = batch.schema.fields;
|
|
484
|
+
if (
|
|
485
|
+
batchFields.length === targetSchema.fields.length &&
|
|
486
|
+
targetSchema.fields.every((f, i) => batchFields[i].name === f.name)
|
|
487
|
+
) {
|
|
488
|
+
return batch;
|
|
489
|
+
}
|
|
490
|
+
const ids: number[] = [];
|
|
491
|
+
for (const f of targetSchema.fields) {
|
|
492
|
+
const idx = batchFields.findIndex((bf) => bf.name === f.name);
|
|
493
|
+
if (idx < 0) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`emitted batch is missing projected output column '${f.name}' ` +
|
|
496
|
+
`(emitted: [${batchFields.map((bf) => bf.name).join(", ")}])`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
ids.push(idx);
|
|
500
|
+
}
|
|
501
|
+
return projectBatch(ids, batch) as unknown as VgiBatch;
|
|
502
|
+
}
|
|
503
|
+
return new Proxy(inner, {
|
|
504
|
+
get(target, prop, receiver) {
|
|
505
|
+
if (prop === "emit") {
|
|
506
|
+
return function (
|
|
507
|
+
batchOrColumns: VgiBatch | Record<string, any[]>,
|
|
508
|
+
metadata?: Map<string, string>,
|
|
509
|
+
) {
|
|
510
|
+
if (batchOrColumns && typeof (batchOrColumns as any).getChild === "function") {
|
|
511
|
+
return (target as any).emit(reconcile(batchOrColumns as VgiBatch), metadata);
|
|
512
|
+
}
|
|
513
|
+
return (target as any).emit(batchOrColumns, metadata);
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const v = Reflect.get(target, prop, receiver);
|
|
517
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// Per-output-row provenance (batched correlated LATERAL)
|
|
524
|
+
// ============================================================================
|
|
525
|
+
|
|
526
|
+
/** Metadata key carrying per-output-row provenance: base64 of a raw
|
|
527
|
+
* little-endian int32[] mapping each output row to the input row that
|
|
528
|
+
* produced it. Shared by string with the C++ extension and vgi-python. */
|
|
529
|
+
export const PARENT_ROW_METADATA_KEY = "vgi_rpc.parent_row#b64";
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Fold per-output-row provenance into an emit metadata map.
|
|
533
|
+
*
|
|
534
|
+
* Used by the **batched correlated LATERAL** operator (blended
|
|
535
|
+
* RowTransformFunction under `FROM t, f(t.x)` / `LATERAL`): the C++ extension
|
|
536
|
+
* ships a whole input chunk to the worker in ONE exchange and reads ONE
|
|
537
|
+
* output batch, then maps each output row back to the input row that produced
|
|
538
|
+
* it via this array — so a 1->N fan-out or 1->0 filter can be batched instead
|
|
539
|
+
* of driven row-by-row.
|
|
540
|
+
*
|
|
541
|
+
* `parentRows[i]` is the 0-based index (into the input batch) of the row that
|
|
542
|
+
* produced output row `i`. Encoded as a raw little-endian `int32` array (NOT
|
|
543
|
+
* Arrow IPC), base64-encoded, under `vgi_rpc.parent_row#b64`. Absent metadata
|
|
544
|
+
* means an identity 1->1 map (the common case: the extension assumes it, and
|
|
545
|
+
* requires output rows == input rows).
|
|
546
|
+
*
|
|
547
|
+
* Contract: `parentRows.length` MUST equal the emitted batch's row count (a
|
|
548
|
+
* mismatch is a worker bug that would corrupt the stamping). Values are
|
|
549
|
+
* range-checked against the input width on the C++ side. Mirrors vgi-python's
|
|
550
|
+
* `_merge_parent_rows` / `out.emit(..., parent_rows=[...])`.
|
|
551
|
+
*/
|
|
552
|
+
export function parentRowsMetadata(
|
|
553
|
+
parentRows: number[],
|
|
554
|
+
outputRows: number,
|
|
555
|
+
extra?: Map<string, string>,
|
|
556
|
+
): Map<string, string> {
|
|
557
|
+
if (parentRows.length !== outputRows) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`parentRowsMetadata: length ${parentRows.length} != output rows ${outputRows}; ` +
|
|
560
|
+
`parentRows must carry exactly one input-row index per emitted output row`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
const md = new Map<string, string>(extra ?? []);
|
|
564
|
+
if (outputRows === 0) {
|
|
565
|
+
// Nothing to map; skip the base64+pack for an empty emit.
|
|
566
|
+
return md;
|
|
567
|
+
}
|
|
568
|
+
const raw = new Uint8Array(parentRows.length * 4);
|
|
569
|
+
const dv = new DataView(raw.buffer);
|
|
570
|
+
for (let i = 0; i < parentRows.length; i++) {
|
|
571
|
+
dv.setInt32(i * 4, parentRows[i], true);
|
|
572
|
+
}
|
|
573
|
+
md.set(PARENT_ROW_METADATA_KEY, base64Encode(raw));
|
|
574
|
+
return md;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function base64Encode(bytes: Uint8Array): string {
|
|
578
|
+
if (typeof (globalThis as any).Buffer !== "undefined") {
|
|
579
|
+
return (globalThis as any).Buffer.from(bytes).toString("base64");
|
|
580
|
+
}
|
|
581
|
+
let bin = "";
|
|
582
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
583
|
+
return (globalThis as any).btoa(bin);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ============================================================================
|
|
587
|
+
// Blended ("UNNEST-style") row-transform functions
|
|
588
|
+
// ============================================================================
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Process params for a blended row-transform function. `args` carries only the
|
|
592
|
+
* NAMED (bind-time scalar) options — the positional args are the per-row input
|
|
593
|
+
* columns, read from `batch` in process() (by declared name for fixed args,
|
|
594
|
+
* positionally for varargs).
|
|
595
|
+
*/
|
|
596
|
+
export type RowTransformProcessParams<TArgs = Record<string, any>> =
|
|
597
|
+
TableInOutProcessParams<TArgs>;
|
|
598
|
+
|
|
599
|
+
export interface RowTransformConfig<TArgs = Record<string, any>> {
|
|
600
|
+
name: string;
|
|
601
|
+
description?: string;
|
|
602
|
+
/**
|
|
603
|
+
* Positional args = the per-row INPUT COLUMNS (real typed args on the wire,
|
|
604
|
+
* no synthetic TABLE placeholder). Read from `batch` by declared name in
|
|
605
|
+
* process(); NOT surfaced on `params.args`.
|
|
606
|
+
*/
|
|
607
|
+
args?: Record<string, VgiDataType>;
|
|
608
|
+
/**
|
|
609
|
+
* Trailing VARARGS input columns: the per-row input is N columns of the
|
|
610
|
+
* declared type. A varargs blended function has no per-column declared
|
|
611
|
+
* names (the C++ bind names them col0..colN-1), so process() reads the
|
|
612
|
+
* columns POSITIONALLY off `batch`.
|
|
613
|
+
*/
|
|
614
|
+
varargs?: { name: string; type: VgiDataType; doc?: string };
|
|
615
|
+
/** Named (string-position) args stay bind-time scalars on `params.args`. */
|
|
616
|
+
namedArgs?: Record<string, VgiDataType>;
|
|
617
|
+
argDefaults?: Record<string, any>;
|
|
618
|
+
/** Per-argument descriptions keyed by arg name (surfaced as `vgi_doc`). */
|
|
619
|
+
argDocs?: Record<string, string>;
|
|
620
|
+
/** Bind: return the output schema. The input schema (the declared per-row
|
|
621
|
+
* columns, typed by the C++ bind) is on `params.bindCall.input_schema`. */
|
|
622
|
+
onBind: (params: TableInOutBindParams<TArgs>) =>
|
|
623
|
+
| { outputSchema: VgiSchema; opaqueData?: Uint8Array }
|
|
624
|
+
| Promise<{ outputSchema: VgiSchema; opaqueData?: Uint8Array }>;
|
|
625
|
+
/**
|
|
626
|
+
* Per-row map: transform one input batch, emit exactly one output batch via
|
|
627
|
+
* `out`. 1->1, 1->N (with {@link parentRowsMetadata} provenance), and 1->0
|
|
628
|
+
* (a 0-row emit) all work. There is NO finalize — a blended function is a
|
|
629
|
+
* per-row map (DuckDB forbids FinalExecute under correlated LATERAL, one of
|
|
630
|
+
* the call shapes blended must serve). Accumulating functions use a classic
|
|
631
|
+
* TableInput table-in-out or a TableBufferingFunction.
|
|
632
|
+
*/
|
|
633
|
+
process: (
|
|
634
|
+
params: RowTransformProcessParams<TArgs>,
|
|
635
|
+
batch: VgiBatch,
|
|
636
|
+
out: OutputCollector,
|
|
637
|
+
) => void | Promise<void>;
|
|
638
|
+
// Metadata
|
|
639
|
+
projectionPushdown?: boolean;
|
|
640
|
+
filterPushdown?: boolean;
|
|
641
|
+
autoApplyFilters?: boolean;
|
|
642
|
+
stability?: FunctionStability;
|
|
643
|
+
examples?: FunctionExample[];
|
|
644
|
+
categories?: string[];
|
|
645
|
+
tags?: Record<string, string>;
|
|
646
|
+
maxWorkers?: number;
|
|
647
|
+
requiredSettings?: string[];
|
|
648
|
+
requiredSecrets?: string[];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Define a blended ("UNNEST-style") table-in-out function: its positional args
|
|
653
|
+
* ARE its per-row input columns, so ONE registration serves every call shape —
|
|
654
|
+
* `f(52, 13)` (literal -> one input row), `FROM t, f(t.x, t.y)` (columns ->
|
|
655
|
+
* streaming), and `LATERAL f(t.x, t.y)`. Mirrors vgi-python's
|
|
656
|
+
* `RowTransformFunction` (Phase B).
|
|
657
|
+
*
|
|
658
|
+
* Registers as a TABLE function with `FunctionInfo.input_from_args = true`;
|
|
659
|
+
* the worker's overload resolution matches blended overloads by INPUT-COLUMN
|
|
660
|
+
* count (the positional args are not on the wire). Map-shaped, no finalize.
|
|
661
|
+
*/
|
|
662
|
+
export function defineRowTransformFunction<
|
|
663
|
+
TArgs = Record<string, any>,
|
|
664
|
+
>(config: RowTransformConfig<TArgs>): VgiFunction {
|
|
665
|
+
// Foot-gun guard (mirrors Python's resolve_metadata check): a blended
|
|
666
|
+
// function needs at least one positional Arg — its per-row input column.
|
|
667
|
+
const numPositional = Object.keys(config.args ?? {}).length + (config.varargs ? 1 : 0);
|
|
668
|
+
if (numPositional === 0) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`${config.name}: a blended row-transform function needs at least one ` +
|
|
671
|
+
`positional arg (its per-row input column); found none.`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Positional args are the per-row input columns — REAL typed args on the
|
|
676
|
+
// wire (no TableInput placeholder). Named args follow.
|
|
677
|
+
const specs: ArgumentSpec[] = [];
|
|
678
|
+
let posIdx = 0;
|
|
679
|
+
if (config.args) {
|
|
680
|
+
for (const [name, type] of Object.entries(config.args)) {
|
|
681
|
+
specs.push({
|
|
682
|
+
name,
|
|
683
|
+
position: posIdx++,
|
|
684
|
+
arrowType: type,
|
|
685
|
+
doc: config.argDocs?.[name],
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (config.varargs) {
|
|
690
|
+
specs.push({
|
|
691
|
+
name: config.varargs.name,
|
|
692
|
+
position: posIdx++,
|
|
693
|
+
arrowType: config.varargs.type,
|
|
694
|
+
isVarargs: true,
|
|
695
|
+
doc: config.varargs.doc ?? config.argDocs?.[config.varargs.name],
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (config.namedArgs) {
|
|
699
|
+
for (const [name, type] of Object.entries(config.namedArgs)) {
|
|
700
|
+
specs.push({
|
|
701
|
+
name,
|
|
702
|
+
position: name,
|
|
703
|
+
arrowType: type,
|
|
704
|
+
doc: config.argDocs?.[name],
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const meta: FunctionMeta = {
|
|
710
|
+
name: config.name,
|
|
711
|
+
description: config.description,
|
|
712
|
+
stability: config.stability,
|
|
713
|
+
projectionPushdown: config.projectionPushdown,
|
|
714
|
+
filterPushdown: config.filterPushdown,
|
|
715
|
+
autoApplyFilters: config.autoApplyFilters,
|
|
716
|
+
examples: config.examples,
|
|
717
|
+
categories: config.categories,
|
|
718
|
+
tags: config.tags,
|
|
719
|
+
maxWorkers: config.maxWorkers,
|
|
720
|
+
requiredSettings: config.requiredSettings,
|
|
721
|
+
requiredSecrets: config.requiredSecrets,
|
|
722
|
+
// Map-shaped, per-row: a blended function never has a finalize.
|
|
723
|
+
hasFinalize: false,
|
|
724
|
+
inputFromArgs: true,
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Only NAMED args are on the wire — positional args are the input columns,
|
|
728
|
+
// absent from the wire arguments in every call shape.
|
|
729
|
+
function extractArgs(request: BindRequest): TArgs {
|
|
730
|
+
const args: Record<string, any> = {};
|
|
731
|
+
for (const spec of specs) {
|
|
732
|
+
if (typeof spec.position !== "string") continue;
|
|
733
|
+
const defaultVal =
|
|
734
|
+
config.argDefaults?.[spec.name] !== undefined
|
|
735
|
+
? config.argDefaults[spec.name]
|
|
736
|
+
: undefined;
|
|
737
|
+
let val = request.arguments.get(spec.position, defaultVal ?? null);
|
|
738
|
+
if (typeof val === "bigint") val = Number(val);
|
|
739
|
+
args[spec.name] = val;
|
|
740
|
+
}
|
|
741
|
+
return args as TArgs;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
kind: "table_in_out",
|
|
746
|
+
meta,
|
|
747
|
+
argumentSpecs: specs,
|
|
748
|
+
|
|
749
|
+
async bind(request: BindRequest): Promise<BindResponse> {
|
|
750
|
+
const args = extractArgs(request);
|
|
751
|
+
const settings = batchToScalarDict(request.settings);
|
|
752
|
+
const secrets = batchToSecretDict(request.secrets);
|
|
753
|
+
const result = await config.onBind({
|
|
754
|
+
args,
|
|
755
|
+
bindCall: request,
|
|
756
|
+
settings,
|
|
757
|
+
secrets,
|
|
758
|
+
});
|
|
759
|
+
return {
|
|
760
|
+
output_schema: result.outputSchema,
|
|
761
|
+
opaque_data: result.opaqueData ?? null,
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
|
|
765
|
+
async globalInit(request: InitRequest): Promise<GlobalInitResponse> {
|
|
766
|
+
if (request.execution_id) {
|
|
767
|
+
return {
|
|
768
|
+
max_workers: DEFAULT_MAX_WORKERS,
|
|
769
|
+
execution_id: request.execution_id,
|
|
770
|
+
opaque_data: null,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
const executionId = new Uint8Array(16);
|
|
774
|
+
crypto.getRandomValues(executionId);
|
|
775
|
+
return {
|
|
776
|
+
max_workers: config.maxWorkers ?? 1,
|
|
777
|
+
execution_id: executionId,
|
|
778
|
+
opaque_data: null,
|
|
779
|
+
};
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
createStreamHandlers(
|
|
783
|
+
request: InitRequest,
|
|
784
|
+
response: GlobalInitResponse,
|
|
785
|
+
_accumulatedState?: any,
|
|
786
|
+
): StreamHandlers {
|
|
787
|
+
const args = extractArgs(request.bind_call);
|
|
788
|
+
const settings = batchToScalarDict(request.bind_call.settings);
|
|
789
|
+
const secrets = batchToSecretDict(request.bind_call.secrets);
|
|
790
|
+
|
|
791
|
+
const projIds = request.projection_ids && meta.projectionPushdown
|
|
792
|
+
? request.projection_ids
|
|
793
|
+
: null;
|
|
794
|
+
const outputSchema = projIds
|
|
795
|
+
? projectSchema(projIds, request.output_schema)
|
|
796
|
+
: request.output_schema;
|
|
797
|
+
|
|
798
|
+
const joinKeysLookup = buildJoinKeysLookup(request.join_keys);
|
|
799
|
+
const pushdownFilters = request.pushdown_filters
|
|
800
|
+
? deserializeFilters(request.pushdown_filters, joinKeysLookup)
|
|
801
|
+
: undefined;
|
|
802
|
+
|
|
803
|
+
const boundStorage = new BoundStorage(
|
|
804
|
+
defaultStorage,
|
|
805
|
+
response.execution_id ?? new Uint8Array(16),
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
const processParams: RowTransformProcessParams<TArgs> = {
|
|
809
|
+
args,
|
|
810
|
+
initCall: request,
|
|
811
|
+
initResponse: response,
|
|
812
|
+
outputSchema,
|
|
813
|
+
settings,
|
|
814
|
+
secrets,
|
|
815
|
+
pushdownFilters,
|
|
816
|
+
storage: boundStorage,
|
|
817
|
+
substreamId: request.substream_id ?? null,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
if (request.phase === TableInOutPhase.FINALIZE) {
|
|
821
|
+
throw new Error(
|
|
822
|
+
`${config.name}: a blended row-transform function has no FINALIZE ` +
|
|
823
|
+
`phase (it is a per-row map; has_finalize is advertised false).`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
outputSchema,
|
|
829
|
+
inputSchema: request.bind_call.input_schema ?? undefined,
|
|
830
|
+
exchangeInit: () => ({ state: null, processParams }),
|
|
831
|
+
exchangeFn: async (
|
|
832
|
+
eState: { state: null; processParams: RowTransformProcessParams<TArgs> },
|
|
833
|
+
input: VgiBatch,
|
|
834
|
+
out: OutputCollector,
|
|
835
|
+
) => {
|
|
836
|
+
applyRevalidationValidators(eState.processParams, input);
|
|
837
|
+
let wrappedOut: OutputCollector = makeMetadataBakingCollector(out);
|
|
838
|
+
if (projIds) {
|
|
839
|
+
wrappedOut = makeSchemaReconcilingCollector(wrappedOut, outputSchema);
|
|
840
|
+
}
|
|
841
|
+
if (config.autoApplyFilters && pushdownFilters) {
|
|
842
|
+
wrappedOut = new FilteringOutputCollector(wrappedOut, pushdownFilters) as unknown as OutputCollector;
|
|
843
|
+
}
|
|
844
|
+
await config.process(eState.processParams, input, wrappedOut);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
}
|
package/src/functions/types.ts
CHANGED
|
@@ -68,6 +68,18 @@ export interface FunctionMeta {
|
|
|
68
68
|
* otherwise calling FinalExecute is unsupported and crashes the C++ side.
|
|
69
69
|
*/
|
|
70
70
|
hasFinalize?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Blended ("UNNEST-style") table-in-out: the function's positional args ARE
|
|
73
|
+
* its per-row input columns (real typed args, no synthetic TABLE
|
|
74
|
+
* placeholder), so ONE registration serves f(52,13) (literal -> 1 input
|
|
75
|
+
* row), FROM t, f(t.x, t.y) (columns -> streaming), and LATERAL f(t.x,t.y).
|
|
76
|
+
* Set by {@link defineRowTransformFunction}; surfaces as
|
|
77
|
+
* FunctionInfo.input_from_args. The C++ extension reads it to enter the
|
|
78
|
+
* in-out registration branch with real-typed args and drive the literal
|
|
79
|
+
* single-row scan-mode. Mirrors vgi-python's RowTransformFunction /
|
|
80
|
+
* ResolvedMetadata.input_from_args.
|
|
81
|
+
*/
|
|
82
|
+
inputFromArgs?: boolean;
|
|
71
83
|
/**
|
|
72
84
|
* table_buffering: force ParallelSink=false in the C++ operator
|
|
73
85
|
* (single-thread, source-ordered ingest).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GENERATED by vgi.codegen.ts_client. DO NOT EDIT BY HAND.
|
|
3
3
|
//
|
|
4
4
|
// Generator: vgi-gen-ts-client v1
|
|
5
|
-
// Content hash:
|
|
5
|
+
// Content hash: 226553bd666c
|
|
6
6
|
//
|
|
7
7
|
// To regenerate:
|
|
8
8
|
// uv run --project ~/Development/vgi-python vgi-gen-ts-client \
|
|
@@ -138,6 +138,7 @@ export interface FunctionInfo {
|
|
|
138
138
|
source_order_dependent: boolean;
|
|
139
139
|
sink_order_dependent: boolean;
|
|
140
140
|
requires_input_batch_index: boolean;
|
|
141
|
+
input_from_args: boolean;
|
|
141
142
|
required_settings: string[];
|
|
142
143
|
required_secrets: SecretLookupEntry[];
|
|
143
144
|
}
|
|
@@ -471,6 +472,7 @@ export interface InitRequest {
|
|
|
471
472
|
finalize_state_id?: Uint8Array | null;
|
|
472
473
|
execution_id?: Uint8Array | null;
|
|
473
474
|
init_opaque_data?: Uint8Array | null;
|
|
475
|
+
substream_id?: Uint8Array | null;
|
|
474
476
|
order_by_column_name?: string | null;
|
|
475
477
|
order_by_direction?: OrderByDirection | null;
|
|
476
478
|
order_by_null_order?: OrderByNullOrder | null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GENERATED by vgi.codegen.ts_schemas. DO NOT EDIT BY HAND.
|
|
3
3
|
//
|
|
4
4
|
// Generator: vgi-gen-ts-schemas v2
|
|
5
|
-
// Content hash:
|
|
5
|
+
// Content hash: 214d149e8f9c
|
|
6
6
|
//
|
|
7
7
|
// To regenerate:
|
|
8
8
|
// uv run --project ~/Development/vgi-python vgi-gen-ts-schemas \
|
|
@@ -114,6 +114,7 @@ export const FunctionInfoSchema = schema([
|
|
|
114
114
|
field("source_order_dependent", bool(), false),
|
|
115
115
|
field("sink_order_dependent", bool(), false),
|
|
116
116
|
field("requires_input_batch_index", bool(), false),
|
|
117
|
+
field("input_from_args", bool(), false),
|
|
117
118
|
field("required_settings", list(field("item", utf8(), true)), false),
|
|
118
119
|
field("required_secrets", list(field("item", struct([field("secret_type", utf8(), false), field("scope", utf8(), true), field("secret_name", utf8(), true)]), true)), false),
|
|
119
120
|
]);
|
package/src/index.core.ts
CHANGED
|
@@ -39,7 +39,17 @@ export {
|
|
|
39
39
|
type AggregateUpdateParams,
|
|
40
40
|
type AggregateFinalizeParams,
|
|
41
41
|
} from "./functions/aggregate.js";
|
|
42
|
-
export {
|
|
42
|
+
export {
|
|
43
|
+
defineTableInOutFunction,
|
|
44
|
+
defineRowTransformFunction,
|
|
45
|
+
parentRowsMetadata,
|
|
46
|
+
PARENT_ROW_METADATA_KEY,
|
|
47
|
+
type TableInOutConfig,
|
|
48
|
+
type TableInOutBindParams,
|
|
49
|
+
type TableInOutProcessParams,
|
|
50
|
+
type RowTransformConfig,
|
|
51
|
+
type RowTransformProcessParams,
|
|
52
|
+
} from "./functions/table-in-out.js";
|
|
43
53
|
export {
|
|
44
54
|
defineTableBufferingFunction,
|
|
45
55
|
type TableBufferingConfig,
|
|
@@ -53,6 +53,9 @@ const INIT_REQUEST_SCHEMA = makeSchema([
|
|
|
53
53
|
field("phase", utf8(), true),
|
|
54
54
|
field("execution_id", binary(), true),
|
|
55
55
|
field("init_opaque_data", binary(), true),
|
|
56
|
+
// Per-substream identity for parallel streaming table-in-out (client-minted,
|
|
57
|
+
// stable across this substream's init/process/finalize; null when absent).
|
|
58
|
+
field("substream_id", binary(), true),
|
|
56
59
|
// Order pushdown hints from DuckDB's RowGroupPruner (all null when no hint).
|
|
57
60
|
field("order_by_column_name", utf8(), true),
|
|
58
61
|
field("order_by_direction", utf8(), true),
|
|
@@ -87,6 +90,7 @@ export function serializeInitRequest(req: InitRequest): VgiBatch {
|
|
|
87
90
|
phase: req.phase ?? null,
|
|
88
91
|
execution_id: req.execution_id ?? null,
|
|
89
92
|
init_opaque_data: req.init_opaque_data ?? null,
|
|
93
|
+
substream_id: req.substream_id ?? null,
|
|
90
94
|
order_by_column_name: req.order_by_column_name ?? null,
|
|
91
95
|
order_by_direction: req.order_by_direction ?? null,
|
|
92
96
|
order_by_null_order: req.order_by_null_order ?? null,
|
|
@@ -177,6 +181,9 @@ export function deserializeInitRequest(
|
|
|
177
181
|
init_opaque_data: params.init_opaque_data
|
|
178
182
|
? toUint8Array(params.init_opaque_data)
|
|
179
183
|
: null,
|
|
184
|
+
substream_id: params.substream_id
|
|
185
|
+
? toUint8Array(params.substream_id)
|
|
186
|
+
: null,
|
|
180
187
|
order_by_column_name: parseEnum(decodeDictValue(params.order_by_column_name)) ?? null,
|
|
181
188
|
order_by_direction: parseDirection(decodeDictValue(params.order_by_direction)),
|
|
182
189
|
order_by_null_order: parseNullOrder(decodeDictValue(params.order_by_null_order)),
|
package/src/protocol/types.ts
CHANGED
|
@@ -119,6 +119,18 @@ export interface InitRequest {
|
|
|
119
119
|
finalize_state_id: Uint8Array | null;
|
|
120
120
|
execution_id: Uint8Array | null;
|
|
121
121
|
init_opaque_data: Uint8Array | null;
|
|
122
|
+
/**
|
|
123
|
+
* Per-substream identity for parallel streaming table-in-out functions.
|
|
124
|
+
* A stable, CLIENT-minted id for one substream (one DuckDB PipelineExecutor),
|
|
125
|
+
* identical across this substream's init / every process tick / finalize.
|
|
126
|
+
* Unlike a worker-minted `execution_id`, it survives an HTTP load balancer
|
|
127
|
+
* dispatching each request to an arbitrary backend: a finalize that lands on
|
|
128
|
+
* a different backend than the process() calls can still key the substream's
|
|
129
|
+
* accumulated state (in shared storage) by `substream_id`. `null` when the
|
|
130
|
+
* client did not supply one (serial path, non-table-in-out functions, old
|
|
131
|
+
* clients). Mirrors vgi-python's `InitRequest.substream_id`.
|
|
132
|
+
*/
|
|
133
|
+
substream_id: Uint8Array | null;
|
|
122
134
|
// Order pushdown hints from DuckDB's RowGroupPruner (null when no hint).
|
|
123
135
|
order_by_column_name: string | null;
|
|
124
136
|
order_by_direction: OrderByDirection | null;
|
package/src/worker.ts
CHANGED
|
@@ -205,7 +205,7 @@ export class Worker {
|
|
|
205
205
|
*/
|
|
206
206
|
async serveStream(
|
|
207
207
|
readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream,
|
|
208
|
-
writable?: number | import("node:net").Socket,
|
|
208
|
+
writable?: number | import("node:net").Socket | import("@query-farm/vgi-rpc").ByteSink,
|
|
209
209
|
): Promise<void> {
|
|
210
210
|
await serveStreamRpc(this.buildProtocol(), { readable, writable });
|
|
211
211
|
}
|