4. Search API GraphQL surface
Date: 2026-06-25
Status
Accepted
Builds on ADR 3 (Search API core query model).
Context
Given the engine-neutral core of ADR 3, the first API surface is GraphQL, derived from the same source as the index so it cannot drift. It must be framework-free: resolvers are standard graphql-js, not tied to Fastify/Mercurius, so any GraphQL server can host the schema (DR mounts it inline; a Fastify wrapper is a deferred separate package).
Decision
Runtime configuration, not code generation
The surface is constructed at runtime from the field-model configuration (buildGraphQLSchema(schema, options)), once at startup, with generic resolvers shipped in the package attached to that schema – nothing is emitted or committed. The resolvers are inherently generic (one root resolver per type maps args to a SearchQuery, calls the engine, and maps the result back; the field model only parameterises data), so codegen would emit N near-identical stubs that all delegate to the same logic, plus a build step and staleness risk, for no benefit.
A live GraphQL API serves its own schema via introspection, so clients need no committed .graphql file; the field-model diff is the reviewable change. printGraphQLSchema() exists only as an optional CI snapshot test guarding the frozen contract against accidental breaking changes – not a shipped artifact.
The schema-building function
The function takes the whole SearchSchema and emits one root query field per SearchType – a schema may declare multiple root types (e.g. Person AND CreativeWork), each searchable in its own way. Separately built GraphQLSchemas could never be merged later (one Query type; the shared types would collide), so multi-type composition happens before build, per the compose-before-build principle. Shared types (LanguageString, buckets, filter inputs, reference types) are created once and reused across root types.
function buildGraphQLSchema(
schema: SearchSchema, // every root type, keyed by class IRI
options?: {
types?: Record<
string, // SearchType.name (the logical API name); entries are optional fine-tuning
{
queryField?: string; // root field; default lowercased plural of the type's name
queryDefaults?: (q: SearchQuery, ctx: SearchContext) => SearchQuery; // per-type consumer policy
}
>;
languageOrder?: LanguageOrder; // output-language ordering; default Accept-Language first
},
): GraphQLSchema; // executable schema: types + generic resolvers attached
// optional CI helper only:
function printGraphQLSchema(schema, options): string; // SDL, for a snapshot/breaking-change testbuildGraphQLSchema is the standalone, framework-agnostic artifact (depends only on graphql). Deep customisation of the emitted schema is deferred (see Consequences).
Typed boundaries, dynamic middle
Values are typed at both ends, with the resolver as the typed transform between them:
| layer | localized text | reference | int64 | keyword (array) | boolean |
|---|---|---|---|---|---|
IR (ResultDocument) | LocalizedValue (lang map) | Reference | number | readonly string[] | boolean |
| GraphQL | LanguageString[] (best-first list) | named type (Organization) | Float/number | [String!]!/string[] | Boolean!/boolean |
What stays unchecked is the generic resolver’s dynamic middle: it loops over the field model with runtime-string names, so TS cannot prove the object it builds matches the emitted output types – it casts at that boundary, and graphql-js’s executor (not TS) enforces the output types at runtime (a wrong-typed return raises a field error). Same “typed boundaries, dynamic middle” shape as the engine port and the projection: type the edges where it is honest, accept a cast where iteration is inherently dynamic. The contract is guarded by the optional printGraphQLSchema() SDL snapshot (the real artifact).
Construction rules (field model → schema)
Type names derive from each SearchType’s logical name; shared types (LanguageString, ValueBucket, RangeBucket, SortDirection, StringFilter, IntRange, FloatRange, DateRange, and the reference types) are emitted once across all root types, and the per-type keyed facets object is named <name>Facets. A type with no filterable fields gets no where arg, and one with no facetable fields no facets field (empty GraphQL types are invalid). GraphQL field names are the field model name verbatim (declare camelCase).
Output type – one field per
outputfield:text+localized→[LanguageString!]!(best-first;[0].language= served language, the per-fieldContent-Language);keywordarray →[String!]!, scalar →String;integer→Int(signed 32-bit);number→Float(exact integers to 2^53);date→String(ISO 8601);boolean→Boolean!(absent = false);reference→ see below. Nullability fromarray/ required / optional;idisString!. A magnitude that can exceed 32 bits (a 64-bit count or byte size – e.g. DR’ssize) isnumber→Float, sinceIntwould overflow; aLong/BigIntcustom scalar is the deferred alternative.Reference types – a
referencefield is typed by the referenced shape (sh:class/sh:node), emitted once and reused by every field referencing the same shape. Its fields follownestedStrategy:nestedStrategyGraphQL idOnlyString(the IRI)labelOnly(v1 default)named type { id: String!, name: [LanguageString!]! }inline(later)the named type plus the referenced shape’s projected fields So DR emits
publisher: Organization(thefoaf:Agentshape) andterminologySource: [Term!]!. Named, not a generic GraphQLReference: goinglabelOnly → inlinethen only adds fields (non-breaking), whereas generic→named later would break the contract.whereinput – one field perfilterablefield:keyword/reference→StringFilter { in: [String!] };integer→IntRange { min, max };number→FloatRange;date→DateRange { min, max }(Strings);boolean→Boolean(theisvalue);textis excluded (it goes through thequeryarg).orderBy–RELEVANCE(the sane default when aqueryis present) plus everysortablefield, as an enum, in a single{ field, direction }input. Only publicly-selectable sorts appear; the resolver expands the client’s one choice into the internalSort[], appending deployment tie-breaks like DR’sstatus_rankviaqueryDefaults(never exposed). Single for now because a user picks one dimension; promoting it to a list later is backward-compatible only for inline-literal clients (list input coercion) – variable-based clients break ($o: DatasetOrderBywhere[DatasetOrderBy!]is expected) – so a future array is a deliberate, potentially breaking change.Facets – a keyed object (
<Type>Facets), one field perfacetablefield, typed by the field’s kind: a numeric range-facet field is[RangeBucket!]!, every other facet is[ValueBucket!]!. The facet set and each bucket shape are thus encoded statically in the schema, not discovered at runtime through an enum + polymorphic bucket (no__typename, no fragments). Selection is the request: only the facet keys a query selects are computed (the resolver inspects the selection), each with its own where-filter removed (skip-own-filter – a multi-select facet still lists its other options; dropping astatusfilter also drops the valid-only default, so the status facet counts across every status). Two bucket types:ValueBucket { value, count, label }–valueis the selection key (filter viafield.in);label(nullable) is the engine-resolved canonical data label, present only for reference (IRI-keyed) facets,nullfor token/free-string facets whose display the consumer owns (its i18n for controlled tokens likevalid→ “Geldig”/“Valid”, or thevalueitself). The null is load-bearing.RangeBucket { min, max, count }– a half-open[min, max)numeric bin (maxnull on an open-ended top bin), filtered viafield.range.- A grouped facet (a coarse category alongside granular values, e.g.
group:rdfnext to media types) needs no special bucket: its tokens are denormalized into the field at index time, so they are ordinaryValueBucketvalues – faceted, filtered (field.in: ["group:rdf"]) and, where output, read like any other value (see ADR 0003).
Resulting schema (DR example, abridged)
type LanguageString {
language: String
value: String!
} # language null = untagged (@none)
type Organization {
id: String!
name: [LanguageString!]!
} # labelOnly; gains fields if inline
type Term {
id: String!
name: [LanguageString!]!
}
type Dataset {
id: String!
title: [LanguageString!]!
description: [LanguageString!]!
publisher: Organization
terminologySource: [Term!]!
format: [String!]!
size: Float # int64 magnitude → Float, not Int (32-bit)
datePosted: String
status: String
iiif: Boolean!
# … keyword, language, iiifManifestCount, ndeSchemaAp, linkedData, terms, persistentUris
}
# shared inputs are emitted once and reused: DR uses StringFilter + FloatRange +
# SortDirection (IntRange / DateRange are pruned – no filterable int/date field).
input DatasetWhere {
publisher: StringFilter
format: StringFilter
class: StringFilter
status: StringFilter
size: FloatRange
# … keyword, language, terminologySource, catalog
}
enum DatasetSortField {
RELEVANCE
TITLE
DATE_POSTED
SIZE
}
input DatasetOrderBy {
field: DatasetSortField!
direction: SortDirection! = DESC
}
type ValueBucket {
value: String! # selection key: a media type, a token (group:rdf), or an IRI for reference facets
count: Int!
label: [LanguageString!] # nullable; resolved data label for reference facets, else null
}
type RangeBucket {
min: Float # half-open [min, max); max null = open-ended top bin
max: Float
count: Int!
}
type DatasetFacets {
# one field per facetable field, typed by kind; selection = request, skip-own-filter applied
publisher: [ValueBucket!]!
keyword: [ValueBucket!]!
language: [ValueBucket!]!
format: [ValueBucket!]!
class: [ValueBucket!]!
terminologySource: [ValueBucket!]!
status: [ValueBucket!]!
size: [RangeBucket!]!
}
type DatasetSearchResult {
items: [Dataset!]!
total: Int!
page: Int!
perPage: Int!
facets: DatasetFacets!
}
type Query {
datasets(
query: String
where: DatasetWhere
orderBy: DatasetOrderBy
page: Int = 1
perPage: Int = 20 # no `facets` arg – selecting facet keys IS the request
): DatasetSearchResult!
}Numbered pagination (page/perPage + total), per ADR 3 – no Relay connection. The reference types carry id + name (labelOnly) from DR’s sidecar labels collection, resolved by the adapter. publisher is single (dct:publisher maxCount 1); creator is search-only (its name feeds full-text query but it has no output field); catalog is filter-only (in where, not output); class is facet + filter but not output (its group: tokens surface only as facet buckets, never as card values); datePosted is sortable + output only; and the NDE compatibility booleans (iiif, ndeSchemaAp, linkedData, terms) are output-only vinkjes – in neither where nor the facets until “filter by vinkje” ships.
Resolver behaviour
The single, generic root resolver (shipped in the package, not emitted):
- Args →
SearchQuery(pure):query→text;where→Filter[];orderBy→Sort[](RELEVANCE→reservedrelevance);page/perPage→offset/limit;facets→logical names;locale←context.acceptLanguage[0]. - Apply
options.queryDefaults– the generic resolver bakes no deployment defaults; DR injects its policy here: defaultstatus:=valid; default sortrelevancewhen aqueryis present elsetitle; and thestatus_ranktie-break appended to either. context.engine.search(searchType, query)→SearchResult(the engine is bound to the whole schema at construction and routes per type).SearchResult→ output – scalars pass through; aLocalizedValuemap →[LanguageString]ordered byoptions.languageOrder(available, acceptLanguage); reference values likewise; facets keyed logical→enum. GraphQL field selection prunes.
Default languageOrder: Accept-Language entries first, then remaining tagged languages, then untagged (und) last – so [0] is always the best available value.
Lifecycle and performance
- Built once at startup, reused for every request. The field model is static per deployment, so the single
GraphQLSchemais constructed during boot (sub-millisecond to low-single-digit-ms for a schema this size) and never rebuilt per request – the same object codegen would have produced, with no per-request penalty (Mercurius additionally caches it). - Hot path is the engine, not GraphQL. Per-request cost is dominated by the Typesense round-trip; parse/validate/resolve of a small query is sub-millisecond.
- Introspection serves the contract (cheap, client-cached). Leave it on, or disable in production and use
printGraphQLSchemafor tooling.
Context contract
interface SearchContext {
// The deployment's engine, bound to the whole SearchSchema at construction;
// resolvers pass each root field's search type per call and the engine
// routes it to its collection (rejecting a type outside its schema).
engine: SearchEngine;
acceptLanguage: readonly string[]; // parsed, ordered; drives locale + output ordering
}Each transport populates it per request; no framework type appears in the package.
Consequences
- The GraphQL surface is configured at runtime from the ADR 3 field model, so it cannot drift from the index or a later REST surface, and works under any GraphQL server.
- Frozen (public contract):
LanguageString, the named reference types (Organization,Term, …), output types,whereoperators,orderByenums, numbered-pagination args, facet types. Breaking to change – right in v1. - Internal: args→
SearchQuerymapping, language ordering, how the adapter computes facets, theSearchDocumentshape. - Named reference types per shape rather than one uniform reference type – chosen for ergonomics and additive
inlinegrowth (labelOnly→inlineonly adds fields). - Deferred: a
dataset(id)single-resource query (DR detail stays on SPARQL); cross-collection@referencejoins beyond inline labels; cursor pagination; aDatescalar (kept ISOString) and aLong/BigIntscalar for 64-bit integers (keptFloat); transport-layer persisted queries / cost limits; a root or per-field language argument (Accept-Language is the sole preference mechanism); metadata-language-availability filtering (a facetable dimension, not v1); schema extension hooks (extendTypeDefs/extendResolversor exported typeDefs/resolvers for manual composition); a static TS mirror of the contract (OutputOf<S>/WhereOf<S>/OrderByOf<S>/FacetOf<S>mapped types over adefineSearchTypedeclaration) for typed in-process callers.