@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,883 @@
1
+ // THE VECTOR INDEX CORE — a real, stateful Upstash Vector index folded out of the @volter/world-core
2
+ // kernel action log. This is the file that makes this pack a twin rather than a stub: `/query`
3
+ // returns the vectors `/upsert` actually wrote, RANKED BY A SCORE COMPUTED FROM THE STORED
4
+ // COORDINATES with Upstash's own published normalization, a dimension mismatch is refused the way
5
+ // the vendor refuses it, and metadata filtering runs a real parsed expression over real metadata.
6
+ //
7
+ // ── GROUNDED (2026-08-19) — see spec-sources.json ─────────────────────────────────────────────
8
+ // (a) upstash.com/docs/vector/api/endpoints/{upsert,query,fetch,range,delete,reset,info} — the
9
+ // request/response JSON of every endpoint, quoted in each method's docstring below;
10
+ // (b) upstash.com/docs/vector/features/{namespaces,filtering,similarityfunctions,metadata};
11
+ // (c) the ACTUALLY-INSTALLED `@upstash/vector@1.2.3` package's own compiled source
12
+ // (node_modules/@upstash/vector/dist/chunk-*.mjs — its HttpClient and every Command's
13
+ // endpoint construction), read read-only. This is where the SDK-shaped facts come from:
14
+ // every command is sent as **POST** regardless of the documented method, namespaces are a
15
+ // PATH SUFFIX, `reset({all:true})` is `reset?all`, and `queryMany` posts an ARRAY to
16
+ // `/query`.
17
+ // Unlike its sibling `upstash`, this pack could NOT live-probe a real index (no account), so
18
+ // error STRINGS that Upstash does not publish are twin-authored rather than verbatim. Exactly one
19
+ // error message here is quoted from the vendor's own docs — the dimension mismatch — and it is
20
+ // marked as such. The README's ## Coverage says which is which; nothing below claims a vendor
21
+ // string it did not read.
22
+ //
23
+ // ── STATE LIVES IN THE KERNEL, NOT IN A MAP ───────────────────────────────────────────────────
24
+ // Every vector is ONE kernel subject (`type:'vector'`, id `vec:<ns>:<id>`), written through
25
+ // `applyTwinWrite` and read back through `projectResources`. Namespaces and the index's own
26
+ // configuration are their own subject types. There is no side-store: kill the process, point a new
27
+ // one at the same root, and `/query` still ranks the same vectors.
28
+ //
29
+ // ── THE SCORES ARE COMPUTED, NEVER FAKED ──────────────────────────────────────────────────────
30
+ // `similarityScore` implements the three formulas Upstash publishes, verbatim, at
31
+ // upstash.com/docs/vector/features/similarityfunctions:
32
+ // COSINE (1 + cosine_similarity(v1, v2)) / 2
33
+ // EUCLIDEAN 1 / (1 + squared_distance(v1, v2))
34
+ // DOT_PRODUCT (1 + dot_product(v1, v2)) / 2
35
+ // so a caller can hand-check any ranking this twin produces with a calculator. That is the whole
36
+ // point: a vector twin whose `score` was a counter, a random number, or an insertion-order rank
37
+ // would let a broken retrieval pipeline pass its tests.
38
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
39
+ import { FilterError, matchesFilter, parseFilter, evaluateFilter } from "./upstashvector-filter.js";
40
+ export const SERVICE = 'upstashvector';
41
+ /** The default namespace is named `""` (empty string) — upstash.com/docs/vector/features/namespaces. */
42
+ export const DEFAULT_NAMESPACE = '';
43
+ /** The three metrics an Upstash Vector index can be created with (`/info`.similarityFunction). */
44
+ export const SIMILARITY_FUNCTIONS = ['COSINE', 'EUCLIDEAN', 'DOT_PRODUCT'];
45
+ /** The `/info`.indexType values. This twin models DENSE only; see `UNSUPPORTED_SPARSE`. */
46
+ export const DEFAULT_SIMILARITY = 'COSINE';
47
+ /**
48
+ * A vendor-shaped API failure: a message and the HTTP status it is served with.
49
+ *
50
+ * The status codes are the ones upstash.com/docs/vector/api/get-started enumerates — 400 for
51
+ * "syntax/command errors", 401 for auth, 405 for an unsupported method — plus the 422 the upsert
52
+ * endpoint's own error example shows for a dimension mismatch.
53
+ */
54
+ export class VectorApiError extends Error {
55
+ status;
56
+ constructor(message, status) {
57
+ super(message);
58
+ this.status = status;
59
+ this.name = 'VectorApiError';
60
+ }
61
+ }
62
+ /** Thrown when a write is attempted against a read-only twin. Mapped to HTTP 405 by the handler. */
63
+ export class ReadOnlyError extends Error {
64
+ constructor() {
65
+ super('read_only: this twin was started read-only; omit readOnly to accept writes');
66
+ this.name = 'ReadOnlyError';
67
+ }
68
+ }
69
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
70
+ // THE MATH — real, deterministic, hand-checkable
71
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
72
+ /**
73
+ * Refuse to score vectors of different lengths.
74
+ *
75
+ * Every one of these functions is EXPORTED, so a consumer (or a reviewer) can call them directly,
76
+ * and a naive `for (i < a.length)` loop silently ignores b's extra coordinates — computing a
77
+ * confident number from a truncated overlap. `dotProduct([1,2], [1,2,3])` would answer 5 as though
78
+ * the third dimension did not exist. A wrong similarity is far worse than a thrown error, because
79
+ * it flows straight into a ranking that still looks plausible. (The request path never reaches
80
+ * here with a mismatch — `checkDimension` refuses it with the vendor's 422 first — so this guard
81
+ * exists for the direct-call surface.)
82
+ */
83
+ function assertSameLength(a, b) {
84
+ if (a.length !== b.length) {
85
+ throw new VectorApiError(`upstashvector: cannot compare vectors of different dimensions (${a.length} vs ${b.length})`, 422);
86
+ }
87
+ }
88
+ /** Σ aᵢ·bᵢ. */
89
+ export function dotProduct(a, b) {
90
+ assertSameLength(a, b);
91
+ let sum = 0;
92
+ for (let i = 0; i < a.length; i++)
93
+ sum += a[i] * b[i];
94
+ return sum;
95
+ }
96
+ /** Σ (aᵢ−bᵢ)² — the SQUARED euclidean distance, which is what the vendor's formula takes. */
97
+ export function squaredDistance(a, b) {
98
+ assertSameLength(a, b);
99
+ let sum = 0;
100
+ for (let i = 0; i < a.length; i++) {
101
+ const d = a[i] - b[i];
102
+ sum += d * d;
103
+ }
104
+ return sum;
105
+ }
106
+ /**
107
+ * dot(a,b) / (‖a‖·‖b‖).
108
+ *
109
+ * ZERO-VECTOR EDGE (twin-defined, and the vendor does not document it): the true cosine of a
110
+ * zero-length vector is undefined — the denominator is 0. Returning the raw `0/0` would put a
111
+ * `NaN` into the score, which `JSON.stringify` renders as `null`, so a caller would receive a
112
+ * result row whose `score` key was null and no error anywhere. This returns 0 (a normalized score
113
+ * of exactly 0.5, "no information") instead, so the response stays well-typed. Documented in the
114
+ * README's ## Coverage as an unverified edge rather than presented as vendor behaviour.
115
+ */
116
+ export function cosineSimilarity(a, b) {
117
+ assertSameLength(a, b);
118
+ let dot = 0;
119
+ let na = 0;
120
+ let nb = 0;
121
+ for (let i = 0; i < a.length; i++) {
122
+ dot += a[i] * b[i];
123
+ na += a[i] * a[i];
124
+ nb += b[i] * b[i];
125
+ }
126
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
127
+ return denom === 0 ? 0 : dot / denom;
128
+ }
129
+ /**
130
+ * The NORMALIZED score Upstash returns, per metric. Quoted verbatim from
131
+ * upstash.com/docs/vector/features/similarityfunctions:
132
+ * COSINE "(1 + cosine_similarity(v1, v2)) / 2"
133
+ * EUCLIDEAN "1 / (1 + squared_distance(v1, v2))"
134
+ * DOT_PRODUCT "(1 + dot_product(v1, v2)) / 2"
135
+ * All three are monotonically increasing in similarity, so a higher score is always a better match.
136
+ */
137
+ export function similarityScore(fn, a, b) {
138
+ switch (fn) {
139
+ case 'COSINE': return (1 + cosineSimilarity(a, b)) / 2;
140
+ case 'EUCLIDEAN': return 1 / (1 + squaredDistance(a, b));
141
+ case 'DOT_PRODUCT': return (1 + dotProduct(a, b)) / 2;
142
+ }
143
+ }
144
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
145
+ // SUBJECT IDS
146
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
147
+ /**
148
+ * A vector's kernel subject id.
149
+ *
150
+ * Both components are percent-encoded, which is what makes the join UNAMBIGUOUS: `encodeURIComponent`
151
+ * escapes `:` to `%3A`, so a namespace named `a` holding id `b:c` and a namespace named `a:b`
152
+ * holding id `c` produce different subject ids. A naive `vec:${ns}:${id}` would map both to
153
+ * `vec:a:b:c` — one vector silently overwriting the other, across namespaces, which is precisely
154
+ * the isolation namespaces exist to provide.
155
+ */
156
+ export function vectorSubjectId(ns, vid) {
157
+ return `vec:${encodeKeyPart(ns, 'namespace')}:${encodeKeyPart(vid, 'vector id')}`;
158
+ }
159
+ /** A namespace's kernel subject id. */
160
+ export function namespaceSubjectId(ns) {
161
+ return `ns:${encodeKeyPart(ns, 'namespace')}`;
162
+ }
163
+ /**
164
+ * Percent-encode one subject-key component, refusing what cannot be encoded.
165
+ *
166
+ * `encodeURIComponent` THROWS a raw `URIError` on an unpaired UTF-16 surrogate, and `JSON.parse`
167
+ * happily produces one from `"\ud800"` — so before this guard a lone surrogate in an id escaped as
168
+ * an unhandled `URIError`: a twin-attributed HTTP 500 on `/upsert`, and (worse) a raw throw out of
169
+ * `mapVector` on the connector's hot path, AFTER the vendor calls had already been paid for. §9
170
+ * round 1 found both.
171
+ *
172
+ * It is refused rather than repaired on purpose. The tempting fix — replacing the surrogate, or
173
+ * round-tripping through UTF-8 — maps DIFFERENT ids onto the SAME bytes (every lone surrogate
174
+ * becomes U+FFFD), which would silently merge two distinct vectors: a collision in the one place
175
+ * this module works hardest to make collisions impossible. A 400 is the honest answer.
176
+ *
177
+ * HONESTY: Upstash's own behaviour for a lone-surrogate id is UNVERIFIED (no live index to probe),
178
+ * so this status/message is the twin's own — see `upstashvector.errors.vendor_error_strings`.
179
+ */
180
+ function encodeKeyPart(value, what) {
181
+ try {
182
+ return encodeURIComponent(value);
183
+ }
184
+ catch {
185
+ throw new VectorApiError(`upstashvector: ${what} contains an unpaired UTF-16 surrogate and cannot be addressed — refusing rather than lossily rewriting it (two different ids would collapse onto one)`, 400);
186
+ }
187
+ }
188
+ /** The single index-configuration subject. */
189
+ export const CONFIG_SUBJECT_ID = 'config:index';
190
+ /** The kernel subject types this twin writes. Mirrored by the conformance check. */
191
+ export const UPSTASHVECTOR_RESOURCE_TYPES = ['vector', 'namespace', 'config'];
192
+ /**
193
+ * A synchronous in-memory image of the whole index for the duration of ONE request.
194
+ *
195
+ * Seeded from `projectResources` (the kernel IS the source of truth); every mutation records what
196
+ * it touched, and `flush` writes exactly those subjects back. Nothing here outlives the request —
197
+ * no cache, no singleton, no state a later request could inherit.
198
+ */
199
+ export class IndexSpace {
200
+ ctx;
201
+ rows = new Map(); // subjectId → row
202
+ namespaces = new Set();
203
+ touched = new Map();
204
+ /** subjectId → the last write ordinal seen, INCLUDING for subjects currently deleted. */
205
+ revs = new Map();
206
+ dimension;
207
+ similarity;
208
+ configDirty = false;
209
+ /** True when stored vectors disagree on length, so no dimension can be honestly inferred. */
210
+ mixedDimensions = false;
211
+ constructor(ctx) {
212
+ this.ctx = ctx;
213
+ let storedDimension = null;
214
+ let storedSimilarity = null;
215
+ for (const r of projectResources(SERVICE, ctx.root)) {
216
+ const rec = r;
217
+ if (r.type === 'config' && r.id === CONFIG_SUBJECT_ID) {
218
+ if (typeof rec._rev === 'number')
219
+ this.revs.set(r.id, rec._rev);
220
+ if (typeof rec.dimension === 'number')
221
+ storedDimension = rec.dimension;
222
+ if (typeof rec.similarity === 'string' && SIMILARITY_FUNCTIONS.includes(rec.similarity)) {
223
+ storedSimilarity = rec.similarity;
224
+ }
225
+ continue;
226
+ }
227
+ if (r.type === 'namespace') {
228
+ if (typeof rec._rev === 'number')
229
+ this.revs.set(r.id, rec._rev);
230
+ if (rec.gone !== true && typeof rec.name === 'string')
231
+ this.namespaces.add(rec.name);
232
+ continue;
233
+ }
234
+ if (r.type !== 'vector')
235
+ continue;
236
+ // Record the ordinal FIRST, for deleted rows too: a vector that is later re-upserted must
237
+ // CONTINUE the sequence rather than restart it, or a same-millisecond rewrite of a value the
238
+ // subject ALREADY held would hash to an action that already exists and be dropped as a
239
+ // replay — the reply reporting success while state kept the old value. (The identical
240
+ // hazard `upstash-store.ts` documents at `KeyRow._rev`.)
241
+ if (typeof rec._rev === 'number')
242
+ this.revs.set(r.id, rec._rev);
243
+ if (rec.gone === true)
244
+ continue;
245
+ if (typeof rec.ns !== 'string' || typeof rec.vid !== 'string' || !Array.isArray(rec.values))
246
+ continue;
247
+ // REHYDRATION GUARD. Coordinates come back out of JSON, where a NaN was serialised as `null`
248
+ // and `Number(null)` is 0 — so a coercing read silently substitutes a real coordinate with
249
+ // zero, and a non-finite one poisons every score it touches into `"score": null` on the wire.
250
+ // §9 round 2 found both reachable through the connector. A row that cannot be read as a
251
+ // vector of finite numbers is treated as corrupt and excluded, exactly like the shape checks
252
+ // on the line above — it can then neither be ranked nor rank anything else wrongly.
253
+ const values = rec.values.map((n) => (typeof n === 'number' ? n : Number.NaN));
254
+ if (!values.every((n) => Number.isFinite(n) && Math.abs(n) <= FLOAT32_MAX))
255
+ continue;
256
+ this.rows.set(r.id, {
257
+ ns: rec.ns,
258
+ vid: rec.vid,
259
+ values,
260
+ metadata: rec.metadata !== null && typeof rec.metadata === 'object' ? rec.metadata : null,
261
+ data: typeof rec.data === 'string' && rec.data !== '' ? rec.data : null,
262
+ ...(typeof rec._rev === 'number' ? { _rev: rec._rev } : {}),
263
+ });
264
+ }
265
+ // The DEFAULT namespace always exists — `/info` lists it even on an empty index.
266
+ this.namespaces.add(DEFAULT_NAMESPACE);
267
+ // Server-supplied configuration is the index's creation-time setting and WINS over whatever a
268
+ // previous run inferred; otherwise the persisted value stands; otherwise the dimension stays
269
+ // open for the first upsert to lock in.
270
+ this.dimension = ctx.dimension ?? storedDimension;
271
+ this.similarity = ctx.similarityFunction ?? storedSimilarity ?? DEFAULT_SIMILARITY;
272
+ // INFER the dimension from the vectors themselves when nothing has declared one.
273
+ //
274
+ // The connector writes vectors but NOT the `config` subject (that gap is filed as
275
+ // upstashvector.connector.pull_index_configuration), so a freshly PULLED index used to sit at
276
+ // `dimension === null`. §9 round 1 showed how badly that degrades: `/info` reported
277
+ // `dimension: 0` while holding dim-3 vectors; `/query` with a dim-2 vector answered a silent
278
+ // `200 []` instead of the promised 422 (because `checkDimension` returns early on null and the
279
+ // ranker skips length-mismatched rows); and the next `/upsert` LOCKED IN the wrong dimension,
280
+ // permanently orphaning every pulled vector. Inferring here makes pulled state coherent with
281
+ // locally-written state on all three paths.
282
+ //
283
+ // Only adopted when every stored vector AGREES — a root holding mixed lengths (which the
284
+ // enforcement above prevents locally, but a pull could deliver) has no single honest answer,
285
+ // so it stays null rather than picking a winner and orphaning the rest. Inference is
286
+ // in-memory only: it deliberately does NOT set `configDirty`, so a READ never writes.
287
+ if (this.dimension === null && this.rows.size > 0) {
288
+ const lengths = new Set([...this.rows.values()].map((r) => r.values.length));
289
+ if (lengths.size === 1)
290
+ this.dimension = [...lengths][0];
291
+ else
292
+ this.mixedDimensions = true;
293
+ }
294
+ if (ctx.dimension !== undefined && ctx.dimension !== storedDimension)
295
+ this.configDirty = true;
296
+ if (ctx.similarityFunction !== undefined && ctx.similarityFunction !== storedSimilarity)
297
+ this.configDirty = true;
298
+ }
299
+ assertWritable() {
300
+ if (this.ctx.readOnly)
301
+ throw new ReadOnlyError();
302
+ }
303
+ /** The next write ordinal for a subject. Survives deletes — see the constructor's note. */
304
+ nextRev(subjectId) {
305
+ const next = (this.revs.get(subjectId) ?? 0) + 1;
306
+ this.revs.set(subjectId, next);
307
+ return next;
308
+ }
309
+ // ── configuration ──────────────────────────────────────────────────────────────────────────
310
+ /** The dimension this index enforces, or `null` while no upsert has locked one in yet. */
311
+ get indexDimension() { return this.dimension; }
312
+ get similarityFunction() { return this.similarity; }
313
+ /**
314
+ * Check a vector's length against the index, LOCKING the dimension in on the first upsert.
315
+ *
316
+ * The vendor's index has a dimension chosen at creation time and cannot change it. A local twin
317
+ * has no creation step, so the first upsert plays that role; from then on the check is exactly
318
+ * as strict as the vendor's, including the error, which is the ONE message in this pack quoted
319
+ * verbatim from the vendor's own docs (the /upsert endpoint's 422 example).
320
+ */
321
+ checkDimension(values, lockIn) {
322
+ if (this.dimension === null) {
323
+ if (!lockIn)
324
+ return; // a query against an index with nothing in it cannot mismatch
325
+ if (values.length === 0)
326
+ throw new VectorApiError('upstashvector: a vector must have at least one dimension', 422);
327
+ if (this.mixedDimensions) {
328
+ // The index already holds vectors of DIFFERENT lengths — a state only a pull can produce
329
+ // (local writes are dimension-checked). Letting this upsert lock a dimension in would pick
330
+ // an arbitrary winner and permanently orphan every vector of the other length: §9 round 2
331
+ // showed exactly that happening one request after the mixed pull, which is the round-1
332
+ // defect the "stay null" branch was supposed to prevent and did not. Refusing is what
333
+ // actually delivers the stated intent.
334
+ throw new VectorApiError('upstashvector: this index holds vectors of differing dimensions, so no dimension can be inferred — '
335
+ + 'start the twin with an explicit dimension (--dimension N) rather than letting this write orphan the others', 422);
336
+ }
337
+ this.dimension = values.length;
338
+ this.configDirty = true;
339
+ return;
340
+ }
341
+ if (values.length !== this.dimension) {
342
+ // VERBATIM from upstash.com/docs/vector/api/endpoints/upsert's own 422 example:
343
+ // {"error": "Invalid vector dimension: 2, expected: 256", "status": 422}
344
+ throw new VectorApiError(`Invalid vector dimension: ${values.length}, expected: ${this.dimension}`, 422);
345
+ }
346
+ }
347
+ // ── namespaces ─────────────────────────────────────────────────────────────────────────────
348
+ /** Every namespace that currently exists, default first then lexicographic (deterministic). */
349
+ listNamespaces() {
350
+ const rest = [...this.namespaces].filter((n) => n !== DEFAULT_NAMESPACE).sort();
351
+ return [DEFAULT_NAMESPACE, ...rest];
352
+ }
353
+ hasNamespace(ns) { return this.namespaces.has(ns); }
354
+ ensureNamespace(ns) {
355
+ if (this.namespaces.has(ns))
356
+ return;
357
+ this.namespaces.add(ns);
358
+ this.touched.set(namespaceSubjectId(ns), { kind: 'namespace', ns, operation: 'namespace.create', alive: true });
359
+ }
360
+ /**
361
+ * `POST|DELETE /delete-namespace/{ns}` — remove a namespace AND everything in it.
362
+ *
363
+ * Distinct from `/reset/{ns}`, which empties a namespace but leaves it existing. Keeping both
364
+ * behaviours distinct is why namespaces are tracked as their own kernel subject rather than
365
+ * derived from "does any vector mention this name" — a derived model would make the two
366
+ * endpoints indistinguishable, which would quietly drop a real part of the vendor's surface.
367
+ *
368
+ * TWIN-DEFINED (unverified): deleting the DEFAULT namespace is refused. The default namespace
369
+ * has no name to address and `/reset` already empties it, so a request to remove it is far more
370
+ * likely to be a bug than an intent. Stated in the README's ## Coverage.
371
+ */
372
+ deleteNamespace(ns) {
373
+ this.assertWritable();
374
+ if (ns === DEFAULT_NAMESPACE) {
375
+ throw new VectorApiError('upstashvector: the default namespace cannot be deleted — use /reset to empty it', 400);
376
+ }
377
+ if (!this.namespaces.has(ns)) {
378
+ throw new VectorApiError(`upstashvector: namespace '${ns}' not found`, 404);
379
+ }
380
+ for (const row of this.vectorsIn(ns))
381
+ this.removeVector(row.ns, row.vid, 'namespace.delete');
382
+ this.namespaces.delete(ns);
383
+ this.touched.set(namespaceSubjectId(ns), { kind: 'namespace', ns, operation: 'namespace.delete', alive: false });
384
+ }
385
+ // ── vectors ────────────────────────────────────────────────────────────────────────────────
386
+ /** Every live vector in one namespace, in a DETERMINISTIC order (id ascending). */
387
+ vectorsIn(ns) {
388
+ return [...this.rows.values()].filter((r) => r.ns === ns).sort((a, b) => (a.vid < b.vid ? -1 : a.vid > b.vid ? 1 : 0));
389
+ }
390
+ getVector(ns, vid) {
391
+ return this.rows.get(vectorSubjectId(ns, vid));
392
+ }
393
+ putVector(row, operation) {
394
+ this.assertWritable();
395
+ const sid = vectorSubjectId(row.ns, row.vid);
396
+ this.rows.set(sid, row);
397
+ this.touched.set(sid, { kind: 'vector', ns: row.ns, vid: row.vid, operation, row });
398
+ }
399
+ removeVector(ns, vid, operation) {
400
+ this.assertWritable();
401
+ const sid = vectorSubjectId(ns, vid);
402
+ this.rows.delete(sid);
403
+ this.touched.set(sid, { kind: 'vector', ns, vid, operation, row: null });
404
+ }
405
+ /**
406
+ * `POST /upsert[/{ns}]` — insert or REPLACE whole vectors.
407
+ *
408
+ * Upsert REPLACES: the vendor's `/update` endpoint exists precisely because `/upsert` does not
409
+ * merge, so an upsert without `metadata` clears any metadata the id previously had. Getting this
410
+ * backwards would make `/update`'s whole reason for existing invisible.
411
+ *
412
+ * Returns the vendor's `{"result": "Success"}` payload string.
413
+ */
414
+ upsert(ns, items) {
415
+ this.assertWritable();
416
+ if (items.length === 0)
417
+ throw new VectorApiError('upstashvector: upsert requires at least one vector', 400);
418
+ const parsed = items.map((raw) => this.readUpsertItem(raw));
419
+ // Validate the WHOLE batch before writing any of it, so a bad element cannot leave the index
420
+ // half-updated. (The vendor's own batching semantics are unverified; refusing atomically is
421
+ // the conservative direction — it can never produce a partial state a caller did not ask for.)
422
+ for (const item of parsed)
423
+ this.checkDimension(item.values, true);
424
+ this.ensureNamespace(ns);
425
+ for (const item of parsed) {
426
+ this.putVector({ ns, vid: item.vid, values: item.values, metadata: item.metadata, data: item.data }, 'vector.upsert');
427
+ }
428
+ return 'Success';
429
+ }
430
+ /** Read one `/upsert` element: `{id, vector, metadata?, data?}`. */
431
+ readUpsertItem(raw) {
432
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
433
+ throw new VectorApiError('upstashvector: each upsert element must be an object with an id and a vector', 400);
434
+ }
435
+ const o = raw;
436
+ const vid = readId(o.id);
437
+ if (o.sparseVector !== undefined) {
438
+ // An honest refusal, not a silent drop: this twin models DENSE indexes only, and accepting a
439
+ // sparseVector while ignoring it would make a hybrid-index caller's recall look fine while
440
+ // half their signal vanished. See the manifest's sparse/hybrid todos.
441
+ throw new VectorApiError('upstashvector: this twin models DENSE indexes only — sparseVector is not supported (upstashvector.sparse.upsert is the filed gap)', 422);
442
+ }
443
+ if (o.vector === undefined) {
444
+ // `data` WITHOUT `vector` is the /upsert-data embedding path, which needs the index's own
445
+ // embedding model. See `unsupportedEmbedding`.
446
+ if (o.data !== undefined)
447
+ throw unsupportedEmbedding();
448
+ throw new VectorApiError('upstashvector: upsert element is missing its `vector`', 400);
449
+ }
450
+ return { vid, values: readVector(o.vector), metadata: readMetadata(o.metadata), data: readData(o.data) };
451
+ }
452
+ /**
453
+ * `POST /update[/{ns}]` — partially modify ONE existing vector.
454
+ *
455
+ * `metadataUpdateMode` (from the vendor's update endpoint): `OVERWRITE` (the default) replaces
456
+ * the metadata object wholesale; `PATCH` merges the supplied keys over what is there.
457
+ * Returns `{updated: 0|1}` — 0 when the id does not exist, which is a normal answer, not an error.
458
+ */
459
+ update(ns, raw) {
460
+ this.assertWritable();
461
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
462
+ throw new VectorApiError('upstashvector: update requires an object with an id', 400);
463
+ }
464
+ const o = raw;
465
+ const vid = readId(o.id);
466
+ const existing = this.getVector(ns, vid);
467
+ if (existing === undefined)
468
+ return { updated: 0 };
469
+ const mode = o.metadataUpdateMode === undefined ? 'OVERWRITE' : String(o.metadataUpdateMode).toUpperCase();
470
+ if (mode !== 'OVERWRITE' && mode !== 'PATCH') {
471
+ throw new VectorApiError(`upstashvector: metadataUpdateMode must be OVERWRITE or PATCH, got '${String(o.metadataUpdateMode)}'`, 400);
472
+ }
473
+ let values = existing.values;
474
+ if (o.vector !== undefined) {
475
+ values = readVector(o.vector);
476
+ this.checkDimension(values, true);
477
+ }
478
+ let metadata = existing.metadata;
479
+ if (o.metadata !== undefined) {
480
+ const next = readMetadata(o.metadata);
481
+ metadata = mode === 'PATCH' && existing.metadata !== null && next !== null ? { ...existing.metadata, ...next } : next;
482
+ }
483
+ const data = o.data !== undefined ? readData(o.data) : existing.data;
484
+ this.putVector({ ns, vid, values, metadata, data }, 'vector.update');
485
+ return { updated: 1 };
486
+ }
487
+ /**
488
+ * `POST /query[/{ns}]` — rank the namespace's vectors against a query vector.
489
+ *
490
+ * The ORDER: score DESCENDING, ties broken by id ASCENDING. The tie-break is TWIN-DEFINED (the
491
+ * vendor does not specify one) and exists so a verify that seeds two equidistant vectors gets a
492
+ * reproducible answer instead of whatever the map iteration happened to yield.
493
+ */
494
+ query(ns, raw) {
495
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
496
+ throw new VectorApiError('upstashvector: query requires an object body', 400);
497
+ }
498
+ const o = raw;
499
+ if (o.data !== undefined && o.vector === undefined)
500
+ throw unsupportedEmbedding();
501
+ if (o.sparseVector !== undefined) {
502
+ throw new VectorApiError('upstashvector: this twin models DENSE indexes only — sparseVector queries are not supported (upstashvector.sparse.query is the filed gap)', 422);
503
+ }
504
+ if (o.vector === undefined)
505
+ throw new VectorApiError('upstashvector: query is missing its `vector`', 400);
506
+ const values = readVector(o.vector);
507
+ this.checkDimension(values, false);
508
+ const topK = o.topK === undefined ? 10 : readPositiveInt(o.topK, 'topK');
509
+ const predicate = compileFilter(o.filter);
510
+ const scored = [];
511
+ for (const row of this.vectorsIn(ns)) {
512
+ if (predicate !== null && !evaluateFilter(predicate, row.metadata))
513
+ continue;
514
+ // A stored vector whose length disagrees with the query's cannot be scored — this can only
515
+ // happen against state written before the dimension was locked in, and skipping it is
516
+ // better than producing a score from a truncated overlap.
517
+ if (row.values.length !== values.length)
518
+ continue;
519
+ scored.push({ row, score: similarityScore(this.similarity, values, row.values) });
520
+ }
521
+ scored.sort((a, b) => (b.score - a.score) || (a.row.vid < b.row.vid ? -1 : a.row.vid > b.row.vid ? 1 : 0));
522
+ return scored.slice(0, topK).map(({ row, score }) => ({ id: row.vid, score, ...projection(row, o) }));
523
+ }
524
+ /**
525
+ * `POST /fetch[/{ns}]` — look vectors up BY ID (or by id prefix), unranked.
526
+ *
527
+ * By ids: "Array elements can be `null` if no such vector exists with the provided id" (the
528
+ * vendor's own fetch doc), and the result is positionally aligned with the request. By prefix:
529
+ * only the matches, so no nulls.
530
+ */
531
+ fetch(ns, raw) {
532
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
533
+ throw new VectorApiError('upstashvector: fetch requires an object body', 400);
534
+ }
535
+ const o = raw;
536
+ if (o.ids !== undefined) {
537
+ if (!Array.isArray(o.ids))
538
+ throw new VectorApiError('upstashvector: fetch `ids` must be an array', 400);
539
+ return o.ids.map((raw2) => {
540
+ const row = this.getVector(ns, readId(raw2));
541
+ return row === undefined ? null : { id: row.vid, ...projection(row, o) };
542
+ });
543
+ }
544
+ if (typeof o.prefix === 'string') {
545
+ return this.vectorsIn(ns).filter((r) => r.vid.startsWith(o.prefix)).map((row) => ({ id: row.vid, ...projection(row, o) }));
546
+ }
547
+ throw new VectorApiError('upstashvector: fetch requires `ids` or `prefix`', 400);
548
+ }
549
+ /**
550
+ * `POST /range[/{ns}]` — paginate the namespace in id order.
551
+ *
552
+ * The cursor is a decimal OFFSET as a string (the vendor's own example advances `""` → `"2"`),
553
+ * and `nextCursor` is `""` when the walk is finished. Ordering is id-ascending, which is what
554
+ * makes the offset cursor stable across pages.
555
+ */
556
+ range(ns, raw) {
557
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
558
+ throw new VectorApiError('upstashvector: range requires an object body', 400);
559
+ }
560
+ const o = raw;
561
+ const limit = o.limit === undefined ? 10 : readPositiveInt(o.limit, 'limit');
562
+ const cursorRaw = o.cursor === undefined || o.cursor === '' ? '0' : String(o.cursor);
563
+ if (!/^\d+$/.test(cursorRaw))
564
+ throw new VectorApiError(`upstashvector: '${cursorRaw}' is not a valid range cursor`, 400);
565
+ const start = Number(cursorRaw);
566
+ let all = this.vectorsIn(ns);
567
+ if (typeof o.prefix === 'string')
568
+ all = all.filter((r) => r.vid.startsWith(o.prefix));
569
+ const page = all.slice(start, start + limit);
570
+ const next = start + page.length;
571
+ return {
572
+ // `""` means "no more" — so the cursor only advances while there is genuinely another page.
573
+ nextCursor: next >= all.length ? '' : String(next),
574
+ vectors: page.map((row) => ({ id: row.vid, ...projection(row, o) })),
575
+ };
576
+ }
577
+ /**
578
+ * `POST|DELETE /delete[/{ns}]` — by ids, by id prefix, or by metadata filter.
579
+ *
580
+ * Returns `{deleted: N}`, the vendor's own shape, where N counts vectors that ACTUALLY existed —
581
+ * deleting an unknown id is not an error and contributes 0.
582
+ */
583
+ delete(ns, raw) {
584
+ this.assertWritable();
585
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
586
+ throw new VectorApiError('upstashvector: delete requires an object body', 400);
587
+ }
588
+ const o = raw;
589
+ const victims = [];
590
+ if (o.ids !== undefined) {
591
+ if (!Array.isArray(o.ids))
592
+ throw new VectorApiError('upstashvector: delete `ids` must be an array', 400);
593
+ for (const rawId of o.ids) {
594
+ const row = this.getVector(ns, readId(rawId));
595
+ if (row !== undefined)
596
+ victims.push(row);
597
+ }
598
+ }
599
+ else if (typeof o.prefix === 'string') {
600
+ victims.push(...this.vectorsIn(ns).filter((r) => r.vid.startsWith(o.prefix)));
601
+ }
602
+ else if (typeof o.filter === 'string') {
603
+ const predicate = compileFilter(o.filter);
604
+ if (predicate === null) {
605
+ // An EMPTY filter string on a DELETE is refused rather than interpreted.
606
+ //
607
+ // On `/query` an empty filter harmlessly means "no filter" (and is treated that way). The
608
+ // SAME reading on `/delete` would mean "match every vector" — i.e. silently wipe the
609
+ // namespace because a caller's template rendered to an empty string. A twin must never
610
+ // guess in that direction, so this is an explicit 400.
611
+ //
612
+ // (Before this guard the empty string reached `evaluateFilter(null, …)` and produced a
613
+ // raw TypeError surfaced as a 500 — safe in that it deleted nothing, but the wrong status
614
+ // and an internal-fault message. Found by self-probe during the §9 pass.)
615
+ throw new VectorApiError('upstashvector: `filter` is empty — refusing to guess between "no criteria" and "match everything" on a delete; pass ids, prefix, or a non-empty filter', 400);
616
+ }
617
+ victims.push(...this.vectorsIn(ns).filter((r) => evaluateFilter(predicate, r.metadata)));
618
+ }
619
+ else {
620
+ throw new VectorApiError('upstashvector: delete requires `ids`, `prefix` or `filter`', 400);
621
+ }
622
+ // Dedupe: the same id listed twice must count once, or `{deleted}` would over-report.
623
+ const seen = new Set();
624
+ let deleted = 0;
625
+ for (const row of victims) {
626
+ const sid = vectorSubjectId(row.ns, row.vid);
627
+ if (seen.has(sid))
628
+ continue;
629
+ seen.add(sid);
630
+ this.removeVector(row.ns, row.vid, 'vector.delete');
631
+ deleted++;
632
+ }
633
+ return { deleted };
634
+ }
635
+ /**
636
+ * `POST|DELETE /reset[/{ns}]` and `/reset?all` — empty a namespace (or every namespace).
637
+ *
638
+ * Emptying, NOT removing: the namespaces themselves survive a reset. `?all` is how the SDK's
639
+ * `reset({all:true})` spells it (`ResetCommand` appends the bare `?all` query flag).
640
+ */
641
+ reset(opts) {
642
+ this.assertWritable();
643
+ if (opts.all === true) {
644
+ for (const row of [...this.rows.values()])
645
+ this.removeVector(row.ns, row.vid, 'index.reset_all');
646
+ return 'Success';
647
+ }
648
+ const ns = opts.namespace ?? DEFAULT_NAMESPACE;
649
+ for (const row of this.vectorsIn(ns))
650
+ this.removeVector(row.ns, row.vid, 'index.reset');
651
+ return 'Success';
652
+ }
653
+ /**
654
+ * `GET|POST /info` — index-wide counts and configuration.
655
+ *
656
+ * Shape quoted from upstash.com/docs/vector/api/endpoints/info. `pendingVectorCount` is always 0
657
+ * here and that is HONEST rather than a stub: the vendor's pending count reflects vectors still
658
+ * being indexed asynchronously, and this twin's upsert is synchronous, so nothing is ever
659
+ * pending. `indexSize` is a byte estimate; the vendor's exact accounting is not published, so
660
+ * this reports a computed, deterministic size (see `estimateIndexSize`) rather than a constant.
661
+ */
662
+ info() {
663
+ const namespaces = {};
664
+ for (const ns of this.listNamespaces()) {
665
+ namespaces[ns] = { vectorCount: this.vectorsIn(ns).length, pendingVectorCount: 0 };
666
+ }
667
+ const dimension = this.dimension ?? 0;
668
+ return {
669
+ vectorCount: this.rows.size,
670
+ pendingVectorCount: 0,
671
+ indexSize: this.estimateIndexSize(),
672
+ dimension,
673
+ similarityFunction: this.similarity,
674
+ indexType: 'DENSE',
675
+ denseIndex: { dimension, similarityFunction: this.similarity },
676
+ namespaces,
677
+ };
678
+ }
679
+ /**
680
+ * `indexSize`, computed with the VENDOR'S OWN PUBLISHED FORMULA.
681
+ *
682
+ * upstash.com/docs/vector/help/faq states it verbatim: "Each dimension is estimated to be 4
683
+ * bytes, resulting in vector storage being calculated as vector count * dimension count * 4
684
+ * bytes", and adds that the storage charge combines that with metadata (up to 48 KB/vector) and
685
+ * data (up to 1 MB/vector). So this is `dimensions x 4` per vector plus the encoded size of its
686
+ * metadata and data — not a twin invention. (An earlier draft said the vendor published no
687
+ * accounting and also counted the id's bytes, which the formula does not include; both were
688
+ * corrected once the FAQ was found during the §9 pass.)
689
+ *
690
+ * It stays an ESTIMATE in the vendor's own sense of the word, and it is a real function of real
691
+ * state — it grows when you add vectors and shrinks when you delete them.
692
+ */
693
+ estimateIndexSize() {
694
+ let bytes = 0;
695
+ for (const row of this.rows.values()) {
696
+ bytes += row.values.length * 4;
697
+ if (row.metadata !== null)
698
+ bytes += Buffer.byteLength(JSON.stringify(row.metadata), 'utf8');
699
+ if (row.data !== null)
700
+ bytes += Buffer.byteLength(row.data, 'utf8');
701
+ }
702
+ return bytes;
703
+ }
704
+ /** Did this run write anything? */
705
+ get dirty() { return this.touched.size > 0 || this.configDirty; }
706
+ /**
707
+ * Write every touched subject back to the kernel action log.
708
+ *
709
+ * The kernel MERGES fields, so a delete writes EVERY field back to its "nothing here" value —
710
+ * leaving `values`/`metadata` behind would let a later re-upsert of the same id inherit a dead
711
+ * coordinate array if it ever wrote a partial field set.
712
+ */
713
+ async flush() {
714
+ for (const pending of this.touched.values()) {
715
+ if (pending.kind === 'vector') {
716
+ const sid = vectorSubjectId(pending.ns, pending.vid);
717
+ const row = pending.row;
718
+ await applyTwinWrite(SERVICE, {
719
+ operation: pending.operation,
720
+ subjectType: 'vector',
721
+ subjectId: sid,
722
+ fields: row === null
723
+ ? { ns: pending.ns, vid: pending.vid, values: [], metadata: null, data: '', gone: true, _rev: this.nextRev(sid) }
724
+ : { ns: row.ns, vid: row.vid, values: row.values, metadata: row.metadata, data: row.data ?? '', gone: false, _rev: this.nextRev(sid) },
725
+ occurredAt: this.ctx.occurredAt,
726
+ actor: { kind: 'agent' },
727
+ }, this.ctx.root);
728
+ continue;
729
+ }
730
+ if (pending.kind === 'namespace') {
731
+ const sid = namespaceSubjectId(pending.ns);
732
+ await applyTwinWrite(SERVICE, {
733
+ operation: pending.operation,
734
+ subjectType: 'namespace',
735
+ subjectId: sid,
736
+ fields: { name: pending.ns, gone: !pending.alive, _rev: this.nextRev(sid) },
737
+ occurredAt: this.ctx.occurredAt,
738
+ actor: { kind: 'agent' },
739
+ }, this.ctx.root);
740
+ }
741
+ }
742
+ // A READ-ONLY twin must append NOTHING, including this.
743
+ //
744
+ // §9 round 1 found the hole: `configDirty` is set by the CONSTRUCTOR whenever the server was
745
+ // started with a `dimension`/`similarityFunction` that differs from what the root already
746
+ // holds — and `flush()` runs after every successful command, including a plain `GET /info`.
747
+ // So `world-upstashvector serve --read-only --dimension 4` appended an `index.configure`
748
+ // action on a pure READ. The capability calls this "the pure-mirror mode a pulled index is
749
+ // served in", and a pure mirror does not write. Every other write path is stopped by
750
+ // `assertWritable()`; this one was not, because nothing calls it.
751
+ if (this.configDirty && !this.ctx.readOnly) {
752
+ await applyTwinWrite(SERVICE, {
753
+ operation: 'index.configure',
754
+ subjectType: 'config',
755
+ subjectId: CONFIG_SUBJECT_ID,
756
+ fields: { dimension: this.dimension, similarity: this.similarity, _rev: this.nextRev(CONFIG_SUBJECT_ID) },
757
+ occurredAt: this.ctx.occurredAt,
758
+ actor: { kind: 'agent' },
759
+ }, this.ctx.root);
760
+ }
761
+ }
762
+ }
763
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
764
+ // HELPERS
765
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
766
+ /**
767
+ * The refusal for every surface that needs the index's own embedding model — `/upsert-data`,
768
+ * `/query-data`, `/resumable-query-data`.
769
+ *
770
+ * A twin cannot run an embedding model, so it cannot turn `data` into a vector. Failing here is
771
+ * the honest answer and matches how a real index CREATED WITHOUT an embedding model behaves; the
772
+ * alternative — hashing the text into a pseudo-vector — would return confident nonsense rankings
773
+ * that look exactly like a working retrieval pipeline. The routes are filed as manifest gaps.
774
+ */
775
+ function unsupportedEmbedding() {
776
+ return new VectorApiError('upstashvector: this index has no embedding model — send a `vector` instead of `data` '
777
+ + '(this twin runs no embedding model; upstashvector.embedding.upsert_data is the filed gap)', 422);
778
+ }
779
+ /** Compile a filter once per request, or `null` when the request has no filter. */
780
+ function compileFilter(raw) {
781
+ if (raw === undefined || raw === null || raw === '')
782
+ return null;
783
+ if (typeof raw !== 'string')
784
+ throw new VectorApiError('upstashvector: `filter` must be a string', 400);
785
+ try {
786
+ return parseFilter(raw);
787
+ }
788
+ catch (e) {
789
+ if (e instanceof FilterError)
790
+ throw new VectorApiError(e.message, 400);
791
+ throw e;
792
+ }
793
+ }
794
+ /** The optional response fields, gated by the request's `include*` flags. */
795
+ function projection(row, o) {
796
+ const out = {};
797
+ // The vendor OMITS an absent key rather than sending null (its fetch example shows `{"id":"id-1"}`
798
+ // with no `metadata` at all), so a caller checking `'metadata' in result` gets the right answer.
799
+ if (o.includeVectors === true)
800
+ out.vector = row.values;
801
+ if (o.includeMetadata === true && row.metadata !== null)
802
+ out.metadata = row.metadata;
803
+ if (o.includeData === true && row.data !== null)
804
+ out.data = row.data;
805
+ return out;
806
+ }
807
+ /** A vector id. The vendor accepts strings and numbers; both are carried as strings. */
808
+ function readId(raw) {
809
+ if (typeof raw === 'string') {
810
+ if (raw === '')
811
+ throw new VectorApiError('upstashvector: vector id must not be empty', 400);
812
+ return raw;
813
+ }
814
+ if (typeof raw === 'number' && Number.isFinite(raw))
815
+ return String(raw);
816
+ throw new VectorApiError('upstashvector: vector id must be a string or a number', 400);
817
+ }
818
+ /** A dense vector: an array of finite numbers. NaN/Infinity are refused, not stored. */
819
+ function readVector(raw) {
820
+ if (!Array.isArray(raw))
821
+ throw new VectorApiError('upstashvector: `vector` must be an array of numbers', 400);
822
+ const out = [];
823
+ for (const n of raw) {
824
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
825
+ // A non-finite coordinate poisons every score it touches (and `JSON.stringify(NaN)` is
826
+ // `null`), so it is refused at the boundary rather than stored and discovered at query time.
827
+ throw new VectorApiError('upstashvector: `vector` must contain only finite numbers', 400);
828
+ }
829
+ if (Math.abs(n) > FLOAT32_MAX) {
830
+ // Bounding coordinates to FLOAT32 is what keeps the arithmetic total.
831
+ //
832
+ // §9 round 1 showed the hole this closes: `[1e308, 1e308]` is a perfectly finite float64
833
+ // pair, but squaring it overflows to Infinity, so COSINE produced NaN and DOT_PRODUCT
834
+ // produced Infinity — both of which `JSON.stringify` renders as `"score": null`, and both of
835
+ // which make the `(b.score - a.score)` comparator fall through to the id tie-break, silently
836
+ // degrading the ranking to alphabetical order. No error anywhere.
837
+ //
838
+ // WHERE THE BOUND COMES FROM, and what is fact vs inference (§9 round 2 corrected an
839
+ // overstatement here — an earlier comment asserted "Upstash Vector stores float32" as a
840
+ // vendor fact):
841
+ // FACT — upstash.com/docs/vector/help/faq, verbatim: "Each dimension is estimated to be
842
+ // 4 bytes, resulting in vector storage being calculated as vector count *
843
+ // dimension count * 4 bytes." That is a BILLING estimate; the FAQ says nothing
844
+ // about numeric precision, and neither does the pricing page, the similarity
845
+ // page, or the SDK's types (coordinates are plain `number[]`).
846
+ // INFERENCE— four bytes per dimension is the width of a float32, so a coordinate outside
847
+ // float32 range is very unlikely to be representable by the real service.
848
+ // The bound is therefore chosen, not quoted — and it is safe in the direction that matters:
849
+ // it refuses only values ~1e38 and above, which no embedding model produces, while making
850
+ // the arithmetic TOTAL. With every coordinate inside float32, no sum of squares — over the
851
+ // vendor's documented 5,000-dimension maximum or vastly beyond it — can leave float64 range,
852
+ // so "a score is always a finite number" becomes a property of the arithmetic rather than a
853
+ // hope. Confirming the real precision needs a live index (upstashvector.limits.coordinate_precision).
854
+ throw new VectorApiError(`upstashvector: coordinate ${n} exceeds the float32 range this index stores — a value this large is not representable and would overflow the similarity arithmetic`, 400);
855
+ }
856
+ out.push(n);
857
+ }
858
+ return out;
859
+ }
860
+ /** The largest magnitude a float32 coordinate can hold. See `readVector`. */
861
+ const FLOAT32_MAX = 3.4028234663852886e38;
862
+ function readMetadata(raw) {
863
+ if (raw === undefined || raw === null)
864
+ return null;
865
+ if (typeof raw !== 'object' || Array.isArray(raw))
866
+ throw new VectorApiError('upstashvector: `metadata` must be a JSON object', 400);
867
+ return raw;
868
+ }
869
+ function readData(raw) {
870
+ if (raw === undefined || raw === null)
871
+ return null;
872
+ if (typeof raw !== 'string')
873
+ throw new VectorApiError('upstashvector: `data` must be a string', 400);
874
+ return raw === '' ? null : raw;
875
+ }
876
+ function readPositiveInt(raw, field) {
877
+ const n = typeof raw === 'number' ? raw : Number(raw);
878
+ if (!Number.isInteger(n) || n <= 0)
879
+ throw new VectorApiError(`upstashvector: \`${field}\` must be a positive integer`, 400);
880
+ return n;
881
+ }
882
+ /** Re-exported so the handler can map a filter parse failure without importing the filter module. */
883
+ export { matchesFilter };