@query-farm/vgi 0.14.0 → 0.17.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/README.md +53 -0
- package/dist/cache-control.d.ts +90 -0
- package/dist/client-entry.js +3862 -1
- package/dist/client-entry.js.map +34 -3
- package/dist/functions/table.d.ts +12 -0
- package/dist/http/fetch.d.ts +35 -0
- package/dist/index.core.d.ts +1 -0
- package/dist/index.js +217 -2
- package/dist/index.js.map +9 -7
- package/dist/serve-entry.d.ts +84 -0
- package/dist/serve-entry.js +6428 -0
- package/dist/serve-entry.js.map +62 -0
- package/dist/worker-cf-entry.d.ts +4 -22
- package/dist/worker-cf-entry.js +451 -24
- package/dist/worker-cf-entry.js.map +9 -6
- package/package.json +18 -6
- package/src/cache-control.ts +133 -0
- package/src/functions/table.ts +25 -1
- package/src/http/describe-json.ts +6 -1
- package/src/http/fetch.ts +80 -0
- package/src/index.core.ts +21 -0
- package/src/serve-entry.ts +233 -0
- package/src/worker-cf-entry.ts +7 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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)",
|
|
@@ -29,7 +29,13 @@
|
|
|
29
29
|
"node": ">=22.15"
|
|
30
30
|
},
|
|
31
31
|
"type": "module",
|
|
32
|
-
"sideEffects": false,
|
|
32
|
+
"//sideEffects": "Entry modules are listed so the bundler cannot tree-shake them away. Under a bare `sideEffects: false`, an entry consisting only of re-exports builds to a dangling export list with no imports and no definitions \u2014 invalid ESM that throws on first import. Modules not listed stay side-effect-free, so consumers still tree-shake normally.",
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"./src/index.ts",
|
|
35
|
+
"./src/client-entry.ts",
|
|
36
|
+
"./src/worker-cf-entry.ts",
|
|
37
|
+
"./src/serve-entry.ts"
|
|
38
|
+
],
|
|
33
39
|
"main": "dist/index.js",
|
|
34
40
|
"types": "dist/index.d.ts",
|
|
35
41
|
"exports": {
|
|
@@ -53,6 +59,11 @@
|
|
|
53
59
|
"browser": "./src/worker-cf-entry.ts",
|
|
54
60
|
"bun": "./src/worker-cf-entry.ts",
|
|
55
61
|
"import": "./dist/worker-cf-entry.js"
|
|
62
|
+
},
|
|
63
|
+
"./serve": {
|
|
64
|
+
"types": "./dist/serve-entry.d.ts",
|
|
65
|
+
"bun": "./src/serve-entry.ts",
|
|
66
|
+
"import": "./dist/serve-entry.js"
|
|
56
67
|
}
|
|
57
68
|
},
|
|
58
69
|
"files": [
|
|
@@ -75,20 +86,21 @@
|
|
|
75
86
|
},
|
|
76
87
|
"peerDependencies": {
|
|
77
88
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
78
|
-
"@query-farm/vgi-rpc": "^0.
|
|
89
|
+
"@query-farm/vgi-rpc": "^0.13.0"
|
|
79
90
|
},
|
|
80
91
|
"devDependencies": {
|
|
81
92
|
"@duckdb/node-api": "^1.5.2-r.1",
|
|
82
93
|
"@query-farm/apache-arrow": "^21.1.1",
|
|
83
|
-
"@query-farm/vgi-rpc": "^0.
|
|
94
|
+
"@query-farm/vgi-rpc": "^0.13.0",
|
|
84
95
|
"@types/bun": "latest",
|
|
85
96
|
"typescript": "^6.0.3"
|
|
86
97
|
},
|
|
87
98
|
"scripts": {
|
|
88
|
-
"build": "bun run build:types && bun run build:js",
|
|
99
|
+
"build": "bun run build:types && bun run build:js && bun run check:bundles",
|
|
100
|
+
"check:bundles": "bun run scripts/check-bundles.ts",
|
|
89
101
|
"prepack": "bun run build",
|
|
90
102
|
"build:types": "bunx tsc -p tsconfig.build.json",
|
|
91
|
-
"build:js": "bun build ./src/index.ts --outdir dist --target node --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc && bun build ./src/client-entry.ts --outdir dist --target browser --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc && bun build ./src/worker-cf-entry.ts --outdir dist --target node --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc",
|
|
103
|
+
"build:js": "bun build ./src/index.ts --outdir dist --target node --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc && bun build ./src/client-entry.ts --outdir dist --target browser --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc && bun build ./src/worker-cf-entry.ts --outdir dist --target node --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc && bun build ./src/serve-entry.ts --outdir dist --target bun --format esm --sourcemap=external --external @query-farm/apache-arrow --external @query-farm/vgi-rpc",
|
|
92
104
|
"postinstall": "node scripts/postinstall.cjs",
|
|
93
105
|
"build:browser-test": "bun build examples/browser/main.ts --outdir examples/browser/dist --target browser --format esm --sourcemap=external",
|
|
94
106
|
"test": "bun test"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result-cache control metadata (`vgi.cache.*`).
|
|
5
|
+
*
|
|
6
|
+
* A table function can advertise that its result is cacheable by the client
|
|
7
|
+
* (the DuckDB extension) by attaching `vgi.cache.*` metadata to the **first**
|
|
8
|
+
* data batch it emits. The vocabulary mirrors HTTP caching (RFC 9111/9110): a
|
|
9
|
+
* freshness lifetime (`ttl` / `expires`), a reuse `scope`, validators
|
|
10
|
+
* (ETag / Last-Modified) for conditional revalidation, and stale-serving grace
|
|
11
|
+
* windows.
|
|
12
|
+
*
|
|
13
|
+
* The key strings are the single source of truth shared with the C++ extension
|
|
14
|
+
* (which reads them by string). {@link cacheControlMetadata} renders a
|
|
15
|
+
* {@link CacheControl} to the `Map<string, string>` of `vgi.cache.*` keys that
|
|
16
|
+
* rides on batch metadata:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* out.emit(firstBatch, cacheControlMetadata({ ttl: 300 }));
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Pass `extra` to merge in metadata a function already emits per batch (for
|
|
23
|
+
* example `vgi_batch_index`); the rendered cache keys win on collision.
|
|
24
|
+
*
|
|
25
|
+
* Booleans render as `"1"` and are omitted when false; timestamps are RFC 3339
|
|
26
|
+
* UTC strings; durations are whole seconds.
|
|
27
|
+
*
|
|
28
|
+
* Ports `vgi/cache_control.py` from vgi-python.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// --- Response-side metadata keys (worker -> client) -------------------------
|
|
32
|
+
// Defined once here; the C++ extension reads these exact strings.
|
|
33
|
+
|
|
34
|
+
export const CACHE_TTL_KEY = "vgi.cache.ttl";
|
|
35
|
+
export const CACHE_EXPIRES_KEY = "vgi.cache.expires";
|
|
36
|
+
export const CACHE_NO_STORE_KEY = "vgi.cache.no_store";
|
|
37
|
+
export const CACHE_SCOPE_KEY = "vgi.cache.scope";
|
|
38
|
+
export const CACHE_ETAG_KEY = "vgi.cache.etag";
|
|
39
|
+
export const CACHE_LAST_MODIFIED_KEY = "vgi.cache.last_modified";
|
|
40
|
+
export const CACHE_REVALIDATABLE_KEY = "vgi.cache.revalidatable";
|
|
41
|
+
export const CACHE_STALE_WHILE_REVALIDATE_KEY = "vgi.cache.stale_while_revalidate";
|
|
42
|
+
export const CACHE_STALE_IF_ERROR_KEY = "vgi.cache.stale_if_error";
|
|
43
|
+
export const CACHE_NOT_MODIFIED_KEY = "vgi.cache.not_modified";
|
|
44
|
+
|
|
45
|
+
// --- Request-side metadata keys (client -> worker) --------------------------
|
|
46
|
+
// Ride on the first producer tick; surfaced as `TableProcessParams.ifNoneMatch`
|
|
47
|
+
// / `.ifModifiedSince`.
|
|
48
|
+
|
|
49
|
+
export const CACHE_IF_NONE_MATCH_KEY = "vgi.cache.if_none_match";
|
|
50
|
+
export const CACHE_IF_MODIFIED_SINCE_KEY = "vgi.cache.if_modified_since";
|
|
51
|
+
|
|
52
|
+
// --- Reuse-scope values -----------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export const CACHE_SCOPE_CATALOG = "catalog";
|
|
55
|
+
export const CACHE_SCOPE_TRANSACTION = "transaction";
|
|
56
|
+
|
|
57
|
+
/** Reuse scope for a cacheable result. */
|
|
58
|
+
export type CacheScope = typeof CACHE_SCOPE_CATALOG | typeof CACHE_SCOPE_TRANSACTION;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cacheability advertised by a table function on its first result batch.
|
|
62
|
+
*
|
|
63
|
+
* Presence of `ttl` **or** `expires` is what makes a result cacheable;
|
|
64
|
+
* `noStore` overrides any freshness key.
|
|
65
|
+
*/
|
|
66
|
+
export interface CacheControl {
|
|
67
|
+
/** Freshness lifetime in whole seconds, relative to full-result receipt
|
|
68
|
+
* (skew-immune; wins over `expires`). */
|
|
69
|
+
ttl?: number;
|
|
70
|
+
/** Absolute RFC 3339 UTC deadline. Lifetime is `expires - now` at receipt. */
|
|
71
|
+
expires?: string;
|
|
72
|
+
/** Reuse scope — `"catalog"` (default; reusable across transactions within
|
|
73
|
+
* the calling catalog identity) or `"transaction"` (reused only within the
|
|
74
|
+
* same transaction). */
|
|
75
|
+
scope?: CacheScope;
|
|
76
|
+
/** Explicit "never cache"; overrides any freshness key. */
|
|
77
|
+
noStore?: boolean;
|
|
78
|
+
/** Strong validator (opaque quoted string) for conditional revalidation. */
|
|
79
|
+
etag?: string;
|
|
80
|
+
/** Weaker RFC 3339 UTC validator; fallback when no ETag. */
|
|
81
|
+
lastModified?: string;
|
|
82
|
+
/** The worker can check freshness cheaply without recomputing; gates whether
|
|
83
|
+
* the client ever sends a conditional request. */
|
|
84
|
+
revalidatable?: boolean;
|
|
85
|
+
/** Grace window (seconds) to serve stale immediately while revalidating in
|
|
86
|
+
* the background. */
|
|
87
|
+
staleWhileRevalidate?: number;
|
|
88
|
+
/** Grace window (seconds) to serve stale if a revalidation RPC fails. */
|
|
89
|
+
staleIfError?: number;
|
|
90
|
+
/** 304-equivalent — set on a 0-row batch in reply to a conditional request to
|
|
91
|
+
* assert the client's stored payload is still fresh (the client reuses it
|
|
92
|
+
* instead of re-streaming). */
|
|
93
|
+
notModified?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const VALID_SCOPES: readonly string[] = [CACHE_SCOPE_CATALOG, CACHE_SCOPE_TRANSACTION];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render a {@link CacheControl} to the `vgi.cache.*` batch-metadata map.
|
|
100
|
+
*
|
|
101
|
+
* `scope` is always emitted so the client never has to infer the default.
|
|
102
|
+
* Unset optional fields are omitted; false booleans are omitted. Entries from
|
|
103
|
+
* `extra` are written first, so a rendered cache key wins on collision.
|
|
104
|
+
*
|
|
105
|
+
* Throws when `scope` is unrecognized or a duration is negative — a silently
|
|
106
|
+
* ignored advertisement is far harder to debug than a stack trace at the
|
|
107
|
+
* offending `emit()`.
|
|
108
|
+
*/
|
|
109
|
+
export function cacheControlMetadata(cc: CacheControl, extra?: Map<string, string>): Map<string, string> {
|
|
110
|
+
const scope = cc.scope ?? CACHE_SCOPE_CATALOG;
|
|
111
|
+
if (!VALID_SCOPES.includes(scope)) {
|
|
112
|
+
throw new Error(`CacheControl.scope must be one of ${VALID_SCOPES.join(", ")}, got ${JSON.stringify(scope)}`);
|
|
113
|
+
}
|
|
114
|
+
for (const name of ["ttl", "staleWhileRevalidate", "staleIfError"] as const) {
|
|
115
|
+
const value = cc[name];
|
|
116
|
+
if (value !== undefined && value < 0) {
|
|
117
|
+
throw new Error(`CacheControl.${name} must be >= 0, got ${value}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const md = new Map<string, string>(extra ?? []);
|
|
122
|
+
if (cc.ttl !== undefined) md.set(CACHE_TTL_KEY, String(cc.ttl));
|
|
123
|
+
if (cc.expires !== undefined) md.set(CACHE_EXPIRES_KEY, cc.expires);
|
|
124
|
+
if (cc.noStore) md.set(CACHE_NO_STORE_KEY, "1");
|
|
125
|
+
md.set(CACHE_SCOPE_KEY, scope);
|
|
126
|
+
if (cc.etag !== undefined) md.set(CACHE_ETAG_KEY, cc.etag);
|
|
127
|
+
if (cc.lastModified !== undefined) md.set(CACHE_LAST_MODIFIED_KEY, cc.lastModified);
|
|
128
|
+
if (cc.revalidatable) md.set(CACHE_REVALIDATABLE_KEY, "1");
|
|
129
|
+
if (cc.staleWhileRevalidate !== undefined) md.set(CACHE_STALE_WHILE_REVALIDATE_KEY, String(cc.staleWhileRevalidate));
|
|
130
|
+
if (cc.staleIfError !== undefined) md.set(CACHE_STALE_IF_ERROR_KEY, String(cc.staleIfError));
|
|
131
|
+
if (cc.notModified) md.set(CACHE_NOT_MODIFIED_KEY, "1");
|
|
132
|
+
return md;
|
|
133
|
+
}
|
package/src/functions/table.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
type DistinctDependence,
|
|
52
52
|
} from "../types.js";
|
|
53
53
|
import { BoundStorage, storage as globalStorage } from "./storage.js";
|
|
54
|
+
import { CACHE_IF_MODIFIED_SINCE_KEY, CACHE_IF_NONE_MATCH_KEY } from "../cache-control.js";
|
|
54
55
|
|
|
55
56
|
// Base64-decode a string into raw bytes. Used to unpack the dynamic filter
|
|
56
57
|
// update DuckDB attaches to each tick batch's custom metadata.
|
|
@@ -153,6 +154,18 @@ export interface TableProcessParams<TArgs = Record<string, any>> {
|
|
|
153
154
|
*/
|
|
154
155
|
atUnit?: string;
|
|
155
156
|
atValue?: string;
|
|
157
|
+
/**
|
|
158
|
+
* Conditional-revalidation validator (the client's stored ETag). Set when the
|
|
159
|
+
* client holds a stale-but-revalidatable cached result and asks the worker to
|
|
160
|
+
* confirm freshness cheaply; a worker that advertised `revalidatable` compares
|
|
161
|
+
* it and, if unchanged, emits a 0-row batch tagged
|
|
162
|
+
* `cacheControlMetadata({ notModified: true })` instead of re-streaming.
|
|
163
|
+
* Undefined on a normal call.
|
|
164
|
+
*/
|
|
165
|
+
ifNoneMatch?: string;
|
|
166
|
+
/** Conditional-revalidation validator (the client's stored Last-Modified).
|
|
167
|
+
* Companion to {@link ifNoneMatch}. Undefined on a normal call. */
|
|
168
|
+
ifModifiedSince?: string;
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
// ============================================================================
|
|
@@ -443,12 +456,23 @@ export function defineTableFunction<
|
|
|
443
456
|
pState: { state: TState; processParams: TableProcessParams<TArgs> },
|
|
444
457
|
tickMetadata: Map<string, string> | undefined,
|
|
445
458
|
) => {
|
|
459
|
+
if (!tickMetadata) return;
|
|
460
|
+
|
|
461
|
+
// Conditional-revalidation validators: the client holds a stale
|
|
462
|
+
// cached result and asks the worker to confirm freshness cheaply.
|
|
463
|
+
// Over subprocess these ride the first producer tick; over HTTP the
|
|
464
|
+
// first tick folds into the /init request, whose metadata vgi-rpc
|
|
465
|
+
// surfaces here.
|
|
466
|
+
const ifNoneMatch = tickMetadata.get(CACHE_IF_NONE_MATCH_KEY);
|
|
467
|
+
const ifModifiedSince = tickMetadata.get(CACHE_IF_MODIFIED_SINCE_KEY);
|
|
468
|
+
if (ifNoneMatch !== undefined) pState.processParams.ifNoneMatch = ifNoneMatch;
|
|
469
|
+
if (ifModifiedSince !== undefined) pState.processParams.ifModifiedSince = ifModifiedSince;
|
|
470
|
+
|
|
446
471
|
// Dynamic filter pushdown: DuckDB's Top-N optimizer tightens filters
|
|
447
472
|
// between ticks and serializes the current filter into the tick
|
|
448
473
|
// batch's custom metadata under `vgi_pushdown_filters` (base64 of a
|
|
449
474
|
// filter IPC stream). Decode and overwrite the current pushdown
|
|
450
475
|
// filters so process() sees the updated value.
|
|
451
|
-
if (!tickMetadata) return;
|
|
452
476
|
const encoded = tickMetadata.get("vgi_pushdown_filters");
|
|
453
477
|
if (!encoded) return;
|
|
454
478
|
try {
|
|
@@ -399,7 +399,12 @@ async function buildSchemas(
|
|
|
399
399
|
String(a.type).localeCompare(String(b.type)) || String(a.name).localeCompare(String(b.name)),
|
|
400
400
|
);
|
|
401
401
|
|
|
402
|
-
|
|
402
|
+
const schemaObj: Record<string, unknown> = { name: si.name };
|
|
403
|
+
if (si.comment) schemaObj.doc = si.comment;
|
|
404
|
+
schemaObj.tables = tables;
|
|
405
|
+
schemaObj.views = views;
|
|
406
|
+
schemaObj.functions = functions;
|
|
407
|
+
schemas.push(schemaObj);
|
|
403
408
|
counts.schemas += 1;
|
|
404
409
|
counts.tables += tables.length;
|
|
405
410
|
counts.views += views.length;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Shared HTTP fetch-handler factory for VGI workers.
|
|
3
|
+
//
|
|
4
|
+
// Both HTTP entrypoints funnel through here:
|
|
5
|
+
// - `vgi/worker-cf` -> createVgiFetch, exported as `default.fetch` for workerd
|
|
6
|
+
// - `vgi/serve` -> serveVgiWorker, which hands the handler to Bun.serve
|
|
7
|
+
//
|
|
8
|
+
// Centralising the wiring means the state-token key reaches `createHttpHandler`
|
|
9
|
+
// as `tokenKey` (the option the handler actually reads) and reaches
|
|
10
|
+
// `buildVgiProtocol` as `signingKey`, from one source. Passing different keys to
|
|
11
|
+
// those two seams produces a worker that mints tokens it cannot recover.
|
|
12
|
+
|
|
13
|
+
import { createHttpHandler, unpackStateToken, type Protocol } from "@query-farm/vgi-rpc";
|
|
14
|
+
import { arrowStateSerializer } from "../protocol/state-serializer.js";
|
|
15
|
+
import { buildVgiProtocol, type ProtocolConfig } from "../protocol/dispatch.js";
|
|
16
|
+
import type { LandingDescribeProvider } from "./describe-json.js";
|
|
17
|
+
|
|
18
|
+
export interface VgiFetchOptions {
|
|
19
|
+
/** Wire-protocol config (registry + catalogInterface). */
|
|
20
|
+
protocol: Omit<ProtocolConfig, "recoverExchangeState">;
|
|
21
|
+
/** HMAC key for state-token signing. Pass a stable, secret 32-byte key
|
|
22
|
+
* (e.g. derived from a Wrangler secret). Required because Workers don't
|
|
23
|
+
* preserve in-memory state across requests/instances. */
|
|
24
|
+
signingKey: Uint8Array;
|
|
25
|
+
/** State-token TTL in seconds (default 3600). */
|
|
26
|
+
tokenTtl?: number;
|
|
27
|
+
/** URL path prefix for VGI requests (default "/vgi"). Pass "" to mount at
|
|
28
|
+
* the root, which is what a dedicated single-worker HTTP server wants. */
|
|
29
|
+
prefix?: string;
|
|
30
|
+
/** Server ID for state-token attribution (default "vgi-cf"). */
|
|
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;
|
|
36
|
+
/** Public source-repository URL, surfaced on the landing page. */
|
|
37
|
+
repositoryUrl?: string;
|
|
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;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a fetch handler suitable for `export default { fetch }` in a CF
|
|
47
|
+
* Worker module, or for `Bun.serve({ fetch })`. The returned handler is
|
|
48
|
+
* async-safe across Workers' isolate-per-request execution model — all state
|
|
49
|
+
* round-trips through the signed state token created here.
|
|
50
|
+
*/
|
|
51
|
+
export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise<Response> {
|
|
52
|
+
const tokenTtl = opts.tokenTtl ?? 3600;
|
|
53
|
+
// `?? "/vgi"` and not `|| "/vgi"`: an explicit "" means "mount at root".
|
|
54
|
+
const prefix = opts.prefix ?? "/vgi";
|
|
55
|
+
const serverId = opts.serverId ?? "vgi-cf";
|
|
56
|
+
|
|
57
|
+
const protocol: Protocol = buildVgiProtocol({
|
|
58
|
+
signingKey: opts.signingKey,
|
|
59
|
+
...opts.protocol,
|
|
60
|
+
recoverExchangeState: async (opaqueData: Uint8Array) => {
|
|
61
|
+
const tokenString = new TextDecoder().decode(opaqueData);
|
|
62
|
+
// principal binding is enforced by the HTTP handler on the request that
|
|
63
|
+
// carried this token; the recovery path itself is not principal-scoped.
|
|
64
|
+
const unpacked = await unpackStateToken(tokenString, opts.signingKey, tokenTtl, undefined);
|
|
65
|
+
return arrowStateSerializer.deserialize(unpacked.stateBytes);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const handler = createHttpHandler(protocol, {
|
|
70
|
+
prefix,
|
|
71
|
+
serverId,
|
|
72
|
+
tokenKey: opts.signingKey,
|
|
73
|
+
tokenTtl,
|
|
74
|
+
stateSerializer: arrowStateSerializer,
|
|
75
|
+
corsOrigins: opts.corsOrigins,
|
|
76
|
+
repositoryUrl: opts.repositoryUrl,
|
|
77
|
+
landingDescribe: opts.landingDescribe,
|
|
78
|
+
});
|
|
79
|
+
return async (req: Request) => handler(req);
|
|
80
|
+
}
|
package/src/index.core.ts
CHANGED
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
// for Cloudflare Workers / browsers via worker-cf-entry.ts. The node/bun barrel
|
|
8
8
|
// (`./index.ts`) re-exports this plus `Worker`.
|
|
9
9
|
|
|
10
|
+
// Result-cache control vocabulary (`vgi.cache.*`)
|
|
11
|
+
export {
|
|
12
|
+
CACHE_ETAG_KEY,
|
|
13
|
+
CACHE_EXPIRES_KEY,
|
|
14
|
+
CACHE_IF_MODIFIED_SINCE_KEY,
|
|
15
|
+
CACHE_IF_NONE_MATCH_KEY,
|
|
16
|
+
CACHE_LAST_MODIFIED_KEY,
|
|
17
|
+
CACHE_NO_STORE_KEY,
|
|
18
|
+
CACHE_NOT_MODIFIED_KEY,
|
|
19
|
+
CACHE_REVALIDATABLE_KEY,
|
|
20
|
+
CACHE_SCOPE_CATALOG,
|
|
21
|
+
CACHE_SCOPE_KEY,
|
|
22
|
+
CACHE_SCOPE_TRANSACTION,
|
|
23
|
+
CACHE_STALE_IF_ERROR_KEY,
|
|
24
|
+
CACHE_STALE_WHILE_REVALIDATE_KEY,
|
|
25
|
+
CACHE_TTL_KEY,
|
|
26
|
+
type CacheControl,
|
|
27
|
+
cacheControlMetadata,
|
|
28
|
+
type CacheScope,
|
|
29
|
+
} from "./cache-control.js";
|
|
30
|
+
|
|
10
31
|
// Function factories (primary API)
|
|
11
32
|
export { defineScalarFunction, type ScalarFunctionConfig, type ScalarBindParameters, type ScalarParameterDef } from "./functions/scalar.js";
|
|
12
33
|
export { defineTableFunction, type TableFunctionConfig, type TableBindParams, type TableProcessParams } from "./functions/table.js";
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Copyright 2025, 2026 Query Farm LLC - https://query.farm
|
|
2
|
+
// Bun HTTP entrypoint for a VGI worker: `import { serveVgiWorker } from "@query-farm/vgi/serve"`.
|
|
3
|
+
//
|
|
4
|
+
// A worker's HTTP server is the same nine moving parts every time (protocol,
|
|
5
|
+
// state-token key, TTL, CORS, landing surface, port, Bun.serve, startup log).
|
|
6
|
+
// This collapses them to one call so a worker repo's serve script is its
|
|
7
|
+
// registry + catalog and nothing else:
|
|
8
|
+
//
|
|
9
|
+
// serveVgiWorker({
|
|
10
|
+
// name: "ishares",
|
|
11
|
+
// doc: "iShares (BlackRock) US fund data.",
|
|
12
|
+
// version: "0.1.0",
|
|
13
|
+
// repositoryUrl: "https://github.com/Query-farm/vgi-etf-ishares",
|
|
14
|
+
// registry,
|
|
15
|
+
// catalogInterface,
|
|
16
|
+
// });
|
|
17
|
+
//
|
|
18
|
+
// Mounted at the ROOT prefix, so DuckDB attaches the bare origin:
|
|
19
|
+
// ATTACH 'ishares' AS ishares (TYPE vgi, LOCATION 'http://localhost:8787');
|
|
20
|
+
//
|
|
21
|
+
// Bun-only — it calls Bun.serve. Cloudflare Workers use `vgi/worker-cf`'s
|
|
22
|
+
// createVgiFetch instead; both share the handler factory in http/fetch.ts.
|
|
23
|
+
|
|
24
|
+
import type { CatalogInterface } from "./catalog/interface.js";
|
|
25
|
+
import type { FunctionRegistry } from "./functions/registry.js";
|
|
26
|
+
import { createLandingDescribe } from "./http/describe-json.js";
|
|
27
|
+
import { createVgiFetch } from "./http/fetch.js";
|
|
28
|
+
|
|
29
|
+
/** Environment variables `serveVgiWorker` reads. Injectable for testing. */
|
|
30
|
+
export interface ServeEnv {
|
|
31
|
+
PORT?: string;
|
|
32
|
+
VGI_SIGNING_KEY?: string;
|
|
33
|
+
VGI_TOKEN_TTL?: string;
|
|
34
|
+
CORS_ORIGINS?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_PORT = 8787;
|
|
38
|
+
export const DEFAULT_TOKEN_TTL = 3600;
|
|
39
|
+
/** Bytes of HMAC key `createHttpHandler` expects; 64 hex characters. */
|
|
40
|
+
export const SIGNING_KEY_BYTES = 32;
|
|
41
|
+
|
|
42
|
+
export interface ServeVgiWorkerOptions {
|
|
43
|
+
/** Short worker name shown on the landing page, e.g. "ishares". */
|
|
44
|
+
name: string;
|
|
45
|
+
/** One-line description of what the worker serves. */
|
|
46
|
+
doc: string;
|
|
47
|
+
/** Worker version string shown on the landing page. */
|
|
48
|
+
version: string;
|
|
49
|
+
/** Functions the worker serves, already registered. */
|
|
50
|
+
registry: FunctionRegistry;
|
|
51
|
+
/** Catalog DuckDB attaches to. */
|
|
52
|
+
catalogInterface: CatalogInterface;
|
|
53
|
+
|
|
54
|
+
/** Public source-repository URL, linked from the landing page. */
|
|
55
|
+
repositoryUrl?: string;
|
|
56
|
+
/** Overrides `$PORT` (default 8787). Pass 0 to bind an ephemeral port. */
|
|
57
|
+
port?: number;
|
|
58
|
+
/** Overrides `$VGI_SIGNING_KEY`. 32 bytes. */
|
|
59
|
+
signingKey?: Uint8Array;
|
|
60
|
+
/** Overrides `$VGI_TOKEN_TTL` (default 3600 seconds). */
|
|
61
|
+
tokenTtl?: number;
|
|
62
|
+
/** Overrides `$CORS_ORIGINS` (default "*"). Pass `null` to disable CORS. */
|
|
63
|
+
corsOrigins?: string | null;
|
|
64
|
+
/** Server ID for state-token attribution (default: `name`). */
|
|
65
|
+
serverId?: string;
|
|
66
|
+
/** Mount point for the VGI endpoints (default "", the origin root). */
|
|
67
|
+
prefix?: string;
|
|
68
|
+
/** Suppress the startup banner on stdout. */
|
|
69
|
+
quiet?: boolean;
|
|
70
|
+
/** Environment source (default `process.env`). */
|
|
71
|
+
env?: ServeEnv;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The slice of `Bun.serve`'s return value this module uses. */
|
|
75
|
+
export interface VgiHttpServer {
|
|
76
|
+
readonly port: number;
|
|
77
|
+
readonly url: URL;
|
|
78
|
+
stop(closeActiveConnections?: boolean): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface BunLike {
|
|
82
|
+
serve(options: {
|
|
83
|
+
port: number;
|
|
84
|
+
fetch: (req: Request) => Promise<Response>;
|
|
85
|
+
}): VgiHttpServer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decode a 32-byte signing key from its hex representation.
|
|
90
|
+
*
|
|
91
|
+
* Throws on anything that is not exactly 64 hex characters. A silent truncate
|
|
92
|
+
* or zero-pad here would produce a worker that starts happily with a key far
|
|
93
|
+
* weaker than the operator believed they supplied.
|
|
94
|
+
*/
|
|
95
|
+
export function parseSigningKeyHex(hex: string): Uint8Array {
|
|
96
|
+
const cleaned = hex.trim();
|
|
97
|
+
const expected = SIGNING_KEY_BYTES * 2;
|
|
98
|
+
if (cleaned.length !== expected || !/^[0-9a-fA-F]+$/.test(cleaned)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`VGI_SIGNING_KEY must be exactly ${expected} hex characters ` +
|
|
101
|
+
`(${SIGNING_KEY_BYTES} bytes); got ${cleaned.length}. ` +
|
|
102
|
+
`Generate one with: openssl rand -hex ${SIGNING_KEY_BYTES}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const key = new Uint8Array(SIGNING_KEY_BYTES);
|
|
106
|
+
for (let i = 0; i < SIGNING_KEY_BYTES; i++) {
|
|
107
|
+
key[i] = Number.parseInt(cleaned.slice(i * 2, i * 2 + 2), 16);
|
|
108
|
+
}
|
|
109
|
+
return key;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the state-token signing key: explicit option, then `$VGI_SIGNING_KEY`,
|
|
114
|
+
* then a fresh random key.
|
|
115
|
+
*
|
|
116
|
+
* The random fallback is safe but ephemeral, and that tradeoff is why it warns:
|
|
117
|
+
* state tokens minted under it stop validating after a restart, and never
|
|
118
|
+
* validate across a second instance behind a load balancer. It exists so
|
|
119
|
+
* `bun run scripts/serve.ts` works with no setup, not so deployments can skip
|
|
120
|
+
* the key.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveSigningKey(
|
|
123
|
+
explicit: Uint8Array | undefined,
|
|
124
|
+
env: ServeEnv,
|
|
125
|
+
warn: (msg: string) => void = console.error,
|
|
126
|
+
): Uint8Array {
|
|
127
|
+
if (explicit) {
|
|
128
|
+
if (explicit.length !== SIGNING_KEY_BYTES) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`signingKey must be ${SIGNING_KEY_BYTES} bytes; got ${explicit.length}.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return explicit;
|
|
134
|
+
}
|
|
135
|
+
const fromEnv = env.VGI_SIGNING_KEY?.trim();
|
|
136
|
+
if (fromEnv) return parseSigningKeyHex(fromEnv);
|
|
137
|
+
|
|
138
|
+
warn(
|
|
139
|
+
"WARNING: VGI_SIGNING_KEY is not set; generated a random state-token key.\n" +
|
|
140
|
+
" State tokens will not survive a restart, and will not validate\n" +
|
|
141
|
+
" across multiple instances behind a load balancer.\n" +
|
|
142
|
+
` Set it for any real deployment: openssl rand -hex ${SIGNING_KEY_BYTES}`,
|
|
143
|
+
);
|
|
144
|
+
return crypto.getRandomValues(new Uint8Array(SIGNING_KEY_BYTES));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolvePort(explicit: number | undefined, env: ServeEnv): number {
|
|
148
|
+
if (explicit !== undefined) return explicit;
|
|
149
|
+
const raw = env.PORT?.trim();
|
|
150
|
+
if (!raw) return DEFAULT_PORT;
|
|
151
|
+
const port = Number(raw);
|
|
152
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
153
|
+
throw new Error(`PORT must be an integer between 0 and 65535; got ${JSON.stringify(raw)}.`);
|
|
154
|
+
}
|
|
155
|
+
return port;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveTokenTtl(explicit: number | undefined, env: ServeEnv): number {
|
|
159
|
+
if (explicit !== undefined) return explicit;
|
|
160
|
+
const raw = env.VGI_TOKEN_TTL?.trim();
|
|
161
|
+
if (!raw) return DEFAULT_TOKEN_TTL;
|
|
162
|
+
const ttl = Number(raw);
|
|
163
|
+
if (!Number.isInteger(ttl) || ttl < 0) {
|
|
164
|
+
throw new Error(`VGI_TOKEN_TTL must be a non-negative integer; got ${JSON.stringify(raw)}.`);
|
|
165
|
+
}
|
|
166
|
+
return ttl;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build the fetch handler a VGI worker serves over HTTP, with the standardized
|
|
171
|
+
* landing surface mounted at `prefix`.
|
|
172
|
+
*
|
|
173
|
+
* Exposed separately from {@link serveVgiWorker} so a worker can put the VGI
|
|
174
|
+
* routes inside its own Bun/Hono/Elysia server, or drive them from a test
|
|
175
|
+
* without binding a port.
|
|
176
|
+
*/
|
|
177
|
+
export function createVgiWorkerFetch(
|
|
178
|
+
opts: ServeVgiWorkerOptions,
|
|
179
|
+
): (req: Request) => Promise<Response> {
|
|
180
|
+
const env = opts.env ?? (process.env as ServeEnv);
|
|
181
|
+
const signingKey = resolveSigningKey(opts.signingKey, env);
|
|
182
|
+
const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
|
|
183
|
+
// `null` disables CORS; `undefined` falls through to the env var, then "*".
|
|
184
|
+
const corsOrigins =
|
|
185
|
+
opts.corsOrigins === null ? undefined : (opts.corsOrigins ?? env.CORS_ORIGINS ?? "*");
|
|
186
|
+
|
|
187
|
+
return createVgiFetch({
|
|
188
|
+
protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
|
|
189
|
+
signingKey,
|
|
190
|
+
tokenTtl,
|
|
191
|
+
prefix: opts.prefix ?? "",
|
|
192
|
+
serverId: opts.serverId ?? opts.name,
|
|
193
|
+
corsOrigins,
|
|
194
|
+
repositoryUrl: opts.repositoryUrl,
|
|
195
|
+
landingDescribe: createLandingDescribe(opts.catalogInterface, {
|
|
196
|
+
name: opts.name,
|
|
197
|
+
doc: opts.doc,
|
|
198
|
+
version: opts.version,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Serve a VGI worker over HTTP on Bun and return the running server.
|
|
205
|
+
*
|
|
206
|
+
* Reads `PORT`, `VGI_SIGNING_KEY`, `VGI_TOKEN_TTL`, and `CORS_ORIGINS` from the
|
|
207
|
+
* environment; every one has an explicit option that takes precedence.
|
|
208
|
+
*/
|
|
209
|
+
export function serveVgiWorker(opts: ServeVgiWorkerOptions): VgiHttpServer {
|
|
210
|
+
const runtime = (globalThis as { Bun?: BunLike }).Bun;
|
|
211
|
+
if (!runtime) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'"@query-farm/vgi/serve" requires the Bun runtime (it calls Bun.serve). ' +
|
|
214
|
+
'On Cloudflare Workers use createVgiFetch from "@query-farm/vgi/worker-cf"; ' +
|
|
215
|
+
"on other runtimes use createVgiWorkerFetch and bind the port yourself.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const env = opts.env ?? (process.env as ServeEnv);
|
|
220
|
+
const port = resolvePort(opts.port, env);
|
|
221
|
+
const fetch = createVgiWorkerFetch(opts);
|
|
222
|
+
const server = runtime.serve({ port, fetch });
|
|
223
|
+
|
|
224
|
+
if (!opts.quiet) {
|
|
225
|
+
const base = `http://localhost:${server.port}`;
|
|
226
|
+
console.log(`${opts.name} VGI HTTP worker listening on ${base}`);
|
|
227
|
+
console.log(` landing page ${base}/`);
|
|
228
|
+
console.log(` describe.json ${base}/describe.json`);
|
|
229
|
+
console.log(` health ${base}/health`);
|
|
230
|
+
console.log(` attach ATTACH '${opts.name}' AS ${opts.name} (TYPE vgi, LOCATION '${base}');`);
|
|
231
|
+
}
|
|
232
|
+
return server;
|
|
233
|
+
}
|
package/src/worker-cf-entry.ts
CHANGED
|
@@ -13,57 +13,8 @@
|
|
|
13
13
|
// `createVgiFetch` rather than a hard-coded singleton, so a CF Worker module
|
|
14
14
|
// can compose multiple registries the same way the Bun worker does.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import { buildVgiProtocol, type ProtocolConfig } from "./protocol/dispatch.js";
|
|
19
|
-
|
|
20
|
-
export interface VgiFetchOptions {
|
|
21
|
-
/** Wire-protocol config (registry + catalogInterface). */
|
|
22
|
-
protocol: Omit<ProtocolConfig, "recoverExchangeState">;
|
|
23
|
-
/** HMAC key for state-token signing. Pass a stable, secret 32-byte key
|
|
24
|
-
* (e.g. derived from a Wrangler secret). Required because Workers don't
|
|
25
|
-
* preserve in-memory state across requests/instances. */
|
|
26
|
-
signingKey: Uint8Array;
|
|
27
|
-
/** State-token TTL in seconds (default 3600). */
|
|
28
|
-
tokenTtl?: number;
|
|
29
|
-
/** URL path prefix for VGI requests (default "/vgi"). */
|
|
30
|
-
prefix?: string;
|
|
31
|
-
/** Server ID for state-token attribution (default "vgi-cf"). */
|
|
32
|
-
serverId?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Build a fetch handler suitable for `export default { fetch }` in a CF
|
|
37
|
-
* Worker module. The returned handler is async-safe across Workers'
|
|
38
|
-
* isolate-per-request execution model — all state round-trips through the
|
|
39
|
-
* signed state token created here.
|
|
40
|
-
*/
|
|
41
|
-
export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise<Response> {
|
|
42
|
-
const tokenTtl = opts.tokenTtl ?? 3600;
|
|
43
|
-
const prefix = opts.prefix ?? "/vgi";
|
|
44
|
-
const serverId = opts.serverId ?? "vgi-cf";
|
|
45
|
-
|
|
46
|
-
const protocol: Protocol = buildVgiProtocol({
|
|
47
|
-
signingKey: opts.signingKey,
|
|
48
|
-
...opts.protocol,
|
|
49
|
-
recoverExchangeState: async (opaqueData: Uint8Array) => {
|
|
50
|
-
const tokenString = new TextDecoder().decode(opaqueData);
|
|
51
|
-
// principal binding is enforced by the HTTP handler on the request that
|
|
52
|
-
// carried this token; the recovery path itself is not principal-scoped.
|
|
53
|
-
const unpacked = await unpackStateToken(tokenString, opts.signingKey, tokenTtl, undefined);
|
|
54
|
-
return arrowStateSerializer.deserialize(unpacked.stateBytes);
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const handler = createHttpHandler(protocol, {
|
|
59
|
-
prefix,
|
|
60
|
-
serverId,
|
|
61
|
-
tokenKey: opts.signingKey,
|
|
62
|
-
tokenTtl,
|
|
63
|
-
stateSerializer: arrowStateSerializer,
|
|
64
|
-
});
|
|
65
|
-
return async (req: Request) => handler(req);
|
|
66
|
-
}
|
|
16
|
+
export { createVgiFetch } from "./http/fetch.js";
|
|
17
|
+
export type { VgiFetchOptions } from "./http/fetch.js";
|
|
67
18
|
|
|
68
19
|
// Re-export the public types CF Worker authors need to wire a registry +
|
|
69
20
|
// catalog. They import from "vgi/worker-cf" rather than from "vgi" directly
|
|
@@ -102,6 +53,11 @@ export type {
|
|
|
102
53
|
|
|
103
54
|
export type { ProtocolConfig } from "./protocol/dispatch.js";
|
|
104
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";
|
|
60
|
+
|
|
105
61
|
// Backend-agnostic Arrow facade. Same source resolves to arrow-js on
|
|
106
62
|
// Node/Bun and flechette under workerd/worker/browser via the package's
|
|
107
63
|
// `#arrow-impl` conditional import. Worker authors should always go
|