Search
POST /search goes through the search backend you pass as search. arocapi checks the
request against the backend’s capabilities, the backend finds the matching ids, and arocapi loads those entities from
the database and returns them in hit order.
flowchart LR body[Search request] --> validate[Validate against capabilities] validate --> request[Search request with visibility] filter[searchVisibilityFilter] --> request request --> backend[Search backend] backend --> hits[Ordered ids, total, facets] hits --> db[Reload from the database with entityVisibilityFilter] db --> response[Entities with access, after transformers]
What the backend sees
Section titled “What the backend sees”arocapi fills in defaults and rejects, with a 400 VALIDATION_ERROR, a filter key, sort or search type the backend
doesn’t declare, a bad range and geo the backend doesn’t support. The backend gets a normalised request: the query,
filters keyed by field name and split into exact terms and ranges, with dates as UTC instants, the page,
the sort and the result of your search visibility filter. The Search backend reference
lists every field.
With either reference backend, the field declarations decide everything a client can do:
- Basic search matches the
basicfields, weighted byweight. An empty query matches every visible entity. - Filters match exact values, or ranges on
dateandnumberfields: any value within a field, every field. Atextfield is filtered on its exact value, not its words. - Highlights of the matched words in basic and advanced fields arrive in each hit’s
searchExtraonOpenSearchBackend, except on a hit whose Metadata or Content access is denied. - Facets are counted for every declared facet, named by the value and ordered by count, then name; a
datefacet has one bucket per UTC year, namedYYYYand ordered by year. Every declared facet is in every response, empty when nothing matches. - Geo: with a
geofield declared,boundingBoxfilters the hits andgeohashPrecisioncounts them per geohash cell. - Sorts are any declared sort, or
relevance(the default), best match first. Missing values sort last and ties are broken by id, so paging never repeats or skips an entity. - Totals are exact, however many entities match.
GET /capabilities publishes the same filters, facets and sorts, so clients learn what to send.
Choosing a backend
Section titled “Choosing a backend”Both backends take the same field declarations, search documents and search visibility filter, so switching is a change to the factory call. They differ in how they rank and how much they cost to run:
OpenSearchBackend |
PostgresBackend |
|
|---|---|---|
| Ranking | BM25: rare words count for more | ts_rank with field weights, but no rarity (IDF) |
| Matching | Per-field analysers, small typos still match | Whole words, no typos |
| Runs on | A separate OpenSearch cluster | The database you already have |
| Scale | Large collections and heavy query load | Small collections, fine on a Raspberry Pi |
Filters, ranges, facets (date facets too), geo, sorts, exact totals, the search visibility filter, advanced queries,
the query grammar and errors are the same on both. Only which entities a query’s words find, and their relevance
order, differ.
Lightweight: the Postgres backend
Section titled “Lightweight: the Postgres backend”On small hardware, or wherever running OpenSearch isn’t worth it, search with Postgres alone:
- Drop the
opensearchservice from your Compose file, and@opensearch-project/opensearchfrom your packages. - Swap
OpenSearchBackend({ client, fields })fromarocapi/search/opensearchforPostgresBackend({ prisma, fields })fromarocapi/search/postgres, with the samefields, and drop thecreateIndex()call. - Index your entities again with the same
indexcalls. TheEntitySearchtable is already in your database fromprisma migrate.
Write your own backend
Section titled “Write your own backend”To search another engine, or search differently, implement the SearchBackend contract:
capabilities from capabilitiesFromFields(fields), and search, index and remove. arocapi validates every
request against capabilities before your search sees it, so the backend only searches: it applies the filters, the
search visibility filter and the sort, and returns ordered ids, an exact total and every declared facet.
Search and visibility
Section titled “Search and visibility”Your searchVisibilityFilter returns a condition over id and your declared fields, in
one language for every backend. arocapi passes it to the backend
as the request’s visibility, and the backend applies it as a filter beside the query, so hidden entities are excluded
from the hits, from total and from every facet count.
arocapi then loads the hits from the database through your entityVisibilityFilter as well. A hit that the database
filter hides, or that isn’t in the database, is dropped from the page with a warning in the log. total and the facets
come from the backend, so they still count it.
Keep the two filters in step: whatever entityVisibilityFilter checks in the database has to be in the search
document, as a declared field, and searchVisibilityFilter checks the same thing. A field with no role is stored only
for this. For example, if drafts are hidden through a draft flag in the entity’s meta, declare
draft: { type: 'boolean' } and index the flag (see
why the two filters must agree):
import type { SearchVisibilityFilter } from 'arocapi';
const fields = { name: { type: 'text' }, draft: { type: 'boolean' },} as const;
// Also matches entities with no draft flagexport const searchVisibilityFilter: SearchVisibilityFilter<typeof fields> = () => ({ draft: { not: true } });Customising search
Section titled “Customising search”The reference backends are customised through their options only. To search differently, write your own backend.
The request body and response are under POST /search.