@lde/search-api-graphql 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAgBrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAGvB,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B;;sDAEkD;IAClD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACjE;AAED;2EAC2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;uBACmB;IACnB,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,aAAa,KACnB,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC;;kEAE8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;gDAE4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAiff;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
1
+ {"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAmBrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAGvB,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B;;sDAEkD;IAClD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACjE;AAED;2EAC2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;uBACmB;IACnB,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,aAAa,KACnB,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC;;kEAE8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;gDAE4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AA+ED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAuoBf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
@@ -1,9 +1,58 @@
1
- import { GraphQLBoolean, GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
- import { AND_KEY, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, OR_KEY, isRangeFacet, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
1
+ import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
+ import { AND_KEY, DEFAULT_LABEL_FIELD, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, isAbsoluteIri, OR_KEY, isRangeFacet, labelFieldNameOf, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
3
3
  import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
4
4
  import { createFacetLoader } from './facet-batch.js';
5
5
  const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
6
6
  const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNonNull(scalar) : scalar;
7
+ /**
8
+ * The IRI scalar: an absolute IRI, serialized as a string.
9
+ *
10
+ * Its job is to make a filter’s **element type** tell a consumer what the filter
11
+ * keys on, so “which of this type’s fields accept an IRI” is answerable by
12
+ * introspection instead of by a hardcoded, per-deployment list of predicates
13
+ * that drifts whenever a field is added. Wire-compatible with `String` – but
14
+ * deliberately NOT the same type, since the whole point is that the two are
15
+ * distinguishable. Consequence for consumers: a variable must be declared
16
+ * `[IRI!]`, not `[String!]`, because GraphQL checks variable usage nominally.
17
+ *
18
+ * **Input coercion validates; output serialization does not.** Rejecting a bare
19
+ * token on the way in is what turns `where: { material: { in: ["boerenbont"] } }`
20
+ * from a silent empty result into an error saying why – the same job
21
+ * `argsToQuery` already does for an out-of-range `perPage`.
22
+ *
23
+ * It validates on the way **out** too, and deliberately: a type that is enforced
24
+ * in one direction only is not a type a consumer can rely on, and the coarse
25
+ * discovery strategy reads `IRI` as the promise that a value is a selection key.
26
+ * Serving a non-IRI under it would make the promise false exactly where a
27
+ * consumer acts on it. The projection applies the same rule at the source, so
28
+ * the only way to reach this error is an index written before it existed – which
29
+ * a reindex fixes, and which failing names instead of hiding.
30
+ *
31
+ * What counts as an IRI is {@link isAbsoluteIri} – shared with the projection,
32
+ * so the surface cannot promise something the index contradicts.
33
+ */
34
+ const iriScalar = new GraphQLScalarType({
35
+ name: 'IRI',
36
+ description: 'An absolute IRI (RFC 3987), serialized as a string. Any scheme – `https:`, `urn:`, `doi:`, `ark:` – not only HTTP. A field or filter typed `IRI` keys on identity, never on a literal.',
37
+ specifiedByURL: 'https://www.rfc-editor.org/rfc/rfc3987',
38
+ serialize: (value) => assertIriOut(GraphQLString.serialize(value)),
39
+ parseValue: (value) => assertIri(GraphQLString.parseValue(value)),
40
+ parseLiteral: (node, variables) => assertIri(GraphQLString.parseLiteral(node, variables)),
41
+ });
42
+ function assertIri(value) {
43
+ if (!isAbsoluteIri(value)) {
44
+ throw new GraphQLError(`IRI cannot represent “${value}”: an IRI needs a scheme (for example “https:”, “urn:” or “doi:”) and no whitespace. A value like this is usually a label or a token, which selects nothing on a field that keys on identity.`);
45
+ }
46
+ return value;
47
+ }
48
+ /** Outbound the fault is the index, not the caller, so the message points at
49
+ * the fix rather than at the query. */
50
+ function assertIriOut(value) {
51
+ if (!isAbsoluteIri(value)) {
52
+ throw new GraphQLError(`IRI cannot serialize “${value}”: the index holds a value that is not an absolute IRI on a field that keys on identity. The projection drops such values at the source, so this document predates that rule – reindex it.`);
53
+ }
54
+ return value;
55
+ }
7
56
  /** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
8
57
  function screamingSnake(name) {
9
58
  return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
@@ -21,7 +70,8 @@ function screamingSnake(name) {
21
70
  export function buildGraphQLSchema(schema, options = {}) {
22
71
  const languageOrder = options.languageOrder ?? defaultLanguageOrder;
23
72
  const maxPerPage = options.maxPerPage ?? 100;
24
- const rootTypeNames = new Set([...schema.values()].map((searchType) => searchType.name));
73
+ const rootTypesByName = new Map([...schema.values()].map((searchType) => [searchType.name, searchType]));
74
+ const rootTypeNames = new Set(rootTypesByName.keys());
25
75
  for (const name of Object.keys(options.types ?? {})) {
26
76
  if (!rootTypeNames.has(name)) {
27
77
  throw new Error(`Options given for type “${name}”, which is not in the search schema.`);
@@ -34,25 +84,37 @@ export function buildGraphQLSchema(schema, options = {}) {
34
84
  value: { type: new GraphQLNonNull(GraphQLString) },
35
85
  },
36
86
  });
37
- // A plain value facet bucket: a selection key, its count, and (for reference
38
- // facets) the engine-resolved data label; null for token/free-string facets
39
- // whose display the consumer owns.
40
- const valueBucket = new GraphQLObjectType({
41
- name: 'ValueBucket',
42
- fields: {
43
- value: { type: new GraphQLNonNull(GraphQLString) },
44
- count: { type: new GraphQLNonNull(GraphQLInt) },
45
- label: {
46
- type: new GraphQLList(new GraphQLNonNull(languageString)),
47
- resolve: (bucket, _args, context) => {
48
- const label = bucket.label;
49
- return label
50
- ? toLanguageStrings(label, context.acceptLanguage, languageOrder)
51
- : null;
52
- },
87
+ // A value facet bucket: a selection key, its count, and (for reference facets)
88
+ // the engine-resolved data label; null for token/free-string facets whose
89
+ // display the consumer owns. Only the key’s type varies between the two – a
90
+ // facet keys on whatever its field keys on – so both are built from here and
91
+ // cannot drift apart.
92
+ const valueBucketFields = (keyType) => ({
93
+ value: { type: new GraphQLNonNull(keyType) },
94
+ count: { type: new GraphQLNonNull(GraphQLInt) },
95
+ label: {
96
+ type: new GraphQLList(new GraphQLNonNull(languageString)),
97
+ resolve: (bucket, _args, context) => {
98
+ const label = bucket.label;
99
+ return label
100
+ ? toLanguageStrings(label, context.acceptLanguage, languageOrder)
101
+ : null;
53
102
  },
54
103
  },
55
104
  });
105
+ const valueBucket = new GraphQLObjectType({
106
+ name: 'ValueBucket',
107
+ fields: valueBucketFields(GraphQLString),
108
+ });
109
+ // A reference facet’s bucket, whose `value` is an `IRI` because the field it
110
+ // buckets keys on identity. This is what makes the round trip typed end to
111
+ // end: the bucket a consumer selects feeds straight into the ‹Target›Filter
112
+ // that selects it, with no `String`-to-`IRI` boundary in between – the
113
+ // contract BooleanBucket already keeps for `is`.
114
+ const iriBucket = new GraphQLObjectType({
115
+ name: 'IriBucket',
116
+ fields: valueBucketFields(iriScalar),
117
+ });
56
118
  // A numeric range-facet bin: half-open `[min, max)` bounds (max null on an
57
119
  // open-ended top bin) and the count of documents in it.
58
120
  const rangeBucket = new GraphQLObjectType({
@@ -91,12 +153,63 @@ export function buildGraphQLSchema(schema, options = {}) {
91
153
  name: 'SortDirection',
92
154
  values: { ASC: { value: 'asc' }, DESC: { value: 'desc' } },
93
155
  });
94
- const stringFilter = new GraphQLInputObjectType({
95
- name: 'StringFilter',
156
+ // Membership filters, split by what the field keys on. A `keyword` field
157
+ // holds literals (an accession number, an ISO 8601 string, a token); a
158
+ // `reference` holds identity. One shared `StringFilter` said neither, which
159
+ // left `where: { material: { in: ["boerenbont"] } }` valid, silent and empty.
160
+ const keywordFilter = new GraphQLInputObjectType({
161
+ name: 'KeywordFilter',
162
+ description: 'Matches a field holding literal values.',
96
163
  fields: {
97
164
  in: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) },
98
165
  },
99
166
  });
167
+ // The fallback for a reference whose referent is nameable as no type: a
168
+ // canonical vocabulary URI, a licence, a content URL. Its `in` element type is
169
+ // what the COARSE discovery strategy matches on – select every criterion field
170
+ // whose filter takes IRIs, and you have the complete reference-field set with
171
+ // no origin collection at all.
172
+ const iriFilter = new GraphQLInputObjectType({
173
+ name: 'IRIFilter',
174
+ description: 'Matches a field holding IRIs that belong to no collection this API serves.',
175
+ fields: { in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) } },
176
+ });
177
+ /**
178
+ * The filter input for IRIs of one named target, `‹TypeName›Filter` – created
179
+ * once per target and shared by every field pointing at it, INCLUDING the
180
+ * target’s own `id`. That sharing is the whole mechanism: a consumer resolves
181
+ * the collection it is browsing to its filter type through `‹Type›Where.id`,
182
+ * then selects the criterion fields of every other type whose filter is that
183
+ * same type. The name is COMPARED, never parsed – a generic consumer needs no
184
+ * more knowledge of `TermFilter` than it already needs of `terms`.
185
+ */
186
+ const targetFilters = new Map();
187
+ function targetFilter(typeName) {
188
+ const existing = targetFilters.get(typeName);
189
+ if (existing !== undefined) {
190
+ return existing;
191
+ }
192
+ const name = `${typeName}Filter`;
193
+ // graphql-js would reject the duplicate too, but only once the whole schema
194
+ // is assembled and without saying which declaration caused it. Checked
195
+ // against every name already claimed – the built-in filters, the `IRI`
196
+ // scalar, and every root/reference type – since a declaration is free to
197
+ // name a type `TermFilter` beside a root type `Term`.
198
+ if (name === keywordFilter.name ||
199
+ name === iriFilter.name ||
200
+ name === iriScalar.name ||
201
+ takenTypeNames.has(name)) {
202
+ throw new Error(`Type “${typeName}” would be filtered through “${name}”, which is already the name of another type; rename one of them.`);
203
+ }
204
+ takenTypeNames.add(name);
205
+ const filter = new GraphQLInputObjectType({
206
+ name,
207
+ description: `Matches a field holding IRIs of ${typeName}.`,
208
+ fields: { in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) } },
209
+ });
210
+ targetFilters.set(typeName, filter);
211
+ return filter;
212
+ }
100
213
  const intRange = rangeInput('IntRange', GraphQLInt);
101
214
  const floatRange = rangeInput('FloatRange', GraphQLFloat);
102
215
  const dateRange = rangeInput('DateRange', GraphQLString);
@@ -121,6 +234,19 @@ export function buildGraphQLSchema(schema, options = {}) {
121
234
  // and rejects duplicate names schema-wide.
122
235
  const referenceTypes = new Map();
123
236
  const takenTypeNames = new Set(rootTypeNames);
237
+ /** The label key each id-plus-label reference type was registered with; no
238
+ * entry for a surfaced inline one, which carries fields instead of a label. */
239
+ const labelKeys = new Map();
240
+ /** The key a reference serves its resolved label under: the name its label
241
+ * source declares that label field with, so the reference and the type it
242
+ * resolves against agree on the word. An id-only reference resolves no
243
+ * label, so it keeps the default. `searchSchema` guarantees the source is a
244
+ * declared Root Type. */
245
+ function labelKeyOf(field) {
246
+ return field.labelSource === undefined
247
+ ? DEFAULT_LABEL_FIELD
248
+ : labelFieldNameOf(rootTypesByName.get(field.labelSource));
249
+ }
124
250
  /**
125
251
  * Register the GraphQL type one reference field is served as, once per
126
252
  * referenced shape. A **surfaced inline** reference is served as the nested
@@ -133,7 +259,21 @@ export function buildGraphQLSchema(schema, options = {}) {
133
259
  function registerReferenceType(field, owner) {
134
260
  if (field.kind !== 'reference' ||
135
261
  field.ref === undefined ||
136
- referenceTypes.has(field.ref.typeName)) {
262
+ // An `idOnly` reference carries the IRI and nothing else, so it surfaces
263
+ // as the IRI itself rather than as an object – there is no type to
264
+ // register, and no name needed to register one under.
265
+ field.ref.strategy === 'idOnly' ||
266
+ field.ref.typeName === undefined) {
267
+ return;
268
+ }
269
+ if (referenceTypes.has(field.ref.typeName)) {
270
+ // Fields sharing a `ref.typeName` share one emitted type, so they must
271
+ // agree on the word it serves its label under – otherwise which one wins
272
+ // would come down to declaration order.
273
+ const registered = labelKeys.get(field.ref.typeName);
274
+ if (registered !== undefined && registered !== labelKeyOf(field)) {
275
+ throw new Error(`Reference “${owner.name}.${field.name}” serves its label as “${labelKeyOf(field)}”, but “${field.ref.typeName}” is already served with “${registered}”; fields sharing a reference type must resolve labels from sources that agree on their labelField.`);
276
+ }
137
277
  return;
138
278
  }
139
279
  const { typeName } = field.ref;
@@ -145,20 +285,25 @@ export function buildGraphQLSchema(schema, options = {}) {
145
285
  }
146
286
  takenTypeNames.add(graphQLName);
147
287
  const nested = nestedReferenceType(schema, field);
288
+ if (nested === undefined) {
289
+ labelKeys.set(typeName, labelKeyOf(field));
290
+ }
148
291
  referenceTypes.set(typeName, new GraphQLObjectType({
149
292
  name: graphQLName,
150
293
  // A thunk, so a Reference Type nesting another one resolves whatever
151
294
  // the registration order is (the graph is acyclic by searchSchema).
152
295
  fields: () => nested === undefined
153
296
  ? {
154
- id: { type: new GraphQLNonNull(GraphQLString) },
155
- // `label`, the same word the label source declares and a
156
- // reference facet’s bucket carries: one resolved label, one
157
- // name for it wherever it surfaces.
158
- label: labelList((source) => source.label),
297
+ id: { type: new GraphQLNonNull(iriScalar) },
298
+ // The same word the label source declares its label field
299
+ // under (`label` by default): one resolved label, one name for
300
+ // it wherever it surfaces.
301
+ [labelKeyOf(field)]: labelList((source) => source.label),
159
302
  }
160
303
  : {
161
- id: { type: GraphQLString },
304
+ // Nullable, and load-bearing: a referent needs no identity, so
305
+ // a blank-node one nests exactly like a named one, minus this.
306
+ id: { type: iriScalar },
162
307
  ...Object.fromEntries(outputFields(nested).map((nestedField) => [
163
308
  nestedField.name,
164
309
  outputFieldConfig(nestedField),
@@ -197,6 +342,30 @@ export function buildGraphQLSchema(schema, options = {}) {
197
342
  }
198
343
  : { type: scalarOutput(GraphQLString, field) };
199
344
  case 'reference': {
345
+ // `idOnly`: the IRI itself, not an object wrapping it – so a field whose
346
+ // referent this deployment does not describe (a vocabulary URI, a
347
+ // licence, a content URL) reads as the flat list of IRIs it is, while
348
+ // still declaring what it holds.
349
+ //
350
+ // The IR still carries a `Reference` here, as it does for every
351
+ // non-inline reference (see `SearchValue`): the strategy is a statement
352
+ // about the SURFACE, not about the port, so the flattening belongs on
353
+ // this side rather than in each adapter’s result reconstruction. A
354
+ // referent carrying no IRI drops out instead of nulling the field.
355
+ if (field.ref?.strategy === 'idOnly') {
356
+ const iriOf = (value) => value?.id;
357
+ return field.array === true
358
+ ? {
359
+ type: nonNullListOf(iriScalar),
360
+ resolve: (source) => (source[field.name] ?? [])
361
+ .map(iriOf)
362
+ .filter((iri) => iri !== undefined),
363
+ }
364
+ : {
365
+ type: scalarOutput(iriScalar, field),
366
+ resolve: (source) => iriOf(source[field.name]) ?? null,
367
+ };
368
+ }
200
369
  const referenceType = referenceTypes.get(field.ref?.typeName ?? '');
201
370
  return field.array === true
202
371
  ? {
@@ -231,10 +400,18 @@ export function buildGraphQLSchema(schema, options = {}) {
231
400
  };
232
401
  }
233
402
  }
403
+ /** The filter input one field accepts, fixed by what the field keys on: a
404
+ * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
405
+ * names no target), a `keyword` on literals. */
234
406
  function whereFieldType(field) {
235
407
  switch (filterOperatorFor(field.kind)) {
236
408
  case 'in':
237
- return stringFilter;
409
+ if (field.kind !== 'reference') {
410
+ return keywordFilter;
411
+ }
412
+ return field.ref?.typeName === undefined
413
+ ? iriFilter
414
+ : targetFilter(field.ref.typeName);
238
415
  case 'range':
239
416
  return field.kind === 'integer'
240
417
  ? intRange
@@ -252,7 +429,7 @@ export function buildGraphQLSchema(schema, options = {}) {
252
429
  name: typeName,
253
430
  fields: () => {
254
431
  const fields = {
255
- id: { type: new GraphQLNonNull(GraphQLString) },
432
+ id: { type: new GraphQLNonNull(iriScalar) },
256
433
  };
257
434
  for (const field of outputFields(searchType)) {
258
435
  fields[field.name] = outputFieldConfig(field);
@@ -274,7 +451,11 @@ export function buildGraphQLSchema(schema, options = {}) {
274
451
  */
275
452
  const fieldKeys = () => {
276
453
  const fields = {
277
- [ID_FIELD]: { type: stringFilter },
454
+ // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
455
+ // can resolve “the collection I am browsing” to “the filter type that
456
+ // accepts its IRIs”, which is what makes the refined discovery strategy
457
+ // work without hardcoding a single domain name.
458
+ [ID_FIELD]: { type: targetFilter(typeName) },
278
459
  };
279
460
  for (const field of filterable) {
280
461
  fields[field.name] = {
@@ -337,8 +518,8 @@ export function buildGraphQLSchema(schema, options = {}) {
337
518
  },
338
519
  });
339
520
  // Keyed facets object: one field per facetable field, typed by its kind
340
- // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!], else
341
- // [ValueBucket!]). Only the selected
521
+ // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!],
522
+ // reference fields → [IriBucket!], else [ValueBucket!]). Only the selected
342
523
  // fields are resolved (GraphQL prunes the rest), so the selection IS the
343
524
  // request; how they are computed – skip-own-filter, batched into one
344
525
  // engine dispatch – lives in facet-batch.ts.
@@ -394,11 +575,16 @@ export function buildGraphQLSchema(schema, options = {}) {
394
575
  };
395
576
  }
396
577
  /** The bucket type a facet’s kind earns: bins for a range facet, a real
397
- * boolean for a boolean one, a keyed value otherwise. */
578
+ * boolean for a boolean one, an `IRI` key for a reference, a literal key
579
+ * otherwise. The reference test is the one {@link whereFieldType} applies, so
580
+ * a bucket and the filter it feeds cannot disagree about what the field keys
581
+ * on. */
398
582
  function bucketTypeFor(field) {
399
583
  if (isRangeFacet(field))
400
584
  return rangeBucket;
401
- return field.kind === 'boolean' ? booleanBucket : valueBucket;
585
+ if (field.kind === 'boolean')
586
+ return booleanBucket;
587
+ return field.kind === 'reference' ? iriBucket : valueBucket;
402
588
  }
403
589
  /** The keyed facets object for one type (only called with ≥ 1 facetable field). */
404
590
  function facetsTypeFor(typeName, facetable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "description": "Engine- and domain-agnostic GraphQL surface for @lde/search: builds an executable GraphQLSchema from a whole SearchSchema at runtime (no codegen) – one root query field per SearchType – served by generic resolvers over any SearchEngine – and serves it as a framework-agnostic fetch handler with a self-contained playground, SDL endpoint, CORS and depth/cost limits. You supply the schema and per-type typeNames; it names neither your domain nor your engine.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/ldelements/lde.git",
@@ -37,7 +37,7 @@
37
37
  "@escape.tech/graphql-armor-cost-limit": "^2.4.3",
38
38
  "@escape.tech/graphql-armor-max-depth": "^2.4.2",
39
39
  "@graphql-yoga/render-graphiql": "^5.21.2",
40
- "@lde/search": "^0.18.2",
40
+ "@lde/search": "^0.19.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",