@query-farm/vgi 0.26.0 → 0.27.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.26.0",
3
+ "version": "0.27.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)",
package/src/http/fetch.ts CHANGED
@@ -29,10 +29,18 @@ export interface VgiFetchOptions {
29
29
  prefix?: string;
30
30
  /** Server ID for state-token attribution (default "vgi-cf"). */
31
31
  serverId?: string;
32
- /** CORS allowed origins. When set, CORS headers are added to all responses,
33
- * and the preflight `OPTIONS` that browser clients (e.g. the hosted Cupola
34
- * UI) send before `__describe__` will succeed. Omit to disable CORS. */
35
- corsOrigins?: string;
32
+ /** CORS allowed origins, added to all responses so the preflight `OPTIONS`
33
+ * that browser clients send before `__describe__` succeeds.
34
+ *
35
+ * **Defaults to `"*"`.** A VGI worker exists to be attached and explored, and
36
+ * the hosted Cupola UI reaches it from another origin, so cross-origin is the
37
+ * normal case rather than the exception — and a worker that omits this gets
38
+ * no CORS headers at all, which fails only in a browser and only for someone
39
+ * else's page, i.e. nowhere its author is looking. `serveVgiWorker` has
40
+ * always defaulted this way; this matches it so both entries agree.
41
+ *
42
+ * Pass `null` to disable CORS. */
43
+ corsOrigins?: string | null;
36
44
  /** Public source-repository URL, surfaced on the landing page. */
37
45
  repositoryUrl?: string;
38
46
  /** Worker identity for the standardized VGI landing surface: `GET /` serves
@@ -92,7 +100,8 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
92
100
  tokenKey: opts.signingKey,
93
101
  tokenTtl,
94
102
  stateSerializer: arrowStateSerializer,
95
- corsOrigins: opts.corsOrigins,
103
+ // `null` is the explicit opt-out; `undefined` (omitted) means "*".
104
+ corsOrigins: opts.corsOrigins === null ? undefined : (opts.corsOrigins ?? "*"),
96
105
  repositoryUrl: opts.repositoryUrl,
97
106
  // The landing surface is ours, contributed into vgi-rpc's routing rather
98
107
  // than built there. `enableLandingPage: false` drops vgi-rpc's generic
@@ -56,6 +56,62 @@ export interface LandingInfo {
56
56
  /** Default Cupola deployment the page's "Explore" links point at. */
57
57
  export const DEFAULT_CUPOLA_BASE = "https://cupola.query-farm.services";
58
58
 
59
+ // Caching. Both assets are immutable for a given build but live at URLs that
60
+ // never change, which is the combination that goes wrong quietly: the page used
61
+ // to be served with no Cache-Control and no validator at all, so browsers
62
+ // applied heuristic caching with nothing to revalidate against and could hold a
63
+ // stale page indefinitely — every landing-page fix needed a shift-reload to
64
+ // see. The bundle was worse in a different way: `max-age=3600` with no
65
+ // validator meant a full hour where a client could not learn that a release had
66
+ // happened, which is how a bundle that could not decode workerd's compressed
67
+ // responses stayed live in a browser long after it was fixed.
68
+ //
69
+ // `no-cache` does not mean "do not store" — it means "store, but revalidate
70
+ // before reuse". With a strong ETag a repeat visit is a conditional GET that
71
+ // answers 304 with no body, so the bandwidth is the same as a long TTL and the
72
+ // staleness window closes to zero. `public` is kept so shared caches (the
73
+ // Cloudflare edge in front of a Workers deployment) can hold and revalidate it
74
+ // too, rather than each viewer paying the full transfer.
75
+ const REVALIDATE = "public, no-cache";
76
+
77
+ /**
78
+ * FNV-1a over the asset bytes, as a strong ETag.
79
+ *
80
+ * Computed lazily and memoised rather than at module load: most requests to a
81
+ * worker are RPC and never touch these routes, and hashing ~700 KB on every
82
+ * isolate start would tax cold starts for a page nobody may open. Once per
83
+ * isolate on first use is the right trade.
84
+ */
85
+ function etagFor(bytes: Uint8Array): string {
86
+ let hash = 0x811c9dc5;
87
+ for (let i = 0; i < bytes.length; i++) {
88
+ hash ^= bytes[i]!;
89
+ hash = Math.imul(hash, 0x01000193) >>> 0;
90
+ }
91
+ // Length is folded in so two builds that collide on the hash still differ.
92
+ return `"${hash.toString(16)}-${bytes.length.toString(16)}"`;
93
+ }
94
+
95
+ let landingEtag: string | null = null;
96
+ let bundleEtag: string | null = null;
97
+
98
+ /** 304 when the client already holds this exact body, else null. */
99
+ function notModified(request: Request, etag: string, extra?: HeadersInit): Response | null {
100
+ const ifNoneMatch = request.headers.get("If-None-Match");
101
+ if (!ifNoneMatch) return null;
102
+ // A conditional GET may list several validators, and a cache is allowed to
103
+ // weaken ours to `W/"…"` on the way back.
104
+ const matches = ifNoneMatch
105
+ .split(",")
106
+ .map((c) => c.trim().replace(/^W\//, ""))
107
+ .includes(etag);
108
+ if (!matches) return null;
109
+ const headers = new Headers(extra);
110
+ headers.set("ETag", etag);
111
+ headers.set("Cache-Control", REVALIDATE);
112
+ return new Response(null, { status: 304, headers });
113
+ }
114
+
59
115
  /**
60
116
  * Build the VGI landing surface as a route contributed to vgi-rpc's HTTP
61
117
  * handler.
@@ -98,7 +154,17 @@ export function createLandingRoutes(info: LandingInfo): ExtraRouteHandler {
98
154
  ctx.addCorsHeaders(headers);
99
155
  return new Response(body, { status: 200, headers });
100
156
  }
101
- const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
157
+ landingEtag ??= etagFor(LANDING_HTML_BYTES);
158
+ const conditional = notModified(request, landingEtag);
159
+ if (conditional) {
160
+ ctx.addCorsHeaders(conditional.headers);
161
+ return conditional;
162
+ }
163
+ const headers = new Headers({
164
+ "Content-Type": "text/html; charset=utf-8",
165
+ "Cache-Control": REVALIDATE,
166
+ ETag: landingEtag,
167
+ });
102
168
  ctx.addCorsHeaders(headers);
103
169
  return new Response(LANDING_HTML_BYTES as unknown as BodyInit, { status: 200, headers });
104
170
  }
@@ -109,9 +175,16 @@ export function createLandingRoutes(info: LandingInfo): ExtraRouteHandler {
109
175
  // CDN dependency would break air-gapped deployments that today need nothing
110
176
  // but the worker.
111
177
  if (path === `${prefix}/vgi-client.js`) {
178
+ bundleEtag ??= etagFor(CLIENT_BUNDLE_BYTES);
179
+ const conditional = notModified(request, bundleEtag);
180
+ if (conditional) {
181
+ ctx.addCorsHeaders(conditional.headers);
182
+ return conditional;
183
+ }
112
184
  const headers = new Headers({
113
185
  "Content-Type": "text/javascript; charset=utf-8",
114
- "Cache-Control": "public, max-age=3600",
186
+ "Cache-Control": REVALIDATE,
187
+ ETag: bundleEtag,
115
188
  });
116
189
  ctx.addCorsHeaders(headers);
117
190
  return new Response(CLIENT_BUNDLE_BYTES as unknown as BodyInit, { status: 200, headers });
@@ -150,6 +150,8 @@ export function deserializeBindRequest(
150
150
  functionType = FunctionType.SCALAR;
151
151
  } else if (ftStr === "table" || ftStr === "TABLE") {
152
152
  functionType = FunctionType.TABLE;
153
+ } else if (ftStr === "table_buffering" || ftStr === "TABLE_BUFFERING") {
154
+ functionType = FunctionType.TABLE_BUFFERING;
153
155
  } else if (ftStr === "aggregate" || ftStr === "AGGREGATE") {
154
156
  functionType = FunctionType.AGGREGATE;
155
157
  } else {
@@ -179,9 +179,11 @@ export function createVgiWorkerFetch(
179
179
  const env = opts.env ?? (process.env as ServeEnv);
180
180
  const signingKey = resolveSigningKey(opts.signingKey, env);
181
181
  const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
182
- // `null` disables CORS; `undefined` falls through to the env var, then "*".
183
- const corsOrigins =
184
- opts.corsOrigins === null ? undefined : (opts.corsOrigins ?? env.CORS_ORIGINS ?? "*");
182
+ // `null` disables CORS and must stay `null` through to createVgiFetch, which
183
+ // now reads a bare `undefined` as "default to *" — passing `undefined` here to
184
+ // mean "off" would silently re-enable it. `undefined` falls through to the env
185
+ // var and then to that same default.
186
+ const corsOrigins = opts.corsOrigins === null ? null : (opts.corsOrigins ?? env.CORS_ORIGINS);
185
187
 
186
188
  return createVgiFetch({
187
189
  protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
package/src/types.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  export enum FunctionType {
5
5
  SCALAR = "scalar",
6
6
  TABLE = "table",
7
+ // Sink+source function bound through defineTableBufferingFunction. Mirrors
8
+ // vgi-python's FunctionType.TABLE_BUFFERING; without it a buffering bind fell
9
+ // through deserializeBindRequest's normalization to a bare `as` cast.
10
+ TABLE_BUFFERING = "table_buffering",
7
11
  AGGREGATE = "aggregate",
8
12
  }
9
13
 
@@ -36,6 +36,11 @@ export {
36
36
  // The blended config has no `argConstraints` of its own, so a worker that
37
37
  // wants ge/le/choices/pattern on a row-transform's args applies them itself.
38
38
  constraintSpecFields,
39
+ // Result-cache control (`vgi.cache.*`). Advertised as metadata on the FIRST
40
+ // emitted batch, so it composes with `parentRowsMetadata` via the `extra`
41
+ // argument. Belongs on this facade for the same reason the blended helpers do:
42
+ // a workerd worker cannot reach it through the package root.
43
+ cacheControlMetadata,
39
44
  FunctionRegistry,
40
45
  ReadOnlyCatalogInterface,
41
46
  CompositeCatalogInterface,
@@ -50,6 +55,8 @@ export {
50
55
  } from "./index.core.js";
51
56
 
52
57
  export type {
58
+ CacheControl,
59
+ CacheScope,
53
60
  AttachOptionSpec,
54
61
  ColumnStatistics,
55
62
  CatalogAttachResult,