Skip to content
These docs describe arocapi v5, which is in beta. Install it with pnpm add arocapi@beta.

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.

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.

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 query

A 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 all

field 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 passes searchExtra through, dropping highlight from 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 search as searchTime.

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 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.

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 illustration
declare 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');
},
};