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

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 compile
await 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']);

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.

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.

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.

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 entity
await 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. A bottomLeft.lng above topRight.lng crosses the antimeridian; a bottomLeft.lat above topRight.lat is a 400.
  • geohashPrecision (1 to 12) adds a geohashGrid to the response: the matching entities in the box counted per geohash cell at that precision. It needs a boundingBox. 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.