@query-farm/vgi 0.12.0 → 0.14.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.
@@ -0,0 +1,639 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Producer for the versioned VGI HTTP landing contract (`describe.json`) from a
5
+ // worker's catalog introspection. This is the TypeScript counterpart to the
6
+ // Python reference producer (`vgi/http/describe_json.py`); both emit an
7
+ // equivalent document, guarded by the cross-language conformance harness at
8
+ // `vgi/test/landing/`. See `vgi/docs/http-landing-contract.md` for the spec.
9
+ //
10
+ // The document is JSON — it does NOT depend on the (versioned, evolving) VGI
11
+ // wire protocol, so the single shared static `landing.html` (byte-identical
12
+ // across every language worker) can render it same-origin. Table/view columns
13
+ // are lazy: the document carries only a column count, and the page fetches
14
+ // per-object detail from `buildColumnsJson`
15
+ // (`GET {prefix}/describe/{catalog}/{schema}/{table}.json`) on first expand.
16
+
17
+ import {
18
+ deserializeBatch,
19
+ deserializeSchema,
20
+ isBinary,
21
+ isBool,
22
+ isFloat,
23
+ isInt,
24
+ isNull,
25
+ isUtf8,
26
+ type VgiDataType,
27
+ type VgiField,
28
+ } from "../arrow/index.js";
29
+ import type { CatalogInterface } from "../catalog/interface.js";
30
+ import type {
31
+ CatalogAttachResult,
32
+ CatalogInfo,
33
+ FunctionInfo,
34
+ MacroInfo,
35
+ SchemaInfo,
36
+ TableInfo,
37
+ ViewInfo,
38
+ } from "../generated/vgi-client.js";
39
+ import {
40
+ VGI_ARG_KEY,
41
+ VGI_ARG_NAMED,
42
+ VGI_DEFAULT_KEY,
43
+ VGI_DOC_KEY,
44
+ VGI_TYPE_KEY,
45
+ VGI_TYPE_TABLE,
46
+ } from "../types.js";
47
+
48
+ export const LANDING_SCHEMA_VERSION = 1;
49
+ export const CUPOLA_BASE = "https://cupola.query-farm.services";
50
+
51
+ /**
52
+ * Producer for the VGI landing surface, passed to `createHttpHandler`'s
53
+ * `landingDescribe` option. Declared locally (structurally identical to
54
+ * `@query-farm/vgi-rpc`'s `LandingDescribeProvider`) so this package does not
55
+ * hard-couple to a specific vgi-rpc version.
56
+ */
57
+ export interface LandingDescribeProvider {
58
+ describe(ctx: { serverId: string; oauth: boolean }): unknown | Promise<unknown>;
59
+ columns(catalog: string, schema: string, table: string): unknown | null | Promise<unknown | null>;
60
+ }
61
+
62
+ /** Identity of the worker software surfaced in `describe.json` → `worker`. */
63
+ export interface WorkerDescribeInfo {
64
+ /** Worker name (e.g. class or catalog name). */
65
+ name: string;
66
+ /** One-line worker description. */
67
+ doc?: string;
68
+ /** Worker software version. */
69
+ version?: string;
70
+ }
71
+
72
+ // `duckdb_databases().tags` keys (the reserved `vgi.*` namespace) surfaced in
73
+ // the landing page's catalog card. Mirrors the Python reference `_STRING_TAGS`.
74
+ const STRING_TAGS: Record<string, string> = {
75
+ title: "vgi.title",
76
+ doc_md: "vgi.doc_md",
77
+ source_url: "vgi.source_url",
78
+ license: "vgi.license",
79
+ author: "vgi.author",
80
+ copyright: "vgi.copyright",
81
+ support_contact: "vgi.support_contact",
82
+ support_policy_url: "vgi.support_policy_url",
83
+ };
84
+ const KEYWORDS_TAG = "vgi.keywords"; // JSON array encoded as a string in the tags MAP
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Type / field helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Human-readable DuckDB-ish type name for a VGI Arrow type. */
91
+ function typeToString(type: VgiDataType): string {
92
+ if (isNull(type)) return "NULL";
93
+ if (isUtf8(type)) return "VARCHAR";
94
+ if (isBinary(type)) return "BLOB";
95
+ if (isBool(type)) return "BOOLEAN";
96
+ if (isInt(type)) {
97
+ const bw = (type as { bitWidth?: number }).bitWidth ?? 32;
98
+ return bw === 64 ? "BIGINT" : "INTEGER";
99
+ }
100
+ if (isFloat(type)) {
101
+ const precision = (type as { precision?: number }).precision ?? 2;
102
+ return precision === 1 ? "FLOAT" : "DOUBLE";
103
+ }
104
+ const s = (type as { toString?: () => string }).toString?.();
105
+ return s ?? String(type.typeId);
106
+ }
107
+
108
+ /**
109
+ * Raw Arrow type string (e.g. `int64`, `string`, `double`) matching pyarrow's
110
+ * `str(type)`. Unlike {@link typeToString} (DuckDB-ish names used for table/view
111
+ * columns), macro arg types are carried verbatim in `describe.json` — the shared
112
+ * landing page maps Arrow → DuckDB client-side — so they must be byte-identical
113
+ * to what the Python reference producer emits for cross-language parity.
114
+ */
115
+ function arrowTypeString(type: VgiDataType): string {
116
+ if (isNull(type)) return "null";
117
+ if (isBool(type)) return "bool";
118
+ if (isUtf8(type)) return "string";
119
+ if (isBinary(type)) return "binary";
120
+ if (isInt(type)) {
121
+ const bw = (type as { bitWidth?: number }).bitWidth ?? 32;
122
+ const signed =
123
+ (type as { isSigned?: boolean }).isSigned ?? (type as { signed?: boolean }).signed ?? true;
124
+ return `${signed ? "" : "u"}int${bw}`;
125
+ }
126
+ if (isFloat(type)) {
127
+ const precision = (type as { precision?: number }).precision ?? 2;
128
+ return precision === 0 ? "halffloat" : precision === 1 ? "float" : "double";
129
+ }
130
+ const s = (type as { toString?: () => string }).toString?.();
131
+ return (s ?? String(type.typeId)).toLowerCase();
132
+ }
133
+
134
+ function fieldMeta(field: VgiField, key: string): string | undefined {
135
+ return field.metadata.get(key);
136
+ }
137
+
138
+ interface ColumnJson {
139
+ name: string;
140
+ type: string;
141
+ comment?: string;
142
+ }
143
+
144
+ function fieldToColumn(field: VgiField): ColumnJson {
145
+ const col: ColumnJson = { name: field.name, type: typeToString(field.type) };
146
+ const comment = fieldMeta(field, "comment") ?? fieldMeta(field, VGI_DOC_KEY);
147
+ if (comment) col.comment = comment;
148
+ return col;
149
+ }
150
+
151
+ function readSchemaSafe(bytes: Uint8Array | null | undefined) {
152
+ if (!bytes || bytes.length === 0) return null;
153
+ try {
154
+ return deserializeSchema(bytes);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Function mapping
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /** scalar | table | aggregate | table_in_out (mirrors Python `_function_display_type`). */
165
+ function functionDisplayType(fn: FunctionInfo): "scalar" | "table" | "aggregate" | "table_in_out" {
166
+ const t = String(fn.function_type).toUpperCase();
167
+ if (t === "SCALAR") return "scalar";
168
+ if (t === "AGGREGATE") return "aggregate";
169
+ if (fn.has_finalize) return "table_in_out";
170
+ return "table";
171
+ }
172
+
173
+ function functionReturns(fn: FunctionInfo): string | undefined {
174
+ const schema = readSchemaSafe(fn.output_schema);
175
+ if (schema === null || schema.fields.length === 0) return undefined;
176
+ const t = String(fn.function_type).toUpperCase();
177
+ if (t === "SCALAR" || t === "AGGREGATE") {
178
+ // Scalar/aggregate output is a single "result" column.
179
+ return typeToString(schema.fields[0].type);
180
+ }
181
+ const cols = schema.fields.map((f) => `${f.name} ${typeToString(f.type)}`).join(", ");
182
+ return `TABLE(${cols})`;
183
+ }
184
+
185
+ interface ArgJson {
186
+ name: string;
187
+ type: string;
188
+ named?: boolean;
189
+ default?: string;
190
+ desc?: string;
191
+ }
192
+
193
+ function functionArgs(fn: FunctionInfo): ArgJson[] {
194
+ const schema = readSchemaSafe(fn.arguments);
195
+ if (schema === null) return [];
196
+ const args: ArgJson[] = [];
197
+ for (const field of schema.fields) {
198
+ // Skip the piped input relation of a table-in-out function; it's not a
199
+ // user-supplied argument.
200
+ if (fieldMeta(field, VGI_TYPE_KEY) === VGI_TYPE_TABLE) continue;
201
+ const arg: ArgJson = { name: field.name, type: typeToString(field.type) };
202
+ if (fieldMeta(field, VGI_ARG_KEY) === VGI_ARG_NAMED) arg.named = true;
203
+ const doc = fieldMeta(field, VGI_DOC_KEY);
204
+ if (doc) arg.desc = doc;
205
+ const rawDefault = fieldMeta(field, VGI_DEFAULT_KEY);
206
+ if (rawDefault !== undefined) {
207
+ // Stored as a JSON scalar; render the decoded value for display.
208
+ try {
209
+ arg.default = JSON.stringify(JSON.parse(rawDefault));
210
+ } catch {
211
+ arg.default = rawDefault;
212
+ }
213
+ }
214
+ args.push(arg);
215
+ }
216
+ return args;
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Macro mapping
221
+ // ---------------------------------------------------------------------------
222
+
223
+ // A scalar macro is invoked exactly like a scalar function in SQL, and a table
224
+ // macro like a table function; surface them in the same buckets so the landing
225
+ // page lists a catalog's full callable surface (VGI workers commonly expose
226
+ // their "functions" as declarative macros). Mirrors Python `_macro_display_type`.
227
+ function macroDisplayType(m: MacroInfo): "scalar" | "table" {
228
+ return String(m.macro_type).toUpperCase() === "SCALAR" ? "scalar" : "table";
229
+ }
230
+
231
+ /** JSON-encode a decoded macro default scalar, matching Python's `json.dumps`. */
232
+ function macroDefaultJson(value: unknown): string {
233
+ // int64 defaults decode to BigInt, which JSON.stringify rejects; a bigint's
234
+ // decimal string is already its JSON integer encoding (0n -> "0").
235
+ if (typeof value === "bigint") return value.toString();
236
+ try {
237
+ return JSON.stringify(value);
238
+ } catch {
239
+ return String(value);
240
+ }
241
+ }
242
+
243
+ function macroArgs(m: MacroInfo): ArgJson[] {
244
+ // Defaulted parameters are optional and callable by name in DuckDB, so we
245
+ // present them as named args (with their default); the rest are positional.
246
+ const defaults = new Map<string, unknown>();
247
+ const defBytes = m.parameter_default_values;
248
+ if (defBytes && defBytes.length > 0) {
249
+ try {
250
+ const batch = deserializeBatch(defBytes);
251
+ for (const field of batch.schema.fields) {
252
+ try {
253
+ defaults.set(field.name, batch.getChild(field.name)?.get(0));
254
+ } catch {
255
+ // skip an undecodable default column
256
+ }
257
+ }
258
+ } catch {
259
+ // no usable defaults
260
+ }
261
+ }
262
+
263
+ const schema = readSchemaSafe(m.arguments_schema);
264
+ const fields = schema ? schema.fields : null;
265
+ const fieldByName = new Map<string, VgiField>();
266
+ if (fields) for (const f of fields) fieldByName.set(f.name, f);
267
+ const names = fields ? fields.map((f) => f.name) : m.parameters;
268
+
269
+ const args: ArgJson[] = [];
270
+ for (const name of names) {
271
+ const field = fieldByName.get(name);
272
+ // Macro parameters are untyped unless a typed default pins them; show ANY
273
+ // rather than the Arrow null placeholder.
274
+ const arg: ArgJson =
275
+ field !== undefined && !isNull(field.type)
276
+ ? { name, type: arrowTypeString(field.type) }
277
+ : { name, type: "ANY" };
278
+ const doc = field !== undefined ? fieldMeta(field, VGI_DOC_KEY) : undefined;
279
+ if (doc) arg.desc = doc;
280
+ if (defaults.has(name)) {
281
+ arg.named = true;
282
+ arg.default = macroDefaultJson(defaults.get(name));
283
+ }
284
+ args.push(arg);
285
+ }
286
+ return args;
287
+ }
288
+
289
+ function macroToObject(m: MacroInfo): Record<string, unknown> {
290
+ return {
291
+ name: m.name,
292
+ type: macroDisplayType(m),
293
+ doc: m.comment ?? "",
294
+ args: macroArgs(m),
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Catalog contents
300
+ // ---------------------------------------------------------------------------
301
+
302
+ async function schemaContents(
303
+ iface: CatalogInterface,
304
+ aod: Uint8Array,
305
+ name: string,
306
+ kind: "TABLE" | "VIEW",
307
+ ): Promise<TableInfo[] | ViewInfo[]> {
308
+ try {
309
+ if (kind === "TABLE") {
310
+ return await iface.schemaContentsTables(aod, name);
311
+ }
312
+ return await iface.schemaContentsViews(aod, name);
313
+ } catch {
314
+ return [];
315
+ }
316
+ }
317
+
318
+ async function schemaFunctions(iface: CatalogInterface, aod: Uint8Array, name: string): Promise<FunctionInfo[]> {
319
+ const out: FunctionInfo[] = [];
320
+ for (const kind of ["SCALAR_FUNCTION", "TABLE_FUNCTION", "AGGREGATE_FUNCTION"]) {
321
+ try {
322
+ out.push(...(await iface.schemaContentsFunctions(aod, name, kind)));
323
+ } catch {
324
+ // best-effort — keep whatever we collected
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+
330
+ async function schemaMacros(iface: CatalogInterface, aod: Uint8Array, name: string): Promise<MacroInfo[]> {
331
+ const out: MacroInfo[] = [];
332
+ for (const kind of ["SCALAR_MACRO", "TABLE_MACRO"]) {
333
+ try {
334
+ out.push(...(await iface.schemaContentsMacros(aod, name, kind)));
335
+ } catch {
336
+ // best-effort — keep whatever we collected
337
+ }
338
+ }
339
+ return out;
340
+ }
341
+
342
+ interface Counts {
343
+ schemas: number;
344
+ tables: number;
345
+ views: number;
346
+ functions: number;
347
+ }
348
+
349
+ async function buildSchemas(
350
+ iface: CatalogInterface,
351
+ aod: Uint8Array,
352
+ ): Promise<{ schemas: unknown[]; counts: Counts }> {
353
+ let schemaInfos: SchemaInfo[];
354
+ try {
355
+ schemaInfos = await iface.schemas(aod);
356
+ } catch {
357
+ return { schemas: [], counts: { schemas: 0, tables: 0, views: 0, functions: 0 } };
358
+ }
359
+
360
+ const schemas: unknown[] = [];
361
+ const counts: Counts = { schemas: 0, tables: 0, views: 0, functions: 0 };
362
+
363
+ for (const si of schemaInfos) {
364
+ const tablesRaw = (await schemaContents(iface, aod, si.name, "TABLE")) as TableInfo[];
365
+ const viewsRaw = (await schemaContents(iface, aod, si.name, "VIEW")) as ViewInfo[];
366
+ const funcsRaw = await schemaFunctions(iface, aod, si.name);
367
+ const macrosRaw = await schemaMacros(iface, aod, si.name);
368
+
369
+ const tables = tablesRaw.map((t) => {
370
+ const schema = readSchemaSafe(t.columns);
371
+ return { name: t.name, cols: schema ? schema.fields.length : 0, comment: t.comment ?? "" };
372
+ });
373
+
374
+ const views = viewsRaw.map((v) => ({
375
+ name: v.name,
376
+ cols: Object.keys(v.column_comments ?? {}).length,
377
+ comment: v.comment ?? "",
378
+ def: v.definition,
379
+ }));
380
+
381
+ const functions: Record<string, unknown>[] = funcsRaw.map((fn) => {
382
+ const obj: Record<string, unknown> = {
383
+ name: fn.name,
384
+ type: functionDisplayType(fn),
385
+ doc: fn.description ?? "",
386
+ args: functionArgs(fn),
387
+ };
388
+ const ret = functionReturns(fn);
389
+ if (ret) obj.returns = ret;
390
+ return obj;
391
+ });
392
+ // Fold macros into the same scalar/table buckets (VGI catalogs commonly
393
+ // expose their callable surface as declarative macros).
394
+ functions.push(...macrosRaw.map((m) => macroToObject(m)));
395
+ // Deterministic ordering across functions + macros, matching the Python
396
+ // reference producer's `sort(key=(type, name))`.
397
+ functions.sort(
398
+ (a, b) =>
399
+ String(a.type).localeCompare(String(b.type)) || String(a.name).localeCompare(String(b.name)),
400
+ );
401
+
402
+ schemas.push({ name: si.name, tables, views, functions });
403
+ counts.schemas += 1;
404
+ counts.tables += tables.length;
405
+ counts.views += views.length;
406
+ counts.functions += functions.length;
407
+ }
408
+
409
+ return { schemas, counts };
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Catalog-level metadata (tags, attach options, data versions)
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function catalogTags(result: CatalogAttachResult | null): Record<string, unknown> {
417
+ const tags = result?.tags;
418
+ if (!tags) return {};
419
+ const out: Record<string, unknown> = {};
420
+ for (const [key, tag] of Object.entries(STRING_TAGS)) {
421
+ const val = tags[tag];
422
+ if (val) out[key] = val;
423
+ }
424
+ const kw = tags[KEYWORDS_TAG];
425
+ if (kw) {
426
+ try {
427
+ const parsed = JSON.parse(kw);
428
+ if (Array.isArray(parsed)) out.keywords = parsed.map((k) => String(k));
429
+ } catch {
430
+ // ignore unparseable keyword tag
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+
436
+ interface AttachOptionJson {
437
+ name: string;
438
+ type: string;
439
+ default: string;
440
+ description: string;
441
+ }
442
+
443
+ /** Decode a serialized AttachOptionSpec row into the describe shape. */
444
+ function decodeAttachOption(bytes: Uint8Array): AttachOptionJson | null {
445
+ try {
446
+ const batch = deserializeBatch(bytes);
447
+ if (batch.numRows === 0) return null;
448
+ const name = String(batch.getChild("name")?.get(0) ?? "");
449
+ const description = String(batch.getChild("description")?.get(0) ?? "");
450
+ const typeBytes = batch.getChild("type")?.get(0) as Uint8Array | null | undefined;
451
+ let type = "";
452
+ const typeSchema = readSchemaSafe(typeBytes ?? null);
453
+ if (typeSchema && typeSchema.fields.length > 0) type = typeToString(typeSchema.fields[0].type);
454
+ let defaultStr = "";
455
+ const defBytes = batch.getChild("default_value")?.get(0) as Uint8Array | null | undefined;
456
+ if (defBytes) {
457
+ try {
458
+ const defBatch = deserializeBatch(defBytes);
459
+ if (defBatch.numRows > 0) {
460
+ const v = defBatch.getChild("value")?.get(0);
461
+ defaultStr = v == null ? "" : String(v);
462
+ }
463
+ } catch {
464
+ // leave default empty
465
+ }
466
+ }
467
+ return { name, type, default: defaultStr, description };
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ async function attachCatalog(
474
+ iface: CatalogInterface,
475
+ name: string,
476
+ ): Promise<CatalogAttachResult | null> {
477
+ try {
478
+ return await iface.attach(name, {}, null, null);
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+
484
+ async function buildCatalog(
485
+ iface: CatalogInterface,
486
+ name: string,
487
+ info: CatalogInfo | undefined,
488
+ ): Promise<Record<string, unknown>> {
489
+ const result = await attachCatalog(iface, name);
490
+
491
+ const attachOptions = (info?.attach_option_specs ?? [])
492
+ .map((b) => decodeAttachOption(b))
493
+ .filter((o): o is AttachOptionJson => o !== null);
494
+
495
+ const dataVersions = (info?.releases ?? []).map((r) =>
496
+ r.summary ? { spec: r.version, label: r.summary } : { spec: r.version },
497
+ );
498
+
499
+ let impl: string | null = info?.implementation_version ?? null;
500
+ let dvs: string | null = info?.data_version_spec ?? null;
501
+ if (result) {
502
+ impl = result.resolved_implementation_version ?? impl;
503
+ dvs = result.resolved_data_version ?? dvs;
504
+ }
505
+
506
+ let schemas: unknown[] = [];
507
+ let counts: Counts = { schemas: 0, tables: 0, views: 0, functions: 0 };
508
+ if (result) {
509
+ const built = await buildSchemas(iface, result.attach_opaque_data);
510
+ schemas = built.schemas;
511
+ counts = built.counts;
512
+ }
513
+
514
+ return {
515
+ name,
516
+ implementation_version: impl,
517
+ data_version_spec: dvs,
518
+ data_versions: dataVersions,
519
+ attach_options: attachOptions,
520
+ tags: catalogTags(result),
521
+ counts,
522
+ schemas,
523
+ };
524
+ }
525
+
526
+ async function catalogInfoByName(iface: CatalogInterface): Promise<Map<string, CatalogInfo>> {
527
+ const map = new Map<string, CatalogInfo>();
528
+ if (typeof iface.catalogsInfo === "function") {
529
+ try {
530
+ const infos = await iface.catalogsInfo();
531
+ for (const info of infos) map.set(info.name, info);
532
+ } catch {
533
+ // fall back to bare names below
534
+ }
535
+ }
536
+ return map;
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Public API
541
+ // ---------------------------------------------------------------------------
542
+
543
+ /**
544
+ * Build the full `describe.json` document for a worker's catalog interface.
545
+ *
546
+ * `oauth` and `serverId` are supplied by the HTTP server (they depend on runtime
547
+ * config, not the catalog).
548
+ */
549
+ export async function buildDescribeJson(
550
+ iface: CatalogInterface,
551
+ worker: WorkerDescribeInfo,
552
+ opts: { oauth?: boolean; serverId?: string; cupolaBase?: string } = {},
553
+ ): Promise<Record<string, unknown>> {
554
+ const infos = await catalogInfoByName(iface);
555
+ let names: string[];
556
+ try {
557
+ names = iface.catalogs();
558
+ } catch {
559
+ names = [];
560
+ }
561
+
562
+ const catalogs: unknown[] = [];
563
+ for (const name of names) {
564
+ catalogs.push(await buildCatalog(iface, name, infos.get(name)));
565
+ }
566
+
567
+ return {
568
+ landing_schema_version: LANDING_SCHEMA_VERSION,
569
+ worker: {
570
+ name: worker.name,
571
+ doc: worker.doc ?? "",
572
+ version: worker.version ?? "unknown",
573
+ lang: "typescript",
574
+ },
575
+ server_id: opts.serverId ?? "",
576
+ oauth: opts.oauth ?? false,
577
+ cupola_base: opts.cupolaBase ?? CUPOLA_BASE,
578
+ catalogs,
579
+ };
580
+ }
581
+
582
+ /**
583
+ * Build the lazy per-object column payload for one table or view.
584
+ *
585
+ * Returns `{ columns: [{ name, type, comment? }] }`, or `null` when the object
586
+ * can't be found. Tables deserialize their Arrow column schema; views expose
587
+ * their declared column comments (types are only known after binding the SQL,
588
+ * which the worker does not do here).
589
+ */
590
+ export async function buildColumnsJson(
591
+ iface: CatalogInterface,
592
+ catalog: string,
593
+ schema: string,
594
+ table: string,
595
+ ): Promise<{ columns: ColumnJson[] } | null> {
596
+ const result = await attachCatalog(iface, catalog);
597
+ if (!result) return null;
598
+ const aod = result.attach_opaque_data;
599
+
600
+ const tables = (await schemaContents(iface, aod, schema, "TABLE")) as TableInfo[];
601
+ for (const t of tables) {
602
+ if (t.name === table) {
603
+ const arrow = readSchemaSafe(t.columns);
604
+ return { columns: (arrow?.fields ?? []).map((f) => fieldToColumn(f)) };
605
+ }
606
+ }
607
+
608
+ const views = (await schemaContents(iface, aod, schema, "VIEW")) as ViewInfo[];
609
+ for (const v of views) {
610
+ if (v.name === table) {
611
+ return {
612
+ columns: Object.entries(v.column_comments ?? {}).map(([name, comment]) => ({
613
+ name,
614
+ type: "",
615
+ comment,
616
+ })),
617
+ };
618
+ }
619
+ }
620
+
621
+ return null;
622
+ }
623
+
624
+ /**
625
+ * Build a {@link LandingDescribeProvider} to pass to `createHttpHandler` from
626
+ * `@query-farm/vgi-rpc`. Wires the worker's catalog interface into the
627
+ * standardized landing surface (`GET {prefix}/describe.json` and the lazy
628
+ * per-object column endpoint).
629
+ */
630
+ export function createLandingDescribe(
631
+ iface: CatalogInterface,
632
+ worker: WorkerDescribeInfo,
633
+ opts: { cupolaBase?: string } = {},
634
+ ): LandingDescribeProvider {
635
+ return {
636
+ describe: (ctx) => buildDescribeJson(iface, worker, { ...ctx, cupolaBase: opts.cupolaBase }),
637
+ columns: (catalog, schema, table) => buildColumnsJson(iface, catalog, schema, table),
638
+ };
639
+ }
package/src/index.core.ts CHANGED
@@ -120,6 +120,19 @@ export { arrowStateSerializer, EXCHANGE_STATE_SCHEMA, serializeUserState, deseri
120
120
  // Protocol assembly (for embedding the worker dispatcher in a custom HTTP server).
121
121
  export { buildVgiProtocol, type ProtocolConfig } from "./protocol/dispatch.js";
122
122
 
123
+ // VGI HTTP landing surface producer (describe.json contract). Pass
124
+ // `createLandingDescribe(catalogInterface, workerInfo)` to `createHttpHandler`'s
125
+ // `landingDescribe` option. See docs/http-landing-contract.md.
126
+ export {
127
+ buildColumnsJson,
128
+ buildDescribeJson,
129
+ createLandingDescribe,
130
+ CUPOLA_BASE,
131
+ LANDING_SCHEMA_VERSION,
132
+ type LandingDescribeProvider,
133
+ type WorkerDescribeInfo,
134
+ } from "./http/describe-json.js";
135
+
123
136
  // Wire schemas — needed by workers that synthesize TableInfo.bind_result /
124
137
  // TableInfo.scan_function bytes to short-circuit per-scan RPCs.
125
138
  export { BindResultSchema, ScanFunctionResultSchema } from "./generated/vgi-protocol-schemas.js";