Search
POST /search and the search part of GET /capabilities go through a search backend. arocapi ships
OpenSearchBackend and PostgresBackend, or you can
write your own. Search compares them.
| Option | Required | Type | Default | Purpose |
|---|---|---|---|---|
search |
yes | SearchBackend |
none | Answers searches and holds one search document per entity. |
Registration throws search is required without it.
import { Client } from '@opensearch-project/opensearch';import type { FieldDeclarations } from 'arocapi';import { OpenSearchBackend } from 'arocapi/search/opensearch';
const fields = { name: { type: 'text', weight: 3, sort: true, label: 'Name' }, description: { type: 'text' }, inLanguage: { type: 'keyword', facet: { size: 500 }, label: 'Language' }, entityType: { type: 'keyword', facet: true, label: 'Type' }, originatedOn: { type: 'date', filter: true, sort: true, label: 'Date' }, draft: { type: 'boolean' }, location: { type: 'geo' },} satisfies FieldDeclarations;
export const search = OpenSearchBackend({ client: new Client({ node: process.env.OPENSEARCH_URL }), fields, indexName: 'catalog',});
await search.createIndex();
// Values are typed from the declarations: a wrong key or value type fails to compileawait search.index([ { id: 'https://example.org/collection', name: 'Songs from the coast', description: 'Recordings made in 1971', inLanguage: ['eng', 'nys'], entityType: 'http://pcdm.org/models#Collection', originatedOn: '1971-06', draft: false, location: { lat: -33.87, lng: 151.21 }, },]);
await search.remove(['https://example.org/withdrawn']);fields
Section titled “fields”The reference backends take a fields option: one declaration per search field, keyed by field name. The declarations
decide what the index holds, what GET /capabilities publishes, which requests are valid and the type of the search
documents you index.
| Key | Type | Default | Purpose |
|---|---|---|---|
type |
text, keyword, date, number, boolean, geo |
none, required | How values are stored and matched. |
label |
string | none | Published wherever the field is, for clients to show. |
weight |
integer, 1 to 4 | 1 |
text only: how much a basic-search match in it counts. |
basic |
boolean | true for text, otherwise false |
Searched by a basic query. text and keyword only. |
advanced |
boolean | false |
Searchable by name in an advanced query. Not on geo. |
filter |
boolean | false |
Usable as a key in the request’s filters. |
facet |
true or { size } |
false |
Counted in the response’s facets; implies filter. size defaults to 20. Not on number. |
sort |
boolean | false |
Usable as the request’s sort. Takes a single value. |
A field with no role is stored for the search visibility filter
only and is never published or searchable. geo fields take no role, and at most one is allowed.
Filter, facet and sort keys are always the field name. A text field used as a filter, facet or sort is compared by
its exact value; its words are only for basic search.
Field names match ^[A-Za-z_][A-Za-z0-9_]*$, and id, AND, OR and NOT are reserved. relevance is always a sort, so it can’t be
declared as one. The factory throws, naming the field, on a declaration it can’t hold.
Search documents
Section titled “Search documents”You supply one search document per entity: its id and values keyed by declared fields, including the full text you
want searched. arocapi never builds one from the Entity row. Add the entity to the database first, then index its
document.
| Type | Values |
|---|---|
text, keyword |
strings |
date |
YYYY, YYYY-MM, YYYY-MM-DD or RFC 3339 strings, stored as their first UTC instant; a date-time with no offset is UTC |
number |
finite numbers |
boolean |
true or false |
geo |
one { lat, lng } |
Any field but a sort or geo field takes a value or an array of values. A missing field is allowed: the entity
doesn’t match filters on it and sorts last. SearchDocument<typeof fields> is the document type, so a wrong key or
value type fails to compile.
index(docs)upserts a batch: indexing the same documents again is safe. It throws, naming the entity and key, on an undeclared key or a bad value, and stores nothing from that batch.remove(ids)removes documents, ignoring ids that aren’t indexed.
To rebuild an index, loop index over your entities.
OpenSearchBackend
Section titled “OpenSearchBackend”Import it from arocapi/search/opensearch. It needs @opensearch-project/opensearch installed, an optional peer dependency.
| Option | Required | Type | Default | Purpose |
|---|---|---|---|---|
client |
yes | Client from @opensearch-project/opensearch |
none | You own the client and its connections. |
fields |
yes | field declarations | none | See fields. |
indexName |
no | string | entities |
Lets several repositories share a cluster. |
refresh |
no | boolean or 'wait_for' |
false |
OpenSearch’s refresh on each index and remove. |
createIndex() creates the index with a mapping built from fields. It does nothing if the index exists with that
mapping, and throws, naming the field, if it exists with another, so a changed declaration never mis-indexes silently.
Delete the index and index your entities again to apply a change. index doesn’t check that the entities exist.
The index gets OpenSearch’s default of one primary shard; with more than one, facet counts may be approximate.
By default a write is searchable after OpenSearch’s next refresh, about a second later, which keeps bulk indexing fast.
Pass refresh: 'wait_for' if a write must be searchable as soon as it returns, or true in tests, at the cost of
slower writes.
A basic query is a fuzzy multi_match over the basic fields, boosted by weight; an empty query, or one with no words
left once analysed (such as *), matches every visible entity. Totals are exact, and an empty query keeps the same
order across pages. Each hit’s searchExtra has its score and the highlighted matches in its basic and advanced fields;
arocapi drops the highlights from a hit whose Metadata or Content access is denied, as they quote its text. A query
OpenSearch rejects is a 422.
In an advanced query, a text value matches all its words (match with operator: and) or the exact phrase
(match_phrase), a date matches the whole period it names, any other type
matches exactly, and NOT is must_not.
PostgresBackend
Section titled “PostgresBackend”Import it from arocapi/search/postgres.
| Option | Required | Type | Default | Purpose |
|---|---|---|---|---|
prisma |
yes | your Prisma client | none | Usually the client you pass as prisma; it holds the EntitySearch table. |
fields |
yes | field declarations | none | See fields. |
PostgresBackend keeps one row per entity in the EntitySearch table, which ships in arocapi’s prisma/models and
is created by prisma migrate with the rest of the schema. It needs no extensions and no createIndex(): a changed
declaration applies to documents as you index them again. The backend’s SQL names the table without a schema, so it
must be on the connection’s search_path; setting PrismaPg’s schema option alone isn’t enough. The default, public,
works.
index(docs, { tx }) takes an optional Prisma transaction client, so a search document commits or rolls back with its
entity. index throws, naming them, if any entity doesn’t exist yet, and stores nothing from that batch. Deleting an
entity deletes its search document.
import { PrismaPg } from '@prisma/adapter-pg';import type { FieldDeclarations } from 'arocapi';import { PostgresBackend } from 'arocapi/search/postgres';import { PrismaClient } from './generated/prisma/client.ts';
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
const fields = { name: { type: 'text', weight: 3, sort: true, label: 'Name' }, description: { type: 'text' }, inLanguage: { type: 'keyword', facet: { size: 500 }, label: 'Language' }, draft: { type: 'boolean' },} satisfies FieldDeclarations;
export const search = PostgresBackend({ prisma, fields });
// The search document commits, or rolls back, with its entityawait prisma.$transaction(async (tx) => { await tx.entity.create({ data: { id: 'https://example.org/collection', name: 'Songs from the coast', description: 'Recordings made in 1971', entityType: 'http://pcdm.org/models#Collection', metadataLicenseId: 'https://creativecommons.org/licenses/by/4.0/', contentLicenseId: 'https://creativecommons.org/licenses/by/4.0/', }, }); await search.index( [{ id: 'https://example.org/collection', name: 'Songs from the coast', inLanguage: ['eng', 'nys'], draft: false }], { tx }, );});A basic query is a Postgres web search (websearch_to_tsquery) over the basic fields, with words matched whole rather
than with typos; quotes, or and - work as they do on search engines. An empty query, or one with no words (such as
*), matches every visible entity.
Relevance is ts_rank, weighted by weight and tie-broken by id. Filters, facets, sorts, totals and the search
visibility filter behave exactly as on OpenSearchBackend, and strings sort in code point order, as OpenSearch sorts
them. searchExtra is empty.
Geo filters on Postgres’s built-in point type with a GiST index, and counts the geohash grid by prefixes of each
entity’s stored geohash, so it needs no PostGIS.
An advanced query runs over the same simple words: field: words needs every word
in that field, a quoted phrase needs them in order (phraseto_tsquery), a date matches the
whole period it names, and other types match the value exactly. Relevance is ts_rank over the query’s text matches outside NOT, so entities
matching more of the query come first, tie-broken by id, the same order on every page. A value a field can’t hold,
or a query past a Postgres limit such as max_stack_depth, is a 422.
A date value is stored as the first UTC instant of what it names, so "2001" is 2001-01-01T00:00:00Z and a
date-time with no offset is UTC.
A date or number filter takes exact values, an inclusive range { gte, lte } (either bound may be left out), or an
array of ranges, any of which matches:
{ "filters": { "datePublished": [{ "gte": "2001-01-01", "lte": "2001-12-31" }], "pages": { "gte": 10 } } }Date bounds and exact dates are a YYYY-MM-DD date or an RFC 3339 date-time, in UTC unless they carry an offset. A
calendar date covers its whole UTC day, so the year range above matches an entity dated 2001, and the exact value
"2001-12-31" matches any instant that day. A date-time is that instant alone. Number bounds are JSON numbers and
exact numbers are numeric strings.
A partial date (2001, 2001-12) as a bound or exact value, a bound of the wrong type, gte above lte, a range
mixed with exact values, any range on a string or boolean filter, and a boolean filter value other than "true"
or "false" are a 400 VALIDATION_ERROR naming the filter.
A date facet has one bucket per UTC year with matches, named "YYYY" and ordered by year, so an entity dated
2001-12-31T23:30:00-01:00 counts under "2002".
Declaring a geo field publishes geo: { supported: true } in GET /capabilities. Each entity has at most one
point, { lat, lng }.
boundingBox: { topRight, bottomLeft }keeps only entities whose point is inside. AbottomLeft.lngabovetopRight.lngcrosses the antimeridian; abottomLeft.latabovetopRight.latis a 400.geohashPrecision(1 to 12) adds ageohashGridto the response: the matching entities in the box counted per geohash cell at that precision. It needs aboundingBox. Both backends return at most 10,000 cells, those with the most matches.
Without a geo field, either parameter is a 400 VALIDATION_ERROR.
Search describes how a search runs end to end.