@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,1108 @@
|
|
|
1
|
+
// upstashvector capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
|
|
2
|
+
// top-down from what the Upstash Vector REST API actually does, NOT from what this twin has built.
|
|
3
|
+
//
|
|
4
|
+
// GROUNDED (2026-08-19), all read-only — see spec-sources.json:
|
|
5
|
+
// (a) upstash.com/docs/vector/api/endpoints/{upsert,query,fetch,range,delete,reset,info} and
|
|
6
|
+
// upstash.com/docs/vector/api/get-started (the status-code table);
|
|
7
|
+
// (b) upstash.com/docs/vector/features/{namespaces,filtering,similarityfunctions,metadata,
|
|
8
|
+
// sparseindexes,embeddingmodels,resumablequery};
|
|
9
|
+
// (c) the ACTUALLY-INSTALLED `@upstash/vector@1.2.3` package source (its HttpClient and every
|
|
10
|
+
// Command's endpoint construction), which is where the SDK-shaped facts come from: every
|
|
11
|
+
// call is a POST, namespaces are a path suffix, `reset({all:true})` is `reset?all`, and
|
|
12
|
+
// `queryMany` posts an ARRAY to `/query` whose single-element form answers FLAT.
|
|
13
|
+
// The DENOMINATOR is enumerated from the vendor's own docs nav — every REST endpoint crossed with
|
|
14
|
+
// every documented feature (namespaces, filtering, metadata, similarity functions, sparse/hybrid
|
|
15
|
+
// indexes, embedding models, resumable queries, plan limits) — so whole features this twin has NOT
|
|
16
|
+
// built (sparse/hybrid, embedding models, resumable queries) appear as `todo`s rather than being
|
|
17
|
+
// quietly left out of the count.
|
|
18
|
+
//
|
|
19
|
+
// `verify()` (required to count as done) is ground truth: every API verify drives the REAL
|
|
20
|
+
// `handleUpstashVectorTwinRequest` over a FRESH temp root with a PINNED clock, and asserts VALUES —
|
|
21
|
+
// never a bare status code. Nothing here is a mock.
|
|
22
|
+
//
|
|
23
|
+
// ── WHAT THIS PACK COULD NOT GROUND, STATED PLAINLY ───────────────────────────────────────────
|
|
24
|
+
// Its sibling `upstash` live-probed a real ephemeral database for every literal error string.
|
|
25
|
+
// This build had NO live Upstash Vector index, so it could not do the same. Exactly one error
|
|
26
|
+
// message here is quoted verbatim from the vendor's own docs — the dimension mismatch
|
|
27
|
+
// (`Invalid vector dimension: N, expected: M`, from the /upsert endpoint's 422 example) — and its
|
|
28
|
+
// capability says so. Every other error capability asserts the STATUS and the documented
|
|
29
|
+
// `{error,status}` ENVELOPE, which ARE grounded, and does not claim a vendor wording this pack
|
|
30
|
+
// never read. Capabilities whose titles would require an unverified vendor string are `todo`.
|
|
31
|
+
//
|
|
32
|
+
// (Upstash Vector is an API-first vendor — docs/contributing/architecture.md C1b: an integrator CALLS this product
|
|
33
|
+
// from code and the Upstash console is a provisioning/metrics dashboard, not where the work
|
|
34
|
+
// happens — so this pack ships NO mirror and there are no UI capabilities. Same call as the
|
|
35
|
+
// sibling upstash pack, and as pinecone, the other vector-database pack in this repo.
|
|
36
|
+
// See README ## Coverage § No UI mirror.)
|
|
37
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { applyTwinWrite } from '@volter/world-core';
|
|
41
|
+
import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
|
|
42
|
+
import { handleUpstashVectorTwinRequest } from "./upstashvector-twin.js";
|
|
43
|
+
import { checkUpstashVectorConformance } from "./upstashvector-conformance.js";
|
|
44
|
+
import { cosineSimilarity, dotProduct, squaredDistance, similarityScore, IndexSpace, VectorApiError } from "./upstashvector-store.js";
|
|
45
|
+
import { syncUpstashVectorFromReal, pullUpstashVectorRange, pullUpstashVectorNamespaces, mapVector, mapNamespace, } from "./upstashvector-connector.js";
|
|
46
|
+
import { UpstashVectorBudget, guardUpstashVectorClient, upstashvectorCallWeight, UPSTASHVECTOR_BUDGET_CEILING } from "./upstashvector-budget.js";
|
|
47
|
+
/** The pinned clock every verify runs against, so ids and actions are deterministic. */
|
|
48
|
+
const AT = '2026-01-01T00:00:00.000Z';
|
|
49
|
+
const AT_MS = Date.parse(AT);
|
|
50
|
+
/** A later instant, for verifies that need the clock to ADVANCE (repeated identical writes). */
|
|
51
|
+
const laterAt = (ms) => new Date(AT_MS + ms).toISOString();
|
|
52
|
+
const AUTH = { authorization: 'Bearer twin-capability-token' };
|
|
53
|
+
async function withRoot(steps, setup = {}) {
|
|
54
|
+
const root = mkdtempSync(join(tmpdir(), 'upstashvector-cap-'));
|
|
55
|
+
const raw = (req) => handleUpstashVectorTwinRequest({
|
|
56
|
+
method: req.method ?? 'POST',
|
|
57
|
+
path: req.path ?? '/info',
|
|
58
|
+
root,
|
|
59
|
+
occurredAt: req.at ?? AT,
|
|
60
|
+
headers: req.headers === undefined ? AUTH : req.headers,
|
|
61
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
62
|
+
...(req.readOnly !== undefined ? { readOnly: req.readOnly } : {}),
|
|
63
|
+
...(req.token !== undefined ? { token: req.token } : {}),
|
|
64
|
+
// Per-request overrides win over the withRoot-level index configuration. Parenthesised
|
|
65
|
+
// deliberately: `...A ?? B !== undefined ? X : {}` is legal but reads as the wrong grouping.
|
|
66
|
+
...((req.dimension ?? setup.dimension) !== undefined ? { dimension: (req.dimension ?? setup.dimension) } : {}),
|
|
67
|
+
...((req.similarityFunction ?? setup.similarityFunction) !== undefined ? { similarityFunction: (req.similarityFunction ?? setup.similarityFunction) } : {}),
|
|
68
|
+
});
|
|
69
|
+
const run = (async (command, body, opts = {}) => {
|
|
70
|
+
const path = `/${command}${opts.ns !== undefined && opts.ns !== '' ? `/${encodeURIComponent(opts.ns)}` : ''}${opts.query ?? ''}`;
|
|
71
|
+
const res = await raw({
|
|
72
|
+
path,
|
|
73
|
+
...(opts.method !== undefined ? { method: opts.method } : {}),
|
|
74
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
75
|
+
...(opts.at !== undefined ? { at: opts.at } : {}),
|
|
76
|
+
...(opts.headers !== undefined ? { headers: opts.headers } : {}),
|
|
77
|
+
});
|
|
78
|
+
const b = (res.body ?? {});
|
|
79
|
+
return { status: res.status, result: b.result, error: b.error, errorStatus: b.status };
|
|
80
|
+
});
|
|
81
|
+
run.raw = raw;
|
|
82
|
+
run.root = root;
|
|
83
|
+
try {
|
|
84
|
+
return await verifyBoundary('upstashvector.withRoot', () => steps(run, root));
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** A counting fake Upstash Vector client for the connector/budget verifies. */
|
|
91
|
+
function fakeVectorClient(pages) {
|
|
92
|
+
const calls = [];
|
|
93
|
+
const client = {
|
|
94
|
+
info: async () => { calls.push('info'); return { vectorCount: Object.values(pages).flat().length }; },
|
|
95
|
+
listNamespaces: async () => { calls.push('listNamespaces'); return Object.keys(pages); },
|
|
96
|
+
range: async (args, opts) => {
|
|
97
|
+
calls.push(`range:${opts?.namespace ?? ''}`);
|
|
98
|
+
const all = pages[opts?.namespace ?? ''] ?? [];
|
|
99
|
+
const start = args.cursor === '' ? 0 : Number(args.cursor);
|
|
100
|
+
const page = all.slice(start, start + args.limit);
|
|
101
|
+
const next = start + page.length;
|
|
102
|
+
return { nextCursor: next >= all.length ? '' : String(next), vectors: page };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
return { client, calls };
|
|
106
|
+
}
|
|
107
|
+
/** Round a float for comparison against a hand-computed expectation. */
|
|
108
|
+
const near = (a, b, eps = 1e-9) => typeof a === 'number' && Math.abs(a - b) < eps;
|
|
109
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
110
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
111
|
+
export const UPSTASHVECTOR_CAPABILITIES = [
|
|
112
|
+
// ── PROTOCOL ────────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
done('upstashvector.protocol.result_envelope', 'protocol', 'every success answers HTTP 200 with the vendor\'s {"result": …} envelope, whose payload TYPE is per-endpoint — a string for /upsert and /reset, an array for /query and /fetch, an object for /info and /range (all six asserted)', 'api', 'core', () => withRoot(async (r) => {
|
|
114
|
+
// All SIX payload types the title enumerates, not a sample of three (§9 round 1).
|
|
115
|
+
const up = await r('upsert', { id: 'a', vector: [1, 0] });
|
|
116
|
+
const q = await r('query', { vector: [1, 0], topK: 1 });
|
|
117
|
+
const fetched = await r('fetch', { ids: ['a'] });
|
|
118
|
+
const info = await r('info', undefined, { method: 'GET' });
|
|
119
|
+
const page = await r('range', { cursor: '', limit: 10 });
|
|
120
|
+
const reset = await r('reset', undefined, { at: laterAt(1000) });
|
|
121
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
122
|
+
return up.status === 200 && up.result === 'Success' && up.error === undefined
|
|
123
|
+
&& reset.status === 200 && reset.result === 'Success'
|
|
124
|
+
&& q.status === 200 && Array.isArray(q.result) && q.result.length === 1
|
|
125
|
+
&& fetched.status === 200 && Array.isArray(fetched.result) && fetched.result.length === 1
|
|
126
|
+
&& info.status === 200 && isPlainObject(info.result)
|
|
127
|
+
&& page.status === 200 && isPlainObject(page.result)
|
|
128
|
+
&& Array.isArray(page.result.vectors);
|
|
129
|
+
})),
|
|
130
|
+
done('upstashvector.protocol.error_envelope', 'protocol', 'every failure answers the vendor\'s {"error": …, "status": N} envelope with `status` EQUAL to the HTTP status (the shape the /upsert doc\'s own 422 example shows)', 'api', 'core', () => withRoot(async (r) => {
|
|
131
|
+
await r('upsert', { id: 'a', vector: [1, 0] });
|
|
132
|
+
const bad = await r('upsert', { id: 'b', vector: [1, 0, 0] });
|
|
133
|
+
const missing = await r('nosuchendpoint', {});
|
|
134
|
+
return bad.status === 422 && bad.errorStatus === 422 && typeof bad.error === 'string' && bad.result === undefined
|
|
135
|
+
&& missing.status === 404 && missing.errorStatus === 404 && typeof missing.error === 'string';
|
|
136
|
+
})),
|
|
137
|
+
done('upstashvector.protocol.namespace_is_a_path_suffix', 'protocol', 'the namespace is a PATH SUFFIX (`POST /query/ns`), never a body field — so `/upsert/a` and `/upsert/b` are genuinely isolated stores and a body `namespace` key is NOT honoured', 'api', 'core', () => withRoot(async (r) => {
|
|
138
|
+
await r('upsert', { id: 'x', vector: [1, 0] }, { ns: 'team-a' });
|
|
139
|
+
await r('upsert', { id: 'y', vector: [0, 1] }, { ns: 'team-b' });
|
|
140
|
+
// A body-level `namespace` must be ignored: honouring it would let a caller escape the path.
|
|
141
|
+
await r('upsert', { id: 'z', vector: [1, 1], namespace: 'team-a' });
|
|
142
|
+
const a = await r('range', { cursor: '', limit: 10 }, { ns: 'team-a' });
|
|
143
|
+
const b = await r('range', { cursor: '', limit: 10 }, { ns: 'team-b' });
|
|
144
|
+
const dflt = await r('range', { cursor: '', limit: 10 });
|
|
145
|
+
const ids = (x) => (x.result.vectors).map((v) => v.id);
|
|
146
|
+
return JSON.stringify(ids(a)) === JSON.stringify(['x'])
|
|
147
|
+
&& JSON.stringify(ids(b)) === JSON.stringify(['y'])
|
|
148
|
+
&& JSON.stringify(ids(dflt)) === JSON.stringify(['z']);
|
|
149
|
+
})),
|
|
150
|
+
done('upstashvector.protocol.post_and_documented_method_both_work', 'protocol', '/reset and /delete-namespace accept BOTH the DELETE their own doc pages spell and the POST the real SDK actually sends (@upstash/vector\'s HttpClient hardcodes method:"POST", so a DELETE-only twin would 405 every real client — while get-started\'s allow-list omits DELETE entirely, so accepting both is the only way to satisfy every documented caller)', 'api', 'core', () => withRoot(async (r) => {
|
|
151
|
+
await r('upsert', { id: 'a', vector: [1, 0] }, { ns: 'n1' });
|
|
152
|
+
await r('upsert', { id: 'b', vector: [1, 0] }, { ns: 'n2' });
|
|
153
|
+
const postDelete = await r('delete-namespace', undefined, { ns: 'n1', method: 'POST' });
|
|
154
|
+
const httpDelete = await r('delete-namespace', undefined, { ns: 'n2', method: 'DELETE' });
|
|
155
|
+
const resetPost = await r('reset', undefined, { method: 'POST' });
|
|
156
|
+
const resetDelete = await r('reset', undefined, { method: 'DELETE' });
|
|
157
|
+
const names = await r('list-namespaces', undefined, { method: 'GET' });
|
|
158
|
+
return postDelete.result === 'Success' && httpDelete.result === 'Success'
|
|
159
|
+
&& resetPost.result === 'Success' && resetDelete.result === 'Success'
|
|
160
|
+
&& JSON.stringify(names.result) === JSON.stringify(['']);
|
|
161
|
+
})),
|
|
162
|
+
done('upstashvector.protocol.unknown_endpoint_404', 'protocol', 'a path that is not an Upstash Vector endpoint answers 404 — never a fabricated 200', 'api', 'core', () => withRoot(async (r) => {
|
|
163
|
+
const bogus = await r('totally-made-up', {});
|
|
164
|
+
const rootPath = await r.raw({ path: '/' });
|
|
165
|
+
return bogus.status === 404 && bogus.errorStatus === 404
|
|
166
|
+
&& rootPath.status === 404 && rootPath.body.status === 404;
|
|
167
|
+
})),
|
|
168
|
+
done('upstashvector.protocol.unmodeled_endpoint_fails_naming_the_gap', 'protocol', 'a REAL vendor endpoint this twin has not built (/upsert-data, /query-data, /resumable-query*) answers 404 NAMING the manifest gap that tracks it — the unmodeled-ops-fail-like-the-vendor rule, made checkable', 'api', 'core', () => withRoot(async (r) => {
|
|
169
|
+
const upsertData = await r('upsert-data', { id: 'a', data: 'hello' });
|
|
170
|
+
const queryData = await r('query-data', { data: 'hello', topK: 1 });
|
|
171
|
+
const resumable = await r('resumable-query', { vector: [1, 0], topK: 1, maxIdle: 10 });
|
|
172
|
+
return upsertData.status === 404 && (upsertData.error ?? '').includes('upstashvector.embedding.upsert_data')
|
|
173
|
+
&& queryData.status === 404 && (queryData.error ?? '').includes('upstashvector.embedding.query_data')
|
|
174
|
+
&& resumable.status === 404 && (resumable.error ?? '').includes('upstashvector.resumable.start');
|
|
175
|
+
})),
|
|
176
|
+
done('upstashvector.protocol.method_not_allowed_405', 'protocol', 'an HTTP method outside {GET,POST,PUT,HEAD,DELETE,OPTIONS} answers 405. NB the vendor\'s docs CONTRADICT themselves: get-started says verbatim "Only HEAD, GET, POST, and PUT methods are allowed", yet the /reset and /delete-namespace pages document `-X DELETE`. This twin accepts DELETE — the permissive direction, so neither documented caller is refused', 'api', 'niche', () => withRoot(async (r) => {
|
|
177
|
+
const patch = await r.raw({ method: 'PATCH', path: '/info' });
|
|
178
|
+
return patch.status === 405 && patch.body.status === 405;
|
|
179
|
+
})),
|
|
180
|
+
done('upstashvector.protocol.cors_preflight', 'protocol', 'OPTIONS is a CORS preflight answering 200 with an EMPTY body and the echoed origin — and it is answered BEFORE auth, since a browser preflight carries no Authorization header by definition', 'api', 'niche', () => withRoot(async (r) => {
|
|
181
|
+
const preflight = await r.raw({ method: 'OPTIONS', path: '/query', headers: { origin: 'https://app.example.test', 'access-control-request-headers': 'authorization' } });
|
|
182
|
+
return preflight.status === 200 && preflight.body === null
|
|
183
|
+
&& preflight.headers?.['access-control-allow-origin'] === 'https://app.example.test'
|
|
184
|
+
&& preflight.headers?.['access-control-allow-headers'] === 'authorization';
|
|
185
|
+
})),
|
|
186
|
+
done('upstashvector.protocol.malformed_body_400', 'protocol', 'a missing or non-JSON request body answers 400 with the error envelope, never a 200 with an empty result', 'api', 'common', () => withRoot(async (r) => {
|
|
187
|
+
const empty = await r.raw({ path: '/query', body: '' });
|
|
188
|
+
const garbage = await r.raw({ path: '/query', body: '{not json' });
|
|
189
|
+
return empty.status === 400 && empty.body.status === 400
|
|
190
|
+
&& garbage.status === 400 && garbage.body.status === 400;
|
|
191
|
+
})),
|
|
192
|
+
done('upstashvector.protocol.safe_methods_never_mutate', 'protocol', 'HEAD answers 200 with no body at all on a READ, and — because RFC 9110 defines both HEAD and GET as SAFE methods — neither can ever mutate: HEAD or GET against /upsert, /delete or /reset is refused 405 and changes nothing. (§9 round 1 found the opposite: HEAD ran the command and committed the write, then suppressed the body, so `HEAD /reset` silently emptied the index behind a read-shaped 200.)', 'api', 'common', () => withRoot(async (r) => {
|
|
193
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'b', vector: [0, 1] }]);
|
|
194
|
+
const head = await r.raw({ method: 'HEAD', path: '/info' });
|
|
195
|
+
const get = await r.raw({ method: 'GET', path: '/info' });
|
|
196
|
+
const headReset = await r.raw({ method: 'HEAD', path: '/reset', body: '{}' });
|
|
197
|
+
const headDelete = await r.raw({ method: 'HEAD', path: '/delete', body: JSON.stringify({ ids: ['a'] }) });
|
|
198
|
+
const headUpsert = await r.raw({ method: 'HEAD', path: '/upsert', body: JSON.stringify({ id: 'sneaky', vector: [1, 1] }) });
|
|
199
|
+
// …and GET, which caches/prefetchers/crawlers issue freely.
|
|
200
|
+
const getReset = await r.raw({ method: 'GET', path: '/reset' });
|
|
201
|
+
const getDelete = await r.raw({ method: 'GET', path: '/delete', body: JSON.stringify({ ids: ['a'] }) });
|
|
202
|
+
const after = await r('info', undefined, { method: 'GET' });
|
|
203
|
+
return head.status === 200 && head.body === null
|
|
204
|
+
&& get.status === 200 && get.body.result !== undefined
|
|
205
|
+
&& headReset.status === 405 && headDelete.status === 405 && headUpsert.status === 405
|
|
206
|
+
&& getReset.status === 405 && getDelete.status === 405
|
|
207
|
+
// THE POINT: the index is untouched by every one of them.
|
|
208
|
+
&& after.result.vectorCount === 2;
|
|
209
|
+
})),
|
|
210
|
+
done('upstashvector.filtering.malformed_path_is_a_parse_error', 'filtering', 'a malformed metadata PATH (an unterminated `[` subscript, a non-integer index) is rejected at PARSE time with a 400, so the answer never depends on how much data happens to be stored. (§9 round 1: these parsed fine and only failed inside the evaluator, so the SAME filter answered an internal 500 against a populated namespace and a silent 200 [] against an empty one.)', 'api', 'core', () => withRoot(async (r) => {
|
|
211
|
+
const bad = ['a[0 = 1', 'HAS FIELD a[', "tags[x] = 'v'", 'a[] = 1'];
|
|
212
|
+
const empties = [];
|
|
213
|
+
const populated = [];
|
|
214
|
+
// FIRST against an EMPTY namespace…
|
|
215
|
+
for (const filter of bad)
|
|
216
|
+
empties.push((await r('query', { vector: [1, 0], topK: 5, filter })).status);
|
|
217
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { tags: ['x'] } });
|
|
218
|
+
// …then against a POPULATED one. The two must agree.
|
|
219
|
+
for (const filter of bad)
|
|
220
|
+
populated.push((await r('query', { vector: [1, 0], topK: 5, filter })).status);
|
|
221
|
+
// …and the delete path must agree too, without removing anything.
|
|
222
|
+
const del = await r('delete', { filter: 'a[0 = 1' }, { at: laterAt(1000) });
|
|
223
|
+
const survived = await r('info', undefined, { method: 'GET' });
|
|
224
|
+
return empties.every((s) => s === 400) && populated.every((s) => s === 400)
|
|
225
|
+
&& JSON.stringify(empties) === JSON.stringify(populated)
|
|
226
|
+
&& del.status === 400
|
|
227
|
+
&& survived.result.vectorCount === 1;
|
|
228
|
+
})),
|
|
229
|
+
// ── AUTH ────────────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
done('upstashvector.auth.missing_token_401', 'auth', 'a missing credential answers HTTP 401 with the vendor\'s VERBATIM published body \u2014 {"error": "Unauthorized: Invalid auth token", "status": 401} \u2014 quoted from upstash.com/docs/vector/api/get-started\'s own error example (note the capital I in "Invalid"). One of the two strings in this pack taken word-for-word from the vendor, alongside the dimension-mismatch 422', 'api', 'core', () => withRoot(async (r) => {
|
|
231
|
+
const none = await r.raw({ path: '/info', method: 'GET', headers: {} });
|
|
232
|
+
const wrong = await r.raw({ path: '/info', method: 'GET', token: 'the-real-one', headers: { authorization: 'Bearer nope' } });
|
|
233
|
+
// Assert the string EXACTLY. §9 round 2 sabotaged this constant to
|
|
234
|
+
// 'totally wrong sabotaged string' and the whole suite still passed, because the verify only
|
|
235
|
+
// checked `typeof error === 'string'` — a claimed-verbatim vendor body that nothing pinned.
|
|
236
|
+
// A byte-for-byte assertion is the only thing that makes "verbatim" mean anything.
|
|
237
|
+
const expected = 'Unauthorized: Invalid auth token';
|
|
238
|
+
return none.status === 401 && none.body.status === 401
|
|
239
|
+
&& none.body.error === expected
|
|
240
|
+
&& none.body.result === undefined
|
|
241
|
+
// The vendor draws no distinction between missing and wrong: same status, same body.
|
|
242
|
+
&& wrong.status === 401 && wrong.body.error === expected;
|
|
243
|
+
})),
|
|
244
|
+
done('upstashvector.auth.empty_bearer_is_unauthorized', 'auth', 'the header `Authorization: Bearer ` (the scheme with an EMPTY token) is 401, not authenticated as the literal string "Bearer" — the parsing trap a `/^bearer\\s+(.*)$/` pattern walks straight into', 'api', 'common', () => withRoot(async (r) => {
|
|
245
|
+
const blank = await r.raw({ path: '/info', method: 'GET', headers: { authorization: 'Bearer ' } });
|
|
246
|
+
const bareWord = await r.raw({ path: '/info', method: 'GET', headers: { authorization: 'Bearer' } });
|
|
247
|
+
const ok = await r.raw({ path: '/info', method: 'GET', headers: { authorization: 'Bearer real-token' } });
|
|
248
|
+
return blank.status === 401 && bareWord.status === 401 && ok.status === 200;
|
|
249
|
+
})),
|
|
250
|
+
done('upstashvector.auth.exact_token_enforced', 'auth', 'when the twin is started WITH a token, only that exact credential is accepted and any other is 401 — so a world can prove its app is sending the credential it thinks it is', 'api', 'common', () => withRoot(async (r) => {
|
|
251
|
+
const right = await r.raw({ path: '/info', method: 'GET', token: 'secret-abc', headers: { authorization: 'Bearer secret-abc' } });
|
|
252
|
+
const wrong = await r.raw({ path: '/info', method: 'GET', token: 'secret-abc', headers: { authorization: 'Bearer secret-xyz' } });
|
|
253
|
+
return right.status === 200 && wrong.status === 401;
|
|
254
|
+
})),
|
|
255
|
+
done('upstashvector.auth.bare_authorization_header', 'auth', 'a bare `Authorization: <token>` without the Bearer scheme is accepted and drives the FULL surface (a vector upserted under a bare header is readable under one), matching the sibling Upstash REST surface\'s probed tolerance — while a bare header carrying only whitespace is still 401', 'api', 'niche', () => withRoot(async (r) => {
|
|
256
|
+
const bare = { authorization: 'plain-token' };
|
|
257
|
+
// Assert VALUES through the bare-auth path, not just a status: a status-only check here
|
|
258
|
+
// survives a dead handler (§9 / the mutation gate caught exactly that).
|
|
259
|
+
const wrote = await r.raw({ path: '/upsert', body: JSON.stringify({ id: 'bare', vector: [0.25, 0.75], metadata: { via: 'bare-auth' } }), headers: bare });
|
|
260
|
+
const read = await r.raw({ path: '/fetch', body: JSON.stringify({ ids: ['bare'], includeMetadata: true, includeVectors: true }), headers: bare });
|
|
261
|
+
const rows = read.body.result ?? [];
|
|
262
|
+
const blank = await r.raw({ path: '/info', method: 'GET', headers: { authorization: ' ' } });
|
|
263
|
+
return wrote.body.result === 'Success'
|
|
264
|
+
&& rows.length === 1 && rows[0].id === 'bare'
|
|
265
|
+
&& JSON.stringify(rows[0].vector) === JSON.stringify([0.25, 0.75])
|
|
266
|
+
&& JSON.stringify(rows[0].metadata) === JSON.stringify({ via: 'bare-auth' })
|
|
267
|
+
&& blank.status === 401;
|
|
268
|
+
})),
|
|
269
|
+
// ── UPSERT ──────────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
done('upstashvector.upsert.single_and_batch', 'upsert', 'POST /upsert accepts EITHER one vector object or an array of them (both curl forms the vendor documents), and every element becomes queryable state', 'api', 'core', () => withRoot(async (r) => {
|
|
271
|
+
const one = await r('upsert', { id: 'solo', vector: [1, 0] });
|
|
272
|
+
const many = await r('upsert', [{ id: 'a', vector: [0, 1] }, { id: 'b', vector: [1, 1] }]);
|
|
273
|
+
const page = await r('range', { cursor: '', limit: 10 });
|
|
274
|
+
const ids = (page.result.vectors).map((v) => v.id);
|
|
275
|
+
return one.result === 'Success' && many.result === 'Success' && JSON.stringify(ids) === JSON.stringify(['a', 'b', 'solo']);
|
|
276
|
+
})),
|
|
277
|
+
done('upstashvector.upsert.replaces_rather_than_merges', 'upsert', 're-upserting an existing id REPLACES the whole vector — an upsert without `metadata` CLEARS the metadata the id previously had. (The vendor\'s upsert page does NOT state replace-vs-merge; that it ships a separate /update endpoint carrying a metadataUpdateMode is the INFERENCE this models, not a documented fact. What is asserted here is the twin behaviour.)', 'api', 'core', () => withRoot(async (r) => {
|
|
278
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { colour: 'red' }, data: 'first' });
|
|
279
|
+
const before = await r('fetch', { ids: ['a'], includeMetadata: true, includeData: true });
|
|
280
|
+
await r('upsert', { id: 'a', vector: [0, 1] }, { at: laterAt(1000) });
|
|
281
|
+
const after = await r('fetch', { ids: ['a'], includeMetadata: true, includeData: true, includeVectors: true });
|
|
282
|
+
const b0 = before.result[0];
|
|
283
|
+
const a0 = after.result[0];
|
|
284
|
+
return JSON.stringify(b0.metadata) === JSON.stringify({ colour: 'red' }) && b0.data === 'first'
|
|
285
|
+
&& a0.metadata === undefined && a0.data === undefined
|
|
286
|
+
&& JSON.stringify(a0.vector) === JSON.stringify([0, 1]);
|
|
287
|
+
})),
|
|
288
|
+
done('upstashvector.upsert.dimension_locked_and_enforced', 'upsert', 'the index enforces ONE dimension: the first upsert locks it in and a later mismatch is refused with HTTP 422 and the vendor\'s VERBATIM message "Invalid vector dimension: N, expected: M" (the only error string in this pack quoted from Upstash\'s own docs — the /upsert endpoint\'s 422 example)', 'api', 'core', () => withRoot(async (r) => {
|
|
289
|
+
await r('upsert', { id: 'a', vector: [0.1, 0.2] });
|
|
290
|
+
const bad = await r('upsert', { id: 'b', vector: [0.1, 0.2, 0.3] });
|
|
291
|
+
const stillOne = await r('range', { cursor: '', limit: 10 });
|
|
292
|
+
return bad.status === 422 && bad.error === 'Invalid vector dimension: 3, expected: 2'
|
|
293
|
+
&& (stillOne.result.vectors).length === 1;
|
|
294
|
+
})),
|
|
295
|
+
done('upstashvector.upsert.dimension_configured_at_creation', 'upsert', 'a twin started with an explicit `dimension` enforces it from the very FIRST upsert (matching a real index, whose dimension is fixed at creation) rather than adopting whatever arrived first', 'api', 'common', () => withRoot(async (r) => {
|
|
296
|
+
const bad = await r('upsert', { id: 'a', vector: [1, 0] });
|
|
297
|
+
const good = await r('upsert', { id: 'b', vector: [1, 0, 0, 0] });
|
|
298
|
+
return bad.status === 422 && bad.error === 'Invalid vector dimension: 2, expected: 4' && good.result === 'Success';
|
|
299
|
+
}, { dimension: 4 })),
|
|
300
|
+
done('upstashvector.upsert.batch_is_validated_atomically', 'upsert', 'one bad element refuses the WHOLE batch before anything is written — a partially-applied upsert would leave state the caller never asked for', 'api', 'common', () => withRoot(async (r) => {
|
|
301
|
+
await r('upsert', { id: 'seed', vector: [1, 0] });
|
|
302
|
+
const batch = await r('upsert', [{ id: 'ok1', vector: [0, 1] }, { id: 'bad', vector: [0, 1, 2] }], { at: laterAt(1000) });
|
|
303
|
+
const page = await r('range', { cursor: '', limit: 10 });
|
|
304
|
+
const ids = (page.result.vectors).map((v) => v.id);
|
|
305
|
+
return batch.status === 422 && JSON.stringify(ids) === JSON.stringify(['seed']);
|
|
306
|
+
})),
|
|
307
|
+
done('upstashvector.upsert.rejects_malformed_elements', 'upsert', 'a missing `vector`, an empty/absent id, a non-array vector and a non-finite coordinate are each refused at the boundary (a NaN coordinate would poison every score it touched and JSON-serialise to null)', 'api', 'common', () => withRoot(async (r) => {
|
|
308
|
+
const noVector = await r('upsert', { id: 'a' });
|
|
309
|
+
const noId = await r('upsert', { vector: [1, 0] });
|
|
310
|
+
const emptyId = await r('upsert', { id: '', vector: [1, 0] });
|
|
311
|
+
const notArray = await r('upsert', { id: 'a', vector: 'nope' });
|
|
312
|
+
const nan = await r('upsert', { id: 'a', vector: [1, null] });
|
|
313
|
+
const page = await r('range', { cursor: '', limit: 10 });
|
|
314
|
+
return [noVector, noId, emptyId, notArray, nan].every((x) => x.status === 400 && x.result === undefined)
|
|
315
|
+
&& (page.result.vectors).length === 0;
|
|
316
|
+
})),
|
|
317
|
+
done('upstashvector.upsert.numeric_ids_accepted', 'upsert', 'a JSON-number id is accepted and carried as its string form (the SDK types ids as `number|string`), so `fetch([123])` finds what `upsert({id:123})` wrote', 'api', 'niche', () => withRoot(async (r) => {
|
|
318
|
+
await r('upsert', { id: 123, vector: [1, 0] });
|
|
319
|
+
const got = await r('fetch', { ids: [123] });
|
|
320
|
+
const alsoString = await r('fetch', { ids: ['123'] });
|
|
321
|
+
return got.result[0]?.id === '123'
|
|
322
|
+
&& alsoString.result[0]?.id === '123';
|
|
323
|
+
})),
|
|
324
|
+
done('upstashvector.upsert.data_alongside_a_vector', 'upsert', '`data` may accompany an explicit `vector` (the SDK\'s "Upsert data alongside with your embedding" form) and round-trips through includeData — it is only `data` WITHOUT a vector that needs an embedding model', 'api', 'common', () => withRoot(async (r) => {
|
|
325
|
+
const ok = await r('upsert', { id: 'tokyo', vector: [1, 0], data: 'Tokyo is the capital of Japan.' });
|
|
326
|
+
const got = await r('fetch', { ids: ['tokyo'], includeData: true });
|
|
327
|
+
return ok.result === 'Success' && got.result[0]?.data === 'Tokyo is the capital of Japan.';
|
|
328
|
+
})),
|
|
329
|
+
done('upstashvector.upsert.sparse_vector_refused_not_ignored', 'upsert', 'a `sparseVector` payload is REFUSED with 422 rather than silently dropped — accepting it while ignoring it would make a hybrid-index caller\'s recall look fine while half their signal vanished', 'api', 'common', () => withRoot(async (r) => {
|
|
330
|
+
const sparse = await r('upsert', { id: 'a', vector: [1, 0], sparseVector: { indices: [2, 3], values: [0.1, 0.9] } });
|
|
331
|
+
return sparse.status === 422 && (sparse.error ?? '').includes('upstashvector.sparse.upsert');
|
|
332
|
+
})),
|
|
333
|
+
// ── SCORING (the claim this pack stands on) ─────────────────────────────────────────────────
|
|
334
|
+
done('upstashvector.scoring.cosine_formula', 'scoring', 'COSINE scores are computed from the stored coordinates with Upstash\'s published normalization "(1 + cosine_similarity(v1, v2)) / 2" — checked against hand-computed values, including the identical-vector score of exactly 1 and the orthogonal score of exactly 0.5', 'api', 'core', () => withRoot(async (r) => {
|
|
335
|
+
await r('upsert', [
|
|
336
|
+
{ id: 'same', vector: [1, 0] },
|
|
337
|
+
{ id: 'orthogonal', vector: [0, 1] },
|
|
338
|
+
{ id: 'opposite', vector: [-1, 0] },
|
|
339
|
+
]);
|
|
340
|
+
const q = await r('query', { vector: [1, 0], topK: 3 });
|
|
341
|
+
const rows = q.result;
|
|
342
|
+
const byId = Object.fromEntries(rows.map((x) => [x.id, x.score]));
|
|
343
|
+
// cos(same)=1 → 1 ; cos(orthogonal)=0 → 0.5 ; cos(opposite)=-1 → 0
|
|
344
|
+
return rows.length === 3 && near(byId.same, 1) && near(byId.orthogonal, 0.5) && near(byId.opposite, 0)
|
|
345
|
+
&& near(similarityScore('COSINE', [1, 0], [0, 1]), 0.5)
|
|
346
|
+
&& near(cosineSimilarity([3, 4], [3, 4]), 1);
|
|
347
|
+
}, { similarityFunction: 'COSINE' })),
|
|
348
|
+
done('upstashvector.scoring.euclidean_formula', 'scoring', 'EUCLIDEAN scores use the published "1 / (1 + squared_distance(v1, v2))" — so an exact match scores 1 and a squared distance of 3 scores exactly 0.25, both checked against the arithmetic', 'api', 'core', () => withRoot(async (r) => {
|
|
349
|
+
await r('upsert', [{ id: 'exact', vector: [1, 2] }, { id: 'far', vector: [2, 3] }]);
|
|
350
|
+
const q = await r('query', { vector: [1, 2], topK: 2 });
|
|
351
|
+
const rows = q.result;
|
|
352
|
+
// squared_distance([1,2],[2,3]) = 1+1 = 2 → 1/(1+2) = 1/3
|
|
353
|
+
return rows[0].id === 'exact' && near(rows[0].score, 1)
|
|
354
|
+
&& rows[1].id === 'far' && near(rows[1].score, 1 / 3)
|
|
355
|
+
&& squaredDistance([0, 0], [1, 1]) === 2
|
|
356
|
+
&& near(similarityScore('EUCLIDEAN', [0, 0, 0], [1, 1, 1]), 0.25);
|
|
357
|
+
}, { similarityFunction: 'EUCLIDEAN' })),
|
|
358
|
+
done('upstashvector.scoring.dot_product_formula', 'scoring', 'DOT_PRODUCT applies the published "(1 + dot_product(v1, v2)) / 2" LITERALLY, including to non-unit vectors — so magnitude affects the score and a longer parallel vector outranks a shorter one where COSINE would tie them. NB the vendor says dot-product vectors "need to be normalized to be of unit length" and that scores are "between 0 and 1"; this twin does not silently normalize for you, so a non-unit input can exceed 1. Whether the real index clamps, normalizes, or returns the same value is UNVERIFIED (no live index to probe) — what is asserted here is the twin arithmetic', 'api', 'core', () => withRoot(async (r) => {
|
|
359
|
+
await r('upsert', [{ id: 'short', vector: [1, 0] }, { id: 'long', vector: [3, 0] }]);
|
|
360
|
+
const q = await r('query', { vector: [1, 0], topK: 2 });
|
|
361
|
+
const rows = q.result;
|
|
362
|
+
// dot([1,0],[3,0])=3 → 2 ; dot([1,0],[1,0])=1 → 1. Under COSINE these two would TIE at 1.
|
|
363
|
+
return rows[0].id === 'long' && near(rows[0].score, 2)
|
|
364
|
+
&& rows[1].id === 'short' && near(rows[1].score, 1)
|
|
365
|
+
&& dotProduct([1, 2, 3], [4, 5, 6]) === 32;
|
|
366
|
+
}, { similarityFunction: 'DOT_PRODUCT' })),
|
|
367
|
+
done('upstashvector.scoring.metric_changes_the_ranking', 'scoring', 'the configured metric genuinely drives the ORDER, not just the number: the same three vectors and the same query rank differently under COSINE than under DOT_PRODUCT — proof the score is computed rather than stored', 'api', 'core', async () => {
|
|
368
|
+
const rank = async (fn) => {
|
|
369
|
+
let out = [];
|
|
370
|
+
await withRoot(async (r) => {
|
|
371
|
+
await r('upsert', [{ id: 'unit', vector: [1, 0] }, { id: 'big', vector: [5, 0] }, { id: 'diag', vector: [1, 1] }]);
|
|
372
|
+
const q = await r('query', { vector: [1, 0], topK: 3 });
|
|
373
|
+
out = q.result.map((x) => x.id);
|
|
374
|
+
return true;
|
|
375
|
+
}, { similarityFunction: fn });
|
|
376
|
+
return out;
|
|
377
|
+
};
|
|
378
|
+
const cos = await rank('COSINE');
|
|
379
|
+
const dot = await rank('DOT_PRODUCT');
|
|
380
|
+
// COSINE: unit and big are both exactly parallel to the query (score 1) so they tie and the id
|
|
381
|
+
// tie-break puts 'big' first; diag is at 45° (score ~0.854) and comes last.
|
|
382
|
+
// DOT_PRODUCT: big (5) > unit (1) = diag (1), so 'big' leads by a real margin.
|
|
383
|
+
return JSON.stringify(cos) === JSON.stringify(['big', 'unit', 'diag'])
|
|
384
|
+
&& JSON.stringify(dot) === JSON.stringify(['big', 'diag', 'unit']);
|
|
385
|
+
}),
|
|
386
|
+
done('upstashvector.scoring.deterministic_tie_break', 'scoring', 'vectors with EQUAL scores come back in a stable id-ascending order, so a ranking is reproducible run to run (the vendor does not specify a tie-break; this one is the twin\'s, and it is documented as such)', 'api', 'common', () => withRoot(async (r) => {
|
|
387
|
+
await r('upsert', [{ id: 'zzz', vector: [1, 0] }, { id: 'aaa', vector: [1, 0] }, { id: 'mmm', vector: [1, 0] }]);
|
|
388
|
+
const first = await r('query', { vector: [1, 0], topK: 3 });
|
|
389
|
+
const second = await r('query', { vector: [1, 0], topK: 3 });
|
|
390
|
+
const ids = (x) => x.result.map((v) => v.id);
|
|
391
|
+
return JSON.stringify(ids(first)) === JSON.stringify(['aaa', 'mmm', 'zzz'])
|
|
392
|
+
&& JSON.stringify(ids(first)) === JSON.stringify(ids(second));
|
|
393
|
+
})),
|
|
394
|
+
done('upstashvector.scoring.every_score_is_a_finite_number', 'scoring', 'a `score` is ALWAYS a finite number — a zero-length vector scores 0.5 under COSINE instead of NaN, and a coordinate too large for the float32 the index stores is refused at the boundary (400) instead of overflowing. Both matter for the same reason: JSON.stringify renders NaN and Infinity as `null`, so the caller would get a result row with a null score, no error anywhere, and a ranking silently degraded to id order because the comparator sees a falsy difference. (§9 round 1 found the overflow half.)', 'api', 'common', () => withRoot(async (r) => {
|
|
395
|
+
await r('upsert', [{ id: 'zero', vector: [0, 0] }, { id: 'real', vector: [1, 0] }]);
|
|
396
|
+
const q = await r('query', { vector: [1, 0], topK: 2 });
|
|
397
|
+
const rows = q.result;
|
|
398
|
+
// An overflow-scale coordinate is refused rather than stored and discovered at query time.
|
|
399
|
+
const huge = await r('upsert', { id: 'huge', vector: [1e308, 1e308] });
|
|
400
|
+
const stillFine = await r('query', { vector: [1, 0], topK: 5 });
|
|
401
|
+
const after = stillFine.result;
|
|
402
|
+
return rows.every((x) => typeof x.score === 'number' && Number.isFinite(x.score))
|
|
403
|
+
&& near(rows.find((x) => x.id === 'zero').score, 0.5)
|
|
404
|
+
&& cosineSimilarity([0, 0], [1, 0]) === 0
|
|
405
|
+
&& huge.status === 400 && huge.result === undefined
|
|
406
|
+
&& after.length === 2 && after.every((x) => Number.isFinite(x.score));
|
|
407
|
+
})),
|
|
408
|
+
// ── QUERY ───────────────────────────────────────────────────────────────────────────────────
|
|
409
|
+
done('upstashvector.query.topk_and_default', 'query', 'topK bounds the result count and DEFAULTS to 10 when omitted (the vendor\'s documented default), so an unbounded index does not dump itself into a response', 'api', 'core', () => withRoot(async (r) => {
|
|
410
|
+
await r('upsert', Array.from({ length: 15 }, (_, i) => ({ id: `v${String(i).padStart(2, '0')}`, vector: [i / 100, 1] })));
|
|
411
|
+
const explicit = await r('query', { vector: [0, 1], topK: 3 });
|
|
412
|
+
const defaulted = await r('query', { vector: [0, 1] });
|
|
413
|
+
return explicit.result.length === 3 && defaulted.result.length === 10;
|
|
414
|
+
})),
|
|
415
|
+
done('upstashvector.query.include_flags', 'query', 'includeVectors / includeMetadata / includeData each add exactly their field and are OFF by default — and an absent metadata/data key is OMITTED rather than sent as null (the shape the vendor\'s own fetch example shows)', 'api', 'core', () => withRoot(async (r) => {
|
|
416
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { k: 'v' }, data: 'text' });
|
|
417
|
+
await r('upsert', { id: 'b', vector: [1, 0] }, { at: laterAt(10) });
|
|
418
|
+
const bare = await r('query', { vector: [1, 0], topK: 1 });
|
|
419
|
+
const full = await r('query', { vector: [1, 0], topK: 2, includeVectors: true, includeMetadata: true, includeData: true });
|
|
420
|
+
const b0 = bare.result[0];
|
|
421
|
+
const rows = full.result;
|
|
422
|
+
const withMeta = rows.find((x) => x.id === 'a');
|
|
423
|
+
const without = rows.find((x) => x.id === 'b');
|
|
424
|
+
return b0.vector === undefined && b0.metadata === undefined && b0.data === undefined && typeof b0.score === 'number'
|
|
425
|
+
&& JSON.stringify(withMeta.vector) === JSON.stringify([1, 0])
|
|
426
|
+
&& JSON.stringify(withMeta.metadata) === JSON.stringify({ k: 'v' }) && withMeta.data === 'text'
|
|
427
|
+
&& without.metadata === undefined && without.data === undefined && JSON.stringify(without.vector) === JSON.stringify([1, 0]);
|
|
428
|
+
})),
|
|
429
|
+
done('upstashvector.query.query_many_batch', 'query', 'an ARRAY body on /query is `queryMany`: N>1 answers a NESTED array (one result list per query), while a ONE-element batch answers FLAT — the asymmetry the installed SDK\'s QueryManyCommand.exec re-nests, and its own comment documents as real API behaviour', 'api', 'common', () => withRoot(async (r) => {
|
|
430
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'b', vector: [0, 1] }]);
|
|
431
|
+
const two = await r('query', [{ vector: [1, 0], topK: 1 }, { vector: [0, 1], topK: 1 }]);
|
|
432
|
+
const one = await r('query', [{ vector: [1, 0], topK: 1 }]);
|
|
433
|
+
const nested = two.result;
|
|
434
|
+
const flat = one.result;
|
|
435
|
+
return Array.isArray(nested) && nested.length === 2 && Array.isArray(nested[0])
|
|
436
|
+
&& nested[0][0].id === 'a' && nested[1][0].id === 'b'
|
|
437
|
+
&& Array.isArray(flat) && flat.length === 1 && !Array.isArray(flat[0]) && flat[0].id === 'a';
|
|
438
|
+
})),
|
|
439
|
+
done('upstashvector.query.dimension_mismatch_422', 'query', 'a query vector whose length disagrees with the index answers 422 with the vendor\'s verbatim dimension message — the same guard the write path applies, so a mis-sized query fails loudly instead of ranking on a truncated overlap. Holds for any index that HAS a dimension: one upserted into, one configured at construction, or (since the \u00a79 fix) one that was merely PULLED, whose dimension is inferred from the stored vectors when they all agree', 'api', 'common', () => withRoot(async (r) => {
|
|
440
|
+
await r('upsert', { id: 'a', vector: [1, 0] });
|
|
441
|
+
const bad = await r('query', { vector: [1, 0, 0], topK: 1 });
|
|
442
|
+
return bad.status === 422 && bad.error === 'Invalid vector dimension: 3, expected: 2';
|
|
443
|
+
})),
|
|
444
|
+
done('upstashvector.query.empty_index_and_missing_vector', 'query', 'a query against an empty index answers an EMPTY array (not an error), while a query with no `vector` at all answers 400 — absence of results and absence of a query are different failures', 'api', 'common', () => withRoot(async (r) => {
|
|
445
|
+
const empty = await r('query', { vector: [1, 0], topK: 5 });
|
|
446
|
+
const noVector = await r('query', { topK: 5 });
|
|
447
|
+
return empty.status === 200 && Array.isArray(empty.result) && empty.result.length === 0
|
|
448
|
+
&& noVector.status === 400;
|
|
449
|
+
})),
|
|
450
|
+
// ── FETCH ───────────────────────────────────────────────────────────────────────────────────
|
|
451
|
+
done('upstashvector.fetch.by_ids_is_positional_with_nulls', 'fetch', 'fetch by ids returns an array POSITIONALLY ALIGNED with the request, with `null` where "no such vector exists with the provided id" (the vendor\'s own wording) — so index N of the answer always describes id N of the ask', 'api', 'core', () => withRoot(async (r) => {
|
|
452
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'c', vector: [0, 1] }]);
|
|
453
|
+
const got = await r('fetch', { ids: ['a', 'missing', 'c'] });
|
|
454
|
+
const rows = got.result;
|
|
455
|
+
return rows.length === 3 && rows[0]?.id === 'a' && rows[1] === null && rows[2]?.id === 'c';
|
|
456
|
+
})),
|
|
457
|
+
done('upstashvector.fetch.by_prefix', 'fetch', 'fetch by `prefix` returns only the matching ids (no nulls), in id order', 'api', 'common', () => withRoot(async (r) => {
|
|
458
|
+
await r('upsert', [{ id: 'doc-2', vector: [1, 0] }, { id: 'doc-1', vector: [0, 1] }, { id: 'img-1', vector: [1, 1] }]);
|
|
459
|
+
const got = await r('fetch', { prefix: 'doc-' });
|
|
460
|
+
const ids = got.result.map((x) => x.id);
|
|
461
|
+
return JSON.stringify(ids) === JSON.stringify(['doc-1', 'doc-2']);
|
|
462
|
+
})),
|
|
463
|
+
done('upstashvector.fetch.include_flags_and_bad_request', 'fetch', 'fetch honours the include flags, and a body with neither `ids` nor `prefix` answers 400 rather than silently returning everything', 'api', 'common', () => withRoot(async (r) => {
|
|
464
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { n: 1 } });
|
|
465
|
+
const full = await r('fetch', { ids: ['a'], includeVectors: true, includeMetadata: true });
|
|
466
|
+
const neither = await r('fetch', {});
|
|
467
|
+
const row = full.result[0];
|
|
468
|
+
return JSON.stringify(row.vector) === JSON.stringify([1, 0])
|
|
469
|
+
&& JSON.stringify(row.metadata) === JSON.stringify({ n: 1 })
|
|
470
|
+
&& neither.status === 400;
|
|
471
|
+
})),
|
|
472
|
+
// ── RANGE ───────────────────────────────────────────────────────────────────────────────────
|
|
473
|
+
done('upstashvector.range.pagination_walks_the_whole_index', 'range', 'successive /range calls threaded through `nextCursor` visit every vector exactly once, and the walk terminates with nextCursor "" — the vendor\'s documented end-of-range signal', 'api', 'core', () => withRoot(async (r) => {
|
|
474
|
+
await r('upsert', Array.from({ length: 5 }, (_, i) => ({ id: `v${i}`, vector: [i, 1] })));
|
|
475
|
+
const seen = [];
|
|
476
|
+
let cursor = '';
|
|
477
|
+
let guard = 0;
|
|
478
|
+
let last = '';
|
|
479
|
+
for (; guard < 10; guard++) {
|
|
480
|
+
const page = await r('range', { cursor, limit: 2 });
|
|
481
|
+
const body = page.result;
|
|
482
|
+
seen.push(...body.vectors.map((v) => v.id));
|
|
483
|
+
last = body.nextCursor;
|
|
484
|
+
if (body.nextCursor === '')
|
|
485
|
+
break;
|
|
486
|
+
cursor = body.nextCursor;
|
|
487
|
+
}
|
|
488
|
+
return last === '' && JSON.stringify(seen) === JSON.stringify(['v0', 'v1', 'v2', 'v3', 'v4'])
|
|
489
|
+
&& new Set(seen).size === 5;
|
|
490
|
+
})),
|
|
491
|
+
done('upstashvector.range.prefix_and_include_flags', 'range', '/range narrows to a `prefix` and honours the include flags, and an unparseable cursor answers 400 rather than silently restarting the walk from the top', 'api', 'common', () => withRoot(async (r) => {
|
|
492
|
+
await r('upsert', [{ id: 'a-1', vector: [1, 0], metadata: { t: 'x' } }, { id: 'b-1', vector: [0, 1] }]);
|
|
493
|
+
const page = await r('range', { cursor: '', limit: 10, prefix: 'a-', includeMetadata: true });
|
|
494
|
+
const body = page.result;
|
|
495
|
+
const bogus = await r('range', { cursor: 'not-a-cursor', limit: 10 });
|
|
496
|
+
return body.vectors.length === 1 && body.vectors[0].id === 'a-1'
|
|
497
|
+
&& JSON.stringify(body.vectors[0].metadata) === JSON.stringify({ t: 'x' })
|
|
498
|
+
&& body.nextCursor === '' && bogus.status === 400;
|
|
499
|
+
})),
|
|
500
|
+
// ── UPDATE ──────────────────────────────────────────────────────────────────────────────────
|
|
501
|
+
done('upstashvector.update.overwrite_is_the_default', 'update', '/update with metadata replaces the metadata object wholesale by default (metadataUpdateMode OVERWRITE) and answers {updated:1}, leaving the vector itself untouched', 'api', 'common', () => withRoot(async (r) => {
|
|
502
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { colour: 'red', size: 'L' } });
|
|
503
|
+
const upd = await r('update', { id: 'a', metadata: { colour: 'blue' } }, { at: laterAt(1000) });
|
|
504
|
+
const got = await r('fetch', { ids: ['a'], includeMetadata: true, includeVectors: true });
|
|
505
|
+
const row = got.result[0];
|
|
506
|
+
return JSON.stringify(upd.result) === JSON.stringify({ updated: 1 })
|
|
507
|
+
&& JSON.stringify(row.metadata) === JSON.stringify({ colour: 'blue' })
|
|
508
|
+
&& JSON.stringify(row.vector) === JSON.stringify([1, 0]);
|
|
509
|
+
})),
|
|
510
|
+
done('upstashvector.update.patch_mode_merges', 'update', 'metadataUpdateMode PATCH MERGES the supplied keys over the existing metadata instead of replacing it — the behavioural difference that makes the mode worth having', 'api', 'common', () => withRoot(async (r) => {
|
|
511
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { colour: 'red', size: 'L' } });
|
|
512
|
+
const upd = await r('update', { id: 'a', metadata: { colour: 'blue' }, metadataUpdateMode: 'PATCH' }, { at: laterAt(1000) });
|
|
513
|
+
const got = await r('fetch', { ids: ['a'], includeMetadata: true });
|
|
514
|
+
const meta = got.result[0].metadata;
|
|
515
|
+
const bogus = await r('update', { id: 'a', metadata: {}, metadataUpdateMode: 'SIDEWAYS' });
|
|
516
|
+
return JSON.stringify(upd.result) === JSON.stringify({ updated: 1 })
|
|
517
|
+
&& meta.colour === 'blue' && meta.size === 'L' && bogus.status === 400;
|
|
518
|
+
})),
|
|
519
|
+
done('upstashvector.update.vector_and_missing_id', 'update', '/update can replace the coordinates (re-checking the dimension), and an id that does not exist answers {updated:0} — a normal answer, not an error', 'api', 'common', () => withRoot(async (r) => {
|
|
520
|
+
await r('upsert', { id: 'a', vector: [1, 0] });
|
|
521
|
+
const moved = await r('update', { id: 'a', vector: [0, 1] }, { at: laterAt(1000) });
|
|
522
|
+
const q = await r('query', { vector: [0, 1], topK: 1 });
|
|
523
|
+
const nobody = await r('update', { id: 'ghost', metadata: { x: 1 } });
|
|
524
|
+
const wrongDim = await r('update', { id: 'a', vector: [0, 1, 2] });
|
|
525
|
+
return JSON.stringify(moved.result) === JSON.stringify({ updated: 1 })
|
|
526
|
+
&& near(q.result[0].score, 1)
|
|
527
|
+
&& JSON.stringify(nobody.result) === JSON.stringify({ updated: 0 })
|
|
528
|
+
&& wrongDim.status === 422;
|
|
529
|
+
})),
|
|
530
|
+
// ── DELETE ──────────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
done('upstashvector.delete.by_ids_counts_only_real_hits', 'delete', '/delete by ids answers the vendor\'s {deleted:N} where N counts vectors that ACTUALLY existed — an unknown id contributes 0 rather than erroring, and a repeated id counts once', 'api', 'core', () => withRoot(async (r) => {
|
|
532
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'b', vector: [0, 1] }]);
|
|
533
|
+
const del = await r('delete', { ids: ['a', 'ghost', 'a'] }, { at: laterAt(1000) });
|
|
534
|
+
const left = await r('range', { cursor: '', limit: 10 });
|
|
535
|
+
return JSON.stringify(del.result) === JSON.stringify({ deleted: 1 })
|
|
536
|
+
&& JSON.stringify((left.result.vectors).map((v) => v.id)) === JSON.stringify(['b']);
|
|
537
|
+
})),
|
|
538
|
+
done('upstashvector.delete.by_prefix', 'delete', '/delete by `prefix` removes exactly the matching ids and reports the real count', 'api', 'common', () => withRoot(async (r) => {
|
|
539
|
+
await r('upsert', [{ id: 'tmp-1', vector: [1, 0] }, { id: 'tmp-2', vector: [0, 1] }, { id: 'keep', vector: [1, 1] }]);
|
|
540
|
+
const del = await r('delete', { prefix: 'tmp-' }, { at: laterAt(1000) });
|
|
541
|
+
const left = await r('range', { cursor: '', limit: 10 });
|
|
542
|
+
return JSON.stringify(del.result) === JSON.stringify({ deleted: 2 })
|
|
543
|
+
&& JSON.stringify((left.result.vectors).map((v) => v.id)) === JSON.stringify(['keep']);
|
|
544
|
+
})),
|
|
545
|
+
done('upstashvector.delete.by_metadata_filter', 'delete', '/delete by metadata `filter` runs the real filter language over the real metadata and removes exactly the matches (the vendor documents this as a full scan)', 'api', 'common', () => withRoot(async (r) => {
|
|
546
|
+
await r('upsert', [
|
|
547
|
+
{ id: 'a', vector: [1, 0], metadata: { status: 'stale', hits: 1 } },
|
|
548
|
+
{ id: 'b', vector: [0, 1], metadata: { status: 'fresh', hits: 9 } },
|
|
549
|
+
{ id: 'c', vector: [1, 1], metadata: { status: 'stale', hits: 8 } },
|
|
550
|
+
]);
|
|
551
|
+
const del = await r('delete', { filter: "status = 'stale' AND hits < 5" }, { at: laterAt(1000) });
|
|
552
|
+
const left = await r('range', { cursor: '', limit: 10 });
|
|
553
|
+
const neither = await r('delete', {});
|
|
554
|
+
// An EMPTY filter is REFUSED on a delete rather than read as "match everything" — a caller's
|
|
555
|
+
// template rendering to '' must never wipe the namespace. (On /query the same empty string
|
|
556
|
+
// harmlessly means "no filter"; the asymmetry is deliberate and is the safe direction.)
|
|
557
|
+
const emptyFilter = await r('delete', { filter: '' }, { at: laterAt(2000) });
|
|
558
|
+
const survived = await r('range', { cursor: '', limit: 10 });
|
|
559
|
+
return JSON.stringify(del.result) === JSON.stringify({ deleted: 1 })
|
|
560
|
+
&& JSON.stringify((left.result.vectors).map((v) => v.id)) === JSON.stringify(['b', 'c'])
|
|
561
|
+
&& neither.status === 400
|
|
562
|
+
&& emptyFilter.status === 400 && emptyFilter.result === undefined
|
|
563
|
+
&& JSON.stringify((survived.result.vectors).map((v) => v.id)) === JSON.stringify(['b', 'c']);
|
|
564
|
+
})),
|
|
565
|
+
// ── RESET ───────────────────────────────────────────────────────────────────────────────────
|
|
566
|
+
done('upstashvector.reset.namespace_and_all', 'reset', '/reset empties ONE namespace, /reset?all empties every namespace — and `?all` is the bare query flag the SDK\'s ResetCommand actually spells (`reset?all`, with no value)', 'api', 'core', () => withRoot(async (r) => {
|
|
567
|
+
await r('upsert', { id: 'd', vector: [1, 0] });
|
|
568
|
+
await r('upsert', { id: 'n', vector: [0, 1] }, { ns: 'other' });
|
|
569
|
+
const one = await r('reset', undefined, { ns: 'other', at: laterAt(1000) });
|
|
570
|
+
const afterOne = await r('info', undefined, { method: 'GET' });
|
|
571
|
+
const all = await r('reset', undefined, { query: '?all', at: laterAt(2000) });
|
|
572
|
+
const afterAll = await r('info', undefined, { method: 'GET' });
|
|
573
|
+
const count = (x) => x.result.vectorCount;
|
|
574
|
+
return one.result === 'Success' && count(afterOne) === 1
|
|
575
|
+
&& all.result === 'Success' && count(afterAll) === 0;
|
|
576
|
+
})),
|
|
577
|
+
done('upstashvector.reset.keeps_the_namespace_existing', 'reset', 'a reset EMPTIES a namespace but leaves it existing — which is what distinguishes /reset from /delete-namespace, and the reason namespaces are tracked as their own kernel subject rather than derived from "does any vector mention this name"', 'api', 'common', () => withRoot(async (r) => {
|
|
578
|
+
await r('upsert', { id: 'n', vector: [1, 0] }, { ns: 'keepme' });
|
|
579
|
+
await r('reset', undefined, { ns: 'keepme', at: laterAt(1000) });
|
|
580
|
+
const afterReset = await r('list-namespaces', undefined, { method: 'GET' });
|
|
581
|
+
await r('delete-namespace', undefined, { ns: 'keepme', at: laterAt(2000) });
|
|
582
|
+
const afterDelete = await r('list-namespaces', undefined, { method: 'GET' });
|
|
583
|
+
return JSON.stringify(afterReset.result) === JSON.stringify(['', 'keepme'])
|
|
584
|
+
&& JSON.stringify(afterDelete.result) === JSON.stringify(['']);
|
|
585
|
+
})),
|
|
586
|
+
// ── NAMESPACES ──────────────────────────────────────────────────────────────────────────────
|
|
587
|
+
done('upstashvector.namespaces.isolation', 'namespaces', 'a query in one namespace NEVER sees another namespace\'s vectors, even when they share ids — the isolation guarantee namespaces exist to provide, and the bug a naive `vec:<ns>:<id>` subject key would introduce', 'api', 'core', () => withRoot(async (r) => {
|
|
588
|
+
await r('upsert', { id: 'shared', vector: [1, 0], metadata: { owner: 'a' } }, { ns: 'tenant-a' });
|
|
589
|
+
await r('upsert', { id: 'shared', vector: [1, 0], metadata: { owner: 'b' } }, { ns: 'tenant-b' });
|
|
590
|
+
const a = await r('query', { vector: [1, 0], topK: 10, includeMetadata: true }, { ns: 'tenant-a' });
|
|
591
|
+
const b = await r('query', { vector: [1, 0], topK: 10, includeMetadata: true }, { ns: 'tenant-b' });
|
|
592
|
+
const rowsA = a.result;
|
|
593
|
+
const rowsB = b.result;
|
|
594
|
+
return rowsA.length === 1 && rowsB.length === 1
|
|
595
|
+
&& JSON.stringify(rowsA[0].metadata) === JSON.stringify({ owner: 'a' })
|
|
596
|
+
&& JSON.stringify(rowsB[0].metadata) === JSON.stringify({ owner: 'b' });
|
|
597
|
+
})),
|
|
598
|
+
done('upstashvector.namespaces.colon_in_names_and_ids', 'namespaces', 'namespaces and ids containing the subject-key delimiter (`:`) stay isolated — `ns "a", id "b:c"` and `ns "a:b", id "c"` are DIFFERENT vectors. A naive un-encoded key would collapse both onto one subject, silently destroying one tenant\'s data', 'api', 'niche', () => withRoot(async (r) => {
|
|
599
|
+
await r('upsert', { id: 'b:c', vector: [1, 0], metadata: { which: 'first' } }, { ns: 'a' });
|
|
600
|
+
await r('upsert', { id: 'c', vector: [0, 1], metadata: { which: 'second' } }, { ns: 'a:b' });
|
|
601
|
+
const first = await r('fetch', { ids: ['b:c'], includeMetadata: true }, { ns: 'a' });
|
|
602
|
+
const second = await r('fetch', { ids: ['c'], includeMetadata: true }, { ns: 'a:b' });
|
|
603
|
+
const m = (x) => JSON.stringify(x.result[0]?.metadata);
|
|
604
|
+
return m(first) === JSON.stringify({ which: 'first' }) && m(second) === JSON.stringify({ which: 'second' });
|
|
605
|
+
})),
|
|
606
|
+
done('upstashvector.namespaces.list', 'namespaces', '/list-namespaces returns every namespace including the default one, which is named "" (empty string) and always exists even on a brand-new index', 'api', 'common', () => withRoot(async (r) => {
|
|
607
|
+
const fresh = await r('list-namespaces', undefined, { method: 'GET' });
|
|
608
|
+
await r('upsert', { id: 'a', vector: [1, 0] }, { ns: 'zeta' });
|
|
609
|
+
await r('upsert', { id: 'b', vector: [1, 0] }, { ns: 'alpha' });
|
|
610
|
+
const after = await r('list-namespaces', undefined, { method: 'GET' });
|
|
611
|
+
return JSON.stringify(fresh.result) === JSON.stringify([''])
|
|
612
|
+
&& JSON.stringify(after.result) === JSON.stringify(['', 'alpha', 'zeta']);
|
|
613
|
+
})),
|
|
614
|
+
done('upstashvector.namespaces.delete_namespace', 'namespaces', '/delete-namespace removes the namespace AND everything in it, leaving other namespaces untouched; an unknown namespace answers 404 and the DEFAULT namespace is refused (twin-defined — /reset is how you empty it)', 'api', 'common', () => withRoot(async (r) => {
|
|
615
|
+
await r('upsert', { id: 'a', vector: [1, 0] }, { ns: 'doomed' });
|
|
616
|
+
await r('upsert', { id: 'b', vector: [0, 1] }, { ns: 'safe' });
|
|
617
|
+
const del = await r('delete-namespace', undefined, { ns: 'doomed', at: laterAt(1000) });
|
|
618
|
+
const names = await r('list-namespaces', undefined, { method: 'GET' });
|
|
619
|
+
const info = await r('info', undefined, { method: 'GET' });
|
|
620
|
+
const unknown = await r('delete-namespace', undefined, { ns: 'never-existed' });
|
|
621
|
+
const dflt = await r.raw({ path: '/delete-namespace/', method: 'DELETE' });
|
|
622
|
+
return del.result === 'Success'
|
|
623
|
+
&& JSON.stringify(names.result) === JSON.stringify(['', 'safe'])
|
|
624
|
+
&& info.result.vectorCount === 1
|
|
625
|
+
&& unknown.status === 404 && dflt.status === 400;
|
|
626
|
+
})),
|
|
627
|
+
// ── FILTERING ───────────────────────────────────────────────────────────────────────────────
|
|
628
|
+
done('upstashvector.filtering.comparison_operators', 'filtering', 'the full comparison set — `=`, `!=`, `<`, `>`, `<=`, `>=` — filters a query against real metadata, on both numbers and strings', 'api', 'core', () => withRoot(async (r) => {
|
|
629
|
+
await r('upsert', [
|
|
630
|
+
{ id: 'tr', vector: [1, 0], metadata: { country: 'Turkey', population: 15460000, capital: false } },
|
|
631
|
+
{ id: 'de', vector: [1, 0], metadata: { country: 'Germany', population: 3600000, capital: true } },
|
|
632
|
+
{ id: 'fr', vector: [1, 0], metadata: { country: 'France', population: 2100000, capital: true } },
|
|
633
|
+
]);
|
|
634
|
+
const hits = async (filter) => {
|
|
635
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
636
|
+
return q.result.map((x) => x.id).sort();
|
|
637
|
+
};
|
|
638
|
+
return JSON.stringify(await hits("country = 'Turkey'")) === JSON.stringify(['tr'])
|
|
639
|
+
&& JSON.stringify(await hits("country != 'Turkey'")) === JSON.stringify(['de', 'fr'])
|
|
640
|
+
&& JSON.stringify(await hits('population > 3000000')) === JSON.stringify(['de', 'tr'])
|
|
641
|
+
&& JSON.stringify(await hits('population < 3000000')) === JSON.stringify(['fr'])
|
|
642
|
+
&& JSON.stringify(await hits('population >= 3600000')) === JSON.stringify(['de', 'tr'])
|
|
643
|
+
&& JSON.stringify(await hits('population <= 2100000')) === JSON.stringify(['fr']);
|
|
644
|
+
})),
|
|
645
|
+
done('upstashvector.filtering.and_or_precedence_and_parentheses', 'filtering', 'AND binds TIGHTER than OR (the vendor\'s documented precedence) and parentheses override it — proven by an expression whose two readings select DIFFERENT rows, so a twin that got precedence backwards fails here', 'api', 'core', () => withRoot(async (r) => {
|
|
646
|
+
await r('upsert', [
|
|
647
|
+
{ id: 'a', vector: [1, 0], metadata: { x: 1, y: 1 } },
|
|
648
|
+
{ id: 'b', vector: [1, 0], metadata: { x: 2, y: 1 } },
|
|
649
|
+
{ id: 'c', vector: [1, 0], metadata: { x: 2, y: 2 } },
|
|
650
|
+
]);
|
|
651
|
+
const hits = async (filter) => {
|
|
652
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
653
|
+
return q.result.map((x) => x.id).sort();
|
|
654
|
+
};
|
|
655
|
+
// `x = 1 OR x = 2 AND y = 2` reads as `x=1 OR (x=2 AND y=2)` → {a, c}
|
|
656
|
+
// Parenthesised the other way, `(x = 1 OR x = 2) AND y = 2` → {c} alone.
|
|
657
|
+
return JSON.stringify(await hits('x = 1 OR x = 2 AND y = 2')) === JSON.stringify(['a', 'c'])
|
|
658
|
+
&& JSON.stringify(await hits('(x = 1 OR x = 2) AND y = 2')) === JSON.stringify(['c'])
|
|
659
|
+
&& JSON.stringify(await hits('x = 2 AND y = 1')) === JSON.stringify(['b']);
|
|
660
|
+
})),
|
|
661
|
+
done('upstashvector.filtering.in_and_not_in', 'filtering', '`IN (…)` and `NOT IN (…)` match against a parenthesised literal list, and a vector MISSING the field matches neither — absence is asked about with HAS FIELD, not smuggled into NOT IN', 'api', 'common', () => withRoot(async (r) => {
|
|
662
|
+
await r('upsert', [
|
|
663
|
+
{ id: 'de', vector: [1, 0], metadata: { country: 'Germany' } },
|
|
664
|
+
{ id: 'tr', vector: [1, 0], metadata: { country: 'Turkey' } },
|
|
665
|
+
{ id: 'us', vector: [1, 0], metadata: { country: 'USA' } },
|
|
666
|
+
{ id: 'none', vector: [1, 0], metadata: { other: 1 } },
|
|
667
|
+
]);
|
|
668
|
+
const hits = async (filter) => {
|
|
669
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
670
|
+
return q.result.map((x) => x.id).sort();
|
|
671
|
+
};
|
|
672
|
+
return JSON.stringify(await hits("country IN ('Germany', 'Turkey')")) === JSON.stringify(['de', 'tr'])
|
|
673
|
+
&& JSON.stringify(await hits("country NOT IN ('Germany', 'Turkey')")) === JSON.stringify(['us']);
|
|
674
|
+
})),
|
|
675
|
+
done('upstashvector.filtering.glob', 'filtering', 'GLOB runs a real pattern matcher — `*`, `?`, `[abc]` classes, `[^…]` negation and `[a-z]` RANGES — driven through the twin, including the vendor\'s own documented example pattern `?[sz]*[^m-z]`', 'api', 'common', () => withRoot(async (r) => {
|
|
676
|
+
await r('upsert', [
|
|
677
|
+
{ id: 'istanbul', vector: [1, 0], metadata: { city: 'Istanbul' } },
|
|
678
|
+
{ id: 'ankara', vector: [1, 0], metadata: { city: 'Ankara' } },
|
|
679
|
+
{ id: 'izmir', vector: [1, 0], metadata: { city: 'Izmir' } },
|
|
680
|
+
]);
|
|
681
|
+
const hits = async (filter) => {
|
|
682
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
683
|
+
return q.result.map((x) => x.id).sort();
|
|
684
|
+
};
|
|
685
|
+
return JSON.stringify(await hits("city GLOB 'I*'")) === JSON.stringify(['istanbul', 'izmir'])
|
|
686
|
+
&& JSON.stringify(await hits("city GLOB '?[sz]*'")) === JSON.stringify(['istanbul', 'izmir'])
|
|
687
|
+
&& JSON.stringify(await hits("city GLOB 'A?kara'")) === JSON.stringify(['ankara'])
|
|
688
|
+
&& JSON.stringify(await hits("city GLOB '*[^r]'")) === JSON.stringify(['ankara', 'istanbul'])
|
|
689
|
+
&& JSON.stringify(await hits("city NOT GLOB 'I*'")) === JSON.stringify(['ankara'])
|
|
690
|
+
// A RANGE, which the title names and the verify previously did not exercise (§9 round 1).
|
|
691
|
+
// Ankara ends 'a' and Istanbul ends 'l' — both inside a..m; Izmir ends 'r', outside it.
|
|
692
|
+
&& JSON.stringify(await hits("city GLOB '*[a-m]'")) === JSON.stringify(['ankara', 'istanbul'])
|
|
693
|
+
&& JSON.stringify(await hits("city GLOB '*[m-z]'")) === JSON.stringify(['izmir'])
|
|
694
|
+
// The vendor's OWN documented example pattern, likewise named by the title. Istanbul ends
|
|
695
|
+
// 'l' (outside m..z) and matches; Izmir ends 'r' (inside) and does not.
|
|
696
|
+
&& JSON.stringify(await hits("city GLOB '?[sz]*[^m-z]'")) === JSON.stringify(['istanbul']);
|
|
697
|
+
})),
|
|
698
|
+
done('upstashvector.filtering.contains_and_has_field', 'filtering', 'CONTAINS tests array membership and HAS FIELD / HAS NOT FIELD test key PRESENCE — the operators that make "this vector has no such key" expressible rather than conflated with "its value differs"', 'api', 'common', () => withRoot(async (r) => {
|
|
699
|
+
await r('upsert', [
|
|
700
|
+
{ id: 'a', vector: [1, 0], metadata: { tags: ['tourism', 'finance'], geo: { lat: 1 } } },
|
|
701
|
+
{ id: 'b', vector: [1, 0], metadata: { tags: ['mining'] } },
|
|
702
|
+
]);
|
|
703
|
+
const hits = async (filter) => {
|
|
704
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
705
|
+
return q.result.map((x) => x.id).sort();
|
|
706
|
+
};
|
|
707
|
+
return JSON.stringify(await hits("tags CONTAINS 'tourism'")) === JSON.stringify(['a'])
|
|
708
|
+
&& JSON.stringify(await hits("tags NOT CONTAINS 'tourism'")) === JSON.stringify(['b'])
|
|
709
|
+
&& JSON.stringify(await hits('HAS FIELD geo')) === JSON.stringify(['a'])
|
|
710
|
+
&& JSON.stringify(await hits('HAS NOT FIELD geo')) === JSON.stringify(['b'])
|
|
711
|
+
&& JSON.stringify(await hits('HAS FIELD geo.lat')) === JSON.stringify(['a']);
|
|
712
|
+
})),
|
|
713
|
+
done('upstashvector.filtering.nested_paths_and_array_indexing', 'filtering', 'identifiers address arbitrarily deep nested objects with `.` and individual array elements with `[n]` — including the vendor\'s end-relative `[#-1]` form, where `#` marks indexing from the back', 'api', 'common', () => withRoot(async (r) => {
|
|
714
|
+
await r('upsert', [
|
|
715
|
+
{ id: 'a', vector: [1, 0], metadata: { geo: { coord: { lat: 41.0 } }, tags: ['first', 'middle', 'last'] } },
|
|
716
|
+
{ id: 'b', vector: [1, 0], metadata: { geo: { coord: { lat: 12.0 } }, tags: ['only'] } },
|
|
717
|
+
]);
|
|
718
|
+
const hits = async (filter) => {
|
|
719
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
720
|
+
return q.result.map((x) => x.id).sort();
|
|
721
|
+
};
|
|
722
|
+
return JSON.stringify(await hits('geo.coord.lat > 40')) === JSON.stringify(['a'])
|
|
723
|
+
&& JSON.stringify(await hits("tags[0] = 'first'")) === JSON.stringify(['a'])
|
|
724
|
+
&& JSON.stringify(await hits("tags[#-1] = 'last'")) === JSON.stringify(['a'])
|
|
725
|
+
&& JSON.stringify(await hits("tags[#-1] = 'only'")) === JSON.stringify(['b'])
|
|
726
|
+
&& JSON.stringify(await hits("tags[5] = 'nope'")) === JSON.stringify([]);
|
|
727
|
+
})),
|
|
728
|
+
done('upstashvector.filtering.literals_and_quoting', 'filtering', 'string literals may be single OR double quoted, booleans accept both the documented `1`/`0` spelling and `true`/`false`, and a comparison against a MISSING field is false rather than an error', 'api', 'common', () => withRoot(async (r) => {
|
|
729
|
+
await r('upsert', [
|
|
730
|
+
{ id: 'a', vector: [1, 0], metadata: { name: "O'Hare", live: true } },
|
|
731
|
+
{ id: 'b', vector: [1, 0], metadata: { name: 'Plain', live: false } },
|
|
732
|
+
]);
|
|
733
|
+
const hits = async (filter) => {
|
|
734
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
735
|
+
return q.result.map((x) => x.id).sort();
|
|
736
|
+
};
|
|
737
|
+
return JSON.stringify(await hits('name = "O\'Hare"')) === JSON.stringify(['a'])
|
|
738
|
+
&& JSON.stringify(await hits("name = 'Plain'")) === JSON.stringify(['b'])
|
|
739
|
+
&& JSON.stringify(await hits('live = 1')) === JSON.stringify(['a'])
|
|
740
|
+
&& JSON.stringify(await hits('live = true')) === JSON.stringify(['a'])
|
|
741
|
+
&& JSON.stringify(await hits('live = 0')) === JSON.stringify(['b'])
|
|
742
|
+
&& JSON.stringify(await hits('nosuchkey = 1')) === JSON.stringify([]);
|
|
743
|
+
})),
|
|
744
|
+
done('upstashvector.filtering.malformed_filter_is_rejected', 'filtering', 'a malformed filter answers 400 and matches NOTHING — a filter language that silently ignored a typo would turn one mistake into a full-index data leak, which is the single most dangerous way a filter can fail', 'api', 'core', () => withRoot(async (r) => {
|
|
745
|
+
await r('upsert', [{ id: 'a', vector: [1, 0], metadata: { x: 1 } }, { id: 'b', vector: [1, 0], metadata: { x: 2 } }]);
|
|
746
|
+
const bad = ['x =', 'x == 1', '(x = 1', "x = 'unterminated", 'x IN ()', 'HAS FIELD', 'x = unquoted', ''];
|
|
747
|
+
const statuses = [];
|
|
748
|
+
for (const filter of bad) {
|
|
749
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
750
|
+
// An empty filter string is legitimately "no filter"; everything else must be a 400.
|
|
751
|
+
statuses.push(filter === '' ? (Array.isArray(q.result) && q.result.length === 2 ? 200 : 0) : q.status);
|
|
752
|
+
}
|
|
753
|
+
return JSON.stringify(statuses) === JSON.stringify([400, 400, 400, 400, 400, 400, 400, 200]);
|
|
754
|
+
})),
|
|
755
|
+
done('upstashvector.filtering.applies_to_query_and_delete_alike', 'filtering', 'the SAME parsed filter language drives /query and /delete — one implementation, so the rows a filter selects are exactly the rows it would remove', 'api', 'common', () => withRoot(async (r) => {
|
|
756
|
+
const seed = [
|
|
757
|
+
{ id: 'a', vector: [1, 0], metadata: { tier: 'free', seats: 1 } },
|
|
758
|
+
{ id: 'b', vector: [1, 0], metadata: { tier: 'pro', seats: 40 } },
|
|
759
|
+
{ id: 'c', vector: [1, 0], metadata: { tier: 'pro', seats: 2 } },
|
|
760
|
+
];
|
|
761
|
+
await r('upsert', seed);
|
|
762
|
+
const filter = "tier = 'pro' AND seats > 10";
|
|
763
|
+
const q = await r('query', { vector: [1, 0], topK: 10, filter });
|
|
764
|
+
const selected = q.result.map((x) => x.id);
|
|
765
|
+
const del = await r('delete', { filter }, { at: laterAt(1000) });
|
|
766
|
+
const left = await r('range', { cursor: '', limit: 10 });
|
|
767
|
+
return JSON.stringify(selected) === JSON.stringify(['b'])
|
|
768
|
+
&& JSON.stringify(del.result) === JSON.stringify({ deleted: selected.length })
|
|
769
|
+
&& JSON.stringify((left.result.vectors).map((v) => v.id)) === JSON.stringify(['a', 'c']);
|
|
770
|
+
})),
|
|
771
|
+
// ── METADATA ────────────────────────────────────────────────────────────────────────────────
|
|
772
|
+
done('upstashvector.metadata.round_trips_arbitrary_json', 'metadata', 'metadata round-trips as arbitrary JSON — nested objects, arrays, numbers, booleans and null all come back byte-identical, so a caller can store the document shape they actually use', 'api', 'core', () => withRoot(async (r) => {
|
|
773
|
+
const meta = { title: 'Lord of the Rings', genre: ['fantasy', 'classic'], year: 1954, inPrint: true, sequel: null, nested: { a: { b: [1, 2, 3] } } };
|
|
774
|
+
await r('upsert', { id: 'lotr', vector: [1, 0], metadata: meta });
|
|
775
|
+
const got = await r('fetch', { ids: ['lotr'], includeMetadata: true });
|
|
776
|
+
return JSON.stringify(got.result[0].metadata) === JSON.stringify(meta);
|
|
777
|
+
})),
|
|
778
|
+
done('upstashvector.metadata.rejects_non_object', 'metadata', 'a non-object `metadata` (an array, a string) is refused with 400 rather than stored in a shape no filter could ever address', 'api', 'niche', () => withRoot(async (r) => {
|
|
779
|
+
const arr = await r('upsert', { id: 'a', vector: [1, 0], metadata: ['not', 'an', 'object'] });
|
|
780
|
+
const str = await r('upsert', { id: 'b', vector: [1, 0], metadata: 'nope' });
|
|
781
|
+
return arr.status === 400 && str.status === 400;
|
|
782
|
+
})),
|
|
783
|
+
// ── INFO ────────────────────────────────────────────────────────────────────────────────────
|
|
784
|
+
done('upstashvector.info.counts_and_configuration', 'info', '/info reports the real vectorCount, the index dimension and the configured similarityFunction — all derived from live state, so they move when the index does', 'api', 'core', () => withRoot(async (r) => {
|
|
785
|
+
const empty = await r('info', undefined, { method: 'GET' });
|
|
786
|
+
await r('upsert', [{ id: 'a', vector: [1, 0, 0] }, { id: 'b', vector: [0, 1, 0] }]);
|
|
787
|
+
const filled = await r('info', undefined, { method: 'GET' });
|
|
788
|
+
await r('delete', { ids: ['a'] }, { at: laterAt(1000) });
|
|
789
|
+
const afterDelete = await r('info', undefined, { method: 'GET' });
|
|
790
|
+
const i0 = empty.result;
|
|
791
|
+
const i1 = filled.result;
|
|
792
|
+
const i2 = afterDelete.result;
|
|
793
|
+
return i0.vectorCount === 0 && i1.vectorCount === 2 && i2.vectorCount === 1
|
|
794
|
+
&& i1.dimension === 3 && i1.similarityFunction === 'EUCLIDEAN' && i1.pendingVectorCount === 0
|
|
795
|
+
&& JSON.stringify(i1.denseIndex) === JSON.stringify({ dimension: 3, similarityFunction: 'EUCLIDEAN' });
|
|
796
|
+
}, { similarityFunction: 'EUCLIDEAN' })),
|
|
797
|
+
done('upstashvector.info.per_namespace_map', 'info', '/info carries the per-namespace `namespaces` map keyed by namespace name (the default one under the empty-string key), each with its own vectorCount — the vendor\'s own documented shape', 'api', 'common', () => withRoot(async (r) => {
|
|
798
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'b', vector: [0, 1] }]);
|
|
799
|
+
await r('upsert', { id: 'c', vector: [1, 1] }, { ns: 'ns' });
|
|
800
|
+
const info = (await r('info', undefined, { method: 'GET' })).result;
|
|
801
|
+
return info.namespaces[''] !== undefined && info.namespaces[''].vectorCount === 2
|
|
802
|
+
&& info.namespaces.ns !== undefined && info.namespaces.ns.vectorCount === 1
|
|
803
|
+
&& info.namespaces[''].pendingVectorCount === 0;
|
|
804
|
+
})),
|
|
805
|
+
done('upstashvector.info.index_size_uses_the_published_formula', 'info', 'indexSize is computed with the VENDOR\'S OWN published accounting — upstash.com/docs/vector/help/faq: "Each dimension is estimated to be 4 bytes, resulting in vector storage being calculated as vector count * dimension count * 4 bytes", combined with the metadata and data a vector carries — so the exact byte count is checkable arithmetic, and it grows and shrinks with real state', 'api', 'niche', () => withRoot(async (r) => {
|
|
806
|
+
const size = async () => (await r('info', undefined, { method: 'GET' })).result.indexSize;
|
|
807
|
+
const empty = await size();
|
|
808
|
+
// Two 2-dimension vectors, no metadata, no data ⇒ 2 vectors × 2 dims × 4 bytes = 16.
|
|
809
|
+
await r('upsert', [{ id: 'a', vector: [1, 0] }, { id: 'b', vector: [0, 1] }]);
|
|
810
|
+
const bare = await size();
|
|
811
|
+
// Adding metadata adds exactly its encoded length: JSON.stringify({"k":"v"}) is 9 bytes.
|
|
812
|
+
const meta = { k: 'v' };
|
|
813
|
+
await r('upsert', { id: 'c', vector: [1, 1], metadata: meta }, { at: laterAt(1000) });
|
|
814
|
+
const withMeta = await size();
|
|
815
|
+
await r('delete', { ids: ['a'] }, { at: laterAt(2000) });
|
|
816
|
+
const trimmed = await size();
|
|
817
|
+
return empty === 0
|
|
818
|
+
&& bare === 2 * 2 * 4
|
|
819
|
+
&& withMeta === bare + 2 * 4 + Buffer.byteLength(JSON.stringify(meta), 'utf8')
|
|
820
|
+
&& trimmed === withMeta - 2 * 4;
|
|
821
|
+
})),
|
|
822
|
+
// ── SAFETY ──────────────────────────────────────────────────────────────────────────────────
|
|
823
|
+
done('upstashvector.safety.read_only_forbids_writes', 'safety', 'a twin started read-only answers 405 to every WRITE (/upsert, /update, /delete, /reset, /delete-namespace) while reads keep working, and appends NOTHING to the kernel action log even when started WITH an index configuration — the pure-mirror mode a pulled index is served in', 'api', 'core', () => withRoot(async (r) => {
|
|
824
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { k: 'v' } });
|
|
825
|
+
await r('upsert', { id: 'n', vector: [0, 1] }, { ns: 'doomed', at: laterAt(10) });
|
|
826
|
+
// All FIVE writes the title names — /delete-namespace was previously unasserted (§9 round 1).
|
|
827
|
+
const writes = await Promise.all([
|
|
828
|
+
r.raw({ path: '/upsert', body: JSON.stringify({ id: 'b', vector: [0, 1] }), readOnly: true }),
|
|
829
|
+
r.raw({ path: '/update', body: JSON.stringify({ id: 'a', metadata: {} }), readOnly: true }),
|
|
830
|
+
r.raw({ path: '/delete', body: JSON.stringify({ ids: ['a'] }), readOnly: true }),
|
|
831
|
+
r.raw({ path: '/reset', readOnly: true, body: '{}' }),
|
|
832
|
+
r.raw({ path: '/delete-namespace/doomed', method: 'DELETE', readOnly: true }),
|
|
833
|
+
]);
|
|
834
|
+
const read = await r.raw({ path: '/query', body: JSON.stringify({ vector: [1, 0], topK: 1, includeMetadata: true }), readOnly: true });
|
|
835
|
+
const rows = read.body.result;
|
|
836
|
+
const stillThere = await r('fetch', { ids: ['a'] });
|
|
837
|
+
const nsIntact = await r('list-namespaces', undefined, { method: 'GET' });
|
|
838
|
+
// …and a read-only twin must append NOTHING to the action log, even when it was started WITH
|
|
839
|
+
// an index configuration. §9 round 1 found a GET /info doing exactly that: the constructor
|
|
840
|
+
// marked the config dirty and flush() wrote it, because no assertWritable() guarded that path.
|
|
841
|
+
const quiet = mkdtempSync(join(tmpdir(), 'upstashvector-ro-'));
|
|
842
|
+
let appended = 0;
|
|
843
|
+
try {
|
|
844
|
+
await handleUpstashVectorTwinRequest({ method: 'GET', path: '/info', root: quiet, occurredAt: AT, headers: AUTH, readOnly: true, dimension: 4 });
|
|
845
|
+
const log = join(quiet, '.volter', 'world', 'upstashvector', 'actions.jsonl');
|
|
846
|
+
appended = existsSync(log) ? readFileSync(log, 'utf8').split('\n').filter((l) => l.trim() !== '').length : 0;
|
|
847
|
+
}
|
|
848
|
+
finally {
|
|
849
|
+
rmSync(quiet, { recursive: true, force: true });
|
|
850
|
+
}
|
|
851
|
+
return writes.every((x) => x.status === 405)
|
|
852
|
+
&& read.status === 200 && rows.length === 1 && JSON.stringify(rows[0].metadata) === JSON.stringify({ k: 'v' })
|
|
853
|
+
&& stillThere.result[0] !== null
|
|
854
|
+
&& JSON.stringify(nsIntact.result) === JSON.stringify(['', 'doomed'])
|
|
855
|
+
&& appended === 0;
|
|
856
|
+
})),
|
|
857
|
+
// ── PERSISTENCE (the kernel-backed claim, and the dirty-state verifies) ─────────────────────
|
|
858
|
+
done('upstashvector.persistence.state_survives_a_fresh_process', 'persistence', 'state lives in the kernel action log, not in memory: a BRAND-NEW IndexSpace over the same root sees every vector, its metadata and the index\'s locked-in dimension — the "kill the process and it still answers" property', 'api', 'core', () => withRoot(async (r, root) => {
|
|
859
|
+
await r('upsert', { id: 'a', vector: [0.5, 0.5], metadata: { kept: true } });
|
|
860
|
+
await r('upsert', { id: 'b', vector: [1, 0] }, { ns: 'other', at: laterAt(10) });
|
|
861
|
+
// A fresh space, constructed directly — nothing carried over from the requests above.
|
|
862
|
+
const reborn = new IndexSpace({ occurredAt: laterAt(5000), root });
|
|
863
|
+
const a = reborn.getVector('', 'a');
|
|
864
|
+
return a !== undefined && JSON.stringify(a.values) === JSON.stringify([0.5, 0.5])
|
|
865
|
+
&& JSON.stringify(a.metadata) === JSON.stringify({ kept: true })
|
|
866
|
+
&& reborn.indexDimension === 2
|
|
867
|
+
&& JSON.stringify(reborn.listNamespaces()) === JSON.stringify(['', 'other'])
|
|
868
|
+
&& reborn.getVector('other', 'b') !== undefined
|
|
869
|
+
&& reborn.getVector('other', 'a') === undefined;
|
|
870
|
+
})),
|
|
871
|
+
done('upstashvector.persistence.delete_then_recreate', 'persistence', 'a DIRTY-STATE verify: deleting a vector and re-upserting the same id yields the NEW value, not a resurrected old one — the tombstone must not swallow the re-add, and the re-add must not inherit dead metadata', 'api', 'core', () => withRoot(async (r) => {
|
|
872
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { generation: 1 }, data: 'old' });
|
|
873
|
+
await r('delete', { ids: ['a'] }, { at: laterAt(1000) });
|
|
874
|
+
const gone = await r('fetch', { ids: ['a'] });
|
|
875
|
+
await r('upsert', { id: 'a', vector: [0, 1] }, { at: laterAt(2000) });
|
|
876
|
+
const back = await r('fetch', { ids: ['a'], includeMetadata: true, includeVectors: true, includeData: true });
|
|
877
|
+
const row = back.result[0];
|
|
878
|
+
const count = (await r('info', undefined, { method: 'GET' })).result.vectorCount;
|
|
879
|
+
return gone.result[0] === null
|
|
880
|
+
&& row.id === 'a' && JSON.stringify(row.vector) === JSON.stringify([0, 1])
|
|
881
|
+
&& row.metadata === undefined && row.data === undefined
|
|
882
|
+
&& count === 1;
|
|
883
|
+
})),
|
|
884
|
+
done('upstashvector.persistence.same_instant_rewrite_is_not_swallowed', 'persistence', 'a DIRTY-STATE verify for the kernel\'s action dedupe: writing a vector back to a value it ALREADY HELD, at the SAME pinned millisecond, must still take effect. The kernel dedupes by (content + occurredAt ms), so without a per-subject write ordinal the second write is dropped as a replay and state silently disagrees with the reply the caller got', 'api', 'core', () => withRoot(async (r) => {
|
|
885
|
+
const at = laterAt(0); // every write below shares ONE millisecond, deliberately
|
|
886
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { v: 'first' } }, { at });
|
|
887
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { v: 'second' } }, { at });
|
|
888
|
+
await r('upsert', { id: 'a', vector: [1, 0], metadata: { v: 'first' } }, { at }); // back to the ORIGINAL
|
|
889
|
+
const got = await r('fetch', { ids: ['a'], includeMetadata: true });
|
|
890
|
+
const row = got.result[0];
|
|
891
|
+
// The reply said the third write applied; state must agree. A dedupe-dropped write would
|
|
892
|
+
// leave `second` here — a reply and a projection disagreeing, the worst shape of wrong answer.
|
|
893
|
+
return JSON.stringify(row.metadata) === JSON.stringify({ v: 'first' });
|
|
894
|
+
})),
|
|
895
|
+
done('upstashvector.persistence.corrupt_stored_vector_cannot_poison_a_ranking', 'persistence', 'a stored vector whose coordinates are unreadable — a `null` (which is what a NaN becomes once it has been through JSON), a string, or an overflow-scale number — is treated as CORRUPT and excluded from every read, so it can neither be returned nor drag a ranking into `"score": null`. This is the defence for state this pack did not write: the connector refuses such rows at ingest, but a root written by an older build or another tool can still hold one, and `Number(null) === 0` means a coercing reader would silently substitute a different vector. (§9 round 2 found the coercion; the revert matrix then showed the guard was unreachable through the connector, so it is pinned HERE, by writing the poison to the kernel directly.)', 'api', 'common', () => withRoot(async (r, root) => {
|
|
896
|
+
await r('upsert', { id: 'good', vector: [1, 0] });
|
|
897
|
+
// Straight into the kernel, bypassing every twin boundary — exactly what a foreign writer does.
|
|
898
|
+
const poison = async (vid, values) => {
|
|
899
|
+
await applyTwinWrite('upstashvector', {
|
|
900
|
+
operation: 'vector.upsert',
|
|
901
|
+
subjectType: 'vector',
|
|
902
|
+
subjectId: `vec::${encodeURIComponent(vid)}`,
|
|
903
|
+
fields: { ns: '', vid, values, metadata: null, data: '', gone: false, _rev: 1 },
|
|
904
|
+
occurredAt: laterAt(1000),
|
|
905
|
+
actor: { kind: 'agent' },
|
|
906
|
+
}, root);
|
|
907
|
+
};
|
|
908
|
+
await poison('nulled', [null, 1]); // a NaN that has round-tripped through JSON
|
|
909
|
+
await poison('stringy', ['abc', 1]);
|
|
910
|
+
await poison('huge', [1e308, 1]);
|
|
911
|
+
const q = await r('query', { vector: [1, 0], topK: 10 });
|
|
912
|
+
const rows = q.result;
|
|
913
|
+
const info = (await r('info', undefined, { method: 'GET' })).result;
|
|
914
|
+
const fetched = await r('fetch', { ids: ['nulled', 'stringy', 'huge', 'good'] });
|
|
915
|
+
const fetchedRows = fetched.result;
|
|
916
|
+
return q.status === 200
|
|
917
|
+
// Only the readable vector ranks, and its score is a real number.
|
|
918
|
+
&& rows.length === 1 && rows[0].id === 'good' && Number.isFinite(rows[0].score)
|
|
919
|
+
// The corrupt rows are invisible everywhere, not just in the ranking.
|
|
920
|
+
&& info.vectorCount === 1
|
|
921
|
+
&& fetchedRows[0] === null && fetchedRows[1] === null && fetchedRows[2] === null
|
|
922
|
+
&& fetchedRows[3]?.id === 'good';
|
|
923
|
+
})),
|
|
924
|
+
done('upstashvector.persistence.interleaved_namespace_churn', 'persistence', 'a DIRTY-STATE verify over accumulated history: deleting a namespace and recreating one with the SAME name starts EMPTY, and never resurrects the deleted namespace\'s vectors from the log', 'api', 'common', () => withRoot(async (r) => {
|
|
925
|
+
await r('upsert', [{ id: 'x', vector: [1, 0] }, { id: 'y', vector: [0, 1] }], { ns: 'reused' });
|
|
926
|
+
await r('delete-namespace', undefined, { ns: 'reused', at: laterAt(1000) });
|
|
927
|
+
await r('upsert', { id: 'z', vector: [1, 1] }, { ns: 'reused', at: laterAt(2000) });
|
|
928
|
+
const page = await r('range', { cursor: '', limit: 10 }, { ns: 'reused' });
|
|
929
|
+
const ids = (page.result.vectors).map((v) => v.id);
|
|
930
|
+
const info = (await r('info', undefined, { method: 'GET' })).result;
|
|
931
|
+
return JSON.stringify(ids) === JSON.stringify(['z']) && info.vectorCount === 1;
|
|
932
|
+
})),
|
|
933
|
+
// ── CONNECTOR ───────────────────────────────────────────────────────────────────────────────
|
|
934
|
+
done('upstashvector.connector.pull_over_injected_client', 'connector', 'the connector pulls a real index over an INJECTED client (never an imported SDK) and folds it into the twin, so pulled vectors and namespaces are queryable through the twin\'s own API. SCOPE (\u00a79 round 1): this is the kernel\'s fork model \u2014 a pull writes the OBSERVED layer and local writes are projected OVER it, so an id the caller has already written locally keeps its LOCAL value and a later pull of that same id does not surface. That is deliberate (local commits must not be silently reverted by a mirror refresh) and is shared with qstash/upstash, but it means "pulled vectors are queryable" holds for ids with no local write, not unconditionally. upstashvector.connector.pull_shadowed_by_local_writes files the reporting gap', 'connector', 'core', () => withRoot(async (r, root) => {
|
|
935
|
+
const { client } = fakeVectorClient({
|
|
936
|
+
'': [{ id: 'p1', vector: [1, 0], metadata: { src: 'real' } }, { id: 'p2', vector: [0, 1] }],
|
|
937
|
+
team: [{ id: 'p3', vector: [1, 1] }],
|
|
938
|
+
});
|
|
939
|
+
const res = await syncUpstashVectorFromReal(client, { root, occurredAt: AT, budgetOptions: { path: join(root, 'budget.json') } });
|
|
940
|
+
const q = await r('query', { vector: [1, 0], topK: 1, includeMetadata: true });
|
|
941
|
+
const rows = q.result;
|
|
942
|
+
const teamPage = await r('range', { cursor: '', limit: 10 }, { ns: 'team' });
|
|
943
|
+
// 4, not 5: three vectors plus the ONE named namespace. The default namespace ('') always
|
|
944
|
+
// exists on every index and the store synthesizes it, so the pull no longer mints a subject
|
|
945
|
+
// for state the write handler could never produce.
|
|
946
|
+
return res.observed === 4 && res.deltasAppended > 0
|
|
947
|
+
&& rows.length === 1 && rows[0].id === 'p1' && JSON.stringify(rows[0].metadata) === JSON.stringify({ src: 'real' })
|
|
948
|
+
&& (teamPage.result.vectors).map((v) => v.id).join(',') === 'p3';
|
|
949
|
+
})),
|
|
950
|
+
done('upstashvector.connector.pull_is_idempotent', 'connector', 'a re-pull of IDENTICAL vendor state appends ZERO deltas (the kernel\'s shadow-diff dedup) — which is what makes a pull safe to run on a schedule instead of growing the log without bound', 'connector', 'core', () => withRoot(async (_r, root) => {
|
|
951
|
+
const { client } = fakeVectorClient({ '': [{ id: 'p1', vector: [1, 0], metadata: { a: 1 } }] });
|
|
952
|
+
const opts = { root, occurredAt: AT, budgetOptions: { path: join(root, 'budget.json') } };
|
|
953
|
+
const first = await syncUpstashVectorFromReal(client, opts);
|
|
954
|
+
const second = await syncUpstashVectorFromReal(client, opts);
|
|
955
|
+
return first.deltasAppended > 0 && second.deltasAppended === 0 && second.observed === first.observed;
|
|
956
|
+
})),
|
|
957
|
+
done('upstashvector.connector.bounded_walk', 'connector', 'the range walk is BOUNDED by `limit` and stops at the vendor\'s empty cursor — an unbounded walk of a production index is exactly the spend the rate budget exists to stop, so the bound is a safety property, not a convenience', 'connector', 'common', () => withRoot(async (_r, root) => {
|
|
958
|
+
const many = Array.from({ length: 40 }, (_, i) => ({ id: `v${i}`, vector: [i, 1] }));
|
|
959
|
+
const { client, calls } = fakeVectorClient({ '': many });
|
|
960
|
+
const limited = await pullUpstashVectorRange(client, { limit: 7, pageSize: 3, budgetOptions: { path: join(root, 'budget.json') } });
|
|
961
|
+
const namespaces = await pullUpstashVectorNamespaces(client, { budgetOptions: { path: join(root, 'budget.json') } });
|
|
962
|
+
return limited.length === 7 && limited[0].id === 'v0'
|
|
963
|
+
&& calls.filter((c) => c.startsWith('range')).length <= 4
|
|
964
|
+
&& JSON.stringify(namespaces) === JSON.stringify(['']);
|
|
965
|
+
})),
|
|
966
|
+
done('upstashvector.connector.pulled_index_is_coherent', 'connector', 'a PULLED index behaves like a locally-written one: its dimension is inferred from the stored vectors (so /info reports it instead of 0), a mis-sized query gets the vendor\'s 422 instead of a silent empty result, and a later upsert cannot lock in a different dimension and orphan the pulled data. When a pull delivers MIXED lengths no dimension can be honestly inferred, so the next write is REFUSED rather than allowed to pick a winner. (§9 rounds 1-2: all four behaviours were broken or unpinned.)', 'connector', 'core', async () => {
|
|
967
|
+
// A uniform pulled root — nothing local was ever written, so only the pull's own state exists.
|
|
968
|
+
const uniform = await withRoot(async (r, root) => {
|
|
969
|
+
const { client } = fakeVectorClient({ '': [{ id: 'p1', vector: [1, 0, 0] }, { id: 'p2', vector: [0, 1, 0] }] });
|
|
970
|
+
await syncUpstashVectorFromReal(client, { root, occurredAt: AT, budgetOptions: { path: join(root, 'b.json') } });
|
|
971
|
+
const info = (await r('info', undefined, { method: 'GET' })).result;
|
|
972
|
+
const mismatched = await r('query', { vector: [1, 0], topK: 5 });
|
|
973
|
+
const ranked = await r('query', { vector: [1, 0, 0], topK: 1 });
|
|
974
|
+
const orphaning = await r('upsert', { id: 'local', vector: [1, 0] }, { at: laterAt(1000) });
|
|
975
|
+
return info.dimension === 3 && info.vectorCount === 2
|
|
976
|
+
// NOT a silent 200 [] — the promised 422, with the vendor's verbatim message.
|
|
977
|
+
&& mismatched.status === 422 && mismatched.error === 'Invalid vector dimension: 2, expected: 3'
|
|
978
|
+
&& ranked.result[0].id === 'p1'
|
|
979
|
+
// …and a mis-sized local write cannot lock in a rival dimension.
|
|
980
|
+
&& orphaning.status === 422;
|
|
981
|
+
});
|
|
982
|
+
// A MIXED pulled root — no honest dimension exists, so a write must be refused, not guessed.
|
|
983
|
+
const mixed = await withRoot(async (r, root) => {
|
|
984
|
+
const { client } = fakeVectorClient({ '': [{ id: 'a', vector: [1, 0, 0] }, { id: 'b', vector: [0, 1] }] });
|
|
985
|
+
await syncUpstashVectorFromReal(client, { root, occurredAt: AT, budgetOptions: { path: join(root, 'b.json') } });
|
|
986
|
+
const write = await r('upsert', { id: 'local', vector: [1, 0] }, { at: laterAt(1000) });
|
|
987
|
+
const after = (await r('info', undefined, { method: 'GET' })).result;
|
|
988
|
+
return write.status === 422 && (write.error ?? '').includes('differing dimensions')
|
|
989
|
+
// Nothing was written, so neither pulled vector was orphaned.
|
|
990
|
+
&& after.vectorCount === 2;
|
|
991
|
+
});
|
|
992
|
+
return uniform && mixed;
|
|
993
|
+
}),
|
|
994
|
+
done('upstashvector.connector.refuses_unstorable_vendor_rows', 'connector', 'a vendor row this twin cannot store faithfully is REFUSED by id rather than coerced — a non-numeric or overflow-scale coordinate would otherwise become a silent 0 (NaN -> JSON null -> Number(null)) or poison every score it touches into `"score": null` on the wire, ranking FIRST under DOT_PRODUCT. Ids that cannot even be addressed (an unpaired UTF-16 surrogate) are refused the same way instead of throwing a raw URIError off the pull\'s hot path. (§9 rounds 1-2.)', 'connector', 'common', () => withRoot(async () => {
|
|
995
|
+
const refused = (fn) => {
|
|
996
|
+
try {
|
|
997
|
+
fn();
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
catch (e) {
|
|
1001
|
+
return e instanceof VectorApiError && e.status === 422 || e instanceof VectorApiError && e.status === 400;
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
return refused(() => mapVector({ id: 'bad', vector: ['abc', 1] }))
|
|
1005
|
+
&& refused(() => mapVector({ id: 'huge', vector: [1e308, 1] }))
|
|
1006
|
+
&& refused(() => mapVector({ id: 'nan', vector: [Number.NaN] }))
|
|
1007
|
+
&& refused(() => mapVector({ id: 'empty', vector: [] }))
|
|
1008
|
+
// Un-addressable ids and namespaces, on BOTH mappers.
|
|
1009
|
+
&& refused(() => mapVector({ id: 'x\uD800', vector: [1, 0] }))
|
|
1010
|
+
&& refused(() => mapVector({ id: 'ok', namespace: 'n\uD800', vector: [1, 0] }))
|
|
1011
|
+
&& refused(() => mapNamespace('n\uD800'))
|
|
1012
|
+
// …while a well-formed row still maps cleanly.
|
|
1013
|
+
&& mapVector({ id: 'fine', vector: [1, 0] }).id === 'vec::fine'
|
|
1014
|
+
&& mapNamespace('team').id === 'ns:team';
|
|
1015
|
+
})),
|
|
1016
|
+
done('upstashvector.connector.map_vector_is_pure', 'connector', '`mapVector` is a PURE mapper (a real vector → a kernel resource) that touches no client — so the namespace lands in `ns`, the vector id in `vid`, and neither collides with the kernel\'s reserved META keys (`type`/`id`/`updatedAt`), which projectResources SILENTLY DROPS', 'connector', 'common', () => withRoot(async () => {
|
|
1017
|
+
const mapped = mapVector({ id: 'v-1', namespace: 'team', vector: [1, 2], metadata: { a: 1 }, data: 'text' });
|
|
1018
|
+
const fields = mapped.fields;
|
|
1019
|
+
return mapped.type === 'vector' && mapped.id === 'vec:team:v-1'
|
|
1020
|
+
&& fields.vid === 'v-1' && fields.ns === 'team' && fields.gone === false
|
|
1021
|
+
&& JSON.stringify(fields.values) === JSON.stringify([1, 2])
|
|
1022
|
+
&& fields.id === undefined && fields.type === undefined && fields.updatedAt === undefined;
|
|
1023
|
+
})),
|
|
1024
|
+
done('upstashvector.connector.rate_budget_refuses_past_the_ceiling', 'connector', 'every live call goes through the fail-closed budget: after the ceiling is spent the next call THROWS and the injected fake records NO further call — the count, not the throw, is what proves nothing reached the vendor', 'connector', 'core', () => withRoot(async (_r, root) => {
|
|
1025
|
+
const { client, calls } = fakeVectorClient({ '': [{ id: 'v0', vector: [1, 0] }] });
|
|
1026
|
+
const budget = new UpstashVectorBudget({ path: join(root, 'budget.json'), token: 'cap-test' });
|
|
1027
|
+
// Guarded ONCE, with the budget whose ledger path is injected above — never against the
|
|
1028
|
+
// operator's real ~/.volter file. (Guarding is idempotent, so wrapping once is enough.)
|
|
1029
|
+
const guardedClient = guardUpstashVectorClient(client, { budget: budget });
|
|
1030
|
+
// `info` is priced at the default weight of 2, so the ceiling allows exactly ceiling/2 calls.
|
|
1031
|
+
const allowed = UPSTASHVECTOR_BUDGET_CEILING / 2;
|
|
1032
|
+
const guardedInfo = () => guardedClient.info();
|
|
1033
|
+
for (let i = 0; i < allowed; i++)
|
|
1034
|
+
await guardedInfo();
|
|
1035
|
+
const spent = calls.filter((c) => c === 'info').length;
|
|
1036
|
+
let threw = false;
|
|
1037
|
+
try {
|
|
1038
|
+
await guardedInfo();
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
threw = true;
|
|
1042
|
+
}
|
|
1043
|
+
const after = calls.filter((c) => c === 'info').length;
|
|
1044
|
+
return spent === allowed && threw && after === spent
|
|
1045
|
+
&& upstashvectorCallWeight('range') === 6 && upstashvectorCallWeight('info') === 2;
|
|
1046
|
+
})),
|
|
1047
|
+
// ── CONFORMANCE ─────────────────────────────────────────────────────────────────────────────
|
|
1048
|
+
done('upstashvector.conformance.endpoint_probe', 'conformance', 'the conformance check ISSUES REAL REQUESTS against every endpoint the twin claims and asserts the VALUES they return, and cross-checks the declared kernel subject types in both directions — so a dead handler or a drifted inventory fails it', 'api', 'common', async () => {
|
|
1049
|
+
const report = await checkUpstashVectorConformance();
|
|
1050
|
+
return report.ok && report.violations.length === 0 && report.endpointsChecked >= 10 && report.resourceTypesChecked === 3;
|
|
1051
|
+
}),
|
|
1052
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
1053
|
+
// THE DENOMINATOR — real Upstash Vector surface this twin has NOT built.
|
|
1054
|
+
// ══════════════════════════════════════════════════════════════════════════════════════════
|
|
1055
|
+
// Embedding models (upstash.com/docs/vector/features/embeddingmodels).
|
|
1056
|
+
todo('upstashvector.embedding.upsert_data', 'embedding', 'POST /upsert-data — accept a raw-text `data` upsert against the index\'s configured embedding model (today it answers 404; an explicit `vector` is fully supported)', 'api', 'core'),
|
|
1057
|
+
todo('upstashvector.embedding.query_data', 'embedding', 'POST /query-data — accept a raw-text `data` query against the index\'s configured embedding model (today it answers 404)', 'api', 'core'),
|
|
1058
|
+
todo('upstashvector.embedding.model_reported_in_info', 'embedding', '/info reports the index\'s configured embeddingModel inside denseIndex/sparseIndex — this twin has no model so it reports neither, and a caller reading that field sees it absent rather than a fabricated name', 'api', 'niche'),
|
|
1059
|
+
// Sparse & hybrid indexes (upstash.com/docs/vector/features/sparseindexes).
|
|
1060
|
+
todo('upstashvector.sparse.upsert', 'sparse', 'upsert a `sparseVector` ({indices, values}) into a SPARSE index — refused with 422 today rather than silently dropped', 'api', 'common'),
|
|
1061
|
+
todo('upstashvector.sparse.query', 'sparse', 'query a SPARSE index with a sparseVector and rank by the sparse scoring model', 'api', 'common'),
|
|
1062
|
+
todo('upstashvector.sparse.hybrid_index', 'sparse', 'HYBRID indexes carrying BOTH a dense and a sparse vector per id, with /info reporting indexType HYBRID', 'api', 'common'),
|
|
1063
|
+
todo('upstashvector.sparse.fusion_algorithms', 'sparse', 'the hybrid fusion algorithms the SDK exposes — RRF (default) and DBSF — which decide how dense and sparse rankings are combined', 'api', 'niche'),
|
|
1064
|
+
todo('upstashvector.sparse.weighting_strategy', 'sparse', 'the IDF weighting strategy for sparse/hybrid queries', 'api', 'niche'),
|
|
1065
|
+
todo('upstashvector.sparse.query_mode', 'sparse', 'queryMode HYBRID | DENSE | SPARSE, which selects which half of a hybrid index a query searches', 'api', 'niche'),
|
|
1066
|
+
// Resumable queries (upstash.com/docs/vector/features/resumablequery).
|
|
1067
|
+
todo('upstashvector.resumable.start', 'resumable', 'POST /resumable-query — start a server-side query session with a maxIdle timeout, returning a uuid plus the first batch of scores', 'api', 'common'),
|
|
1068
|
+
todo('upstashvector.resumable.next', 'resumable', 'POST /resumable-query-next — fetch the next `additionalK` results of an open session', 'api', 'common'),
|
|
1069
|
+
todo('upstashvector.resumable.stop', 'resumable', 'POST /resumable-query-end — end a session and release its server-side state', 'api', 'common'),
|
|
1070
|
+
todo('upstashvector.resumable.idle_expiry', 'resumable', 'a resumable session expiring after maxIdle seconds, so a later fetchNext fails the way the vendor fails it', 'api', 'niche'),
|
|
1071
|
+
// Published plan limits (upstash.com/pricing/vector + the docs' limits tables).
|
|
1072
|
+
todo('upstashvector.limits.coordinate_precision', 'limits', 'the numeric PRECISION Upstash stores coordinates at. The FAQ publishes 4 bytes per dimension as a BILLING estimate, from which float32 is an inference, not a documented fact \u2014 this twin bounds coordinates to float32 range on that inference (which makes the similarity arithmetic total) but the real precision, and whether the vendor rounds or refuses a wider value, needs a live index to confirm', 'api', 'niche'),
|
|
1073
|
+
todo('upstashvector.limits.max_dimension', 'limits', 'the per-plan maximum vector dimension (1,536 on Free through 5,000 on Pro) enforced at upsert', 'api', 'common'),
|
|
1074
|
+
todo('upstashvector.limits.max_topk', 'limits', 'the documented maximum topK of 1,000, refused above that rather than silently truncated', 'api', 'common'),
|
|
1075
|
+
todo('upstashvector.limits.metadata_size', 'limits', 'the 48 KB per-vector metadata ceiling, refused with the vendor\'s error', 'api', 'niche'),
|
|
1076
|
+
todo('upstashvector.limits.data_size', 'limits', 'the 1 MB per-vector `data` ceiling', 'api', 'niche'),
|
|
1077
|
+
todo('upstashvector.limits.max_batch_size', 'limits', 'the maximum number of vectors accepted in one /upsert batch', 'api', 'niche'),
|
|
1078
|
+
todo('upstashvector.limits.daily_query_quota', 'limits', 'the per-plan daily query quota (10,000/day on Free) and the throttle response it produces — Upstash publishes no HTTP status or body for it, so modeling it needs a live probe first', 'api', 'niche'),
|
|
1079
|
+
todo('upstashvector.limits.storage_quota', 'limits', 'the per-plan storage ceiling (1 GB Free through 1 TB Pro) and the refusal that follows exhausting it', 'api', 'niche'),
|
|
1080
|
+
// Protocol surface not yet modeled.
|
|
1081
|
+
todo('upstashvector.protocol.get_on_a_write_endpoint', 'protocol', 'what the REAL service does with a safe method against a write endpoint (GET /reset, HEAD /delete) is unverified \u2014 no documented client sends one, so this twin refuses them 405 rather than guessing the destructive answer. Confirming the vendor behaviour needs a live index to probe', 'api', 'niche'),
|
|
1082
|
+
todo('upstashvector.protocol.request_size_limit', 'protocol', 'the maximum HTTP request size and the status it is refused with — the sibling upstash pack live-probed a 413 for Redis, but Upstash publishes no figure for Vector and this build had no index to probe', 'api', 'niche'),
|
|
1083
|
+
todo('upstashvector.protocol.telemetry_headers', 'protocol', 'the Upstash-Telemetry-{Sdk,Platform,Runtime} headers the SDK sends by default — accepted and ignored today; recording them would let a world assert which client spoke to it', 'api', 'niche'),
|
|
1084
|
+
todo('upstashvector.protocol.retry_semantics', 'protocol', 'the SDK retries 5 times with exponential backoff by default; a twin that could arm a deterministic transient failure would let a consumer prove their retry path actually runs', 'api', 'niche'),
|
|
1085
|
+
todo('upstashvector.errors.vendor_error_strings', 'errors', 'the LITERAL error bodies Upstash Vector returns for auth, malformed filters, unknown namespaces and quota exhaustion — this pack asserts the documented {error,status} envelope and status codes, but has verbatim text for the dimension mismatch ONLY. Closing this gap needs a live index to probe, exactly as the sibling upstash pack did', 'api', 'common'),
|
|
1086
|
+
// Index management (the console/management surface, not the per-index data plane).
|
|
1087
|
+
todo('upstashvector.management.create_index', 'management', 'creating an index (name, region, similarity function, dimension, embedding model) — the Upstash management API rather than the per-index REST surface this pack models', 'api', 'niche'),
|
|
1088
|
+
todo('upstashvector.management.list_indexes', 'management', 'listing and describing the indexes an account owns', 'api', 'niche'),
|
|
1089
|
+
todo('upstashvector.management.delete_index', 'management', 'deleting a whole index', 'api', 'niche'),
|
|
1090
|
+
todo('upstashvector.management.readonly_token', 'management', 'the read-only REST token an index issues alongside its read-write one, which must reject writes at the vendor rather than at the caller', 'api', 'common'),
|
|
1091
|
+
// Runtime.
|
|
1092
|
+
todo('upstashvector.runtime.pending_vector_count', 'runtime', 'pendingVectorCount tracks vectors still being indexed (this twin\'s upsert is synchronous, so it is always 0)', 'api', 'common'),
|
|
1093
|
+
// Connector surface.
|
|
1094
|
+
todo('upstashvector.connector.push', 'connector', 'push locally-upserted vectors back to a real Upstash Vector index — deliberately not built: an upsert REPLACES, so a blind write-back to a production index is destructive, and the safe design (an explicit id allowlist plus a dry-run diff) has not been settled', 'connector', 'common'),
|
|
1095
|
+
todo('upstashvector.connector.pull_metadata_only', 'connector', 'a cheap metadata-only pull (range with includeVectors:false) for callers who want the corpus shape without paying to move every coordinate', 'connector', 'niche'),
|
|
1096
|
+
todo('upstashvector.connector.partial_sync_on_budget_exhaustion', 'connector', 'syncUpstashVectorFromReal accumulates every namespace\'s rows and calls syncPull ONCE at the end, so a rate-budget refusal part-way through discards every row already paid for \u2014 with the default limits a real index of >=5 namespaces cannot complete a default sync, and the operator is billed for the walk with nothing retained. Folding per namespace (or catching the refusal and syncing what was observed) is strictly better and is not built (\u00a79 round 1)', 'connector', 'common'),
|
|
1097
|
+
todo('upstashvector.connector.pull_shadowed_by_local_writes', 'connector', 'a pull REPORTS deltasAppended for ids that a local write is shadowing, so the caller is told the pull landed when the twin will keep answering with the local value \u2014 and a re-pull of a locally-DELETED id reports deltasAppended:0 ("vendor and twin agree") while the vendor row stays invisible. The kernel\'s fork semantics are correct; what is missing is the connector REPORTING the shadowed ids so an operator can see the divergence (\u00a79 round 1)', 'connector', 'common'),
|
|
1098
|
+
todo('upstashvector.connector.pull_index_configuration', 'connector', 'pulling the real index\'s dimension and similarityFunction from /info so a pulled twin configures itself instead of inferring the dimension from the first vector it sees', 'connector', 'common'),
|
|
1099
|
+
todo('upstashvector.connector.fixtures', 'connector', 'seed a small library of realistic corpora (a docs-search index, a product catalogue, a memory store) for eval worlds', 'connector', 'niche'),
|
|
1100
|
+
];
|
|
1101
|
+
export const UPSTASHVECTOR_AREAS = [
|
|
1102
|
+
'protocol', 'auth', 'upsert', 'query', 'scoring', 'fetch', 'range', 'update', 'delete', 'reset',
|
|
1103
|
+
'namespaces', 'filtering', 'metadata', 'info', 'sparse', 'embedding', 'resumable', 'limits',
|
|
1104
|
+
'errors', 'management', 'safety', 'persistence', 'runtime', 'connector', 'conformance',
|
|
1105
|
+
];
|
|
1106
|
+
export async function upstashvectorCapabilities() {
|
|
1107
|
+
return checkCapabilities('upstashvector', UPSTASHVECTOR_CAPABILITIES);
|
|
1108
|
+
}
|