@query-farm/vgi 0.24.1 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@query-farm/vgi",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Query Farm LLC (https://query.farm)",
@@ -90,12 +90,12 @@
90
90
  },
91
91
  "peerDependencies": {
92
92
  "@query-farm/apache-arrow": "^21.1.1",
93
- "@query-farm/vgi-rpc": ">=0.18.1 <0.19.0"
93
+ "@query-farm/vgi-rpc": ">=0.19.0 <0.20.0"
94
94
  },
95
95
  "devDependencies": {
96
96
  "@duckdb/node-api": "^1.5.2-r.1",
97
97
  "@query-farm/apache-arrow": "^21.1.1",
98
- "@query-farm/vgi-rpc": "^0.18.1",
98
+ "@query-farm/vgi-rpc": "^0.19.0",
99
99
  "@types/bun": "latest",
100
100
  "typescript": "^6.0.3"
101
101
  },
@@ -17,6 +17,11 @@
17
17
  // default_value: Binary nullable -- serialized single-row RecordBatch with
18
18
  // the default value under "value", or
19
19
  // null when no default is declared
20
+ // required: Bool nullable -- the caller must supply this option at ATTACH
21
+ // time. Nullable and appended LAST so a peer
22
+ // that predates the column reads the batch by
23
+ // name and simply doesn't see it; absent and
24
+ // explicit-null both mean "not required".
20
25
  //
21
26
  // The extension reads the outer `attach_option_specs: list<binary>` column
22
27
  // from CatalogInfo and validates user-supplied ATTACH options against the
@@ -28,10 +33,15 @@ import {
28
33
  field,
29
34
  utf8,
30
35
  binary,
36
+ bool,
31
37
  batchFromColumns,
38
+ iterRows,
32
39
  serializeBatch,
40
+ deserializeBatch,
33
41
  serializeSchema,
42
+ deserializeSchema,
34
43
  } from "../arrow/index.js";
44
+ import { toUint8Array } from "../util/bytes.js";
35
45
 
36
46
  /**
37
47
  * Declarative spec for a single attach-time option.
@@ -55,6 +65,16 @@ export interface AttachOptionSpec {
55
65
  * the options dict delivered to attach()).
56
66
  */
57
67
  default?: unknown;
68
+ /**
69
+ * The caller must supply this option at ATTACH time. A catalog that cannot
70
+ * be attached without it advertises that fact at discovery, so a client can
71
+ * say so before attempting the attach rather than surfacing a failure that
72
+ * reads like an empty catalog.
73
+ *
74
+ * Mutually exclusive with `default` — an option that falls back to a value
75
+ * is by definition satisfiable without the caller.
76
+ */
77
+ required?: boolean;
58
78
  }
59
79
 
60
80
  const SPEC_SCHEMA = makeSchema([
@@ -62,6 +82,7 @@ const SPEC_SCHEMA = makeSchema([
62
82
  field("description", utf8(), false),
63
83
  field("type", binary(), false),
64
84
  field("default_value", binary(), true),
85
+ field("required", bool(), true),
65
86
  ]);
66
87
 
67
88
  /**
@@ -69,6 +90,17 @@ const SPEC_SCHEMA = makeSchema([
69
90
  * (one IPC-serialized RecordBatch with a single row).
70
91
  */
71
92
  export function serializeAttachOptionSpec(spec: AttachOptionSpec): Uint8Array {
93
+ // Mirrors AttachOptionSpec.__post_init__ on the Python side: an option that
94
+ // falls back to a value is always satisfiable without the caller, so the
95
+ // combination is a declaration bug rather than a runtime condition.
96
+ if (spec.required && spec.default !== undefined && spec.default !== null) {
97
+ throw new Error(
98
+ `Attach option '${spec.name}' is required but also declares a default ` +
99
+ `(${JSON.stringify(spec.default)}); an option with a default is always ` +
100
+ "satisfiable without the caller. Drop one.",
101
+ );
102
+ }
103
+
72
104
  // `type` is encoded as a serialized Arrow Schema with one field named
73
105
  // "value" of the option's DataType. This lets the extension peek at the
74
106
  // logical type without a separate enum.
@@ -91,6 +123,9 @@ export function serializeAttachOptionSpec(spec: AttachOptionSpec): Uint8Array {
91
123
  description: [spec.description],
92
124
  type: [typeBytes],
93
125
  default_value: [defaultBytes],
126
+ // Written explicitly rather than left null so a reader sees `false`, not
127
+ // NULL, for an option that simply isn't required.
128
+ required: [spec.required ?? false],
94
129
  },
95
130
  SPEC_SCHEMA,
96
131
  );
@@ -105,3 +140,96 @@ export function serializeAttachOptionSpecs(
105
140
  ): Uint8Array[] {
106
141
  return Array.from(specs, serializeAttachOptionSpec);
107
142
  }
143
+
144
+ /**
145
+ * Deserialize one AttachOptionSpec from the wire format above.
146
+ *
147
+ * Reads by column name, so a spec written by a peer that predates the
148
+ * `required` column deserializes with `required: false` rather than failing —
149
+ * the same tolerance the Python and C++ readers have.
150
+ */
151
+ export function deserializeAttachOptionSpec(
152
+ bytes: Uint8Array,
153
+ ): AttachOptionSpec {
154
+ const row = [...iterRows(deserializeBatch(bytes))][0] as
155
+ | Record<string, unknown>
156
+ | undefined;
157
+ if (row === undefined) {
158
+ throw new Error("Cannot deserialize AttachOptionSpec from an empty batch");
159
+ }
160
+
161
+ // `type` is a serialized Arrow Schema whose single "value" field carries the
162
+ // option's DataType.
163
+ const typeSchema = deserializeSchema(toUint8Array(row.type as Uint8Array));
164
+ const type = typeSchema.fields[0]!.type;
165
+
166
+ let value: unknown;
167
+ const defaultBytes = row.default_value;
168
+ if (defaultBytes != null) {
169
+ const asBytes = toUint8Array(defaultBytes as Uint8Array);
170
+ if (asBytes.length > 0) {
171
+ const defaultRow = [...iterRows(deserializeBatch(asBytes))][0] as
172
+ | Record<string, unknown>
173
+ | undefined;
174
+ value = defaultRow?.value;
175
+ }
176
+ }
177
+
178
+ return {
179
+ name: String(row.name),
180
+ description: String(row.description ?? ""),
181
+ type,
182
+ ...(value === undefined || value === null ? {} : { default: value }),
183
+ required: row.required === true,
184
+ };
185
+ }
186
+
187
+ /** Deserialize many specs — the shape `CatalogInfo.attach_option_specs` holds. */
188
+ export function deserializeAttachOptionSpecs(
189
+ specs: Iterable<Uint8Array>,
190
+ ): AttachOptionSpec[] {
191
+ return Array.from(specs, deserializeAttachOptionSpec);
192
+ }
193
+
194
+ /**
195
+ * Raised when `attach` omits options declared `required`.
196
+ *
197
+ * Carries the option names in `missing` so a caller can act on them without
198
+ * parsing the message. Mirrors `MissingAttachOptionsError` on the Python side,
199
+ * message included — the extension's integration suite matches on its text.
200
+ */
201
+ export class MissingAttachOptionsError extends Error {
202
+ readonly missing: string[];
203
+
204
+ constructor(catalogName: string, missing: string[]) {
205
+ const joined = missing.map((name) => `'${name}'`).join(", ");
206
+ super(
207
+ `Catalog '${catalogName}' cannot be attached without the required ` +
208
+ `option${missing.length > 1 ? "s" : ""} ${joined}.`,
209
+ );
210
+ this.name = "MissingAttachOptionsError";
211
+ this.missing = missing;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Throw if any `required` spec has no corresponding entry in `options`.
217
+ *
218
+ * Option names are matched case-insensitively, mirroring DuckDB's handling of
219
+ * ATTACH option keys.
220
+ *
221
+ * @throws {MissingAttachOptionsError} If a required option was not supplied.
222
+ */
223
+ export function validateRequiredAttachOptions(
224
+ catalogName: string,
225
+ specs: Iterable<AttachOptionSpec>,
226
+ options: Record<string, unknown>,
227
+ ): void {
228
+ const supplied = new Set(Object.keys(options).map((key) => key.toLowerCase()));
229
+ const missing = Array.from(specs)
230
+ .filter((spec) => spec.required && !supplied.has(spec.name.toLowerCase()))
231
+ .map((spec) => spec.name);
232
+ if (missing.length > 0) {
233
+ throw new MissingAttachOptionsError(catalogName, missing);
234
+ }
235
+ }
@@ -26,6 +26,7 @@ import type { FunctionRegistry } from "../functions/registry.js";
26
26
  import { argumentSpecsToSchema, macroArgumentsSchema } from "../arguments/argument-spec.js";
27
27
  import { serializeSchema, serializeBatch, emptyBatch, batchFromColumns } from "../util/arrow/index.js";
28
28
  import { resolveMetadata } from "../metadata/resolve.js";
29
+ import { type AttachOptionSpec, validateRequiredAttachOptions } from "./attach-option.js";
29
30
  import { FunctionStability, NullHandling, OrderPreservation, DEFAULT_MAX_WORKERS } from "../types.js";
30
31
  import { Arguments } from "../arguments/arguments.js";
31
32
 
@@ -58,6 +59,19 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
58
59
  return [this._descriptor.name];
59
60
  }
60
61
 
62
+ /**
63
+ * Attach-time options this catalog declares, for the named catalog.
64
+ *
65
+ * Empty by default — the declarative descriptor path carries no attach
66
+ * options. A subclass that advertises specs in `catalogsInfo()` should
67
+ * return the same specs here so `attach()` enforces the `required` ones it
68
+ * advertised. Takes the catalog name because one interface may serve
69
+ * several catalogs with different option sets.
70
+ */
71
+ attachOptionSpecs(_name: string): AttachOptionSpec[] {
72
+ return [];
73
+ }
74
+
61
75
  // Advertise the catalog's discovery record. The declarative path carries no
62
76
  // version metadata or attach option specs, but it does surface the optional
63
77
  // `source_url` from the descriptor so it flows through `catalog_catalogs`
@@ -82,6 +96,12 @@ export class ReadOnlyCatalogInterface extends CatalogInterface {
82
96
  if (!this.catalogs().includes(name)) {
83
97
  throw new Error(`No worker handles catalog '${name}'`);
84
98
  }
99
+ // Refuse an attach that omits a declared-required option, so the caller
100
+ // gets the option name rather than a catalog that looks mysteriously
101
+ // empty. A subclass that overrides `attach` outright bypasses this and
102
+ // should call `validateRequiredAttachOptions` itself — mirrors
103
+ // CatalogInterface.catalog_attach on the Python side.
104
+ validateRequiredAttachOptions(name, this.attachOptionSpecs(name), options ?? {});
85
105
 
86
106
  const attachOpaqueData = new Uint8Array(16);
87
107
  crypto.getRandomValues(attachOpaqueData);
@@ -103,6 +103,17 @@ export {
103
103
  deserializeBatch,
104
104
  } from "./util/arrow/index.js";
105
105
 
106
+ // Arrow type identification — lets a consumer render a DataType for display
107
+ // without reaching into the backend implementation.
108
+ export { TypeId, typeSignature } from "./arrow/predicates.js";
109
+
110
+ // Attach options — the page-facing decoder for CatalogInfo.attach_option_specs.
111
+ export {
112
+ type AttachOptionSpec,
113
+ deserializeAttachOptionSpec,
114
+ deserializeAttachOptionSpecs,
115
+ } from "./catalog/attach-option.js";
116
+
106
117
  // Byte utilities
107
118
  export { toUint8Array } from "./util/bytes.js";
108
119
 
package/src/http/fetch.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { createHttpHandler, unpackStateToken, type Protocol } from "@query-farm/vgi-rpc";
14
14
  import { arrowStateSerializer } from "../protocol/state-serializer.js";
15
15
  import { buildVgiProtocol, type ProtocolConfig } from "../protocol/dispatch.js";
16
- import type { LandingDescribeProvider } from "./describe-json.js";
16
+ import type { LandingInfo } from "@query-farm/vgi-rpc";
17
17
 
18
18
  export interface VgiFetchOptions {
19
19
  /** Wire-protocol config (registry + catalogInterface). */
@@ -36,10 +36,10 @@ export interface VgiFetchOptions {
36
36
  /** Public source-repository URL, surfaced on the landing page. */
37
37
  repositoryUrl?: string;
38
38
  /** Enables the standardized VGI landing surface: `GET /` serves the shared
39
- * landing.html, `GET /describe.json` the catalog contract, and
40
- * `GET /describe/{catalog}/{schema}/{table}.json` lazy per-object columns.
41
- * Build one with `createLandingDescribe(catalogInterface, workerInfo)`. */
42
- landingDescribe?: LandingDescribeProvider;
39
+ * landing.html plus a JSON status document carrying this identity, and
40
+ * `GET /vgi-client.js` serves the browser client build the page reads the
41
+ * catalog with. */
42
+ landingInfo?: LandingInfo;
43
43
  }
44
44
 
45
45
  /**
@@ -74,7 +74,7 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
74
74
  stateSerializer: arrowStateSerializer,
75
75
  corsOrigins: opts.corsOrigins,
76
76
  repositoryUrl: opts.repositoryUrl,
77
- landingDescribe: opts.landingDescribe,
77
+ landingInfo: opts.landingInfo,
78
78
  });
79
79
  return async (req: Request) => handler(req);
80
80
  }
package/src/index.core.ts CHANGED
@@ -153,25 +153,18 @@ export { arrowStateSerializer, EXCHANGE_STATE_SCHEMA, serializeUserState, deseri
153
153
  // Protocol assembly (for embedding the worker dispatcher in a custom HTTP server).
154
154
  export { buildVgiProtocol, type ProtocolConfig } from "./protocol/dispatch.js";
155
155
 
156
- // VGI HTTP landing surface producer (describe.json contract). Pass
157
- // `createLandingDescribe(catalogInterface, workerInfo)` to `createHttpHandler`'s
158
- // `landingDescribe` option. See docs/http-landing-contract.md.
159
- export {
160
- buildColumnsJson,
161
- buildDescribeJson,
162
- createLandingDescribe,
163
- CUPOLA_BASE,
164
- LANDING_SCHEMA_VERSION,
165
- type LandingDescribeProvider,
166
- type WorkerDescribeInfo,
167
- } from "./http/describe-json.js";
156
+ // VGI HTTP landing surface. The shared landing.html reads catalog metadata by
157
+ // speaking the protocol through the client bundle the worker serves beside it,
158
+ // so a worker supplies only its own identity via `createHttpHandler`'s
159
+ // `landingInfo` option.
160
+ export type { LandingInfo } from "@query-farm/vgi-rpc";
168
161
 
169
162
  // Wire schemas — needed by workers that synthesize TableInfo.bind_result /
170
163
  // TableInfo.scan_function bytes to short-circuit per-scan RPCs.
171
164
  export { BindResultSchema, ScanFunctionResultSchema } from "./generated/vgi-protocol-schemas.js";
172
165
 
173
166
  // Attach-option specs (for catalogs that advertise typed ATTACH options).
174
- export { type AttachOptionSpec, serializeAttachOptionSpec, serializeAttachOptionSpecs } from "./catalog/attach-option.js";
167
+ export { type AttachOptionSpec, serializeAttachOptionSpec, serializeAttachOptionSpecs, deserializeAttachOptionSpec, deserializeAttachOptionSpecs } from "./catalog/attach-option.js";
175
168
 
176
169
  // Column statistics (for inlining on TableInfo.column_statistics).
177
170
  export { type ColumnStatistics, serializeColumnStatistics } from "./util/statistics.js";
@@ -23,7 +23,6 @@
23
23
 
24
24
  import type { CatalogInterface } from "./catalog/interface.js";
25
25
  import type { FunctionRegistry } from "./functions/registry.js";
26
- import { createLandingDescribe } from "./http/describe-json.js";
27
26
  import { createVgiFetch } from "./http/fetch.js";
28
27
 
29
28
  /** Environment variables `serveVgiWorker` reads. Injectable for testing. */
@@ -192,11 +191,7 @@ export function createVgiWorkerFetch(
192
191
  serverId: opts.serverId ?? opts.name,
193
192
  corsOrigins,
194
193
  repositoryUrl: opts.repositoryUrl,
195
- landingDescribe: createLandingDescribe(opts.catalogInterface, {
196
- name: opts.name,
197
- doc: opts.doc,
198
- version: opts.version,
199
- }),
194
+ landingInfo: { name: opts.name, doc: opts.doc, version: opts.version },
200
195
  });
201
196
  }
202
197
 
@@ -225,7 +220,7 @@ export function serveVgiWorker(opts: ServeVgiWorkerOptions): VgiHttpServer {
225
220
  const base = `http://localhost:${server.port}`;
226
221
  console.log(`${opts.name} VGI HTTP worker listening on ${base}`);
227
222
  console.log(` landing page ${base}/`);
228
- console.log(` describe.json ${base}/describe.json`);
223
+ console.log(` client bundle ${base}/vgi-client.js`);
229
224
  console.log(` health ${base}/health`);
230
225
  console.log(` attach ATTACH '${opts.name}' AS ${opts.name} (TYPE vgi, LOCATION '${base}');`);
231
226
  }
@@ -53,10 +53,10 @@ export type {
53
53
 
54
54
  export type { ProtocolConfig } from "./protocol/dispatch.js";
55
55
 
56
- // Landing surface. CF workers pass the provider as `landingDescribe` so
57
- // `GET /` serves the standardized VGI landing page for the worker's catalog.
58
- export { createLandingDescribe } from "./http/describe-json.js";
59
- export type { LandingDescribeProvider, WorkerDescribeInfo } from "./http/describe-json.js";
56
+ // Landing surface. CF workers pass their identity as `landingInfo` so `GET /`
57
+ // serves the standardized VGI landing page, and `GET /vgi-client.js` the
58
+ // browser client build it reads the catalog with.
59
+ export type { LandingInfo } from "@query-farm/vgi-rpc";
60
60
 
61
61
  // Backend-agnostic Arrow facade. Same source resolves to arrow-js on
62
62
  // Node/Bun and flechette under workerd/worker/browser via the package's
@@ -1,62 +0,0 @@
1
- import type { CatalogInterface } from "../catalog/interface.js";
2
- export declare const LANDING_SCHEMA_VERSION = 1;
3
- export declare const CUPOLA_BASE = "https://cupola.query-farm.services";
4
- /**
5
- * Producer for the VGI landing surface, passed to `createHttpHandler`'s
6
- * `landingDescribe` option. Declared locally (structurally identical to
7
- * `@query-farm/vgi-rpc`'s `LandingDescribeProvider`) so this package does not
8
- * hard-couple to a specific vgi-rpc version.
9
- */
10
- export interface LandingDescribeProvider {
11
- describe(ctx: {
12
- serverId: string;
13
- oauth: boolean;
14
- }): unknown | Promise<unknown>;
15
- columns(catalog: string, schema: string, table: string): unknown | null | Promise<unknown | null>;
16
- }
17
- /** Identity of the worker software surfaced in `describe.json` → `worker`. */
18
- export interface WorkerDescribeInfo {
19
- /** Worker name (e.g. class or catalog name). */
20
- name: string;
21
- /** One-line worker description. */
22
- doc?: string;
23
- /** Worker software version. */
24
- version?: string;
25
- }
26
- interface ColumnJson {
27
- name: string;
28
- type: string;
29
- comment?: string;
30
- }
31
- /**
32
- * Build the full `describe.json` document for a worker's catalog interface.
33
- *
34
- * `oauth` and `serverId` are supplied by the HTTP server (they depend on runtime
35
- * config, not the catalog).
36
- */
37
- export declare function buildDescribeJson(iface: CatalogInterface, worker: WorkerDescribeInfo, opts?: {
38
- oauth?: boolean;
39
- serverId?: string;
40
- cupolaBase?: string;
41
- }): Promise<Record<string, unknown>>;
42
- /**
43
- * Build the lazy per-object column payload for one table or view.
44
- *
45
- * Returns `{ columns: [{ name, type, comment? }] }`, or `null` when the object
46
- * can't be found. Tables deserialize their Arrow column schema; views expose
47
- * their declared column comments (types are only known after binding the SQL,
48
- * which the worker does not do here).
49
- */
50
- export declare function buildColumnsJson(iface: CatalogInterface, catalog: string, schema: string, table: string): Promise<{
51
- columns: ColumnJson[];
52
- } | null>;
53
- /**
54
- * Build a {@link LandingDescribeProvider} to pass to `createHttpHandler` from
55
- * `@query-farm/vgi-rpc`. Wires the worker's catalog interface into the
56
- * standardized landing surface (`GET {prefix}/describe.json` and the lazy
57
- * per-object column endpoint).
58
- */
59
- export declare function createLandingDescribe(iface: CatalogInterface, worker: WorkerDescribeInfo, opts?: {
60
- cupolaBase?: string;
61
- }): LandingDescribeProvider;
62
- export {};