@lde/search-typesense 0.17.0 → 0.18.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,11 +1,20 @@
1
1
  import type { CollectionCreateSchema } from 'typesense';
2
- import { type SearchType } from '@lde/search';
2
+ import { type SearchSchema, type SearchType } from '@lde/search';
3
3
  /** Deployment-specific options the generic field model does not carry. */
4
4
  export interface CollectionDefinitionOptions {
5
5
  /** The Typesense collection (or alias) name. Omit to derive it from the
6
6
  * type’s `name` ({@link deriveCollectionName}); supply one to override that
7
7
  * (an env prefix, a multi-tenant name, an existing collection). */
8
8
  readonly name?: string;
9
+ /**
10
+ * The Search Schema the type belongs to – required only when the type
11
+ * surfaces an inline reference, whose nested fields are declared from the
12
+ * {@link ReferenceType} the schema resolves. A type without one needs no
13
+ * schema, so a caller that declares no nesting passes none; a type that does
14
+ * fails here rather than building a collection that silently omits the
15
+ * nesting.
16
+ */
17
+ readonly schema?: SearchSchema;
9
18
  /** Snowball stemming locale for non-localized searchable fields (e.g. `en`).
10
19
  * Unset, those fields are not stemmed – folding still applies – so no
11
20
  * language is ever assumed. Localized text search fields always stem in
@@ -45,6 +54,14 @@ export interface CollectionDefinitionOptions {
45
54
  * {@link isInternalField Internal fields} (those declaring no role) are omitted
46
55
  * entirely: they exist only as a projection-time reading device for the
47
56
  * derives, so the collection stores nothing for them.
57
+ *
58
+ * A surfaced (`output`) inline reference is stored as a **nested object** –
59
+ * `object` or `object[]` – with one nested Physical Field per output field of
60
+ * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
61
+ * `enable_nested_fields` for the collection. Nested fields carry `output` only
62
+ * (enforced by `searchSchema`), so they are declared `index: false`: each
63
+ * referent’s values stay grouped on disk and cost no RAM, exactly like the
64
+ * display labels.
48
65
  */
49
66
  export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
50
67
  //# sourceMappingURL=collection-definition.d.ts.map
@@ -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,EAAoB,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAQhE,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;wEAEoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CAexB"}
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,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAWrB,0EAA0E;AAC1E,MAAM,WAAW,2BAA2B;IAC1C;;wEAEoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,UAAU,EACtB,OAAO,GAAE,2BAAgC,GACxC,sBAAsB,CA0BxB"}
@@ -1,4 +1,4 @@
1
- import { displayFieldPattern, isInternalField, physicalFields, } from '@lde/search/adapter';
1
+ import { displayFieldPattern, isInlineReference, isInternalField, 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
@@ -29,13 +29,26 @@ import { deriveCollectionName } from './collection-name.js';
29
29
  * {@link isInternalField Internal fields} (those declaring no role) are omitted
30
30
  * entirely: they exist only as a projection-time reading device for the
31
31
  * derives, so the collection stores nothing for them.
32
+ *
33
+ * A surfaced (`output`) inline reference is stored as a **nested object** –
34
+ * `object` or `object[]` – with one nested Physical Field per output field of
35
+ * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
36
+ * `enable_nested_fields` for the collection. Nested fields carry `output` only
37
+ * (enforced by `searchSchema`), so they are declared `index: false`: each
38
+ * referent’s values stay grouped on disk and cost no RAM, exactly like the
39
+ * display labels.
32
40
  */
33
41
  export function buildCollectionDefinition(searchType, options = {}) {
34
42
  const { defaultLocale } = options;
35
43
  const collection = {
36
44
  name: options.name ?? deriveCollectionName(searchType),
37
- fields: searchType.fields.flatMap((field) => typesenseFields(field, defaultLocale, options.defaultSortingField)),
45
+ fields: searchType.fields.flatMap((field) => typesenseFields(searchType, field, defaultLocale, options.defaultSortingField, options.schema)),
38
46
  };
47
+ // Typesense rejects an `object`/`object[]` field unless nesting is enabled,
48
+ // so the flag is a consequence of the declaration – never a knob.
49
+ if (collection.fields?.some((field) => field.type.startsWith('object'))) {
50
+ collection.enable_nested_fields = true;
51
+ }
39
52
  if (options.defaultSortingField !== undefined) {
40
53
  collection.default_sorting_field = options.defaultSortingField;
41
54
  }
@@ -44,8 +57,24 @@ export function buildCollectionDefinition(searchType, options = {}) {
44
57
  }
45
58
  return collection;
46
59
  }
60
+ /**
61
+ * The {@link ReferenceType} a field nests, or `undefined` when it nests
62
+ * nothing. Throws when the type surfaces an inline reference the caller gave no
63
+ * schema to resolve: the collection would silently store the reference as a
64
+ * string the projection never writes, so every document would fail to import.
65
+ */
66
+ function nestedTypeOf(searchType, field, schema) {
67
+ const nested = schema === undefined ? undefined : nestedReferenceType(schema, field);
68
+ // Reached only for a field carrying a Role, so an inline reference here is a
69
+ // surfaced one: `searchSchema` allows an inline reference no Role but
70
+ // `output`.
71
+ if (nested === undefined && isInlineReference(field)) {
72
+ 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”.`);
73
+ }
74
+ return nested;
75
+ }
47
76
  /** The physical Typesense fields one declaration produces. */
48
- function typesenseFields(field, defaultLocale, defaultSortingField) {
77
+ function typesenseFields(searchType, field, defaultLocale, defaultSortingField, schema) {
49
78
  // An internal field (no role) is projected as a reading device for the
50
79
  // derives and pruned before the writer, so the collection stores nothing for
51
80
  // it: not stored, not indexed, no RAM. Same predicate the projection prunes
@@ -53,6 +82,10 @@ function typesenseFields(field, defaultLocale, defaultSortingField) {
53
82
  if (isInternalField(field)) {
54
83
  return [];
55
84
  }
85
+ const nested = nestedTypeOf(searchType, field, schema);
86
+ if (nested !== undefined) {
87
+ return nestedFields(field.name, field, nested, schema);
88
+ }
56
89
  const names = physicalFields(field);
57
90
  if (field.kind === 'text') {
58
91
  const locales = field.locales;
@@ -121,6 +154,59 @@ function typesenseFields(field, defaultLocale, defaultSortingField) {
121
154
  }
122
155
  return fields;
123
156
  }
157
+ /**
158
+ * The nested object one surfaced inline reference stores, plus one nested
159
+ * Physical Field per output field of its {@link ReferenceType} – recursing for
160
+ * a reference type that itself surfaces an inline reference, to the depth the
161
+ * schema declares (`searchSchema` rejects inline cycles, so the recursion
162
+ * terminates).
163
+ *
164
+ * Everything nested is `index: false`: a nested field carries `output` only, so
165
+ * it is stored with its referent and read back with it, never searched, faceted
166
+ * or sorted on. That keeps the RAM lever intact – a nested document is display
167
+ * weight, like the per-language labels – and lets the declared child types be
168
+ * exactly what the projection writes without an indexing contract to satisfy.
169
+ * Declaring the children rather than only the object is what makes the nesting
170
+ * legible in the collection, and what keeps an {@link isInternalField Internal
171
+ * Field} inside a Reference Type visibly contributing nothing.
172
+ */
173
+ function nestedFields(prefix, reference, referenceType, schema) {
174
+ const array = reference.array === true;
175
+ const fields = [
176
+ {
177
+ name: prefix,
178
+ type: array ? 'object[]' : 'object',
179
+ index: false,
180
+ optional: reference.required !== true,
181
+ },
182
+ ];
183
+ for (const field of referenceType.fields) {
184
+ if (isInternalField(field)) {
185
+ continue;
186
+ }
187
+ const nested = nestedReferenceType(schema, field);
188
+ if (nested !== undefined) {
189
+ fields.push(...nestedFields(nestedFieldName(prefix, field.name), field, nested, schema));
190
+ continue;
191
+ }
192
+ // A nested text field stores its display values exactly as a root one does
193
+ // – one `${name}_<lang>` per present language, matched by one pattern
194
+ // field – only under the referent’s prefix. It has no search or sort
195
+ // companions: those are Roles a nested field cannot declare.
196
+ const pattern = field.kind === 'text' ? displayFieldPattern(field) : undefined;
197
+ fields.push({
198
+ name: nestedFieldName(prefix, pattern ?? field.name),
199
+ type: field.kind === 'text' ? 'string' : typesenseValueType(field),
200
+ index: false,
201
+ // Always optional: `required` is a promise about the *referent* (this
202
+ // value is on every referent), while Typesense’s flag is about the
203
+ // document. Under a multi-valued reference those are different claims,
204
+ // and only the referent-level one is the declaration’s.
205
+ optional: true,
206
+ });
207
+ }
208
+ return fields;
209
+ }
124
210
  /** The Typesense field type for a non-localized field, from its `kind`. 64-bit
125
211
  * integers (and dates, stored as Unix seconds) so large counts never overflow. */
126
212
  function typesenseValueType(field) {
@@ -1,6 +1,6 @@
1
1
  import type { Client } from 'typesense';
2
2
  import type { SearchType } from '@lde/search';
3
- import type { CollectionDefinitionOptions } from './collection-definition.js';
3
+ import { type CollectionDefinitionOptions } from './collection-definition.js';
4
4
  /**
5
5
  * The tuning knobs both rebuild writers share, on top of the collection-definition
6
6
  * options. Each writer may add its own (In-place adds `maxSweepableSources`).
@@ -29,7 +29,12 @@ export interface ResolvedRebuildOptions {
29
29
  * when the deployment supplies none.
30
30
  *
31
31
  * Writers resolve at construction rather than per run, so an underivable name
32
- * throws when the writer is built, not on the first rebuild.
32
+ * throws when the writer is built, not on the first rebuild. The collection
33
+ * definition is built here and discarded for the same reason: every way a
34
+ * declaration can fail to describe a collection – an unresolvable surfaced
35
+ * inline reference, whose nesting needs the schema – then fails at construction
36
+ * rather than inside the first run, after a lock is held and a whole extraction
37
+ * has been paid for.
33
38
  */
34
39
  export declare function resolveRebuildOptions(searchType: SearchType, options: RebuildOptions): ResolvedRebuildOptions;
35
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,4BAA4B,CAAC;AAK9E;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAcxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
1
+ {"version":3,"file":"rebuild-support.d.ts","sourceRoot":"","sources":["../src/rebuild-support.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAEL,KAAK,2BAA2B,EACjC,MAAM,4BAA4B,CAAC;AAKpC;;;GAGG;AACH,MAAM,WAAW,cAAe,SAAQ,2BAA2B;IACjE,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;0FAC0F;AAC1F,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;6DAEyD;IACzD,QAAQ,CAAC,iBAAiB,EAAE,2BAA2B,GAAG;QACxD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,cAAc,GACtB,sBAAsB,CAgBxB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,IAAI,CAWN;AAED,4EAA4E;AAC5E,wBAAuB,cAAc,CAAC,SAAS,EAC7C,SAAS,EAAE,aAAa,CAAC,SAAS,CAAC,EACnC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACtC,aAAa,CAAC,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAInD;AAED,mEAAmE;AACnE,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAKf"}
@@ -1,3 +1,4 @@
1
+ import { buildCollectionDefinition, } from './collection-definition.js';
1
2
  import { deriveCollectionName } from './collection-name.js';
2
3
  import { DEFAULT_LOCK_TTL_MS } from './lock.js';
3
4
  import { DEFAULT_BATCH_SIZE } from './import.js';
@@ -7,11 +8,16 @@ import { DEFAULT_BATCH_SIZE } from './import.js';
7
8
  * when the deployment supplies none.
8
9
  *
9
10
  * Writers resolve at construction rather than per run, so an underivable name
10
- * throws when the writer is built, not on the first rebuild.
11
+ * throws when the writer is built, not on the first rebuild. The collection
12
+ * definition is built here and discarded for the same reason: every way a
13
+ * declaration can fail to describe a collection – an unresolvable surfaced
14
+ * inline reference, whose nesting needs the schema – then fails at construction
15
+ * rather than inside the first run, after a lock is held and a whole extraction
16
+ * has been paid for.
11
17
  */
12
18
  export function resolveRebuildOptions(searchType, options) {
13
19
  const { batchSize = DEFAULT_BATCH_SIZE, lockTtlMs = DEFAULT_LOCK_TTL_MS, ...definitionOptions } = options;
14
- return {
20
+ const resolved = {
15
21
  batchSize,
16
22
  lockTtlMs,
17
23
  definitionOptions: {
@@ -19,6 +25,8 @@ export function resolveRebuildOptions(searchType, options) {
19
25
  name: definitionOptions.name ?? deriveCollectionName(searchType),
20
26
  },
21
27
  };
28
+ buildCollectionDefinition(searchType, resolved.definitionOptions);
29
+ return resolved;
22
30
  }
23
31
  /**
24
32
  * Reject a {@link SearchType} that declares any of the bookkeeping field names
package/dist/search.d.ts CHANGED
@@ -118,8 +118,14 @@ export interface TypesenseSearchResponse {
118
118
  * Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
119
119
  * the flat, fanned-out document is turned back into a logical one (per-locale
120
120
  * display fields → a language map, reference IRIs → labelled references via the
121
- * label-source lookup, scalars passed through). `labels` maps a reference IRI
122
- * to its resolved label; an IRI absent from it yields an id-only reference.
121
+ * label-source lookup, nested objects → nested Search Documents, scalars passed
122
+ * through). `labels` maps a reference IRI to its resolved label; an IRI absent
123
+ * from it yields an id-only reference. `schema` resolves the Reference Type a
124
+ * surfaced inline reference nests – required, not optional: reconstruction is a
125
+ * function of the whole schema exactly as the engine is, and a caller that
126
+ * omitted it would silently serve nothing for every nested field. A schema that
127
+ * does not declare a field’s reference type reconstructs nothing for it rather
128
+ * than the wrong shape, the same call the projection makes.
123
129
  */
124
- export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>): SearchResult;
130
+ export declare function parseSearchResponse(response: TypesenseSearchResponse, searchType: SearchType, labels: ReadonlyMap<string, LocalizedValue>, schema: SearchSchema): SearchResult;
125
131
  //# 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,4 +1,4 @@
1
- import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isRangeFacet, isUnsatisfiable, labelFieldOf, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, labelFieldOf, 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
4
  /**
@@ -192,7 +192,7 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
192
192
  // the URL.
193
193
  const response = await performSingleSearch(client, collections.get(searchType.class), params);
194
194
  const labels = await resolveLabels(cachedLabelsPromise, () => labelLookupGroups([response], labelSources.get(searchType.class), outputReferenceSources.get(searchType.class) ?? []));
195
- return parseSearchResponse(response, searchType, labels);
195
+ return parseSearchResponse(response, searchType, labels, schema);
196
196
  },
197
197
  async searchFacets(searchType, queries) {
198
198
  assertTypeInSchema(schema, searchType);
@@ -243,7 +243,8 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
243
243
  error: new Error(`Typesense facet search for “${queries[index].facets.join('”, “')}” failed${result?.code !== undefined ? ` (${result.code})` : ''}: ${result?.error ?? 'no result entry'}`),
244
244
  }
245
245
  : {
246
- facets: parseSearchResponse(result, searchType, labels).facets,
246
+ facets: parseSearchResponse(result, searchType, labels, schema)
247
+ .facets,
247
248
  };
248
249
  });
249
250
  return outcomes;
@@ -453,13 +454,19 @@ function mergeLabels(maps) {
453
454
  * Reconstruct a Typesense response into the engine-neutral {@link SearchResult}:
454
455
  * the flat, fanned-out document is turned back into a logical one (per-locale
455
456
  * display fields → a language map, reference IRIs → labelled references via the
456
- * label-source lookup, scalars passed through). `labels` maps a reference IRI
457
- * to its resolved label; an IRI absent from it yields an id-only reference.
457
+ * label-source lookup, nested objects → nested Search Documents, scalars passed
458
+ * through). `labels` maps a reference IRI to its resolved label; an IRI absent
459
+ * from it yields an id-only reference. `schema` resolves the Reference Type a
460
+ * surfaced inline reference nests – required, not optional: reconstruction is a
461
+ * function of the whole schema exactly as the engine is, and a caller that
462
+ * omitted it would silently serve nothing for every nested field. A schema that
463
+ * does not declare a field’s reference type reconstructs nothing for it rather
464
+ * than the wrong shape, the same call the projection makes.
458
465
  */
459
- export function parseSearchResponse(response, searchType, labels) {
466
+ export function parseSearchResponse(response, searchType, labels, schema) {
460
467
  const hits = (response.hits ?? []).map((hit) => ({
461
468
  id: String(hit.document.id),
462
- document: reconstructDocument(hit.document, searchType, labels),
469
+ document: reconstructDocument(hit.document, searchType, labels, schema),
463
470
  }));
464
471
  // Reference facets are IRI-keyed; their buckets carry a resolved data label.
465
472
  // Plain facets (tokens, free strings) carry no label – the consumer owns display.
@@ -493,22 +500,40 @@ export function parseSearchResponse(response, searchType, labels) {
493
500
  return { hits, total: response.found, facets };
494
501
  }
495
502
  /** Rebuild one logical document from a flat Typesense document. */
496
- function reconstructDocument(flat, searchType, labels) {
503
+ function reconstructDocument(flat, searchType, labels, schema) {
497
504
  const document = {};
498
505
  for (const field of outputFields(searchType)) {
499
- const value = logicalValue(flat, field, labels);
506
+ const value = logicalValue(flat, field, labels, schema);
500
507
  if (value !== undefined) {
501
508
  document[field.name] = value;
502
509
  }
503
510
  }
504
511
  return document;
505
512
  }
506
- function logicalValue(flat, field, labels) {
513
+ function logicalValue(flat, field, labels, schema) {
507
514
  switch (field.kind) {
508
515
  case 'text':
509
516
  return localizedValue(flat, field);
510
- case 'reference':
511
- return referenceValue(flat, field, labels);
517
+ case 'reference': {
518
+ // A surfaced inline reference carries its referent’s own fields, so it
519
+ // reconstructs as a nested Search Document rather than as an IRI plus a
520
+ // label. Nesting is rebuilt HERE, below every surface, so a second
521
+ // surface inherits it instead of reimplementing it (ADR 11).
522
+ const nested = nestedReferenceType(schema, field);
523
+ if (nested !== undefined) {
524
+ return nestedValue(flat[field.name], field, nested, labels, schema);
525
+ }
526
+ // A surfaced inline reference stores nested documents, not IRIs, so a
527
+ // schema that does not declare its reference type (a type parsed against
528
+ // a foreign schema) reconstructs nothing rather than the wrong shape –
529
+ // the same call the projection makes. Only output fields are
530
+ // reconstructed, and `searchSchema` allows an inline reference no Role
531
+ // but `output`, so every inline reference reaching this point is a
532
+ // surfaced one.
533
+ return isInlineReference(field)
534
+ ? undefined
535
+ : referenceValue(flat, field, labels);
536
+ }
512
537
  case 'keyword': {
513
538
  const value = flat[field.name];
514
539
  return Array.isArray(value) || typeof value === 'string'
@@ -546,6 +571,28 @@ function localizedValue(flat, field) {
546
571
  }
547
572
  return Object.keys(map).length > 0 ? map : undefined;
548
573
  }
574
+ /**
575
+ * Rebuild a surfaced inline reference from the nested object(s) the engine
576
+ * stored: one {@link NestedDocument} per referent, each carrying its Reference
577
+ * Type’s own output fields – so a multi-valued reference keeps every referent’s
578
+ * values grouped rather than smeared across parallel arrays. `id` is carried
579
+ * only when the referent had one; a blank-node referent nests without it, since
580
+ * a nested document is read, not addressed. A nested reference type may itself
581
+ * surface an inline reference, which recurses through {@link reconstructDocument}.
582
+ */
583
+ function nestedValue(raw, field, referenceType, labels, schema) {
584
+ const referents = (Array.isArray(raw) ? raw : [raw]).filter((referent) => typeof referent === 'object' && referent !== null);
585
+ const documents = referents.map((referent) => {
586
+ const document = reconstructDocument(referent, referenceType, labels, schema);
587
+ return typeof referent.id === 'string'
588
+ ? { id: referent.id, ...document }
589
+ : document;
590
+ });
591
+ if (documents.length === 0) {
592
+ return undefined;
593
+ }
594
+ return field.array === true ? documents : documents[0];
595
+ }
549
596
  /** Map stored reference IRIs to labelled references; id-only when no label. */
550
597
  function referenceValue(flat, field, labels) {
551
598
  const raw = flat[field.name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.14.0",
28
+ "@lde/search": "^0.15.0",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"