Search backend
A search backend is a plain object satisfying the exported SearchBackend interface, passed as the
search option. OpenSearchBackend and PostgresBackend are two; your own is an object
literal typed SearchBackend.
interface SearchBackend { readonly capabilities: BackendSearchCapability; search(request: SearchRequest, context: RequestContext): Promise<SearchResult>; index(docs: SearchDocument[], options?: { tx?: unknown }): Promise<void>; remove(ids: string[]): Promise<void>;}The contract has no lifecycle methods: you own any client and its connections.
capabilities
Section titled “capabilities”What GET /capabilities publishes as search, and the only thing arocapi validates requests against:
type BackendSearchCapability = { filters: Record<string, { type: 'string' | 'date' | 'number' | 'boolean'; label?: string }>; facets: Record<string, { label?: string }>; sorts: Record<string, { label?: string }>; searchTypes: { basic: {}; advanced?: { fields: Record<string, { label?: string }> } }; geo: { supported: boolean };};capabilitiesFromFields(fields) derives it from field declarations, publishing only
fields with a role. You can also write it directly. relevance is always a sort and isn’t listed.
search(request, context)
Section titled “search(request, context)”arocapi validates and normalises the request first, so the backend only ever receives a valid one. An undeclared
filter key, sort or search type, a bad range, and geo parameters when geo.supported is false are a 400 before the
backend is called. SearchRequest has:
| Field | Type | Purpose |
|---|---|---|
searchType |
basic or advanced |
A declared search type. |
query |
string for basic, AdvancedQuery tree for advanced |
A basic query is the raw text, for the backend to interpret its own way. |
filters.terms |
Record<string, string[]> |
Exact-term filters on string and boolean filters: any value within a field, every field. |
filters.ranges |
Record<string, { gte?, lte? }[]> |
Inclusive range filters on date and number filters: any range within a field, every field. Date bounds are Dates (UTC instants), number bounds numbers. Exact values on these filters arrive here too, as ranges. |
boundingBox |
{ topRight, bottomLeft }, optional |
Only when geo.supported. bottomLeft.lng above topRight.lng crosses the antimeridian. |
geohashPrecision |
integer, optional | Only when geo.supported, and always with boundingBox. |
limit, offset |
integers | The page. |
sort, order |
a declared sort or relevance; asc or desc |
relevance is best match first, stable across pages for an empty query. |
visibility |
SearchVisibilityWhere, the search visibility filter’s result |
Already resolved, so the backend never calls consumer code. Unchecked: see below. |
An advanced query arrives already parsed and checked against the declared advanced fields and the
caps, as an AdvancedQuery:
type AdvancedQuery = | { type: 'and' | 'or'; clauses: AdvancedQuery[] } | { type: 'not'; clause: AdvancedQuery } // everything the clause doesn't match | { type: 'match'; field: string; value: string; phrase: boolean } | { type: 'all' }; // an empty queryA match on a text field matches all the words of value, or the exact phrase if phrase; on a date field it
matches the whole period value names (a year, month, day or instant, in UTC); on any other type it matches value
exactly. A value the field can’t hold is a SearchQueryError.
context is the request context, { request, fastify }.
The backend checks visibility against its fields on every search, since only it knows the fields with no role. The
exported checkSearchVisibility(fields, visibility) does this from field declarations: on an undeclared field, an
operator that doesn’t fit the field’s type or a bad value, it throws a plain Error naming the field, which arocapi
answers with a 500 and logs. Otherwise it returns a normalised VisibilityCondition tree, with dates as UTC Dates:
type VisibilityCondition = | { type: 'and' | 'or'; clauses: VisibilityCondition[] } // an empty `and` matches everything, an empty `or` nothing | { type: 'not'; clause: VisibilityCondition } | { type: 'in'; field: string; values: (string | number | boolean | Date)[] } // any value of the field is listed | { type: 'present'; field: string } // the field has a value | { type: 'range'; field: string; bounds: Partial<Record<'lt' | 'lte' | 'gt' | 'gte', number | Date>> }; // any value is within allfield is a declared field or id, and a text field compares its exact value. The backend applies the condition to
the hits, the total and the facets, with the semantics every backend shares,
and returns a SearchResult:
type SearchResult = { total: number; // exact hits: { id: string; searchExtra?: { score?: number; highlight?: Record<string, string[]> } }[]; // ordered facets?: Record<string, { name: string; count: number }[]>; geohashGrid?: Record<string, number>;};Facet buckets are named by the value as a string and ordered by count, then name. A date facet has one bucket per
UTC year with matches, named "YYYY" and ordered by year. geohashGrid counts the hits in the box per geohash at
geohashPrecision. arocapi then:
- loads each hit’s Record from the database under
entityVisibilityFilter, dropping (and logging) any it can’t load; - attaches
access, runs the entity transformers and passessearchExtrathrough, droppinghighlightfrom a hit whose Metadata or Content access is denied, since highlights quote the Record’s text; - fills in an empty list for any declared facet the backend left out;
- reports the time spent in
searchassearchTime.
Errors
Section titled “Errors”Throw the exported SearchQueryError for a query you can’t run, including your engine’s own limits. arocapi answers
422 VALIDATION_ERROR with its message. Any other error is a 500, logged with the real error.
index(docs, options?) and remove(ids)
Section titled “index(docs, options?) and remove(ids)”index upserts a batch of search documents idempotently, and remove is
idempotent. Each entity must exist in the database before its document is indexed; a hit whose entity doesn’t exist
is dropped from results. options.tx is for a backend that writes inside the consumer’s database transaction. A
read-only backend throws from both.
Example
Section titled “Example”A read-only backend over an in-memory list. It checks visibility with checkSearchVisibility and applies the
condition before the query and filters, so hidden entities stay out of the hits, the total and the facets:
import { capabilitiesFromFields, checkSearchVisibility, type SearchBackend, SearchQueryError, type VisibilityCondition,} from 'arocapi';
type Doc = { id: string; name: string; inLanguage: string[] };
// A read-only backend over a fixed list, for illustrationdeclare const catalogue: Doc[];
const fields = { name: { type: 'text', sort: true }, inLanguage: { type: 'keyword', facet: true, label: 'Language' },} as const;
const valuesOf = (doc: Doc, field: string) => (field === 'id' ? [doc.id] : [doc[field as keyof Doc]].flat());
const matches = (condition: VisibilityCondition, doc: Doc): boolean => { switch (condition.type) { case 'and': return condition.clauses.every((clause) => matches(clause, doc)); case 'or': return condition.clauses.some((clause) => matches(clause, doc)); case 'not': return !matches(condition.clause, doc); case 'in': return valuesOf(doc, condition.field).some((value) => condition.values.includes(value)); case 'present': return valuesOf(doc, condition.field).length > 0; case 'range': // Only date and number fields take ranges, and this backend declares none return false; }};
export const listBackend: SearchBackend = { capabilities: capabilitiesFromFields(fields),
search: async (request) => { if (request.searchType !== 'basic') { throw new SearchQueryError('Only basic search is supported'); }
// Throws, naming the field, on a filter that doesn't fit the declarations: arocapi answers 500 and logs it const visibility = checkSearchVisibility(fields, request.visibility); const languages = request.filters.terms.inLanguage; const words = request.query.toLowerCase().split(/\s+/).filter(Boolean);
const hits = catalogue .filter((doc) => matches(visibility, doc)) .filter(({ name }) => words.every((word) => name.toLowerCase().includes(word))) .filter(({ inLanguage }) => !languages || inLanguage.some((language) => languages.includes(language)));
const sorted = request.sort === 'name' ? hits.toSorted((a, b) => a.name.localeCompare(b.name) * (request.order === 'asc' ? 1 : -1)) : hits.toSorted((a, b) => a.id.localeCompare(b.id));
const counts = new Map<string, number>(); for (const language of hits.flatMap(({ inLanguage }) => [...new Set(inLanguage)])) { counts.set(language, (counts.get(language) ?? 0) + 1); }
return { total: hits.length, hits: sorted.slice(request.offset, request.offset + request.limit).map(({ id }) => ({ id })), facets: { inLanguage: [...counts] .map(([name, count]) => ({ name, count })) .toSorted((a, b) => b.count - a.count || a.name.localeCompare(b.name)), }, }; },
index: async () => { throw new Error('This backend is read-only'); }, remove: async () => { throw new Error('This backend is read-only'); },};