@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.
@@ -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
@@ -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,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.labelSource !== undefined)
70
+ .filter((field) => labelSourceNameOf(field) !== undefined)
70
71
  .map((field) => {
71
- const source = typesByName.get(field.labelSource);
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
- return parseSearchResponse(response, searchType, labels, schema);
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.labelSource !== undefined)
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 without a label source is id-only by declaration; never
628
- // attach a label, even if the (cached, full-collection) map happens to
629
- // hold this IRI from another source.
630
- const label = field.labelSource === undefined ? undefined : labels.get(iri);
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.24.1",
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.21.1",
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"