@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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +145 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +27 -0
  5. package/dist/src/generated/surface.gen.json +1 -0
  6. package/dist/src/generated/ui.gen.json +1 -0
  7. package/dist/src/index.d.ts +11 -0
  8. package/dist/src/index.js +65 -0
  9. package/dist/src/key-gate.d.ts +3 -0
  10. package/dist/src/key-gate.js +39 -0
  11. package/dist/src/manifest.d.ts +2 -0
  12. package/dist/src/manifest.js +28 -0
  13. package/dist/src/screens/dashboard.d.ts +11 -0
  14. package/dist/src/screens/dashboard.js +191 -0
  15. package/dist/src/semantics/namespaces.d.ts +5 -0
  16. package/dist/src/semantics/namespaces.js +17 -0
  17. package/dist/src/turbopuffer-capabilities.d.ts +6 -0
  18. package/dist/src/turbopuffer-capabilities.js +442 -0
  19. package/dist/src/turbopuffer-conformance.d.ts +8 -0
  20. package/dist/src/turbopuffer-conformance.js +102 -0
  21. package/dist/src/turbopuffer-connector.d.ts +34 -0
  22. package/dist/src/turbopuffer-connector.js +152 -0
  23. package/dist/src/turbopuffer-filter.d.ts +57 -0
  24. package/dist/src/turbopuffer-filter.js +286 -0
  25. package/dist/src/turbopuffer-server.d.ts +23 -0
  26. package/dist/src/turbopuffer-server.js +72 -0
  27. package/dist/src/turbopuffer-stem.d.ts +1 -0
  28. package/dist/src/turbopuffer-stem.js +133 -0
  29. package/dist/src/turbopuffer-store.d.ts +80 -0
  30. package/dist/src/turbopuffer-store.js +1304 -0
  31. package/dist/src/turbopuffer-text.d.ts +44 -0
  32. package/dist/src/turbopuffer-text.js +189 -0
  33. package/dist/src/turbopuffer-twin.d.ts +25 -0
  34. package/dist/src/turbopuffer-twin.js +406 -0
  35. package/package.json +56 -0
  36. package/src/cli.ts +28 -0
  37. package/src/generated/surface.gen.json +1 -0
  38. package/src/generated/ui.gen.json +1 -0
  39. package/src/index.ts +92 -0
  40. package/src/key-gate.ts +39 -0
  41. package/src/manifest.ts +63 -0
  42. package/src/screens/dashboard.tsx +214 -0
  43. package/src/semantics/namespaces.ts +30 -0
  44. package/src/turbopuffer-capabilities.ts +455 -0
  45. package/src/turbopuffer-conformance.ts +101 -0
  46. package/src/turbopuffer-connector.ts +153 -0
  47. package/src/turbopuffer-filter.ts +277 -0
  48. package/src/turbopuffer-server.ts +81 -0
  49. package/src/turbopuffer-stem.ts +104 -0
  50. package/src/turbopuffer-store.ts +1157 -0
  51. package/src/turbopuffer-text.ts +204 -0
  52. package/src/turbopuffer-twin.ts +429 -0
@@ -0,0 +1,1304 @@
1
+ // THE NAMESPACE CORE — Turbopuffer's namespaces and documents as kernel state, and the pure
2
+ // functions that apply a write and answer a query against them.
3
+ //
4
+ // STATE LIVES IN THE KERNEL TREE. A namespace is one `namespace` subject (its schema, distance
5
+ // metric, vector dimensions and timestamps); every document is one `document` subject keyed
6
+ // `<namespace>/<n|s>:<id>` (the id's JSON type is part of its identity: 7 and "7" are two
7
+ // documents). A write request is ONE kernel action — the namespace subject's fields plus a
8
+ // projection that updates/deletes the documents it touched — decided under the kernel's action lock
9
+ // (`applyTwinWriteAtomic`), because Turbopuffer applies a write request atomically. The action's
10
+ // `input` is the request body as the caller sent it, which is exactly what the perform adapter
11
+ // replays against the real vendor.
12
+ //
13
+ // Everything below this header is PURE: it reads a snapshot and returns the next one. The handler
14
+ // (turbopuffer-twin.ts) owns the wire and the kernel calls.
15
+ import { createHash } from 'node:crypto';
16
+ import { ownFields } from '@volter/world-core';
17
+ import { assertModelledFts, attributeValue, badRequest, compareScalars, compileFilter, docTokens, TurbopufferError } from "./turbopuffer-filter.js";
18
+ import { bm25Score, buildCorpus, DEFAULT_FTS, MODELED_TOKENIZERS, queryTerms, TOKENIZERS } from "./turbopuffer-text.js";
19
+ export { badRequest, TurbopufferError } from "./turbopuffer-filter.js";
20
+ export const SERVICE = 'turbopuffer';
21
+ export const TURBOPUFFER_RESOURCE_TYPES = ['namespace', 'document'];
22
+ /** Namespace names: `[A-Za-z0-9-_.]{1,128}` (turbopuffer.com/docs/write, "namespace"). */
23
+ const NAMESPACE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
24
+ export const DISTANCE_METRICS = ['cosine_distance', 'euclidean_squared'];
25
+ /** The attribute types the SDK's `AttributeSchemaConfig.type` doc enumerates. */
26
+ const SCALAR_TYPES = ['string', 'int', 'uint', 'float', 'uuid', 'datetime', 'bool'];
27
+ export function assertNamespaceName(name) {
28
+ if (!NAMESPACE_NAME.test(name))
29
+ throw badRequest(`invalid namespace name "${name}": must be 1-128 characters of [A-Za-z0-9-_.]`);
30
+ }
31
+ const utf8 = new TextEncoder();
32
+ function utf8Length(s) {
33
+ return utf8.encode(s).length;
34
+ }
35
+ export function docKey(id) {
36
+ return typeof id === 'number' ? `n:${id}` : `s:${id}`;
37
+ }
38
+ export function documentSubjectId(ns, id) {
39
+ return `${ns}/${docKey(id)}`;
40
+ }
41
+ // ── loading the snapshot from the tree ──────────────────────────────────────────────────────
42
+ /** Fold the tree's `namespace` / `document` subjects into live namespaces. */
43
+ export function loadNamespaces(resources) {
44
+ const out = new Map();
45
+ for (const r of resources) {
46
+ if (r.type !== 'namespace')
47
+ continue;
48
+ const f = ownFields(r);
49
+ // `deleted` is the kernel's mark for a subject a complete refresh no longer saw; `gone` is deleteAll's.
50
+ if (f.gone === true || f.deleted === true || typeof f.name !== 'string')
51
+ continue;
52
+ out.set(f.name, {
53
+ name: f.name,
54
+ schema: (f.schema !== null && typeof f.schema === 'object' ? f.schema : {}),
55
+ distance_metric: typeof f.distance_metric === 'string' ? f.distance_metric : null,
56
+ vector_dims: typeof f.vector_dims === 'number' ? f.vector_dims : null,
57
+ created_at: typeof f.created_at === 'string' ? f.created_at : r.updatedAt,
58
+ updated_at: typeof f.updated_at === 'string' ? f.updated_at : r.updatedAt,
59
+ last_write_at: typeof f.last_write_at === 'string' ? f.last_write_at : typeof f.updated_at === 'string' ? f.updated_at : r.updatedAt,
60
+ ...(typeof f.parent === 'string' ? { parent: f.parent } : {}),
61
+ docs: new Map(),
62
+ });
63
+ }
64
+ for (const r of resources) {
65
+ if (r.type !== 'document')
66
+ continue;
67
+ const f = ownFields(r);
68
+ if (f.deleted === true || typeof f.namespace !== 'string')
69
+ continue;
70
+ const ns = out.get(f.namespace);
71
+ if (ns === undefined)
72
+ continue;
73
+ const id = f.doc_id;
74
+ if (typeof id !== 'string' && typeof id !== 'number')
75
+ continue;
76
+ ns.docs.set(docKey(id), {
77
+ key: docKey(id),
78
+ id,
79
+ attributes: (f.attributes !== null && typeof f.attributes === 'object' ? f.attributes : {}),
80
+ vector: Array.isArray(f.vector) ? f.vector : null,
81
+ });
82
+ }
83
+ return out;
84
+ }
85
+ export function namespaceFields(ns) {
86
+ return {
87
+ name: ns.name,
88
+ schema: ns.schema,
89
+ distance_metric: ns.distance_metric,
90
+ vector_dims: ns.vector_dims,
91
+ created_at: ns.created_at,
92
+ updated_at: ns.updated_at,
93
+ last_write_at: ns.last_write_at,
94
+ parent: ns.parent ?? null,
95
+ gone: false,
96
+ };
97
+ }
98
+ export function documentFields(ns, d) {
99
+ return { namespace: ns, doc_id: d.id, attributes: d.attributes, vector: d.vector };
100
+ }
101
+ // ── schema ──────────────────────────────────────────────────────────────────────────────────
102
+ /** "Vectors are attributes with a vector type (`[N]f32`, `[N]f16`, or `[N]i8` where N is the number of dimensions)"
103
+ * (https://turbopuffer.com/docs/write, Vectors). */
104
+ export function isVectorType(t) {
105
+ return /^\[\d+\](f16|f32|i8)$/.test(t);
106
+ }
107
+ const dimsOf = (t) => Number(/^\[(\d+)\]/.exec(t)?.[1] ?? NaN);
108
+ function isKnownType(t) {
109
+ if (SCALAR_TYPES.includes(t))
110
+ return true;
111
+ if (t.startsWith('[]') && SCALAR_TYPES.includes(t.slice(2)))
112
+ return true;
113
+ // "`{}f16`: Sparse vector with string keys and 16-bit floats as weights"; "`bytes` holds arbitrary binary data …
114
+ // passed and returned as base64-encoded strings" (https://turbopuffer.com/docs/write, type)
115
+ return isVectorType(t) || isVectorArrayType(t) || t === '{}f16' || t === 'bytes';
116
+ }
117
+ /** "`[][N]f32`: Variable-length array of `N` dimensional f32 vectors, used for late-interaction (multi-vector) search"
118
+ * (https://turbopuffer.com/docs/write, type). */
119
+ export function isVectorArrayType(t) {
120
+ return /^\[\]\[\d+\]f32$/.test(t);
121
+ }
122
+ function normalizeFts(raw, attr, type) {
123
+ if (raw === undefined || raw === false || raw === null)
124
+ return false;
125
+ if (type !== 'string' && type !== '[]string')
126
+ throw badRequest(`invalid schema for attribute "${attr}": full_text_search requires the string or []string type`);
127
+ const given = raw === true ? {} : raw;
128
+ if (typeof given !== 'object' || Array.isArray(given))
129
+ throw badRequest(`invalid schema for attribute "${attr}": full_text_search must be a boolean or an object`);
130
+ const cfg = { ...DEFAULT_FTS };
131
+ for (const [k, v] of Object.entries(given)) {
132
+ const set = FTS_OPTIONS[k];
133
+ if (!set)
134
+ throw badRequest(`invalid schema for attribute "${attr}": unknown full_text_search option "${k}"`);
135
+ set(cfg, v, attr, k);
136
+ }
137
+ // Refused rather than approximated: an unmodelled tokenizer or a stemmer would silently rank
138
+ // differently from the vendor (see turbopuffer-text.ts's header).
139
+ if (!MODELED_TOKENIZERS.includes(cfg.tokenizer))
140
+ throw badRequest(`turbopuffer twin: tokenizer "${cfg.tokenizer}" on attribute "${attr}" is real Turbopuffer surface this twin does not model yet — word_v4, word_v3, word_v2 and pre_tokenized_array are modelled (turbopuffer.fts.tokenizers is the filed gap)`);
141
+ // stemming is modelled for English (./turbopuffer-stem.ts), the one language the twin models
142
+ if (cfg.language !== 'english')
143
+ throw badRequest(`turbopuffer twin: language "${cfg.language}" on attribute "${attr}" is not modelled (turbopuffer.fts.languages is the filed gap)`);
144
+ return cfg;
145
+ }
146
+ function tokenizerOption(cfg, v, attr) {
147
+ if (typeof v !== 'string' || !TOKENIZERS.includes(v))
148
+ throw badRequest(`invalid schema for attribute "${attr}": unknown tokenizer ${JSON.stringify(v)}`);
149
+ cfg.tokenizer = v;
150
+ }
151
+ function flagOption(cfg, v, attr, k) {
152
+ if (typeof v !== 'boolean')
153
+ throw badRequest(`invalid schema for attribute "${attr}": full_text_search.${k} must be a boolean`);
154
+ cfg[k] = v;
155
+ }
156
+ function languageOption(cfg, v, attr) {
157
+ if (typeof v !== 'string')
158
+ throw badRequest(`invalid schema for attribute "${attr}": full_text_search.language must be a string`);
159
+ cfg.language = v;
160
+ }
161
+ /** full_text_search's options (https://turbopuffer.com/docs/write, full_text_search). k3: "Query term frequency
162
+ * saturation parameter for BM25 scoring. Must be greater than zero. Defaults to 8.0"; the twin's queries count each term
163
+ * once, so it is kept, not applied. */
164
+ const FTS_OPTIONS = {
165
+ tokenizer: tokenizerOption, case_sensitive: flagOption, remove_stopwords: flagOption, stemming: flagOption, ascii_folding: flagOption,
166
+ language: languageOption, max_token_length: (cfg, v, attr) => maxTokenLength(cfg, v, attr),
167
+ k1: (cfg, v, attr) => bm25Parameter(cfg, 'k1', v, attr), b: (cfg, v, attr) => bm25Parameter(cfg, 'b', v, attr), k3: (cfg, v, attr) => bm25Parameter(cfg, 'k3', v, attr),
168
+ };
169
+ /** `max_token_length`: "Maximum length of a token in bytes. … Has to be between `1` and `254` (inclusive). Defaults to
170
+ * `39`" (https://turbopuffer.com/docs/write, full_text_search). */
171
+ function maxTokenLength(cfg, v, attr) {
172
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > 254)
173
+ throw badRequest(`invalid schema for attribute "${attr}": max_token_length has to be between 1 and 254`);
174
+ cfg.max_token_length = v;
175
+ }
176
+ /** `k1`, `b` and `k3`, BM25's parameters (https://turbopuffer.com/docs/write, full_text_search; /docs/fts, Advanced tuning). */
177
+ function bm25Parameter(cfg, k, v, attr) {
178
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
179
+ throw badRequest(`invalid schema for attribute "${attr}": full_text_search.${k} must be a non-negative number`);
180
+ cfg[k] = v;
181
+ }
182
+ const SCHEMA_OPTIONS = ['type', 'filterable', 'full_text_search', 'glob', 'regex', 'fuzzy', 'ann', 'sparse_knn', 'embed'];
183
+ /** Normalize one schema entry (`"string"` or a config object) into the stored form. */
184
+ export function normalizeAttrSchema(attr, raw) {
185
+ const given = typeof raw === 'string' ? { type: raw } : raw;
186
+ if (given === null || typeof given !== 'object' || Array.isArray(given))
187
+ throw badRequest(`invalid schema for attribute "${attr}": expected a type string or a config object`);
188
+ const g = given;
189
+ // `embed` is native embedding, applied by mergeSchema (embedSchema)
190
+ for (const k of Object.keys(g))
191
+ if (!SCHEMA_OPTIONS.includes(k))
192
+ throw badRequest(`invalid schema for attribute "${attr}": unknown option "${k}"`);
193
+ if (typeof g.type !== 'string' || !isKnownType(g.type))
194
+ throw badRequest(`invalid schema for attribute "${attr}": unknown type ${JSON.stringify(g.type)}`);
195
+ if (g.ann !== undefined && g.ann !== false && !isVectorType(g.type) && !isVectorArrayType(g.type))
196
+ throw badRequest(`invalid schema for attribute "${attr}": ann requires a vector type`);
197
+ // "On a vector array attribute (`[][N]f32`), `ann: {"late_interaction": true}` builds the index used for
198
+ // late-interaction (multi-vector) search. Set `ann: false` to store the per-token vectors without building the index
199
+ // (exact kNN queries still work, but not ANN queries)" (https://turbopuffer.com/docs/write, ann)
200
+ const late = isVectorArrayType(g.type);
201
+ if (late && !(g.ann === false || (g.ann !== null && typeof g.ann === 'object' && g.ann.late_interaction === true && Object.keys(g.ann).length === 1)))
202
+ throw badRequest(`invalid schema for attribute "${attr}": a [][N]f32 attribute takes ann: {"late_interaction": true} or ann: false`);
203
+ const annOptions = !late && g.ann !== null && typeof g.ann === 'object' ? Object.keys(g.ann) : [];
204
+ if (annOptions.some((k) => k !== 'distance_metric'))
205
+ throw badRequest(`invalid schema for attribute "${attr}": unknown ann option in ${JSON.stringify(annOptions)}`);
206
+ // "sparse_knn … When configured, this attribute can be used as part of a `SparseKNN` query. This is only supported on
207
+ // the `{}f16` type. This requires a `distance_metric` string field, which only supports `dot_product`"; "`{}f16`
208
+ // attributes are not filterable", and neither is `bytes` ("does not support indexing or filtering of any kind")
209
+ if (g.sparse_knn !== undefined && g.sparse_knn !== null) {
210
+ const sk = g.sparse_knn;
211
+ if (g.type !== '{}f16')
212
+ throw badRequest(`invalid schema for attribute "${attr}": sparse_knn is only supported on the {}f16 type`);
213
+ if (typeof sk !== 'object' || Array.isArray(sk) || sk.distance_metric !== 'dot_product' || Object.keys(sk).length !== 1)
214
+ throw badRequest(`invalid schema for attribute "${attr}": sparse_knn requires distance_metric "dot_product"`);
215
+ }
216
+ if ((g.type === '{}f16' || g.type === 'bytes') && g.filterable === true)
217
+ throw badRequest(`invalid schema for attribute "${attr}": ${g.type} attributes are not filterable`);
218
+ if (g.filterable !== undefined && typeof g.filterable !== 'boolean')
219
+ throw badRequest(`invalid schema for attribute "${attr}": filterable must be a boolean`);
220
+ const fts = normalizeFts(g.full_text_search, attr, g.type);
221
+ const annMetric = g.ann !== null && typeof g.ann === 'object' ? g.ann.distance_metric : undefined;
222
+ if (annMetric !== undefined && !DISTANCE_METRICS.includes(annMetric))
223
+ throw badRequest(`invalid schema for attribute "${attr}": ann.distance_metric must be one of ${DISTANCE_METRICS.join(', ')}`);
224
+ return {
225
+ ...(typeof annMetric === 'string' ? { ann_distance_metric: annMetric } : {}),
226
+ type: g.type,
227
+ // "by default, BM25-enabled attributes are not filterable" (SDK AttributeSchemaConfig doc).
228
+ ...(late ? { late_interaction: g.ann !== false } : {}),
229
+ // glob, regex and fuzzy: "If set, `filterable` defaults to `false`; you can override this by setting `filterable: true`"
230
+ filterable: g.type === '{}f16' || g.type === 'bytes' || late ? false : typeof g.filterable === 'boolean' ? g.filterable : fts === false && g.regex !== true && g.fuzzy !== true && g.glob !== true,
231
+ ...(g.sparse_knn !== undefined && g.sparse_knn !== null ? { sparse_knn: { distance_metric: 'dot_product' } } : {}),
232
+ full_text_search: fts,
233
+ ...(g.glob === true ? { glob: true } : {}),
234
+ ...(g.regex === true ? { regex: true } : {}),
235
+ ...(g.fuzzy === true ? { fuzzy: true } : {}),
236
+ };
237
+ }
238
+ /** The type Turbopuffer infers for an attribute first written without a declared schema. */
239
+ function inferType(v) {
240
+ if (typeof v === 'string')
241
+ return 'string';
242
+ if (typeof v === 'boolean')
243
+ return 'bool';
244
+ if (typeof v === 'number')
245
+ return Number.isInteger(v) ? 'int' : 'float';
246
+ // an object, or anything else no type is inferred from, is refused by the caller
247
+ if (!Array.isArray(v))
248
+ return null;
249
+ const first = v.find((x) => x !== null);
250
+ if (first === undefined)
251
+ return '[]string';
252
+ const inner = inferType(first);
253
+ return inner === null || inner.startsWith('[') ? null : `[]${inner}`;
254
+ }
255
+ function valueMatchesType(v, type) {
256
+ if (type.startsWith('[]'))
257
+ return Array.isArray(v) && v.every((x) => x === null || valueMatchesType(x, type.slice(2)));
258
+ switch (type) {
259
+ case 'string':
260
+ case 'uuid':
261
+ case 'datetime': return typeof v === 'string';
262
+ case 'bool': return typeof v === 'boolean';
263
+ case 'float': return typeof v === 'number' && Number.isFinite(v);
264
+ case 'int': return typeof v === 'number' && Number.isInteger(v);
265
+ case 'uint': return typeof v === 'number' && Number.isInteger(v) && v >= 0;
266
+ case 'bytes': return typeof v === 'string' && /^[A-Za-z0-9+/]*={0,2}$/.test(v) && v.length % 4 === 0;
267
+ // "Max dimensions per sparse vector: 1,024" (https://turbopuffer.com/docs/limits)
268
+ case '{}f16': return v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length <= 1024 && Object.values(v).every((x) => typeof x === 'number' && Number.isFinite(x));
269
+ }
270
+ return false;
271
+ }
272
+ // ── writes ──────────────────────────────────────────────────────────────────────────────────
273
+ const WRITE_KEYS = new Set(['upsert_condition', 'patch_condition', 'delete_condition', 'upsert_rows', 'upsert_columns', 'patch_rows', 'patch_columns', 'deletes', 'delete_by_filter', 'delete_by_filter_allow_partial', 'patch_by_filter', 'patch_by_filter_allow_partial', 'schema', 'distance_metric', 'return_affected_ids', 'disable_backpressure']);
274
+ const UNMODELED_WRITE_KEYS = {
275
+ encryption: 'turbopuffer.write.encryption',
276
+ sharding: 'turbopuffer.write.sharding',
277
+ };
278
+ function assertId(id, where) {
279
+ const valid = (typeof id === 'string' && id.length > 0 && id.length <= 64 * 1024) || (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0);
280
+ if (!valid)
281
+ throw badRequest(`invalid ${where}: a document id must be a non-empty string or an unsigned integer, got ${JSON.stringify(id)}`);
282
+ return id;
283
+ }
284
+ function rowsFromColumns(cols, where) {
285
+ if (cols === null || typeof cols !== 'object' || Array.isArray(cols))
286
+ throw badRequest(`invalid ${where}: expected an object of columns`);
287
+ const c = cols;
288
+ if (!Array.isArray(c.id))
289
+ throw badRequest(`invalid ${where}: the id column is required`);
290
+ const n = c.id.length;
291
+ for (const [k, v] of Object.entries(c)) {
292
+ if (!Array.isArray(v))
293
+ throw badRequest(`invalid ${where}: column "${k}" must be an array`);
294
+ if (v.length !== n)
295
+ throw badRequest(`invalid ${where}: column "${k}" has ${v.length} values but the id column has ${n}`);
296
+ }
297
+ return Array.from({ length: n }, (_, i) => Object.fromEntries(Object.entries(c).map(([k, v]) => [k, v[i]])));
298
+ }
299
+ function rowList(v, where) {
300
+ if (!Array.isArray(v))
301
+ throw badRequest(`invalid ${where}: expected an array of rows`);
302
+ return v.map((r, i) => {
303
+ if (r === null || typeof r !== 'object' || Array.isArray(r))
304
+ throw badRequest(`invalid ${where}[${i}]: expected an object`);
305
+ return r;
306
+ });
307
+ }
308
+ function logicalBytes(d) {
309
+ return utf8Length(JSON.stringify(d.id)) + utf8Length(JSON.stringify(d.attributes)) + (d.vector ? d.vector.length * 4 : 0);
310
+ }
311
+ export function approxLogicalBytes(ns) {
312
+ let n = 0;
313
+ for (const d of ns.docs.values())
314
+ n += logicalBytes(d);
315
+ return n;
316
+ }
317
+ /** `copy_from_namespace` and `branch_from_namespace` (https://turbopuffer.com/docs/write): the destination is made as a
318
+ * copy of the source's documents, schema and distance metric. "The destination namespace you are copying into must be
319
+ * empty. The initial request currently cannot make schema changes or contain documents"; a branch is "an instant
320
+ * copy-on-write clone of the source namespace. The destination namespace must be empty", and "reads, writes, queries,
321
+ * and deletes on one namespace do not affect the other". A copy answers "namespace cloned successfully" (the async
322
+ * copy's result, https://turbopuffer.com/docs/api-overview), and a destination that exists "destination namespace
323
+ * already exists" (the same page's failed result).
324
+ *
325
+ * Where the docs stop and the twin decides: a World holds one organization's namespaces in one store whatever region a
326
+ * request names, so a copy's `source_region` and `source_api_key` read the same store; `rows_affected` counts the
327
+ * documents cloned; a copy bills the logical bytes copied and a branch none ("billed at a flat rate"); a branch's
328
+ * answer carries the copy's message. Pure. */
329
+ export function applyClone(spaces, name, rawBody, at) {
330
+ assertNamespaceName(name);
331
+ const body = rawBody;
332
+ const branch = body.branch_from_namespace !== undefined;
333
+ const key = branch ? 'branch_from_namespace' : 'copy_from_namespace';
334
+ if (body.copy_from_namespace !== undefined && branch)
335
+ throw badRequest('invalid write request: copy_from_namespace and branch_from_namespace cannot both be set');
336
+ for (const k of Object.keys(body))
337
+ if (k !== key && k !== 'return_affected_ids' && k !== 'disable_backpressure')
338
+ throw cloneRefusal(k, key);
339
+ const raw = body[key];
340
+ const cfg = typeof raw === 'string' ? { source_namespace: raw } : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined;
341
+ const allowed = branch ? ['source_namespace'] : ['source_namespace', 'source_api_key', 'source_region', 'dest_encryption'];
342
+ if (!cfg || typeof cfg.source_namespace !== 'string' || Object.keys(cfg).some((k) => !allowed.includes(k)))
343
+ throw badRequest(`invalid ${key}: expected a namespace name or { ${allowed.join(', ')} }`);
344
+ if (cfg.dest_encryption !== undefined)
345
+ throw badRequest('turbopuffer twin: "dest_encryption" is real Turbopuffer surface this twin does not model yet (turbopuffer.write.encryption is the filed gap)');
346
+ const source = spaces.get(cfg.source_namespace);
347
+ if (source === undefined)
348
+ throw new TurbopufferError(404, `namespace '${cfg.source_namespace}' was not found`);
349
+ if (spaces.has(name))
350
+ throw badRequest('destination namespace already exists');
351
+ const docs = new Map([...source.docs].map(([k, d]) => [k, { ...d, attributes: { ...d.attributes } }]));
352
+ const next = {
353
+ name, schema: structuredClone(source.schema), distance_metric: source.distance_metric, vector_dims: source.vector_dims,
354
+ created_at: at, updated_at: at, last_write_at: at, ...(branch ? { parent: source.name } : {}), docs,
355
+ };
356
+ const upserts = [...docs.values()];
357
+ const bytes = branch ? 0 : upserts.reduce((n, d) => n + logicalBytes(d), 0);
358
+ const response = {
359
+ status: 'OK', message: 'namespace cloned successfully', rows_affected: upserts.length,
360
+ ...(body.return_affected_ids === true && upserts.length ? { upserted_ids: upserts.map((d) => d.id) } : {}),
361
+ billing: { billable_logical_bytes_written: bytes }, performance: { server_total_ms: 0 },
362
+ };
363
+ return { next, created: true, upserts, deletes: [], response };
364
+ }
365
+ /** A clone request carrying something else: "The initial request currently cannot make schema changes or contain
366
+ * documents" (https://turbopuffer.com/docs/write, copy_from_namespace); its `encryption` and `sharding`, which the page
367
+ * lets a copy set, are not modelled. */
368
+ function cloneRefusal(k, key) {
369
+ if (k === 'encryption' || k === 'sharding')
370
+ return gap(`"${k}" on a ${key} request`, `turbopuffer.write.${k}`);
371
+ return badRequest(`invalid write request: a ${key} request cannot also carry "${k}"`);
372
+ }
373
+ /** Apply one write request body to a namespace (or create it). Pure. */
374
+ export function applyWrite(existing, name, rawBody, at) {
375
+ assertNamespaceName(name);
376
+ if (rawBody === null || typeof rawBody !== 'object' || Array.isArray(rawBody))
377
+ throw badRequest('invalid write request: expected a JSON object');
378
+ const body = rawBody;
379
+ for (const k of Object.keys(body)) {
380
+ const gap = UNMODELED_WRITE_KEYS[k];
381
+ if (gap !== undefined)
382
+ throw badRequest(`turbopuffer twin: "${k}" is real Turbopuffer surface this twin does not model yet (${gap} is the filed gap)`);
383
+ if (!WRITE_KEYS.has(k))
384
+ throw badRequest(`invalid write request: unknown field "${k}"`);
385
+ }
386
+ const ops = ['upsert_rows', 'upsert_columns', 'patch_rows', 'patch_columns', 'deletes', 'delete_by_filter', 'patch_by_filter'].filter((k) => body[k] !== undefined);
387
+ if (ops.length === 0 && body.schema === undefined)
388
+ throw badRequest('invalid write request: the request contains no write operation');
389
+ const ns = existing
390
+ ? { ...existing, schema: { ...existing.schema }, docs: new Map(existing.docs) }
391
+ : { name, schema: {}, distance_metric: null, vector_dims: null, created_at: at, updated_at: at, last_write_at: at, docs: new Map() };
392
+ // "updated_at … when the namespace's data or schema was last modified"; "last_write_at … when the namespace's data was
393
+ // last modified" (https://turbopuffer.com/docs/metadata)
394
+ ns.updated_at = at;
395
+ if (ops.length > 0)
396
+ ns.last_write_at = at;
397
+ // 1. schema (declared) — a type may never change under an attribute that already has one.
398
+ if (body.schema !== undefined) {
399
+ if (body.schema === null || typeof body.schema !== 'object' || Array.isArray(body.schema))
400
+ throw badRequest('invalid write request: schema must be an object');
401
+ for (const [attr, raw] of Object.entries(body.schema))
402
+ mergeSchema(ns, attr, raw, existing === undefined);
403
+ }
404
+ // 2. distance metric — fixed once set.
405
+ if (body.distance_metric !== undefined) {
406
+ if (typeof body.distance_metric !== 'string' || !DISTANCE_METRICS.includes(body.distance_metric))
407
+ throw badRequest(`invalid write request: distance_metric must be one of ${DISTANCE_METRICS.join(', ')}`);
408
+ if (ns.distance_metric !== null && ns.distance_metric !== body.distance_metric)
409
+ throw badRequest(`invalid write request: namespace "${name}" uses distance_metric ${ns.distance_metric}, not ${body.distance_metric}`);
410
+ ns.distance_metric = body.distance_metric;
411
+ }
412
+ const upserted = new Map();
413
+ const deleted = new Set();
414
+ const upsertedIds = [];
415
+ const patchedIds = [];
416
+ const deletedIds = [];
417
+ let rowsUpserted = 0;
418
+ let rowsPatched = 0;
419
+ let rowsDeleted = 0;
420
+ const ctx = () => ({ schema: ns.schema, tokenCache: new Map() });
421
+ const remove = (key) => { const d = ns.docs.get(key); if (d) {
422
+ ns.docs.delete(key);
423
+ upserted.delete(key);
424
+ deleted.add(key);
425
+ } };
426
+ const put = (d) => { ns.docs.set(d.key, d); upserted.set(d.key, d); deleted.delete(d.key); };
427
+ // Order of application: filtered deletes, filtered patches, upserts, patches, deletes.
428
+ if (body.delete_by_filter !== undefined) {
429
+ const pred = compileFilter(body.delete_by_filter, ctx());
430
+ for (const d of [...ns.docs.values()])
431
+ if (pred(d)) {
432
+ remove(d.key);
433
+ rowsDeleted++;
434
+ deletedIds.push(d.id);
435
+ }
436
+ }
437
+ if (body.patch_by_filter !== undefined) {
438
+ const pbf = body.patch_by_filter;
439
+ if (pbf === null || typeof pbf !== 'object' || Array.isArray(pbf) || pbf.patch === null || typeof pbf.patch !== 'object' || Array.isArray(pbf.patch))
440
+ throw badRequest('invalid patch_by_filter: expected { filters, patch }');
441
+ const pred = compileFilter(pbf.filters, ctx());
442
+ const patch = pbf.patch;
443
+ if ('id' in patch)
444
+ throw badRequest('invalid patch_by_filter: the patch may not set id');
445
+ for (const d of [...ns.docs.values()])
446
+ if (pred(d)) {
447
+ put(patchDoc(ns, d, patch));
448
+ rowsPatched++;
449
+ patchedIds.push(d.id);
450
+ }
451
+ }
452
+ const upsertRows = [
453
+ ...(body.upsert_rows !== undefined ? rowList(body.upsert_rows, 'upsert_rows') : []),
454
+ ...(body.upsert_columns !== undefined ? rowsFromColumns(body.upsert_columns, 'upsert_columns') : []),
455
+ ];
456
+ const passes = conditionOf(ns, body, 'upsert_condition');
457
+ const passesPatch = conditionOf(ns, body, 'patch_condition');
458
+ const passesDelete = conditionOf(ns, body, 'delete_condition');
459
+ for (const row of upsertRows) {
460
+ const current = row.id === undefined ? undefined : ns.docs.get(docKey(assertId(row.id, 'upsert_rows')));
461
+ if (current !== undefined && passes && !passes(current, row))
462
+ continue;
463
+ const d = buildDoc(ns, row, 'upsert_rows');
464
+ embedRow(ns, d);
465
+ checkAllVectors(ns, d, 'upsert_rows');
466
+ put(d);
467
+ rowsUpserted++;
468
+ upsertedIds.push(d.id);
469
+ }
470
+ const patchRows = [
471
+ ...(body.patch_rows !== undefined ? rowList(body.patch_rows, 'patch_rows') : []),
472
+ ...(body.patch_columns !== undefined ? rowsFromColumns(body.patch_columns, 'patch_columns') : []),
473
+ ];
474
+ for (const row of patchRows) {
475
+ const id = assertId(row.id, 'patch_rows');
476
+ const current = ns.docs.get(docKey(id));
477
+ if (current === undefined)
478
+ continue; // a patch never creates a document
479
+ if (passesPatch && !passesPatch(current, row))
480
+ continue;
481
+ const { id: _id, ...patch } = row;
482
+ put(patchDoc(ns, current, patch));
483
+ rowsPatched++;
484
+ patchedIds.push(id);
485
+ }
486
+ if (body.deletes !== undefined) {
487
+ if (!Array.isArray(body.deletes))
488
+ throw badRequest('invalid deletes: expected an array of ids');
489
+ for (const raw of body.deletes) {
490
+ const id = assertId(raw, 'deletes');
491
+ if (passesDelete && !conditionalDelete(ns, id, passesDelete))
492
+ continue;
493
+ remove(docKey(id));
494
+ rowsDeleted++;
495
+ deletedIds.push(id);
496
+ }
497
+ }
498
+ const upserts = [...upserted.values()];
499
+ const bytes = upserts.reduce((n, d) => n + logicalBytes(d), 0);
500
+ const queried = ['upsert_condition', 'patch_condition', 'delete_condition', 'patch_by_filter', 'delete_by_filter'].some((k) => body[k] !== undefined);
501
+ const queriedBytes = existing ? approxLogicalBytes(existing) : 0;
502
+ const returnIds = body.return_affected_ids === true;
503
+ const response = {
504
+ status: 'OK',
505
+ // the Write page's answers: "message": "documents committed successfully"
506
+ message: 'documents committed successfully',
507
+ rows_affected: rowsUpserted + rowsPatched + rowsDeleted,
508
+ ...(upsertRows.length > 0 ? { rows_upserted: rowsUpserted } : {}),
509
+ ...(patchRows.length > 0 || body.patch_by_filter !== undefined ? { rows_patched: rowsPatched } : {}),
510
+ ...(body.deletes !== undefined || body.delete_by_filter !== undefined ? { rows_deleted: rowsDeleted } : {}),
511
+ ...(body.delete_by_filter !== undefined || body.patch_by_filter !== undefined ? { rows_remaining: false } : {}),
512
+ ...(returnIds && upsertedIds.length > 0 ? { upserted_ids: upsertedIds } : {}),
513
+ ...(returnIds && patchedIds.length > 0 ? { patched_ids: patchedIds } : {}),
514
+ ...(returnIds && deletedIds.length > 0 ? { deleted_ids: deletedIds } : {}),
515
+ billing: {
516
+ billable_logical_bytes_written: bytes,
517
+ // "query (object, optional): query billing information when the write involves a query-like operation (for a
518
+ // conditional write, patch_by_filter, delete_by_filter …)" (https://turbopuffer.com/docs/write); the twin bills the
519
+ // namespace's logical bytes as queried and none as returned, its reading
520
+ ...(queried ? { query: { billable_logical_bytes_queried: queriedBytes, billable_logical_bytes_returned: 0 } } : {}),
521
+ },
522
+ performance: { server_total_ms: 0 },
523
+ };
524
+ return { next: ns, created: existing === undefined, upserts, deletes: [...deleted], response };
525
+ }
526
+ /** Whether a conditional delete of `id` goes ahead: "If the document does not exist, the write is … skipped
527
+ * unconditionally for patches and deletes", and "`$ref_new` references are given a `null` value for all attributes"
528
+ * (https://turbopuffer.com/docs/write, delete_condition). */
529
+ function conditionalDelete(ns, id, passes) {
530
+ const current = ns.docs.get(docKey(id));
531
+ return current !== undefined && passes(current, {});
532
+ }
533
+ /** A write's condition (https://turbopuffer.com/docs/write, Conditional writes): "The condition syntax matches the
534
+ * filters parameter in the query API, with an additional feature: you can reference the new value being written using
535
+ * $ref_new references", evaluated "using the current value of the document with the matching ID". For deletes,
536
+ * "`$ref_new` references are given a `null` value for all attributes". Undefined when the body sets none. */
537
+ function conditionOf(ns, body, key) {
538
+ const cond = body[key];
539
+ if (cond === undefined)
540
+ return undefined;
541
+ if (!Array.isArray(cond))
542
+ throw badRequest(`invalid ${key}: expected a filter`);
543
+ const resolve = (node, next) => {
544
+ if (Array.isArray(node))
545
+ return node.map((n) => resolve(n, next));
546
+ if (node !== null && typeof node === 'object') {
547
+ const ref = node.$ref_new;
548
+ if (Object.keys(node).length !== 1 || typeof ref !== 'string')
549
+ throw badRequest(`invalid ${key}: an object in a condition must be {"$ref_new": "<attribute>"}`);
550
+ return next[ref] ?? null;
551
+ }
552
+ return node;
553
+ };
554
+ compileFilter(resolve(cond, {}), { schema: ns.schema, tokenCache: new Map() }); // a condition that does not parse refuses the write
555
+ return (current, next) => compileFilter(resolve(cond, next), { schema: ns.schema, tokenCache: new Map() })(current);
556
+ }
557
+ function mergeSchema(ns, attr, raw, creating = false) {
558
+ if (attr === 'id')
559
+ return mergeIdSchema(ns, raw);
560
+ const next = normalizeAttrSchema(attr, raw);
561
+ const prev = ns.schema[attr];
562
+ const embed = raw !== null && typeof raw === 'object' && !Array.isArray(raw) && 'embed' in raw ? raw.embed : undefined;
563
+ if (embed === undefined) {
564
+ if (prev?.embed)
565
+ next.embed = prev.embed;
566
+ }
567
+ else
568
+ embedSchema(ns, attr, next, prev, embed, creating);
569
+ if (prev !== undefined && prev.type !== next.type)
570
+ throw badRequest(`invalid schema for attribute "${attr}": cannot change type from ${prev.type} to ${next.type}`);
571
+ if (attr === 'vector' || isVectorType(next.type)) {
572
+ const dims = dimsOf(next.type);
573
+ // "Vector attributes require an ANN index, configured via the `ann` schema parameter"; `ann` "Must be set to `true`
574
+ // for vector type attributes" (https://turbopuffer.com/docs/write)
575
+ const ann = raw !== null && typeof raw === 'object' ? raw.ann : undefined;
576
+ if (ann !== true && (ann === null || typeof ann !== 'object'))
577
+ throw badRequest(`invalid schema for attribute "${attr}": vector attributes require ann: true`);
578
+ if (attr === 'vector') {
579
+ if (ns.vector_dims !== null && ns.vector_dims !== dims)
580
+ throw badRequest(`invalid schema: vector has ${ns.vector_dims} dimensions, not ${dims}`);
581
+ ns.vector_dims = dims;
582
+ // "Vector columns must be declared in the schema and are fixed at namespace creation time" (Multiple vector columns)
583
+ }
584
+ else if (prev === undefined && !creating)
585
+ throw badRequest(`invalid schema for attribute "${attr}": vector columns are fixed at namespace creation time`);
586
+ if (next.ann_distance_metric !== undefined)
587
+ annMetric(ns, next.ann_distance_metric);
588
+ delete next.ann_distance_metric;
589
+ }
590
+ ns.schema[attr] = next;
591
+ }
592
+ // "Document IDs are unsigned 64-bit integers, 128-bit UUIDs, or strings up to 64 bytes" and a UUID id "must be set
593
+ // explicitly in the schema" (https://turbopuffer.com/docs/write: `"id": "uuid"` in its Configuring the schema example);
594
+ // "All attributes are nullable, except for `id`". The id's type is otherwise the first document's: `uint` for a number
595
+ // (the Namespace metadata example answers `"id": {"type": "uint"}`), `string` for a string.
596
+ const ID_TYPES = ['uint', 'uuid', 'string'];
597
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
598
+ function mergeIdSchema(ns, raw) {
599
+ const type = typeof raw === 'string' ? raw : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw.type : undefined;
600
+ if (typeof type !== 'string' || !ID_TYPES.includes(type))
601
+ throw badRequest(`invalid schema for attribute "id": type must be one of ${ID_TYPES.join(', ')}`);
602
+ const prev = ns.schema.id;
603
+ if (prev !== undefined && prev.type !== type)
604
+ throw badRequest(`invalid schema for attribute "id": cannot change type from ${prev.type} to ${type}`);
605
+ ns.schema.id = { type, filterable: true, full_text_search: false };
606
+ }
607
+ /** An id against the namespace's id type, which the first document sets when no schema did. */
608
+ function checkIdType(ns, id, where) {
609
+ const declared = ns.schema.id?.type;
610
+ if (declared === undefined) {
611
+ ns.schema.id = { type: typeof id === 'number' ? 'uint' : 'string', filterable: true, full_text_search: false };
612
+ return;
613
+ }
614
+ const ok = declared === 'uint' ? typeof id === 'number' : declared === 'uuid' ? typeof id === 'string' && UUID.test(id) : typeof id === 'string';
615
+ if (!ok)
616
+ throw badRequest(`invalid ${where}: the id ${JSON.stringify(id)} is not of the namespace's id type ${declared}`);
617
+ }
618
+ // ── native embedding ──────────────────────────────────────────────────────────────────────
619
+ // https://turbopuffer.com/docs/embedding and /docs/write (embed): "use the `embed` property on an attribute's schema to
620
+ // specify an embedding model. Writes and queries to that attribute are turned into vectors on the fly." `embed` "Can
621
+ // only be set on `string` fields", and is a model name or {model, attribute?, dims?, dtype?}: `attribute` is "The
622
+ // vector attribute that stores embedding vectors for this field. If omitted, turbopuffer creates a computed vector
623
+ // attribute named `embed_<attribute>`. … Required when enabling embedding on an existing attribute"; `dims` "If not set,
624
+ // will use the model's default dimensionality. … Must match the size of the vector specified in `attribute`". `embed`
625
+ // "can be added or removed, but not modified", and `null` removes it ("any future write to this namespace requires
626
+ // writing the embed_text vector"). "With `embed` set, you can still provide a vector yourself. Each row that includes
627
+ // a vector is stored as-is, and rows with no vector have one computed". A query ranks by `["Embed", text, {model?}]`
628
+ // under ANN or kNN; `model` is "Required when using the `Embed` function to rank a vector attribute with native
629
+ // embedding, optional when ranking a source attribute that has `embed` set in the schema".
630
+ // Where the docs stop and the twin decides: an embedding is a labelled placeholder, not a model's output (the twin runs
631
+ // no model): a unit vector derived from the model's name and the text, the same for a document and a query of the same
632
+ // text, so ANN over it ranks by nothing a model knows; the models are the Embedding page's and its changelog's, and a
633
+ // model's default dimensions, which the page shows only on request, are 1024 (the dims every example on the page
634
+ // names); a stored vector is f32; the tokens embedded, which the page says both answers carry, are not answered, since
635
+ // no page names their field.
636
+ export const EMBEDDING_MODELS = [
637
+ 'baai/bge-m3', 'cohere/embed-v4.0', 'google/gemini-embedding-2', 'nvidia/nemotron-3-embed-1b', 'nvidia/nemotron-3-embed-8b',
638
+ 'openai/text-embedding-3-large', 'openai/text-embedding-3-small', 'openai/text-embedding-ada-002', 'qwen/qwen3-embedding-0p6b',
639
+ 'qwen/qwen3-embedding-4b', 'qwen/qwen3-embedding-8b', 'voyage/voyage-4', 'voyage/voyage-4-large', 'voyage/voyage-4-lite',
640
+ 'voyage/voyage-4-nano', 'voyage/voyage-code-3', 'voyage/voyage-code-4', 'zeroentropy/zembed-1',
641
+ // deprecated, still available (https://turbopuffer.com/docs/embedding/changelog, August 2026)
642
+ 'cohere/embed-english-v3.0', 'google/gemini-embedding-001', 'google/text-embedding-005', 'voyage/voyage-3-large', 'voyage/voyage-3.5', 'voyage/voyage-3.5-lite',
643
+ ];
644
+ const DEFAULT_EMBED_DIMS = 1024;
645
+ const sha256Bytes = (s) => createHash('sha256').update(s).digest();
646
+ function checkModel(model) {
647
+ if (typeof model !== 'string' || !EMBEDDING_MODELS.includes(model))
648
+ throw badRequest(`invalid embed: unknown embedding model ${JSON.stringify(model)}`);
649
+ return model;
650
+ }
651
+ function embedSchema(ns, attr, next, prev, raw, creating) {
652
+ if (raw === null) {
653
+ delete next.embed;
654
+ return;
655
+ }
656
+ if (next.type !== 'string')
657
+ throw badRequest(`invalid schema for attribute "${attr}": embed can only be set on string fields`);
658
+ const cfg = typeof raw === 'string' ? { model: raw } : raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined;
659
+ if (!cfg || Object.keys(cfg).some((k) => !['model', 'attribute', 'dims', 'dtype'].includes(k)))
660
+ throw badRequest(`invalid schema for attribute "${attr}": embed is a model name or { model, attribute, dims, dtype }`);
661
+ const model = checkModel(cfg.model);
662
+ if (prev?.embed) {
663
+ next.embed = keptEmbed(prev.embed, model, cfg, attr);
664
+ return;
665
+ }
666
+ const existingAttr = prev !== undefined || (!creating && [...ns.docs.values()].some((d) => d.attributes[attr] !== undefined));
667
+ if (existingAttr && cfg.attribute === undefined)
668
+ throw badRequest(`invalid schema for attribute "${attr}": attribute is required when enabling embedding on an existing attribute`);
669
+ const target = typeof cfg.attribute === 'string' ? cfg.attribute : `embed_${attr}`;
670
+ if (cfg.dtype !== undefined && cfg.dtype !== 'f32')
671
+ throw badRequest(`invalid schema for attribute "${attr}": the twin stores embeddings as f32`);
672
+ const column = target === 'vector' ? (ns.vector_dims !== null ? `[${ns.vector_dims}]f32` : undefined) : ns.schema[target]?.type;
673
+ const dims = typeof cfg.dims === 'number' ? cfg.dims : column ? dimsOf(column) : DEFAULT_EMBED_DIMS;
674
+ if (column !== undefined && dimsOf(column) !== dims)
675
+ throw badRequest(`invalid schema for attribute "${attr}": dims ${dims} does not match the size of "${target}" (${column})`);
676
+ if (column === undefined) {
677
+ if (target === 'vector') {
678
+ ns.vector_dims = dims;
679
+ ns.schema.vector = { type: `[${dims}]f32`, filterable: false, full_text_search: false };
680
+ }
681
+ else
682
+ ns.schema[target] = { type: `[${dims}]f32`, filterable: false, full_text_search: false };
683
+ }
684
+ next.embed = { model, attribute: target, dims };
685
+ }
686
+ /** An `embed` sent again for an attribute that has one: "embed (can be added or removed, but not modified)"
687
+ * (https://turbopuffer.com/docs/write, Updating attributes). */
688
+ function keptEmbed(prev, model, cfg, attr) {
689
+ if (prev.model !== model || (cfg.attribute !== undefined && cfg.attribute !== prev.attribute))
690
+ throw badRequest(`invalid schema for attribute "${attr}": embed can be added or removed, but not modified`);
691
+ return prev;
692
+ }
693
+ /** The placeholder embedding of a text: a unit vector of `dims` derived from the model's name and the text. */
694
+ export function placeholderEmbedding(model, text, dims) {
695
+ const out = [];
696
+ let block = new Uint8Array();
697
+ for (let i = 0; out.length < dims; i++) {
698
+ if (i % 8 === 0)
699
+ block = new Uint8Array(sha256Bytes(`${model}\u0000${text}\u0000${i / 8}`));
700
+ const j = (i % 8) * 4;
701
+ out.push(((block[j] << 8 | block[j + 1]) / 65535) * 2 - 1);
702
+ }
703
+ const norm = Math.sqrt(out.reduce((n, x) => n + x * x, 0)) || 1;
704
+ return out.map((x) => x / norm);
705
+ }
706
+ /** Fill each embedded attribute's vector where the row sent none ("rows with no vector have one computed"). */
707
+ function embedRow(ns, d) {
708
+ for (const [attr, c] of Object.entries(ns.schema)) {
709
+ if (!c.embed)
710
+ continue;
711
+ const text = d.attributes[attr];
712
+ if (typeof text !== 'string')
713
+ continue;
714
+ const target = c.embed.attribute;
715
+ if (target === 'vector') {
716
+ if (d.vector === null)
717
+ d.vector = placeholderEmbedding(c.embed.model, text, c.embed.dims);
718
+ }
719
+ else if (d.attributes[target] === undefined)
720
+ d.attributes[target] = placeholderEmbedding(c.embed.model, text, c.embed.dims);
721
+ }
722
+ }
723
+ function checkVector(ns, v, where) {
724
+ if (typeof v === 'string')
725
+ throw badRequest(`turbopuffer twin: base64-encoded vectors are not modelled (turbopuffer.vectors.base64 is the filed gap)`);
726
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'number' && Number.isFinite(x)))
727
+ throw badRequest(`invalid ${where}: vector must be a non-empty array of finite numbers`);
728
+ if (ns.distance_metric === null)
729
+ throw badRequest(`invalid ${where}: distance_metric is required when writing vectors`);
730
+ if (ns.vector_dims === null) {
731
+ ns.vector_dims = v.length;
732
+ ns.schema.vector = { type: `[${v.length}]f32`, filterable: false, full_text_search: false };
733
+ }
734
+ else if (v.length !== ns.vector_dims)
735
+ throw badRequest(`invalid ${where}: vector has ${v.length} dimensions, but the namespace has ${ns.vector_dims}`);
736
+ return v;
737
+ }
738
+ function checkAttribute(ns, attr, v, where) {
739
+ const declared = ns.schema[attr];
740
+ if (declared !== undefined && isVectorType(declared.type)) {
741
+ checkVectorValue(declared.type, attr, v, where);
742
+ return;
743
+ }
744
+ if (declared !== undefined && isVectorArrayType(declared.type)) {
745
+ // "Every vector in the array must have the same dimensionality (`N`)" (https://turbopuffer.com/docs/query, Late interaction)
746
+ if (!Array.isArray(v))
747
+ throw badRequest(`invalid ${where}: attribute "${attr}" is ${declared.type}, an array of vectors`);
748
+ for (const x of v)
749
+ checkVectorValue(declared.type.slice(2), attr, x, where);
750
+ return;
751
+ }
752
+ if (attr.startsWith('$'))
753
+ throw badRequest(`invalid ${where}: attribute names may not start with "$" ("${attr}")`);
754
+ if (attr.length > 128)
755
+ throw badRequest(`invalid ${where}: attribute name "${attr.slice(0, 32)}…" is longer than 128 characters`);
756
+ let config = ns.schema[attr];
757
+ if (config === undefined) {
758
+ const inferred = inferType(v);
759
+ if (inferred === null)
760
+ throw badRequest(`invalid ${where}: attribute "${attr}" has an unsupported value ${JSON.stringify(v).slice(0, 80)}`);
761
+ config = { type: inferred, filterable: true, full_text_search: false };
762
+ ns.schema[attr] = config;
763
+ }
764
+ if (!valueMatchesType(v, config.type))
765
+ throw badRequest(`invalid ${where}: attribute "${attr}" has type ${config.type}, but the value ${JSON.stringify(v).slice(0, 80)} does not match it`);
766
+ }
767
+ /** `ann: {distance_metric}` (the spec's AnnConfig): the namespace's metric, which "will apply to all vector columns
768
+ * configured for this namespace" (https://turbopuffer.com/docs/write, distance_metric). */
769
+ function annMetric(ns, metric) {
770
+ if (ns.distance_metric !== null && ns.distance_metric !== metric)
771
+ throw badRequest(`invalid schema: namespace uses distance_metric ${ns.distance_metric}, not ${metric}`);
772
+ ns.distance_metric = metric;
773
+ }
774
+ /** A value of a vector column: "encoded as either a JSON array of numbers, or as a base64-encoded string", and "Elements
775
+ * of a vector attribute must have the same number of dimensions" (https://turbopuffer.com/docs/write, Vectors). The
776
+ * twin keeps the numbers as sent, its reading: the docs do not say how an f16 or i8 column answers a value read back.
777
+ * An i8 element is an integer from -128 to 127, the twin's reading of the type's name. */
778
+ function checkVectorValue(type, attr, v, where) {
779
+ if (typeof v === 'string')
780
+ throw badRequest(`turbopuffer twin: base64-encoded vectors are not modelled (turbopuffer.vectors.base64 is the filed gap)`);
781
+ const dims = dimsOf(type);
782
+ if (!Array.isArray(v) || v.length !== dims || !v.every((x) => typeof x === 'number' && Number.isFinite(x)))
783
+ throw badRequest(`invalid ${where}: attribute "${attr}" must be ${dims} numbers (${type})`);
784
+ if (type.endsWith('i8') && !v.every((x) => Number.isInteger(x) && x >= -128 && x <= 127))
785
+ throw badRequest(`invalid ${where}: attribute "${attr}" is ${type}, so each element must be an integer from -128 to 127`);
786
+ }
787
+ /** "A namespace may or may not have vector indexes. If it does, all documents must include all vector attributes"
788
+ * (https://turbopuffer.com/docs/write, upsert_rows). */
789
+ function checkAllVectors(ns, d, where) {
790
+ for (const [attr, c] of Object.entries(ns.schema)) {
791
+ if (!isVectorType(c.type))
792
+ continue;
793
+ const has = attr === 'vector' ? d.vector !== null : d.attributes[attr] !== undefined;
794
+ if (!has)
795
+ throw badRequest(`invalid ${where}: every document must include every vector attribute, and ${JSON.stringify(d.id)} has no "${attr}"`);
796
+ }
797
+ }
798
+ function buildDoc(ns, row, where) {
799
+ const id = assertId(row.id, where);
800
+ checkIdType(ns, id, where);
801
+ const attributes = {};
802
+ let vector = null;
803
+ for (const [k, v] of Object.entries(row)) {
804
+ if (k === 'id')
805
+ continue;
806
+ if (k === 'vector') {
807
+ if (v !== null && v !== undefined)
808
+ vector = checkVector(ns, v, where);
809
+ continue;
810
+ }
811
+ if (v === null || v === undefined)
812
+ continue; // null is "no value" — the attribute is absent
813
+ checkAttribute(ns, k, v, where);
814
+ attributes[k] = v;
815
+ }
816
+ return { key: docKey(id), id, attributes, vector };
817
+ }
818
+ function patchDoc(ns, d, patch) {
819
+ const attributes = { ...d.attributes };
820
+ let vector = d.vector;
821
+ for (const [k, v] of Object.entries(patch)) {
822
+ if (k === 'vector') {
823
+ vector = v === null ? null : checkVector(ns, v, 'patch');
824
+ continue;
825
+ }
826
+ if (v === null || v === undefined) {
827
+ delete attributes[k];
828
+ continue;
829
+ }
830
+ checkAttribute(ns, k, v, 'patch');
831
+ attributes[k] = v;
832
+ }
833
+ return { ...d, attributes, vector };
834
+ }
835
+ // ── queries ─────────────────────────────────────────────────────────────────────────────────
836
+ const QUERY_KEYS = new Set(['group_by', 'rank_by', 'filters', 'top_k', 'limit', 'include_attributes', 'exclude_attributes', 'aggregate_by', 'consistency', 'distance_metric', 'vector_encoding']);
837
+ const UNMODELED_QUERY_KEYS = {
838
+ compute_attributes: 'turbopuffer.query.compute_attributes',
839
+ };
840
+ /** `vector_encoding` other than float: "base64" is documented ("The encoding to use for vectors in the response", the
841
+ * spec's VectorEncoding) and not modelled; anything else is refused. */
842
+ export function vectorEncodingRefusal(v) {
843
+ return v === 'base64' ? gap('vector_encoding "base64"', 'turbopuffer.vectors.base64') : badRequest('invalid query: vector_encoding must be "float" or "base64"');
844
+ }
845
+ function gap(what, id) {
846
+ return badRequest(`turbopuffer twin: ${what} is real Turbopuffer surface this twin does not model yet (${id} is the filed gap)`);
847
+ }
848
+ /** Answer one query (the body of `POST /v2/namespaces/{ns}/query`, or one of `queries[]`). Pure. */
849
+ export function runQuery(ns, rawQuery, opts = {}) {
850
+ if (rawQuery === null || typeof rawQuery !== 'object' || Array.isArray(rawQuery))
851
+ throw badRequest('invalid query: expected a JSON object');
852
+ const q = rawQuery;
853
+ for (const k of Object.keys(q)) {
854
+ const g = UNMODELED_QUERY_KEYS[k];
855
+ if (g !== undefined)
856
+ throw gap(`the query parameter "${k}"`, g);
857
+ if (!QUERY_KEYS.has(k) || (opts.multi && (k === 'consistency' || k === 'vector_encoding')))
858
+ throw badRequest(`invalid query: unknown field "${k}"`);
859
+ }
860
+ if (q.vector_encoding !== undefined && q.vector_encoding !== 'float')
861
+ throw vectorEncodingRefusal(q.vector_encoding);
862
+ checkConsistency(q.consistency);
863
+ const ctx = { schema: ns.schema, tokenCache: new Map() };
864
+ const pred = q.filters === undefined || q.filters === null ? () => true : compileFilter(q.filters, ctx);
865
+ const matched = [...ns.docs.values()].filter(pred);
866
+ if (q.group_by !== undefined && q.aggregate_by === undefined)
867
+ throw badRequest('invalid query: group_by is only valid when aggregate_by is set');
868
+ if (q.aggregate_by !== undefined) {
869
+ // "Cannot be specified with rank_by or include_attributes" (https://turbopuffer.com/docs/query, aggregate_by)
870
+ if (q.rank_by !== undefined || q.include_attributes !== undefined)
871
+ throw badRequest('invalid query: aggregate_by cannot be specified with rank_by or include_attributes');
872
+ if (q.group_by !== undefined)
873
+ return { aggregation_groups: groupAggregate(q.aggregate_by, q.group_by, matched, q.limit === undefined && q.top_k === undefined ? MAX_GROUPS : resolveLimit(q.top_k, q.limit)) };
874
+ return { aggregations: aggregate(q.aggregate_by, matched) };
875
+ }
876
+ // The Query page lists rank_by "required unless aggregate_by is set", but the Quickstart's runnable example sends a
877
+ // query with only `filters` and `limit` (`"filters": ["text", "Regex", "\\w+fish"]`), and "To find all documents
878
+ // matching filters when order isn't important to you, rank by the `id` attribute" (Lookups): the twin answers a query
879
+ // with neither as that lookup, in id order, its reading of the two pages.
880
+ const rankBy = q.rank_by ?? ['id', 'asc'];
881
+ const per = limitPer(q.limit);
882
+ const limit = resolveLimit(q.top_k, q.limit);
883
+ const ranked = rank(ns, rankBy, matched, ctx, q.distance_metric);
884
+ const rows = diversify(ranked, per, limit).map((r) => projectRow(r, q.include_attributes, q.exclude_attributes));
885
+ return { rows };
886
+ }
887
+ export function checkConsistency(c) {
888
+ if (c === undefined)
889
+ return;
890
+ if (c === null || typeof c !== 'object' || Array.isArray(c))
891
+ throw badRequest('invalid query: consistency must be an object');
892
+ const level = c.level;
893
+ // The twin is always strongly consistent, which satisfies both levels.
894
+ if (level !== undefined && level !== 'strong' && level !== 'eventual')
895
+ throw badRequest('invalid query: consistency.level must be "strong" or "eventual"');
896
+ }
897
+ function resolveLimit(topK, limit) {
898
+ let n = topK;
899
+ if (limit !== undefined) {
900
+ if (typeof limit === 'object' && limit !== null && !Array.isArray(limit)) {
901
+ const l = limit;
902
+ n = l.total;
903
+ }
904
+ else
905
+ n = limit;
906
+ }
907
+ if (n === undefined)
908
+ return 10;
909
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1)
910
+ throw badRequest('invalid query: top_k / limit must be a positive integer');
911
+ return n;
912
+ }
913
+ /** `limit.per` (https://turbopuffer.com/docs/query): "limits the number of documents with the same value for a set of
914
+ * attributes (the "limit key") that can appear in the results", with `attributes` and `limit`. */
915
+ function limitPer(limit) {
916
+ if (limit === null || typeof limit !== 'object' || Array.isArray(limit))
917
+ return undefined;
918
+ const per = limit.per;
919
+ if (per === undefined)
920
+ return undefined;
921
+ const p = per;
922
+ if (per === null || typeof per !== 'object' || !Array.isArray(p.attributes) || !p.attributes.every((a) => typeof a === 'string') || typeof p.limit !== 'number' || !Number.isInteger(p.limit) || p.limit < 1)
923
+ throw badRequest('invalid query: limit.per must be { attributes: string[], limit: positive integer }');
924
+ return { attributes: p.attributes, limit: p.limit };
925
+ }
926
+ /** The ranked documents in order, at most `per.limit` for each value of the limit key, until `total`. */
927
+ function diversify(ranked, per, total) {
928
+ if (!per)
929
+ return ranked.slice(0, total);
930
+ const seen = new Map();
931
+ const out = [];
932
+ for (const r of ranked) {
933
+ if (out.length >= total)
934
+ break;
935
+ const key = JSON.stringify(per.attributes.map((a) => attributeValue(r.doc, a)));
936
+ const n = seen.get(key) ?? 0;
937
+ if (n >= per.limit)
938
+ continue;
939
+ seen.set(key, n + 1);
940
+ out.push(r);
941
+ }
942
+ return out;
943
+ }
944
+ /** "Max aggregation groups per query: 10k" (https://turbopuffer.com/docs/limits). */
945
+ const MAX_GROUPS = 10_000;
946
+ /** `group_by` (https://turbopuffer.com/docs/query): "Groups documents by the specified attributes or expressions (the
947
+ * "group key") before computing aggregates. Aggregates are computed separately for each group. Up to limit.total
948
+ * groups are returned, ordered by group key." An entry is an attribute name, or `{label: ["ForEachUnique", attr]}`,
949
+ * which "creates a separate group for each unique element of the array". Answered as `aggregation_groups`: "one for
950
+ * each aggregation group, containing the group key and the computed value of each requested aggregation".
951
+ * Where the docs stop and the twin decides: a document whose grouping attribute is absent is grouped under null, and
952
+ * null sorts first; with no limit every group is answered, up to the 10k limit. */
953
+ function groupAggregate(spec, groupBy, docs, limit) {
954
+ if (!Array.isArray(groupBy) || groupBy.length === 0)
955
+ throw badRequest('invalid query: group_by must be a non-empty array');
956
+ const keys = groupBy.map((g) => {
957
+ if (typeof g === 'string')
958
+ return { label: g, attr: g, each: false };
959
+ const entries = g !== null && typeof g === 'object' && !Array.isArray(g) ? Object.entries(g) : [];
960
+ const [label, expr] = entries[0] ?? [];
961
+ if (entries.length !== 1 || !Array.isArray(expr) || expr[0] !== 'ForEachUnique' || typeof expr[1] !== 'string')
962
+ throw badRequest(`invalid group_by entry ${JSON.stringify(g)}: expected an attribute name or {label: ["ForEachUnique", attribute]}`);
963
+ return { label: label, attr: expr[1], each: true };
964
+ });
965
+ const groups = new Map();
966
+ for (const d of docs) {
967
+ let tuples = [[]];
968
+ for (const k of keys) {
969
+ const v = attributeValue(d, k.attr);
970
+ const values = k.each ? (Array.isArray(v) ? [...new Map(v.map((x) => [JSON.stringify(x), x])).values()] : []) : [v];
971
+ tuples = tuples.flatMap((t) => values.map((x) => [...t, x]));
972
+ }
973
+ for (const t of tuples) {
974
+ const id = JSON.stringify(t);
975
+ const g = groups.get(id) ?? { key: t, docs: [] };
976
+ g.docs.push(d);
977
+ groups.set(id, g);
978
+ }
979
+ }
980
+ // an array value (a group key of an array attribute, the Quickstart's `"category": ["fish"]`) orders element by element
981
+ const one = (x, y) => (x === null && y === null ? 0 : x === null ? -1 : y === null ? 1 : Array.isArray(x) || Array.isArray(y) ? compareArrays(x, y) : compareScalars(x, y));
982
+ const order = (a, b) => a.map((x, i) => one(x, b[i])).find((c) => c !== 0 && !Number.isNaN(c)) ?? 0;
983
+ function compareArrays(x, y) {
984
+ const xs = Array.isArray(x) ? x : [x];
985
+ const ys = Array.isArray(y) ? y : [y];
986
+ for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
987
+ const c = compareScalars(xs[i], ys[i]);
988
+ if (c !== 0 && !Number.isNaN(c))
989
+ return c;
990
+ }
991
+ return xs.length - ys.length;
992
+ }
993
+ return [...groups.values()].sort((a, b) => order(a.key, b.key)).slice(0, limit)
994
+ .map((g) => ({ ...Object.fromEntries(keys.map((k, i) => [k.label, g.key[i] ?? null])), ...aggregate(spec, g.docs) }));
995
+ }
996
+ function aggregate(spec, docs) {
997
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
998
+ throw badRequest('invalid query: aggregate_by must be an object');
999
+ const out = {};
1000
+ for (const [label, fn] of Object.entries(spec)) {
1001
+ if (!Array.isArray(fn) || fn.length === 0)
1002
+ throw badRequest(`invalid aggregate "${label}": expected ["Count"], ["Count", attr] or ["Sum", attr]`);
1003
+ if (fn[0] === 'Count' && fn.length === 1)
1004
+ out[label] = docs.length;
1005
+ else if (fn[0] === 'Count' && fn.length === 2 && typeof fn[1] === 'string')
1006
+ out[label] = docs.filter((d) => attributeValue(d, fn[1]) !== null).length;
1007
+ else if (fn[0] === 'Sum' && fn.length === 2 && typeof fn[1] === 'string') {
1008
+ let sum = 0;
1009
+ // Summed in id order, so every root folds the same floats in the same order.
1010
+ for (const d of [...docs].sort((a, b) => compareIds(a.id, b.id))) {
1011
+ const v = attributeValue(d, fn[1]);
1012
+ if (typeof v === 'number')
1013
+ sum += v;
1014
+ }
1015
+ out[label] = sum;
1016
+ }
1017
+ else
1018
+ throw badRequest(`invalid aggregate "${label}": expected ["Count"], ["Count", attr] or ["Sum", attr]`);
1019
+ }
1020
+ return out;
1021
+ }
1022
+ function compareIds(a, b) {
1023
+ if (typeof a === 'number' && typeof b === 'number')
1024
+ return a - b;
1025
+ if (typeof a === 'number')
1026
+ return -1;
1027
+ if (typeof b === 'number')
1028
+ return 1;
1029
+ return a < b ? -1 : a > b ? 1 : 0;
1030
+ }
1031
+ function rank(ns, rankBy, docs, ctx, metric) {
1032
+ if (!Array.isArray(rankBy) || rankBy.length === 0)
1033
+ throw badRequest(`invalid rank_by: ${JSON.stringify(rankBy)}`);
1034
+ // [[attr, 'asc'|'desc'], ...] — multi-attribute order.
1035
+ if (Array.isArray(rankBy[0]))
1036
+ return orderBy(rankBy, docs);
1037
+ if (rankBy.length === 2 && (rankBy[1] === 'asc' || rankBy[1] === 'desc'))
1038
+ return orderBy([rankBy], docs);
1039
+ if (rankBy[1] === 'ANN' || rankBy[1] === 'kNN')
1040
+ return nearest(ns, rankBy, docs, metric);
1041
+ // Text ranking: BM25 clauses and their Sum / Max / Product combinators.
1042
+ const corpora = new Map();
1043
+ const scorer = textScorer(ns, rankBy, ctx, corpora);
1044
+ const out = [];
1045
+ for (const d of docs) {
1046
+ const s = scorer(d);
1047
+ if (s !== 0)
1048
+ out.push({ doc: d, dist: s }); // "Documents with a score of zero are excluded from results"
1049
+ }
1050
+ return out.sort((a, b) => (b.dist - a.dist) || compareIds(a.doc.id, b.doc.id));
1051
+ }
1052
+ function textScorer(ns, clause, ctx, corpora) {
1053
+ if (!Array.isArray(clause) || clause.length < 2)
1054
+ throw badRequest(`invalid rank_by clause: ${JSON.stringify(clause)}`);
1055
+ if (clause[0] === 'Sum' || clause[0] === 'Max') {
1056
+ if (!Array.isArray(clause[1]) || clause[1].length === 0)
1057
+ throw badRequest(`invalid rank_by: ${clause[0]} takes a non-empty array of clauses`);
1058
+ const parts = clause[1].map((c) => textScorer(ns, c, ctx, corpora));
1059
+ return clause[0] === 'Sum' ? (d) => parts.reduce((n, p) => n + p(d), 0) : (d) => Math.max(...parts.map((p) => p(d)));
1060
+ }
1061
+ if (clause[0] === 'Product') {
1062
+ const [w, inner] = typeof clause[1] === 'number' ? [clause[1], clause[2]] : [clause[2], clause[1]];
1063
+ if (typeof w !== 'number')
1064
+ throw badRequest('invalid rank_by: Product takes a number and a clause');
1065
+ const part = textScorer(ns, inner, ctx, corpora);
1066
+ return (d) => w * part(d);
1067
+ }
1068
+ // SparseKNN (https://turbopuffer.com/docs/query): "rank by the distance between a sparse vector attribute and a sparse
1069
+ // vector query", with the attribute's `dot_product` metric, and "compatible with FTS operators: Sum, Max". A larger dot
1070
+ // product ranks higher, and "Documents with a score of zero are excluded from results".
1071
+ if (clause[1] === 'SparseKNN') {
1072
+ const [attr, , q] = clause;
1073
+ const cfg = typeof attr === 'string' ? ns.schema[attr] : undefined;
1074
+ if (cfg === undefined || cfg.type !== '{}f16' || cfg.sparse_knn === undefined)
1075
+ throw badRequest(`invalid rank_by: SparseKNN needs a {}f16 attribute with sparse_knn configured, not ${JSON.stringify(attr)}`);
1076
+ if (q === null || typeof q !== 'object' || Array.isArray(q) || !Object.values(q).every((x) => typeof x === 'number'))
1077
+ throw badRequest('invalid rank_by: the SparseKNN query must be an object of numeric weights');
1078
+ const query = Object.entries(q);
1079
+ return (d) => {
1080
+ const v = d.attributes[attr];
1081
+ return v ? query.reduce((n, [k, w]) => n + w * (v[k] ?? 0), 0) : 0;
1082
+ };
1083
+ }
1084
+ if (clause[0] === 'Saturate' || clause[0] === 'Decay' || clause[0] === 'Dist' || clause[0] === 'Attribute')
1085
+ throw gap(`the ${clause[0]} rank_by expression`, 'turbopuffer.query.rank_expressions');
1086
+ // "Rank by filter: Filters can be used inside rank_by expressions to conditionally boost documents matching certain
1087
+ // criteria. Documents that pass the filter get a score of 1, and are otherwise scored 0" (https://turbopuffer.com/docs/query)
1088
+ if (clause[0] === 'And' || clause[0] === 'Or' || clause[0] === 'Not' || (typeof clause[1] === 'string' && !['BM25', 'asc', 'desc', 'ANN', 'kNN'].includes(clause[1]))) {
1089
+ const pass = compileFilter(clause, ctx);
1090
+ return (d) => (pass(d) ? 1 : 0);
1091
+ }
1092
+ if (clause[1] !== 'BM25')
1093
+ throw rankClauseRefusal(clause);
1094
+ const attr = clause[0];
1095
+ if (typeof attr !== 'string')
1096
+ throw badRequest('invalid rank_by: BM25 needs an attribute name');
1097
+ const config = ns.schema[attr];
1098
+ if (config === undefined || config.full_text_search === false)
1099
+ throw badRequest(`invalid rank_by: attribute "${attr}" does not have full-text search enabled`);
1100
+ const fts = config.full_text_search;
1101
+ assertModelledFts(attr, fts);
1102
+ const query = clause[2];
1103
+ if (typeof query !== 'string' && !(Array.isArray(query) && query.every((x) => typeof x === 'string')))
1104
+ throw badRequest('invalid rank_by: the BM25 query must be a string or an array of strings');
1105
+ const params = (clause[3] ?? {});
1106
+ if (typeof params !== 'object' || params === null || Array.isArray(params))
1107
+ throw badRequest('invalid rank_by: BM25 params must be an object');
1108
+ for (const k of Object.keys(params))
1109
+ if (k !== 'last_as_prefix')
1110
+ throw badRequest(`invalid rank_by: unknown BM25 param "${k}"`);
1111
+ const terms = queryTerms(query, fts, params.last_as_prefix === true);
1112
+ let corpus = corpora.get(attr);
1113
+ if (corpus === undefined) {
1114
+ // IDF and average length are over the WHOLE namespace, not the filtered subset.
1115
+ corpus = buildCorpus([...ns.docs.values()].map((d) => [d.key, attributeValue(d, attr)]), fts);
1116
+ corpora.set(attr, corpus);
1117
+ }
1118
+ const c = corpus;
1119
+ // Keep the shared token cache warm for any token filter in the same query.
1120
+ return (d) => { docTokens(ctx, attr, fts, d); return bm25Score(d.key, c, terms, fts); };
1121
+ }
1122
+ /** A rank_by clause that is none of the kinds modelled: an attribute order inside a text expression (the Query page's
1123
+ * "Rank by attribute" uses the `Attribute` operator, not modelled), or no rank_by at all. */
1124
+ function rankClauseRefusal(clause) {
1125
+ return clause[1] === 'asc' || clause[1] === 'desc' ? gap('an attribute order inside a text rank expression', 'turbopuffer.query.rank_expressions') : badRequest(`invalid rank_by clause: ${JSON.stringify(clause)}`);
1126
+ }
1127
+ function nearest(ns, rankBy, docs, metric) {
1128
+ const [attrRaw, op, targetRaw] = rankBy;
1129
+ let attr = attrRaw;
1130
+ let target = targetRaw;
1131
+ // ["Embed", text, {model?}]: the text embedded as the query vector (native embedding, above)
1132
+ if (Array.isArray(targetRaw) && targetRaw[0] === 'Embed') {
1133
+ const [, text, params] = targetRaw;
1134
+ if (typeof text !== 'string')
1135
+ throw badRequest('invalid rank_by: Embed takes the text to embed');
1136
+ const source = typeof attr === 'string' ? ns.schema[attr] : undefined;
1137
+ const model = params?.model !== undefined ? checkModel(params.model) : source?.embed?.model;
1138
+ if (model === undefined)
1139
+ throw badRequest(`invalid rank_by: Embed on ${JSON.stringify(attr)} needs a model: the attribute has no embed configured`);
1140
+ if (source?.embed)
1141
+ attr = source.embed.attribute;
1142
+ const col = attr === 'vector' ? (ns.vector_dims !== null ? `[${ns.vector_dims}]f32` : undefined) : typeof attr === 'string' ? ns.schema[attr]?.type : undefined;
1143
+ if (col === undefined || !isVectorType(col))
1144
+ throw badRequest(`invalid rank_by: attribute ${JSON.stringify(attr)} is not a vector attribute`);
1145
+ target = placeholderEmbedding(model, text, dimsOf(col));
1146
+ }
1147
+ const column = typeof attr === 'string' ? ns.schema[attr] : undefined;
1148
+ if (column !== undefined && isVectorArrayType(column.type))
1149
+ return lateInteraction(ns, attr, column, op, target, docs, metric);
1150
+ if (typeof attr !== 'string' || (attr !== 'vector' && (column === undefined || !isVectorType(column.type))))
1151
+ throw badRequest(`invalid rank_by: attribute ${JSON.stringify(attr)} is not a vector attribute`);
1152
+ const dims = attr === 'vector' ? ns.vector_dims : dimsOf(column.type);
1153
+ const valueOf = (d) => (attr === 'vector' ? d.vector : Array.isArray(d.attributes[attr]) ? d.attributes[attr] : null);
1154
+ if (!Array.isArray(target) || !target.every((x) => typeof x === 'number'))
1155
+ throw badRequest('invalid rank_by: the ANN target must be a vector of numbers or ["Embed", text]');
1156
+ const m = metric ?? ns.distance_metric;
1157
+ if (m !== 'cosine_distance' && m !== 'euclidean_squared')
1158
+ throw badRequest('invalid query: the namespace has no distance_metric; write vectors with one first');
1159
+ if (dims !== null && target.length !== dims)
1160
+ throw badRequest(`invalid query: the query vector has ${target.length} dimensions, but "${attr}" has ${dims}`);
1161
+ const q = target;
1162
+ const out = [];
1163
+ for (const d of docs) {
1164
+ const v = valueOf(d);
1165
+ if (v === null || v.length !== q.length)
1166
+ continue;
1167
+ out.push({ doc: d, dist: m === 'cosine_distance' ? cosineDistance(q, v) : euclideanSquared(q, v) });
1168
+ }
1169
+ return out.sort((a, b) => (a.dist - b.dist) || compareIds(a.doc.id, b.doc.id));
1170
+ }
1171
+ /** Late interaction (https://turbopuffer.com/docs/query): "Pass a query as an array of vectors with `ANN` to rank
1172
+ * documents with late interaction. … Each document's `$dist` is the sum of the distance from each query vector to its
1173
+ * closest vector in the document (lower is better), and results are ordered ascending"; `kNN` "for situations that
1174
+ * require perfect recall"; ANN needs the attribute's late-interaction index. The distance is the namespace's metric.
1175
+ * Where the docs stop and the twin decides: the feature "is in private beta. Contact us to enable late interaction for
1176
+ * your organization", and the twin enables it for every organization; ANN and kNN rank exactly alike here. */
1177
+ function lateInteraction(ns, attr, column, op, target, docs, metric) {
1178
+ if (op === 'ANN' && !column.late_interaction)
1179
+ throw badRequest(`invalid rank_by: attribute "${attr}" was stored with ann: false, so it takes kNN, not ANN`);
1180
+ const dims = dimsOf(column.type.slice(2));
1181
+ if (!Array.isArray(target) || target.length === 0 || !target.every((q) => Array.isArray(q) && q.length === dims && q.every((x) => typeof x === 'number')))
1182
+ throw badRequest(`invalid rank_by: a late-interaction query is a non-empty array of ${dims}-dimensional vectors`);
1183
+ const m = metric ?? ns.distance_metric;
1184
+ if (m !== 'cosine_distance' && m !== 'euclidean_squared')
1185
+ throw badRequest('invalid query: the namespace has no distance_metric; write vectors with one first');
1186
+ const dist = m === 'cosine_distance' ? cosineDistance : euclideanSquared;
1187
+ const out = [];
1188
+ for (const d of docs) {
1189
+ const vs = d.attributes[attr];
1190
+ if (!Array.isArray(vs) || vs.length === 0)
1191
+ continue;
1192
+ const score = target.reduce((n, q) => n + Math.min(...vs.map((v) => dist(q, v))), 0);
1193
+ out.push({ doc: d, dist: score });
1194
+ }
1195
+ return out.sort((a, b) => (a.dist - b.dist) || compareIds(a.doc.id, b.doc.id));
1196
+ }
1197
+ export function cosineDistance(a, b) {
1198
+ let dot = 0;
1199
+ let na = 0;
1200
+ let nb = 0;
1201
+ for (let i = 0; i < a.length; i++) {
1202
+ dot += a[i] * b[i];
1203
+ na += a[i] * a[i];
1204
+ nb += b[i] * b[i];
1205
+ }
1206
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
1207
+ // turbopuffer's vectors are f32, so their similarity is taken at f32 precision: two equal vectors are distance 0, as
1208
+ // the Query page's vector example answers (`{ "$dist": 0.0, "id": 1 }`), not a double's rounding residue
1209
+ return denom === 0 ? 1 : 1 - Math.fround(dot / denom);
1210
+ }
1211
+ export function euclideanSquared(a, b) {
1212
+ let s = 0;
1213
+ for (let i = 0; i < a.length; i++) {
1214
+ const x = a[i] - b[i];
1215
+ s += x * x;
1216
+ }
1217
+ return s;
1218
+ }
1219
+ function orderBy(orders, docs) {
1220
+ const keys = orders.map((o) => {
1221
+ if (!Array.isArray(o) || o.length !== 2 || typeof o[0] !== 'string' || (o[1] !== 'asc' && o[1] !== 'desc'))
1222
+ throw badRequest(`invalid rank_by: expected [attribute, "asc" | "desc"], got ${JSON.stringify(o)}`);
1223
+ return { attr: o[0], dir: o[1] === 'asc' ? 1 : -1 };
1224
+ });
1225
+ // absent values sort last, in either direction; documents equal on every key order by id
1226
+ const one = (a, b, attr, dir) => {
1227
+ const va = attributeValue(a, attr);
1228
+ const vb = attributeValue(b, attr);
1229
+ return va === null && vb === null ? 0 : va === null ? 1 : vb === null ? -1 : (attr === 'id' ? compareIds(va, vb) : compareScalars(va, vb)) * dir;
1230
+ };
1231
+ return docs.map((doc) => ({ doc })).sort((a, b) => keys.map(({ attr, dir }) => one(a.doc, b.doc, attr, dir)).find((c) => c !== 0 && !Number.isNaN(c)) ?? compareIds(a.doc.id, b.doc.id));
1232
+ }
1233
+ function projectRow(r, include, exclude) {
1234
+ const row = { id: r.doc.id };
1235
+ if (r.dist !== undefined)
1236
+ row.$dist = r.dist;
1237
+ let names = [];
1238
+ const all = [...Object.keys(r.doc.attributes), ...(r.doc.vector !== null ? ['vector'] : [])];
1239
+ if (exclude !== undefined) {
1240
+ if (!Array.isArray(exclude) || !exclude.every((x) => typeof x === 'string'))
1241
+ throw badRequest('invalid query: exclude_attributes must be an array of strings');
1242
+ if (include !== undefined && include !== false)
1243
+ throw badRequest('invalid query: include_attributes and exclude_attributes cannot both be set');
1244
+ names = all.filter((n) => !exclude.includes(n));
1245
+ }
1246
+ else if (include === true)
1247
+ names = all;
1248
+ else if (Array.isArray(include)) {
1249
+ if (!include.every((x) => typeof x === 'string'))
1250
+ throw badRequest('invalid query: include_attributes must be a boolean or an array of strings');
1251
+ names = include.filter((n) => n !== 'id');
1252
+ }
1253
+ else if (include !== undefined && include !== false)
1254
+ throw badRequest('invalid query: include_attributes must be a boolean or an array of strings');
1255
+ for (const n of names) {
1256
+ if (n === 'vector') {
1257
+ if (r.doc.vector !== null)
1258
+ row.vector = r.doc.vector;
1259
+ continue;
1260
+ }
1261
+ const v = r.doc.attributes[n];
1262
+ row[n] = v === undefined ? null : v;
1263
+ }
1264
+ return row;
1265
+ }
1266
+ /** The wire form of a namespace's schema: every attribute with its full resolved config. */
1267
+ export function schemaWire(ns) {
1268
+ const out = {};
1269
+ for (const [attr, c] of Object.entries(ns.schema)) {
1270
+ if (attr === 'id') {
1271
+ out.id = { type: c.type };
1272
+ continue;
1273
+ }
1274
+ out[attr] = {
1275
+ type: c.type,
1276
+ ...(isVectorType(c.type) ? { ann: { distance_metric: ns.distance_metric } } : { filterable: c.filterable }),
1277
+ ...(c.full_text_search !== false ? { full_text_search: c.full_text_search } : {}),
1278
+ ...(c.glob ? { glob: true } : {}),
1279
+ ...(c.regex ? { regex: true } : {}),
1280
+ ...(c.fuzzy ? { fuzzy: true } : {}),
1281
+ ...(c.sparse_knn ? { sparse_knn: c.sparse_knn } : {}),
1282
+ };
1283
+ }
1284
+ return out;
1285
+ }
1286
+ export function queryBilling(ns, returned) {
1287
+ return {
1288
+ billable_logical_bytes_queried: approxLogicalBytes(ns),
1289
+ billable_logical_bytes_returned: utf8Length(JSON.stringify(returned ?? null)),
1290
+ };
1291
+ }
1292
+ export function queryPerformance(ns) {
1293
+ return {
1294
+ // "last_included_write_at (string): the timestamp of the last write operation that the query observed"
1295
+ // (https://turbopuffer.com/docs/query, Response); the twin's queries observe every write
1296
+ last_included_write_at: ns.last_write_at,
1297
+ approx_namespace_size: ns.docs.size,
1298
+ cache_hit_ratio: 1,
1299
+ cache_temperature: 'hot',
1300
+ exhaustive_search_count: 0,
1301
+ query_execution_ms: 0,
1302
+ server_total_ms: 0,
1303
+ };
1304
+ }