@lde/search-typesense 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAcrB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
1
+ {"version":3,"file":"collection-definition.d.ts","sourceRoot":"","sources":["../src/collection-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAExD,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAerB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,KAAK,MAAM,CAAC;IAChE;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;4BAGwB;IACxB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA2BxB"}
@@ -1,4 +1,4 @@
1
- import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, localLookupTypeOf, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
1
+ import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, localLookupTargetsOf, TARGET_FIELD, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
2
2
  import { deriveCollectionName } from './collection-name.js';
3
3
  /**
4
4
  * Build a Typesense collection definition from the unified {@link SearchType}, so
@@ -70,20 +70,19 @@ export function buildCollectionDefinition(searchType, options = {}) {
70
70
  return collection;
71
71
  }
72
72
  /**
73
- * The type a field nests, or `undefined` when it nests nothing. Throws when the
74
- * type carries an inline reference the caller gave no schema to resolve: the
75
- * collection would silently store the reference as a string the projection
76
- * never writes, so every document would fail to import.
73
+ * The types a field nests – one for an inline reference or a single-target
74
+ * `local` lookup, several for a `local` lookup naming several targets – or
75
+ * none when it nests nothing. Throws when the type carries an inline reference
76
+ * the caller gave no schema to resolve: the collection would silently store
77
+ * the reference as a string the projection never writes, so every document
78
+ * would fail to import.
77
79
  */
78
- function nestedTypeOf(searchType, field, schema) {
79
- const nested = schema === undefined
80
- ? undefined
81
- : (nestedReferenceType(schema, field) ??
82
- localLookupTypeOf(field, schema));
80
+ function nestedTypesOf(searchType, field, schema) {
81
+ const nested = schema === undefined ? [] : nestedTypesOfNestedField(field, schema);
83
82
  // Reached only for a field carrying a Role, so an inline reference here
84
83
  // stores entries: a Role-less one is an internal reading device, pruned
85
84
  // before the writer.
86
- if (nested === undefined && isInlineReference(field)) {
85
+ if (nested.length === 0 && isInlineReference(field)) {
87
86
  throw new Error(`Building the collection for “${searchType.name}” needs the search schema its surfaced inline reference “${field.name}” resolves against; pass it as the collection-definition option “schema”.`);
88
87
  }
89
88
  return nested;
@@ -143,10 +142,10 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
143
142
  if (isInternalField(field)) {
144
143
  return [];
145
144
  }
146
- const nested = nestedTypeOf(searchType, field, schema);
147
- if (nested !== undefined) {
145
+ const nested = nestedTypesOf(searchType, field, schema);
146
+ if (nested.length > 0) {
148
147
  return [
149
- ...nestedFields(field.name, field, nested, schema, defaultLocale, false,
148
+ ...nestedObjectFields(field.name, field, nested, nested.length > 1, schema, defaultLocale, false,
150
149
  // The declaring type is already on the path, exactly as the frame and
151
150
  // the extraction seed theirs, so a lookup back to it is cut at the
152
151
  // same depth here as it is there.
@@ -260,6 +259,39 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
260
259
  * the children rather than declared, and this is the one place that invariant
261
260
  * can be got wrong.
262
261
  */
262
+ function nestedObjectFields(prefix, reference, nestedTypes, polymorphic, schema, defaultLocale, withinArray, onPath) {
263
+ // `polymorphic` is about the DECLARATION, `nestedTypes` about what is
264
+ // descended into here: a lookup naming several targets of which only one is
265
+ // still off the path stores a discriminator all the same.
266
+ if (!polymorphic) {
267
+ return nestedFields(prefix, reference, nestedTypes[0], schema, defaultLocale, withinArray, onPath);
268
+ }
269
+ // A lookup naming several targets stores a referent of whichever kind it
270
+ // turns out to be under ONE nested object, so the object declares the union
271
+ // of the targets’ fields – each declared once, and `searchSchema` has
272
+ // already refused two targets declaring one name differently – plus the
273
+ // discriminator the projection writes to say which kind an entry is.
274
+ const byName = new Map();
275
+ for (const nestedType of nestedTypes) {
276
+ for (const declared of nestedFields(prefix, reference, nestedType, schema, defaultLocale, withinArray, onPath)) {
277
+ if (!byName.has(declared.name)) {
278
+ byName.set(declared.name, declared);
279
+ }
280
+ }
281
+ }
282
+ const flattensToArray = withinArray || reference.array === true;
283
+ const children = [...byName.values()].filter((declared) => declared.name !== prefix);
284
+ children.push(targetField(prefix, flattensToArray));
285
+ return [
286
+ {
287
+ name: prefix,
288
+ type: flattensToArray ? 'object[]' : 'object',
289
+ index: children.some((child) => child.index !== false),
290
+ optional: reference.required !== true,
291
+ },
292
+ ...children,
293
+ ];
294
+ }
263
295
  function nestedFields(prefix, reference, nestedType, schema, defaultLocale, withinArray = false, onPath = new Set()) {
264
296
  const array = reference.array === true;
265
297
  // A type already on this path is not descended into again. `searchSchema`
@@ -292,8 +324,11 @@ function nestedFields(prefix, reference, nestedType, schema, defaultLocale, with
292
324
  if (isInternalField(field)) {
293
325
  continue;
294
326
  }
295
- const deeper = nestedTypeOfNestedField(field, schema);
296
- if (deeper !== undefined && walked.has(deeper.name)) {
327
+ const deeper = nestedTypesOfNestedField(field, schema);
328
+ // Descend into the types not yet on the path; where every one is, the
329
+ // cycle stops here.
330
+ const descend = deeper.filter((type) => !walked.has(type.name));
331
+ if (deeper.length > 0 && descend.length === 0) {
297
332
  // The cycle stops here, and so does the frame – but a value is still
298
333
  // stored: the extraction falls back to the target's key hop, and the
299
334
  // projection writes the referent as an `{id}` object beside its identity
@@ -309,7 +344,10 @@ function nestedFields(prefix, reference, nestedType, schema, defaultLocale, with
309
344
  // leaf a filter welds on sits beside the object, not inside it.
310
345
  index: false,
311
346
  optional: true,
312
- }, {
347
+ },
348
+ // A cut lookup naming several targets still says which one it
349
+ // stored, exactly as an expanded one does.
350
+ ...(deeper.length > 1 ? [targetField(boundary, boundaryArray)] : []), {
313
351
  // Always a Root Type, so always identified: `searchSchema` rejects
314
352
  // inline cycles, so the only way to arrive at a type already on the
315
353
  // path is a `local` lookup, whose target declares a class.
@@ -320,8 +358,8 @@ function nestedFields(prefix, reference, nestedType, schema, defaultLocale, with
320
358
  }, ...nestedIdentityFields(prefix, field, schema, flattensToArray));
321
359
  continue;
322
360
  }
323
- if (deeper !== undefined) {
324
- children.push(...nestedFields(nestedFieldName(prefix, field.name), field, deeper, schema, defaultLocale, flattensToArray, walked),
361
+ if (descend.length > 0) {
362
+ children.push(...nestedObjectFields(nestedFieldName(prefix, field.name), field, descend, deeper.length > 1, schema, defaultLocale, flattensToArray, walked),
325
363
  // A nested field that stores an object cannot be filtered as itself,
326
364
  // and its own id is a level deeper than a condition can be welded to –
327
365
  // an engine welds conditions on an entry's LEAF fields only. Its
@@ -414,11 +452,24 @@ function nestedIdentityFields(prefix, field, schema, flattensToArray) {
414
452
  },
415
453
  ];
416
454
  }
417
- /** The type a *nested* field itself nests: another inline reference’s type, or
418
- * the Root Type a {@link ReferenceStrategy.local local} lookup projects its
419
- * endpoint through. */
420
- function nestedTypeOfNestedField(field, schema) {
421
- return nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
455
+ /** The stored discriminator of a lookup naming several targets
456
+ * ({@link TARGET_FIELD}): a stored value, never indexed. */
457
+ function targetField(prefix, array) {
458
+ return {
459
+ name: nestedFieldName(prefix, TARGET_FIELD),
460
+ type: array ? 'string[]' : 'string',
461
+ index: false,
462
+ optional: true,
463
+ };
464
+ }
465
+ /** The types a field nests: another inline reference’s type, or the Root
466
+ * Types a {@link ReferenceStrategy.local local} lookup projects its endpoint
467
+ * through. */
468
+ function nestedTypesOfNestedField(field, schema) {
469
+ const referenceType = nestedReferenceType(schema, field);
470
+ return referenceType === undefined
471
+ ? localLookupTargetsOf(field, schema)
472
+ : [referenceType];
422
473
  }
423
474
  /**
424
475
  * The Physical Fields one non-nesting nested field contributes: exactly what
package/dist/lookup.d.ts CHANGED
@@ -16,16 +16,26 @@ import { type ReferenceProjection, type RootType, type SearchSchema, type Search
16
16
  */
17
17
  export type ResolvedReferents = {
18
18
  readonly via: 'lookup';
19
- /** The Root Type these referents are declared by – reconstruction reads
20
- * them through the target’s own declaration, never the referrer’s. */
21
- readonly target: RootType;
22
- readonly documents: ReadonlyMap<string, Record<string, unknown>>;
23
- /** Keyed by the reference field’s name on the type this level carries. */
24
- readonly children: ReadonlyMap<string, ResolvedReferents>;
19
+ /** By IRI. A lookup naming several targets holds referents of any of
20
+ * them here, each saying which. */
21
+ readonly documents: ReadonlyMap<string, ResolvedReferent>;
22
+ /** The levels below, by target name – what a stored referent no
23
+ * collection answered for is reconstructed against. */
24
+ readonly childrenByTarget: ReadonlyMap<string, ReadonlyMap<string, ResolvedReferents>>;
25
25
  } | {
26
26
  readonly via: 'nested';
27
27
  readonly children: ReadonlyMap<string, ResolvedReferents>;
28
28
  };
29
+ /** One referent a lookup level fetched. */
30
+ export interface ResolvedReferent {
31
+ /** The Root Type this referent is declared by – the target whose collection
32
+ * answered for it. Reconstruction reads it through that target’s own
33
+ * declaration, never the referrer’s. */
34
+ readonly target: RootType;
35
+ readonly document: Record<string, unknown>;
36
+ /** The levels below, keyed by the reference field’s name on `target`. */
37
+ readonly children: ReadonlyMap<string, ResolvedReferents>;
38
+ }
29
39
  /**
30
40
  * Resolve a projection against the documents that name its referents: one
31
41
  * batched `multi_search` **per level**, never per document, because the IRIs of
@@ -1 +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;AAUrB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,iBAAiB,GACzB;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB;2EACuE;IACvE,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,GACD;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,CAAC;AAKN;;;;;;;;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,CAwFjD"}
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;AAUrB;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,iBAAiB,GACzB;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB;wCACoC;IACpC,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC1D;4DACwD;IACxD,QAAQ,CAAC,gBAAgB,EAAE,WAAW,CACpC,MAAM,EACN,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CACvC,CAAC;CACH,GACD;IACE,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC3D,CAAC;AAEN,2CAA2C;AAC3C,MAAM,WAAW,gBAAgB;IAC/B;;6CAEyC;IACzC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,yEAAyE;IACzE,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,CAmHjD"}
package/dist/lookup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { displayFieldName, fieldNamed, labelFieldOf, nestedReferenceType, rootTypeNamed, } from '@lde/search/adapter';
1
+ import { displayFieldName, fieldNamed, labelFieldOf, nestedReferenceType, referencedTargetsOf, } from '@lde/search/adapter';
2
2
  import { escapeFilterValue } from './query-compiler.js';
3
3
  /** Typesense caps a filter list; the same batch size the label lookup uses. */
4
4
  const BATCH_SIZE = 200;
@@ -43,30 +43,51 @@ export async function resolveProjection(client, projection, searchType, schema,
43
43
  // hand-built query from throwing here rather than at the port’s guard.
44
44
  return undefined;
45
45
  }
46
- const target = rootTypeNamed(schema, field.ref.target);
47
- const collection = target === undefined ? undefined : collections.get(target.class);
48
- if (target === undefined || collection === undefined) {
46
+ const targets = referencedTargetsOf(field, schema).filter((target) => collections.has(target.class));
47
+ const iris = distinctIris(parents, name);
48
+ if (targets.length === 0 || iris.length === 0) {
49
49
  return undefined;
50
50
  }
51
- const include = includeFields(target, level.fields, level.resolve);
52
- const iris = distinctIris(parents, name);
53
- // Nothing to read: no referent named, or the selection reduced to the
54
- // `id` the referring document already carries.
55
- if (iris.length === 0 || include.length <= 1) {
51
+ // One fetch per target, concurrently: a referent lives in one of the
52
+ // collections, and which one is what a lookup naming several targets
53
+ // exists to find out – so with several, every collection is asked even
54
+ // for a selection reduced to `id`, since the answer types the referent.
55
+ // With one target, a selection reduced to the `id` the referring
56
+ // document already carries reads nothing.
57
+ const perTarget = await Promise.all(targets.map(async (target) => {
58
+ const include = includeFields(target, level.fields, level.resolve);
59
+ if (targets.length === 1 && include.length <= 1) {
60
+ return undefined;
61
+ }
62
+ const fetched = await fetchReferents(client, collections.get(target.class), iris, include, onError);
63
+ // The level below reads the IRIs off the documents this one just
64
+ // fetched – one more round-trip for the page, whatever its size.
65
+ const children = await resolveProjection(client, level.resolve, target, schema, collections, [...fetched.values()], onError);
66
+ return { target, fetched, children };
67
+ }));
68
+ // Declaration order is precedence: an IRI two collections hold belongs
69
+ // to the target declared first.
70
+ const documents = new Map();
71
+ const childrenByTarget = new Map();
72
+ for (const resolved of perTarget) {
73
+ if (resolved === undefined) {
74
+ continue;
75
+ }
76
+ childrenByTarget.set(resolved.target.name, resolved.children);
77
+ for (const [iri, document] of resolved.fetched) {
78
+ if (!documents.has(iri)) {
79
+ documents.set(iri, {
80
+ target: resolved.target,
81
+ document,
82
+ children: resolved.children,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ if (perTarget.every((resolved) => resolved === undefined)) {
56
88
  return undefined;
57
89
  }
58
- const documents = await fetchReferents(client, collection, iris, include, onError);
59
- return [
60
- name,
61
- {
62
- via: 'lookup',
63
- target,
64
- documents,
65
- // The level below reads the IRIs off the documents this one just
66
- // fetched – one more round-trip for the page, whatever its size.
67
- children: await resolveProjection(client, level.resolve, target, schema, collections, [...documents.values()], onError),
68
- },
69
- ];
90
+ return [name, { via: 'lookup', documents, childrenByTarget }];
70
91
  }));
71
92
  for (const level of levels) {
72
93
  if (level !== undefined) {
@@ -142,8 +163,12 @@ function includeFields(target, wanted, below) {
142
163
  physical.add(field.name);
143
164
  }
144
165
  }
166
+ // With several targets the level below merges every target’s selection,
167
+ // so ask each collection only for the reference fields it declares.
145
168
  for (const name of Object.keys(below ?? {})) {
146
- physical.add(name);
169
+ if (fieldNamed(target, name) !== undefined) {
170
+ physical.add(name);
171
+ }
147
172
  }
148
173
  return [...physical];
149
174
  }
@@ -1 +1 @@
1
- {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,QAAQ,EAEb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAC;AAmBrB;mDACmD;AACnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,KACpB,UAAU,GAAG,SAAS,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AA4fD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
1
+ {"version":3,"file":"query-compiler.d.ts","sourceRoot":"","sources":["../src/query-compiler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAEzE,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,QAAQ,EAEb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,UAAU,EAIhB,MAAM,aAAa,CAAC;AAoBrB;mDACmD;AACnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,KACpB,UAAU,GAAG,SAAS,CAAC;IAC5B;;;;;;OAMG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,wBAA6B,GACrC,YAAY,CAAC,MAAM,CAAC,CAkCtB;AAwgBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD"}
@@ -1,5 +1,5 @@
1
1
  import { fold } from '@lde/text-normalization';
2
- import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isInternalField, isRangeFacet, isWelded, joinGraph, localLookupTypeOf, pageForOffset, physicalFields, nestedReferenceType, resolvePath, searchableFields, } from '@lde/search/adapter';
2
+ import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isInternalField, isRangeFacet, isWelded, joinGraph, localLookupTargetsOf, matchLanguage, pageForOffset, physicalFields, nestedReferenceType, resolvePath, searchableFields, } from '@lde/search/adapter';
3
3
  /**
4
4
  * Compile the engine-neutral {@link SearchQuery} into Typesense search
5
5
  * parameters – the query half of the engine adapter. Pure (no client, no env),
@@ -110,12 +110,13 @@ function collectSearchable(searchType, locale, schema, prefix, onPath, names, we
110
110
  const baseWeight = field.searchable.weight;
111
111
  if (field.kind === 'text') {
112
112
  const locales = field.locales;
113
+ const activeLocale = matchLanguage(locale, locales)[0];
113
114
  search.forEach((name, index) => {
114
115
  names.push(qualify(prefix, name));
115
116
  // The active locale keeps full weight; `und` is language-neutral, so
116
117
  // it is never demoted (an untagged-only field would otherwise always
117
118
  // rank below its declared weight).
118
- weights.push(locales[index] === locale || locales[index] === 'und'
119
+ weights.push(locales[index] === activeLocale || locales[index] === 'und'
119
120
  ? baseWeight
120
121
  : Math.max(1, baseWeight - 1));
121
122
  });
@@ -137,14 +138,22 @@ function collectSearchable(searchType, locale, schema, prefix, onPath, names, we
137
138
  if (isInternalField(field)) {
138
139
  continue;
139
140
  }
140
- const nested = nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
141
+ const referenceType = nestedReferenceType(schema, field);
142
+ const nested = referenceType === undefined
143
+ ? localLookupTargetsOf(field, schema)
144
+ : [referenceType];
141
145
  // Cut on the CHILD, not on entry, and against the set this level was
142
146
  // reached with – the same boundary the collection's own walk cuts at
143
147
  // (`nestedFields`). Returning on entry instead skipped the companions of
144
148
  // the level the collection HAD declared, leaving them indexed and absent
145
- // from `query_by` wherever a type reached itself.
146
- if (nested !== undefined && !onPath.has(nested.name)) {
147
- collectSearchable(nested, locale, schema, qualify(prefix, field.name), walked, names, weights);
149
+ // from `query_by` wherever a type reached itself. Several targets each
150
+ // contribute their searchable fields: the one nested object declares them
151
+ // all, and free text has to reach a referent of either kind.
152
+ for (const nestedType of nested) {
153
+ if (onPath.has(nestedType.name)) {
154
+ continue;
155
+ }
156
+ collectSearchable(nestedType, locale, schema, qualify(prefix, field.name), walked, names, weights);
148
157
  }
149
158
  }
150
159
  }
@@ -444,9 +453,11 @@ function compileSort(sort, searchType, locale) {
444
453
  }
445
454
  const field = fieldNamed(searchType, sort.field);
446
455
  if (field !== undefined && field.kind === 'text') {
447
- // Sort on the active locale's key, falling back to the first declared
448
- // locale (e.g. an und-only field sorted by a user in any language).
449
- const index = field.locales.indexOf(locale);
456
+ // Sort on the key of the declared locale the active one best matches as a
457
+ // language range (`nl-NL` sorts on `nl`), falling back to the first
458
+ // declared locale (e.g. an und-only field sorted by a user in any language).
459
+ const activeLocale = matchLanguage(locale, field.locales)[0];
460
+ const index = activeLocale === undefined ? -1 : field.locales.indexOf(activeLocale);
450
461
  const sortName = physicalFields(field).sort[index === -1 ? 0 : index];
451
462
  if (sortName !== undefined) {
452
463
  return `${sortName}:${sort.direction}`;
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/dist/search.js CHANGED
@@ -1,7 +1,8 @@
1
- import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, labelTargetNameOf, localLookupTypeOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { NESTED_DOCUMENT_TYPE, } from '@lde/search';
2
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNamesOf, labelTargetNamesOf, localLookupTargetsOf, nestedReferenceType, outputFields, physicalFields, referencedTargetsOf, referenceFields, storedTargetOf, } from '@lde/search/adapter';
2
3
  import { buildSearchParams, escapeFilterValue, } from './query-compiler.js';
3
4
  import { deriveCollectionName } from './collection-name.js';
4
- import { resolveProjection } from './lookup.js';
5
+ import { resolveProjection, } from './lookup.js';
5
6
  /**
6
7
  * A Typesense-backed {@link SearchEngine}, bound to the whole
7
8
  * {@link SearchSchema} at construction – like every other schema consumer.
@@ -65,25 +66,27 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
65
66
  // (`searchSchema`/`labelFieldOf`), and every type resolved a collection
66
67
  // above, so both lookups below always hit.
67
68
  const typesByName = new Map([...schema.values()].map((searchType) => [searchType.name, searchType]));
69
+ // Per field, one source per target it names, in order of precedence – a
70
+ // reference naming several targets reads labels from every one of them.
68
71
  const labelSources = new Map([...schema.values()].map((searchType) => [
69
72
  searchType.class,
70
73
  new Map(referenceFields(searchType)
71
74
  // An INLINE reference names its label target one level in, through
72
75
  // its identity companion: the companion holds that field's ids, so
73
76
  // that field's target is what can label them.
74
- .filter((field) => labelTargetNameOf(field, schema) !== undefined)
75
- .map((field) => {
76
- const source = typesByName.get(labelTargetNameOf(field, schema));
77
- const labelField = labelFieldOf(source);
78
- return [
79
- field.name,
80
- {
77
+ .filter((field) => labelTargetNamesOf(field, schema).length > 0)
78
+ .map((field) => [
79
+ field.name,
80
+ labelTargetNamesOf(field, schema).map((targetName) => {
81
+ const source = typesByName.get(targetName);
82
+ const labelField = labelFieldOf(source);
83
+ return {
81
84
  collection: collections.get(source.class),
82
85
  labelField,
83
86
  queryBy: physicalFields(labelField).search.join(','),
84
- },
85
- ];
86
- })),
87
+ };
88
+ }),
89
+ ])),
87
90
  ]));
88
91
  // Typesense reports a facet under the field it faceted, which for a reference
89
92
  // inheriting a facet policy is the `${name}_facet` companion, not the
@@ -106,7 +109,9 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
106
109
  const distinctLabelSources = new Map([...labelSources].map(([type, sources]) => [
107
110
  type,
108
111
  [
109
- ...new Map([...sources.values()].map((source) => [source.collection, source])).values(),
112
+ ...new Map([...sources.values()]
113
+ .flat()
114
+ .map((source) => [source.collection, source])).values(),
110
115
  ],
111
116
  ]));
112
117
  // The output reference fields that carry hit labels, each paired with its
@@ -128,10 +133,10 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
128
133
  // the entry – and their FACET buckets are labelled by a separate
129
134
  // path that reads the companion.
130
135
  nestedReferenceType(schema, field) === undefined &&
131
- localLookupTypeOf(field, schema) === undefined)
136
+ localLookupTargetsOf(field, schema).length === 0)
132
137
  .map((field) => ({
133
138
  name: field.name,
134
- source: sources.get(field.name),
139
+ sources: sources.get(field.name),
135
140
  })),
136
141
  ];
137
142
  }));
@@ -242,7 +247,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
242
247
  // in the body, so the filter is bounded by the caller's request, not by
243
248
  // the URL.
244
249
  const response = withDeclaredFacetNames(await performSingleSearch(client, collections.get(searchType.class), params), declaredFacetNames.get(searchType.class));
245
- const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
250
+ const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
246
251
  // What the caller asked to resolve, level by level. Independent of the
247
252
  // label lookup above: that one serves facet buckets and unprojected
248
253
  // references, and stays cacheable because a label is one small value.
@@ -286,7 +291,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
286
291
  : withDeclaredFacetNames(result, declaredNames));
287
292
  // One label lookup serves every successful facet result in the batch.
288
293
  const responses = results.filter((result) => !('error' in result));
289
- const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses, labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
294
+ const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups(responses, labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? [], distinctLabelSources.get(searchType.class) ?? []));
290
295
  // multi_search reports a failed entry inline instead of rejecting the
291
296
  // call; pass that through as a per-query outcome – naming the facets,
292
297
  // not the position, since the batch order is the caller's internal –
@@ -371,33 +376,40 @@ async function loadAllLabels(client, source) {
371
376
  * source, one group per source collection. Fields without a `labelSource`
372
377
  * stay id-only, so their IRIs never travel.
373
378
  */
374
- function labelLookupGroups(responses, sources, outputSources) {
379
+ function labelLookupGroups(responses, sources, outputSources, ordered) {
375
380
  if (sources === undefined || sources.size === 0) {
376
381
  return [];
377
382
  }
378
- const irisByCollection = new Map();
379
- const add = (source, iri) => {
380
- let group = irisByCollection.get(source.collection);
381
- if (group === undefined) {
382
- group = { source, iris: new Set() };
383
- irisByCollection.set(source.collection, group);
383
+ // One group per collection, in the order the TYPE’s fields first name them
384
+ // – seeded up front rather than in the order the hits happen to mention
385
+ // them, because `fetchLabels` reads group order as precedence and a label
386
+ // is resolved per type, not per field: an IRI two collections hold gets the
387
+ // label of the one the type declares first, whichever field carried it.
388
+ const irisByCollection = new Map(ordered.map((source) => [
389
+ source.collection,
390
+ { source, iris: new Set() },
391
+ ]));
392
+ // An IRI of a field naming several targets travels to EVERY one of their
393
+ // collections: which holds it is not known until one answers.
394
+ const add = (fieldSources, iri) => {
395
+ for (const source of fieldSources) {
396
+ irisByCollection.get(source.collection)?.iris.add(iri);
384
397
  }
385
- group.iris.add(iri);
386
398
  };
387
399
  for (const response of responses) {
388
400
  // Hits only carry labels for OUTPUT reference fields (reconstructDocument
389
401
  // skips non-output fields); `outputSources` pairs each with its resolved
390
402
  // source, precomputed per type.
391
403
  for (const hit of response.hits ?? []) {
392
- for (const { name, source } of outputSources) {
404
+ for (const { name, sources: fieldSources } of outputSources) {
393
405
  const raw = hit.document[name];
394
406
  if (Array.isArray(raw)) {
395
407
  for (const value of raw) {
396
- add(source, String(value));
408
+ add(fieldSources, String(value));
397
409
  }
398
410
  }
399
411
  else if (typeof raw === 'string') {
400
- add(source, raw);
412
+ add(fieldSources, raw);
401
413
  }
402
414
  }
403
415
  }
@@ -405,19 +417,18 @@ function labelLookupGroups(responses, sources, outputSources) {
405
417
  // like `class`); resolve them in the same lookup. Skip a non-source facet
406
418
  // (e.g. a keyword facet) in one check instead of probing every bucket.
407
419
  for (const facet of response.facet_counts ?? []) {
408
- const source = sources.get(facet.field_name);
409
- if (source === undefined) {
420
+ const facetSources = sources.get(facet.field_name);
421
+ if (facetSources === undefined) {
410
422
  continue;
411
423
  }
412
424
  for (const bucket of facet.counts) {
413
- add(source, bucket.value);
425
+ add(facetSources, bucket.value);
414
426
  }
415
427
  }
416
428
  }
417
- return [...irisByCollection.values()].map(({ source, iris }) => ({
418
- source,
419
- iris: [...iris],
420
- }));
429
+ return [...irisByCollection.values()]
430
+ .filter(({ iris }) => iris.size > 0)
431
+ .map(({ source, iris }) => ({ source, iris: [...iris] }));
421
432
  }
422
433
  /**
423
434
  * The response with each facet filed under the declared field name its engine
@@ -504,9 +515,17 @@ export async function fetchLabels(client, groups, onError) {
504
515
  return;
505
516
  }
506
517
  for (const hit of result.hits ?? []) {
518
+ const id = String(hit.document.id);
519
+ // First group wins: the groups arrive in the order a field declares its
520
+ // targets, so an IRI two collections hold is labelled by the target
521
+ // declared first – the same precedence the lookup and the projection
522
+ // apply.
523
+ if (labels.has(id)) {
524
+ continue;
525
+ }
507
526
  const label = localizedValue(hit.document, groupPerSearch[index].source.labelField);
508
527
  if (label !== undefined) {
509
- labels.set(String(hit.document.id), label);
528
+ labels.set(id, label);
510
529
  }
511
530
  }
512
531
  });
@@ -523,7 +542,10 @@ function mergeLabels(maps) {
523
542
  const merged = new Map();
524
543
  for (const map of maps) {
525
544
  for (const [iri, label] of map) {
526
- merged.set(iri, label);
545
+ // First source wins, as it does for a fetched lookup (`fetchLabels`).
546
+ if (!merged.has(iri)) {
547
+ merged.set(iri, label);
548
+ }
527
549
  }
528
550
  }
529
551
  return merged;
@@ -552,7 +574,7 @@ export function parseSearchResponse(response, searchType, labels, schema, refere
552
574
  // reference facet stays id-only even when a cached full-collection map holds
553
575
  // its IRIs.
554
576
  const referenceFacets = new Set(referenceFields(searchType)
555
- .filter((field) => labelTargetNameOf(field, schema) !== undefined)
577
+ .filter((field) => labelTargetNamesOf(field, schema).length > 0)
556
578
  .map((field) => field.name));
557
579
  const facets = {};
558
580
  for (const facet of response.facet_counts ?? []) {
@@ -606,15 +628,15 @@ function logicalValue(flat, field, labels, schema, referents) {
606
628
  if (nested !== undefined) {
607
629
  // A nested level resolves nothing itself; what it carries are the
608
630
  // levels below it, which the entries are reconstructed against.
609
- return nestedValue(flat[field.name], field, nested, labels, schema, resolved?.children);
631
+ return nestedValue(flat[field.name], field, nested, labels, schema, resolved?.via === 'nested' ? resolved.children : undefined);
610
632
  }
611
633
  // Checked BEFORE the plain lookup below, because a `local` lookup
612
634
  // resolves through the same kind of level while storing a different
613
635
  // shape: its stored value is the endpoint's own document, not an id, so
614
636
  // reading it as one would stringify the object into a bogus `id`.
615
- const localType = localLookupTypeOf(field, schema);
616
- if (localType !== undefined) {
617
- return localLookupValue(flat[field.name], field, localType, resolved, labels, schema);
637
+ const localTargets = localLookupTargetsOf(field, schema);
638
+ if (localTargets.length > 0) {
639
+ return localLookupValue(flat[field.name], field, localTargets, resolved, labels, schema);
618
640
  }
619
641
  // A resolved lookup carries the referent's own fields, fetched from the
620
642
  // target's collection. It reconstructs through the SAME path a hit does,
@@ -687,12 +709,7 @@ function localizedValue(flat, field) {
687
709
  */
688
710
  function nestedValue(raw, field, referenceType, labels, schema, children) {
689
711
  const referents = (Array.isArray(raw) ? raw : [raw]).filter((referent) => typeof referent === 'object' && referent !== null);
690
- const documents = referents.map((referent) => {
691
- const document = reconstructDocument(referent, referenceType, labels, schema, children);
692
- return typeof referent.id === 'string'
693
- ? { id: referent.id, ...document }
694
- : document;
695
- });
712
+ const documents = referents.map((referent) => nestedDocument(referent, referenceType, labels, schema, children));
696
713
  if (documents.length === 0) {
697
714
  return undefined;
698
715
  }
@@ -717,16 +734,51 @@ function nestedValue(raw, field, referenceType, labels, schema, children) {
717
734
  * than a bare IRI;
718
735
  * - not identified → what was stated here, with no `id` at all.
719
736
  */
720
- function localLookupValue(raw, field, target, resolved, labels, schema) {
721
- const fetched = resolved?.via === 'lookup' ? resolved.documents : new Map();
722
- const entries = (Array.isArray(raw) ? raw : [raw])
737
+ function localLookupValue(raw, field, targets, resolved, labels, schema) {
738
+ const fetched = resolved?.via === 'lookup'
739
+ ? resolved.documents
740
+ : new Map();
741
+ const documents = (Array.isArray(raw) ? raw : [raw])
723
742
  .filter((entry) => typeof entry === 'object' && entry !== null)
724
743
  .map((entry) => {
725
744
  const id = typeof entry.id === 'string' ? entry.id : undefined;
726
745
  const authoritative = id === undefined ? undefined : fetched.get(id);
727
- return authoritative ?? entry;
746
+ // An entry no collection answered for is read through the declaration
747
+ // it was stored under – which it says itself where there were several
748
+ // to choose from, and the only one otherwise.
749
+ if (authoritative !== undefined) {
750
+ return typedDocument(targets.length > 1, authoritative.target, nestedDocument(authoritative.document, authoritative.target, labels, schema, authoritative.children));
751
+ }
752
+ // Its own lookups resolve against the level its target’s fetch carried
753
+ // down, so a nested IRI it shares with a fetched referent still nests.
754
+ const target = storedTargetOf(entry, targets);
755
+ return typedDocument(targets.length > 1, target, nestedDocument(entry, target, labels, schema, resolved?.via === 'lookup'
756
+ ? resolved.childrenByTarget.get(target.name)
757
+ : undefined));
728
758
  });
729
- return nestedValue(entries, field, target, labels, schema, resolved?.children);
759
+ if (documents.length === 0) {
760
+ return undefined;
761
+ }
762
+ return field.array === true ? documents : documents[0];
763
+ }
764
+ /**
765
+ * A nested document read through one of a lookup’s **several** targets, marked
766
+ * with that type’s name ({@link NESTED_DOCUMENT_TYPE}) so a surface can tell
767
+ * which it is – the marker is a symbol, so it never surfaces as a field. A
768
+ * single-target lookup marks nothing: there is nothing to tell apart.
769
+ */
770
+ function typedDocument(polymorphic, target, document) {
771
+ return polymorphic
772
+ ? { ...document, [NESTED_DOCUMENT_TYPE]: target.name }
773
+ : document;
774
+ }
775
+ /** One nested document rebuilt from a flat referent, carrying its `id` where
776
+ * it has one – the shared body of every nesting that reads a referent. */
777
+ function nestedDocument(referent, searchType, labels, schema, children) {
778
+ const document = reconstructDocument(referent, searchType, labels, schema, children);
779
+ return typeof referent.id === 'string'
780
+ ? { id: referent.id, ...document }
781
+ : document;
730
782
  }
731
783
  /**
732
784
  * Rebuild a resolved `lookup` reference: each stored IRI becomes the referent’s
@@ -741,14 +793,15 @@ function lookupValue(raw, field, resolved, labels, schema) {
741
793
  return undefined;
742
794
  }
743
795
  const iris = Array.isArray(raw) ? raw : [String(raw)];
796
+ const polymorphic = referencedTargetsOf(field, schema).length > 1;
744
797
  const documents = iris.map((iri) => {
745
798
  const referent = resolved.documents.get(iri);
746
799
  return referent === undefined
747
800
  ? { id: iri }
748
- : {
801
+ : typedDocument(polymorphic, referent.target, {
749
802
  id: iri,
750
- ...reconstructDocument(referent, resolved.target, labels, schema, resolved.children),
751
- };
803
+ ...reconstructDocument(referent.document, referent.target, labels, schema, referent.children),
804
+ });
752
805
  });
753
806
  return field.array === true ? documents : documents[0];
754
807
  }
@@ -763,7 +816,7 @@ function referenceValue(flat, field, labels) {
763
816
  // A reference that resolves nothing is id-only by declaration; never attach
764
817
  // a label, even if the (cached, full-collection) map happens to hold this
765
818
  // IRI from another source.
766
- const label = labelSourceNameOf(field) === undefined ? undefined : labels.get(iri);
819
+ const label = labelSourceNamesOf(field).length === 0 ? undefined : labels.get(iri);
767
820
  return label === undefined ? { id: iri } : { id: iri, label };
768
821
  });
769
822
  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.33.0",
3
+ "version": "0.34.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.0",
28
+ "@lde/search": "^0.29.0",
29
29
  "@lde/text-normalization": "^0.2.0",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.1.1"