endee 1.7.1 → 1.8.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Collection client for Endee-DB (v2 Collections API).
3
+ *
4
+ * A collection holds objects, each carrying values for one or more named, typed
5
+ * fields (dense `vector`, `sparse`, or `multi_vector`). Obtain a Collection via
6
+ * `Endee.getCollection()` (or after `Endee.createCollection()`).
7
+ */
8
+ import axios from 'axios';
9
+ import { encode, decode } from '@msgpack/msgpack';
10
+ import { jsonZip, jsonUnzip } from './crypto.js';
11
+ import { raiseException } from './exceptions.js';
12
+ import { DEFAULT_EF_SEARCH, DEFAULT_FILTER_BOOST_PERCENTAGE, DEFAULT_PREFILTER_THRESHOLD, DEFAULT_TOP_K, MAX_EF_SEARCH_ALLOWED, MAX_FILTER_BOOST_PERCENTAGE, MAX_KEY_BYTES, MAX_PREFILTER_THRESHOLD, MAX_TOP_K_ALLOWED, MAX_VALUE_BYTES, MAX_VECTORS_PER_BATCH, MIN_PREFILTER_THRESHOLD, NORM_EPSILON, } from './constants.js';
13
+ /**
14
+ * Reserved meta key. For cosine fields the client sends unit vectors (the server
15
+ * stores no norms), so we stash each vector's norm here at upsert time;
16
+ * `getObjects` multiplies the unit vector by it to reconstruct the original
17
+ * vector. The key is stripped from the meta the user gets back.
18
+ */
19
+ const NORMS_KEY = 'internal_';
20
+ // ── result decode helpers ─────────────────────────────────────────────────────
21
+ /** Best-effort decode of a result's meta (zlib+JSON bytes, plain JSON, or object). */
22
+ function decodeMeta(m) {
23
+ if (m === null || m === undefined || m === '')
24
+ return {};
25
+ if (m instanceof Uint8Array || Buffer.isBuffer(m)) {
26
+ if (m.length === 0)
27
+ return {};
28
+ const buf = Buffer.isBuffer(m) ? m : Buffer.from(m);
29
+ try {
30
+ return jsonUnzip(buf); // zlib + JSON (matches jsonZip on upsert)
31
+ }
32
+ catch {
33
+ try {
34
+ return JSON.parse(buf.toString('utf-8'));
35
+ }
36
+ catch {
37
+ return {};
38
+ }
39
+ }
40
+ }
41
+ if (typeof m === 'string') {
42
+ try {
43
+ return JSON.parse(m);
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ return m; // already a dict
50
+ }
51
+ /** Decode a result filter (JSON string/bytes) into an object. */
52
+ function decodeFilter(f) {
53
+ if (!f)
54
+ return {};
55
+ let s = f;
56
+ if (s instanceof Uint8Array || Buffer.isBuffer(s)) {
57
+ s = Buffer.from(s).toString('utf-8');
58
+ }
59
+ if (typeof s === 'string') {
60
+ try {
61
+ return JSON.parse(s);
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ return s;
68
+ }
69
+ /**
70
+ * The objects map in a SearchResult is keyed by integer (internal) ids. Depending
71
+ * on the msgpack decoder it comes back as a `Map` or a plain object (numeric keys
72
+ * coerced to strings). This normalizes both into a lookup by integer id.
73
+ */
74
+ function lookupObject(objectsMap, intId) {
75
+ if (objectsMap instanceof Map) {
76
+ return (objectsMap.get(intId) ?? objectsMap.get(String(intId)));
77
+ }
78
+ if (objectsMap && typeof objectsMap === 'object') {
79
+ return objectsMap[String(intId)];
80
+ }
81
+ return undefined;
82
+ }
83
+ /**
84
+ * Decode one ObjectMeta entry from the SearchResult objects map.
85
+ * Wire layout: ObjectMeta = [strId, metaBytes, filterStr].
86
+ */
87
+ function decodeObjectMeta(intId, objectsMap) {
88
+ const obj = lookupObject(objectsMap, intId);
89
+ if (obj === undefined) {
90
+ return { id: String(intId), meta: {}, filter: {}, similarity: 0 };
91
+ }
92
+ const meta = obj.length > 1 ? decodeMeta(obj[1]) : {};
93
+ delete meta[NORMS_KEY]; // search results never expose the internal norms key
94
+ return {
95
+ id: obj[0],
96
+ meta,
97
+ filter: obj.length > 2 ? decodeFilter(obj[2]) : {},
98
+ similarity: 0,
99
+ };
100
+ }
101
+ /** Decode a field's ranked hits ([[intId, score], ...]) into result objects. */
102
+ function decodeFieldHits(hits, objectsMap, limit) {
103
+ const out = [];
104
+ for (const hit of (hits || []).slice(0, limit)) {
105
+ const d = decodeObjectMeta(hit[0], objectsMap);
106
+ d.similarity = Number(hit[1]);
107
+ out.push(d);
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * Decode one full object from a get-objects ObjectBatch.
113
+ * Wire layout: Object = [id, meta, filter, vectors, sparses, multi_vectors].
114
+ *
115
+ * For cosine fields the server stores unit vectors; we multiply them by the
116
+ * norms stashed in meta (`internal_`) at upsert time to return the original
117
+ * vectors. The `internal_` entry is then stripped from the returned meta.
118
+ */
119
+ function decodeFullObject(obj) {
120
+ const meta = obj.length > 1 ? decodeMeta(obj[1]) : {};
121
+ const norms = (meta[NORMS_KEY] ?? {});
122
+ const vectorsRaw = (obj.length > 3 ? obj[3] : {});
123
+ const sparsesRaw = (obj.length > 4 ? obj[4] : {});
124
+ const multiRaw = (obj.length > 5 ? obj[5] : {});
125
+ // Rebuild originals: unit_vector * norm (cosine fields only; others have no norm).
126
+ const vectors = {};
127
+ for (const [k, v] of mapEntries(vectorsRaw)) {
128
+ const vec = Array.from(v);
129
+ const n = norms[k];
130
+ vectors[k] = typeof n === 'number' && Number.isFinite(n) ? vec.map((x) => x * n) : vec;
131
+ }
132
+ const sparses = {};
133
+ for (const [k, v] of mapEntries(sparsesRaw)) {
134
+ sparses[k] = { indices: Array.from(v[0]), values: Array.from(v[1]) };
135
+ }
136
+ const multi_vectors = {};
137
+ for (const [k, v] of mapEntries(multiRaw)) {
138
+ const ns = norms[k];
139
+ multi_vectors[k] = v.map((member, i) => {
140
+ const row = Array.from(member);
141
+ if (Array.isArray(ns) && i < ns.length)
142
+ return row.map((x) => x * ns[i]);
143
+ return row;
144
+ });
145
+ }
146
+ delete meta[NORMS_KEY]; // hide the internal norms key from the user
147
+ return {
148
+ id: obj[0],
149
+ meta,
150
+ filter: obj.length > 2 ? decodeFilter(obj[2]) : {},
151
+ vectors,
152
+ sparses,
153
+ multi_vectors,
154
+ };
155
+ }
156
+ function mapEntries(m) {
157
+ if (!m)
158
+ return [];
159
+ if (m instanceof Map)
160
+ return Array.from(m.entries());
161
+ return Object.entries(m);
162
+ }
163
+ // ── normalization helpers (cosine-only, mirrors the Python client) ─────────────
164
+ /** L2-normalize a dense vector for cosine; returns [vector, norm]. */
165
+ function normalizeDense(vec, spaceType) {
166
+ for (const v of vec) {
167
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
168
+ throw new Error('dense vector contains NaN or infinity');
169
+ }
170
+ }
171
+ if (spaceType !== 'cosine')
172
+ return [vec.slice(), 1.0];
173
+ let sum = 0;
174
+ for (const v of vec)
175
+ sum += v * v;
176
+ const norm = Math.max(Math.sqrt(sum), NORM_EPSILON);
177
+ return [vec.map((v) => v / norm), norm];
178
+ }
179
+ /** L2-normalize each sub-vector for cosine; returns [vectors, norms]. */
180
+ function normalizeMulti(vectors, spaceType) {
181
+ if (!Array.isArray(vectors) || vectors.length === 0 || !Array.isArray(vectors[0])) {
182
+ throw new Error('multi_vector field must be a list of equal-length vectors');
183
+ }
184
+ for (const row of vectors) {
185
+ for (const v of row) {
186
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
187
+ throw new Error('multi_vector contains NaN or infinity');
188
+ }
189
+ }
190
+ }
191
+ if (spaceType !== 'cosine')
192
+ return [vectors.map((r) => r.slice()), vectors.map(() => 1.0)];
193
+ const out = [];
194
+ const norms = [];
195
+ for (const row of vectors) {
196
+ let sum = 0;
197
+ for (const v of row)
198
+ sum += v * v;
199
+ const norm = Math.max(Math.sqrt(sum), NORM_EPSILON);
200
+ out.push(row.map((v) => v / norm));
201
+ norms.push(norm);
202
+ }
203
+ return [out, norms];
204
+ }
205
+ /** Accept {indices/values}, {sparse_indices/sparse_values}, or {position/values}. */
206
+ function extractSparse(data) {
207
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
208
+ throw new Error('sparse field expects a dict with indices and values');
209
+ }
210
+ const indicesRaw = data.indices ?? data.sparse_indices ?? data.position ?? [];
211
+ const valuesRaw = data.values ?? data.sparse_values ?? [];
212
+ const indices = indicesRaw.map((i) => Math.trunc(i));
213
+ const values = valuesRaw.map((v) => Number(v));
214
+ if (indices.length !== values.length) {
215
+ throw new Error(`sparse indices and values must match in length (${indices.length} vs ${values.length})`);
216
+ }
217
+ return [indices, values];
218
+ }
219
+ /** Filter key/value size guards (mirrors the Python client). */
220
+ function validateFilter(flt) {
221
+ if (typeof flt !== 'object' || flt === null || Array.isArray(flt))
222
+ return;
223
+ for (const [key, value] of Object.entries(flt)) {
224
+ if (Buffer.byteLength(String(key), 'utf8') > MAX_KEY_BYTES) {
225
+ throw new Error(`Filter key must be <= ${MAX_KEY_BYTES} bytes: ${key}`);
226
+ }
227
+ const valuesToCheck = Array.isArray(value) ? value : [value];
228
+ for (const v of valuesToCheck) {
229
+ if (typeof v === 'string' && Buffer.byteLength(v, 'utf8') > MAX_VALUE_BYTES) {
230
+ throw new Error(`Filter value must be <= ${MAX_VALUE_BYTES} bytes`);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ function isConfigDict(x) {
236
+ return typeof x === 'object' && x !== null && !Array.isArray(x) && 'query' in x;
237
+ }
238
+ /**
239
+ * Resolve a field's `limit` (max hits to return for this field). A missing
240
+ * limit defaults to `DEFAULT_TOP_K`; the value must be in [1, MAX_TOP_K_ALLOWED].
241
+ */
242
+ function resolveFieldLimit(fieldName, limit) {
243
+ if (limit === undefined || limit === null)
244
+ return DEFAULT_TOP_K;
245
+ if (typeof limit !== 'number' ||
246
+ !Number.isInteger(limit) ||
247
+ limit < 1 ||
248
+ limit > MAX_TOP_K_ALLOWED) {
249
+ throw new Error(`Search field '${fieldName}': limit must be an integer between 1 and ${MAX_TOP_K_ALLOWED}.`);
250
+ }
251
+ return limit;
252
+ }
253
+ /**
254
+ * Build one entry for the `fields` array sent to the server from the unified
255
+ * per-field config: `{ query, limit?, ef_search? }`. `query` is required.
256
+ * Returns `[entry, fieldLimit]` so the caller can truncate decoded hits to the
257
+ * same per-field limit.
258
+ */
259
+ function buildFieldSearchEntry(fieldName, fieldData, efSearch) {
260
+ if (!isConfigDict(fieldData)) {
261
+ throw new Error(`Search field '${fieldName}' must be an object of the form { query, limit?, ef_search? }.`);
262
+ }
263
+ const { query, limit, ef_search, ...rest } = fieldData;
264
+ const fieldLimit = resolveFieldLimit(fieldName, limit);
265
+ const cfg = {
266
+ ...rest,
267
+ query,
268
+ limit: fieldLimit,
269
+ ef_search: ef_search === undefined ? efSearch : ef_search,
270
+ };
271
+ return [{ [fieldName]: cfg }, fieldLimit];
272
+ }
273
+ export class Collection {
274
+ name;
275
+ token;
276
+ v2Url;
277
+ fields;
278
+ createdAt;
279
+ layoutVersion;
280
+ http;
281
+ constructor(name, token, v2Url, metadata, http) {
282
+ this.name = name;
283
+ this.token = token;
284
+ this.v2Url = v2Url;
285
+ this.fields = metadata.fields ?? [];
286
+ this.createdAt = metadata.created_at;
287
+ this.layoutVersion = metadata.layout_version ?? 1;
288
+ this.http = http ?? axios.create();
289
+ }
290
+ toString() {
291
+ return this.name;
292
+ }
293
+ /** name → { type, space_type, dimension } from the collection metadata. */
294
+ fieldIndex() {
295
+ const idx = {};
296
+ for (const f of this.fields) {
297
+ const params = f.params ?? {};
298
+ idx[f.name] = {
299
+ type: f.type ?? 'vector',
300
+ space_type: params.space_type ?? 'cosine',
301
+ dimension: params.dimension ?? 0,
302
+ };
303
+ }
304
+ return idx;
305
+ }
306
+ authHeaders(extra = {}) {
307
+ return { Authorization: this.token, ...extra };
308
+ }
309
+ // ── upsert ───────────────────────────────────────────────────────────────
310
+ /**
311
+ * Insert or update objects. Cosine `vector`/`multi_vector` values are
312
+ * L2-normalized client-side; the batch is serialized as msgpack into the
313
+ * server ObjectBatch layout and POSTed as `application/msgpack`.
314
+ *
315
+ * Wire layout (maps keyed by field name):
316
+ * ObjectBatch = [ objects ]
317
+ * Object = [ id, meta, filter, vectors, sparses, multi_vectors ]
318
+ * vectors = { fieldName: [float, ...] }
319
+ * sparses = { fieldName: [indices, values] }
320
+ * multi_vectors = { fieldName: [[float, ...], ...] }
321
+ *
322
+ * Returns `{ upserted: <count> }`.
323
+ */
324
+ async upsert(objects) {
325
+ if (!Array.isArray(objects)) {
326
+ throw new Error('objects must be an array');
327
+ }
328
+ if (objects.length > MAX_VECTORS_PER_BATCH) {
329
+ throw new Error(`Cannot upsert more than ${MAX_VECTORS_PER_BATCH} objects at a time (got ${objects.length})`);
330
+ }
331
+ const fieldIdx = this.fieldIndex();
332
+ const wireObjects = [];
333
+ const seenIds = new Set();
334
+ for (const obj of objects) {
335
+ const oid = String(obj.id ?? '');
336
+ if (!oid)
337
+ throw new Error('object id must be a non-empty string');
338
+ if (seenIds.has(oid))
339
+ throw new Error(`Duplicate id in batch: ${oid}`);
340
+ seenIds.add(oid);
341
+ // filter → JSON string ("" when absent)
342
+ const flt = obj.filter;
343
+ let filterStr;
344
+ if (flt === null || flt === undefined) {
345
+ filterStr = '';
346
+ }
347
+ else if (typeof flt === 'string') {
348
+ filterStr = flt;
349
+ }
350
+ else {
351
+ validateFilter(flt);
352
+ filterStr = JSON.stringify(flt);
353
+ }
354
+ // For cosine fields we also collect the per-vector norm(s) to stash in
355
+ // meta so getObjects can reconstruct the original (non-unit) vectors.
356
+ const vectors = {};
357
+ const sparses = {};
358
+ const multiVectors = {};
359
+ const norms = {};
360
+ for (const [fname, fdata] of Object.entries(obj.fields ?? {})) {
361
+ const cfg = fieldIdx[fname];
362
+ if (cfg === undefined) {
363
+ throw new Error(`Unknown field '${fname}'. Collection fields: ${Object.keys(fieldIdx).join(', ')}`);
364
+ }
365
+ const dim = cfg.dimension || 0;
366
+ const space = cfg.space_type ?? 'cosine';
367
+ if (cfg.type === 'vector') {
368
+ const [vec, norm] = normalizeDense(fdata, space);
369
+ if (dim && vec.length !== dim) {
370
+ throw new Error(`Field '${fname}': expected dimension ${dim}, got ${vec.length}`);
371
+ }
372
+ vectors[fname] = vec;
373
+ if (space === 'cosine')
374
+ norms[fname] = norm;
375
+ }
376
+ else if (cfg.type === 'sparse') {
377
+ const [indices, values] = extractSparse(fdata);
378
+ sparses[fname] = [indices, values];
379
+ }
380
+ else if (cfg.type === 'multi_vector') {
381
+ const [vecs, mnorms] = normalizeMulti(fdata, space);
382
+ if (dim && vecs.some((v) => v.length !== dim)) {
383
+ throw new Error(`Field '${fname}': every multi_vector must have dimension ${dim}`);
384
+ }
385
+ multiVectors[fname] = vecs;
386
+ if (space === 'cosine')
387
+ norms[fname] = mnorms; // one norm per member
388
+ }
389
+ else {
390
+ throw new Error(`Field '${fname}' has unknown type '${cfg.type}'`);
391
+ }
392
+ }
393
+ // meta → zlib(JSON(...)) bytes. Stash cosine norms under the reserved
394
+ // `internal_` key so getObjects can rebuild the original vectors.
395
+ // Pre-encoded bytes meta is sent as-is and cannot carry norms.
396
+ const rawMeta = obj.meta;
397
+ let metaBytes;
398
+ if (rawMeta instanceof Uint8Array || Buffer.isBuffer(rawMeta)) {
399
+ metaBytes = Buffer.from(rawMeta);
400
+ }
401
+ else {
402
+ const metaDict = {
403
+ ...(rawMeta ?? {}),
404
+ };
405
+ if (Object.keys(norms).length > 0)
406
+ metaDict[NORMS_KEY] = norms;
407
+ metaBytes = jsonZip(metaDict);
408
+ }
409
+ // Object = [id, meta, filter, vectors, sparses, multi_vectors]
410
+ wireObjects.push([oid, metaBytes, filterStr, vectors, sparses, multiVectors]);
411
+ }
412
+ // ObjectBatch = [objects] (single-element array)
413
+ const payload = encode([wireObjects], { forceFloat32: true });
414
+ let response;
415
+ try {
416
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/objects`, payload, {
417
+ headers: this.authHeaders({ 'Content-Type': 'application/msgpack' }),
418
+ responseType: 'arraybuffer',
419
+ });
420
+ }
421
+ catch (err) {
422
+ if (axios.isAxiosError(err) && err.response) {
423
+ raiseException(err.response.status, err.response.data);
424
+ }
425
+ throw err;
426
+ }
427
+ return parseJsonResponse(response.data, { status: 'ok' });
428
+ }
429
+ // ── search ───────────────────────────────────────────────────────────────
430
+ /**
431
+ * Search the collection across one or more fields in a single request.
432
+ *
433
+ * Each field is queried with the unified config `{ query, limit?, ef_search? }`,
434
+ * where `limit` is the max number of hits to return for that field (defaults
435
+ * to `DEFAULT_TOP_K`). The result is always one ranked list per field:
436
+ * `{ results: { [field]: SearchHit[] } }`. Use `rerank()` to fuse these into a
437
+ * single ranked list.
438
+ *
439
+ * For filtered searches, `prefilterCardinalityThreshold` and
440
+ * `filterBoostPercentage` tune the speed/recall trade-off; when either is
441
+ * set they're sent to the server as `filter_params`.
442
+ */
443
+ async search(options) {
444
+ const { fields, filter = null, efSearch = DEFAULT_EF_SEARCH, prefilterCardinalityThreshold, filterBoostPercentage, } = options;
445
+ const fieldNames = Object.keys(fields ?? {});
446
+ if (fieldNames.length === 0)
447
+ throw new Error('search requires at least one field');
448
+ if (!(efSearch > 0 && efSearch <= MAX_EF_SEARCH_ALLOWED)) {
449
+ throw new Error(`ef_search must be between 1 and ${MAX_EF_SEARCH_ALLOWED}`);
450
+ }
451
+ // Optional filter-tuning params (filtered search only). Validate the ranges
452
+ // up front; they're only sent to the server when explicitly provided.
453
+ if (prefilterCardinalityThreshold !== undefined) {
454
+ const t = prefilterCardinalityThreshold;
455
+ if (!Number.isFinite(t) || t < MIN_PREFILTER_THRESHOLD || t > MAX_PREFILTER_THRESHOLD) {
456
+ throw new Error(`prefilterCardinalityThreshold must be between ${MIN_PREFILTER_THRESHOLD} and ${MAX_PREFILTER_THRESHOLD}`);
457
+ }
458
+ }
459
+ if (filterBoostPercentage !== undefined) {
460
+ const b = filterBoostPercentage;
461
+ if (!Number.isFinite(b) || b < 0 || b > MAX_FILTER_BOOST_PERCENTAGE) {
462
+ throw new Error(`filterBoostPercentage must be between 0 and ${MAX_FILTER_BOOST_PERCENTAGE}`);
463
+ }
464
+ }
465
+ // L2-normalize query vectors for cosine fields (mirrors upsert).
466
+ const fieldIdx = this.fieldIndex();
467
+ const fieldsArray = [];
468
+ const fieldLimits = {};
469
+ for (const fname of fieldNames) {
470
+ const fieldData = fields[fname];
471
+ if (!isConfigDict(fieldData)) {
472
+ throw new Error(`Search field '${fname}' must be an object of the form { query, limit?, ef_search? }.`);
473
+ }
474
+ const normalized = { ...fieldData };
475
+ let q = fieldData.query;
476
+ const cfg = fieldIdx[fname];
477
+ if (cfg) {
478
+ const spaceType = cfg.space_type ?? 'cosine';
479
+ if (cfg.type === 'vector' && spaceType === 'cosine') {
480
+ if (Array.isArray(q) && q.length > 0 && !Array.isArray(q[0])) {
481
+ [q] = normalizeDense(q, spaceType);
482
+ }
483
+ }
484
+ else if (cfg.type === 'multi_vector' && spaceType === 'cosine') {
485
+ if (Array.isArray(q) && q.length > 0 && Array.isArray(q[0])) {
486
+ [q] = normalizeMulti(q, spaceType);
487
+ }
488
+ }
489
+ }
490
+ normalized.query = q;
491
+ const [entry, fieldLimit] = buildFieldSearchEntry(fname, normalized, efSearch);
492
+ fieldsArray.push(entry);
493
+ fieldLimits[fname] = fieldLimit;
494
+ }
495
+ const payload = { fields: fieldsArray };
496
+ if (filter !== null)
497
+ payload.filter = filter;
498
+ // Filter tuning: send filter_params only when the caller tunes at least one
499
+ // knob, defaulting the other so the server gets a complete object.
500
+ if (prefilterCardinalityThreshold !== undefined || filterBoostPercentage !== undefined) {
501
+ payload.filter_params = {
502
+ prefilter_threshold: prefilterCardinalityThreshold ?? DEFAULT_PREFILTER_THRESHOLD,
503
+ boost_percentage: filterBoostPercentage ?? DEFAULT_FILTER_BOOST_PERCENTAGE,
504
+ };
505
+ }
506
+ let response;
507
+ try {
508
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/search`, payload, {
509
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
510
+ responseType: 'arraybuffer',
511
+ });
512
+ }
513
+ catch (err) {
514
+ if (axios.isAxiosError(err) && err.response) {
515
+ raiseException(err.response.status, err.response.data);
516
+ }
517
+ throw err;
518
+ }
519
+ // SearchResult: [objectsMap, resultsMap]
520
+ // objectsMap = { intId → ObjectMeta }
521
+ // resultsMap = { fieldName → [[intId, score], ...] }
522
+ const raw = decode(new Uint8Array(response.data));
523
+ const objectsMap = raw[0];
524
+ const resultsMap = raw[1];
525
+ const getField = (name) => {
526
+ if (resultsMap instanceof Map)
527
+ return (resultsMap.get(name) ?? []);
528
+ return (resultsMap?.[name] ?? []);
529
+ };
530
+ // Always per-field results, unfused. Each field is truncated to its own
531
+ // limit (already applied server-side via the per-field limit).
532
+ const perField = {};
533
+ for (const fname of fieldNames) {
534
+ perField[fname] = decodeFieldHits(getField(fname), objectsMap, fieldLimits[fname]);
535
+ }
536
+ return { results: perField };
537
+ }
538
+ // ── object operations ──────────────────────────────────────────────────────
539
+ /** Delete a single object by id. Returns `{ deleted: "<id>" }`. */
540
+ async deleteObject(id) {
541
+ let response;
542
+ try {
543
+ response = await this.http.delete(`${this.v2Url}/collections/${this.name}/objects/${id}`, {
544
+ headers: this.authHeaders(),
545
+ });
546
+ }
547
+ catch (err) {
548
+ if (axios.isAxiosError(err) && err.response) {
549
+ raiseException(err.response.status, err.response.data);
550
+ }
551
+ throw err;
552
+ }
553
+ return response.data;
554
+ }
555
+ /**
556
+ * Fetch full objects (meta, filter, and stored vectors) by id. Ids that don't
557
+ * exist are skipped by the server.
558
+ */
559
+ async getObjects(ids) {
560
+ if (!Array.isArray(ids) || ids.length === 0) {
561
+ throw new Error('getObjects requires a non-empty array of ids');
562
+ }
563
+ let response;
564
+ try {
565
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/objects/query`, { ids: ids.map((i) => String(i)) }, {
566
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
567
+ responseType: 'arraybuffer',
568
+ });
569
+ }
570
+ catch (err) {
571
+ if (axios.isAxiosError(err) && err.response) {
572
+ raiseException(err.response.status, err.response.data);
573
+ }
574
+ throw err;
575
+ }
576
+ // ObjectBatch = [objects]
577
+ const batch = decode(new Uint8Array(response.data));
578
+ let objects = [];
579
+ if (Array.isArray(batch)) {
580
+ objects = batch[0] ?? [];
581
+ }
582
+ else if (batch && typeof batch === 'object') {
583
+ objects = batch.objects ?? [];
584
+ }
585
+ return (objects || []).map((o) => decodeFullObject(o));
586
+ }
587
+ /**
588
+ * Delete all objects matching a filter. Returns `{ deleted: <count> }`.
589
+ * `filter` uses the array format, e.g. `[{ category: { $eq: 'news' } }]`.
590
+ */
591
+ async deleteByFilter(filter) {
592
+ if (!Array.isArray(filter)) {
593
+ throw new Error('filter must be an array, e.g. [{ field: { $op: value } }]');
594
+ }
595
+ let response;
596
+ try {
597
+ response = await this.http.delete(`${this.v2Url}/collections/${this.name}/objects`, {
598
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
599
+ data: { filter },
600
+ });
601
+ }
602
+ catch (err) {
603
+ if (axios.isAxiosError(err) && err.response) {
604
+ raiseException(err.response.status, err.response.data);
605
+ }
606
+ throw err;
607
+ }
608
+ return response.data;
609
+ }
610
+ /**
611
+ * Update filter tags on existing objects (no re-upsert of vectors).
612
+ * Returns `{ updated: <count> }`. `updates` is a list of `{ id, filter }`.
613
+ */
614
+ async updateFilters(updates) {
615
+ if (!Array.isArray(updates) || updates.length === 0) {
616
+ throw new Error('updates must be a non-empty array of { id, filter } objects');
617
+ }
618
+ let response;
619
+ try {
620
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/filters`, { updates }, { headers: this.authHeaders({ 'Content-Type': 'application/json' }) });
621
+ }
622
+ catch (err) {
623
+ if (axios.isAxiosError(err) && err.response) {
624
+ raiseException(err.response.status, err.response.data);
625
+ }
626
+ throw err;
627
+ }
628
+ return response.data;
629
+ }
630
+ // ── maintenance ──────────────────────────────────────────────────────────
631
+ /** Return collection metadata: `{ name, fields, created_at, layout_version }`. */
632
+ async describe() {
633
+ let response;
634
+ try {
635
+ response = await this.http.get(`${this.v2Url}/collections/${this.name}`, {
636
+ headers: this.authHeaders(),
637
+ });
638
+ }
639
+ catch (err) {
640
+ if (axios.isAxiosError(err) && err.response) {
641
+ raiseException(err.response.status, err.response.data);
642
+ }
643
+ throw err;
644
+ }
645
+ return response.data;
646
+ }
647
+ /**
648
+ * Rebuild a vector field's HNSW graph with new M / ef_con (async).
649
+ * Only M/ef_con may change; dimension/space_type/precision are immutable.
650
+ * Poll progress with `rebuildStatus()`.
651
+ */
652
+ async rebuild(options) {
653
+ const { field, m, efCon } = options;
654
+ if (!field)
655
+ throw new Error('rebuild requires the vector field name');
656
+ const body = { field };
657
+ if (m !== undefined)
658
+ body.M = m;
659
+ if (efCon !== undefined)
660
+ body.ef_con = efCon;
661
+ let response;
662
+ try {
663
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/rebuild`, body, {
664
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
665
+ });
666
+ }
667
+ catch (err) {
668
+ if (axios.isAxiosError(err) && err.response) {
669
+ raiseException(err.response.status, err.response.data);
670
+ }
671
+ throw err;
672
+ }
673
+ return response.data;
674
+ }
675
+ /** Poll rebuild progress. */
676
+ async rebuildStatus() {
677
+ let response;
678
+ try {
679
+ response = await this.http.get(`${this.v2Url}/collections/${this.name}/rebuild/status`, {
680
+ headers: this.authHeaders(),
681
+ });
682
+ }
683
+ catch (err) {
684
+ if (axios.isAxiosError(err) && err.response) {
685
+ raiseException(err.response.status, err.response.data);
686
+ }
687
+ throw err;
688
+ }
689
+ return response.data;
690
+ }
691
+ /** Defragment the collection's storage in place. */
692
+ async shrink() {
693
+ let response;
694
+ try {
695
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/shrink`, undefined, {
696
+ headers: this.authHeaders(),
697
+ });
698
+ }
699
+ catch (err) {
700
+ if (axios.isAxiosError(err) && err.response) {
701
+ raiseException(err.response.status, err.response.data);
702
+ }
703
+ throw err;
704
+ }
705
+ return response.data;
706
+ }
707
+ /**
708
+ * Start an async backup of this collection. Returns
709
+ * `{ backup_name, status: "in_progress" }`. Poll `Endee.activeBackup()` /
710
+ * `Endee.listBackups()` for completion.
711
+ */
712
+ async createBackup(name) {
713
+ if (!name)
714
+ throw new Error('backup name is required');
715
+ let response;
716
+ try {
717
+ response = await this.http.post(`${this.v2Url}/collections/${this.name}/backups`, { name }, { headers: this.authHeaders({ 'Content-Type': 'application/json' }) });
718
+ }
719
+ catch (err) {
720
+ if (axios.isAxiosError(err) && err.response) {
721
+ raiseException(err.response.status, err.response.data);
722
+ }
723
+ throw err;
724
+ }
725
+ return response.data;
726
+ }
727
+ }
728
+ /** Parse a possibly-empty arraybuffer/JSON response body, with a fallback. */
729
+ function parseJsonResponse(data, fallback) {
730
+ let text;
731
+ if (data instanceof ArrayBuffer) {
732
+ text = new TextDecoder().decode(data);
733
+ }
734
+ else if (data instanceof Uint8Array || Buffer.isBuffer(data)) {
735
+ text = Buffer.from(data).toString('utf-8');
736
+ }
737
+ else if (typeof data === 'string') {
738
+ text = data;
739
+ }
740
+ else if (data && typeof data === 'object') {
741
+ return data;
742
+ }
743
+ else {
744
+ return fallback;
745
+ }
746
+ if (!text)
747
+ return fallback;
748
+ try {
749
+ return JSON.parse(text);
750
+ }
751
+ catch {
752
+ return { status: text };
753
+ }
754
+ }
755
+ //# sourceMappingURL=collection.js.map