@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,564 @@
|
|
|
1
|
+
// UPSTASH VECTOR METADATA FILTERING — a REAL tokenizer, recursive-descent parser and evaluator for
|
|
2
|
+
// the filter language documented at upstash.com/docs/vector/features/filtering.
|
|
3
|
+
//
|
|
4
|
+
// This is a genuine language implementation, not a substring match: `filter` strings arrive as
|
|
5
|
+
// opaque text on `/query`, `/delete` and `/update`, and a twin that "supported filtering" by
|
|
6
|
+
// checking `metadata[k] === v` for the one shape its own tests used would be exactly the fake
|
|
7
|
+
// success this repo forbids. The grammar below is parsed to an AST and the AST is evaluated
|
|
8
|
+
// against each candidate's metadata, so an expression the tests never anticipated still works and
|
|
9
|
+
// a malformed one is REJECTED with a parse error rather than silently matching everything.
|
|
10
|
+
//
|
|
11
|
+
// ── GROUNDED (2026-08-19) ─────────────────────────────────────────────────────────────────────
|
|
12
|
+
// upstash.com/docs/vector/features/filtering, quoted where it is quotable:
|
|
13
|
+
// • operators: `=`, `!=`, `<`, `>`, `<=`, `>=`, `GLOB`, `NOT GLOB`, `IN`, `NOT IN`, `CONTAINS`,
|
|
14
|
+
// `NOT CONTAINS`, `HAS FIELD`, `HAS NOT FIELD`, combined with `AND` / `OR`;
|
|
15
|
+
// • "Nested objects can be at arbitrary depths, so more than one `.` accessor can be used in the
|
|
16
|
+
// same identifier";
|
|
17
|
+
// • "individual array elements can also be filtered by referencing them with the `[]` accessor by
|
|
18
|
+
// their indexes", and "it is possible to index from the back using the `#` character with
|
|
19
|
+
// negative values" (`arr[#-1]` is the last element);
|
|
20
|
+
// • "The string literals can be either single or double quoted";
|
|
21
|
+
// • "Boolean literals are represented as `1` or `0`" — while the docs' own `!=` example uses
|
|
22
|
+
// `is_capital != true`, so BOTH spellings are accepted here (see `parseOperand`);
|
|
23
|
+
// • "`AND` will have higher precedence than `OR`" when no parentheses are given — which is why
|
|
24
|
+
// `parseOr` sits above `parseAnd` below rather than the two sharing one precedence level.
|
|
25
|
+
//
|
|
26
|
+
// ── WHAT IS DELIBERATELY NOT CLAIMED ──────────────────────────────────────────────────────────
|
|
27
|
+
// Upstash does not publish the literal text of its parse errors, and this pack has no live index to
|
|
28
|
+
// probe (unlike its sibling upstash, which live-probed every string). So `FilterError.message`
|
|
29
|
+
// is TWIN-AUTHORED and says so; what is faithful — and what the capability manifest asserts — is
|
|
30
|
+
// that a malformed filter is REJECTED with the vendor's documented `{error,status}` envelope at
|
|
31
|
+
// HTTP 400 rather than being ignored. A filter that silently matched everything would turn a typo
|
|
32
|
+
// into a data leak, which is the failure this module exists to prevent.
|
|
33
|
+
/** A filter that could not be parsed. The handler maps this to the vendor's 400 envelope. */
|
|
34
|
+
export class FilterError extends Error {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'FilterError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const OPERATOR_CHARS = new Set(['=', '!', '<', '>']);
|
|
41
|
+
/** Reserved words. Case-INSENSITIVE in the docs' examples (`and` and `AND` both appear). */
|
|
42
|
+
const WORDS = new Set(['AND', 'OR', 'NOT', 'IN', 'GLOB', 'CONTAINS', 'HAS', 'FIELD']);
|
|
43
|
+
/**
|
|
44
|
+
* Split a filter string into tokens.
|
|
45
|
+
*
|
|
46
|
+
* Identifiers carry their `.`/`[]` accessors as ONE token (`geography.coordinates.latitude`,
|
|
47
|
+
* `major_industries[0]`, `tags[#-1]`) because the path is resolved as a unit at evaluation time;
|
|
48
|
+
* splitting them would force the parser to re-join them and lose the distinction between the
|
|
49
|
+
* accessor dot and a decimal point.
|
|
50
|
+
*/
|
|
51
|
+
export function tokenizeFilter(input) {
|
|
52
|
+
const tokens = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < input.length) {
|
|
55
|
+
const c = input[i];
|
|
56
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const pos = i;
|
|
61
|
+
if (c === '(' || c === ')' || c === ',') {
|
|
62
|
+
tokens.push({ kind: 'punct', value: c, pos });
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (c === "'" || c === '"') {
|
|
67
|
+
// A quoted string. The closing quote must match the opening one, so an apostrophe inside a
|
|
68
|
+
// double-quoted literal is ordinary text.
|
|
69
|
+
const quote = c;
|
|
70
|
+
i++;
|
|
71
|
+
let out = '';
|
|
72
|
+
let closed = false;
|
|
73
|
+
while (i < input.length) {
|
|
74
|
+
const ch = input[i];
|
|
75
|
+
// NO BACKSLASH ESCAPE. An earlier draft treated `\x` as an escape for `x`; §9 round 1
|
|
76
|
+
// showed that is both undocumented by Upstash and a source of SILENT WRONG ANSWERS:
|
|
77
|
+
// with it, `path = 'C:\Users'` matched a vector whose stored path was `C:Users` and did
|
|
78
|
+
// NOT match the one holding `C:\Users` — a confidently wrong row at HTTP 200. Upstash's
|
|
79
|
+
// filter language is SQLite-family (`GLOB`, `IN`, `HAS FIELD`), and no member of that
|
|
80
|
+
// family gives backslash a special meaning inside a string literal. A backslash is an
|
|
81
|
+
// ordinary character; a quote is closed only by its own kind, which is what the two
|
|
82
|
+
// quote styles are for.
|
|
83
|
+
if (ch === quote) {
|
|
84
|
+
closed = true;
|
|
85
|
+
i++;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
out += ch;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
if (!closed)
|
|
92
|
+
throw new FilterError(`upstashvector: unterminated string literal starting at position ${pos} in filter`);
|
|
93
|
+
tokens.push({ kind: 'string', value: out, pos });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (OPERATOR_CHARS.has(c)) {
|
|
97
|
+
const two = input.slice(i, i + 2);
|
|
98
|
+
if (two === '!=' || two === '<=' || two === '>=') {
|
|
99
|
+
tokens.push({ kind: 'op', value: two, pos });
|
|
100
|
+
i += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === '=' || c === '<' || c === '>') {
|
|
104
|
+
tokens.push({ kind: 'op', value: c, pos });
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
throw new FilterError(`upstashvector: unexpected character '${c}' at position ${pos} in filter (did you mean '!='?)`);
|
|
109
|
+
}
|
|
110
|
+
// A number: an optional sign is handled by the operand parser, not here, so that `a>-1` still
|
|
111
|
+
// tokenizes as `a` `>` `-1`.
|
|
112
|
+
if (/[0-9]/.test(c) || (c === '-' && /[0-9.]/.test(input[i + 1] ?? ''))) {
|
|
113
|
+
let out = c;
|
|
114
|
+
i++;
|
|
115
|
+
while (i < input.length && /[0-9._eE+-]/.test(input[i])) {
|
|
116
|
+
// Stop before an `e+`/`e-` that is not part of an exponent, so `a=1 e` does not swallow.
|
|
117
|
+
const ch = input[i];
|
|
118
|
+
if ((ch === '+' || ch === '-') && !/[eE]/.test(out[out.length - 1] ?? ''))
|
|
119
|
+
break;
|
|
120
|
+
out += ch;
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
if (!Number.isFinite(Number(out)))
|
|
124
|
+
throw new FilterError(`upstashvector: '${out}' at position ${pos} is not a valid number in filter`);
|
|
125
|
+
tokens.push({ kind: 'number', value: out, pos });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
129
|
+
let out = '';
|
|
130
|
+
while (i < input.length && /[A-Za-z0-9_.[\]#-]/.test(input[i])) {
|
|
131
|
+
// `-` is legal inside a bracket subscript (`[#-1]`) but is NOT an identifier character
|
|
132
|
+
// otherwise — otherwise `a-b` would read as one name and a typo would become a field.
|
|
133
|
+
if (input[i] === '-' && !out.includes('['))
|
|
134
|
+
break;
|
|
135
|
+
if (input[i] === '-' && out.lastIndexOf('[') < out.lastIndexOf(']'))
|
|
136
|
+
break;
|
|
137
|
+
out += input[i];
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
tokens.push({ kind: WORDS.has(out.toUpperCase()) ? 'word' : 'ident', value: out, pos });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
throw new FilterError(`upstashvector: unexpected character '${c}' at position ${pos} in filter`);
|
|
144
|
+
}
|
|
145
|
+
return tokens;
|
|
146
|
+
}
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// PARSER (recursive descent; AND binds tighter than OR, per the docs)
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
class Parser {
|
|
151
|
+
tokens;
|
|
152
|
+
source;
|
|
153
|
+
at = 0;
|
|
154
|
+
constructor(tokens, source) {
|
|
155
|
+
this.tokens = tokens;
|
|
156
|
+
this.source = source;
|
|
157
|
+
}
|
|
158
|
+
peek() { return this.tokens[this.at]; }
|
|
159
|
+
next() { return this.tokens[this.at++]; }
|
|
160
|
+
isWord(word, offset = 0) {
|
|
161
|
+
const t = this.tokens[this.at + offset];
|
|
162
|
+
return t !== undefined && t.kind === 'word' && t.value.toUpperCase() === word;
|
|
163
|
+
}
|
|
164
|
+
expectWord(word) {
|
|
165
|
+
if (!this.isWord(word))
|
|
166
|
+
throw new FilterError(`upstashvector: expected ${word} in filter '${this.source}'`);
|
|
167
|
+
this.at++;
|
|
168
|
+
}
|
|
169
|
+
parse() {
|
|
170
|
+
if (this.tokens.length === 0)
|
|
171
|
+
throw new FilterError('upstashvector: empty filter expression');
|
|
172
|
+
const node = this.parseOr();
|
|
173
|
+
const rest = this.peek();
|
|
174
|
+
if (rest !== undefined) {
|
|
175
|
+
throw new FilterError(`upstashvector: unexpected '${rest.value}' at position ${rest.pos} in filter '${this.source}'`);
|
|
176
|
+
}
|
|
177
|
+
// Validate every metadata PATH eagerly, at parse time.
|
|
178
|
+
//
|
|
179
|
+
// §9 round 1 found the bug this closes: a path like `a[0` (an unterminated subscript)
|
|
180
|
+
// tokenizes and parses fine, and only `parsePath` — called from `resolvePath` DURING
|
|
181
|
+
// EVALUATION — rejects it. That made the failure DATA-DEPENDENT: with rows present the
|
|
182
|
+
// FilterError escaped as an internal HTTP 500, and against an empty namespace the evaluator
|
|
183
|
+
// was never reached at all, so the same malformed filter answered 200 `[]`. A filter is either
|
|
184
|
+
// well-formed or it is not; that cannot depend on how many vectors happen to be stored.
|
|
185
|
+
validatePaths(node);
|
|
186
|
+
return node;
|
|
187
|
+
}
|
|
188
|
+
/** OR binds LOOSEST — "AND will have higher precedence than OR" (docs). */
|
|
189
|
+
parseOr() {
|
|
190
|
+
let left = this.parseAnd();
|
|
191
|
+
while (this.isWord('OR')) {
|
|
192
|
+
this.at++;
|
|
193
|
+
left = { kind: 'or', left, right: this.parseAnd() };
|
|
194
|
+
}
|
|
195
|
+
return left;
|
|
196
|
+
}
|
|
197
|
+
parseAnd() {
|
|
198
|
+
let left = this.parseUnary();
|
|
199
|
+
while (this.isWord('AND')) {
|
|
200
|
+
this.at++;
|
|
201
|
+
left = { kind: 'and', left, right: this.parseUnary() };
|
|
202
|
+
}
|
|
203
|
+
return left;
|
|
204
|
+
}
|
|
205
|
+
parseUnary() {
|
|
206
|
+
const t = this.peek();
|
|
207
|
+
if (t === undefined)
|
|
208
|
+
throw new FilterError(`upstashvector: unexpected end of filter '${this.source}'`);
|
|
209
|
+
if (t.kind === 'punct' && t.value === '(') {
|
|
210
|
+
this.at++;
|
|
211
|
+
const inner = this.parseOr();
|
|
212
|
+
const close = this.next();
|
|
213
|
+
if (close === undefined || close.value !== ')')
|
|
214
|
+
throw new FilterError(`upstashvector: unbalanced parenthesis in filter '${this.source}'`);
|
|
215
|
+
return inner;
|
|
216
|
+
}
|
|
217
|
+
// `HAS FIELD x` / `HAS NOT FIELD x` — the only prefix-form operators in the language.
|
|
218
|
+
if (this.isWord('HAS')) {
|
|
219
|
+
this.at++;
|
|
220
|
+
let negated = false;
|
|
221
|
+
if (this.isWord('NOT')) {
|
|
222
|
+
this.at++;
|
|
223
|
+
negated = true;
|
|
224
|
+
}
|
|
225
|
+
this.expectWord('FIELD');
|
|
226
|
+
const ident = this.next();
|
|
227
|
+
if (ident === undefined || ident.kind !== 'ident') {
|
|
228
|
+
throw new FilterError(`upstashvector: HAS ${negated ? 'NOT ' : ''}FIELD needs a metadata key in filter '${this.source}'`);
|
|
229
|
+
}
|
|
230
|
+
return { kind: 'hasField', path: ident.value, negated };
|
|
231
|
+
}
|
|
232
|
+
return this.parseComparison();
|
|
233
|
+
}
|
|
234
|
+
parseComparison() {
|
|
235
|
+
const ident = this.next();
|
|
236
|
+
if (ident === undefined || ident.kind !== 'ident') {
|
|
237
|
+
throw new FilterError(`upstashvector: expected a metadata key but found '${ident?.value ?? 'end of input'}' in filter '${this.source}'`);
|
|
238
|
+
}
|
|
239
|
+
const path = ident.value;
|
|
240
|
+
let negated = false;
|
|
241
|
+
if (this.isWord('NOT')) {
|
|
242
|
+
this.at++;
|
|
243
|
+
negated = true;
|
|
244
|
+
}
|
|
245
|
+
const op = this.next();
|
|
246
|
+
if (op === undefined)
|
|
247
|
+
throw new FilterError(`upstashvector: '${path}' has no operator in filter '${this.source}'`);
|
|
248
|
+
if (op.kind === 'word' && op.value.toUpperCase() === 'IN') {
|
|
249
|
+
return { kind: 'in', path, negated, values: this.parseList() };
|
|
250
|
+
}
|
|
251
|
+
if (op.kind === 'word' && op.value.toUpperCase() === 'GLOB') {
|
|
252
|
+
return { kind: 'compare', path, op: negated ? 'NOT GLOB' : 'GLOB', value: this.parseOperand() };
|
|
253
|
+
}
|
|
254
|
+
if (op.kind === 'word' && op.value.toUpperCase() === 'CONTAINS') {
|
|
255
|
+
return { kind: 'compare', path, op: negated ? 'NOT CONTAINS' : 'CONTAINS', value: this.parseOperand() };
|
|
256
|
+
}
|
|
257
|
+
if (negated) {
|
|
258
|
+
// `NOT` only prefixes IN / GLOB / CONTAINS; `a NOT = 1` is not the language.
|
|
259
|
+
throw new FilterError(`upstashvector: NOT must be followed by IN, GLOB or CONTAINS in filter '${this.source}'`);
|
|
260
|
+
}
|
|
261
|
+
if (op.kind !== 'op') {
|
|
262
|
+
throw new FilterError(`upstashvector: '${op.value}' is not a comparison operator in filter '${this.source}'`);
|
|
263
|
+
}
|
|
264
|
+
return { kind: 'compare', path, op: op.value, value: this.parseOperand() };
|
|
265
|
+
}
|
|
266
|
+
parseList() {
|
|
267
|
+
const open = this.next();
|
|
268
|
+
if (open === undefined || open.value !== '(')
|
|
269
|
+
throw new FilterError(`upstashvector: IN needs a parenthesised list in filter '${this.source}'`);
|
|
270
|
+
const values = [];
|
|
271
|
+
for (;;) {
|
|
272
|
+
const t = this.peek();
|
|
273
|
+
if (t === undefined)
|
|
274
|
+
throw new FilterError(`upstashvector: unterminated IN list in filter '${this.source}'`);
|
|
275
|
+
if (t.kind === 'punct' && t.value === ')') {
|
|
276
|
+
this.at++;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
values.push(this.parseOperand());
|
|
280
|
+
const sep = this.peek();
|
|
281
|
+
if (sep !== undefined && sep.kind === 'punct' && sep.value === ',') {
|
|
282
|
+
this.at++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (sep !== undefined && sep.kind === 'punct' && sep.value === ')') {
|
|
286
|
+
this.at++;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
throw new FilterError(`upstashvector: expected ',' or ')' in IN list in filter '${this.source}'`);
|
|
290
|
+
}
|
|
291
|
+
if (values.length === 0)
|
|
292
|
+
throw new FilterError(`upstashvector: empty IN list in filter '${this.source}'`);
|
|
293
|
+
return values;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* A literal operand.
|
|
297
|
+
*
|
|
298
|
+
* BOOLEANS: the docs say "Boolean literals are represented as `1` or `0`", but their own `!=`
|
|
299
|
+
* example is `is_capital != true`. Both are accepted; `1`/`0` stay NUMBERS (so `population > 0`
|
|
300
|
+
* still compares numerically) and are matched against a boolean field by `looseEquals`.
|
|
301
|
+
*/
|
|
302
|
+
parseOperand() {
|
|
303
|
+
const t = this.next();
|
|
304
|
+
if (t === undefined)
|
|
305
|
+
throw new FilterError(`upstashvector: expected a value in filter '${this.source}'`);
|
|
306
|
+
if (t.kind === 'string')
|
|
307
|
+
return t.value;
|
|
308
|
+
if (t.kind === 'number')
|
|
309
|
+
return Number(t.value);
|
|
310
|
+
if (t.kind === 'ident' || t.kind === 'word') {
|
|
311
|
+
const lower = t.value.toLowerCase();
|
|
312
|
+
if (lower === 'true')
|
|
313
|
+
return true;
|
|
314
|
+
if (lower === 'false')
|
|
315
|
+
return false;
|
|
316
|
+
// A bare word is not a literal — an unquoted string is the single commonest filter typo, and
|
|
317
|
+
// matching it against the field name would silently compare a value to itself.
|
|
318
|
+
throw new FilterError(`upstashvector: '${t.value}' at position ${t.pos} is not a literal — string values must be quoted in filter '${this.source}'`);
|
|
319
|
+
}
|
|
320
|
+
throw new FilterError(`upstashvector: unexpected '${t.value}' where a value was expected in filter '${this.source}'`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Walk the AST and parse every metadata path, so a malformed one is a PARSE error.
|
|
325
|
+
* See the note in `Parser.parse` for the data-dependent-status bug this closes.
|
|
326
|
+
*/
|
|
327
|
+
function validatePaths(node) {
|
|
328
|
+
switch (node.kind) {
|
|
329
|
+
case 'and':
|
|
330
|
+
case 'or':
|
|
331
|
+
validatePaths(node.left);
|
|
332
|
+
validatePaths(node.right);
|
|
333
|
+
return;
|
|
334
|
+
case 'compare':
|
|
335
|
+
case 'in':
|
|
336
|
+
case 'hasField':
|
|
337
|
+
parsePath(node.path); // throws FilterError on a malformed path
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Parse a filter string into an AST. Throws `FilterError` on anything malformed. */
|
|
341
|
+
export function parseFilter(filter) {
|
|
342
|
+
return new Parser(tokenizeFilter(filter), filter).parse();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Split `geography.coordinates[0]` / `tags[#-1]` into steps.
|
|
346
|
+
*
|
|
347
|
+
* `[#-1]` is the docs' "index from the back using the `#` character with negative values": the
|
|
348
|
+
* `#` marks end-relative addressing and the value that follows is the offset, so `[#-1]` is the
|
|
349
|
+
* LAST element. A plain `[0]` is head-relative.
|
|
350
|
+
*/
|
|
351
|
+
export function parsePath(path) {
|
|
352
|
+
const steps = [];
|
|
353
|
+
let buf = '';
|
|
354
|
+
let i = 0;
|
|
355
|
+
const flush = () => { if (buf !== '') {
|
|
356
|
+
steps.push({ key: buf });
|
|
357
|
+
buf = '';
|
|
358
|
+
} };
|
|
359
|
+
while (i < path.length) {
|
|
360
|
+
const c = path[i];
|
|
361
|
+
if (c === '.') {
|
|
362
|
+
flush();
|
|
363
|
+
i++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (c === '[') {
|
|
367
|
+
flush();
|
|
368
|
+
const close = path.indexOf(']', i);
|
|
369
|
+
if (close === -1)
|
|
370
|
+
throw new FilterError(`upstashvector: unterminated '[' in metadata path '${path}'`);
|
|
371
|
+
const raw = path.slice(i + 1, close);
|
|
372
|
+
const fromEnd = raw.startsWith('#');
|
|
373
|
+
const digits = fromEnd ? raw.slice(1) : raw;
|
|
374
|
+
// `Number('')` is 0, so a BARE `a[]` used to parse as `a[0]` — silently addressing the first
|
|
375
|
+
// element of an array the caller never named an index for. Require an explicit integer
|
|
376
|
+
// literal rather than trusting the numeric coercion.
|
|
377
|
+
if (!/^-?\d+$/.test(digits)) {
|
|
378
|
+
throw new FilterError(`upstashvector: '${raw}' is not an array index in metadata path '${path}'`);
|
|
379
|
+
}
|
|
380
|
+
const n = Number(digits);
|
|
381
|
+
if (!Number.isInteger(n))
|
|
382
|
+
throw new FilterError(`upstashvector: '${raw}' is not an array index in metadata path '${path}'`);
|
|
383
|
+
steps.push({ index: n, fromEnd });
|
|
384
|
+
i = close + 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
buf += c;
|
|
388
|
+
i++;
|
|
389
|
+
}
|
|
390
|
+
flush();
|
|
391
|
+
if (steps.length === 0)
|
|
392
|
+
throw new FilterError(`upstashvector: empty metadata path in filter`);
|
|
393
|
+
return steps;
|
|
394
|
+
}
|
|
395
|
+
/** The sentinel for "this path is not present". Distinct from a stored `null`, which IS present. */
|
|
396
|
+
export const MISSING = Symbol('upstashvector.missing');
|
|
397
|
+
/** Resolve a metadata path. Returns `MISSING` when any step is absent. */
|
|
398
|
+
export function resolvePath(metadata, path) {
|
|
399
|
+
let cur = metadata;
|
|
400
|
+
for (const step of parsePath(path)) {
|
|
401
|
+
if (cur === null || cur === undefined)
|
|
402
|
+
return MISSING;
|
|
403
|
+
if ('key' in step) {
|
|
404
|
+
if (typeof cur !== 'object' || Array.isArray(cur))
|
|
405
|
+
return MISSING;
|
|
406
|
+
const rec = cur;
|
|
407
|
+
if (!Object.prototype.hasOwnProperty.call(rec, step.key))
|
|
408
|
+
return MISSING;
|
|
409
|
+
cur = rec[step.key];
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(cur))
|
|
413
|
+
return MISSING;
|
|
414
|
+
// `[#-1]` addresses from the end; `[0]` from the start. A negative head-relative index is not
|
|
415
|
+
// the documented spelling, so it simply misses rather than silently wrapping.
|
|
416
|
+
const idx = step.fromEnd ? cur.length + step.index : step.index;
|
|
417
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length)
|
|
418
|
+
return MISSING;
|
|
419
|
+
cur = cur[idx];
|
|
420
|
+
}
|
|
421
|
+
return cur;
|
|
422
|
+
}
|
|
423
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
424
|
+
// GLOB
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
426
|
+
/**
|
|
427
|
+
* SQLite-style GLOB: `*` any run, `?` one character, `[abc]` a class, `[^abc]` a negated class,
|
|
428
|
+
* `[a-z]` a range. Case-SENSITIVE (that is what distinguishes GLOB from LIKE).
|
|
429
|
+
*
|
|
430
|
+
* Implemented as a backtracking matcher rather than by translating to a RegExp: a translation has
|
|
431
|
+
* to escape every regex metacharacter that is ORDINARY in a glob (`.`, `+`, `(`, `$`, …), and
|
|
432
|
+
* missing one turns a filter into a different filter — a silent wrong-answer bug rather than an
|
|
433
|
+
* error. The docs' own example, `city GLOB '?[sz]*[^m-z]'`, exercises all four constructs.
|
|
434
|
+
*/
|
|
435
|
+
export function globMatch(pattern, subject) {
|
|
436
|
+
const walk = (p, s) => {
|
|
437
|
+
if (p === pattern.length)
|
|
438
|
+
return s === subject.length;
|
|
439
|
+
const pc = pattern[p];
|
|
440
|
+
if (pc === '*') {
|
|
441
|
+
// Collapse runs of `*` so `a**b` costs no more than `a*b`.
|
|
442
|
+
let q = p;
|
|
443
|
+
while (q < pattern.length && pattern[q] === '*')
|
|
444
|
+
q++;
|
|
445
|
+
for (let k = s; k <= subject.length; k++)
|
|
446
|
+
if (walk(q, k))
|
|
447
|
+
return true;
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
if (s === subject.length)
|
|
451
|
+
return false;
|
|
452
|
+
if (pc === '?')
|
|
453
|
+
return walk(p + 1, s + 1);
|
|
454
|
+
if (pc === '[') {
|
|
455
|
+
const close = classEnd(pattern, p);
|
|
456
|
+
if (close === -1)
|
|
457
|
+
return pattern[p] === subject[s] && walk(p + 1, s + 1); // an unclosed '[' is a literal
|
|
458
|
+
const negated = pattern[p + 1] === '^';
|
|
459
|
+
const body = pattern.slice(p + 1 + (negated ? 1 : 0), close);
|
|
460
|
+
return inClass(body, subject[s]) !== negated && walk(close + 1, s + 1);
|
|
461
|
+
}
|
|
462
|
+
return pc === subject[s] && walk(p + 1, s + 1);
|
|
463
|
+
};
|
|
464
|
+
return walk(0, 0);
|
|
465
|
+
}
|
|
466
|
+
/** Index of the `]` closing the class opened at `open`, or -1. A `]` FIRST in the class is literal. */
|
|
467
|
+
function classEnd(pattern, open) {
|
|
468
|
+
let i = open + 1;
|
|
469
|
+
if (pattern[i] === '^')
|
|
470
|
+
i++;
|
|
471
|
+
if (pattern[i] === ']')
|
|
472
|
+
i++; // a leading ']' is a literal member, not the terminator
|
|
473
|
+
for (; i < pattern.length; i++)
|
|
474
|
+
if (pattern[i] === ']')
|
|
475
|
+
return i;
|
|
476
|
+
return -1;
|
|
477
|
+
}
|
|
478
|
+
function inClass(body, ch) {
|
|
479
|
+
for (let i = 0; i < body.length; i++) {
|
|
480
|
+
// A range needs a `-` with members on BOTH sides; a trailing `-` is a literal hyphen.
|
|
481
|
+
if (body[i + 1] === '-' && i + 2 < body.length) {
|
|
482
|
+
const lo = body[i];
|
|
483
|
+
const hi = body[i + 2];
|
|
484
|
+
if (ch >= lo && ch <= hi)
|
|
485
|
+
return true;
|
|
486
|
+
i += 2;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (body[i] === ch)
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
495
|
+
// EVALUATOR
|
|
496
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
497
|
+
/**
|
|
498
|
+
* Compare a stored metadata value with a filter literal for EQUALITY.
|
|
499
|
+
*
|
|
500
|
+
* `1`/`0` are the docs' own boolean spelling, so a numeric 1 matches a stored `true`. Everything
|
|
501
|
+
* else is compared by JS `===` after that coercion, so `'5'` (a string literal) does NOT equal a
|
|
502
|
+
* stored number 5 — a filter language that silently coerced across types would make
|
|
503
|
+
* `price = '10'` match a `price: 10` field and mask the caller's bug.
|
|
504
|
+
*/
|
|
505
|
+
function looseEquals(stored, literal) {
|
|
506
|
+
if (typeof stored === 'boolean' && typeof literal === 'number')
|
|
507
|
+
return stored === (literal === 1);
|
|
508
|
+
if (typeof stored === 'boolean' && typeof literal === 'boolean')
|
|
509
|
+
return stored === literal;
|
|
510
|
+
return stored === literal;
|
|
511
|
+
}
|
|
512
|
+
/** Ordering comparison. Only like-typed operands are ordered; anything else simply does not match. */
|
|
513
|
+
function ordered(stored, literal, op) {
|
|
514
|
+
if (typeof stored === 'number' && typeof literal === 'number') {
|
|
515
|
+
return op === '<' ? stored < literal : op === '>' ? stored > literal : op === '<=' ? stored <= literal : stored >= literal;
|
|
516
|
+
}
|
|
517
|
+
if (typeof stored === 'string' && typeof literal === 'string') {
|
|
518
|
+
return op === '<' ? stored < literal : op === '>' ? stored > literal : op === '<=' ? stored <= literal : stored >= literal;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
/** Evaluate a parsed filter against one vector's metadata. */
|
|
523
|
+
export function evaluateFilter(node, metadata) {
|
|
524
|
+
switch (node.kind) {
|
|
525
|
+
case 'and': return evaluateFilter(node.left, metadata) && evaluateFilter(node.right, metadata);
|
|
526
|
+
case 'or': return evaluateFilter(node.left, metadata) || evaluateFilter(node.right, metadata);
|
|
527
|
+
case 'hasField': {
|
|
528
|
+
const present = resolvePath(metadata, node.path) !== MISSING;
|
|
529
|
+
return node.negated ? !present : present;
|
|
530
|
+
}
|
|
531
|
+
case 'in': {
|
|
532
|
+
const v = resolvePath(metadata, node.path);
|
|
533
|
+
// A MISSING field is in no list — and is also not "NOT IN" anything, because the vendor's
|
|
534
|
+
// HAS FIELD operator exists precisely so that absence is asked about explicitly. Treating
|
|
535
|
+
// absence as a NOT IN match would make `country NOT IN ('X')` select vectors with no
|
|
536
|
+
// `country` at all, which is the classic filter-language footgun.
|
|
537
|
+
if (v === MISSING)
|
|
538
|
+
return false;
|
|
539
|
+
const hit = node.values.some((lit) => looseEquals(v, lit));
|
|
540
|
+
return node.negated ? !hit : hit;
|
|
541
|
+
}
|
|
542
|
+
case 'compare': {
|
|
543
|
+
const v = resolvePath(metadata, node.path);
|
|
544
|
+
if (v === MISSING)
|
|
545
|
+
return false; // same rule as IN: absence never satisfies a comparison
|
|
546
|
+
switch (node.op) {
|
|
547
|
+
case '=': return looseEquals(v, node.value);
|
|
548
|
+
case '!=': return !looseEquals(v, node.value);
|
|
549
|
+
case '<':
|
|
550
|
+
case '>':
|
|
551
|
+
case '<=':
|
|
552
|
+
case '>=': return ordered(v, node.value, node.op);
|
|
553
|
+
case 'GLOB': return typeof v === 'string' && typeof node.value === 'string' && globMatch(node.value, v);
|
|
554
|
+
case 'NOT GLOB': return typeof v === 'string' && typeof node.value === 'string' && !globMatch(node.value, v);
|
|
555
|
+
case 'CONTAINS': return Array.isArray(v) && v.some((el) => looseEquals(el, node.value));
|
|
556
|
+
case 'NOT CONTAINS': return Array.isArray(v) && !v.some((el) => looseEquals(el, node.value));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** Parse + evaluate in one step. Exported for the handler's single call site. */
|
|
562
|
+
export function matchesFilter(filter, metadata) {
|
|
563
|
+
return evaluateFilter(parseFilter(filter), metadata);
|
|
564
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SimilarityFunction } from './upstashvector-store.js';
|
|
2
|
+
export type UpstashVectorServerOptions = {
|
|
3
|
+
root?: string;
|
|
4
|
+
port?: number;
|
|
5
|
+
readOnly?: boolean;
|
|
6
|
+
/** The token the twin demands. Omit to accept any non-empty credential (still 401s a missing one). */
|
|
7
|
+
token?: string;
|
|
8
|
+
/** The dimension this index enforces. Omit to let the first upsert lock one in. */
|
|
9
|
+
dimension?: number;
|
|
10
|
+
/** The metric this index ranks with. Defaults to COSINE. */
|
|
11
|
+
similarityFunction?: SimilarityFunction;
|
|
12
|
+
/** The injected clock. Returns the ISO instant this request "happens at". */
|
|
13
|
+
now?: () => string;
|
|
14
|
+
};
|
|
15
|
+
/** Options every Upstash Vector-twin HTTP surface needs, independent of who owns the socket. */
|
|
16
|
+
export interface UpstashVectorTwinFetchOptions {
|
|
17
|
+
root?: string;
|
|
18
|
+
readOnly?: boolean;
|
|
19
|
+
/** The token the twin demands. Omit to accept any non-empty credential (still 401s a missing one). */
|
|
20
|
+
token?: string;
|
|
21
|
+
/** The dimension this index enforces. Omit to let the first upsert lock one in. */
|
|
22
|
+
dimension?: number;
|
|
23
|
+
/** The metric this index ranks with. Defaults to COSINE. */
|
|
24
|
+
similarityFunction?: SimilarityFunction;
|
|
25
|
+
/** The injected clock. Returns the ISO instant this request "happens at". */
|
|
26
|
+
now?: () => string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createUpstashVectorTwinFetch(options?: UpstashVectorTwinFetchOptions): (request: Request) => Promise<Response>;
|
|
29
|
+
export declare function createUpstashVectorTwinServer(options?: UpstashVectorServerOptions): Promise<{
|
|
30
|
+
port: number;
|
|
31
|
+
stop: () => void;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// upstashvector twin HTTP server — serve the Upstash Vector REST twin over HTTP so the real
|
|
2
|
+
// `@upstash/vector` SDK (pointed at it with nothing but its own public `url` option) works
|
|
3
|
+
// UNMODIFIED.
|
|
4
|
+
//
|
|
5
|
+
// ── THE INDEX IS CONFIGURED HERE, NOT DISCOVERED ──────────────────────────────────────────────
|
|
6
|
+
// A real Upstash Vector index is created with a fixed `dimension` and `similarityFunction` and can
|
|
7
|
+
// never change them. A twin has no creation step, so those become server options: pass them and the
|
|
8
|
+
// index behaves exactly like one created that way (including the vendor's 422 on a mismatched
|
|
9
|
+
// vector); omit `dimension` and the FIRST upsert locks one in, which is the convenient local
|
|
10
|
+
// default. `similarityFunction` defaults to COSINE, the console's own default.
|
|
11
|
+
//
|
|
12
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createUpstashVectorTwinFetch` and
|
|
13
|
+
// the SERVER is one line of `Bun.serve` around it. This is a CUSTOM fetch, not the kernel adapter
|
|
14
|
+
// (`createTwinFetchFromHandler`): replies carry ONLY the handler's own headers, and the clock is
|
|
15
|
+
// an injectable seam.
|
|
16
|
+
import { serveHttp } from '@volter/world-core';
|
|
17
|
+
import { handleUpstashVectorTwinRequest } from "./upstashvector-twin.js";
|
|
18
|
+
import { worldNow, statefulTwinManifest } from '@volter/world-core';
|
|
19
|
+
export function createUpstashVectorTwinFetch(options = {}) {
|
|
20
|
+
const readOnly = options.readOnly ?? false;
|
|
21
|
+
const now = options.now ?? (() => worldNow());
|
|
22
|
+
return async function upstashVectorTwinFetch(request) {
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
25
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
26
|
+
return Response.json(statefulTwinManifest({ vendor: 'upstashvector', twinOf: 'the Upstash Vector REST API', stores: 'namespaces and vectors, queried by a real scorer' }));
|
|
27
|
+
}
|
|
28
|
+
const body = request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : '';
|
|
29
|
+
const headers = {};
|
|
30
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
31
|
+
const { status, body: out, headers: outHeaders } = await handleUpstashVectorTwinRequest({
|
|
32
|
+
method: request.method,
|
|
33
|
+
path: url.pathname + (url.search || ''),
|
|
34
|
+
body,
|
|
35
|
+
headers,
|
|
36
|
+
readOnly,
|
|
37
|
+
occurredAt: now(),
|
|
38
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
39
|
+
...(options.token !== undefined ? { token: options.token } : {}),
|
|
40
|
+
...(options.dimension !== undefined ? { dimension: options.dimension } : {}),
|
|
41
|
+
...(options.similarityFunction !== undefined ? { similarityFunction: options.similarityFunction } : {}),
|
|
42
|
+
});
|
|
43
|
+
// A null body means "no body at all" (HEAD, CORS preflight).
|
|
44
|
+
if (out === null)
|
|
45
|
+
return new Response(null, { status, headers: { ...(outHeaders ?? {}) } });
|
|
46
|
+
return new Response(JSON.stringify(out), { status, headers: { ...(outHeaders ?? {}) } });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function createUpstashVectorTwinServer(options = {}) {
|
|
50
|
+
const server = await serveHttp({
|
|
51
|
+
port: options.port ?? 0,
|
|
52
|
+
idleTimeout: 60,
|
|
53
|
+
fetch: createUpstashVectorTwinFetch(options),
|
|
54
|
+
});
|
|
55
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
56
|
+
}
|