@lde/search-typesense 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lookup.d.ts +30 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/lookup.js +141 -0
- package/dist/search.d.ts +2 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +52 -16
- package/package.json +2 -2
package/dist/lookup.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Client } from 'typesense';
|
|
2
|
+
import { type ReferenceProjection, type RootType, type SearchSchema, type SearchType } from '@lde/search';
|
|
3
|
+
/**
|
|
4
|
+
* One resolved level of a {@link ReferenceProjection}: the referents fetched
|
|
5
|
+
* for it, keyed by IRI and still in the engine’s flat physical shape, plus the
|
|
6
|
+
* levels resolved *from* them.
|
|
7
|
+
*
|
|
8
|
+
* Kept flat rather than reconstructed here so one reconstruction path serves
|
|
9
|
+
* hits and referents alike – the surface never learns that a nested document
|
|
10
|
+
* arrived by a second round-trip rather than from the hit itself.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResolvedReferents {
|
|
13
|
+
/** The Root Type these referents are declared by – reconstruction reads them
|
|
14
|
+
* through the target’s own declaration, never the referrer’s. */
|
|
15
|
+
readonly target: RootType;
|
|
16
|
+
readonly documents: ReadonlyMap<string, Record<string, unknown>>;
|
|
17
|
+
/** Keyed by the reference field’s name on the type this level carries. */
|
|
18
|
+
readonly children: ReadonlyMap<string, ResolvedReferents>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a projection against the documents that name its referents: one
|
|
22
|
+
* batched `multi_search` **per level**, never per document, because the IRIs of
|
|
23
|
+
* a level are deduped across the whole page before the round-trip and grouped
|
|
24
|
+
* by the collection they live in.
|
|
25
|
+
*
|
|
26
|
+
* Failure degrades rather than throws: an unresolved level leaves its
|
|
27
|
+
* references as bare ids, exactly as an unresolved label does.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveProjection(client: Pick<Client, 'multiSearch'>, projection: ReferenceProjection | undefined, searchType: SearchType, schema: SearchSchema, collections: ReadonlyMap<string, string>, parents: readonly Record<string, unknown>[], onError?: (error: unknown) => void): Promise<ReadonlyMap<string, ResolvedReferents>>;
|
|
30
|
+
//# sourceMappingURL=lookup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../src/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAQrB;;;;;;;;GAQG;AACH,MAAM,WAAW,iBAAiB;IAChC;sEACkE;IAClE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjE,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D;AAKD;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EACnC,UAAU,EAAE,mBAAmB,GAAG,SAAS,EAC3C,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,EACpB,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC3C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACjC,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAqEjD"}
|
package/dist/lookup.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { displayFieldName, fieldNamed, labelFieldOf, } from '@lde/search/adapter';
|
|
2
|
+
import { escapeFilterValue } from './query-compiler.js';
|
|
3
|
+
/** Typesense caps a filter list; the same batch size the label lookup uses. */
|
|
4
|
+
const BATCH_SIZE = 200;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a projection against the documents that name its referents: one
|
|
7
|
+
* batched `multi_search` **per level**, never per document, because the IRIs of
|
|
8
|
+
* a level are deduped across the whole page before the round-trip and grouped
|
|
9
|
+
* by the collection they live in.
|
|
10
|
+
*
|
|
11
|
+
* Failure degrades rather than throws: an unresolved level leaves its
|
|
12
|
+
* references as bare ids, exactly as an unresolved label does.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveProjection(client, projection, searchType, schema, collections, parents, onError) {
|
|
15
|
+
const resolved = new Map();
|
|
16
|
+
if (projection === undefined || parents.length === 0) {
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
const targetsByName = new Map([...schema.values()].map((rootType) => [rootType.name, rootType]));
|
|
20
|
+
// The level's fields resolve CONCURRENTLY: they read from different
|
|
21
|
+
// collections and nothing links them, so running them in turn would make the
|
|
22
|
+
// stated bound – one round-trip per level – one per field instead.
|
|
23
|
+
const levels = await Promise.all(Object.entries(projection).map(async ([name, level]) => {
|
|
24
|
+
const field = fieldNamed(searchType, name);
|
|
25
|
+
if (field === undefined ||
|
|
26
|
+
field.kind !== 'reference' ||
|
|
27
|
+
field.ref?.strategy !== 'lookup') {
|
|
28
|
+
// `assertValidQuery` rejects this for every caller; skipping keeps a
|
|
29
|
+
// hand-built query from throwing here rather than at the port’s guard.
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const target = targetsByName.get(field.ref.target);
|
|
33
|
+
const collection = target === undefined ? undefined : collections.get(target.class);
|
|
34
|
+
if (target === undefined || collection === undefined) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const include = includeFields(target, level.fields, level.resolve);
|
|
38
|
+
const iris = distinctIris(parents, name);
|
|
39
|
+
// Nothing to read: no referent named, or the selection reduced to the
|
|
40
|
+
// `id` the referring document already carries.
|
|
41
|
+
if (iris.length === 0 || include.length <= 1) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const documents = await fetchReferents(client, collection, iris, include, onError);
|
|
45
|
+
return [
|
|
46
|
+
name,
|
|
47
|
+
{
|
|
48
|
+
target,
|
|
49
|
+
documents,
|
|
50
|
+
// The level below reads the IRIs off the documents this one just
|
|
51
|
+
// fetched – one more round-trip for the page, whatever its size.
|
|
52
|
+
children: await resolveProjection(client, level.resolve, target, schema, collections, [...documents.values()], onError),
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}));
|
|
56
|
+
for (const level of levels) {
|
|
57
|
+
if (level !== undefined) {
|
|
58
|
+
resolved.set(level[0], level[1]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
/** Every distinct IRI a page of documents carries under one reference field. */
|
|
64
|
+
function distinctIris(documents, field) {
|
|
65
|
+
const iris = new Set();
|
|
66
|
+
for (const document of documents) {
|
|
67
|
+
const raw = document[field];
|
|
68
|
+
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
69
|
+
if (typeof value === 'string' && value !== '') {
|
|
70
|
+
iris.add(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...iris];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The physical fields a level needs: the target’s `id`, the physical fanout of
|
|
78
|
+
* each logical field it asked for, and the reference fields the level below
|
|
79
|
+
* reads its IRIs from. Asking for a field set rather than the whole document is
|
|
80
|
+
* what keeps a lookup’s cost proportional to the query rather than to how wide
|
|
81
|
+
* the target happens to be.
|
|
82
|
+
*
|
|
83
|
+
* With no `fields`, a level carries the target’s label alone – what every
|
|
84
|
+
* reference carried before a projection could ask for more.
|
|
85
|
+
*/
|
|
86
|
+
function includeFields(target, wanted, below) {
|
|
87
|
+
const labelField = labelFieldOf(target);
|
|
88
|
+
const names = wanted ?? (labelField === undefined ? [] : [labelField.name]);
|
|
89
|
+
const physical = new Set(['id']);
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const field = fieldNamed(target, name);
|
|
92
|
+
if (field === undefined || field.output !== true) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (field.kind === 'text') {
|
|
96
|
+
for (const locale of field.locales) {
|
|
97
|
+
physical.add(displayFieldName(field, locale));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
physical.add(field.name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const name of Object.keys(below ?? {})) {
|
|
105
|
+
physical.add(name);
|
|
106
|
+
}
|
|
107
|
+
return [...physical];
|
|
108
|
+
}
|
|
109
|
+
/** Fetch a level’s referents by IRI, batched, as flat engine documents. */
|
|
110
|
+
async function fetchReferents(client, collection, iris, include, onError) {
|
|
111
|
+
const documents = new Map();
|
|
112
|
+
const searches = [];
|
|
113
|
+
for (let start = 0; start < iris.length; start += BATCH_SIZE) {
|
|
114
|
+
const batch = iris.slice(start, start + BATCH_SIZE);
|
|
115
|
+
searches.push({
|
|
116
|
+
collection,
|
|
117
|
+
q: '*',
|
|
118
|
+
filter_by: `id:[${batch.map(escapeFilterValue).join(',')}]`,
|
|
119
|
+
include_fields: include.join(','),
|
|
120
|
+
per_page: batch.length,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const { results } = (await client.multiSearch.perform({ searches }));
|
|
125
|
+
for (const result of results) {
|
|
126
|
+
// multi_search reports a failed entry inline instead of rejecting;
|
|
127
|
+
// isolate it, so the other batches' referents still land.
|
|
128
|
+
if (result.error !== undefined) {
|
|
129
|
+
onError?.(new Error(`Typesense reference lookup in “${collection}” failed: ${result.error}`));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const hit of result.hits ?? []) {
|
|
133
|
+
documents.set(String(hit.document.id), hit.document);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
onError?.(error);
|
|
139
|
+
}
|
|
140
|
+
return documents;
|
|
141
|
+
}
|
package/dist/search.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Client } from 'typesense';
|
|
2
2
|
import { type LocalizedValue, type RootTypeOf, type SearchEngine, type SearchResult, type SearchSchema, type SearchType, type TextField } from '@lde/search';
|
|
3
3
|
import { type BuildSearchParamsOptions } from './query-compiler.js';
|
|
4
|
+
import { type ResolvedReferents } from './lookup.js';
|
|
4
5
|
/** Where the engine reads documents – plus every query-compiler knob
|
|
5
6
|
* ({@link BuildSearchParamsOptions}), declared once there and forwarded
|
|
6
7
|
* wholesale into each search. Reference labels resolve per field from the
|
|
@@ -127,5 +128,5 @@ export interface TypesenseSearchResponse {
|
|
|
127
128
|
* does not declare a field’s reference type reconstructs nothing for it rather
|
|
128
129
|
* than the wrong shape, the same call the projection makes.
|
|
129
130
|
*/
|
|
130
|
-
export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>, schema: SearchSchema): SearchResult;
|
|
131
|
+
export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>, schema: SearchSchema, referents?: ReadonlyMap<string, ResolvedReferents>): SearchResult;
|
|
131
132
|
//# sourceMappingURL=search.d.ts.map
|
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;AAExC,OAAO,EAGL,KAAK,cAAc,EAOnB,KAAK,UAAU,EAGf,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,UAAU,EAEf,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
|
package/dist/search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
|
|
1
|
+
import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, nestedReferenceType, 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
|
+
import { resolveProjection } from './lookup.js';
|
|
4
5
|
/**
|
|
5
6
|
* A Typesense-backed {@link SearchEngine}, bound to the whole
|
|
6
7
|
* {@link SearchSchema} at construction – like every other schema consumer.
|
|
@@ -66,9 +67,9 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
66
67
|
const labelSources = new Map([...schema.values()].map((searchType) => [
|
|
67
68
|
searchType.class,
|
|
68
69
|
new Map(referenceFields(searchType)
|
|
69
|
-
.filter((field) => field
|
|
70
|
+
.filter((field) => labelSourceNameOf(field) !== undefined)
|
|
70
71
|
.map((field) => {
|
|
71
|
-
const source = typesByName.get(field
|
|
72
|
+
const source = typesByName.get(labelSourceNameOf(field));
|
|
72
73
|
const labelField = labelFieldOf(source);
|
|
73
74
|
return [
|
|
74
75
|
field.name,
|
|
@@ -194,7 +195,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
194
195
|
// invalid query (unknown field, wrong operator, unknown facet) are both
|
|
195
196
|
// rejected up front, for EVERY caller.
|
|
196
197
|
assertTypeInSchema(schema, searchType);
|
|
197
|
-
assertValidQuery(query, searchType, joins);
|
|
198
|
+
assertValidQuery(query, searchType, schema, joins);
|
|
198
199
|
// Asks for no document (an empty `id` membership): answer it without a
|
|
199
200
|
// round-trip rather than dispatching a query whose only filter compiles
|
|
200
201
|
// away, which would hand back the whole collection.
|
|
@@ -211,12 +212,16 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
|
|
|
211
212
|
// the URL.
|
|
212
213
|
const response = await performSingleSearch(client, collections.get(searchType.class), params);
|
|
213
214
|
const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
|
|
214
|
-
|
|
215
|
+
// What the caller asked to resolve, level by level. Independent of the
|
|
216
|
+
// label lookup above: that one serves facet buckets and unprojected
|
|
217
|
+
// references, and stays cacheable because a label is one small value.
|
|
218
|
+
const referents = await resolveProjection(client, query.resolve, searchType, schema, collections, (response.hits ?? []).map((hit) => hit.document), options.onLabelError);
|
|
219
|
+
return parseSearchResponse(response, searchType, labels, schema, referents);
|
|
215
220
|
},
|
|
216
221
|
async searchFacets(searchType, queries) {
|
|
217
222
|
assertTypeInSchema(schema, searchType);
|
|
218
223
|
for (const query of queries) {
|
|
219
|
-
assertValidQuery(query, searchType, joins);
|
|
224
|
+
assertValidQuery(query, searchType, schema, joins);
|
|
220
225
|
}
|
|
221
226
|
if (queries.length === 0) {
|
|
222
227
|
return [];
|
|
@@ -482,10 +487,10 @@ function mergeLabels(maps) {
|
|
|
482
487
|
* does not declare a field’s reference type reconstructs nothing for it rather
|
|
483
488
|
* than the wrong shape, the same call the projection makes.
|
|
484
489
|
*/
|
|
485
|
-
export function parseSearchResponse(response, searchType, labels, schema) {
|
|
490
|
+
export function parseSearchResponse(response, searchType, labels, schema, referents) {
|
|
486
491
|
const hits = (response.hits ?? []).map((hit) => ({
|
|
487
492
|
id: String(hit.document.id),
|
|
488
|
-
document: reconstructDocument(hit.document, searchType, labels, schema),
|
|
493
|
+
document: reconstructDocument(hit.document, searchType, labels, schema, referents),
|
|
489
494
|
}));
|
|
490
495
|
// Reference facets are IRI-keyed; their buckets carry a resolved data label.
|
|
491
496
|
// Plain facets (tokens, free strings) carry no label – the consumer owns display.
|
|
@@ -493,7 +498,7 @@ export function parseSearchResponse(response, searchType, labels, schema) {
|
|
|
493
498
|
// reference facet stays id-only even when a cached full-collection map holds
|
|
494
499
|
// its IRIs.
|
|
495
500
|
const referenceFacets = new Set(referenceFields(searchType)
|
|
496
|
-
.filter((field) => field
|
|
501
|
+
.filter((field) => labelSourceNameOf(field) !== undefined)
|
|
497
502
|
.map((field) => field.name));
|
|
498
503
|
const facets = {};
|
|
499
504
|
for (const facet of response.facet_counts ?? []) {
|
|
@@ -523,17 +528,17 @@ export function parseSearchResponse(response, searchType, labels, schema) {
|
|
|
523
528
|
return { hits, total: response.found, facets };
|
|
524
529
|
}
|
|
525
530
|
/** Rebuild one logical document from a flat Typesense document. */
|
|
526
|
-
function reconstructDocument(flat, searchType, labels, schema) {
|
|
531
|
+
function reconstructDocument(flat, searchType, labels, schema, referents) {
|
|
527
532
|
const document = {};
|
|
528
533
|
for (const field of outputFields(searchType)) {
|
|
529
|
-
const value = logicalValue(flat, field, labels, schema);
|
|
534
|
+
const value = logicalValue(flat, field, labels, schema, referents);
|
|
530
535
|
if (value !== undefined) {
|
|
531
536
|
document[field.name] = value;
|
|
532
537
|
}
|
|
533
538
|
}
|
|
534
539
|
return document;
|
|
535
540
|
}
|
|
536
|
-
function logicalValue(flat, field, labels, schema) {
|
|
541
|
+
function logicalValue(flat, field, labels, schema, referents) {
|
|
537
542
|
switch (field.kind) {
|
|
538
543
|
case 'text':
|
|
539
544
|
return localizedValue(flat, field);
|
|
@@ -546,6 +551,13 @@ function logicalValue(flat, field, labels, schema) {
|
|
|
546
551
|
if (nested !== undefined) {
|
|
547
552
|
return nestedValue(flat[field.name], field, nested, labels, schema);
|
|
548
553
|
}
|
|
554
|
+
// A resolved lookup carries the referent's own fields, fetched from the
|
|
555
|
+
// target's collection. It reconstructs through the SAME path a hit does,
|
|
556
|
+
// so a consumer cannot tell a projected referent from an inline one.
|
|
557
|
+
const resolved = referents?.get(field.name);
|
|
558
|
+
if (resolved !== undefined) {
|
|
559
|
+
return lookupValue(flat[field.name], field, resolved, labels, schema);
|
|
560
|
+
}
|
|
549
561
|
// A surfaced inline reference stores nested documents, not IRIs, so a
|
|
550
562
|
// schema that does not declare its reference type (a type parsed against
|
|
551
563
|
// a foreign schema) reconstructs nothing rather than the wrong shape –
|
|
@@ -616,6 +628,30 @@ function nestedValue(raw, field, referenceType, labels, schema) {
|
|
|
616
628
|
}
|
|
617
629
|
return field.array === true ? documents : documents[0];
|
|
618
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Rebuild a resolved `lookup` reference: each stored IRI becomes the referent’s
|
|
633
|
+
* own document, read through the **target’s** declaration and carrying whatever
|
|
634
|
+
* the projection asked for. A referent the lookup did not return – an IRI with
|
|
635
|
+
* no document in the target’s collection, or a level whose round-trip failed –
|
|
636
|
+
* degrades to its bare `id` rather than dropping out, so a dangling reference
|
|
637
|
+
* still says which IRI it pointed at.
|
|
638
|
+
*/
|
|
639
|
+
function lookupValue(raw, field, resolved, labels, schema) {
|
|
640
|
+
if (raw === undefined) {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
const iris = Array.isArray(raw) ? raw : [String(raw)];
|
|
644
|
+
const documents = iris.map((iri) => {
|
|
645
|
+
const referent = resolved.documents.get(iri);
|
|
646
|
+
return referent === undefined
|
|
647
|
+
? { id: iri }
|
|
648
|
+
: {
|
|
649
|
+
id: iri,
|
|
650
|
+
...reconstructDocument(referent, resolved.target, labels, schema, resolved.children),
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
return field.array === true ? documents : documents[0];
|
|
654
|
+
}
|
|
619
655
|
/** Map stored reference IRIs to labelled references; id-only when no label. */
|
|
620
656
|
function referenceValue(flat, field, labels) {
|
|
621
657
|
const raw = flat[field.name];
|
|
@@ -624,10 +660,10 @@ function referenceValue(flat, field, labels) {
|
|
|
624
660
|
}
|
|
625
661
|
const iris = Array.isArray(raw) ? raw : [String(raw)];
|
|
626
662
|
const references = iris.map((iri) => {
|
|
627
|
-
// A reference
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
const label = field
|
|
663
|
+
// A reference that resolves nothing is id-only by declaration; never attach
|
|
664
|
+
// a label, even if the (cached, full-collection) map happens to hold this
|
|
665
|
+
// IRI from another source.
|
|
666
|
+
const label = labelSourceNameOf(field) === undefined ? undefined : labels.get(iri);
|
|
631
667
|
return label === undefined ? { id: iri } : { id: iri, label };
|
|
632
668
|
});
|
|
633
669
|
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.25.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.22.0",
|
|
29
29
|
"@lde/text-normalization": "^0.1.2",
|
|
30
30
|
"tslib": "^2.3.0",
|
|
31
31
|
"typesense": "^3.0.6"
|