@lde/search 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.js CHANGED
@@ -139,20 +139,27 @@ export function isInlineReference(field) {
139
139
  return field.kind === 'reference' && field.ref?.strategy === 'inline';
140
140
  }
141
141
  /**
142
- * The {@link ReferenceType} a **surfaced** inline reference nests – an
143
- * `output` inline reference’s referent shape – or `undefined` for every other
144
- * field. The one predicate the collection definition, result reconstruction and
145
- * the API surfaces share, so they cannot disagree about which fields carry a
146
- * nested Search Document and which carry a bare IRI: an inline reference
147
- * declaring no Role is a reading device, pruned before the writer
148
- * ({@link isInternalField}), and a `labelOnly`/`idOnly` reference stays an id
149
- * (plus a resolved label).
142
+ * The {@link ReferenceType} an inline reference **stores entries of**, or
143
+ * `undefined` for every other field. The one predicate the collection
144
+ * definition, result reconstruction and the API surfaces share, so they cannot
145
+ * disagree about which fields carry nested Search Documents and which carry a
146
+ * bare IRI: a `labelOnly`/`idOnly` reference stays an id (plus a resolved
147
+ * label).
148
+ *
149
+ * The line is *any Role at all*, not `output`: an inline reference declaring no
150
+ * Role is a reading device, pruned before the writer
151
+ * ({@link isInternalField}), while one that is `filterable` or `facetable`
152
+ * without being `output` still has its entries stored – they are what a nested
153
+ * filter constrains and what the
154
+ * {@link ReferenceStrategy.identity identity companion} is harvested from. Only
155
+ * `output` decides whether they are *surfaced*, which is the surfaces’ own
156
+ * question ({@link outputFields}).
150
157
  *
151
158
  * A schema built by {@link searchSchema} always resolves the reference type of
152
159
  * its own types; `undefined` for a type declared against another schema.
153
160
  */
154
161
  export function nestedReferenceType(schema, field) {
155
- return isInlineReference(field) && field.output === true
162
+ return isInlineReference(field) && !isInternalField(field)
156
163
  ? referenceTypeNamed(schema, field.ref.typeName)
157
164
  : undefined;
158
165
  }
@@ -173,33 +180,127 @@ export function nestedFieldName(parent, name) {
173
180
  return `${parent}.${name}`;
174
181
  }
175
182
  /**
176
- * The framing depth a Root Type needs: how many hops the inline reference graph
177
- * reaches from it (`Dataset → Subset → Measurement` is two), floored at one so
178
- * the existing single-hop embed for non-inline references is preserved. Depth is
179
- * a property of the declaration, bounded because {@link searchSchema} rejects
180
- * inline cycles – never a knob or a constant. Framing bounded per batch keeps
181
- * memory bounded by the unit of work (ADR 12), not the graph.
183
+ * The framing depth a Root Type needs: how far the **furthest value it declares**
184
+ * lies from it, in hops. Depth is a property of the declaration, bounded because
185
+ * {@link searchSchema} rejects inline cycles – never a knob or a constant.
186
+ * Framing bounded per batch keeps memory bounded by the unit of work (ADR 12),
187
+ * not the graph.
188
+ *
189
+ * Reach is measured in hops and depth in *intermediate* nodes, so depth is one
190
+ * less: reading `<a>/<b>` off a root needs the root’s own triples plus those of
191
+ * the node `<a>` reaches, which is depth 1. Floored at one, preserving the
192
+ * single-hop embed a non-inline reference has always had.
193
+ *
194
+ * Three things add hops, and counting only the first is what let a value fall
195
+ * outside the frame and be stored as absent, in silence:
196
+ *
197
+ * - **a property path**, which may traverse (`<a>/<b>` is two hops);
198
+ * - **an inline reference**, whose referent’s own fields reach on from wherever
199
+ * its path landed;
200
+ * - **a reference naming a keyed target**, whose document key is read one hop
201
+ * past the referent (ADR 22) – so a reference reached through an inline
202
+ * chain needs its key hop inside the frame too, or it stores an un-keyed id
203
+ * that matches nothing in the target’s collection.
182
204
  */
183
205
  export function inlineFramingDepth(schema, searchType) {
184
- return Math.max(1, inlineChainLength(schema, searchType));
206
+ return Math.max(1, framingReach(schema, searchType) - 1);
185
207
  }
186
- /** The longest inline reference chain reachable from `searchType`. The recursion
187
- * terminates because {@link searchSchema} rejects inline cycles; a reference the
188
- * given schema does not declare (e.g. a type framed against another schema)
189
- * contributes no depth. */
190
- function inlineChainLength(schema, searchType) {
191
- let longest = 0;
208
+ /**
209
+ * The furthest a declared value lies from a node of `searchType`, in hops. A
210
+ * reference the given schema does not declare (e.g. a type framed against
211
+ * another schema) contributes no reach.
212
+ *
213
+ * `visiting` is what makes the walk terminate. {@link searchSchema} rejects
214
+ * *inline* cycles, but a {@link ReferenceStrategy.local local} lookup can reach
215
+ * a Root Type that reaches back – `Work → Person → Work` is a perfectly
216
+ * reasonable schema – and nothing forbids it. A type already on the path
217
+ * contributes no further reach, so a cycle stops rather than being rejected:
218
+ * the depth it would ask for is unbounded, and the useful depth is the acyclic
219
+ * one.
220
+ */
221
+ function framingReach(schema, searchType, visiting = new Set()) {
222
+ if (visiting.has(searchType.name)) {
223
+ return 0;
224
+ }
225
+ const onPath = new Set(visiting).add(searchType.name);
226
+ let furthest = 0;
192
227
  for (const field of searchType.fields) {
193
- if (!isInlineReference(field)) {
228
+ if (field.path === undefined) {
229
+ continue;
230
+ }
231
+ const hops = pathHopCount(field.path);
232
+ if (isInlineReference(field)) {
233
+ const referent = referenceTypeNamed(schema, field.ref.typeName);
234
+ furthest = Math.max(furthest, hops +
235
+ (referent === undefined ? 0 : framingReach(schema, referent, onPath)));
194
236
  continue;
195
237
  }
196
- const referent = referenceTypeNamed(schema, field.ref.typeName);
197
- if (referent === undefined) {
238
+ // A `local` lookup reads the target’s OWN fields off the referent, so the
239
+ // target’s reach continues from wherever this field’s path landed – the
240
+ // same accumulation an inline reference makes, through a Root Type.
241
+ const local = localLookupTypeOf(field, schema);
242
+ if (local !== undefined) {
243
+ furthest = Math.max(furthest, hops + framingReach(schema, local, onPath));
198
244
  continue;
199
245
  }
200
- longest = Math.max(longest, 1 + inlineChainLength(schema, referent));
246
+ // A keyed target’s key field is itself path-bearing, so its own traversal
247
+ // counts too – the extraction reads it off the referent with that path.
248
+ const keyPath = keyedTargetKeyPath(field, schema);
249
+ furthest = Math.max(furthest, hops + (keyPath === undefined ? 0 : pathHopCount(keyPath)));
250
+ }
251
+ return furthest;
252
+ }
253
+ /** The `path` of the key field of the keyed Root Type a reference names, when
254
+ * it names one that declares a {@link RootType.key}. */
255
+ function keyedTargetKeyPath(field, schema) {
256
+ if (field.kind !== 'reference') {
257
+ return undefined;
258
+ }
259
+ const targetName = labelSourceNameOf(field);
260
+ const target = targetName === undefined ? undefined : rootTypeNamed(schema, targetName);
261
+ if (target?.key === undefined) {
262
+ return undefined;
263
+ }
264
+ // `searchSchema` guarantees a key field that is declared and path-bearing –
265
+ // the same guarantee the extraction’s key hop is built on.
266
+ return fieldNamed(target, target.key.field).path;
267
+ }
268
+ /**
269
+ * How many hops a `path` traverses: the number of top-level sequence steps.
270
+ * `<a>/<b>` is two; alternation and inverse do not add a hop, and a `/` inside
271
+ * an IRI or a group is not a step.
272
+ *
273
+ * A path that carries no `<` is a **single term** – one IRI – however many `/`
274
+ * its IRI happens to contain (`<https://schema.org/name>` is one hop, not
275
+ * four). That is not a special case but the grammar: a sequence is only
276
+ * expressible with each IRI delimited, since `?s a/b ?o` does not parse.
277
+ * Reading the delimiters is therefore what tells a path apart from an IRI, and
278
+ * it is the whole reason this is safe to scan rather than parse.
279
+ *
280
+ * Deliberately a scanner: the grammar belongs to the reader adapter (which
281
+ * parses it for real), and only the *shape* of the sequence matters here. It is
282
+ * also the safe direction to be approximate in – an over-count frames one hop
283
+ * too wide, which costs a little; an under-count leaves a declared value
284
+ * outside the frame, which is silent.
285
+ */
286
+ function pathHopCount(path) {
287
+ if (!path.includes('<')) {
288
+ return 1;
289
+ }
290
+ let steps = 1;
291
+ let nesting = 0;
292
+ for (const character of path) {
293
+ if (character === '<' || character === '(') {
294
+ nesting++;
295
+ }
296
+ else if (character === '>' || character === ')') {
297
+ nesting--;
298
+ }
299
+ else if (character === '/' && nesting === 0) {
300
+ steps++;
301
+ }
201
302
  }
202
- return longest;
303
+ return steps;
203
304
  }
204
305
  /**
205
306
  * Every inline `ref.typeName` must resolve to a **declared Reference Type**, and
@@ -218,23 +319,156 @@ function assertResolvableInlineReferences(types, referenceTypes) {
218
319
  if (!referenceTypes.has(field.ref.typeName)) {
219
320
  throw new Error(`Inline reference “${searchType.name}.${field.name}” names “${field.ref.typeName}”, which is not a declared reference type; declare a reference type (a SearchType with no class) with that name.`);
220
321
  }
221
- // The two jobs of an inline reference, told apart only by Roles: a
222
- // reading device (no Role, pruned before the writer) or a surfaced nested
223
- // object (`output`). Anything else asks an engine to search, filter,
224
- // facet or sort on a value that is stored as a nested document – which no
225
- // query compiler serves, so it would be ignored per query.
226
- const extraRoles = unserviceableNestedRoles(field);
322
+ // An inline reference’s stored value is a nested object, which an engine
323
+ // can neither tokenize for free text nor order by. `filterable` and
324
+ // `facetable` it CAN serve – through the identity companion below – so
325
+ // only the roles no companion could answer are refused here.
326
+ const extraRoles = unserviceableInlineRoles(field);
227
327
  if (extraRoles.length > 0) {
228
328
  throw new Error(`Inline reference “${searchType.name}.${field.name}” declares ${extraRoles
229
329
  .map((role) => `“${role}”`)
230
- .join(', ')}, which it cannot serve: an inline reference is either a reading device (no Role) or surfaced (“output”).`);
330
+ .join(', ')}, which it cannot serve: its value is a nested object, not a token an engine can search, sort or join on.`);
231
331
  }
332
+ assertIdentityCompanion(searchType, field, referenceTypes);
232
333
  }
233
334
  }
335
+ for (const searchType of types) {
336
+ for (const field of searchType.fields) {
337
+ assertServiceableLocalLookup(searchType, field);
338
+ }
339
+ }
340
+ assertConsistentIdentities(types);
234
341
  for (const referenceType of referenceTypes.values()) {
235
342
  assertNoInlineCycle(referenceType, referenceTypes, new Set());
236
343
  }
237
344
  }
345
+ /**
346
+ * An inline reference’s {@link ReferenceStrategy.identity identity companion}
347
+ * holds together in both directions: `filterable`/`facetable` need an
348
+ * `identity` to point the engine at, and an `identity` with neither Role is a
349
+ * companion nothing reads.
350
+ *
351
+ * The named field must be a nested **reference that names a target** – a
352
+ * `lookup`’s `target` or an `idOnly`’s `labelSource` – because naming one is
353
+ * exactly the claim the companion rests on: *these values are ids of documents
354
+ * in that collection* (ADR 20). That the target resolves to a declared Root
355
+ * Type needs no rule here: {@link assertResolvableLabelSources} walks Reference
356
+ * Types too, so the nested reference this names is resolved with every other.
357
+ */
358
+ function assertIdentityCompanion(searchType, field, referenceTypes) {
359
+ const where = `Inline reference “${searchType.name}.${field.name}”`;
360
+ const identity = field.ref.identity;
361
+ const filters = field.filterable === true || field.facetable === true;
362
+ if (identity === undefined) {
363
+ if (filters) {
364
+ throw new Error(`${where} declares “filterable”/“facetable” without an “identity”: a nested object is not a value an engine filters or facets, so it needs a nested reference to name the ids to filter and facet in its place.`);
365
+ }
366
+ return;
367
+ }
368
+ if (!filters) {
369
+ throw new Error(`${where} declares an “identity” but neither “filterable” nor “facetable”: the companion it names would be indexed and never read. Declare a Role for it, or drop the “identity”.`);
370
+ }
371
+ // A companion is a FLAT field beside the reference, and only a Root Type has
372
+ // somewhere flat to put one: inside a Reference Type it would be written into
373
+ // each entry and declared nowhere, so a filter on it would name a field the
374
+ // collection does not carry. An edge nested inside another edge is not a
375
+ // shape anything has needed; refusing it is better than serving it wrong.
376
+ if (searchType.class === undefined) {
377
+ throw new Error(`${where} declares an “identity”, but “${searchType.name}” is a reference type: an identity companion is a flat field beside the reference, and a nested one has nowhere to live. Declare the companion on the reference that carries this type.`);
378
+ }
379
+ // Resolvable by construction: the typeName was checked just above.
380
+ const referenceType = referenceTypes.get(field.ref.typeName);
381
+ const identityField = referenceType && fieldNamed(referenceType, identity);
382
+ if (identityField === undefined) {
383
+ throw new Error(`${where} names identity “${identity}”, which “${field.ref.typeName}” does not declare.`);
384
+ }
385
+ if (identityField.kind !== 'reference' ||
386
+ labelSourceNameOf(identityField) === undefined) {
387
+ throw new Error(`${where} names identity “${identity}”, which names no target: an identity companion holds ids of documents in a collection, so the field it harvests must be a reference declaring a “lookup” target or a label source.`);
388
+ }
389
+ }
390
+ /**
391
+ * A {@link ReferenceStrategy.local local} lookup stores a nested object rather
392
+ * than an id, so it is held to the same Roles an inline reference is: an engine
393
+ * can neither tokenize an object for free text, order by it, facet it, nor
394
+ * point a reference field at it.
395
+ *
396
+ * Without this the failures are all silent-ish and all different: a `joinable`
397
+ * one emits an engine reference over a field holding objects, a `facetable` one
398
+ * has `physicalFields` name a facet field the collection never declares, and a
399
+ * `filterable` one compiles a membership clause against an object. Refusing the
400
+ * declaration is the only place these are one mistake.
401
+ *
402
+ * `filterable` it CAN serve, and does so the same way an inline reference does:
403
+ * through a flat `${name}_id` companion beside the stored object
404
+ * ({@link physicalFields}). That is what lets a condition on the endpoint's
405
+ * identity be welded to a condition on the edge's own value – an engine can
406
+ * only weld conditions on an entry's own **leaf** fields, and the endpoint's id
407
+ * is otherwise one level further in.
408
+ */
409
+ function assertServiceableLocalLookup(searchType, field) {
410
+ if (field.kind !== 'reference' ||
411
+ field.ref?.strategy !== 'lookup' ||
412
+ field.ref.local !== true) {
413
+ return;
414
+ }
415
+ const unserviceable = unserviceableInlineRoles(field);
416
+ if (unserviceable.length > 0 ||
417
+ field.facetable === true ||
418
+ field.joinable === true) {
419
+ const roles = [
420
+ ...unserviceable,
421
+ ...(field.facetable === true ? ['facetable'] : []),
422
+ // `validateSearchType` refuses `joinable` on an INLINE reference, not on
423
+ // a lookup, so a `local` one would otherwise pass: `joinGraph` builds the
424
+ // edge, the collection builder takes the nesting branch and emits no
425
+ // engine reference, and the join fails at query time against a field the
426
+ // collection never declared.
427
+ ...(field.joinable === true ? ['joinable'] : []),
428
+ ];
429
+ throw new Error(`Local lookup “${searchType.name}.${field.name}” declares ${roles
430
+ .map((role) => `“${role}”`)
431
+ .join(', ')}, which it cannot serve: a “local” lookup stores the referent’s own document, not an id an engine can search, sort, facet or join on.`);
432
+ }
433
+ }
434
+ /**
435
+ * Every field nesting one Reference Type must agree about whether it declares
436
+ * an {@link ReferenceStrategy.identity identity}.
437
+ *
438
+ * One reference type yields ONE emitted filter type, shared by every field that
439
+ * nests it, and whether that filter offers an “ids it holds” arm is exactly
440
+ * whether an identity was declared. Left to disagree, the arm would be decided
441
+ * by whichever field a surface happened to build the type from: the
442
+ * identity-bearing field silently loses the ability to filter by id, or the
443
+ * identity-less one gains an arm that filters a nested object an engine cannot
444
+ * read. Both are silent, and which one you get depends on declaration order.
445
+ *
446
+ * The rule is therefore about the *edge type*, not about each field reaching
447
+ * it, and it fails at startup naming both fields.
448
+ */
449
+ function assertConsistentIdentities(types) {
450
+ const declaredBy = new Map();
451
+ for (const searchType of types) {
452
+ for (const field of searchType.fields) {
453
+ if (!isInlineReference(field) || isInternalField(field)) {
454
+ continue;
455
+ }
456
+ const identity = field.ref.identity !== undefined;
457
+ const where = `${searchType.name}.${field.name}`;
458
+ const first = declaredBy.get(field.ref.typeName);
459
+ if (first === undefined) {
460
+ declaredBy.set(field.ref.typeName, { field: where, identity });
461
+ continue;
462
+ }
463
+ if (first.identity !== identity) {
464
+ const [with_, without] = first.identity
465
+ ? [first.field, where]
466
+ : [where, first.field];
467
+ throw new Error(`References “${with_}” and “${without}” both nest “${field.ref.typeName}” but disagree about “identity”: they share one emitted filter type, so one of them would silently lose the ability to filter by id – or gain one that filters a nested object. Declare an identity on both, or on neither.`);
468
+ }
469
+ }
470
+ }
471
+ }
238
472
  function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
239
473
  if (onPath.has(referenceType.name)) {
240
474
  throw new Error(`Inline reference cycle through reference type “${referenceType.name}”; an inline reference graph must be acyclic, so its framing depth stays bounded.`);
@@ -252,17 +486,31 @@ function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
252
486
  }
253
487
  /**
254
488
  * Every field of a {@link ReferenceType} must be one the nesting can actually
255
- * serve. A nested field carries **`output` only**: it is stored as part of its
256
- * referent and read back with it, so it never becomes an indexed, addressable
257
- * field of its own. `searchable`, `filterable`, `facetable` and `sortable` on a
258
- * nested field would need query-compiler and engine support that no engine port
259
- * declares, and a `labelSource` would need a label lookup no reconstruction
260
- * runs – each would be silently ignored per query. A Role-less field stays an
261
- * {@link isInternalField Internal Field}, the reading device that is the other
262
- * half of an inline reference’s job.
489
+ * serve. A nested field carries `output`, `filterable` and `searchable`; it may
490
+ * also be a `lookup`, resolved by descending its entries rather than by a
491
+ * round-trip of its own. `facetable` and `sortable` are refused, and for
492
+ * measured reasons rather than for want of a compiler:
493
+ *
494
+ * - **`facetable`** – an engine’s facet counts over a nested field are
495
+ * *document-level*. A filter scoped to one entry does not scope the facet, so
496
+ * a bucket counts every entry of every matching document, including the
497
+ * entries that did not satisfy the filter. The count is therefore wrong in a
498
+ * way no work on our side recovers. Facet a qualified edge through its
499
+ * {@link ReferenceStrategy.identity identity companion}, which is a flat
500
+ * field an engine facets exactly.
501
+ * - **`sortable`** – there is no such thing as sorting *into* an element of an
502
+ * array; a document holds many entries and a sort key is one value.
503
+ *
504
+ * A `labelSource` stays refused: it would need a label lookup no reconstruction
505
+ * runs, and a nested reference that wants its referent’s fields declares a
506
+ * `lookup` instead. A Role-less field stays an {@link isInternalField Internal
507
+ * Field}, the reading device that is the other half of an inline reference’s
508
+ * job.
263
509
  *
264
510
  * Checked schema-wide, like the label sources and for the same reason: a single
265
511
  * declaration cannot see whether it is a Reference Type at all.
512
+ *
513
+ * See [ADR 24](../../docs/decisions/0024-carry-data-on-a-reference-edge.md).
266
514
  */
267
515
  function assertServiceableNestedFields(referenceTypes) {
268
516
  for (const referenceType of referenceTypes.values()) {
@@ -271,34 +519,49 @@ function assertServiceableNestedFields(referenceTypes) {
271
519
  if (unserviceable.length > 0) {
272
520
  throw new Error(`Nested field “${referenceType.name}.${field.name}” declares ${unserviceable
273
521
  .map((role) => `“${role}”`)
274
- .join(', ')}, which an inline reference cannot serve; a nested field carries “output” only.`);
522
+ .join(', ')}, which nesting cannot serve: an engine facets a nested field per document rather than per entry, sorts on one value rather than into an array, and has no collection to join to from inside an entry. Facet the edge through the inline reference’s “identity” companion instead.`);
275
523
  }
276
524
  if (field.labelSource !== undefined) {
277
- throw new Error(`Nested field “${referenceType.name}.${field.name}” declares a label source, which an inline reference cannot serve; nest the referent through an inline reference instead of resolving a label for it.`);
278
- }
279
- // A lookup is resolved from the hit's own projection, level by level; a
280
- // nested document is read back with its referent and never appears in
281
- // one. Accepting it would emit a type whose every field serves null.
282
- if (field.kind === 'reference' && field.ref?.strategy === 'lookup') {
283
- throw new Error(`Nested field “${referenceType.name}.${field.name}” is a lookup, which an inline reference cannot serve: a nested document is read back with its referent, so nothing resolves a lookup inside one. Nest the referent through an inline reference instead.`);
525
+ throw new Error(`Nested field “${referenceType.name}.${field.name}” declares a label source, which an inline reference cannot serve; declare a “lookup” on the nested reference instead of resolving a label for it.`);
284
526
  }
285
527
  }
286
528
  }
287
529
  }
288
- /** The Roles a field declares that nesting cannot serve – every Role but
289
- * `output`, in declaration order, so a message names them all at once. */
530
+ /** The Roles a **nested** field declares that nesting cannot serve, in
531
+ * declaration order, so a message names them all at once. */
290
532
  function unserviceableNestedRoles(field) {
291
- return UNSERVICEABLE_NESTED_ROLES.filter((role) => role === 'searchable'
292
- ? field.searchable !== undefined
293
- : field[role] === true);
533
+ return UNSERVICEABLE_NESTED_ROLES.filter((role) => field[role] === true);
294
534
  }
295
- /** The Roles a nested field cannot carry – everything but `output`. */
535
+ /**
536
+ * The Roles a nested field cannot carry. An engine facets a nested field per
537
+ * document rather than per entry, and cannot sort into an array element.
538
+ *
539
+ * `joinable` is refused for a different reason: a join is a clause about
540
+ * another COLLECTION, and there is none to address from inside an entry.
541
+ * `buildJoinGraph` walks Root Types, so a nested one builds no edge and the
542
+ * collection emits no engine reference – while the surface still offers the
543
+ * filter, whose criterion then degrades to a vacuous `in: []` and matches
544
+ * EVERYTHING. Refusing the declaration is the only place that is one mistake.
545
+ */
296
546
  const UNSERVICEABLE_NESTED_ROLES = [
297
- 'searchable',
298
- 'filterable',
299
547
  'facetable',
300
548
  'sortable',
549
+ 'joinable',
301
550
  ];
551
+ /** The Roles the **inline reference itself** declares that it cannot serve, in
552
+ * declaration order. Its value is a nested object, which an engine can neither
553
+ * tokenize for free text nor order by; `filterable` and `facetable` it serves
554
+ * through its {@link ReferenceStrategy.identity identity companion}. */
555
+ function unserviceableInlineRoles(field) {
556
+ return UNSERVICEABLE_INLINE_ROLES.filter((role) => role === 'searchable'
557
+ ? field.searchable !== undefined
558
+ : field[role] === true);
559
+ }
560
+ /** The Roles an inline reference cannot carry. `joinable` is absent because
561
+ * {@link validateSearchType} already refuses it on an inline reference, with a
562
+ * reason of its own; refusing it twice would only make which message appears
563
+ * depend on validation order. */
564
+ const UNSERVICEABLE_INLINE_ROLES = ['searchable', 'sortable'];
302
565
  /** The label field name a type falls back to when it declares no
303
566
  * {@link SearchTypeBase.labelField}. */
304
567
  export const DEFAULT_LABEL_FIELD = 'label';
@@ -341,11 +604,16 @@ export function labelSourceNameOf(field) {
341
604
  * can actually serve labels ({@link labelFieldOf}). Checked schema-wide,
342
605
  * because a single declaration cannot see its siblings.
343
606
  *
344
- * Resolved through {@link rootTypeNamed}, which is exactly the set that can
345
- * serve labels: a label field is `searchable`, and
346
- * {@link assertServiceableNestedFields} – which runs first – already rejects a
347
- * `searchable` field on a Reference Type, so a resolved label always has a
348
- * collection to come from.
607
+ * Resolved through {@link rootTypeNamed}, which is exactly the set that has a
608
+ * collection to serve labels from – a Reference Type is nested inside its
609
+ * referrer and has none, so naming one here fails to resolve rather than
610
+ * resolving to something unreadable.
611
+ *
612
+ * Reference Types are walked too, so a **nested** `lookup` has its `target`
613
+ * resolved by the same pass a top-level one does. That is also what validates
614
+ * the target behind an inline reference’s
615
+ * {@link ReferenceStrategy.identity identity companion}, since the companion
616
+ * harvests a nested reference that names one.
349
617
  */
350
618
  function assertResolvableLabelSources(types, schema) {
351
619
  for (const searchType of types) {
@@ -742,6 +1010,69 @@ export function assertValidSearchType(searchType) {
742
1010
  throw new Error(`Invalid search type “${searchType.name}”: ${detail}.`);
743
1011
  }
744
1012
  }
1013
+ /** The physical name of an inline reference’s
1014
+ * {@link ReferenceStrategy.identity identity companion}. */
1015
+ export function identityFieldName(name) {
1016
+ return `${name}_id`;
1017
+ }
1018
+ /**
1019
+ * The Root Type whose labels a reference’s **facet buckets** read – its own
1020
+ * label source, or, for an inline reference, the one its
1021
+ * {@link ReferenceStrategy.identity identity companion} points at.
1022
+ *
1023
+ * The same one-level-in reading {@link inheritedFacetKeys} makes, and for the
1024
+ * same reason: the companion holds that field’s ids, so the type that names
1025
+ * those ids is the type that can label them. Kept together with it so a facet
1026
+ * cannot inherit a policy from one type and its labels from another – or, as
1027
+ * happened first, inherit the policy and no labels at all.
1028
+ */
1029
+ export function labelTargetNameOf(field, schema) {
1030
+ if (field.kind !== 'reference') {
1031
+ return undefined;
1032
+ }
1033
+ return labelSourceNameOf(identityFieldOf(field, schema) ?? field);
1034
+ }
1035
+ /**
1036
+ * The Root Type a {@link ReferenceStrategy.local local} lookup projects its
1037
+ * referents through, or `undefined` for every other field. Such a reference
1038
+ * stores nested documents shaped by the **target’s own declaration** – so it
1039
+ * needs no reference type of its own, and reconstructs through the same path a
1040
+ * resolved referent does.
1041
+ */
1042
+ export function localLookupTypeOf(field, schema) {
1043
+ if (schema === undefined ||
1044
+ field.kind !== 'reference' ||
1045
+ field.ref?.strategy !== 'lookup' ||
1046
+ field.ref.local !== true) {
1047
+ return undefined;
1048
+ }
1049
+ return rootTypeNamed(schema, field.ref.target);
1050
+ }
1051
+ /**
1052
+ * The nested reference an inline reference’s
1053
+ * {@link ReferenceStrategy.identity identity companion} harvests, or
1054
+ * `undefined` where the field declares none (or the schema cannot resolve its
1055
+ * reference type). The one reading of *which nested field identifies the edge*,
1056
+ * so the projection (which harvests it), {@link inheritedFacetKeys} (which
1057
+ * reads its target’s facet policy) and an adapter’s filter compiler cannot
1058
+ * resolve it differently.
1059
+ */
1060
+ export function identityFieldOf(field, schema) {
1061
+ if (schema === undefined || !isInlineReference(field)) {
1062
+ return undefined;
1063
+ }
1064
+ const identity = field.ref.identity;
1065
+ if (identity === undefined) {
1066
+ return undefined;
1067
+ }
1068
+ const referenceType = referenceTypeNamed(schema, field.ref.typeName);
1069
+ if (referenceType === undefined) {
1070
+ return undefined;
1071
+ }
1072
+ // `assertIdentityCompanion` guarantees the named field is declared and is a
1073
+ // reference, so this is a read rather than a search.
1074
+ return fieldNamed(referenceType, identity);
1075
+ }
745
1076
  /**
746
1077
  * The display fields of a localized `text` field are pattern-based, not
747
1078
  * enumerated per declared locale: projection stores `${name}_${lang}` for
@@ -1049,16 +1380,29 @@ export function physicalFields(field, schema) {
1049
1380
  : [],
1050
1381
  // Text is never facetable (`validateSearchType`).
1051
1382
  facet: undefined,
1383
+ identity: undefined,
1052
1384
  };
1053
1385
  }
1386
+ // A field storing a nested OBJECT cannot be filtered or faceted as itself, so
1387
+ // everything an engine indexes for it reads the identity companion instead –
1388
+ // the facet included, which is why the facet name is derived from the
1389
+ // companion rather than from `name`. Two shapes store one: an inline
1390
+ // reference that names an `identity`, and a `local` lookup, whose own id is
1391
+ // otherwise a level further in than an engine can weld a condition to.
1392
+ const nestsAnObject = identityFieldOf(field, schema) !== undefined ||
1393
+ (localLookupTypeOf(field, schema) !== undefined &&
1394
+ field.filterable === true);
1395
+ const identity = nestsAnObject ? identityFieldName(field.name) : undefined;
1396
+ const filtered = identity ?? field.name;
1054
1397
  return {
1055
1398
  search: field.searchable !== undefined ? [`${field.name}_search`] : [],
1056
1399
  sort: [],
1057
1400
  facet: field.facetable !== true
1058
1401
  ? undefined
1059
1402
  : inheritedFacetKeys(field, schema) === undefined
1060
- ? field.name
1061
- : `${field.name}_facet`,
1403
+ ? filtered
1404
+ : `${filtered}_facet`,
1405
+ identity,
1062
1406
  };
1063
1407
  }
1064
1408
  /**
@@ -1071,6 +1415,14 @@ export function physicalFields(field, schema) {
1071
1415
  * own values rather than reading a referent, is re-keyed by nothing and so
1072
1416
  * narrowed by nothing either. The one reading the projection (which writes the
1073
1417
  * companion) and {@link physicalFields} (which names it) share.
1418
+ *
1419
+ * An **inline** reference names its target one level in, through the nested
1420
+ * reference its {@link ReferenceStrategy.identity identity companion} harvests
1421
+ * ({@link identityFieldOf}). The boundary is the same one: the companion holds
1422
+ * that field’s ids, so the policy that narrows a facet over those ids is the
1423
+ * policy of the type that field names. Reading it through the nested field
1424
+ * rather than declaring it twice is what keeps *which of a type’s ids deserve
1425
+ * a bucket* a fact about the type.
1074
1426
  */
1075
1427
  export function inheritedFacetKeys(field, schema) {
1076
1428
  if (schema === undefined ||
@@ -1079,7 +1431,8 @@ export function inheritedFacetKeys(field, schema) {
1079
1431
  field.derive !== undefined) {
1080
1432
  return undefined;
1081
1433
  }
1082
- const targetName = labelSourceNameOf(field);
1434
+ const identity = identityFieldOf(field, schema);
1435
+ const targetName = labelSourceNameOf(identity ?? field);
1083
1436
  return targetName === undefined
1084
1437
  ? undefined
1085
1438
  : rootTypeNamed(schema, targetName)?.facetKeys;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "description": "Engine- and domain-agnostic core for RDF-backed search: a unified declarative field model (SearchField/SearchType/SearchSchema), a neutral query IR, the SearchEngine port with logical result types, and a streaming CONSTRUCT-to-document projection. Bakes in no engine, protocol, or domain.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -52,7 +52,7 @@
52
52
  "devDependencies": {
53
53
  "@tpluscode/rdf-ns-builders": "^5.0.0",
54
54
  "@types/jsonld": "^1.5.15",
55
- "n3": "^2.1.2"
55
+ "n3": "^2.4.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vitest": "^4.0.0"