@lde/search-typesense 0.27.3 → 0.28.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.
@@ -19,11 +19,11 @@ export interface CollectionDefinitionOptions {
19
19
  */
20
20
  readonly collectionNameFor?: (searchType: SearchType) => string;
21
21
  /**
22
- * The Search Schema the type belongs to – required when the type surfaces an
23
- * inline reference (whose nested fields are declared from the
24
- * {@link ReferenceType} the schema resolves), declares a joinable
25
- * reference (whose target collection the schema’s {@link joinGraph}
26
- * resolves). A type doing neither needs no schema, so a caller passes none;
22
+ * The Search Schema the type belongs to – required when the type carries an
23
+ * inline reference (whose nested fields are declared from the reference type
24
+ * the schema resolves), or declares a joinable reference (whose target
25
+ * collection the schema’s {@link joinGraph} resolves).
26
+ * A type doing neither needs no schema, so a caller passes none;
27
27
  * a type that does fails here rather than building a collection that
28
28
  * silently omits the nesting or the reference.
29
29
  *
@@ -82,13 +82,18 @@ export interface CollectionDefinitionOptions {
82
82
  * entirely: they exist only as a projection-time reading device for the
83
83
  * derives, so the collection stores nothing for them.
84
84
  *
85
- * A surfaced (`output`) inline reference is stored as a **nested object** –
86
- * `object` or `object[]` – with one nested Physical Field per output field of
87
- * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
88
- * `enable_nested_fields` for the collection. Nested fields carry `output` only
89
- * (enforced by `searchSchema`), so they are declared `index: false`: each
90
- * referent’s values stay grouped on disk and cost no RAM, exactly like the
91
- * display labels.
85
+ * An inline reference is stored as a **nested object** – `object` or
86
+ * `object[]` – with one nested Physical Field per field of the type it nests
87
+ * ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
88
+ * collection. A nested field is `index: false` unless it declares a query Role,
89
+ * so a display-only nesting stays disk weight and costs no RAM, exactly like
90
+ * the display labels; one that opts into `filterable` or `searchable` is
91
+ * indexed, and its parent object with it (see {@link nestedFields}).
92
+ *
93
+ * An inline reference declaring `filterable` or `facetable` also emits its
94
+ * {@link ReferenceStrategy.identity identity companion} – a flat id field the
95
+ * engine can filter and facet, which a nested object is not
96
+ * ({@link identityCompanionFields}).
92
97
  */
93
98
  export declare function buildCollectionDefinition(searchType: SearchType, options?: CollectionDefinitionOptions): CollectionCreateSchema;
94
99
  //# 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,EAGL,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AAarB,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;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;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;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, nestedFieldName, nestedReferenceType, physicalFields, } from '@lde/search/adapter';
1
+ import { displayFieldPattern, ID_FIELD, isInlineReference, isInternalField, joinGraph, localLookupTypeOf, 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
@@ -36,13 +36,18 @@ import { deriveCollectionName } from './collection-name.js';
36
36
  * entirely: they exist only as a projection-time reading device for the
37
37
  * derives, so the collection stores nothing for them.
38
38
  *
39
- * A surfaced (`output`) inline reference is stored as a **nested object** –
40
- * `object` or `object[]` – with one nested Physical Field per output field of
41
- * its {@link ReferenceType} ({@link nestedFieldName}), which turns on
42
- * `enable_nested_fields` for the collection. Nested fields carry `output` only
43
- * (enforced by `searchSchema`), so they are declared `index: false`: each
44
- * referent’s values stay grouped on disk and cost no RAM, exactly like the
45
- * display labels.
39
+ * An inline reference is stored as a **nested object** – `object` or
40
+ * `object[]` – with one nested Physical Field per field of the type it nests
41
+ * ({@link nestedFieldName}), which turns on `enable_nested_fields` for the
42
+ * collection. A nested field is `index: false` unless it declares a query Role,
43
+ * so a display-only nesting stays disk weight and costs no RAM, exactly like
44
+ * the display labels; one that opts into `filterable` or `searchable` is
45
+ * indexed, and its parent object with it (see {@link nestedFields}).
46
+ *
47
+ * An inline reference declaring `filterable` or `facetable` also emits its
48
+ * {@link ReferenceStrategy.identity identity companion} – a flat id field the
49
+ * engine can filter and facet, which a nested object is not
50
+ * ({@link identityCompanionFields}).
46
51
  */
47
52
  export function buildCollectionDefinition(searchType, options = {}) {
48
53
  const { defaultLocale, collectionNameFor = deriveCollectionName } = options;
@@ -64,16 +69,19 @@ export function buildCollectionDefinition(searchType, options = {}) {
64
69
  return collection;
65
70
  }
66
71
  /**
67
- * The {@link ReferenceType} a field nests, or `undefined` when it nests
68
- * nothing. Throws when the type surfaces an inline reference the caller gave no
69
- * schema to resolve: the collection would silently store the reference as a
70
- * string the projection never writes, so every document would fail to import.
72
+ * The type a field nests, or `undefined` when it nests nothing. Throws when the
73
+ * type carries an inline reference the caller gave no schema to resolve: the
74
+ * collection would silently store the reference as a string the projection
75
+ * never writes, so every document would fail to import.
71
76
  */
72
77
  function nestedTypeOf(searchType, field, schema) {
73
- const nested = schema === undefined ? undefined : nestedReferenceType(schema, field);
74
- // Reached only for a field carrying a Role, so an inline reference here is a
75
- // surfaced one: `searchSchema` allows an inline reference no Role but
76
- // `output`.
78
+ const nested = schema === undefined
79
+ ? undefined
80
+ : (nestedReferenceType(schema, field) ??
81
+ localLookupTypeOf(field, schema));
82
+ // Reached only for a field carrying a Role, so an inline reference here
83
+ // stores entries: a Role-less one is an internal reading device, pruned
84
+ // before the writer.
77
85
  if (nested === undefined && isInlineReference(field)) {
78
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”.`);
79
87
  }
@@ -136,7 +144,10 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
136
144
  }
137
145
  const nested = nestedTypeOf(searchType, field, schema);
138
146
  if (nested !== undefined) {
139
- return nestedFields(field.name, field, nested, schema);
147
+ return [
148
+ ...nestedFields(field.name, field, nested, schema, defaultLocale),
149
+ ...identityCompanionFields(field, schema),
150
+ ];
140
151
  }
141
152
  const names = physicalFields(field, schema);
142
153
  if (field.kind === 'text') {
@@ -223,53 +234,217 @@ function typesenseFields(searchType, field, defaultLocale, defaultSortingField,
223
234
  return fields;
224
235
  }
225
236
  /**
226
- * The nested object one surfaced inline reference stores, plus one nested
227
- * Physical Field per output field of its {@link ReferenceType} – recursing for
228
- * a reference type that itself surfaces an inline reference, to the depth the
229
- * schema declares (`searchSchema` rejects inline cycles, so the recursion
230
- * terminates).
237
+ * The nested object one inline reference stores, plus one nested Physical Field
238
+ * per field of the type it nests – recursing for a nested field that itself
239
+ * nests (another inline reference, or a
240
+ * {@link ReferenceStrategy.local local} lookup, which stores its endpoint
241
+ * shaped by the target’s own declaration). `searchSchema` rejects inline
242
+ * cycles, so the recursion terminates.
231
243
  *
232
- * Everything nested is `index: false`: a nested field carries `output` only, so
233
- * it is stored with its referent and read back with it, never searched, faceted
234
- * or sorted on. That keeps the RAM lever intact – a nested document is display
235
- * weight, like the per-language labels – and lets the declared child types be
236
- * exactly what the projection writes without an indexing contract to satisfy.
237
- * Declaring the children rather than only the object is what makes the nesting
238
- * legible in the collection, and what keeps an {@link isInternalField Internal
239
- * Field} inside a Reference Type visibly contributing nothing.
244
+ * **A nested field is indexed exactly when it declares a query Role.** One that
245
+ * declares only `output` keeps `index: false`: stored with its referent, read
246
+ * back with it, costing disk rather than RAM – the same lever the per-language
247
+ * display labels use. `filterable` or `searchable` turns indexing on for that
248
+ * one field, and nothing else.
249
+ *
250
+ * **The parent object must be indexed whenever any descendant is.** Typesense
251
+ * silently ignores an indexed child under an `index: false` parent: no error,
252
+ * no match, every query answering empty. So the parent’s flag is computed from
253
+ * the children rather than declared, and this is the one place that invariant
254
+ * can be got wrong.
240
255
  */
241
- function nestedFields(prefix, reference, referenceType, schema) {
256
+ function nestedFields(prefix, reference, nestedType, schema, defaultLocale, withinArray = false, onPath = new Set()) {
242
257
  const array = reference.array === true;
243
- const fields = [
244
- {
245
- name: prefix,
246
- type: array ? 'object[]' : 'object',
258
+ // A type already on this path is not descended into again. `searchSchema`
259
+ // rejects INLINE cycles, but a {@link ReferenceStrategy.local local} lookup
260
+ // may reach a Root Type that reaches back – `Work → Person → Work` is a
261
+ // reasonable schema and nothing forbids it – and without this the builder
262
+ // recurses until the stack gives out. Stopping here matches where the FRAME
263
+ // stops ({@link inlineFramingDepth}), so the collection declares exactly the
264
+ // depth the projection can fill.
265
+ const walked = new Set(onPath).add(nestedType.name);
266
+ // Typesense flattens a nested object, so a value under a multi-valued
267
+ // ancestor arrives as an array however single-valued the declaration is.
268
+ // That only matters for an INDEXED field, whose declared type is checked
269
+ // against what is stored; an unindexed one is stored as-is.
270
+ const flattensToArray = withinArray || array;
271
+ const children = [];
272
+ // A `local` lookup nests its endpoint’s own document, which carries the `id`
273
+ // that identifies it. The target type does not declare `id` – no type
274
+ // declares its own key – so it is added here or it would be the one stored
275
+ // value the collection never mentions.
276
+ if (nestedType.class !== undefined) {
277
+ children.push({
278
+ name: nestedFieldName(prefix, 'id'),
279
+ type: flattensToArray ? 'string[]' : 'string',
247
280
  index: false,
248
- optional: reference.required !== true,
249
- },
250
- ];
251
- for (const field of referenceType.fields) {
281
+ optional: true,
282
+ });
283
+ }
284
+ for (const field of nestedType.fields) {
252
285
  if (isInternalField(field)) {
253
286
  continue;
254
287
  }
255
- const nested = nestedReferenceType(schema, field);
256
- if (nested !== undefined) {
257
- fields.push(...nestedFields(nestedFieldName(prefix, field.name), field, nested, schema));
288
+ const deeper = nestedTypeOfNestedField(field, schema);
289
+ if (deeper !== undefined && walked.has(deeper.name)) {
290
+ // The cycle stops here, and so does the frame – nothing will ever be
291
+ // stored under this field, so nothing is declared for it. Falling
292
+ // through would declare it as a LEAF, whose scalar type is not what a
293
+ // nested object would be stored as.
258
294
  continue;
259
295
  }
260
- // A nested text field stores its display values exactly as a root one does
261
- // – one `${name}_<lang>` per present language, matched by one pattern
262
- // field – only under the referent’s prefix. It has no search or sort
263
- // companions: those are Roles a nested field cannot declare.
264
- const pattern = field.kind === 'text' ? displayFieldPattern(field) : undefined;
296
+ if (deeper !== undefined) {
297
+ children.push(...nestedFields(nestedFieldName(prefix, field.name), field, deeper, schema, defaultLocale, flattensToArray, walked),
298
+ // A nested field that stores an object cannot be filtered as itself,
299
+ // and its own id is a level deeper than a condition can be welded to –
300
+ // an engine welds conditions on an entry's LEAF fields only. Its
301
+ // identity companion is that leaf, so it sits beside the object rather
302
+ // than inside it.
303
+ ...nestedIdentityFields(prefix, field, schema));
304
+ continue;
305
+ }
306
+ children.push(...nestedLeafFields(prefix, field, defaultLocale, flattensToArray));
307
+ }
308
+ return [
309
+ {
310
+ name: prefix,
311
+ // Flattening applies to an object exactly as it does to a value: a
312
+ // single-valued nesting inside a multi-valued one arrives as a list of
313
+ // objects, so it is only declared `object` where nothing above it is an
314
+ // `object[]`.
315
+ type: flattensToArray ? 'object[]' : 'object',
316
+ // Never hard-coded: see the parent-object rule above.
317
+ index: children.some((child) => child.index !== false),
318
+ optional: reference.required !== true,
319
+ },
320
+ ...children,
321
+ ];
322
+ }
323
+ /**
324
+ * The **identity companion** of an inline reference: the flat field holding the
325
+ * ids its entries reference, which the engine filters and facets in place of
326
+ * the nested object it cannot. Empty for an inline reference declaring no
327
+ * `identity` – a display-only nesting keeps costing no RAM.
328
+ *
329
+ * A second, narrowed field joins it where the target declares a facet policy,
330
+ * exactly as it does for a top-level facetable reference: the facet then reads
331
+ * the admitted subset, so an excluded id is never seen by the engine rather
332
+ * than merely unlabelled.
333
+ */
334
+ function identityCompanionFields(field, schema) {
335
+ const names = physicalFields(field, schema);
336
+ if (names.identity === undefined) {
337
+ return [];
338
+ }
339
+ const fields = [
340
+ {
341
+ name: names.identity,
342
+ type: 'string[]',
343
+ facet: names.facet === names.identity,
344
+ optional: true,
345
+ },
346
+ ];
347
+ if (names.facet !== undefined && names.facet !== names.identity) {
348
+ fields.push({
349
+ name: names.facet,
350
+ type: 'string[]',
351
+ facet: true,
352
+ optional: true,
353
+ });
354
+ }
355
+ return fields;
356
+ }
357
+ /** The identity companion of a nested field that stores an object: a flat leaf
358
+ * beside it, holding the ids a filter welds on. Empty for a nested field that
359
+ * declares no query Role, which needs none. */
360
+ function nestedIdentityFields(prefix, field, schema) {
361
+ const names = physicalFields(field, schema);
362
+ if (names.identity === undefined) {
363
+ return [];
364
+ }
365
+ return [
366
+ {
367
+ name: nestedFieldName(prefix, names.identity),
368
+ // Always a list, whatever the enclosing reference's arity: the projection
369
+ // writes it with `setArray`, and an indexed field's declared type is
370
+ // checked against what is stored – a `string` here rejects every
371
+ // document carrying such an edge, at import.
372
+ type: 'string[]',
373
+ index: true,
374
+ optional: true,
375
+ },
376
+ ];
377
+ }
378
+ /** The type a *nested* field itself nests: another inline reference’s type, or
379
+ * the Root Type a {@link ReferenceStrategy.local local} lookup projects its
380
+ * endpoint through. */
381
+ function nestedTypeOfNestedField(field, schema) {
382
+ return nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
383
+ }
384
+ /**
385
+ * The Physical Fields one non-nesting nested field contributes: exactly what
386
+ * the projection writes under the referent’s prefix, so the collection and the
387
+ * document cannot disagree.
388
+ *
389
+ * A nested `text` field stores its display values as a root one does – one
390
+ * `${name}_<lang>` per present language, matched by a single pattern field,
391
+ * never indexed – plus a folded search companion per locale when it is
392
+ * `searchable`. Anything undeclared here would still arrive in the document,
393
+ * and Typesense indexes what it is not told to leave alone, so the fanout is
394
+ * enumerated rather than left to inference.
395
+ */
396
+ function nestedLeafFields(prefix, field, defaultLocale, flattensToArray) {
397
+ const names = physicalFields(field);
398
+ const searchString = flattensToArray ? 'string[]' : 'string';
399
+ const fields = [];
400
+ if (field.kind === 'text') {
401
+ const pattern = displayFieldPattern(field);
402
+ if (pattern !== undefined) {
403
+ // Display values are never indexed, whatever Roles the field declares:
404
+ // search hits the folded companions below, so the display copies stay on
405
+ // disk with every language they carry.
406
+ fields.push({
407
+ name: nestedFieldName(prefix, pattern),
408
+ type: 'string',
409
+ index: false,
410
+ optional: true,
411
+ });
412
+ }
413
+ fields.push(...names.search.map((name, index) => {
414
+ const locale = field.locales[index] === 'und' ? defaultLocale : field.locales[index];
415
+ return {
416
+ name: nestedFieldName(prefix, name),
417
+ type: searchString,
418
+ optional: true,
419
+ ...(locale !== undefined && { stem: true, locale }),
420
+ };
421
+ }));
422
+ return fields;
423
+ }
424
+ // A nested field can be `filterable` or `searchable`, never `facetable` or
425
+ // `sortable` (`searchSchema`), so `filterable` is what indexes the value
426
+ // itself – its search companion below indexes on its own account.
427
+ const indexed = field.filterable === true;
428
+ const valueType = typesenseValueType(field);
429
+ // Two ways a list arrives: the field declares one, or an ancestor's
430
+ // `object[]` flattens it into one. `typesenseValueType` honours `array` only
431
+ // for the string-shaped kinds, so a multi-valued nested `integer` would
432
+ // otherwise be declared scalar and rejected at import.
433
+ const storesAList = flattensToArray || field.array === true;
434
+ fields.push({
435
+ name: nestedFieldName(prefix, field.name),
436
+ type: indexed && storesAList ? arrayValueType(valueType) : valueType,
437
+ index: indexed,
438
+ // Always optional: `required` is a promise about the *referent* (this value
439
+ // is on every referent), while Typesense’s flag is about the document.
440
+ // Under a multi-valued reference those are different claims, and only the
441
+ // referent-level one is the declaration’s.
442
+ optional: true,
443
+ });
444
+ for (const name of names.search) {
265
445
  fields.push({
266
- name: nestedFieldName(prefix, pattern ?? field.name),
267
- type: field.kind === 'text' ? 'string' : typesenseValueType(field),
268
- index: false,
269
- // Always optional: `required` is a promise about the *referent* (this
270
- // value is on every referent), while Typesense’s flag is about the
271
- // document. Under a multi-valued reference those are different claims,
272
- // and only the referent-level one is the declaration’s.
446
+ name: nestedFieldName(prefix, name),
447
+ type: searchString,
273
448
  optional: true,
274
449
  });
275
450
  }
@@ -277,6 +452,30 @@ function nestedFields(prefix, reference, referenceType, schema) {
277
452
  }
278
453
  /** The Typesense field type for a non-localized field, from its `kind`. 64-bit
279
454
  * integers (and dates, stored as Unix seconds) so large counts never overflow. */
455
+ /**
456
+ * The array form of a value type, for a nested field that an ancestor’s
457
+ * `object[]` flattens into a list. Only an **indexed** field needs it: Typesense
458
+ * checks a declared type against what is stored, and a value under a
459
+ * multi-valued ancestor arrives as an array however single-valued its own
460
+ * declaration is. Enumerated rather than string-appended so an invalid
461
+ * combination cannot be built.
462
+ */
463
+ function arrayValueType(type) {
464
+ switch (type) {
465
+ case 'string':
466
+ return 'string[]';
467
+ case 'int64':
468
+ return 'int64[]';
469
+ case 'float':
470
+ return 'float[]';
471
+ case 'bool':
472
+ return 'bool[]';
473
+ // Already a list: a multi-valued declaration under a multi-valued ancestor
474
+ // flattens no further.
475
+ case 'string[]':
476
+ return 'string[]';
477
+ }
478
+ }
280
479
  function typesenseValueType(field) {
281
480
  switch (field.kind) {
282
481
  case 'integer':
package/dist/lookup.d.ts CHANGED
@@ -1,22 +1,31 @@
1
1
  import type { Client } from 'typesense';
2
2
  import { type ReferenceProjection, type RootType, type SearchSchema, type SearchType } from '@lde/search';
3
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.
4
+ * One resolved level of a {@link ReferenceProjection}. A level is one of two
5
+ * things, discriminated by `via`, because the two cost different things:
6
+ *
7
+ * - **`lookup`** – referents fetched from the target’s own collection, keyed by
8
+ * id and still in the engine’s flat physical shape. One batched round trip.
9
+ * - **`nested`** – an inline reference descended into. Its entries are already
10
+ * in the hit, so nothing is fetched and there is no target; the level exists
11
+ * only to carry the levels resolved *below* it.
7
12
  *
8
13
  * Kept flat rather than reconstructed here so one reconstruction path serves
9
14
  * hits and referents alike – the surface never learns that a nested document
10
15
  * arrived by a second round-trip rather than from the hit itself.
11
16
  */
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. */
17
+ export type ResolvedReferents = {
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. */
15
21
  readonly target: RootType;
16
22
  readonly documents: ReadonlyMap<string, Record<string, unknown>>;
17
23
  /** Keyed by the reference field’s name on the type this level carries. */
18
24
  readonly children: ReadonlyMap<string, ResolvedReferents>;
19
- }
25
+ } | {
26
+ readonly via: 'nested';
27
+ readonly children: ReadonlyMap<string, ResolvedReferents>;
28
+ };
20
29
  /**
21
30
  * Resolve a projection against the documents that name its referents: one
22
31
  * 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;AASrB;;;;;;;;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,CAkEjD"}
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"}
package/dist/lookup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { displayFieldName, fieldNamed, labelFieldOf, rootTypeNamed, } from '@lde/search/adapter';
1
+ import { displayFieldName, fieldNamed, labelFieldOf, nestedReferenceType, rootTypeNamed, } 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;
@@ -21,9 +21,24 @@ export async function resolveProjection(client, projection, searchType, schema,
21
21
  // stated bound – one round-trip per level – one per field instead.
22
22
  const levels = await Promise.all(Object.entries(projection).map(async ([name, level]) => {
23
23
  const field = fieldNamed(searchType, name);
24
- if (field === undefined ||
25
- field.kind !== 'reference' ||
26
- field.ref?.strategy !== 'lookup') {
24
+ if (field === undefined) {
25
+ return undefined;
26
+ }
27
+ // An inline level costs no round trip: its entries are already in the
28
+ // parents. Flatten them out and hand them down, so the level BELOW reads
29
+ // its ids off the entries rather than off the document – which is the
30
+ // whole of what makes a lookup inside a qualified edge resolvable.
31
+ const nested = nestedReferenceType(schema, field);
32
+ if (nested !== undefined) {
33
+ return [
34
+ name,
35
+ {
36
+ via: 'nested',
37
+ children: await resolveProjection(client, level.resolve, nested, schema, collections, entriesOf(parents, name), onError),
38
+ },
39
+ ];
40
+ }
41
+ if (field.kind !== 'reference' || field.ref?.strategy !== 'lookup') {
27
42
  // `assertValidQuery` rejects this for every caller; skipping keeps a
28
43
  // hand-built query from throwing here rather than at the port’s guard.
29
44
  return undefined;
@@ -44,6 +59,7 @@ export async function resolveProjection(client, projection, searchType, schema,
44
59
  return [
45
60
  name,
46
61
  {
62
+ via: 'lookup',
47
63
  target,
48
64
  documents,
49
65
  // The level below reads the IRIs off the documents this one just
@@ -59,14 +75,40 @@ export async function resolveProjection(client, projection, searchType, schema,
59
75
  }
60
76
  return resolved;
61
77
  }
62
- /** Every distinct IRI a page of documents carries under one reference field. */
78
+ /**
79
+ * The nested entries a page of documents carries under one inline reference,
80
+ * flattened into one list – the “documents” the level below resolves against.
81
+ *
82
+ * Flattening rather than grouping is what keeps the round-trip count per
83
+ * *level* rather than per document: every entry on the page contributes its ids
84
+ * to one batch, and reconstruction re-associates them afterwards by id.
85
+ */
86
+ function entriesOf(documents, field) {
87
+ return documents.flatMap((document) => {
88
+ const raw = document[field];
89
+ return (Array.isArray(raw) ? raw : [raw]).filter((entry) => typeof entry === 'object' && entry !== null);
90
+ });
91
+ }
92
+ /**
93
+ * Every distinct id a page of documents carries under one reference field.
94
+ *
95
+ * A reference stores its ids one of two ways, and which one is about how much
96
+ * of the referent the deployment wants stored, not about identity: a plain
97
+ * reference stores the id itself, while a
98
+ * {@link ReferenceStrategy.local local} lookup stores the endpoint’s own
99
+ * document, whose `id` is the same value. Both are read, so a level resolves
100
+ * the same either way.
101
+ */
63
102
  function distinctIris(documents, field) {
64
103
  const iris = new Set();
65
104
  for (const document of documents) {
66
105
  const raw = document[field];
67
106
  for (const value of Array.isArray(raw) ? raw : [raw]) {
68
- if (typeof value === 'string' && value !== '') {
69
- iris.add(value);
107
+ const id = typeof value === 'object' && value !== null
108
+ ? value.id
109
+ : value;
110
+ if (typeof id === 'string' && id !== '') {
111
+ iris.add(id);
70
112
  }
71
113
  }
72
114
  }
@@ -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,EAEhB,MAAM,aAAa,CAAC;AAarB;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,CA8BtB;AA6VD;;;;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;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;AAwfD;;;;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, isRangeFacet, pageForOffset, physicalFields, searchableFields, } from '@lde/search/adapter';
2
+ import { fieldNamed, filterOperator, filterOperatorFor, ID_FIELD, isoToUnixSeconds, isInternalField, isRangeFacet, isWelded, joinGraph, localLookupTypeOf, 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),
@@ -16,7 +16,7 @@ export function buildSearchParams(query, searchType, options = {}) {
16
16
  const folded = query.text !== undefined && query.text.length > 0
17
17
  ? fold(query.text)
18
18
  : undefined;
19
- const { names, weights } = queryFields(searchType, query.locale);
19
+ const { names, weights } = queryFields(searchType, query.locale, options.schema);
20
20
  const filterBy = compileFilterBy(query.where, searchType, options);
21
21
  const sortBy = query.orderBy
22
22
  .map((sort) => compileSort(sort, searchType, query.locale))
@@ -78,16 +78,41 @@ function compileRangeFacet(name, ranges) {
78
78
  * 1), so a match in the user’s language ranks higher while cross-language matches
79
79
  * still surface.
80
80
  */
81
- function queryFields(searchType, locale) {
81
+ function queryFields(searchType, locale, schema) {
82
82
  const names = [];
83
83
  const weights = [];
84
+ collectSearchable(searchType, locale, schema, '', new Set(), names, weights);
85
+ return { names, weights };
86
+ }
87
+ /**
88
+ * Collect the searchable physical fields of a type and of everything it nests,
89
+ * each under the path an engine addresses it by.
90
+ *
91
+ * Nested fields are walked because a nested field may declare `searchable`
92
+ * (ADR 24) – and a companion that is indexed but absent from `query_by` is the
93
+ * worst of both: it costs the RAM of an indexed field and matches nothing, in
94
+ * silence. So the walk here is what makes that Role mean anything.
95
+ *
96
+ * `onPath` guards the walk, and is scoped to the PATH rather than to the walk
97
+ * as a whole: two fields may nest one type – `creator` and `contributor` over
98
+ * the same edge – and each reaches it under its own prefix, so each must
99
+ * contribute its own companions. A set shared across the walk would let the
100
+ * first field claim the type and leave the second's companions indexed but
101
+ * unqueried. What it does guard is a type reached from ITSELF, which a
102
+ * {@link ReferenceStrategy.local local} lookup can do.
103
+ */
104
+ function collectSearchable(searchType, locale, schema, prefix, onPath, names, weights) {
105
+ if (onPath.has(searchType.name)) {
106
+ return;
107
+ }
108
+ const walked = new Set(onPath).add(searchType.name);
84
109
  for (const field of searchableFields(searchType)) {
85
110
  const search = physicalFields(field).search;
86
111
  const baseWeight = field.searchable.weight;
87
112
  if (field.kind === 'text') {
88
113
  const locales = field.locales;
89
114
  search.forEach((name, index) => {
90
- names.push(name);
115
+ names.push(qualify(prefix, name));
91
116
  // The active locale keeps full weight; `und` is language-neutral, so
92
117
  // it is never demoted (an untagged-only field would otherwise always
93
118
  // rank below its declared weight).
@@ -98,12 +123,26 @@ function queryFields(searchType, locale) {
98
123
  }
99
124
  else {
100
125
  for (const name of search) {
101
- names.push(name);
126
+ names.push(qualify(prefix, name));
102
127
  weights.push(baseWeight);
103
128
  }
104
129
  }
105
130
  }
106
- return { names, weights };
131
+ if (schema === undefined) {
132
+ return;
133
+ }
134
+ for (const field of searchType.fields) {
135
+ // An internal field is pruned before the writer and declared in no
136
+ // collection, so its target's companions exist nowhere – asking `query_by`
137
+ // for one makes the ENGINE reject every search on the collection.
138
+ if (isInternalField(field)) {
139
+ continue;
140
+ }
141
+ const nested = nestedReferenceType(schema, field) ?? localLookupTypeOf(field, schema);
142
+ if (nested !== undefined) {
143
+ collectSearchable(nested, locale, schema, qualify(prefix, field.name), walked, names, weights);
144
+ }
145
+ }
107
146
  }
108
147
  /** AND-join the compiled `where` clauses. A clause that states no constraint is
109
148
  * skipped; one that can match nothing compiles to {@link MATCHES_NOTHING}
@@ -201,16 +240,31 @@ const UNUSABLE = Symbol('unusable');
201
240
  function compileCriterion(criterion, searchType, options) {
202
241
  const on = criterion.on ?? [];
203
242
  if (on.length === 0) {
204
- return compileLeaf(criterion, searchType, options.schema);
243
+ return isWelded(criterion)
244
+ ? compileWelded(criterion, searchType, options.schema, '')
245
+ : compileLeaf(criterion, searchType, options.schema);
205
246
  }
247
+ // The path carries two kinds of hop, and they compile differently: a JOIN
248
+ // crosses into another collection and wraps the leaf in `$collection(…)`; a
249
+ // NESTING stays in this document and qualifies the leaf’s field name. The
250
+ // schema is what tells them apart, so without one every hop is read as a
251
+ // join – exactly the behaviour before nesting existed.
252
+ const resolvedPath = options.schema === undefined
253
+ ? undefined
254
+ : resolvePath(searchType, on, options.schema, joinGraph(options.schema));
255
+ if (typeof resolvedPath === 'string') {
256
+ return UNUSABLE;
257
+ }
258
+ const joinHops = resolvedPath?.joinPath ?? on;
259
+ const nestedHops = resolvedPath?.nestedPath ?? [];
206
260
  // A joined criterion constrains a document in ANOTHER collection, so the leaf
207
261
  // is compiled against the type that path reaches and then wrapped, one
208
262
  // `$collection(…)` per hop, outermost hop first:
209
263
  // `['dataset', 'publisher']` → `$datasets($publishers(id:=…))`.
210
264
  const collections = [];
211
265
  let target = searchType;
212
- for (let hop = 1; hop <= on.length; hop++) {
213
- const resolved = options.joinTargetFor?.(searchType, on.slice(0, hop));
266
+ for (let hop = 1; hop <= joinHops.length; hop++) {
267
+ const resolved = options.joinTargetFor?.(searchType, joinHops.slice(0, hop));
214
268
  if (resolved === undefined) {
215
269
  // An unresolvable path is a criterion that cannot be compiled at all –
216
270
  // false, so it drops out and its siblings still stand. Through the
@@ -220,7 +274,11 @@ function compileCriterion(criterion, searchType, options) {
220
274
  collections.push(resolved.collection);
221
275
  target = resolved.searchType;
222
276
  }
223
- const leaf = compileLeaf(criterion, target, options.schema);
277
+ const leafType = resolvedPath?.leafType ?? target;
278
+ const prefix = nestedHops.join('.');
279
+ const leaf = isWelded(criterion)
280
+ ? compileWelded(criterion, leafType, options.schema, prefix)
281
+ : compileLeaf(criterion, leafType, options.schema, prefix);
224
282
  // A vacuous leaf states no constraint on the referent, so the join as a whole
225
283
  // states none either – the reading, and so the outcome, passes straight
226
284
  // through the hops.
@@ -229,9 +287,16 @@ function compileCriterion(criterion, searchType, options) {
229
287
  }
230
288
  return collections.reduceRight((inner, collection) => `$${collection}(${inner})`, leaf);
231
289
  }
232
- /** The criterion’s own term, against the type it actually constrains – the
233
- * searched type, or the one its join path reaches. */
234
- function compileLeaf(criterion, searchType, schema) {
290
+ /**
291
+ * The criterion’s own term, against the type it actually constrains – the
292
+ * searched type, the one its join path reaches, or the reference type its
293
+ * nesting path walks into.
294
+ *
295
+ * `prefix` is that nesting path, dotted: an engine addresses a stored nested
296
+ * document by qualifying the field name, so `role` inside `creator` becomes
297
+ * `creator.role`. Empty for every unnested criterion.
298
+ */
299
+ function compileLeaf(criterion, searchType, schema, prefix = '') {
235
300
  // `id` is the Typesense document key, not a declared field. Exact `:=`
236
301
  // membership, like a non-facet field ({@link compileMembership}), so an IRI
237
302
  // cannot partial-match on a shared path segment. (`fetchLabels` resolves
@@ -239,7 +304,12 @@ function compileLeaf(criterion, searchType, schema) {
239
304
  // clause.) An empty identity membership enumerates NO document, so it is
240
305
  // unusable rather than vacuous – the one place the two readings diverge.
241
306
  if (criterion.field === ID_FIELD) {
242
- return 'in' in criterion && criterion.in.length > 0
307
+ // `id` names the DOCUMENT key, which exists only at the top level: inside a
308
+ // nested reference there is no document to key. Compiling it under a prefix
309
+ // would filter the root collection's `id` instead – a different question,
310
+ // silently answered. `assertValidQuery` rejects it first; this is the guard
311
+ // for a hand-built query.
312
+ return prefix === '' && 'in' in criterion && criterion.in.length > 0
243
313
  ? `${ID_FIELD}:=[${criterion.in.map(escapeFilterValue).join(',')}]`
244
314
  : UNUSABLE;
245
315
  }
@@ -254,13 +324,57 @@ function compileLeaf(criterion, searchType, schema) {
254
324
  }
255
325
  if ('in' in criterion) {
256
326
  return criterion.in.length > 0
257
- ? compileMembership(field, criterion.in, schema)
327
+ ? compileMembership(field, criterion.in, schema, prefix)
258
328
  : VACUOUS;
259
329
  }
260
330
  if ('range' in criterion) {
261
- return compileRange(field, criterion.range) ?? VACUOUS;
331
+ return compileRange(field, criterion.range, prefix) ?? VACUOUS;
332
+ }
333
+ // Every other shape returned above; a welded criterion never reaches here
334
+ // (compileWelded handles it before the leaf compiler is called).
335
+ return `${qualify(prefix, field.name)}:=${criterion.is}`;
336
+ }
337
+ /** A field name under its nesting path, if any – the one place the dotted
338
+ * addressing an engine uses for a stored nested document is spelled. */
339
+ function qualify(prefix, name) {
340
+ return prefix === '' ? name : `${prefix}.${name}`;
341
+ }
342
+ /**
343
+ * A **welded** criterion: `path.{a && b}`, matching a document with ONE entry
344
+ * that satisfies every condition – as against `path.a && path.b`, which two
345
+ * different entries can satisfy between them.
346
+ *
347
+ * Each condition is compiled against the reference type with an EMPTY prefix,
348
+ * so what goes inside the braces is always a leaf name. That is a correctness
349
+ * requirement, not tidiness: Typesense 30.2 **hangs** on a dotted path inside a
350
+ * group (`creator.{creator.name:=…}` never returns – no error, no result), so
351
+ * the compiler must be structurally incapable of emitting one. A field whose
352
+ * value is itself nested is reached through its identity companion, which is a
353
+ * leaf ({@link physicalFields}).
354
+ *
355
+ * A condition that states no constraint drops out, and the weld is whatever
356
+ * remains; a weld left with nothing is vacuous like any other empty clause.
357
+ */
358
+ function compileWelded(criterion, searchType, schema, prefix) {
359
+ const entryType = schema &&
360
+ nestedReferenceType(schema, fieldNamed(searchType, criterion.field) ?? {});
361
+ if (entryType === undefined) {
362
+ return UNUSABLE;
363
+ }
364
+ const conditions = [];
365
+ for (const condition of criterion.entry) {
366
+ const compiled = compileLeaf(condition, entryType, schema);
367
+ if (compiled === UNUSABLE) {
368
+ return UNUSABLE;
369
+ }
370
+ if (compiled !== VACUOUS) {
371
+ conditions.push(compiled);
372
+ }
373
+ }
374
+ if (conditions.length === 0) {
375
+ return VACUOUS;
262
376
  }
263
- return `${field.name}:=${criterion.is}`;
377
+ return `${qualify(prefix, criterion.field)}.{${conditions.join(' && ')}}`;
264
378
  }
265
379
  /**
266
380
  * A membership clause. A non-facet (tokenized) field uses the exact `:=`
@@ -272,11 +386,15 @@ function compileLeaf(criterion, searchType, schema) {
272
386
  * filter on such a field is exactly what the policy promises stays whole, so
273
387
  * it must compile exact, not tokenized.
274
388
  */
275
- function compileMembership(field, values, schema) {
389
+ function compileMembership(field, values, schema, prefix = '') {
276
390
  const list = `[${values.map(escapeFilterValue).join(',')}]`;
277
- return physicalFields(field, schema).facet === field.name
278
- ? `${field.name}:${list}`
279
- : `${field.name}:=${list}`;
391
+ const names = physicalFields(field, schema);
392
+ // An inline reference stores a nested object, which an engine cannot filter.
393
+ // Its identity companion is the flat id field standing in for it – so the
394
+ // logical name a consumer writes and the physical name the engine reads
395
+ // differ here, and only here.
396
+ const name = qualify(prefix, names.identity ?? field.name);
397
+ return names.facet === name ? `${name}:${list}` : `${name}:=${list}`;
280
398
  }
281
399
  /** An inclusive Typesense range clause, or `undefined` when neither bound is
282
400
  * usable. Which of the two readings that is – the caller set no bounds, or the
@@ -285,8 +403,8 @@ function compileMembership(field, values, schema) {
285
403
  * from the conjunction widens it, so the same reading cannot be right in both
286
404
  * positions. `validateQuery` rejects an unreadable bound outright instead,
287
405
  * which is the only answer that neither widens nor narrows. */
288
- function compileRange(field, range) {
289
- const name = field.name;
406
+ function compileRange(field, range, prefix = '') {
407
+ const name = qualify(prefix, field.name);
290
408
  // A bound a caller sent as `null` – a GraphQL variable left unfilled, which
291
409
  // the surface passes through – is a bound NOT SET, not a bound of `null`.
292
410
  // Read literally it reaches the engine as `datePosted:[null..…]`, which
@@ -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, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, nestedReferenceType, outputFields, physicalFields, referenceFields, } from '@lde/search/adapter';
1
+ import { assertTypeInSchema, assertValidQuery, displayLangOf, facetableFields, fieldNamed, isInlineReference, isRangeFacet, isUnsatisfiable, joinGraph, labelFieldOf, labelSourceNameOf, labelTargetNameOf, localLookupTypeOf, 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
  import { resolveProjection } from './lookup.js';
@@ -68,9 +68,12 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
68
68
  const labelSources = new Map([...schema.values()].map((searchType) => [
69
69
  searchType.class,
70
70
  new Map(referenceFields(searchType)
71
- .filter((field) => labelSourceNameOf(field) !== undefined)
71
+ // An INLINE reference names its label target one level in, through
72
+ // its identity companion: the companion holds that field's ids, so
73
+ // that field's target is what can label them.
74
+ .filter((field) => labelTargetNameOf(field, schema) !== undefined)
72
75
  .map((field) => {
73
- const source = typesByName.get(labelSourceNameOf(field));
76
+ const source = typesByName.get(labelTargetNameOf(field, schema));
74
77
  const labelField = labelFieldOf(source);
75
78
  return [
76
79
  field.name,
@@ -114,7 +117,18 @@ export function createTypesenseSearchEngine(client, schema, options = {}) {
114
117
  return [
115
118
  searchType.class,
116
119
  referenceFields(searchType)
117
- .filter((field) => field.output === true && sources?.has(field.name))
120
+ .filter((field) => field.output === true &&
121
+ sources?.has(field.name) === true &&
122
+ // A field storing NESTED DOCUMENTS is excluded, and must be: this
123
+ // list drives the per-hit label lookup, which reads each stored
124
+ // value as an id. An inline reference and a `local` lookup store
125
+ // objects, so every entry would contribute the string
126
+ // `"[object Object]"` to the batched `id:[…]` filter. Neither
127
+ // needs entry labels anyway – the referent's own fields are in
128
+ // the entry – and their FACET buckets are labelled by a separate
129
+ // path that reads the companion.
130
+ nestedReferenceType(schema, field) === undefined &&
131
+ localLookupTypeOf(field, schema) === undefined)
118
132
  .map((field) => ({
119
133
  name: field.name,
120
134
  source: sources.get(field.name),
@@ -538,7 +552,7 @@ export function parseSearchResponse(response, searchType, labels, schema, refere
538
552
  // reference facet stays id-only even when a cached full-collection map holds
539
553
  // its IRIs.
540
554
  const referenceFacets = new Set(referenceFields(searchType)
541
- .filter((field) => labelSourceNameOf(field) !== undefined)
555
+ .filter((field) => labelTargetNameOf(field, schema) !== undefined)
542
556
  .map((field) => field.name));
543
557
  const facets = {};
544
558
  for (const facet of response.facet_counts ?? []) {
@@ -587,15 +601,25 @@ function logicalValue(flat, field, labels, schema, referents) {
587
601
  // reconstructs as a nested Search Document rather than as an IRI plus a
588
602
  // label. Nesting is rebuilt HERE, below every surface, so a second
589
603
  // surface inherits it instead of reimplementing it (ADR 11).
604
+ const resolved = referents?.get(field.name);
590
605
  const nested = nestedReferenceType(schema, field);
591
606
  if (nested !== undefined) {
592
- return nestedValue(flat[field.name], field, nested, labels, schema);
607
+ // A nested level resolves nothing itself; what it carries are the
608
+ // levels below it, which the entries are reconstructed against.
609
+ return nestedValue(flat[field.name], field, nested, labels, schema, resolved?.children);
610
+ }
611
+ // Checked BEFORE the plain lookup below, because a `local` lookup
612
+ // resolves through the same kind of level while storing a different
613
+ // shape: its stored value is the endpoint's own document, not an id, so
614
+ // 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);
593
618
  }
594
619
  // A resolved lookup carries the referent's own fields, fetched from the
595
620
  // target's collection. It reconstructs through the SAME path a hit does,
596
621
  // so a consumer cannot tell a projected referent from an inline one.
597
- const resolved = referents?.get(field.name);
598
- if (resolved !== undefined) {
622
+ if (resolved !== undefined && resolved.via === 'lookup') {
599
623
  return lookupValue(flat[field.name], field, resolved, labels, schema);
600
624
  }
601
625
  // A surfaced inline reference stores nested documents, not IRIs, so a
@@ -655,10 +679,10 @@ function localizedValue(flat, field) {
655
679
  * a nested document is read, not addressed. A nested reference type may itself
656
680
  * surface an inline reference, which recurses through {@link reconstructDocument}.
657
681
  */
658
- function nestedValue(raw, field, referenceType, labels, schema) {
682
+ function nestedValue(raw, field, referenceType, labels, schema, children) {
659
683
  const referents = (Array.isArray(raw) ? raw : [raw]).filter((referent) => typeof referent === 'object' && referent !== null);
660
684
  const documents = referents.map((referent) => {
661
- const document = reconstructDocument(referent, referenceType, labels, schema);
685
+ const document = reconstructDocument(referent, referenceType, labels, schema, children);
662
686
  return typeof referent.id === 'string'
663
687
  ? { id: referent.id, ...document }
664
688
  : document;
@@ -668,6 +692,34 @@ function nestedValue(raw, field, referenceType, labels, schema) {
668
692
  }
669
693
  return field.array === true ? documents : documents[0];
670
694
  }
695
+ /**
696
+ * Rebuild a {@link ReferenceStrategy.local local} lookup: the endpoint’s own
697
+ * document as this document stated it, with the **resolved** document overlaid
698
+ * where the lookup found one.
699
+ *
700
+ * Both sides are the target type’s flat physical shape, so the merge is a plain
701
+ * override and the authoritative value wins field by field. Three cases fall
702
+ * out of one expression, which is the point of storing local fields
703
+ * unconditionally:
704
+ *
705
+ * - identified and indexed → the target’s own record, over what was stated here;
706
+ * - identified but not indexed → what was stated here, plus the `id`, rather
707
+ * than a bare IRI;
708
+ * - not identified → what was stated here, with no `id` at all.
709
+ */
710
+ function localLookupValue(raw, field, target, resolved, labels, schema) {
711
+ const fetched = resolved?.via === 'lookup' ? resolved.documents : new Map();
712
+ const merged = (Array.isArray(raw) ? raw : [raw])
713
+ .filter((entry) => typeof entry === 'object' && entry !== null)
714
+ .map((entry) => {
715
+ const id = typeof entry.id === 'string' ? entry.id : undefined;
716
+ const authoritative = id === undefined ? undefined : fetched.get(id);
717
+ return authoritative === undefined
718
+ ? entry
719
+ : { ...entry, ...authoritative };
720
+ });
721
+ return nestedValue(merged, field, target, labels, schema, resolved?.children);
722
+ }
671
723
  /**
672
724
  * Rebuild a resolved `lookup` reference: each stored IRI becomes the referent’s
673
725
  * own document, read through the **target’s** declaration and carrying whatever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-typesense",
3
- "version": "0.27.3",
3
+ "version": "0.28.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,17 +25,17 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.23.2",
28
+ "@lde/search": "^0.24.0",
29
29
  "@lde/text-normalization": "^0.1.2",
30
30
  "tslib": "^2.3.0",
31
31
  "typesense": "^3.0.6"
32
32
  },
33
33
  "devDependencies": {
34
- "n3": "^2.1.2",
34
+ "n3": "^2.4.0",
35
35
  "testcontainers": "^12.1.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@lde/dataset": "^0.7.9",
39
- "@lde/pipeline": "^0.36.2"
39
+ "@lde/pipeline": "^0.36.4"
40
40
  }
41
41
  }