@lde/search 0.28.0 → 0.29.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
@@ -120,7 +120,7 @@ export function referenceTypeNamed(schema, name) {
120
120
  }
121
121
  /**
122
122
  * The {@link RootType} a declaration names – a `lookup`’s `target`, an
123
- * `idOnly`’s {@link ReferenceField.labelSource} ({@link labelSourceNameOf}), a
123
+ * `idOnly`’s {@link ReferenceField.labelSource} ({@link labelSourceNamesOf}), a
124
124
  * join edge – or `undefined` when the schema declares no Root Type by that
125
125
  * name. The one reading of *which type does this point at*, so the projection
126
126
  * (which re-keys a reference through its target’s {@link KeyField}), the
@@ -250,27 +250,34 @@ function framingReach(schema, searchType, visiting = new Set()) {
250
250
  // the cut as “reach 0, and nothing else to count” left the innermost
251
251
  // referent’s key one hop outside the frame, so it stored a node IRI that
252
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));
256
- continue;
253
+ //
254
+ // Several targets each contribute their own reach: a referent is framed
255
+ // once, and the frame has to hold whichever declaration it turns out to
256
+ // match, so the furthest of them decides.
257
+ // The field’s own traversal counts whatever it names.
258
+ furthest = Math.max(furthest, hops);
259
+ const local = localLookupTargetsOf(field, schema).filter((target) => !onPath.has(target.name));
260
+ for (const target of local) {
261
+ furthest = Math.max(furthest, hops + framingReach(schema, target, onPath));
257
262
  }
258
263
  // A keyed target’s key field is itself path-bearing, so its own traversal
259
264
  // 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)));
265
+ // Counted for every target that is not locally expanded above, cut ones
266
+ // included: there the extraction falls back to exactly this hop.
267
+ for (const target of referencedTargetsOf(field, schema)) {
268
+ if (local.includes(target)) {
269
+ continue;
270
+ }
271
+ const keyPath = keyPathOf(target);
272
+ furthest = Math.max(furthest, hops + (keyPath === undefined ? 0 : pathHopCount(keyPath)));
273
+ }
262
274
  }
263
275
  return furthest;
264
276
  }
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) {
277
+ /** The `path` of a Root Type’s key field, when it declares a
278
+ * {@link RootType.key}. */
279
+ function keyPathOf(target) {
280
+ if (target.key === undefined) {
274
281
  return undefined;
275
282
  }
276
283
  // `searchSchema` guarantees a key field that is declared and path-bearing –
@@ -395,7 +402,7 @@ function assertIdentityCompanion(searchType, field, referenceTypes) {
395
402
  throw new Error(`${where} names identity “${identity}”, which “${field.ref.typeName}” does not declare.`);
396
403
  }
397
404
  if (identityField.kind !== 'reference' ||
398
- labelSourceNameOf(identityField) === undefined) {
405
+ labelSourceNamesOf(identityField).length === 0) {
399
406
  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
407
  }
401
408
  }
@@ -612,15 +619,36 @@ export function labelFieldOf(searchType) {
612
619
  : undefined;
613
620
  }
614
621
  /**
615
- * The Root Type a reference resolves labels from, by name: a `lookup`’s
616
- * `target`, an `idOnly`’s {@link ReferenceField.labelSource}, or `undefined`
617
- * when it resolves none. One reading for the two declarations, so a consumer
618
- * never branches on the strategy to find the collection.
622
+ * The Root Types a reference resolves labels from, by name and in order of
623
+ * precedence: a `lookup`’s `target`, an `idOnly`’s
624
+ * {@link ReferenceField.labelSource}, or none when it resolves none. One
625
+ * reading for the two declarations, so a consumer never branches on the
626
+ * strategy to find the collection – and one **shape** for the one-target and
627
+ * the several-target declaration, so a consumer never branches on that either:
628
+ * a single name is the list of one.
619
629
  */
620
- export function labelSourceNameOf(field) {
621
- return field.ref?.strategy === 'lookup'
622
- ? field.ref.target
623
- : field.labelSource;
630
+ export function labelSourceNamesOf(field) {
631
+ const declared = field.ref?.strategy === 'lookup' ? field.ref.target : field.labelSource;
632
+ return declared === undefined
633
+ ? []
634
+ : typeof declared === 'string'
635
+ ? [declared]
636
+ : declared;
637
+ }
638
+ /**
639
+ * The Root Types a reference resolves against ({@link labelSourceNamesOf}),
640
+ * resolved in the given schema and in order of precedence. A name the schema
641
+ * does not declare as a Root Type is skipped – `searchSchema` rejects such a
642
+ * declaration, so this only happens for a field read against a foreign schema,
643
+ * where it resolves what it can.
644
+ */
645
+ export function referencedTargetsOf(field, schema) {
646
+ if (schema === undefined || field.kind !== 'reference') {
647
+ return [];
648
+ }
649
+ return labelSourceNamesOf(field)
650
+ .map((name) => rootTypeNamed(schema, name))
651
+ .filter((target) => target !== undefined);
624
652
  }
625
653
  /**
626
654
  * Every name a reference resolves labels from – a `lookup`’s `target` or an
@@ -655,28 +683,97 @@ function assertResolvableLabelSources(types, schema) {
655
683
  throw new Error(`Reference “${searchType.name}.${field.name}” declares a label source on a ${field.ref.strategy} reference; only an idOnly reference does, for its facet buckets.`);
656
684
  }
657
685
  }
658
- const sourceName = field.kind === 'reference' && field.ref?.strategy === 'lookup'
659
- ? field.ref.target
660
- : labelSource;
661
- if (sourceName === undefined) {
686
+ if (field.kind !== 'reference') {
662
687
  continue;
663
688
  }
664
- const source = rootTypeNamed(schema, sourceName);
665
- if (source === undefined) {
666
- // A name that IS declared, just not as a Root Type, is the confusing
667
- // case: telling the author to declare a type they already declared
668
- // would send them looking in the wrong place. Only a Root Type has a
669
- // collection to resolve against, so name that instead.
670
- throw new Error(referenceTypeNamed(schema, sourceName) === undefined
671
- ? `Reference “${searchType.name}.${field.name}” names unknown label source “${sourceName}”; declare a SearchType with that name.`
672
- : `Reference “${searchType.name}.${field.name}” names label source “${sourceName}”, which is a Reference Type; a label source must be a Root Type, since a resolved label is read from that type’s own collection.`);
689
+ const sources = [];
690
+ for (const sourceName of labelSourceNamesOf(field)) {
691
+ const source = rootTypeNamed(schema, sourceName);
692
+ if (source === undefined) {
693
+ // A name that IS declared, just not as a Root Type, is the confusing
694
+ // case: telling the author to declare a type they already declared
695
+ // would send them looking in the wrong place. Only a Root Type has a
696
+ // collection to resolve against, so name that instead.
697
+ throw new Error(referenceTypeNamed(schema, sourceName) === undefined
698
+ ? `Reference “${searchType.name}.${field.name}” names unknown label source “${sourceName}”; declare a SearchType with that name.`
699
+ : `Reference “${searchType.name}.${field.name}” names label source “${sourceName}”, which is a Reference Type; a label source must be a Root Type, since a resolved label is read from that type’s own collection.`);
700
+ }
701
+ if (labelFieldOf(source) === undefined) {
702
+ throw new Error(`Reference “${searchType.name}.${field.name}” uses label source “${sourceName}”, which must declare an output, searchable text field “${labelFieldNameOf(source)}”.`);
703
+ }
704
+ sources.push(source);
705
+ }
706
+ assertCompatibleTargets(searchType, field, sources);
707
+ }
708
+ }
709
+ }
710
+ /**
711
+ * The targets of a reference naming **several** may not disagree about a
712
+ * field they both declare: a stored referent of either kind lands in one
713
+ * nested object, which an engine declares once, so `birthDate` cannot be a
714
+ * `date` on one target and a `keyword` on the other, nor a list on one and a
715
+ * single value on the other – nor an id on one and a nested document on the
716
+ * other, nor indexed on one and stored only on the other. Everything that
717
+ * decides a nested field’s physical shape has to agree: kind, arity, the
718
+ * Roles it opts into, and for a reference the strategy and what it points at.
719
+ * A field only one target declares is fine – the other simply never fills it
720
+ * – and so is a `text` field whose locales differ, since display stores every
721
+ * present language regardless.
722
+ */
723
+ function assertCompatibleTargets(searchType, field, targets) {
724
+ const seen = new Map();
725
+ for (const target of targets) {
726
+ for (const declared of target.fields) {
727
+ const earlier = seen.get(declared.name);
728
+ if (earlier === undefined) {
729
+ seen.set(declared.name, { target, field: declared });
730
+ continue;
673
731
  }
674
- if (labelFieldOf(source) === undefined) {
675
- throw new Error(`Reference “${searchType.name}.${field.name}” uses label source “${sourceName}”, which must declare an output, searchable text field “${labelFieldNameOf(source)}”.`);
732
+ if (describeShape(earlier.field) !== describeShape(declared)) {
733
+ throw new Error(`Reference “${searchType.name}.${field.name}” names targets “${earlier.target.name}” and “${target.name}”, which both declare “${declared.name}” but not alike (${describeShape(earlier.field)} vs ${describeShape(declared)}); a referent of either kind is stored in one shape, so a field the targets share must be declared the same on both.`);
676
734
  }
677
735
  }
678
736
  }
679
737
  }
738
+ /**
739
+ * Everything that decides a field’s physical shape, spelled out for a message
740
+ * comparing two declarations: kind and arity, the Roles it carries, and for a
741
+ * reference its strategy, what it points at, and whether it stores a copy.
742
+ */
743
+ function describeShape(field) {
744
+ const roles = ['filterable', 'facetable', 'sortable'].filter((role) => field[role] === true);
745
+ if (field.searchable !== undefined) {
746
+ roles.push('searchable');
747
+ }
748
+ const ref = field.kind === 'reference' && field.ref !== undefined
749
+ ? [
750
+ field.ref.strategy,
751
+ ...labelSourceNamesOf(field),
752
+ ...(field.ref.strategy === 'inline' ? [field.ref.typeName] : []),
753
+ ...(field.ref.strategy === 'lookup' && field.ref.local === true
754
+ ? ['local']
755
+ : []),
756
+ ]
757
+ : [];
758
+ return [
759
+ field.array === true ? `${field.kind} list` : field.kind,
760
+ ...(ref.length > 0 ? [`→ ${ref.join(' ')}`] : []),
761
+ ...(roles.length > 0 ? [`(${roles.join(', ')})`] : []),
762
+ ].join(' ');
763
+ }
764
+ /**
765
+ * The Root Type a stored referent was projected through, among the targets
766
+ * its lookup names: the one its discriminator names ({@link TARGET_FIELD}),
767
+ * or the first declared where it carries none – a single-target lookup stores
768
+ * none, and the first target is also what an unmatched referent was projected
769
+ * through. The one reading of a stored discriminator, so the projection
770
+ * (which prunes through it) and an engine adapter (which reconstructs through
771
+ * it) cannot read it differently.
772
+ */
773
+ export function storedTargetOf(entry, targets) {
774
+ const name = entry[TARGET_FIELD];
775
+ return targets.find((target) => target.name === name) ?? targets[0];
776
+ }
680
777
  /**
681
778
  * The reserved logical name of a document’s IRI – the one field every indexed
682
779
  * thing carries, and the only one no {@link SearchType} declares. It is the
@@ -706,9 +803,28 @@ export const ID_FIELD = 'id';
706
803
  */
707
804
  export const AND_KEY = 'and';
708
805
  export const OR_KEY = 'or';
806
+ /**
807
+ * The physical name under which a stored referent of a lookup naming
808
+ * **several** targets records which of them it belongs to: the `name` of the
809
+ * Root Type whose `class` its `rdf:type` matched first, or the first target
810
+ * declared where none did. Written by the projection, read by an engine
811
+ * adapter to reconstruct the referent through the right declaration when no
812
+ * collection answers for it – an unidentified referent, or an identified one
813
+ * whose document is not indexed. A single-target lookup stores none: there is
814
+ * nothing to tell apart.
815
+ *
816
+ * Reserved schema-wide ({@link validateSearchType} rejects a field of this
817
+ * name), so it can never shadow a declared field of a stored entry.
818
+ */
819
+ export const TARGET_FIELD = '_target';
709
820
  /** The logical field names no {@link SearchType} may declare, each because a
710
821
  * surface already gives that name a meaning of its own. */
711
- const RESERVED_FIELD_NAMES = [ID_FIELD, AND_KEY, OR_KEY];
822
+ const RESERVED_FIELD_NAMES = [
823
+ ID_FIELD,
824
+ AND_KEY,
825
+ OR_KEY,
826
+ TARGET_FIELD,
827
+ ];
712
828
  /** Kinds that can feed full-text search (project a folded search field). */
713
829
  const SEARCHABLE_KINDS = ['text', 'keyword', 'reference'];
714
830
  /** Kinds whose projection applies the {@link KeywordField.transform}. */
@@ -833,16 +949,38 @@ export function validateSearchType(searchType) {
833
949
  if (field.output === true &&
834
950
  ((field.ref?.strategy === 'inline' &&
835
951
  field.ref.typeName === undefined) ||
836
- (field.ref?.strategy === 'lookup' && field.ref.target === undefined))) {
952
+ (field.ref?.strategy === 'lookup' &&
953
+ labelSourceNamesOf(field).length === 0))) {
837
954
  issue('missing-ref-type-name');
838
955
  }
839
956
  // A join addresses the referent's collection – the one a lookup's
840
957
  // `target` or an idOnly's `labelSource` names. With neither, the flag
841
958
  // states an edge to nowhere.
842
- if (field.joinable === true &&
843
- labelSourceNameOf(field) === undefined) {
959
+ const targets = labelSourceNamesOf(field);
960
+ if (field.joinable === true && targets.length === 0) {
844
961
  issue('joinable-without-label-source');
845
962
  }
963
+ // An engine reference names ONE collection, and ids that live in
964
+ // several have no single collection to reference – so a reference whose
965
+ // referent may be any of several types can never be an engine edge. Its
966
+ // labels, facets and id filters all work from this document; a
967
+ // condition on the referent’s own fields does not (#717).
968
+ if (field.joinable === true && targets.length > 1) {
969
+ issue('joinable-with-several-targets');
970
+ }
971
+ // Precedence between targets is declaration order, so a name declared
972
+ // twice would have to mean two different ranks.
973
+ if (new Set(targets).size !== targets.length) {
974
+ issue('duplicate-target');
975
+ }
976
+ // A list of targets names at least one: an empty one would silently
977
+ // read as no target at all – no re-keying, no labels, no facet policy.
978
+ const declaredTargets = field.ref?.strategy === 'lookup'
979
+ ? field.ref.target
980
+ : field.labelSource;
981
+ if (Array.isArray(declaredTargets) && declaredTargets.length === 0) {
982
+ issue('empty-target');
983
+ }
846
984
  // An inline reference is stored as a NESTED OBJECT, not as an id an
847
985
  // engine can point a reference field at, so the two cannot both hold:
848
986
  // the collection definition would emit the nesting and silently drop the
@@ -1040,44 +1178,46 @@ export function identityFieldName(name) {
1040
1178
  return `${name}_id`;
1041
1179
  }
1042
1180
  /**
1043
- * The Root Type whose labels a reference’s **facet buckets** read – its own
1044
- * label source, or, for an inline reference, the one its
1045
- * {@link ReferenceStrategy.identity identity companion} points at.
1181
+ * The Root Types whose labels a reference’s **facet buckets** read, by name and
1182
+ * in order of precedence – its own label sources, or, for an inline reference,
1183
+ * those its {@link ReferenceStrategy.identity identity companion} points at.
1184
+ * Empty for a field that labels no bucket.
1046
1185
  *
1047
- * The same one-level-in reading {@link inheritedFacetKeys} makes, and for the
1048
- * same reason: the companion holds that field’s ids, so the type that names
1049
- * those ids is the type that can label them. Kept together with it so a facet
1050
- * cannot inherit a policy from one type and its labels from another – or, as
1051
- * happened first, inherit the policy and no labels at all.
1186
+ * The same one-level-in reading {@link inheritedFacetPolicies} makes, and for
1187
+ * the same reason: the companion holds that field’s ids, so the types that
1188
+ * name those ids are the types that can label them. Kept together with it so a
1189
+ * facet cannot inherit a policy from one type and its labels from another –
1190
+ * or, as happened first, inherit the policy and no labels at all.
1052
1191
  */
1053
- export function labelTargetNameOf(field, schema) {
1192
+ export function labelTargetNamesOf(field, schema) {
1054
1193
  if (field.kind !== 'reference') {
1055
- return undefined;
1194
+ return [];
1056
1195
  }
1057
- return labelSourceNameOf(identityFieldOf(field, schema) ?? field);
1196
+ return labelSourceNamesOf(identityFieldOf(field, schema) ?? field);
1058
1197
  }
1059
1198
  /**
1060
- * The Root Type a {@link ReferenceStrategy.local local} lookup projects its
1061
- * referents through, or `undefined` for every other field. Such a reference
1062
- * stores nested documents shaped by the **target’s own declaration** – so it
1063
- * needs no reference type of its own, and reconstructs through the same path a
1064
- * resolved referent does.
1199
+ * The Root Types a {@link ReferenceStrategy.local local} lookup projects its
1200
+ * referents through, in order of precedence – or none for every other field.
1201
+ * Such a reference stores nested documents shaped by the **target’s own
1202
+ * declaration** – so it needs no reference type of its own, and reconstructs
1203
+ * through the same path a resolved referent does. With several targets, each
1204
+ * referent is projected through the one its `rdf:type` matches
1205
+ * ({@link TARGET_FIELD}).
1065
1206
  */
1066
- export function localLookupTypeOf(field, schema) {
1067
- if (schema === undefined ||
1068
- field.kind !== 'reference' ||
1207
+ export function localLookupTargetsOf(field, schema) {
1208
+ if (field.kind !== 'reference' ||
1069
1209
  field.ref?.strategy !== 'lookup' ||
1070
1210
  field.ref.local !== true) {
1071
- return undefined;
1211
+ return [];
1072
1212
  }
1073
- return rootTypeNamed(schema, field.ref.target);
1213
+ return referencedTargetsOf(field, schema);
1074
1214
  }
1075
1215
  /**
1076
1216
  * The nested reference an inline reference’s
1077
1217
  * {@link ReferenceStrategy.identity identity companion} harvests, or
1078
1218
  * `undefined` where the field declares none (or the schema cannot resolve its
1079
1219
  * reference type). The one reading of *which nested field identifies the edge*,
1080
- * so the projection (which harvests it), {@link inheritedFacetKeys} (which
1220
+ * so the projection (which harvests it), {@link inheritedFacetPolicies} (which
1081
1221
  * reads its target’s facet policy) and an adapter’s filter compiler cannot
1082
1222
  * resolve it differently.
1083
1223
  */
@@ -1388,7 +1528,7 @@ export function unixSecondsToIso(seconds) {
1388
1528
  * The facet is the one member that depends on more than the declaration: a
1389
1529
  * facetable reference whose target declares a {@link FacetKeys facet policy}
1390
1530
  * facets a `${name}_facet` companion rather than itself, and only the `schema`
1391
- * can resolve that target ({@link inheritedFacetKeys}). Without one the field
1531
+ * can resolve that target ({@link inheritedFacetPolicies}). Without one the field
1392
1532
  * facets on its own name – the same reading the projection makes without a
1393
1533
  * schema, where it cannot re-key a reference either.
1394
1534
  */
@@ -1414,7 +1554,7 @@ export function physicalFields(field, schema) {
1414
1554
  // reference that names an `identity`, and a `local` lookup, whose own id is
1415
1555
  // otherwise a level further in than an engine can weld a condition to.
1416
1556
  const nestsAnObject = identityFieldOf(field, schema) !== undefined ||
1417
- (localLookupTypeOf(field, schema) !== undefined &&
1557
+ (localLookupTargetsOf(field, schema).length > 0 &&
1418
1558
  field.filterable === true);
1419
1559
  const identity = nestsAnObject ? identityFieldName(field.name) : undefined;
1420
1560
  const filtered = identity ?? field.name;
@@ -1423,16 +1563,20 @@ export function physicalFields(field, schema) {
1423
1563
  sort: [],
1424
1564
  facet: field.facetable !== true
1425
1565
  ? undefined
1426
- : inheritedFacetKeys(field, schema) === undefined
1566
+ : inheritedFacetPolicies(field, schema).size === 0
1427
1567
  ? filtered
1428
1568
  : `${filtered}_facet`,
1429
1569
  identity,
1430
1570
  };
1431
1571
  }
1432
1572
  /**
1433
- * The {@link FacetKeys facet policy} a field inherits: the `facetKeys` of the
1434
- * Root Type it names ({@link labelSourceNameOf}), when the field is a facetable
1435
- * reference and the schema resolves that type. The boundary is *naming the
1573
+ * The {@link FacetKeys facet policies} a field inherits, keyed by the `name` of
1574
+ * the Root Type declaring each: the `facetKeys` of every type the field names
1575
+ * ({@link labelSourceNamesOf}) that declares one, when the field is a facetable
1576
+ * reference and the schema resolves those types. Empty where nothing is
1577
+ * inherited. With several targets, an id is admitted to a bucket by the policy
1578
+ * of the target it belongs to, and unconditionally where that target declares
1579
+ * none. The boundary is *naming the
1436
1580
  * target* – the same line along which a reference is re-keyed and a join is
1437
1581
  * drawn – so a reference that names no type inherits nothing, whatever type
1438
1582
  * its values happen to point at; and a `derive`d reference, which produces its
@@ -1448,16 +1592,19 @@ export function physicalFields(field, schema) {
1448
1592
  * rather than declaring it twice is what keeps *which of a type’s ids deserve
1449
1593
  * a bucket* a fact about the type.
1450
1594
  */
1451
- export function inheritedFacetKeys(field, schema) {
1595
+ export function inheritedFacetPolicies(field, schema) {
1596
+ const policies = new Map();
1452
1597
  if (schema === undefined ||
1453
1598
  field.kind !== 'reference' ||
1454
1599
  field.facetable !== true ||
1455
1600
  field.derive !== undefined) {
1456
- return undefined;
1601
+ return policies;
1457
1602
  }
1458
1603
  const identity = identityFieldOf(field, schema);
1459
- const targetName = labelSourceNameOf(identity ?? field);
1460
- return targetName === undefined
1461
- ? undefined
1462
- : rootTypeNamed(schema, targetName)?.facetKeys;
1604
+ for (const target of referencedTargetsOf(identity ?? field, schema)) {
1605
+ if (target.facetKeys !== undefined) {
1606
+ policies.set(target.name, target.facetKeys);
1607
+ }
1608
+ }
1609
+ return policies;
1463
1610
  }
@@ -1 +1 @@
1
- {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAoBhD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAuRN"}
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAsBhD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,YAAY,GACzB,IAAI,CAoSN"}
package/dist/testing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { filterOn, filterOperatorFor, } from './query.js';
3
- import { facetableFields, filterableFields, ID_FIELD, inheritedFacetKeys, nestedReferenceType, outputFields, } from './schema.js';
3
+ import { facetableFields, filterableFields, ID_FIELD, identityFieldOf, inheritedFacetPolicies, referencedTargetsOf, nestedReferenceType, outputFields, } from './schema.js';
4
4
  /**
5
5
  * The executable {@link SearchEngine} port contract (import from
6
6
  * `@lde/search/testing`): a vitest suite every engine adapter runs against a
@@ -212,8 +212,16 @@ export function describeSearchEngineContract(name, engine) {
212
212
  // second assertion bites.
213
213
  for (const searchType of types()) {
214
214
  for (const field of facetableFields(searchType)) {
215
- const policy = inheritedFacetKeys(field, engine().schema);
216
- if (policy === undefined) {
215
+ // A bucket keyed on an id of one target is admitted by that
216
+ // target’s policy, and a target declaring none admits every one of
217
+ // its ids. Which target a bucket belongs to is not visible here, so
218
+ // the contract is checked only where every target constrains: then
219
+ // a bucket failing every policy belongs to no admitting target.
220
+ const policies = [
221
+ ...inheritedFacetPolicies(field, engine().schema).values(),
222
+ ];
223
+ const targets = referencedTargetsOf(identityFieldOf(field, engine().schema) ?? field, engine().schema);
224
+ if (policies.length === 0 || policies.length < targets.length) {
217
225
  continue;
218
226
  }
219
227
  const result = await engine().search(searchType, {
@@ -223,7 +231,7 @@ export function describeSearchEngineContract(name, engine) {
223
231
  });
224
232
  const buckets = result.facets[field.name] ?? [];
225
233
  for (const bucket of buckets) {
226
- expect(policy.only(bucket.value)).toBe(true);
234
+ expect(policies.some((policy) => policy.only(bucket.value))).toBe(true);
227
235
  expect(bucket.label).toBeDefined();
228
236
  }
229
237
  if (field.output !== true || field.filterable !== true) {
@@ -231,7 +239,7 @@ export function describeSearchEngineContract(name, engine) {
231
239
  }
232
240
  const excluded = result.hits
233
241
  .flatMap((hit) => referenceIds(hit.document[field.name]))
234
- .find((id) => !policy.only(id));
242
+ .find((id) => !policies.some((policy) => policy.only(id)));
235
243
  if (excluded === undefined) {
236
244
  continue;
237
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",