@lde/search-typesense 0.4.4 → 0.6.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
@@ -26,20 +26,28 @@ they cost no RAM; only the folded `*_search_${locale}`, facet/reference and
26
26
  `*_sort_${locale}` companions are indexed. Keeping retrieval-only fields
27
27
  un-indexed is the lever for holding a large index’s RAM down.
28
28
 
29
- `createTypesenseSearchEngine(client, { collection, labelsCollection })` is the
29
+ `createTypesenseSearchEngine(client, schema, { collections })` is the
30
30
  `SearchEngine` implementation. Each search:
31
31
 
32
32
  - validates the query against the search type (the port contract — a
33
33
  structurally invalid query is rejected, never sent);
34
34
  - compiles it into Typesense search params (`buildSearchParams`);
35
35
  - runs the search;
36
- - resolves reference (and reference-facet) labels from the sidecar `labels`
37
- collection in a single lookup;
36
+ - resolves reference (and reference-facet) labels **per reference field**
37
+ from the collection of the `SearchType` its `labelSource` names – all
38
+ sources bundled into a single lookup. A reference without a `labelSource`
39
+ stays id-only. With `labelCacheTtlMs` set, each label-source collection is
40
+ instead loaded once into an in-memory cache;
38
41
  - reconstructs the logical `SearchResult` (`parseSearchResponse`) — language
39
42
  maps, labelled references, labelled facet buckets.
40
43
 
44
+ A label source is just another `SearchType` in the schema (with an `output`,
45
+ `searchable` text field called `label`) whose collection appears in
46
+ `collections` – a typed entity collection and a ‘labels collection’ are the
47
+ same kind of thing.
48
+
41
49
  `searchFacets` – the port’s batch entry point – answers a whole batch of
42
- facet-only queries (e.g. a faceted sidebar’s skip-own-filter variants) as a
50
+ facet-only queries (e.g. a faceted listing’s skip-own-filter variants) as a
43
51
  **single `multi_search` round-trip**, with one bundled label lookup shared by
44
52
  every facet result in the batch. A failed entry is reported in place as a
45
53
  per-query outcome, so its siblings’ facets survive.
package/dist/search.d.ts CHANGED
@@ -1,42 +1,53 @@
1
1
  import type { Client } from 'typesense';
2
- import { type LocalizedValue, type SearchEngine, type SearchResult, type SearchSchema, type SearchType } from '@lde/search';
2
+ import { type LocalizedValue, type SearchEngine, type SearchResult, type SearchSchema, type SearchType, type TextField } from '@lde/search';
3
3
  import { type BuildSearchParamsOptions } from './query-compiler.js';
4
- /** Where the engine reads documents and (optionally) reference labels — plus
5
- * every query-compiler knob ({@link BuildSearchParamsOptions}), declared once
6
- * there and forwarded wholesale into each search. `TypeName` is the union of
7
- * the schema’s type names, so a literal schema makes the `collections` map
8
- * exhaustive at compile time. */
4
+ /** Where the engine reads documents — plus every query-compiler knob
5
+ * ({@link BuildSearchParamsOptions}), declared once there and forwarded
6
+ * wholesale into each search. `TypeName` is the union of the schema’s type
7
+ * names, so a literal schema makes the `collections` map exhaustive at
8
+ * compile time. Reference labels resolve per field from the collection of
9
+ * the SearchType its `labelSource` names — a label source is just another
10
+ * entry in `collections`. */
9
11
  export interface TypesenseSearchEngineOptions<TypeName extends string = string> extends BuildSearchParamsOptions {
10
12
  /** The collection (or alias) per search type, keyed by the type’s `name` —
11
13
  * with a literal schema, omitting a type is a compile error. */
12
14
  readonly collections: Readonly<Record<TypeName, string>>;
13
- /** The sidecar `labels` collection (IRI → label); omit for id-only references. */
14
- readonly labelsCollection?: string;
15
15
  /**
16
16
  * Called when reference-label resolution fails; the search then degrades to
17
17
  * id-only references rather than failing. Optional — omit to swallow silently.
18
18
  */
19
19
  readonly onLabelError?: (error: unknown) => void;
20
20
  /**
21
- * Opt-in in-memory label cache. When set (and {@link labelsCollection} is
22
- * set), the FULL sidecar `labels` collection is loaded once via the documents
23
- * export endpoint and held in a process-lifetime cache for this many
24
- * milliseconds; each `search` then resolves its reference labels by in-memory
25
- * lookup instead of a per-search `multi_search` round-trip. Omit to keep the
26
- * per-search {@link fetchLabels} behaviour unchanged.
21
+ * Opt-in in-memory label cache. When set, each label-source collection is
22
+ * loaded in FULL once via the documents export endpoint and held in a
23
+ * process-lifetime cache for this many milliseconds; each `search` then
24
+ * resolves its reference labels by in-memory lookup instead of a per-search
25
+ * `multi_search` round-trip. Omit to keep the per-search
26
+ * {@link fetchLabels} behaviour unchanged.
27
27
  */
28
28
  readonly labelCacheTtlMs?: number;
29
29
  }
30
+ /**
31
+ * One reference field’s resolved label source: the collection it reads, the
32
+ * source type’s `label` declaration (for reconstructing localized labels) and
33
+ * the comma-joined physical search fields (one per locale) a label search
34
+ * queries.
35
+ */
36
+ export interface LabelSource {
37
+ readonly collection: string;
38
+ readonly labelField: TextField;
39
+ readonly queryBy: string;
40
+ }
30
41
  /**
31
42
  * A Typesense-backed {@link SearchEngine}, bound to the whole
32
43
  * {@link SearchSchema} at construction — like every other schema consumer.
33
- * Each type’s collection comes from `options.collections`; the label sidecar
34
- * and its cache are shared across all types. `search` compiles the query
35
- * ({@link buildSearchParams}), runs it against the type’s collection,
36
- * resolves the reference labels for the page of hits from the sidecar
37
- * `labels` collection in one lookup, and reconstructs the engine-neutral
44
+ * Each type’s collection comes from `options.collections`. `search` compiles
45
+ * the query ({@link buildSearchParams}), runs it against the type’s
46
+ * collection, resolves the reference labels for the page of hits — each
47
+ * reference field from its own label source’s collection, all sources bundled
48
+ * into one lookup — and reconstructs the engine-neutral
38
49
  * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
39
- * a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
50
+ * a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) as a
40
51
  * single `multi_search` round-trip with one shared label lookup; a failed
41
52
  * entry is reported in place as a per-query outcome, so it never discards
42
53
  * its siblings’ facets. Every engine specific stays here; consumers see only
@@ -47,20 +58,26 @@ export interface TypesenseSearchEngineOptions<TypeName extends string = string>
47
58
  * facet/document keys per call — no caller-side generics.
48
59
  */
49
60
  export declare function createTypesenseSearchEngine<const Types extends readonly SearchType[]>(client: Client, schema: SearchSchema<Types>, options: TypesenseSearchEngineOptions<Types[number]['name']>): SearchEngine<Types>;
61
+ /** One label lookup: a source and the distinct IRIs to resolve against it. */
62
+ export interface LabelLookupGroup {
63
+ readonly source: LabelSource;
64
+ readonly iris: readonly string[];
65
+ }
50
66
  /**
51
- * Resolve labels for `iris` from the sidecar `labels` collection. Each
52
- * `label_${locale}` becomes a language-map entry; the default `label` is the
53
- * untagged (`und`) fallback when no locale variant exists.
67
+ * Resolve labels from each group’s label-source collection. Localized labels
68
+ * are reconstructed from the source type’s `label` declaration; a bare
69
+ * untagged `label` value is the `und` fallback when no locale variant exists.
54
70
  *
55
- * Sent as one `multi_search` (POST) call, the id-list split over per-search
56
- * batches: the id-list of a page or facet carrying many references — e.g. a
57
- * dataset with dozens of classes — would overflow Typesense’s GET query-string
58
- * limit (4000 chars, and IRIs URL-encode to several times their length) if it
59
- * travelled in the URL. POST puts it in the body; each batch stays under
60
- * Typesense’s `per_page` cap, and bundling the batches keeps it one round-trip
61
- * regardless of IRI count. Exported for unit testing against a fake client.
71
+ * All groups travel as ONE `multi_search` (POST) call, each group’s id-list
72
+ * split over per-search batches: the id-list of a page or facet carrying many
73
+ * references — e.g. a dataset with dozens of classes — would overflow
74
+ * Typesense’s GET query-string limit (4000 chars, and IRIs URL-encode to
75
+ * several times their length) if it travelled in the URL. POST puts it in the
76
+ * body; each batch stays under Typesense’s `per_page` cap, and bundling the
77
+ * batches keeps it one round-trip regardless of IRI or source count. Exported
78
+ * for unit testing against a fake client.
62
79
  */
63
- export declare function fetchLabels(client: Pick<Client, 'multiSearch'>, collection: string, iris: readonly string[]): Promise<Map<string, LocalizedValue>>;
80
+ export declare function fetchLabels(client: Pick<Client, 'multiSearch'>, groups: readonly LabelLookupGroup[], onError?: (error: Error) => void): Promise<Map<string, LocalizedValue>>;
64
81
  /** The subset of a Typesense search response this adapter reads. */
65
82
  export interface TypesenseSearchResponse {
66
83
  readonly found: number;
@@ -79,7 +96,7 @@ export interface TypesenseSearchResponse {
79
96
  * Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
80
97
  * the flat, fanned-out document is turned back into a logical one (per-locale
81
98
  * display fields → a language map, reference IRIs → labelled references via the
82
- * sidecar `labels` lookup, scalars passed through). `labels` maps a reference IRI
99
+ * label-source lookup, scalars passed through). `labels` maps a reference IRI
83
100
  * to its resolved label; an IRI absent from it yields an id-only reference.
84
101
  */
85
102
  export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>): SearchResult;
@@ -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,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"}
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,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAC;AAE7B;;;;;;8BAM8B;AAC9B,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;;;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;;;;;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;;;;;;;;;;;;;;;;;;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,CAsRrB;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AA0FD;;;;;;;;;;;;;GAaG;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,CAgDtC;AAgDD,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,CAsCd"}
package/dist/search.js CHANGED
@@ -1,15 +1,15 @@
1
- import { assertTypeInSchema, assertValidQuery, fieldNamed, isRangeFacet, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, fieldNamed, isRangeFacet, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
2
2
  import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
3
3
  /**
4
4
  * A Typesense-backed {@link SearchEngine}, bound to the whole
5
5
  * {@link SearchSchema} at construction — like every other schema consumer.
6
- * Each type’s collection comes from `options.collections`; the label sidecar
7
- * and its cache are shared across all types. `search` compiles the query
8
- * ({@link buildSearchParams}), runs it against the type’s collection,
9
- * resolves the reference labels for the page of hits from the sidecar
10
- * `labels` collection in one lookup, and reconstructs the engine-neutral
6
+ * Each type’s collection comes from `options.collections`. `search` compiles
7
+ * the query ({@link buildSearchParams}), runs it against the type’s
8
+ * collection, resolves the reference labels for the page of hits — each
9
+ * reference field from its own label source’s collection, all sources bundled
10
+ * into one lookup — and reconstructs the engine-neutral
11
11
  * {@link SearchResult} ({@link parseSearchResponse}). `searchFacets` answers
12
- * a whole facet batch (e.g. a sidebar’s skip-own-filter query variants) as a
12
+ * a whole facet batch (e.g. a faceted listing’s skip-own-filter query variants) as a
13
13
  * single `multi_search` round-trip with one shared label lookup; a failed
14
14
  * entry is reported in place as a per-query outcome, so it never discards
15
15
  * its siblings’ facets. Every engine specific stays here; consumers see only
@@ -30,21 +30,69 @@ export function createTypesenseSearchEngine(client, schema, options) {
30
30
  }
31
31
  return [searchType.type, collection];
32
32
  }));
33
- // Process-lifetime cache for the FULL `labels` collection, held in the engine
34
- // closure. Populated lazily on the first cached search; `loadAll` is the
35
- // single-flight in-flight promise so concurrent first-loads share one export.
36
- let cachedLabels;
37
- let cacheExpiresAt = 0;
38
- let inFlightLoad;
39
- function cachedAllLabels(labelsCollection, ttlMs) {
40
- if (cachedLabels !== undefined && Date.now() < cacheExpiresAt) {
41
- return Promise.resolve(cachedLabels);
33
+ // Resolve every reference field's label source ONCE at construction. The
34
+ // schema already guarantees the named type exists and serves labels
35
+ // (`searchSchema`/`labelFieldOf`), and `collections` is exhaustive per type.
36
+ const typesByName = new Map([...schema.values()].map((searchType) => [searchType.name, searchType]));
37
+ const labelSources = new Map([...schema.values()].map((searchType) => [
38
+ searchType.type,
39
+ new Map(referenceFields(searchType)
40
+ .filter((field) => field.labelSource !== undefined)
41
+ .map((field) => {
42
+ const source = typesByName.get(field.labelSource);
43
+ const labelField = labelFieldOf(source);
44
+ return [
45
+ field.name,
46
+ {
47
+ collection: collections.get(source.type),
48
+ labelField,
49
+ queryBy: physicalFields(labelField).search.join(','),
50
+ },
51
+ ];
52
+ })),
53
+ ]));
54
+ // The distinct source collections per type, for the cached path – fixed at
55
+ // construction, so no per-search dedup dance.
56
+ const distinctLabelSources = new Map([...labelSources].map(([type, sources]) => [
57
+ type,
58
+ [
59
+ ...new Map([...sources.values()].map((source) => [source.collection, source])).values(),
60
+ ],
61
+ ]));
62
+ // The output reference fields that carry hit labels, each paired with its
63
+ // resolved label source – fixed at construction, so labelLookupGroups need
64
+ // not re-derive or re-resolve them on every search.
65
+ const outputReferenceSources = new Map([...schema.values()].map((searchType) => {
66
+ const sources = labelSources.get(searchType.type);
67
+ return [
68
+ searchType.type,
69
+ referenceFields(searchType)
70
+ .filter((field) => field.output === true && sources?.has(field.name))
71
+ .map((field) => ({
72
+ name: field.name,
73
+ source: sources.get(field.name),
74
+ })),
75
+ ];
76
+ }));
77
+ // Process-lifetime cache per label-source collection, held in the engine
78
+ // closure. Populated lazily on the first cached search; `inFlightLoads` is
79
+ // the single-flight promise per collection so concurrent first-loads share
80
+ // one export each.
81
+ const cachedLabels = new Map();
82
+ const inFlightLoads = new Map();
83
+ function cachedAllLabels(source, ttlMs) {
84
+ const cached = cachedLabels.get(source.collection);
85
+ if (cached !== undefined && Date.now() < cached.expiresAt) {
86
+ return Promise.resolve(cached.labels);
42
87
  }
43
88
  // Single-flight: a load already running serves every concurrent caller.
44
- inFlightLoad ??= loadAllLabels(client, labelsCollection)
89
+ let load = inFlightLoads.get(source.collection);
90
+ load ??= loadAllLabels(client, source)
45
91
  .then((loaded) => {
46
- cachedLabels = loaded;
47
- cacheExpiresAt = Date.now() + ttlMs;
92
+ cachedLabels.set(source.collection, {
93
+ labels: loaded,
94
+ expiresAt: Date.now() + ttlMs,
95
+ });
48
96
  return loaded;
49
97
  })
50
98
  // A failed load degrades to id-only references and is NOT cached, so the
@@ -54,35 +102,52 @@ export function createTypesenseSearchEngine(client, schema, options) {
54
102
  return new Map();
55
103
  })
56
104
  .finally(() => {
57
- inFlightLoad = undefined;
105
+ inFlightLoads.delete(source.collection);
58
106
  });
59
- return inFlightLoad;
107
+ inFlightLoads.set(source.collection, load);
108
+ return load;
109
+ }
110
+ // The merged per-type view over the cached per-collection maps, reused
111
+ // until any constituent map reloads (same instances = still valid), so a
112
+ // multi-source type does not pay an O(all labels) merge per search.
113
+ const mergedCache = new Map();
114
+ function mergeCachedLabels(typeIri, parts) {
115
+ const cached = mergedCache.get(typeIri);
116
+ if (cached !== undefined &&
117
+ cached.parts.length === parts.length &&
118
+ cached.parts.every((part, index) => part === parts[index])) {
119
+ return cached.merged;
120
+ }
121
+ const merged = mergeLabels(parts);
122
+ mergedCache.set(typeIri, { parts, merged });
123
+ return merged;
60
124
  }
61
- // Cached path: the once-loaded full collection serves labels by in-memory
125
+ // Cached path: the once-loaded full collections serve labels by in-memory
62
126
  // lookup (no per-search round-trip). The load does not depend on the
63
127
  // response, so it is started BEFORE awaiting the search and runs alongside
64
128
  // it; it never rejects (a failed load degrades to an empty map), so it
65
129
  // 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;
130
+ // `undefined` when the cache is off or the type has no label sources.
131
+ function startCachedLabels(searchType) {
132
+ const sources = distinctLabelSources.get(searchType.type);
133
+ if (options.labelCacheTtlMs === undefined ||
134
+ sources === undefined ||
135
+ sources.length === 0) {
136
+ return undefined;
137
+ }
138
+ const ttlMs = options.labelCacheTtlMs;
139
+ return Promise.all(sources.map((source) => cachedAllLabels(source, ttlMs))).then((maps) => mergeCachedLabels(searchType.type, maps));
72
140
  }
73
- // Labels are supplementary: a failed lookup (e.g. the sidecar collection
141
+ // Labels are supplementary: a failed lookup (e.g. a label-source collection
74
142
  // 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) {
143
+ // search, so the listing still renders with bare IRIs. `groups` is a thunk
144
+ // so the cached and source-less paths never pay for collecting the IRIs.
145
+ async function resolveLabels(cachedLabelsPromise, groups) {
78
146
  if (cachedLabelsPromise !== undefined) {
79
147
  return cachedLabelsPromise;
80
148
  }
81
- if (options.labelsCollection === undefined) {
82
- return new Map();
83
- }
84
149
  try {
85
- return await fetchLabels(client, options.labelsCollection, iris());
150
+ return await fetchLabels(client, groups(), options.onLabelError);
86
151
  }
87
152
  catch (error) {
88
153
  options.onLabelError?.(error);
@@ -98,12 +163,12 @@ export function createTypesenseSearchEngine(client, schema, options) {
98
163
  assertTypeInSchema(schema, searchType);
99
164
  assertValidQuery(query, searchType);
100
165
  const params = buildSearchParams(query, searchType, options);
101
- const cachedLabelsPromise = startCachedLabels();
166
+ const cachedLabelsPromise = startCachedLabels(searchType);
102
167
  const response = (await client
103
168
  .collections(collections.get(searchType.type))
104
169
  .documents()
105
170
  .search(params));
106
- const labels = await resolveLabels(cachedLabelsPromise, () => referenceIris(response, searchType));
171
+ const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.type), outputReferenceSources.get(searchType.type) ?? []));
107
172
  return parseSearchResponse(response, searchType, labels);
108
173
  },
109
174
  async searchFacets(searchType, queries) {
@@ -115,7 +180,7 @@ export function createTypesenseSearchEngine(client, schema, options) {
115
180
  return [];
116
181
  }
117
182
  const collection = collections.get(searchType.type);
118
- const cachedLabelsPromise = startCachedLabels();
183
+ const cachedLabelsPromise = startCachedLabels(searchType);
119
184
  // The whole batch travels as ONE multi_search round-trip. Each query
120
185
  // compiles as facet-only regardless of what it carries: no hits
121
186
  // (per_page 0) and no ordering – nothing is transferred or sorted that
@@ -128,9 +193,7 @@ export function createTypesenseSearchEngine(client, schema, options) {
128
193
  }));
129
194
  // One label lookup serves every successful facet result in the batch.
130
195
  const responses = results.filter((result) => !('error' in result));
131
- const labels = await resolveLabels(cachedLabelsPromise, () => [
132
- ...new Set(responses.flatMap((response) => referenceIris(response, searchType))),
133
- ]);
196
+ const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses, labelSources.get(searchType.type), outputReferenceSources.get(searchType.type) ?? []));
134
197
  // multi_search reports a failed entry inline instead of rejecting the
135
198
  // call; pass that through as a per-query outcome – naming the facets,
136
199
  // not the position, since the batch order is the caller's internal –
@@ -146,121 +209,172 @@ export function createTypesenseSearchEngine(client, schema, options) {
146
209
  return engine;
147
210
  }
148
211
  /**
149
- * Load the FULL `labels` collection into a label map via the documents export
150
- * endpoint, which streams every document as JSONL (one JSON object per line).
151
- * Each line is reconstructed by {@link labelToLocalizedValue}, exactly as the
212
+ * Load a FULL label-source collection into a label map via the documents
213
+ * export endpoint, which streams every document as JSONL (one JSON object per
214
+ * line). Each line is reconstructed by {@link labelValue}, exactly as the
152
215
  * per-search {@link fetchLabels} path does for its `multi_search` hits.
153
216
  */
154
- async function loadAllLabels(client, collection) {
155
- const jsonl = await client.collections(collection).documents().export();
217
+ async function loadAllLabels(client, source) {
218
+ const jsonl = await client
219
+ .collections(source.collection)
220
+ .documents()
221
+ .export();
156
222
  const labels = new Map();
157
223
  for (const line of jsonl.split('\n')) {
158
224
  if (line.length === 0) {
159
225
  continue;
160
226
  }
161
227
  const document = JSON.parse(line);
162
- labels.set(String(document.id), labelToLocalizedValue(document));
228
+ const label = labelValue(document, source.labelField);
229
+ if (label !== undefined) {
230
+ labels.set(String(document.id), label);
231
+ }
163
232
  }
164
233
  return labels;
165
234
  }
166
- /** Every distinct reference IRI whose label the result will actually use. */
167
- function referenceIris(response, searchType) {
168
- const referenceFieldSet = new Set(referenceFields(searchType).map((field) => field.name));
169
- // Hits only carry labels for OUTPUT reference fields: reconstructDocument skips
170
- // non-output fields, so resolving a non-output reference's hit labels (e.g. a
171
- // facet-only `class` with dozens of IRIs per hit) is pure waste.
172
- const outputReferenceFields = referenceFields(searchType)
173
- .filter((field) => field.output === true)
174
- .map((field) => field.name);
175
- const iris = new Set();
176
- for (const hit of response.hits ?? []) {
177
- for (const name of outputReferenceFields) {
178
- const raw = hit.document[name];
179
- if (Array.isArray(raw)) {
180
- for (const value of raw) {
181
- iris.add(String(value));
235
+ /**
236
+ * Group every reference IRI the result will actually use by its field’s label
237
+ * source, one group per source collection. Fields without a `labelSource`
238
+ * stay id-only, so their IRIs never travel.
239
+ */
240
+ function labelLookupGroups(responses, sources, outputSources) {
241
+ if (sources === undefined || sources.size === 0) {
242
+ return [];
243
+ }
244
+ const irisByCollection = new Map();
245
+ const add = (source, iri) => {
246
+ let group = irisByCollection.get(source.collection);
247
+ if (group === undefined) {
248
+ group = { source, iris: new Set() };
249
+ irisByCollection.set(source.collection, group);
250
+ }
251
+ group.iris.add(iri);
252
+ };
253
+ for (const response of responses) {
254
+ // Hits only carry labels for OUTPUT reference fields (reconstructDocument
255
+ // skips non-output fields); `outputSources` pairs each with its resolved
256
+ // source, precomputed per type.
257
+ for (const hit of response.hits ?? []) {
258
+ for (const { name, source } of outputSources) {
259
+ const raw = hit.document[name];
260
+ if (Array.isArray(raw)) {
261
+ for (const value of raw) {
262
+ add(source, String(value));
263
+ }
264
+ }
265
+ else if (typeof raw === 'string') {
266
+ add(source, raw);
182
267
  }
183
- }
184
- else if (typeof raw === 'string') {
185
- iris.add(raw);
186
268
  }
187
269
  }
188
- }
189
- // Reference-facet bucket values are IRIs too (incl. facet-only references like
190
- // `class`); resolve them in the same lookup.
191
- for (const facet of response.facet_counts ?? []) {
192
- if (referenceFieldSet.has(facet.field_name)) {
270
+ // Reference-facet bucket values are IRIs too (incl. facet-only references
271
+ // like `class`); resolve them in the same lookup. Skip a non-source facet
272
+ // (e.g. a keyword facet) in one check instead of probing every bucket.
273
+ for (const facet of response.facet_counts ?? []) {
274
+ const source = sources.get(facet.field_name);
275
+ if (source === undefined) {
276
+ continue;
277
+ }
193
278
  for (const bucket of facet.counts) {
194
- iris.add(bucket.value);
279
+ add(source, bucket.value);
195
280
  }
196
281
  }
197
282
  }
198
- return [...iris];
283
+ return [...irisByCollection.values()].map(({ source, iris }) => ({
284
+ source,
285
+ iris: [...iris],
286
+ }));
199
287
  }
200
288
  /**
201
- * Resolve labels for `iris` from the sidecar `labels` collection. Each
202
- * `label_${locale}` becomes a language-map entry; the default `label` is the
203
- * untagged (`und`) fallback when no locale variant exists.
289
+ * Resolve labels from each group’s label-source collection. Localized labels
290
+ * are reconstructed from the source type’s `label` declaration; a bare
291
+ * untagged `label` value is the `und` fallback when no locale variant exists.
204
292
  *
205
- * Sent as one `multi_search` (POST) call, the id-list split over per-search
206
- * batches: the id-list of a page or facet carrying many references — e.g. a
207
- * dataset with dozens of classes — would overflow Typesense’s GET query-string
208
- * limit (4000 chars, and IRIs URL-encode to several times their length) if it
209
- * travelled in the URL. POST puts it in the body; each batch stays under
210
- * Typesense’s `per_page` cap, and bundling the batches keeps it one round-trip
211
- * regardless of IRI count. Exported for unit testing against a fake client.
293
+ * All groups travel as ONE `multi_search` (POST) call, each group’s id-list
294
+ * split over per-search batches: the id-list of a page or facet carrying many
295
+ * references — e.g. a dataset with dozens of classes — would overflow
296
+ * Typesense’s GET query-string limit (4000 chars, and IRIs URL-encode to
297
+ * several times their length) if it travelled in the URL. POST puts it in the
298
+ * body; each batch stays under Typesense’s `per_page` cap, and bundling the
299
+ * batches keeps it one round-trip regardless of IRI or source count. Exported
300
+ * for unit testing against a fake client.
212
301
  */
213
- export async function fetchLabels(client, collection, iris) {
302
+ export async function fetchLabels(client, groups, onError) {
214
303
  const labels = new Map();
215
- if (iris.length === 0) {
216
- return labels;
217
- }
218
304
  const searches = [];
219
- for (let start = 0; start < iris.length; start += LABEL_BATCH_SIZE) {
220
- const batch = iris.slice(start, start + LABEL_BATCH_SIZE);
221
- searches.push({
222
- collection,
223
- q: '*',
224
- query_by: 'label',
225
- filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
226
- per_page: batch.length,
227
- });
305
+ const groupPerSearch = [];
306
+ for (const group of groups) {
307
+ for (let start = 0; start < group.iris.length; start += LABEL_BATCH_SIZE) {
308
+ const batch = group.iris.slice(start, start + LABEL_BATCH_SIZE);
309
+ searches.push({
310
+ collection: group.source.collection,
311
+ q: '*',
312
+ query_by: group.source.queryBy,
313
+ filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
314
+ per_page: batch.length,
315
+ });
316
+ groupPerSearch.push(group);
317
+ }
318
+ }
319
+ if (searches.length === 0) {
320
+ return labels;
228
321
  }
229
322
  const { results } = (await client.multiSearch.perform({ searches }));
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.
323
+ results.forEach((result, index) => {
324
+ // multi_search reports a failed entry inline instead of rejecting. Isolate
325
+ // it: report the failed source and skip its entry so the other sources'
326
+ // labels still land, instead of blanking the whole page to id-only – its
327
+ // own references fall back to id-only as their IRIs stay absent.
234
328
  if ('error' in result) {
235
- throw new Error(`Typesense label lookup failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
329
+ const failure = new Error(`Typesense label lookup in “${groupPerSearch[index].source.collection}” failed${result.code !== undefined ? ` (${result.code})` : ''}: ${result.error}`);
330
+ onError?.(failure);
331
+ return;
236
332
  }
237
333
  for (const hit of result.hits ?? []) {
238
- labels.set(String(hit.document.id), labelToLocalizedValue(hit.document));
334
+ const label = labelValue(hit.document, groupPerSearch[index].source.labelField);
335
+ if (label !== undefined) {
336
+ labels.set(String(hit.document.id), label);
337
+ }
239
338
  }
240
- }
339
+ });
241
340
  return labels;
242
341
  }
243
342
  /** Typesense caps `per_page` at 250; the multi_search POST body holds the
244
343
  * id-list comfortably, so resolve references in batches of this size. */
245
344
  const LABEL_BATCH_SIZE = 200;
246
- /** Turn a `labels` document into a language map (`label_${locale}` → locale). */
247
- function labelToLocalizedValue(document) {
248
- const map = {};
249
- for (const [key, value] of Object.entries(document)) {
250
- if (key.startsWith('label_') && typeof value === 'string') {
251
- map[key.slice('label_'.length)] = [value];
252
- }
345
+ /**
346
+ * Reconstruct a label document into a language map via the source type’s
347
+ * `label` declaration (its per-locale display fields), with a bare untagged
348
+ * `label` value as the `und` fallback when no locale variant exists, or
349
+ * `undefined` when the document carries no usable label – so the reference
350
+ * stays id-only rather than gaining an empty label.
351
+ */
352
+ function labelValue(document, labelField) {
353
+ const localized = localizedValue(document, labelField);
354
+ if (localized !== undefined) {
355
+ return localized;
356
+ }
357
+ const untagged = document[labelField.name];
358
+ return typeof untagged === 'string' ? { und: [untagged] } : undefined;
359
+ }
360
+ /** Merge per-collection label maps into one IRI-keyed map (URI identity). */
361
+ function mergeLabels(maps) {
362
+ if (maps.length === 1) {
363
+ return maps[0];
253
364
  }
254
- if (Object.keys(map).length === 0 && typeof document.label === 'string') {
255
- map.und = [document.label];
365
+ const merged = new Map();
366
+ for (const map of maps) {
367
+ for (const [iri, label] of map) {
368
+ merged.set(iri, label);
369
+ }
256
370
  }
257
- return map;
371
+ return merged;
258
372
  }
259
373
  /**
260
374
  * Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
261
375
  * the flat, fanned-out document is turned back into a logical one (per-locale
262
376
  * display fields → a language map, reference IRIs → labelled references via the
263
- * sidecar `labels` lookup, scalars passed through). `labels` maps a reference IRI
377
+ * label-source lookup, scalars passed through). `labels` maps a reference IRI
264
378
  * to its resolved label; an IRI absent from it yields an id-only reference.
265
379
  */
266
380
  export function parseSearchResponse(response, searchType, labels) {
@@ -270,7 +384,12 @@ export function parseSearchResponse(response, searchType, labels) {
270
384
  }));
271
385
  // Reference facets are IRI-keyed; their buckets carry a resolved data label.
272
386
  // Plain facets (tokens, free strings) carry no label — the consumer owns display.
273
- const referenceFacets = new Set(referenceFields(searchType).map((field) => field.name));
387
+ // Only reference facets with a label source get bucket labels; an id-only
388
+ // reference facet stays id-only even when a cached full-collection map holds
389
+ // its IRIs.
390
+ const referenceFacets = new Set(referenceFields(searchType)
391
+ .filter((field) => field.labelSource !== undefined)
392
+ .map((field) => field.name));
274
393
  const facets = {};
275
394
  for (const facet of response.facet_counts ?? []) {
276
395
  const labelled = referenceFacets.has(facet.field_name);
@@ -348,7 +467,10 @@ function referenceValue(flat, field, labels) {
348
467
  }
349
468
  const iris = Array.isArray(raw) ? raw : [String(raw)];
350
469
  const references = iris.map((iri) => {
351
- const label = labels.get(iri);
470
+ // A reference without a label source is id-only by declaration; never
471
+ // attach a label, even if the (cached, full-collection) map happens to
472
+ // hold this IRI from another source.
473
+ const label = field.labelSource === undefined ? undefined : labels.get(iri);
352
474
  return label === undefined ? { id: iri } : { id: iri, label };
353
475
  });
354
476
  return field.array === true ? references : references[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.4.4",
3
+ "version": "0.6.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.3.1",
28
+ "@lde/search": "^0.4.0",
29
29
  "@lde/text-normalization": "^0.1.1",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@lde/dataset": "^0.7.8",
38
- "@lde/pipeline": "^0.33.4"
38
+ "@lde/pipeline": "^0.34.0"
39
39
  }
40
40
  }