@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.
- package/LICENSE +202 -0
- package/README.md +233 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +45 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +106 -0
- package/dist/src/upstashvector-budget.d.ts +84 -0
- package/dist/src/upstashvector-budget.js +443 -0
- package/dist/src/upstashvector-capabilities.d.ts +4 -0
- package/dist/src/upstashvector-capabilities.js +1108 -0
- package/dist/src/upstashvector-conformance.d.ts +7 -0
- package/dist/src/upstashvector-conformance.js +163 -0
- package/dist/src/upstashvector-connector.d.ts +109 -0
- package/dist/src/upstashvector-connector.js +286 -0
- package/dist/src/upstashvector-filter.d.ts +80 -0
- package/dist/src/upstashvector-filter.js +564 -0
- package/dist/src/upstashvector-server.d.ts +32 -0
- package/dist/src/upstashvector-server.js +56 -0
- package/dist/src/upstashvector-store.d.ts +248 -0
- package/dist/src/upstashvector-store.js +883 -0
- package/dist/src/upstashvector-twin.d.ts +67 -0
- package/dist/src/upstashvector-twin.js +287 -0
- package/package.json +51 -0
- package/src/cli.ts +47 -0
- package/src/index.ts +193 -0
- package/src/upstashvector-budget.ts +489 -0
- package/src/upstashvector-capabilities.ts +1242 -0
- package/src/upstashvector-conformance.ts +175 -0
- package/src/upstashvector-connector.ts +328 -0
- package/src/upstashvector-filter.ts +525 -0
- package/src/upstashvector-server.ts +86 -0
- package/src/upstashvector-store.ts +944 -0
- package/src/upstashvector-twin.ts +347 -0
|
@@ -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);
|