Search: collections indexed, captured, and served from the engine
Collections are searched today with one LIKE filter against MariaDB and browsed with a raw A-Z prefix LIKE. This MR takes the entity through the search pipeline end to end: an index built from PHP, kept fresh by the outbox and poller, serving /collections, with the database as a live fallback. At 1,284 rows the engine matches and MySQL keeps ordering, paging and hydration.
The document. Nine flat fields, text plus a bounded keyword, no analyzers. collection_fold is what both LIKE oracles match on, folded by foldPrecomposed(), which folds a precomposed accent and leaves a standalone combining mark alone. That is what utf8mb4_unicode_ci does, and why the suffix is not the artifact document's _ascii. names and names_fold ship unread. Capture is one line in OWN_DOCUMENT_ENTITY_TYPES and no cascade arm, which is safe because collections has zero foreign keys in either direction.
The read side. One _search returns the whole matching id set and the three facet bucket sets; MySQL applies the primary-key IN and keeps everything else. The page has two LIKE oracles over one column and they differ on both axes: the filter runs the plugin's escaper, the browse interpolates raw, and both match a foldPrecomposed() field. Neither shipped translator produces that pair, so LikeWildcard gains foldPattern(). It is additive and expected to be temporary: pattern() has one production caller left, and the proveniences fold retrofit takes the class back to two methods. The A-Z browse becomes a wildcard on collection_fold.keyword and reproduces the database on all 26 letters, where the unfolded field manages 23 and misses the six rows whose initial is É, Š, Ş or Ü.
The facets. Three dropdowns are new: actor, holding, country. Counted from the same request that returned the ids, and each a real filter on both paths, so the database serves them identically when the engine is down. Country labels resolve from gadm_entities; the two classification columns are their own labels.
No divergence is pinned, against 13 for Proveniences and 39 for Publications, and the reason differs per comparison. Against Proveniences it is the fold: that entity matches _ascii fields with removeDiacritics(), whose largest pin class is the composition mismatch this MR closes. Against Publications it is the data, since that entity already uses this fold: collections.collection carries a combining mark on 0 of 1,284 rows, against 341 of publications.title's 8,319 non-null titles. The harness derives 206 parameter sets across 20 classes and diffs every one through both paths; expectedDivergences() is empty, which it treats as an assertion.
Failure handling splits deliberately. An unreachable engine logs one warning and serves from the database with full dropdowns. A refused query surfaces as an error rather than hiding behind plausible rows. And three inputs go to the database before the engine is asked, because for each an unguarded mirror returns a wrong id set rather than an error: a value at or past 300 folded characters, a non-string letter, and any recognised parameter that is not valid UTF-8. All three render what the pre-slice page renders, and the route is taken before the try, so it logs nothing.
What changes on purpose. The three facets, and nothing else.
Not in this MR
The filter still searches collections.collection only. Widening it to the multilingual names is worth doing later and cheap then, since the document already carries the fields: 889 of 1,353 name rows carry text absent from the collection's own value, across 760 of 1,284 collections, so Weltmuseum finds nothing today. A corpus class holds the decline. Also out: collection_actor_status and collection_holding_status, which would need a mapping bump, and the unescaped letter, which is live on both paths and belongs in its own change.
No analyzer work, which is issue #2018, and one constraint is worth stating because this entity sharpens it: that analyzer folds combining marks and collection_fold exists to keep them, so pointing it at a _fold field reopens what the fold closes and turns these zero pins back into a composition-mismatch class. #2018 exempts those fields or accepts that.
Runbook
search_index_create --entity collection, then search_index_swap --entity collection --version 1 --alias both, then search_backfill --entity collection, which needs php -d memory_limit=1G under the container's 128M default. Nothing else changes operationally: the engine owns matching only, so an engine that is down is a warning and a page that still works.
Evidence and timings are in comments.