@lde/search-api-graphql 0.1.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 ADDED
@@ -0,0 +1,115 @@
1
+ # @lde/search-api-graphql
2
+
3
+ The GraphQL surface for the [`@lde/search`](../search) core. **Both engine- and
4
+ domain-agnostic:** it builds an executable
5
+ [graphql-js](https://graphql.org/graphql-js/) `GraphQLSchema` from your whole
6
+ [`SearchSchema`](../search/README.md#terminology) at runtime — one root query
7
+ field per `SearchType`, each searchable in its own way. All root fields are
8
+ served by the same resolver implementation (no per-type code, no codegen);
9
+ each root field gets its own instance of it, bound to that field’s
10
+ `SearchType`, over any `SearchEngine`. It names neither your **domain** (each type’s GraphQL name
11
+ is the `SearchType`’s own logical `name` — `Dataset`, `Person`, `CreativeWork`,
12
+ …) nor your **engine** (the resolver calls the schema-bound `context.engine`, be it
13
+ [`@lde/search-typesense`](../search-typesense) or another adapter).
14
+
15
+ ## Runtime configuration, not codegen
16
+
17
+ `buildGraphQLSchema(schema)` constructs the GraphQL schema once at startup from
18
+ the field model — no SDL artifact, no generated resolver stubs. For you that
19
+ means: no codegen step in the build, no generated files to commit and review,
20
+ and no stale artifact that can drift from the declaration — change the
21
+ `SearchType`, restart, and the API is current. (The flip side, no artifact
22
+ showing contract changes as diffs, is restored by the
23
+ [snapshot guard](#guarding-the-contract).) The field model
24
+ is the single source; the GraphQL contract is derived from it. Type names
25
+ come from each `SearchType`’s `name`; output types, the `where`/`orderBy`/facet
26
+ inputs, reference types and nullability are all derived from each field’s
27
+ `kind` and capability flags. The common case needs no options at all:
28
+
29
+ ```ts
30
+ import { searchSchema } from '@lde/search';
31
+ import { buildGraphQLSchema } from '@lde/search-api-graphql';
32
+
33
+ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
34
+
35
+ // The API now serves `datasets(…)` and `persons(…)` root fields.
36
+ // Hand `gqlSchema` to any graphql-js server; populate the per-request context:
37
+ // { engine: SearchEngine, acceptLanguage: string[] }
38
+ ```
39
+
40
+ Per-type options are pure fine-tuning, only for the types that need it: a
41
+ `queryField` when the default root field (the lowercased plural of the type’s
42
+ `name`) is wrong, and a `queryDefaults` policy applied to every query of that
43
+ type:
44
+
45
+ ```ts
46
+ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON), {
47
+ types: {
48
+ Dataset: {
49
+ queryDefaults: (query) => ({
50
+ ...query,
51
+ where: [...query.where, { field: 'status', in: ['valid'] }],
52
+ }),
53
+ },
54
+ Person: { queryField: 'people' },
55
+ },
56
+ });
57
+ ```
58
+
59
+ Shared types (`LanguageString`, the facet buckets, filter inputs and reference
60
+ types such as a common `Agent`) are created once and reused across root types.
61
+
62
+ ## Serving a subset of the schema
63
+
64
+ `types` never filters: every `SearchType` in the schema you pass gets a root
65
+ field (options for a type not in the schema are a build-time error). To expose
66
+ only part of what you index, narrow the **schema argument**
67
+ (`searchSchema(…)` is a cheap constructor):
68
+
69
+ ```ts
70
+ // Index all three types…
71
+ projectGraph(quads, searchSchema(DATASET, PERSON, INTERNAL));
72
+
73
+ // …but serve only two.
74
+ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
75
+ ```
76
+
77
+ ## What it builds (per root type)
78
+
79
+ - **Output type** (the `SearchType`’s `name`): localized text → best-first `[LanguageString!]!`
80
+ (`[0].language` is the language actually served); references → named per-shape
81
+ types (`Organization`, `Term`) with a `name`; scalars/booleans per kind; `date`
82
+ → ISO 8601 string; nullability from `required` / `array` / `kind`.
83
+ - **`where`** one input per `filterable` field (`StringFilter`, `IntRange` /
84
+ `FloatRange` / `DateRange`, or `Boolean`); omitted entirely for a type with no
85
+ filterable fields.
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).
91
+
92
+ ## Guarding the contract
93
+
94
+ Why the API, the index and a future REST surface cannot drift apart is the
95
+ search family’s overall approach — one field model, one query IR — described
96
+ in [`@lde/search`](../search/README.md). Specific to this surface: the GraphQL
97
+ contract is **frozen** (breaking to change), yet generated rather than
98
+ handwritten, so nothing in the repo shows a contract change as a reviewable
99
+ diff. A _consumer_ restores that with one snapshot test over its **own**
100
+ search schema:
101
+
102
+ ```ts
103
+ import { printGraphQLSchema } from '@lde/search-api-graphql';
104
+
105
+ it('keeps the public GraphQL contract stable', () => {
106
+ expect(printGraphQLSchema(searchSchema(DATASET, PERSON))).toMatchSnapshot();
107
+ });
108
+ ```
109
+
110
+ The first run writes the emitted SDL to a committed snapshot file; every later
111
+ run re-emits and diffs against it. Any contract change — your own schema edit,
112
+ or a new version of this library emitting different GraphQL for the same
113
+ declaration — fails the test and shows the SDL diff, until you consciously
114
+ accept it (`vitest -u`) and the reviewer sees the contract change spelled out
115
+ in the PR.
@@ -0,0 +1,59 @@
1
+ import { GraphQLSchema } from 'graphql';
2
+ import { type SearchEngine, type SearchQuery, type SearchSchema } from '@lde/search';
3
+ import { type LanguageOrder } from './language.js';
4
+ /** Populated per request by the transport; no framework type appears here. */
5
+ export interface SearchContext {
6
+ /** The deployment’s engine, bound to the whole {@link SearchSchema} at
7
+ * construction; the resolvers pass each root field’s search type per call
8
+ * and the engine routes it to its collection. */
9
+ readonly engine: SearchEngine;
10
+ /** Parsed, ordered `Accept-Language`; drives locale selection and output order. */
11
+ readonly acceptLanguage: readonly string[];
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.
16
+ */
17
+ readonly onFacetError?: (field: string, error: unknown) => void;
18
+ }
19
+ /** Per-root-type fine-tuning. The type’s name comes from the {@link SearchType}
20
+ * itself (`name`); options exist only for what has a sensible default. */
21
+ export interface SearchTypeOptions {
22
+ /** Root query field; defaults to the lowercased plural of the type’s `name`
23
+ * (e.g. `Dataset` → `datasets`). */
24
+ readonly queryField?: string;
25
+ /** Consumer policy applied to every query of this type (default status, sort,
26
+ * tie-breaks). */
27
+ readonly queryDefaults?: (query: SearchQuery, context: SearchContext) => SearchQuery;
28
+ }
29
+ export interface BuildGraphQLSchemaOptions {
30
+ /** Optional fine-tuning per root type, keyed by the {@link SearchType}
31
+ * `name` (the logical API name, e.g. `Dataset`) — the key a consumer knows
32
+ * the type by. A type without an entry gets the defaults. */
33
+ readonly types?: Readonly<Record<string, SearchTypeOptions>>;
34
+ /** Output-language ordering; defaults to Accept-Language-first, `und` last. */
35
+ readonly languageOrder?: LanguageOrder;
36
+ /** Upper bound for the `perPage` argument (default 100). A request outside
37
+ * `1 ≤ perPage ≤ maxPerPage` or with `page < 1` is rejected with a clear
38
+ * error instead of reaching the engine. */
39
+ readonly maxPerPage?: number;
40
+ }
41
+ /**
42
+ * Construct an executable GraphQL schema from the whole {@link SearchSchema} at
43
+ * runtime — no codegen, no SDL artifact. One root query field per
44
+ * {@link SearchType} (e.g. `datasets`, `people`), each searchable in its own
45
+ * way through its own output/`where`/`orderBy`/facet types, while the shared
46
+ * types (`LanguageString`, buckets, filter inputs, reference types) are created
47
+ * once. One generic resolver per root field maps the arguments to a
48
+ * {@link SearchQuery}, calls `context.engine`, and maps the result back; the
49
+ * field model only parameterises data.
50
+ */
51
+ export declare function buildGraphQLSchema(schema: SearchSchema, options?: BuildGraphQLSchemaOptions): GraphQLSchema;
52
+ /**
53
+ * The SDL of the built schema. Not a shipped artifact — a consumer uses it for an
54
+ * optional CI snapshot test over its own schema, catching accidental breaking
55
+ * changes to its frozen contract (including a `buildGraphQLSchema` change in a
56
+ * future version of this library silently altering it).
57
+ */
58
+ export declare function printGraphQLSchema(schema: SearchSchema, options?: BuildGraphQLSchemaOptions): string;
59
+ //# sourceMappingURL=build-schema.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,407 @@
1
+ import { GraphQLBoolean, GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
+ import { facetableFields, filterableFields, filterOperatorFor, isRangeFacet, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
3
+ import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
4
+ const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
5
+ const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNonNull(scalar) : scalar;
6
+ /** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
7
+ function screamingSnake(name) {
8
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
9
+ }
10
+ /**
11
+ * Construct an executable GraphQL schema from the whole {@link SearchSchema} at
12
+ * runtime — no codegen, no SDL artifact. One root query field per
13
+ * {@link SearchType} (e.g. `datasets`, `people`), each searchable in its own
14
+ * way through its own output/`where`/`orderBy`/facet types, while the shared
15
+ * types (`LanguageString`, buckets, filter inputs, reference types) are created
16
+ * once. One generic resolver per root field maps the arguments to a
17
+ * {@link SearchQuery}, calls `context.engine`, and maps the result back; the
18
+ * field model only parameterises data.
19
+ */
20
+ export function buildGraphQLSchema(schema, options = {}) {
21
+ const languageOrder = options.languageOrder ?? defaultLanguageOrder;
22
+ const maxPerPage = options.maxPerPage ?? 100;
23
+ const rootTypeNames = new Set([...schema.values()].map((searchType) => searchType.name));
24
+ for (const name of Object.keys(options.types ?? {})) {
25
+ if (!rootTypeNames.has(name)) {
26
+ throw new Error(`Options given for type “${name}”, which is not in the search schema.`);
27
+ }
28
+ }
29
+ const languageString = new GraphQLObjectType({
30
+ name: 'LanguageString',
31
+ fields: {
32
+ language: { type: GraphQLString },
33
+ value: { type: new GraphQLNonNull(GraphQLString) },
34
+ },
35
+ });
36
+ // A plain value facet bucket: a selection key, its count, and (for reference
37
+ // facets) the engine-resolved data label; null for token/free-string facets
38
+ // whose display the consumer owns.
39
+ const valueBucket = new GraphQLObjectType({
40
+ name: 'ValueBucket',
41
+ fields: {
42
+ value: { type: new GraphQLNonNull(GraphQLString) },
43
+ count: { type: new GraphQLNonNull(GraphQLInt) },
44
+ label: {
45
+ type: new GraphQLList(new GraphQLNonNull(languageString)),
46
+ resolve: (bucket, _args, context) => {
47
+ const label = bucket.label;
48
+ return label
49
+ ? toLanguageStrings(label, context.acceptLanguage, languageOrder)
50
+ : null;
51
+ },
52
+ },
53
+ },
54
+ });
55
+ // A numeric range-facet bin: half-open `[min, max)` bounds (max null on an
56
+ // open-ended top bin) and the count of documents in it.
57
+ const rangeBucket = new GraphQLObjectType({
58
+ name: 'RangeBucket',
59
+ fields: {
60
+ min: { type: GraphQLFloat },
61
+ max: { type: GraphQLFloat },
62
+ count: { type: new GraphQLNonNull(GraphQLInt) },
63
+ },
64
+ });
65
+ const sortDirection = new GraphQLEnumType({
66
+ name: 'SortDirection',
67
+ values: { ASC: { value: 'asc' }, DESC: { value: 'desc' } },
68
+ });
69
+ const stringFilter = new GraphQLInputObjectType({
70
+ name: 'StringFilter',
71
+ fields: {
72
+ in: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) },
73
+ },
74
+ });
75
+ const intRange = rangeInput('IntRange', GraphQLInt);
76
+ const floatRange = rangeInput('FloatRange', GraphQLFloat);
77
+ const dateRange = rangeInput('DateRange', GraphQLString);
78
+ const labelList = (resolveLabel) => ({
79
+ type: nonNullListOf(languageString),
80
+ resolve: (source, _args, context) => {
81
+ const value = resolveLabel(source);
82
+ return value
83
+ ? toLanguageStrings(value, context.acceptLanguage, languageOrder)
84
+ : [];
85
+ },
86
+ });
87
+ // Duplicate root type names cannot occur: SearchSchema is branded, so
88
+ // searchSchema() — which rejects duplicates — is the only constructor.
89
+ // One reference type per referenced shape, shared across every root type and
90
+ // reused by every field (Person and CreativeWork both referencing Agent yield
91
+ // one Agent type).
92
+ const referenceTypes = new Map();
93
+ for (const searchType of schema.values()) {
94
+ for (const field of outputFields(searchType)) {
95
+ if (field.kind !== 'reference' || field.ref === undefined) {
96
+ continue;
97
+ }
98
+ const { typeName } = field.ref;
99
+ if (rootTypeNames.has(typeName)) {
100
+ throw new Error(`Reference type name “${typeName}” (field “${field.name}” of “${searchType.name}”) collides with a root type of the same name; rename one — a reference does not resolve to a root type.`);
101
+ }
102
+ if (!referenceTypes.has(typeName)) {
103
+ referenceTypes.set(typeName, new GraphQLObjectType({
104
+ name: typeName,
105
+ fields: {
106
+ id: { type: new GraphQLNonNull(GraphQLString) },
107
+ name: labelList((source) => source.label),
108
+ },
109
+ }));
110
+ }
111
+ }
112
+ }
113
+ function outputFieldConfig(field) {
114
+ switch (field.kind) {
115
+ case 'text':
116
+ return labelList((source) => source[field.name]);
117
+ case 'keyword':
118
+ return field.array === true
119
+ ? {
120
+ type: nonNullListOf(GraphQLString),
121
+ resolve: (s) => s[field.name] ?? [],
122
+ }
123
+ : { type: scalarOutput(GraphQLString, field) };
124
+ case 'reference': {
125
+ const referenceType = referenceTypes.get(field.ref?.typeName ?? '');
126
+ return field.array === true
127
+ ? {
128
+ type: nonNullListOf(referenceType),
129
+ resolve: (s) => s[field.name] ?? [],
130
+ }
131
+ : {
132
+ type: field.required === true
133
+ ? new GraphQLNonNull(referenceType)
134
+ : referenceType,
135
+ };
136
+ }
137
+ case 'integer':
138
+ return { type: scalarOutput(GraphQLInt, field) };
139
+ case 'number':
140
+ return { type: scalarOutput(GraphQLFloat, field) };
141
+ case 'date':
142
+ // Stored as Unix seconds (int64); the surface serves ISO 8601 (ADR 4).
143
+ return {
144
+ type: scalarOutput(GraphQLString, field),
145
+ resolve: (source) => {
146
+ const value = source[field.name];
147
+ return typeof value === 'number'
148
+ ? unixSecondsToIso(value)
149
+ : (value ?? null);
150
+ },
151
+ };
152
+ case 'boolean':
153
+ return {
154
+ type: new GraphQLNonNull(GraphQLBoolean),
155
+ resolve: (source) => source[field.name] === true,
156
+ };
157
+ }
158
+ }
159
+ function whereFieldType(field) {
160
+ switch (filterOperatorFor(field.kind)) {
161
+ case 'in':
162
+ return stringFilter;
163
+ case 'range':
164
+ return field.kind === 'integer'
165
+ ? intRange
166
+ : field.kind === 'number'
167
+ ? floatRange
168
+ : dateRange;
169
+ default:
170
+ return GraphQLBoolean;
171
+ }
172
+ }
173
+ /** The root query field for one {@link SearchType}, with its derived types. */
174
+ function rootField(searchType, typeOptions) {
175
+ const typeName = searchType.name;
176
+ const outputType = new GraphQLObjectType({
177
+ name: typeName,
178
+ fields: () => {
179
+ const fields = {
180
+ id: { type: new GraphQLNonNull(GraphQLString) },
181
+ };
182
+ for (const field of outputFields(searchType)) {
183
+ fields[field.name] = outputFieldConfig(field);
184
+ }
185
+ return fields;
186
+ },
187
+ });
188
+ // A GraphQL input object must have at least one field, so a type with no
189
+ // filterable fields gets no `where` arg at all rather than an invalid
190
+ // empty input.
191
+ const filterable = filterableFields(searchType);
192
+ const whereInput = filterable.length === 0
193
+ ? undefined
194
+ : new GraphQLInputObjectType({
195
+ name: `${typeName}Where`,
196
+ fields: () => {
197
+ const fields = {};
198
+ for (const field of filterable) {
199
+ fields[field.name] = { type: whereFieldType(field) };
200
+ }
201
+ return fields;
202
+ },
203
+ });
204
+ const sortValues = {
205
+ RELEVANCE: { value: 'relevance' },
206
+ };
207
+ for (const field of sortableFields(searchType)) {
208
+ sortValues[screamingSnake(field.name)] = { value: field.name };
209
+ }
210
+ const sortField = new GraphQLEnumType({
211
+ name: `${typeName}SortField`,
212
+ values: sortValues,
213
+ });
214
+ const orderByInput = new GraphQLInputObjectType({
215
+ name: `${typeName}OrderBy`,
216
+ fields: {
217
+ field: { type: new GraphQLNonNull(sortField) },
218
+ direction: {
219
+ type: new GraphQLNonNull(sortDirection),
220
+ defaultValue: 'desc',
221
+ },
222
+ },
223
+ });
224
+ // 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.
229
+ // Like `where`, omitted entirely for a type with no facetable fields (a
230
+ // GraphQL object type must have at least one field).
231
+ const facetable = facetableFields(searchType);
232
+ const facetsType = facetable.length === 0
233
+ ? undefined
234
+ : facetsTypeFor(searchType, typeName, facetable);
235
+ const resultType = new GraphQLObjectType({
236
+ name: `${typeName}SearchResult`,
237
+ fields: {
238
+ items: { type: nonNullListOf(outputType) },
239
+ total: { type: new GraphQLNonNull(GraphQLInt) },
240
+ page: { type: new GraphQLNonNull(GraphQLInt) },
241
+ perPage: { type: new GraphQLNonNull(GraphQLInt) },
242
+ // Resolved lazily, per selected key (skip-own-filter); the result object
243
+ // (which carries the resolved `query`) is the facets source.
244
+ ...(facetsType && {
245
+ facets: {
246
+ type: new GraphQLNonNull(facetsType),
247
+ resolve: (source) => source,
248
+ },
249
+ }),
250
+ },
251
+ });
252
+ return {
253
+ type: new GraphQLNonNull(resultType),
254
+ args: {
255
+ query: { type: GraphQLString },
256
+ ...(whereInput && { where: { type: whereInput } }),
257
+ orderBy: { type: orderByInput },
258
+ page: { type: GraphQLInt, defaultValue: 1 },
259
+ perPage: { type: GraphQLInt, defaultValue: 20 },
260
+ },
261
+ resolve: async (_source, args, context) => {
262
+ const built = argsToQuery(args, context, searchType, maxPerPage);
263
+ const finalQuery = typeOptions?.queryDefaults
264
+ ? typeOptions.queryDefaults(built, context)
265
+ : built;
266
+ // Items + total only; facets are resolved lazily per selected key.
267
+ const result = await context.engine.search(searchType, {
268
+ ...finalQuery,
269
+ facets: [],
270
+ });
271
+ return {
272
+ items: result.hits.map((hit) => ({ id: hit.id, ...hit.document })),
273
+ total: result.total,
274
+ page: pageForOffset(finalQuery.offset, finalQuery.limit),
275
+ perPage: finalQuery.limit,
276
+ // Carried for the facets resolver (skip-own-filter per key).
277
+ query: finalQuery,
278
+ };
279
+ },
280
+ };
281
+ }
282
+ /** The keyed facets object for one type (only called with ≥ 1 facetable field). */
283
+ function facetsTypeFor(searchType, typeName, facetable) {
284
+ return new GraphQLObjectType({
285
+ name: `${typeName}Facets`,
286
+ fields: () => {
287
+ const fields = {};
288
+ for (const field of facetable) {
289
+ fields[field.name] = {
290
+ 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
+ },
315
+ };
316
+ }
317
+ return fields;
318
+ },
319
+ });
320
+ }
321
+ const queryFields = {};
322
+ for (const searchType of schema.values()) {
323
+ const typeOptions = options.types?.[searchType.name];
324
+ const typeName = searchType.name;
325
+ const queryField = typeOptions?.queryField ??
326
+ `${typeName.charAt(0).toLowerCase()}${typeName.slice(1)}s`;
327
+ if (queryField in queryFields) {
328
+ throw new Error(`Duplicate root query field “${queryField}”; set queryField to disambiguate.`);
329
+ }
330
+ queryFields[queryField] = rootField(searchType, typeOptions);
331
+ }
332
+ return new GraphQLSchema({
333
+ query: new GraphQLObjectType({ name: 'Query', fields: queryFields }),
334
+ });
335
+ }
336
+ /**
337
+ * The SDL of the built schema. Not a shipped artifact — a consumer uses it for an
338
+ * optional CI snapshot test over its own schema, catching accidental breaking
339
+ * changes to its frozen contract (including a `buildGraphQLSchema` change in a
340
+ * future version of this library silently altering it).
341
+ */
342
+ export function printGraphQLSchema(schema, options = {}) {
343
+ return printSchema(buildGraphQLSchema(schema, options));
344
+ }
345
+ /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
346
+ * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error — a
347
+ * negative offset or an unbounded page size must not reach the engine. */
348
+ function argsToQuery(args, context, searchType, maxPerPage) {
349
+ const perPage = args.perPage ?? 20;
350
+ const page = args.page ?? 1;
351
+ if (page < 1) {
352
+ throw new Error(`page must be at least 1; got ${page}.`);
353
+ }
354
+ // perPage: 0 is a legitimate facet-only query (no hits, page pins to 1).
355
+ if (perPage < 0 || perPage > maxPerPage) {
356
+ throw new Error(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
357
+ }
358
+ return {
359
+ text: args.query,
360
+ where: whereToFilters(args.where, searchType),
361
+ orderBy: args.orderBy
362
+ ? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
363
+ : [],
364
+ limit: perPage,
365
+ offset: (page - 1) * perPage,
366
+ // Facets are requested per-key by the facets resolver, not via an arg.
367
+ facets: [],
368
+ locale: context.acceptLanguage[0] ?? 'und',
369
+ };
370
+ }
371
+ function whereToFilters(where, searchType) {
372
+ if (where === undefined) {
373
+ return [];
374
+ }
375
+ const filters = [];
376
+ for (const field of filterableFields(searchType)) {
377
+ const value = where[field.name];
378
+ if (value === undefined || value === null) {
379
+ continue;
380
+ }
381
+ switch (filterOperatorFor(field.kind)) {
382
+ case 'in':
383
+ filters.push({
384
+ field: field.name,
385
+ in: value.in ?? [],
386
+ });
387
+ break;
388
+ case 'range': {
389
+ const range = value;
390
+ filters.push({
391
+ field: field.name,
392
+ range: { min: range.min, max: range.max },
393
+ });
394
+ break;
395
+ }
396
+ default:
397
+ filters.push({ field: field.name, is: value });
398
+ }
399
+ }
400
+ return filters;
401
+ }
402
+ function rangeInput(name, bound) {
403
+ return new GraphQLInputObjectType({
404
+ name,
405
+ fields: { min: { type: bound }, max: { type: bound } },
406
+ });
407
+ }
@@ -0,0 +1,5 @@
1
+ export { buildGraphQLSchema, printGraphQLSchema } from './build-schema.js';
2
+ export type { SearchContext, BuildGraphQLSchemaOptions, SearchTypeOptions, } from './build-schema.js';
3
+ export { defaultLanguageOrder } from './language.js';
4
+ export type { LanguageString, LanguageOrder } from './language.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,YAAY,EACV,aAAa,EACb,yBAAyB,EACzB,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { buildGraphQLSchema, printGraphQLSchema } from './build-schema.js';
2
+ export { defaultLanguageOrder } from './language.js';
@@ -0,0 +1,19 @@
1
+ import type { LocalizedValue } from '@lde/search';
2
+ /** One entry of the surface’s best-first `[LanguageString!]!`. `language` is null
3
+ * for untagged (`und`) values; `[0]` is the value to display and `[0].language`
4
+ * is the language actually served (the per-field `Content-Language`). */
5
+ export interface LanguageString {
6
+ readonly language: string | null;
7
+ readonly value: string;
8
+ }
9
+ /** Orders a localized value’s available languages against the request. */
10
+ export type LanguageOrder = (available: readonly string[], accept: readonly string[]) => readonly string[];
11
+ /**
12
+ * Default ordering: requested languages first (in request order), then the
13
+ * remaining tagged languages, then untagged (`und`) last — so `[0]` is always the
14
+ * best available value.
15
+ */
16
+ export declare const defaultLanguageOrder: LanguageOrder;
17
+ /** Flatten a language map into a best-first `LanguageString` list. */
18
+ export declare function toLanguageStrings(value: LocalizedValue, accept: readonly string[], order: LanguageOrder): LanguageString[];
19
+ //# sourceMappingURL=language.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language.d.ts","sourceRoot":"","sources":["../src/language.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;0EAE0E;AAC1E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,0EAA0E;AAC1E,MAAM,MAAM,aAAa,GAAG,CAC1B,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,MAAM,EAAE,SAAS,MAAM,EAAE,KACtB,SAAS,MAAM,EAAE,CAAC;AAEvB;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAOlC,CAAC;AAEF,sEAAsE;AACtE,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,KAAK,EAAE,aAAa,GACnB,cAAc,EAAE,CAWlB"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Default ordering: requested languages first (in request order), then the
3
+ * remaining tagged languages, then untagged (`und`) last — so `[0]` is always the
4
+ * best available value.
5
+ */
6
+ export const defaultLanguageOrder = (available, accept) => {
7
+ const requested = accept.filter((language) => available.includes(language));
8
+ const rest = available.filter((language) => language !== 'und' && !requested.includes(language));
9
+ const untagged = available.includes('und') ? ['und'] : [];
10
+ return [...requested, ...rest, ...untagged];
11
+ };
12
+ /** Flatten a language map into a best-first `LanguageString` list. */
13
+ export function toLanguageStrings(value, accept, order) {
14
+ const result = [];
15
+ for (const language of order(Object.keys(value), accept)) {
16
+ for (const text of value[language] ?? []) {
17
+ result.push({
18
+ language: language === 'und' ? null : language,
19
+ value: text,
20
+ });
21
+ }
22
+ }
23
+ return result;
24
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@lde/search-api-graphql",
3
+ "version": "0.1.0",
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
+ "repository": {
6
+ "url": "git+https://github.com/ldelements/lde.git",
7
+ "directory": "packages/search-api-graphql"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "exports": {
12
+ "./package.json": "./package.json",
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "development": "./src/index.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist",
25
+ "!**/*.tsbuildinfo"
26
+ ],
27
+ "dependencies": {
28
+ "@lde/search": "^0.2.0",
29
+ "graphql": "^15.8.0",
30
+ "tslib": "^2.3.0"
31
+ }
32
+ }