@volter/twin-turbopuffer 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 +145 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +27 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +65 -0
- package/dist/src/key-gate.d.ts +3 -0
- package/dist/src/key-gate.js +39 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +28 -0
- package/dist/src/screens/dashboard.d.ts +11 -0
- package/dist/src/screens/dashboard.js +191 -0
- package/dist/src/semantics/namespaces.d.ts +5 -0
- package/dist/src/semantics/namespaces.js +17 -0
- package/dist/src/turbopuffer-capabilities.d.ts +6 -0
- package/dist/src/turbopuffer-capabilities.js +442 -0
- package/dist/src/turbopuffer-conformance.d.ts +8 -0
- package/dist/src/turbopuffer-conformance.js +102 -0
- package/dist/src/turbopuffer-connector.d.ts +34 -0
- package/dist/src/turbopuffer-connector.js +152 -0
- package/dist/src/turbopuffer-filter.d.ts +57 -0
- package/dist/src/turbopuffer-filter.js +286 -0
- package/dist/src/turbopuffer-server.d.ts +23 -0
- package/dist/src/turbopuffer-server.js +72 -0
- package/dist/src/turbopuffer-stem.d.ts +1 -0
- package/dist/src/turbopuffer-stem.js +133 -0
- package/dist/src/turbopuffer-store.d.ts +80 -0
- package/dist/src/turbopuffer-store.js +1304 -0
- package/dist/src/turbopuffer-text.d.ts +44 -0
- package/dist/src/turbopuffer-text.js +189 -0
- package/dist/src/turbopuffer-twin.d.ts +25 -0
- package/dist/src/turbopuffer-twin.js +406 -0
- package/package.json +56 -0
- package/src/cli.ts +28 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +92 -0
- package/src/key-gate.ts +39 -0
- package/src/manifest.ts +63 -0
- package/src/screens/dashboard.tsx +214 -0
- package/src/semantics/namespaces.ts +30 -0
- package/src/turbopuffer-capabilities.ts +455 -0
- package/src/turbopuffer-conformance.ts +101 -0
- package/src/turbopuffer-connector.ts +153 -0
- package/src/turbopuffer-filter.ts +277 -0
- package/src/turbopuffer-server.ts +81 -0
- package/src/turbopuffer-stem.ts +104 -0
- package/src/turbopuffer-store.ts +1157 -0
- package/src/turbopuffer-text.ts +204 -0
- package/src/turbopuffer-twin.ts +429 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// FULL-TEXT SEARCH — the tokenizer and the BM25 scorer behind `rank_by: [attr, 'BM25', q]` and the
|
|
2
|
+
// `ContainsAllTokens` / `ContainsAnyToken` filters.
|
|
3
|
+
//
|
|
4
|
+
// ── WHAT IS GROUNDED, AND WHERE THE EVIDENCE STOPS ──────────────────────────────────────────
|
|
5
|
+
// Grounded in the installed `@turbopuffer/turbopuffer@2.8.0` (src/resources/namespaces.ts,
|
|
6
|
+
// `FullTextSearchConfig`): the defaults — `k1` 1.2, `b` 0.75, case-insensitive, `language`
|
|
7
|
+
// english, `stemming` false, `ascii_folding` false, `max_token_length` 39
|
|
8
|
+
// BYTES (tokens longer are filtered out), and "by default, BM25-enabled attributes are not
|
|
9
|
+
// filterable". `remove_stopwords` defaults to false: "Defaults to false (i.e. keep common words)"
|
|
10
|
+
// (https://turbopuffer.com/docs/write, full_text_search) since "`remove_stopwords` now defaults to `false`"
|
|
11
|
+
// (https://turbopuffer.com/docs/roadmap, January 2026); the spec's description, and the client's doc comment, still
|
|
12
|
+
// say true. Grounded in Dub's provider (apps/web/lib/api/partners/search/providers/
|
|
13
|
+
// turbopuffer.ts, measured against the real service): `word_v2` splits a URL into its
|
|
14
|
+
// components (so `scottdigital` matches `https://www.scottdigital-42.techcorp.io`), and "a
|
|
15
|
+
// single-token `last_as_prefix` query scores every match at exactly 1".
|
|
16
|
+
//
|
|
17
|
+
// EXTRAPOLATED (deterministic, not the vendor's exact algorithm): `word_v2` is modelled as
|
|
18
|
+
// maximal runs of Unicode letters, digits, marks and `_`; the English stopword list is the
|
|
19
|
+
// classic Lucene/tantivy 33-word list; BM25 is the textbook Okapi form with the Lucene IDF
|
|
20
|
+
// `ln(1 + (N − n + 0.5)/(n + 0.5))` over the namespace's documents that carry the attribute, and a
|
|
21
|
+
// `last_as_prefix` final token contributes a constant 1 per matching document. Scores are
|
|
22
|
+
// therefore deterministic and ordered sensibly, but they are not byte-equal to Turbopuffer's.
|
|
23
|
+
// Tokenizers other than `word_v2` and `pre_tokenized_array`, stemming, and non-English languages
|
|
24
|
+
// are refused (400) rather than silently approximated.
|
|
25
|
+
|
|
26
|
+
import { stemEnglish } from './turbopuffer-stem.ts';
|
|
27
|
+
|
|
28
|
+
export const TOKENIZERS = ['pre_tokenized_array', 'word_v0', 'word_v1', 'word_v2', 'word_v3', 'word_v4'] as const;
|
|
29
|
+
/** The tokenizers this twin models. Every other one in `TOKENIZERS` is refused with a 400. */
|
|
30
|
+
export const MODELED_TOKENIZERS: readonly string[] = ['word_v4', 'word_v3', 'word_v2', 'pre_tokenized_array'];
|
|
31
|
+
/** The vendor's default tokenizer (namespaces.ts: "Defaults to `word_v4`"). */
|
|
32
|
+
export const DEFAULT_TOKENIZER = 'word_v4';
|
|
33
|
+
|
|
34
|
+
export type FtsConfig = {
|
|
35
|
+
tokenizer: string;
|
|
36
|
+
case_sensitive: boolean;
|
|
37
|
+
remove_stopwords: boolean;
|
|
38
|
+
stemming: boolean;
|
|
39
|
+
ascii_folding: boolean;
|
|
40
|
+
language: string;
|
|
41
|
+
max_token_length: number;
|
|
42
|
+
k1: number;
|
|
43
|
+
b: number;
|
|
44
|
+
k3?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_FTS: FtsConfig = {
|
|
48
|
+
tokenizer: DEFAULT_TOKENIZER,
|
|
49
|
+
case_sensitive: false,
|
|
50
|
+
remove_stopwords: false,
|
|
51
|
+
stemming: false,
|
|
52
|
+
ascii_folding: false,
|
|
53
|
+
language: 'english',
|
|
54
|
+
max_token_length: 39,
|
|
55
|
+
k1: 1.2,
|
|
56
|
+
b: 0.75,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ENGLISH_STOPWORDS: ReadonlySet<string> = new Set([
|
|
60
|
+
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'if', 'in', 'into', 'is', 'it', 'no', 'not',
|
|
61
|
+
'of', 'on', 'or', 'such', 'that', 'the', 'their', 'then', 'there', 'these', 'they', 'this', 'to', 'was',
|
|
62
|
+
'will', 'with',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const WORD = /[\p{L}\p{N}\p{M}_]+/gu;
|
|
66
|
+
const encoder = new TextEncoder();
|
|
67
|
+
|
|
68
|
+
function normalizeToken(token: string, cfg: FtsConfig): string {
|
|
69
|
+
let t = cfg.case_sensitive ? token : token.toLowerCase();
|
|
70
|
+
if (cfg.ascii_folding) t = t.normalize('NFD').replace(/\p{M}+/gu, '');
|
|
71
|
+
return t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Tokenize one value (a string, or a `[]string`, whose elements are tokenized and concatenated).
|
|
76
|
+
* `keepStopwords` is used for the final prefix token of a `last_as_prefix` query, which must not
|
|
77
|
+
* vanish because the user has so far typed only `an` of `anna`.
|
|
78
|
+
*/
|
|
79
|
+
export function tokenize(value: unknown, cfg: FtsConfig, opts: { keepStopwords?: boolean } = {}): string[] {
|
|
80
|
+
const parts: string[] = Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : typeof value === 'string' ? [value] : [];
|
|
81
|
+
if (cfg.tokenizer === 'word_v4' || cfg.tokenizer === 'word_v3') return parts.flatMap((part) => uax29Tokens(part, cfg, opts));
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
for (const part of parts) {
|
|
84
|
+
const raw = cfg.tokenizer === 'pre_tokenized_array' ? [part] : (part.match(WORD) ?? []);
|
|
85
|
+
for (const r of raw) {
|
|
86
|
+
const t = normalizeToken(r, cfg);
|
|
87
|
+
if (t === '' || encoder.encode(t).length > cfg.max_token_length) continue;
|
|
88
|
+
if (cfg.remove_stopwords && !opts.keepStopwords && ENGLISH_STOPWORDS.has(t)) continue;
|
|
89
|
+
out.push(cfg.stemming ? stemEnglish(t) : t);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── word_v4 and word_v3 ─────────────────────────────────────────────────────────────────────
|
|
96
|
+
// "The `word_v4` and `word_v3` tokenizers use Unicode v17.0 text segmentation rules (UAX #29) for accurate
|
|
97
|
+
// segmentation across most languages, scripts, and emojis. `word_v4` is the current default for new namespaces; it
|
|
98
|
+
// behaves like `word_v3`, but is roughly 3x faster and fixes a few tokenization edge cases. It's powered by our
|
|
99
|
+
// open-source alyze library" (https://turbopuffer.com/docs/fts, Tokenizers). Modelled on alyze at
|
|
100
|
+
// 1de437c8604b751f26e7061070b9d2b35aebdc73 (github.com/turbopuffer/alyze, src/analyze/mod.rs, src/uax29/word/mod.rs):
|
|
101
|
+
// - word boundaries by UAX #29, and only "word-like" segments become tokens: a segment "is 'word-like' if it contains
|
|
102
|
+
// any char that is ALetter, HebrewLetter, or Numeric, Ideographic or Extended_Pictographic, Other_Number general
|
|
103
|
+
// category, [or] a character whose Script is something meaningful … as opposed to Script=Common/Inherited/Unknown";
|
|
104
|
+
// - then, in alyze's order: the maximum token length (alyze admits a token within the limit in bytes or in
|
|
105
|
+
// characters, `s.len() <= max || s.chars().nth(max).is_none()`), lowercasing unless case_sensitive, stopword removal
|
|
106
|
+
// (the Lucene English list, as word_v2's), and ASCII folding, lowercased again.
|
|
107
|
+
// Where the evidence stops: the twin segments with the host's Intl.Segmenter (ICU's UAX #29, with runs of ideographs
|
|
108
|
+
// and hiragana split back into characters as UAX #29's rules leave them; ICU's dictionary segmentation of Thai, Lao,
|
|
109
|
+
// Khmer and Burmese is kept, which alyze may not do), whose Unicode version is
|
|
110
|
+
// the runtime's, not 17.0; alyze's own DFA may differ from ICU on edge cases, and the "few tokenization edge cases"
|
|
111
|
+
// word_v4 fixes over word_v3 are not documented, so the two are one tokenizer here; ASCII folding strips combining
|
|
112
|
+
// marks after NFD, where alyze's `ascii_fold` may map more characters (ß, æ).
|
|
113
|
+
const segmenter = new Intl.Segmenter('und', { granularity: 'word' });
|
|
114
|
+
const WORD_LIKE = /[\p{L}\p{Nd}\p{No}\p{Extended_Pictographic}\p{Ideographic}]|[^\p{Script=Common}\p{Script=Inherited}\p{Script=Unknown}]/u;
|
|
115
|
+
|
|
116
|
+
function uax29Tokens(text: string, cfg: FtsConfig, opts: { keepStopwords?: boolean }): string[] {
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
// ICU joins runs of ideographs and hiragana by dictionary; UAX #29's rules break between them (neither is ALetter,
|
|
119
|
+
// and no rule joins them), so such a run is split back into its characters
|
|
120
|
+
const segments = [...segmenter.segment(text)].flatMap(({ segment }) => (/^[\p{Ideographic}\p{Script=Hiragana}]+$/u.test(segment) ? [...segment] : [segment]));
|
|
121
|
+
for (const segment of segments) {
|
|
122
|
+
if (!WORD_LIKE.test(segment)) continue;
|
|
123
|
+
if (!(encoder.encode(segment).length <= cfg.max_token_length || [...segment].length <= cfg.max_token_length)) continue;
|
|
124
|
+
let t = cfg.case_sensitive ? segment : segment.toLowerCase();
|
|
125
|
+
if (cfg.remove_stopwords && !opts.keepStopwords && ENGLISH_STOPWORDS.has(t)) continue;
|
|
126
|
+
if (cfg.stemming) t = stemEnglish(t);
|
|
127
|
+
out.push(cfg.ascii_folding && /[^\x00-\x7f]/.test(t) ? foldToken(t, cfg) : t);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** alyze's ASCII folding of a non-ASCII token, lowercased again unless case_sensitive ("ASCII folding can produce
|
|
133
|
+
* uppercase ASCII characters, so we'll lowercase again if case folding is enabled", alyze src/analyze/mod.rs). */
|
|
134
|
+
function foldToken(t: string, cfg: FtsConfig): string {
|
|
135
|
+
const folded = t.normalize('NFD').replace(/\p{M}+/gu, '');
|
|
136
|
+
return cfg.case_sensitive ? folded : folded.toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A query's terms, split into the exact terms and the optional trailing prefix. */
|
|
140
|
+
export type QueryTerms = { exact: string[]; prefix: string | null };
|
|
141
|
+
|
|
142
|
+
export function queryTerms(query: unknown, cfg: FtsConfig, lastAsPrefix: boolean): QueryTerms {
|
|
143
|
+
if (!lastAsPrefix) return { exact: [...new Set(tokenize(query, cfg))], prefix: null };
|
|
144
|
+
// The prefix is the LAST token of the raw input, kept even when it is a stopword.
|
|
145
|
+
const all = tokenize(query, cfg, { keepStopwords: true });
|
|
146
|
+
if (all.length === 0) return { exact: [], prefix: null };
|
|
147
|
+
const prefix = all[all.length - 1]!;
|
|
148
|
+
const head = cfg.remove_stopwords ? all.slice(0, -1).filter((t) => !ENGLISH_STOPWORDS.has(t)) : all.slice(0, -1);
|
|
149
|
+
return { exact: [...new Set(head)], prefix };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Does a document's token list satisfy the terms? `all` for ContainsAllTokens, else any. */
|
|
153
|
+
export function tokensMatch(docTokens: readonly string[], terms: QueryTerms, mode: 'all' | 'any'): boolean {
|
|
154
|
+
const set = new Set(docTokens);
|
|
155
|
+
const checks: boolean[] = terms.exact.map((t) => set.has(t));
|
|
156
|
+
if (terms.prefix !== null) {
|
|
157
|
+
const p = terms.prefix;
|
|
158
|
+
checks.push(docTokens.some((t) => t.startsWith(p)));
|
|
159
|
+
}
|
|
160
|
+
// A query whose every token is dropped (all stopwords, all over-length) matches nothing, in
|
|
161
|
+
// either mode — the same documents BM25 would score above zero. The vendor's answer here is
|
|
162
|
+
// unverified; this keeps the twin's count and its ranked search in agreement.
|
|
163
|
+
if (checks.length === 0) return false;
|
|
164
|
+
return mode === 'all' ? checks.every(Boolean) : checks.some(Boolean);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The per-attribute corpus statistics BM25 needs, computed once per query over the namespace. */
|
|
168
|
+
export type Corpus = { n: number; avgdl: number; df: Map<string, number>; docs: Map<string, string[]> };
|
|
169
|
+
|
|
170
|
+
export function buildCorpus(entries: Iterable<[string, unknown]>, cfg: FtsConfig): Corpus {
|
|
171
|
+
const docs = new Map<string, string[]>();
|
|
172
|
+
const df = new Map<string, number>();
|
|
173
|
+
let total = 0;
|
|
174
|
+
for (const [key, value] of entries) {
|
|
175
|
+
if (value === null || value === undefined) continue;
|
|
176
|
+
const tokens = tokenize(value, cfg);
|
|
177
|
+
docs.set(key, tokens);
|
|
178
|
+
total += tokens.length;
|
|
179
|
+
for (const t of new Set(tokens)) df.set(t, (df.get(t) ?? 0) + 1);
|
|
180
|
+
}
|
|
181
|
+
return { n: docs.size, avgdl: docs.size === 0 ? 0 : total / docs.size, df, docs };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** BM25 of one document against the query terms; 0 means "no term matched". */
|
|
185
|
+
export function bm25Score(key: string, corpus: Corpus, terms: QueryTerms, cfg: FtsConfig): number {
|
|
186
|
+
const tokens = corpus.docs.get(key);
|
|
187
|
+
if (tokens === undefined || tokens.length === 0) return 0;
|
|
188
|
+
let score = 0;
|
|
189
|
+
const dl = tokens.length;
|
|
190
|
+
for (const term of terms.exact) {
|
|
191
|
+
let tf = 0;
|
|
192
|
+
for (const t of tokens) if (t === term) tf++;
|
|
193
|
+
if (tf === 0) continue;
|
|
194
|
+
const n = corpus.df.get(term) ?? 0;
|
|
195
|
+
const idf = Math.log(1 + (corpus.n - n + 0.5) / (n + 0.5));
|
|
196
|
+
const norm = corpus.avgdl === 0 ? 1 : 1 - cfg.b + cfg.b * (dl / corpus.avgdl);
|
|
197
|
+
score += idf * ((tf * (cfg.k1 + 1)) / (tf + cfg.k1 * norm));
|
|
198
|
+
}
|
|
199
|
+
if (terms.prefix !== null) {
|
|
200
|
+
const p = terms.prefix;
|
|
201
|
+
if (tokens.some((t) => t.startsWith(p))) score += 1;
|
|
202
|
+
}
|
|
203
|
+
return score;
|
|
204
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// TURBOPUFFER TWIN — THE REQUEST HANDLER. Contract:
|
|
2
|
+
// handleTurbopufferTwinRequest({ method, path, body, headers, root, occurredAt, readOnly, token })
|
|
3
|
+
// -> { status, body, headers }
|
|
4
|
+
//
|
|
5
|
+
// The wire, grounded in the installed @turbopuffer/turbopuffer@2.8.0 (src/resources/namespaces.ts,
|
|
6
|
+
// src/client.ts — see the README's "Grounded wire" table):
|
|
7
|
+
//
|
|
8
|
+
// GET /v1/namespaces list namespaces (prefix, page_size, cursor)
|
|
9
|
+
// POST /v2/namespaces/{ns} write: upsert/patch/delete rows or columns, by
|
|
10
|
+
// id or by filter, with schema + distance_metric
|
|
11
|
+
// DELETE /v2/namespaces/{ns} deleteAll
|
|
12
|
+
// POST /v2/namespaces/{ns}/query query; a body carrying `queries` is multiQuery
|
|
13
|
+
// (the SDK also tags it ?stainless_overload=multiQuery)
|
|
14
|
+
// GET /v2/namespaces/{ns}/metadata metadata
|
|
15
|
+
// GET /v1/namespaces/{ns}/schema schema
|
|
16
|
+
// POST /v1/namespaces/{ns}/schema updateSchema
|
|
17
|
+
// GET /v1/namespaces/{ns}/hint_cache_warm hintCacheWarm
|
|
18
|
+
//
|
|
19
|
+
// A leading region segment (`/aws-us-east-1/v2/...`) is accepted and ignored: it is how a world
|
|
20
|
+
// points an unmodified client here through the SDK's own `TURBOPUFFER_BASE_URL`, whose `{region}`
|
|
21
|
+
// placeholder the client fills in (client.ts:273-281) — see the pack descriptor's `endpointEnv`.
|
|
22
|
+
//
|
|
23
|
+
// Errors wear the `{ "status": "error", "error": "<message>" }` envelope. The message STRINGS are
|
|
24
|
+
// twin-authored (this build had no account to probe the real service); the statuses follow the
|
|
25
|
+
// SDK's error classes (400 BadRequest, 401 Authentication, 404 NotFound).
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { applyTwinWriteAtomic, ownFields, twinResources, type TwinWriteInput } from '@volter/world-core';
|
|
28
|
+
import {
|
|
29
|
+
applyClone,
|
|
30
|
+
applyWrite,
|
|
31
|
+
approxLogicalBytes,
|
|
32
|
+
assertNamespaceName,
|
|
33
|
+
badRequest,
|
|
34
|
+
checkConsistency,
|
|
35
|
+
documentFields,
|
|
36
|
+
documentSubjectId,
|
|
37
|
+
loadNamespaces,
|
|
38
|
+
namespaceFields,
|
|
39
|
+
queryBilling,
|
|
40
|
+
queryPerformance,
|
|
41
|
+
runQuery,
|
|
42
|
+
schemaWire,
|
|
43
|
+
SERVICE,
|
|
44
|
+
TurbopufferError,
|
|
45
|
+
TURBOPUFFER_RESOURCE_TYPES,
|
|
46
|
+
vectorEncodingRefusal,
|
|
47
|
+
type NamespaceState,
|
|
48
|
+
} from './turbopuffer-store.ts';
|
|
49
|
+
|
|
50
|
+
export type TurbopufferTwinRequest = {
|
|
51
|
+
method: string;
|
|
52
|
+
path: string;
|
|
53
|
+
body?: string;
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
root?: string;
|
|
56
|
+
occurredAt?: string;
|
|
57
|
+
readOnly?: boolean;
|
|
58
|
+
/** The API key the twin demands. Omit to accept any non-empty key (a missing one still 401s). */
|
|
59
|
+
token?: string;
|
|
60
|
+
};
|
|
61
|
+
export type TurbopufferTwinResponse = { status: number; body: unknown; headers?: Record<string, string> };
|
|
62
|
+
|
|
63
|
+
const JSON_HEADERS = { 'content-type': 'application/json' };
|
|
64
|
+
|
|
65
|
+
/** Real Turbopuffer routes this twin does not serve yet, each with the manifest gap that tracks it. */
|
|
66
|
+
const UNMODELED_ROUTES: Array<{ method: string; re: RegExp; gap: string }> = [
|
|
67
|
+
{ method: 'PATCH', re: /^\/v1\/namespaces\/[^/]+\/metadata$/, gap: 'turbopuffer.namespaces.update_metadata' },
|
|
68
|
+
{ method: 'POST', re: /^\/v2\/namespaces\/[^/]+\/explain_query$/, gap: 'turbopuffer.query.explain' },
|
|
69
|
+
{ method: 'POST', re: /^\/v1\/namespaces\/[^/]+\/_debug\/recall$/, gap: 'turbopuffer.query.recall' },
|
|
70
|
+
{ method: 'POST', re: /^\/v1\/namespaces\/[^/]+(\/query)?$/, gap: 'turbopuffer.legacy.v1_api' },
|
|
71
|
+
{ method: 'GET', re: /^\/v1\/namespaces\/[^/]+$/, gap: 'turbopuffer.legacy.v1_api' },
|
|
72
|
+
{ method: 'DELETE', re: /^\/v1\/namespaces\/[^/]+$/, gap: 'turbopuffer.legacy.v1_api' },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function fail(status: number, error: string): TurbopufferTwinResponse {
|
|
76
|
+
return { status, body: { status: 'error', error }, headers: { ...JSON_HEADERS } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ok(body: unknown): TurbopufferTwinResponse {
|
|
80
|
+
return { status: 200, body, headers: { ...JSON_HEADERS } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lowerHeaders(h: Record<string, string> | undefined): Record<string, string> {
|
|
84
|
+
const out: Record<string, string> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(h ?? {})) out[k.toLowerCase()] = v;
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `Authorization: Bearer <key>` (client.ts `authHeaders`). An empty key is no key. */
|
|
90
|
+
export function extractTurbopufferApiKey(headers: Record<string, string> | undefined): string | null {
|
|
91
|
+
const raw = lowerHeaders(headers).authorization;
|
|
92
|
+
if (raw === undefined) return null;
|
|
93
|
+
const m = /^bearer(?:\s+(.*))?$/i.exec(raw.trim());
|
|
94
|
+
if (!m) return null;
|
|
95
|
+
const key = (m[1] ?? '').trim();
|
|
96
|
+
return key === '' ? null : key;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Collapse duplicate slashes and drop a leading region segment (see the header). */
|
|
100
|
+
export function normalizeTurbopufferPath(rawPath: string): string {
|
|
101
|
+
let p = `/${rawPath.replace(/^\/+/, '')}`.replace(/\/{2,}/g, '/');
|
|
102
|
+
p = p.replace(/^\/(?:aws|gcp|azure)-[a-z0-9-]+(?=\/v\d+\/)/, '');
|
|
103
|
+
return p.length > 1 ? p.replace(/\/+$/, '') : p;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeNs(seg: string): string {
|
|
107
|
+
let name: string;
|
|
108
|
+
try { name = decodeURIComponent(seg); } catch { name = seg; }
|
|
109
|
+
assertNamespaceName(name);
|
|
110
|
+
return name;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseBody(raw: string | undefined): unknown {
|
|
114
|
+
if (raw === undefined || raw.trim() === '') return {};
|
|
115
|
+
try { return JSON.parse(raw); } catch { throw badRequest('invalid request: body is not valid JSON'); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function notFound(ns: string): TurbopufferError {
|
|
119
|
+
return new TurbopufferError(404, `namespace '${ns}' was not found`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readSpace(ns: string, root: string | undefined): NamespaceState {
|
|
123
|
+
const space = loadNamespaces(twinResources(SERVICE, root)).get(ns);
|
|
124
|
+
if (space === undefined) throw notFound(ns);
|
|
125
|
+
return space;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function handleTurbopufferTwinRequest(req: TurbopufferTwinRequest): Promise<TurbopufferTwinResponse> {
|
|
129
|
+
const method = req.method.toUpperCase();
|
|
130
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
131
|
+
const query = new URLSearchParams(rawQuery ?? '');
|
|
132
|
+
const path = normalizeTurbopufferPath(rawPath ?? '/');
|
|
133
|
+
const at = req.occurredAt ?? new Date().toISOString();
|
|
134
|
+
|
|
135
|
+
const key = extractTurbopufferApiKey(req.headers);
|
|
136
|
+
if (key === null) return fail(401, 'missing API key: send Authorization: Bearer <TURBOPUFFER_API_KEY>');
|
|
137
|
+
if (req.token !== undefined && key !== req.token) return fail(401, 'invalid API key');
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
if (path === '/v1/namespaces') {
|
|
141
|
+
if (method !== 'GET') return fail(405, `method ${method} not allowed on ${path}`);
|
|
142
|
+
return ok(listNamespaces(req.root, query));
|
|
143
|
+
}
|
|
144
|
+
let m = /^\/v2\/namespaces\/([^/]+)$/.exec(path);
|
|
145
|
+
if (m) {
|
|
146
|
+
const ns = decodeNs(m[1]!);
|
|
147
|
+
if (method === 'POST') {
|
|
148
|
+
const body = parseBody(req.body);
|
|
149
|
+
return asyncRequested(req, body) ? await asyncCopy(req, ns, body, at) : await write(req, ns, body, at);
|
|
150
|
+
}
|
|
151
|
+
return method === 'DELETE' ? await deleteAll(req, ns, at) : unrouted(method, path);
|
|
152
|
+
}
|
|
153
|
+
m = /^\/v2\/namespaces\/([^/]+)\/query$/.exec(path);
|
|
154
|
+
if (m) {
|
|
155
|
+
return method === 'POST' ? ok(queryNamespace(decodeNs(m[1]!), parseBody(req.body), req.root)) : unrouted(method, path);
|
|
156
|
+
}
|
|
157
|
+
m = /^\/v1\/namespaces\/([^/]+)\/operations\/([^/]+)$/.exec(path);
|
|
158
|
+
if (m && method === 'GET') return pollOperation(req, decodeNs(m[1]!), decodeURIComponent(m[2]!), at);
|
|
159
|
+
// GET /v1/namespaces/:namespace/metadata is the docs' path (https://turbopuffer.com/docs/metadata); the client sends /v2
|
|
160
|
+
m = /^\/v[12]\/namespaces\/([^/]+)\/metadata$/.exec(path);
|
|
161
|
+
if (m && method === 'GET') return ok(metadata(readSpace(decodeNs(m[1]!), req.root)));
|
|
162
|
+
m = /^\/v1\/namespaces\/([^/]+)\/schema$/.exec(path);
|
|
163
|
+
if (m) return await schemaRoute(req, decodeNs(m[1]!), method, path, at);
|
|
164
|
+
m = /^\/v1\/namespaces\/([^/]+)\/hint_cache_warm$/.exec(path);
|
|
165
|
+
return m && method === 'GET' ? hintCacheWarm(decodeNs(m[1]!), req.root) : unrouted(method, path);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return e instanceof TurbopufferError ? fail(e.status, e.message) : internalFault(method, path, e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A request this handler has no route for: a method a path does not take (405), a route of turbopuffer's the twin does
|
|
172
|
+
* not model (404 naming its gap), or none (404). The derived dispatch hands this handler only the operations it serves,
|
|
173
|
+
* so only the protocol 2 harness (turbopuffer-conformance.ts's router census) asks these. */
|
|
174
|
+
/** The twin has no cache to warm: every namespace is always "hot". Accepting the hint is its answer, 202 as the spec's
|
|
175
|
+
* operation answers it. */
|
|
176
|
+
function hintCacheWarm(ns: string, root: string | undefined): TurbopufferTwinResponse {
|
|
177
|
+
readSpace(ns, root);
|
|
178
|
+
return { status: 202, body: { status: 'ACCEPTED', message: 'cache warm hint accepted' }, headers: { ...JSON_HEADERS } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unrouted(method: string, path: string): TurbopufferTwinResponse {
|
|
182
|
+
const r = UNMODELED_ROUTES.find((x) => x.method === method && x.re.test(path));
|
|
183
|
+
if (r) return fail(404, `turbopuffer twin: ${method} ${path} is real Turbopuffer surface this twin does not model yet (${r.gap} is the filed gap)`);
|
|
184
|
+
return /^(\/v2\/namespaces\/[^/]+(\/query)?|\/v1\/namespaces\/[^/]+\/schema)$/.test(path) ? fail(405, `method ${method} not allowed on ${path}`) : fail(404, `not found: ${method} ${path}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The engine's own bug guard: an error that is not a refusal of turbopuffer's. */
|
|
188
|
+
function internalFault(method: string, path: string, e: unknown): TurbopufferTwinResponse {
|
|
189
|
+
return fail(500, `turbopuffer twin: internal fault handling ${method} ${path} — ${e instanceof Error ? e.message : String(e)}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** GET and POST /v1/namespaces/{namespace}/schema, the spec's "Get namespace schema" and "Update namespace schema",
|
|
193
|
+
* which the client sends (`schema()`, `updateSchema()`); turbopuffer's pages now show a namespace's schema in its
|
|
194
|
+
* metadata and change it with a write carrying only `{"schema": …}`. */
|
|
195
|
+
async function schemaRoute(req: TurbopufferTwinRequest, ns: string, method: string, path: string, at: string): Promise<TurbopufferTwinResponse> {
|
|
196
|
+
if (method === 'GET') return ok(schemaWire(readSpace(ns, req.root)));
|
|
197
|
+
return method === 'POST' ? await updateSchema(req, ns, parseBody(req.body), at) : unrouted(method, path);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── routes ──────────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
function listNamespaces(root: string | undefined, query: URLSearchParams): Record<string, unknown> {
|
|
203
|
+
const prefix = query.get('prefix') ?? '';
|
|
204
|
+
const sizeRaw = query.get('page_size');
|
|
205
|
+
// "page_size … default: 100 … (max of 1000)" (https://turbopuffer.com/docs/namespaces)
|
|
206
|
+
const pageSize = sizeRaw === null ? 100 : Number(sizeRaw);
|
|
207
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000) throw badRequest('invalid page_size: must be an integer between 1 and 1000');
|
|
208
|
+
const cursor = query.get('cursor') ?? '';
|
|
209
|
+
const names = [...loadNamespaces(twinResources(SERVICE, root)).keys()]
|
|
210
|
+
.filter((n) => n.startsWith(prefix) && (cursor === '' || n > cursor))
|
|
211
|
+
.sort();
|
|
212
|
+
const page = names.slice(0, pageSize);
|
|
213
|
+
return {
|
|
214
|
+
namespaces: page.map((id) => ({ id })),
|
|
215
|
+
...(names.length > pageSize ? { next_cursor: page[page.length - 1] } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type Decided = { ok: true; response: unknown } | { ok: false; error: TurbopufferError };
|
|
220
|
+
|
|
221
|
+
async function commit(
|
|
222
|
+
req: TurbopufferTwinRequest,
|
|
223
|
+
decide: (spaces: Map<string, NamespaceState>) => { response: unknown; write: TwinWriteInput },
|
|
224
|
+
): Promise<TurbopufferTwinResponse> {
|
|
225
|
+
if (req.readOnly) return fail(405, 'read_only: this twin was started read-only; writes are refused');
|
|
226
|
+
const { value } = await applyTwinWriteAtomic<Decided>(SERVICE, (resources) => {
|
|
227
|
+
try { const { response, write } = decide(loadNamespaces(resources)); return { kind: 'write', value: { ok: true, response }, write }; } catch (e) { return refusedDecision(e); }
|
|
228
|
+
}, req.root);
|
|
229
|
+
return value.ok ? ok(value.response) : fail(value.error.status, value.error.message);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A write the store refused (a TurbopufferError) is skipped and answered; any other error is the engine's. */
|
|
233
|
+
function refusedDecision(e: unknown): { kind: 'skip'; value: Decided } {
|
|
234
|
+
if (e instanceof TurbopufferError) return { kind: 'skip', value: { ok: false, error: e } };
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function write(req: TurbopufferTwinRequest, ns: string, body: unknown, at: string): Promise<TurbopufferTwinResponse> {
|
|
239
|
+
return commit(req, (spaces) => {
|
|
240
|
+
const b = body !== null && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
|
241
|
+
const out = b.copy_from_namespace !== undefined || b.branch_from_namespace !== undefined ? applyClone(spaces, ns, body, at) : applyWrite(spaces.get(ns), ns, body, at);
|
|
242
|
+
return {
|
|
243
|
+
response: out.response,
|
|
244
|
+
write: {
|
|
245
|
+
operation: 'namespace.write',
|
|
246
|
+
subjectType: 'namespace',
|
|
247
|
+
subjectId: ns,
|
|
248
|
+
fields: namespaceFields(out.next),
|
|
249
|
+
input: body as Record<string, unknown>,
|
|
250
|
+
projection: {
|
|
251
|
+
updates: out.upserts.map((d) => ({ type: 'document', id: documentSubjectId(ns, d.id), fields: documentFields(ns, d) })),
|
|
252
|
+
deletes: out.deletes.map((k) => ({ type: 'document', id: `${ns}/${k}` })),
|
|
253
|
+
},
|
|
254
|
+
occurredAt: at,
|
|
255
|
+
actor: { kind: 'agent' },
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── asynchronous requests ───────────────────────────────────────────────────────────────────
|
|
262
|
+
// https://turbopuffer.com/docs/api-overview, Asynchronous requests: "Currently supported operations: copy_from_namespace,
|
|
263
|
+
// recall evaluation. Send the `Prefer: respond-async` header to allow the server to start the operation in the
|
|
264
|
+
// background. The server returns `202 Accepted` with a `Location` header pointing to the operation. Poll that location
|
|
265
|
+
// to check on progress: The response is `{"status": "running"}` until the operation finishes, then carries the result.
|
|
266
|
+
// The result is retained for one hour after the operation finishes, polling after will return `404`." Its example
|
|
267
|
+
// answers `Preference-Applied: respond-async`, `Location: /v1/namespaces/<ns>/operations/tpuf-abc123` and
|
|
268
|
+
// `{"token": "tpuf-abc123"}`; a finished poll `{"status": "finished", "result": {"success": <the write's answer>}}` or
|
|
269
|
+
// `{"status": "finished", "result": {"error": {"status_code": 400, "detail": {"status": "error", "error": …}}}}`.
|
|
270
|
+
// Where the docs stop and the twin decides: the twin copies at once, so an operation is finished when it is started and
|
|
271
|
+
// no poll answers "running"; recall evaluation is not served, so a copy is the only asynchronous operation; the token is
|
|
272
|
+
// `tpuf-` and 12 hex digits derived from the namespace, the moment and the count of operations.
|
|
273
|
+
const RETAINED_MS = 60 * 60 * 1000;
|
|
274
|
+
|
|
275
|
+
function asyncRequested(req: TurbopufferTwinRequest, body: unknown): boolean {
|
|
276
|
+
const prefer = lowerHeaders(req.headers).prefer ?? '';
|
|
277
|
+
const wants = prefer.split(',').some((p) => p.trim().toLowerCase() === 'respond-async');
|
|
278
|
+
return wants && body !== null && typeof body === 'object' && !Array.isArray(body) && (body as Record<string, unknown>).copy_from_namespace !== undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function asyncCopy(req: TurbopufferTwinRequest, ns: string, body: unknown, at: string): Promise<TurbopufferTwinResponse> {
|
|
282
|
+
if (req.readOnly) return fail(405, 'read_only: this twin was started read-only; writes are refused');
|
|
283
|
+
const { value: token } = await applyTwinWriteAtomic<string>(SERVICE, (resources) => {
|
|
284
|
+
const n = resources.filter((r) => r.type === '_operation').length + 1;
|
|
285
|
+
const token = `tpuf-${createHash('sha256').update(`${ns}:${at}:${n}`).digest('hex').slice(0, 12)}`;
|
|
286
|
+
const record = (result: Record<string, unknown>) => ({ type: '_operation', id: `${ns}/${token}`, fields: { namespace: ns, token, finished_at: at, result } });
|
|
287
|
+
try {
|
|
288
|
+
const out = applyClone(loadNamespaces(resources), ns, body, at);
|
|
289
|
+
return { kind: 'write', value: token, write: {
|
|
290
|
+
operation: 'namespace.write', subjectType: 'namespace', subjectId: ns, fields: namespaceFields(out.next), input: body as Record<string, unknown>,
|
|
291
|
+
projection: { updates: [...out.upserts.map((d) => ({ type: 'document', id: documentSubjectId(ns, d.id), fields: documentFields(ns, d) })), record({ success: out.response })] },
|
|
292
|
+
occurredAt: at, actor: { kind: 'agent' },
|
|
293
|
+
} };
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (!(e instanceof TurbopufferError)) throw e;
|
|
296
|
+
const op = record({ error: { status_code: e.status, detail: { status: 'error', error: e.message } } });
|
|
297
|
+
return { kind: 'write', value: token, write: { operation: 'operation.record', subjectType: op.type, subjectId: op.id, fields: op.fields, occurredAt: at, actor: { kind: 'system' } } };
|
|
298
|
+
}
|
|
299
|
+
}, req.root);
|
|
300
|
+
return { status: 202, body: { token }, headers: { ...JSON_HEADERS, 'preference-applied': 'respond-async', location: `/v1/namespaces/${encodeURIComponent(ns)}/operations/${token}` } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function pollOperation(req: TurbopufferTwinRequest, ns: string, token: string, at: string): TurbopufferTwinResponse {
|
|
304
|
+
const op = twinResources(SERVICE, req.root).find((r) => r.type === '_operation' && r.id === `${ns}/${token}`);
|
|
305
|
+
const f = op ? ownFields(op) : undefined;
|
|
306
|
+
if (!f || Date.parse(at) - Date.parse(String(f.finished_at)) > RETAINED_MS) return fail(404, `operation '${token}' was not found`);
|
|
307
|
+
return ok({ status: 'finished', result: f.result });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function deleteAll(req: TurbopufferTwinRequest, ns: string, at: string): Promise<TurbopufferTwinResponse> {
|
|
311
|
+
return commit(req, (spaces) => {
|
|
312
|
+
const space = spaces.get(ns);
|
|
313
|
+
if (space === undefined) throw notFound(ns);
|
|
314
|
+
return {
|
|
315
|
+
response: { status: 'OK' },
|
|
316
|
+
write: {
|
|
317
|
+
operation: 'namespace.delete_all',
|
|
318
|
+
subjectType: 'namespace',
|
|
319
|
+
subjectId: ns,
|
|
320
|
+
fields: { ...namespaceFields(space), schema: {}, distance_metric: null, vector_dims: null, updated_at: at, gone: true },
|
|
321
|
+
projection: { deletes: [...space.docs.keys()].map((k) => ({ type: 'document', id: `${ns}/${k}` })) },
|
|
322
|
+
occurredAt: at,
|
|
323
|
+
actor: { kind: 'agent' },
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function updateSchema(req: TurbopufferTwinRequest, ns: string, body: unknown, at: string): Promise<TurbopufferTwinResponse> {
|
|
330
|
+
return commit(req, (spaces) => {
|
|
331
|
+
const space = spaces.get(ns);
|
|
332
|
+
if (space === undefined) throw notFound(ns);
|
|
333
|
+
const out = applyWrite(space, ns, { schema: body }, at);
|
|
334
|
+
return {
|
|
335
|
+
response: schemaWire(out.next),
|
|
336
|
+
write: {
|
|
337
|
+
operation: 'namespace.update_schema',
|
|
338
|
+
subjectType: 'namespace',
|
|
339
|
+
subjectId: ns,
|
|
340
|
+
fields: namespaceFields(out.next),
|
|
341
|
+
input: { schema: body },
|
|
342
|
+
occurredAt: at,
|
|
343
|
+
actor: { kind: 'agent' },
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function queryNamespace(ns: string, body: unknown, root: string | undefined): Record<string, unknown> {
|
|
350
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw badRequest('invalid query: expected a JSON object');
|
|
351
|
+
const space = readSpace(ns, root);
|
|
352
|
+
const b = body as Record<string, unknown>;
|
|
353
|
+
if (b.queries !== undefined) {
|
|
354
|
+
for (const k of Object.keys(b)) {
|
|
355
|
+
if (k !== 'queries' && k !== 'rerank_by' && k !== 'consistency' && k !== 'vector_encoding') throw badRequest(`invalid multi-query: unknown field "${k}"`);
|
|
356
|
+
}
|
|
357
|
+
if (!Array.isArray(b.queries) || b.queries.length === 0) throw badRequest('invalid multi-query: queries must be a non-empty array');
|
|
358
|
+
checkConsistency(b.consistency);
|
|
359
|
+
if (b.vector_encoding !== undefined && b.vector_encoding !== 'float') throw vectorEncodingRefusal(b.vector_encoding);
|
|
360
|
+
const results = b.queries.map((q) => runQuery(space, q, { multi: true }));
|
|
361
|
+
const answered = b.rerank_by === undefined ? results : [rerankRrf(b.rerank_by, b.queries, results)];
|
|
362
|
+
return { results: answered, billing: queryBilling(space, answered), performance: queryPerformance(space) };
|
|
363
|
+
}
|
|
364
|
+
const result = runQuery(space, b);
|
|
365
|
+
return { ...result, billing: queryBilling(space, result), performance: queryPerformance(space) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** `rerank_by: ["RRF", {rank_constant?, weights?}]` (https://turbopuffer.com/docs/query, Reciprocal rank fusion): "The
|
|
369
|
+
* results contain a single list combining all the subquery results, sorted by descending RRF score. The RRF score for
|
|
370
|
+
* each row is reported in the `$dist` field. It is calculated as the sum of `weight / (rank_constant + rank)` across all
|
|
371
|
+
* subquery results", rank 1-based, `rank_constant` "defaults to 60 and can be set to any integer greater than zero",
|
|
372
|
+
* each weight "defaults to 1 and can be set to any number greater than zero. Exactly one weight must be provided for
|
|
373
|
+
* each subquery", and "RRF reranking requires at least two subqueries and is not supported for aggregations".
|
|
374
|
+
* Where the docs stop and the twin decides: a row carries the attributes its first subquery returned; rows of equal
|
|
375
|
+
* score order by id; every fused row is answered (the docs' "up to limit.total" names no limit on a multi-query). */
|
|
376
|
+
function rerankRrf(spec: unknown, queries: unknown[], results: Array<Record<string, unknown>>): Record<string, unknown> {
|
|
377
|
+
if (!Array.isArray(spec) || spec[0] !== 'RRF' || spec.length > 2) throw badRequest('invalid rerank_by: the supported reranking function is ["RRF"] or ["RRF", {rank_constant, weights}]');
|
|
378
|
+
const cfg = (spec[1] ?? {}) as Record<string, unknown>;
|
|
379
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg) || Object.keys(cfg).some((k) => k !== 'rank_constant' && k !== 'weights')) throw badRequest('invalid rerank_by: RRF takes { rank_constant, weights }');
|
|
380
|
+
const k = cfg.rank_constant ?? 60;
|
|
381
|
+
if (typeof k !== 'number' || !Number.isInteger(k) || k <= 0) throw badRequest('invalid rerank_by: rank_constant must be an integer greater than zero');
|
|
382
|
+
const weights = cfg.weights ?? queries.map(() => 1);
|
|
383
|
+
if (!Array.isArray(weights) || weights.length !== queries.length || !weights.every((w) => typeof w === 'number' && w > 0)) throw badRequest('invalid rerank_by: exactly one weight greater than zero must be provided for each subquery');
|
|
384
|
+
if (queries.length < 2) throw badRequest('invalid rerank_by: RRF reranking requires at least two subqueries');
|
|
385
|
+
if (results.some((r) => r.rows === undefined)) throw badRequest('invalid rerank_by: RRF reranking is not supported for aggregations');
|
|
386
|
+
const fused = new Map<string, { row: Record<string, unknown>; score: number }>();
|
|
387
|
+
results.forEach((r, i) => (r.rows as Array<Record<string, unknown>>).forEach((row, at) => {
|
|
388
|
+
const id = JSON.stringify(row.id);
|
|
389
|
+
const hit = fused.get(id) ?? { row, score: 0 };
|
|
390
|
+
hit.score += (weights[i] as number) / (k + at + 1);
|
|
391
|
+
fused.set(id, hit);
|
|
392
|
+
}));
|
|
393
|
+
const rows = [...fused.values()].sort((a, b) => b.score - a.score || JSON.stringify(a.row.id).localeCompare(JSON.stringify(b.row.id)))
|
|
394
|
+
.map(({ row, score }) => { const { $dist: _d, ...rest } = row; return { ...rest, $dist: score }; });
|
|
395
|
+
return { rows };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function metadata(space: NamespaceState): Record<string, unknown> {
|
|
399
|
+
return {
|
|
400
|
+
approx_logical_bytes: approxLogicalBytes(space),
|
|
401
|
+
approx_row_count: space.docs.size,
|
|
402
|
+
created_at: space.created_at,
|
|
403
|
+
last_write_at: space.last_write_at,
|
|
404
|
+
updated_at: space.updated_at,
|
|
405
|
+
encryption: { mode: 'default' },
|
|
406
|
+
index: { status: 'up-to-date' },
|
|
407
|
+
schema: schemaWire(space),
|
|
408
|
+
// "branching … Only present for branched namespaces. … `parent` (string): The namespace this was branched from"
|
|
409
|
+
...(space.parent !== undefined ? { branching: { parent: space.parent } } : {}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── the conformance snapshot ────────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export function turbopufferTwinSnapshot(): { resourceTypes: readonly string[]; implementedEndpoints: readonly string[] } {
|
|
416
|
+
return {
|
|
417
|
+
resourceTypes: TURBOPUFFER_RESOURCE_TYPES,
|
|
418
|
+
implementedEndpoints: [
|
|
419
|
+
'GET /v1/namespaces',
|
|
420
|
+
'POST /v2/namespaces/{ns}',
|
|
421
|
+
'DELETE /v2/namespaces/{ns}',
|
|
422
|
+
'POST /v2/namespaces/{ns}/query',
|
|
423
|
+
'GET /v2/namespaces/{ns}/metadata',
|
|
424
|
+
'GET /v1/namespaces/{ns}/schema',
|
|
425
|
+
'POST /v1/namespaces/{ns}/schema',
|
|
426
|
+
'GET /v1/namespaces/{ns}/hint_cache_warm',
|
|
427
|
+
],
|
|
428
|
+
};
|
|
429
|
+
}
|