@lde/search-typesense 0.4.3 → 0.5.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 +12 -4
- package/dist/search.d.ts +49 -32
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +239 -117
- package/package.json +3 -3
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, {
|
|
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
|
|
37
|
-
collection
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
52
|
-
*
|
|
53
|
-
* untagged
|
|
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
|
-
*
|
|
56
|
-
* batches: the id-list of a page or facet carrying many
|
|
57
|
-
* dataset with dozens of classes — would overflow
|
|
58
|
-
* limit (4000 chars, and IRIs URL-encode to
|
|
59
|
-
* travelled in the URL. POST puts it in the
|
|
60
|
-
* Typesense’s `per_page` cap, and bundling the
|
|
61
|
-
*
|
|
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'>,
|
|
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
|
-
*
|
|
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;
|
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;AACxC,OAAO,EAGL,KAAK,cAAc,EAMnB,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
89
|
+
let load = inFlightLoads.get(source.collection);
|
|
90
|
+
load ??= loadAllLabels(client, source)
|
|
45
91
|
.then((loaded) => {
|
|
46
|
-
cachedLabels
|
|
47
|
-
|
|
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
|
-
|
|
105
|
+
inFlightLoads.delete(source.collection);
|
|
58
106
|
});
|
|
59
|
-
|
|
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
|
|
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
|
|
67
|
-
function startCachedLabels() {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
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. `
|
|
76
|
-
// the cached and
|
|
77
|
-
async function resolveLabels(cachedLabelsPromise,
|
|
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.
|
|
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, () =>
|
|
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
|
|
150
|
-
* endpoint, which streams every document as JSONL (one JSON object per
|
|
151
|
-
* Each line is reconstructed by {@link
|
|
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,
|
|
155
|
-
const jsonl = await client
|
|
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
|
-
|
|
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
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
*
|
|
203
|
-
* untagged
|
|
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
|
-
*
|
|
206
|
-
* batches: the id-list of a page or facet carrying many
|
|
207
|
-
* dataset with dozens of classes — would overflow
|
|
208
|
-
* limit (4000 chars, and IRIs URL-encode to
|
|
209
|
-
* travelled in the URL. POST puts it in the
|
|
210
|
-
* Typesense’s `per_page` cap, and bundling the
|
|
211
|
-
*
|
|
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,
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
// multi_search reports a failed entry inline instead of rejecting
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.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.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.
|
|
38
|
+
"@lde/pipeline": "^0.33.4"
|
|
39
39
|
}
|
|
40
40
|
}
|