@lde/search-typesense 0.2.0 → 0.3.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/README.md CHANGED
@@ -28,6 +28,12 @@ search/sort keys) matches what the projection writes, via
28
28
  - reconstructs the logical `SearchResult` (`parseSearchResponse`) — language
29
29
  maps, labelled references, labelled facet buckets.
30
30
 
31
+ `searchFacets` – the port’s batch entry point – answers a whole batch of
32
+ facet-only queries (e.g. a faceted sidebar’s skip-own-filter variants) as a
33
+ **single `multi_search` round-trip**, with one bundled label lookup shared by
34
+ every facet result in the batch. A failed entry is reported in place as a
35
+ per-query outcome, so its siblings’ facets survive.
36
+
31
37
  The pure halves `buildSearchParams` and `parseSearchResponse` are exported for
32
38
  direct use and testing.
33
39
 
package/dist/search.d.ts CHANGED
@@ -35,8 +35,12 @@ export interface TypesenseSearchEngineOptions<TypeName extends string = string>
35
35
  * ({@link buildSearchParams}), runs it against the type’s collection,
36
36
  * resolves the reference labels for the page of hits from the sidecar
37
37
  * `labels` collection in one lookup, and reconstructs the engine-neutral
38
- * {@link SearchResult} ({@link parseSearchResponse}). Every engine specific
39
- * stays here; consumers see only logical documents.
38
+ * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
39
+ * a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
40
+ * single `multi_search` round-trip with one shared label lookup; a failed
41
+ * entry is reported in place as a per-query outcome, so it never discards
42
+ * its siblings’ facets. Every engine specific stays here; consumers see only
43
+ * logical documents.
40
44
  *
41
45
  * With a schema built by `searchSchema` over `defineSearchType` literals,
42
46
  * `search()` accepts only the deployment’s own types and returns typo-safe
@@ -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;AACxC,OAAO,EAEL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAGhB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;kCAIkC;AAClC,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;qEACiE;IACjE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC;;;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;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,EAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAC3D,YAAY,CAAC,KAAK,CAAC,CAkGrB;AA+DD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAyBtC;AAsBD,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,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;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAiCd"}
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,EAGL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAGhB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;kCAIkC;AAClC,MAAM,WAAW,4BAA4B,CAC3C,QAAQ,SAAS,MAAM,GAAG,MAAM,CAChC,SAAQ,wBAAwB;IAChC;qEACiE;IACjE,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,kFAAkF;IAClF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC;;;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;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,CAAC,KAAK,SAAS,SAAS,UAAU,EAAE,EAEzC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC,EAC3B,OAAO,EAAE,4BAA4B,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,GAC3D,YAAY,CAAC,KAAK,CAAC,CAwKrB;AA+DD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAmCtC;AA6BD,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,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;KACL,EAAE,CAAC;CACL;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,uBAAuB,EACjC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,GAC1C,YAAY,CAiCd"}
package/dist/search.js CHANGED
@@ -8,8 +8,12 @@ import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
8
8
  * ({@link buildSearchParams}), runs it against the type’s collection,
9
9
  * resolves the reference labels for the page of hits from the sidecar
10
10
  * `labels` collection in one lookup, and reconstructs the engine-neutral
11
- * {@link SearchResult} ({@link parseSearchResponse}). Every engine specific
12
- * stays here; consumers see only logical documents.
11
+ * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
12
+ * a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
13
+ * single `multi_search` round-trip with one shared label lookup; a failed
14
+ * entry is reported in place as a per-query outcome, so it never discards
15
+ * its siblings’ facets. Every engine specific stays here; consumers see only
16
+ * logical documents.
13
17
  *
14
18
  * With a schema built by `searchSchema` over `defineSearchType` literals,
15
19
  * `search()` accepts only the deployment’s own types and returns typo-safe
@@ -54,6 +58,37 @@ export function createTypesenseSearchEngine(client, schema, options) {
54
58
  });
55
59
  return inFlightLoad;
56
60
  }
61
+ // Cached path: the once-loaded full collection serves labels by in-memory
62
+ // lookup (no per-search round-trip). The load does not depend on the
63
+ // response, so it is started BEFORE awaiting the search and runs alongside
64
+ // it; it never rejects (a failed load degrades to an empty map), so it
65
+ // cannot leave an unhandled rejection behind if the search itself fails.
66
+ // `undefined` when the cache (or the sidecar) is not configured.
67
+ function startCachedLabels() {
68
+ return options.labelsCollection !== undefined &&
69
+ options.labelCacheTtlMs !== undefined
70
+ ? cachedAllLabels(options.labelsCollection, options.labelCacheTtlMs)
71
+ : undefined;
72
+ }
73
+ // Labels are supplementary: a failed lookup (e.g. the sidecar collection
74
+ // mid-rebuild) degrades to id-only references rather than failing the whole
75
+ // search, so the listing still renders with bare IRIs. `iris` is a thunk so
76
+ // the cached and no-sidecar paths never pay for collecting them.
77
+ async function resolveLabels(cachedLabelsPromise, iris) {
78
+ if (cachedLabelsPromise !== undefined) {
79
+ return cachedLabelsPromise;
80
+ }
81
+ if (options.labelsCollection === undefined) {
82
+ return new Map();
83
+ }
84
+ try {
85
+ return await fetchLabels(client, options.labelsCollection, iris());
86
+ }
87
+ catch (error) {
88
+ options.onLabelError?.(error);
89
+ return new Map();
90
+ }
91
+ }
57
92
  const engine = {
58
93
  schema,
59
94
  async search(searchType, query) {
@@ -63,35 +98,48 @@ export function createTypesenseSearchEngine(client, schema, options) {
63
98
  assertTypeInSchema(schema, searchType);
64
99
  assertValidQuery(query, searchType);
65
100
  const params = buildSearchParams(query, searchType, options);
66
- // Cached path: the once-loaded full collection serves labels by in-memory
67
- // lookup (no per-search round-trip). The load does not depend on the
68
- // response, so it runs alongside the search; it never rejects (a failed
69
- // load degrades to an empty map), so it cannot leave an unhandled
70
- // rejection behind if the search itself fails.
71
- const cachedLabelsPromise = options.labelsCollection !== undefined &&
72
- options.labelCacheTtlMs !== undefined
73
- ? cachedAllLabels(options.labelsCollection, options.labelCacheTtlMs)
74
- : undefined;
101
+ const cachedLabelsPromise = startCachedLabels();
75
102
  const response = (await client
76
103
  .collections(collections.get(searchType.type))
77
104
  .documents()
78
105
  .search(params));
79
- // Labels are supplementary: a failed lookup (e.g. the sidecar collection
80
- // mid-rebuild) degrades to id-only references rather than failing the whole
81
- // search, so the listing still renders with bare IRIs.
82
- let labels = new Map();
83
- if (cachedLabelsPromise !== undefined) {
84
- labels = await cachedLabelsPromise;
106
+ const labels = await resolveLabels(cachedLabelsPromise, () => referenceIris(response, searchType));
107
+ return parseSearchResponse(response, searchType, labels);
108
+ },
109
+ async searchFacets(searchType, queries) {
110
+ assertTypeInSchema(schema, searchType);
111
+ for (const query of queries) {
112
+ assertValidQuery(query, searchType);
85
113
  }
86
- else if (options.labelsCollection !== undefined) {
87
- try {
88
- labels = await fetchLabels(client, options.labelsCollection, referenceIris(response, searchType));
89
- }
90
- catch (error) {
91
- options.onLabelError?.(error);
92
- }
114
+ if (queries.length === 0) {
115
+ return [];
93
116
  }
94
- return parseSearchResponse(response, searchType, labels);
117
+ const collection = collections.get(searchType.type);
118
+ const cachedLabelsPromise = startCachedLabels();
119
+ // The whole batch travels as ONE multi_search round-trip. Each query
120
+ // compiles as facet-only regardless of what it carries: no hits
121
+ // (per_page 0) and no ordering – nothing is transferred or sorted that
122
+ // this method cannot return.
123
+ const { results } = (await client.multiSearch.perform({
124
+ searches: queries.map((query) => ({
125
+ collection,
126
+ ...buildSearchParams({ ...query, orderBy: [], limit: 0, offset: 0 }, searchType, options),
127
+ })),
128
+ }));
129
+ // One label lookup serves every successful facet result in the batch.
130
+ const responses = results.filter((result) => !('error' in result));
131
+ const labels = await resolveLabels(cachedLabelsPromise, () => [
132
+ ...new Set(responses.flatMap((response) => referenceIris(response, searchType))),
133
+ ]);
134
+ // multi_search reports a failed entry inline instead of rejecting the
135
+ // call; pass that through as a per-query outcome – naming the facets,
136
+ // not the position, since the batch order is the caller's internal –
137
+ // so one failed query never discards its siblings' facets.
138
+ return results.map((result, index) => 'error' in result
139
+ ? {
140
+ error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`),
141
+ }
142
+ : { facets: parseSearchResponse(result, searchType, labels).facets });
95
143
  },
96
144
  };
97
145
  // The runtime object is string-keyed; the literal-schema typing narrows it.
@@ -180,6 +228,12 @@ export async function fetchLabels(client, collection, iris) {
180
228
  }
181
229
  const { results } = (await client.multiSearch.perform({ searches }));
182
230
  for (const result of results) {
231
+ // multi_search reports a failed entry inline instead of rejecting; throw
232
+ // so the caller's degradation path (onLabelError, id-only references)
233
+ // engages rather than mistaking the failure for absent labels.
234
+ if ('error' in result) {
235
+ throw new Error(`Typesense label lookup failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
236
+ }
183
237
  for (const hit of result.hits ?? []) {
184
238
  labels.set(String(hit.document.id), labelToLocalizedValue(hit.document));
185
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.2.0",
3
+ "version": "0.3.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.2.0",
28
+ "@lde/search": "^0.3.0",
29
29
  "@lde/text-normalization": "^0.1.1",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"