@volter/twin-upstashvector 0.1.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.
@@ -0,0 +1,67 @@
1
+ import { type SimilarityFunction } from './upstashvector-store.js';
2
+ export type UpstashVectorRequest = {
3
+ method: string;
4
+ path: string;
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ occurredAt?: string;
8
+ root?: string;
9
+ readOnly?: boolean;
10
+ /**
11
+ * The token this twin accepts. When set, the request's credential must match it EXACTLY.
12
+ * When omitted the twin accepts any NON-EMPTY credential and still 401s a missing/blank one —
13
+ * the honest default for a local twin whose whole point is that you hold no real secret.
14
+ */
15
+ token?: string;
16
+ /** The dimension this index was created with. Omit to let the first upsert lock one in. */
17
+ dimension?: number;
18
+ /** The metric this index was created with. Defaults to COSINE. */
19
+ similarityFunction?: SimilarityFunction;
20
+ };
21
+ export type UpstashVectorResponse = {
22
+ status: number;
23
+ body: unknown;
24
+ headers?: Record<string, string>;
25
+ };
26
+ /**
27
+ * The 401 body — VERBATIM from upstash.com/docs/vector/api/get-started, whose error example is
28
+ * exactly `{"error": "Unauthorized: Invalid auth token", "status": 401}`.
29
+ *
30
+ * (§9 round 1 caught this pack UNDER-claiming: an earlier draft used its own wording and declared
31
+ * the vendor's text unpublished, when the get-started page publishes it. Note the capital `I` in
32
+ * "Invalid" — matching it exactly is the difference between a consumer's error assertion passing
33
+ * against this twin and only passing against the real service. This is now one of TWO strings in
34
+ * the pack quoted verbatim from the vendor, alongside the dimension-mismatch 422.)
35
+ */
36
+ export declare const UPSTASH_VECTOR_UNAUTHORIZED_ERROR = "Unauthorized: Invalid auth token";
37
+ /** Every endpoint this twin routes, and the ones it deliberately refuses. */
38
+ export type UpstashVectorCommand = 'upsert' | 'query' | 'fetch' | 'range' | 'delete' | 'update' | 'reset' | 'info' | 'list-namespaces' | 'delete-namespace';
39
+ /**
40
+ * Extract the caller's credential from `Authorization: Bearer <token>` (what the SDK sends) or a
41
+ * bare `Authorization: <token>`.
42
+ *
43
+ * The optional-group regex matters: `Authorization: Bearer ` — the scheme with an EMPTY token —
44
+ * trims to the bare word "Bearer". A `/^bearer\s+(.*)$/` pattern does not match it, so a naive
45
+ * implementation falls through and treats the literal string "Bearer" as the credential,
46
+ * authenticating a request that carries no token at all.
47
+ */
48
+ export declare function extractUpstashVectorCredential(headers: Record<string, string> | undefined): string | null;
49
+ /** The parsed shape of a request path: which command, and which namespace. */
50
+ export type UpstashVectorRoute = {
51
+ command: string;
52
+ namespace: string;
53
+ };
54
+ /**
55
+ * Split `/query/my-ns` into `{command:'query', namespace:'my-ns'}`.
56
+ *
57
+ * Everything after the first segment is the namespace, percent-DECODED and re-joined — a namespace
58
+ * is an arbitrary string, so one containing a `/` must survive the round trip rather than being
59
+ * truncated at the first slash into a DIFFERENT namespace's data.
60
+ */
61
+ export declare function routeUpstashVectorPath(path: string): UpstashVectorRoute;
62
+ export declare function handleUpstashVectorTwinRequest(req: UpstashVectorRequest): Promise<UpstashVectorResponse>;
63
+ export type UpstashVectorTwinSnapshot = {
64
+ resourceTypes: readonly string[];
65
+ implementedEndpoints: readonly string[];
66
+ };
67
+ export declare function upstashvectorTwinSnapshot(): UpstashVectorTwinSnapshot;
@@ -0,0 +1,287 @@
1
+ // UPSTASH VECTOR REST TWIN — THE REQUEST HANDLER. Contract:
2
+ // handleUpstashVectorTwinRequest({ method, path, body, headers, root, occurredAt, readOnly, token })
3
+ // -> { status, body, headers }
4
+ //
5
+ // This file is the TRANSPORT half; every vector semantic lives in `upstashvector-store.ts` and the
6
+ // metadata-filter language in `upstashvector-filter.ts`. What is modeled here is Upstash Vector's
7
+ // REST protocol itself:
8
+ //
9
+ // POST /upsert body: one vector object OR an array of them
10
+ // POST /upsert/{ns} ... into a named namespace (namespaces are a PATH SUFFIX)
11
+ // POST /query body: one query object → {result:[...]}
12
+ // POST /query body: an ARRAY of query objects → queryMany (see below)
13
+ // POST /fetch /range /delete /update (+ their /{ns} forms)
14
+ // POST|DELETE /reset /reset/{ns} /reset?all
15
+ // GET|POST /info GET|POST /list-namespaces
16
+ // POST|DELETE /delete-namespace/{ns}
17
+ //
18
+ // ── FOUR THINGS A NAIVE TWIN GETS WRONG ───────────────────────────────────────────────────────
19
+ //
20
+ // 1. EVERY SDK CALL IS A **POST**, whatever the docs say the method is. `@upstash/vector@1.2.3`'s
21
+ // `HttpClient.request` hardcodes `method: "POST"` (confirmed in the installed dist), so although
22
+ // the docs spell `/reset` and `/delete-namespace` as `-X DELETE`, the real SDK never sends
23
+ // DELETE at all. A twin that accepted only the documented method would 405 the actual client.
24
+ // Both are accepted here.
25
+ //
26
+ // 2. NAMESPACES ARE A PATH SUFFIX, NOT A BODY FIELD. `index.namespace("ns").query(...)` becomes
27
+ // `POST /query/ns` — the namespace never appears in the JSON. A twin reading `body.namespace`
28
+ // would silently serve every namespaced request out of the default namespace, so every tenant
29
+ // would see every other tenant's vectors.
30
+ //
31
+ // 3. `/query` IS OVERLOADED ON BODY SHAPE. An OBJECT body is one query and answers
32
+ // `{"result":[...]}`; an ARRAY body is `queryMany` and answers a nested `{"result":[[...],...]}`
33
+ // — EXCEPT for a single-element array, which the real API answers FLAT. That is not a guess:
34
+ // `QueryManyCommand.exec` in the installed SDK re-nests it and its own comment says "When a
35
+ // single query is sent via queryMany, the API returns a flat array of results instead of a
36
+ // nested array." A twin that always nested would hand `queryMany([q])` a doubly-nested result.
37
+ //
38
+ // 4. AN UNMODELED ENDPOINT MUST 404, NEVER 200. `/upsert-data`, `/query-data` and the
39
+ // resumable-query family are real vendor surface this twin does not serve; each fails with the
40
+ // vendor's `{error,status}` envelope naming what is missing, so a caller can never mistake
41
+ // silence for success.
42
+ //
43
+ // GROUNDED (2026-08-19) — see spec-sources.json and the store module's header. Upstash publishes
44
+ // no literal text for most of its errors and this pack had no live index to probe, so the error
45
+ // STRINGS below are twin-authored except the dimension mismatch, which is quoted from the vendor's
46
+ // own upsert doc. What IS faithful and asserted: the `{result}` / `{error,status}` envelope, the
47
+ // status codes, the routing table, and every response body shape.
48
+ import { DEFAULT_NAMESPACE, IndexSpace, ReadOnlyError, VectorApiError, UPSTASHVECTOR_RESOURCE_TYPES, } from "./upstashvector-store.js";
49
+ /** Response headers. `content-type` is what the SDK's `res.json()` requires. */
50
+ const BASE_HEADERS = {
51
+ 'content-type': 'application/json; charset=utf-8',
52
+ 'access-control-allow-credentials': 'true',
53
+ };
54
+ /**
55
+ * The 401 body — VERBATIM from upstash.com/docs/vector/api/get-started, whose error example is
56
+ * exactly `{"error": "Unauthorized: Invalid auth token", "status": 401}`.
57
+ *
58
+ * (§9 round 1 caught this pack UNDER-claiming: an earlier draft used its own wording and declared
59
+ * the vendor's text unpublished, when the get-started page publishes it. Note the capital `I` in
60
+ * "Invalid" — matching it exactly is the difference between a consumer's error assertion passing
61
+ * against this twin and only passing against the real service. This is now one of TWO strings in
62
+ * the pack quoted verbatim from the vendor, alongside the dimension-mismatch 422.)
63
+ */
64
+ export const UPSTASH_VECTOR_UNAUTHORIZED_ERROR = 'Unauthorized: Invalid auth token';
65
+ /** Real vendor endpoints this twin does NOT serve, each with the manifest gap that tracks it. */
66
+ const UNMODELED_ENDPOINTS = {
67
+ 'upsert-data': 'upstashvector.embedding.upsert_data',
68
+ 'query-data': 'upstashvector.embedding.query_data',
69
+ 'resumable-query': 'upstashvector.resumable.start',
70
+ 'resumable-query-data': 'upstashvector.resumable.start',
71
+ 'resumable-query-next': 'upstashvector.resumable.next',
72
+ 'resumable-query-end': 'upstashvector.resumable.stop',
73
+ };
74
+ const MODELED = new Set([
75
+ 'upsert', 'query', 'fetch', 'range', 'delete', 'update', 'reset', 'info', 'list-namespaces', 'delete-namespace',
76
+ ]);
77
+ function lowerHeaders(h) {
78
+ const out = {};
79
+ for (const [k, v] of Object.entries(h ?? {}))
80
+ out[k.toLowerCase()] = v;
81
+ return out;
82
+ }
83
+ /**
84
+ * Extract the caller's credential from `Authorization: Bearer <token>` (what the SDK sends) or a
85
+ * bare `Authorization: <token>`.
86
+ *
87
+ * The optional-group regex matters: `Authorization: Bearer ` — the scheme with an EMPTY token —
88
+ * trims to the bare word "Bearer". A `/^bearer\s+(.*)$/` pattern does not match it, so a naive
89
+ * implementation falls through and treats the literal string "Bearer" as the credential,
90
+ * authenticating a request that carries no token at all.
91
+ */
92
+ export function extractUpstashVectorCredential(headers) {
93
+ const raw = lowerHeaders(headers).authorization;
94
+ if (raw === undefined)
95
+ return null;
96
+ const trimmed = raw.trim();
97
+ const bearer = /^bearer(?:\s+(.*))?$/i.exec(trimmed);
98
+ const token = bearer ? (bearer[1] ?? '').trim() : trimmed;
99
+ return token === '' ? null : token;
100
+ }
101
+ /**
102
+ * Split `/query/my-ns` into `{command:'query', namespace:'my-ns'}`.
103
+ *
104
+ * Everything after the first segment is the namespace, percent-DECODED and re-joined — a namespace
105
+ * is an arbitrary string, so one containing a `/` must survive the round trip rather than being
106
+ * truncated at the first slash into a DIFFERENT namespace's data.
107
+ */
108
+ export function routeUpstashVectorPath(path) {
109
+ const clean = (path.split('?')[0] ?? '/').replace(/^\/+/, '');
110
+ const segments = clean.split('/');
111
+ const command = decodeSegment(segments[0] ?? '').toLowerCase();
112
+ const namespace = segments.length > 1 ? segments.slice(1).map(decodeSegment).join('/') : DEFAULT_NAMESPACE;
113
+ return { command, namespace };
114
+ }
115
+ function decodeSegment(seg) {
116
+ try {
117
+ return decodeURIComponent(seg);
118
+ }
119
+ catch {
120
+ return seg; // a malformed escape is passed through rather than turned into a 500
121
+ }
122
+ }
123
+ export async function handleUpstashVectorTwinRequest(req) {
124
+ const method = req.method.toUpperCase();
125
+ const [rawPath, rawQuery] = req.path.split('?');
126
+ const query = new URLSearchParams(rawQuery ?? '');
127
+ const headers = lowerHeaders(req.headers);
128
+ const occurredAt = req.occurredAt ?? new Date().toISOString();
129
+ // CORS preflight. Answered before auth — a browser preflight carries no Authorization header by
130
+ // definition, so 401ing it would make the twin unusable from any browser client.
131
+ if (method === 'OPTIONS') {
132
+ return {
133
+ status: 200,
134
+ body: null,
135
+ headers: {
136
+ 'access-control-allow-origin': headers.origin ?? '*',
137
+ 'access-control-allow-methods': 'GET,POST,PUT,DELETE,HEAD,OPTIONS',
138
+ 'access-control-allow-headers': headers['access-control-request-headers'] ?? 'authorization,content-type',
139
+ 'access-control-allow-credentials': 'true',
140
+ },
141
+ };
142
+ }
143
+ // upstash.com/docs/vector/api/get-started documents 405 for an unsupported method. DELETE is
144
+ // included because the docs spell `/reset` and `/delete-namespace` with `-X DELETE`.
145
+ if (!['GET', 'POST', 'PUT', 'HEAD', 'DELETE'].includes(method)) {
146
+ return fail(405, `Method Not Allowed: ${method}`);
147
+ }
148
+ // ── auth, before anything else ─────────────────────────────────────────────────────────────
149
+ const credential = extractUpstashVectorCredential(req.headers);
150
+ const authorized = req.token === undefined ? credential !== null : credential === req.token;
151
+ if (!authorized)
152
+ return fail(401, UPSTASH_VECTOR_UNAUTHORIZED_ERROR);
153
+ const { command, namespace } = routeUpstashVectorPath(rawPath ?? '/');
154
+ if (command === '')
155
+ return fail(404, 'Not Found: no endpoint at /');
156
+ if (!MODELED.has(command)) {
157
+ const gap = UNMODELED_ENDPOINTS[command];
158
+ // A REAL vendor endpoint this twin has not built fails 404 naming the filed gap; anything else
159
+ // is simply not an Upstash Vector endpoint. Neither can ever be mistaken for a success.
160
+ return fail(404, gap !== undefined
161
+ ? `upstashvector: /${command} is real Upstash Vector surface this twin does not model yet (${gap} is the filed gap)`
162
+ : `Not Found: /${command} is not an Upstash Vector endpoint`);
163
+ }
164
+ // GET AND HEAD ARE SAFE METHODS, so neither may mutate anything.
165
+ //
166
+ // §9 round 1 found the worst bug in this file: the HEAD check used to live AFTER `runCommand`
167
+ // and `flush()`, so `HEAD /reset` emptied the index and `HEAD /delete` removed vectors — then
168
+ // suppressed the body and answered a read-shaped, body-less 200. A destructive operation
169
+ // disguised as a read is the worst available failure mode. Forcing the request read-only means
170
+ // a HEAD to a write endpoint takes the ordinary 405 read-only path, while `HEAD /info` still
171
+ // answers 200 with no body.
172
+ //
173
+ // GET is included for the same reason (RFC 9110 defines both as safe): caches, prefetchers,
174
+ // crawlers and link-checkers issue GET freely, and no documented Upstash Vector client ever
175
+ // sends GET to a write endpoint — the SDK POSTs everything, and the docs use POST or DELETE. So
176
+ // the cost of refusing is zero for every real caller, while the cost of allowing it is an index
177
+ // destroyed by something that thought it was reading. What the REAL service does with
178
+ // `GET /reset` is UNVERIFIED (no live index to probe) and is filed as
179
+ // `upstashvector.protocol.get_on_a_write_endpoint`; this twin takes the safe side deliberately
180
+ // rather than guessing the destructive one.
181
+ const readOnly = (req.readOnly ?? false) || method === 'HEAD' || method === 'GET';
182
+ const ctx = {
183
+ occurredAt,
184
+ ...(req.root !== undefined ? { root: req.root } : {}),
185
+ ...(readOnly ? { readOnly: true } : {}),
186
+ ...(req.dimension !== undefined ? { dimension: req.dimension } : {}),
187
+ ...(req.similarityFunction !== undefined ? { similarityFunction: req.similarityFunction } : {}),
188
+ };
189
+ try {
190
+ const space = new IndexSpace(ctx);
191
+ const result = await runCommand(space, command, namespace, req.body, query);
192
+ await space.flush();
193
+ // HEAD answers with no body at all.
194
+ if (method === 'HEAD')
195
+ return { status: 200, body: null, headers: { ...BASE_HEADERS } };
196
+ return { status: 200, body: { result }, headers: { ...BASE_HEADERS } };
197
+ }
198
+ catch (e) {
199
+ if (e instanceof ReadOnlyError) {
200
+ // Distinguish the two reasons a write can be refused. §9 round 2: a `HEAD /upsert` against a
201
+ // perfectly writable twin used to answer "this twin was started read-only", which is simply
202
+ // false and sends the reader hunting for a flag they never set.
203
+ return fail(405, readOnly && !(req.readOnly ?? false)
204
+ ? `upstashvector: ${method} is a safe method and cannot modify the index — use POST (or PUT/DELETE where the endpoint documents it)`
205
+ : e.message);
206
+ }
207
+ if (e instanceof VectorApiError)
208
+ return fail(e.status, e.message);
209
+ // LAST-RESORT ENVELOPE. A twin's caller must always get a response it can reason about, and an
210
+ // internal fault must be VISIBLE rather than silent — an unhandled rejection would leave the
211
+ // request with no HTTP answer at all. Surfaced as a loud, twin-attributed 500.
212
+ return fail(500, `upstashvector: internal fault handling /${command} — ${e instanceof Error ? e.message : String(e)}`);
213
+ }
214
+ }
215
+ /** The vendor's error envelope: `{"error": "...", "status": N}` (upsert doc's own 422 example). */
216
+ function fail(status, error) {
217
+ return { status, body: { error, status }, headers: { ...BASE_HEADERS } };
218
+ }
219
+ async function runCommand(space, command, namespace, rawBody, query) {
220
+ switch (command) {
221
+ case 'info':
222
+ return space.info();
223
+ case 'list-namespaces':
224
+ return space.listNamespaces();
225
+ case 'delete-namespace':
226
+ space.deleteNamespace(namespace);
227
+ return 'Success';
228
+ case 'reset':
229
+ // `?all` is a BARE query flag — `ResetCommand` builds the literal string `reset?all`, so the
230
+ // parameter has an empty value and `query.get('all')` is `''`, not `null`. Testing
231
+ // `.has('all')` is what makes the SDK's own spelling work.
232
+ return space.reset(query.has('all') ? { all: true } : { namespace });
233
+ case 'upsert': {
234
+ const body = parseJsonBody(rawBody);
235
+ // The vendor accepts a single object OR an array (its own doc shows both curl forms).
236
+ return space.upsert(namespace, Array.isArray(body) ? body : [body]);
237
+ }
238
+ case 'update':
239
+ return space.update(namespace, parseJsonBody(rawBody));
240
+ case 'fetch':
241
+ return space.fetch(namespace, parseJsonBody(rawBody));
242
+ case 'range':
243
+ return space.range(namespace, parseJsonBody(rawBody));
244
+ case 'delete':
245
+ return space.delete(namespace, parseJsonBody(rawBody));
246
+ case 'query': {
247
+ const body = parseJsonBody(rawBody);
248
+ if (!Array.isArray(body))
249
+ return space.query(namespace, body);
250
+ // queryMany. See the module header, point 3: a ONE-element batch answers FLAT, which is the
251
+ // shape the installed SDK's own normalization proves the real API returns.
252
+ if (body.length === 0)
253
+ throw new VectorApiError('upstashvector: query batch must not be empty', 400);
254
+ const results = body.map((one) => space.query(namespace, one));
255
+ return body.length === 1 ? results[0] : results;
256
+ }
257
+ }
258
+ }
259
+ /** Parse a JSON request body, refusing what the vendor refuses. */
260
+ function parseJsonBody(raw) {
261
+ if (raw === undefined || raw.trim() === '')
262
+ throw new VectorApiError('upstashvector: request body is required', 400);
263
+ try {
264
+ return JSON.parse(raw);
265
+ }
266
+ catch {
267
+ throw new VectorApiError('upstashvector: request body is not valid JSON', 400);
268
+ }
269
+ }
270
+ export function upstashvectorTwinSnapshot() {
271
+ return {
272
+ resourceTypes: UPSTASHVECTOR_RESOURCE_TYPES,
273
+ implementedEndpoints: [
274
+ 'POST /upsert[/{namespace}] (one vector object or an array of them)',
275
+ 'POST /query[/{namespace}] (an object = one query; an array = queryMany)',
276
+ 'POST /fetch[/{namespace}] (by ids — positionally aligned, null for a miss — or by prefix)',
277
+ 'POST /range[/{namespace}] (offset cursor, nextCursor "" when exhausted)',
278
+ 'POST /update[/{namespace}] ({updated:0|1}, metadataUpdateMode OVERWRITE|PATCH)',
279
+ 'POST|DELETE /delete[/{namespace}] (by ids, prefix or metadata filter)',
280
+ 'POST|DELETE /reset[/{namespace}] and /reset?all',
281
+ 'GET|POST /info (counts + configuration + the per-namespace map)',
282
+ 'GET|POST /list-namespaces',
283
+ 'POST|DELETE /delete-namespace/{namespace}',
284
+ 'OPTIONS (CORS preflight)',
285
+ ],
286
+ };
287
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@volter/twin-upstashvector",
3
+ "version": "0.1.0",
4
+ "description": "Local Upstash Vector (REST API) twin: a real stateful vector index served over Upstash Vector's per-index REST protocol — POST /upsert, /query, /fetch, /range, /delete, /update, /reset, /info, /list-namespaces, /delete-namespace, each with its /{namespace} path form — with Bearer-token auth, the {result}/{error,status} envelope, and REAL similarity math (cosine/euclidean/dot-product scores computed from the stored vectors and normalized with Upstash's own published formulas, never faked) plus a real metadata-filter parser/evaluator. Built on @volter/world-core.",
5
+ "author": "Volter (https://github.com/volter-ai)",
6
+ "license": "Apache-2.0",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE",
11
+ "!**/*.test.ts",
12
+ "dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/volter-ai/twin.git",
17
+ "directory": "packages/twin/upstashvector"
18
+ },
19
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/upstashvector#readme",
20
+ "type": "module",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "default": "./dist/src/index.js"
25
+ }
26
+ },
27
+ "bin": {
28
+ "world-upstashvector": "dist/src/cli.js"
29
+ },
30
+ "scripts": {
31
+ "test": "bun test src/*.test.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "build": "node ../../../scripts/publish/build.mjs",
34
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
35
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
36
+ },
37
+ "peerDependencies": {
38
+ "@volter/world-core": "2.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "^1.2.20",
42
+ "@types/node": "^24.0.0",
43
+ "@volter/world-core": "2.0.0",
44
+ "@volter/world-tooling": "0.1.0",
45
+ "@upstash/vector": "1.2.3",
46
+ "typescript": "^5.9.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.3"
50
+ }
51
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-upstashvector CLI: serve the KERNEL-BACKED Upstash Vector REST twin, or run conformance.
4
+ // State lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
5
+ // lazy-imported so the bin runs without @volter/world-tooling (E2).
6
+ import { hasFlag, optionValue } from '@volter/world-core/args';
7
+ import { createUpstashVectorTwinServer } from './upstashvector-server.ts';
8
+ import { SIMILARITY_FUNCTIONS, type SimilarityFunction } from './upstashvector-store.ts';
9
+
10
+ const [cmd, ...rest] = process.argv.slice(2);
11
+ const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
12
+ const root = optionValue(rest, '--root') || undefined;
13
+ // The token this twin will DEMAND. Omit it and any non-empty credential is accepted (a missing one
14
+ // is still a vendor-shaped 401) — the right default for a local twin that holds no real secret.
15
+ const token = optionValue(rest, '--token') || undefined;
16
+ const dimension = Number(optionValue(rest, '--dimension', '0')) || undefined;
17
+ const similarityRaw = optionValue(rest, '--similarity') || undefined;
18
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
19
+
20
+ if (similarityRaw !== undefined && !(SIMILARITY_FUNCTIONS as readonly string[]).includes(similarityRaw.toUpperCase())) {
21
+ process.stderr.write(`--similarity must be one of ${SIMILARITY_FUNCTIONS.join(', ')}\n`);
22
+ process.exit(1);
23
+ }
24
+ const similarityFunction = similarityRaw?.toUpperCase() as SimilarityFunction | undefined;
25
+
26
+ if (cmd === 'serve' || cmd === undefined) {
27
+ const s = await createUpstashVectorTwinServer({
28
+ readOnly,
29
+ ...(root ? { root } : {}),
30
+ ...(port ? { port } : {}),
31
+ ...(token ? { token } : {}),
32
+ ...(dimension ? { dimension } : {}),
33
+ ...(similarityFunction ? { similarityFunction } : {}),
34
+ });
35
+ process.stdout.write(
36
+ `upstashvector twin (Upstash Vector REST: /upsert /query /fetch /range /delete /update /reset /info, namespaces as a path suffix)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`
37
+ + ` point a client at it with UPSTASH_VECTOR_REST_URL=http://127.0.0.1:${s.port} and any non-empty UPSTASH_VECTOR_REST_TOKEN\n`,
38
+ );
39
+ await keepProcessAlive();
40
+ } else if (cmd === 'conformance') {
41
+ const { checkUpstashVectorConformance } = await import('./upstashvector-conformance.ts');
42
+ const report = await checkUpstashVectorConformance();
43
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
44
+ if (!report.ok) process.exitCode = 1;
45
+ } else {
46
+ process.stdout.write('Usage: world-upstashvector serve|conformance [--port N] [--root DIR] [--token T] [--dimension N] [--similarity COSINE|EUCLIDEAN|DOT_PRODUCT] [--read-only]\n');
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,193 @@
1
+ // @volter/twin-upstashvector — the Upstash Vector REST API twin, built on the shared @volter/world-core
2
+ // kernel. Per-index host transport (`https://<slug>-<region>-vector.upstash.io`), the vendor's
3
+ // command-per-path REST surface (`/upsert`, `/query`, `/fetch`, `/range`, `/delete`, `/update`,
4
+ // `/reset`, `/info`, `/list-namespaces`, `/delete-namespace`) with namespaces as a PATH SUFFIX,
5
+ // Bearer-token auth, and the `{result}` / `{error,status}` envelope.
6
+ //
7
+ // Behind that protocol is a REAL vector index. Two things make that claim more than a slogan:
8
+ //
9
+ // 1. THE SCORES ARE COMPUTED, NEVER FAKED. `/query` ranks by a similarity score derived from the
10
+ // stored coordinates using Upstash's own published normalizations —
11
+ // COSINE `(1 + cosine_similarity)/2`, EUCLIDEAN `1/(1 + squared_distance)`,
12
+ // DOT_PRODUCT `(1 + dot_product)/2` — so any ranking this twin produces can be hand-checked
13
+ // with a calculator. A vector twin whose `score` was a counter or an insertion-order rank
14
+ // would let a broken retrieval pipeline pass its tests, which is the exact failure this pack
15
+ // exists to prevent.
16
+ // 2. METADATA FILTERING IS A REAL LANGUAGE. `filter` strings are tokenized, parsed to an AST and
17
+ // evaluated (`=`, `!=`, `<`, `>`, `<=`, `>=`, `GLOB`, `IN`, `NOT IN`, `CONTAINS`,
18
+ // `HAS FIELD`, `HAS NOT FIELD`, `AND`/`OR` with the documented precedence, nested `.` paths and
19
+ // `[]`/`[#-n]` array indexing) — so an expression this pack's own tests never anticipated
20
+ // still works, and a malformed one is REJECTED rather than silently matching everything.
21
+ //
22
+ // State lives ENTIRELY in the kernel action log (no side-store): every vector is one kernel
23
+ // subject, so a restart against the same root answers the same `/query`.
24
+ //
25
+ // THE FILED GAPS: this twin runs no embedding model, so the `data`-based `/upsert-data` and
26
+ // `/query-data` surfaces fail loudly rather than hashing text into a fake vector; sparse/hybrid
27
+ // indexes and the resumable-query family are modeled as filed gaps, not silently accepted.
28
+ // See README ## Coverage.
29
+ export {
30
+ handleUpstashVectorTwinRequest,
31
+ routeUpstashVectorPath,
32
+ upstashvectorTwinSnapshot,
33
+ extractUpstashVectorCredential,
34
+ UPSTASH_VECTOR_UNAUTHORIZED_ERROR,
35
+ } from './upstashvector-twin.ts';
36
+ export type {
37
+ UpstashVectorRequest,
38
+ UpstashVectorResponse,
39
+ UpstashVectorRoute,
40
+ UpstashVectorCommand,
41
+ UpstashVectorTwinSnapshot,
42
+ } from './upstashvector-twin.ts';
43
+
44
+ // The index core — exported so a caller can drive the vector semantics (and the MATH) in-process,
45
+ // without HTTP. `similarityScore` in particular is the claim this pack stands on, so it must be
46
+ // callable and checkable directly.
47
+ export {
48
+ IndexSpace,
49
+ SERVICE,
50
+ DEFAULT_NAMESPACE,
51
+ DEFAULT_SIMILARITY,
52
+ SIMILARITY_FUNCTIONS,
53
+ UPSTASHVECTOR_RESOURCE_TYPES,
54
+ CONFIG_SUBJECT_ID,
55
+ VectorApiError,
56
+ ReadOnlyError,
57
+ cosineSimilarity,
58
+ dotProduct,
59
+ squaredDistance,
60
+ similarityScore,
61
+ vectorSubjectId,
62
+ namespaceSubjectId,
63
+ } from './upstashvector-store.ts';
64
+ export type {
65
+ SimilarityFunction,
66
+ VectorContext,
67
+ VectorRow,
68
+ UpstashVectorResourceType,
69
+ } from './upstashvector-store.ts';
70
+
71
+ // The metadata-filter language. Exported because filter fidelity is a headline claim of this pack
72
+ // and a consumer (or a reviewer) must be able to run an expression against it directly.
73
+ export {
74
+ parseFilter,
75
+ tokenizeFilter,
76
+ evaluateFilter,
77
+ matchesFilter,
78
+ resolvePath,
79
+ parsePath,
80
+ globMatch,
81
+ FilterError,
82
+ MISSING,
83
+ } from './upstashvector-filter.ts';
84
+ export type { FilterNode, FilterValue, ComparisonOp, Token, TokenKind } from './upstashvector-filter.ts';
85
+
86
+ export { createUpstashVectorTwinFetch, createUpstashVectorTwinServer } from './upstashvector-server.ts';
87
+ export type { UpstashVectorServerOptions, UpstashVectorTwinFetchOptions } from './upstashvector-server.ts';
88
+
89
+ export {
90
+ mapVector,
91
+ mapNamespace,
92
+ pullUpstashVectorRange,
93
+ pullUpstashVectorNamespaces,
94
+ syncUpstashVectorFromReal,
95
+ } from './upstashvector-connector.ts';
96
+ export type {
97
+ UpstashVectorLikeClient,
98
+ UpstashVectorRealVector,
99
+ UpstashVectorBudgetedOptions,
100
+ } from './upstashvector-connector.ts';
101
+
102
+ // The client-side rate budget — the fail-closed backstop every live call goes through. The
103
+ // MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives here is this vendor's
104
+ // DECLARATION (window/ceiling/per-method weights) plus `guardUpstashVectorClient`, the choke point
105
+ // the connector entrypoints apply unconditionally. Exported so an operator can inspect spend
106
+ // (`snapshot`) and a caller can catch `UpstashVectorBudgetError` by type; there is deliberately no
107
+ // export that disables the guard.
108
+ export {
109
+ UPSTASHVECTOR_BUDGETED_METHODS,
110
+ UPSTASHVECTOR_BUDGET_CEILING,
111
+ UPSTASHVECTOR_BUDGET_MAX_RETRY_AFTER_S,
112
+ UPSTASHVECTOR_BUDGET_WINDOW_MS,
113
+ UPSTASHVECTOR_CALL_WEIGHTS,
114
+ UPSTASHVECTOR_RATE_BUDGET,
115
+ UpstashVectorBudget,
116
+ UpstashVectorBudgetError,
117
+ upstashvectorBudgetPath,
118
+ upstashvectorCallWeight,
119
+ upstashvectorClientBudget,
120
+ guardUpstashVectorClient,
121
+ } from './upstashvector-budget.ts';
122
+ export type {
123
+ UpstashVectorBudgetErrorKind,
124
+ UpstashVectorBudgetOptions,
125
+ UpstashVectorBudgetReservation,
126
+ UpstashVectorBudgetSnapshot,
127
+ } from './upstashvector-budget.ts';
128
+
129
+ // Registry descriptor: the pack self-describes so tooling can discover it.
130
+ import { registerPack, type TwinPack } from '@volter/world-core';
131
+ import { performUpstashVectorAction, syncUpstashVectorFromRemote } from './upstashvector-connector.ts';
132
+ import { UPSTASHVECTOR_RATE_BUDGET as RATE_BUDGET } from './upstashvector-budget.ts';
133
+ export const pack: TwinPack = {
134
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of
135
+ // the real state system. Moved 2026-09-08.
136
+ protocol: '2',
137
+ // The refresh ranges every namespace, which on a real index is a lot of vectors. Fifteen minutes.
138
+ refresh: { every: '15m', webhook: false, onDemand: { atMost: '30s' } },
139
+ stateSystem: { perform: performUpstashVectorAction, refresh: syncUpstashVectorFromRemote },
140
+ // The round trip is an upsert — Upstash Vector's own write, and an upsert of the same id is the
141
+ // vendor's own idempotence rather than a refusal, so a second send on a branch lands cleanly.
142
+ roundTrip: { method: 'POST', path: '/upsert', body: { id: 'round-trip', vector: [0.1, 0.2, 0.3], metadata: { round: 'trip' } }, headers: { authorization: 'Bearer round-trip' } },
143
+ parityOrigin: 'http://twin',
144
+ shapeParity: 'held',
145
+
146
+ // The SAME object upstashvector-budget.ts declares at module load — one source of truth, so
147
+ // registering the pack and importing the connector can never arm two different ceilings.
148
+ rateBudget: RATE_BUDGET,
149
+ vendor: 'upstashvector',
150
+ transport: 'rest',
151
+ archetype: 'crud',
152
+ bin: 'world-upstashvector',
153
+ resources: ['vector', 'namespace', 'config'],
154
+ specSource:
155
+ 'upstash.com/docs/vector/api/endpoints/* + features/{namespaces,filtering,similarityfunctions,metadata} + the installed @upstash/vector@1.2.3 compiled source (HttpClient + every Command\'s endpoint construction); see spec-sources.json.',
156
+ description:
157
+ 'Upstash Vector REST twin — a real stateful dense-vector index (upsert/query/fetch/range/update/delete/reset/info, namespaces as a path suffix) served over Upstash Vector\'s REST protocol with Bearer auth and the {result}/{error,status} envelope. Similarity scores are COMPUTED from the stored coordinates using Upstash\'s own published normalizations (cosine/euclidean/dot-product), and metadata filtering is a real parsed expression language, so rankings are hand-checkable rather than faked. Kernel-backed; no embedding model (data-based upsert/query fail loudly).',
158
+ browserRouting: { apiPathPrefix: '/', loaderHost: 'https://twin-vector.upstash.io' },
159
+ // Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
160
+ // 2026-08-31). The `scopes` entry is the exact PACKAGE name, not an apex: the @upstash/ scope
161
+ // spans several DIFFERENT Upstash products, each with its own pack. The stem comes from
162
+ // UPSTASH_VECTOR_REST_URL / _REST_TOKEN (the SDK's own env names, including its NEXT_PUBLIC_
163
+ // variants, which the framework-prefix strip handles).
164
+ adoption: {
165
+ // Upstash's official Vector Python SDK (upstash/vector-py).
166
+ pypi: ['upstash-vector'],
167
+ sdks: ['@upstash/vector'], scopes: ['@upstash/vector'], envStems: ['UPSTASHVECTOR'],
168
+ },
169
+ // SAME APEX as Upstash Redis, so the two are told apart by host SHAPE — the one discriminator
170
+ // that is stable and checkable:
171
+ // Vector https://<slug>-<region>-vector.upstash.io (living-whale-89944-us1-vector.upstash.io,
172
+ // boss-lioness-52864-gcp-usc1-vector.upstash.io)
173
+ // Redis https://<slug>.upstash.io (striking-osprey-20681.upstash.io)
174
+ // Every real Vector index endpoint carries a `-vector` token immediately before the apex: the
175
+ // product suffix is appended to the index slug (which is where the region lives). Corroborated
176
+ // three ways — ~90 literal hostnames found in public code, live DNS (real index hosts CNAME into
177
+ // per-region `*-vector` fleets), and the REST token itself, which base64-decodes to
178
+ // `<slug><role>` where `<slug>` is exactly the host minus `-vector.upstash.io`.
179
+ //
180
+ // The two predicates are MUTUALLY EXCLUSIVE by construction rather than by declaration order:
181
+ // the Redis rule (still in the hand VENDOR_HOSTS table, because it needs exclusions this
182
+ // descriptor's rule kinds cannot express) explicitly excludes `*-vector.upstash.io`, and
183
+ // vendor-hosts.test.ts pins both directions. `resolveTwin` iterates VENDOR_HOSTS in insertion
184
+ // order, so an overlap would have made routing depend on key order — it does not.
185
+ //
186
+ // Upstash publishes no hostname format in its docs (every example uses the
187
+ // $UPSTASH_VECTOR_REST_URL placeholder), so this rule is grounded in observed reality rather
188
+ // than a spec. A custom-domain or private-link Vector endpoint would not match; that is the safe
189
+ // direction — it simply is not intercepted, rather than being routed to the wrong twin.
190
+ hosts: [{ suffix: '-vector.upstash.io' }],
191
+ };
192
+
193
+ registerPack(pack);