@lde/search-typesense 0.15.6 → 0.16.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.
@@ -1,14 +1,14 @@
1
1
  import type { SearchParams } from 'typesense/lib/Typesense/Documents.js';
2
2
  import { type Filter, type SearchQuery, type SearchType } from '@lde/search';
3
3
  /**
4
- * Options for {@link buildSearchParams} — the query half of the engine
4
+ * Options for {@link buildSearchParams} – the query half of the engine
5
5
  * adapter. {@link TypesenseSearchEngineOptions} extends this, so each knob is
6
6
  * declared once and the engine forwards its options wholesale.
7
7
  */
8
8
  export interface BuildSearchParamsOptions {
9
9
  /**
10
10
  * Cap on the number of buckets returned per facet (`max_facet_values`). Left
11
- * unset, Typesense defaults to 10 — too few for high-cardinality facets
11
+ * unset, Typesense defaults to 10 – too few for high-cardinality facets
12
12
  * (publisher, keyword), so a deployment with such facets must raise it. Range
13
13
  * facets return one bucket per declared range regardless, but a value > the
14
14
  * range count is still safe.
@@ -27,7 +27,7 @@ export interface BuildSearchParamsOptions {
27
27
  }
28
28
  /**
29
29
  * Compile the engine-neutral {@link SearchQuery} into Typesense search
30
- * parameters — the query half of the engine adapter. Pure (no client, no env),
30
+ * parameters – the query half of the engine adapter. Pure (no client, no env),
31
31
  * so the mapping is asserted directly in unit tests. Field names come from
32
32
  * {@link physicalFields}, the same convention the projection and the collection
33
33
  * schema use, so a query can never reference a field the index does not carry.
@@ -1 +1 @@
1
- {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAYrB;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AA2LD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
1
+ {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAEL,KAAK,MAAM,EAEX,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAarB;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AAqMD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
@@ -1,8 +1,8 @@
1
1
  import { fold } from '@lde/text-normalization';
2
- import { fieldNamed, filterOperator, filterOperatorFor, isoToUnixSeconds, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
2
+ import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
3
3
  /**
4
4
  * Compile the engine-neutral {@link SearchQuery} into Typesense search
5
- * parameters — the query half of the engine adapter. Pure (no client, no env),
5
+ * parameters – the query half of the engine adapter. Pure (no client, no env),
6
6
  * so the mapping is asserted directly in unit tests. Field names come from
7
7
  * {@link physicalFields}, the same convention the projection and the collection
8
8
  * schema use, so a query can never reference a field the index does not carry.
@@ -110,12 +110,22 @@ function compileFilterBy(where, searchType, onIgnoredFilter) {
110
110
  .join(' && ');
111
111
  }
112
112
  function compileFilter(filter, searchType) {
113
+ // `id` is the Typesense document key, not a declared field. Exact `:=`
114
+ // membership, like a non-facet field ({@link compileMembership}), so an IRI
115
+ // cannot partial-match on a shared path segment. (`fetchLabels` resolves
116
+ // labels with the looser `id:[…]`; these are deliberately not the same
117
+ // clause.)
118
+ if (filter.field === ID_FIELD) {
119
+ return 'in' in filter && filter.in.length > 0
120
+ ? `${ID_FIELD}:=[${filter.in.map(escapeFilterValue).join(',')}]`
121
+ : undefined;
122
+ }
113
123
  const field = fieldNamed(searchType, filter.field);
114
124
  if (field === undefined) {
115
125
  return undefined;
116
126
  }
117
127
  // A clause whose operator does not match the field's kind (e.g. `range` on a
118
- // keyword) would reach the engine as garbage syntax — skip it instead.
128
+ // keyword) would reach the engine as garbage syntax – skip it instead.
119
129
  if (filterOperatorFor(field.kind) !== filterOperator(filter)) {
120
130
  return undefined;
121
131
  }
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/dist/search.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isRangeFacet, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isRangeFacet, isUnsatisfiable, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
2
2
  import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
3
3
  import { deriveCollectionName } from './collection-name.js';
4
4
  /**
@@ -176,12 +176,21 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
176
176
  // rejected up front, for EVERY caller.
177
177
  assertTypeInSchema(schema, searchType);
178
178
  assertValidQuery(query, searchType);
179
+ // Asks for no document (an empty `id` membership): answer it without a
180
+ // round-trip rather than dispatching a query whose only filter compiles
181
+ // away, which would hand back the whole collection.
182
+ if (isUnsatisfiable(query)) {
183
+ return { total: 0, hits: [], facets: {} };
184
+ }
179
185
  const params = buildSearchParams(query, searchType, options);
180
186
  const cachedLabelsPromise = startCachedLabels(searchType);
181
- const response = (await client
182
- .collections(collections.get(searchType.class))
183
- .documents()
184
- .search(params));
187
+ // ONE search, but through `multi_search` (POST) like the facet batch and
188
+ // the label lookup: `documents().search()` is a GET, so a long
189
+ // `filter_by` – a batch `id` lookup, or any membership over many IRIs –
190
+ // would overflow Typesense's 4000-char query-string limit. POST puts it
191
+ // in the body, so the filter is bounded by the caller's request, not by
192
+ // the URL.
193
+ const response = await performSingleSearch(client, collections.get(searchType.class), params);
185
194
  const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
186
195
  return parseSearchResponse(response, searchType, labels);
187
196
  },
@@ -195,12 +204,23 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
195
204
  }
196
205
  const collection = collections.get(searchType.class);
197
206
  const cachedLabelsPromise = startCachedLabels(searchType);
207
+ // A query asking for no document faces the same inversion as in
208
+ // `search()`: its only filter would compile away and it would count
209
+ // facets over the whole collection. Such a query is answered with empty
210
+ // facets and kept OUT of the batch, so `dispatched` maps each sent search
211
+ // back to its caller position.
212
+ const dispatched = queries
213
+ .map((query, index) => ({ query, index }))
214
+ .filter(({ query }) => !isUnsatisfiable(query));
215
+ if (dispatched.length === 0) {
216
+ return queries.map(() => ({ facets: {} }));
217
+ }
198
218
  // The whole batch travels as ONE multi_search round-trip. Each query
199
219
  // compiles as facet-only regardless of what it carries: no hits
200
220
  // (per_page 0) and no ordering – nothing is transferred or sorted that
201
221
  // this method cannot return.
202
222
  const { results } = (await client.multiSearch.perform({
203
- searches: queries.map((query) => ({
223
+ searches: dispatched.map(({ query }) => ({
204
224
  collection,
205
225
  ...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, options),
206
226
  })),
@@ -211,12 +231,22 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
211
231
  // multi_search reports a failed entry inline instead of rejecting the
212
232
  // call; pass that through as a per-query outcome – naming the facets,
213
233
  // not the position, since the batch order is the caller's internal –
214
- // so one failed query never discards its siblings' facets.
215
- return results.map((result, index) => 'error' in result
216
- ? {
217
- error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`),
218
- }
219
- : { facets: parseSearchResponse(result, searchType, labels).facets });
234
+ // so one failed query never discards its siblings' facets. Every caller
235
+ // position is answered: a dispatched query by its entry, an
236
+ // unsatisfiable one by empty facets.
237
+ const outcomes = queries.map(() => ({ facets: {} }));
238
+ dispatched.forEach(({ index }, entry) => {
239
+ const result = results[entry];
240
+ outcomes[index] =
241
+ result === undefined || 'error' in result
242
+ ? {
243
+ error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result?.code !== undefined ? ` (${result.code})` : ''}: ${result?.error ?? 'no result entry'}`),
244
+ }
245
+ : {
246
+ facets: parseSearchResponse(result, searchType, labels).facets,
247
+ };
248
+ });
249
+ return outcomes;
220
250
  },
221
251
  };
222
252
  // The runtime object is string-keyed; the literal-schema typing narrows it.
@@ -329,6 +359,25 @@ function labelLookupGroups(responses, sources, outputSources) {
329
359
  iris: [...iris],
330
360
  }));
331
361
  }
362
+ /**
363
+ * Dispatch one search as a single-entry `multi_search` (POST). `multi_search`
364
+ * reports a failed entry inline instead of rejecting the call, so the entry is
365
+ * translated back into a throw: `search()` rejects on engine failure, and that
366
+ * contract must not weaken just because the transport moved off GET.
367
+ */
368
+ async function performSingleSearch(client, collection, params) {
369
+ const { results } = (await client.multiSearch.perform({
370
+ searches: [{ collection, ...params }],
371
+ }));
372
+ const [result] = results;
373
+ if (result === undefined) {
374
+ throw new Error(`Typesense search of “${collection}” returned no result entry.`);
375
+ }
376
+ if ('error' in result) {
377
+ throw new Error(`Typesense search of “${collection}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
378
+ }
379
+ return result;
380
+ }
332
381
  /**
333
382
  * Resolve labels from each group’s label-source collection. Labels are
334
383
  * reconstructed from the source type’s `label` declaration ({@link
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.15.6",
3
+ "version": "0.16.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.12.3",
28
+ "@lde/search": "^0.13.0",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"