@lde/search-api-graphql 0.20.3 → 0.21.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.
@@ -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;AAkBrB,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,CA6gBf;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;AAqGD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CA4pBf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
@@ -1,9 +1,79 @@
1
- import { GraphQLBoolean, GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
- import { AND_KEY, DEFAULT_LABEL_FIELD, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, OR_KEY, isRangeFacet, labelFieldNameOf, 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 userError(`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 – and it carries no `BAD_USER_INPUT`,
50
+ * since there is nothing the query could have done differently. */
51
+ function assertIriOut(value) {
52
+ if (!isAbsoluteIri(value)) {
53
+ 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.`);
54
+ }
55
+ return value;
56
+ }
57
+ /**
58
+ * An error the CALLER can fix, marked as such: a `GraphQLError` carrying
59
+ * `extensions.code = 'BAD_USER_INPUT'`.
60
+ *
61
+ * Both halves matter, and both are about the consumer rather than about us. A
62
+ * plain `Error` thrown from a resolver is a server fault as far as the
63
+ * transport is concerned, so graphql-yoga masks it to `“Unexpected error.”`;
64
+ * the message then survives only in the API container’s log, where a
65
+ * presentation-layer developer building against a hosted endpoint cannot read
66
+ * it. Throwing a `GraphQLError` keeps the sentence that says what was wrong.
67
+ * The `code` says whose fault it is, so a client can distinguish “fix your
68
+ * query” from “retry later” without matching on prose – the convention every
69
+ * major GraphQL server shares. `“Unexpected error.”` is left for what the name
70
+ * says: faults we did not anticipate.
71
+ */
72
+ function userError(message) {
73
+ return new GraphQLError(message, {
74
+ extensions: { code: 'BAD_USER_INPUT' },
75
+ });
76
+ }
7
77
  /** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
8
78
  function screamingSnake(name) {
9
79
  return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
@@ -35,25 +105,37 @@ export function buildGraphQLSchema(schema, options = {}) {
35
105
  value: { type: new GraphQLNonNull(GraphQLString) },
36
106
  },
37
107
  });
38
- // A plain value facet bucket: a selection key, its count, and (for reference
39
- // facets) the engine-resolved data label; null for token/free-string facets
40
- // whose display the consumer owns.
41
- const valueBucket = new GraphQLObjectType({
42
- name: 'ValueBucket',
43
- fields: {
44
- value: { type: new GraphQLNonNull(GraphQLString) },
45
- count: { type: new GraphQLNonNull(GraphQLInt) },
46
- label: {
47
- type: new GraphQLList(new GraphQLNonNull(languageString)),
48
- resolve: (bucket, _args, context) => {
49
- const label = bucket.label;
50
- return label
51
- ? toLanguageStrings(label, context.acceptLanguage, languageOrder)
52
- : null;
53
- },
108
+ // A value facet bucket: a selection key, its count, and (for reference facets)
109
+ // the engine-resolved data label; null for token/free-string facets whose
110
+ // display the consumer owns. Only the key’s type varies between the two – a
111
+ // facet keys on whatever its field keys on – so both are built from here and
112
+ // cannot drift apart.
113
+ const valueBucketFields = (keyType) => ({
114
+ value: { type: new GraphQLNonNull(keyType) },
115
+ count: { type: new GraphQLNonNull(GraphQLInt) },
116
+ label: {
117
+ type: new GraphQLList(new GraphQLNonNull(languageString)),
118
+ resolve: (bucket, _args, context) => {
119
+ const label = bucket.label;
120
+ return label
121
+ ? toLanguageStrings(label, context.acceptLanguage, languageOrder)
122
+ : null;
54
123
  },
55
124
  },
56
125
  });
126
+ const valueBucket = new GraphQLObjectType({
127
+ name: 'ValueBucket',
128
+ fields: valueBucketFields(GraphQLString),
129
+ });
130
+ // A reference facet’s bucket, whose `value` is an `IRI` because the field it
131
+ // buckets keys on identity. This is what makes the round trip typed end to
132
+ // end: the bucket a consumer selects feeds straight into the ‹Target›Filter
133
+ // that selects it, with no `String`-to-`IRI` boundary in between – the
134
+ // contract BooleanBucket already keeps for `is`.
135
+ const iriBucket = new GraphQLObjectType({
136
+ name: 'IriBucket',
137
+ fields: valueBucketFields(iriScalar),
138
+ });
57
139
  // A numeric range-facet bin: half-open `[min, max)` bounds (max null on an
58
140
  // open-ended top bin) and the count of documents in it.
59
141
  const rangeBucket = new GraphQLObjectType({
@@ -92,12 +174,63 @@ export function buildGraphQLSchema(schema, options = {}) {
92
174
  name: 'SortDirection',
93
175
  values: { ASC: { value: 'asc' }, DESC: { value: 'desc' } },
94
176
  });
95
- const stringFilter = new GraphQLInputObjectType({
96
- name: 'StringFilter',
177
+ // Membership filters, split by what the field keys on. A `keyword` field
178
+ // holds literals (an accession number, an ISO 8601 string, a token); a
179
+ // `reference` holds identity. One shared `StringFilter` said neither, which
180
+ // left `where: { material: { in: ["boerenbont"] } }` valid, silent and empty.
181
+ const keywordFilter = new GraphQLInputObjectType({
182
+ name: 'KeywordFilter',
183
+ description: 'Matches a field holding literal values.',
97
184
  fields: {
98
185
  in: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) },
99
186
  },
100
187
  });
188
+ // The fallback for a reference whose referent is nameable as no type: a
189
+ // canonical vocabulary URI, a licence, a content URL. Its `in` element type is
190
+ // what the COARSE discovery strategy matches on – select every criterion field
191
+ // whose filter takes IRIs, and you have the complete reference-field set with
192
+ // no origin collection at all.
193
+ const iriFilter = new GraphQLInputObjectType({
194
+ name: 'IRIFilter',
195
+ description: 'Matches a field holding IRIs that belong to no collection this API serves.',
196
+ fields: { in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) } },
197
+ });
198
+ /**
199
+ * The filter input for IRIs of one named target, `‹TypeName›Filter` – created
200
+ * once per target and shared by every field pointing at it, INCLUDING the
201
+ * target’s own `id`. That sharing is the whole mechanism: a consumer resolves
202
+ * the collection it is browsing to its filter type through `‹Type›Where.id`,
203
+ * then selects the criterion fields of every other type whose filter is that
204
+ * same type. The name is COMPARED, never parsed – a generic consumer needs no
205
+ * more knowledge of `TermFilter` than it already needs of `terms`.
206
+ */
207
+ const targetFilters = new Map();
208
+ function targetFilter(typeName) {
209
+ const existing = targetFilters.get(typeName);
210
+ if (existing !== undefined) {
211
+ return existing;
212
+ }
213
+ const name = `${typeName}Filter`;
214
+ // graphql-js would reject the duplicate too, but only once the whole schema
215
+ // is assembled and without saying which declaration caused it. Checked
216
+ // against every name already claimed – the built-in filters, the `IRI`
217
+ // scalar, and every root/reference type – since a declaration is free to
218
+ // name a type `TermFilter` beside a root type `Term`.
219
+ if (name === keywordFilter.name ||
220
+ name === iriFilter.name ||
221
+ name === iriScalar.name ||
222
+ takenTypeNames.has(name)) {
223
+ throw new Error(`Type “${typeName}” would be filtered through “${name}”, which is already the name of another type; rename one of them.`);
224
+ }
225
+ takenTypeNames.add(name);
226
+ const filter = new GraphQLInputObjectType({
227
+ name,
228
+ description: `Matches a field holding IRIs of ${typeName}.`,
229
+ fields: { in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) } },
230
+ });
231
+ targetFilters.set(typeName, filter);
232
+ return filter;
233
+ }
101
234
  const intRange = rangeInput('IntRange', GraphQLInt);
102
235
  const floatRange = rangeInput('FloatRange', GraphQLFloat);
103
236
  const dateRange = rangeInput('DateRange', GraphQLString);
@@ -145,7 +278,13 @@ export function buildGraphQLSchema(schema, options = {}) {
145
278
  * stays the id-plus-label pair its strategy carries.
146
279
  */
147
280
  function registerReferenceType(field, owner) {
148
- if (field.kind !== 'reference' || field.ref === undefined) {
281
+ if (field.kind !== 'reference' ||
282
+ field.ref === undefined ||
283
+ // An `idOnly` reference carries the IRI and nothing else, so it surfaces
284
+ // as the IRI itself rather than as an object – there is no type to
285
+ // register, and no name needed to register one under.
286
+ field.ref.strategy === 'idOnly' ||
287
+ field.ref.typeName === undefined) {
149
288
  return;
150
289
  }
151
290
  if (referenceTypes.has(field.ref.typeName)) {
@@ -176,14 +315,16 @@ export function buildGraphQLSchema(schema, options = {}) {
176
315
  // the registration order is (the graph is acyclic by searchSchema).
177
316
  fields: () => nested === undefined
178
317
  ? {
318
+ id: { type: new GraphQLNonNull(iriScalar) },
179
319
  // The same word the label source declares its label field
180
320
  // under (`label` by default): one resolved label, one name for
181
321
  // it wherever it surfaces.
182
- id: { type: new GraphQLNonNull(GraphQLString) },
183
322
  [labelKeyOf(field)]: labelList((source) => source.label),
184
323
  }
185
324
  : {
186
- id: { type: GraphQLString },
325
+ // Nullable, and load-bearing: a referent needs no identity, so
326
+ // a blank-node one nests exactly like a named one, minus this.
327
+ id: { type: iriScalar },
187
328
  ...Object.fromEntries(outputFields(nested).map((nestedField) => [
188
329
  nestedField.name,
189
330
  outputFieldConfig(nestedField),
@@ -222,6 +363,30 @@ export function buildGraphQLSchema(schema, options = {}) {
222
363
  }
223
364
  : { type: scalarOutput(GraphQLString, field) };
224
365
  case 'reference': {
366
+ // `idOnly`: the IRI itself, not an object wrapping it – so a field whose
367
+ // referent this deployment does not describe (a vocabulary URI, a
368
+ // licence, a content URL) reads as the flat list of IRIs it is, while
369
+ // still declaring what it holds.
370
+ //
371
+ // The IR still carries a `Reference` here, as it does for every
372
+ // non-inline reference (see `SearchValue`): the strategy is a statement
373
+ // about the SURFACE, not about the port, so the flattening belongs on
374
+ // this side rather than in each adapter’s result reconstruction. A
375
+ // referent carrying no IRI drops out instead of nulling the field.
376
+ if (field.ref?.strategy === 'idOnly') {
377
+ const iriOf = (value) => value?.id;
378
+ return field.array === true
379
+ ? {
380
+ type: nonNullListOf(iriScalar),
381
+ resolve: (source) => (source[field.name] ?? [])
382
+ .map(iriOf)
383
+ .filter((iri) => iri !== undefined),
384
+ }
385
+ : {
386
+ type: scalarOutput(iriScalar, field),
387
+ resolve: (source) => iriOf(source[field.name]) ?? null,
388
+ };
389
+ }
225
390
  const referenceType = referenceTypes.get(field.ref?.typeName ?? '');
226
391
  return field.array === true
227
392
  ? {
@@ -256,10 +421,18 @@ export function buildGraphQLSchema(schema, options = {}) {
256
421
  };
257
422
  }
258
423
  }
424
+ /** The filter input one field accepts, fixed by what the field keys on: a
425
+ * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
426
+ * names no target), a `keyword` on literals. */
259
427
  function whereFieldType(field) {
260
428
  switch (filterOperatorFor(field.kind)) {
261
429
  case 'in':
262
- return stringFilter;
430
+ if (field.kind !== 'reference') {
431
+ return keywordFilter;
432
+ }
433
+ return field.ref?.typeName === undefined
434
+ ? iriFilter
435
+ : targetFilter(field.ref.typeName);
263
436
  case 'range':
264
437
  return field.kind === 'integer'
265
438
  ? intRange
@@ -277,7 +450,7 @@ export function buildGraphQLSchema(schema, options = {}) {
277
450
  name: typeName,
278
451
  fields: () => {
279
452
  const fields = {
280
- id: { type: new GraphQLNonNull(GraphQLString) },
453
+ id: { type: new GraphQLNonNull(iriScalar) },
281
454
  };
282
455
  for (const field of outputFields(searchType)) {
283
456
  fields[field.name] = outputFieldConfig(field);
@@ -299,7 +472,11 @@ export function buildGraphQLSchema(schema, options = {}) {
299
472
  */
300
473
  const fieldKeys = () => {
301
474
  const fields = {
302
- [ID_FIELD]: { type: stringFilter },
475
+ // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
476
+ // can resolve “the collection I am browsing” to “the filter type that
477
+ // accepts its IRIs”, which is what makes the refined discovery strategy
478
+ // work without hardcoding a single domain name.
479
+ [ID_FIELD]: { type: targetFilter(typeName) },
303
480
  };
304
481
  for (const field of filterable) {
305
482
  fields[field.name] = {
@@ -362,8 +539,8 @@ export function buildGraphQLSchema(schema, options = {}) {
362
539
  },
363
540
  });
364
541
  // Keyed facets object: one field per facetable field, typed by its kind
365
- // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!], else
366
- // [ValueBucket!]). Only the selected
542
+ // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!],
543
+ // reference fields → [IriBucket!], else [ValueBucket!]). Only the selected
367
544
  // fields are resolved (GraphQL prunes the rest), so the selection IS the
368
545
  // request; how they are computed – skip-own-filter, batched into one
369
546
  // engine dispatch – lives in facet-batch.ts.
@@ -389,11 +566,31 @@ export function buildGraphQLSchema(schema, options = {}) {
389
566
  return {
390
567
  type: new GraphQLNonNull(resultType),
391
568
  args: {
392
- query: { type: GraphQLString },
393
- where: { type: whereInput },
394
- orderBy: { type: orderByInput },
395
- page: { type: GraphQLInt, defaultValue: 1 },
396
- perPage: { type: GraphQLInt, defaultValue: 20 },
569
+ query: {
570
+ type: GraphQLString,
571
+ description: 'Free-text query. Omit it to browse by filter alone.',
572
+ },
573
+ where: {
574
+ type: whereInput,
575
+ description: 'Conditions every result must satisfy.',
576
+ },
577
+ orderBy: {
578
+ type: orderByInput,
579
+ description: 'Sort order. Defaults to relevance for a free-text query.',
580
+ },
581
+ page: {
582
+ type: GraphQLInt,
583
+ defaultValue: 1,
584
+ // The bound in the SDL, so the playground’s own documentation
585
+ // answers “how large may a page be?” before a request has to fail
586
+ // to say it.
587
+ description: '1-based page number; at least 1.',
588
+ },
589
+ perPage: {
590
+ type: GraphQLInt,
591
+ defaultValue: 20,
592
+ description: `Results per page, between 0 and ${maxPerPage}. Use 0 for a facet-only query: no results are fetched, and the facet counts still come back.`,
593
+ },
397
594
  },
398
595
  resolve: async (_source, args, context) => {
399
596
  const built = argsToQuery(args, context, searchType, maxPerPage);
@@ -419,11 +616,16 @@ export function buildGraphQLSchema(schema, options = {}) {
419
616
  };
420
617
  }
421
618
  /** The bucket type a facet’s kind earns: bins for a range facet, a real
422
- * boolean for a boolean one, a keyed value otherwise. */
619
+ * boolean for a boolean one, an `IRI` key for a reference, a literal key
620
+ * otherwise. The reference test is the one {@link whereFieldType} applies, so
621
+ * a bucket and the filter it feeds cannot disagree about what the field keys
622
+ * on. */
423
623
  function bucketTypeFor(field) {
424
624
  if (isRangeFacet(field))
425
625
  return rangeBucket;
426
- return field.kind === 'boolean' ? booleanBucket : valueBucket;
626
+ if (field.kind === 'boolean')
627
+ return booleanBucket;
628
+ return field.kind === 'reference' ? iriBucket : valueBucket;
427
629
  }
428
630
  /** The keyed facets object for one type (only called with ≥ 1 facetable field). */
429
631
  function facetsTypeFor(typeName, facetable) {
@@ -475,11 +677,11 @@ function argsToQuery(args, context, searchType, maxPerPage) {
475
677
  const perPage = args.perPage ?? 20;
476
678
  const page = args.page ?? 1;
477
679
  if (page < 1) {
478
- throw new Error(`page must be at least 1; got ${page}.`);
680
+ throw userError(`page must be at least 1; got ${page}.`);
479
681
  }
480
682
  // perPage: 0 is a legitimate facet-only query (no hits, page pins to 1).
481
683
  if (perPage < 0 || perPage > maxPerPage) {
482
- throw new Error(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
684
+ throw userError(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
483
685
  }
484
686
  return {
485
687
  text: args.query,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.20.3",
3
+ "version": "0.21.1",
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.3",
40
+ "@lde/search": "^0.19.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",