From aaa3ab55068f74ab91b67defb1e512d30cafe7f3 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Thu, 17 Sep 2026 14:22:40 -0500
Subject: [PATCH 01/11] Bring improvements to the '/search' endpoint and
finalize functionality alignment.
---
controllers/crud.js | 5 +-
controllers/search.js | 287 +++++++-----------
openapi/contracts/core-provider.openapi.yaml | 18 +-
public/API.html | 45 ++-
routes/__tests__/query.test.js | 46 +++
routes/__tests__/search.test.js | 291 ++++++++++++++++---
6 files changed, 466 insertions(+), 226 deletions(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index 93941a5c..810ca8d0 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -86,8 +86,11 @@ const query = async function (req, res, next) {
}
const { limit, skip } = getPagination(req.query, { res })
try {
+ // Objects whose _id is not a string are legacy data with no addressable URL, so they are not
+ // served. $and rather than a merge into props, so a client's own _id condition still applies.
// One record past the page is read only to learn whether another page exists. It is never served.
- let matches = await db.find(props).sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()
+ let matches = await db.find({ $and: [props, { _id: { $type: "string" } }] })
+ .sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()
const hasNext = matches.length > limit
matches = matches.slice(0, limit).map(o => idNegotiation(o))
res.set(utils.configureLDHeadersFor(matches))
diff --git a/controllers/search.js b/controllers/search.js
index 16ee1009..ca64bf2b 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -6,80 +6,107 @@
*/
import { db } from '../database/index.js'
import utils from '../utils.js'
-import { idNegotiation, getPagination } from './utils.js'
+import { idNegotiation, getPagination, setNextPageLink } from './utils.js'
/**
- * Merges and deduplicates results from multiple MongoDB Atlas Search index queries.
- *
- * This function combines search results from both the IIIF Presentation API 3.0 index
- * (presi3AnnotationText) and the IIIF Presentation API 2.1 index (presi2AnnotationText).
- *
- * @param {Array
Following rel="next"
- While another page exists, Custom Query responses carry a rel="next" link in the Link header (RFC 8288), next to the JSON-LD context link. The final page has no rel="next". Follow it until it is gone.
+ While another page exists, Custom Query, Text Search, and Phrase Search responses carry a rel="next" link in the Link header (RFC 8288), next to the JSON-LD context link. The final page has no rel="next". Follow it until it is gone.
rel="next" advances skip. It is still present when the next page would start past the skip maximum, and following it is answered with the 400 that names the maximum.
-
Custom Query takes a POST. The link only carries the paging URL parameters, so send the same request body to it that you sent to the first page.
+
All three endpoints take a POST. The link only carries the paging URL parameters, so send the same request body to it that you sent to the first page.
- The search endpoints page in descending relevance score, which you can read back from __rerum.score on each result. That order is the same across both the IIIF 3.0 and IIIF 2.1 indexes, so the best match is the first record of the first page whichever shape it was written in.
+ Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in, and consecutive pages tile that order without repeating or missing a record.
+
+
What is not served
+
+ A handful of objects in the store were written outside the API, or imported from RERUM v0, and their _id is not a string. They have no addressable URL — there is nothing for a client to follow, read back, or update — so Custom Query, Text Search, and Phrase Search do not return them. They are excluded before a page is measured, so a page still fills to the applied limit while matches remain.
-
Descending score is an ordering, not a tiling guarantee.
Text Search
@@ -674,7 +677,7 @@
Text Search
IIIF Presentation API 2.1 fields⚠️: oa:Annotation resource.chars, oa:Annotation resource.cnt:chars, and nested structures in sc:AnnotationList resources, sc:Canvas otherContent, and sc:Manifest sequences.
- To allow for more records in the response one can add the URL parameter limit to the search requests. If you expect the search request will have a very large response with many objects, your application should use a paged search by also using the skip URL parameter. You will see an example of this below.
+ To allow for more records in the response one can add the URL parameter limit to the search requests. If you expect the search request will have a very large response with many objects, your application should use a paged search that follows the rel="next" link in each response's Link header until there is none. You will see an example of this below.
A limit above the maximum of 500 is reduced to 500, and the response says so — see Pagination parameters. Read the applied value from the Pagination-Limit response header rather than assuming you received everything you asked for.
@@ -759,6 +762,34 @@
Text Search
Results are returned sorted by relevance score in descending order. The __rerum.score property indicates match quality.
+
+
Paged Search Javascript Example
+
+ const many_results = await pagedSearch("lorem ipsum")
+
+ async function pagedSearch(searchText, lim = 100) {
+ const request = {
+ method: "POST",
+ headers: { "Content-Type": "application/json; charset=utf-8" },
+ body: JSON.stringify({ searchText })
+ }
+ const allResults = []
+ let url = `https://devstore.rerum.io/v1/api/search?limit=${lim}`
+ while (url) {
+ const response = await fetch(url, request)
+ if (response.status === 400 && allResults.length) {
+ console.warn(`Stopped at the skip maximum of ${response.headers.get("Pagination-Skip-Max")}. These results are incomplete. Narrow the search.`)
+ return allResults
+ }
+ if (!response.ok) throw new Error(await response.text())
+ allResults.push(...await response.json())
+ url = response.headers.get("Link")?.match(/<([^>]+)>;\s*rel="next"/)?.[1]
+ }
+ return allResults
+ }
+
+
Stop when there is no rel="next", not when a page looks short. /search/phrase pages the same way.
+
Phrase Search
@@ -792,7 +823,7 @@
Phrase Search
IIIF Presentation API 2.1 fields⚠️: oa:Annotation resource.chars, oa:Annotation resource.cnt:chars, and nested structures in sc:AnnotationList resources, sc:Canvas otherContent, and sc:Manifest sequences.
- To allow for more records in the response one can add the URL parameter limit to the search requests. If you expect the search request will have a very large response with many objects, your application should use a paged search by also using the skip URL parameter. You will see an example of this below.
+ To allow for more records in the response one can add the URL parameter limit to the search requests. If you expect the search request will have a very large response with many objects, your application should use a paged search that follows the rel="next" link in each response's Link header until there is none. You will see an example of this below.
A limit above the maximum of 500 is reduced to 500, and the response says so — see Pagination parameters. Read the applied value from the Pagination-Limit response header rather than assuming you received everything you asked for.
@@ -831,7 +862,7 @@
Phrase Search
When you need more precision than standard search but more flexibility than exact matching
- The limit and skip URL parameters work the same as in the standard text search endpoint for pagination support.
+ The limit and skip URL parameters, and the rel="next" link, work the same as in the standard text search endpoint — see Pagination parameters.
Javascript Example with Custom Slop
diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js
index a7970cfa..47dbe65b 100644
--- a/routes/__tests__/query.test.js
+++ b/routes/__tests__/query.test.js
@@ -65,6 +65,52 @@ it("'/query' route functions", async () => {
assert.strictEqual(response.body[0]._id, undefined)
})
+describe('objects whose _id is not a string are not served by /query', () => {
+ /** The filter the controller handed to db.find on the most recent request. */
+ let filter = null
+
+ const post = (body) => {
+ db.find.mockImplementation((props) => {
+ filter = props
+ return {
+ sort() { return this },
+ limit() { return this },
+ skip() { return this },
+ async toArray() { return [] }
+ }
+ })
+ return request(routeTester)
+ .post("/query")
+ .set("Content-Type", "application/json")
+ .send(body)
+ }
+
+ // They are legacy data with no addressable URL: idNegotiation() strips _id, so a client receives
+ // bare content it can neither follow nor update.
+ it("adds the string _id condition to the client's filter", async () => {
+ const response = await post({ test: "item" })
+
+ assert.strictEqual(response.statusCode, 200)
+ assert.deepStrictEqual(filter, { $and: [{ test: "item" }, { _id: { $type: "string" } }] })
+ })
+
+ it("keeps a client's own _id condition, whether a literal or an operator", async () => {
+ await post({ _id: "testid123" })
+ assert.deepStrictEqual(
+ filter,
+ { $and: [{ _id: "testid123" }, { _id: { $type: "string" } }] },
+ "a merge into props would have replaced the client's _id outright"
+ )
+
+ await post({ _id: { $not: { $type: "string" } } })
+ assert.deepStrictEqual(
+ filter,
+ { $and: [{ _id: { $not: { $type: "string" } } }, { _id: { $type: "string" } }] },
+ "the two conditions contradict, which is how this query comes back empty"
+ )
+ })
+})
+
describe('pagination parameters on /query', () => {
const pagedTester = express()
pagedTester.use(express.json({ type: ["application/json", "application/ld+json"] }))
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index d32f8bf8..6c24414f 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -5,6 +5,7 @@ import express from 'express'
import request from 'supertest'
import controller from '../../db-controller.js'
+import { searchFuzzily, searchWildly, searchAlikes } from '../../controllers/search.js'
import rest from '../../rest.js'
import { db, resetMocks } from '../../database/index.js'
@@ -19,23 +20,40 @@ beforeEach(() => {
resetMocks()
})
+/** What the controller last handed to db.aggregate, and how many times, since the mock was installed. */
+const searchCalls = { count: 0, pipeline: null }
+
+/** The value a pipeline gives an aggregation operator, e.g. stageOf(pipeline, '$limit'). */
+const stageOf = (pipeline, operator) => pipeline.find(stage => operator in stage)?.[operator]
+
+/** Where an operator sits in a pipeline, so the order of the stages can be asserted. */
+const positionOf = (pipeline, operator) => pipeline.findIndex(stage => operator in stage)
+
function mockAggregateResults(docs) {
- // db.aggregate is called twice (presi3 + presi2 indexes) in parallel; mockReturnValue
- // applies to every call until the next reset.
- db.aggregate.mockReturnValue({
- toArray: () => Promise.resolve(docs)
+ // One index means one aggregation per request, so there is a single pipeline to record.
+ searchCalls.count = 0
+ db.aggregate.mockImplementation((pipeline) => {
+ searchCalls.count++
+ searchCalls.pipeline = pipeline
+ return { toArray: () => Promise.resolve(docs) }
})
}
/**
- * Answer the two branches with different documents, so cross-index behavior can be observed.
- *
- * The controllers build the Promise.all array literal presi3 first, and array elements evaluate
- * left to right, so the first queued result is the IIIF 3.0 branch.
+ * Answer the search from a fixed, score ordered set of documents, honouring the $skip and $limit
+ * the controller put in the pipeline so a walk pages the way Atlas would.
*/
-function mockBranchResults(presi3Docs, presi2Docs) {
- db.aggregate.mockReturnValueOnce({ toArray: () => Promise.resolve(presi3Docs) })
- db.aggregate.mockReturnValueOnce({ toArray: () => Promise.resolve(presi2Docs) })
+function mockSearchCollection(docs) {
+ searchCalls.count = 0
+ db.aggregate.mockImplementation((pipeline) => {
+ searchCalls.count++
+ searchCalls.pipeline = pipeline
+ const skip = stageOf(pipeline, '$skip') ?? 0
+ const limit = stageOf(pipeline, '$limit') ?? Infinity
+ return {
+ toArray: () => Promise.resolve(docs.slice(skip, skip + limit).map(doc => structuredClone(doc)))
+ }
+ })
}
/** A search hit carrying its relevance where the branch pipelines actually put it. */
@@ -103,16 +121,11 @@ describe('search controllers', () => {
assert.strictEqual(response.body[0]['@id'], doc['@id'])
})
- // The two parallel db.aggregate calls (presi3 + presi2) can return overlapping documents.
- // mergeSearchResults must dedupe by _id; a regression that drops the dedupe would return
- // duplicates here.
- it("searchAsWords dedupes when both indexes return the same document", async () => {
- const doc = {
- _id: 'shared-doc',
- '@id': 'https://store.rerum.io/v1/id/shared-doc',
- text: 'shared'
- }
- mockAggregateResults([doc])
+ // One index returns every matching document exactly once, in score order, so there is nothing for
+ // the controller to merge, deduplicate or sort. A regression back to a per-index fan-out would
+ // show up here as a second aggregation.
+ it("searchAsWords runs one search, against the index that covers both vocabularies", async () => {
+ mockAggregateResults([])
const response = await request(routeTester)
.post('/search')
@@ -120,14 +133,28 @@ describe('search controllers', () => {
.send('shared')
assert.strictEqual(response.statusCode, 200)
- assert.strictEqual(response.body.length, 1, 'duplicate _id across indexes should be deduped')
+ assert.strictEqual(searchCalls.count, 1)
+ assert.strictEqual(searchCalls.pipeline[0].$search.index, 'annotationText')
})
- it("searchAsWords ranks across both indexes by score, not by which index answered", async () => {
- mockBranchResults(
- [scoredDoc('presi3-weak', 1.69), scoredDoc('presi3-weaker', 1.24)],
- [scoredDoc('presi2-best', 83.92)]
- )
+ it("searches the IIIF 3.0 and IIIF 2.1 text fields in one compound query", async () => {
+ mockAggregateResults([])
+
+ await request(routeTester)
+ .post('/search')
+ .set('Content-Type', 'text/plain')
+ .send('line')
+
+ const compound = searchCalls.pipeline[0].$search.compound
+ const searched = JSON.stringify(compound.should)
+ for (const path of ['body.value', 'bodyValue', 'resource.chars', 'resource.cnt:chars']) {
+ assert.ok(searched.includes(path), `${path} must still be searched`)
+ }
+ assert.strictEqual(compound.minimumShouldMatch, 1, 'any one clause matching qualifies a document')
+ })
+
+ it("serves the relevance order the database returned, without resorting it", async () => {
+ mockAggregateResults([scoredDoc('best', 83.92), scoredDoc('weak', 1.69), scoredDoc('weaker', 1.24)])
const response = await request(routeTester)
.post('/search')
@@ -135,24 +162,156 @@ describe('search controllers', () => {
.send('line')
assert.strictEqual(response.statusCode, 200)
- assert.deepStrictEqual(idsOf(response), ['presi2-best', 'presi3-weak', 'presi3-weaker'])
+ assert.deepStrictEqual(idsOf(response), ['best', 'weak', 'weaker'])
})
- it("pages the score order, so skip walks best-first across both indexes", async () => {
- const branches = () => mockBranchResults(
- [scoredDoc('p3-c', 3), scoredDoc('p3-d', 2)],
- [scoredDoc('p2-a', 9), scoredDoc('p2-b', 5)]
- )
- const page = (queryString) => {
- branches()
- return request(routeTester)
- .post(`/search${queryString}`)
+ it("hands skip and limit to the database rather than paging in memory", async () => {
+ mockSearchCollection([scoredDoc('a', 9), scoredDoc('b', 5), scoredDoc('c', 3), scoredDoc('d', 2)])
+ const page = (queryString) => request(routeTester)
+ .post(`/search${queryString}`)
+ .set('Content-Type', 'text/plain')
+ .send('line')
+
+ assert.deepStrictEqual(idsOf(await page('?limit=2&skip=0')), ['a', 'b'])
+ assert.strictEqual(stageOf(searchCalls.pipeline, '$skip'), 0)
+ assert.deepStrictEqual(idsOf(await page('?limit=2&skip=2')), ['c', 'd'])
+ assert.strictEqual(stageOf(searchCalls.pipeline, '$skip'), 2)
+ // limit + 1: the extra record is the only evidence that another page exists.
+ assert.strictEqual(stageOf(searchCalls.pipeline, '$limit'), 3)
+ })
+
+ // Legacy objects whose _id is not a string have no addressable URL, so a page made of them is a
+ // page a client cannot use. Excluding them after $limit would leave the page short instead.
+ it("excludes objects whose _id is not a string, before the page is measured", async () => {
+ for (const path of ['/search', '/search/phrase']) {
+ mockAggregateResults([])
+
+ await request(routeTester)
+ .post(path)
.set('Content-Type', 'text/plain')
- .send('line')
+ .send('manuscript')
+
+ const pipeline = searchCalls.pipeline
+ assert.deepStrictEqual(stageOf(pipeline, '$match'), { _id: { $type: 'string' } }, path)
+ assert.ok(
+ positionOf(pipeline, '$match') < positionOf(pipeline, '$limit'),
+ `${path} must exclude them before $limit, or the page under-fills`
+ )
+ }
+ })
+})
+
+describe('rel="next" links on /search', () => {
+ /** The Link header of a response as { rel: url }. */
+ const linksOf = (response) => Object.fromEntries(
+ [...(response.headers.link ?? '').matchAll(/<([^>]+)>;\s*rel="([^"]+)"/g)].map(([, url, rel]) => [rel, url])
+ )
+
+ /** The query string of a link target, which may be absolute or path-only. */
+ const paramsOf = (url) => new URL(url, 'http://localhost').searchParams
+
+ const post = (path) => request(routeTester)
+ .post(path)
+ .set('Content-Type', 'text/plain')
+ .send('line')
+
+ const scored = (ids) => ids.map((id, i) => scoredDoc(id, ids.length - i))
+
+ it("links the next page while more records exist, and serves only the page", async () => {
+ mockSearchCollection(scored(['a', 'b', 'c']))
+ const response = await post('/search?limit=2')
+ const links = linksOf(response)
+
+ assert.strictEqual(response.statusCode, 200)
+ assert.deepStrictEqual(idsOf(response), ['a', 'b'])
+ assert.ok(links.next, 'a next link is present while more records exist')
+ assert.strictEqual(paramsOf(links.next).get('limit'), '2')
+ assert.strictEqual(paramsOf(links.next).get('skip'), '2')
+ })
+
+ it("keeps the JSON-LD context link alongside the next link", async () => {
+ mockSearchCollection(scored(['a', 'b', 'c']))
+ const links = linksOf(await post('/search?limit=2'))
+ assert.ok(links['http://www.w3.org/ns/json-ld#context'])
+ assert.ok(links.next)
+ })
+
+ it("omits next on the final page", async () => {
+ mockSearchCollection(scored(['a', 'b']))
+ const response = await post('/search?limit=2')
+
+ assert.deepStrictEqual(idsOf(response), ['a', 'b'])
+ assert.deepStrictEqual(
+ Object.keys(linksOf(response)),
+ ['http://www.w3.org/ns/json-ld#context'],
+ 'only the JSON-LD context link remains'
+ )
+ })
+
+ it("never serves the record read past the page, even at the maximum limit", async () => {
+ mockSearchCollection(scored(['a']))
+ const limitMax = Number((await post('/search?limit=1')).headers['pagination-limit-max'])
+ const ids = Array.from({ length: limitMax + 1 }, (_, i) => `id${String(i).padStart(6, '0')}`)
+ mockSearchCollection(scored(ids))
+
+ const response = await post(`/search?limit=${limitMax + 100}`)
+
+ assert.strictEqual(response.body.length, limitMax)
+ assert.ok(!idsOf(response).includes(ids.at(-1)), 'the extra record must not be served')
+ assert.ok(linksOf(response).next)
+ })
+
+ it("still links next past the skip maximum, so a walk too deep to finish ends in the 400 that names it", async () => {
+ mockSearchCollection(scored(['a']))
+ const skipMax = Number((await post('/search?limit=1')).headers['pagination-skip-max'])
+ // Every page this double serves has a record past it, however deep the skip.
+ db.aggregate.mockImplementation(() => ({
+ toArray: () => Promise.resolve(scored(['a', 'b', 'c']))
+ }))
+
+ const deepest = await post(`/search?limit=2&skip=${skipMax}`)
+ const next = linksOf(deepest).next
+ assert.strictEqual(deepest.statusCode, 200)
+ assert.strictEqual(
+ paramsOf(next).get('skip'),
+ String(skipMax + 2),
+ 'absence would tell the client the walk was complete'
+ )
+
+ const beyond = await post(`/search?${paramsOf(next)}`)
+ assert.strictEqual(beyond.statusCode, 400)
+ assert.strictEqual(beyond.headers['pagination-skip-max'], String(skipMax))
+ })
+
+ it("walks every record exactly once by following only rel=\"next\"", async () => {
+ const ids = ['d01', 'd02', 'd03', 'd04', 'd05', 'd06', 'd07']
+ mockSearchCollection(scored(ids))
+
+ const walked = []
+ let path = '/search?limit=3'
+ for (let requests = 0; path; requests++) {
+ assert.ok(requests < ids.length, 'the walk must terminate')
+ const response = await post(path)
+ assert.strictEqual(response.statusCode, 200)
+ walked.push(...idsOf(response))
+ const next = linksOf(response).next
+ path = next && `/search?${paramsOf(next)}`
}
- assert.deepStrictEqual(idsOf(await page('?limit=2&skip=0')), ['p2-a', 'p2-b'])
- assert.deepStrictEqual(idsOf(await page('?limit=2&skip=2')), ['p3-c', 'p3-d'])
+ assert.deepStrictEqual(walked, ids)
+ })
+
+ it("links the next page of a phrase search the same way", async () => {
+ mockSearchCollection(scored(['a', 'b', 'c']))
+ const response = await request(routeTester)
+ .post('/search/phrase?limit=2')
+ .set('Content-Type', 'text/plain')
+ .send('a line')
+ const links = linksOf(response)
+
+ assert.deepStrictEqual(idsOf(response), ['a', 'b'])
+ assert.ok(links['http://www.w3.org/ns/json-ld#context'])
+ assert.strictEqual(paramsOf(links.next).get('skip'), '2')
})
})
@@ -186,3 +345,55 @@ describe('search pagination parameters', () => {
assert.ok(Number(response.headers['pagination-skip-max']) > 0)
})
})
+
+// searchFuzzily, searchWildly and searchAlikes have no routes yet. They are covered here so the
+// paging contract cannot be lost when they get one.
+describe('the unmounted search variants page the same way', () => {
+ const variantTester = express()
+ variantTester.use(express.json({ type: ['application/json', 'application/ld+json'] }))
+ variantTester.use(express.text())
+ variantTester.post('/fuzzy', searchFuzzily)
+ variantTester.post('/wildcard', searchWildly)
+ variantTester.post('/alike', searchAlikes)
+ variantTester.use(rest.messenger)
+
+ const variants = [
+ { path: '/fuzzy', body: 'manuscrpt', contentType: 'text/plain' },
+ { path: '/wildcard', body: 'man*', contentType: 'text/plain' },
+ { path: '/alike', body: { body: { value: 'a line' } }, contentType: 'application/json' }
+ ]
+
+ for (const { path, body, contentType } of variants) {
+ it(`${path} searches one index, excludes non-string _id before $limit, and over-fetches by one`, async () => {
+ mockAggregateResults([])
+
+ const response = await request(variantTester)
+ .post(`${path}?limit=2&skip=4`)
+ .set('Content-Type', contentType)
+ .send(body)
+
+ assert.strictEqual(response.statusCode, 200)
+ assert.strictEqual(searchCalls.count, 1)
+ const pipeline = searchCalls.pipeline
+ assert.strictEqual(pipeline[0].$search.index, 'annotationText')
+ assert.deepStrictEqual(stageOf(pipeline, '$match'), { _id: { $type: 'string' } })
+ assert.ok(positionOf(pipeline, '$match') < positionOf(pipeline, '$limit'))
+ assert.strictEqual(stageOf(pipeline, '$skip'), 4)
+ assert.strictEqual(stageOf(pipeline, '$limit'), 3)
+ })
+
+ it(`${path} links the next page and serves only the page`, async () => {
+ mockSearchCollection([scoredDoc('a', 9), scoredDoc('b', 5), scoredDoc('c', 3)])
+
+ const response = await request(variantTester)
+ .post(`${path}?limit=2`)
+ .set('Content-Type', contentType)
+ .send(body)
+
+ assert.deepStrictEqual(idsOf(response), ['a', 'b'])
+ const next = (response.headers.link ?? '').match(/<([^>]+)>;\s*rel="next"/)?.[1]
+ assert.ok(next, 'a next link is present while more records exist')
+ assert.strictEqual(new URL(next, 'http://localhost').searchParams.get('skip'), '2')
+ })
+ }
+})
From f1ef165ce436dfd95003074becab7fd6bc23be7d Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 11:34:36 -0500
Subject: [PATCH 02/11] Update search section in API.html and drop dead clauses
from the search pipeline
---
controllers/search.js | 41 +++++++++++------------------------------
public/API.html | 7 ++++++-
2 files changed, 17 insertions(+), 31 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index ca64bf2b..96c189a4 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -36,18 +36,18 @@ const SEARCH_INDEX = "annotationText"
* @description
* Stages, in order:
* - $search, which returns matches in descending relevance score order
- * - $addFields, to carry that score onto the record as __rerum.score
* - $match, to drop records a client could never resolve, before the page is measured
+ * - $addFields, to carry the relevance score onto each surviving record as __rerum.score
* - $skip and $limit, so paging is the database's work rather than this process's
*/
function searchPipelineFor(searchQuery, limit, skip) {
return [
{ $search: searchQuery },
- { $addFields: { "__rerum.score": { $meta: "searchScore" } } },
// Objects whose _id is not a string are legacy data with no addressable URL, so they are not
// served. Atlas Search cannot express a BSON type condition, which is why this is a $match -
// and it has to precede $limit, or the page fills with records the client cannot use.
{ $match: { _id: { $type: "string" } } },
+ { $addFields: { "__rerum.score": { $meta: "searchScore" } } },
{ $skip: skip },
// One record past the page is read only to learn whether another page exists. It is never served.
{ $limit: limit + 1 }
@@ -90,19 +90,24 @@ function serveSearchPage(req, res, page, { limit, skip }) {
*
* @description
* IIIF 3.0 clauses:
- * - Direct text fields: body.value, bodyValue
- * - Embedded items: items.annotations.items.body.value
- * - Annotation items: annotations.items.body.value
+ * - Web Annotation fields: body.value, bodyValue
+ * - Canvas annotations: annotations[].items[].body.value
+ * - AnnotationPage and Manifest items: items[].body.value, items[].annotations[].items[].body.value
*
* IIIF 2.1 clauses:
* - Open Annotation fields: resource.chars, resource.cnt:chars
* - AnnotationList resources: resources[].resource.chars
* - Canvas otherContent: otherContent[].resources[].resource.chars
* - Manifest sequences: sequences[].canvases[].otherContent[].resources[].resource.chars
- * - Nested embeddedDocument operators for multi-level array traversal
*
* Every one of them is a "should" clause of a single compound query, so any one match qualifies and
* a document matching several of them scores higher for it.
+ *
+ * An embeddedDocument clause names the innermost embeddedDocuments path of the index, such as
+ * annotations.items rather than annotations. Scoped to an outer embeddedDocuments path, a clause
+ * cannot see the fields of the one nested inside it, and matches nothing without raising an error.
+ * A Manifest's items[].annotations[].items[] text is reached through the items clause, because the
+ * index maps items as embeddedDocuments and the annotations and items beneath it as plain documents.
*/
function buildSearchPipeline(searchText, operator, limit, skip) {
const searchQuery = {
@@ -116,30 +121,6 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
...operator.options
}
},
- {
- embeddedDocument: {
- path: "items.annotations.items",
- operator: {
- [operator.type]: {
- query: searchText,
- path: ["items.annotations.items.body.value", "items.annotations.items.bodyValue"],
- ...operator.options
- }
- }
- }
- },
- {
- embeddedDocument: {
- path: "annotations",
- operator: {
- [operator.type]: {
- query: searchText,
- path: ["annotations.items.body.value", "annotations.items.bodyValue"],
- ...operator.options
- }
- }
- }
- },
{
embeddedDocument: {
path: "annotations.items",
diff --git a/public/API.html b/public/API.html
index 06aa3aa4..ad55d32a 100644
--- a/public/API.html
+++ b/public/API.html
@@ -568,6 +568,8 @@
Custom Query
return allResults}if (!response.ok) throw new Error(await response.text())
+ const applied = Number(response.headers.get("Pagination-Limit"))
+ if (applied < lim) console.warn(`Asked for ${lim} per page, served ${applied}. The rel="next" link already accounts for it.`)allResults.push(...await response.json())url = response.headers.get("Link")?.match(/<([^>]+)>;\s*rel="next"/)?.[1]}
@@ -637,8 +639,9 @@
Result ordering
- Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in, and consecutive pages tile that order without repeating or missing a record.
+ Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in, and consecutive pages tile that order.
+
Many results share an identical score, and paging is by offset, so a boundary that falls inside a run of equal scores is not pinned to particular records. A walk of a store that is being written to at the same time can repeat or miss a record at a page boundary.
What is not served
A handful of objects in the store were written outside the API, or imported from RERUM v0, and their _id is not a string. They have no addressable URL — there is nothing for a client to follow, read back, or update — so Custom Query, Text Search, and Phrase Search do not return them. They are excluded before a page is measured, so a page still fills to the applied limit while matches remain.
@@ -782,6 +785,8 @@
Text Search
return allResults}if (!response.ok) throw new Error(await response.text())
+ const applied = Number(response.headers.get("Pagination-Limit"))
+ if (applied < lim) console.warn(`Asked for ${lim} per page, served ${applied}. The rel="next" link already accounts for it.`)allResults.push(...await response.json())url = response.headers.get("Link")?.match(/<([^>]+)>;\s*rel="next"/)?.[1]}
From 2d40d53523c4e4113c394b987f22370247168a97 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 11:44:40 -0500
Subject: [PATCH 03/11] comment and documentation cleanup
---
controllers/crud.js | 4 +---
controllers/search.js | 28 ++++------------------------
2 files changed, 5 insertions(+), 27 deletions(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index 810ca8d0..427d9fbc 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -86,9 +86,7 @@ const query = async function (req, res, next) {
}
const { limit, skip } = getPagination(req.query, { res })
try {
- // Objects whose _id is not a string are legacy data with no addressable URL, so they are not
- // served. $and rather than a merge into props, so a client's own _id condition still applies.
- // One record past the page is read only to learn whether another page exists. It is never served.
+ // Objects whose _id is not a string are bad data points, so they are not included.
let matches = await db.find({ $and: [props, { _id: { $type: "string" } }] })
.sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()
const hasNext = matches.length > limit
diff --git a/controllers/search.js b/controllers/search.js
index 96c189a4..171a8b9e 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -10,18 +10,7 @@ import { idNegotiation, getPagination, setNextPageLink } from './utils.js'
/**
* The MongoDB Atlas Search index every search operator queries.
- *
- * It covers both vocabularies RERUM stores - the IIIF Presentation API 3.0 paths (body.value,
- * bodyValue, and the items / annotations embedded documents) and the IIIF Presentation API 2.1
- * paths (resource.chars, resource.cnt:chars, and the resources / otherContent / sequences
- * embedded documents).
- *
- * One index is what makes an honest page possible. Atlas scores each document once across every
- * clause it matched, returns it exactly once, and hands back the whole result set already in
- * descending score order, so there is nothing for this process to merge, deduplicate or sort.
- *
- * The definition lives in Atlas, not in this repository. A path named below that the index does
- * not cover contributes no matches and raises no error.
+ * It covers both the IIIF Presentation API 3.0 paths and the IIIF Presentation API 2.1 paths.
*/
const SEARCH_INDEX = "annotationText"
@@ -43,9 +32,7 @@ const SEARCH_INDEX = "annotationText"
function searchPipelineFor(searchQuery, limit, skip) {
return [
{ $search: searchQuery },
- // Objects whose _id is not a string are legacy data with no addressable URL, so they are not
- // served. Atlas Search cannot express a BSON type condition, which is why this is a $match -
- // and it has to precede $limit, or the page fills with records the client cannot use.
+ // Objects whose _id is not a string are bad data points, so they are not included.
{ $match: { _id: { $type: "string" } } },
{ $addFields: { "__rerum.score": { $meta: "searchScore" } } },
{ $skip: skip },
@@ -79,6 +66,8 @@ function serveSearchPage(req, res, page, { limit, skip }) {
/**
* Builds the MongoDB Atlas Search aggregation pipeline for a text-like search operator.
+ * Every one of them is a "should" clause of a single compound query, so any one match qualifies and
+ * a document matching several of them scores higher for it.
*
* @param {string} searchText - The text query to search for
* @param {Object} operator - Search operator configuration
@@ -99,15 +88,6 @@ function serveSearchPage(req, res, page, { limit, skip }) {
* - AnnotationList resources: resources[].resource.chars
* - Canvas otherContent: otherContent[].resources[].resource.chars
* - Manifest sequences: sequences[].canvases[].otherContent[].resources[].resource.chars
- *
- * Every one of them is a "should" clause of a single compound query, so any one match qualifies and
- * a document matching several of them scores higher for it.
- *
- * An embeddedDocument clause names the innermost embeddedDocuments path of the index, such as
- * annotations.items rather than annotations. Scoped to an outer embeddedDocuments path, a clause
- * cannot see the fields of the one nested inside it, and matches nothing without raising an error.
- * A Manifest's items[].annotations[].items[] text is reached through the items clause, because the
- * index maps items as embeddedDocuments and the annotations and items beneath it as plain documents.
*/
function buildSearchPipeline(searchText, operator, limit, skip) {
const searchQuery = {
From 72ce472382d1d72a9d06b018a27219c05e555fdf Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 11:52:45 -0500
Subject: [PATCH 04/11] cleanup documentation and test
---
public/API.html | 5 ---
routes/__tests__/query.test.js | 2 --
routes/__tests__/search.test.js | 57 ---------------------------------
3 files changed, 64 deletions(-)
diff --git a/public/API.html b/public/API.html
index ad55d32a..950bd953 100644
--- a/public/API.html
+++ b/public/API.html
@@ -641,11 +641,6 @@
Result ordering
Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in, and consecutive pages tile that order.
-
Many results share an identical score, and paging is by offset, so a boundary that falls inside a run of equal scores is not pinned to particular records. A walk of a store that is being written to at the same time can repeat or miss a record at a page boundary.
-
What is not served
-
- A handful of objects in the store were written outside the API, or imported from RERUM v0, and their _id is not a string. They have no addressable URL — there is nothing for a client to follow, read back, or update — so Custom Query, Text Search, and Phrase Search do not return them. They are excluded before a page is measured, so a page still fills to the applied limit while matches remain.
-
Text Search
diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js
index 47dbe65b..4d8e3af9 100644
--- a/routes/__tests__/query.test.js
+++ b/routes/__tests__/query.test.js
@@ -85,8 +85,6 @@ describe('objects whose _id is not a string are not served by /query', () => {
.send(body)
}
- // They are legacy data with no addressable URL: idNegotiation() strips _id, so a client receives
- // bare content it can neither follow nor update.
it("adds the string _id condition to the client's filter", async () => {
const response = await post({ test: "item" })
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index 6c24414f..a2f6f93d 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -121,9 +121,6 @@ describe('search controllers', () => {
assert.strictEqual(response.body[0]['@id'], doc['@id'])
})
- // One index returns every matching document exactly once, in score order, so there is nothing for
- // the controller to merge, deduplicate or sort. A regression back to a per-index fan-out would
- // show up here as a second aggregation.
it("searchAsWords runs one search, against the index that covers both vocabularies", async () => {
mockAggregateResults([])
@@ -180,8 +177,6 @@ describe('search controllers', () => {
assert.strictEqual(stageOf(searchCalls.pipeline, '$limit'), 3)
})
- // Legacy objects whose _id is not a string have no addressable URL, so a page made of them is a
- // page a client cannot use. Excluding them after $limit would leave the page short instead.
it("excludes objects whose _id is not a string, before the page is measured", async () => {
for (const path of ['/search', '/search/phrase']) {
mockAggregateResults([])
@@ -345,55 +340,3 @@ describe('search pagination parameters', () => {
assert.ok(Number(response.headers['pagination-skip-max']) > 0)
})
})
-
-// searchFuzzily, searchWildly and searchAlikes have no routes yet. They are covered here so the
-// paging contract cannot be lost when they get one.
-describe('the unmounted search variants page the same way', () => {
- const variantTester = express()
- variantTester.use(express.json({ type: ['application/json', 'application/ld+json'] }))
- variantTester.use(express.text())
- variantTester.post('/fuzzy', searchFuzzily)
- variantTester.post('/wildcard', searchWildly)
- variantTester.post('/alike', searchAlikes)
- variantTester.use(rest.messenger)
-
- const variants = [
- { path: '/fuzzy', body: 'manuscrpt', contentType: 'text/plain' },
- { path: '/wildcard', body: 'man*', contentType: 'text/plain' },
- { path: '/alike', body: { body: { value: 'a line' } }, contentType: 'application/json' }
- ]
-
- for (const { path, body, contentType } of variants) {
- it(`${path} searches one index, excludes non-string _id before $limit, and over-fetches by one`, async () => {
- mockAggregateResults([])
-
- const response = await request(variantTester)
- .post(`${path}?limit=2&skip=4`)
- .set('Content-Type', contentType)
- .send(body)
-
- assert.strictEqual(response.statusCode, 200)
- assert.strictEqual(searchCalls.count, 1)
- const pipeline = searchCalls.pipeline
- assert.strictEqual(pipeline[0].$search.index, 'annotationText')
- assert.deepStrictEqual(stageOf(pipeline, '$match'), { _id: { $type: 'string' } })
- assert.ok(positionOf(pipeline, '$match') < positionOf(pipeline, '$limit'))
- assert.strictEqual(stageOf(pipeline, '$skip'), 4)
- assert.strictEqual(stageOf(pipeline, '$limit'), 3)
- })
-
- it(`${path} links the next page and serves only the page`, async () => {
- mockSearchCollection([scoredDoc('a', 9), scoredDoc('b', 5), scoredDoc('c', 3)])
-
- const response = await request(variantTester)
- .post(`${path}?limit=2`)
- .set('Content-Type', contentType)
- .send(body)
-
- assert.deepStrictEqual(idsOf(response), ['a', 'b'])
- const next = (response.headers.link ?? '').match(/<([^>]+)>;\s*rel="next"/)?.[1]
- assert.ok(next, 'a next link is present while more records exist')
- assert.strictEqual(new URL(next, 'http://localhost').searchParams.get('skip'), '2')
- })
- }
-})
From 9b6aca7a4b129cbbde08e7c254533061709cbaff Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 12:34:29 -0500
Subject: [PATCH 05/11] Changes to search input guard so that 400s occur as
expected instead of unexpected 500s
---
controllers/search.js | 8 ++++----
routes/__tests__/search.test.js | 17 ++++++++++++++++-
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index 171a8b9e..d9f073fa 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -230,7 +230,7 @@ const searchAsWords = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let searchText = req.body?.searchText ?? req.body
const searchOptions = req.body?.options ?? {}
- if (!searchText) {
+ if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
status: 400
@@ -309,7 +309,7 @@ const searchAsPhrase = async function (req, res, next) {
{
slop: 2
}
- if (!searchText) {
+ if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
status: 400
@@ -380,7 +380,7 @@ const searchFuzzily = async function (req, res, next) {
maxExpansions: 50
}
}
- if (!searchText) {
+ if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
status: 400
@@ -459,7 +459,7 @@ const searchWildly = async function (req, res, next) {
{
allowAnalyzedField: true
}
- if (!searchText) {
+ if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
status: 400
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index a2f6f93d..756891a7 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -5,7 +5,6 @@ import express from 'express'
import request from 'supertest'
import controller from '../../db-controller.js'
-import { searchFuzzily, searchWildly, searchAlikes } from '../../controllers/search.js'
import rest from '../../rest.js'
import { db, resetMocks } from '../../database/index.js'
@@ -102,6 +101,22 @@ describe('search controllers', () => {
assert.strictEqual(response.statusCode, 400)
})
+ it("returns 400 without searching when the JSON body has no searchText string", async () => {
+ for (const path of ['/search', '/search/phrase']) {
+ for (const body of [{ text: 'line' }, { searchText: 5 }, { searchText: ['line'] }]) {
+ mockAggregateResults([])
+
+ const response = await request(routeTester)
+ .post(path)
+ .set('Content-Type', 'application/json')
+ .send(body)
+
+ assert.strictEqual(response.statusCode, 400, `${path} ${JSON.stringify(body)}`)
+ assert.strictEqual(searchCalls.count, 0, `${path} ${JSON.stringify(body)} must not reach the database`)
+ }
+ }
+ })
+
it("searchAsPhrase returns 200 and an array of results for a text body", async () => {
const doc = {
_id: 'doc-2',
From 14ee76910474080884ce30c6175d9e5b3454d80d Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 13:10:30 -0500
Subject: [PATCH 06/11] Changes during testing and review
---
controllers/crud.js | 1 +
controllers/search.js | 92 ++++++++++++++++++++++++---------
public/API.html | 8 +--
routes/__tests__/search.test.js | 46 ++++++++++++++++-
4 files changed, 118 insertions(+), 29 deletions(-)
diff --git a/controllers/crud.js b/controllers/crud.js
index 427d9fbc..dd9c4f82 100644
--- a/controllers/crud.js
+++ b/controllers/crud.js
@@ -87,6 +87,7 @@ const query = async function (req, res, next) {
const { limit, skip } = getPagination(req.query, { res })
try {
// Objects whose _id is not a string are bad data points, so they are not included.
+ // One record past the page is read only to learn whether another page exists.
let matches = await db.find({ $and: [props, { _id: { $type: "string" } }] })
.sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()
const hasNext = matches.length > limit
diff --git a/controllers/search.js b/controllers/search.js
index d9f073fa..42335d45 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -64,6 +64,23 @@ function serveSearchPage(req, res, page, { limit, skip }) {
res.json(results)
}
+/**
+ * Reads the operator options a client sent in a JSON request body.
+ *
+ * @param {Object|string} body - The request body
+ * @param {Object} defaults - The options to use when the client sent none
+ * @returns {Object|null} The options to hand to the operator, or null when they are not a JSON object
+ *
+ * @description
+ * Atlas rejects malformed operator options with an error that cannot be told apart from a server fault,
+ * so options that are not a JSON object are refused here, before they reach the $search stage.
+ */
+function readSearchOptions(body, defaults) {
+ const options = body?.options ?? defaults
+ if (typeof options !== "object" || Array.isArray(options)) return null
+ return options
+}
+
/**
* Builds the MongoDB Atlas Search aggregation pipeline for a text-like search operator.
* Every one of them is a "should" clause of a single compound query, so any one match qualifies and
@@ -187,9 +204,10 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
/**
* Standard text search endpoint - searches for exact word matches across both IIIF 3.0 and 2.1 resources.
*
- * @route POST /search
+ * @route POST /v1/api/search
* @param {Object} req.body - Request body containing search text
* @param {string} req.body.searchText - The text to search for (can also be a plain string body)
+ * @param {Object} [req.body.options] - Text operator options, which must be a JSON object
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
* @returns {Array} JSON array of matching annotation objects sorted by relevance score
@@ -198,15 +216,16 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
* Performs a standard MongoDB Atlas Search text query that:
* - Tokenizes the search text into words
* - Searches for exact word matches (case-insensitive)
- * - Applies standard linguistic analysis (stemming, stop words, etc.)
+ * - Uses the lucene.standard analyzer, which lowercases words but neither stems them nor removes stop words
* - Searches both IIIF Presentation API 3.0 and 2.1 fields in one query, against one index
* - Pages in the database, and carries a rel="next" Link header while another page exists
* - Returns results sorted by relevance score (highest first)
*
* Search Behavior:
- * - "Bryan Haberberger" → finds documents containing both "Bryan" AND "Haberberger"
+ * - "Bryan Haberberger" → finds documents containing "Bryan" OR "Haberberger", usually ranking documents that contain both higher
* - Searches are case-insensitive
- * - Standard analyzer removes common stop words
+ * - Words are not stemmed, so "transcribe" does not match "transcribing"
+ * - Stop words such as "the" are not removed, and are searched like any other word
* - Partial word matches are NOT supported (use wildcardSearch for that)
*
* IIIF 3.0 Fields Searched:
@@ -221,15 +240,15 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
* - sequences[].canvases[].otherContent[].resources[].resource.chars (Manifest)
*
* @example
- * POST /search
+ * POST /v1/api/search
* Body: {"searchText": "Hello World"}
- * Returns: All annotations containing "Hello" and "World"
+ * Returns: All annotations containing "Hello" or "World"
*
*/
const searchAsWords = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let searchText = req.body?.searchText ?? req.body
- const searchOptions = req.body?.options ?? {}
+ const searchOptions = readSearchOptions(req.body, {})
if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
@@ -237,6 +256,13 @@ const searchAsWords = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
+ if (!searchOptions) {
+ let err = {
+ message: "The 'options' property of the search request must be a JSON object.",
+ status: 400
+ }
+ return next(utils.createExpressError(err))
+ }
const { limit, skip } = getPagination(req.query, { res })
const pipeline = buildSearchPipeline(searchText, { type: "text", options: searchOptions }, limit, skip)
try {
@@ -250,9 +276,10 @@ const searchAsWords = async function (req, res, next) {
/**
* Phrase search endpoint - searches for multi-word phrases with words in proximity.
*
- * @route POST /phraseSearch
+ * @route POST /v1/api/search/phrase
* @param {Object} req.body - Request body containing search phrase
* @param {string} req.body.searchText - The phrase to search for (can also be a plain string body)
+ * @param {Object} [req.body.options] - Phrase operator options, which must be a JSON object whose slop is a whole number
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
* @returns {Array} JSON array of matching annotation objects sorted by relevance score
@@ -288,7 +315,7 @@ const searchAsWords = async function (req, res, next) {
* - More precise than standard search, more flexible than exact match
*
* Comparison with Other Search Types:
- * - Standard search: Finds "Bryan" AND "Haberberger" anywhere in document
+ * - Standard search: Finds "Bryan" OR "Haberberger" anywhere in document
* - Phrase search: Finds "Bryan" near "Haberberger" (within 2 words)
* - Exact match: Would require "Bryan Haberberger" with no intervening words
*
@@ -298,17 +325,14 @@ const searchAsWords = async function (req, res, next) {
* - Good balance of precision and recall
*
* @example
- * POST /phraseSearch
+ * POST /v1/api/search/phrase
* Body: "medieval manuscript"
* Returns: Annotations with "medieval" and "manuscript" in proximity
*/
const searchAsPhrase = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let searchText = req.body?.searchText ?? req.body
- const phraseOptions = req.body?.options ??
- {
- slop: 2
- }
+ const phraseOptions = readSearchOptions(req.body, { slop: 2 })
if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
@@ -316,6 +340,14 @@ const searchAsPhrase = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
+ const slopIsValid = !("slop" in (phraseOptions ?? {})) || (Number.isInteger(phraseOptions.slop) && phraseOptions.slop >= 0)
+ if (!phraseOptions || !slopIsValid) {
+ let err = {
+ message: "The 'options' property of the search request must be a JSON object, and its 'slop' a whole number.",
+ status: 400
+ }
+ return next(utils.createExpressError(err))
+ }
const { limit, skip } = getPagination(req.query, { res })
const pipeline = buildSearchPipeline(searchText, { type: "phrase", options: phraseOptions }, limit, skip)
try {
@@ -329,9 +361,10 @@ const searchAsPhrase = async function (req, res, next) {
/**
* Fuzzy text search endpoint - searches for approximate matches allowing for typos and misspellings.
*
- * @route POST /fuzzySearch
+ * @route Not mounted. See the note at the end of routes/search.js.
* @param {Object} req.body - Request body containing search text
* @param {string} req.body.searchText - The text to search for (can also be a plain string body)
+ * @param {Object} [req.body.options] - Text operator options, which must be a JSON object
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
* @returns {Array} JSON array of matching annotation objects sorted by relevance score
@@ -372,14 +405,13 @@ const searchAsPhrase = async function (req, res, next) {
const searchFuzzily = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let searchText = req.body?.searchText ?? req.body
- const fuzzyOptions = req.body?.options ??
- {
+ const fuzzyOptions = readSearchOptions(req.body, {
fuzzy: {
maxEdits: 1,
prefixLength: 2,
maxExpansions: 50
}
- }
+ })
if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
@@ -387,6 +419,13 @@ const searchFuzzily = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
+ if (!fuzzyOptions) {
+ let err = {
+ message: "The 'options' property of the search request must be a JSON object.",
+ status: 400
+ }
+ return next(utils.createExpressError(err))
+ }
const { limit, skip } = getPagination(req.query, { res })
const pipeline = buildSearchPipeline(searchText, { type: "text", options: fuzzyOptions }, limit, skip)
try {
@@ -400,9 +439,10 @@ const searchFuzzily = async function (req, res, next) {
/**
* Wildcard pattern search endpoint - searches using wildcard patterns for partial matches.
*
- * @route POST /wildcardSearch
+ * @route Not mounted. See the note at the end of routes/search.js.
* @param {Object} req.body - Request body containing search pattern
* @param {string} req.body.searchText - The wildcard pattern to search for (must contain * or ?)
+ * @param {Object} [req.body.options] - Wildcard operator options, which must be a JSON object
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
* @returns {Array} JSON array of matching annotation objects sorted by relevance score
@@ -455,10 +495,7 @@ const searchFuzzily = async function (req, res, next) {
const searchWildly = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let searchText = req.body?.searchText ?? req.body
- const wildcardOptions = req.body?.options ??
- {
- allowAnalyzedField: true
- }
+ const wildcardOptions = readSearchOptions(req.body, { allowAnalyzedField: true })
if (typeof searchText !== "string" || !searchText) {
let err = {
message: "You did not provide text to search for in the search request.",
@@ -466,6 +503,13 @@ const searchWildly = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
+ if (!wildcardOptions) {
+ let err = {
+ message: "The 'options' property of the search request must be a JSON object.",
+ status: 400
+ }
+ return next(utils.createExpressError(err))
+ }
// Require wildcards in the search text
if (!searchText.includes('*') && !searchText.includes('?')) {
let err = {
@@ -487,7 +531,7 @@ const searchWildly = async function (req, res, next) {
/**
* "More Like This" search endpoint - finds documents similar to a provided example document.
*
- * @route POST /searchAlikes
+ * @route Not mounted. See the note at the end of routes/search.js.
* @param {Object} req.body - A complete JSON document to use as the search example
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
diff --git a/public/API.html b/public/API.html
index 950bd953..191a6541 100644
--- a/public/API.html
+++ b/public/API.html
@@ -665,7 +665,7 @@
Text Search
Response: [{JSON}]—An array of annotation objects matching the search, sorted by relevance score
- The Text Search endpoint performs a full-text search across Annotation text content. It searches for exact word matches and tokenizes the search text, finding documents that contain all the search terms anywhere in their text content.
+ The Text Search endpoint performs a full-text search across Annotation text content. It tokenizes the search text into words and matches each word exactly, finding documents that contain any of the search terms anywhere in their text content.
The search covers multiple text fields depending on the syntax of objects. In particular it covers the current Web Annotation syntax, IIIF Presentation API 3.0 syntax, and IIIF Presentation API 2.1 syntax⚠️. See below for specific details.
@@ -685,8 +685,8 @@
Text Search
Results will only include Web Annotation, IIIF Presentation API 3.0, and IIIF Presentation API 2.1⚠️ resource types that have the text embedded within their structure.
Searches are case-insensitive
Partial word matches and wildcards are NOT supported in this search
-
Standard linguistic analysis is applied (stemming, stop words, etc.)
-
Multi-word searches find documents containing all the words (AND logic)
+
Words are not stemmed, so "transcribe" does not match "transcribing". Common words such as "the" are not removed, and are searched like any other word.
+
Multi-word searches find documents containing any of the words (OR logic). Documents that contain more of the words usually score higher. To find words that appear together, use Phrase Search.
Results are sorted by relevance score (highest first)
A __rerum.score property is added to each result indicating match quality
@@ -697,7 +697,7 @@
Text Search
General full-text search across annotation content
Finding all annotations containing specific keywords or concepts
Broad discovery when exact word order doesn't matter
-
Best for single-word searches or when all search terms should appear anywhere in the document
+
Best for single-word searches, or when a document may match on any one of the search terms
Javascript Example (JSON Object)
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index 756891a7..d7ad4329 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -55,7 +55,7 @@ function mockSearchCollection(docs) {
})
}
-/** A search hit carrying its relevance where the branch pipelines actually put it. */
+/** A search hit carrying its relevance where the search pipeline puts it. */
const scoredDoc = (id, score) => ({
_id: id,
'@id': `https://store.rerum.io/v1/id/${id}`,
@@ -117,6 +117,50 @@ describe('search controllers', () => {
}
})
+ it("returns 400 without searching when options is not a JSON object", async () => {
+ for (const path of ['/search', '/search/phrase']) {
+ for (const options of ['x', ['x'], 5]) {
+ mockAggregateResults([])
+
+ const response = await request(routeTester)
+ .post(path)
+ .set('Content-Type', 'application/json')
+ .send({ searchText: 'a line', options })
+
+ assert.strictEqual(response.statusCode, 400, `${path} ${JSON.stringify(options)}`)
+ assert.strictEqual(searchCalls.count, 0, `${path} ${JSON.stringify(options)} must not reach the database`)
+ }
+ }
+ })
+
+ it("returns 400 without searching when a phrase search's slop is not a whole number", async () => {
+ for (const slop of ['x', -1, 1.5]) {
+ mockAggregateResults([])
+
+ const response = await request(routeTester)
+ .post('/search/phrase')
+ .set('Content-Type', 'application/json')
+ .send({ searchText: 'a line', options: { slop } })
+
+ assert.strictEqual(response.statusCode, 400, JSON.stringify(slop))
+ assert.strictEqual(searchCalls.count, 0, `slop ${JSON.stringify(slop)} must not reach the database`)
+ }
+ })
+
+ it("hands a client's options to the phrase operator, and the default slop when there are none", async () => {
+ for (const [options, slop] of [[{ slop: 5 }, 5], [null, 2]]) {
+ mockAggregateResults([])
+
+ const response = await request(routeTester)
+ .post('/search/phrase')
+ .set('Content-Type', 'application/json')
+ .send({ searchText: 'a line', options })
+
+ assert.strictEqual(response.statusCode, 200, JSON.stringify(options))
+ assert.strictEqual(searchCalls.pipeline[0].$search.compound.should[0].phrase.slop, slop)
+ }
+ })
+
it("searchAsPhrase returns 200 and an array of results for a text body", async () => {
const doc = {
_id: 'doc-2',
From 4665e32bcef612c33896c213bb13decd43aa699f Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 13:26:48 -0500
Subject: [PATCH 07/11] Changes during testing and review
---
controllers/search.js | 36 ++++++++++++++++++---------------
public/API.html | 3 ++-
routes/__tests__/search.test.js | 25 +++++++++++++++++++++++
3 files changed, 47 insertions(+), 17 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index 42335d45..c4403c8e 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -95,6 +95,9 @@ function readSearchOptions(body, defaults) {
* @returns {Array} The aggregation pipeline to hand to db.aggregate()
*
* @description
+ * The options are applied first in every clause, so they can tune the operator but never replace
+ * the validated searchText or the paths the clause searches.
+ *
* IIIF 3.0 clauses:
* - Web Annotation fields: body.value, bodyValue
* - Canvas annotations: annotations[].items[].body.value
@@ -113,9 +116,9 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
should: [
{
[operator.type]: {
+ ...operator.options,
query: searchText,
- path: ["body.value", "bodyValue"],
- ...operator.options
+ path: ["body.value", "bodyValue"]
}
},
{
@@ -123,9 +126,9 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
path: "annotations.items",
operator: {
[operator.type]: {
+ ...operator.options,
query: searchText,
- path: ["annotations.items.body.value", "annotations.items.bodyValue"],
- ...operator.options
+ path: ["annotations.items.body.value", "annotations.items.bodyValue"]
}
}
}
@@ -135,23 +138,23 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
path: "items",
operator: {
[operator.type]: {
+ ...operator.options,
query: searchText,
path: [
"items.body.value",
"items.bodyValue",
"items.annotations.items.body.value",
"items.annotations.items.bodyValue"
- ],
- ...operator.options
+ ]
}
}
}
},
{
[operator.type]: {
+ ...operator.options,
query: searchText,
- path: ["resource.chars", "resource.cnt:chars"],
- ...operator.options
+ path: ["resource.chars", "resource.cnt:chars"]
}
},
{
@@ -159,9 +162,9 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
path: "resources",
operator: {
[operator.type]: {
+ ...operator.options,
query: searchText,
- path: ["resources.resource.chars", "resources.resource.cnt:chars"],
- ...operator.options
+ path: ["resources.resource.chars", "resources.resource.cnt:chars"]
}
}
}
@@ -171,9 +174,9 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
path: "otherContent.resources",
operator: {
[operator.type]: {
+ ...operator.options,
query: searchText,
- path: ["otherContent.resources.resource.chars", "otherContent.resources.resource.cnt:chars"],
- ...operator.options
+ path: ["otherContent.resources.resource.chars", "otherContent.resources.resource.cnt:chars"]
}
}
}
@@ -183,12 +186,12 @@ function buildSearchPipeline(searchText, operator, limit, skip) {
path: "sequences.canvases.otherContent.resources",
operator: {
[operator.type]: {
+ ...operator.options,
query: searchText,
path: [
"sequences.canvases.otherContent.resources.resource.chars",
"sequences.canvases.otherContent.resources.resource.cnt:chars"
- ],
- ...operator.options
+ ]
}
}
}
@@ -305,8 +308,9 @@ const searchAsWords = async function (req, res, next) {
* - "manuscript illumination" → matches:
* ✓ "manuscript illumination"
* ✓ "manuscript and illumination"
- * ✓ "illumination of manuscript" (reversed order with slop)
- * ✓ "illuminated manuscript"
+ * ✓ "illumination manuscript" (adjacent words in reverse order cost a slop of 2)
+ * ✗ "illumination of manuscript" (reverse order with a word between needs a slop of 3)
+ * ✗ "illuminated manuscript" (words are not stemmed)
*
* Use Cases:
* - Finding exact or near-exact phrases
diff --git a/public/API.html b/public/API.html
index 191a6541..4f92c2ed 100644
--- a/public/API.html
+++ b/public/API.html
@@ -847,7 +847,8 @@
Phrase Search
✓ "medieval manuscript"
✓ "medieval illuminated manuscript"
-
✓ "manuscript from medieval times"
+
✓ "manuscript medieval" (adjacent words in reverse order use a slop of 2)
+
✗ "manuscript from medieval times" (reverse order with a word between needs a slop of 3)
✗ "medieval art with many beautiful decorated manuscripts" (too many words between)
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index d7ad4329..9279f7d3 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -161,6 +161,31 @@ describe('search controllers', () => {
}
})
+ it("never lets options replace the searchText or the paths a clause searches", async () => {
+ /** The operator document of every should clause, whether it is top level or inside embeddedDocument. */
+ const operatorsOf = (pipeline, type) => pipeline[0].$search.compound.should
+ .map(clause => (clause.embeddedDocument?.operator ?? clause)[type])
+ const search = async (path, options) => {
+ mockAggregateResults([])
+ const response = await request(routeTester)
+ .post(path)
+ .set('Content-Type', 'application/json')
+ .send({ searchText: 'a line', options })
+ assert.strictEqual(response.statusCode, 200, `${path} ${JSON.stringify(options)}`)
+ return searchCalls.pipeline
+ }
+
+ for (const [path, type] of [['/search', 'text'], ['/search/phrase', 'phrase']]) {
+ const expectedPaths = operatorsOf(await search(path, {}), type).map(op => op.path)
+ const operators = operatorsOf(await search(path, { query: '', path: 'x', slop: 3 }), type)
+
+ assert.ok(operators.length > 0, path)
+ for (const op of operators) assert.strictEqual(op.query, 'a line', `${path} must search the searchText`)
+ assert.deepStrictEqual(operators.map(op => op.path), expectedPaths, `${path} must search its own paths`)
+ assert.ok(operators.every(op => op.slop === 3), `${path} still applies the client's other options`)
+ }
+ })
+
it("searchAsPhrase returns 200 and an array of results for a text body", async () => {
const doc = {
_id: 'doc-2',
From 1afef1a3556395b23e752238bbf0cc61145bf72b Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 13:44:05 -0500
Subject: [PATCH 08/11] Touch ups from testing and review
---
controllers/search.js | 20 ++++++++------------
public/API.html | 2 +-
routes/__tests__/search.test.js | 14 --------------
3 files changed, 9 insertions(+), 27 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index c4403c8e..d6a520c7 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -42,7 +42,8 @@ function searchPipelineFor(searchQuery, limit, skip) {
}
/**
- * Serves one page of search results.
+ * Serves one page of search results. The over-fetched record is the only evidence
+ * that another page exists. It is trimmed here and never serialized.
*
* @param {Object} req - Express request, which the next-page link is built from
* @param {Object} res - Express response
@@ -52,9 +53,7 @@ function searchPipelineFor(searchQuery, limit, skip) {
* @param {number} pagination.skip - The applied skip
*
* @description
- * The over-fetched record is the only evidence that another page exists. It is trimmed here and
- * never serialized. The JSON-LD headers go on first because configureLDHeadersFor() replaces Link
- * while setNextPageLink() appends to it; reversing the two would discard the next-page link.
+
*/
function serveSearchPage(req, res, page, { limit, skip }) {
const hasNext = page.length > limit
@@ -66,14 +65,12 @@ function serveSearchPage(req, res, page, { limit, skip }) {
/**
* Reads the operator options a client sent in a JSON request body.
+ * Atlas rejects malformed operator options with an error that cannot be told apart from a server fault,
+ * so options that are not a JSON object are refused here, before they reach the $search stage.
*
* @param {Object|string} body - The request body
* @param {Object} defaults - The options to use when the client sent none
* @returns {Object|null} The options to hand to the operator, or null when they are not a JSON object
- *
- * @description
- * Atlas rejects malformed operator options with an error that cannot be told apart from a server fault,
- * so options that are not a JSON object are refused here, before they reach the $search stage.
*/
function readSearchOptions(body, defaults) {
const options = body?.options ?? defaults
@@ -282,7 +279,7 @@ const searchAsWords = async function (req, res, next) {
* @route POST /v1/api/search/phrase
* @param {Object} req.body - Request body containing search phrase
* @param {string} req.body.searchText - The phrase to search for (can also be a plain string body)
- * @param {Object} [req.body.options] - Phrase operator options, which must be a JSON object whose slop is a whole number
+ * @param {Object} [req.body.options] - Phrase operator options, which must be a JSON object
* @param {number} [req.query.limit=100] - Maximum number of results to return
* @param {number} [req.query.skip=0] - Number of results to skip for pagination
* @returns {Array} JSON array of matching annotation objects sorted by relevance score
@@ -344,10 +341,9 @@ const searchAsPhrase = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
- const slopIsValid = !("slop" in (phraseOptions ?? {})) || (Number.isInteger(phraseOptions.slop) && phraseOptions.slop >= 0)
- if (!phraseOptions || !slopIsValid) {
+ if (!phraseOptions) {
let err = {
- message: "The 'options' property of the search request must be a JSON object, and its 'slop' a whole number.",
+ message: "The 'options' property of the search request must be a JSON object.",
status: 400
}
return next(utils.createExpressError(err))
diff --git a/public/API.html b/public/API.html
index 4f92c2ed..cf7ba4a2 100644
--- a/public/API.html
+++ b/public/API.html
@@ -639,7 +639,7 @@
Result ordering
- Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in, and consecutive pages tile that order.
+ Text Search and Phrase Search page in descending relevance score, which you can read back from __rerum.score on each result. Every object is scored once against one index that covers both the IIIF 3.0 and the IIIF 2.1 text fields, so the best match is the first record of the first page whichever shape it was written in.
Text Search
diff --git a/routes/__tests__/search.test.js b/routes/__tests__/search.test.js
index 9279f7d3..6d841ccc 100644
--- a/routes/__tests__/search.test.js
+++ b/routes/__tests__/search.test.js
@@ -133,20 +133,6 @@ describe('search controllers', () => {
}
})
- it("returns 400 without searching when a phrase search's slop is not a whole number", async () => {
- for (const slop of ['x', -1, 1.5]) {
- mockAggregateResults([])
-
- const response = await request(routeTester)
- .post('/search/phrase')
- .set('Content-Type', 'application/json')
- .send({ searchText: 'a line', options: { slop } })
-
- assert.strictEqual(response.statusCode, 400, JSON.stringify(slop))
- assert.strictEqual(searchCalls.count, 0, `slop ${JSON.stringify(slop)} must not reach the database`)
- }
- })
-
it("hands a client's options to the phrase operator, and the default slop when there are none", async () => {
for (const [options, slop] of [[{ slop: 5 }, 5], [null, 2]]) {
mockAggregateResults([])
From 682ee2e6edc123eb29ca446dfa1f512494b070fe Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 13:46:57 -0500
Subject: [PATCH 09/11] Touch ups from testing and review
---
controllers/search.js | 3 ---
1 file changed, 3 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index d6a520c7..4fd6566c 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -51,9 +51,6 @@ function searchPipelineFor(searchQuery, limit, skip) {
* @param {Object} pagination - The values getPagination() applied
* @param {number} pagination.limit - The applied limit
* @param {number} pagination.skip - The applied skip
- *
- * @description
-
*/
function serveSearchPage(req, res, page, { limit, skip }) {
const hasNext = page.length > limit
From 24ae47e8c2e027e1ab0444dda5ca9f558eafe199 Mon Sep 17 00:00:00 2001
From: Bryan Haberberger
Date: Fri, 18 Sep 2026 14:06:56 -0500
Subject: [PATCH 10/11] touch ups from testing and review
---
controllers/search.js | 1 +
public/API.html | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/controllers/search.js b/controllers/search.js
index 4fd6566c..4cc26bf6 100644
--- a/controllers/search.js
+++ b/controllers/search.js
@@ -291,6 +291,7 @@ const searchAsWords = async function (req, res, next) {
*
* Phrase Options:
* - slop: 2 (allows up to 2 words between search terms)
+ * - Options a client sends replace these defaults entirely. Without a slop of their own, the slop is 0.
*
* Phrase Matching Examples (with slop: 2):
* - "Bryan Haberberger" → matches:
diff --git a/public/API.html b/public/API.html
index cf7ba4a2..9958480e 100644
--- a/public/API.html
+++ b/public/API.html
@@ -808,7 +808,7 @@
Phrase Search
-
{JSON}—An object with a searchText property containing the phrase to search for, and an optional options property (default slop: 2)
+
{JSON}—An object with a searchText property containing the phrase to search for, and an optional options property (default {"slop": 2})
"string"—Alternatively, a plain string phrase to search for
Response: [{JSON}]—An array of annotation objects matching the phrase search, sorted by relevance score
@@ -833,7 +833,7 @@
Phrase Search
Results will only include Web Annotation, IIIF Presentation API 3.0, and IIIF Presentation API 2.1⚠️ resource types that have the text embedded within their structure.
Searches are case-insensitive
Partial word matches and wildcards are NOT supported in this search
-
Uses a "slop" value (default: 2) that allows intervening words between search terms (up to the default or provided value). You may supply your own slop option.
+
Uses a "slop" value (default: 2) that allows intervening words between search terms (up to the default or provided value). You may supply your own slop option. Any options you send replace the default entirely, so include slop in them. Without it the slop is 0, and the words must be adjacent and in order.
Words don't need to be directly adjacent, providing flexibility while maintaining phrase coherence
More precise than standard text search for multi-word queries
Results are sorted by relevance score (highest first)
From 03f19ca7d3f6fe82d53ede2726074e33e0f39d20 Mon Sep 17 00:00:00 2001
From: cubap
Date: Thu, 24 Sep 2026 13:03:05 -0500
Subject: [PATCH 11/11] docs: document that non-string _id objects are not
served
#313 asks public/API.html to state that objects whose _id is not a string
are not served. The exclusion was documented in the OpenAPI contract only.
---
public/API.html | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/public/API.html b/public/API.html
index 9958480e..d8a34f82 100644
--- a/public/API.html
+++ b/public/API.html
@@ -545,7 +545,7 @@
Custom Query
]
- All responses are a JSON Array, even if a single or zero records are matched. Results are in ascending _id order — see Pagination parameters.
+ All responses are a JSON Array, even if a single or zero records are matched. Results are in ascending _id order — see Pagination parameters. Objects whose _id is not a string are not served.
@@ -688,6 +688,7 @@
Text Search
Words are not stemmed, so "transcribe" does not match "transcribing". Common words such as "the" are not removed, and are searched like any other word.
Multi-word searches find documents containing any of the words (OR logic). Documents that contain more of the words usually score higher. To find words that appear together, use Phrase Search.
Results are sorted by relevance score (highest first)
+
Objects whose _id is not a string are not served
A __rerum.score property is added to each result indicating match quality
@@ -837,6 +838,7 @@
Phrase Search
Words don't need to be directly adjacent, providing flexibility while maintaining phrase coherence
More precise than standard text search for multi-word queries
Results are sorted by relevance score (highest first)
+
Objects whose _id is not a string are not served
A __rerum.score property is added to each result indicating match quality