@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.
- package/dist/http/describe-json.d.ts +62 -0
- package/dist/index.core.d.ts +1 -0
- package/dist/index.js +409 -1
- package/dist/index.js.map +5 -4
- package/dist/worker-cf-entry.js +1 -1
- package/dist/worker-cf-entry.js.map +1 -1
- package/package.json +3 -3
- package/src/http/describe-json.ts +639 -0
- package/src/index.core.ts +13 -0
|
@@ -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";
|