@lde/search-api-graphql 0.12.1 → 0.12.3

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
@@ -4,27 +4,13 @@ The GraphQL surface for the [`@lde/search`](../search) core. **Both engine- and
4
4
  domain-agnostic:** it builds an executable
5
5
  [graphql-js](https://graphql.org/graphql-js/) `GraphQLSchema` from your whole
6
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).
7
+ field per `SearchType`, each searchable in its own way.
14
8
 
15
- ## Runtime configuration, not codegen
9
+ ## Installation
16
10
 
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:
11
+ ```sh
12
+ npm install @lde/search-api-graphql
13
+ ```
28
14
 
29
15
  ```ts
30
16
  import { searchSchema } from '@lde/search';
@@ -37,142 +23,6 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
37
23
  // { engine: SearchEngine, acceptLanguage: string[] }
38
24
  ```
39
25
 
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 the API
63
-
64
- `createSearchGraphQLHandler` turns the schema into a **served API**: one
65
- framework-agnostic `(request: Request) => Promise<Response>` handler (built on
66
- [graphql-yoga](https://the-guild.dev/graphql/yoga-server), see
67
- [ADR 14](../../docs/decisions/0014-serve-the-search-graphql-api-with-graphql-yoga.md))
68
- covering POST execution, introspection, error shaping and per-request
69
- `Accept-Language` parsing:
70
-
71
- ```ts
72
- import { createSearchGraphQLHandler } from '@lde/search-api-graphql';
73
-
74
- const handler = createSearchGraphQLHandler({
75
- searchSchema: searchSchema(DATASET, PERSON),
76
- engine, // e.g. createTypesenseSearchEngine(…)
77
- });
78
-
79
- // SvelteKit (src/routes/graphql/+server.ts):
80
- export const GET = ({ request }) => handler(request);
81
- export const POST = GET;
82
-
83
- // Plain node:http:
84
- import { createServerAdapter } from '@whatwg-node/server';
85
- createServer(createServerAdapter(handler)).listen(4000);
86
- ```
87
-
88
- Every host that speaks `Request`/`Response` (SvelteKit, Hono, Fastify via a
89
- bridge, plain Node) mounts it the same way. Batteries included:
90
-
91
- - **Playground**: `GET /graphql` serves the bundled GraphiQL –
92
- self-contained (no external CDN) and sent without framing headers, so a docs
93
- site can `<iframe>` the deployed playground as a live client. Disable it per
94
- environment (`playground: false`) or swap the renderer (`renderPlayground`).
95
- - **SDL**: `GET /graphql?sdl` returns the schema contract as SDL – publish it
96
- or generate static docs in CI without a running introspection query.
97
- - **CORS** for cross-origin browser clients (configurable via `cors`).
98
- - **Depth and cost limits** ([graphql-armor](https://escape.tech/graphql-armor/);
99
- `maxDepth`, default 15, and `maxCost`, default 5000) guard the public
100
- endpoint against arbitrarily expensive queries; introspection stays exempt.
101
-
102
- To serve **custom fields next to the generated search API**, merge your own
103
- schema with `buildGraphQLSchema()`’s output (e.g. `@graphql-tools/schema`’s
104
- `mergeSchemas`) and pass the union as `schema` instead of `searchSchema`; the
105
- same endpoint and playground serve both:
106
-
107
- ```ts
108
- const handler = createSearchGraphQLHandler({
109
- schema: mergeSchemas({
110
- schemas: [buildGraphQLSchema(searchSchema(DATASET)), myCustomSchema],
111
- }),
112
- engine,
113
- });
114
- ```
115
-
116
- ## Serving a subset of the schema
117
-
118
- `types` never filters: every `SearchType` in the schema you pass gets a root
119
- field (options for a type not in the schema are a build-time error). To expose
120
- only part of what you index, narrow the **schema argument** you hand
121
- `buildGraphQLSchema` (`searchSchema(…)` is a cheap constructor, so build one per
122
- consumer):
123
-
124
- ```ts
125
- // Index a superset: hand a three-type schema to the pipeline, which projects and
126
- // stores one collection per type (see @lde/search-pipeline). INTERNAL is indexed
127
- // (e.g. a label source references resolve against) but never served.
128
- const indexed = searchSchema(DATASET, PERSON, INTERNAL);
129
-
130
- // Serve a subset: the GraphQL API exposes only two of those types.
131
- const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
132
- ```
133
-
134
- ## What it builds (per root type)
135
-
136
- - **Output type** (the `SearchType`’s `name`): localized text → best-first `[LanguageString!]!`
137
- (`[0].language` is the language actually served); references → named per-shape
138
- types (`Organization`, `Term`) with a `name`; scalars/booleans per kind; `date`
139
- → ISO 8601 string; nullability from `required` / `array` / `kind`.
140
- - **`where`** one input per `filterable` field (`StringFilter`, `IntRange` /
141
- `FloatRange` / `DateRange`, or `Boolean`); omitted entirely for a type with no
142
- filterable fields.
143
- - **`orderBy`**: `RELEVANCE` plus every `sortable` field, as an enum.
144
- - **Facets**: a keyed object with one field per `facetable` field; a bucket
145
- carries `value` + `count` + a nullable `label` – the resolved data label for
146
- **reference** facets, `null` for token/free-string facets whose display the
147
- consumer owns (its own i18n, or the value itself). Selecting facet fields IS
148
- the request: each selected facet is computed with its own `where`-filter
149
- removed (skip-own-filter), and the whole selection is **batched per
150
- request** – facets whose field carries no active filter share one query
151
- (the unfiltered browse collapses to a single query) and everything is
152
- dispatched as one `engine.searchFacets` call, so a typical page costs the
153
- listing search plus one batched facet round-trip.
154
-
155
- ## Guarding the contract
156
-
157
- Why the API, the index and a future REST surface cannot drift apart is the
158
- search family’s overall approach – one field model, one query IR – described
159
- in [`@lde/search`](../search/README.md). Specific to this surface: the GraphQL
160
- contract is **frozen** (breaking to change), yet generated rather than
161
- handwritten, so nothing in the repo shows a contract change as a reviewable
162
- diff. A _consumer_ restores that with one snapshot test over its **own**
163
- search schema:
164
-
165
- ```ts
166
- import { printGraphQLSchema } from '@lde/search-api-graphql';
167
-
168
- it('keeps the public GraphQL contract stable', () => {
169
- expect(printGraphQLSchema(searchSchema(DATASET, PERSON))).toMatchSnapshot();
170
- });
171
- ```
26
+ ## Documentation
172
27
 
173
- The first run writes the emitted SDL to a committed snapshot file; every later
174
- run re-emits and diffs against it. Any contract change – your own schema edit,
175
- or a new version of this library emitting different GraphQL for the same
176
- declaration – fails the test and shows the SDL diff, until you consciously
177
- accept it (`vitest -u`) and the reviewer sees the contract change spelled out
178
- in the PR.
28
+ See the [full documentation](https://ldelements.org/reference/search-api-graphql).
@@ -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,EAIL,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
+ {"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,EAIL,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,CAoXf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
@@ -89,26 +89,36 @@ export function buildGraphQLSchema(schema, options = {}) {
89
89
  // searchSchema() – which rejects duplicates – is the only constructor.
90
90
  // One reference type per referenced shape, shared across every root type and
91
91
  // reused by every field (Person and CreativeWork both referencing Agent yield
92
- // one Agent type).
92
+ // one Agent type). A reference may name a root type – the `labelOnly` way to
93
+ // carry an id plus a label resolved from that root’s collection (`creator` →
94
+ // `Person`) – but GraphQL type names must be unique, so such a reference is
95
+ // served under `‹Name›Reference` instead. An `inline` reference can never
96
+ // collide: searchSchema resolves its typeName to a declared Reference Type
97
+ // and rejects duplicate names schema-wide.
93
98
  const referenceTypes = new Map();
99
+ const takenTypeNames = new Set(rootTypeNames);
94
100
  for (const searchType of schema.values()) {
95
101
  for (const field of outputFields(searchType)) {
96
- if (field.kind !== 'reference' || field.ref === undefined) {
102
+ if (field.kind !== 'reference' ||
103
+ field.ref === undefined ||
104
+ referenceTypes.has(field.ref.typeName)) {
97
105
  continue;
98
106
  }
99
107
  const { typeName } = field.ref;
100
- if (rootTypeNames.has(typeName)) {
101
- 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.`);
102
- }
103
- if (!referenceTypes.has(typeName)) {
104
- referenceTypes.set(typeName, new GraphQLObjectType({
105
- name: typeName,
106
- fields: {
107
- id: { type: new GraphQLNonNull(GraphQLString) },
108
- name: labelList((source) => source.label),
109
- },
110
- }));
108
+ const graphQLName = rootTypeNames.has(typeName)
109
+ ? `${typeName}Reference`
110
+ : typeName;
111
+ if (takenTypeNames.has(graphQLName)) {
112
+ throw new Error(`Reference type “${typeName}” (field “${field.name}” of “${searchType.name}”) would be served as “${graphQLName}”, which collides with another type name; rename one.`);
111
113
  }
114
+ takenTypeNames.add(graphQLName);
115
+ referenceTypes.set(typeName, new GraphQLObjectType({
116
+ name: graphQLName,
117
+ fields: {
118
+ id: { type: new GraphQLNonNull(GraphQLString) },
119
+ name: labelList((source) => source.label),
120
+ },
121
+ }));
112
122
  }
113
123
  }
114
124
  function outputFieldConfig(field) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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",
@@ -28,7 +28,7 @@
28
28
  "@escape.tech/graphql-armor-cost-limit": "^2.4.3",
29
29
  "@escape.tech/graphql-armor-max-depth": "^2.4.2",
30
30
  "@graphql-yoga/render-graphiql": "^5.21.2",
31
- "@lde/search": "^0.12.1",
31
+ "@lde/search": "^0.12.3",
32
32
  "dataloader": "^2.2.3",
33
33
  "graphql": "^16.0.0",
34
34
  "graphql-yoga": "^5.21.2",