Skip to content

Objects whose _id is not a string are served by /query and dropped by accident in /search #313

Closed

Description

@thehabes

Summary

Objects whose _id is not a string are old, bad data and do not need to appear in /query or /search results. Today both endpoints serve them, and /search removes some of them by accident rather than by intent.

Exclude them where the results are gathered:

  • controllers/crud.js:90 — db.find(props) applies only the client's filter.
  • controllers/search.js:209,214 — the two $search pipelines for /search and /search/phrase, plus :640,658 for the unmounted searchAlikes.

A {"_id": {"$type": "string"}} condition in those four places makes the exclusion deliberate, fills pages back to limit, and retires the dedup-key problem described in #307, because strings key uniquely under toString().

Why this matters

The data

Measured 2026-09-17, read-only, by paging /query to the first skip that returns nothing:

Collection Non-string _id ObjectId _id Embedded-object _id
store.rerum.io 3 3 0
dev (localhost:3001, same collection as devstore.rerum.io) over 100000 over 100000 6427

The three on production are test junk with no @id and no __rerum: {"born":"today"}, {"New":"Item","Who":"Dis?"}, {"a":"same","b":"same"}. They were inserted outside the API, since nothing in the write path produces a document in that shape. The dev population is the v0 import from 165.134.105.29/annotationstore, whose @id values are the serialized Java ObjectId interpolated into a URL and are not resolvable.

/query serves them

POST /v1/api/query?limit=3  {"_id":{"$not":{"$type":"string"}}}
-> 200, three documents, on dev and on production

They also appear in any broad query that happens to match them. A client cannot do anything with them: idNegotiation() strips _id, so a document with no @id arrives as bare content like {"born":"today"}, with no identity to follow or update.

/search removes them by accident, and under-fills its pages

mergeSearchResults() keys deduplication on result._id?.$oid || result._id?.toString() (controllers/search.js:37). Every embedded-object _id keys to "[object Object]", so one such document per response survives and the rest are discarded. The outcome is close to what we want, but the mechanism is wrong in two visible ways:

Filtering in the pipeline fixes both: the page is filled from documents that are actually servable.

It is not measurably more expensive

/query at limit=500&skip=100000 on dev, three runs each:

Filter Timings
{"_id":{"$exists":true}} 541ms, 483ms, 365ms
{"$and":[{"_id":{"$exists":true}},{"_id":{"$type":"string"}}]} 414ms, 361ms, 375ms
{"_id":{"$type":"string"}} 226ms, 228ms, 219ms

The type predicate brackets to the string range on the _id index, so the sort and the deep skip stay index-driven.

Proposed change

/query

Combine rather than merge, so a client's own _id condition survives:

// controllers/crud.js — not {...props, _id: …}, which would silently replace a client's own _id filter
let matches = await db.find({ $and: [props, { _id: { $type: "string" } }] })
    .sort({ _id: 1 }).limit(limit + 1).skip(skip).toArray()

The limit + 1 over-fetch from #302 stays correct, since hasNext is then computed over servable documents only.

/search

A $match stage after $search and before the $limit, in all four pipelines, so the limit is reached with servable documents:

{ $search: presi3Query },
{ $addFields: { "__rerum.score": { $meta: "searchScore" } } },
{ $match: { _id: { $type: "string" } } },
{ $limit: limit + skip }

Atlas Search cannot express a BSON type condition inside $search, so this belongs in a $match.

If #309's restructuring lands first, the condition goes in its single pipeline instead, once.

Alternative considered: clean the data instead

Deleting the three junk documents on production and the v0 import on dev would remove the need for any code change. Rejected as the primary fix: a direct insert outside the API can recreate the shape at any time, and a permanent guarantee in the read path is cheaper than a promise about the collection's contents. Removing production's three junk documents is still worth doing on its own, and no dev data should be deleted without an explicit decision — over 100000 documents is not a cleanup to run casually.

Notes

Acceptance criteria

  • POST /v1/api/query with {"_id":{"$not":{"$type":"string"}}} returns 200 [] on both deployments
  • A client's own _id condition still applies, verified with a filter that names an _id explicitly and one that uses an operator
  • /search and /search/phrase return no document whose _id is not a string, and manuscript at limit=100 fills the page from servable documents instead of returning 50
  • The rel="next" contract from /query paged responses carry no rel="next", so no client can tell a full page from the last page #302 still holds: hasNext is computed over servable documents, and a walk terminates without repeating or skipping a record
  • Deep-skip /query latency is unchanged, measured with the probes in this issue
  • The unmounted searchFuzzily, searchWildly, and searchAlikes carry the same exclusion, so it is not lost when they are routed
  • Regression tests assert the filter reaches the database call for /query and appears before $limit in each search pipeline
  • public/API.html documents that non-string _id objects are not served
  • Before-and-after result counts on representative dev queries are posted here before cutover

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions