@query-farm/vgi 0.26.0 → 0.26.1

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.26.1",
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)",
@@ -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 {
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,