@query-farm/vgi 0.11.0 → 0.13.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,516 @@
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
+ SchemaInfo,
35
+ TableInfo,
36
+ ViewInfo,
37
+ } from "../generated/vgi-client.js";
38
+ import {
39
+ VGI_ARG_KEY,
40
+ VGI_ARG_NAMED,
41
+ VGI_DEFAULT_KEY,
42
+ VGI_DOC_KEY,
43
+ VGI_TYPE_KEY,
44
+ VGI_TYPE_TABLE,
45
+ } from "../types.js";
46
+
47
+ export const LANDING_SCHEMA_VERSION = 1;
48
+ export const CUPOLA_BASE = "https://cupola.query-farm.services";
49
+
50
+ /**
51
+ * Producer for the VGI landing surface, passed to `createHttpHandler`'s
52
+ * `landingDescribe` option. Declared locally (structurally identical to
53
+ * `@query-farm/vgi-rpc`'s `LandingDescribeProvider`) so this package does not
54
+ * hard-couple to a specific vgi-rpc version.
55
+ */
56
+ export interface LandingDescribeProvider {
57
+ describe(ctx: { serverId: string; oauth: boolean }): unknown | Promise<unknown>;
58
+ columns(catalog: string, schema: string, table: string): unknown | null | Promise<unknown | null>;
59
+ }
60
+
61
+ /** Identity of the worker software surfaced in `describe.json` → `worker`. */
62
+ export interface WorkerDescribeInfo {
63
+ /** Worker name (e.g. class or catalog name). */
64
+ name: string;
65
+ /** One-line worker description. */
66
+ doc?: string;
67
+ /** Worker software version. */
68
+ version?: string;
69
+ }
70
+
71
+ // `duckdb_databases().tags` keys (the reserved `vgi.*` namespace) surfaced in
72
+ // the landing page's catalog card. Mirrors the Python reference `_STRING_TAGS`.
73
+ const STRING_TAGS: Record<string, string> = {
74
+ title: "vgi.title",
75
+ doc_md: "vgi.doc_md",
76
+ source_url: "vgi.source_url",
77
+ license: "vgi.license",
78
+ author: "vgi.author",
79
+ copyright: "vgi.copyright",
80
+ support_contact: "vgi.support_contact",
81
+ support_policy_url: "vgi.support_policy_url",
82
+ };
83
+ const KEYWORDS_TAG = "vgi.keywords"; // JSON array encoded as a string in the tags MAP
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Type / field helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /** Human-readable DuckDB-ish type name for a VGI Arrow type. */
90
+ function typeToString(type: VgiDataType): string {
91
+ if (isNull(type)) return "NULL";
92
+ if (isUtf8(type)) return "VARCHAR";
93
+ if (isBinary(type)) return "BLOB";
94
+ if (isBool(type)) return "BOOLEAN";
95
+ if (isInt(type)) {
96
+ const bw = (type as { bitWidth?: number }).bitWidth ?? 32;
97
+ return bw === 64 ? "BIGINT" : "INTEGER";
98
+ }
99
+ if (isFloat(type)) {
100
+ const precision = (type as { precision?: number }).precision ?? 2;
101
+ return precision === 1 ? "FLOAT" : "DOUBLE";
102
+ }
103
+ const s = (type as { toString?: () => string }).toString?.();
104
+ return s ?? String(type.typeId);
105
+ }
106
+
107
+ function fieldMeta(field: VgiField, key: string): string | undefined {
108
+ return field.metadata.get(key);
109
+ }
110
+
111
+ interface ColumnJson {
112
+ name: string;
113
+ type: string;
114
+ comment?: string;
115
+ }
116
+
117
+ function fieldToColumn(field: VgiField): ColumnJson {
118
+ const col: ColumnJson = { name: field.name, type: typeToString(field.type) };
119
+ const comment = fieldMeta(field, "comment") ?? fieldMeta(field, VGI_DOC_KEY);
120
+ if (comment) col.comment = comment;
121
+ return col;
122
+ }
123
+
124
+ function readSchemaSafe(bytes: Uint8Array | null | undefined) {
125
+ if (!bytes || bytes.length === 0) return null;
126
+ try {
127
+ return deserializeSchema(bytes);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Function mapping
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** scalar | table | aggregate | table_in_out (mirrors Python `_function_display_type`). */
138
+ function functionDisplayType(fn: FunctionInfo): "scalar" | "table" | "aggregate" | "table_in_out" {
139
+ const t = String(fn.function_type).toUpperCase();
140
+ if (t === "SCALAR") return "scalar";
141
+ if (t === "AGGREGATE") return "aggregate";
142
+ if (fn.has_finalize) return "table_in_out";
143
+ return "table";
144
+ }
145
+
146
+ function functionReturns(fn: FunctionInfo): string | undefined {
147
+ const schema = readSchemaSafe(fn.output_schema);
148
+ if (schema === null || schema.fields.length === 0) return undefined;
149
+ const t = String(fn.function_type).toUpperCase();
150
+ if (t === "SCALAR" || t === "AGGREGATE") {
151
+ // Scalar/aggregate output is a single "result" column.
152
+ return typeToString(schema.fields[0].type);
153
+ }
154
+ const cols = schema.fields.map((f) => `${f.name} ${typeToString(f.type)}`).join(", ");
155
+ return `TABLE(${cols})`;
156
+ }
157
+
158
+ interface ArgJson {
159
+ name: string;
160
+ type: string;
161
+ named?: boolean;
162
+ default?: string;
163
+ desc?: string;
164
+ }
165
+
166
+ function functionArgs(fn: FunctionInfo): ArgJson[] {
167
+ const schema = readSchemaSafe(fn.arguments);
168
+ if (schema === null) return [];
169
+ const args: ArgJson[] = [];
170
+ for (const field of schema.fields) {
171
+ // Skip the piped input relation of a table-in-out function; it's not a
172
+ // user-supplied argument.
173
+ if (fieldMeta(field, VGI_TYPE_KEY) === VGI_TYPE_TABLE) continue;
174
+ const arg: ArgJson = { name: field.name, type: typeToString(field.type) };
175
+ if (fieldMeta(field, VGI_ARG_KEY) === VGI_ARG_NAMED) arg.named = true;
176
+ const doc = fieldMeta(field, VGI_DOC_KEY);
177
+ if (doc) arg.desc = doc;
178
+ const rawDefault = fieldMeta(field, VGI_DEFAULT_KEY);
179
+ if (rawDefault !== undefined) {
180
+ // Stored as a JSON scalar; render the decoded value for display.
181
+ try {
182
+ arg.default = JSON.stringify(JSON.parse(rawDefault));
183
+ } catch {
184
+ arg.default = rawDefault;
185
+ }
186
+ }
187
+ args.push(arg);
188
+ }
189
+ return args;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Catalog contents
194
+ // ---------------------------------------------------------------------------
195
+
196
+ async function schemaContents(
197
+ iface: CatalogInterface,
198
+ aod: Uint8Array,
199
+ name: string,
200
+ kind: "TABLE" | "VIEW",
201
+ ): Promise<TableInfo[] | ViewInfo[]> {
202
+ try {
203
+ if (kind === "TABLE") {
204
+ return await iface.schemaContentsTables(aod, name);
205
+ }
206
+ return await iface.schemaContentsViews(aod, name);
207
+ } catch {
208
+ return [];
209
+ }
210
+ }
211
+
212
+ async function schemaFunctions(iface: CatalogInterface, aod: Uint8Array, name: string): Promise<FunctionInfo[]> {
213
+ const out: FunctionInfo[] = [];
214
+ for (const kind of ["SCALAR_FUNCTION", "TABLE_FUNCTION", "AGGREGATE_FUNCTION"]) {
215
+ try {
216
+ out.push(...(await iface.schemaContentsFunctions(aod, name, kind)));
217
+ } catch {
218
+ // best-effort — keep whatever we collected
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ interface Counts {
225
+ schemas: number;
226
+ tables: number;
227
+ views: number;
228
+ functions: number;
229
+ }
230
+
231
+ async function buildSchemas(
232
+ iface: CatalogInterface,
233
+ aod: Uint8Array,
234
+ ): Promise<{ schemas: unknown[]; counts: Counts }> {
235
+ let schemaInfos: SchemaInfo[];
236
+ try {
237
+ schemaInfos = await iface.schemas(aod);
238
+ } catch {
239
+ return { schemas: [], counts: { schemas: 0, tables: 0, views: 0, functions: 0 } };
240
+ }
241
+
242
+ const schemas: unknown[] = [];
243
+ const counts: Counts = { schemas: 0, tables: 0, views: 0, functions: 0 };
244
+
245
+ for (const si of schemaInfos) {
246
+ const tablesRaw = (await schemaContents(iface, aod, si.name, "TABLE")) as TableInfo[];
247
+ const viewsRaw = (await schemaContents(iface, aod, si.name, "VIEW")) as ViewInfo[];
248
+ const funcsRaw = await schemaFunctions(iface, aod, si.name);
249
+
250
+ const tables = tablesRaw.map((t) => {
251
+ const schema = readSchemaSafe(t.columns);
252
+ return { name: t.name, cols: schema ? schema.fields.length : 0, comment: t.comment ?? "" };
253
+ });
254
+
255
+ const views = viewsRaw.map((v) => ({
256
+ name: v.name,
257
+ cols: Object.keys(v.column_comments ?? {}).length,
258
+ comment: v.comment ?? "",
259
+ def: v.definition,
260
+ }));
261
+
262
+ const functions = funcsRaw
263
+ .slice()
264
+ .sort((a, b) =>
265
+ String(a.function_type).localeCompare(String(b.function_type)) || a.name.localeCompare(b.name),
266
+ )
267
+ .map((fn) => {
268
+ const obj: Record<string, unknown> = {
269
+ name: fn.name,
270
+ type: functionDisplayType(fn),
271
+ doc: fn.description ?? "",
272
+ args: functionArgs(fn),
273
+ };
274
+ const ret = functionReturns(fn);
275
+ if (ret) obj.returns = ret;
276
+ return obj;
277
+ });
278
+
279
+ schemas.push({ name: si.name, tables, views, functions });
280
+ counts.schemas += 1;
281
+ counts.tables += tables.length;
282
+ counts.views += views.length;
283
+ counts.functions += functions.length;
284
+ }
285
+
286
+ return { schemas, counts };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Catalog-level metadata (tags, attach options, data versions)
291
+ // ---------------------------------------------------------------------------
292
+
293
+ function catalogTags(result: CatalogAttachResult | null): Record<string, unknown> {
294
+ const tags = result?.tags;
295
+ if (!tags) return {};
296
+ const out: Record<string, unknown> = {};
297
+ for (const [key, tag] of Object.entries(STRING_TAGS)) {
298
+ const val = tags[tag];
299
+ if (val) out[key] = val;
300
+ }
301
+ const kw = tags[KEYWORDS_TAG];
302
+ if (kw) {
303
+ try {
304
+ const parsed = JSON.parse(kw);
305
+ if (Array.isArray(parsed)) out.keywords = parsed.map((k) => String(k));
306
+ } catch {
307
+ // ignore unparseable keyword tag
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+
313
+ interface AttachOptionJson {
314
+ name: string;
315
+ type: string;
316
+ default: string;
317
+ description: string;
318
+ }
319
+
320
+ /** Decode a serialized AttachOptionSpec row into the describe shape. */
321
+ function decodeAttachOption(bytes: Uint8Array): AttachOptionJson | null {
322
+ try {
323
+ const batch = deserializeBatch(bytes);
324
+ if (batch.numRows === 0) return null;
325
+ const name = String(batch.getChild("name")?.get(0) ?? "");
326
+ const description = String(batch.getChild("description")?.get(0) ?? "");
327
+ const typeBytes = batch.getChild("type")?.get(0) as Uint8Array | null | undefined;
328
+ let type = "";
329
+ const typeSchema = readSchemaSafe(typeBytes ?? null);
330
+ if (typeSchema && typeSchema.fields.length > 0) type = typeToString(typeSchema.fields[0].type);
331
+ let defaultStr = "";
332
+ const defBytes = batch.getChild("default_value")?.get(0) as Uint8Array | null | undefined;
333
+ if (defBytes) {
334
+ try {
335
+ const defBatch = deserializeBatch(defBytes);
336
+ if (defBatch.numRows > 0) {
337
+ const v = defBatch.getChild("value")?.get(0);
338
+ defaultStr = v == null ? "" : String(v);
339
+ }
340
+ } catch {
341
+ // leave default empty
342
+ }
343
+ }
344
+ return { name, type, default: defaultStr, description };
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ async function attachCatalog(
351
+ iface: CatalogInterface,
352
+ name: string,
353
+ ): Promise<CatalogAttachResult | null> {
354
+ try {
355
+ return await iface.attach(name, {}, null, null);
356
+ } catch {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ async function buildCatalog(
362
+ iface: CatalogInterface,
363
+ name: string,
364
+ info: CatalogInfo | undefined,
365
+ ): Promise<Record<string, unknown>> {
366
+ const result = await attachCatalog(iface, name);
367
+
368
+ const attachOptions = (info?.attach_option_specs ?? [])
369
+ .map((b) => decodeAttachOption(b))
370
+ .filter((o): o is AttachOptionJson => o !== null);
371
+
372
+ const dataVersions = (info?.releases ?? []).map((r) =>
373
+ r.summary ? { spec: r.version, label: r.summary } : { spec: r.version },
374
+ );
375
+
376
+ let impl: string | null = info?.implementation_version ?? null;
377
+ let dvs: string | null = info?.data_version_spec ?? null;
378
+ if (result) {
379
+ impl = result.resolved_implementation_version ?? impl;
380
+ dvs = result.resolved_data_version ?? dvs;
381
+ }
382
+
383
+ let schemas: unknown[] = [];
384
+ let counts: Counts = { schemas: 0, tables: 0, views: 0, functions: 0 };
385
+ if (result) {
386
+ const built = await buildSchemas(iface, result.attach_opaque_data);
387
+ schemas = built.schemas;
388
+ counts = built.counts;
389
+ }
390
+
391
+ return {
392
+ name,
393
+ implementation_version: impl,
394
+ data_version_spec: dvs,
395
+ data_versions: dataVersions,
396
+ attach_options: attachOptions,
397
+ tags: catalogTags(result),
398
+ counts,
399
+ schemas,
400
+ };
401
+ }
402
+
403
+ async function catalogInfoByName(iface: CatalogInterface): Promise<Map<string, CatalogInfo>> {
404
+ const map = new Map<string, CatalogInfo>();
405
+ if (typeof iface.catalogsInfo === "function") {
406
+ try {
407
+ const infos = await iface.catalogsInfo();
408
+ for (const info of infos) map.set(info.name, info);
409
+ } catch {
410
+ // fall back to bare names below
411
+ }
412
+ }
413
+ return map;
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Public API
418
+ // ---------------------------------------------------------------------------
419
+
420
+ /**
421
+ * Build the full `describe.json` document for a worker's catalog interface.
422
+ *
423
+ * `oauth` and `serverId` are supplied by the HTTP server (they depend on runtime
424
+ * config, not the catalog).
425
+ */
426
+ export async function buildDescribeJson(
427
+ iface: CatalogInterface,
428
+ worker: WorkerDescribeInfo,
429
+ opts: { oauth?: boolean; serverId?: string; cupolaBase?: string } = {},
430
+ ): Promise<Record<string, unknown>> {
431
+ const infos = await catalogInfoByName(iface);
432
+ let names: string[];
433
+ try {
434
+ names = iface.catalogs();
435
+ } catch {
436
+ names = [];
437
+ }
438
+
439
+ const catalogs: unknown[] = [];
440
+ for (const name of names) {
441
+ catalogs.push(await buildCatalog(iface, name, infos.get(name)));
442
+ }
443
+
444
+ return {
445
+ landing_schema_version: LANDING_SCHEMA_VERSION,
446
+ worker: {
447
+ name: worker.name,
448
+ doc: worker.doc ?? "",
449
+ version: worker.version ?? "unknown",
450
+ lang: "typescript",
451
+ },
452
+ server_id: opts.serverId ?? "",
453
+ oauth: opts.oauth ?? false,
454
+ cupola_base: opts.cupolaBase ?? CUPOLA_BASE,
455
+ catalogs,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Build the lazy per-object column payload for one table or view.
461
+ *
462
+ * Returns `{ columns: [{ name, type, comment? }] }`, or `null` when the object
463
+ * can't be found. Tables deserialize their Arrow column schema; views expose
464
+ * their declared column comments (types are only known after binding the SQL,
465
+ * which the worker does not do here).
466
+ */
467
+ export async function buildColumnsJson(
468
+ iface: CatalogInterface,
469
+ catalog: string,
470
+ schema: string,
471
+ table: string,
472
+ ): Promise<{ columns: ColumnJson[] } | null> {
473
+ const result = await attachCatalog(iface, catalog);
474
+ if (!result) return null;
475
+ const aod = result.attach_opaque_data;
476
+
477
+ const tables = (await schemaContents(iface, aod, schema, "TABLE")) as TableInfo[];
478
+ for (const t of tables) {
479
+ if (t.name === table) {
480
+ const arrow = readSchemaSafe(t.columns);
481
+ return { columns: (arrow?.fields ?? []).map((f) => fieldToColumn(f)) };
482
+ }
483
+ }
484
+
485
+ const views = (await schemaContents(iface, aod, schema, "VIEW")) as ViewInfo[];
486
+ for (const v of views) {
487
+ if (v.name === table) {
488
+ return {
489
+ columns: Object.entries(v.column_comments ?? {}).map(([name, comment]) => ({
490
+ name,
491
+ type: "",
492
+ comment,
493
+ })),
494
+ };
495
+ }
496
+ }
497
+
498
+ return null;
499
+ }
500
+
501
+ /**
502
+ * Build a {@link LandingDescribeProvider} to pass to `createHttpHandler` from
503
+ * `@query-farm/vgi-rpc`. Wires the worker's catalog interface into the
504
+ * standardized landing surface (`GET {prefix}/describe.json` and the lazy
505
+ * per-object column endpoint).
506
+ */
507
+ export function createLandingDescribe(
508
+ iface: CatalogInterface,
509
+ worker: WorkerDescribeInfo,
510
+ opts: { cupolaBase?: string } = {},
511
+ ): LandingDescribeProvider {
512
+ return {
513
+ describe: (ctx) => buildDescribeJson(iface, worker, { ...ctx, cupolaBase: opts.cupolaBase }),
514
+ columns: (catalog, schema, table) => buildColumnsJson(iface, catalog, schema, table),
515
+ };
516
+ }
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";