@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,347 @@
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 {
49
+ DEFAULT_NAMESPACE,
50
+ IndexSpace,
51
+ ReadOnlyError,
52
+ VectorApiError,
53
+ UPSTASHVECTOR_RESOURCE_TYPES,
54
+ type SimilarityFunction,
55
+ type VectorContext,
56
+ } from './upstashvector-store.ts';
57
+
58
+ export type UpstashVectorRequest = {
59
+ method: string;
60
+ path: string;
61
+ body?: string;
62
+ headers?: Record<string, string>;
63
+ occurredAt?: string;
64
+ root?: string;
65
+ readOnly?: boolean;
66
+ /**
67
+ * The token this twin accepts. When set, the request's credential must match it EXACTLY.
68
+ * When omitted the twin accepts any NON-EMPTY credential and still 401s a missing/blank one —
69
+ * the honest default for a local twin whose whole point is that you hold no real secret.
70
+ */
71
+ token?: string;
72
+ /** The dimension this index was created with. Omit to let the first upsert lock one in. */
73
+ dimension?: number;
74
+ /** The metric this index was created with. Defaults to COSINE. */
75
+ similarityFunction?: SimilarityFunction;
76
+ };
77
+
78
+ export type UpstashVectorResponse = { status: number; body: unknown; headers?: Record<string, string> };
79
+
80
+ /** Response headers. `content-type` is what the SDK's `res.json()` requires. */
81
+ const BASE_HEADERS: Record<string, string> = {
82
+ 'content-type': 'application/json; charset=utf-8',
83
+ 'access-control-allow-credentials': 'true',
84
+ };
85
+
86
+ /**
87
+ * The 401 body — VERBATIM from upstash.com/docs/vector/api/get-started, whose error example is
88
+ * exactly `{"error": "Unauthorized: Invalid auth token", "status": 401}`.
89
+ *
90
+ * (§9 round 1 caught this pack UNDER-claiming: an earlier draft used its own wording and declared
91
+ * the vendor's text unpublished, when the get-started page publishes it. Note the capital `I` in
92
+ * "Invalid" — matching it exactly is the difference between a consumer's error assertion passing
93
+ * against this twin and only passing against the real service. This is now one of TWO strings in
94
+ * the pack quoted verbatim from the vendor, alongside the dimension-mismatch 422.)
95
+ */
96
+ export const UPSTASH_VECTOR_UNAUTHORIZED_ERROR = 'Unauthorized: Invalid auth token';
97
+
98
+ /** Every endpoint this twin routes, and the ones it deliberately refuses. */
99
+ export type UpstashVectorCommand =
100
+ | 'upsert' | 'query' | 'fetch' | 'range' | 'delete' | 'update' | 'reset'
101
+ | 'info' | 'list-namespaces' | 'delete-namespace';
102
+
103
+ /** Real vendor endpoints this twin does NOT serve, each with the manifest gap that tracks it. */
104
+ const UNMODELED_ENDPOINTS: Record<string, string> = {
105
+ 'upsert-data': 'upstashvector.embedding.upsert_data',
106
+ 'query-data': 'upstashvector.embedding.query_data',
107
+ 'resumable-query': 'upstashvector.resumable.start',
108
+ 'resumable-query-data': 'upstashvector.resumable.start',
109
+ 'resumable-query-next': 'upstashvector.resumable.next',
110
+ 'resumable-query-end': 'upstashvector.resumable.stop',
111
+ };
112
+
113
+ const MODELED: ReadonlySet<string> = new Set<UpstashVectorCommand>([
114
+ 'upsert', 'query', 'fetch', 'range', 'delete', 'update', 'reset', 'info', 'list-namespaces', 'delete-namespace',
115
+ ]);
116
+
117
+ function lowerHeaders(h: Record<string, string> | undefined): Record<string, string> {
118
+ const out: Record<string, string> = {};
119
+ for (const [k, v] of Object.entries(h ?? {})) out[k.toLowerCase()] = v;
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Extract the caller's credential from `Authorization: Bearer <token>` (what the SDK sends) or a
125
+ * bare `Authorization: <token>`.
126
+ *
127
+ * The optional-group regex matters: `Authorization: Bearer ` — the scheme with an EMPTY token —
128
+ * trims to the bare word "Bearer". A `/^bearer\s+(.*)$/` pattern does not match it, so a naive
129
+ * implementation falls through and treats the literal string "Bearer" as the credential,
130
+ * authenticating a request that carries no token at all.
131
+ */
132
+ export function extractUpstashVectorCredential(headers: Record<string, string> | undefined): string | null {
133
+ const raw = lowerHeaders(headers).authorization;
134
+ if (raw === undefined) return null;
135
+ const trimmed = raw.trim();
136
+ const bearer = /^bearer(?:\s+(.*))?$/i.exec(trimmed);
137
+ const token = bearer ? (bearer[1] ?? '').trim() : trimmed;
138
+ return token === '' ? null : token;
139
+ }
140
+
141
+ /** The parsed shape of a request path: which command, and which namespace. */
142
+ export type UpstashVectorRoute = { command: string; namespace: string };
143
+
144
+ /**
145
+ * Split `/query/my-ns` into `{command:'query', namespace:'my-ns'}`.
146
+ *
147
+ * Everything after the first segment is the namespace, percent-DECODED and re-joined — a namespace
148
+ * is an arbitrary string, so one containing a `/` must survive the round trip rather than being
149
+ * truncated at the first slash into a DIFFERENT namespace's data.
150
+ */
151
+ export function routeUpstashVectorPath(path: string): UpstashVectorRoute {
152
+ const clean = (path.split('?')[0] ?? '/').replace(/^\/+/, '');
153
+ const segments = clean.split('/');
154
+ const command = decodeSegment(segments[0] ?? '').toLowerCase();
155
+ const namespace = segments.length > 1 ? segments.slice(1).map(decodeSegment).join('/') : DEFAULT_NAMESPACE;
156
+ return { command, namespace };
157
+ }
158
+
159
+ function decodeSegment(seg: string): string {
160
+ try {
161
+ return decodeURIComponent(seg);
162
+ } catch {
163
+ return seg; // a malformed escape is passed through rather than turned into a 500
164
+ }
165
+ }
166
+
167
+ export async function handleUpstashVectorTwinRequest(req: UpstashVectorRequest): Promise<UpstashVectorResponse> {
168
+ const method = req.method.toUpperCase();
169
+ const [rawPath, rawQuery] = req.path.split('?');
170
+ const query = new URLSearchParams(rawQuery ?? '');
171
+ const headers = lowerHeaders(req.headers);
172
+ const occurredAt = req.occurredAt ?? new Date().toISOString();
173
+
174
+ // CORS preflight. Answered before auth — a browser preflight carries no Authorization header by
175
+ // definition, so 401ing it would make the twin unusable from any browser client.
176
+ if (method === 'OPTIONS') {
177
+ return {
178
+ status: 200,
179
+ body: null,
180
+ headers: {
181
+ 'access-control-allow-origin': headers.origin ?? '*',
182
+ 'access-control-allow-methods': 'GET,POST,PUT,DELETE,HEAD,OPTIONS',
183
+ 'access-control-allow-headers': headers['access-control-request-headers'] ?? 'authorization,content-type',
184
+ 'access-control-allow-credentials': 'true',
185
+ },
186
+ };
187
+ }
188
+
189
+ // upstash.com/docs/vector/api/get-started documents 405 for an unsupported method. DELETE is
190
+ // included because the docs spell `/reset` and `/delete-namespace` with `-X DELETE`.
191
+ if (!['GET', 'POST', 'PUT', 'HEAD', 'DELETE'].includes(method)) {
192
+ return fail(405, `Method Not Allowed: ${method}`);
193
+ }
194
+
195
+ // ── auth, before anything else ─────────────────────────────────────────────────────────────
196
+ const credential = extractUpstashVectorCredential(req.headers);
197
+ const authorized = req.token === undefined ? credential !== null : credential === req.token;
198
+ if (!authorized) return fail(401, UPSTASH_VECTOR_UNAUTHORIZED_ERROR);
199
+
200
+ const { command, namespace } = routeUpstashVectorPath(rawPath ?? '/');
201
+
202
+ if (command === '') return fail(404, 'Not Found: no endpoint at /');
203
+ if (!MODELED.has(command)) {
204
+ const gap = UNMODELED_ENDPOINTS[command];
205
+ // A REAL vendor endpoint this twin has not built fails 404 naming the filed gap; anything else
206
+ // is simply not an Upstash Vector endpoint. Neither can ever be mistaken for a success.
207
+ return fail(404, gap !== undefined
208
+ ? `upstashvector: /${command} is real Upstash Vector surface this twin does not model yet (${gap} is the filed gap)`
209
+ : `Not Found: /${command} is not an Upstash Vector endpoint`);
210
+ }
211
+
212
+ // GET AND HEAD ARE SAFE METHODS, so neither may mutate anything.
213
+ //
214
+ // §9 round 1 found the worst bug in this file: the HEAD check used to live AFTER `runCommand`
215
+ // and `flush()`, so `HEAD /reset` emptied the index and `HEAD /delete` removed vectors — then
216
+ // suppressed the body and answered a read-shaped, body-less 200. A destructive operation
217
+ // disguised as a read is the worst available failure mode. Forcing the request read-only means
218
+ // a HEAD to a write endpoint takes the ordinary 405 read-only path, while `HEAD /info` still
219
+ // answers 200 with no body.
220
+ //
221
+ // GET is included for the same reason (RFC 9110 defines both as safe): caches, prefetchers,
222
+ // crawlers and link-checkers issue GET freely, and no documented Upstash Vector client ever
223
+ // sends GET to a write endpoint — the SDK POSTs everything, and the docs use POST or DELETE. So
224
+ // the cost of refusing is zero for every real caller, while the cost of allowing it is an index
225
+ // destroyed by something that thought it was reading. What the REAL service does with
226
+ // `GET /reset` is UNVERIFIED (no live index to probe) and is filed as
227
+ // `upstashvector.protocol.get_on_a_write_endpoint`; this twin takes the safe side deliberately
228
+ // rather than guessing the destructive one.
229
+ const readOnly = (req.readOnly ?? false) || method === 'HEAD' || method === 'GET';
230
+
231
+ const ctx: VectorContext = {
232
+ occurredAt,
233
+ ...(req.root !== undefined ? { root: req.root } : {}),
234
+ ...(readOnly ? { readOnly: true } : {}),
235
+ ...(req.dimension !== undefined ? { dimension: req.dimension } : {}),
236
+ ...(req.similarityFunction !== undefined ? { similarityFunction: req.similarityFunction } : {}),
237
+ };
238
+
239
+ try {
240
+ const space = new IndexSpace(ctx);
241
+ const result = await runCommand(space, command as UpstashVectorCommand, namespace, req.body, query);
242
+ await space.flush();
243
+ // HEAD answers with no body at all.
244
+ if (method === 'HEAD') return { status: 200, body: null, headers: { ...BASE_HEADERS } };
245
+ return { status: 200, body: { result }, headers: { ...BASE_HEADERS } };
246
+ } catch (e) {
247
+ if (e instanceof ReadOnlyError) {
248
+ // Distinguish the two reasons a write can be refused. §9 round 2: a `HEAD /upsert` against a
249
+ // perfectly writable twin used to answer "this twin was started read-only", which is simply
250
+ // false and sends the reader hunting for a flag they never set.
251
+ return fail(405, readOnly && !(req.readOnly ?? false)
252
+ ? `upstashvector: ${method} is a safe method and cannot modify the index — use POST (or PUT/DELETE where the endpoint documents it)`
253
+ : e.message);
254
+ }
255
+ if (e instanceof VectorApiError) return fail(e.status, e.message);
256
+ // LAST-RESORT ENVELOPE. A twin's caller must always get a response it can reason about, and an
257
+ // internal fault must be VISIBLE rather than silent — an unhandled rejection would leave the
258
+ // request with no HTTP answer at all. Surfaced as a loud, twin-attributed 500.
259
+ return fail(500, `upstashvector: internal fault handling /${command} — ${e instanceof Error ? e.message : String(e)}`);
260
+ }
261
+ }
262
+
263
+ /** The vendor's error envelope: `{"error": "...", "status": N}` (upsert doc's own 422 example). */
264
+ function fail(status: number, error: string): UpstashVectorResponse {
265
+ return { status, body: { error, status }, headers: { ...BASE_HEADERS } };
266
+ }
267
+
268
+ async function runCommand(
269
+ space: IndexSpace,
270
+ command: UpstashVectorCommand,
271
+ namespace: string,
272
+ rawBody: string | undefined,
273
+ query: URLSearchParams,
274
+ ): Promise<unknown> {
275
+ switch (command) {
276
+ case 'info':
277
+ return space.info();
278
+ case 'list-namespaces':
279
+ return space.listNamespaces();
280
+ case 'delete-namespace':
281
+ space.deleteNamespace(namespace);
282
+ return 'Success';
283
+ case 'reset':
284
+ // `?all` is a BARE query flag — `ResetCommand` builds the literal string `reset?all`, so the
285
+ // parameter has an empty value and `query.get('all')` is `''`, not `null`. Testing
286
+ // `.has('all')` is what makes the SDK's own spelling work.
287
+ return space.reset(query.has('all') ? { all: true } : { namespace });
288
+ case 'upsert': {
289
+ const body = parseJsonBody(rawBody);
290
+ // The vendor accepts a single object OR an array (its own doc shows both curl forms).
291
+ return space.upsert(namespace, Array.isArray(body) ? body : [body]);
292
+ }
293
+ case 'update':
294
+ return space.update(namespace, parseJsonBody(rawBody));
295
+ case 'fetch':
296
+ return space.fetch(namespace, parseJsonBody(rawBody));
297
+ case 'range':
298
+ return space.range(namespace, parseJsonBody(rawBody));
299
+ case 'delete':
300
+ return space.delete(namespace, parseJsonBody(rawBody));
301
+ case 'query': {
302
+ const body = parseJsonBody(rawBody);
303
+ if (!Array.isArray(body)) return space.query(namespace, body);
304
+ // queryMany. See the module header, point 3: a ONE-element batch answers FLAT, which is the
305
+ // shape the installed SDK's own normalization proves the real API returns.
306
+ if (body.length === 0) throw new VectorApiError('upstashvector: query batch must not be empty', 400);
307
+ const results = body.map((one) => space.query(namespace, one));
308
+ return body.length === 1 ? results[0]! : results;
309
+ }
310
+ }
311
+ }
312
+
313
+ /** Parse a JSON request body, refusing what the vendor refuses. */
314
+ function parseJsonBody(raw: string | undefined): unknown {
315
+ if (raw === undefined || raw.trim() === '') throw new VectorApiError('upstashvector: request body is required', 400);
316
+ try {
317
+ return JSON.parse(raw);
318
+ } catch {
319
+ throw new VectorApiError('upstashvector: request body is not valid JSON', 400);
320
+ }
321
+ }
322
+
323
+ // ── the self-referential conformance snapshot ──────────────────────────────────────────────────
324
+
325
+ export type UpstashVectorTwinSnapshot = {
326
+ resourceTypes: readonly string[];
327
+ implementedEndpoints: readonly string[];
328
+ };
329
+
330
+ export function upstashvectorTwinSnapshot(): UpstashVectorTwinSnapshot {
331
+ return {
332
+ resourceTypes: UPSTASHVECTOR_RESOURCE_TYPES,
333
+ implementedEndpoints: [
334
+ 'POST /upsert[/{namespace}] (one vector object or an array of them)',
335
+ 'POST /query[/{namespace}] (an object = one query; an array = queryMany)',
336
+ 'POST /fetch[/{namespace}] (by ids — positionally aligned, null for a miss — or by prefix)',
337
+ 'POST /range[/{namespace}] (offset cursor, nextCursor "" when exhausted)',
338
+ 'POST /update[/{namespace}] ({updated:0|1}, metadataUpdateMode OVERWRITE|PATCH)',
339
+ 'POST|DELETE /delete[/{namespace}] (by ids, prefix or metadata filter)',
340
+ 'POST|DELETE /reset[/{namespace}] and /reset?all',
341
+ 'GET|POST /info (counts + configuration + the per-namespace map)',
342
+ 'GET|POST /list-namespaces',
343
+ 'POST|DELETE /delete-namespace/{namespace}',
344
+ 'OPTIONS (CORS preflight)',
345
+ ],
346
+ };
347
+ }