@lde/search-api-graphql 0.1.0 → 0.2.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/README.md CHANGED
@@ -84,10 +84,16 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
84
84
  `FloatRange` / `DateRange`, or `Boolean`); omitted entirely for a type with no
85
85
  filterable fields.
86
86
  - **`orderBy`**: `RELEVANCE` plus every `sortable` field, as an enum.
87
- - **Facets**: an enum of every `facetable` field; a bucket carries `value` +
88
- `count` + a nullable `label` the resolved data label for **reference** facets,
89
- `null` for token/free-string facets whose display the consumer owns (its own
90
- i18n, or the value itself).
87
+ - **Facets**: a keyed object with one field per `facetable` field; a bucket
88
+ carries `value` + `count` + a nullable `label` the resolved data label for
89
+ **reference** facets, `null` for token/free-string facets whose display the
90
+ consumer owns (its own i18n, or the value itself). Selecting facet fields IS
91
+ the request: each selected facet is computed with its own `where`-filter
92
+ removed (skip-own-filter), and the whole selection is **batched per
93
+ request** – facets whose field carries no active filter share one query
94
+ (the unfiltered browse collapses to a single query) and everything is
95
+ dispatched as one `engine.searchFacets` call, so a typical page costs the
96
+ listing search plus one batched facet round-trip.
91
97
 
92
98
  ## Guarding the contract
93
99
 
@@ -10,9 +10,11 @@ export interface SearchContext {
10
10
  /** Parsed, ordered `Accept-Language`; drives locale selection and output order. */
11
11
  readonly acceptLanguage: readonly string[];
12
12
  /**
13
- * Called when a single facet's computation fails. The facet degrades to an
14
- * empty list (a supplementary facet must not fail the whole query); supply
15
- * this to log the cause. Optional omit to swallow silently.
13
+ * Called once per affected facet field when its computation fails for a
14
+ * failed facet query only that query's fields, for a failed batch dispatch
15
+ * every selected field. The affected facets degrade to empty lists (a
16
+ * supplementary facet must not fail the whole query); supply this to log
17
+ * the cause. Optional – omit to swallow silently.
16
18
  */
17
19
  readonly onFacetError?: (field: string, error: unknown) => void;
18
20
  }
@@ -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,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAEvB,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;;;;OAIG;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,CAiYf;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,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,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,CAwWf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
@@ -1,6 +1,7 @@
1
1
  import { GraphQLBoolean, GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
2
  import { facetableFields, filterableFields, filterOperatorFor, isRangeFacet, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
3
3
  import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
4
+ import { createFacetLoader } from './facet-batch.js';
4
5
  const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
5
6
  const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNonNull(scalar) : scalar;
6
7
  /** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
@@ -222,16 +223,14 @@ export function buildGraphQLSchema(schema, options = {}) {
222
223
  },
223
224
  });
224
225
  // Keyed facets object: one field per facetable field, typed by its kind
225
- // (range fields → [RangeBucket!], else [ValueBucket!]). Each field's resolver
226
- // computes that facet with its OWN where-filter removed (skip-own-filter), so a
227
- // multi-select facet still lists its other options; only the selected fields
228
- // are resolved (GraphQL prunes the rest), so the selection IS the request.
226
+ // (range fields → [RangeBucket!], else [ValueBucket!]). Only the selected
227
+ // fields are resolved (GraphQL prunes the rest), so the selection IS the
228
+ // request; how they are computed skip-own-filter, batched into one
229
+ // engine dispatch lives in facet-batch.ts.
229
230
  // Like `where`, omitted entirely for a type with no facetable fields (a
230
231
  // GraphQL object type must have at least one field).
231
232
  const facetable = facetableFields(searchType);
232
- const facetsType = facetable.length === 0
233
- ? undefined
234
- : facetsTypeFor(searchType, typeName, facetable);
233
+ const facetsType = facetable.length === 0 ? undefined : facetsTypeFor(typeName, facetable);
235
234
  const resultType = new GraphQLObjectType({
236
235
  name: `${typeName}SearchResult`,
237
236
  fields: {
@@ -240,7 +239,7 @@ export function buildGraphQLSchema(schema, options = {}) {
240
239
  page: { type: new GraphQLNonNull(GraphQLInt) },
241
240
  perPage: { type: new GraphQLNonNull(GraphQLInt) },
242
241
  // Resolved lazily, per selected key (skip-own-filter); the result object
243
- // (which carries the resolved `query`) is the facets source.
242
+ // (which carries the per-request facet loader) is the facets source.
244
243
  ...(facetsType && {
245
244
  facets: {
246
245
  type: new GraphQLNonNull(facetsType),
@@ -273,14 +272,14 @@ export function buildGraphQLSchema(schema, options = {}) {
273
272
  total: result.total,
274
273
  page: pageForOffset(finalQuery.offset, finalQuery.limit),
275
274
  perPage: finalQuery.limit,
276
- // Carried for the facets resolver (skip-own-filter per key).
277
- query: finalQuery,
275
+ // Carried for the facet field resolvers (see facet-batch.ts).
276
+ loadFacet: createFacetLoader(context.engine, searchType, finalQuery, context.onFacetError),
278
277
  };
279
278
  },
280
279
  };
281
280
  }
282
281
  /** The keyed facets object for one type (only called with ≥ 1 facetable field). */
283
- function facetsTypeFor(searchType, typeName, facetable) {
282
+ function facetsTypeFor(typeName, facetable) {
284
283
  return new GraphQLObjectType({
285
284
  name: `${typeName}Facets`,
286
285
  fields: () => {
@@ -288,30 +287,10 @@ export function buildGraphQLSchema(schema, options = {}) {
288
287
  for (const field of facetable) {
289
288
  fields[field.name] = {
290
289
  type: nonNullListOf(isRangeFacet(field) ? rangeBucket : valueBucket),
291
- resolve: async (source, _args, context) => {
292
- const query = source.query;
293
- // Drop this facet's own filter so its other options still count
294
- // (a removed `status` filter also drops the valid-only default, so
295
- // the status facet counts across every status).
296
- const facetQuery = {
297
- ...query,
298
- where: query.where.filter((filter) => filter.field !== field.name),
299
- facets: [field.name],
300
- limit: 0,
301
- offset: 0,
302
- };
303
- // A facet is supplementary: degrade a failed facet to an empty list
304
- // rather than failing the whole query (which would null the non-null
305
- // result and discard the items + every other facet).
306
- try {
307
- const result = await context.engine.search(searchType, facetQuery);
308
- return result.facets[field.name] ?? [];
309
- }
310
- catch (error) {
311
- context.onFacetError?.(field.name, error);
312
- return [];
313
- }
314
- },
290
+ // The skip-own-filter query building, the batching into one
291
+ // engine dispatch and the degrade-to-[] error handling all live
292
+ // in the loader (facet-batch.ts).
293
+ resolve: (source) => source.loadFacet(field.name),
315
294
  };
316
295
  }
317
296
  return fields;
@@ -0,0 +1,33 @@
1
+ import type { FacetBucket, SearchEngine, SearchQuery, SearchType } from '@lde/search';
2
+ /** Resolves one selected facet field to its buckets; see {@link createFacetLoader}. */
3
+ export type FacetLoader = (field: string) => Promise<readonly FacetBucket[]>;
4
+ /**
5
+ * A per-request batcher behind the keyed facets object. Each selected facet
6
+ * field’s resolver calls the loader; GraphQL resolves the sibling facet
7
+ * fields synchronously, so the loads land in the same tick, where the
8
+ * DataLoader collects them into one batch, which is grouped into the fewest
9
+ * equivalent queries ({@link groupFacetQueries}) and dispatched as ONE
10
+ * `engine.searchFacets` call – one engine round-trip for the whole sidebar
11
+ * instead of one search per facet.
12
+ *
13
+ * A facet is supplementary: a failed facet query degrades exactly its own
14
+ * facets to empty lists – reported per field via `onFacetError` – while its
15
+ * siblings keep their buckets; only a batch-level failure (the dispatch
16
+ * itself rejecting) degrades every facet. Neither fails the whole GraphQL
17
+ * query, which would null the non-null result and discard the items.
18
+ */
19
+ export declare function createFacetLoader(engine: SearchEngine, searchType: SearchType, query: SearchQuery, onFacetError?: (field: string, error: unknown) => void): FacetLoader;
20
+ /**
21
+ * Group the selected facet fields into the fewest facet-only queries with
22
+ * unchanged skip-own-filter semantics. Each facet counts with its OWN
23
+ * `where`-filter removed, so removal only matters for a facet whose field is
24
+ * actively filtered: every facet whose field carries no filter shares the
25
+ * untouched `where` – one query faceting all of them (the unfiltered browse
26
+ * collapses to a single query) – while each own-filtered facet gets its own
27
+ * query with its own effective `where`. (Dropping a facet’s filter also drops
28
+ * a policy default on that field, e.g. valid-only `status`, so the facet
29
+ * counts across every value.) The queries are facet-only: no hits (`limit:
30
+ * 0`) and, with no hits to order, no `orderBy`.
31
+ */
32
+ export declare function groupFacetQueries(query: SearchQuery, fields: readonly string[]): SearchQuery[];
33
+ //# sourceMappingURL=facet-batch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACX,MAAM,aAAa,CAAC;AAErB,uFAAuF;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;AAE7E;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,UAAU,EACtB,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,GACrD,WAAW,CAoCb;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,WAAW,EAAE,CAgBf"}
@@ -0,0 +1,80 @@
1
+ import DataLoader from 'dataloader';
2
+ /**
3
+ * A per-request batcher behind the keyed facets object. Each selected facet
4
+ * field’s resolver calls the loader; GraphQL resolves the sibling facet
5
+ * fields synchronously, so the loads land in the same tick, where the
6
+ * DataLoader collects them into one batch, which is grouped into the fewest
7
+ * equivalent queries ({@link groupFacetQueries}) and dispatched as ONE
8
+ * `engine.searchFacets` call – one engine round-trip for the whole sidebar
9
+ * instead of one search per facet.
10
+ *
11
+ * A facet is supplementary: a failed facet query degrades exactly its own
12
+ * facets to empty lists – reported per field via `onFacetError` – while its
13
+ * siblings keep their buckets; only a batch-level failure (the dispatch
14
+ * itself rejecting) degrades every facet. Neither fails the whole GraphQL
15
+ * query, which would null the non-null result and discard the items.
16
+ */
17
+ export function createFacetLoader(engine, searchType, query, onFacetError) {
18
+ const loader = new DataLoader(async (fields) => {
19
+ const queries = groupFacetQueries(query, fields);
20
+ const buckets = new Map();
21
+ try {
22
+ const outcomes = await engine.searchFacets(searchType, queries);
23
+ queries.forEach((facetQuery, index) => {
24
+ const outcome = outcomes[index];
25
+ if (outcome !== undefined && !('error' in outcome)) {
26
+ for (const field of facetQuery.facets) {
27
+ buckets.set(field, outcome.facets[field] ?? []);
28
+ }
29
+ return;
30
+ }
31
+ // A failed (or missing – a port-contract breach) outcome degrades
32
+ // exactly this query's facets; its siblings keep theirs.
33
+ const error = outcome === undefined
34
+ ? new Error('The engine returned no outcome for this query.')
35
+ : outcome.error;
36
+ for (const field of facetQuery.facets) {
37
+ onFacetError?.(field, error);
38
+ }
39
+ });
40
+ }
41
+ catch (error) {
42
+ // A batch-level failure leaves `buckets` empty: every facet in the
43
+ // batch degrades to [].
44
+ for (const field of fields) {
45
+ onFacetError?.(field, error);
46
+ }
47
+ }
48
+ return fields.map((field) => buckets.get(field) ?? []);
49
+ });
50
+ return (field) => loader.load(field);
51
+ }
52
+ /**
53
+ * Group the selected facet fields into the fewest facet-only queries with
54
+ * unchanged skip-own-filter semantics. Each facet counts with its OWN
55
+ * `where`-filter removed, so removal only matters for a facet whose field is
56
+ * actively filtered: every facet whose field carries no filter shares the
57
+ * untouched `where` – one query faceting all of them (the unfiltered browse
58
+ * collapses to a single query) – while each own-filtered facet gets its own
59
+ * query with its own effective `where`. (Dropping a facet’s filter also drops
60
+ * a policy default on that field, e.g. valid-only `status`, so the facet
61
+ * counts across every value.) The queries are facet-only: no hits (`limit:
62
+ * 0`) and, with no hits to order, no `orderBy`.
63
+ */
64
+ export function groupFacetQueries(query, fields) {
65
+ const filteredFields = new Set(query.where.map((filter) => filter.field));
66
+ const facetOnly = { ...query, orderBy: [], limit: 0, offset: 0 };
67
+ const sharedFields = fields.filter((field) => !filteredFields.has(field));
68
+ const queries = [];
69
+ if (sharedFields.length > 0) {
70
+ queries.push({ ...facetOnly, facets: sharedFields });
71
+ }
72
+ for (const field of fields.filter((field) => filteredFields.has(field))) {
73
+ queries.push({
74
+ ...facetOnly,
75
+ where: query.where.filter((filter) => filter.field !== field),
76
+ facets: [field],
77
+ });
78
+ }
79
+ return queries;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.1.0",
3
+ "version": "0.2.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. 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",
@@ -25,7 +25,8 @@
25
25
  "!**/*.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@lde/search": "^0.2.0",
28
+ "@lde/search": "^0.3.0",
29
+ "dataloader": "^2.2.3",
29
30
  "graphql": "^15.8.0",
30
31
  "tslib": "^2.3.0"
31
32
  }