Repository navigation
Spec: pluggable search backends for 5.0 #97
Description
Problem Statement
arocapi can only search through OpenSearch. raspbocrate runs Oni and arocapi on a Raspberry Pi with 4–8 GB of memory, which can't sensibly run OpenSearch, so a repository on small hardware has no usable search. The OpenSearch path also has faults that any consumer can hit:
- Advanced queries go straight to
query_string, which reaches every indexed field. A request can search inside Metadata or Content it would be Denied. - Filter and facet keys can disagree (
fieldvsfield.keyword). - Undeclared filter keys and bad ranges aren't rejected, though the RO-Crate API spec says they MUST be a 400.
- The index name is fixed at
entities. totalstops being exact above 10,000 hits.- RO-Crate API v0.4.0 gives an implementation no way to say it lacks advanced search, geo or relevance sorting, so a lightweight implementation can't describe itself honestly.
Consumers also have to hand-build an OpenSearch mapping and a DSL visibility query that must agree with fields they index elsewhere, and nothing checks that they do.
Solution
arocapi 5.0 makes search pluggable. A consumer passes a Search backend as a required search option. arocapi ships two reference backends as exported factories, OpenSearchBackend and PostgresBackend, and a consumer may write their own against the exported SearchBackend interface.
The consumer declares their search fields once (a Field declaration per field, with a type and Roles). That single declaration drives the index, /capabilities, request validation, filter and facet keys, advanced-query fields and the typed Search document shape. Search visibility becomes one typed filter language over declared fields that every backend applies, so a Search visibility filter carries over unchanged when switching backends.
arocapi parses advanced queries itself into a tree, so no raw query ever reaches an engine, and it rejects undeclared fields, syntax errors and oversized queries with a 422. It implements RO-Crate API v0.5.0, which publishes searchTypes, geo and sorts in /capabilities.
arocapi 5.0 supports Postgres 18+ only. MySQL is dropped. Every consumer gets the EntitySearch table through the usual prisma/models symlink and prisma migrate. A Pi deployment drops OpenSearch from its Compose file and swaps the factory.
User Stories
Choosing and configuring a backend
- As a repository operator on a Raspberry Pi, I want to search with Postgres alone, so that I don't have to run OpenSearch on a 4–8 GB device.
- As a consumer, I want to pass a search backend as a single required
searchoption, so that all search configuration lives in one place. - As a consumer, I want
OpenSearchBackend({...})andPostgresBackend({...})exported from arocapi, so that I can configure a reference backend without writing search code. - As a consumer, I want the reference backends customised through options only, so that upgrades within 5.x don't break me through changed internals.
- As a consumer with unusual needs, I want to write my own backend as an object literal typed
SearchBackend, so that I'm not limited to the reference backends. - As a consumer, I want to keep owning my OpenSearch client and Prisma client, so that connections, pooling and shutdown stay under my control.
- As a consumer, I want to set the OpenSearch index name with
indexName(defaultentities), so that several repositories can share a cluster. - As a consumer switching from OpenSearch to Postgres, I want to change only the factory call, so that my field declarations, search documents and visibility filter carry over unchanged.
- As a consumer upgrading from 4.x, I want every breaking change in the release notes to name what was removed and its replacement, so that I can migrate without an upgrade guide.
Declaring fields
- As a consumer, I want to declare each search field once with a type (
text,keyword,date,number,boolean,geo), so that every part of search agrees on it. - As a consumer, I want to opt fields into roles (
basic,advanced,filter,facet,sort), so that I control exactly what clients can search, filter, facet and sort by. - As a consumer, I want every
textfield in basic search by default, withbasic: falseto opt out, so that the common case needs no configuration. - As a consumer, I want to give text fields a weight of 1–4, so that matches in a title rank above matches in a description.
- As a consumer, I want
advanced,filter,facetandsortto be opt-in, so that no field becomes queryable by accident and leaks Denied Metadata or Content. - As a consumer, I want
facetto implyfilter, so that every facet a client sees can also be filtered on, as the spec requires. - As a consumer, I want to set a facet's bucket count with
facet: { size }(default 20, any positive size), so that a language facet can list thousands of languages. - As a consumer, I want one optional
labelper field used wherever it's published, so that Oni shows human-friendly names. - As a consumer, I want to declare a field with no role, so that I can store a fact used only by my visibility filter without publishing it.
- As a consumer, I want filter, facet and sort keys always to be the field name, so that I never meet
.keywordmismatches. - As a consumer, I want a
textfield to be usable as a filter, facet or sort through a hidden exact form, so that I don't have to declare the same data twice. - As a consumer, I want the factory to throw on a bad declaration (a name not matching
^[A-Za-z_][A-Za-z0-9_]*$,id, arelevancesort field, more than onegeofield, a bad weight or size), so that configuration errors show up at startup. - As a custom backend author, I want
capabilitiesFromFields(fields)exported, so that I can derive/capabilities-shaped capabilities from the same declarations. - As a custom backend author, I want to write
capabilitiesdirectly if I don't use field declarations, so that the contract doesn't force the declaration format on me.
Indexing
- As a consumer, I want to supply one search document per entity, keyed by declared fields and including full text, so that I decide what is searchable and arocapi never guesses from Entity columns or
meta. - As a consumer, I want
index()typed from my field declarations (keyword→string | string[],geo→{ lat, lng }, …), so that a wrong key or value type fails to compile. - As a consumer, I want
index(docs)to upsert a batch idempotently, so that reloading the same data is safe. - As a consumer, I want
remove(ids)to be idempotent, so that removing something already gone isn't an error. - As a consumer, I want
indexto throw on an undeclared key, a value of the wrong type, an unparseable date, bad coordinates or an array in a sort field, naming the entity and key, so that I find bad data at load time. - As a consumer, I want a missing field allowed in a document, so that the entity just doesn't match filters on it and sorts last.
- As a consumer, I want any non-sort field to accept a scalar or an array, so that multi-valued properties such as languages work.
- As a consumer, I want dates given as
YYYY,YYYY-MM,YYYY-MM-DDor RFC 3339, stored as their first UTC instant (a date-time with no offset is UTC), so that partial dates from RO-Crates index without preprocessing. - As a consumer on Postgres, I want to pass a Prisma transaction (
{ tx }) toindex, so that an entity and its search document commit together. - As a consumer on Postgres, I want
indexto throw an error naming the missing ids when an entity doesn't exist yet, and roll back the batch, so that I see a clear error, not a raw foreign-key violation. - As a consumer, I want deleting an entity to remove its Postgres search row by cascade, so that search never returns deleted entities.
- As a consumer on OpenSearch, I want
createIndex()to build the mapping from my field declarations, so that I never hand-write a mapping. - As a consumer on OpenSearch, I want
createIndex()to error on an existing index whose mapping differs, naming the field, so that a changed declaration doesn't silently mis-index. - As a consumer, I want to rebuild an index by looping
indexover my entities, so that I need no special rebuild API. - As a future deposit API, I want
indexandremoveon the contract, so that arocapi can write through any backend. - As a read-only custom backend author, I want to throw from
indexandremove, so that I can still satisfy the contract.
Searching
- As a client, I want basic search to take a free-text string each backend interprets in its own way, so that typing words just works.
- As a client, I want an empty query to return every visible entity (filters still apply), for both search types, so that cockatiel and the downloader can browse by filter alone.
- As a client, I want advanced queries with
field: value,AND,OR, brackets, quoted phrases, unaryNOTand field groups (field: (a OR b)), so that Oni's advanced search keeps working. - As a client, I want precedence
NOT>AND>OR, operators uppercase only and whitespace around:ignored, so that queries mean the same thing on every backend. - As a client, I want a
textfield value to match when the field contains all its words, or the exact phrase if quoted, so that advanced matching is predictable. - As a client, I want non-text fields in advanced queries to match exactly, so that
inLanguage: engmeans that language. - As a client, I want wildcards,
?,~, regexes, ranges and boosts treated as ordinary characters, not errors, so that pasted text doesn't fail. - As a client, I want a syntax error to be a 422
VALIDATION_ERRORgiving the position and what was expected, withdetails.violations: [{ field: 'query', message }], so that I can show a useful message. - As a repository operator, I want an advanced query naming an undeclared field to be a 422 that names it, so that nobody can search inside Denied Metadata or Content.
- As a repository operator, I want queries capped at 1024 characters, and advanced queries at 10 levels of nesting and 64 leaf clauses (counted after field groups are expanded), so that a hostile query can't exhaust the server.
- As a client, I want a cap breach to be a 422
VALIDATION_ERRORonquerynaming the limit, so that I know what to shorten. - As a client, I want a backend's own engine limits (OpenSearch too-many-clauses, Postgres stack depth) to come back as a 422, not a 500, so that an unusual query never looks like a server fault.
- As a client, I want undeclared filter keys, undeclared sorts, undeclared search types, ranges on string or boolean filters and geo parameters on a backend without geo to be a 400
VALIDATION_ERROR, so that mistakes are reported, not ignored. - As a client, I want exact-term filters and date and number range filters, with date bounds treated as UTC, so that Oni's year filter
2001-01-01..2001-12-31matches an entity dated2001. - As a client, I want
sortto accept any declared sort orrelevance, so that custom sorts such asidentifierororiginatedOnwork. - As a client, I want
relevanceto mean best match first and to stay stable across pages for an empty query, so that paging never repeats or skips results. - As a client, I want
totalto be exact, so that page counts are right above 10,000 hits. - As a client, I want every declared facet present in every response (empty when nothing matches), so that my facet UI doesn't flicker.
- As a client, I want facet buckets named by the value as a string and ordered by count, then name, so that facets are identical across backends.
- As a client, I want a
datefacet bucketed by UTC year with names"YYYY", ordered by year, so that I don't have to parse epoch timestamps. - As a client, I want bounding-box filtering and a geohash grid when the backend declares a
geofield, so that Oni's map works on both backends. - As a client, I want
searchExtra(score and highlights where the backend provides them) passed through unchanged, so that existing result rendering keeps working. - As a client, I want
searchTimemeasured around the backend call, so that it's comparable between backends. - As a client, I want each search hit to be a full Record with
accessattached and the consumer's entity transformers applied, so that search results look exactly like entity listings.
Visibility
- As a consumer, I want a required top-level
searchVisibilityFilter(context)returning, or resolving to, a filter overidand declared fields, so that hidden entities never appear in hits, totals or facets on any backend. - As a consumer, I want the filter typed from my backend's field declarations, so that an unknown field or a wrong value type fails to compile.
- As a consumer, I want
equals(or a bare value),not,inandnotInon every type butgeo, pluslt,lte,gtandgteon dates and numbers, so that I can express my visibility rules. - As a consumer, I want
AND,ORandNOTtaking arrays of filters, with several keys in one object meaningAND, so that I can combine rules. - As a consumer, I want a multi-valued field to match when any value matches, and
not/notInto match when none does (including when the field is missing), so thatdraft: { not: true }shows entities with no draft flag. - As a consumer, I want
nullto match a missing value and{ not: null }a present one, so that I can test for presence. - As a consumer of a public repository, I want to opt in explicitly with
AllVisibleSearchFilter({}), so that visibility is never on by accident. - As a repository operator, I want an invalid visibility filter (undeclared field, wrong operator for the type, bad value) to fail closed as a 500 that's logged and names the field, so that a misconfiguration never leaks hidden entities.
- As a repository operator, I want arocapi to re-apply
entityVisibilityFilterwhen loading hits and drop any hit it can't load, so that a mismatch between the two filters gives a short page, not a leak. - As a custom backend author, I want
request.visibilityhanded to me already resolved, so that my backend never calls consumer code.
Capabilities
- As a client, I want
/capabilitiesto reportapiVersion0.5.0, so that I know which spec the server follows. - As a client, I want
search.searchTypeswithbasicalways present andadvanced(listing its queryablefieldsand labels) only when supported, so that Oni can build or hide its advanced search. - As a client, I want
search.geo: { supported }, so that Oni can hide its map when geo isn't available. - As a client, I want
search.sortslisting declared sorts with labels (relevanceimplied), so that I can offer the right sort options. - As a client, I want
search.filtersandsearch.facetsderived from the declarations, with fields that have no role never published, so that/capabilitiesmatches what the server accepts.
Database and tooling
- As a consumer, I want arocapi to run on Postgres 18+, with the
EntitySearchtable in the sharedprisma/models, so that setup stays one symlink plusprisma migrate. - As an OpenSearch consumer, I want the
EntitySearchtable to need no extensions and to stay empty, so that the Postgres backend costs me nothing. - As a consumer, I want all shipped string columns to be
text, so that long ids and names aren't truncated by MySQL-era limits. - As a maintainer, I want the test suite to run on Postgres 18 with both reference backends, so that the two backends are held to the same contract.
- As a maintainer, I want the dev loader script and docs examples moved to
createIndex()+index()and Postgres JSON paths, so that they show the supported way.
Documentation
- As a new consumer, I want Getting started to set up Postgres 18 + OpenSearch, declare
fields, callcreateIndex()and index after adding an entity, so that I have a working search quickly. - As a consumer choosing a backend, I want Strategies → Search to compare OpenSearch and Postgres (ranking, analysers, scale vs footprint; identical filters, facets, totals, sorts, grammar and errors) and show how to switch and how to write my own, so that I can decide.
- As a consumer, I want Configuration → Search to document
search,fieldsand each backend's options, so that I have one full reference. - As a consumer, I want Configuration → Visibility to document the search filter language and its semantics, so that I write correct rules.
- As a client developer, I want Reference → Search queries to document the grammar, caps and 422s, so that I can build queries that work.
- As a custom backend author, I want Reference → Search backend to document the full contract (
capabilities,search(request, context)includingvisibility,index/remove, entity-must-exist,SearchQueryError,capabilitiesFromFields), so that I can implement it correctly.
Implementation Decisions
The decisions are recorded in full in ADR 0003 (pluggable search backends) and ADR 0001 (entity visibility filters). This section summarises them for implementers and adds how they land in the codebase.
Public API (breaking, 5.0)
- New required option
search: SearchBackend. The optionsopensearch,queryBuilderClassandqueryBuilderOptionsare removed, along with theOpensearchQueryBuilderexport and itsQueryBuilderOptions/FilterCapability/FacetCapability/SearchCapabilitytypes. There are noREMOVED_OPTIONSruntime migration errors for them. searchVisibilityFilterstays a required top-level option but changes type. It now returns aSearchVisibilityWherein the shared language, typed from thesearchoption's declarations, instead of an OpenSearchQueryContainer.AllVisibleSearchFilterstays and now returns{}.entityVisibilityFilterandAllVisibleEntityFilterare unchanged.- New exports: the
SearchBackendinterface and its request/result types,SearchQueryError,OpenSearchBackend,PostgresBackend,capabilitiesFromFields, the field declaration types,SearchDocument<Fields>andSearchVisibilityWhere<Fields>.createIndex()is exposed byOpenSearchBackendand isn't on the contract. @opensearch-project/opensearchstays a dependency forOpenSearchBackend, and its types no longer appear in the core options.- The
@prisma/clientpeer stays^7(Prisma 8 can't run arocapi orEntitySearch; revisit for 6.0).
The contract
interface SearchBackend {
readonly capabilities: BackendSearchCapability; // /capabilities-shaped: filters, facets, sorts, searchTypes, geo
search(request: SearchRequest, context: RequestContext): Promise<SearchResult>;
index(docs: SearchDocument[], options?: { tx?: unknown }): Promise<void>;
remove(ids: string[]): Promise<void>;
}
type SearchResult = {
total: number; // exact
hits: { id: string; searchExtra?: SearchExtra }[]; // ordered
facets?: Record<string, { name: string; count: number }[]>;
geohashGrid?: Record<string, number>;
};(The shape comes from the "Search backend contract: functions or class?" decision, amended by the index and visibility decisions.)
SearchRequestis normalised and valid. It carries the search type, with a raw string for basic or a parsed tree for advanced; filters already split into exact terms and ranges, with date bounds as UTC instants; optional bounding box and geohash precision; limit/offset; sort and order; andvisibility.capabilitiesholds only what/capabilitiespublishes. Weights, facet sizes and text vs keyword stay out of the contract.- No lifecycle methods. The consumer owns clients.
Who does what
- arocapi (the search route): validates the request against
backend.capabilities(400 for undeclared filter keys, sorts and search types, bad ranges, and geo without support); applies defaults and paging; enforces query caps; parses advanced queries; callssearchVisibilityFilter(context)and puts the result on the request; timessearchTimearoundbackend.search; mapsSearchQueryErrorto 422 and any other error to 500; loads Records from Prisma underentityVisibilityFilter, dropping hits it can't load (with a warning log); runs access and the transformer pipeline; attachessearchExtraunchanged; makes sure every declared facet key is present. - The backend: checks
request.visibilityagainst its declarations (a plainErrornaming the field on any fault, which becomes a 500); applies visibility to hits, total and facets; runs the query; returns ordered ids, exact total, facets and geohash grid.
Advanced query parser (new arocapi module)
- Grammar:
field: value(an unquoted value runs to the next operator,)or end;a: x b: yis a 422), quoted phrases,AND,OR, unaryNOT, brackets, field groups rewritten to(field: a OR field: b). Precedence isNOT>AND>OR; operators are uppercase only; whitespace around:is insignificant. Dropped:field: *, bare terms with no field,\escapes. Wildcards,?,~, regexes, ranges and boosts are ordinary characters. - Fields are checked against
capabilities.searchTypes.advanced.fields. An undeclared field is a 422 naming it. - Caps are fixed constants: 1024 code points for any query; 10 levels of nesting and 64 leaf clauses after the field-group rewrite for advanced. Each breach is a 422
VALIDATION_ERRORnaming the limit. The length cap isn't a request-schema bound, so it's never a 400. - Every query 422 uses
code: VALIDATION_ERRORanddetails.violations: [{ field: 'query', message }], with the position and what was expected for syntax errors. - Basic queries are never parsed: only the length cap applies.
Field declarations (shared module used by both reference backends)
- Shape:
fields: { [name]: { type, label?, weight?, basic?, advanced?, filter?, facet?: true | { size }, sort? } }, inferred as a literal so documents and visibility filters are typed. capabilitiesFromFieldsmapstext/keyword→stringin/capabilities, includes advanced fields undersearchTypes.advanced.fields, setsgeo.supportedwhen ageofield exists and omits fields with no role.- Document validation (shared by both backends'
index) covers: undeclared keys, value types, date parsing (partial date → first UTC instant; no offset → UTC),{ lat, lng }ranges and arrays in sort fields. - Facets: value buckets are named by the stringified value and ordered by count desc, then name asc; the default size is 20. A
datefacet has one bucket per UTC year with matches, named"YYYY"and ordered by year.
Search visibility language (shared module)
- Field conditions: a bare value (=
equals), or{ equals, not, in, notIn }(every type butgeo) and{ lt, lte, gt, gte }(date,number).textcompares its exact form. Combinators:AND,OR,NOT(arrays); several keys in one object =AND. Usable keys:idand any declared field, including fields with no role. - Semantics: any value matches;
not/notInmatch when no value matches, including missing;null= missing,{ not: null }= present. - Each backend translates it: OpenSearch to a
boolfilter; Postgres to parameterised SQL overEntitySearch. Neither ever takes consumer SQL or DSL.
OpenSearchBackend
- Options: the consumer's OpenSearch client,
fields,indexName(defaultentities). createIndex()builds the mapping from declarations (text plus a private exact sub-field when filter/facet/sort is needed;geo_pointfor geo) and errors, naming the field, on an existing index whose mapping differs.- Basic:
multi_matchboosted by weight. Advanced tree:bool/matchwithoperator: and/match_phrase/must_not/match_all.track_total_hits: true. Date facets use a yearly histogram named"YYYY"in UTC. Highlights go intosearchExtra. - An OpenSearch 400 or a too-many-clauses error is translated to
SearchQueryError. indexdoesn't check that entities exist.
PostgresBackend and the EntitySearch model
- Options: the consumer's Prisma client and
fields. EntitySearchships in the sharedprisma/modelswith@relationtoEntityandonDelete: Cascade;Entitygains the back-relation. It holds a weighted tsvector over basic text fields (weight 4→A … 1→D), per-field tsvectors for every declared text field (so each is matchable on its own), declared field values for exact matching, filtering, faceting and sorting, a point behind GiST and a stored full-length geohash. GIN goes through@@index(type: Gin)with no raw ops, so there's no migration drift. No extensions (no PostGIS, nopg_trgm).- Queries use raw SQL (
$queryRaw+Prisma.sql), always parameterised. Basic:websearch_to_tsquery. Advanced: a SQL boolean over per-field matches,phraseto_tsqueryfor phrases,NOT EXISTS/NOTfor negation, noWHEREfor empty. Relevance:ts_rankwith weights, tie-broken by id so empty-query order is stable. indexaccepts{ tx }, upserts in one batch, catches the foreign-key violation, rolls back and throws an error naming the missing entity ids.- Postgres engine limits (e.g.
max_stack_depth) are mapped toSearchQueryError.
Database
- The Prisma datasource becomes
postgresqland migrations are regenerated from scratch. All shippedStringcolumns drop their@db.VarChar(n)limits and becometext. - Docs examples and test fixtures use Postgres JSON paths (
path: ['draft']).
Capabilities route
apiVersionbecomes0.5.0.searchcomes frombackend.capabilitiesand includes the requiredsearchTypes,geoandsorts.- Spec dependency: RO-Crate API v0.5.0 is proposed in Search capabilities for lightweight implementations (v0.5.0). arocapi implements that proposal.
Release
- Every breaking commit carries a
BREAKING CHANGE:footer naming what was removed or changed and its replacement, e.g.opensearch→search: OpenSearchBackend({...}); thesearchVisibilityFilterDSL → the shared filter language; MySQL → Postgres 18+. There is no upgrade guide.
Docs
- Getting started: Postgres 18 + OpenSearch Compose,
@prisma/adapter-pg, declarefields,createIndex(), add an entity thenindexit. - Strategies → Search: backend comparison, the Postgres backend as the lightweight option, a short write-your-own section. All query-builder content goes.
- Configuration → Search:
search,fields, each backend's options. Configuration → Visibility: the filter language. Configuration → Data sources: onlyprisma. - Reference gains Search queries (grammar, caps, 422s; linked from Errors) and Search backend (the contract; linked from Strategies).
Testing Decisions
Good tests here exercise external behaviour only: HTTP responses and the exported backend methods. They don't assert on generated OpenSearch DSL or SQL, internal module calls or the parser's internals. Relevance and basic-search matching legitimately differ between backends, so shared tests never compare relevance order or which documents fuzzy basic search matches. They hold both backends to the same filters, facets, sorts, exact totals, visibility, advanced-query matching and errors.
Three seams, agreed during planning:
-
The HTTP API with a real backend (main seam). A shared contract suite drives
POST /searchandGET /capabilitiesthroughapp.injecton the full plugin. It runs once with Postgres 18 +OpenSearchBackendand once with Postgres 18 +PostgresBackend. Each case sets up its ownfields, search documents (viaindex) andsearchVisibilityFilter, then checks:- hits, exact totals, facets (presence, naming, ordering,
"YYYY"date buckets) and geohash grids - declared sorts, and stable order for empty queries across pages
- exact and range filters with UTC date bounds
- advanced queries (words, phrases,
NOT, field groups, precedence) - the visibility language's any/none/null semantics applied to hits, totals and facets
- 400s, 422s, and the 500 on an invalid visibility filter
/capabilitiesderived from the declarations
The existing integration tests and their setup are prior art. They move to Postgres, and the whole integration file runs against both backends.
- hits, exact totals, facets (presence, naming, ordering,
-
The HTTP API with a fake
SearchBackend. Route tests register the plugin with an object literal that records eachSearchRequestand returns canned results. They cover arocapi's half:- validation from
capabilities(400s) - the advanced parser, by asserting the tree the backend receives, plus syntax 422s with position and expected
- query caps
SearchQueryError→ 422, other errors → 500- dropping hits Prisma can't load
- access and transformers on hits,
searchExtrapassthrough,searchTime - filling in missing declared facet keys, and
request.visibilitycarrying the filter's result
This replaces the mocked OpenSearch client in the current search route tests, which with the capabilities route tests are prior art, and needs no containers.
- validation from
-
The reference backends' public write surface. Tests call
index,remove,createIndex()and the factories' declaration checks directly against the real engines:- idempotent upserts and removes
- each
indexvalidation error naming entity and key - partial dates stored as their first UTC instant
{ tx }committing or rolling back with the entity- Postgres naming missing entity ids, and cascade delete from
Entity createIndex()erroring on a differing mapping- factory errors on bad declarations
Most cases are shared across both backends.
Other test files that mock Prisma stay as they are. Type-level behaviour (typed index documents and typed visibility filters rejecting unknown fields) is covered by the consumer type check and the docs examples type check in pnpm lint. Tooling: postgres:18 replaces mysql:8 in test Compose and CI, and @prisma/adapter-pg replaces @prisma/adapter-mariadb. Everything runs in one job with one lock, generating and migrating once, with a single coverage report kept at 100%.
Out of Scope
- MySQL as a datasource (MySQL users stay on 4.x; requests are
wontfix) and SQLite as a datasource or backend. - A grep or in-memory backend, except possibly as a test fake.
- The deposit API itself (it only motivated putting
index/removeon the contract). - Zero-downtime index rebuilds (e.g. an OpenSearch alias swap); addable after 5.0 without a breaking change.
- Several geo points per entity, and a partial-date type matching the range it covers.
- Consumer-configurable query caps (addable later as an optional setting).
- Letting
entityVisibilityFilterreach consumer-owned tables (Visibility: let entityVisibilityFilter reach consumer-owned tables). - Prisma 8 support.
- Drafting the spec PR on
crate-works/ro-crate-api. - Moving raspbocrate from MySQL to Postgres.
- Follow-ups in other repos: Oni reading
searchTypes,geoandsortsfrom/capabilities, emittingAND NOT, skipping empty advanced lines instead of sending*, trimming its advanced-search help, and reading"YYYY"date-facet names; nabu emittingkey_as_string; ldacapi moving tocreateIndex()+index().
Further Notes
- This spec is the destination of the map Search: pluggable search backends for 5.0. Every decision behind it is linked from that map's Decisions so far. It supersedes the requirements in Search: decide whether to support backends beyond OpenSearch #48 (undeclared filter keys, range filters, configurable index, facet/filter mismatch).
- Measured on a Pi 5 (4 GB, SD card), a full Postgres response (page, exact total, five facets) takes ≤72 ms at 10k entities, ≤280 ms at 50k and about 1 s at 200k.
- Every push to
mainpublishes abetaprerelease, so the breaking changes should land on an integration branch or in an order that keepsmainconsistent; seeRELEASING.md. - A suggested order of work is: Postgres-only database and tooling → contract + parser + validation with the fake backend → field declarations and visibility language →
OpenSearchBackend→PostgresBackend→ capabilities v0.5.0 → docs.
Activity
johnf commentedon Oct 21, 2026
Shipped in #108 (merge 8eb34cf): all ten tickets (#98–#107) landed on one integration branch and merged as one PR. CI passes; 837 tests across both backends, 100% coverage.
Where the shipped work differs from this spec (ADR 0003 is up to date):
SearchRequest.filters.rangesholds an array of ranges per field. Exact date/number filter values become ranges, a calendar date covering its UTC day; partial dates (2001) aren't valid filter bounds. Boolean filter values other thantrue/falseare a 400.PostgresBackendstores one text tsvector with lexemes prefixed by field name instead of a tsvector per field;EntitySearchmust be on the connection'ssearch_path.searchExtraon hits whose Metadata or Content is Denied, sosearchExtraisn't passed through entirely unchanged.checkSearchVisibilityandVisibilityConditionare exported for custom backends.OpenSearchBackendtakesrefresh(defaultfalse).number, roles ongeo,basicon non-text;AND/OR/NOTare reserved field names. Sorts are declared (breaking).*,!!!) matches every visible entity.Known limitations (listed in #108): a visibility filter is only typed against the declarations when annotated
SearchVisibilityFilter<typeof fields>; OpenSearch can't exactly match text values over 8191 characters; facet counts may be approximate on a multi-shard index; with no basic fields declared,*differs between backends.github-actions commentedon Oct 21, 2026
🎉 This issue has been resolved in version 5.0.0-beta.6 🎉
The release is available on:
Your semantic-release bot 📦🚀