@lde/search 0.23.2 → 0.24.1

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,139 @@ 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
+ * Four 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 {@link ReferenceStrategy.local local} lookup**, whose target’s own
201
+ * fields reach on from the endpoint in the same way, through a Root Type;
202
+ * - **a reference naming a keyed target**, whose document key is read one hop
203
+ * past the referent (ADR 22) – so a reference reached through an inline
204
+ * chain needs its key hop inside the frame too, or it stores an un-keyed id
205
+ * that matches nothing in the target’s collection. This one is also what a
206
+ * cut local expansion falls back to, so it is counted there as well.
182
207
  */
183
208
  export function inlineFramingDepth(schema, searchType) {
184
- return Math.max(1, inlineChainLength(schema, searchType));
209
+ return Math.max(1, framingReach(schema, searchType) - 1);
185
210
  }
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;
211
+ /**
212
+ * The furthest a declared value lies from a node of `searchType`, in hops. A
213
+ * reference the given schema does not declare (e.g. a type framed against
214
+ * another schema) contributes no reach.
215
+ *
216
+ * `visiting` is what makes the walk terminate. {@link searchSchema} rejects
217
+ * *inline* cycles, but a {@link ReferenceStrategy.local local} lookup can reach
218
+ * a Root Type that reaches back – `Work → Person → Work` is a perfectly
219
+ * reasonable schema – and nothing forbids it. A type already on the path
220
+ * contributes no further reach, so a cycle stops rather than being rejected:
221
+ * the depth it would ask for is unbounded, and the useful depth is the acyclic
222
+ * one.
223
+ *
224
+ * The cut is made **where the field is read**, not on entry, because the
225
+ * extraction cuts there too and still emits the key hop it falls back to. A
226
+ * guard on entry would count the cut as reach 0 and nothing else, framing that
227
+ * hop out – and the innermost referent would store a node IRI keying nothing.
228
+ */
229
+ function framingReach(schema, searchType, visiting = new Set()) {
230
+ const onPath = new Set(visiting).add(searchType.name);
231
+ let furthest = 0;
192
232
  for (const field of searchType.fields) {
193
- if (!isInlineReference(field)) {
233
+ if (field.path === undefined) {
234
+ continue;
235
+ }
236
+ const hops = pathHopCount(field.path);
237
+ if (isInlineReference(field)) {
238
+ const referent = referenceTypeNamed(schema, field.ref.typeName);
239
+ furthest = Math.max(furthest, hops +
240
+ (referent === undefined ? 0 : framingReach(schema, referent, onPath)));
194
241
  continue;
195
242
  }
196
- const referent = referenceTypeNamed(schema, field.ref.typeName);
197
- if (referent === undefined) {
243
+ // A `local` lookup reads the target’s OWN fields off the referent, so the
244
+ // target’s reach continues from wherever this field’s path landed – the
245
+ // same accumulation an inline reference makes, through a Root Type.
246
+ //
247
+ // Cut at a type already on the path, exactly where the extraction cuts:
248
+ // there the local expansion contributes nothing and the extraction falls
249
+ // back to the key hop, whose own traversal still has to be framed. Reading
250
+ // the cut as “reach 0, and nothing else to count” left the innermost
251
+ // referent’s key one hop outside the frame, so it stored a node IRI that
252
+ // matches nothing in the target’s collection.
253
+ const local = localLookupTypeOf(field, schema);
254
+ if (local !== undefined && !onPath.has(local.name)) {
255
+ furthest = Math.max(furthest, hops + framingReach(schema, local, onPath));
198
256
  continue;
199
257
  }
200
- longest = Math.max(longest, 1 + inlineChainLength(schema, referent));
258
+ // A keyed target’s key field is itself path-bearing, so its own traversal
259
+ // counts too – the extraction reads it off the referent with that path.
260
+ const keyPath = keyedTargetKeyPath(field, schema);
261
+ furthest = Math.max(furthest, hops + (keyPath === undefined ? 0 : pathHopCount(keyPath)));
262
+ }
263
+ return furthest;
264
+ }
265
+ /** The `path` of the key field of the keyed Root Type a reference names, when
266
+ * it names one that declares a {@link RootType.key}. */
267
+ function keyedTargetKeyPath(field, schema) {
268
+ if (field.kind !== 'reference') {
269
+ return undefined;
270
+ }
271
+ const targetName = labelSourceNameOf(field);
272
+ const target = targetName === undefined ? undefined : rootTypeNamed(schema, targetName);
273
+ if (target?.key === undefined) {
274
+ return undefined;
275
+ }
276
+ // `searchSchema` guarantees a key field that is declared and path-bearing –
277
+ // the same guarantee the extraction’s key hop is built on.
278
+ return fieldNamed(target, target.key.field).path;
279
+ }
280
+ /**
281
+ * How many hops a `path` traverses: the number of top-level sequence steps.
282
+ * `<a>/<b>` is two; alternation and inverse do not add a hop, and a `/` inside
283
+ * an IRI or a group is not a step.
284
+ *
285
+ * A path that carries no `<` is a **single term** – one IRI – however many `/`
286
+ * its IRI happens to contain (`<https://schema.org/name>` is one hop, not
287
+ * four). That is not a special case but the grammar: a sequence is only
288
+ * expressible with each IRI delimited, since `?s a/b ?o` does not parse.
289
+ * Reading the delimiters is therefore what tells a path apart from an IRI, and
290
+ * it is the whole reason this is safe to scan rather than parse.
291
+ *
292
+ * Deliberately a scanner: the grammar belongs to the reader adapter (which
293
+ * parses it for real), and only the *shape* of the sequence matters here. It is
294
+ * also the safe direction to be approximate in – an over-count frames one hop
295
+ * too wide, which costs a little; an under-count leaves a declared value
296
+ * outside the frame, which is silent.
297
+ */
298
+ function pathHopCount(path) {
299
+ if (!path.includes('<')) {
300
+ return 1;
301
+ }
302
+ let steps = 1;
303
+ let nesting = 0;
304
+ for (const character of path) {
305
+ if (character === '<' || character === '(') {
306
+ nesting++;
307
+ }
308
+ else if (character === '>' || character === ')') {
309
+ nesting--;
310
+ }
311
+ else if (character === '/' && nesting === 0) {
312
+ steps++;
313
+ }
201
314
  }
202
- return longest;
315
+ return steps;
203
316
  }
204
317
  /**
205
318
  * Every inline `ref.typeName` must resolve to a **declared Reference Type**, and
@@ -218,23 +331,156 @@ function assertResolvableInlineReferences(types, referenceTypes) {
218
331
  if (!referenceTypes.has(field.ref.typeName)) {
219
332
  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
333
  }
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);
334
+ // An inline reference’s stored value is a nested object, which an engine
335
+ // can neither tokenize for free text nor order by. `filterable` and
336
+ // `facetable` it CAN serve – through the identity companion below – so
337
+ // only the roles no companion could answer are refused here.
338
+ const extraRoles = unserviceableInlineRoles(field);
227
339
  if (extraRoles.length > 0) {
228
340
  throw new Error(`Inline reference “${searchType.name}.${field.name}” declares ${extraRoles
229
341
  .map((role) => `“${role}”`)
230
- .join(', ')}, which it cannot serve: an inline reference is either a reading device (no Role) or surfaced (“output”).`);
342
+ .join(', ')}, which it cannot serve: its value is a nested object, not a token an engine can search, sort or join on.`);
231
343
  }
344
+ assertIdentityCompanion(searchType, field, referenceTypes);
232
345
  }
233
346
  }
347
+ for (const searchType of types) {
348
+ for (const field of searchType.fields) {
349
+ assertServiceableLocalLookup(searchType, field);
350
+ }
351
+ }
352
+ assertConsistentIdentities(types);
234
353
  for (const referenceType of referenceTypes.values()) {
235
354
  assertNoInlineCycle(referenceType, referenceTypes, new Set());
236
355
  }
237
356
  }
357
+ /**
358
+ * An inline reference’s {@link ReferenceStrategy.identity identity companion}
359
+ * holds together in both directions: `filterable`/`facetable` need an
360
+ * `identity` to point the engine at, and an `identity` with neither Role is a
361
+ * companion nothing reads.
362
+ *
363
+ * The named field must be a nested **reference that names a target** – a
364
+ * `lookup`’s `target` or an `idOnly`’s `labelSource` – because naming one is
365
+ * exactly the claim the companion rests on: *these values are ids of documents
366
+ * in that collection* (ADR 20). That the target resolves to a declared Root
367
+ * Type needs no rule here: {@link assertResolvableLabelSources} walks Reference
368
+ * Types too, so the nested reference this names is resolved with every other.
369
+ */
370
+ function assertIdentityCompanion(searchType, field, referenceTypes) {
371
+ const where = `Inline reference “${searchType.name}.${field.name}”`;
372
+ const identity = field.ref.identity;
373
+ const filters = field.filterable === true || field.facetable === true;
374
+ if (identity === undefined) {
375
+ if (filters) {
376
+ 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.`);
377
+ }
378
+ return;
379
+ }
380
+ if (!filters) {
381
+ 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”.`);
382
+ }
383
+ // A companion is a FLAT field beside the reference, and only a Root Type has
384
+ // somewhere flat to put one: inside a Reference Type it would be written into
385
+ // each entry and declared nowhere, so a filter on it would name a field the
386
+ // collection does not carry. An edge nested inside another edge is not a
387
+ // shape anything has needed; refusing it is better than serving it wrong.
388
+ if (searchType.class === undefined) {
389
+ 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.`);
390
+ }
391
+ // Resolvable by construction: the typeName was checked just above.
392
+ const referenceType = referenceTypes.get(field.ref.typeName);
393
+ const identityField = referenceType && fieldNamed(referenceType, identity);
394
+ if (identityField === undefined) {
395
+ throw new Error(`${where} names identity “${identity}”, which “${field.ref.typeName}” does not declare.`);
396
+ }
397
+ if (identityField.kind !== 'reference' ||
398
+ labelSourceNameOf(identityField) === undefined) {
399
+ 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.`);
400
+ }
401
+ }
402
+ /**
403
+ * A {@link ReferenceStrategy.local local} lookup stores a nested object rather
404
+ * than an id, so it is held to the same Roles an inline reference is: an engine
405
+ * can neither tokenize an object for free text, order by it, facet it, nor
406
+ * point a reference field at it.
407
+ *
408
+ * Without this the failures are all silent-ish and all different: a `joinable`
409
+ * one emits an engine reference over a field holding objects, a `facetable` one
410
+ * has `physicalFields` name a facet field the collection never declares, and a
411
+ * `filterable` one compiles a membership clause against an object. Refusing the
412
+ * declaration is the only place these are one mistake.
413
+ *
414
+ * `filterable` it CAN serve, and does so the same way an inline reference does:
415
+ * through a flat `${name}_id` companion beside the stored object
416
+ * ({@link physicalFields}). That is what lets a condition on the endpoint's
417
+ * identity be welded to a condition on the edge's own value – an engine can
418
+ * only weld conditions on an entry's own **leaf** fields, and the endpoint's id
419
+ * is otherwise one level further in.
420
+ */
421
+ function assertServiceableLocalLookup(searchType, field) {
422
+ if (field.kind !== 'reference' ||
423
+ field.ref?.strategy !== 'lookup' ||
424
+ field.ref.local !== true) {
425
+ return;
426
+ }
427
+ const unserviceable = unserviceableInlineRoles(field);
428
+ if (unserviceable.length > 0 ||
429
+ field.facetable === true ||
430
+ field.joinable === true) {
431
+ const roles = [
432
+ ...unserviceable,
433
+ ...(field.facetable === true ? ['facetable'] : []),
434
+ // `validateSearchType` refuses `joinable` on an INLINE reference, not on
435
+ // a lookup, so a `local` one would otherwise pass: `joinGraph` builds the
436
+ // edge, the collection builder takes the nesting branch and emits no
437
+ // engine reference, and the join fails at query time against a field the
438
+ // collection never declared.
439
+ ...(field.joinable === true ? ['joinable'] : []),
440
+ ];
441
+ throw new Error(`Local lookup “${searchType.name}.${field.name}” declares ${roles
442
+ .map((role) => `“${role}”`)
443
+ .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.`);
444
+ }
445
+ }
446
+ /**
447
+ * Every field nesting one Reference Type must agree about whether it declares
448
+ * an {@link ReferenceStrategy.identity identity}.
449
+ *
450
+ * One reference type yields ONE emitted filter type, shared by every field that
451
+ * nests it, and whether that filter offers an “ids it holds” arm is exactly
452
+ * whether an identity was declared. Left to disagree, the arm would be decided
453
+ * by whichever field a surface happened to build the type from: the
454
+ * identity-bearing field silently loses the ability to filter by id, or the
455
+ * identity-less one gains an arm that filters a nested object an engine cannot
456
+ * read. Both are silent, and which one you get depends on declaration order.
457
+ *
458
+ * The rule is therefore about the *edge type*, not about each field reaching
459
+ * it, and it fails at startup naming both fields.
460
+ */
461
+ function assertConsistentIdentities(types) {
462
+ const declaredBy = new Map();
463
+ for (const searchType of types) {
464
+ for (const field of searchType.fields) {
465
+ if (!isInlineReference(field) || isInternalField(field)) {
466
+ continue;
467
+ }
468
+ const identity = field.ref.identity !== undefined;
469
+ const where = `${searchType.name}.${field.name}`;
470
+ const first = declaredBy.get(field.ref.typeName);
471
+ if (first === undefined) {
472
+ declaredBy.set(field.ref.typeName, { field: where, identity });
473
+ continue;
474
+ }
475
+ if (first.identity !== identity) {
476
+ const [with_, without] = first.identity
477
+ ? [first.field, where]
478
+ : [where, first.field];
479
+ 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.`);
480
+ }
481
+ }
482
+ }
483
+ }
238
484
  function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
239
485
  if (onPath.has(referenceType.name)) {
240
486
  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 +498,31 @@ function assertNoInlineCycle(referenceType, referenceTypes, onPath) {
252
498
  }
253
499
  /**
254
500
  * 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.
501
+ * serve. A nested field carries `output`, `filterable` and `searchable`; it may
502
+ * also be a `lookup`, resolved by descending its entries rather than by a
503
+ * round-trip of its own. `facetable` and `sortable` are refused, and for
504
+ * measured reasons rather than for want of a compiler:
505
+ *
506
+ * - **`facetable`** – an engine’s facet counts over a nested field are
507
+ * *document-level*. A filter scoped to one entry does not scope the facet, so
508
+ * a bucket counts every entry of every matching document, including the
509
+ * entries that did not satisfy the filter. The count is therefore wrong in a
510
+ * way no work on our side recovers. Facet a qualified edge through its
511
+ * {@link ReferenceStrategy.identity identity companion}, which is a flat
512
+ * field an engine facets exactly.
513
+ * - **`sortable`** – there is no such thing as sorting *into* an element of an
514
+ * array; a document holds many entries and a sort key is one value.
515
+ *
516
+ * A `labelSource` stays refused: it would need a label lookup no reconstruction
517
+ * runs, and a nested reference that wants its referent’s fields declares a
518
+ * `lookup` instead. A Role-less field stays an {@link isInternalField Internal
519
+ * Field}, the reading device that is the other half of an inline reference’s
520
+ * job.
263
521
  *
264
522
  * Checked schema-wide, like the label sources and for the same reason: a single
265
523
  * declaration cannot see whether it is a Reference Type at all.
524
+ *
525
+ * See [ADR 24](../../docs/decisions/0024-carry-data-on-a-reference-edge.md).
266
526
  */
267
527
  function assertServiceableNestedFields(referenceTypes) {
268
528
  for (const referenceType of referenceTypes.values()) {
@@ -271,34 +531,49 @@ function assertServiceableNestedFields(referenceTypes) {
271
531
  if (unserviceable.length > 0) {
272
532
  throw new Error(`Nested field “${referenceType.name}.${field.name}” declares ${unserviceable
273
533
  .map((role) => `“${role}”`)
274
- .join(', ')}, which an inline reference cannot serve; a nested field carries “output” only.`);
534
+ .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
535
  }
276
536
  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.`);
537
+ 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
538
  }
285
539
  }
286
540
  }
287
541
  }
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. */
542
+ /** The Roles a **nested** field declares that nesting cannot serve, in
543
+ * declaration order, so a message names them all at once. */
290
544
  function unserviceableNestedRoles(field) {
291
- return UNSERVICEABLE_NESTED_ROLES.filter((role) => role === 'searchable'
292
- ? field.searchable !== undefined
293
- : field[role] === true);
545
+ return UNSERVICEABLE_NESTED_ROLES.filter((role) => field[role] === true);
294
546
  }
295
- /** The Roles a nested field cannot carry – everything but `output`. */
547
+ /**
548
+ * The Roles a nested field cannot carry. An engine facets a nested field per
549
+ * document rather than per entry, and cannot sort into an array element.
550
+ *
551
+ * `joinable` is refused for a different reason: a join is a clause about
552
+ * another COLLECTION, and there is none to address from inside an entry.
553
+ * `buildJoinGraph` walks Root Types, so a nested one builds no edge and the
554
+ * collection emits no engine reference – while the surface still offers the
555
+ * filter, whose criterion then degrades to a vacuous `in: []` and matches
556
+ * EVERYTHING. Refusing the declaration is the only place that is one mistake.
557
+ */
296
558
  const UNSERVICEABLE_NESTED_ROLES = [
297
- 'searchable',
298
- 'filterable',
299
559
  'facetable',
300
560
  'sortable',
561
+ 'joinable',
301
562
  ];
563
+ /** The Roles the **inline reference itself** declares that it cannot serve, in
564
+ * declaration order. Its value is a nested object, which an engine can neither
565
+ * tokenize for free text nor order by; `filterable` and `facetable` it serves
566
+ * through its {@link ReferenceStrategy.identity identity companion}. */
567
+ function unserviceableInlineRoles(field) {
568
+ return UNSERVICEABLE_INLINE_ROLES.filter((role) => role === 'searchable'
569
+ ? field.searchable !== undefined
570
+ : field[role] === true);
571
+ }
572
+ /** The Roles an inline reference cannot carry. `joinable` is absent because
573
+ * {@link validateSearchType} already refuses it on an inline reference, with a
574
+ * reason of its own; refusing it twice would only make which message appears
575
+ * depend on validation order. */
576
+ const UNSERVICEABLE_INLINE_ROLES = ['searchable', 'sortable'];
302
577
  /** The label field name a type falls back to when it declares no
303
578
  * {@link SearchTypeBase.labelField}. */
304
579
  export const DEFAULT_LABEL_FIELD = 'label';
@@ -341,11 +616,16 @@ export function labelSourceNameOf(field) {
341
616
  * can actually serve labels ({@link labelFieldOf}). Checked schema-wide,
342
617
  * because a single declaration cannot see its siblings.
343
618
  *
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.
619
+ * Resolved through {@link rootTypeNamed}, which is exactly the set that has a
620
+ * collection to serve labels from – a Reference Type is nested inside its
621
+ * referrer and has none, so naming one here fails to resolve rather than
622
+ * resolving to something unreadable.
623
+ *
624
+ * Reference Types are walked too, so a **nested** `lookup` has its `target`
625
+ * resolved by the same pass a top-level one does. That is also what validates
626
+ * the target behind an inline reference’s
627
+ * {@link ReferenceStrategy.identity identity companion}, since the companion
628
+ * harvests a nested reference that names one.
349
629
  */
350
630
  function assertResolvableLabelSources(types, schema) {
351
631
  for (const searchType of types) {
@@ -742,6 +1022,69 @@ export function assertValidSearchType(searchType) {
742
1022
  throw new Error(`Invalid search type “${searchType.name}”: ${detail}.`);
743
1023
  }
744
1024
  }
1025
+ /** The physical name of an inline reference’s
1026
+ * {@link ReferenceStrategy.identity identity companion}. */
1027
+ export function identityFieldName(name) {
1028
+ return `${name}_id`;
1029
+ }
1030
+ /**
1031
+ * The Root Type whose labels a reference’s **facet buckets** read – its own
1032
+ * label source, or, for an inline reference, the one its
1033
+ * {@link ReferenceStrategy.identity identity companion} points at.
1034
+ *
1035
+ * The same one-level-in reading {@link inheritedFacetKeys} makes, and for the
1036
+ * same reason: the companion holds that field’s ids, so the type that names
1037
+ * those ids is the type that can label them. Kept together with it so a facet
1038
+ * cannot inherit a policy from one type and its labels from another – or, as
1039
+ * happened first, inherit the policy and no labels at all.
1040
+ */
1041
+ export function labelTargetNameOf(field, schema) {
1042
+ if (field.kind !== 'reference') {
1043
+ return undefined;
1044
+ }
1045
+ return labelSourceNameOf(identityFieldOf(field, schema) ?? field);
1046
+ }
1047
+ /**
1048
+ * The Root Type a {@link ReferenceStrategy.local local} lookup projects its
1049
+ * referents through, or `undefined` for every other field. Such a reference
1050
+ * stores nested documents shaped by the **target’s own declaration** – so it
1051
+ * needs no reference type of its own, and reconstructs through the same path a
1052
+ * resolved referent does.
1053
+ */
1054
+ export function localLookupTypeOf(field, schema) {
1055
+ if (schema === undefined ||
1056
+ field.kind !== 'reference' ||
1057
+ field.ref?.strategy !== 'lookup' ||
1058
+ field.ref.local !== true) {
1059
+ return undefined;
1060
+ }
1061
+ return rootTypeNamed(schema, field.ref.target);
1062
+ }
1063
+ /**
1064
+ * The nested reference an inline reference’s
1065
+ * {@link ReferenceStrategy.identity identity companion} harvests, or
1066
+ * `undefined` where the field declares none (or the schema cannot resolve its
1067
+ * reference type). The one reading of *which nested field identifies the edge*,
1068
+ * so the projection (which harvests it), {@link inheritedFacetKeys} (which
1069
+ * reads its target’s facet policy) and an adapter’s filter compiler cannot
1070
+ * resolve it differently.
1071
+ */
1072
+ export function identityFieldOf(field, schema) {
1073
+ if (schema === undefined || !isInlineReference(field)) {
1074
+ return undefined;
1075
+ }
1076
+ const identity = field.ref.identity;
1077
+ if (identity === undefined) {
1078
+ return undefined;
1079
+ }
1080
+ const referenceType = referenceTypeNamed(schema, field.ref.typeName);
1081
+ if (referenceType === undefined) {
1082
+ return undefined;
1083
+ }
1084
+ // `assertIdentityCompanion` guarantees the named field is declared and is a
1085
+ // reference, so this is a read rather than a search.
1086
+ return fieldNamed(referenceType, identity);
1087
+ }
745
1088
  /**
746
1089
  * The display fields of a localized `text` field are pattern-based, not
747
1090
  * enumerated per declared locale: projection stores `${name}_${lang}` for
@@ -1049,16 +1392,29 @@ export function physicalFields(field, schema) {
1049
1392
  : [],
1050
1393
  // Text is never facetable (`validateSearchType`).
1051
1394
  facet: undefined,
1395
+ identity: undefined,
1052
1396
  };
1053
1397
  }
1398
+ // A field storing a nested OBJECT cannot be filtered or faceted as itself, so
1399
+ // everything an engine indexes for it reads the identity companion instead –
1400
+ // the facet included, which is why the facet name is derived from the
1401
+ // companion rather than from `name`. Two shapes store one: an inline
1402
+ // reference that names an `identity`, and a `local` lookup, whose own id is
1403
+ // otherwise a level further in than an engine can weld a condition to.
1404
+ const nestsAnObject = identityFieldOf(field, schema) !== undefined ||
1405
+ (localLookupTypeOf(field, schema) !== undefined &&
1406
+ field.filterable === true);
1407
+ const identity = nestsAnObject ? identityFieldName(field.name) : undefined;
1408
+ const filtered = identity ?? field.name;
1054
1409
  return {
1055
1410
  search: field.searchable !== undefined ? [`${field.name}_search`] : [],
1056
1411
  sort: [],
1057
1412
  facet: field.facetable !== true
1058
1413
  ? undefined
1059
1414
  : inheritedFacetKeys(field, schema) === undefined
1060
- ? field.name
1061
- : `${field.name}_facet`,
1415
+ ? filtered
1416
+ : `${filtered}_facet`,
1417
+ identity,
1062
1418
  };
1063
1419
  }
1064
1420
  /**
@@ -1071,6 +1427,14 @@ export function physicalFields(field, schema) {
1071
1427
  * own values rather than reading a referent, is re-keyed by nothing and so
1072
1428
  * narrowed by nothing either. The one reading the projection (which writes the
1073
1429
  * companion) and {@link physicalFields} (which names it) share.
1430
+ *
1431
+ * An **inline** reference names its target one level in, through the nested
1432
+ * reference its {@link ReferenceStrategy.identity identity companion} harvests
1433
+ * ({@link identityFieldOf}). The boundary is the same one: the companion holds
1434
+ * that field’s ids, so the policy that narrows a facet over those ids is the
1435
+ * policy of the type that field names. Reading it through the nested field
1436
+ * rather than declaring it twice is what keeps *which of a type’s ids deserve
1437
+ * a bucket* a fact about the type.
1074
1438
  */
1075
1439
  export function inheritedFacetKeys(field, schema) {
1076
1440
  if (schema === undefined ||
@@ -1079,7 +1443,8 @@ export function inheritedFacetKeys(field, schema) {
1079
1443
  field.derive !== undefined) {
1080
1444
  return undefined;
1081
1445
  }
1082
- const targetName = labelSourceNameOf(field);
1446
+ const identity = identityFieldOf(field, schema);
1447
+ const targetName = labelSourceNameOf(identity ?? field);
1083
1448
  return targetName === undefined
1084
1449
  ? undefined
1085
1450
  : 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.1",
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"