@lde/search-typesense 0.36.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/search.d.ts +2 -2
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +262 -78
- package/package.json +2 -2
package/dist/search.d.ts
CHANGED
|
@@ -69,8 +69,8 @@ export interface LabelSource {
|
|
|
69
69
|
* reference field from its own label source’s collection, all sources bundled
|
|
70
70
|
* into one lookup – and reconstructs the engine-neutral
|
|
71
71
|
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
72
|
-
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants)
|
|
73
|
-
*
|
|
72
|
+
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) in
|
|
73
|
+
* one `multi_search` round-trip with one shared label lookup; a failed
|
|
74
74
|
* entry is reported in place as a per-query outcome, so it never discards
|
|
75
75
|
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
76
76
|
* logical documents.
|
package/dist/search.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAIL,KAAK,cAAc,EAMnB,KAAK,UAAU,EAGf,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AA0BrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,aAAa,CAAC;AAErB;;;;sDAIsD;AACtD,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAIhC,SAAQ,IAAI,CAAC,wBAAwB,EAAE,eAAe,CAAC;IACvD;;;;;;;;OAQG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACnE;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB,CACpC,KAAK,SAAS,SAAS,UAAU,EAAE,GAAG,SAAS,UAAU,EAAE,CAC3D,SAAQ,YAAY,CAAC,KAAK,CAAC;IAC3B;;;;;;;OAOG;IACH,iBAAiB,CAAC,UAAU,EAAE,UAAU,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC;CAC1D;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,GAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAM,GAChE,qBAAqB,CAAC,KAAK,CAAC,CAic9B;AAyCD,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAmXD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAwDtC;AAgCD,oEAAoE;AACpE,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EAAE,CAAC;IAC1E,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS;QAC/B;;2EAEmE;QACnE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS;YACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;YACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;SACxB,EAAE,CAAC;QACJ;;qFAE6E;QAC7E,QAAQ,CAAC,KAAK,CAAC,EACX;YAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;SAAE,GAC9C;YAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC;YAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAC;KACpD,EAAE,CAAC;CACL;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,EAC3C,MAAM,EAAE,YAAY,EACpB,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,GACjD,YAAY,CAuDd"}
|
package/dist/search.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NESTED_DOCUMENT_TYPE, } from '@lde/search';
|
|
2
|
-
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isBinnedRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNamesOf, labelTargetNamesOf, localLookupTargetsOf, nestedReferenceType, outputFields, physicalFields, referencedTargetsOf, referenceFields, storedTargetOf, } from '@lde/search/adapter';
|
|
2
|
+
import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, facetSearches, fieldNamed, filterOn, inheritedFacetPolicies, isInlineReference, isBinnedRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNamesOf, labelTargetNamesOf, localLookupTargetsOf, nestedReferenceType, outputFields, physicalFields, referencedTargetsOf, referenceFields, storedTargetOf, } from '@lde/search/adapter';
|
|
3
3
|
import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
|
|
4
4
|
import { deriveCollectionName } from './collection-name.js';
|
|
5
5
|
import { resolveProjection, } from './lookup.js';
|
|
@@ -13,8 +13,8 @@ import { resolveProjection, } from './lookup.js';
|
|
|
13
13
|
* reference field from its own label source’s collection, all sources bundled
|
|
14
14
|
* into one lookup – and reconstructs the engine-neutral
|
|
15
15
|
* {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
|
|
16
|
-
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants)
|
|
17
|
-
*
|
|
16
|
+
* a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) in
|
|
17
|
+
* one `multi_search` round-trip with one shared label lookup; a failed
|
|
18
18
|
* entry is reported in place as a per-query outcome, so it never discards
|
|
19
19
|
* its siblings’ facets. Every engine specific stays here; consumers see only
|
|
20
20
|
* logical documents.
|
|
@@ -220,6 +220,63 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
220
220
|
return new Map();
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Answer the searches each caller’s query needs and fold them back into one
|
|
225
|
+
* response per caller: the hits and total of its first search, the facets
|
|
226
|
+
* of all. The searches travel together in one `multi_search` round-trip
|
|
227
|
+
* (split into requests of {@link MULTI_SEARCH_LIMIT}) – a POST, so a long
|
|
228
|
+
* `filter_by` (a batch `id` lookup, any membership over many IRIs) is
|
|
229
|
+
* bounded by the caller’s request, not by Typesense’s 4000-char
|
|
230
|
+
* query-string limit. A search asking for no document is answered empty
|
|
231
|
+
* without being sent: its only filter would compile away and hand back the
|
|
232
|
+
* whole collection ({@link isUnsatisfiable}). Each facet then reports the
|
|
233
|
+
* values its search selected ({@link withSelectedValues}).
|
|
234
|
+
*
|
|
235
|
+
* A failed search fails exactly its caller, which gets an `Error` in its
|
|
236
|
+
* place; only the transport failing rejects.
|
|
237
|
+
*/
|
|
238
|
+
async function answer(searchType, callers) {
|
|
239
|
+
const collection = collections.get(searchType.class);
|
|
240
|
+
const planned = callers.flat();
|
|
241
|
+
const dispatched = planned.filter(({ query }) => !isUnsatisfiable(query));
|
|
242
|
+
const results = await performInChunks(client, dispatched.map(({ query }) => ({
|
|
243
|
+
collection,
|
|
244
|
+
...buildSearchParams(query, searchType, searchParamsOptions),
|
|
245
|
+
})));
|
|
246
|
+
const transportFailure = results.find((result) => result instanceof Error);
|
|
247
|
+
if (transportFailure !== undefined) {
|
|
248
|
+
throw transportFailure;
|
|
249
|
+
}
|
|
250
|
+
const declaredNames = declaredFacetNames.get(searchType.class);
|
|
251
|
+
let entry = 0;
|
|
252
|
+
const responses = planned.map((search) => {
|
|
253
|
+
if (isUnsatisfiable(search.query)) {
|
|
254
|
+
return EMPTY_RESPONSE;
|
|
255
|
+
}
|
|
256
|
+
const result = results[entry++];
|
|
257
|
+
return result === undefined || 'error' in result
|
|
258
|
+
? searchError(collection, search, result)
|
|
259
|
+
: withDeclaredFacetNames(result, declaredNames);
|
|
260
|
+
});
|
|
261
|
+
const completed = await withSelectedValues(client, collection, searchType, planned.map((search, index) => ({
|
|
262
|
+
...search,
|
|
263
|
+
response: responses[index],
|
|
264
|
+
})), searchParamsOptions);
|
|
265
|
+
let next = 0;
|
|
266
|
+
return callers.map((searches) => {
|
|
267
|
+
const own = completed.slice(next, (next += searches.length));
|
|
268
|
+
const failed = own.find((response) => response instanceof Error);
|
|
269
|
+
if (failed !== undefined) {
|
|
270
|
+
return failed;
|
|
271
|
+
}
|
|
272
|
+
const answered = own;
|
|
273
|
+
const [first = EMPTY_RESPONSE] = answered;
|
|
274
|
+
return {
|
|
275
|
+
...first,
|
|
276
|
+
facet_counts: answered.flatMap((response) => response.facet_counts ?? []),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
223
280
|
const engine = {
|
|
224
281
|
schema,
|
|
225
282
|
collectionNameFor(searchType) {
|
|
@@ -232,21 +289,27 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
232
289
|
// rejected up front, for EVERY caller.
|
|
233
290
|
assertTypeInSchema(schema, searchType);
|
|
234
291
|
assertValidQuery(query, searchType, schema, joins);
|
|
235
|
-
// Asks for no document (an empty `id` membership): answer it without a
|
|
236
|
-
// round-trip rather than dispatching a query whose only filter compiles
|
|
237
|
-
// away, which would hand back the whole collection.
|
|
238
|
-
if (isUnsatisfiable(query)) {
|
|
239
|
-
return { total: 0, hits: [], facets: {} };
|
|
240
|
-
}
|
|
241
|
-
const params = buildSearchParams(query, searchType, searchParamsOptions);
|
|
242
292
|
const cachedLabelsPromise = startCachedLabels(searchType);
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// `
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
293
|
+
// The hits come from the query as asked, carrying the facets that share
|
|
294
|
+
// its `where`; each own-filtered facet is counted by a search of its own
|
|
295
|
+
// (`facetSearches`), in the same round-trip.
|
|
296
|
+
const searches = facetSearches(query);
|
|
297
|
+
const shared = searches.find((search) => search.own === undefined);
|
|
298
|
+
const [response] = await answer(searchType, [
|
|
299
|
+
[
|
|
300
|
+
{
|
|
301
|
+
query: { ...query, facets: shared?.query.facets ?? [] },
|
|
302
|
+
selected: {},
|
|
303
|
+
facetOnly: false,
|
|
304
|
+
},
|
|
305
|
+
...searches
|
|
306
|
+
.filter((search) => search !== shared)
|
|
307
|
+
.map(facetOnlySearch),
|
|
308
|
+
],
|
|
309
|
+
]);
|
|
310
|
+
if (response instanceof Error) {
|
|
311
|
+
throw response;
|
|
312
|
+
}
|
|
250
313
|
const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
|
|
251
314
|
// What the caller asked to resolve, level by level. Independent of the
|
|
252
315
|
// label lookup above: that one serves facet buckets and unprojected
|
|
@@ -262,56 +325,22 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
262
325
|
if (queries.length === 0) {
|
|
263
326
|
return [];
|
|
264
327
|
}
|
|
265
|
-
const collection = collections.get(searchType.class);
|
|
266
328
|
const cachedLabelsPromise = startCachedLabels(searchType);
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// facets over the whole collection. Such a query is answered with empty
|
|
270
|
-
// facets and kept OUT of the batch, so `dispatched` maps each sent search
|
|
271
|
-
// back to its caller position.
|
|
272
|
-
const dispatched = queries
|
|
273
|
-
.map((query, index) => ({ query, index }))
|
|
274
|
-
.filter(({ query }) => !isUnsatisfiable(query));
|
|
275
|
-
if (dispatched.length === 0) {
|
|
276
|
-
return queries.map(() => ({ facets: {} }));
|
|
277
|
-
}
|
|
278
|
-
// The whole batch travels as ONE multi_search round-trip. Each query
|
|
279
|
-
// compiles as facet-only regardless of what it carries: no hits
|
|
329
|
+
// Every search of every query travels in one multi_search round-trip,
|
|
330
|
+
// each facet-only regardless of what its query carries: no hits
|
|
280
331
|
// (per_page 0) and no ordering – nothing is transferred or sorted that
|
|
281
332
|
// this method cannot return.
|
|
282
|
-
const
|
|
283
|
-
searches: dispatched.map(({ query }) => ({
|
|
284
|
-
collection,
|
|
285
|
-
...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, searchParamsOptions),
|
|
286
|
-
})),
|
|
287
|
-
}));
|
|
288
|
-
const declaredNames = declaredFacetNames.get(searchType.class);
|
|
289
|
-
const results = rawResults.map((result) => 'error' in result
|
|
290
|
-
? result
|
|
291
|
-
: withDeclaredFacetNames(result, declaredNames));
|
|
333
|
+
const responses = await answer(searchType, queries.map((query) => facetSearches(query).map(facetOnlySearch)));
|
|
292
334
|
// One label lookup serves every successful facet result in the batch.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
dispatched.forEach(({ index }, entry) => {
|
|
303
|
-
const result = results[entry];
|
|
304
|
-
outcomes[index] =
|
|
305
|
-
result === undefined || 'error' in result
|
|
306
|
-
? {
|
|
307
|
-
error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result?.code !== undefined ? ` (${result.code})` : ''}: ${result?.error ?? 'no result entry'}`),
|
|
308
|
-
}
|
|
309
|
-
: {
|
|
310
|
-
facets: parseSearchResponse(result, searchType, labels, schema)
|
|
311
|
-
.facets,
|
|
312
|
-
};
|
|
313
|
-
});
|
|
314
|
-
return outcomes;
|
|
335
|
+
const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses.filter((response) => !(response instanceof Error)), labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
|
|
336
|
+
// A failed query is reported in place, so it never discards its
|
|
337
|
+
// siblings’ facets.
|
|
338
|
+
return responses.map((response) => response instanceof Error
|
|
339
|
+
? { error: response }
|
|
340
|
+
: {
|
|
341
|
+
facets: parseSearchResponse(response, searchType, labels, schema)
|
|
342
|
+
.facets,
|
|
343
|
+
});
|
|
315
344
|
},
|
|
316
345
|
};
|
|
317
346
|
// The runtime object is string-keyed; the literal-schema typing narrows it.
|
|
@@ -449,24 +478,179 @@ function withDeclaredFacetNames(response, declaredNames) {
|
|
|
449
478
|
})),
|
|
450
479
|
};
|
|
451
480
|
}
|
|
481
|
+
/** A planned facet search as sent: no hits, and with no hits no ordering. */
|
|
482
|
+
function facetOnlySearch(search) {
|
|
483
|
+
return {
|
|
484
|
+
query: { ...search.query, orderBy: [], limit: 0, offset: 0 },
|
|
485
|
+
selected: search.selected,
|
|
486
|
+
facetOnly: true,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** The error a failed or missing `multi_search` entry stands for, naming a
|
|
490
|
+
* facet search by its facets – the batch position is the adapter’s own. */
|
|
491
|
+
function searchError(collection, search, result) {
|
|
492
|
+
return entryError(search.facetOnly
|
|
493
|
+
? `facet search for “${search.query.facets.join('”, “')}”`
|
|
494
|
+
: `search of “${collection}”`, result);
|
|
495
|
+
}
|
|
496
|
+
/** The error a failed or missing `multi_search` entry stands for. */
|
|
497
|
+
function entryError(subject, result) {
|
|
498
|
+
return new Error(result === undefined
|
|
499
|
+
? `Typesense ${subject} returned no result entry.`
|
|
500
|
+
: `Typesense ${subject} failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
|
|
501
|
+
}
|
|
452
502
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* contract must not weaken just because the transport moved off GET.
|
|
503
|
+
* Send searches as `multi_search` requests of at most {@link
|
|
504
|
+
* MULTI_SEARCH_LIMIT} each, concurrently, and return their entries in order.
|
|
505
|
+
* A request that rejects rejects the whole call: the transport failed.
|
|
457
506
|
*/
|
|
458
|
-
async function
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const [result] = results;
|
|
463
|
-
if (result === undefined) {
|
|
464
|
-
throw new Error(`Typesense search of “${collection}” returned no result entry.`);
|
|
465
|
-
}
|
|
466
|
-
if ('error' in result) {
|
|
467
|
-
throw new Error(`Typesense search of “${collection}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
|
|
507
|
+
async function performInChunks(client, searches) {
|
|
508
|
+
const chunks = [];
|
|
509
|
+
for (let start = 0; start < searches.length; start += MULTI_SEARCH_LIMIT) {
|
|
510
|
+
chunks.push(searches.slice(start, start + MULTI_SEARCH_LIMIT));
|
|
468
511
|
}
|
|
469
|
-
|
|
512
|
+
const answered = await Promise.all(chunks.map(async (chunk) => {
|
|
513
|
+
try {
|
|
514
|
+
return (await client.multiSearch.perform({ searches: [...chunk] })).results;
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
// The request as a whole failed: so did every search it carried, and
|
|
518
|
+
// nothing else. The Typesense client rejects with an Error.
|
|
519
|
+
return chunk.map(() => error);
|
|
520
|
+
}
|
|
521
|
+
}));
|
|
522
|
+
return answered.flat();
|
|
523
|
+
}
|
|
524
|
+
/** A response with nothing in it: the answer to a search that asks for no
|
|
525
|
+
* document, which is never dispatched. */
|
|
526
|
+
const EMPTY_RESPONSE = { found: 0, hits: [] };
|
|
527
|
+
/** The bucket cap Typesense applies when `max_facet_values` is unset. */
|
|
528
|
+
const TYPESENSE_DEFAULT_MAX_FACET_VALUES = 10;
|
|
529
|
+
/** The searches Typesense accepts in one `multi_search` request by default
|
|
530
|
+
* (`limit_multi_searches`). */
|
|
531
|
+
const MULTI_SEARCH_LIMIT = 50;
|
|
532
|
+
/**
|
|
533
|
+
* Each response with the values its search selected among the facet buckets,
|
|
534
|
+
* merged with the engine’s own in descending count order. A response that is
|
|
535
|
+
* already an `Error` passes through unchanged.
|
|
536
|
+
*
|
|
537
|
+
* A value missing from a facet the engine reported in full matches no
|
|
538
|
+
* document, so it gets count 0 without another search. A value missing from a
|
|
539
|
+
* facet cut off at the bucket cap, or narrowed by a facet policy, may still
|
|
540
|
+
* match documents, so it is counted by a search of its own: the search’s
|
|
541
|
+
* `where` plus that value. One search per value, because a facet search
|
|
542
|
+
* filtered on several values of a multi-valued field could cut off the very
|
|
543
|
+
* values it was asked to count. These searches travel together, in as few
|
|
544
|
+
* `multi_search` requests as Typesense accepts. The common cases (no
|
|
545
|
+
* selection, a selection with hits, a facet under the cap) cost nothing.
|
|
546
|
+
*
|
|
547
|
+
* A failed count fails exactly the response it was for, which becomes an
|
|
548
|
+
* `Error` in its place.
|
|
549
|
+
*/
|
|
550
|
+
async function withSelectedValues(client, collection, searchType, entries, options) {
|
|
551
|
+
const cap = options.maxFacetValues ?? TYPESENSE_DEFAULT_MAX_FACET_VALUES;
|
|
552
|
+
const missing = entries.map(({ query, selected, response }) => response instanceof Error
|
|
553
|
+
? []
|
|
554
|
+
: Object.entries(selected).flatMap(([field, values]) => {
|
|
555
|
+
const counts = response.facet_counts?.find((facet) => facet.field_name === field)
|
|
556
|
+
?.counts ?? [];
|
|
557
|
+
const present = new Set(counts.map((bucket) => bucket.value));
|
|
558
|
+
// A facet narrowed by a facet policy never reports a value the
|
|
559
|
+
// policy excludes, though a filter on the field still matches it:
|
|
560
|
+
// absent is no proof of 0 there either, so count it. A search that
|
|
561
|
+
// asks for no document has nothing to count.
|
|
562
|
+
const uncertain = !isUnsatisfiable(query) &&
|
|
563
|
+
(counts.length >= cap ||
|
|
564
|
+
narrowedByPolicy(searchType, field, options));
|
|
565
|
+
return values
|
|
566
|
+
.filter((value) => !present.has(value))
|
|
567
|
+
.map((value) => ({ field, value, uncertain }));
|
|
568
|
+
}));
|
|
569
|
+
const toCount = missing.flatMap((values, entry) => values
|
|
570
|
+
.filter(({ uncertain }) => uncertain)
|
|
571
|
+
.map(({ field, value }) => ({ entry, field, value })));
|
|
572
|
+
const found = new Map();
|
|
573
|
+
const key = (entry, field, value) => JSON.stringify([entry, field, value]);
|
|
574
|
+
const results = await performInChunks(client, toCount.map(({ entry, field, value }) => {
|
|
575
|
+
const { query } = entries[entry];
|
|
576
|
+
return {
|
|
577
|
+
collection,
|
|
578
|
+
...buildSearchParams({
|
|
579
|
+
...query,
|
|
580
|
+
where: [...query.where, selecting(searchType, field, value)],
|
|
581
|
+
facets: [],
|
|
582
|
+
orderBy: [],
|
|
583
|
+
limit: 0,
|
|
584
|
+
offset: 0,
|
|
585
|
+
}, searchType, options),
|
|
586
|
+
};
|
|
587
|
+
}));
|
|
588
|
+
toCount.forEach(({ entry, field, value }, index) => {
|
|
589
|
+
const result = results[index];
|
|
590
|
+
found.set(key(entry, field, value), result instanceof Error
|
|
591
|
+
? result
|
|
592
|
+
: result === undefined || 'error' in result
|
|
593
|
+
? entryError(`count of facet value “${value}” on “${field}”`, result)
|
|
594
|
+
: result.found);
|
|
595
|
+
});
|
|
596
|
+
return entries.map(({ response }, entry) => {
|
|
597
|
+
const values = missing[entry];
|
|
598
|
+
if (response instanceof Error || values.length === 0) {
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
601
|
+
const added = new Map();
|
|
602
|
+
for (const { field, value, uncertain } of values) {
|
|
603
|
+
// Every uncertain value was counted above.
|
|
604
|
+
const count = uncertain
|
|
605
|
+
? found.get(key(entry, field, value))
|
|
606
|
+
: 0;
|
|
607
|
+
if (count instanceof Error) {
|
|
608
|
+
return count;
|
|
609
|
+
}
|
|
610
|
+
added.set(field, [...(added.get(field) ?? []), { value, count }]);
|
|
611
|
+
}
|
|
612
|
+
const facetCounts = response.facet_counts ?? [];
|
|
613
|
+
return {
|
|
614
|
+
...response,
|
|
615
|
+
facet_counts: [
|
|
616
|
+
...facetCounts.map((facet) => ({
|
|
617
|
+
...facet,
|
|
618
|
+
// Merged by count: a value a policy hid can outnumber every bucket.
|
|
619
|
+
counts: added.has(facet.field_name)
|
|
620
|
+
? [
|
|
621
|
+
...facet.counts,
|
|
622
|
+
...added.get(facet.field_name),
|
|
623
|
+
].sort(byCount)
|
|
624
|
+
: facet.counts,
|
|
625
|
+
})),
|
|
626
|
+
// A facet the response does not carry at all: an unanswered search’s.
|
|
627
|
+
...[...added]
|
|
628
|
+
.filter(([field]) => !facetCounts.some((facet) => facet.field_name === field))
|
|
629
|
+
.map(([field, counts]) => ({
|
|
630
|
+
field_name: field,
|
|
631
|
+
counts: counts.sort(byCount),
|
|
632
|
+
})),
|
|
633
|
+
],
|
|
634
|
+
};
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/** The clause that selects one facet value: `is` on a boolean, `in` on
|
|
638
|
+
* anything else. */
|
|
639
|
+
function selecting(searchType, field, value) {
|
|
640
|
+
return filterOn(fieldNamed(searchType, field)?.kind === 'boolean'
|
|
641
|
+
? { field, is: value === 'true' }
|
|
642
|
+
: { field, in: [value] });
|
|
643
|
+
}
|
|
644
|
+
/** Orders buckets by descending count, as the engine orders its own. */
|
|
645
|
+
function byCount(first, second) {
|
|
646
|
+
return second.count - first.count;
|
|
647
|
+
}
|
|
648
|
+
/** Whether a facet policy narrows the facet on `name` ({@link
|
|
649
|
+
* inheritedFacetPolicies}), so its buckets leave out values its field holds. */
|
|
650
|
+
function narrowedByPolicy(searchType, name, options) {
|
|
651
|
+
const field = fieldNamed(searchType, name);
|
|
652
|
+
return (field !== undefined &&
|
|
653
|
+
inheritedFacetPolicies(field, options.schema).size > 0);
|
|
470
654
|
}
|
|
471
655
|
/**
|
|
472
656
|
* Resolve labels from each group’s label-source collection. Labels are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-typesense",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Typesense implementation of the @lde/search SearchEngine port: collection-schema builder, query compiler, label-resolving result reconstruction, and blue/green index lifecycle. Engine-specific (Typesense) but domain-agnostic.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
28
|
+
"@lde/search": "^0.31.0",
|
|
29
29
|
"@lde/text-normalization": "^0.2.0",
|
|
30
30
|
"tslib": "^2.3.0",
|
|
31
31
|
"typesense": "^3.1.1"
|